File: MetadataAsSource\DecompilationMetadataAsSourceFileProvider.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Composition;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.DecompiledSource;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.PdbSourceDocument;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.Structure;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.MetadataAsSource;
 
[ExportMetadataAsSourceFileProvider(ProviderName), Shared]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed class DecompilationMetadataAsSourceFileProvider(IImplementationAssemblyLookupService implementationAssemblyLookupService) : IMetadataAsSourceFileProvider
{
    internal const string ProviderName = "Decompilation";
 
    /// <summary>
    /// Accessed only in <see cref="GetGeneratedFileAsync"/> and <see cref="CleanupGeneratedFiles"/>, both of which
    /// are called under a lock in <see cref="MetadataAsSourceFileService"/>.  So this is safe as a plain
    /// dictionary.
    /// </summary>
    private readonly Dictionary<UniqueDocumentKey, MetadataAsSourceGeneratedFileInfo> _keyToInformation = [];
 
    /// <summary>
    /// Accessed both in <see cref="GetGeneratedFileAsync"/> and in UI thread operations.  Those should not
    /// generally run concurrently.  However, to be safe, we make this a concurrent dictionary to be safe to that
    /// potentially happening.
    /// </summary>
    private readonly ConcurrentDictionary<string, (MetadataAsSourceGeneratedFileInfo Metadata, DocumentId DocumentId)> _generatedFilenameToInformation = new(StringComparer.OrdinalIgnoreCase);
 
    private readonly IImplementationAssemblyLookupService _implementationAssemblyLookupService = implementationAssemblyLookupService;
 
    public async Task<MetadataAsSourceFile?> GetGeneratedFileAsync(
        MetadataAsSourceWorkspace metadataWorkspace,
        Workspace sourceWorkspace,
        Project sourceProject,
        ISymbol symbol,
        bool signaturesOnly,
        MetadataAsSourceOptions options,
        string tempPath,
        TelemetryMessage? telemetryMessage,
        CancellationToken cancellationToken)
    {
        // Use the current fallback analyzer config options from the source workspace.
        // Decompilation does not add projects to the MAS workspace, hence the workspace might remain empty and not receive fallback options automatically.
        metadataWorkspace.OnSolutionFallbackAnalyzerOptionsChanged(sourceWorkspace.CurrentSolution.FallbackAnalyzerOptions);
 
        var topLevelNamedType = MetadataAsSourceHelpers.GetTopLevelContainingNamedType(symbol);
        var symbolId = SymbolKey.Create(symbol, cancellationToken);
        var compilation = await sourceProject.GetRequiredCompilationAsync(cancellationToken).ConfigureAwait(false);
 
        // If we've been asked for signatures only, then we never want to use the decompiler
        var useDecompiler = !signaturesOnly && options.NavigateToDecompiledSources;
 
        // If the assembly wants to suppress decompilation we respect that
        if (useDecompiler)
        {
#pragma warning disable SYSLIB0025  // 'SuppressIldasmAttribute' is obsolete: 'SuppressIldasmAttribute has no effect in .NET 6.0+.'
            useDecompiler = !symbol.ContainingAssembly.GetAttributes().Any(static attribute => attribute.AttributeClass?.Name == nameof(SuppressIldasmAttribute)
                && attribute.AttributeClass.ToNameDisplayString() == typeof(SuppressIldasmAttribute).FullName);
#pragma warning restore SYSLIB0025
        }
 
        var refInfo = GetReferenceInfo(compilation, symbol.ContainingAssembly);
 
        // If its a reference assembly we won't get real code anyway, so better to
        // not use the decompiler, as the stubs will at least be in the right language
        // (decompiler only produces C#)
        if (useDecompiler)
        {
            useDecompiler = !refInfo.isReferenceAssembly;
        }
 
        var infoKey = await GetUniqueDocumentKeyAsync(sourceProject, topLevelNamedType, signaturesOnly: !useDecompiler, cancellationToken).ConfigureAwait(false);
 
        var fileInfo = _keyToInformation.GetOrAdd(infoKey,
            _ => new MetadataAsSourceGeneratedFileInfo(tempPath, sourceWorkspace, sourceProject, topLevelNamedType, signaturesOnly: !useDecompiler));
 
        DocumentId generatedDocumentId;
        Location navigateLocation;
        if (!_generatedFilenameToInformation.TryGetValue(fileInfo.TemporaryFilePath, out var existingDocumentId))
        {
            // We don't have this file in the workspace.  We need to create a project to put it in.
            var (temporaryProjectInfo, temporaryDocumentId) = GenerateProjectAndDocumentInfo(fileInfo, metadataWorkspace.CurrentSolution.Services, sourceProject, topLevelNamedType);
            metadataWorkspace.OnProjectAdded(temporaryProjectInfo);
            var temporaryDocument = metadataWorkspace.CurrentSolution
                .GetRequiredDocument(temporaryDocumentId);
 
            // Generate the file if it doesn't exist (we may still have it if there was a previous request for it that was then closed).
            if (!File.Exists(fileInfo.TemporaryFilePath))
            {
                if (useDecompiler)
                {
                    try
                    {
                        // Fetch the IDecompiledSourceService from the temporary document, not the original one -- it
                        // may be a different language because we don't have support for decompiling into VB.NET, so we just
                        // use C#.
                        var decompiledSourceService = temporaryDocument.GetLanguageService<IDecompiledSourceService>();
 
                        if (decompiledSourceService != null)
                        {
                            var decompilationDocument = await decompiledSourceService.AddSourceToAsync(temporaryDocument, compilation, symbol, refInfo.metadataReference, refInfo.assemblyLocation, formattingOptions: null, cancellationToken).ConfigureAwait(false);
                            telemetryMessage?.SetDecompiled(decompilationDocument is not null);
                            if (decompilationDocument is not null)
                            {
                                temporaryDocument = decompilationDocument;
                            }
                            else
                            {
                                useDecompiler = false;
                            }
                        }
                        else
                        {
                            useDecompiler = false;
                        }
                    }
                    catch (Exception e) when (FatalError.ReportAndCatchUnlessCanceled(e, cancellationToken, ErrorSeverity.General))
                    {
                        useDecompiler = false;
                    }
                }
 
                if (!useDecompiler)
                {
                    var sourceFromMetadataService = temporaryDocument.Project.Services.GetRequiredService<IMetadataAsSourceService>();
                    temporaryDocument = await sourceFromMetadataService.AddSourceToAsync(temporaryDocument, compilation, symbol, formattingOptions: null, cancellationToken).ConfigureAwait(false);
                }
 
                // We have the content, so write it out to disk
                var text = await temporaryDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
 
                // Create the directory. It's possible a parallel deletion is happening in another process, so we may have
                // to retry this a few times.
                //
                // If we still can't create the folder after 5 seconds, assume we will not be able to create it and
                // continue without actually writing the text to disk.
                var directoryToCreate = Path.GetDirectoryName(fileInfo.TemporaryFilePath)!;
                var stopwatch = SharedStopwatch.StartNew();
                var timeout = TimeSpan.FromSeconds(5);
                var firstAttempt = true;
                var skipWritingFile = false;
 
                while (!IOUtilities.PerformIO(() => Directory.Exists(directoryToCreate)))
                {
                    if (stopwatch.Elapsed > timeout)
                    {
                        // If we still can't create the folder after 5 seconds, assume we will not be able to create it.
                        skipWritingFile = true;
                        break;
                    }
 
                    if (firstAttempt)
                    {
                        firstAttempt = false;
                    }
                    else
                    {
                        await Task.Delay(DelayTimeSpan.Short, cancellationToken).ConfigureAwait(false);
                    }
 
                    IOUtilities.PerformIO(() => Directory.CreateDirectory(directoryToCreate));
                }
 
                if (!skipWritingFile && !File.Exists(fileInfo.TemporaryFilePath))
                {
                    using (var textWriter = new StreamWriter(fileInfo.TemporaryFilePath, append: false, encoding: MetadataAsSourceGeneratedFileInfo.Encoding))
                    {
                        text.Write(textWriter, cancellationToken);
                    }
 
                    // Mark read-only
                    new FileInfo(fileInfo.TemporaryFilePath).IsReadOnly = true;
                }
            }
 
            // Retrieve the navigable location for the symbol using the generated syntax.  
            navigateLocation = await MetadataAsSourceHelpers.GetLocationInGeneratedSourceAsync(symbolId, temporaryDocument, cancellationToken).ConfigureAwait(false);
 
            // Update the workspace to pull the text from the document.
            var newLoader = new WorkspaceFileTextLoader(temporaryDocument.Project.Solution.Services, fileInfo.TemporaryFilePath, MetadataAsSourceGeneratedFileInfo.Encoding);
            metadataWorkspace.OnDocumentTextLoaderChanged(temporaryDocumentId, newLoader);
            _generatedFilenameToInformation.Add(fileInfo.TemporaryFilePath, (fileInfo, temporaryDocument.Id));
            generatedDocumentId = temporaryDocument.Id;
        }
        else
        {
            // The file already exists in the workspace, so we can just use that.
            generatedDocumentId = existingDocumentId.DocumentId;
            var document = metadataWorkspace.CurrentSolution.GetRequiredDocument(generatedDocumentId);
            navigateLocation = await MetadataAsSourceHelpers.GetLocationInGeneratedSourceAsync(symbolId, document, cancellationToken).ConfigureAwait(false);
 
        }
 
        var documentName = string.Format(
            "{0} [{1}]",
            topLevelNamedType.Name,
            useDecompiler ? FeaturesResources.Decompiled : FeaturesResources.from_metadata);
 
        var documentTooltip = topLevelNamedType.ToDisplayString(new SymbolDisplayFormat(typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces));
 
        return new MetadataAsSourceFile(fileInfo.TemporaryFilePath, navigateLocation, documentName, documentTooltip);
    }
 
    private (MetadataReference? metadataReference, string? assemblyLocation, bool isReferenceAssembly) GetReferenceInfo(Compilation compilation, IAssemblySymbol containingAssembly)
    {
        var metadataReference = compilation.GetMetadataReference(containingAssembly);
        var assemblyLocation = (metadataReference as PortableExecutableReference)?.FilePath;
 
        var isReferenceAssembly = MetadataAsSourceHelpers.IsReferenceAssembly(containingAssembly);
 
        if (assemblyLocation is not null &&
            isReferenceAssembly &&
            !_implementationAssemblyLookupService.TryFindImplementationAssemblyPath(assemblyLocation, out assemblyLocation))
        {
            try
            {
                var fullAssemblyName = containingAssembly.Identity.GetDisplayName();
                GlobalAssemblyCache.Instance.ResolvePartialName(fullAssemblyName, out assemblyLocation, preferredCulture: CultureInfo.CurrentCulture);
                isReferenceAssembly = assemblyLocation is null;
            }
            catch (IOException)
            {
                // If we get an IO exception we can safely ignore it, and the system will show the metadata view of the reference assembly.
            }
            catch (Exception e) when (FatalError.ReportAndCatch(e, ErrorSeverity.Diagnostic))
            {
            }
        }
 
        return (metadataReference, assemblyLocation, isReferenceAssembly);
    }
 
    public bool ShouldCollapseOnOpen(MetadataAsSourceWorkspace workspace, string filePath, BlockStructureOptions blockStructureOptions)
    {
        if (_generatedFilenameToInformation.TryGetValue(filePath, out var info))
        {
            return info.Metadata.SignaturesOnly
                ? blockStructureOptions.CollapseEmptyMetadataImplementationsWhenFirstOpened
                : blockStructureOptions.CollapseMetadataImplementationsWhenFirstOpened;
        }
 
        return false;
    }
 
    private bool RemoveDocumentFromWorkspace(MetadataAsSourceWorkspace workspace, MetadataAsSourceGeneratedFileInfo fileInfo)
    {
        // Serial access is guaranteed by the caller.
        if (_generatedFilenameToInformation.TryRemove(fileInfo.TemporaryFilePath, out var documentIdInfo))
        {
            workspace.OnDocumentClosed(documentIdInfo.DocumentId, new WorkspaceFileTextLoader(workspace.Services.SolutionServices, fileInfo.TemporaryFilePath, MetadataAsSourceGeneratedFileInfo.Encoding));
            workspace.OnProjectRemoved(documentIdInfo.DocumentId.ProjectId);
 
            return true;
        }
 
        return false;
    }
 
    public Project? MapDocument(Document document)
    {
        MetadataAsSourceGeneratedFileInfo? fileInfo;
 
        if (document.FilePath is not null && _generatedFilenameToInformation.TryGetValue(document.FilePath, out var documentIdInfo))
        {
            fileInfo = documentIdInfo.Metadata;
            var solution = fileInfo.Workspace.CurrentSolution;
            var project = solution.GetProject(fileInfo.SourceProjectId);
            return project;
        }
        else
        {
            // If we don't have the file in our cache, then we can't map it.
            return null;
        }
    }
 
    public void CleanupGeneratedFiles(MetadataAsSourceWorkspace workspace)
    {
        // Clone the list so we don't break our own enumeration
        foreach (var generatedFileInfo in _generatedFilenameToInformation.Values.ToList())
        {
            RemoveDocumentFromWorkspace(workspace, generatedFileInfo.Metadata);
 
        }
 
        _generatedFilenameToInformation.Clear();
        _keyToInformation.Clear();
    }
 
    private static (ProjectInfo, DocumentId) GenerateProjectAndDocumentInfo(
        MetadataAsSourceGeneratedFileInfo fileInfo,
        SolutionServices services,
        Project sourceProject,
        INamedTypeSymbol topLevelNamedType)
    {
        var projectId = ProjectId.CreateNewId();
 
        var parseOptions = sourceProject.Language == fileInfo.LanguageName
            ? sourceProject.ParseOptions
            : sourceProject.Solution.Services.GetLanguageServices(fileInfo.LanguageName).GetRequiredService<ISyntaxTreeFactoryService>().GetDefaultParseOptionsWithLatestLanguageVersion();
 
        var assemblyIdentity = topLevelNamedType.ContainingAssembly.Identity;
 
        // Just say it's always a DLL since we probably won't have a Main method
        var compilationOptions = services.GetRequiredLanguageService<ICompilationFactoryService>(fileInfo.LanguageName).GetDefaultCompilationOptions().WithOutputKind(OutputKind.DynamicallyLinkedLibrary);
 
        // We need to include the version information of the assembly so InternalsVisibleTo and stuff works
        var assemblyInfoDocumentId = DocumentId.CreateNewId(projectId);
        var assemblyInfoFileName = "AssemblyInfo" + fileInfo.Extension;
        var assemblyInfoString = fileInfo.LanguageName == LanguageNames.CSharp
            ? string.Format(@"[assembly: System.Reflection.AssemblyVersion(""{0}"")]", assemblyIdentity.Version)
            : string.Format(@"<Assembly: System.Reflection.AssemblyVersion(""{0}"")>", assemblyIdentity.Version);
 
        var assemblyInfoSourceText = SourceText.From(assemblyInfoString, MetadataAsSourceGeneratedFileInfo.Encoding, MetadataAsSourceGeneratedFileInfo.ChecksumAlgorithm);
 
        var assemblyInfoDocument = DocumentInfo.Create(
            assemblyInfoDocumentId,
            assemblyInfoFileName,
            loader: TextLoader.From(assemblyInfoSourceText.Container, VersionStamp.Default),
            filePath: null,
            isGenerated: true)
            .WithDesignTimeOnly(true);
 
        var emptySourceText = SourceText.From(string.Empty, MetadataAsSourceGeneratedFileInfo.Encoding, MetadataAsSourceGeneratedFileInfo.ChecksumAlgorithm);
        var generatedDocumentId = DocumentId.CreateNewId(projectId);
        var generatedDocument = DocumentInfo.Create(
            generatedDocumentId,
            Path.GetFileName(fileInfo.TemporaryFilePath),
            // We'll update the loader later when we actually write the file to disk.
            loader: TextLoader.From(emptySourceText.Container, VersionStamp.Default),
            filePath: fileInfo.TemporaryFilePath,
            isGenerated: true)
            .WithDesignTimeOnly(true);
 
        var projectInfo = ProjectInfo.Create(
            new ProjectInfo.ProjectAttributes(
                id: projectId,
                version: VersionStamp.Default,
                name: assemblyIdentity.Name,
                assemblyName: assemblyIdentity.Name,
                language: fileInfo.LanguageName,
                compilationOutputInfo: default,
                checksumAlgorithm: MetadataAsSourceGeneratedFileInfo.ChecksumAlgorithm),
            compilationOptions: compilationOptions,
            parseOptions: parseOptions,
            documents: [assemblyInfoDocument, generatedDocument],
            metadataReferences: [.. sourceProject.MetadataReferences]);
 
        return (projectInfo, generatedDocumentId);
 
    }
 
    private static async Task<UniqueDocumentKey> GetUniqueDocumentKeyAsync(Project project, INamedTypeSymbol topLevelNamedType, bool signaturesOnly, CancellationToken cancellationToken)
    {
        var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false);
        Contract.ThrowIfNull(compilation, "We are trying to produce a key for a language that doesn't support compilations.");
 
        var peMetadataReference = compilation.GetMetadataReference(topLevelNamedType.ContainingAssembly) as PortableExecutableReference;
 
        if (peMetadataReference?.FilePath != null)
        {
            return new UniqueDocumentKey(peMetadataReference.FilePath, peMetadataReference.GetMetadataId(), project.Language, SymbolKey.Create(topLevelNamedType, cancellationToken), signaturesOnly);
        }
        else
        {
            var containingAssembly = topLevelNamedType.ContainingAssembly;
            return new UniqueDocumentKey(containingAssembly.Identity, containingAssembly.GetMetadata()?.Id, project.Language, SymbolKey.Create(topLevelNamedType, cancellationToken), signaturesOnly);
        }
    }
 
    private sealed class UniqueDocumentKey : IEquatable<UniqueDocumentKey>
    {
        private static readonly IEqualityComparer<SymbolKey> s_symbolIdComparer = SymbolKey.GetComparer(ignoreCase: false, ignoreAssemblyKeys: true);
 
        /// <summary>
        /// The path to the assembly. Null in the case of in-memory assemblies, where we then use assembly identity.
        /// </summary>
        private readonly string? _filePath;
 
        /// <summary>
        /// Assembly identity. Only non-null if <see cref="_filePath"/> is null, where it's an in-memory assembly.
        /// </summary>
        private readonly AssemblyIdentity? _assemblyIdentity;
 
        private readonly MetadataId? _metadataId;
        private readonly string _language;
        private readonly SymbolKey _symbolId;
        private readonly bool _signaturesOnly;
 
        public UniqueDocumentKey(string filePath, MetadataId? metadataId, string language, SymbolKey symbolId, bool signaturesOnly)
        {
            Contract.ThrowIfNull(filePath);
 
            _filePath = filePath;
            _metadataId = metadataId;
            _language = language;
            _symbolId = symbolId;
            _signaturesOnly = signaturesOnly;
        }
 
        public UniqueDocumentKey(AssemblyIdentity assemblyIdentity, MetadataId? metadataId, string language, SymbolKey symbolId, bool signaturesOnly)
        {
            Contract.ThrowIfNull(assemblyIdentity);
 
            _assemblyIdentity = assemblyIdentity;
            _metadataId = metadataId;
            _language = language;
            _symbolId = symbolId;
            _signaturesOnly = signaturesOnly;
        }
 
        public bool Equals(UniqueDocumentKey? other)
        {
            if (other == null)
            {
                return false;
            }
 
            return StringComparer.OrdinalIgnoreCase.Equals(_filePath, other._filePath) &&
                object.Equals(_assemblyIdentity, other._assemblyIdentity) &&
                object.Equals(_metadataId, other._metadataId) &&
                _language == other._language &&
                s_symbolIdComparer.Equals(_symbolId, other._symbolId) &&
                _signaturesOnly == other._signaturesOnly;
        }
 
        public override bool Equals(object? obj)
            => Equals(obj as UniqueDocumentKey);
 
        public override int GetHashCode()
        {
            return
                Hash.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(_filePath ?? string.Empty),
                    Hash.Combine(_assemblyIdentity?.GetHashCode() ?? 0,
                        Hash.Combine(_metadataId?.GetHashCode() ?? 0,
                            Hash.Combine(_language.GetHashCode(),
                                Hash.Combine(s_symbolIdComparer.GetHashCode(_symbolId),
                                    _signaturesOnly.GetHashCode())))));
        }
    }
}