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
40 changes: 40 additions & 0 deletions Parsing/Visitors/AstNodeCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Reflection;

namespace SharpTS.Parsing.Visitors;

/// <summary>
/// The single canonical list of concrete AST node types, derived once from the <see cref="Expr"/>
/// and <see cref="Stmt"/> record definitions in <c>Parsing/AST.cs</c>.
/// </summary>
/// <remarks>
/// <para>
/// This is the convergence point for the AST dispatch tables (issue #1243, epic #1094). Every
/// consumer that needs "the set of AST node kinds" reads it from here instead of re-deriving it:
/// <see cref="NodeRegistry{TContext, TExprResult, TStmtResult}"/> uses it for exhaustiveness
/// checks (<c>Freeze</c>/<c>FreezeAsync</c>) and handler auto-registration (<c>AutoRegister</c>),
/// and the visitor-dispatch guard test validates <see cref="AstVisitorBase"/>'s switch against it.
/// A new node added to <c>AST.cs</c> automatically appears here, so it cannot be silently omitted
/// from a dispatch table without a loud failure (a startup exception from the registry, or a
/// failing guard test for the visitor switch).
/// </para>
/// <para>
/// "Node type" is defined exactly as the dispatch tables historically defined it: a directly
/// nested type of <see cref="Expr"/>/<see cref="Stmt"/> that is concrete and inherits from the
/// base. This excludes helper records nested under the bases that are not themselves dispatched
/// nodes — <c>Expr.PropertyKey</c>/<c>Property</c>, <c>Stmt.Parameter</c>, the interface-member
/// records, and so on.
/// </para>
/// </remarks>
public static class AstNodeCatalog
{
/// <summary>Every concrete <see cref="Expr"/> node type, in reflection order.</summary>
public static readonly IReadOnlyList<Type> ExprTypes = CollectConcreteNodes(typeof(Expr));

/// <summary>Every concrete <see cref="Stmt"/> node type, in reflection order.</summary>
public static readonly IReadOnlyList<Type> StmtTypes = CollectConcreteNodes(typeof(Stmt));

private static IReadOnlyList<Type> CollectConcreteNodes(Type baseType) =>
baseType.GetNestedTypes(BindingFlags.Public)
.Where(t => baseType.IsAssignableFrom(t) && !t.IsAbstract && t != baseType)
.ToArray();
}
4 changes: 3 additions & 1 deletion Parsing/Visitors/AstVisitorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ public virtual void Visit(Expr expr)
// Fail loud on an unhandled node rather than silently skipping its subtree. A newly
// added Expr kind that nobody wired here would otherwise be invisible to every analyzer
// built on AstVisitorBase (closure/suspension/variable analysis) — a latent miscompile,
// not just missing coverage (#1107).
// not just missing coverage (#1107). AstDispatchTests validates this switch against the
// canonical AstNodeCatalog so an omission fails a test, not just at visit time (#1243).
default:
throw new NotSupportedException(
$"AstVisitorBase has no dispatch case for expression node '{expr.GetType().Name}'. " +
Expand Down Expand Up @@ -130,6 +131,7 @@ public virtual void Visit(Stmt stmt)
case Stmt.DeclareGlobal s: VisitDeclareGlobal(s); break;
case Stmt.Using s: VisitUsing(s); break;
// Fail loud on an unhandled node rather than silently skipping its subtree (#1107).
// AstDispatchTests validates this switch against the canonical AstNodeCatalog (#1243).
default:
throw new NotSupportedException(
$"AstVisitorBase has no dispatch case for statement node '{stmt.GetType().Name}'. " +
Expand Down
36 changes: 8 additions & 28 deletions Parsing/Visitors/NodeRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,25 +227,17 @@ public NodeRegistry<TContext, TExprResult, TStmtResult> Freeze()

var missingTypes = new List<string>();

// Get all concrete Expr types (nested types in Expr that inherit from Expr)
var allExprTypes = typeof(Expr).GetNestedTypes(BindingFlags.Public)
.Where(t => typeof(Expr).IsAssignableFrom(t) && !t.IsAbstract && t != typeof(Expr))
.ToList();

foreach (var exprType in allExprTypes)
// The canonical node lists come from AstNodeCatalog so this check, AutoRegister, and the
// AstVisitorBase dispatch switch all agree on the node set by construction (#1243).
foreach (var exprType in AstNodeCatalog.ExprTypes)
{
if (!_exprHandlers.ContainsKey(exprType))
{
missingTypes.Add($"Expr.{exprType.Name}");
}
}

// Get all concrete Stmt types (nested types in Stmt that inherit from Stmt)
var allStmtTypes = typeof(Stmt).GetNestedTypes(BindingFlags.Public)
.Where(t => typeof(Stmt).IsAssignableFrom(t) && !t.IsAbstract && t != typeof(Stmt))
.ToList();

foreach (var stmtType in allStmtTypes)
foreach (var stmtType in AstNodeCatalog.StmtTypes)
{
if (!_stmtHandlers.ContainsKey(stmtType))
{
Expand Down Expand Up @@ -282,11 +274,7 @@ public NodeRegistry<TContext, TExprResult, TStmtResult> FreezeAsync()

var missingAsyncTypes = new List<string>();

var allExprTypes = typeof(Expr).GetNestedTypes(BindingFlags.Public)
.Where(t => typeof(Expr).IsAssignableFrom(t) && !t.IsAbstract && t != typeof(Expr))
.ToList();

foreach (var exprType in allExprTypes)
foreach (var exprType in AstNodeCatalog.ExprTypes)
{
if (!_asyncExprHandlers.ContainsKey(exprType))
{
Expand Down Expand Up @@ -351,12 +339,8 @@ public NodeRegistry<TContext, TExprResult, TStmtResult> AutoRegister()
var contextType = typeof(TContext);
var missingMethods = new List<string>();

// Register all expression types
var allExprTypes = typeof(Expr).GetNestedTypes(BindingFlags.Public)
.Where(t => typeof(Expr).IsAssignableFrom(t) && !t.IsAbstract && t != typeof(Expr))
.ToList();

foreach (var exprType in allExprTypes)
// Register all expression types (node list from AstNodeCatalog — see Freeze). #1243
foreach (var exprType in AstNodeCatalog.ExprTypes)
{
var methodName = $"Visit{exprType.Name}";
var method = contextType.GetMethod(methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, [exprType]);
Expand All @@ -372,11 +356,7 @@ public NodeRegistry<TContext, TExprResult, TStmtResult> AutoRegister()
}

// Register all statement types
var allStmtTypes = typeof(Stmt).GetNestedTypes(BindingFlags.Public)
.Where(t => typeof(Stmt).IsAssignableFrom(t) && !t.IsAbstract && t != typeof(Stmt))
.ToList();

foreach (var stmtType in allStmtTypes)
foreach (var stmtType in AstNodeCatalog.StmtTypes)
{
var methodName = $"Visit{stmtType.Name}";
var method = contextType.GetMethod(methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, [stmtType]);
Expand Down
111 changes: 111 additions & 0 deletions SharpTS.Tests/RegistryTests/AstDispatchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Runtime.CompilerServices;
using SharpTS.Parsing;
using SharpTS.Parsing.Visitors;
using Xunit;

namespace SharpTS.Tests.RegistryTests;

/// <summary>
/// Guards the convergence of the AST dispatch tables onto <see cref="AstNodeCatalog"/> (issue #1243,
/// epic #1094). The catalog is the single source of truth for the AST node set; these tests ensure
/// the hand-maintained <see cref="AstVisitorBase"/> dispatch switch cannot silently omit a node the
/// catalog knows about. <see cref="NodeRegistry{TContext, TExprResult, TStmtResult}"/>'s own
/// exhaustiveness (interpreter + type-checker handlers) is already enforced at startup via the same
/// catalog, so its static initializer running under any test transitively covers that table.
/// </summary>
public class AstDispatchTests
{
/// <summary>A concrete visitor that keeps AstVisitorBase's default child-traversal behavior.</summary>
private sealed class ProbeVisitor : AstVisitorBase;

// Expr/Stmt subtypes deliberately absent from the catalog, used to prove the dispatch switch's
// default arm actually throws for an unhandled node — i.e. that the probe below distinguishes
// "handled" from "unhandled" rather than passing vacuously.
private sealed record UnknownExpr : Expr;
private sealed record UnknownStmt : Stmt;

[Fact]
public void Catalog_is_populated_and_excludes_non_node_records()
{
Assert.NotEmpty(AstNodeCatalog.ExprTypes);
Assert.NotEmpty(AstNodeCatalog.StmtTypes);

// Representative nodes are present.
Assert.Contains(typeof(Expr.Binary), AstNodeCatalog.ExprTypes);
Assert.Contains(typeof(Stmt.If), AstNodeCatalog.StmtTypes);

// Every entry is a concrete subtype of its base.
Assert.All(AstNodeCatalog.ExprTypes, t =>
{
Assert.True(typeof(Expr).IsAssignableFrom(t));
Assert.False(t.IsAbstract);
});
Assert.All(AstNodeCatalog.StmtTypes, t =>
{
Assert.True(typeof(Stmt).IsAssignableFrom(t));
Assert.False(t.IsAbstract);
});

// Helper records nested under the bases that are not dispatched nodes stay out.
Assert.DoesNotContain(typeof(Expr.Property), AstNodeCatalog.ExprTypes);
Assert.DoesNotContain(typeof(Stmt.Parameter), AstNodeCatalog.StmtTypes);
}

[Fact]
public void AstVisitorBase_dispatches_every_Expr_node()
{
foreach (var type in AstNodeCatalog.ExprTypes)
{
var node = (Expr)RuntimeHelpers.GetUninitializedObject(type);
Assert.False(
HitsDefaultArm(() => new ProbeVisitor().Visit(node)),
$"AstVisitorBase.Visit(Expr) has no dispatch case for Expr.{type.Name}; " +
$"add a case that calls a Visit{type.Name} method.");
}
}

[Fact]
public void AstVisitorBase_dispatches_every_Stmt_node()
{
foreach (var type in AstNodeCatalog.StmtTypes)
{
var node = (Stmt)RuntimeHelpers.GetUninitializedObject(type);
Assert.False(
HitsDefaultArm(() => new ProbeVisitor().Visit(node)),
$"AstVisitorBase.Visit(Stmt) has no dispatch case for Stmt.{type.Name}; " +
$"add a case that calls a Visit{type.Name} method.");
}
}

[Fact]
public void Probe_detects_an_unhandled_node()
{
// Sanity check on the discriminator itself: an out-of-catalog node DOES hit the default arm.
Assert.True(HitsDefaultArm(() => new ProbeVisitor().Visit(new UnknownExpr())));
Assert.True(HitsDefaultArm(() => new ProbeVisitor().Visit(new UnknownStmt())));
}

/// <summary>
/// Runs <paramref name="visit"/> against an uninitialized node and reports whether dispatch fell
/// through to <see cref="AstVisitorBase"/>'s default arm. That arm is the ONLY source of
/// <see cref="NotSupportedException"/> in the visitor: a handled node either returns cleanly (a
/// leaf) or throws <see cref="NullReferenceException"/> as its traversal walks the uninitialized
/// node's null children — neither of which is a NotSupportedException.
/// </summary>
private static bool HitsDefaultArm(Action visit)
{
try
{
visit();
return false;
}
catch (NotSupportedException)
{
return true;
}
catch
{
return false;
}
}
}
Loading