File: IntelliSense\AsyncCompletion\CommitManager.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Completion.Providers;
using Microsoft.CodeAnalysis.Completion.Providers.Snippets;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Threading;
using Roslyn.Utilities;
using AsyncCompletionData = Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data;
using RoslynCompletionItem = Microsoft.CodeAnalysis.Completion.CompletionItem;
using VSCompletionItem = Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data.CompletionItem;
 
namespace Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense.AsyncCompletion;
 
internal sealed class CommitManager : IAsyncCompletionCommitManager
{
    private static readonly AsyncCompletionData.CommitResult CommitResultUnhandled =
        new(isHandled: false, AsyncCompletionData.CommitBehavior.None);
 
    private readonly RecentItemsManager _recentItemsManager;
    private readonly ITextView _textView;
    private readonly IGlobalOptionService _globalOptions;
    private readonly IThreadingContext _threadingContext;
    private readonly ILanguageServerSnippetExpander? _languageServerSnippetExpander;
 
    public IEnumerable<char> PotentialCommitCharacters
    {
        get
        {
            if (_textView.Properties.TryGetProperty(CompletionSource.PotentialCommitCharacters, out ImmutableArray<char> potentialCommitCharacters))
            {
                return potentialCommitCharacters;
            }
            else
            {
                // If we were not initialized with a CompletionService or are called for a wrong textView, we should not make a commit.
                return [];
            }
        }
    }
 
    internal CommitManager(
        ITextView textView,
        RecentItemsManager recentItemsManager,
        IGlobalOptionService globalOptions,
        IThreadingContext threadingContext,
        ILanguageServerSnippetExpander? languageServerSnippetExpander)
    {
        _globalOptions = globalOptions;
        _threadingContext = threadingContext;
        _recentItemsManager = recentItemsManager;
        _textView = textView;
        _languageServerSnippetExpander = languageServerSnippetExpander;
    }
 
    /// <summary>
    /// The method performs a preliminarily filtering of commit availability.
    /// In case of a doubt, it should respond with true.
    /// We will be able to cancel later in 
    /// <see cref="TryCommit(IAsyncCompletionSession, ITextBuffer, VSCompletionItem, char, CancellationToken)"/> 
    /// based on <see cref="VSCompletionItem"/> item, e.g. based on <see cref="CompletionItemRules"/>.
    /// </summary>
    public bool ShouldCommitCompletion(
        IAsyncCompletionSession session,
        SnapshotPoint location,
        char typedChar,
        CancellationToken cancellationToken)
    {
        // this is called only when the typedChar is in the list returned by PotentialCommitCharacters.
        // It's possible typedChar is intended to be a filter char for some items (either currently considered for commit of not)
        // we let this case to be handled in `TryCommit` instead, where we will have all the information needed to decide.
        return true;
    }
 
    public AsyncCompletionData.CommitResult TryCommit(
        IAsyncCompletionSession session,
        ITextBuffer subjectBuffer,
        VSCompletionItem item,
        char typedChar,
        CancellationToken cancellationToken)
    {
        // We can make changes to buffers. We would like to be sure nobody can change them at the same time.
        _threadingContext.ThrowIfNotOnUIThread();
 
        var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
        {
            return CommitResultUnhandled;
        }
 
        var completionService = document.GetLanguageService<CompletionService>();
        if (completionService == null)
        {
            return CommitResultUnhandled;
        }
 
        if (!CompletionItemData.TryGetData(item, out var itemData))
        {
            // Roslyn should not be called if the item committing was not provided by Roslyn.
            return CommitResultUnhandled;
        }
 
        var roslynItem = itemData.RoslynItem;
        var filterText = session.ApplicableToSpan.GetText(session.ApplicableToSpan.TextBuffer.CurrentSnapshot) + typedChar;
 
        if (Helpers.IsFilterCharacter(roslynItem, typedChar, filterText))
        {
            // Returning Cancel means we keep the current session and consider the character for further filtering.
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.CancelCommit);
        }
 
