File: Formatting\Passes\HtmlOnTypeFormattingPass.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.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.TextDifferencing;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.Razor.Formatting;
 
/// <summary>
/// Gets edits in Html files, and returns edits to Razor files, with nicely formatted Html
/// </summary>
internal sealed class HtmlOnTypeFormattingPass : IFormattingPass
{
    public async Task<ImmutableArray<TextChange>> ExecuteAsync(FormattingContext context, ImmutableArray<TextChange> changes, CancellationToken cancellationToken)
    {
        if (changes.Length == 0)
        {
            // There are no HTML edits for us to apply. No op.
            return [];
        }
 
        var originalText = context.SourceText;
 
        var changedText = originalText;
        var changedContext = context;
 
        context.Logger?.LogSourceText("BeforeHtmlFormatter", changedText);
 
        if (changes.Length > 0)
        {
            var filteredChanges = FilterIncomingChanges(changedContext, changes);
 
            changedText = originalText.WithChanges(filteredChanges);
            // Create a new formatting context for the changed razor document.
            changedContext = await context.WithTextAsync(changedText, cancellationToken).ConfigureAwait(false);
 
            context.Logger?.LogSourceText("AfterNormalizedEdits", changedText);
        }
 
        var indentationChanges = AdjustRazorIndentation(changedContext);
        if (indentationChanges.Length > 0)
        {
            // Apply the edits that adjust indentation.
            changedText = changedText.WithChanges(indentationChanges);
            context.Logger?.LogSourceText("AfterAdjustRazorIndentation", changedText);
        }
 
        return SourceTextDiffer.GetMinimalTextChanges(originalText, changedText, DiffKind.Char);
    }
 
    private static ImmutableArray<TextChange> FilterIncomingChanges(FormattingContext context, ImmutableArray<TextChange> changes)
    {
        var root = context.CodeDocument.GetRequiredSyntaxRoot();
 
        using var changesToKeep = new PooledArrayBuilder<TextChange>(capacity: changes.Length);
 
        foreach (var change in changes)
        {
            // Don't keep changes that start inside of a razor comment block.
            var comment = root.FindInnermostNode(change.Span.Start)?.FirstAncestorOrSelf<RazorCommentBlockSyntax>();
            if (comment is not null)
            {
                continue;
            }
 
            changesToKeep.Add(change);
        }
 
        return changesToKeep.ToImmutableAndClear();
    }
 
    private static ImmutableArray<TextChange> AdjustRazorIndentation(FormattingContext context)
    {
        // Assume HTML formatter has already run at this point and HTML is relatively indented correctly.
        // But HTML doesn't know about Razor blocks.
        // Our goal here is to indent each line according to the surrounding Razor blocks.
        var sourceText = context.SourceText;
        var indentations = context.GetIndentations();
 
        using var editsToApply = new PooledArrayBuilder<TextChange>(capacity: sourceText.Lines.Count);
        for (var i = 0; i < sourceText.Lines.Count; i++)
        {
            var line = sourceText.Lines[i];
            if (line.Span.Length == 0)
            {
                // Empty line.
                continue;
            }
 
            if (indentations[i].StartsInCSharpContext)
            {
                // Normally we don't do HTML things in C# contexts but there is one
                // edge case when including render fragments in a C# code block, eg:
                //
                // @code {
                //      void Foo()
                //      {
                //          Render(@<SurveyPrompt />);
                //      {
                // }
                //
                // This is popular in some libraries, like bUnit. The issue here is that
                // the HTML formatter sees ~~~~~<SurveyPrompt /> and puts a newline before
                // the tag, but obviously that breaks things.
                //
                // It's straight forward enough to just check for this situation and special case
                // it by removing the newline again.
 
                // There needs to be at least one more line, and the current line needs to end with
                // an @ sign, and have an open angle bracket at the start of the next line.
                if (sourceText.Lines.Count >= i + 1 &&
                    line.Text?.Length > 1 &&
                    line.Text?[line.End - 1] == '@')
                {
                    var nextLine = sourceText.Lines[i + 1];
                    var firstChar = nextLine.GetFirstNonWhitespaceOffset().GetValueOrDefault();
 
                    // When the HTML formatter inserts the newline in this scenario, it doesn't
                    // indent the component tag, so we use that as another signal that this is
                    // the scenario we think it is.
                    if (firstChar == 0 &&
                        nextLine.Text?[nextLine.Start] == '<')
                    {
                        var lineBreakLength = line.EndIncludingLineBreak - line.End;
                        var spanToReplace = new TextSpan(line.End, lineBreakLength);
                        var change = new TextChange(spanToReplace, string.Empty);
                        editsToApply.Add(change);
 
                        // Skip the next line because we've essentially just removed it.
                        i++;
                    }
                }
 
                continue;
            }
 
            var razorDesiredIndentationLevel = indentations[i].RazorIndentationLevel;
            if (razorDesiredIndentationLevel == 0)
            {
                // This line isn't under any Razor specific constructs. Trust the HTML formatter.
                continue;
            }
 
            var htmlDesiredIndentationLevel = indentations[i].HtmlIndentationLevel;
            if (htmlDesiredIndentationLevel == 0 && !IsPartOfHtmlTag(context, indentations[i].FirstSpan.Span.Start))
            {
                // This line is under some Razor specific constructs but not under any HTML tag.
                // E.g,
                // @{
                //          @* comment *@ <----
                // }
                //
                // In this case, the HTML formatter wouldn't touch it but we should format it correctly.
                // So, let's use our syntax understanding to rewrite the indentation.
                // Note: This case doesn't apply for HTML tags (HTML formatter will touch it even if it is in the root).
                // Hence the second part of the if condition.
                //
                var desiredIndentationLevel = indentations[i].IndentationLevel;
                var desiredIndentationString = context.GetIndentationLevelString(desiredIndentationLevel);
                var spanToReplace = new TextSpan(line.Start, indentations[i].ExistingIndentation);
                var change = new TextChange(spanToReplace, desiredIndentationString);
                editsToApply.Add(change);
            }
            else
            {
                // This line is under some Razor specific constructs and HTML tags.
                // E.g,
                // @{
                //    <div class="foo"
                //         id="oof">  <----
                //    </div>
                // }
                //
                // In this case, the HTML formatter would've formatted it correctly. Let's not use our syntax understanding.
                // Instead, we should just add to the existing indentation.
                //
                var razorDesiredIndentationString = context.GetIndentationLevelString(razorDesiredIndentationLevel);
                var existingIndentationString = FormattingUtilities.GetIndentationString(indentations[i].ExistingIndentationSize, context.Options.InsertSpaces, context.Options.TabSize);
                var desiredIndentationString = existingIndentationString + razorDesiredIndentationString;
                var spanToReplace = new TextSpan(line.Start, indentations[i].ExistingIndentation);
                var change = new TextChange(spanToReplace, desiredIndentationString);
                editsToApply.Add(change);
            }
        }
 
        return editsToApply.ToImmutableAndClear();
    }
 
    private static bool IsPartOfHtmlTag(FormattingContext context, int position)
    {
        var root = context.CodeDocument.GetRequiredSyntaxRoot();
        var owner = root.FindInnermostNode(position, includeWhitespace: true);
        if (owner is null)
        {
            // Can't determine owner of this position.
            return false;
        }
 
        // E.g, (| is position)
        //
        // `<p csharpattr="|Variable">` - true
        //
        return owner.AncestorsAndSelf().Any(
            n => n is MarkupStartTagSyntax or MarkupTagHelperStartTagSyntax or MarkupEndTagSyntax or MarkupTagHelperEndTagSyntax);
    }
}