Skip to content

Commit c166218

Browse files
authored
Drop Html edits that would split a C# literal across multiple lines (#12396)
Fixes #11846 Also happened in dotnet/vscode-csharp#7264 The VS Code html formatter loves to break long C# strings, which at worst means we abandon the formatting operation, or at worst Roslyn throws. Also some stuff in here to use the new formatting logging, which is how I repro'd the issue. Yay!
2 parents fac6edf + 29dad74 commit c166218

File tree

10 files changed

+440
-24
lines changed

10 files changed

+440
-24
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public async Task<ImmutableArray<TextChange>> ExecuteAsync(FormattingContext con
2828
// Process changes from previous passes
2929
var changedText = context.SourceText.WithChanges(changes);
3030
var changedContext = await context.WithTextAsync(changedText, cancellationToken).ConfigureAwait(false);
31+
context.Logger?.LogObject("SourceMappings", changedContext.CodeDocument.GetRequiredCSharpDocument().SourceMappings);
3132

3233
// To format C# code we generate a C# document that represents the indentation semantics the user would be
3334
// expecting in their Razor file. See the doc comments on CSharpDocumentGenerator for more info

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 maximum 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 multiline 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: 2 additions & 2 deletions
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
];
@@ -92,7 +92,7 @@ public async Task<ImmutableArray<TextChange>> GetDocumentFormattingChangesAsync(
9292

9393
var logger = _formattingLoggerFactory.CreateLogger(documentContext.FilePath, range is null ? "Full" : "Range");
9494
logger?.LogObject("Options", options);
95-
logger?.LogObject("HtmlChanges", htmlChanges);
95+
logger?.LogObject("HtmlChanges", htmlChanges.SelectAsArray(e => e.ToRazorTextChange()));
9696
logger?.LogObject("Range", range);
9797
logger?.LogSourceText("InitialDocument", sourceText);
9898

src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingTestBase.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,9 @@ internal static IDocumentSnapshot CreateDocumentSnapshot(
347347
return CreateDocumentSnapshot(
348348
path, fileKind, codeDocument, projectEngine, imports, importDocuments, tagHelpers, inGlobalNamespace);
349349
});
350+
snapshotMock
351+
.Setup(d => d.GetCSharpSyntaxTreeAsync(It.IsAny<CancellationToken>()))
352+
.ReturnsAsync(codeDocument.GetOrParseCSharpSyntaxTree(CancellationToken.None));
350353

351354
return snapshotMock.Object;
352355
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Linq;
5+
using System.Runtime.CompilerServices;
6+
using System.Runtime.Serialization;
7+
using System.Text.Json;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Razor;
10+
using Microsoft.AspNetCore.Razor.Language;
11+
using Microsoft.AspNetCore.Razor.Test.Common;
12+
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Features;
13+
using Microsoft.CodeAnalysis.Razor.Formatting;
14+
using Microsoft.CodeAnalysis.Razor.Protocol;
15+
using Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
16+
using Microsoft.VisualStudio.Razor.LanguageClient.Cohost.Formatting;
17+
using Xunit;
18+
using Xunit.Abstractions;
19+
20+
namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost.Formatting;
21+
22+
/// <summary>
23+
/// Not tests of the formatting log, but tests that use formatting logs sent in
24+
/// by users reporting issues.
25+
/// </summary>
26+
[Collection(HtmlFormattingCollection.Name)]
27+
public class FormattingLogTest(FormattingTestContext context, HtmlFormattingFixture fixture, ITestOutputHelper testOutput)
28+
: FormattingTestBase(context, fixture.Service, testOutput), IClassFixture<FormattingTestContext>
29+
{
30+
[Fact]
31+
[WorkItem("https://github.com/dotnet/vscode-csharp/issues/7264")]
32+
public async Task UnexpectedFalseInIndentBlockOperation()
33+
{
34+
var contents = GetResource("InitialDocument.txt");
35+
var document = CreateProjectAndRazorDocument(contents);
36+
37+
var optionsFile = GetResource("Options.json");
38+
var options = (TempRazorFormattingOptions)JsonSerializer.Deserialize(optionsFile, typeof(TempRazorFormattingOptions), JsonHelpers.JsonSerializerOptions).AssumeNotNull();
39+
40+
var formattingService = (RazorFormattingService)OOPExportProvider.GetExportedValue<IRazorFormattingService>();
41+
formattingService.GetTestAccessor().SetFormattingLoggerFactory(new TestFormattingLoggerFactory(TestOutputHelper));
42+
43+
var htmlChangesFile = GetResource("HtmlChanges.json");
44+
var htmlChanges = JsonSerializer.Deserialize<RazorTextChange[]>(htmlChangesFile, JsonHelpers.JsonSerializerOptions);
45+
var sourceText = await document.GetTextAsync();
46+
var htmlEdits = htmlChanges.Select(c => sourceText.GetTextEdit(c.ToTextChange())).ToArray();
47+
48+
await GetFormattingEditsAsync(document, htmlEdits, span: default, options.CodeBlockBraceOnNextLine, options.InsertSpaces, options.TabSize, options.ToRazorFormattingOptions().CSharpSyntaxFormattingOptions.AssumeNotNull());
49+
}
50+
51+
private string GetResource(string name, [CallerMemberName] string? testName = null)
52+
{
53+
var baselineFileName = $@"TestFiles\FormattingLog\{testName}\{name}";
54+
55+
var testFile = TestFile.Create(baselineFileName, GetType().Assembly);
56+
Assert.True(testFile.Exists());
57+
58+
return testFile.ReadAllText();
59+
}
60+
61+
// HACK: Temporary types for deserializing because RazorCSharpSyntaxFormattingOptions doesn't have a parameterless constructor.
62+
internal class TempRazorFormattingOptions()
63+
{
64+
[DataMember(Order = 0)]
65+
public bool InsertSpaces { get; init; } = true;
66+
[DataMember(Order = 1)]
67+
public int TabSize { get; init; } = 4;
68+
[DataMember(Order = 2)]
69+
public bool CodeBlockBraceOnNextLine { get; init; } = false;
70+
[DataMember(Order = 3)]
71+
public TempRazorCSharpSyntaxFormattingOptions? CSharpSyntaxFormattingOptions { get; init; }
72+
73+
public RazorFormattingOptions ToRazorFormattingOptions()
74+
=> new()
75+
{
76+
InsertSpaces = InsertSpaces,
77+
TabSize = TabSize,
78+
CodeBlockBraceOnNextLine = CodeBlockBraceOnNextLine,
79+
CSharpSyntaxFormattingOptions = CSharpSyntaxFormattingOptions is not null
80+
? new RazorCSharpSyntaxFormattingOptions(
81+
CSharpSyntaxFormattingOptions.Spacing,
82+
CSharpSyntaxFormattingOptions.SpacingAroundBinaryOperator,
83+
CSharpSyntaxFormattingOptions.NewLines,
84+
CSharpSyntaxFormattingOptions.LabelPositioning,
85+
CSharpSyntaxFormattingOptions.Indentation,
86+
CSharpSyntaxFormattingOptions.WrappingKeepStatementsOnSingleLine,
87+
CSharpSyntaxFormattingOptions.WrappingPreserveSingleLine,
88+
CSharpSyntaxFormattingOptions.NamespaceDeclarations,
89+
CSharpSyntaxFormattingOptions.PreferTopLevelStatements,
90+
CSharpSyntaxFormattingOptions.CollectionExpressionWrappingLength)
91+
: RazorCSharpSyntaxFormattingOptions.Default
92+
};
93+
}
94+
95+
[DataContract]
96+
internal sealed record class TempRazorCSharpSyntaxFormattingOptions(
97+
[property: DataMember] RazorSpacePlacement Spacing,
98+
[property: DataMember] RazorBinaryOperatorSpacingOptions SpacingAroundBinaryOperator,
99+
[property: DataMember] RazorNewLinePlacement NewLines,
100+
[property: DataMember] RazorLabelPositionOptions LabelPositioning,
101+
[property: DataMember] RazorIndentationPlacement Indentation,
102+
[property: DataMember] bool WrappingKeepStatementsOnSingleLine,
103+
[property: DataMember] bool WrappingPreserveSingleLine,
104+
[property: DataMember] RazorNamespaceDeclarationPreference NamespaceDeclarations,
105+
[property: DataMember] bool PreferTopLevelStatements,
106+
[property: DataMember] int CollectionExpressionWrappingLength)
107+
{
108+
public TempRazorCSharpSyntaxFormattingOptions()
109+
: this(
110+
default,
111+
default,
112+
default,
113+
default,
114+
default,
115+
default,
116+
default,
117+
default,
118+
true,
119+
default)
120+
{
121+
}
122+
}
123+
}

