File: Formatting\Passes\RazorFormattingPass.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.Language.Extensions;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.TextDifferencing;
using Microsoft.CodeAnalysis.Text;
using RazorRazorSyntaxNodeList = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxList<Microsoft.AspNetCore.Razor.Language.Syntax.RazorSyntaxNode>;
using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode;
using RazorSyntaxNodeList = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxList<Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode>;
using RazorSyntaxNodeOrToken = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNodeOrToken;
 
namespace Microsoft.CodeAnalysis.Razor.Formatting;
 
internal sealed class RazorFormattingPass : IFormattingPass
{
    public async Task<ImmutableArray<TextChange>> ExecuteAsync(FormattingContext context, ImmutableArray<TextChange> changes, CancellationToken cancellationToken)
    {
        // Apply previous edits if any.
        var originalText = context.SourceText;
        var changedText = originalText;
        var changedContext = context;
        if (changes.Length > 0)
        {
            changedText = changedText.WithChanges(changes);
            changedContext = await context.WithTextAsync(changedText, cancellationToken).ConfigureAwait(false);
 
            cancellationToken.ThrowIfCancellationRequested();
        }
 
        // Format the razor bits of the file
        var syntaxTree = changedContext.CodeDocument.GetRequiredTagHelperRewrittenSyntaxTree();
        var razorChanges = FormatRazor(changedContext, syntaxTree);
 
        if (razorChanges.Length > 0)
        {
            // Compute the final combined set of edits
            changedText = changedText.WithChanges(razorChanges);
            changedContext.Logger?.LogSourceText("AfterRazorFormatter", changedText);
        }
 
        return SourceTextDiffer.GetMinimalTextChanges(originalText, changedText, DiffKind.Char);
    }
 
    private static ImmutableArray<TextChange> FormatRazor(FormattingContext context, RazorSyntaxTree syntaxTree)
    {
        using var changes = new PooledArrayBuilder<TextChange>();
        var source = syntaxTree.Source;
 
        foreach (var node in syntaxTree.Root.DescendantNodes())
        {
            // Disclaimer: CSharpCodeBlockSyntax is used a _lot_ in razor so these methods are probably
            // being overly careful to only try to format syntax forms they care about.
            TryFormatCSharpBlockStructure(context, ref changes.AsRef(), source, node);
            TryFormatSingleLineDirective(ref changes.AsRef(), node);
            TryFormatBlocks(context, ref changes.AsRef(), source, node);
        }
 
        return changes.ToImmutable();
    }
 
    private static void TryFormatBlocks(FormattingContext context, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, RazorSyntaxNode node)
    {
        // We only want to run one of these
        _ = TryFormatFunctionsBlock(context, ref changes, source, node) ||
            TryFormatCSharpExplicitTransition(context, ref changes, source, node) ||
            TryFormatHtmlInCSharp(context, ref changes, source, node) ||
            TryFormatComplexCSharpBlock(context, ref changes, source, node) ||
            TryFormatSectionBlock(context, ref changes, source, node);
    }
 
