From 415e0cb96f7036e4181c4ed0f83160e944bf5aaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 09:02:05 +0000 Subject: [PATCH] [minor] Add CallExpression, ExpressionStatement and ConditionalExpression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AST could describe a declaration in a language-agnostic way and a body only if that body was an operator applied to operands. Anything else — a call, a void call, a choice between two values — had to be handed to a VariableReference as target-language text, which passes through every generator unchanged and is therefore right in at most one language. That made a tree built for one language untranslatable to another, which is the premise of the library. CallExpression models the receiver separately from the callee, because the receiver is the part the languages disagree about: a.b(c) in C#, C++, Python and JavaScript, and b(&a, c) in C, which has no member functions and lowers one to a free function taking the instance. That is the same lowering CGenerator already performs on the declaration, so the call site now follows the declaration it was emitted for. The callee itself is text and is written verbatim. A square root is std::sqrt, Math.Sqrt, math.sqrt and Math.sqrt, and there is no shared idea underneath those four spellings for the AST to hold, the way there is underneath a type. Choosing the name stays the caller's, as SourceFile.Imports and CompileTimeAssertion.Condition already are. ExpressionStatement is where a call made for its effect stands. The AST could say what to do with a value but not that a value is beside the point, so a void call had nowhere to go at all. ConditionalExpression is one node rather than a branch and a temporary because four of the targets spell it as an expression. Python only reorders the operands, which is exactly the kind of difference the AST exists to absorb. Each node is added to AstSchema, AstFields, AstNodeCatalog, the YAML serializer and deserializer, and all five generators. TryAttachAt is split in three along the way, because one switch over every slot is more branches than CA1502 accepts. The C receiver lowering is checked by compiling it rather than by pinning its spelling: whether the call site and the declaration meet is a question about types, which is not visible in the text. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 11 + Coder.Graph/AstFields.cs | 10 + Coder.Graph/AstNodeCatalog.cs | 6 + Coder.Graph/AstSchema.cs | 140 ++++++++- Coder.Test/Ast/CallExpressionTests.cs | 247 ++++++++++++++++ Coder.Test/Ast/ConditionalExpressionTests.cs | 159 +++++++++++ Coder.Test/Ast/ExpressionStatementTests.cs | 148 ++++++++++ .../Graph/CallAndConditionalSlotsTests.cs | 266 ++++++++++++++++++ .../CGeneratedSourceCompilesTests.cs | 93 ++++++ Coder/Ast/CallExpression.cs | 118 ++++++++ Coder/Ast/ConditionalExpression.cs | 87 ++++++ Coder/Ast/ExpressionStatement.cs | 66 +++++ Coder/Languages/CGenerator.cs | 45 +++ Coder/Languages/CSharpGenerator.cs | 9 + Coder/Languages/LanguageGeneratorBase.cs | 97 +++++++ Coder/Languages/PythonGenerator.cs | 21 ++ Coder/Languages/StandardLanguageGenerator.cs | 12 + Coder/Serialization/YamlDeserializer.cs | 121 ++++++++ Coder/Serialization/YamlSerializer.cs | 58 ++++ README.md | 11 + 20 files changed, 1717 insertions(+), 8 deletions(-) create mode 100644 Coder.Test/Ast/CallExpressionTests.cs create mode 100644 Coder.Test/Ast/ConditionalExpressionTests.cs create mode 100644 Coder.Test/Ast/ExpressionStatementTests.cs create mode 100644 Coder.Test/Graph/CallAndConditionalSlotsTests.cs create mode 100644 Coder/Ast/CallExpression.cs create mode 100644 Coder/Ast/ConditionalExpression.cs create mode 100644 Coder/Ast/ExpressionStatement.cs diff --git a/CLAUDE.md b/CLAUDE.md index 23b8e22..3699d61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,17 @@ source in five target languages. The solution uses: `_Static_assert`, the second of which requires a message, so an assertion with none is given its own condition; the others write a comment, because a file that quietly loses a guarantee looks like one that still makes it. +- `Coder/Ast/CallExpression.cs`, `ExpressionStatement.cs`, `ConditionalExpression.cs` — what a + function *body* is made of beyond an operator applied to operands. `CallExpression`'s `Callee` is + text and written verbatim, for the reason `SourceFile.Imports` and `CompileTimeAssertion.Condition` + are: a square root is `std::sqrt`, `Math.Sqrt`, `math.sqrt` and `Math.sqrt`, and there is no shared + idea underneath those to model. Its `Receiver` *is* modelled, because that is the part the + languages disagree about — `a.b(c)` in four of them and `b(&a, c)` in C, which is the same lowering + `CGenerator` already performs on the declaration, so the call site follows the declaration. + `ExpressionStatement` is where a call made for its effect stands: without it a `void` call has + nowhere to go, since the AST could say what to do with a value but not that a value is beside the + point. `ConditionalExpression` is one node rather than a branch and a temporary because four + targets spell it as an expression and the fifth, Python, only reorders the operands. - `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares. - `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index 314a1d6..beb00fe 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -105,6 +105,9 @@ public static class AstFields /// The name of the field every node holding one value exposes. private const string ValueField = "Value"; + /// The name of the field a call exposes its callee through. + private const string CalleeField = "Callee"; + private static readonly IReadOnlyList FunctionKinds = [ .. Enum.GetValues().Select(kind => new AstFieldChoice(kind.ToString(), kind.ToString())), @@ -272,6 +275,11 @@ private static IReadOnlyList OfExpression(AstNode node) new("Name", AstFieldKind.Text, varRef.Name), ], + CallExpression callExpr => + [ + new(CalleeField, AstFieldKind.Text, callExpr.Callee), + ], + BinaryExpression binary => [ new("Operator", AstFieldKind.Choice, binary.Operator.ToString(), OperatorChoices()), @@ -463,6 +471,8 @@ private static bool TryWriteExpression(AstNode node, string fieldName, string va { (VariableReference varRef, "Name") => value.Length > 0 && Assign(() => varRef.Name = value), + (CallExpression callExpr, CalleeField) => value.Length > 0 && Assign(() => callExpr.Callee = value), + (BinaryExpression binary, "Operator") => Enum.TryParse(value, out BinaryOperator binaryOp) && Assign(() => binary.Operator = binaryOp), (UnaryExpression unary, "Operator") => diff --git a/Coder.Graph/AstNodeCatalog.cs b/Coder.Graph/AstNodeCatalog.cs index e7f5747..fcb7500 100644 --- a/Coder.Graph/AstNodeCatalog.cs +++ b/Coder.Graph/AstNodeCatalog.cs @@ -52,6 +52,7 @@ public static class AstNodeCatalog new("Declarations", "Entry point", () => new EntryPoint()), new("Statements", "Return", () => new ReturnStatement()), + new("Statements", "Expression", () => new ExpressionStatement()), .. Enum.GetValues().Select(op => new AstNodeTemplate( "Statements", Spell(op), @@ -69,6 +70,11 @@ public static class AstNodeCatalog () => new UnaryExpression(op, AstSchema.Unfilled()), "Unary")), new("Expressions", "Variable reference", () => new VariableReference("value")), + new("Expressions", "Call", () => new CallExpression("function")), + new("Expressions", "Conditional", () => new ConditionalExpression( + AstSchema.Unfilled(), + AstSchema.Unfilled(), + AstSchema.Unfilled())), new("Literals", "Text", () => Literal.Text("text")), new("Literals", "Number", () => Literal.Number(0)), diff --git a/Coder.Graph/AstSchema.cs b/Coder.Graph/AstSchema.cs index 28f3c3b..1c1232c 100644 --- a/Coder.Graph/AstSchema.cs +++ b/Coder.Graph/AstSchema.cs @@ -40,6 +40,10 @@ public static class AstSchema private static readonly AstSlot ArgumentsSlot = new(ArgumentsSlotName, AstSlotCardinality.Many, AstSlotKind.Expression); private static readonly AstSlot EnumMembersSlot = new("Members", AstSlotCardinality.Many, AstSlotKind.EnumMember); + private static readonly AstSlot ReceiverSlot = new("Receiver", AstSlotCardinality.One, AstSlotKind.Expression); + private static readonly AstSlot ConditionSlot = new("Condition", AstSlotCardinality.One, AstSlotKind.Expression); + private static readonly AstSlot WhenTrueSlot = new("WhenTrue", AstSlotCardinality.One, AstSlotKind.Expression); + private static readonly AstSlot WhenFalseSlot = new("WhenFalse", AstSlotCardinality.One, AstSlotKind.Expression); /// /// Lists the slots a node exposes, in the order the editor should draw them. @@ -55,6 +59,9 @@ public static class AstSchema FieldDeclaration => [InitialValueSlot], MemberInitialiser => [ValueSlot], ConstructionExpression => [ArgumentsSlot], + CallExpression => [ReceiverSlot, ArgumentsSlot], + ConditionalExpression => [ConditionSlot, WhenTrueSlot, WhenFalseSlot], + ExpressionStatement => [ExpressionSlot], FunctionDeclaration => [ParametersSlot, BodySlot], EntryPoint => [BodySlot], ReturnStatement => [ExpressionSlot], @@ -88,6 +95,11 @@ public static IReadOnlyList ChildrenOf(AstNode node, AstSlot slot) (MemberInitialiser initialiser, "Value") => initialiser.Value, (AssignmentStatement assignment, "Target") => assignment.Target, (AssignmentStatement assignment, "Value") => assignment.Value, + (CallExpression callExpr, "Receiver") => callExpr.Receiver, + (ConditionalExpression conditional, "Condition") => conditional.Condition, + (ConditionalExpression conditional, "WhenTrue") => conditional.WhenTrue, + (ConditionalExpression conditional, "WhenFalse") => conditional.WhenFalse, + (ExpressionStatement statement, "Expression") => statement.Expression, _ => null, }; @@ -103,6 +115,7 @@ public static IReadOnlyList ChildrenOf(AstNode node, AstSlot slot) (ClassDeclaration classDecl, "Members") => [.. classDecl.Members], (EnumDeclaration enumDecl, "Members") => [.. enumDecl.Members], (ConstructionExpression construction, ArgumentsSlotName) => [.. construction.Arguments], + (CallExpression callExpr, ArgumentsSlotName) => [.. callExpr.Arguments], (FunctionDeclaration function, "Parameters") => [.. function.Parameters], (FunctionDeclaration function, "Body") => [.. function.Body], (EntryPoint entryPoint, "Body") => [.. entryPoint.Body], @@ -142,12 +155,27 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode return TryReplaceAt(parent, slot, index, child); } + return TryAttachOperand(parent, slot, child) + || TryAttachStatementOperand(parent, slot, child) + || TryAttachSequence(parent, slot, child); + } + + /// + /// Fills one of an expression's own operand slots. + /// + /// The parent node. + /// The slot to fill. + /// The node to attach. + /// True if the child was attached; false if this is not one of these slots. + /// + /// Split from the slots a statement or a declaration owns, and from the sequences, only because + /// one switch over every slot the AST has is more branches than the analyzer accepts. The three + /// are tried in turn and a slot belongs to exactly one of them. + /// + private static bool TryAttachOperand(AstNode parent, AstSlot slot, AstNode child) + { switch (parent, slot.Name) { - case (ReturnStatement returnStmt, "Expression"): - returnStmt.SetExpression(child); - return true; - case (BinaryExpression binary, "Left") when child is Expression leftExpr: binary.Left = leftExpr; return true; @@ -160,6 +188,47 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode unary.Operand = operandExpr; return true; + case (CallExpression callExpr, "Receiver") when child is Expression receiverExpr: + callExpr.Receiver = receiverExpr; + return true; + + case (ConditionalExpression conditional, "Condition") when child is Expression conditionExpr: + conditional.Condition = conditionExpr; + return true; + + case (ConditionalExpression conditional, "WhenTrue") when child is Expression whenTrueExpr: + conditional.WhenTrue = whenTrueExpr; + return true; + + case (ConditionalExpression conditional, "WhenFalse") when child is Expression whenFalseExpr: + conditional.WhenFalse = whenFalseExpr; + return true; + + default: + return false; + } + } + + /// + /// Fills the single-valued slot a statement or a declaration holds an expression in. + /// + /// The parent node. + /// The slot to fill. + /// The node to attach. + /// True if the child was attached; false if this is not one of these slots. + /// + private static bool TryAttachStatementOperand(AstNode parent, AstSlot slot, AstNode child) + { + switch (parent, slot.Name) + { + case (ReturnStatement returnStmt, "Expression"): + returnStmt.SetExpression(child); + return true; + + case (ExpressionStatement statement, "Expression") when child is Expression statementExpr: + statement.Expression = statementExpr; + return true; + case (VariableDeclaration varDecl, "InitialValue") when child is Expression initialExpr: varDecl.InitialValue = initialExpr; return true; @@ -172,10 +241,6 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode initialiser.Value = initialiserExpr; return true; - case (ConstructionExpression construction, ArgumentsSlotName): - construction.Arguments.Add(child); - return true; - case (AssignmentStatement assignment, "Target") when child is Expression targetExpr: assignment.Target = targetExpr; return true; @@ -184,6 +249,31 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode assignment.Value = valueExpr; return true; + default: + return false; + } + } + + /// + /// Appends a child to one of the slots that hold several. + /// + /// The parent node. + /// The slot to append to. + /// The node to attach. + /// True if the child was attached; false if this is not one of these slots. + /// + private static bool TryAttachSequence(AstNode parent, AstSlot slot, AstNode child) + { + switch (parent, slot.Name) + { + case (ConstructionExpression construction, ArgumentsSlotName): + construction.Arguments.Add(child); + return true; + + case (CallExpression callExpr, ArgumentsSlotName): + callExpr.Arguments.Add(child); + return true; + case (FunctionDeclaration function, "Parameters") when child is Parameter parameter: function.Parameters.Add(parameter); return true; @@ -261,6 +351,10 @@ private static bool TryReplaceAt(AstNode parent, AstSlot slot, int index, AstNod construction.Arguments[index] = child; return true; + case (CallExpression callExpr, ArgumentsSlotName): + callExpr.Arguments[index] = child; + return true; + default: return false; } @@ -367,6 +461,33 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index) construction.Arguments.RemoveAt(index); return true; + case (CallExpression callExpr, ArgumentsSlotName) when index < callExpr.Arguments.Count: + callExpr.Arguments.RemoveAt(index); + return true; + + // A receiver is genuinely optional -- a call with none is a free function rather than an + // unfinished member call -- so detaching one clears it instead of leaving a placeholder. + case (CallExpression callExpr, "Receiver"): + bool hadReceiver = callExpr.Receiver is not null; + callExpr.Receiver = null; + return hadReceiver; + + case (ConditionalExpression conditional, "Condition"): + conditional.Condition = Unfilled(); + return true; + + case (ConditionalExpression conditional, "WhenTrue"): + conditional.WhenTrue = Unfilled(); + return true; + + case (ConditionalExpression conditional, "WhenFalse"): + conditional.WhenFalse = Unfilled(); + return true; + + case (ExpressionStatement statement, "Expression"): + statement.Expression = Unfilled(); + return true; + default: return false; } @@ -473,6 +594,9 @@ public static string Describe(AstNode node) EntryPoint => "entry point", Parameter parameter => $"param {parameter.Name ?? ""}", ReturnStatement => "return", + CallExpression callExpr => $"call {(callExpr.Callee.Length == 0 ? "" : callExpr.Callee)}", + ConditionalExpression => "conditional", + ExpressionStatement => "expression", BinaryExpression binary => $"binary {SpellOrName(binary.Operator)}", UnaryExpression unary => $"unary {SpellOrName(unary.Operator)}", AssignmentStatement assignment => $"assign {SpellOrName(assignment.Operator)}", diff --git a/Coder.Test/Ast/CallExpressionTests.cs b/Coder.Test/Ast/CallExpressionTests.cs new file mode 100644 index 0000000..872f059 --- /dev/null +++ b/Coder.Test/Ast/CallExpressionTests.cs @@ -0,0 +1,247 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Ast; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using ktsu.Coder.Serialization; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for and its generation in every target language. +/// +[TestClass] +public class CallExpressionTests +{ + /// + /// Tests that a free function keeps its callee and arguments and has no receiver. + /// + [TestMethod] + public void CallExpression_ShouldCreateCorrectStructure() + { + CallExpression call = new("sqrt") + { + Arguments = { new VariableReference("x") }, + }; + + Assert.AreEqual("sqrt", call.Callee); + Assert.IsNull(call.Receiver); + Assert.AreEqual(1, call.Arguments.Count); + } + + /// + /// Tests that a member call keeps the receiver it was constructed with. + /// + [TestMethod] + public void CallExpression_KeepsItsReceiver() + { + VariableReference receiver = new("point"); + + CallExpression call = new(receiver, "translate"); + + Assert.AreSame(receiver, call.Receiver); + Assert.AreEqual("translate", call.Callee); + } + + /// + /// Tests that a null callee or receiver is refused, since every emitter writes both unconditionally. + /// + [TestMethod] + public void CallExpression_RejectsNullArguments() + { + Assert.ThrowsExactly(() => new CallExpression(null!)); + Assert.ThrowsExactly(() => new CallExpression(null!, "translate")); + Assert.ThrowsExactly(() => new CallExpression(new VariableReference("point"), null!)); + } + + /// + /// Tests that cloning copies the callee and metadata and deep-copies the receiver and arguments. + /// + [TestMethod] + public void CallExpression_Clone_IsDeep() + { + CallExpression original = new(new VariableReference("point"), "translate") + { + ExpectedType = "void", + Arguments = { new VariableReference("dx"), Literal.Number(2) }, + }; + original.Metadata["origin"] = "test"; + + CallExpression clone = (CallExpression)original.Clone(); + + Assert.AreEqual("translate", clone.Callee); + Assert.AreEqual("void", clone.ExpectedType); + Assert.AreEqual("test", clone.Metadata["origin"]); + Assert.IsNotNull(clone.Receiver); + Assert.AreNotSame(original.Receiver, clone.Receiver); + Assert.AreEqual("point", ((VariableReference)clone.Receiver).Name); + Assert.AreEqual(2, clone.Arguments.Count); + Assert.AreNotSame(original.Arguments[0], clone.Arguments[0]); + } + + /// + /// Tests that a free function is spelled identically by every generator, C included: with no + /// receiver there is nothing for the languages to disagree about. + /// + [TestMethod] + public void FreeFunctionCall_IsSpelledIdenticallyByEveryGenerator() + { + CallExpression call = new("sqrt") + { + Arguments = { new VariableReference("x") }, + }; + + Assert.AreEqual("sqrt(x)", new CSharpGenerator().Generate(call), "C#"); + Assert.AreEqual("sqrt(x)", new CppGenerator().Generate(call), "C++"); + Assert.AreEqual("sqrt(x)", new CGenerator().Generate(call), "C"); + Assert.AreEqual("sqrt(x)", new PythonGenerator().Generate(call), "Python"); + Assert.AreEqual("sqrt(x)", new JavaScriptGenerator().Generate(call), "JavaScript"); + } + + /// + /// Tests that a call with no arguments still writes its parentheses, which is what distinguishes + /// calling something from naming it. + /// + [TestMethod] + public void CallWithNoArguments_StillWritesItsParentheses() => + Assert.AreEqual("reset()", new CSharpGenerator().Generate(new CallExpression("reset"))); + + /// + /// Tests that arguments are separated by a comma and a space, in the order they were added. + /// + [TestMethod] + public void Arguments_AreWrittenInOrder() + { + CallExpression call = new("clamp") + { + Arguments = { new VariableReference("value"), Literal.Number(0), Literal.Number(1) }, + }; + + Assert.AreEqual("clamp(value, 0, 1)", new CppGenerator().Generate(call)); + } + + /// + /// Tests that four of the five targets write a member call with a dot. + /// + [TestMethod] + public void MemberCall_IsWrittenWithADotByEveryLanguageThatHasMembers() + { + CallExpression call = new(new VariableReference("point"), "translate") + { + Arguments = { new VariableReference("dx") }, + }; + + Assert.AreEqual("point.translate(dx)", new CSharpGenerator().Generate(call), "C#"); + Assert.AreEqual("point.translate(dx)", new CppGenerator().Generate(call), "C++"); + Assert.AreEqual("point.translate(dx)", new PythonGenerator().Generate(call), "Python"); + Assert.AreEqual("point.translate(dx)", new JavaScriptGenerator().Generate(call), "JavaScript"); + } + + /// + /// Tests that C turns the receiver into the first argument, matching the lowering it already + /// performs on a member function's declaration. + /// + /// + /// This is the case the node exists for: the same tree comes out as two different shapes, which + /// is not something a caller passing the call as text could get. + /// + [TestMethod] + public void C_PassesTheReceiverAsTheFirstArgument() + { + CallExpression call = new(new VariableReference("point"), "Point_translate") + { + Arguments = { new VariableReference("dx"), new VariableReference("dy") }, + }; + + Assert.AreEqual("Point_translate(&point, dx, dy)", new CGenerator().Generate(call)); + } + + /// + /// Tests that C writes no trailing comma when the receiver is the only argument. + /// + [TestMethod] + public void C_WritesNoSeparatorWhenTheReceiverIsTheOnlyArgument() + { + CallExpression call = new(new VariableReference("point"), "Point_reset"); + + Assert.AreEqual("Point_reset(&point)", new CGenerator().Generate(call)); + } + + /// + /// Tests that the receiver and the arguments are recursed into rather than stringified, so a + /// call can be made on the result of another one. + /// + [TestMethod] + public void CallExpression_RecursesIntoCompoundOperands() + { + CallExpression inner = new("origin"); + CallExpression outer = new(inner, "translate") + { + Arguments = + { + new BinaryExpression(new VariableReference("a"), BinaryOperator.Add, new VariableReference("b")), + }, + }; + + Assert.AreEqual("origin().translate((a + b))", new CSharpGenerator().Generate(outer)); + } + + /// + /// Tests that a call survives a round trip through YAML with its callee, receiver and arguments. + /// + [TestMethod] + public void CallExpression_RoundTripsThroughYaml() + { + CallExpression original = new(new VariableReference("point"), "translate") + { + ExpectedType = "void", + Arguments = { new VariableReference("dx"), Literal.Number(3) }, + }; + + string yaml = new YamlSerializer().Serialize(original); + AstNode? deserialized = new YamlDeserializer().Deserialize(yaml); + + Assert.IsInstanceOfType(deserialized); + + CallExpression roundTripped = (CallExpression)deserialized; + Assert.AreEqual("translate", roundTripped.Callee); + Assert.AreEqual("void", roundTripped.ExpectedType); + Assert.IsInstanceOfType(roundTripped.Receiver); + Assert.AreEqual("point", ((VariableReference)roundTripped.Receiver!).Name); + Assert.AreEqual(2, roundTripped.Arguments.Count); + Assert.AreEqual("point.translate(dx, 3)", new CSharpGenerator().Generate(roundTripped)); + } + + /// + /// Tests that a free function round-trips without gaining a receiver, so the absence of one is + /// carried rather than defaulted. + /// + [TestMethod] + public void CallExpression_WithNoReceiver_RoundTripsWithoutGainingOne() + { + string yaml = new YamlSerializer().Serialize(new CallExpression("sqrt") + { + Arguments = { new VariableReference("x") }, + }); + + AstNode? deserialized = new YamlDeserializer().Deserialize(yaml); + + Assert.IsInstanceOfType(deserialized); + Assert.IsNull(((CallExpression)deserialized).Receiver); + } + + /// + /// Tests that every generator accepts a call, so none of them can be handed one it would refuse. + /// + [TestMethod] + public void EveryGenerator_AcceptsACall() + { + CallExpression call = new("sqrt"); + + foreach (ILanguageGenerator generator in + new ILanguageGenerator[] { new CSharpGenerator(), new CppGenerator(), new CGenerator(), new PythonGenerator(), new JavaScriptGenerator() }) + { + Assert.IsTrue(generator.CanGenerate(call), $"{generator.DisplayName} should accept a call"); + } + } +} diff --git a/Coder.Test/Ast/ConditionalExpressionTests.cs b/Coder.Test/Ast/ConditionalExpressionTests.cs new file mode 100644 index 0000000..331471a --- /dev/null +++ b/Coder.Test/Ast/ConditionalExpressionTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Ast; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using ktsu.Coder.Serialization; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for and its generation in every target language. +/// +[TestClass] +public class ConditionalExpressionTests +{ + private static ConditionalExpression Choice() => new( + new VariableReference("ready"), + new VariableReference("go"), + new VariableReference("wait")); + + /// + /// Tests that a conditional keeps the three operands it was constructed with. + /// + [TestMethod] + public void ConditionalExpression_ShouldCreateCorrectStructure() + { + VariableReference condition = new("ready"); + VariableReference whenTrue = new("go"); + VariableReference whenFalse = new("wait"); + + ConditionalExpression conditional = new(condition, whenTrue, whenFalse); + + Assert.AreSame(condition, conditional.Condition); + Assert.AreSame(whenTrue, conditional.WhenTrue); + Assert.AreSame(whenFalse, conditional.WhenFalse); + } + + /// + /// Tests that a null operand is refused, since every emitter recurses into all three unconditionally. + /// + [TestMethod] + public void ConditionalExpression_RejectsNullOperands() + { + VariableReference filled = new("value"); + + Assert.ThrowsExactly(() => new ConditionalExpression(null!, filled, filled)); + Assert.ThrowsExactly(() => new ConditionalExpression(filled, null!, filled)); + Assert.ThrowsExactly(() => new ConditionalExpression(filled, filled, null!)); + } + + /// + /// Tests that cloning copies the metadata and deep-copies all three operands. + /// + [TestMethod] + public void ConditionalExpression_Clone_IsDeep() + { + ConditionalExpression original = Choice(); + original.ExpectedType = "int"; + original.Metadata["origin"] = "test"; + + ConditionalExpression clone = (ConditionalExpression)original.Clone(); + + Assert.AreEqual("int", clone.ExpectedType); + Assert.AreEqual("test", clone.Metadata["origin"]); + Assert.AreNotSame(original.Condition, clone.Condition); + Assert.AreNotSame(original.WhenTrue, clone.WhenTrue); + Assert.AreNotSame(original.WhenFalse, clone.WhenFalse); + Assert.AreEqual("ready", ((VariableReference)clone.Condition).Name); + Assert.AreEqual("go", ((VariableReference)clone.WhenTrue).Name); + Assert.AreEqual("wait", ((VariableReference)clone.WhenFalse).Name); + } + + /// + /// Tests that the four C-family targets spell this with the ternary operator. + /// + [TestMethod] + public void CFamilyGenerators_SpellItWithTheTernaryOperator() + { + ConditionalExpression conditional = Choice(); + + Assert.AreEqual("(ready ? go : wait)", new CSharpGenerator().Generate(conditional), "C#"); + Assert.AreEqual("(ready ? go : wait)", new CppGenerator().Generate(conditional), "C++"); + Assert.AreEqual("(ready ? go : wait)", new CGenerator().Generate(conditional), "C"); + Assert.AreEqual("(ready ? go : wait)", new JavaScriptGenerator().Generate(conditional), "JavaScript"); + } + + /// + /// Tests that Python reorders the operands around the condition, which is the whole reason this + /// is a node rather than text. + /// + [TestMethod] + public void Python_PutsTheConditionBetweenTheTwoValues() => + Assert.AreEqual("(go if ready else wait)", new PythonGenerator().Generate(Choice())); + + /// + /// Tests that the operands are recursed into rather than stringified, so a conditional can + /// choose between compound expressions. + /// + [TestMethod] + public void ConditionalExpression_RecursesIntoCompoundOperands() + { + ConditionalExpression conditional = new( + new BinaryExpression(new VariableReference("a"), BinaryOperator.LessThan, new VariableReference("b")), + new CallExpression("first"), + new CallExpression("second")); + + Assert.AreEqual("((a < b) ? first() : second())", new CSharpGenerator().Generate(conditional)); + Assert.AreEqual("(first() if (a < b) else second())", new PythonGenerator().Generate(conditional)); + } + + /// + /// Tests that a conditional is parenthesised, so nesting one inside another is unambiguous + /// despite the AST carrying no precedence. + /// + [TestMethod] + public void NestedConditionals_AreUnambiguous() + { + ConditionalExpression conditional = new( + new VariableReference("outer"), + Choice(), + new VariableReference("fallback")); + + Assert.AreEqual("(outer ? (ready ? go : wait) : fallback)", new CSharpGenerator().Generate(conditional)); + } + + /// + /// Tests that a conditional survives a round trip through YAML with all three operands in place. + /// + [TestMethod] + public void ConditionalExpression_RoundTripsThroughYaml() + { + ConditionalExpression original = Choice(); + original.ExpectedType = "int"; + + string yaml = new YamlSerializer().Serialize(original); + AstNode? deserialized = new YamlDeserializer().Deserialize(yaml); + + Assert.IsInstanceOfType(deserialized); + + ConditionalExpression roundTripped = (ConditionalExpression)deserialized; + Assert.AreEqual("int", roundTripped.ExpectedType); + Assert.AreEqual("(ready ? go : wait)", new CSharpGenerator().Generate(roundTripped)); + } + + /// + /// Tests that every generator accepts a conditional. + /// + [TestMethod] + public void EveryGenerator_AcceptsAConditional() + { + ConditionalExpression conditional = Choice(); + + foreach (ILanguageGenerator generator in + new ILanguageGenerator[] { new CSharpGenerator(), new CppGenerator(), new CGenerator(), new PythonGenerator(), new JavaScriptGenerator() }) + { + Assert.IsTrue(generator.CanGenerate(conditional), $"{generator.DisplayName} should accept a conditional"); + } + } +} diff --git a/Coder.Test/Ast/ExpressionStatementTests.cs b/Coder.Test/Ast/ExpressionStatementTests.cs new file mode 100644 index 0000000..169bb5f --- /dev/null +++ b/Coder.Test/Ast/ExpressionStatementTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Ast; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using ktsu.Coder.Serialization; +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for and its generation in every target language. +/// +[TestClass] +public class ExpressionStatementTests +{ + private static ExpressionStatement Effect() => + new(new CallExpression("assert") { Arguments = { new VariableReference("ready") } }); + + /// + /// Tests that a statement keeps the expression it was constructed with. + /// + [TestMethod] + public void ExpressionStatement_ShouldCreateCorrectStructure() + { + CallExpression call = new("reset"); + + ExpressionStatement statement = new(call); + + Assert.AreSame(call, statement.Expression); + } + + /// + /// Tests that the parameterless constructor installs the same placeholder the rest of the AST + /// uses, so a freshly created statement is a node the library already understands. + /// + [TestMethod] + public void ExpressionStatement_StartsUnfilled() => + Assert.IsInstanceOfType>(new ExpressionStatement().Expression); + + /// + /// Tests that a null expression is refused, since every emitter recurses into it unconditionally. + /// + [TestMethod] + public void ExpressionStatement_RejectsNullExpression() => + Assert.ThrowsExactly(() => new ExpressionStatement(null!)); + + /// + /// Tests that cloning copies the metadata and deep-copies the expression. + /// + [TestMethod] + public void ExpressionStatement_Clone_IsDeep() + { + ExpressionStatement original = Effect(); + original.Metadata["origin"] = "test"; + + ExpressionStatement clone = (ExpressionStatement)original.Clone(); + + Assert.AreEqual("test", clone.Metadata["origin"]); + Assert.AreNotSame(original.Expression, clone.Expression); + Assert.AreEqual("assert", ((CallExpression)clone.Expression).Callee); + } + + /// + /// Tests that the four languages with a statement terminator end this with one. + /// + [TestMethod] + public void LanguagesWithATerminator_EndTheStatementWithOne() + { + string expected = $"assert(ready);{CodeBlocker.DefaultNewLineString}"; + + Assert.AreEqual(expected, new CSharpGenerator().Generate(Effect()), "C#"); + Assert.AreEqual(expected, new CppGenerator().Generate(Effect()), "C++"); + Assert.AreEqual(expected, new CGenerator().Generate(Effect()), "C"); + Assert.AreEqual(expected, new JavaScriptGenerator().Generate(Effect()), "JavaScript"); + } + + /// + /// Tests that Python writes no terminator, its statements ending at the line break the enclosing + /// body writes. + /// + [TestMethod] + public void Python_WritesNoTerminator() => + Assert.AreEqual("assert(ready)", new PythonGenerator().Generate(Effect())); + + /// + /// Tests that a void call reaches a function body, which is the case the node exists for: before + /// it there was nowhere in the AST for a call made for its effect to stand. + /// + [TestMethod] + public void VoidCall_ReachesAFunctionBody() + { + FunctionDeclaration function = new("update") + { + ReturnType = "void", + Body = { Effect(), new ExpressionStatement(new CallExpression(new VariableReference("items"), "clear")) }, + }; + + string generated = new CSharpGenerator().Generate(function); + + StringAssert.Contains(generated, "assert(ready);"); + StringAssert.Contains(generated, "items.clear();"); + } + + /// + /// Tests that an expression statement can hold something other than a call, since what makes one + /// is that the value is beside the point rather than what kind of expression produced it. + /// + [TestMethod] + public void ExpressionStatement_AcceptsAnyExpression() + { + ExpressionStatement statement = new( + new BinaryExpression(new VariableReference("a"), BinaryOperator.Add, new VariableReference("b"))); + + Assert.AreEqual($"(a + b);{CodeBlocker.DefaultNewLineString}", new CSharpGenerator().Generate(statement)); + } + + /// + /// Tests that a statement survives a round trip through YAML with its expression in place. + /// + [TestMethod] + public void ExpressionStatement_RoundTripsThroughYaml() + { + string yaml = new YamlSerializer().Serialize(Effect()); + AstNode? deserialized = new YamlDeserializer().Deserialize(yaml); + + Assert.IsInstanceOfType(deserialized); + + ExpressionStatement roundTripped = (ExpressionStatement)deserialized; + Assert.IsInstanceOfType(roundTripped.Expression); + Assert.AreEqual("assert", ((CallExpression)roundTripped.Expression).Callee); + } + + /// + /// Tests that every generator accepts an expression statement. + /// + [TestMethod] + public void EveryGenerator_AcceptsAnExpressionStatement() + { + ExpressionStatement statement = Effect(); + + foreach (ILanguageGenerator generator in + new ILanguageGenerator[] { new CSharpGenerator(), new CppGenerator(), new CGenerator(), new PythonGenerator(), new JavaScriptGenerator() }) + { + Assert.IsTrue(generator.CanGenerate(statement), $"{generator.DisplayName} should accept an expression statement"); + } + } +} diff --git a/Coder.Test/Graph/CallAndConditionalSlotsTests.cs b/Coder.Test/Graph/CallAndConditionalSlotsTests.cs new file mode 100644 index 0000000..85c8216 --- /dev/null +++ b/Coder.Test/Graph/CallAndConditionalSlotsTests.cs @@ -0,0 +1,266 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Graph; + +using ktsu.Coder.Ast; +using ktsu.Coder.Graph; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that , and know +/// the three nodes that carry an expression's body: , +/// and . +/// +/// +/// A node the AST has and the schema does not is one the editor cannot wire up, and the failure is +/// silent — the palette offers it and its pins never appear. These cover the same ground for the new +/// nodes that covers for the rest. +/// +[TestClass] +public class CallAndConditionalSlotsTests +{ + private static readonly string[] CallSlots = ["Receiver", "Arguments"]; + private static readonly string[] ConditionalSlots = ["Condition", "WhenTrue", "WhenFalse"]; + private static readonly string[] ExpressionStatementSlots = ["Expression"]; + + private static AstSlot Slot(AstNode node, string name) => + AstSchema.SlotsOf(node).Single(slot => slot.Name == name); + + private static ConditionalExpression NewConditional() => new( + new VariableReference("ready"), + new VariableReference("go"), + new VariableReference("wait")); + + /// + /// Tests that each of the three nodes exposes the slots it should, in drawing order. + /// + [TestMethod] + public void SlotsOf_DescribesTheNewNodes() + { + CollectionAssert.AreEqual( + CallSlots, + AstSchema.SlotsOf(new CallExpression("f")).Select(slot => slot.Name).ToArray()); + + CollectionAssert.AreEqual( + ConditionalSlots, + AstSchema.SlotsOf(NewConditional()).Select(slot => slot.Name).ToArray()); + + CollectionAssert.AreEqual( + ExpressionStatementSlots, + AstSchema.SlotsOf(new ExpressionStatement()).Select(slot => slot.Name).ToArray()); + } + + /// + /// Tests that a call with no receiver reports the slot as empty rather than throwing, since a + /// free function is a finished call rather than an unfinished member one. + /// + [TestMethod] + public void ChildrenOf_ReportsAnAbsentReceiverAsEmpty() + { + CallExpression call = new("sqrt"); + + Assert.AreEqual(0, AstSchema.ChildrenOf(call, Slot(call, "Receiver")).Count); + } + + /// + /// Tests that a receiver and arguments attach through the schema and read back in order. + /// + [TestMethod] + public void TryAttach_FillsACall() + { + CallExpression call = new("translate"); + VariableReference receiver = new("point"); + + Assert.IsTrue(AstSchema.TryAttach(call, Slot(call, "Receiver"), receiver)); + Assert.IsTrue(AstSchema.TryAttach(call, Slot(call, "Arguments"), new VariableReference("dx"))); + Assert.IsTrue(AstSchema.TryAttach(call, Slot(call, "Arguments"), new VariableReference("dy"))); + + Assert.AreSame(receiver, call.Receiver); + Assert.AreEqual(2, call.Arguments.Count); + Assert.AreEqual("dx", ((VariableReference)call.Arguments[0]).Name); + Assert.AreEqual("dy", ((VariableReference)call.Arguments[1]).Name); + } + + /// + /// Tests that attaching inside an existing argument list swaps that entry rather than appending, + /// so reconnecting one pin does not reorder the others. + /// + [TestMethod] + public void TryAttachAt_SwapsAnArgumentInPlace() + { + CallExpression call = new("clamp") + { + Arguments = { new VariableReference("a"), new VariableReference("b") }, + }; + + Assert.IsTrue(AstSchema.TryAttachAt(call, Slot(call, "Arguments"), 0, new VariableReference("z"))); + + Assert.AreEqual(2, call.Arguments.Count); + Assert.AreEqual("z", ((VariableReference)call.Arguments[0]).Name); + Assert.AreEqual("b", ((VariableReference)call.Arguments[1]).Name); + } + + /// + /// Tests that detaching a receiver clears it, leaving a free function rather than a placeholder. + /// + /// + /// The other operand slots cannot be cleared and take the placeholder instead, because a binary + /// expression with one operand is unfinished. A call without a receiver is not. + /// + [TestMethod] + public void TryDetachAt_ClearsAReceiverRatherThanPlaceholderingIt() + { + CallExpression call = new(new VariableReference("point"), "translate"); + + Assert.IsTrue(AstSchema.TryDetachAt(call, Slot(call, "Receiver"), 0)); + Assert.IsNull(call.Receiver); + + // Nothing left to detach, which is reported rather than repeated. + Assert.IsFalse(AstSchema.TryDetachAt(call, Slot(call, "Receiver"), 0)); + } + + /// + /// Tests that detaching an argument removes it from the list. + /// + [TestMethod] + public void TryDetachAt_RemovesAnArgument() + { + CallExpression call = new("clamp") + { + Arguments = { new VariableReference("a"), new VariableReference("b") }, + }; + + Assert.IsTrue(AstSchema.TryDetachAt(call, Slot(call, "Arguments"), 0)); + + Assert.AreEqual(1, call.Arguments.Count); + Assert.AreEqual("b", ((VariableReference)call.Arguments[0]).Name); + } + + /// + /// Tests that each of a conditional's three operands attaches and detaches independently, and + /// that detaching leaves the placeholder the rest of the AST uses for an outstanding operand. + /// + /// The slot under test. + [TestMethod] + [DataRow("Condition")] + [DataRow("WhenTrue")] + [DataRow("WhenFalse")] + public void ConditionalOperands_AttachAndDetachIndependently(string slotName) + { + ConditionalExpression conditional = NewConditional(); + AstSlot slot = Slot(conditional, slotName); + VariableReference replacement = new("replaced"); + + Assert.IsTrue(AstSchema.TryAttach(conditional, slot, replacement)); + Assert.AreSame(replacement, AstSchema.ChildrenOf(conditional, slot).Single()); + + Assert.IsTrue(AstSchema.TryDetachAt(conditional, slot, 0)); + Assert.IsTrue(AstSchema.IsUnfilled(AstSchema.ChildrenOf(conditional, slot).Single())); + } + + /// + /// Tests that an expression statement's slot attaches and detaches the same way. + /// + [TestMethod] + public void ExpressionStatementSlot_AttachesAndDetaches() + { + ExpressionStatement statement = new(); + AstSlot slot = Slot(statement, "Expression"); + CallExpression call = new("reset"); + + Assert.IsTrue(AstSchema.TryAttach(statement, slot, call)); + Assert.AreSame(call, statement.Expression); + + Assert.IsTrue(AstSchema.TryDetachAt(statement, slot, 0)); + Assert.IsTrue(AstSchema.IsUnfilled(statement.Expression)); + } + + /// + /// Tests that a statement slot takes a call, which is what makes a void call reachable from the + /// editor at all. + /// + [TestMethod] + public void AFunctionBody_TakesAnExpressionStatement() + { + FunctionDeclaration function = new("update"); + AstSlot body = Slot(function, "Body"); + + Assert.IsTrue(AstSchema.TryAttach(function, body, new ExpressionStatement(new CallExpression("reset")))); + + Assert.AreEqual(1, function.Body.Count); + } + + /// + /// Tests that the new nodes get captions naming what distinguishes them, rather than falling + /// back to their type name. + /// + [TestMethod] + public void Describe_NamesTheNewNodes() + { + Assert.AreEqual("call sqrt", AstSchema.Describe(new CallExpression("sqrt"))); + Assert.AreEqual("call ", AstSchema.Describe(new CallExpression())); + Assert.AreEqual("conditional", AstSchema.Describe(NewConditional())); + Assert.AreEqual("expression", AstSchema.Describe(new ExpressionStatement())); + } + + /// + /// Tests that a call's callee is editable from the inspector, so a call created from the palette + /// can be pointed at something without editing the YAML by hand. + /// + [TestMethod] + public void Callee_IsEditableFromTheInspector() + { + CallExpression call = new("placeholder"); + + AstField callee = AstFields.Of(call).Single(field => field.Name == "Callee"); + Assert.AreEqual(AstFieldKind.Text, callee.Kind); + Assert.AreEqual("placeholder", callee.Value); + + Assert.IsTrue(AstFields.TryWrite(call, "Callee", "sqrt")); + Assert.AreEqual("sqrt", call.Callee); + } + + /// + /// Tests that an empty callee is refused, matching how a variable reference's name behaves: a + /// half-typed field leaves the document alone rather than writing a call to nothing. + /// + [TestMethod] + public void AnEmptyCallee_IsRefused() + { + CallExpression call = new("sqrt"); + + Assert.IsFalse(AstFields.TryWrite(call, "Callee", string.Empty)); + Assert.AreEqual("sqrt", call.Callee); + } + + /// + /// Tests that the palette offers all three, since a node the catalog omits cannot be created. + /// + [TestMethod] + public void ThePalette_OffersTheNewNodes() + { + Assert.IsTrue(AstNodeCatalog.Templates.Any(template => template.Create() is CallExpression), "call"); + Assert.IsTrue(AstNodeCatalog.Templates.Any(template => template.Create() is ConditionalExpression), "conditional"); + Assert.IsTrue(AstNodeCatalog.Templates.Any(template => template.Create() is ExpressionStatement), "expression statement"); + } + + /// + /// Tests that what the palette creates is valid on its own, which is what lets a freshly created + /// node be connected rather than filled in first. + /// + [TestMethod] + public void WhatThePaletteCreates_IsReadyToConnect() + { + foreach (AstNodeTemplate template in AstNodeCatalog.Templates + .Where(template => template.Create() is CallExpression or ConditionalExpression or ExpressionStatement)) + { + AstNode node = template.Create(); + + foreach (AstSlot slot in AstSchema.SlotsOf(node)) + { + // Reading a slot must not throw, whether or not anything is in it yet. + AstSchema.ChildrenOf(node, slot); + } + } + } +} diff --git a/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs index 09a8b63..16b2753 100644 --- a/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs @@ -110,6 +110,99 @@ public void GeneratedHeader_Compiles() } } + /// + /// The consumer of the call header, which only has to reach the one function in it. + /// + private const string CallDriver = """ + #include "calls.h" + + int main(void) + { + return Counter_span(); + } + + """; + + /// + /// Tests that a call whose receiver C moves into the first argument reaches the member function + /// the same generator emitted for it. + /// + /// + /// This is the one thing about that a pinned spelling cannot settle. + /// C lowers a member function to a free function taking a pointer to the instance, so the call + /// site has to take the receiver's address for the two to meet — and whether they meet is a + /// question about types, which is not visible in the text. A compiler is the only thing that + /// knows the answer. + /// + [TestMethod] + public void ACallWithAReceiver_ReachesTheFunctionItLowersTo() + { + string? compiler = FindCompiler(); + if (compiler is null) + { + Assert.Inconclusive("No C compiler on the path, so nothing was compiled."); + return; + } + + string directory = Path.Combine(Path.GetTempPath(), $"coder-c-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + try + { + File.WriteAllText( + Path.Combine(directory, "calls.h"), + new CGenerator().Generate(CallExemplar())); + File.WriteAllText(Path.Combine(directory, "driver.c"), CallDriver); + + (int exitCode, string output) = Run( + compiler, + "-std=c11 -Wall -Wextra -pedantic -c driver.c -o driver.o", + directory); + + Assert.AreEqual(0, exitCode, $"{compiler} rejected the generated calls:{Environment.NewLine}{output}"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// Builds a header whose one static member calls a member function on a local instance, both as + /// a statement and for its value. + /// + /// The file to generate. + private static SourceFile CallExemplar() + { + SourceFile file = new("calls") { IsHeader = true }; + file.HeaderComment.Add("Generated by Coder. Do not edit."); + + ClassDeclaration counter = new("Counter") { Kind = TypeDeclarationKind.Struct }; + counter.Documentation.Add("Something with a member function to call."); + counter.Members.Add(new VariableDeclaration("count", "int")); + + // The member the call has to reach. C writes it as Counter_value(const Counter* self). + FunctionDeclaration value = new("value") { ReturnType = "int", IsReadOnly = true }; + value.Body.Add(new ReturnStatement(0)); + counter.Members.Add(value); + + ConstructionExpression zero = new(new TypeReference("Counter")); + zero.Arguments.Add(new MemberInitialiser("count") { Value = new LiteralExpression(0) }); + + FunctionDeclaration span = new("span") { ReturnType = "int", IsStatic = true }; + span.Body.Add(new VariableDeclaration("here", "Counter", zero)); + + // Once for its effect and once for its value, which are the two shapes the node exists for. + span.Body.Add(new ExpressionStatement( + new CallExpression(new VariableReference("here"), "Counter_value"))); + span.Body.Add(new ReturnStatement( + new CallExpression(new VariableReference("here"), "Counter_value"))); + counter.Members.Add(span); + + file.Members.Add(counter); + return file; + } + /// /// Builds a header holding one of everything the generator has a spelling for. /// diff --git a/Coder/Ast/CallExpression.cs b/Coder/Ast/CallExpression.cs new file mode 100644 index 0000000..2b003b1 --- /dev/null +++ b/Coder/Ast/CallExpression.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +using System.Collections.ObjectModel; + +/// +/// Calls something and evaluates to what it answers with. +/// Examples: sqrt(x), point.translate(dx, dy) +/// +/// +/// builds a value of a type; this reaches one that already +/// exists. Without it an expression beyond an operator applied to operands cannot be said at all, +/// so a caller with a call to make has to hand a the whole thing as +/// text — which passes through every generator unchanged, and is therefore right in at most one +/// language. +/// +/// is modelled rather than folded into because that is +/// the part the languages disagree about: a.b(c) in C#, C++, Python and JavaScript, and +/// b(&a, c) in C, which has no member functions and lowers one to a free function taking +/// the instance. That is the same transformation already performs +/// on the declaration side, so modelling the receiver is what lets the call site follow the +/// declaration. +/// +/// +/// is text and is written verbatim, which is a decision rather than an +/// oversight. A square root is std::sqrt, Math.Sqrt, math.sqrt and +/// Math.sqrt in the five targets, and there is no shared idea underneath those four spellings +/// for the AST to hold — the way there is underneath a type, which is why +/// is structure. Choosing the name is the caller's, exactly as +/// and are the +/// caller's. What the AST does carry is the shape of the call, which is what the +/// generators need in order to disagree about it. +/// +/// +public class CallExpression : Expression +{ + /// + /// Initializes a new instance of the class. + /// Used for deserialization. + /// + public CallExpression() => Callee = string.Empty; + + /// + /// Initializes a new instance of the class for a free function. + /// + /// What is being called, spelled as the target language spells it. + public CallExpression(string callee) + { + Ensure.NotNull(callee); + Callee = callee; + } + + /// + /// Initializes a new instance of the class for a member call. + /// + /// The instance the call is made on. + /// What is being called, spelled as the target language spells it. + public CallExpression(Expression receiver, string callee) + { + Ensure.NotNull(receiver); + Ensure.NotNull(callee); + Receiver = receiver; + Callee = callee; + } + + /// + /// Gets or sets what is being called, written verbatim. + /// + public string Callee { get; set; } + + /// + /// Gets or sets the instance the call is made on, or null for a free function. + /// + public Expression? Receiver { get; set; } + + /// + /// Gets the arguments, in order. + /// + /// + /// rather than , matching + /// : the legacy shapes + /// are not expressions but every generator emits them where one is expected. + /// + public Collection Arguments { get; init; } = []; + + /// + /// Gets the type name of this node for serialization purposes. + /// + /// The name of the node type. + public override string GetNodeTypeName() => "CallExpression"; + + /// + /// Creates a deep clone of this call. + /// + /// A new instance with the same callee and cloned receiver and arguments. + public override AstNode Clone() + { + CallExpression clone = new() + { + Callee = Callee, + Receiver = (Expression?)Receiver?.DeepClone(), + ExpectedType = ExpectedType, + }; + + foreach ((string key, object? value) in Metadata) + { + clone.Metadata[key] = value; + } + + foreach (AstNode argument in Arguments) + { + clone.Arguments.Add(argument.Clone()); + } + + return clone; + } +} diff --git a/Coder/Ast/ConditionalExpression.cs b/Coder/Ast/ConditionalExpression.cs new file mode 100644 index 0000000..a74425e --- /dev/null +++ b/Coder/Ast/ConditionalExpression.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +/// +/// Chooses between two values on a condition. +/// Examples: ready ? go : wait, and in Python go if ready else wait +/// +/// +/// A choice between two values is not a choice between two statements: four of the five targets +/// spell this as an expression and would need a temporary and a branch to say it any other way, and +/// the fifth — Python — reorders the operands rather than lacking it. That reordering is precisely +/// the kind of difference the AST exists to absorb, and it is invisible to a caller that hands a +/// generator this node instead of the text. +/// +public class ConditionalExpression : Expression +{ + /// + /// Initializes a new instance of the class. + /// Used for deserialization. + /// + public ConditionalExpression() + { + Condition = new LiteralExpression(string.Empty); + WhenTrue = new LiteralExpression(string.Empty); + WhenFalse = new LiteralExpression(string.Empty); + } + + /// + /// Initializes a new instance of the class. + /// + /// What decides which value the expression takes. + /// The value taken when the condition holds. + /// The value taken when it does not. + public ConditionalExpression(Expression condition, Expression whenTrue, Expression whenFalse) + { + Ensure.NotNull(condition); + Ensure.NotNull(whenTrue); + Ensure.NotNull(whenFalse); + Condition = condition; + WhenTrue = whenTrue; + WhenFalse = whenFalse; + } + + /// + /// Gets or sets what decides which value the expression takes. + /// + public Expression Condition { get; set; } + + /// + /// Gets or sets the value taken when the condition holds. + /// + public Expression WhenTrue { get; set; } + + /// + /// Gets or sets the value taken when the condition does not hold. + /// + public Expression WhenFalse { get; set; } + + /// + /// Gets the type name of this node for serialization purposes. + /// + /// The name of the node type. + public override string GetNodeTypeName() => "ConditionalExpression"; + + /// + /// Creates a deep clone of this expression. + /// + /// A new instance with all three operands cloned. + public override AstNode Clone() + { + ConditionalExpression clone = new() + { + Condition = (Expression)Condition.DeepClone(), + WhenTrue = (Expression)WhenTrue.DeepClone(), + WhenFalse = (Expression)WhenFalse.DeepClone(), + ExpectedType = ExpectedType, + }; + + foreach ((string key, object? value) in Metadata) + { + clone.Metadata[key] = value; + } + + return clone; + } +} diff --git a/Coder/Ast/ExpressionStatement.cs b/Coder/Ast/ExpressionStatement.cs new file mode 100644 index 0000000..138354a --- /dev/null +++ b/Coder/Ast/ExpressionStatement.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +/// +/// An expression evaluated for its effect rather than its value. +/// Examples: assert(ready);, items.clear(); +/// +/// +/// The AST could already say what to do with a value — return it, assign it, pass it — but not that +/// a value is beside the point. That leaves a call that answers with nothing with nowhere to stand, +/// which is a hole rather than a simplification: a language where most of what a function body does +/// is call other functions cannot describe a body at all. +/// +/// Every generator ends this the way it ends a return or an assignment, so Python's statement ends +/// at the newline and the other four end at a semicolon without either being a special case here. +/// +/// +public class ExpressionStatement : AstNode +{ + /// + /// Initializes a new instance of the class. + /// Used for deserialization. + /// + public ExpressionStatement() => Expression = new LiteralExpression(string.Empty); + + /// + /// Initializes a new instance of the class. + /// + /// The expression to evaluate. + public ExpressionStatement(Expression expression) + { + Ensure.NotNull(expression); + Expression = expression; + } + + /// + /// Gets or sets the expression being evaluated. + /// + public Expression Expression { get; set; } + + /// + /// Gets the type name of this node for serialization purposes. + /// + /// The name of the node type. + public override string GetNodeTypeName() => "ExpressionStatement"; + + /// + /// Creates a deep clone of this statement. + /// + /// A new instance with a cloned expression. + public override AstNode Clone() + { + ExpressionStatement clone = new() + { + Expression = (Expression)Expression.DeepClone(), + }; + + foreach ((string key, object? value) in Metadata) + { + clone.Metadata[key] = value; + } + + return clone; + } +} diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs index 6955136..270badc 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -713,6 +713,51 @@ protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker co protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code) => WriteList(construction, code, asExpression: true); + /// + /// + /// C has no member functions, so a receiver is not written in front of the callee: it becomes the + /// first argument, which is the same lowering already performs on + /// the declaration — Point_translate(Point* self, …). A call site that kept the dot would + /// not reach the function this generator emitted for it. + /// + /// Its address is taken, because that self parameter is a pointer. That assumes the + /// receiver is an instance rather than already a pointer to one, which is an assumption rather + /// than a deduction: a knows the receiver's spelling and not its + /// type. It is the assumption worth making, because taking the address is the only one of the two + /// a caller cannot write for itself — the AST has no address-of operator — and because what this + /// generator emits elsewhere is instances. A caller holding a pointer spells the call as a free + /// function and passes the pointer as an ordinary argument. + /// + /// + /// What is not done is mangling the name: the declaration's is built from the type it belongs to, + /// and the receiver's type is exactly what is not known here, so + /// is written verbatim and choosing it stays the caller's — + /// which is what the node says it is everywhere else too. + /// + /// + protected override void GenerateCallExpression(CallExpression callExpr, CodeBlocker code) + { + Ensure.NotNull(callExpr); + Ensure.NotNull(code); + + code.Write(callExpr.Callee); + code.Write("("); + + if (callExpr.Receiver is not null) + { + code.Write("&"); + GenerateInternal(callExpr.Receiver, code); + + if (callExpr.Arguments.Count > 0) + { + code.Write(", "); + } + } + + GenerateArgumentList(callExpr.Arguments, code); + code.Write(")"); + } + /// /// Writes what a declaration starts at. /// diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 4b2c868..a4a3928 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -61,6 +61,15 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code) case UnaryExpression unaryExpr: GenerateUnaryExpression(unaryExpr, code, GetUnaryOperator(unaryExpr.Operator)); break; + case CallExpression call: + GenerateCallExpression(call, code); + break; + case ConditionalExpression conditional: + GenerateConditionalExpression(conditional, code); + break; + case ExpressionStatement statement: + GenerateExpressionStatement(statement, code); + break; default: GenerateExpressionOrLeaf(node, code); break; diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index 6f39a5c..a8b4e29 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -3,6 +3,7 @@ namespace ktsu.Coder.Languages; using System; +using System.Collections.Generic; using System.Globalization; using ktsu.Coder.Ast; using ktsu.CodeBlocker; @@ -345,6 +346,99 @@ protected void GenerateAssignmentStatement(AssignmentStatement assignment, CodeB EndStatement(code); } + /// + /// Emits an expression evaluated for its effect, ending it as a statement. + /// + /// The statement to emit. + /// The writer to emit into. + protected void GenerateExpressionStatement(ExpressionStatement statement, CodeBlocker code) + { + Ensure.NotNull(statement); + Ensure.NotNull(code); + + GenerateInternal(statement.Expression, code); + EndStatement(code); + } + + /// + /// Emits a call, recursing into its receiver and arguments. + /// + /// The call to emit. + /// The writer to emit into. + /// + /// A receiver is written in front of the callee, separated by a dot, which is how four of the + /// five targets spell a member call. C is the exception and overrides this: it has no member + /// functions, so the receiver becomes the first argument. + /// + /// is written verbatim. Nothing here maps a function's name + /// between languages, and nothing pretends to — see the node's own remarks for why. + /// + /// + protected virtual void GenerateCallExpression(CallExpression callExpr, CodeBlocker code) + { + Ensure.NotNull(callExpr); + Ensure.NotNull(code); + + if (callExpr.Receiver is not null) + { + GenerateInternal(callExpr.Receiver, code); + code.Write("."); + } + + code.Write(callExpr.Callee); + code.Write("("); + GenerateArgumentList(callExpr.Arguments, code); + code.Write(")"); + } + + /// + /// Emits a parenthesised choice between two values. + /// + /// The expression to emit. + /// The writer to emit into. + /// + /// Defaults to the C-family ?:. Python spells the same thing with its operands in a + /// different order and overrides this. + /// + /// Parenthesised for the reason a binary expression is: the AST carries no precedence, so nesting + /// one of these inside another would otherwise be ambiguous. + /// + /// + protected virtual void GenerateConditionalExpression(ConditionalExpression conditional, CodeBlocker code) + { + Ensure.NotNull(conditional); + Ensure.NotNull(code); + + code.Write("("); + GenerateInternal(conditional.Condition, code); + code.Write(" ? "); + GenerateInternal(conditional.WhenTrue, code); + code.Write(" : "); + GenerateInternal(conditional.WhenFalse, code); + code.Write(")"); + } + + /// + /// Emits a comma-separated argument list, without the surrounding parentheses. + /// + /// The arguments to emit, in order. + /// The writer to emit into. + protected void GenerateArgumentList(IReadOnlyList arguments, CodeBlocker code) + { + Ensure.NotNull(arguments); + Ensure.NotNull(code); + + for (int index = 0; index < arguments.Count; index++) + { + if (index > 0) + { + code.Write(", "); + } + + GenerateInternal(arguments[index], code); + } + } + /// /// Emits a parenthesised binary expression, recursing into both operands. /// @@ -410,6 +504,9 @@ or CompileTimeAssertion or UsingAlias or MemberInitialiser or ConstructionExpression + or CallExpression + or ConditionalExpression + or ExpressionStatement or SourceFile or NamespaceDeclaration or ClassDeclaration diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 6f2eba3..b3b224b 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -51,6 +51,27 @@ protected override void EndStatement(CodeBlocker code) // Python statements end at the newline the caller writes. } + /// + /// + /// Python puts the chosen values around the condition rather than after it, so the same node + /// comes out as go if ready else wait where the others write ready ? go : wait. + /// This is the whole of the difference: the operands are the same three and only their order + /// changes, which is why the node is worth having rather than the text. + /// + protected override void GenerateConditionalExpression(ConditionalExpression conditional, CodeBlocker code) + { + Ensure.NotNull(conditional); + Ensure.NotNull(code); + + code.Write("("); + GenerateInternal(conditional.WhenTrue, code); + code.Write(" if "); + GenerateInternal(conditional.Condition, code); + code.Write(" else "); + GenerateInternal(conditional.WhenFalse, code); + code.Write(")"); + } + /// /// /// Python's documentation is a docstring rather than a comment, and a docstring belongs inside diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index 9c24c9c..cf6cca6 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -75,6 +75,18 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code) GenerateConstructionExpression(construction, code); break; + case CallExpression call: + GenerateCallExpression(call, code); + break; + + case ConditionalExpression conditional: + GenerateConditionalExpression(conditional, code); + break; + + case ExpressionStatement statement: + GenerateExpressionStatement(statement, code); + break; + case EnumDeclaration enumDecl: GenerateEnumDeclaration(enumDecl, code); break; diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index 6931d03..0300283 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -65,6 +65,12 @@ public YamlDeserializer() "MemberInitialiser" => DeserializeMemberInitialiser(nodeData), "constructionExpression" => DeserializeConstructionExpression(nodeData), "ConstructionExpression" => DeserializeConstructionExpression(nodeData), + "callExpression" => DeserializeCallExpression(nodeData), + "CallExpression" => DeserializeCallExpression(nodeData), + "conditionalExpression" => DeserializeConditionalExpression(nodeData), + "ConditionalExpression" => DeserializeConditionalExpression(nodeData), + "expressionStatement" => DeserializeExpressionStatement(nodeData), + "ExpressionStatement" => DeserializeExpressionStatement(nodeData), "enumDeclaration" => DeserializeEnumDeclaration(nodeData), "EnumDeclaration" => DeserializeEnumDeclaration(nodeData), "enumMember" => DeserializeEnumMember(nodeData), @@ -476,6 +482,121 @@ private ConstructionExpression DeserializeConstructionExpression(object? nodeDat return construction; } + /// + /// Reads back an expression written as a single-entry mapping of node type to node data. + /// + /// The mapping, as the deserializer produced it. + /// The expression, or null when the mapping holds nothing that is one. + private Expression? DeserializeNestedExpression(object? value) + { + if (value is not Dictionary dict) + { + return null; + } + + foreach ((object nodeType, object nodeData) in dict) + { + if (DeserializeNode(nodeType.ToString() ?? string.Empty, nodeData) is Expression expression) + { + return expression; + } + } + + return null; + } + + private CallExpression DeserializeCallExpression(object? nodeData) + { + CallExpression callExpr = new(); + if (nodeData is not Dictionary dict) + { + return callExpr; + } + + if (dict.TryGetValue("callee", out object? calleeObj)) + { + callExpr.Callee = calleeObj?.ToString() ?? string.Empty; + } + + if (dict.TryGetValue("receiver", out object? receiverObj)) + { + callExpr.Receiver = DeserializeNestedExpression(receiverObj); + } + + if (dict.TryGetValue("arguments", out object? argumentsObj) && argumentsObj is List arguments) + { + foreach (Dictionary argumentDict in Mappings(arguments)) + { + (object argumentType, object argumentData) = argumentDict.First(); + if (DeserializeNode(argumentType.ToString() ?? string.Empty, argumentData) is AstNode node) + { + callExpr.Arguments.Add(node); + } + } + } + + if (dict.TryGetValue("expectedType", out object? typeObj)) + { + callExpr.ExpectedType = typeObj?.ToString(); + } + + DeserializeMetadata(callExpr, dict); + return callExpr; + } + + private ConditionalExpression DeserializeConditionalExpression(object? nodeData) + { + ConditionalExpression conditional = new(); + if (nodeData is not Dictionary dict) + { + return conditional; + } + + if (dict.TryGetValue("condition", out object? conditionObj) && + DeserializeNestedExpression(conditionObj) is Expression condition) + { + conditional.Condition = condition; + } + + if (dict.TryGetValue("whenTrue", out object? whenTrueObj) && + DeserializeNestedExpression(whenTrueObj) is Expression whenTrue) + { + conditional.WhenTrue = whenTrue; + } + + if (dict.TryGetValue("whenFalse", out object? whenFalseObj) && + DeserializeNestedExpression(whenFalseObj) is Expression whenFalse) + { + conditional.WhenFalse = whenFalse; + } + + if (dict.TryGetValue("expectedType", out object? typeObj)) + { + conditional.ExpectedType = typeObj?.ToString(); + } + + DeserializeMetadata(conditional, dict); + return conditional; + } + + private ExpressionStatement DeserializeExpressionStatement(object? nodeData) + { + ExpressionStatement statement = new(); + if (nodeData is not Dictionary dict) + { + return statement; + } + + if (dict.TryGetValue("expression", out object? expressionObj) && + DeserializeNestedExpression(expressionObj) is Expression expression) + { + statement.Expression = expression; + } + + DeserializeMetadata(statement, dict); + return statement; + } + private EnumDeclaration DeserializeEnumDeclaration(object? nodeData) { EnumDeclaration enumDecl = new(); diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index 318c2e7..55dfca4 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -140,6 +140,15 @@ private static void SerializeOtherNode(AstNode node, Dictionary case ConstructionExpression construction: SerializeConstructionExpression(construction, nodeData); break; + case CallExpression callExpr: + SerializeCallExpression(callExpr, nodeData); + break; + case ConditionalExpression conditional: + SerializeConditionalExpression(conditional, nodeData); + break; + case ExpressionStatement statement: + SerializeExpressionStatement(statement, nodeData); + break; case ReturnStatement returnStmt: SerializeReturnStatement(returnStmt, nodeData); break; @@ -371,6 +380,55 @@ private static void SerializeConstructionExpression(ConstructionExpression const } } + private static void SerializeCallExpression(CallExpression callExpr, Dictionary nodeData) + { + nodeData["callee"] = callExpr.Callee; + + if (callExpr.Receiver is not null) + { + Dictionary receiverData = []; + SerializeNode(callExpr.Receiver, receiverData); + nodeData["receiver"] = receiverData; + } + + if (callExpr.Arguments.Count > 0) + { + nodeData["arguments"] = SerializeBodyStatements(callExpr.Arguments); + } + + if (callExpr.ExpectedType != null) + { + nodeData["expectedType"] = callExpr.ExpectedType; + } + } + + private static void SerializeConditionalExpression(ConditionalExpression conditional, Dictionary nodeData) + { + Dictionary conditionData = []; + SerializeNode(conditional.Condition, conditionData); + nodeData["condition"] = conditionData; + + Dictionary whenTrueData = []; + SerializeNode(conditional.WhenTrue, whenTrueData); + nodeData["whenTrue"] = whenTrueData; + + Dictionary whenFalseData = []; + SerializeNode(conditional.WhenFalse, whenFalseData); + nodeData["whenFalse"] = whenFalseData; + + if (conditional.ExpectedType != null) + { + nodeData["expectedType"] = conditional.ExpectedType; + } + } + + private static void SerializeExpressionStatement(ExpressionStatement statement, Dictionary nodeData) + { + Dictionary expressionData = []; + SerializeNode(statement.Expression, expressionData); + nodeData["expression"] = expressionData; + } + private static void SerializeEnumDeclaration(EnumDeclaration enumDecl, Dictionary nodeData) { if (enumDecl.Name != null) diff --git a/README.md b/README.md index 6490c26..bcf4913 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,13 @@ This makes it ideal for code generation tools, transpilers, and any application - **FunctionDeclaration**: Represents function/method declarations with parameters and body - **Parameter**: Function parameters with optional default values - **ReturnStatement**: Return statements with optional expressions +- **ExpressionStatement**: An expression evaluated for its effect rather than its value (`items.clear();`) - **VariableDeclaration**: Variable declarations, optionally constant or type-inferred - **AssignmentStatement**: Assignments, including the compound operators (`+=`, `<<=`, …) - **BinaryExpression**: Two operands and an operator (`a + b`, `x == y`, `p && q`) - **UnaryExpression**: One operator applied to one operand (`-x`, `!ready`, `~mask`) +- **ConditionalExpression**: A choice between two values (`ready ? go : wait`) +- **CallExpression**: Calling something, with an optional receiver (`sqrt(x)`, `point.translate(dx, dy)`) - **VariableReference**: A reference to a variable by name - **LiteralExpression**: Typed literals (string, int, bool, double) - **AstLeafNode**: Generic leaf nodes for literals (strings, numbers, booleans) @@ -54,6 +57,14 @@ Increment and decrement are deliberately absent from `UnaryOperator`: Python has them, and an `AssignmentStatement` with `AssignmentOperator.AddAssign` expresses the same effect in every target language. +A `CallExpression`'s `Callee` is text and is written verbatim, which is a decision rather than a +gap. A square root is `std::sqrt`, `Math.Sqrt`, `math.sqrt` and `Math.sqrt` across the targets, and +there is no shared idea underneath those spellings for the AST to hold — the way there is +underneath a type, which is why `TypeReference` is structure. What the AST does carry is the shape +of the call, and the shape is what the languages disagree about: `Receiver` is modelled separately +so that `a.b(c)` in four of the targets becomes `b(&a, c)` in C, which has no member functions and +lowers one to a free function taking the instance. + ### Visibility A `ClassDeclaration`, a `FunctionDeclaration` and a `VariableDeclaration` each carry a `Visibility`: