File: BraceCompletion\AbstractCurlyBraceOrBracketCompletionService.cs
Web Access
Project: src\src\Features\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Features.csproj (Microsoft.CodeAnalysis.CSharp.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.Diagnostics;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.BraceCompletion;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Formatting.Rules;
using Microsoft.CodeAnalysis.Indentation;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CSharp.BraceCompletion;
 
internal abstract class AbstractCurlyBraceOrBracketCompletionService : AbstractCSharpBraceCompletionService
{
    /// <summary>
    /// Annotation used to find the closing brace location after formatting changes are applied.
    /// The closing brace location is then used as the caret location.
    /// </summary>
    private static readonly SyntaxAnnotation s_closingBraceFormatAnnotation = new(nameof(s_closingBraceFormatAnnotation));
    private static readonly SyntaxAnnotation s_closingBraceNewlineAnnotation = new(nameof(s_closingBraceNewlineAnnotation));
 
    protected abstract ImmutableArray<AbstractFormattingRule> GetBraceFormattingIndentationRulesAfterReturn(IndentationOptions options);
 
    protected abstract int AdjustFormattingEndPoint(ParsedDocument document, int startPoint, int endPoint);
 
    public sealed override BraceCompletionResult? GetTextChangesAfterCompletion(BraceCompletionContext context, IndentationOptions options, CancellationToken cancellationToken)
    {
        // After the closing brace is completed we need to format the span from the opening point to the closing point.
        // E.g. when the user triggers completion for an if statement ($$ is the caret location) we insert braces to get
        // if (true){$$}
        // We then need to format this to
        // if (true) { $$}
 
        if (!options.AutoFormattingOptions.FormatOnCloseBrace)
        {
            return null;
        }
 
        var (_, formattingChanges, finalCurlyBraceEnd) = FormatTrackingSpan(
            context.Document,
            context.OpeningPoint,
            context.ClosingPoint,
            // We're not trying to format the indented block here, so no need to pass in additional rules.
            braceFormattingIndentationRules: [],
            options,
            cancellationToken);
 
        if (formattingChanges.IsEmpty)
        {
            return null;
        }
 
        // The caret location should be at the start of the closing brace character.
        var formattedText = context.Document.Text.WithChanges(formattingChanges);
        var caretLocation = formattedText.Lines.GetLinePosition(finalCurlyBraceEnd - 1);
 
        return new BraceCompletionResult(formattingChanges, caretLocation);
    }
 
    private static bool ContainsOnlyWhitespace(SourceText text, int openingPosition, int closingBraceEndPoint)
    {
        // Set the start point to the character after the opening brace.
        var start = openingPosition + 1;
        // Set the end point to the closing brace start character position.
        var end = closingBraceEndPoint - 1;
 
        for (var i = start; i < end; i++)
        {
            if (!char.IsWhiteSpace(text[i]))
            {
                return false;
            }
        }
 
        return true;
    }
 
    public sealed override BraceCompletionResult? GetTextChangeAfterReturn(
        BraceCompletionContext context,
        IndentationOptions options,
        CancellationToken cancellationToken)
    {
        var document = context.Document;
        var closingPoint = context.ClosingPoint;
        var openingPoint = context.OpeningPoint;
        var originalDocumentText = document.Text;
 
        // check whether shape of the braces are what we support
        // shape must be either "{|}" or "{ }". | is where caret is. otherwise, we don't do any special behavior
        if (!ContainsOnlyWhitespace(originalDocumentText, openingPoint, closingPoint))
        {
            return null;
        }
 
        var openingPointLine = originalDocumentText.Lines.GetLineFromPosition(openingPoint).LineNumber;
        var closingPointLine = originalDocumentText.Lines.GetLineFromPosition(closingPoint).LineNumber;
 
        // If there are already multiple empty lines between the braces, don't do anything.
        // We need to allow a single empty line between the braces to account for razor scenarios where they insert a line.
        if (closingPointLine - openingPointLine > 2)
        {
            return null;
        }
 
        // If there is not already an empty line inserted between the braces, insert one.
        TextChange? newLineEdit = null;
        if (closingPointLine - openingPointLine == 1)
        {
            // Handling syntax tree directly to avoid parsing in potentially UI blocking code-path
            var closingToken = FindClosingBraceToken(document.Root, closingPoint);
            var annotatedNewline = SyntaxFactory.EndOfLine(options.FormattingOptions.NewLine).WithAdditionalAnnotations(s_closingBraceNewlineAnnotation);
            var newClosingToken = closingToken.WithPrependedLeadingTrivia(annotatedNewline);
 
            var rootToFormat = document.Root.ReplaceToken(closingToken, newClosingToken);
            annotatedNewline = rootToFormat.GetAnnotatedTrivia(s_closingBraceNewlineAnnotation).Single();
 
            document = GetUpdatedDocument(document, [new TextChange(closingToken.FullSpan, newClosingToken.ToFullString())], rootToFormat);
 
            // Calculate text change for adding a newline and adjust closing point location.
            closingPoint = annotatedNewline.Token.Span.End;
            newLineEdit = new TextChange(new TextSpan(annotatedNewline.SpanStart, 0), annotatedNewline.ToString());
        }
 
        // Format the text that contains the newly inserted line.
        var (formattedRoot, formattingChanges, newClosingPoint) = FormatTrackingSpan(
            document,
            openingPoint,
            closingPoint,
            braceFormattingIndentationRules: GetBraceFormattingIndentationRulesAfterReturn(options),
            options,
            cancellationToken);
 
        var newDocument = GetUpdatedDocument(document, formattingChanges, formattedRoot);
 
        // Get the empty line between the curly braces.
        var desiredCaretLine = GetLineBetweenCurlys(newClosingPoint, newDocument.Text);
        Debug.Assert(desiredCaretLine.GetFirstNonWhitespacePosition() == null, "the line between the formatted braces is not empty");
 
        // Set the caret position to the properly indented column in the desired line.
        var caretPosition = GetIndentedLinePosition(newDocument, newDocument.Text, desiredCaretLine.LineNumber, options, cancellationToken);
 
        return new BraceCompletionResult(GetMergedChanges(newLineEdit, formattingChanges, newDocument.Text), caretPosition);
 
        // Create a new ParsedDocument given an old document, a set of changes and the new root. Typically, creating a new ParsedDocument is
        // done with either a call to ParsedDocument.WithChangedRoot or ParsedDocument.WithChangedText. The former ends up creating
        // a SourceText, while the latter parses the document. For performance reasons, we don't want to do either of those, so
        // we'll just created the ParsedDocument directly with the information we already have.
        static ParsedDocument GetUpdatedDocument(ParsedDocument oldDocument, IEnumerable<TextChange> changes, SyntaxNode newRoot)
        {
            var newText = oldDocument.Text.WithChanges(changes);
 
            return new ParsedDocument(oldDocument.Id, newText, newRoot, oldDocument.HostLanguageServices);
        }
 
        static TextLine GetLineBetweenCurlys(int closingPosition, SourceText text)
        {
            var closingBraceLineNumber = text.Lines.GetLineFromPosition(closingPosition - 1).LineNumber;
            return text.Lines[closingBraceLineNumber - 1];
        }
 
        static LinePosition GetIndentedLinePosition(ParsedDocument document, SourceText sourceText, int lineNumber, IndentationOptions options, CancellationToken cancellationToken)
        {
            var indentationService = document.LanguageServices.GetRequiredService<IIndentationService>();
            var indentation = indentationService.GetIndentation(document, lineNumber, options, cancellationToken);
 
            var baseLinePosition = sourceText.Lines.GetLinePosition(indentation.BasePosition);
            var offsetOfBasePosition = baseLinePosition.Character;
            var totalOffset = offsetOfBasePosition + indentation.Offset;
            var indentedLinePosition = new LinePosition(lineNumber, totalOffset);
            return indentedLinePosition;
        }
 
        static ImmutableArray<TextChange> GetMergedChanges(TextChange? newLineEdit, ImmutableArray<TextChange> formattingChanges, SourceText formattedText)
        {
            // The new line edit is calculated against the original text, d0, to get text d1.
            // The formatting edits are calculated against d1 to get text d2.
            // Merge the formatting and new line edits into a set of whitespace only text edits that all apply to d0.
            if (!newLineEdit.HasValue)
                return formattingChanges;
 
            // Depending on options, we might not get any formatting change.
            // In this case, the newline edit is the only change.
            if (formattingChanges.IsEmpty)
                return [newLineEdit.Value];
 
            var newRanges = TextChangeRangeExtensions.Merge(
                [newLineEdit.Value.ToTextChangeRange()],
                formattingChanges.SelectAsArray(f => f.ToTextChangeRange()));
 
            var mergedChanges = new FixedSizeArrayBuilder<TextChange>(newRanges.Length);
            var amountToShift = 0;
            foreach (var newRange in newRanges)
            {
                var newTextChangeSpan = newRange.Span;
                // Get the text to put in the text change by looking at the span in the formatted text.
                // As the new range start is relative to the original text, we need to adjust it assuming the previous changes were applied
                // to get the correct start location in the formatted text.
                // E.g. with changes
                //     1. Insert "hello" at 2
                //     2. Insert "goodbye" at 3
                // "goodbye" is after "hello" at location 3 + 5 (length of "hello") in the new text.
                var newTextChangeText = formattedText.GetSubText(new TextSpan(newRange.Span.Start + amountToShift, newRange.NewLength)).ToString();
                amountToShift += (newRange.NewLength - newRange.Span.Length);
                mergedChanges.Add(new TextChange(newTextChangeSpan, newTextChangeText));
            }
 
            return mergedChanges.MoveToImmutable();
        }
    }
 
    /// <summary>
    /// Formats the span between the opening and closing points, options permitting.
    /// Returns the text changes that should be applied to the input document to 
    /// get the formatted text and the end of the close curly brace in the formatted text.
    /// </summary>
    private (SyntaxNode formattedRoot, ImmutableArray<TextChange> textChanges, int finalBraceEnd) FormatTrackingSpan(
        ParsedDocument document,
        int openingPoint,
        int closingPoint,
        ImmutableArray<AbstractFormattingRule> braceFormattingIndentationRules,
        IndentationOptions options,
        CancellationToken cancellationToken)
    {
        var startPoint = openingPoint;
        var endPoint = AdjustFormattingEndPoint(document, startPoint, closingPoint);
 
        if (options.IndentStyle == FormattingOptions2.IndentStyle.Smart)
        {
            // Set the formatting start point to be the beginning of the first word to the left 
            // of the opening brace location.
            // skip whitespace
            while (startPoint >= 0 && char.IsWhiteSpace(document.Text[startPoint]))
            {
                startPoint--;
            }
 
            // skip tokens in the first word to the left.
            startPoint--;
            while (startPoint >= 0 && !char.IsWhiteSpace(document.Text[startPoint]))
            {
                startPoint--;
            }
        }
 
        var spanToFormat = TextSpan.FromBounds(Math.Max(startPoint, 0), endPoint);
        var rules = FormattingRuleUtilities.GetFormattingRules(document, spanToFormat, braceFormattingIndentationRules);
 
        // Annotate the original closing brace so we can find it after formatting.
        var annotatedRoot = GetSyntaxRootWithAnnotatedClosingBrace(document.Root, closingPoint);
 
        var result = Formatter.GetFormattingResult(
            annotatedRoot, [spanToFormat], document.SolutionServices, options.FormattingOptions, rules, cancellationToken);
 
        if (result == null)
        {
            return (document.Root, ImmutableArray<TextChange>.Empty, closingPoint);
        }
 
        var newRoot = result.GetFormattedRoot(cancellationToken);
        var newClosingPoint = newRoot.GetAnnotatedTokens(s_closingBraceFormatAnnotation).Single().SpanStart + 1;
 
        var textChanges = result.GetTextChanges(cancellationToken).ToImmutableArray();
        return (newRoot, textChanges, newClosingPoint);
 
        SyntaxNode GetSyntaxRootWithAnnotatedClosingBrace(SyntaxNode originalRoot, int closingBraceEndPoint)
        {
            var closeBraceToken = FindClosingBraceToken(originalRoot, closingBraceEndPoint);
            var newCloseBraceToken = closeBraceToken.WithAdditionalAnnotations(s_closingBraceFormatAnnotation);
            return originalRoot.ReplaceToken(closeBraceToken, newCloseBraceToken);
        }
    }
 
    private SyntaxToken FindClosingBraceToken(SyntaxNode root, int closingBraceEndPoint)
    {
        var closeBraceToken = root.FindToken(closingBraceEndPoint - 1);
        Debug.Assert(IsValidClosingBraceToken(closeBraceToken));
        return closeBraceToken;
    }
}