    private static bool TryFormatSectionBlock(FormattingContext context, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, RazorSyntaxNode node)
    {
        // @section Goo {
        // }
        //
        // or
        //
        // @section Goo
        // {
        // }
        if (node is CSharpCodeBlockSyntax directiveCode &&
            directiveCode.Children is [RazorDirectiveSyntax directive, ..] &&
            directive.IsDirective(SectionDirective.Directive) &&
            directive.DirectiveBody.CSharpCode.Children is { } children)
        {
            if (TryGetWhitespace(children, out var whitespaceBeforeSectionName, out var whitespaceAfterSectionName))
            {
                // For whitespace we normalize it differently depending on if its multi-line or not
                FormatWhitespaceBetweenDirectiveAndBrace(whitespaceBeforeSectionName, directive, ref changes, source, context, forceNewLine: false);
                FormatWhitespaceBetweenDirectiveAndBrace(whitespaceAfterSectionName, directive, ref changes, source, context, forceNewLine: false);
 
                return true;
            }
            else if (children.TryGetOpenBraceToken(out var brace))
            {
                // If there is no whitespace at all we normalize to a single space
                changes.Add(new TextChange(new TextSpan(brace.SpanStart, 0), " "));
 
                return true;
            }
        }
 
        return false;
 
        static bool TryGetWhitespace(RazorRazorSyntaxNodeList children, [NotNullWhen(true)] out CSharpStatementLiteralSyntax? whitespaceBeforeSectionName, [NotNullWhen(true)] out UnclassifiedTextLiteralSyntax? whitespaceAfterSectionName)
        {
            // If there is whitespace between the directive and the section name, and the section name and the brace, they will be in the first child
            // and third child of the 6 total children
            whitespaceBeforeSectionName = null;
            whitespaceAfterSectionName = null;
            if (children.Count == 6 &&
                children[0] is CSharpStatementLiteralSyntax before &&
                before.ContainsOnlyWhitespace() &&
                children[2] is UnclassifiedTextLiteralSyntax after &&
                after.ContainsOnlyWhitespace())
            {
                whitespaceBeforeSectionName = before;
                whitespaceAfterSectionName = after;
 
            }
 
            return whitespaceBeforeSectionName != null;
        }
    }
 
    private static bool TryFormatFunctionsBlock(FormattingContext context, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, RazorSyntaxNode node)
    {
        // @functions
        // {
        // }
        //
        // or
        //
        // @code
        // {
        // }
 
        // In design time code gen, there is only one child of a node like this, but at runtime any leading whitespace is included
        // as a child, so we handle both cases by just checking the last child.
        if (node is CSharpCodeBlockSyntax { Children: [.., RazorDirectiveSyntax directive] })
        {
            if (!IsCodeOrFunctionsBlock(directive.DirectiveBody.Keyword))
            {
                return false;
            }
 
            var csharpCodeChildren = directive.DirectiveBody.CSharpCode.Children;
            if (!csharpCodeChildren.TryGetOpenBraceNode(out var openBrace) ||
                !csharpCodeChildren.TryGetCloseBraceNode(out var closeBrace))
            {
                // Don't trust ourselves in an incomplete scenario.
                return false;
            }
 
            var code = csharpCodeChildren.PreviousSiblingOrSelf(closeBrace) as CSharpCodeBlockSyntax;
 
            var openBraceNode = openBrace;
            var codeNode = code.AssumeNotNull();
            var closeBraceNode = closeBrace;
 
            return FormatBlock(context, source, directive, openBraceNode, codeNode, closeBraceNode, ref changes);
        }
 
        return false;
    }
 
    private static bool TryFormatCSharpExplicitTransition(FormattingContext context, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, RazorSyntaxNode node)
    {
        // We're looking for a code block like this:
        //
        // @{
        //     var x = 1;
        // }
        // Using LastOrDefault because runtime code-gen puts whitespace before the statement
        if (node is CSharpCodeBlockSyntax explicitCode &&
            explicitCode.Children.LastOrDefault() is CSharpStatementSyntax statement &&
            statement.Body is CSharpStatementBodySyntax csharpStatementBody)
        {
            var openBraceNode = csharpStatementBody.OpenBrace;
            var codeNode = csharpStatementBody.CSharpCode;
            var closeBraceNode = csharpStatementBody.CloseBrace;
 
            return FormatBlock(context, source, directiveNode: null, openBraceNode, codeNode, closeBraceNode, ref changes);
        }
 
        return false;
    }
 
