Skip to content

Commit a273d11

Browse files
committed
Don't allow the html formatter to split a literal with a newline
1 parent 43ddc68 commit a273d11

File tree

2 files changed

+70
-17
lines changed

2 files changed

+70
-17
lines changed

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlFormattingPass.cs

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,52 @@
77
using Microsoft.AspNetCore.Razor.Language;
88
using Microsoft.AspNetCore.Razor.Language.Syntax;
99
using Microsoft.AspNetCore.Razor.PooledObjects;
10+
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
1011
using Microsoft.CodeAnalysis.Razor.TextDifferencing;
1112
using Microsoft.CodeAnalysis.Text;
1213

1314
namespace Microsoft.CodeAnalysis.Razor.Formatting;
1415

15-
internal sealed class HtmlFormattingPass : IFormattingPass
16+
internal sealed class HtmlFormattingPass(IDocumentMappingService documentMappingService) : IFormattingPass
1617
{
17-
public Task<ImmutableArray<TextChange>> ExecuteAsync(FormattingContext context, ImmutableArray<TextChange> changes, CancellationToken cancellationToken)
18+
private readonly IDocumentMappingService _documentMappingService = documentMappingService;
19+
20+
public async Task<ImmutableArray<TextChange>> ExecuteAsync(FormattingContext context, ImmutableArray<TextChange> changes, CancellationToken cancellationToken)
1821
{
1922
var changedText = context.SourceText;
2023

2124
if (changes.Length > 0)
2225
{
23-
var filteredChanges = FilterIncomingChanges(context.CodeDocument.GetRequiredSyntaxTree(), changes);
26+
var filteredChanges = await FilterIncomingChangesAsync(context, changes, cancellationToken).ConfigureAwait(false);
2427
changedText = changedText.WithChanges(filteredChanges);
2528

2629
context.Logger?.LogSourceText("AfterHtmlFormatter", changedText);
2730
}
2831

29-
return Task.FromResult(SourceTextDiffer.GetMinimalTextChanges(context.SourceText, changedText, DiffKind.Char));
32+
return SourceTextDiffer.GetMinimalTextChanges(context.SourceText, changedText, DiffKind.Char);
3033
}
3134

32-
private static ImmutableArray<TextChange> FilterIncomingChanges(RazorSyntaxTree syntaxTree, ImmutableArray<TextChange> changes)
35+
private async Task<ImmutableArray<TextChange>> FilterIncomingChangesAsync(FormattingContext context, ImmutableArray<TextChange> changes, CancellationToken cancellationToken)
3336
{
34-
var sourceText = syntaxTree.Source.Text;
37+
var codeDocument = context.CodeDocument;
38+
var csharpDocument = codeDocument.GetRequiredCSharpDocument();
39+
var syntaxRoot = codeDocument.GetRequiredSyntaxRoot();
40+
var sourceText = codeDocument.Source.Text;
41+
SyntaxNode? csharpSyntaxRoot = null;
3542

3643
using var changesToKeep = new PooledArrayBuilder<TextChange>(capacity: changes.Length);
3744

3845
foreach (var change in changes)
3946
{
40-
// Don't keep changes that start inside of a razor comment block.
41-
var comment = syntaxTree.Root.FindInnermostNode(change.Span.Start)?.FirstAncestorOrSelf<RazorCommentBlockSyntax>();
47+
// We don't keep changes that start inside of a razor comment block.
48+
var node = syntaxRoot.FindInnermostNode(change.Span.Start);
49+
var comment = node?.FirstAncestorOrSelf<RazorCommentBlockSyntax>();
4250
if (comment is not null && change.Span.Start > comment.SpanStart)
4351
{
4452
continue;
4553
}
4654

47-
// Normally we don't touch Html changes much but there is one
48-
// edge case when including render fragments in a C# code block, eg:
55+
// When render fragments are inside a C# code block, eg:
4956
//
5057
// @code {
5158
// void Foo()
@@ -56,22 +63,68 @@ private static ImmutableArray<TextChange> FilterIncomingChanges(RazorSyntaxTree
5663
//
5764
// This is popular in some libraries, like bUnit. The issue here is that
5865
// the Html formatter sees ~~~~~<SurveyPrompt /> and puts a newline before
59-
// the tag, but obviously that breaks things.
66+
// the tag, but obviously that breaks things by separating the transition and the tag.
6067
//
6168
// It's straight forward enough to just check for this situation and ignore the change.
6269

6370
// There needs to be a newline being inserted between an '@' and a '<'.
64-
if (change.NewText is ['\r' or '\n', ..] &&
65-
sourceText.Length > 1 &&
66-
sourceText[change.Span.Start - 1] == '@' &&
67-
sourceText[change.Span.Start] == '<')
71+
if (change.NewText is ['\r' or '\n', ..])
6872
{
69-
continue;
73+
if (change.Span.Start > 0 &&
74+
sourceText.Length > 1 &&
75+
sourceText[change.Span.Start - 1] == '@' &&
76+
sourceText[change.Span.Start] == '<')
77+
{
78+
continue;
79+
}
80+
81+
// The Html formatter in VS Code wraps long lines, based on a user setting, but when there
82+
// are long C# string literals that ends up breaking the code. For example:
83+
//
84+
// @("this is a long string that spans past some user set maximmum limit")
85+
//
86+
// could become
87+
//
88+
// @("this is a long string that spans past
89+
// some user set maximum limit")
90+
//
91+
// That doesn't compile, and depending on the scenario, can even cause a crash inside the
92+
// Roslyn formatter.
93+
//
94+
// Strictly speaking if literal is a verbatim string, or multline raw string literal, then
95+
// it would compile, but it would also change the value of the string, and since these edits
96+
// come from the Html formatter which clearly has no idea it's doing that, it is safer to
97+
// disregard them all equally, and let the user make the final decision.
98+
//
99+
// In order to avoid hard coding all of the various string syntax kinds here, we can just check
100+
// for any literal, as the only literals that can contain spaces, which is what the Html formatter
101+
// will wrap on, are strings. And if it did decide to insert a newline into a number, or the 'null'
102+
// keyword, that would be pretty bad too.
103+
if (csharpSyntaxRoot is null)
104+
{
105+
var csharpSyntaxTree = await context.OriginalSnapshot.GetCSharpSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
106+
csharpSyntaxRoot = await csharpSyntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false);
107+
}
108+
109+
if (_documentMappingService.TryMapToCSharpDocumentPosition(csharpDocument, change.Span.Start, out _, out var csharpIndex) &&
110+
csharpSyntaxRoot.FindNode(new TextSpan(csharpIndex, 0), getInnermostNodeForTie: true) is { } csharpNode &&
111+
csharpNode is CSharp.Syntax.LiteralExpressionSyntax or CSharp.Syntax.InterpolatedStringTextSyntax)
112+
{
113+
continue;
114+
}
70115
}
71116

72117
changesToKeep.Add(change);
73118
}
74119

75120
return changesToKeep.ToImmutableAndClear();
76121
}
122+
123+
internal TestAccessor GetTestAccessor() => new(this);
124+
125+
internal readonly struct TestAccessor(HtmlFormattingPass pass)
126+
{
127+
public Task<ImmutableArray<TextChange>> FilterIncomingChangesAsync(FormattingContext context, ImmutableArray<TextChange> changes, CancellationToken cancellationToken)
128+
=> pass.FilterIncomingChangesAsync(context, changes, cancellationToken);
129+
}
77130
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public RazorFormattingService(
5151
];
5252

5353
_documentFormattingPasses = [
54-
new HtmlFormattingPass(),
54+
new HtmlFormattingPass(documentMappingService),
5555
new RazorFormattingPass(),
5656
new CSharpFormattingPass(hostServicesProvider, loggerFactory),
5757
];

0 commit comments

Comments
 (0)