Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using Xunit;

namespace Microsoft.AspNetCore.Razor.Language.Intermediate;

// Verifies that IntermediateNode.Clone() produces a faithful deep copy -- every field, child, and
// deep-cloned side reference. The dump used for the comparison reflects over every property rather than
// reusing FormatNode (which writes only the handful of properties chosen for display), so a dropped field
// is caught.
public class IntermediateNodeCloneTest
{
private readonly RazorProjectEngine _projectEngine = RazorProjectEngine.Create(
RazorConfiguration.Default,
RazorProjectFileSystem.Create(AppContext.BaseDirectory));

[Fact]
public void Clone_Component_ProducesFaithfulDeepCopy()
{
var source = """
@page "/counter"
@using System.Text
@inject System.IServiceProvider Services
@typeparam TItem

<h1 class="title" data-x="@Value">Hello @Name</h1>
<section>
<p>Line1</p>
<MyChild Title="@Value" @onclick="OnClick">child body</MyChild>
</section>

@code {
[Parameter] public string Name { get; set; }
private int Value = 1;
private void OnClick() { Value++; }
}
""";

// The tree contains at least one aliased child (a tag helper's unbound HTML attribute), so the
// clone's alias preservation is actually exercised rather than vacuously passing.
var aliasCount = AssertClone(source, RazorFileKind.Component);
Assert.True(aliasCount > 0);
}

[Fact]
public void Clone_LegacyView_ProducesFaithfulDeepCopy()
{
var source = """
@using System.Text
<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<div class="c">@DateTime.Now</div>
@{ var x = 1; }
<p>value is @x</p>
</body>
</html>
""";

// The tree contains at least one aliased child (a tag helper's unbound HTML attribute), so the
// clone's alias preservation is actually exercised rather than vacuously passing.
var aliasCount = AssertClone(source, RazorFileKind.Legacy);
Assert.True(aliasCount > 0);
}

[Fact]
public void Clone_EdgeConstructs_ProducesFaithfulDeepCopy()
{
// Malformed directives and markup-element fallback containers are lowered node kinds that the other
// documents don't produce; a malformed @addTagHelper yields a MalformedDirectiveIntermediateNode and
// the mixed literal/expression attribute value yields a MarkupElementIntermediateNode fallback.
var source = """
@addTagHelper *
<MyTag data-a="x @DateTime.Now y" class="c">body</MyTag>
""";

var kinds = CollectKinds(Lower(source, RazorFileKind.Legacy));
Assert.Contains(nameof(MalformedDirectiveIntermediateNode), kinds);
Assert.Contains(nameof(MarkupElementIntermediateNode), kinds);

AssertClone(source, RazorFileKind.Legacy);
}

private int AssertClone(string content, RazorFileKind fileKind)
{
var documentNode = Lower(content, fileKind);

var clone = (DocumentIntermediateNode)documentNode.Clone();

Assert.Equal(Dump(documentNode), Dump(clone));

// The declaration subtree, options, and code target are shared by reference on purpose.
Assert.Same(documentNode.DeclDocumentNode, clone.DeclDocumentNode);
Assert.Same(documentNode.Options, clone.Options);
Assert.Same(documentNode.Target, clone.Target);

return AssertAliasesPreserved(documentNode, clone);
}

// A node-typed property that holds one of the node's own Children is an alias (e.g.
// UnresolvedAttributeIntermediateNode.HtmlAttributeNode == Children[^1]). Cloning must preserve that
// aliasing: the cloned property has to point at the cloned child, not an independent copy. Otherwise the
// clone carries two divergent instances and a phase that mutates one while walking the other sees stale
// state. Walk the original and clone trees in lockstep and assert every alias is preserved by reference.
// Returns the number of aliases verified so a test can assert the tree actually exercised one.
private static int AssertAliasesPreserved(IntermediateNode original, IntermediateNode clone)
{
Assert.Equal(original.GetType(), clone.GetType());
Assert.Equal(original.Children.Count, clone.Children.Count);

var aliasCount = 0;

foreach (var (name, node) in NodeProperties(original))
{
var index = IndexOfReference(original.Children, node);
if (index < 0)
{
continue;
}

Assert.Same(clone.Children[index], GetPropertyValue(clone, name));
aliasCount++;
}

for (var i = 0; i < original.Children.Count; i++)
{
aliasCount += AssertAliasesPreserved(original.Children[i], clone.Children[i]);
}

return aliasCount;
}

// Enumerates the node-typed properties of a node, using the same reflection/exclusion rules as the dump.
private static IEnumerable<(string Name, IntermediateNode Node)> NodeProperties(IntermediateNode node)
{
foreach (var property in node.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.OrderBy(p => p.Name, StringComparer.Ordinal))
{
if (property.Name is "Children" or "Parent" or "DeclDocumentNode")
{
continue;
}

object? value;
try
{
value = property.GetValue(node);
}
catch
{
continue;
}

if (value is IntermediateNode childNode)
{
yield return (property.Name, childNode);
}
}
}

private static IntermediateNode GetPropertyValue(IntermediateNode node, string name)
=> (IntermediateNode)node.GetType()
.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(node)!;

private static int IndexOfReference(IntermediateNodeCollection children, IntermediateNode node)
{
for (var i = 0; i < children.Count; i++)
{
if (ReferenceEquals(children[i], node))
{
return i;
}
}

return -1;
}

// Collects the distinct node-kind names present in a tree, walking node-typed side references and
// Children, so a test can assert a document actually exercises a given kind.
private static HashSet<string> CollectKinds(IntermediateNode root)
{
var kinds = new HashSet<string>();
Collect(root);
return kinds;

void Collect(IntermediateNode node)
{
kinds.Add(node.GetType().Name);

foreach (var (_, child) in NodeProperties(node))
{
Collect(child);
}

foreach (var child in node.Children)
{
Collect(child);
}
}
}

// Runs the engine phases up to (but not including) tag-helper discovery, producing the lowered but
// still unresolved intermediate tree.
private DocumentIntermediateNode Lower(string content, RazorFileKind fileKind)
{
var source = RazorSourceDocument.Create(content, "test.razor");
var codeDocument = _projectEngine.CreateCodeDocument(source, fileKind);

foreach (var phase in _projectEngine.Engine.Phases)
{
if (phase is DefaultRazorTagHelperContextDiscoveryPhase)
{
break;
}

codeDocument = phase.Execute(codeDocument);
}

return codeDocument.GetRequiredDocumentNode();
}

// Serializes the full state of a node tree: every readable data property, then each deep-cloned side
// reference, then Children. DeclDocumentNode is shared by reference (asserted separately), so it is not
// recursed.
private static string Dump(IntermediateNode root)
{
var builder = new StringBuilder();
DumpNode(root, builder, depth: 0, activePath: new HashSet<IntermediateNode>());
return builder.ToString();
}

// `activePath` tracks the current recursion stack so a true cycle is broken, while a node reachable from
// two places (incidental sharing) is still dumped fully at each site. That keeps the dump symmetric
// between the original (which may share an instance) and its clone (which deep-copies each reference).
private static void DumpNode(IntermediateNode node, StringBuilder builder, int depth, HashSet<IntermediateNode> activePath)
{
if (!activePath.Add(node))
{
builder.Append(' ', depth * 2).AppendLine("<cycle>");
return;
}

builder.Append(' ', depth * 2).Append(node.GetType().Name);

var sideReferences = new List<(string Name, IntermediateNode Node)>();

foreach (var property in node.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.OrderBy(p => p.Name, StringComparer.Ordinal))
{
if (property.Name is "Children" or "Parent" or "DeclDocumentNode" or "IsLazy")
{
// IsLazy is a content-storage detail: cloning a token materializes its lazy content into an
// eager string, so IsLazy legitimately differs while Content is identical.
continue;
}

object? value;
try
{
value = property.GetValue(node);
}
catch
{
continue;
}

if (value is IntermediateNode childNode)
{
sideReferences.Add((property.Name, childNode));
}
else
{
builder.Append(' ').Append(property.Name).Append('=').Append(Format(value));
}
}

builder.AppendLine();

foreach (var (name, child) in sideReferences)
{
builder.Append(' ', (depth + 1) * 2).Append('.').Append(name).Append(':').AppendLine();
DumpNode(child, builder, depth + 2, activePath);
}

foreach (var child in node.Children)
{
DumpNode(child, builder, depth + 1, activePath);
}

activePath.Remove(node);
}

private static string Format(object? value)
{
switch (value)
{
case null:
return "null";
case string s:
return "\"" + s + "\"";
case IEnumerable enumerable:
var items = enumerable.Cast<object>().Select(Format);
return "[" + string.Join(", ", items) + "]";
default:
return value.ToString() ?? "null";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ public override void Accept(IntermediateNodeVisitor visitor)
AcceptExtensionNode(this, visitor);
}

protected override IntermediateNode CloneNode()
{
var clone = new ComponentInjectIntermediateNode(TypeName, MemberName, TypeSpan, MemberSpan)
{
IsSynthesizedHelper = IsSynthesizedHelper,
};

return clone;
}

public override void WriteNode(CodeTarget target, CodeRenderingContext context)
{
if (target == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ internal sealed class RouteAttributeExtensionNode(string template) : ExtensionIn

public override void Accept(IntermediateNodeVisitor visitor) => AcceptExtensionNode(this, visitor);

protected override IntermediateNode CloneNode()
{
var clone = new RouteAttributeExtensionNode(Template)
{
IsSynthesizedHelper = IsSynthesizedHelper,
};

return clone;
}

public override void WriteNode(CodeTarget target, CodeRenderingContext context)
{
context.CodeWriter.Write("[global::");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ public override void Accept(IntermediateNodeVisitor visitor)
AcceptExtensionNode(this, visitor);
}

protected override IntermediateNode CloneNode()
{
var clone = new RazorCompiledItemMetadataAttributeIntermediateNode
{
Key = Key,
Value = Value,
ValueStringSyntax = ValueStringSyntax,
IsSynthesizedHelper = IsSynthesizedHelper,
};

return clone;
}

public override void WriteNode(CodeTarget target, CodeRenderingContext context)
{
if (target == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ public override void Accept(IntermediateNodeVisitor visitor)
AcceptExtensionNode<SectionIntermediateNode>(this, visitor);
}

protected override IntermediateNode CloneNode()
{
var clone = new SectionIntermediateNode
{
SectionName = SectionName,
IsSynthesizedHelper = IsSynthesizedHelper,
};

return clone;
}

public override void WriteNode(CodeTarget target, CodeRenderingContext context)
{
if (target == null)
Expand Down
Loading
Loading