    private static bool TryFormatComplexCSharpBlock(FormattingContext context, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, RazorSyntaxNode node)
    {
        // complex situations like
        // @{
        //  void Method(){
        //      @(DateTime.Now)
        //  }
        // }
        if (node is CSharpRazorBlockSyntax csharpRazorBlock &&
            csharpRazorBlock.Parent is CSharpCodeBlockSyntax innerCodeBlock &&
            csharpRazorBlock.Parent.Parent is CSharpCodeBlockSyntax outerCodeBlock)
        {
            var codeNode = csharpRazorBlock;
            var openBraceNode = outerCodeBlock.Children.PreviousSiblingOrSelf(innerCodeBlock);
            var closeBraceNode = outerCodeBlock.Children.NextSiblingOrSelf(innerCodeBlock);
 
            return FormatBlock(context, source, directiveNode: null, openBraceNode, codeNode, closeBraceNode, ref changes);
        }
 
        return false;
    }
 
    private static bool TryFormatHtmlInCSharp(FormattingContext context, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, RazorSyntaxNode node)
    {
        // void Method()
        // {
        //     <div></div>
        // }
        if (node is MarkupBlockSyntax markupBlockNode &&
            markupBlockNode.Parent is CSharpCodeBlockSyntax csharpCodeBlock)
        {
            var openBraceNode = csharpCodeBlock.Children.PreviousSiblingOrSelf(markupBlockNode);
            var closeBraceNode = csharpCodeBlock.Children.NextSiblingOrSelf(markupBlockNode);
 
            return FormatBlock(context, source, directiveNode: null, openBraceNode, markupBlockNode, closeBraceNode, ref changes);
        }
 
        return false;
    }
 
    private static void TryFormatCSharpBlockStructure(FormattingContext context, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, RazorSyntaxNode node)
    {
        // We're looking for a code block like this:
        //
        // @code {
        //    var x = 1;
        // }
        //
        // The nodes will be a grandchild of a RazorDirective (the "@code") and we expect there to be
        // at least three children, being:
        // 1. Optional whitespace
        // 2. The opening brace
        // 3. The C# code
        // 4. The closing brace
        if (node is CSharpCodeBlockSyntax code &&
            node.Parent?.Parent is RazorDirectiveSyntax directive &&
            !directive.ContainsDiagnostics &&
            directive.IsDirectiveKind(DirectiveKind.CodeBlock))
        {
            // If we're formatting a @code or @functions directive, the user might have indicated they always want a newline
            var forceNewLine = context.Options.CodeBlockBraceOnNextLine &&
                directive.DirectiveBody.Keyword is { } keyword &&
                IsCodeOrFunctionsBlock(keyword);
 
            var children = code.Children;
            if (TryGetLeadingWhitespace(children, out var whitespace))
            {
                // For whitespace we normalize it differently depending on if its multi-line or not
                FormatWhitespaceBetweenDirectiveAndBrace(whitespace, directive, ref changes, source, context, forceNewLine);
            }
            else if (children.TryGetOpenBraceToken(out var brace))
            {
                // If there is no whitespace at all we normalize to a single space
                var newText = forceNewLine
                    ? context.NewLineString + FormattingUtilities.GetIndentationString(
                        directive.GetLinePositionSpan(source).Start.Character, context.Options.InsertSpaces, context.Options.TabSize)
                    : " ";
 
                changes.Add(new TextChange(new TextSpan(brace.SpanStart, 0), newText));
            }
        }
 
        static bool TryGetLeadingWhitespace(RazorRazorSyntaxNodeList children, [NotNullWhen(true)] out UnclassifiedTextLiteralSyntax? whitespace)
        {
            // If there is whitespace between the directive and the brace, it will be in the first child
            // of the 4 total children
            whitespace = null;
            if (children.Count == 4 &&
                children[0] is UnclassifiedTextLiteralSyntax literal &&
                literal.ContainsOnlyWhitespace())
            {
                whitespace = literal;
            }
 
            return whitespace != null;
        }
    }
 
