File: RawStringLiteral\RawStringLiteralCommandHandler_Return.cs
Web Access
Project: src\src\EditorFeatures\CSharp\Microsoft.CodeAnalysis.CSharp.EditorFeatures.csproj (Microsoft.CodeAnalysis.CSharp.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.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Indentation;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Editor.CSharp.RawStringLiteral;
 
internal partial class RawStringLiteralCommandHandler : ICommandHandler<ReturnKeyCommandArgs>
{
    public CommandState GetCommandState(ReturnKeyCommandArgs args)
        => CommandState.Unspecified;
 
    /// <summary>
    /// Checks to see if the user is typing <c>return</c> in <c>"""$$"""</c> and then properly indents the end
    /// delimiter of the raw string literal.
    /// </summary>
    public bool ExecuteCommand(ReturnKeyCommandArgs args, CommandExecutionContext context)
    {
        var textView = args.TextView;
        var subjectBuffer = args.SubjectBuffer;
        var spans = textView.Selection.GetSnapshotSpansOnBuffer(subjectBuffer);
 
        if (spans.Count != 1)
            return false;
 
        var span = spans.First();
        if (span.Length != 0)
            return false;
 
        var caret = textView.GetCaretPoint(subjectBuffer);
        if (caret == null)
            return false;
 
        var position = caret.Value.Position;
        var currentSnapshot = subjectBuffer.CurrentSnapshot;
        if (position >= currentSnapshot.Length)
            return false;
 
        var cancellationToken = context.OperationContext.UserCancellationToken;
 
        var document = currentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
            return false;
 
        return currentSnapshot[position] == '"'
            ? ExecuteReturnCommandBeforeQuoteCharacter()
            : ExecuteReturnCommandNotBeforeQuoteCharacter();
 
        bool ExecuteReturnCommandBeforeQuoteCharacter()
        {
            var quotesBefore = 0;
            var quotesAfter = 0;
 
            // Ensure we're in between a balanced set of quotes, with at least 3 quotes on each side.
 
            var currentSnapshot = subjectBuffer.CurrentSnapshot;
            for (int i = position, n = currentSnapshot.Length; i < n; i++)
            {
                if (currentSnapshot[i] != '"')
                    break;
 
                quotesAfter++;
            }
 
            for (var i = position - 1; i >= 0; i--)
            {
                if (currentSnapshot[i] != '"')
                    break;
 
                quotesBefore++;
            }
 
            // We support two cases here.  Something simple like `"""$$"""`.  In this case, we have to be hitting enter
            // inside balanced quotes.  But we also support `"""goo$$"""`.  In this case it's ok if quotes are not
            // balanced.  We're going to go through the non-empty path involving adding multiple newlines to the final
            // text.
 
            var isEmpty = quotesBefore > 0;
            if (isEmpty && quotesAfter != quotesBefore)
                return false;
 
            if (quotesAfter < 3)
                return false;
 
            // Looks promising based on text alone.  Now ensure we're actually on a raw string token/expression.
            var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
 
            var token = parsedDocument.Root.FindToken(position);
            if (token.Kind() is not (SyntaxKind.SingleLineRawStringLiteralToken or
                                     SyntaxKind.MultiLineRawStringLiteralToken or
                                     SyntaxKind.InterpolatedSingleLineRawStringStartToken or
                                     SyntaxKind.InterpolatedMultiLineRawStringStartToken) ||
                token.Parent is not ExpressionSyntax expression)
            {
                return false;
            }
 
            return MakeEdit(parsedDocument, expression, isEmpty);
        }
 
        bool ExecuteReturnCommandNotBeforeQuoteCharacter()
        {
            // If the caret is not on a quote, we need to find whether we are within the contents of a single-line raw
            // string literal but not inside an interpolation. If we are inside a raw string literal and the caret is not on
            // top of a quote, it is part of the literal's text. Here we try to ensure that the literal's closing quotes are
            // properly placed in their own line We could reach this point after pressing enter within a single-line raw
            // string
 
            var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
 
            var token = parsedDocument.Root.FindToken(position);
            ExpressionSyntax expression;
            switch (token.Kind())
            {
                case SyntaxKind.SingleLineRawStringLiteralToken when token.Parent is ExpressionSyntax parentExpression:
                    expression = parentExpression;
                    break;
 
                case SyntaxKind.InterpolatedStringTextToken:
                case SyntaxKind.OpenBraceToken:
                    if (token is not
                        {
                            Parent.Parent: InterpolatedStringExpressionSyntax
                            {
                                StringStartToken.RawKind: (int)SyntaxKind.InterpolatedSingleLineRawStringStartToken,
                            } interpolatedStringExpression,
                        })
                    {
                        return false;
                    }
 
                    if (token.Kind() is SyntaxKind.OpenBraceToken && position != token.SpanStart)
                        return false;
 
                    expression = interpolatedStringExpression;
                    break;
 
                default:
                    return false;
            }
 
            return MakeEdit(parsedDocument, expression, isEmpty: false);
        }
 
        bool MakeEdit(
            ParsedDocument parsedDocument,
            ExpressionSyntax expression,
            bool isEmpty)
        {
            var project = document.Project;
            var indentationOptions = subjectBuffer.GetIndentationOptions(_editorOptionsService, project.GetFallbackAnalyzerOptions(), project.Services, explicitFormat: false);
            var indentation = expression.GetFirstToken().GetPreferredIndentation(parsedDocument, indentationOptions, cancellationToken);
 
            var newLine = indentationOptions.FormattingOptions.NewLine;
 
            using var transaction = CaretPreservingEditTransaction.TryCreate(
                CSharpEditorResources.Split_raw_string, textView, _undoHistoryRegistry, _editorOperationsFactoryService);
            var edit = subjectBuffer.CreateEdit();
 
            if (isEmpty)
            {
                // If the literal is empty, we just want to help the user transform it into a multiline raw string
                // literal with the extra empty newline between the delimiters to place the caret at
                edit.Insert(position, newLine + newLine + indentation);
 
                var finalSnapshot = edit.Apply();
 
                // move caret to the right location in virtual space for the blank line we added.
                var lineInNewSnapshot = finalSnapshot.GetLineFromPosition(position);
                var nextLine = finalSnapshot.GetLineFromLineNumber(lineInNewSnapshot.LineNumber + 1);
                textView.Caret.MoveTo(new VirtualSnapshotPoint(nextLine, indentation.Length));
            }
            else
            {
                // Otherwise, we're starting with a raw string that has content in it.  That's something like:
                // """GooBar""".  If we hit enter at the `G` we only want to insert a single new line before the caret.
                // However, if we were to hit enter anywhere after that, we want two new lines inserted.  One after the
                // `"""` and one at the caret itself.
                var newLineAndIndentation = newLine + indentation;
 
                // Add a newline at the position of the end literal
                var closingStart = GetStartPositionOfClosingDelimiter(expression);
                edit.Insert(closingStart, newLineAndIndentation);
 
                // Add a newline at the caret's position, to insert the newline that the user requested
                edit.Insert(position, newLineAndIndentation);
 
                // Also add a newline at the start of the text, only if there is text before the caret's position
                var insertedLinesBeforeCaret = 1;
                var openingEnd = GetEndPositionOfOpeningDelimiter(expression);
                if (openingEnd != position)
                {
                    insertedLinesBeforeCaret = 2;
                    edit.Insert(openingEnd, newLineAndIndentation);
                }
 
                var finalSnapshot = edit.Apply();
 
                var lineInNewSnapshot = finalSnapshot.GetLineFromPosition(openingEnd);
                var nextLine = finalSnapshot.GetLineFromLineNumber(lineInNewSnapshot.LineNumber + insertedLinesBeforeCaret);
                textView.Caret.MoveTo(new VirtualSnapshotPoint(nextLine, indentation.Length));
            }
 
            transaction?.Complete();
            return true;
        }
 
        int GetStartPositionOfClosingDelimiter(ExpressionSyntax expression)
        {
            if (expression is InterpolatedStringExpressionSyntax interpolatedStringExpression)
                return interpolatedStringExpression.StringEndToken.Span.Start;
 
            var index = expression.Span.End;
            while (currentSnapshot[index - 1] == '"')
                index--;
 
            return index;
        }
 
        int GetEndPositionOfOpeningDelimiter(ExpressionSyntax expression)
        {
            if (expression is InterpolatedStringExpressionSyntax interpolatedStringExpression)
                return interpolatedStringExpression.StringStartToken.Span.End;
 
            var index = expression.Span.Start;
            while (currentSnapshot[index] == '"')
                index++;
 
            return index;
        }
    }
}