From 54567a23dd29dc438239d78b2dde869850d65749 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:00:51 +0000 Subject: [PATCH 1/3] Initial plan From e0859c9e3e9f001fa7f464908d05a68e7a4c7b41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:34:32 +0000 Subject: [PATCH 2/3] Extract MergeSourceSpans helper and fix duplicated condition branches Agent-Logs-Url: https://github.com/dotnet/razor/sessions/12930d00-85fa-458f-8e7b-720aa26f3454 Co-authored-by: chsienki <16246502+chsienki@users.noreply.github.com> --- ...olutionPhase.ComponentTagHelperResolver.cs | 10 +---- ...ResolutionPhase.LegacyTagHelperResolver.cs | 39 ++++-------------- .../DefaultTagHelperResolutionPhase.cs | 41 ++++++++++--------- 3 files changed, 31 insertions(+), 59 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.ComponentTagHelperResolver.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.ComponentTagHelperResolver.cs index 9b0007092b3..cc617ff2dfb 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.ComponentTagHelperResolver.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.ComponentTagHelperResolver.cs @@ -580,15 +580,7 @@ private static void MergeAdjacentCSharpTokens(IntermediateNode node) SourceSpan? mergedSpan = null; if (firstSpan is { } first && lastSpan is { } last) { - var endAbsolute = last.AbsoluteIndex + last.Length; - mergedSpan = new SourceSpan( - first.FilePath, - first.AbsoluteIndex, - first.LineIndex, - first.CharacterIndex, - endAbsolute - first.AbsoluteIndex, - last.LineIndex - first.LineIndex + 1, - last.EndCharacterIndex); + mergedSpan = MergeSourceSpans(first, last); } var content = sb.ToString(); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.LegacyTagHelperResolver.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.LegacyTagHelperResolver.cs index 742bb3ce48a..2c738725138 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.LegacyTagHelperResolver.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.LegacyTagHelperResolver.cs @@ -509,19 +509,14 @@ CSharpExpressionIntermediateNode or targetNode.Children.Add(new CSharpIntermediateToken(content, source)); } } - else if (isBoundStringProperty && hasDynamicContent) - { - // Bound string property with dynamic content (expressions/code blocks): - // Unwrap attribute value nodes to content nodes for BeginWriteTagHelperAttribute pattern. - UnwrapValueChildrenToTokens(targetNode, htmlAttr); - } else if (!isBoundStringProperty && hasDynamicContent) { ConvertDynamicNonStringValueChildren(targetNode, htmlAttr, sourceDocument); } else { - // Complex/dynamic value - unwrap attribute value nodes to content nodes. + // Bound string property with dynamic content, or complex/non-dynamic fallback: + // unwrap attribute value nodes to content nodes for BeginWriteTagHelperAttribute pattern. UnwrapValueChildrenToTokens(targetNode, htmlAttr); } @@ -772,21 +767,12 @@ private static void ConvertPureCSharpExpressionValue(IntermediateNode targetNode attrSource.FilePath, closeParenAbsIndex, attrSource.LineIndex, closeParenCharIndex, 1, 0, closeParenCharIndex + 1); targetNode.Children.Add(new CSharpIntermediateToken(")", closeParenSource)); + return; } - else - { - UnwrapValueChildrenToTokens(targetNode, htmlAttr); - } - } - else - { - UnwrapValueChildrenToTokens(targetNode, htmlAttr); } } - else - { - UnwrapValueChildrenToTokens(targetNode, htmlAttr); - } + + UnwrapValueChildrenToTokens(targetNode, htmlAttr); } private static void UnwrapValueChildrenToTokens(IntermediateNode targetNode, HtmlAttributeIntermediateNode htmlAttr) @@ -1148,14 +1134,7 @@ private static void MergeAdjacentHtmlContent(IntermediateNode parent, int index, current.Children.AddRange(next.Children); if (current.Source is SourceSpan currentSource && next.Source is SourceSpan nextSource) { - current.Source = new SourceSpan( - currentSource.FilePath, - currentSource.AbsoluteIndex, - currentSource.LineIndex, - currentSource.CharacterIndex, - (nextSource.AbsoluteIndex + nextSource.Length) - currentSource.AbsoluteIndex, - nextSource.LineCount, - nextSource.EndCharacterIndex); + current.Source = MergeSourceSpans(currentSource, nextSource); } else if (current.Source == null) { @@ -1383,8 +1362,7 @@ private static void LowerImplicitExpressionAttribute_Legacy( var mergedContent = sb.ToString(); var tokenSpan = firstSpan is { } f && lastSpan is { } l - ? new SourceSpan(f.FilePath, f.AbsoluteIndex, f.LineIndex, f.CharacterIndex, - (l.AbsoluteIndex + l.Length) - f.AbsoluteIndex, l.LineIndex - f.LineIndex, l.EndCharacterIndex) + ? MergeSourceSpans(f, l) : firstSpan; expr.Children.Add(new CSharpIntermediateToken( LazyContent.Create(mergedContent, static s => s), tokenSpan)); @@ -1606,8 +1584,7 @@ private static void FlushPendingLiterals( if (pendingFirstSpan is { } f && pendingLastSpan is { } l) { - htmlContent.Source = new SourceSpan(f.FilePath, f.AbsoluteIndex, f.LineIndex, f.CharacterIndex, - (l.AbsoluteIndex + l.Length) - f.AbsoluteIndex, l.LineIndex - f.LineIndex + 1, l.EndCharacterIndex); + htmlContent.Source = MergeSourceSpans(f, l); } target.Children.Add(htmlContent); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs index 74daa974dad..3b05d30f006 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs @@ -392,10 +392,7 @@ private static void MergeAdjacentHtmlContent(IntermediateNode parent) if (current.Source is SourceSpan cs && next.Source is SourceSpan ns) { // Adjacent nodes are sequential, so next always ends after current. - var end = ns.AbsoluteIndex + ns.Length; - var lineCount = (ns.LineIndex + ns.LineCount) - cs.LineIndex; - current.Source = new SourceSpan(cs.FilePath, cs.AbsoluteIndex, cs.LineIndex, cs.CharacterIndex, - end - cs.AbsoluteIndex, lineCount, ns.EndCharacterIndex); + current.Source = MergeSourceSpans(cs, ns); } else if (current.Source == null) { @@ -545,8 +542,7 @@ private static void ConvertUnresolvedValuesToBasicForm(HtmlAttributeIntermediate var lastSrc = htmlContent.Children[^1].Source; if (firstSrc is { } fs && lastSrc is { } ls) { - htmlContent.Source = new SourceSpan(fs.FilePath, fs.AbsoluteIndex, fs.LineIndex, fs.CharacterIndex, - (ls.AbsoluteIndex + ls.Length) - fs.AbsoluteIndex, ls.LineIndex - fs.LineIndex, ls.EndCharacterIndex); + htmlContent.Source = MergeSourceSpans(fs, ls); } } @@ -743,20 +739,11 @@ private static AttributeStructure InferAttributeStructure(HtmlAttributeIntermedi SourceSpan? result = null; foreach (var child in htmlAttr.Children) { - // For HtmlAttributeValueIntermediateNode, use the inner token sources (not the wrapper). - if (child is HtmlAttributeValueIntermediateNode attrValue) + // For HtmlAttributeValueIntermediateNode and CSharpExpressionAttributeValueIntermediateNode, + // use the inner token sources (not the wrapper). + if (child is HtmlAttributeValueIntermediateNode or CSharpExpressionAttributeValueIntermediateNode) { - foreach (var token in attrValue.Children) - { - if (token.Source is SourceSpan tokenSource) - { - result = result == null ? tokenSource : MergeSpans(result.Value, tokenSource); - } - } - } - else if (child is CSharpExpressionAttributeValueIntermediateNode csharpAttrValue) - { - foreach (var token in csharpAttrValue.Children) + foreach (var token in child.Children) { if (token.Source is SourceSpan tokenSource) { @@ -783,6 +770,22 @@ private static SourceSpan MergeSpans(SourceSpan a, SourceSpan b) end - start, lineCount, last.EndCharacterIndex); } + /// + /// Merges two already-ordered source spans into a single span covering both. + /// must start at or before . + /// + private static SourceSpan MergeSourceSpans(SourceSpan first, SourceSpan last) + { + return new SourceSpan( + first.FilePath, + first.AbsoluteIndex, + first.LineIndex, + first.CharacterIndex, + last.AbsoluteIndex + last.Length - first.AbsoluteIndex, + last.LineIndex + last.LineCount - first.LineIndex, + last.EndCharacterIndex); + } + /// /// Splits an explicit expression @(expr) at the given source position into /// structured children: @, (, From a5505e3d28de09606b2ba303cbee8b126eae8142 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:27:02 +0000 Subject: [PATCH 3/3] Add Debug.Assert and unit tests for MergeSourceSpans Agent-Logs-Url: https://github.com/dotnet/razor/sessions/25acbb13-48b6-4614-a78d-69886fbbc90b Co-authored-by: chsienki <16246502+chsienki@users.noreply.github.com> --- .../DefaultTagHelperResolutionPhaseTest.cs | 107 ++++++++++++++++++ .../DefaultTagHelperResolutionPhase.cs | 5 +- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultTagHelperResolutionPhaseTest.cs diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultTagHelperResolutionPhaseTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultTagHelperResolutionPhaseTest.cs new file mode 100644 index 00000000000..2e80286a537 --- /dev/null +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultTagHelperResolutionPhaseTest.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Language; + +public class DefaultTagHelperResolutionPhaseTest +{ + [Fact] + public void MergeSourceSpans_SameLine_ReturnsCorrectSpan() + { + // Arrange + var filePath = "test.razor"; + var first = new SourceSpan(filePath, absoluteIndex: 10, lineIndex: 2, characterIndex: 5, length: 3, lineCount: 0, endCharacterIndex: 8); + var last = new SourceSpan(filePath, absoluteIndex: 15, lineIndex: 2, characterIndex: 10, length: 4, lineCount: 0, endCharacterIndex: 14); + + // Act + var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last); + + // Assert + Assert.Equal(filePath, result.FilePath); + Assert.Equal(10, result.AbsoluteIndex); // starts at first + Assert.Equal(2, result.LineIndex); // same line as first + Assert.Equal(5, result.CharacterIndex); // same column as first + Assert.Equal(9, result.Length); // (15 + 4) - 10 = 9 + Assert.Equal(0, result.LineCount); // 2 + 0 - 2 = 0 (same line) + Assert.Equal(14, result.EndCharacterIndex); // taken from last + } + + [Fact] + public void MergeSourceSpans_MultiLine_ReturnsCorrectSpan() + { + // Arrange + var filePath = "test.razor"; + // first spans lines 1-2 (lineCount = 1 means it crosses into the next line) + var first = new SourceSpan(filePath, absoluteIndex: 0, lineIndex: 1, characterIndex: 0, length: 10, lineCount: 1, endCharacterIndex: 5); + // last is on line 3 (lineIndex = 3) + var last = new SourceSpan(filePath, absoluteIndex: 20, lineIndex: 3, characterIndex: 2, length: 5, lineCount: 0, endCharacterIndex: 7); + + // Act + var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last); + + // Assert + Assert.Equal(filePath, result.FilePath); + Assert.Equal(0, result.AbsoluteIndex); // starts at first + Assert.Equal(1, result.LineIndex); // line of first + Assert.Equal(0, result.CharacterIndex); // column of first + Assert.Equal(25, result.Length); // (20 + 5) - 0 = 25 + Assert.Equal(2, result.LineCount); // (3 + 0) - 1 = 2 + Assert.Equal(7, result.EndCharacterIndex); // end column from last + } + + [Fact] + public void MergeSourceSpans_AdjacentSpans_ReturnsCorrectSpan() + { + // Arrange + var filePath = "test.razor"; + var first = new SourceSpan(filePath, absoluteIndex: 5, lineIndex: 0, characterIndex: 5, length: 3, lineCount: 0, endCharacterIndex: 8); + // last starts right where first ends + var last = new SourceSpan(filePath, absoluteIndex: 8, lineIndex: 0, characterIndex: 8, length: 4, lineCount: 0, endCharacterIndex: 12); + + // Act + var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last); + + // Assert + Assert.Equal(5, result.AbsoluteIndex); + Assert.Equal(7, result.Length); // (8 + 4) - 5 = 7 + Assert.Equal(0, result.LineCount); + Assert.Equal(12, result.EndCharacterIndex); + } + + [Fact] + public void MergeSourceSpans_SameSpan_ReturnsEquivalentSpan() + { + // Arrange + var filePath = "test.razor"; + var span = new SourceSpan(filePath, absoluteIndex: 10, lineIndex: 1, characterIndex: 3, length: 5, lineCount: 0, endCharacterIndex: 8); + + // Act — first and last are the same span + var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(span, span); + + // Assert + Assert.Equal(10, result.AbsoluteIndex); + Assert.Equal(1, result.LineIndex); + Assert.Equal(3, result.CharacterIndex); + Assert.Equal(5, result.Length); // (10 + 5) - 10 = 5 + Assert.Equal(0, result.LineCount); // 1 + 0 - 1 = 0 + Assert.Equal(8, result.EndCharacterIndex); + } + + [Fact] + public void MergeSourceSpans_NullFilePath_PreservesNullFilePath() + { + // Arrange — file path is null (e.g. for in-memory content) + var first = new SourceSpan(filePath: null, absoluteIndex: 0, lineIndex: 0, characterIndex: 0, length: 3, lineCount: 0, endCharacterIndex: 3); + var last = new SourceSpan(filePath: null, absoluteIndex: 5, lineIndex: 0, characterIndex: 5, length: 2, lineCount: 0, endCharacterIndex: 7); + + // Act + var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last); + + // Assert + Assert.Null(result.FilePath); + Assert.Equal(0, result.AbsoluteIndex); + Assert.Equal(7, result.Length); // (5 + 2) - 0 = 7 + } +} diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs index 3b05d30f006..7311b070695 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.Linq; using System.Threading; using Microsoft.AspNetCore.Razor.Language.Components; @@ -774,8 +775,10 @@ private static SourceSpan MergeSpans(SourceSpan a, SourceSpan b) /// Merges two already-ordered source spans into a single span covering both. /// must start at or before . /// - private static SourceSpan MergeSourceSpans(SourceSpan first, SourceSpan last) + internal static SourceSpan MergeSourceSpans(SourceSpan first, SourceSpan last) { + Debug.Assert(first.AbsoluteIndex <= last.AbsoluteIndex, + "first span must start at or before the last span"); return new SourceSpan( first.FilePath, first.AbsoluteIndex,