    private static void TryFormatSingleLineDirective(ref PooledArrayBuilder<TextChange> changes, RazorSyntaxNode node)
    {
        // Looking for single line directives like...
        //
        // @attribute [Obsolete("old")]
        //
        // The CSharpCodeBlockSyntax covers everything from the end of "attribute" to the end of the line
        //
        // ... or using directives.
 
        // Shrink any block of C# that only has whitespace down to a single space.
        // In the @attribute case above this would only be the whitespace between the directive and code
        // but for @inject its also between the type and the field name.
 
        if (IsSingleLineDirective(node, out var children))
        {
            foreach (var child in children)
            {
                if (child.ContainsOnlyWhitespace(includingNewLines: false))
                {
                    ShrinkToSingleSpace(child, ref changes);
                }
            }
        }
        else if (node.IsUsingDirective(out var tokens))
        {
            foreach (var token in tokens)
            {
                if (token.ContainsOnlyWhitespace(includingNewLines: false))
                {
                    ShrinkToSingleSpace(token, ref changes);
                }
            }
        }
 
        static bool IsSingleLineDirective(RazorSyntaxNode node, out RazorSyntaxNodeList children)
        {
            if (node is CSharpCodeBlockSyntax
                {
                    Parent.Parent: RazorDirectiveSyntax { DirectiveDescriptor.Kind: DirectiveKind.SingleLine }
                } content)
            {
                children = content.Children;
                return true;
            }
 
            children = default;
            return false;
        }
    }
 
    private static void FormatWhitespaceBetweenDirectiveAndBrace(RazorSyntaxNode node, RazorDirectiveSyntax directive, ref PooledArrayBuilder<TextChange> changes, RazorSourceDocument source, FormattingContext context, bool forceNewLine)
    {
        if (node.ContainsOnlyWhitespace(includingNewLines: false) && !forceNewLine)
        {
            ShrinkToSingleSpace(node, ref changes);
        }
        else
        {
            // If there is a newline then we want to have just one newline after the directive
            // and indent the { to match the @
            var newText = context.NewLineString + FormattingUtilities.GetIndentationString(
                    directive.GetLinePositionSpan(source).Start.Character, context.Options.InsertSpaces, context.Options.TabSize);
 
            changes.Add(new TextChange(node.Span, newText));
        }
    }
 
    private static void ShrinkToSingleSpace(RazorSyntaxNodeOrToken nodeOrToken, ref PooledArrayBuilder<TextChange> changes)
    {
        // If there is anything other than one single space then we replace with one space between directive and brace.
        //
        // ie, "@code     {" will become "@code {"
        changes.Add(new TextChange(nodeOrToken.Span, " "));
    }
 
