File: Formatting\Passes\CSharpFormattingPass.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;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Features;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.TextDifferencing;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.Razor.Formatting;
 
internal sealed partial class CSharpFormattingPass(
    IHostServicesProvider hostServicesProvider,
    IDocumentMappingService documentMappingService,
    ILoggerFactory loggerFactory) : IFormattingPass
{
    private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<CSharpFormattingPass>();
    private readonly IHostServicesProvider _hostServicesProvider = hostServicesProvider;
    private readonly IDocumentMappingService _documentMappingService = documentMappingService;
 
    public async Task<ImmutableArray<TextChange>> ExecuteAsync(FormattingContext context, ImmutableArray<TextChange> changes, CancellationToken cancellationToken)
    {
        // Process changes from previous passes
        var changedText = context.SourceText.WithChanges(changes);
        var changedContext = await context.WithTextAsync(changedText, cancellationToken).ConfigureAwait(false);
        context.Logger?.LogObject("SourceMappings", changedContext.CodeDocument.GetRequiredCSharpDocument().SourceMappingsSortedByGenerated);
 
        var csharpSyntaxTrue = await changedContext.CurrentSnapshot.GetCSharpSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
        var csharpSyntaxRoot = await csharpSyntaxTrue.GetRootAsync(cancellationToken).ConfigureAwait(false);
 
        // To format C# code we generate a C# document that represents the indentation semantics the user would be
        // expecting in their Razor file. See the doc comments on CSharpDocumentGenerator for more info
        var generatedDocument = CSharpDocumentGenerator.Generate(changedContext.CodeDocument, csharpSyntaxRoot, context.Options, _documentMappingService);
 
        var generatedCSharpText = generatedDocument.SourceText;
        context.Logger?.LogSourceText("FormattingDocument", generatedCSharpText);
        context.Logger?.LogObject("FormattingDocumentLineInfo", generatedDocument.LineInfo);
        var formattedCSharpText = await FormatCSharpAsync(generatedCSharpText, context.Options, cancellationToken).ConfigureAwait(false);
        context.Logger?.LogSourceText("FormattedFormattingDocument", formattedCSharpText);
 
        // We now have a formatted C# document, and an original document, but we can't just apply the changes to the original
        // document as they come from very different places. What we want to do is go through each line of the generated document,
        // take the indentation that is in it, and apply it to the original document, and then take any formatting changes
        // on that line, and translate them across to the original document.
        // Essentially each line is split in two, with indentation on the left of the first non-whitespace char, and formatting
        // changes on the right. Sometimes we need to skip parts of the right (eg, skip the `@` in `@if`), and sometimes we skip
        // one side entirely.
 
        using var formattingChanges = new PooledArrayBuilder<TextChange>();
        FormattingUtilities.GetOriginalDocumentChangesFromLineInfo(context, changedText, generatedDocument.LineInfo, formattedCSharpText, _logger, shouldKeepInsertedNewlineAtPosition: null, ref formattingChanges.AsRef(), out var lastFormattedTextLine);
 
        // We're finished processing the original file, which means we've done all of the indentation for the file, and we've done
        // the formatting changes for lines that are entirely C#, or start with C#, and lines that are Html or Razor. Now we process
        // the "additional changes", which is formatting for C# that is inside Html, via implicit or explicit expressions.
 
        // Previous to this step, all of our changes will have been in order by definition of how we go through the document, so
        // we haven't had to worry about overlaps, but now we do. In order to not loop constantly, we keep track of an extra index
        // variable for where we are in the changes, to check for overlaps.
        var iChanges = 0;
        for (var iFormatted = lastFormattedTextLine; iFormatted < formattedCSharpText.Lines.Count; iFormatted++)
        {
            // Any C# that is in the middle of a line of Html/Razor will be emitted at the end of the generated document, with a
            // comment above it that encodes where it came from in the original file. We just look for the comment, and then apply
            // the next line as formatted content.
            if (CSharpDocumentGenerator.TryParseAdditionalLineComment(formattedCSharpText.Lines[iFormatted], out var start, out var length))
            {
                iFormatted++;
 
                // Skip ahead to where changes are likely to become relevant, to save looping the whole set every time
                while (iChanges < formattingChanges.Count)
                {
                    if (formattingChanges[iChanges].Span.End > start)
                    {
                        break;
                    }
 
                    iChanges++;
                }
 
                if (iChanges < formattingChanges.Count &&
                    formattingChanges[iChanges].Span.Contains(start))
                {
                    // To avoid overlapping changes, which Roslyn will throw on, we just have to drop this change. It gives the user
                    // something at least, and hopefully they'll report a bug for this case so we can find it.
                    context.Logger?.LogMessage($"Skipping a change that would have overlapped an existing change, starting at {start} for {length} chars, overlapping a change at {formattingChanges[iChanges].Span}. iFormatted={iFormatted}, iChanges={iChanges}");
                    continue;
                }
 
                formattingChanges.Add(new TextChange(new TextSpan(start, length), formattedCSharpText.Lines[iFormatted].ToString()));
            }
        }
 
        var finalFormattingChanges = formattingChanges.ToArray();
        context.Logger?.LogObject("FinalFormattingChanges", finalFormattingChanges);
        changedText = changedText.WithChanges(finalFormattingChanges);
        context.Logger?.LogSourceText("FinalFormattedDocument", changedText);
 
        // And we're done, we have a final set of changes to apply. BUT these are changes to the document after Html and Razor
        // formatting, and the return from this method must be changes relative to the original passed in document. The algorithm
        // above is fairly naive anyway, and a lot of them will be no-ops, so it's nice to have this final step as a filter.
        return SourceTextDiffer.GetMinimalTextChanges(context.SourceText, changedText, DiffKind.Char);
    }
 
    private async Task<SourceText> FormatCSharpAsync(SourceText generatedCSharpText, RazorFormattingOptions options, CancellationToken cancellationToken)
    {
        using var helper = new RoslynWorkspaceHelper(_hostServicesProvider);
 
        var tree = CSharpSyntaxTree.ParseText(generatedCSharpText, cancellationToken: cancellationToken);
        var csharpRoot = await tree.GetRootAsync(cancellationToken).ConfigureAwait(false);
        var csharpSyntaxFormattingOptions = options.CSharpSyntaxFormattingOptions;
 
        if (csharpSyntaxFormattingOptions is not null)
        {
            // Roslyn can be configured to insert a space after a method call, or a dot, but that can break Razor. eg:
            //
            // <div>@PrintHello()</div>
            // @DateTime.Now.ToString()
            //
            // Would become:
            //
            // <div>@PrintHello ()</div>
            // @DateTime. Now. ToString()
            //
            // In Razor, that's not a method call, its a method group (ie C# compile error) followed by Html, and
            // the dot after DateTime is also just Html, as is the rest of the line.
            // We're not smart enough (yet?) to ignore this change when its inline in Razor, but allow it when
            // in a code block, so we just force these options to off.
            csharpSyntaxFormattingOptions = csharpSyntaxFormattingOptions with
            {
                Spacing = csharpSyntaxFormattingOptions.Spacing
                    & ~RazorSpacePlacement.AfterMethodCallName
                    & ~RazorSpacePlacement.AfterDot
            };
        }
 
        var csharpChanges = RazorCSharpFormattingInteractionService.GetFormattedTextChanges(helper.HostWorkspaceServices, csharpRoot, csharpRoot.FullSpan, options.ToIndentationOptions(), csharpSyntaxFormattingOptions, cancellationToken);
 
        return generatedCSharpText.WithChanges(csharpChanges);
    }
 
    [Obsolete("Only for the syntax visualizer, do not call")]
    internal static string GetFormattingDocumentContentsForSyntaxVisualizer(RazorCodeDocument codeDocument, SyntaxNode csharpSyntaxRoot, IDocumentMappingService documentMappingService)
        => CSharpDocumentGenerator.Generate(codeDocument, csharpSyntaxRoot, new(), documentMappingService).SourceText.ToString();
}