From 3382b9c426f816aab336711767c9155cb3903896 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 02:10:06 +0000 Subject: [PATCH] Let a construction hold a designated initialiser in the graph (closes #52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConstructionExpression.Arguments` was declared `AstSlotKind.Expression`, and a `MemberInitialiser` is not an expression: it names the member a value is for rather than evaluating to one. So `Accepts` refused it, and the handling two cases further down — deliberately written with no `when child is Expression` guard, unlike its five neighbours — was unreachable. The AST, the serializer and all seven generators handle one in exactly this position. The graph is meant to be the uniform view of the AST, and here it could not express a shape every other projection could. ## A kind rather than a widening The slot is shared with `CallExpression.Arguments`, and the two should not be widened together: every generator reads `construction.Arguments` for a member initialiser and **none** reads a call's. One attached to a call would be written as whatever fell out rather than as a named argument. So `AstSlotKind.Element` is its own kind and a construction gets its own slot, with the same name — the pin reads the same and the attach cases switch on the name — and a different rule. The kinds part exactly where the generators do, and a test pins both halves: a construction takes one, a call refuses it. The graph-level test goes through the whole chain rather than the schema's answer alone, because `ValidateConnection` is where the editor felt this. Both tests were checked against the bug: with the `Element` arm reverted, the schema test fails on `Accepts` and the graph test on `result.Success`. 850/850 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- Coder.Graph/AstSchema.cs | 15 ++++++- Coder.Graph/AstSlot.cs | 13 ++++++ Coder.Test/Graph/AstGraphTests.cs | 26 ++++++++++++ Coder.Test/Graph/DeclarationSlotsTests.cs | 50 +++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/Coder.Graph/AstSchema.cs b/Coder.Graph/AstSchema.cs index 887fd30..8fd5f7d 100644 --- a/Coder.Graph/AstSchema.cs +++ b/Coder.Graph/AstSchema.cs @@ -63,6 +63,16 @@ public static class AstSchema private const string Unnamed = ""; private static readonly AstSlot ArgumentsSlot = new(ArgumentsSlotName, AstSlotCardinality.Many, AstSlotKind.Expression); + + /// + /// A construction's arguments, which take a member initialiser where a call's do not. + /// + /// + /// The same name as , because the editor labels the pin the same way + /// and the attach cases switch on the name; a different kind, because what may stand in it + /// differs. + /// + private static readonly AstSlot ElementsSlot = new(ArgumentsSlotName, AstSlotCardinality.Many, AstSlotKind.Element); private static readonly AstSlot EnumMembersSlot = new("Members", AstSlotCardinality.Many, AstSlotKind.EnumMember); private static readonly AstSlot ReceiverSlot = new(ReceiverSlotName, AstSlotCardinality.One, AstSlotKind.Expression); private static readonly AstSlot ConditionSlot = new(ConditionSlotName, AstSlotCardinality.One, AstSlotKind.Expression); @@ -83,7 +93,7 @@ public static class AstSchema FieldDeclaration => [InitialValueSlot], PropertyDeclaration => [GetterSlot, SetterSlot], MemberInitialiser => [ValueSlot], - ConstructionExpression => [ArgumentsSlot], + ConstructionExpression => [ElementsSlot], CallExpression => [ReceiverSlot, ArgumentsSlot], ConditionalExpression => [ConditionSlot, WhenTrueSlot, WhenFalseSlot], ExpressionStatement => [ExpressionSlot], @@ -625,6 +635,9 @@ public static bool Accepts(AstSlot slot, AstNode candidate) { AstSlotKind.Parameter => candidate is Parameter, AstSlotKind.Expression => IsExpression(candidate), + // A member initialiser is an element and not an expression: it names the member a value + // is for rather than evaluating to one. + AstSlotKind.Element => IsExpression(candidate) || candidate is MemberInitialiser, // A parameter is not a statement, and neither is an entry point: a program starts // running at one, so it belongs to a class or to the document rather than inside a body. AstSlotKind.Statement => candidate is not (Parameter or EntryPoint), diff --git a/Coder.Graph/AstSlot.cs b/Coder.Graph/AstSlot.cs index b796ab1..843a51d 100644 --- a/Coder.Graph/AstSlot.cs +++ b/Coder.Graph/AstSlot.cs @@ -36,6 +36,19 @@ public enum AstSlotKind /// Anything that evaluates to a value, including the legacy leaf nodes. Expression, + /// + /// One element of a braced list: an expression, or a + /// naming the member it is for. + /// + /// + /// Separate from because a member initialiser does not evaluate to a + /// value — it says which member a value is for — and separate from a call's arguments because + /// only a construction has a spelling for one. Every generator reads a construction's arguments + /// for a member initialiser and none reads a call's, so the kinds part exactly where the + /// generators do. + /// + Element, + /// Anything that can stand as a statement in a body. Statement, diff --git a/Coder.Test/Graph/AstGraphTests.cs b/Coder.Test/Graph/AstGraphTests.cs index 4e8834c..de0309c 100644 --- a/Coder.Test/Graph/AstGraphTests.cs +++ b/Coder.Test/Graph/AstGraphTests.cs @@ -379,6 +379,32 @@ public void MoveTo_LeavesTheNodeInTheGraphWhenTheSlotRefusesIt() Assert.IsNotNull(graph.Nodes.Values.SingleOrDefault(n => ReferenceEquals(n, returnStmt))); } + /// + /// Tests that a designated initialiser can be wired into a construction from the editor. + /// + /// + /// The whole chain, not just the schema's answer: the editor drags a link, the graph validates + /// it against the slot, and the AST ends up holding the node. Every generator writes one in this + /// position, and until the slot kind existed this was the one shape the graph could hold, the + /// serializer could persist and the editor could not build. + /// + [TestMethod] + public void Connect_PutsAMemberInitialiserIntoAConstruction() + { + ConstructionExpression construction = new(new TypeReference("Point")); + AstGraph graph = new(construction); + + MemberInitialiser initialiser = new("x") { Value = new LiteralExpression(1) }; + graph.AddDetached(initialiser, Vector2.Zero); + + AstConnectResult result = Connect(graph, initialiser, construction, "Arguments", 0); + + Assert.IsTrue(result.Success, result.Message); + Assert.HasCount(1, construction.Arguments); + Assert.AreSame(initialiser, construction.Arguments[0]); + Assert.IsEmpty(graph.Detached); + } + /// /// Connects a node to a named slot of a parent, looking the pins up the way the editor does. /// diff --git a/Coder.Test/Graph/DeclarationSlotsTests.cs b/Coder.Test/Graph/DeclarationSlotsTests.cs index 859bfc4..a254538 100644 --- a/Coder.Test/Graph/DeclarationSlotsTests.cs +++ b/Coder.Test/Graph/DeclarationSlotsTests.cs @@ -135,6 +135,56 @@ public void MemberInitialiserValue_IsFilledAndEmptied() Assert.IsNull(initialiser.Value); } + /// + /// A construction takes a member initialiser among its arguments, which is what a designated + /// initialiser is. + /// + /// + /// The AST, the serializer and all seven generators handle one in this position — it is a + /// designated initialiser in C++, an object initialiser in C#, a keyword argument in Python and + /// an object literal in JavaScript. The graph is the uniform view of the AST, so a shape every + /// other projection can express has to be one it can express too. + /// + [TestMethod] + public void ConstructionArguments_TakeAMemberInitialiser() + { + ConstructionExpression construction = new(new TypeReference("holo::Kilograms")); + AstSlot arguments = AstSchema.SlotsOf(construction)[0]; + + Assert.IsTrue(AstSchema.Accepts(arguments, new MemberInitialiser("value_"))); + Assert.IsTrue(AstSchema.TryAttach( + construction, + arguments, + new MemberInitialiser("value_") { Value = new LiteralExpression(1.0) })); + + Assert.HasCount(1, AstSchema.ChildrenOf(construction, arguments)); + Assert.IsInstanceOfType(construction.Arguments[0]); + + // And it comes out again, so one loaded from a document can be rewired rather than stranded. + Assert.IsTrue(AstSchema.TryDetachAt(construction, arguments, 0)); + Assert.IsEmpty(construction.Arguments); + } + + /// + /// A call does not take one, because no generator has a spelling for it there. + /// + /// + /// The two share a slot name and a collection type, and the difference is the whole reason the + /// kinds are separate: every generator reads construction.Arguments for a member + /// initialiser and none reads a call's, so one attached to a call would be written as whatever + /// fell out rather than as a named argument. + /// + [TestMethod] + public void CallArguments_DoNotTakeAMemberInitialiser() + { + CallExpression call = new("std::sqrt"); + AstSlot arguments = AstSchema.SlotsOf(call).Single(slot => slot.Name == "Arguments"); + + Assert.IsFalse(AstSchema.Accepts(arguments, new MemberInitialiser("value_"))); + Assert.IsFalse(AstSchema.TryAttach(call, arguments, new MemberInitialiser("value_"))); + Assert.IsEmpty(call.Arguments); + } + /// /// A construction's arguments are a sequence, so they are added, swapped and removed in order. ///