Skip to content

Commit 5c7a847

Browse files
committed
Emit render fragments as block bodied lambdas
This means we do a much better, but not perfect, job of formatting render fragments.
1 parent 75c6eb0 commit 5c7a847

File tree

2 files changed

+97
-19
lines changed

2 files changed

+97
-19
lines changed

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

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
using System.Diagnostics;
77
using System.Linq;
88
using System.Text;
9+
using Microsoft.AspNetCore.Razor;
910
using Microsoft.AspNetCore.Razor.Language;
1011
using Microsoft.AspNetCore.Razor.Language.Syntax;
1112
using Microsoft.AspNetCore.Razor.PooledObjects;
13+
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Features;
1214
using Microsoft.CodeAnalysis.Razor.Workspaces;
1315
using Microsoft.CodeAnalysis.Text;
1416
using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode;
@@ -147,6 +149,7 @@ private sealed class Generator(
147149
private readonly RazorCodeDocument _codeDocument = codeDocument;
148150
private readonly bool _insertSpaces = options.InsertSpaces;
149151
private readonly int _tabSize = options.TabSize;
152+
private readonly RazorCSharpSyntaxFormattingOptions? _csharpSyntaxFormattingOptions = options.CSharpSyntaxFormattingOptions;
150153
private readonly StringBuilder _builder = builder;
151154
private readonly ImmutableArray<LineInfo>.Builder _lineInfoBuilder = lineInfoBuilder;
152155

@@ -386,6 +389,18 @@ _sourceText.Lines[nodeStartLine] is { } previousLine &&
386389
public override LineInfo VisitCSharpStatementLiteral(CSharpStatementLiteralSyntax node)
387390
{
388391
Debug.Assert(node.LiteralTokens.Count > 0);
392+
393+
// If this is the end of a multi-line CSharp template (ie, RenderFragment) then we need to close
394+
// out the lambda expression that we started when we opened it.
395+
if (node.LiteralTokens.Where(static t => !t.IsWhitespace()).FirstOrDefault() is { Content: ";" } &&
396+
node.TryGetPreviousSibling(out var previousSibling) &&
397+
previousSibling is CSharpTemplateBlockSyntax &&
398+
GetLineNumber(previousSibling.GetFirstToken()) != GetLineNumber(previousSibling.GetLastToken()))
399+
{
400+
_builder.AppendLine("};");
401+
return CreateLineInfo();
402+
}
403+
389404
return VisitCSharpLiteral(node, node.LiteralTokens[^1]);
390405
}
391406

@@ -412,22 +427,47 @@ private LineInfo VisitCSharpLiteral(RazorSyntaxNode node, RazorSyntaxToken lastT
412427
// Now emit the contents
413428
var span = TextSpan.FromBounds(_currentFirstNonWhitespacePosition, node.EndPosition);
414429
_builder.Append(_sourceText.ToString(span));
415-
// Append a comment at the end so whitespace isn't removed, as Roslyn thinks its the end of the line, but we know it isn't.
416-
_builder.AppendLine(" //");
417430

418431
// Putting a semi-colon on the end might make for invalid C#, but it means this line won't cause indentation,
419432
// which is all we need. If we're in an explicit expression body though, we don't want to do this, as the
420433
// close paren of the expression will do the same job (and the semi-colon would confuse that).
421434
var emitSemiColon = node.Parent.Parent is not CSharpExplicitExpressionBodySyntax;
435+
436+
var skipNextLineIfBrace = false;
437+
int formattedOffsetFromEndOfLine;
438+
439+
// If the template is multiline we emit a lambda expression, otherwise just a null statement so there
440+
// is something there. See VisitMarkupTransition for more info
441+
if (token.Parent?.Parent.Parent is CSharpTemplateBlockSyntax template &&
442+
_sourceText.GetLinePositionSpan(template.Span).SpansMultipleLines())
443+
{
444+
emitSemiColon = false;
445+
skipNextLineIfBrace = true;
446+
_builder.AppendLine("() => {");
447+
448+
// We only want to format up to the text we added, but if Roslyn inserted a newline before the brace
449+
// then that position will be different. If we're not given the options then we assume the default behaviour of
450+
// Roslyn which is to insert the newline.
451+
formattedOffsetFromEndOfLine = _csharpSyntaxFormattingOptions?.NewLines.IsFlagSet(RazorNewLinePlacement.BeforeOpenBraceInLambdaExpressionBody) ?? true
452+
? 5
453+
: 7;
454+
}
455+
else
456+
{
457+
_builder.AppendLine("null");
458+
formattedOffsetFromEndOfLine = 4;
459+
}
460+
422461
if (emitSemiColon)
423462
{
424463
_builder.AppendLine(";");
425464
}
426465

427466
return CreateLineInfo(
428467
skipNextLine: emitSemiColon,
468+
skipNextLineIfBrace: skipNextLineIfBrace,
429469
formattedLength: span.Length,
430-
formattedOffsetFromEndOfLine: 3,
470+
formattedOffsetFromEndOfLine: formattedOffsetFromEndOfLine,
431471
processFormatting: true,
432472
// We turn off check for new lines because that only works if the content doesn't change from the original,
433473
// but we're deliberately leaving out a bunch of the original file because it would confuse the Roslyn formatter.
@@ -458,8 +498,25 @@ public override LineInfo VisitMarkupStartTag(MarkupStartTagSyntax node)
458498

459499
public override LineInfo VisitMarkupEndTag(MarkupEndTagSyntax node)
460500
{
461-
// Since this visitor only sees nodes at the start of a line, an end tag always means de-dent.
462-
//return new("}");
501+
return VisitEndTag(node);
502+
}
503+
504+
private LineInfo VisitEndTag(BaseMarkupEndTagSyntax node)
505+
{
506+
// If this is the last line of a multi-line CSharp template (ie, RenderFragment), and the semicolon that ends
507+
// if is on the same line, then we need to close out the lambda expression that we started when we opened it.
508+
// If the semicolon is on the next line, then we'll take care of that when we get to it.
509+
if (node.Parent.Parent.Parent is CSharpTemplateBlockSyntax template &&
510+
GetLineNumber(template.GetLastToken()) == GetLineNumber(_currentToken) &&
511+
GetLineNumber(template.GetFirstToken()) != GetLineNumber(template.GetLastToken()) &&
512+
template.GetLastToken().GetNextToken() is { } semiColonToken &&
513+
semiColonToken.Content == ";" &&
514+
GetLineNumber(semiColonToken) == GetLineNumber(_currentToken))
515+
{
516+
_builder.AppendLine("};");
517+
return CreateLineInfo();
518+
}
519+
463520
return EmitCurrentLineAsComment();
464521
}
465522

@@ -537,29 +594,45 @@ private LineInfo VisitMarkupLiteral()
537594

538595
public override LineInfo VisitMarkupTagHelperEndTag(MarkupTagHelperEndTagSyntax node)
539596
{
540-
// Since this visitor only sees nodes at the start of a line, an end tag always means de-dent.
541-
//return new("}");
542-
return EmitCurrentLineAsComment();
597+
return VisitEndTag(node);
543598
}
544599

545600
public override LineInfo VisitMarkupTransition(MarkupTransitionSyntax node)
546601
{
547-
// A transition to Html is treated the same as Html, which is to say nothing interesting.
548-
// We could emit as a comment, so C# indentation is handled, but it is often that a markup transition
549-
// appears after assigning a RenderFragment, eg
602+
// A transition to Html means the start of a RenderFragment. These are challenging because conceptually
603+
// they are like a Write() call, because their contents are sent to the output, but they can also contain
604+
// statements. eg:
550605
//
551606
// RenderFragment f =
552607
// @<div>
553-
// <p>Some text</p>
608+
// @if (true)
609+
// {
610+
// <p>Some text</p>
611+
// }
554612
// </div>;
555613
//
556-
// If we just emit a comment there, the C# formatter will not indent it, and it will leave a hanging
557-
// expression which affects future indentation. So instead we emit some fake C# just to make sure
558-
// nothing is left open. A single semi-colon will suffice.
614+
// If we convert that to C# the like we normally do, we end up with statements in a C# context where only
615+
// expressions are valid. To avoid that, we need to emit C# such that we can be sure we're in a context
616+
// where statements are valid. To do this we emit a block bodied lambda expression. Ironically this whole
617+
// formatting engine arguably exists because the compiler loves to emit lambda expressions, but they're
618+
// really annoying to format. This just happens to be the one case where a lambda is the right choice.
619+
559620
// Emit the whitespace, so user spacing is honoured if possible
560621
_builder.Append(_sourceText.ToString(TextSpan.FromBounds(_currentLine.Start, _currentFirstNonWhitespacePosition)));
561-
_builder.AppendLine(";");
562-
return CreateLineInfo();
622+
623+
// If its a one-line render fragment, then we don't need to worry.
624+
if (GetLineNumber(node.Parent.GetLastToken()) == GetLineNumber(_currentToken))
625+
{
626+
_builder.AppendLine("null;");
627+
return CreateLineInfo();
628+
}
629+
630+
// Roslyn may move the opening brace to the next line, depending on its options. Unlike with code block
631+
// formatting where we put the opening brace on the next line ourselves (and Roslyn might bring it back)
632+
// if we do that for lambdas, Roslyn wont' adjust the opening brace position at all. See, told you lambdas
633+
// were annoying to format.
634+
_builder.AppendLine("() => {");
635+
return CreateLineInfo(skipNextLineIfBrace: true);
563636
}
564637

565638
public override LineInfo VisitRazorCommentBlock(RazorCommentBlockSyntax node)

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,15 @@ public async Task<ImmutableArray<TextChange>> ExecuteAsync(FormattingContext con
147147
else if (lineInfo.SkipNextLineIfBrace)
148148
{
149149
// If the next line is a brace, we skip it. This is used to skip the opening brace of a class
150-
// that we insert, but Roslyn settings might place on the same like as the class declaration.
150+
// that we insert, but Roslyn settings might place on the same like as the class declaration,
151+
// or skip the opening brace of a lambda definition we insert, but Roslyn might place it on the
152+
// next line. In that case, we can't place it on the next line ourselves because Roslyn doesn't
153+
// adjust the indentation of opening braces of lambdas in that scenario.
151154
if (iFormatted + 1 < formattedCSharpText.Lines.Count &&
152155
formattedCSharpText.Lines[iFormatted + 1] is { Span.Length: > 0 } nextLine &&
153-
nextLine.CharAt(0) == '{')
156+
nextLine.GetFirstNonWhitespaceOffset() is { } firstNonWhitespace &&
157+
nextLine.Start + firstNonWhitespace == nextLine.End - 1 &&
158+
nextLine.CharAt(firstNonWhitespace) == '{')
154159
{
155160
iFormatted++;
156161
}

0 commit comments

Comments
 (0)