File: NavigateTo\RoslynNavigateToItem.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.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Runtime.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.CodeAnalysis.Navigation;
using Microsoft.CodeAnalysis.PatternMatching;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Storage;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.NavigateTo;
 
/// <summary>
/// Data about a navigate to match.  Only intended for use by C# and VB.  Carries enough rich information to
/// rehydrate everything needed quickly on either the host or remote side.
/// </summary>
[DataContract]
internal readonly struct RoslynNavigateToItem(
    bool isStale,
    DocumentKey documentKey,
    ImmutableArray<ProjectId> additionalMatchingProjects,
    DeclaredSymbolInfo declaredSymbolInfo,
    string kind,
    NavigateToMatchKind matchKind,
    bool isCaseSensitive,
    ImmutableArray<TextSpan> nameMatchSpans,
    ImmutableArray<PatternMatch> matches)
{
    [DataMember(Order = 0)]
    public readonly bool IsStale = isStale;
 
    [DataMember(Order = 1)]
    public readonly DocumentKey DocumentKey = documentKey;
 
    [DataMember(Order = 2)]
    public readonly ImmutableArray<ProjectId> AdditionalMatchingProjects = additionalMatchingProjects;
 
    [DataMember(Order = 3)]
    public readonly DeclaredSymbolInfo DeclaredSymbolInfo = declaredSymbolInfo;
 
    /// <summary>
    /// Will be one of the values from <see cref="NavigateToItemKind"/>.
    /// </summary>
    [DataMember(Order = 4)]
    public readonly string Kind = kind;
 
    [DataMember(Order = 5)]
    public readonly NavigateToMatchKind MatchKind = matchKind;
 
    [DataMember(Order = 6)]
    public readonly bool IsCaseSensitive = isCaseSensitive;
 
    [DataMember(Order = 7)]
    public readonly ImmutableArray<TextSpan> NameMatchSpans = nameMatchSpans;
 
    [DataMember(Order = 8)]
    public readonly ImmutableArray<PatternMatch> Matches = matches;
 
    public DocumentId DocumentId => this.DocumentKey.Id;
 
    public async Task<INavigateToSearchResult?> TryCreateSearchResultAsync(
        Solution solution, Document? activeDocument, CancellationToken cancellationToken)
    {
        if (IsStale)
        {
            // may refer to a document that doesn't exist anymore.  Bail out gracefully in that case.
            var document = solution.GetDocument(DocumentId);
            if (document == null)
                return null;
 
            return new NavigateToSearchResult(this, document, activeDocument);
        }
        else
        {
            var document = await solution.GetRequiredDocumentAsync(
                DocumentId, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false);
            return new NavigateToSearchResult(this, document, activeDocument);
        }
    }
 
    private sealed class NavigateToSearchResult : INavigateToSearchResult, INavigableItem
    {
        private static readonly char[] s_dotArray = ['.'];
 
        private readonly RoslynNavigateToItem _item;
 
        /// <summary>
        /// The <see cref="Document"/> that <see cref="_item"/> is contained within.
        /// </summary>
        private readonly INavigableItem.NavigableDocument _itemDocument;
 
        /// <summary>
        /// The document the user was editing when they invoked the navigate-to operation.
        /// </summary>
        private readonly (DocumentId id, IReadOnlyList<string> folders)? _activeDocument;
 
        private readonly string _additionalInformation;
        private readonly Lazy<string> _secondarySort;
 
        public NavigateToSearchResult(
            RoslynNavigateToItem item,
            Document itemDocument,
            Document? activeDocument)
        {
            _item = item;
            _itemDocument = INavigableItem.NavigableDocument.FromDocument(itemDocument);
            if (activeDocument is not null)
                _activeDocument = (activeDocument.Id, activeDocument.Folders);
 
            _additionalInformation = ComputeAdditionalInformation(in item, itemDocument);
            _secondarySort = new Lazy<string>(ComputeSecondarySort);
        }
 
        private static string ComputeAdditionalInformation(in RoslynNavigateToItem item, Document itemDocument)
        {
            // For partial types, state what file they're in so the user can disambiguate the results.
            var combinedProjectName = ComputeCombinedProjectName(in item, itemDocument);
            return (item.DeclaredSymbolInfo.IsPartial, IsNonNestedNamedType(in item)) switch
            {
                (true, true) => string.Format(FeaturesResources._0_dash_1, itemDocument.Name, combinedProjectName),
                (true, false) => string.Format(FeaturesResources.in_0_1_2, item.DeclaredSymbolInfo.ContainerDisplayName, itemDocument.Name, combinedProjectName),
                (false, true) => string.Format(FeaturesResources.project_0, combinedProjectName),
                (false, false) => string.Format(FeaturesResources.in_0_project_1, item.DeclaredSymbolInfo.ContainerDisplayName, combinedProjectName),
            };
        }
 
        private static string ComputeCombinedProjectName(in RoslynNavigateToItem item, Document itemDocument)
        {
            // If there aren't any additional matches in other projects, we don't need to merge anything.
            if (item.AdditionalMatchingProjects.Length > 0)
            {
                // First get the simple project name and flavor for the actual project we got a hit in.  If we can't
                // figure this out, we can't create a merged name.
                var firstProject = itemDocument.Project;
                var (firstProjectName, firstProjectFlavor) = firstProject.State.NameAndFlavor;
 
                if (firstProjectName != null)
                {
                    var solution = firstProject.Solution;
 
                    using var _ = ArrayBuilder<string>.GetInstance(out var flavors);
                    flavors.Add(firstProjectFlavor!);
 
                    // Now, do the same for the other projects where we had a match. As above, if we can't figure out the
                    // simple name/flavor, or if the simple project name doesn't match the simple project name we started
                    // with then we can't merge these.
                    foreach (var additionalProjectId in item.AdditionalMatchingProjects)
                    {
                        var additionalProject = solution.GetRequiredProject(additionalProjectId);
                        var (projectName, projectFlavor) = additionalProject.State.NameAndFlavor;
                        if (projectName == firstProjectName)
                            flavors.Add(projectFlavor!);
                    }
 
                    flavors.RemoveDuplicates();
                    flavors.Sort();
 
                    return $"{firstProjectName} ({string.Join(", ", flavors)})";
                }
            }
 
            // Couldn't compute a merged project name (or only had one project).  Just return the name of hte project itself.
            return itemDocument.Project.Name;
        }
 
        string INavigateToSearchResult.AdditionalInformation => _additionalInformation;
 
        private static bool IsNonNestedNamedType(in RoslynNavigateToItem item)
            => !item.DeclaredSymbolInfo.IsNestedType && IsNamedType(in item);
 
        private static bool IsNamedType(in RoslynNavigateToItem item)
        {
            switch (item.DeclaredSymbolInfo.Kind)
            {
                case DeclaredSymbolInfoKind.Class:
                case DeclaredSymbolInfoKind.Record:
                case DeclaredSymbolInfoKind.Enum:
                case DeclaredSymbolInfoKind.Interface:
                case DeclaredSymbolInfoKind.Module:
                case DeclaredSymbolInfoKind.Struct:
                case DeclaredSymbolInfoKind.RecordStruct:
                    return true;
                default:
                    return false;
            }
        }
 
        string INavigateToSearchResult.Kind => _item.Kind;
 
        NavigateToMatchKind INavigateToSearchResult.MatchKind => _item.MatchKind;
 
        bool INavigateToSearchResult.IsCaseSensitive => _item.IsCaseSensitive;
 
        string INavigateToSearchResult.Name => _item.DeclaredSymbolInfo.Name;
 
        ImmutableArray<TextSpan> INavigateToSearchResult.NameMatchSpans => _item.NameMatchSpans;
 
        string INavigateToSearchResult.SecondarySort => _secondarySort.Value;
 
        private string ComputeSecondarySort()
        {
            using var _ = ArrayBuilder<string>.GetInstance(out var parts);
 
            // Ensure if all else is equal, that high-pri items (e.g. from the user's current file) come first
            // before low pri items.  This only applies if things like the MatchKind are the same.  So we'll
            // still show an exact match from another file before a substring match from the current file.
            parts.Add(ComputeFolderDistance().ToString("X4"));
 
            parts.Add(_item.DeclaredSymbolInfo.ParameterCount.ToString("X4"));
            parts.Add(_item.DeclaredSymbolInfo.TypeParameterCount.ToString("X4"));
            parts.Add(_item.DeclaredSymbolInfo.Name);
 
            // For partial types, we break up the file name into pieces.  i.e. If we have
            // Outer.cs and Outer.Inner.cs  then we add "Outer" and "Outer Inner" to 
            // the secondary sort string.  That way "Outer.cs" will be weighted above
            // "Outer.Inner.cs"
            var fileName = Path.GetFileNameWithoutExtension(_itemDocument.FilePath ?? "");
            parts.AddRange(fileName.Split(s_dotArray));
 
            return string.Join(" ", parts);
 
            // How close these files are in terms of file system path.  Identical files will have distance 0. Files
            // in the same folder will have distance 1.  Files in different folders will have increasing values here
            // depending on how many folder elements they share/differ on.
            int ComputeFolderDistance()
            {
                // No need to compute anything if there is no active document.  Consider all documents equal.
                if (_activeDocument is not { } activeDocument)
                    return 0;
 
                // The result was in the active document, this get highest priority.
                if (activeDocument.id == _itemDocument.Id)
                    return 0;
 
                var activeFolders = activeDocument.folders;
                var itemFolders = _itemDocument.Folders;
 
                // see how many folder they have in common.
                var commonCount = GetCommonFolderCount();
 
                // from this, we can see how many folders then differ between them.
                var activeDiff = activeFolders.Count - commonCount;
                var itemDiff = itemFolders.Count - commonCount;
 
                // Add one more to the result.  This way if they share all the same folders that we still return
                // '1', indicating that this close to, but not as good a match as an exact file match.
                return activeDiff + itemDiff + 1;
 
                int GetCommonFolderCount()
                {
                    var activeFolders = activeDocument.folders;
                    var itemFolders = _itemDocument.Folders;
 
                    var maxCommon = Math.Min(activeFolders.Count, itemFolders.Count);
                    for (var i = 0; i < maxCommon; i++)
                    {
                        if (activeFolders[i] != itemFolders[i])
                            return i;
                    }
 
                    return maxCommon;
                }
            }
        }
 
        string? INavigateToSearchResult.Summary => null;
 
        INavigableItem INavigateToSearchResult.NavigableItem => this;
 
        ImmutableArray<PatternMatch> INavigateToSearchResult.Matches => _item.Matches;
 
        #region INavigableItem
 
        Glyph INavigableItem.Glyph => GetGlyph(_item.DeclaredSymbolInfo.Kind, _item.DeclaredSymbolInfo.Accessibility);
 
        private static Glyph GetPublicGlyph(DeclaredSymbolInfoKind kind)
            => kind switch
            {
                DeclaredSymbolInfoKind.Class => Glyph.ClassPublic,
                DeclaredSymbolInfoKind.Constant => Glyph.ConstantPublic,
                DeclaredSymbolInfoKind.Constructor => Glyph.MethodPublic,
                DeclaredSymbolInfoKind.Delegate => Glyph.DelegatePublic,
                DeclaredSymbolInfoKind.Enum => Glyph.EnumPublic,
                DeclaredSymbolInfoKind.EnumMember => Glyph.EnumMemberPublic,
                DeclaredSymbolInfoKind.Event => Glyph.EventPublic,
                DeclaredSymbolInfoKind.ExtensionMethod => Glyph.ExtensionMethodPublic,
                DeclaredSymbolInfoKind.Field => Glyph.FieldPublic,
                DeclaredSymbolInfoKind.Indexer => Glyph.PropertyPublic,
                DeclaredSymbolInfoKind.Interface => Glyph.InterfacePublic,
                DeclaredSymbolInfoKind.Method => Glyph.MethodPublic,
                DeclaredSymbolInfoKind.Module => Glyph.ModulePublic,
                DeclaredSymbolInfoKind.Property => Glyph.PropertyPublic,
                DeclaredSymbolInfoKind.Struct => Glyph.StructurePublic,
                DeclaredSymbolInfoKind.RecordStruct => Glyph.StructurePublic,
                _ => Glyph.ClassPublic,
            };
 
        private static Glyph GetGlyph(DeclaredSymbolInfoKind kind, Accessibility accessibility)
        {
            // Glyphs are stored in this order:
            //  ClassPublic,
            //  ClassProtected,
            //  ClassPrivate,
            //  ClassInternal,
 
            var rawGlyph = GetPublicGlyph(kind);
 
            switch (accessibility)
            {
                case Accessibility.Private:
                    rawGlyph += (Glyph.ClassPrivate - Glyph.ClassPublic);
                    break;
                case Accessibility.Internal:
                    rawGlyph += (Glyph.ClassInternal - Glyph.ClassPublic);
                    break;
                case Accessibility.Protected:
                case Accessibility.ProtectedOrInternal:
                case Accessibility.ProtectedAndInternal:
                    rawGlyph += (Glyph.ClassProtected - Glyph.ClassPublic);
                    break;
            }
 
            return rawGlyph;
        }
 
        ImmutableArray<TaggedText> INavigableItem.DisplayTaggedParts
            => [new TaggedText(
                TextTags.Text, _item.DeclaredSymbolInfo.Name + _item.DeclaredSymbolInfo.NameSuffix)];
 
        bool INavigableItem.DisplayFileLocation => false;
 
        /// <summary>
        /// DeclaredSymbolInfos always come from some actual declaration in source.  So they're
        /// never implicitly declared.
        /// </summary>
        bool INavigableItem.IsImplicitlyDeclared => false;
 
        INavigableItem.NavigableDocument INavigableItem.Document => _itemDocument;
 
        TextSpan INavigableItem.SourceSpan => _item.DeclaredSymbolInfo.Span;
 
        bool INavigableItem.IsStale => _item.IsStale;
 
        ImmutableArray<INavigableItem> INavigableItem.ChildItems => [];
 
        #endregion
    }
}