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.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..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;
@@ -392,10 +393,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 +543,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 +740,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 +771,24 @@ 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 .
+ ///
+ 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,
+ 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: @, (,