src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/Formatting/FormattingTestBase.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,11 @@ private protected async Task RunFormattingTestAsync(
7676
var uri = new Uri(document.CreateUri(), $"{document.FilePath}{FeatureOptions.HtmlVirtualDocumentSuffix}");
7777
var htmlEdits = await _htmlFormattingService.GetDocumentFormattingEditsAsync(LoggerFactory, uri, generatedHtml, insertSpaces, tabSize);
7878

79-
var requestInvoker = new TestHtmlRequestInvoker([(Methods.TextDocumentFormattingName, htmlEdits)]);
80-
81-
var clientSettingsManager = new ClientSettingsManager(changeTriggers: []);
82-
clientSettingsManager.Update(clientSettingsManager.GetClientSettings().AdvancedSettings with { CodeBlockBraceOnNextLine = codeBlockBraceOnNextLine });
83-
8479
var span = input.TryGetNamedSpans(string.Empty, out var spans)
8580
? spans.First()
8681
: default;
87-
var edits = await GetFormattingEditsAsync(span, insertSpaces, tabSize, document, requestInvoker, clientSettingsManager, csharpSyntaxFormattingOptions);
82+
83+
var edits = await GetFormattingEditsAsync(document, htmlEdits, span, codeBlockBraceOnNextLine, insertSpaces, tabSize, csharpSyntaxFormattingOptions);
8884

8985
if (edits is null)
9086
{
@@ -99,6 +95,17 @@ private protected async Task RunFormattingTestAsync(
9995
AssertEx.EqualOrDiff(expected, finalText.ToString());
10096
}
10197

98+
private protected async Task<TextEdit[]?> GetFormattingEditsAsync(TextDocument document, TextEdit[]? htmlEdits, TextSpan span, bool codeBlockBraceOnNextLine, bool insertSpaces, int tabSize, RazorCSharpSyntaxFormattingOptions csharpSyntaxFormattingOptions)
99+
{
100+
var requestInvoker = new TestHtmlRequestInvoker([(Methods.TextDocumentFormattingName, htmlEdits)]);
101+
102+
var clientSettingsManager = new ClientSettingsManager(changeTriggers: []);
103+
clientSettingsManager.Update(clientSettingsManager.GetClientSettings().AdvancedSettings with { CodeBlockBraceOnNextLine = codeBlockBraceOnNextLine });
104+
105+
var edits = await GetFormattingEditsAsync(span, insertSpaces, tabSize, document, requestInvoker, clientSettingsManager, csharpSyntaxFormattingOptions);
106+
return edits;
107+
}
108+
102109
private protected async Task RunOnTypeFormattingTestAsync(
103110
TestCode input,
104111
string expected,

0 commit comments

Comments
 (0)