File: DesignerAttribute\DesignerAttributeDiscoveryService.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.Collections.Immutable;
using System.ComponentModel;
using System.Composition;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Remote;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Storage;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.DesignerAttribute;
 
[ExportWorkspaceService(typeof(IDesignerAttributeDiscoveryService)), Shared]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed partial class DesignerAttributeDiscoveryService() : IDesignerAttributeDiscoveryService
{
    /// <summary>
    /// Ugly, but sufficient hack.  During times where we're missing global usings (which may not always be available
    /// while the sdk is regenerating/restoring on things like a tfm switch), we hardcode in knowledge we have about
    /// which namespaces the core designable types are in.  That way we can still make a solid guess about what the base
    /// type is, even if we can't resolve it at this moment.
    /// </summary> 
    private static readonly ImmutableArray<string> s_wellKnownDesignerNamespaces = [
        "System.Windows.Forms.Form",
        "System.Windows.Forms.Design",
        "System.ComponentModel"];
 
    /// <summary>
    /// Cache from the individual references a project has, to a boolean specifying if reference knows about the
    /// System.ComponentModel.DesignerCategoryAttribute attribute.
    /// </summary>
    private static readonly ConditionalWeakTable<MetadataId, AsyncLazy<bool>> s_metadataIdToDesignerAttributeInfo = new();
 
    /// <summary>
    /// Protects mutable state in this type.
    /// </summary>
    private readonly SemaphoreSlim _gate = new(initialCount: 1);
 
    /// <summary>
    /// Keep track of the last information we reported.  We will avoid notifying the host if we recompute and these
    /// don't change.
    /// </summary>
    private readonly ConcurrentDictionary<DocumentId, (string? category, VersionStamp projectVersion)> _documentToLastReportedInformation = [];
 
    private static async ValueTask<bool> HasDesignerCategoryTypeAsync(Project project, CancellationToken cancellationToken)
    {
        var solutionServices = project.Solution.Services;
        var solutionKey = SolutionKey.ToSolutionKey(project.Solution);
        foreach (var reference in project.MetadataReferences)
        {
            if (reference is PortableExecutableReference peReference)
            {
                if (await HasDesignerCategoryTypeAsync(
                        solutionServices, solutionKey, peReference, cancellationToken).ConfigureAwait(false))
                {
                    return true;
                }
            }
        }
 
        return false;
 
        static async Task<bool> HasDesignerCategoryTypeAsync(
           SolutionServices solutionServices,
           SolutionKey solutionKey,
           PortableExecutableReference peReference,
           CancellationToken cancellationToken)
        {
            MetadataId metadataId;
            try
            {
                metadataId = peReference.GetMetadataId();
            }
            catch (Exception ex) when (ex is BadImageFormatException or IOException)
            {
                return false;
            }
 
            var asyncLazy = s_metadataIdToDesignerAttributeInfo.GetValue(
                metadataId, _ => AsyncLazy.Create(static (arg, cancellationToken) =>
                    ComputeHasDesignerCategoryTypeAsync(arg.solutionServices, arg.solutionKey, arg.peReference, cancellationToken),
                    arg: (solutionServices, solutionKey, peReference)));
            return await asyncLazy.GetValueAsync(cancellationToken).ConfigureAwait(false);
        }
 
        static async Task<bool> ComputeHasDesignerCategoryTypeAsync(
            SolutionServices solutionServices,
            SolutionKey solutionKey,
            PortableExecutableReference peReference,
            CancellationToken cancellationToken)
        {
            var info = await SymbolTreeInfo.GetInfoForMetadataReferenceAsync(
                solutionServices, solutionKey, peReference, checksum: null, cancellationToken).ConfigureAwait(false);
            var result =
                info.ContainsSymbolWithName(nameof(System)) &&
                info.ContainsSymbolWithName(nameof(System.ComponentModel)) &&
                info.ContainsSymbolWithName(nameof(System.ComponentModel.DesignerCategoryAttribute));
            return result;
        }
    }
 
    public async ValueTask ProcessPriorityDocumentAsync(
        Solution solution,
        DocumentId priorityDocumentId,
        IDesignerAttributeDiscoveryService.ICallback callback,
        CancellationToken cancellationToken)
    {
        if (!solution.GetRequiredProject(priorityDocumentId.ProjectId).SupportsCompilation)
            return;
 
        // Create a frozen snapshot guaranteed to have this document in it.  Note: it's important that we do
        // this, and not just depend on the solution.WithFrozenPartialCompilationsAsync below.  Very
        // importantly, that solution may not contain this document yet.  This does mean we'll process two
        // separate solutions.
        var frozenDocument = solution
            .GetRequiredDocument(priorityDocumentId)
            .WithFrozenPartialSemantics(cancellationToken);
 
        var frozenProject = frozenDocument.Project;
 
        using (await _gate.DisposableWaitAsync(cancellationToken).ConfigureAwait(false))
        {
            var lazyProjectVersion = AsyncLazy.Create(static (frozenProject, c) =>
                frozenProject.GetSemanticVersionAsync(c),
                arg: frozenProject);
 
            await ScanForDesignerCategoryUsageAsync(
                frozenProject, frozenDocument, callback, lazyProjectVersion, cancellationToken).ConfigureAwait(false);
        }
    }
 
    public async ValueTask ProcessSolutionAsync(
        Solution solution,
        IDesignerAttributeDiscoveryService.ICallback callback,
        CancellationToken cancellationToken)
    {
        // Freeze the entire solution at this point.  We don't want to run generators (as they are very unlikely
        // to contribute any changes that would affect which types we think are designable), and we want to be 
        // very fast to update the ui as a user types.
        var frozenSolution = await solution.WithFrozenPartialCompilationsAsync(cancellationToken).ConfigureAwait(false);
 
        using (await _gate.DisposableWaitAsync(cancellationToken).ConfigureAwait(false))
        {
            // Remove any documents that are now gone.
            foreach (var docId in _documentToLastReportedInformation.Keys)
            {
                if (!solution.ContainsDocument(docId))
                    _documentToLastReportedInformation.TryRemove(docId, out _);
            }
 
            // Process the rest of the projects in dependency order so that their data is ready when we hit the 
            // projects that depend on them.
            var dependencyGraph = frozenSolution.GetProjectDependencyGraph();
            foreach (var projectId in dependencyGraph.GetTopologicallySortedProjects(cancellationToken))
                await ProcessProjectAsync(frozenSolution.GetRequiredProject(projectId), callback, cancellationToken).ConfigureAwait(false);
        }
    }
 
    private async Task ProcessProjectAsync(
        Project project,
        IDesignerAttributeDiscoveryService.ICallback callback,
        CancellationToken cancellationToken)
    {
        if (!project.SupportsCompilation)
            return;
 
        // Defer expensive work until it's actually needed.
        // The top level project version for this project.  We only care if anything top level changes here.
        // Downstream impact will already happen due to us keying off of the references a project has (which will
        // change if anything it depends on changes).
        var lazyProjectVersion = AsyncLazy.Create(static (project, c) =>
            project.GetSemanticVersionAsync(c),
            arg: project);
 
        await ScanForDesignerCategoryUsageAsync(
            project, specificDocument: null, callback, lazyProjectVersion, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task ScanForDesignerCategoryUsageAsync(
        Project project,
        Document? specificDocument,
        IDesignerAttributeDiscoveryService.ICallback callback,
        AsyncLazy<VersionStamp> lazyProjectVersion,
        CancellationToken cancellationToken)
    {
        // Now get all the values that actually changed and notify VS about them. We don't need
        // to tell it about the ones that didn't change since that will have no effect on the
        // user experience.
        var changedData = await ComputeChangedDataAsync(
            project, specificDocument, lazyProjectVersion, cancellationToken).ConfigureAwait(false);
 
        // Only bother reporting non-empty information to save an unnecessary RPC.
        if (!changedData.IsEmpty)
            await callback.ReportDesignerAttributeDataAsync(changedData.SelectAsArray(d => d.data), cancellationToken).ConfigureAwait(false);
 
        // Now, keep track of what we've reported to the host so we won't report unchanged files in the future. We
        // do this after the report has gone through as we want to make sure that if it cancels for any reason we
        // don't hold onto values that may not have made it all the way to the project system.
        foreach (var (data, projectVersion) in changedData)
            _documentToLastReportedInformation[data.DocumentId] = (data.Category, projectVersion);
    }
 
    private async Task<ImmutableArray<(DesignerAttributeData data, VersionStamp version)>> ComputeChangedDataAsync(
        Project project,
        Document? specificDocument,
        AsyncLazy<VersionStamp> lazyProjectVersion,
        CancellationToken cancellationToken)
    {
        // NOTE: While we could potentially process the documents in a project in parallel, we intentionally do not.
        // That's because this runs automatically in the BG in response to *any* change in the workspace.  So it's
        // very often going to be running, and it will be potentially competing against explicitly invoked actions
        // by the user.  Processing only one doc at a time, means we're not saturating the TPL with this work at the
        // expense of other features.
 
        bool? hasDesignerCategoryType = null;
 
        using var _ = ArrayBuilder<(DesignerAttributeData data, VersionStamp version)>.GetInstance(out var results);
 
        // Avoid realizing document instances until needed.
        foreach (var documentId in project.DocumentIds)
        {
            // If we're only analyzing a specific document, then skip the rest.
            if (specificDocument != null && documentId != specificDocument.Id)
                continue;
 
            // If we don't have a path for this document, we cant proceed with it.
            // We need that path to inform the project system which file we're referring to.
            var filePath = project.State.DocumentStates.GetRequiredState(documentId).FilePath;
            if (filePath is null)
                continue;
 
            // If nothing has changed at the top level between the last time we analyzed this document and now, then
            // no need to analyze again.
            var projectVersion = await lazyProjectVersion.GetValueAsync(cancellationToken).ConfigureAwait(false);
            if (_documentToLastReportedInformation.TryGetValue(documentId, out var existingInfo) &&
                existingInfo.projectVersion == projectVersion)
            {
                continue;
            }
 
            hasDesignerCategoryType ??= await HasDesignerCategoryTypeAsync(project, cancellationToken).ConfigureAwait(false);
            var data = await ComputeDesignerAttributeDataAsync(project, documentId, filePath, hasDesignerCategoryType.Value, existingInfo.category).ConfigureAwait(false);
            if (data.Category != existingInfo.category)
                results.Add((data, projectVersion));
        }
 
        return results.ToImmutableAndClear();
 
        async Task<DesignerAttributeData> ComputeDesignerAttributeDataAsync(
            Project project, DocumentId documentId, string filePath, bool hasDesignerCategoryType, string? existingCategory)
        {
            // We either haven't computed the designer info, or our data was out of date.  We need
            // So recompute here.  Figure out what the current category is, and if that's different
            // from what we previously stored.
            var category = await ComputeDesignerAttributeCategoryAsync(
                hasDesignerCategoryType, project, documentId, existingCategory, cancellationToken).ConfigureAwait(false);
 
            return new DesignerAttributeData
            {
                Category = category,
                DocumentId = documentId,
                FilePath = filePath,
            };
        }
    }
 
    public static async Task<string?> ComputeDesignerAttributeCategoryAsync(
        bool hasDesignerCategoryType, Project project, DocumentId documentId, string? existingCategory, CancellationToken cancellationToken)
    {
        // simple case.  If there's no DesignerCategory type in this compilation, then there's definitely no
        // designable types.
        if (!hasDesignerCategoryType)
            return null;
 
        // Wait to realize the document to avoid unnecessary allocations when indexing documents.
        var document = project.GetRequiredDocument(documentId);
 
        var root = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
        var syntaxFacts = document.GetRequiredLanguageService<ISyntaxFactsService>();
 
        // Legacy behavior.  We only register the designer info for the first non-nested class
        // in the file.
        var firstClass = FindFirstNonNestedClass(syntaxFacts.GetMembersOfCompilationUnit(root));
        if (firstClass == null)
            return null;
 
        var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
        var firstClassType = (INamedTypeSymbol)semanticModel.GetRequiredDeclaredSymbol(firstClass, cancellationToken);
 
        foreach (var type in GetBaseTypesAndThis(semanticModel.Compilation, firstClassType))
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            // If we hit an error type while walking up, then preserve the existing category.  We do want a temporary
            // invalid base type to not cause us to lose the existing category, causing a designable type to revert to
            // an undesignable one.  The designer can still support scenarios like this as it is itself error tolerant,
            // falling back to the prior category in a case like this.
            if (type is IErrorTypeSymbol errorType)
                return existingCategory;
 
            // See if it has the designer attribute on it. Use symbol-equivalence instead of direct equality
            // as the symbol we have 
            var attribute = type.GetAttributes().FirstOrDefault(d => IsDesignerAttribute(d.AttributeClass));
            if (attribute is { ConstructorArguments: [{ Type.SpecialType: SpecialType.System_String, Value: string stringValue }] })
                return stringValue.Trim();
        }
 
        return null;
 
        static IEnumerable<ITypeSymbol> GetBaseTypesAndThis(Compilation compilation, INamedTypeSymbol firstType)
        {
            var current = firstType;
            while (current != null)
            {
                yield return current;
                current = current.BaseType;
 
                if (current is IErrorTypeSymbol errorType)
                    current = TryMapToNonErrorType(compilation, errorType);
            }
        }
 
        static INamedTypeSymbol? TryMapToNonErrorType(Compilation compilation, IErrorTypeSymbol errorType)
        {
            foreach (var wellKnownNamespace in s_wellKnownDesignerNamespaces)
            {
                var wellKnownType = compilation.GetTypeByMetadataName($"{wellKnownNamespace}.{errorType.Name}");
                if (wellKnownType != null)
                    return wellKnownType;
            }
 
            // Couldn't find a match.  Just return the error type as is.  Caller will handle this case and try to
            // preserve the existing category.
            return errorType;
        }
 
        static bool IsDesignerAttribute(INamedTypeSymbol? attributeClass)
            => attributeClass is
            {
                Name: nameof(DesignerCategoryAttribute),
                ContainingNamespace.Name: nameof(System.ComponentModel),
                ContainingNamespace.ContainingNamespace.Name: nameof(System),
                ContainingNamespace.ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true,
            };
 
        SyntaxNode? FindFirstNonNestedClass(SyntaxList<SyntaxNode> members)
        {
            foreach (var member in members)
            {
                cancellationToken.ThrowIfCancellationRequested();
                if (syntaxFacts.IsBaseNamespaceDeclaration(member))
                {
                    var firstClass = FindFirstNonNestedClass(syntaxFacts.GetMembersOfBaseNamespaceDeclaration(member));
                    if (firstClass != null)
                        return firstClass;
                }
                else if (syntaxFacts.IsClassDeclaration(member))
                {
                    return member;
                }
            }
 
            return null;
        }
    }
 
    public static async Task DiscoverDesignerAttributesAsync(
        Solution solution,
        Document? activeDocument,
        RemoteHostClient client,
        IAsynchronousOperationListener listener,
        IDesignerAttributeDiscoveryService.ICallback target,
        CancellationToken cancellationToken)
    {
        using var connection = client.CreateConnection<IRemoteDesignerAttributeDiscoveryService>(callbackTarget: target);
 
        // If there is an active document, then process changes to it right away, so that the UI updates quickly
        // when the user adds/removes a form from a particular document.
        if (RemoteSupportedLanguages.IsSupported(activeDocument?.Project.Language))
        {
            // We only need to do a project sync to compute the up to date data for this particular file.
            var priorityDocumentId = activeDocument.Id;
            await connection.TryInvokeAsync(
                activeDocument.Project,
                (service, checksum, callbackId, cancellationToken) => service.DiscoverDesignerAttributesAsync(
                    callbackId, checksum, priorityDocumentId, cancellationToken),
                cancellationToken).ConfigureAwait(false);
        }
 
        // Wait a little after the priority document and process the rest of the solution at a lower priority.
        await listener.Delay(DelayTimeSpan.NonFocus, cancellationToken).ConfigureAwait(false);
 
        await connection.TryInvokeAsync(
            solution,
            (service, checksum, callbackId, cancellationToken) => service.DiscoverDesignerAttributesAsync(
                callbackId, checksum, cancellationToken),
            cancellationToken).ConfigureAwait(false);
    }
}