    private static bool FormatBlock(FormattingContext context, RazorSourceDocument source, RazorSyntaxNode? directiveNode, RazorSyntaxNode openBraceNode, RazorSyntaxNode codeNode, RazorSyntaxNode closeBraceNode, ref PooledArrayBuilder<TextChange> changes)
    {
        var didFormat = false;
 
        if (!codeNode.TryGetLinePositionSpanWithoutWhitespace(source, out var codeRange))
        {
            return didFormat;
        }
 
        // When there are multiple sibling markup blocks in a C# code block (e.g., "<text>:</text> <InputFile />"),
        // the open/close brace nodes passed to this method may actually be sibling markup blocks rather than
        // actual braces. We should not try to format between sibling markup blocks as they should remain on the
        // same line.
        var openBraceIsMarkupBlock = openBraceNode is MarkupBlockSyntax && openBraceNode != codeNode;
        var closeBraceIsMarkupBlock = closeBraceNode is MarkupBlockSyntax && closeBraceNode != codeNode;
 
        var additionalIndentation = "";
 
        // It's important with the new formatting engine that we maintain the indentation that the Html formatter would have applied,
        // if the Razor formatting pass had happened first. This is only applicable inside an element, as that is the only place that
        // the Html formatter will do anything.
        // TODO: Rather than ascend up the tree, this could be smarter as this class already descends down the tree
        if (openBraceNode.AncestorsAndSelf().Any(n => n is MarkupTagHelperElementSyntax or MarkupElementSyntax))
        {
            var openBraceLineNumber = openBraceNode.GetLinePositionSpan(source).Start.Line;
            var openBraceLine = source.Text.Lines[openBraceLineNumber];
 
            // The open brace node might actually start with a newline on the line before, which could be blank,
            // so make sure we find some actual content.
            while (!openBraceLine.GetFirstNonWhitespacePosition().HasValue)
            {
                openBraceLine = source.Text.Lines[++openBraceLineNumber];
            }
 
            additionalIndentation = openBraceLine.GetLeadingWhitespace();
        }
 
        if (!openBraceIsMarkupBlock &&
            openBraceNode.TryGetLinePositionSpanWithoutWhitespace(source, out var openBraceRange) &&
            openBraceRange.End.Line == codeRange.Start.Line &&
            !RangeHasBeenModified(ref changes, source.Text, codeRange))
        {
            var end = codeRange.Start;
            var newText = context.NewLineString + additionalIndentation;
            changes.Add(new TextChange(source.Text.GetTextSpan(openBraceRange.End, end), newText));
            didFormat = true;
        }
 
        if (!closeBraceIsMarkupBlock &&
            closeBraceNode.Span.Length > 0 &&
            closeBraceNode.TryGetLinePositionSpanWithoutWhitespace(source, out var closeBraceRange) &&
            !RangeHasBeenModified(ref changes, source.Text, codeRange))
        {
            if (directiveNode is not null &&
                directiveNode.GetRange(source).Start.Character < closeBraceRange.Start.Character)
            {
                // If we have a directive, then we line the close brace up with it, and ensure
                // there is a close brace
                var span = new LinePositionSpan(codeRange.End, closeBraceRange.Start);
                var newText = context.NewLineString + FormattingUtilities.GetIndentationString(
                        directiveNode.GetRange(source).Start.Character, context.Options.InsertSpaces, context.Options.TabSize);
 
                changes.Add(new TextChange(source.Text.GetTextSpan(span), newText));
                didFormat = true;
            }
            else if (codeRange.End.Line == closeBraceRange.Start.Line &&
                codeNode.GetLastToken(includeZeroWidth: false) is not { Kind: SyntaxKind.NewLine })
            {
                // Add a Newline between the content and the "}" if one doesn't already exist, and make sure it lines
                // up with the start of the line that the open brace is on, as though it had been through the Html formatter.
                // In the new formatter, we have to make sure there is no extra whitespace on the new line, or it will be
                // kept when recording Html indentation. This probably wouldn't be an issue in the old engine, but I'm being
                // cautious.
                var start = closeBraceRange.Start;
                changes.Add(new TextChange(source.Text.GetTextSpan(codeRange.End, start), context.NewLineString + additionalIndentation));
                didFormat = true;
            }
 
            // If there is code after the close brace, then we want to add a newline after it and push the code to the next
            // line. In other words, we expect only whitespace characters after the close brace, on this line.
            var closeBraceLine = source.Text.Lines[closeBraceRange.End.Line];
            if (closeBraceLine.GetFirstNonWhitespaceOffset(closeBraceRange.End.Character).HasValue)
            {
                // Insert a newline after the close brace
                changes.Add(new TextChange(source.Text.GetTextSpan(closeBraceRange.End, closeBraceRange.End), context.NewLineString));
            }
        }
 
        return didFormat;
 
        static bool RangeHasBeenModified(ref readonly PooledArrayBuilder<TextChange> changes, SourceText sourceText, LinePositionSpan span)
        {
            // Because we don't always know what kind of Razor object we're operating on we have to do this to avoid duplicate edits.
            // The other way to accomplish this would be to apply the edits after every node and function, but that's not in scope for my current work.
            var endIndex = sourceText.GetRequiredAbsoluteIndex(span.End);
            var hasBeenModified = changes.Any(e => e.Span.End == endIndex);
 
            return hasBeenModified;
        }
    }
 
    private static bool IsCodeOrFunctionsBlock(RazorSyntaxNode keyword)
    {
        var keywordContent = keyword.GetContent();
        return keywordContent == FunctionsDirective.Directive.Directive ||
            keywordContent == ComponentCodeDirective.Directive.Directive;
    }
}