        // typedChar could be a filter character for another item. If we find such an item that the current filter
        // text matches its start, then we should cancel commit and give ItemManager a chance to handle it.
        // This is done here instead of in `ShouldCommitCompletion` because `ShouldCommitCompletion` might be called before
        // CompletionSource add the `excludedCommitCharactersMap` to the session property bag.
        if (session.Properties.TryGetProperty(CompletionSource.ExcludedCommitCharactersMap, out MultiDictionary<char, RoslynCompletionItem> excludedCommitCharactersMap))
        {
            foreach (var potentialItemForSelection in excludedCommitCharactersMap[typedChar])
            {
                if (potentialItemForSelection != roslynItem && Helpers.TextTypedSoFarMatchesItem(potentialItemForSelection, filterText))
                    return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.CancelCommit);
            }
        }
 
        var options = _globalOptions.GetCompletionOptions(document.Project.Language);
        var serviceRules = completionService.GetRules(options);
 
        // We can be called before for ShouldCommitCompletion. However, that call does not provide rules applied for the completion item.
        // Now we check for the commit character in the context of Rules that could change the list of commit characters.
 
        if (!Helpers.IsStandardCommitCharacter(typedChar) && !IsCommitCharacter(serviceRules, roslynItem, typedChar))
        {
            // Returning None means we complete the current session with a void commit. 
            // The Editor then will try to trigger a new completion session for the character.
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.None);
        }
 
        if (!itemData.TriggerLocation.HasValue)
        {
            // Need the trigger snapshot to calculate the span when the commit changes to be applied.
            // They should always be available from items provided by Roslyn CompletionSource.
            // Just to be defensive, if it's not found here, Roslyn should not make a commit.
            return CommitResultUnhandled;
        }
 
        var triggerDocument = itemData.TriggerLocation.Value.Snapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (triggerDocument == null)
        {
            return CommitResultUnhandled;
        }
 
        var sessionData = CompletionSessionData.GetOrCreateSessionData(session);
        if (!sessionData.CompletionListSpan.HasValue)
        {
            return CommitResultUnhandled;
        }
 
        // Commit with completion service assumes that null is provided is case of invoke. VS provides '\0' in the case.
        var commitChar = typedChar == '\0' ? null : (char?)typedChar;
        return Commit(
            session, triggerDocument, completionService, subjectBuffer,
            roslynItem, sessionData.CompletionListSpan.Value, commitChar, itemData.TriggerLocation.Value.Snapshot, serviceRules,
            filterText, cancellationToken);
    }
 
    private AsyncCompletionData.CommitResult Commit(
        IAsyncCompletionSession session,
        Document document,
        CompletionService completionService,
        ITextBuffer subjectBuffer,
        RoslynCompletionItem roslynItem,
        TextSpan completionListSpan,
        char? commitCharacter,
        ITextSnapshot triggerSnapshot,
        CompletionRules rules,
        string filterText,
        CancellationToken cancellationToken)
    {
        _threadingContext.ThrowIfNotOnUIThread();
 
        bool includesCommitCharacter;
        if (!subjectBuffer.CheckEditAccess())
        {
            // We are on the wrong thread.
            FatalError.ReportAndCatch(new InvalidOperationException("Subject buffer did not provide Edit Access"), ErrorSeverity.Critical);
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.None);
        }
 
        if (subjectBuffer.EditInProgress)
        {
            FatalError.ReportAndCatch(new InvalidOperationException("Subject buffer is editing by someone else."), ErrorSeverity.Critical);
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.None);
        }
 
        // This might be an item promoted by us, make sure we restore it to the original state first.
        roslynItem = Helpers.DemoteItem(roslynItem);
        CompletionChange change;
 
        // We met an issue when external code threw an OperationCanceledException and the cancellationToken is not canceled.
        // Catching this scenario for further investigations.
        // See https://github.com/dotnet/roslyn/issues/38455.
        try
        {
            // Cached items have a span computed at the point they were created.  This span may no 
            // longer be valid when used again.  In that case, override the span with the latest span
            // for the completion list itself.
            if (roslynItem.Flags.IsCached())
                roslynItem.Span = completionListSpan;
 
            // Adding import is not allowed in debugger view
            if (_textView is IDebuggerTextView)
                roslynItem = ImportCompletionItem.MarkItemToAlwaysFullyQualify(roslynItem);
 
            change = completionService.GetChangeAsync(document, roslynItem, commitCharacter, cancellationToken).WaitAndGetResult(cancellationToken);
        }
        catch (OperationCanceledException e) when (e.CancellationToken != cancellationToken && FatalError.ReportAndCatch(e))
        {
            return CommitResultUnhandled;
        }
 
        cancellationToken.ThrowIfCancellationRequested();
 
        var view = session.TextView;
 
        var provider = completionService.GetProvider(roslynItem, document.Project);
        if (provider is ICustomCommitCompletionProvider customCommitProvider)
        {
            customCommitProvider.Commit(roslynItem, document, view, subjectBuffer, triggerSnapshot, commitCharacter);
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.None);
        }
 
        var textChange = change.TextChange;
        var triggerSnapshotSpan = new SnapshotSpan(triggerSnapshot, textChange.Span.ToSpan());
        var mappedSpan = triggerSnapshotSpan.TranslateTo(subjectBuffer.CurrentSnapshot, SpanTrackingMode.EdgeInclusive);
 
        // Specifically for snippets, we check to see if the associated completion item is a snippet,
        // and if so, we call upon the LanguageServerSnippetExpander's TryExpand to insert the snippet.
        if (SnippetCompletionItem.IsSnippet(roslynItem))
        {
            Contract.ThrowIfNull(_languageServerSnippetExpander);
 
            var lspSnippetText = change.Properties[SnippetCompletionItem.LSPSnippetKey];
 
            Contract.ThrowIfNull(lspSnippetText);
            if (!_languageServerSnippetExpander.TryExpand(lspSnippetText, mappedSpan, _textView))
            {
                FatalError.ReportAndCatch(new InvalidOperationException("The invoked LSP snippet expander came back as false."), ErrorSeverity.Critical);
            }
 
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.None);
        }
 
        ITextSnapshot updatedCurrentSnapshot;
        using (var edit = subjectBuffer.CreateEdit(EditOptions.DefaultMinimalChange, reiteratedVersionNumber: null, editTag: null))
        {
            edit.Replace(mappedSpan.Span, change.TextChange.NewText);
 
            // edit.Apply() may trigger changes made by extensions.
            // updatedCurrentSnapshot will contain changes made by Roslyn but not by other extensions.
            updatedCurrentSnapshot = edit.Apply();
        }
 
        if (change.NewPosition.HasValue)
        {
            // Roslyn knows how to position the caret in the snapshot we just created.
            // If there were more edits made by extensions, TryMoveCaretToAndEnsureVisible maps the snapshot point to the most recent one.
            view.TryMoveCaretToAndEnsureVisible(new SnapshotPoint(updatedCurrentSnapshot, change.NewPosition.Value));
        }
        else
        {
            // Or, If we're doing a minimal change, then the edit that we make to the 
            // buffer may not make the total text change that places the caret where we 
            // would expect it to go based on the requested change. In this case, 
            // determine where the item should go and set the care manually.
 
            // Note: we only want to move the caret if the caret would have been moved 
            // by the edit.  i.e. if the caret was actually in the mapped span that 
            // we're replacing.
            var caretPositionInBuffer = view.GetCaretPoint(subjectBuffer);
            if (caretPositionInBuffer.HasValue && mappedSpan.IntersectsWith(caretPositionInBuffer.Value))
            {
                view.TryMoveCaretToAndEnsureVisible(new SnapshotPoint(subjectBuffer.CurrentSnapshot, mappedSpan.Start.Position + textChange.NewText?.Length ?? 0));
            }
            else
            {
                view.Caret.EnsureVisible();
            }
        }
 
        includesCommitCharacter = change.IncludesCommitCharacter;
 
        if (roslynItem.Rules.FormatOnCommit)
        {
            // The edit updates the snapshot however other extensions may make changes there.
            // Therefore, it is required to use subjectBuffer.CurrentSnapshot for further calculations rather than the updated current snapshot defined above.
            var currentDocument = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
            var formattingService = currentDocument?.GetRequiredLanguageService<IFormattingInteractionService>();
 
            if (currentDocument != null && formattingService != null)
            {
                var spanToFormat = triggerSnapshotSpan.TranslateTo(subjectBuffer.CurrentSnapshot, SpanTrackingMode.EdgeInclusive);
 
                // Note: C# always completes synchronously, TypeScript is async
                var changes = formattingService.GetFormattingChangesAsync(currentDocument, subjectBuffer, spanToFormat.Span.ToTextSpan(), cancellationToken).WaitAndGetResult(cancellationToken);
                subjectBuffer.ApplyChanges(changes);
            }
        }
 
        _recentItemsManager.MakeMostRecentItem(roslynItem);
 
        if (provider is INotifyCommittingItemCompletionProvider notifyProvider)
        {
            _ = _threadingContext.JoinableTaskFactory.RunAsync(async () =>
            {
                // Make sure the notification isn't sent on UI thread.
                await TaskScheduler.Default;
                _ = notifyProvider.NotifyCommittingItemAsync(document, roslynItem, commitCharacter, cancellationToken).ReportNonFatalErrorAsync();
            });
        }
 
        if (includesCommitCharacter)
        {
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.SuppressFurtherTypeCharCommandHandlers);
        }
 
        if (commitCharacter == '\n' && SendEnterThroughToEditor(rules, roslynItem, filterText))
        {
            return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.RaiseFurtherReturnKeyAndTabKeyCommandHandlers);
        }
 
        return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.None);
    }
 
    internal static bool IsCommitCharacter(CompletionRules completionRules, CompletionItem item, char ch)
    {
        // First see if the item has any specific commit rules it wants followed.
        foreach (var rule in item.Rules.CommitCharacterRules)
        {
            switch (rule.Kind)
            {
                case CharacterSetModificationKind.Add:
                    if (rule.Characters.Contains(ch))
                    {
                        return true;
                    }
 
                    continue;
 
                case CharacterSetModificationKind.Remove:
                    if (rule.Characters.Contains(ch))
                    {
                        return false;
                    }
 
                    continue;
 
                case CharacterSetModificationKind.Replace:
                    return rule.Characters.Contains(ch);
            }
        }
 
        // Fall back to the default rules for this language's completion service.
        return completionRules.DefaultCommitCharacters.IndexOf(ch) >= 0;
    }
 
    internal static bool SendEnterThroughToEditor(CompletionRules rules, RoslynCompletionItem item, string textTypedSoFar)
    {
        var rule = item.Rules.EnterKeyRule;
        if (rule == EnterKeyRule.Default)
        {
            rule = rules.DefaultEnterKeyRule;
        }
 
        switch (rule)
        {
            default:
            case EnterKeyRule.Default:
            case EnterKeyRule.Never:
                return false;
            case EnterKeyRule.Always:
                return true;
            case EnterKeyRule.AfterFullyTypedWord:
                // textTypedSoFar is concatenated from individual chars typed.
                // '\n' is the enter char.
                // That is why, there is no need to check for '\r\n'.
                if (textTypedSoFar.LastOrDefault() == '\n')
                {
                    textTypedSoFar = textTypedSoFar[..^1];
                }
 
                return item.GetEntireDisplayText() == textTypedSoFar;
        }
    }
}