diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/MarkupSplitterComponentTest.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/MarkupSplitterComponentTest.cs new file mode 100644 index 0000000000000..74a04f36aaeeb --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/MarkupSplitterComponentTest.cs @@ -0,0 +1,450 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Language.IntegrationTests; + +// Verifies the markup splitter against real, fully-lowered component IR (the shape it sees when the +// decl/impl lowering phases run). The whole design rests on class-body markup still being present as +// markup IR nodes at that point and on every class-body child being a kind the splitter can route. +public class MarkupSplitterComponentTest : RazorIntegrationTestBase +{ + internal override RazorFileKind? FileKind => RazorFileKind.Component; + + internal override bool UseTwoPhaseCompilation => true; + + [Fact] + public void MarkupField_FallsBackAndCompiles() + { + // A field initializer with markup can't be lifted (declaration-order side effects), so the file + // falls back and still compiles. + var generated = CompileToCSharp(""" + @code { + private Microsoft.AspNetCore.Components.RenderFragment _frag = @
Hi
; + } + """); + + CompileToAssembly(generated); + } + + [Fact] + public void MarkupMethod_LiftsToImpl_AndCompiles() + { + var generated = CompileToCSharp(""" + @code { + private Microsoft.AspNetCore.Components.RenderFragment Make() => @

Hi

; + } + """); + + // The markup method lifts wholesale to the impl half; the markup-free decl half keeps none of it. + Assert.NotNull(generated.DeclCode); + Assert.DoesNotContain("Make", generated.DeclCode); + Assert.Contains("Make", generated.Code); + + // decl + impl are emitted as partial halves that recombine and compile. + CompileToAssembly(generated); + } + + [Fact] + public void MarkupMethod_AlongsideMarkupFreeMembers_RoutesEachHalf() + { + var generated = CompileToCSharp(""" + @code { + [Microsoft.AspNetCore.Components.Parameter] public int Count { get; set; } + private Microsoft.AspNetCore.Components.RenderFragment Make() => @

Hi

; + } + """); + + // The parameter (descriptor surface) stays in decl; the markup method lifts to impl. + Assert.NotNull(generated.DeclCode); + Assert.Contains("Count", generated.DeclCode); + Assert.DoesNotContain("Make", generated.DeclCode); + Assert.Contains("Make", generated.Code); + + CompileToAssembly(generated); + } + + [Fact] + public void AccessorBodiedMarkupProperty_FallsBackAndCompiles() + { + // A property with markup falls back (a property is descriptor surface that must stay in decl, + // where markup cannot live). The original class-body layout still compiles. + var generated = CompileToCSharp(""" + @code { + public Microsoft.AspNetCore.Components.RenderFragment Foo { get => @
Hi
; } + } + """); + + CompileToAssembly(generated); + } + + [Fact] + public void MarkupInitializerProperty_FallsBackAndCompiles() + { + // Markup in a property (here its initializer) produces a fallback decision. The original + // class-body layout must still compile. + var generated = CompileToCSharp(""" + @code { + public Microsoft.AspNetCore.Components.RenderFragment Foo { get; set; } = @
Hi
; + } + """); + + CompileToAssembly(generated); + } + + [Fact] + public void MultipleMarkupMethods_AllLiftToImplAndCompile() + { + var generated = CompileToCSharp(""" + @code { + private Microsoft.AspNetCore.Components.RenderFragment A() => @

A

; + private Microsoft.AspNetCore.Components.RenderFragment B() => @

B

; + } + """); + + Assert.NotNull(generated.DeclCode); + Assert.DoesNotContain("

A

", generated.DeclCode); + Assert.DoesNotContain("

B

", generated.DeclCode); + Assert.Contains("

A

", generated.Code); + Assert.Contains("

B

", generated.Code); + CompileToAssembly(generated); + } + + [Fact] + public void ConditionalCompilation_AroundMarkupMethod_Compiles() + { + // If a #if/#endif around a lifted member were split across halves, the halves would have + // unbalanced directives and fail to compile. Verify the split keeps each half well-formed. + var generated = CompileToCSharp(""" + @code { + #if true + private Microsoft.AspNetCore.Components.RenderFragment M() => @

Hi

; + #endif + } + """); + + CompileToAssembly(generated); + } + + [Fact] + public void MarkupProperty_FallsBackAndCompiles() + { + var generated = CompileToCSharp(""" + @code { + public Microsoft.AspNetCore.Components.RenderFragment Foo => @
Hello
; + } + """); + + var documentNode = generated.CodeDocument.GetDocumentNode(); + Assert.NotNull(documentNode); + var primaryClass = documentNode.FindPrimaryClass(); + var renderMethod = documentNode.FindPrimaryMethod(); + Assert.NotNull(primaryClass); + Assert.NotNull(renderMethod); + + // A markup property produces a fallback decision on every language version. + var decision = MarkupSplitter.Split(primaryClass, renderMethod, generated.CodeDocument.ParserOptions); + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.MarkupProperty, fallback.Reason); + + // The unrouted output still compiles. + CompileToAssembly(generated); + } + + [Fact] + public void MarkupFreeProperty_WithMarkupMethod_StaysInDeclAndSplits() + { + // A markup-free property is descriptor surface and stays in the decl half (the fast path); the + // markup method that forces the split lifts to impl. + var generated = CompileToCSharp(""" + @code { + [Microsoft.AspNetCore.Components.Parameter] public int Count { get; set; } + private Microsoft.AspNetCore.Components.RenderFragment Make() => @
@Count
; + } + """); + + Assert.NotNull(generated.DeclCode); + Assert.Contains("Count", generated.DeclCode); + Assert.DoesNotContain("Make", generated.DeclCode); + Assert.Contains("Make", generated.Code); + + CompileToAssembly(generated); + } + + [Fact] + public void ExpressionTemplateProperty_SurvivesAsTemplateMarkup_ButFallsBack() + { + var generated = CompileToCSharp(""" + @code { + public Microsoft.AspNetCore.Components.RenderFragment Header => @
Hello
; + } + """); + + var documentNode = generated.CodeDocument.GetDocumentNode(); + Assert.NotNull(documentNode); + var primaryClass = documentNode.FindPrimaryClass(); + var renderMethod = documentNode.FindPrimaryMethod(); + Assert.NotNull(primaryClass); + Assert.NotNull(renderMethod); + + // Invariant: the `@<...>` markup is still an IR node at class-body scope (not pre-lowered to + // __builder C#), and specifically an expression-position TemplateIntermediateNode. + Assert.True(MarkupSplitter.HasClassBodyMarkup(primaryClass, renderMethod)); + var children = MarkupSplitter.CollectClassBodyChildren(primaryClass, renderMethod); + Assert.Contains(children, static c => c is TemplateIntermediateNode); + + // But because the markup is in a property, the file falls back rather than splits. + var decision = MarkupSplitter.Split(primaryClass, renderMethod, generated.CodeDocument.ParserOptions); + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.MarkupProperty, fallback.Reason); + } + + [Fact] + public void PureCSharpCode_HasNoClassBodyMarkup_AndDoesNotSplit() + { + var generated = CompileToCSharp(""" + @code { + private int _count; + private void Increment() => _count++; + } + """); + + var documentNode = generated.CodeDocument.GetDocumentNode(); + Assert.NotNull(documentNode); + var primaryClass = documentNode.FindPrimaryClass(); + var renderMethod = documentNode.FindPrimaryMethod(); + Assert.NotNull(primaryClass); + Assert.NotNull(renderMethod); + + Assert.False(MarkupSplitter.HasClassBodyMarkup(primaryClass, renderMethod)); + Assert.Same( + SplitDecision.NoSplit, + MarkupSplitter.Split(primaryClass, renderMethod, generated.CodeDocument.ParserOptions)); + } + + [Fact] + public void MarkupMethod_WithTypeParam_FallsBackAndCompiles() + { + // A generic component (@typeparam) with a markup method takes the single-document path: the early + // move-based split would give the two partial halves inconsistent arity. Verify it still compiles. + var generated = CompileToCSharp(""" + @typeparam T + @code { + private Microsoft.AspNetCore.Components.RenderFragment Make() => @

Hi

; + } + """); + + CompileToAssembly(generated); + } + + [Fact] + public void MarkupMethod_WithInject_Compiles() + { + // @inject is a document-level directive, not part of the @code class body, so it doesn't prevent + // the markup method from being routed to the impl half. Verify the split halves recombine and + // compile with the injected member present. + var generated = CompileToCSharp(""" + @inject System.IServiceProvider Services + @code { + private Microsoft.AspNetCore.Components.RenderFragment Make() => @

Hi

; + } + """); + + CompileToAssembly(generated); + } + + [Fact] + public void MarkupMethod_InNestedClass_FallsBackAndCompiles() + { + // A type declared inside @code is a single class-body member; when it carries markup (here in a + // method) it can't be lifted -- a nested type may be referenced from the decl half or itself hold + // markup members -- so the component falls back to the single-document path. Verify it compiles. + var generated = CompileToCSharp(""" + @code { + class Nested + { + public Microsoft.AspNetCore.Components.RenderFragment Make() => @

Hi

; + } + } + """); + + var documentNode = generated.CodeDocument.GetDocumentNode(); + Assert.NotNull(documentNode); + var primaryClass = documentNode.FindPrimaryClass(); + var renderMethod = documentNode.FindPrimaryMethod(); + Assert.NotNull(primaryClass); + Assert.NotNull(renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, generated.CodeDocument.ParserOptions); + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnsupportedMarkupMember, fallback.Reason); + + CompileToAssembly(generated); + } + + [Fact] + public void MarkupProperty_InNestedClass_FallsBackAndCompiles() + { + // Same as the nested-class method case, but the markup lives in a property of the nested type. The + // nested type still can't be lifted, so the component falls back and compiles. + var generated = CompileToCSharp(""" + @code { + class Nested + { + public Microsoft.AspNetCore.Components.RenderFragment Header => @
Hi
; + } + } + """); + + var documentNode = generated.CodeDocument.GetDocumentNode(); + Assert.NotNull(documentNode); + var primaryClass = documentNode.FindPrimaryClass(); + var renderMethod = documentNode.FindPrimaryMethod(); + Assert.NotNull(primaryClass); + Assert.NotNull(renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, generated.CodeDocument.ParserOptions); + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnsupportedMarkupMember, fallback.Reason); + + CompileToAssembly(generated); + } + + [Fact] + public void Inject_AlongsideMarkup_FallsBack() + { + // @inject lowers to a ComponentInjectIntermediateNode (surface, an ExtensionIntermediateNode + // like a template). The splitter can't route it, so a component mixing it with markup falls back. + // A markup *method* is used so the inject is the unambiguous cause (a markup property would fall + // back on its own). + var generated = CompileToCSharp(""" + @inject System.IServiceProvider Services + @code { + private Microsoft.AspNetCore.Components.RenderFragment Make() => @
Hello
; + } + """); + + var documentNode = generated.CodeDocument.GetDocumentNode(); + Assert.NotNull(documentNode); + var primaryClass = documentNode.FindPrimaryClass(); + var renderMethod = documentNode.FindPrimaryMethod(); + Assert.NotNull(primaryClass); + Assert.NotNull(renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, generated.CodeDocument.ParserOptions); + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnsupportedClassBodyNode, fallback.Reason); + } + + [Fact] + public void MarkupEdit_InMethodBody_LeavesDeclHalfByteIdentical() + { + // The core value of the split: editing markup (which lives in the impl half) must not perturb the + // decl half, so incremental tag-helper discovery can reuse it. The markup-free `[Parameter]` + // stays in decl; the markup method lives wholly in impl. The two sources differ only in the markup + // on one line, so nothing in decl shifts. + var a = CompileToCSharp(""" + @code { + [Microsoft.AspNetCore.Components.Parameter] public int Count { get; set; } + private Microsoft.AspNetCore.Components.RenderFragment Make() => @
Hello
; + } + """); + + var b = CompileToCSharp(""" + @code { + [Microsoft.AspNetCore.Components.Parameter] public int Count { get; set; } + private Microsoft.AspNetCore.Components.RenderFragment Make() => @Bye; + } + """); + + Assert.NotNull(a.DeclCode); + Assert.NotNull(b.DeclCode); + + // The decl halves are byte-identical despite the differing markup, and neither leaks the markup. + Assert.Equal(a.DeclCode, b.DeclCode); + Assert.DoesNotContain("Hello", a.DeclCode); + Assert.DoesNotContain("Bye", b.DeclCode); + + // The impl halves do differ (that's where the edited markup lives). + Assert.NotEqual(a.Code, b.Code); + } + + [Fact] + public void MarkupEdit_InMethodBody_AddingLines_LeavesDeclHalfByteIdentical() + { + // Same as above but the edit adds lines to the method body (which follows the decl member), to + // confirm the decl half's line mappings don't shift with impl-only growth. + var a = CompileToCSharp(""" + @code { + [Microsoft.AspNetCore.Components.Parameter] public int Count { get; set; } + private void Make(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) + { +
Hello
+ } + } + """); + + var b = CompileToCSharp(""" + @code { + [Microsoft.AspNetCore.Components.Parameter] public int Count { get; set; } + private void Make(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) + { +
Hello
+

An extra line of markup

+ And another + } + } + """); + + Assert.NotNull(a.DeclCode); + Assert.NotNull(b.DeclCode); + Assert.Equal(a.DeclCode, b.DeclCode); + } + + [Fact] + public void LiftedMarkupMethod_ContentIsSourceMappedToRazor() + { + // A diagnostic inside a lifted markup method must map back to the .razor, which relies on the + // method's nodes keeping their source mappings when they move to the impl half. Assert that + // directly: the `@Count` inside the lifted method has a source mapping whose original text (read + // from the .razor) and generated text (read from the impl half) both read "Count" -- so the + // lifted content points at the user's source, not unmapped scaffolding. + var generated = CompileToCSharp(""" + @code { + [Microsoft.AspNetCore.Components.Parameter] public int Count { get; set; } + private Microsoft.AspNetCore.Components.RenderFragment Make() => @
@Count
; + } + """); + + var impl = generated.CodeDocument.GetRequiredImplCSharpDocument(); + var implText = impl.Text.ToString(); + var sourceText = generated.CodeDocument.Source.Text.ToString(); + + Assert.Contains(impl.SourceMappingsSortedByGenerated, m => + Slice(sourceText, m.OriginalSpan) == "Count" && + Slice(implText, m.GeneratedSpan) == "Count"); + } + + [Fact] + public void DeclDocument_IsProducedBeforeTagHelperResolution() + { + // The whole point of the split: the decl half is emitted *before* tag-helper resolution, so + // discovery can consume a resolution-independent, byte-stable decl and stay incremental. Drive the + // pipeline only up to (not including) tag-helper resolution and assert the decl already exists and + // is markup-free -- if decl production ever regressed to run after resolution, this would see null. + var codeDocument = ProcessComponentUpToPhase(""" + @code { + private Microsoft.AspNetCore.Components.RenderFragment Make() => @

Hi

; + } + """); + + var decl = codeDocument.GetDeclCSharpDocument(); + Assert.NotNull(decl); + Assert.DoesNotContain("Make", decl.Text.ToString()); + } + + private static string Slice(string text, SourceSpan span) => text.Substring(span.AbsoluteIndex, span.Length); +} diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/MarkupSplitterTest.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/MarkupSplitterTest.cs new file mode 100644 index 0000000000000..716008d95f586 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/MarkupSplitterTest.cs @@ -0,0 +1,685 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Language; + +public class MarkupSplitterTest +{ + private static MethodDeclarationIntermediateNode CreateRenderMethod() + => new() { IsPrimaryMethod = true, Name = "BuildRenderTree" }; + + private static ClassDeclarationIntermediateNode CreatePrimaryClass(params IntermediateNode[] children) + { + var @class = new ClassDeclarationIntermediateNode { IsPrimaryClass = true, Name = "TestComponent" }; + + foreach (var child in children) + { + @class.Children.Add(child); + } + + return @class; + } + + private static CSharpCodeIntermediateNode CreateCSharpCode(string content) + { + var node = new CSharpCodeIntermediateNode(); + node.Children.Add(new CSharpIntermediateToken(content, source: null)); + return node; + } + + [Fact] + public void HasClassBodyMarkup_PureCSharp_ReturnsFalse() + { + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("[Parameter] public int Count { get; set; }"), + CreateCSharpCode("private void Increment() => Count++;"), + renderMethod); + + Assert.False(MarkupSplitter.HasClassBodyMarkup(primaryClass, renderMethod)); + } + + [Fact] + public void HasClassBodyMarkup_ClassBodyMarkupNode_ReturnsTrue() + { + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("public RenderFragment Header => "), + new MarkupElementIntermediateNode { TagName = "div" }, + CreateCSharpCode(";"), + renderMethod); + + Assert.True(MarkupSplitter.HasClassBodyMarkup(primaryClass, renderMethod)); + } + + [Fact] + public void HasClassBodyMarkup_SkipsRenderMethodAndSynthesizedHelpers() + { + var renderMethod = CreateRenderMethod(); + + // The render method carries the component's top-level markup as its own children; that markup + // is nested, not a class-body child, so it must not trip the gate. A synthesized helper that + // happens to be markup-shaped must also be skipped. + renderMethod.Children.Add(new MarkupElementIntermediateNode { TagName = "p" }); + + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("private int _count;"), + new MarkupElementIntermediateNode { TagName = "span", IsSynthesizedHelper = true }, + renderMethod); + + Assert.False(MarkupSplitter.HasClassBodyMarkup(primaryClass, renderMethod)); + } + + [Fact] + public void IsClassBodyMarkup_CSharpAndStructuredDeclarations_AreNotMarkup() + { + Assert.False(MarkupSplitter.IsClassBodyMarkup(new CSharpCodeIntermediateNode())); + Assert.False(MarkupSplitter.IsClassBodyMarkup(new FieldDeclarationIntermediateNode { Name = "_f", Type = "int" })); + Assert.False(MarkupSplitter.IsClassBodyMarkup( + new PropertyDeclarationIntermediateNode + { + Name = "P", + Type = new CSharpIntermediateToken("int", source: null), + ExpressionBody = "0", + })); + Assert.False(MarkupSplitter.IsClassBodyMarkup(new MethodDeclarationIntermediateNode())); + } + + [Fact] + public void IsClassBodyMarkup_MarkupNodes_AreMarkup() + { + Assert.True(MarkupSplitter.IsClassBodyMarkup(new MarkupElementIntermediateNode())); + Assert.True(MarkupSplitter.IsClassBodyMarkup(new MarkupBlockIntermediateNode())); + Assert.True(MarkupSplitter.IsClassBodyMarkup(new HtmlContentIntermediateNode())); + } + + [Fact] + public void Split_NoMarkup_ReturnsNoSplit() + { + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass(CreateCSharpCode("private int _count;"), renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, RazorParserOptions.Default); + + Assert.Same(SplitDecision.NoSplit, decision); + Assert.False(decision.RequiresSplit); + Assert.False(decision.IsFallback); + } + + [Fact] + public void Fallback_IsFallbackAndDoesNotRequireSplit() + { + var decision = SplitDecision.Fallback(FallbackReason.MarkupProperty); + + Assert.True(decision.IsFallback); + Assert.False(decision.RequiresSplit); + Assert.Equal(FallbackReason.MarkupProperty, decision.Reason); + } + + [Fact] + public void CollectClassBodyChildren_PreservesOrderAndExcludesRenderAndSynthesized() + { + var renderMethod = CreateRenderMethod(); + var first = CreateCSharpCode("private int _a;"); + var markup = new MarkupElementIntermediateNode { TagName = "div" }; + var last = CreateCSharpCode("private int _b;"); + var synthesized = new CSharpCodeIntermediateNode { IsSynthesizedHelper = true }; + + var primaryClass = CreatePrimaryClass(first, synthesized, renderMethod, markup, last); + + var collected = MarkupSplitter.CollectClassBodyChildren(primaryClass, renderMethod); + + Assert.Equal(new IntermediateNode[] { first, markup, last }, collected); + } + + + [Fact] + public void BuildAnalysisDocument_PureCSharp_EmitsTextWithNoMarkers() + { + var chunk = CreateCSharpCode("[Parameter] public int Count { get; set; }"); + var analysis = MarkupSplitter.BuildAnalysisDocument([chunk]); + + Assert.Contains("[Parameter] public int Count { get; set; }", analysis.Text); + Assert.DoesNotContain(MarkupSplitter.MarkerMethodName, analysis.Text); + var child = Assert.Single(analysis.Children); + Assert.IsType(child.Node); + Assert.Same(chunk, child.Node); + } + + [Fact] + public void BuildAnalysisDocument_StatementMarkup_EmitsStatementMarker() + { + var before = CreateCSharpCode("void M(RenderTreeBuilder __builder) { "); + var markup = new MarkupElementIntermediateNode { TagName = "ul" }; + var after = CreateCSharpCode(" }"); + + var analysis = MarkupSplitter.BuildAnalysisDocument([before, markup, after]); + + Assert.Contains(MarkupSplitter.MarkerMethodName + "();", analysis.Text); + Assert.Equal(3, analysis.Children.Length); + + var markupChild = analysis.Children[1]; + Assert.True(MarkupSplitter.IsMarkupNode(markupChild.Node)); + Assert.Same(markup, markupChild.Node); + + // Child offsets must index the emitted marker text exactly. + var sliced = analysis.Text.Substring(markupChild.Start, markupChild.Length); + Assert.Equal(MarkupSplitter.MarkerMethodName + "();", sliced); + } + + [Fact] + public void BuildAnalysisDocument_ExpressionTemplate_EmitsExpressionMarker() + { + var before = CreateCSharpCode("public RenderFragment Header => "); + var template = new TemplateIntermediateNode(); + var after = CreateCSharpCode(";"); + + var analysis = MarkupSplitter.BuildAnalysisDocument([before, template, after]); + + var markupChild = analysis.Children[1]; + Assert.True(MarkupSplitter.IsMarkupNode(markupChild.Node)); + + var sliced = analysis.Text.Substring(markupChild.Start, markupChild.Length); + Assert.Equal(MarkupSplitter.MarkerMethodName + "()", sliced); + + // The whole document parses as valid C# (a get-only expression-bodied property). Parse from a + // SourceText to match the product code, which avoids the banned string-based ParseText overload. + var tree = CSharpSyntaxTree.ParseText(SourceText.From(analysis.Text)); + Assert.Empty(tree.GetDiagnostics()); + } + + [Fact] + public void IsExpressionPositionMarkup_OnlyTemplateIsExpression() + { + Assert.True(MarkupSplitter.IsExpressionPositionMarkup(new TemplateIntermediateNode())); + Assert.False(MarkupSplitter.IsExpressionPositionMarkup(new MarkupElementIntermediateNode())); + Assert.False(MarkupSplitter.IsExpressionPositionMarkup(new MarkupBlockIntermediateNode())); + Assert.False(MarkupSplitter.IsExpressionPositionMarkup(new HtmlContentIntermediateNode())); + } + + private static SplitDecision ClassifyChildren(params IntermediateNode[] children) + { + var analysis = MarkupSplitter.BuildAnalysisDocument([.. children]); + return MarkupSplitter.ClassifyFromAnalysis(analysis, CSharpParseOptions.Default); + } + + [Fact] + public void ClassifyFromAnalysis_PureCSharpField_RoutesToDecl() + { + var decision = ClassifyChildren(CreateCSharpCode("private int _n;")); + + var plan = Assert.IsType(decision); + var member = Assert.Single(plan.Members); + Assert.Empty(member.ImplPieces); + Assert.NotEmpty(member.DeclPieces); + } + + [Fact] + public void ClassifyFromAnalysis_MarkupHelperMethod_RoutesToImpl() + { + var decision = ClassifyChildren( + CreateCSharpCode("void M(RenderTreeBuilder __builder) { "), + new MarkupElementIntermediateNode { TagName = "ul" }, + CreateCSharpCode(" }")); + + var plan = Assert.IsType(decision); + var member = Assert.Single(plan.Members); + Assert.Empty(member.DeclPieces); + Assert.NotEmpty(member.ImplPieces); + } + + [Fact] + public void ClassifyFromAnalysis_MarkupExpressionProperty_FallsBack() + { + var decision = ClassifyChildren( + CreateCSharpCode("public RenderFragment Header => "), + new TemplateIntermediateNode(), + CreateCSharpCode(";")); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.MarkupProperty, fallback.Reason); + } + + [Fact] + public void ClassifyFromAnalysis_ExplicitInterfaceMarkupProperty_FallsBack() + { + // Any property/indexer with markup produces a fallback decision -- explicit-interface properties + // included. + var decision = ClassifyChildren( + CreateCSharpCode("global::Microsoft.AspNetCore.Components.RenderFragment IFoo.Bar => "), + new TemplateIntermediateNode(), + CreateCSharpCode(";")); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.MarkupProperty, fallback.Reason); + } + + [Fact] + public void ClassifyFromAnalysis_MarkupField_FallsBack() + { + // A field's initializer runs in declaration order; lifting it across partials could perturb that, + // so a markup field produces a fallback decision. + var decision = ClassifyChildren( + CreateCSharpCode("private RenderFragment _frag = "), + new TemplateIntermediateNode(), + CreateCSharpCode(";")); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnsupportedMarkupMember, fallback.Reason); + } + + [Fact] + public void ClassifyFromAnalysis_NestedTypeWithMarkup_FallsBack() + { + // A nested type carrying markup can't be lifted as if it were a method (it may be referenced from + // decl, and its own markup members aren't handled), so it forces fallback. + var decision = ClassifyChildren( + CreateCSharpCode("public class Nested { public RenderFragment View => "), + new TemplateIntermediateNode(), + CreateCSharpCode("; }")); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnsupportedMarkupMember, fallback.Reason); + } + + [Fact] + public void Split_MarkupField_FallsBack() + { + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("private RenderFragment _frag = "), + new TemplateIntermediateNode(), + CreateCSharpCode(";"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp13)); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnsupportedMarkupMember, fallback.Reason); + } + + [Fact] + public void ClassifyFromAnalysis_MarkupPropertyAmongMembers_FallsBack() + { + // A clean field and a markup method pass, but the trailing markup property forces the whole file + // to fall back rather than route. + var decision = ClassifyChildren( + CreateCSharpCode("[Parameter] public int Count { get; set; } private void Helper(RenderTreeBuilder __builder) { "), + new MarkupElementIntermediateNode { TagName = "div" }, + CreateCSharpCode(" } public RenderFragment Foo => "), + new TemplateIntermediateNode(), + CreateCSharpCode(";")); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.MarkupProperty, fallback.Reason); + } + + [Fact] + public void ClassifyFromAnalysis_UnrecoverableBraces_FallsBack() + { + // An unmatched open brace before markup: the class-body close brace goes missing, so the marker + // ends up outside any recoverable member. + var analysis = MarkupSplitter.BuildAnalysisDocument([ + CreateCSharpCode("void M(RenderTreeBuilder __builder) { "), + new MarkupElementIntermediateNode { TagName = "div" }]); + var decision = MarkupSplitter.ClassifyFromAnalysis(analysis, CSharpParseOptions.Default); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnrecoverableParse, fallback.Reason); + } + + [Fact] + public void SplitCSharpNode_NoCuts_ReturnsSameNode() + { + var node = CreateCSharpCode("private int _a;"); + var pieces = MarkupSplitter.SplitCSharpNode(node, System.Collections.Immutable.ImmutableArray.Empty); + Assert.Same(node, Assert.Single(pieces)); + } + + [Fact] + public void SplitCSharpNode_SingleCut_SplitsContent() + { + var node = CreateCSharpCode("private int _a; void M() {"); + var pieces = MarkupSplitter.SplitCSharpNode(node, [15]); + + Assert.Equal(2, pieces.Length); + Assert.Equal("private int _a;", TokenText(pieces[0])); + Assert.Equal(" void M() {", TokenText(pieces[1])); + } + + [Fact] + public void SliceToken_RecomputesLineAndCharacterAcrossNewline() + { + // A token spanning two source lines, starting at line 3, char 4. + var source = new SourceSpan(filePath: "C.razor", absoluteIndex: 100, lineIndex: 3, characterIndex: 4, length: 12); + var token = new CSharpIntermediateToken("ab\ncdefghij", source); + + // Slice starting after the newline ("cdef...") must land on line 4, char 0. + var sliced = MarkupSplitter.SliceToken(token, localStart: 3, localLength: 4); + + Assert.Equal("cdef", sliced.Content); + var s = sliced.Source!.Value; + Assert.Equal(103, s.AbsoluteIndex); // 100 + 3 + Assert.Equal(4, s.LineIndex); // advanced past one newline + Assert.Equal(0, s.CharacterIndex); // reset after the newline + Assert.Equal(4, s.Length); + Assert.Equal(0, s.LineCount); // "cdef" is single-line: zero line breaks + Assert.Equal(4, s.EndCharacterIndex); + } + + [Fact] + public void SliceToken_SpanningNewline_ReportsOneLineBreak() + { + // A slice that itself crosses a newline reports LineCount 1 (one line break), and its end + // character resets on the new line. + var source = new SourceSpan(filePath: "C.razor", absoluteIndex: 100, lineIndex: 3, characterIndex: 4, length: 12); + var token = new CSharpIntermediateToken("ab\ncdefghij", source); + + var sliced = MarkupSplitter.SliceToken(token, localStart: 0, localLength: 5); // "ab\ncd" + var s = sliced.Source!.Value; + + Assert.Equal("ab\ncd", sliced.Content); + Assert.Equal(3, s.LineIndex); + Assert.Equal(1, s.LineCount); // one newline crossed + Assert.Equal(2, s.EndCharacterIndex); // "cd" -> char 2 on the new line + } + + [Fact] + public void SliceToken_BeforeNewline_KeepsStartLineAndCharacter() + { + var source = new SourceSpan(filePath: "C.razor", absoluteIndex: 100, lineIndex: 3, characterIndex: 4, length: 12); + var token = new CSharpIntermediateToken("ab\ncdefghij", source); + + var sliced = MarkupSplitter.SliceToken(token, localStart: 0, localLength: 2); + + Assert.Equal("ab", sliced.Content); + var s = sliced.Source!.Value; + Assert.Equal(100, s.AbsoluteIndex); + Assert.Equal(3, s.LineIndex); + Assert.Equal(4, s.CharacterIndex); + } + + [Fact] + public void SliceToken_NullSource_ProducesNullSource() + { + var token = new CSharpIntermediateToken("abcdef", source: null); + var sliced = MarkupSplitter.SliceToken(token, 2, 3); + Assert.Equal("cde", sliced.Content); + Assert.Null(sliced.Source); + } + + [Fact] + public void SliceToken_AtCarriageReturnNewlineBoundary_DoesNotOverAdvance() + { + // Slicing exactly between the \r and \n of a \r\n pair must not pull the \n into the preceding + // slice: the second slice's content begins with the \n, so its absolute/line/character must point + // at the \n, not one past it. (Advancing across the prefix used to consume the paired \n even when + // the slice ended on the \r, corrupting the boundary-aligned slice's source mapping.) + var source = new SourceSpan(filePath: "C.razor", absoluteIndex: 100, lineIndex: 3, characterIndex: 4, length: 6); + var token = new CSharpIntermediateToken("ab\r\ncd", source); + + var before = MarkupSplitter.SliceToken(token, localStart: 0, localLength: 3); // "ab\r" + var after = MarkupSplitter.SliceToken(token, localStart: 3, localLength: 3); // "\ncd" + + Assert.Equal("ab\r", before.Content); + Assert.Equal("\ncd", after.Content); + + // The second slice starts at the \n (absolute 103), on the line the \r opened, at character 0 -- + // not skipped past the \n to 104/'c'. + var s = after.Source!.Value; + Assert.Equal(103, s.AbsoluteIndex); + Assert.Equal(4, s.LineIndex); + Assert.Equal(0, s.CharacterIndex); + } + + [Theory] + [InlineData("abc", 0, 3, 3, 0, 3)] // no newline: char advances by 3 + [InlineData("a\nb", 0, 3, 3, 1, 1)] // one \n: line +1, char 1 + [InlineData("a\r\nb", 0, 4, 4, 1, 1)] // \r\n counts once: line +1, char 1 + [InlineData("a\rb", 0, 3, 3, 1, 1)] // lone \r counts as a break + [InlineData("a\r\nb", 0, 2, 2, 1, 0)] // range ends on the \r: lone break, the \n is left for the next slice (not over-advanced) + [InlineData("a\r\nb", 2, 2, 2, 1, 1)] // the matching next slice starts on that \n: it counts as the break for this range + public void AdvanceLocation_CountsLineBreaks(string text, int start, int count, int expectedAbs, int expectedLine, int expectedChar) + { + var (abs, line, ch) = MarkupSplitter.AdvanceLocation(absolute: 0, line: 0, character: 0, text, start, count); + Assert.Equal(expectedAbs, abs); + Assert.Equal(expectedLine, line); + Assert.Equal(expectedChar, ch); + } + + [Fact] + public void IsMarkupNode_RecognizesMarkupKinds() + { + Assert.True(MarkupSplitter.IsMarkupNode(new TemplateIntermediateNode())); + Assert.True(MarkupSplitter.IsMarkupNode(new MarkupElementIntermediateNode())); + Assert.True(MarkupSplitter.IsMarkupNode(new MarkupBlockIntermediateNode())); + Assert.True(MarkupSplitter.IsMarkupNode(new HtmlContentIntermediateNode())); + } + + [Fact] + public void IsMarkupNode_ExcludesCSharpAndSurfaceNodes() + { + // Raw C# and structured/extension members are not routable markup -- crucially an @inject node, + // which (like a template) is an ExtensionIntermediateNode but is surface, not markup. + Assert.False(MarkupSplitter.IsMarkupNode(new CSharpCodeIntermediateNode())); + Assert.False(MarkupSplitter.IsMarkupNode(new FieldDeclarationIntermediateNode { Name = "_f", Type = "int" })); + Assert.False(MarkupSplitter.IsMarkupNode(new MethodDeclarationIntermediateNode())); + Assert.False(MarkupSplitter.IsMarkupNode( + new Components.ComponentInjectIntermediateNode("Foo", "Bar", typeSpan: null, memberSpan: null))); + } + + [Fact] + public void Split_ClassBodyWithInject_FallsBack() + { + // A component whose @code mixes markup with an @inject cannot be split: the inject is surface the + // splitter cannot route, so classification falls back rather than moving it to impl. + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + new Components.ComponentInjectIntermediateNode("Foo", "Bar", typeSpan: null, memberSpan: null), + CreateCSharpCode("void M(RenderTreeBuilder __builder) { "), + new MarkupElementIntermediateNode { TagName = "ul" }, + CreateCSharpCode(" }"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp13)); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnsupportedClassBodyNode, fallback.Reason); + } + + [Fact] + public void Split_MarkupHelperMethod_ReturnsSplitPlanRoutedToImpl() + { + var renderMethod = CreateRenderMethod(); + var markup = new MarkupElementIntermediateNode { TagName = "ul" }; + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("void M(RenderTreeBuilder __builder) { "), + markup, + CreateCSharpCode(" }"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp10)); + + var plan = Assert.IsType(decision); + Assert.True(plan.RequiresSplit); + var member = Assert.Single(plan.Members); + Assert.Empty(member.DeclPieces); + + // The whole method lifts to impl: its C# chunks stay by reference and the markup node is carried + // through untouched. + Assert.Equal(3, member.ImplPieces.Length); + Assert.Same(markup, member.ImplPieces[1]); + } + + [Fact] + public void Split_ClassBodyWithDirective_FallsBack() + { + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("#nullable enable\n public RenderFragment Header => "), + new TemplateIntermediateNode(), + CreateCSharpCode(";"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp13)); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.ClassBodyHasDirectives, fallback.Reason); + } + + [Theory] + [InlineData("#if DEBUG", true)] + [InlineData(" #pragma warning disable", true)] // leading whitespace before the directive + [InlineData("int x = 1;\n#endif", true)] // directive on a later line + [InlineData("int x = 1;", false)] + [InlineData("var s = \"not # a directive\";", false)] // hash mid-line is not a directive + public void HasPreprocessorDirective_DetectsLineAnchoredHash(string text, bool expected) + { + Assert.Equal(expected, MarkupSplitter.HasPreprocessorDirective(text)); + } + + [Fact] + public void Split_MarkupProperty_FallsBack() + { + // A property with markup cannot stay in the markup-free decl half but must (it is descriptor + // surface), so it produces a fallback decision on every language version. + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("[Parameter] public RenderFragment Header => "), + new TemplateIntermediateNode(), + CreateCSharpCode(";"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp13)); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.MarkupProperty, fallback.Reason); + } + + [Fact] + public void Split_MarkupFreeProperty_AlongsideMarkupMethod_StaysInDecl() + { + // A markup-free property is descriptor surface and stays in decl; the markup method that forces + // the split lifts to impl. + var renderMethod = CreateRenderMethod(); + var property = CreateCSharpCode("[Parameter] public int Count { get; set; } void M(RenderTreeBuilder __builder) { "); + var primaryClass = CreatePrimaryClass( + property, + new MarkupElementIntermediateNode { TagName = "ul" }, + CreateCSharpCode(" }"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp13)); + + var plan = Assert.IsType(decision); + Assert.Equal(2, plan.Members.Length); + // The markup-free property stays wholly in decl. + Assert.Empty(plan.Members[0].ImplPieces); + Assert.Contains("Count", TokenText((CSharpCodeIntermediateNode)Assert.Single(plan.Members[0].DeclPieces))); + // The markup method lifts wholly to impl. + Assert.Empty(plan.Members[1].DeclPieces); + Assert.NotEmpty(plan.Members[1].ImplPieces); + } + + [Fact] + public void Split_MarkupProperty_BelowCSharp13_AlsoFallsBack() + { + // The fallback is version-independent: no C# 13 gate anymore. + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("public RenderFragment Header => "), + new TemplateIntermediateNode(), + CreateCSharpCode(";"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp10)); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.MarkupProperty, fallback.Reason); + } + + [Fact] + public void Split_MarkupMethod_BelowCSharp13_StillSplits() + { + // Only markup properties fall back; a markup method lifts wholesale on any C#. + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("void M(RenderTreeBuilder __builder) { "), + new MarkupElementIntermediateNode { TagName = "ul" }, + CreateCSharpCode(" }"), + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp10)); + + Assert.IsType(decision); + } + + [Fact] + public void Split_UnrecoverableParse_FallsBack() + { + // An unmatched open brace before markup leaves the marker outside any recoverable member. + var renderMethod = CreateRenderMethod(); + var primaryClass = CreatePrimaryClass( + CreateCSharpCode("void M(RenderTreeBuilder __builder) { "), + new MarkupElementIntermediateNode { TagName = "div" }, + renderMethod); + + var decision = MarkupSplitter.Split(primaryClass, renderMethod, ParserOptions(LanguageVersion.CSharp13)); + + var fallback = Assert.IsType(decision); + Assert.Equal(FallbackReason.UnrecoverableParse, fallback.Reason); + } + + [Fact] + public void ClassifyFromAnalysis_MixedMembers_SlicesChunkAndRoutesEachInOrder() + { + // One C# chunk holds a whole field plus the start of a markup method, so it must be sliced at the + // member boundary and each slice routed to its own member. (A markup property produces a fallback + // decision before routing, so routing only sees a markup-free member and a markup method.) + var markup = new MarkupElementIntermediateNode { TagName = "div" }; + var analysis = MarkupSplitter.BuildAnalysisDocument([ + CreateCSharpCode("[Parameter] public int Count { get; set; } private void Helper(RenderTreeBuilder __builder) { "), + markup, + CreateCSharpCode(" }")]); + + var plan = Assert.IsType( + MarkupSplitter.ClassifyFromAnalysis(analysis, CSharpParseOptions.Default)); + var routed = plan.Members; + + Assert.Equal(2, routed.Length); + + // int Count -> decl only, one sliced C# piece. + Assert.Empty(routed[0].ImplPieces); + var countPiece = Assert.IsType(Assert.Single(routed[0].DeclPieces)); + Assert.Contains("int Count", TokenText(countPiece)); + Assert.DoesNotContain("Helper", TokenText(countPiece)); + + // Helper -> impl only, carrying the markup node by reference. + Assert.Empty(routed[1].DeclPieces); + Assert.Contains(routed[1].ImplPieces, p => ReferenceEquals(p, markup)); + } + + private static RazorParserOptions ParserOptions(LanguageVersion version) + => RazorParserOptions.Default.WithCSharpParseOptions( + CSharpParseOptions.Default.WithLanguageVersion(version)); + + private static string TokenText(CSharpCodeIntermediateNode node) + { + var sb = new System.Text.StringBuilder(); + foreach (var child in node.Children) + { + if (child is IntermediateToken token) + { + sb.Append(token.Content); + } + } + + return sb.ToString(); + } +} diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs index 4f46559cfbc09..b47b22b73f388 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs @@ -39,6 +39,7 @@ private static void AssertDefaultPhases(RazorProjectEngine engine) phase => Assert.IsType(phase), phase => Assert.IsType(phase), phase => Assert.IsType(phase), + phase => Assert.IsType(phase), phase => Assert.IsType(phase), phase => Assert.IsType(phase), phase => Assert.IsType(phase), diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.builder.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.builder.txt index c8f306d8bf846..3a6bf8c808339 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.builder.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.builder.txt @@ -1,13 +1,8 @@ [0000] AddMarkupContent("

Element with no attributes

\r\n") -[0000] OpenElement("li") -[0001] AddAttribute("data-index", i) [0001] OpenElement("parent") -[0002] AddAttribute("TestCssScope") [0002] AddAttribute("with-attributes", "yes") [0003] AddAttribute("with-csharp-attribute-value", 123) -[0003] AddContent("Something ") [0004] AddAttribute("TestCssScope") -[0004] AddContent(i) [0005] AddMarkupContent("\r\n ") [0006] AddMarkupContent("With text\r\n ") [0007] OpenComponent() @@ -25,4 +20,9 @@ [0019] AddAttribute("another-attr", "Another attr value") [0020] AddAttribute("value", global::Microsoft.AspNetCore.Components.BindConverter.FormatValue( myVariable )) [0021] AddAttribute("onchange", global::Microsoft.AspNetCore.Components.EventCallback.Factory.CreateBinder(this, __value => myVariable = __value, myVariable)) -[0022] AddAttribute("TestCssScope") \ No newline at end of file +[0022] AddAttribute("TestCssScope") +[0023] OpenElement("li") +[0024] AddAttribute("data-index", i) +[0025] AddAttribute("TestCssScope") +[0026] AddContent("Something ") +[0027] AddContent(i) \ No newline at end of file diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.codegen.cs index 11605969b003f..63013db83cd3b 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.codegen.cs @@ -114,6 +114,52 @@ protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components. } #pragma warning restore 1998 +#nullable restore +#line (21,1)-(26,1) "x:\dir\subdir\Test\TestComponent.cshtml" + + void MethodRenderingMarkup(RenderTreeBuilder __builder) + { + for (var i = 0; i < 10; i++) + { + +#line default +#line hidden +#nullable disable + + __builder.OpenElement(23, "li"); + __builder.AddAttribute(24, "data-index", +#nullable restore +#line (26,29)-(26,30) "x:\dir\subdir\Test\TestComponent.cshtml" +i + +#line default +#line hidden +#nullable disable + ); + __builder.AddAttribute(25, "TestCssScope"); + __builder.AddContent(26, "Something "); +#nullable restore +#line (26,42)-(26,43) 25 "x:\dir\subdir\Test\TestComponent.cshtml" +__builder.AddContent(27, i + +#line default +#line hidden +#nullable disable + ); + __builder.CloseElement(); +#nullable restore +#line (27,1)-(33,1) "x:\dir\subdir\Test\TestComponent.cshtml" + } + + System.GC.KeepAlive(myElementReference); + System.GC.KeepAlive(myComponentReference); + System.GC.KeepAlive(myVariable); + } + +#line default +#line hidden +#nullable disable + } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.codegen.cs index d4317c428c923..50a6593d3b647 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.codegen.cs @@ -27,50 +27,25 @@ public partial class TestComponent : global::Microsoft.AspNetCore.Components.Com #nullable disable { #nullable restore -#line (17,8)-(26,1) "x:\dir\subdir\Test\TestComponent.cshtml" +#line (17,8)-(19,1) "x:\dir\subdir\Test\TestComponent.cshtml" ElementReference myElementReference; - TemplatedComponent myComponentReference; - string myVariable; - - void MethodRenderingMarkup(RenderTreeBuilder __builder) - { - for (var i = 0; i < 10; i++) - { #line default #line hidden #nullable disable - __builder.OpenElement(0, "li"); - __builder.AddAttribute(1, "data-index", #nullable restore -#line (26,29)-(26,30) "x:\dir\subdir\Test\TestComponent.cshtml" -i +#line (19,1)-(20,1) "x:\dir\subdir\Test\TestComponent.cshtml" + TemplatedComponent myComponentReference; #line default #line hidden #nullable disable - ); - __builder.AddAttribute(2, "TestCssScope"); - __builder.AddContent(3, "Something "); -#nullable restore -#line (26,42)-(26,43) 24 "x:\dir\subdir\Test\TestComponent.cshtml" -__builder.AddContent(4, i -#line default -#line hidden -#nullable disable - ); - __builder.CloseElement(); #nullable restore -#line (27,1)-(33,1) "x:\dir\subdir\Test\TestComponent.cshtml" - } - - System.GC.KeepAlive(myElementReference); - System.GC.KeepAlive(myComponentReference); - System.GC.KeepAlive(myVariable); - } +#line (20,1)-(21,1) "x:\dir\subdir\Test\TestComponent.cshtml" + string myVariable; #line default #line hidden diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.mappings.txt index 38c5f1fc02575..a4fa693878f3f 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.decl.mappings.txt @@ -8,53 +8,26 @@ Source Location: (45:1,1 [47] x:\dir\subdir\Test\TestComponent.cshtml) Generated Location: (524:18,0 [47] ) |using Microsoft.AspNetCore.Components.Rendering| -Source Location: (651:16,7 [233] x:\dir\subdir\Test\TestComponent.cshtml) +Source Location: (651:16,7 [44] x:\dir\subdir\Test\TestComponent.cshtml) | ElementReference myElementReference; - TemplatedComponent myComponentReference; - string myVariable; - - void MethodRenderingMarkup(RenderTreeBuilder __builder) - { - for (var i = 0; i < 10; i++) - { | -Generated Location: (869:30,0 [233] ) +Generated Location: (869:30,0 [44] ) | ElementReference myElementReference; - TemplatedComponent myComponentReference; - string myVariable; - - void MethodRenderingMarkup(RenderTreeBuilder __builder) - { - for (var i = 0; i < 10; i++) - { | -Source Location: (912:25,28 [1] x:\dir\subdir\Test\TestComponent.cshtml) -|i| -Generated Location: (1329:48,0 [1] ) -|i| - -Source Location: (925:25,41 [1] x:\dir\subdir\Test\TestComponent.cshtml) -|i| -Generated Location: (1605:58,24 [1] ) -|i| - -Source Location: (933:26,0 [164] x:\dir\subdir\Test\TestComponent.cshtml) -| } - - System.GC.KeepAlive(myElementReference); - System.GC.KeepAlive(myComponentReference); - System.GC.KeepAlive(myVariable); - } +Source Location: (695:18,0 [46] x:\dir\subdir\Test\TestComponent.cshtml) +| TemplatedComponent myComponentReference; +| +Generated Location: (1047:39,0 [46] ) +| TemplatedComponent myComponentReference; | -Generated Location: (1787:67,0 [164] ) -| } - System.GC.KeepAlive(myElementReference); - System.GC.KeepAlive(myComponentReference); - System.GC.KeepAlive(myVariable); - } +Source Location: (741:19,0 [24] x:\dir\subdir\Test\TestComponent.cshtml) +| string myVariable; +| +Generated Location: (1227:47,0 [24] ) +| string myVariable; | diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.mappings.txt index e910a5ddd489d..89c0c08713229 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Component_WithCssScope/TestComponent.mappings.txt @@ -49,3 +49,45 @@ Generated Location: (4078:108,0 [3] ) |} | +Source Location: (765:20,0 [119] x:\dir\subdir\Test\TestComponent.cshtml) +| + void MethodRenderingMarkup(RenderTreeBuilder __builder) + { + for (var i = 0; i < 10; i++) + { +| +Generated Location: (4264:118,0 [119] ) +| + void MethodRenderingMarkup(RenderTreeBuilder __builder) + { + for (var i = 0; i < 10; i++) + { +| + +Source Location: (912:25,28 [1] x:\dir\subdir\Test\TestComponent.cshtml) +|i| +Generated Location: (4612:132,0 [1] ) +|i| + +Source Location: (925:25,41 [1] x:\dir\subdir\Test\TestComponent.cshtml) +|i| +Generated Location: (4891:142,25 [1] ) +|i| + +Source Location: (933:26,0 [164] x:\dir\subdir\Test\TestComponent.cshtml) +| } + + System.GC.KeepAlive(myElementReference); + System.GC.KeepAlive(myComponentReference); + System.GC.KeepAlive(myVariable); + } +| +Generated Location: (5073:151,0 [164] ) +| } + + System.GC.KeepAlive(myElementReference); + System.GC.KeepAlive(myComponentReference); + System.GC.KeepAlive(myVariable); + } +| + diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs index 05ce811a28039..5cea863294169 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs @@ -25,6 +25,69 @@ protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components. { } #pragma warning restore 1998 +#nullable restore +#line (2,8)-(5,1) "x:\dir\subdir\Test\TestComponent.cshtml" + + void MyMethod(RenderTreeBuilder __builder) + { + +#line default +#line hidden +#nullable disable + + __builder.AddContent(0, " "); + __builder.OpenElement(1, "ul"); + __builder.AddMarkupContent(2, "\r\n"); +#nullable restore +#line (6,1)-(6,13) "x:\dir\subdir\Test\TestComponent.cshtml" + + +#line default +#line hidden +#nullable disable + +#nullable restore +#line (6,14)-(8,1) "x:\dir\subdir\Test\TestComponent.cshtml" +for (var i = 0; i < 100; i++) + { + +#line default +#line hidden +#nullable disable + + __builder.AddContent(3, " "); + __builder.OpenElement(4, "li"); + __builder.AddMarkupContent(5, "\r\n "); +#nullable restore +#line (9,22)-(9,23) 24 "x:\dir\subdir\Test\TestComponent.cshtml" +__builder.AddContent(6, i + +#line default +#line hidden +#nullable disable + ); + __builder.AddMarkupContent(7, "\r\n "); + __builder.CloseElement(); + __builder.AddMarkupContent(8, "\r\n"); +#nullable restore +#line (11,1)-(12,1) "x:\dir\subdir\Test\TestComponent.cshtml" + } + +#line default +#line hidden +#nullable disable + + __builder.AddContent(9, " "); + __builder.CloseElement(); + __builder.AddMarkupContent(10, "\r\n"); +#nullable restore +#line (13,1)-(14,1) "x:\dir\subdir\Test\TestComponent.cshtml" + } + +#line default +#line hidden +#nullable disable + } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs index 17b5196a42161..e4d3195ddb7a1 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs @@ -20,69 +20,6 @@ namespace Test public partial class TestComponent : global::Microsoft.AspNetCore.Components.ComponentBase #nullable disable { -#nullable restore -#line (2,8)-(5,1) "x:\dir\subdir\Test\TestComponent.cshtml" - - void MyMethod(RenderTreeBuilder __builder) - { - -#line default -#line hidden -#nullable disable - - __builder.AddContent(0, " "); - __builder.OpenElement(1, "ul"); - __builder.AddMarkupContent(2, "\r\n"); -#nullable restore -#line (6,1)-(6,13) "x:\dir\subdir\Test\TestComponent.cshtml" - - -#line default -#line hidden -#nullable disable - -#nullable restore -#line (6,14)-(8,1) "x:\dir\subdir\Test\TestComponent.cshtml" -for (var i = 0; i < 100; i++) - { - -#line default -#line hidden -#nullable disable - - __builder.AddContent(3, " "); - __builder.OpenElement(4, "li"); - __builder.AddMarkupContent(5, "\r\n "); -#nullable restore -#line (9,22)-(9,23) 24 "x:\dir\subdir\Test\TestComponent.cshtml" -__builder.AddContent(6, i - -#line default -#line hidden -#nullable disable - ); - __builder.AddMarkupContent(7, "\r\n "); - __builder.CloseElement(); - __builder.AddMarkupContent(8, "\r\n"); -#nullable restore -#line (11,1)-(12,1) "x:\dir\subdir\Test\TestComponent.cshtml" - } - -#line default -#line hidden -#nullable disable - - __builder.AddContent(9, " "); - __builder.CloseElement(); - __builder.AddMarkupContent(10, "\r\n"); -#nullable restore -#line (13,1)-(14,1) "x:\dir\subdir\Test\TestComponent.cshtml" - } - -#line default -#line hidden -#nullable disable - } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt index 6e259d7c13a56..7eae02f423321 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt @@ -3,47 +3,3 @@ Generated Location: (372:12,0 [47] ) |using Microsoft.AspNetCore.Components.Rendering| -Source Location: (57:1,7 [57] x:\dir\subdir\Test\TestComponent.cshtml) -| - void MyMethod(RenderTreeBuilder __builder) - { -| -Generated Location: (715:24,0 [57] ) -| - void MyMethod(RenderTreeBuilder __builder) - { -| - -Source Location: (128:5,0 [12] x:\dir\subdir\Test\TestComponent.cshtml) -| | -Generated Location: (1040:37,0 [12] ) -| | - -Source Location: (141:5,13 [46] x:\dir\subdir\Test\TestComponent.cshtml) -|for (var i = 0; i < 100; i++) - { -| -Generated Location: (1187:45,0 [46] ) -|for (var i = 0; i < 100; i++) - { -| - -Source Location: (230:8,21 [1] x:\dir\subdir\Test\TestComponent.cshtml) -|i| -Generated Location: (1557:57,24 [1] ) -|i| - -Source Location: (256:10,0 [15] x:\dir\subdir\Test\TestComponent.cshtml) -| } -| -Generated Location: (1851:68,0 [15] ) -| } -| - -Source Location: (286:12,0 [7] x:\dir\subdir\Test\TestComponent.cshtml) -| } -| -Generated Location: (2130:79,0 [7] ) -| } -| - diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt index 7eae02f423321..23ed4fd5eb9f3 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/Legacy_3_1_WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt @@ -3,3 +3,47 @@ Generated Location: (372:12,0 [47] ) |using Microsoft.AspNetCore.Components.Rendering| +Source Location: (57:1,7 [57] x:\dir\subdir\Test\TestComponent.cshtml) +| + void MyMethod(RenderTreeBuilder __builder) + { +| +Generated Location: (941:29,0 [57] ) +| + void MyMethod(RenderTreeBuilder __builder) + { +| + +Source Location: (128:5,0 [12] x:\dir\subdir\Test\TestComponent.cshtml) +| | +Generated Location: (1266:42,0 [12] ) +| | + +Source Location: (141:5,13 [46] x:\dir\subdir\Test\TestComponent.cshtml) +|for (var i = 0; i < 100; i++) + { +| +Generated Location: (1413:50,0 [46] ) +|for (var i = 0; i < 100; i++) + { +| + +Source Location: (230:8,21 [1] x:\dir\subdir\Test\TestComponent.cshtml) +|i| +Generated Location: (1783:62,24 [1] ) +|i| + +Source Location: (256:10,0 [15] x:\dir\subdir\Test\TestComponent.cshtml) +| } +| +Generated Location: (2077:73,0 [15] ) +| } +| + +Source Location: (286:12,0 [7] x:\dir\subdir\Test\TestComponent.cshtml) +| } +| +Generated Location: (2356:84,0 [7] ) +| } +| + diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.codegen.cs index 20e9aaf6a8da5..edd877af76c66 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.codegen.cs @@ -24,6 +24,38 @@ protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components. { } #pragma warning restore 1998 +#nullable restore +#line (3,8)-(9,1) "x:\dir\subdir\Test\TestComponent.cshtml" + + void RenderChildComponent(RenderTreeBuilder __builder) + { + var output = string.Empty; + if (__builder == null) output = "Builder is null!"; + else output = "Builder is not null!"; + +#line default +#line hidden +#nullable disable + + __builder.OpenElement(0, "p"); + __builder.AddContent(1, "Output: "); +#nullable restore +#line (9,21)-(9,27) 24 "x:\dir\subdir\Test\TestComponent.cshtml" +__builder.AddContent(2, output + +#line default +#line hidden +#nullable disable + ); + __builder.CloseElement(); +#nullable restore +#line (10,1)-(11,1) "x:\dir\subdir\Test\TestComponent.cshtml" + } + +#line default +#line hidden +#nullable disable + } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.codegen.cs index ebb0b2ad852ac..262493e325900 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.codegen.cs @@ -19,38 +19,6 @@ namespace Test public partial class TestComponent : global::Microsoft.AspNetCore.Components.ComponentBase #nullable disable { -#nullable restore -#line (3,8)-(9,1) "x:\dir\subdir\Test\TestComponent.cshtml" - - void RenderChildComponent(RenderTreeBuilder __builder) - { - var output = string.Empty; - if (__builder == null) output = "Builder is null!"; - else output = "Builder is not null!"; - -#line default -#line hidden -#nullable disable - - __builder.OpenElement(0, "p"); - __builder.AddContent(1, "Output: "); -#nullable restore -#line (9,21)-(9,27) 24 "x:\dir\subdir\Test\TestComponent.cshtml" -__builder.AddContent(2, output - -#line default -#line hidden -#nullable disable - ); - __builder.CloseElement(); -#nullable restore -#line (10,1)-(11,1) "x:\dir\subdir\Test\TestComponent.cshtml" - } - -#line default -#line hidden -#nullable disable - } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.mappings.txt index e098374fe0949..1c936962d3b9e 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.decl.mappings.txt @@ -3,32 +3,3 @@ Generated Location: (372:12,0 [48] ) |using Microsoft.AspNetCore.Components.Rendering;| -Source Location: (60:2,7 [213] x:\dir\subdir\Test\TestComponent.cshtml) -| - void RenderChildComponent(RenderTreeBuilder __builder) - { - var output = string.Empty; - if (__builder == null) output = "Builder is null!"; - else output = "Builder is not null!"; -| -Generated Location: (709:23,0 [213] ) -| - void RenderChildComponent(RenderTreeBuilder __builder) - { - var output = string.Empty; - if (__builder == null) output = "Builder is null!"; - else output = "Builder is not null!"; -| - -Source Location: (293:8,20 [6] x:\dir\subdir\Test\TestComponent.cshtml) -|output| -Generated Location: (1169:38,24 [6] ) -|output| - -Source Location: (305:9,0 [7] x:\dir\subdir\Test\TestComponent.cshtml) -| } -| -Generated Location: (1356:47,0 [7] ) -| } -| - diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.mappings.txt index 1c936962d3b9e..cba1b49db6c28 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SingleLineControlFlowStatements_InCodeDirective/TestComponent.mappings.txt @@ -3,3 +3,32 @@ Generated Location: (372:12,0 [48] ) |using Microsoft.AspNetCore.Components.Rendering;| +Source Location: (60:2,7 [213] x:\dir\subdir\Test\TestComponent.cshtml) +| + void RenderChildComponent(RenderTreeBuilder __builder) + { + var output = string.Empty; + if (__builder == null) output = "Builder is null!"; + else output = "Builder is not null!"; +| +Generated Location: (935:28,0 [213] ) +| + void RenderChildComponent(RenderTreeBuilder __builder) + { + var output = string.Empty; + if (__builder == null) output = "Builder is null!"; + else output = "Builder is not null!"; +| + +Source Location: (293:8,20 [6] x:\dir\subdir\Test\TestComponent.cshtml) +|output| +Generated Location: (1395:43,24 [6] ) +|output| + +Source Location: (305:9,0 [7] x:\dir\subdir\Test\TestComponent.cshtml) +| } +| +Generated Location: (1582:52,0 [7] ) +| } +| + diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.codegen.cs index bbd6ee69e7a12..29f06675c5c71 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.codegen.cs @@ -24,6 +24,55 @@ protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components. { } #pragma warning restore 1998 +#nullable restore +#line (5,1)-(9,10) "x:\dir\subdir\Test\TestComponent.cshtml" + + private static RenderFragment RenderBadge(SampleType type) => type switch + { + SampleType.Alpha => (__builder) => + { + +#line default +#line hidden +#nullable disable + +#nullable restore +#line (9,10)-(10,1) "x:\dir\subdir\Test\TestComponent.cshtml" + + +#line default +#line hidden +#nullable disable + + __builder.AddMarkupContent(0, "Alpha"); +#nullable restore +#line (11,1)-(13,10) "x:\dir\subdir\Test\TestComponent.cshtml" + }, + _ => (__builder) => + { + +#line default +#line hidden +#nullable disable + +#nullable restore +#line (13,10)-(14,1) "x:\dir\subdir\Test\TestComponent.cshtml" + + +#line default +#line hidden +#nullable disable + + __builder.AddMarkupContent(1, "Unknown"); +#nullable restore +#line (15,1)-(17,1) "x:\dir\subdir\Test\TestComponent.cshtml" + } + }; + +#line default +#line hidden +#nullable disable + } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.codegen.cs index 11750c707bbd2..b7c0e726ec920 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.codegen.cs @@ -20,52 +20,10 @@ public partial class TestComponent : global::Microsoft.AspNetCore.Components.Com #nullable disable { #nullable restore -#line (3,8)-(9,10) "x:\dir\subdir\Test\TestComponent.cshtml" +#line (3,8)-(5,1) "x:\dir\subdir\Test\TestComponent.cshtml" public enum SampleType { Alpha, Beta, Gamma } - private static RenderFragment RenderBadge(SampleType type) => type switch - { - SampleType.Alpha => (__builder) => - { - -#line default -#line hidden -#nullable disable - -#nullable restore -#line (9,10)-(10,1) "x:\dir\subdir\Test\TestComponent.cshtml" - - -#line default -#line hidden -#nullable disable - - __builder.AddMarkupContent(0, "Alpha"); -#nullable restore -#line (11,1)-(13,10) "x:\dir\subdir\Test\TestComponent.cshtml" - }, - _ => (__builder) => - { - -#line default -#line hidden -#nullable disable - -#nullable restore -#line (13,10)-(14,1) "x:\dir\subdir\Test\TestComponent.cshtml" - - -#line default -#line hidden -#nullable disable - - __builder.AddMarkupContent(1, "Unknown"); -#nullable restore -#line (15,1)-(17,1) "x:\dir\subdir\Test\TestComponent.cshtml" - } - }; - #line default #line hidden #nullable disable diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.mappings.txt index 9104888da88f0..ba199b293e158 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.decl.mappings.txt @@ -3,52 +3,12 @@ Generated Location: (320:11,0 [37] ) |using Microsoft.AspNetCore.Components| -Source Location: (49:2,7 [194] x:\dir\subdir\Test\TestComponent.cshtml) +Source Location: (49:2,7 [53] x:\dir\subdir\Test\TestComponent.cshtml) | public enum SampleType { Alpha, Beta, Gamma } - - private static RenderFragment RenderBadge(SampleType type) => type switch - { - SampleType.Alpha => (__builder) => - {| -Generated Location: (654:23,0 [194] ) -| - public enum SampleType { Alpha, Beta, Gamma } - - private static RenderFragment RenderBadge(SampleType type) => type switch - { - SampleType.Alpha => (__builder) => - {| - -Source Location: (243:8,9 [2] x:\dir\subdir\Test\TestComponent.cshtml) -| -| -Generated Location: (984:37,0 [2] ) -| | - -Source Location: (277:10,0 [50] x:\dir\subdir\Test\TestComponent.cshtml) -| }, - _ => (__builder) => - {| -Generated Location: (1183:46,0 [50] ) -| }, - _ => (__builder) => - {| - -Source Location: (327:12,9 [2] x:\dir\subdir\Test\TestComponent.cshtml) -| -| -Generated Location: (1370:56,0 [2] ) +Generated Location: (653:23,0 [53] ) | -| - -Source Location: (363:14,0 [19] x:\dir\subdir\Test\TestComponent.cshtml) -| } - }; -| -Generated Location: (1570:65,0 [19] ) -| } - }; + public enum SampleType { Alpha, Beta, Gamma } | diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.mappings.txt index bd712b2e103f1..0a5c85dff7051 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/SwitchExpression_WithMarkupInLambdaArms/TestComponent.mappings.txt @@ -3,3 +3,48 @@ Generated Location: (320:11,0 [37] ) |using Microsoft.AspNetCore.Components| +Source Location: (102:4,0 [141] x:\dir\subdir\Test\TestComponent.cshtml) +| + private static RenderFragment RenderBadge(SampleType type) => type switch + { + SampleType.Alpha => (__builder) => + {| +Generated Location: (880:28,0 [141] ) +| + private static RenderFragment RenderBadge(SampleType type) => type switch + { + SampleType.Alpha => (__builder) => + {| + +Source Location: (243:8,9 [2] x:\dir\subdir\Test\TestComponent.cshtml) +| +| +Generated Location: (1157:40,0 [2] ) +| +| + +Source Location: (277:10,0 [50] x:\dir\subdir\Test\TestComponent.cshtml) +| }, + _ => (__builder) => + {| +Generated Location: (1356:49,0 [50] ) +| }, + _ => (__builder) => + {| + +Source Location: (327:12,9 [2] x:\dir\subdir\Test\TestComponent.cshtml) +| +| +Generated Location: (1543:59,0 [2] ) +| +| + +Source Location: (363:14,0 [19] x:\dir\subdir\Test\TestComponent.cshtml) +| } + }; +| +Generated Location: (1743:68,0 [19] ) +| } + }; +| + diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs index 05ce811a28039..91e7bc73127d3 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.codegen.cs @@ -25,6 +25,61 @@ protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components. { } #pragma warning restore 1998 +#nullable restore +#line (2,8)-(5,1) "x:\dir\subdir\Test\TestComponent.cshtml" + + void MyMethod(RenderTreeBuilder __builder) + { + +#line default +#line hidden +#nullable disable + + __builder.OpenElement(0, "ul"); +#nullable restore +#line (6,1)-(6,13) "x:\dir\subdir\Test\TestComponent.cshtml" + + +#line default +#line hidden +#nullable disable + +#nullable restore +#line (6,14)-(8,1) "x:\dir\subdir\Test\TestComponent.cshtml" +for (var i = 0; i < 100; i++) + { + +#line default +#line hidden +#nullable disable + + __builder.OpenElement(1, "li"); +#nullable restore +#line (9,22)-(9,23) 24 "x:\dir\subdir\Test\TestComponent.cshtml" +__builder.AddContent(2, i + +#line default +#line hidden +#nullable disable + ); + __builder.CloseElement(); +#nullable restore +#line (11,1)-(12,1) "x:\dir\subdir\Test\TestComponent.cshtml" + } + +#line default +#line hidden +#nullable disable + + __builder.CloseElement(); +#nullable restore +#line (13,1)-(14,1) "x:\dir\subdir\Test\TestComponent.cshtml" + } + +#line default +#line hidden +#nullable disable + } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs index b9310cbe535b9..e4d3195ddb7a1 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.codegen.cs @@ -20,61 +20,6 @@ namespace Test public partial class TestComponent : global::Microsoft.AspNetCore.Components.ComponentBase #nullable disable { -#nullable restore -#line (2,8)-(5,1) "x:\dir\subdir\Test\TestComponent.cshtml" - - void MyMethod(RenderTreeBuilder __builder) - { - -#line default -#line hidden -#nullable disable - - __builder.OpenElement(0, "ul"); -#nullable restore -#line (6,1)-(6,13) "x:\dir\subdir\Test\TestComponent.cshtml" - - -#line default -#line hidden -#nullable disable - -#nullable restore -#line (6,14)-(8,1) "x:\dir\subdir\Test\TestComponent.cshtml" -for (var i = 0; i < 100; i++) - { - -#line default -#line hidden -#nullable disable - - __builder.OpenElement(1, "li"); -#nullable restore -#line (9,22)-(9,23) 24 "x:\dir\subdir\Test\TestComponent.cshtml" -__builder.AddContent(2, i - -#line default -#line hidden -#nullable disable - ); - __builder.CloseElement(); -#nullable restore -#line (11,1)-(12,1) "x:\dir\subdir\Test\TestComponent.cshtml" - } - -#line default -#line hidden -#nullable disable - - __builder.CloseElement(); -#nullable restore -#line (13,1)-(14,1) "x:\dir\subdir\Test\TestComponent.cshtml" - } - -#line default -#line hidden -#nullable disable - } } #pragma warning restore 1591 diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt index 57932cc4f7f5d..7eae02f423321 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.decl.mappings.txt @@ -3,47 +3,3 @@ Generated Location: (372:12,0 [47] ) |using Microsoft.AspNetCore.Components.Rendering| -Source Location: (57:1,7 [57] x:\dir\subdir\Test\TestComponent.cshtml) -| - void MyMethod(RenderTreeBuilder __builder) - { -| -Generated Location: (715:24,0 [57] ) -| - void MyMethod(RenderTreeBuilder __builder) - { -| - -Source Location: (128:5,0 [12] x:\dir\subdir\Test\TestComponent.cshtml) -| | -Generated Location: (946:35,0 [12] ) -| | - -Source Location: (141:5,13 [46] x:\dir\subdir\Test\TestComponent.cshtml) -|for (var i = 0; i < 100; i++) - { -| -Generated Location: (1093:43,0 [46] ) -|for (var i = 0; i < 100; i++) - { -| - -Source Location: (230:8,21 [1] x:\dir\subdir\Test\TestComponent.cshtml) -|i| -Generated Location: (1341:53,24 [1] ) -|i| - -Source Location: (256:10,0 [15] x:\dir\subdir\Test\TestComponent.cshtml) -| } -| -Generated Location: (1523:62,0 [15] ) -| } -| - -Source Location: (286:12,0 [7] x:\dir\subdir\Test\TestComponent.cshtml) -| } -| -Generated Location: (1707:71,0 [7] ) -| } -| - diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt index 7eae02f423321..dc4bffdfbb3fa 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/ComponentCodeGenerationTest/WhiteSpace_InMarkupInFunctionsBlock/TestComponent.mappings.txt @@ -3,3 +3,47 @@ Generated Location: (372:12,0 [47] ) |using Microsoft.AspNetCore.Components.Rendering| +Source Location: (57:1,7 [57] x:\dir\subdir\Test\TestComponent.cshtml) +| + void MyMethod(RenderTreeBuilder __builder) + { +| +Generated Location: (941:29,0 [57] ) +| + void MyMethod(RenderTreeBuilder __builder) + { +| + +Source Location: (128:5,0 [12] x:\dir\subdir\Test\TestComponent.cshtml) +| | +Generated Location: (1172:40,0 [12] ) +| | + +Source Location: (141:5,13 [46] x:\dir\subdir\Test\TestComponent.cshtml) +|for (var i = 0; i < 100; i++) + { +| +Generated Location: (1319:48,0 [46] ) +|for (var i = 0; i < 100; i++) + { +| + +Source Location: (230:8,21 [1] x:\dir\subdir\Test\TestComponent.cshtml) +|i| +Generated Location: (1567:58,24 [1] ) +|i| + +Source Location: (256:10,0 [15] x:\dir\subdir\Test\TestComponent.cshtml) +| } +| +Generated Location: (1749:67,0 [15] ) +| } +| + +Source Location: (286:12,0 [7] x:\dir\subdir\Test\TestComponent.cshtml) +| } +| +Generated Location: (1933:76,0 [7] ) +| } +| + diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs index c0316286540f0..3360c1aa6c593 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs @@ -25,11 +25,20 @@ protected override RazorCodeDocument ExecuteCore(RazorCodeDocument codeDocument, throw new InvalidOperationException(message); } - // Fork: when the decl phase produced its half, this phase produces the matching - // impl half -- a minimal partial class containing just the render method body and - // any compiler-synthesized plumbing. Otherwise (non-component, suppressed primary - // method body, malformed primary structure, or the decl phase didn't run for any - // other reason) fall through to the original single-file lowering path. + // When the split partitioned the component before resolution, the working node is already the + // impl half: write it directly rather than re-deriving an impl spine from a classified single tree. + if (documentNode.IsSplitImplDocument) + { + var implCSharpDocument = RazorCSharpDocumentWriter.Write(documentNode, codeDocument, cancellationToken: cancellationToken); + return codeDocument.WithImplCSharpDocument(implCSharpDocument); + } + + // The decl phase produced its half from the classified tree (a markup-free component, or a markup + // component whose shape the split couldn't partition early). Produce the matching impl half -- a + // partial class with the render method body, any compiler-synthesized plumbing, and (for the + // fallback case) the markup-bearing methods lifted from the class body. Otherwise (non-component, + // suppressed primary method body, malformed primary structure, or the decl phase didn't run for + // any other reason) fall through to the original single-file lowering path. if (codeDocument.GetDeclCSharpDocument() is not null && TryWriteImplDocument(documentNode, codeDocument, cancellationToken, out var implDocument)) { @@ -81,6 +90,20 @@ private static bool TryWriteImplDocument( } } + // Fallback tier: a markup component the early split couldn't partition reaches here unsplit, so + // lift its markup-bearing methods into the impl half over the classified tree. + var plan = MarkupSplitter.GetRoutablePlan(primaryClass, renderMethod, codeDocument.ParserOptions); + if (plan is not null) + { + foreach (var member in plan.Members) + { + foreach (var piece in member.ImplPieces) + { + implClass.Children.Add(piece); + } + } + } + foreach (var usingDirective in usings) { implNamespace.Children.Add(usingDirective); diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorDeclCSharpLoweringPhase.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorDeclCSharpLoweringPhase.cs index 8956e248d02c1..e49bc4fb8de25 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorDeclCSharpLoweringPhase.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorDeclCSharpLoweringPhase.cs @@ -9,22 +9,33 @@ namespace Microsoft.AspNetCore.Razor.Language; /// -/// For Razor components whose primary method body is not being suppressed, this phase produces -/// the "decl" C# document and stashes it on via -/// WithDeclCSharpDocument. The matching "impl" half is produced later by -/// ; both halves are emitted as partial -/// so they rejoin at compile time. +/// Produces the "decl" C# document -- the component's public API surface -- for a Razor component and +/// stashes it on via WithDeclCSharpDocument. The matching "impl" +/// half is produced by ; both are emitted as partial +/// so they rejoin at compile time. This is the late tier of the decl/impl split: most markup components +/// are partitioned earlier (before tag-helper resolution), and for them this phase is a no-op (it skips +/// when a decl document already exists). It handles markup-free components and, as a fallback, markup +/// components whose raw shape the early analysis could not partition -- routing their markup over the +/// already-classified tree. /// /// /// -/// The decl document carries the user's component API surface: the partial class declaration with -/// base type / interfaces / type parameters / user-authored class-level attributes (route, -/// layout), all properties / fields / parameters / inject members / sibling methods, and any -/// document-level metadata (source-checksum attributes, etc.). It deliberately omits the render -/// method body and any compiler-synthesized plumbing (marked with -/// ) so it depends only on user source -- not -/// on tag helper resolution -- and can therefore run earlier in the pipeline than the final -/// C# lowering phase. +/// When this phase produces the decl from the full classified tree (a markup-free component, or a +/// markup component that fell back), the decl document carries the user's component API surface: the +/// partial class declaration with base type / interfaces / type parameters / user-authored class-level +/// attributes (route, layout), all properties / fields / parameters / inject members and sibling +/// methods, and any document-level metadata (source-checksum attributes, etc.). It omits the render +/// method body and compiler-synthesized plumbing (marked with +/// ). +/// +/// +/// For a component the early split partitioned, the decl is instead produced by +/// DefaultRazorMarkupSplitPhase.LowerDeclDocument from just the using directives and the +/// markup-free @code pieces, before directive classification. Directive-authored members that +/// live outside @code -- an @inject property, for instance -- are not part of that decl +/// input, so they ride along in the impl half. That is invisible to tag-helper discovery (an injected +/// member shapes no bound attribute) and both halves are emitted as partial, so the recombined +/// class is unchanged. /// /// /// The split affects only the generated C# (GetDeclCSharpDocument() gives the decl half; @@ -41,6 +52,13 @@ namespace Microsoft.AspNetCore.Razor.Language; /// stays null and falls through to the prior /// single-file behavior. /// +/// +/// This two-tier arrangement is interim. The target is a single decision made entirely by the early +/// markup-split phase: a component either splits -- its decl half feeding the pre-compilation output and +/// its impl half the implementation output -- or stays a single document. Reaching it requires the early +/// phase to partition the shapes this tier handles as a fallback and to produce the decl for markup-free +/// components too, after which this late decl/impl production can be removed. +/// /// internal sealed class DefaultRazorDeclCSharpLoweringPhase : RazorEnginePhaseBase, IRazorCSharpLoweringPhase { @@ -59,6 +77,13 @@ protected override RazorCodeDocument ExecuteCore(RazorCodeDocument codeDocument, throw new InvalidOperationException(message); } + // If the early decl/impl split already produced the decl document (a component with class-body + // markup), don't produce it again from the impl-shaped working tree. + if (codeDocument.GetCSharpDocument(declarationDocument: true) is not null) + { + return codeDocument; + } + // Skip the split for any document that shouldn't be split: // - Non-components: the split is component-only. // - SuppressPrimaryMethodBody (e.g. ProcessDeclarationOnly): caller wants the @@ -92,14 +117,33 @@ protected override RazorCodeDocument ExecuteCore(RazorCodeDocument codeDocument, var declNamespace = RazorCSharpDocumentWriter.CloneContainer(primaryNamespace); var declClass = RazorCSharpDocumentWriter.CloneContainer(primaryClass); - foreach (var classChild in primaryClass.Children) + // Route the class body into the decl half. Most markup components are partitioned by the early + // split phases before resolution; a component whose raw @code the early analysis couldn't + // partition reaches here unsplit, so route its markup now over the classified tree (markup-free + // members stay in decl, markup-bearing methods lift to impl). Without a plan the class body is + // markup-free, so every non-render/non-synth child stays in decl. + var plan = MarkupSplitter.GetRoutablePlan(primaryClass, renderMethod, codeDocument.ParserOptions); + if (plan is not null) { - if (classChild == renderMethod || classChild.IsSynthesizedHelper) + foreach (var member in plan.Members) { - continue; + foreach (var piece in member.DeclPieces) + { + declClass.Children.Add(piece); + } } + } + else + { + foreach (var classChild in primaryClass.Children) + { + if (classChild == renderMethod || classChild.IsSynthesizedHelper) + { + continue; + } - declClass.Children.Add(classChild); + declClass.Children.Add(classChild); + } } foreach (var nsChild in primaryNamespace.Children) diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorMarkupSplitPhase.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorMarkupSplitPhase.cs new file mode 100644 index 0000000000000..a05f37860995d --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorMarkupSplitPhase.cs @@ -0,0 +1,294 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Razor.Language.Components; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.CSharp; + +namespace Microsoft.AspNetCore.Razor.Language; + +/// +/// Splits a component into decl and impl halves before tag-helper resolution. For a component whose +/// @code mixes markup with C#, it decides the split (a pure, resolution-independent function of +/// the class-body IR -- see ), produces the markup-free decl C# document +/// immediately by reusing the engine's classifier and decl-lowering phases, and rewrites the working +/// node into the impl half (the render body plus the markup-bearing members), which flows through the +/// rest of the pipeline. +/// +/// +/// Producing the decl half here -- before tag-helper resolution -- is the point: the decl document is +/// markup-free and depends only on user source, so tag-helper discovery can consume it early and stay +/// incremental. A component with no class-body markup, or one whose shape can't be split safely (a +/// markup property, an unsupported node, a preprocessor directive, a header/arity directive, or +/// unrecoverable syntax), is left untouched for the single-document lowering. The final C# lowering +/// phase emits the rewritten node directly as the impl half, gated on +/// . +/// +internal sealed class DefaultRazorMarkupSplitPhase : RazorEnginePhaseBase +{ + protected override RazorCodeDocument ExecuteCore(RazorCodeDocument codeDocument, CancellationToken cancellationToken) + { + var documentNode = codeDocument.GetDocumentNode(); + ThrowForMissingDocumentDependency(documentNode); + + // Gate cheaply, before any parsing: the split is component-only, a suppressed primary body already + // wants decl-shaped single-document output, and a header/arity directive (@inherits/@implements/ + // @typeparam) can't be reconciled across the two partial halves by the move-based partition. + if (!codeDocument.FileKind.IsComponent() || + codeDocument.CodeGenerationOptions.SuppressPrimaryMethodBody || + HasUnsplittableDocumentDirective(documentNode)) + { + return codeDocument; + } + + // Before classification the class body still lives as raw C# interleaved with markup IR under the + // code-block directives -- the resolution-independent input the split reasons about. A class body + // with no markup needs no split and is filtered here without parsing anything. + var children = CollectCodeBlockChildren(documentNode); + if (!ContainsMarkup(children)) + { + return codeDocument; + } + + // Decide the split. A fallback (a markup property, an unsupported node, a directive, or + // unrecoverable syntax) leaves the document untouched for the late single-document lowering. + var analysis = MarkupSplitter.BuildAnalysisDocument(children); + var parseOptions = codeDocument.ParserOptions.CSharpParseOptions ?? CSharpParseOptions.Default; + if (MarkupSplitter.ClassifyFromAnalysis(analysis, parseOptions, cancellationToken) is not SplitDecision.SplitPlan plan) + { + return codeDocument; + } + + using var declPiecesBuilder = new PooledArrayBuilder(); + using var implPiecesBuilder = new PooledArrayBuilder(); + + foreach (var member in plan.Members) + { + foreach (var piece in member.DeclPieces) + { + declPiecesBuilder.Add(piece); + } + + foreach (var piece in member.ImplPieces) + { + implPiecesBuilder.Add(piece); + } + } + + // Produce the decl half now, before tag-helper resolution: it is markup-free and depends only on + // user source, which is what lets tag-helper discovery consume it early and stay incremental. + var declCSharp = LowerDeclDocument(codeDocument, documentNode, declPiecesBuilder.ToImmutable(), cancellationToken); + + // Rewrite the working node into the impl half; it flows through the rest of the pipeline (including + // tag-helper resolution), and the final C# lowering phase emits it directly. + MakeImplInPlace(documentNode, implPiecesBuilder.ToImmutable()); + documentNode.IsSplitImplDocument = true; + + var result = codeDocument.WithDocumentNode(documentNode); + if (declCSharp is not null) + { + result = result.WithDeclCSharpDocument(declCSharp); + } + + return result; + } + + // The ordered children of every @code/@functions block in the document, in source order. Before + // classification these code-block directives hold the user's class body verbatim -- the same nodes + // FunctionsDirectivePass later moves into the primary class -- so this is the class body the split + // reasons about, gathered without depending on resolution or the classified structure. + private static ImmutableArray CollectCodeBlockChildren(DocumentIntermediateNode documentNode) + { + using var directives = new PooledArrayBuilder>(); + + documentNode.CollectDirectiveReferences(FunctionsDirective.Directive, ref directives.AsRef()); + documentNode.CollectDirectiveReferences(ComponentCodeDirective.Directive, ref directives.AsRef()); + + if (directives.Count == 0) + { + return []; + } + + directives.Sort(static (a, b) => + Comparer.Default.Compare(a.Node.Source?.AbsoluteIndex, b.Node.Source?.AbsoluteIndex)); + + using var children = new PooledArrayBuilder(); + + foreach (var directive in directives) + { + foreach (var child in directive.Node.Children) + { + children.Add(child); + } + } + + return children.ToImmutableAndClear(); + } + + // True if any collected class-body node (or a descendant) is a markup transition -- a node that can + // only be lowered after tag-helper resolution. O(nodes) with no parsing, so a markup-free class body + // is filtered cheaply. + private static bool ContainsMarkup(ImmutableArray nodes) + { + foreach (var node in nodes) + { + if (MarkupSplitter.IsMarkupNode(node) || ContainsMarkup(node)) + { + return true; + } + } + + return false; + } + + private static bool ContainsMarkup(IntermediateNode node) + { + foreach (var child in node.Children) + { + if (MarkupSplitter.IsMarkupNode(child) || ContainsMarkup(child)) + { + return true; + } + } + + return false; + } + + // Header- and arity-shaping directives (@inherits, @implements, @typeparam) must be consistent across + // the two partial halves. The move-based partition does not reconcile them -- it neither strips the + // base type and interfaces from the impl header nor duplicates the type parameters onto both halves -- + // so a component that combines one of them with class-body markup takes the single-document path. + private static bool HasUnsplittableDocumentDirective(DocumentIntermediateNode documentNode) + { + foreach (var child in documentNode.Children) + { + if (child is DirectiveIntermediateNode { DirectiveName: "inherits" or "implements" or "typeparam" }) + { + return true; + } + } + + return false; + } + + // Builds a markup-free decl document from the surface parts of the raw tree and lowers it to C# by + // running the engine's own classifier, directive-classifier, and decl C# lowering phases on it. Reusing + // the engine's phases (rather than a separate configuration) keeps the decl bytes identical to what the + // single-document path would produce for the same surface. + private RazorCSharpDocument? LowerDeclDocument( + RazorCodeDocument codeDocument, + DocumentIntermediateNode source, + ImmutableArray declPieces, + CancellationToken cancellationToken) + { + var declNode = new DocumentIntermediateNode { Options = source.Options }; + + foreach (var child in source.Children) + { + // Usings are needed by both halves; duplicate them so classifying decl doesn't reparent the + // impl's copies. + if (child is UsingDirectiveIntermediateNode usingDirective) + { + declNode.Children.Add(CloneUsing(usingDirective)); + } + } + + if (declPieces.Length > 0) + { + var codeDirective = new DirectiveIntermediateNode + { + DirectiveName = ComponentCodeDirective.Directive.Directive, + Directive = ComponentCodeDirective.Directive, + }; + + foreach (var piece in declPieces) + { + codeDirective.Children.Add(piece); + } + + declNode.Children.Add(codeDirective); + } + + var declCodeDoc = codeDocument.WithDocumentNode(declNode); + + // The split runs before the tag-helper rewrite phase, so the rewritten syntax tree isn't set yet. + // The decl half is markup-free and resolution-independent, so its rewritten tree is just the parsed + // syntax tree; seed it here so consumers that read it from the decl document (e.g. cohost diagnostic + // filtering, which walks it for using directives) don't see a null. + if (codeDocument.GetTagHelperRewrittenSyntaxTree() is null && + codeDocument.GetSyntaxTree() is { } syntaxTree) + { + declCodeDoc = declCodeDoc.WithTagHelperRewrittenSyntaxTree(syntaxTree); + } + + declCodeDoc = Engine.Phases.OfType().Single().Execute(declCodeDoc, cancellationToken); + declCodeDoc = Engine.Phases.OfType().Single().Execute(declCodeDoc, cancellationToken); + declCodeDoc = Engine.Phases.OfType().Single().Execute(declCodeDoc, cancellationToken); + + return declCodeDoc.GetCSharpDocument(declarationDocument: true); + } + + // Rewrites the working document into the impl half: the markup-bearing @code members replace the + // class-body content of a single consolidated code-block directive; every other code-block directive + // is dropped. The render body and any surface directives (e.g. @inject) remain in the impl half. + private static void MakeImplInPlace(DocumentIntermediateNode documentNode, ImmutableArray implPieces) + { + DirectiveIntermediateNode? primaryCodeDirective = null; + + using var toRemove = new PooledArrayBuilder(); + + foreach (var child in documentNode.Children) + { + if (child is DirectiveIntermediateNode directive && IsCodeBlockDirective(directive)) + { + if (primaryCodeDirective is null) + { + primaryCodeDirective = directive; + } + else + { + toRemove.Add(directive); + } + } + } + + foreach (var node in toRemove) + { + documentNode.Children.Remove(node); + } + + if (primaryCodeDirective is not null) + { + primaryCodeDirective.Children.Clear(); + + foreach (var piece in implPieces) + { + primaryCodeDirective.Children.Add(piece); + } + + if (implPieces.Length == 0) + { + documentNode.Children.Remove(primaryCodeDirective); + } + } + } + + private static bool IsCodeBlockDirective(DirectiveIntermediateNode directive) + => directive.Directive?.Kind == DirectiveKind.CodeBlock; + + private static UsingDirectiveIntermediateNode CloneUsing(UsingDirectiveIntermediateNode node) + => new() + { + Content = node.Content, + HasExplicitSemicolon = node.HasExplicitSemicolon, + AppendLineDefaultAndHidden = node.AppendLineDefaultAndHidden, + Source = node.Source, + IsImported = node.IsImported, + }; +} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Intermediate/DocumentIntermediateNode.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Intermediate/DocumentIntermediateNode.cs index f96fbf67905e1..ee5c757584783 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Intermediate/DocumentIntermediateNode.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Intermediate/DocumentIntermediateNode.cs @@ -14,6 +14,14 @@ public sealed class DocumentIntermediateNode : IntermediateNode public string DocumentKind { get; set; } + /// + /// Set by once it has produced the decl C# document and + /// rewritten this node into the impl half (before tag-helper resolution). Signals the final C# + /// lowering phase to emit this node directly as the impl half instead of deriving an impl spine from + /// a single classified tree. + /// + internal bool IsSplitImplDocument { get; set; } + public RazorCodeGenerationOptions Options { get; set; } public CodeTarget Target { get; set; } diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Analysis.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Analysis.cs new file mode 100644 index 0000000000000..23aafeed92fcd --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Analysis.cs @@ -0,0 +1,125 @@ +// 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.Text; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.AspNetCore.Razor.PooledObjects; + +namespace Microsoft.AspNetCore.Razor.Language; + +internal static partial class MarkupSplitter +{ + // A markup transition in expression position (an `@<...>` / `@:` template, which lowers to a + // RenderFragment value) is replaced by an expression; one in statement position (bare markup + // enabled by AllowRazorInAllCodeBlocks) is replaced by a statement. Substituting the right form + // for the markup's span yields text that parses iff the user's original C# was valid. + private const string ExpressionMarker = MarkerMethodName + "()"; + private const string StatementMarker = MarkerMethodName + "();"; + + // The class-body children are wrapped in a throwaway class so they parse as member declarations. + // Recorded child offsets are relative to the full wrapped text so they line up with the parse tree. + private const string AnalysisClassHeader = "class __C {\n"; + private const string AnalysisClassFooter = "\n}\n"; + + /// + /// Renders the collected class-body children into a parse-only C# document, replacing each markup + /// node with a position-aware marker, and records where each child landed (a ) + /// so the parser's member boundaries can be mapped back to the original IR nodes. The document is + /// never emitted; it exists only to recover member boundaries and detect which members carry markup. + /// + internal static AnalysisDocument BuildAnalysisDocument(ImmutableArray children) + { + using var _ = StringBuilderPool.GetPooledObject(out var builder); + builder.Append(AnalysisClassHeader); + + using var spans = new PooledArrayBuilder(capacity: children.Length); + + foreach (var child in children) + { + var start = builder.Length; + + switch (child) + { + case CSharpCodeIntermediateNode csharp: + AppendCSharpText(builder, csharp); + break; + + case var markup when IsMarkupNode(markup): + builder.Append(IsExpressionPositionMarkup(markup) ? ExpressionMarker : StatementMarker); + break; + + default: + // A synthesized structured declaration (e.g. an injected property). It carries no + // markup and is surface, so it contributes no analysis text -- but is still recorded + // (as a zero-length span) so routing can place it. + break; + } + + spans.Add(new ChildSpan(start, builder.Length - start, child)); + } + + builder.Append(AnalysisClassFooter); + + return new AnalysisDocument(builder.ToString(), spans.ToImmutableAndClear()); + } + + /// + /// Expression-position markup is exactly a (from @<...> + /// / @:); every other class-body markup node sits in statement position. Keying on this single + /// node kind -- rather than an enumerated list of the statement-position kinds -- keeps the rule + /// correct as new markup node kinds are introduced. + /// + internal static bool IsExpressionPositionMarkup(IntermediateNode node) + => node is TemplateIntermediateNode; + + private static void AppendCSharpText(StringBuilder builder, CSharpCodeIntermediateNode node) + { + foreach (var child in node.Children) + { + if (child is IntermediateToken token) + { + builder.Append(token.Content); + } + } + } +} + +/// +/// Where one class-body child landed in the throwaway analysis text, so member boundaries the parser +/// reports (in analysis-document coordinates) can be mapped back to the original IR node regardless of +/// the length difference between a markup node and its marker. A child's role -- raw C#, markup, or a +/// zero-length surface declaration -- is read from directly rather than stored. +/// +internal readonly struct ChildSpan +{ + public ChildSpan(int start, int length, IntermediateNode node) + { + Start = start; + Length = length; + Node = node; + } + + public int Start { get; } + + public int Length { get; } + + public int End => Start + Length; + + public IntermediateNode Node { get; } +} + +/// The throwaway analysis text plus the placements mapping its spans back to IR nodes. +internal sealed class AnalysisDocument +{ + public AnalysisDocument(string text, ImmutableArray children) + { + Text = text; + Children = children; + } + + public string Text { get; } + + public ImmutableArray Children { get; } +} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Classify.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Classify.cs new file mode 100644 index 0000000000000..8c3f9d3c4ecc8 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Classify.cs @@ -0,0 +1,306 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.AspNetCore.Razor.Language; + +internal static partial class MarkupSplitter +{ + /// + /// Turns a prepared into a in a single + /// pass: it applies the pre-parse safety gates (unsupported node, preprocessor directive), parses the + /// throwaway class, validates that every marker and every C# character is covered by a member, then + /// classifies and routes each member together -- bailing on the first member whose markup can't be + /// lifted, otherwise emitting the routed pieces. Classification and routing share the one parse and + /// the one set of member spans, so there is no intermediate classified-member table. The + /// analysis-building half runs in an earlier phase, so this is the resolution-independent decision the + /// split-classification phase drives; it never returns because a + /// document with no class-body markup never reaches analysis. + /// + internal static SplitDecision ClassifyFromAnalysis( + AnalysisDocument analysis, + CSharpParseOptions parseOptions, + CancellationToken cancellationToken = default) + { + // Every routable node is raw C# or recognized markup. A structured/extension member that reached + // the class body (e.g. an @inject) can't be placed, so leave the class body unrouted. + foreach (var child in analysis.Children) + { + if (!IsSupportedClassBodyNode(child.Node)) + { + return SplitDecision.Fallback(FallbackReason.UnsupportedClassBodyNode); + } + } + + // A preprocessor directive scopes across members; routing one to the other half would orphan it. + if (HasPreprocessorDirective(analysis.Text)) + { + return SplitDecision.Fallback(FallbackReason.ClassBodyHasDirectives); + } + + // Parse from a SourceText (the string-based ParseText overload is banned in this project). The + // analysis document is throwaway and never emitted, so its encoding is irrelevant. + var tree = CSharpSyntaxTree.ParseText(SourceText.From(analysis.Text), parseOptions, cancellationToken: cancellationToken); + var root = tree.GetCompilationUnitRoot(cancellationToken); + + var markerClass = root.Members.OfType().FirstOrDefault(); + if (markerClass is null || markerClass.OpenBraceToken.IsMissing || markerClass.CloseBraceToken.IsMissing) + { + return SplitDecision.Fallback(FallbackReason.UnrecoverableParse); + } + + // Member spans, in source order. These and the child spans both live in analysis-document + // coordinates (a markup node contributes its marker's span), so intersecting them below never has + // to reconcile the markup/marker length difference. + var members = markerClass.Members; + var memberSpans = new TextSpan[members.Count]; + for (var i = 0; i < members.Count; i++) + { + memberSpans[i] = members[i].FullSpan; + } + + // Every markup marker and every non-whitespace C# character must land inside some member. A leak + // -- brace imbalance let a marker escape a member (or the class), or skipped tokens fell outside + // every member -- can't be routed without dropping content or leaking markup into decl, so it is + // unrecoverable. This, not an ordinary transient syntax error (which still recovers member + // boundaries), is what the catastrophic safety net exists for. + if (!AllMarkupCovered(analysis.Children, memberSpans) || + !AllCSharpContentCovered(analysis, memberSpans)) + { + return SplitDecision.Fallback(FallbackReason.UnrecoverableParse); + } + + // Classify each member and, in the same pass, note whether it lifts to impl. A markup-bearing + // plain method lifts wholesale; markup anywhere else fails the whole file, so bail on the first + // such member before doing any routing work. + var liftToImpl = new bool[members.Count]; + for (var i = 0; i < members.Count; i++) + { + if (!MemberCoversMarkup(analysis.Children, memberSpans[i])) + { + continue; + } + + switch (members[i]) + { + // Only a plain method can be lifted wholesale to impl: it has no field-initializer + // ordering to preserve and isn't descriptor surface, so its absence from decl is invisible. + case MethodDeclarationSyntax: + liftToImpl[i] = true; + break; + + // A property/indexer is descriptor surface -- it must stay in decl, where markup can't + // live -- so markup in one forces fallback. + case PropertyDeclarationSyntax or IndexerDeclarationSyntax: + return SplitDecision.Fallback(FallbackReason.MarkupProperty); + + // Anything else with markup -- a field/event (whose initializer runs in declaration + // order), a nested type (which may be referenced from decl, or itself contain markup + // members), a constructor/operator, or an incomplete member -- isn't safe to lift. + default: + return SplitDecision.Fallback(FallbackReason.UnsupportedMarkupMember); + } + } + + return new SplitDecision.SplitPlan(RouteMembers(analysis, memberSpans, liftToImpl)); + } + + /// + /// Groups the analysis children under their owning parsed members, slicing straddling C# chunks at + /// member boundaries, to produce one per member in original order -- + /// already resolved into the pieces each half emits. Parser member spans and IR nodes both live in + /// analysis-document offsets (a markup node contributes its marker span), so intersecting them here + /// means the markup/marker length difference never matters. A member either stays wholly in decl or + /// (a markup method) lifts wholly to impl, per . + /// + private static ImmutableArray RouteMembers( + AnalysisDocument analysis, + TextSpan[] memberSpans, + bool[] liftToImpl) + { + // Accumulate each member's pieces (sliced C# chunks and markup nodes) in source order. + var pieceBuilders = new List[memberSpans.Length]; + for (var i = 0; i < memberSpans.Length; i++) + { + pieceBuilders[i] = []; + } + + foreach (var child in analysis.Children) + { + if (child.Node is CSharpCodeIntermediateNode csharp) + { + RouteCSharpChild(csharp, child, memberSpans, pieceBuilders); + } + else + { + // A markup marker or a zero-length synthesized declaration lives entirely inside one + // member; route the original node there by reference (keeping its source mappings). + var owner = FindMemberIndex(memberSpans, child.Start); + if (owner >= 0) + { + pieceBuilders[owner].Add(child.Node); + } + } + } + + var result = ImmutableArray.CreateBuilder(memberSpans.Length); + for (var i = 0; i < memberSpans.Length; i++) + { + var pieces = pieceBuilders[i].ToImmutableArray(); + + // A markup-free member stays in decl; a markup-bearing method lifts wholesale to impl. + // (Markup properties produced a fallback decision before this runs.) + result.Add(liftToImpl[i] + ? new RoutedMember(declPieces: [], implPieces: pieces) + : new RoutedMember(declPieces: pieces, implPieces: [])); + } + + return result.ToImmutable(); + } + + // Slices a raw C# chunk at any member boundaries that fall within it and routes each slice to the + // member that owns its start. A single class-body C# chunk commonly straddles several members (a + // field immediately followed by a markup-bearing method), so it can't be routed as a unit. + private static void RouteCSharpChild( + CSharpCodeIntermediateNode node, + ChildSpan child, + TextSpan[] memberSpans, + List[] pieceBuilders) + { + // Member boundaries strictly inside the child become node-local cut offsets. Members are in + // source order with contiguous, increasing spans, so the cuts come out strictly increasing. + var cuts = ImmutableArray.CreateBuilder(); + foreach (var span in memberSpans) + { + var boundary = span.End; + if (boundary > child.Start && boundary < child.End) + { + cuts.Add(boundary - child.Start); + } + } + + var cutOffsets = cuts.ToImmutable(); + var slices = SplitCSharpNode(node, cutOffsets); + + for (var i = 0; i < slices.Length; i++) + { + var localStart = i == 0 ? 0 : cutOffsets[i - 1]; + var owner = FindMemberIndex(memberSpans, child.Start + localStart); + if (owner >= 0) + { + pieceBuilders[owner].Add(slices[i]); + } + } + } + + // The index of the member whose analysis-document span contains the offset. Members partition the + // class body contiguously, so any interior offset has exactly one owner; a boundary offset belongs to + // the following member (spans are half-open), which is the member that content begins. + private static int FindMemberIndex(TextSpan[] memberSpans, int offset) + { + for (var i = 0; i < memberSpans.Length; i++) + { + if (memberSpans[i].Contains(offset)) + { + return i; + } + } + + return -1; + } + + // True when every non-whitespace character of every C# child falls within some member's span. Members + // partition the class body contiguously in the common case, so this only fails on real gaps (leading/ + // trailing skipped tokens or brace imbalance), which routing must not silently drop. + private static bool AllCSharpContentCovered(AnalysisDocument analysis, TextSpan[] memberSpans) + { + var text = analysis.Text; + + foreach (var child in analysis.Children) + { + if (child.Node is not CSharpCodeIntermediateNode) + { + continue; + } + + for (var index = child.Start; index < child.End; index++) + { + if (!char.IsWhiteSpace(text[index]) && !IsCoveredByMember(memberSpans, index)) + { + return false; + } + } + } + + return true; + } + + // Binary search the source-ordered, contiguous member spans for the one containing the offset. Called + // per character by AllCSharpContentCovered, so an O(log memberCount) probe keeps that scan near-linear + // in the text length instead of O(textLength * memberCount). + private static bool IsCoveredByMember(TextSpan[] memberSpans, int index) + { + var lo = 0; + var hi = memberSpans.Length - 1; + + while (lo <= hi) + { + var mid = lo + (hi - lo) / 2; + var span = memberSpans[mid]; + + if (index < span.Start) + { + hi = mid - 1; + } + else if (index >= span.End) + { + lo = mid + 1; + } + else + { + return true; + } + } + + return false; + } + + // True when every markup marker starts within some member's span. A marker outside every member means + // brace imbalance let it leak out (unrecoverable): routing it would drop it or leak markup into decl. + private static bool AllMarkupCovered(ImmutableArray children, TextSpan[] memberSpans) + { + foreach (var child in children) + { + if (IsMarkupNode(child.Node) && !IsCoveredByMember(memberSpans, child.Start)) + { + return false; + } + } + + return true; + } + + // A member carries markup when a markup child's marker starts within the member's span. Detection is + // by node kind over the child spans, never by matching the marker identifier name -- user code may + // itself call a method of that name. + private static bool MemberCoversMarkup(ImmutableArray children, TextSpan memberSpan) + { + foreach (var child in children) + { + if (IsMarkupNode(child.Node) && memberSpan.Contains(child.Start)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Slicing.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Slicing.cs new file mode 100644 index 0000000000000..e786ce4db29a5 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.Slicing.cs @@ -0,0 +1,186 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Razor.Language; + +internal static partial class MarkupSplitter +{ + /// + /// Splits a raw C# node at one or more offsets within its concatenated text, producing a piece per + /// sub-range. A single class-body C# chunk often straddles several parsed members (e.g. a field + /// immediately followed by a markup-bearing method), so it must be cut at the member boundaries and + /// each slice routed independently. Each produced token's is recomputed -- + /// line and character indices, not just the absolute index -- because a slice that starts after a + /// newline maps to a different line, and getting that wrong corrupts source mappings. + /// + /// The C# node to split. + /// Strictly-increasing offsets in the node's concatenated text, each strictly + /// between 0 and the text length. Produces cuts.Length + 1 pieces. + internal static ImmutableArray SplitCSharpNode( + CSharpCodeIntermediateNode node, + ImmutableArray cuts) + { + if (cuts.IsDefaultOrEmpty) + { + return [node]; + } + + var pieces = new CSharpCodeIntermediateNode[cuts.Length + 1]; + for (var i = 0; i < pieces.Length; i++) + { + pieces[i] = new CSharpCodeIntermediateNode { Source = node.Source, IsImported = node.IsImported }; + } + + // Walk each token, tracking its start offset in the node's concatenated text. A token that spans + // one or more cut points is itself sliced so each slice lands in the correct piece. + var tokenStart = 0; + foreach (var child in node.Children) + { + if (child is not IntermediateToken token) + { + continue; + } + + var content = token.Content; + var tokenEnd = tokenStart + content.Length; + + // The cut offsets that fall strictly inside this token become local slice boundaries. + var localBoundaries = new List { 0 }; + var pieceOfFirstSlice = PieceIndexOf(cuts, tokenStart); + + foreach (var cut in cuts) + { + if (cut > tokenStart && cut < tokenEnd) + { + localBoundaries.Add(cut - tokenStart); + } + } + + localBoundaries.Add(content.Length); + + for (var i = 0; i < localBoundaries.Count - 1; i++) + { + var localStart = localBoundaries[i]; + var localLength = localBoundaries[i + 1] - localBoundaries[i]; + if (localLength == 0) + { + continue; + } + + var pieceIndex = pieceOfFirstSlice + i; + pieces[pieceIndex].Children.Add(SliceToken(token, localStart, localLength)); + } + + tokenStart = tokenEnd; + } + + return ImmutableCollectionsMarshal.AsImmutableArray(pieces); + } + + // The index of the piece that content at the given node-text offset belongs to: the number of cut + // points at or before the offset. + private static int PieceIndexOf(ImmutableArray cuts, int offset) + { + var index = 0; + foreach (var cut in cuts) + { + if (cut <= offset) + { + index++; + } + else + { + break; + } + } + + return index; + } + + /// + /// Produces a token for the substring [localStart, localStart + localLength) of the given + /// token's content, with its advanced from the token's start across the + /// skipped prefix (so line/character indices are correct even across newlines). + /// + internal static CSharpIntermediateToken SliceToken(IntermediateToken token, int localStart, int localLength) + { + var content = token.Content; + var slicedContent = content.Substring(localStart, localLength); + + if (token.Source is not { } source) + { + return new CSharpIntermediateToken(slicedContent, source: null); + } + + var (startAbsolute, startLine, startCharacter) = + AdvanceLocation(source.AbsoluteIndex, source.LineIndex, source.CharacterIndex, content, 0, localStart); + + var (_, endLine, endCharacter) = + AdvanceLocation(startAbsolute, startLine, startCharacter, content, localStart, localLength); + + var slicedSource = new SourceSpan( + source.FilePath, + startAbsolute, + startLine, + startCharacter, + localLength, + // LineCount is the number of line breaks the span covers (0 for a single-line slice), matching + // how the writer derives the enhanced #line end line as LineIndex + 1 + LineCount. + lineCount: endLine - startLine, + endCharacterIndex: endCharacter); + + return new CSharpIntermediateToken(slicedContent, slicedSource); + } + + /// + /// Advances a source location across characters of + /// starting at . Matches the writer's line-break accounting: \r\n, a + /// lone \r, and a lone \n each count as one line break that resets the character index. + /// The \n of a \r\n pair is only consumed when it falls inside the requested range: a + /// range that ends exactly on the \r treats it as a lone break and leaves the \n for the + /// next slice, so a slice boundary that lands between the two characters can't over-advance past it. + /// + internal static (int Absolute, int Line, int Character) AdvanceLocation( + int absolute, int line, int character, string text, int start, int count) + { + var i = start; + var end = start + count; + + while (i < end) + { + var c = text[i]; + absolute++; + + if (c == '\r') + { + if (i + 1 < end && text[i + 1] == '\n') + { + // Consume the paired newline as a single line break. + absolute++; + i++; + } + + line++; + character = 0; + } + else if (c == '\n') + { + line++; + character = 0; + } + else + { + character++; + } + + i++; + } + + return (absolute, line, character); + } +} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.cs new file mode 100644 index 0000000000000..c2173de8bc846 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/MarkupSplitter.cs @@ -0,0 +1,201 @@ +// 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 Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.AspNetCore.Razor.PooledObjects; + +namespace Microsoft.AspNetCore.Razor.Language; + +/// +/// Decides, for a component's primary class body, which parts of the user's @code belong in +/// the markup-free "decl" half (the tag-helper descriptor surface) and which markup-bearing parts +/// belong in the "impl" half (lowered after tag-helper resolution). +/// +/// +/// +/// The @code contents arrive on the primary as a +/// flat sequence of raw C# text ( holding +/// ) interleaved with markup nodes. The vast majority of +/// @code is pure C# with no markup, so a cheap structural gate () +/// runs first: with no class-body markup there is nothing to route to the impl half, so it reports +/// without parsing anything -- the whole class body stays in the +/// decl half. +/// +/// +/// The split decision is a pure function of the class body's IR content and the parse options; it does +/// not branch on the language version, so every caller reaches the same decision for the same document. +/// The markup-split phase computes it once, produces the decl half, and rewrites the working node into +/// the impl half -- all before tag-helper resolution. +/// +/// +internal static partial class MarkupSplitter +{ + /// + /// Identifier emitted into the throwaway analysis document to stand in for a markup transition, so + /// the class body parses as ordinary C# without needing resolved tag helpers. It never appears in + /// generated output. Markup is detected from the analysis document's per-child placements, never by + /// matching this name (user code may legitimately contain a call of the same name). + /// + public const string MarkerMethodName = "__RazorMarkupTransition"; + + /// + /// Computes the split decision for the given primary class body: gates on class-body markup, builds + /// the analysis document, and classifies it. Pure and uncached. + /// + public static SplitDecision Split( + ClassDeclarationIntermediateNode primaryClass, + MethodDeclarationIntermediateNode renderMethod, + RazorParserOptions parserOptions) + { + ArgHelper.ThrowIfNull(primaryClass); + ArgHelper.ThrowIfNull(renderMethod); + ArgHelper.ThrowIfNull(parserOptions); + + // Fast path: with no class-body markup there is nothing to move to the impl half. + if (!HasClassBodyMarkup(primaryClass, renderMethod)) + { + return SplitDecision.NoSplit; + } + + var children = CollectClassBodyChildren(primaryClass, renderMethod); + var analysis = BuildAnalysisDocument(children); + return ClassifyFromAnalysis(analysis, parserOptions.CSharpParseOptions); + } + + /// + /// The routable plan for the given classified class body, or when the body + /// keeps its unsplit shape. This is the fallback entry point: the primary decl/impl split + /// runs early (before tag-helper resolution) over the raw @code, but a component whose raw + /// shape the early analysis can't partition falls through to the late lowering phases, which route + /// its markup here over the already-classified tree instead. + /// + public static SplitDecision.SplitPlan? GetRoutablePlan( + ClassDeclarationIntermediateNode primaryClass, + MethodDeclarationIntermediateNode renderMethod, + RazorParserOptions parserOptions) + => Split(primaryClass, renderMethod, parserOptions) as SplitDecision.SplitPlan; + + /// + /// True if any line of the analysis text begins (after leading whitespace) with a preprocessor + /// directive. A line-anchored scan avoids misfiring on a # inside a string or interpolation; + /// a rare false positive only costs an unnecessary fallback, never a mis-split. + /// + internal static bool HasPreprocessorDirective(string text) + { + var atLineStart = true; + + foreach (var c in text) + { + if (c is '\n' or '\r') + { + atLineStart = true; + } + else if (!atLineStart) + { + continue; + } + else if (c == '#') + { + return true; + } + else if (!char.IsWhiteSpace(c)) + { + atLineStart = false; + } + } + + return false; + } + + /// + /// True if the primary class body contains a markup transition (a node that can only be lowered + /// after tag-helper resolution). Runs in O(children) with no parsing. + /// + public static bool HasClassBodyMarkup( + ClassDeclarationIntermediateNode primaryClass, + MethodDeclarationIntermediateNode renderMethod) + { + foreach (var child in primaryClass.Children) + { + if (ReferenceEquals(child, renderMethod) || child.IsSynthesizedHelper) + { + continue; + } + + if (IsClassBodyMarkup(child)) + { + return true; + } + } + + return false; + } + + /// + /// Classifies a class-body child as markup rather than C#. Defined as the complement of the known + /// C#/structured-declaration node kinds so that an unrecognized (e.g. newly introduced) markup node + /// is treated as markup: erring toward running the split machinery is a harmless cost, whereas + /// missing a markup node would let it leak into the resolution-free decl half. + /// + /// + /// This is the deliberately over-eager gate classifier. It can flag a non-markup extension + /// node (an @inject) as "markup"; that only causes to run, which then sees + /// the node isn't a kind it can route () and falls back. Routing + /// itself uses the precise allow-list , never this predicate. + /// + internal static bool IsClassBodyMarkup(IntermediateNode node) + => node is not (CSharpCodeIntermediateNode or + FieldDeclarationIntermediateNode or + PropertyDeclarationIntermediateNode or + MethodDeclarationIntermediateNode); + + /// + /// The precise allow-list of markup intermediate node kinds the splitter knows how to route to the + /// impl half: an expression-position (from @<...>) + /// and the statement-position markup nodes. Unlike the fail-safe gate, + /// this is positive: a class-body node that is neither raw C# nor one of these kinds -- e.g. an + /// @inject (ComponentInjectIntermediateNode, itself an + /// just like ) or a + /// structured member declaration -- is not treated as routable markup. + /// + internal static bool IsMarkupNode(IntermediateNode node) + => node is TemplateIntermediateNode or + MarkupElementIntermediateNode or + MarkupBlockIntermediateNode or + HtmlContentIntermediateNode; + + /// + /// A class-body node the splitter can route: raw C# text (which stays in decl or lifts to impl with + /// its member) or a recognized markup node (which lifts to impl). Any other kind -- a structured or + /// extension member such as @inject -- means the file can't be split and must fall back. + /// + internal static bool IsSupportedClassBodyNode(IntermediateNode node) + => node is CSharpCodeIntermediateNode || IsMarkupNode(node); + + /// + /// The ordered user-authored class-body children -- everything that isn't the render method or a + /// synthesized helper -- in source order. This is the flat sequence of raw C# chunks and markup + /// transitions the analysis document and routing operate over. + /// + internal static ImmutableArray CollectClassBodyChildren( + ClassDeclarationIntermediateNode primaryClass, + MethodDeclarationIntermediateNode renderMethod) + { + using var builder = new PooledArrayBuilder(); + + foreach (var child in primaryClass.Children) + { + if (ReferenceEquals(child, renderMethod) || child.IsSynthesizedHelper) + { + continue; + } + + builder.Add(child); + } + + return builder.ToImmutableAndClear(); + } +} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs index 8bc3ad3a16f7a..de87d0e4febbe 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs @@ -260,6 +260,7 @@ private static void AddDefaultPhases(ImmutableArray.Builder p phases.Add(new DefaultRazorSyntaxTreePhase()); phases.Add(new DefaultRazorTagHelperContextDiscoveryPhase()); phases.Add(new DefaultRazorIntermediateNodeLoweringPhase()); + phases.Add(new DefaultRazorMarkupSplitPhase()); phases.Add(new DefaultTagHelperResolutionPhase()); phases.Add(new DefaultRazorTagHelperRewritePhase()); phases.Add(new DefaultRazorDocumentClassifierPhase()); diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/SplitDecision.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/SplitDecision.cs new file mode 100644 index 0000000000000..29cd12ce9c030 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/SplitDecision.cs @@ -0,0 +1,157 @@ +// 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 Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Razor.Language; + +/// +/// The outcome of analyzing a component's primary class body for the decl/impl markup split. One of +/// three cases: +/// +/// -- no class-body markup, so the caller keeps the single-file behavior. +/// -- the class body mixes markup and C# and can be split safely into a +/// markup-free decl half and a markup-bearing impl half; describes how the pieces route. +/// -- the class body has markup but cannot be split safely (a markup +/// property, an unsupported node, a directive, or an unrecoverable parse), so the caller retains the +/// original class-body layout and can select its fallback pipeline. +/// +/// +/// +/// This is a closed hierarchy, produced once per primary class and shared by both lowering phases. The +/// decision is a pure function of the class body's IR and the parse options, and it never branches on +/// the language version, so all callers reach the same decision for the same document. Split plans are +/// consumed by the lowering phases; an explicit fallback result lets pipeline callers preserve unsplit +/// processing for shapes that cannot be routed safely. +/// +internal abstract class SplitDecision +{ + private protected SplitDecision() + { + } + + /// + /// The class body has no markup (or nothing that needs splitting); the caller keeps the single-file + /// behavior. Shared singleton. + /// + public static SplitDecision NoSplit { get; } = new NoSplitDecision(); + + /// + /// The class body has markup but cannot be split safely; the caller retains the original class-body + /// layout and can select its fallback pipeline. + /// + public static SplitFallback Fallback(FallbackReason reason) => new(reason); + + /// True when this decision requires the caller to build separate decl and impl halves. + public bool RequiresSplit => this is SplitPlan; + + /// + /// True when the file has markup but cannot be split, so the caller must retain the original + /// class-body layout instead of routing members between declaration and implementation output. + /// + public bool IsFallback => this is SplitFallback; + + private sealed class NoSplitDecision : SplitDecision + { + } + + /// + /// The class body has markup that cannot be split safely, so its original layout must be retained. + /// This preserves correctness while allowing the surrounding pipeline to choose a fallback path. + /// + public sealed class SplitFallback : SplitDecision + { + public SplitFallback(FallbackReason reason) + { + Reason = reason; + } + + /// Why the file falls back instead of splitting (for diagnostics/telemetry and tests). + public FallbackReason Reason { get; } + } + + /// + /// Describes how each class-body member routes between the decl and impl halves. Produced only when + /// the class body mixes markup and C# and the file can be split safely. + /// + public sealed class SplitPlan : SplitDecision + { + public SplitPlan(ImmutableArray members) + { + Members = members.NullToEmpty(); + } + + /// The routed class-body members in original order; each drives what its half emits. + public ImmutableArray Members { get; } + } +} + +/// +/// A user-authored class-body member after routing, already resolved into the IR pieces each half emits: +/// for the decl half and for the impl half. Original +/// nodes are shared by reference (keeping their source mappings). A member is either markup-free (all its +/// pieces stay in decl) or a markup-bearing method (all its pieces lift to impl); a markup property +/// never reaches routing because it produces a fallback decision. The lowering phases simply append the +/// pieces for their half. +/// +internal readonly struct RoutedMember +{ + public RoutedMember( + ImmutableArray declPieces, + ImmutableArray implPieces) + { + DeclPieces = declPieces.NullToEmpty(); + ImplPieces = implPieces.NullToEmpty(); + } + + /// The pieces this member contributes to the decl half, in order. + public ImmutableArray DeclPieces { get; } + + /// The pieces this member contributes to the impl half, in order. + public ImmutableArray ImplPieces { get; } +} + +/// +/// Why a markup-bearing class body retains its original layout instead of being routed between +/// declaration and implementation output. +/// +internal enum FallbackReason +{ + /// + /// A property/indexer carries markup. A property is tag-helper descriptor surface (a + /// [Parameter] shapes the component's attributes), so it must stay in the decl half -- but + /// markup cannot live in the markup-free decl half. Rather than reshape the property, the splitter + /// reports fallback so the property remains in place. + /// + MarkupProperty, + + /// + /// A non-method, non-property member carries markup -- a field/event (whose initializer runs in + /// declaration order, which splitting across partials would perturb), a nested type, a + /// constructor/operator, or an incomplete member. It cannot be safely lifted, so the splitter reports + /// fallback. + /// + UnsupportedMarkupMember, + + /// + /// The analysis parse is unrecoverable (brace mismatch, or a markup marker isn't contained by any + /// member), so member boundaries can't be trusted. Not triggered by ordinary transient typos, which + /// still recover member boundaries. + /// + UnrecoverableParse, + + /// + /// The class body contains a node the splitter can't route -- neither raw C# nor a recognized markup + /// node -- such as an @inject or another structured/extension member. Retaining the original + /// layout avoids moving descriptor surface into the impl half. + /// + UnsupportedClassBodyNode, + + /// + /// The class body contains a preprocessor directive (#if/#endif, #region, + /// #pragma, #nullable). Splitting could route a member out of the directive's scope and + /// orphan it in the other half; retaining the original layout keeps directives balanced. + /// + ClassBodyHasDirectives, +} diff --git a/src/Razor/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/IntegrationTests/RazorIntegrationTestBase.cs b/src/Razor/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/IntegrationTests/RazorIntegrationTestBase.cs index 4bbd5802fc750..151e6379767ff 100644 --- a/src/Razor/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/IntegrationTests/RazorIntegrationTestBase.cs +++ b/src/Razor/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/IntegrationTests/RazorIntegrationTestBase.cs @@ -207,6 +207,29 @@ protected CompileToCSharpResult CompileToCSharp(string cshtmlContent, params Dia expectedCSharpDiagnostics: expectedCSharpDiagnostics); } + // Runs the engine's phases over a component built from , stopping + // immediately before the first phase of type . Lets a test observe + // intermediate pipeline state -- e.g. that the decl document already exists before a later phase runs. + private protected RazorCodeDocument ProcessComponentUpToPhase(string content) + where TStopBefore : IRazorEnginePhase + { + var projectEngine = CreateProjectEngine(Configuration, Array.Empty(), supportLocalizedComponentNames: false, csharpParseOptions: null); + var projectItem = CreateProjectItem("TestComponent.razor", content, RazorFileKind.Component); + var codeDocument = projectEngine.CreateCodeDocument(projectItem); + + foreach (var phase in projectEngine.Engine.Phases) + { + if (phase is TStopBefore) + { + break; + } + + codeDocument = phase.Execute(codeDocument); + } + + return codeDocument; + } + protected CompileToCSharpResult CompileToCSharp( string cshtmlContent, string? cssScope = null,