diff --git a/CLAUDE.md b/CLAUDE.md index 20c9e80..6a02416 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,19 @@ source in six target languages. The solution uses: own condition — and Rust, whose `const _: () = assert!(…)` needs no macro crate because a constant nobody names still has to be evaluated for the program to build; 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 `f64::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 five 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 a choice between two values rather than between two statements, + which every target can express and each spells differently: `?:` in the four C-family ones, + `go if ready else wait` in Python, and `if ready { go } else { wait }` in Rust, which has no + ternary operator at all and makes `if` an expression instead. - `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..c5cce22 --- /dev/null +++ b/Coder.Test/Ast/CallExpressionTests.cs @@ -0,0 +1,249 @@ +// 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"); + Assert.AreEqual("sqrt(x)", new RustGenerator().Generate(call), "Rust"); + } + + /// + /// 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 five of the six 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"); + Assert.AreEqual("point.translate(dx)", new RustGenerator().Generate(call), "Rust"); + } + + /// + /// 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(), new RustGenerator() }) + { + 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..7e6a54c --- /dev/null +++ b/Coder.Test/Ast/ConditionalExpressionTests.cs @@ -0,0 +1,172 @@ +// 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 Rust writes an if-expression, having no ternary operator at all. + /// + /// + /// The strongest case for the node being a node: the inherited ?: is not merely a different + /// spelling in Rust, it is not Rust. A caller who had passed this as text would have had to know + /// that before writing it. + /// + [TestMethod] + public void Rust_SpellsItAsAnIfExpression() => + Assert.AreEqual("(if ready { go } else { wait })", new RustGenerator().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)); + Assert.AreEqual("(if (a < b) { first() } else { second() })", new RustGenerator().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(), new RustGenerator() }) + { + 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..131a346 --- /dev/null +++ b/Coder.Test/Ast/ExpressionStatementTests.cs @@ -0,0 +1,149 @@ +// 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 five 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"); + Assert.AreEqual(expected, new RustGenerator().Generate(Effect()), "Rust"); + } + + /// + /// 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(), new RustGenerator() }) + { + 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 2ddaaef..f253634 100644 --- a/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs @@ -102,6 +102,92 @@ 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 = ToolchainHarness.FindOnPath(Compilers); + if (compiler is null) + { + Assert.Inconclusive("No C compiler on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText( + Path.Combine(directory, "calls.h"), + new CGenerator().Generate(CallExemplar())); + File.WriteAllText(Path.Combine(directory, "driver.c"), CallDriver); + + (int exitCode, string output) = ToolchainHarness.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}"); + }); + } + + /// + /// 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.Test/Languages/RustGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs index b5a673a..248be71 100644 --- a/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs @@ -78,6 +78,7 @@ private static SourceFile Exemplar() point.Members.Add(Plus()); point.Members.Add(Negate()); point.Members.Add(ToDouble()); + point.Members.Add(Pick()); ClassDeclaration circle = new("Circle") { BaseType = "Point" }; circle.Documentation.Add("A shape with one radius."); @@ -132,6 +133,37 @@ private static FunctionDeclaration Shift() return shift; } + /// + /// Builds a member that calls another for its effect and then chooses between two values. + /// + /// The declaration. + /// + /// The two nodes Rust cannot take on trust. An holding a call is + /// the only way to say "do this and discard what it answers with", and a + /// has no ternary operator to fall back on here — the + /// inherited ?: would not be a different spelling, it would not parse. Both are checked by + /// compiling rather than by pinning their text. + /// + private static FunctionDeclaration Pick() + { + // Not read-only, so the receiver is &mut self and it may call the member that shifts it. + FunctionDeclaration pick = new("pick") { ReturnType = "int" }; + + pick.Body.Add(new ExpressionStatement( + new CallExpression(new VariableReference("self"), "shift") + { + Arguments = { new LiteralExpression(1) }, + })); + + pick.Body.Add(new ReturnStatement(new ConditionalExpression( + new BinaryExpression( + new VariableReference("self.x"), BinaryOperator.GreaterThan, new VariableReference("self.y")), + new VariableReference("self.x"), + new VariableReference("self.y")))); + + return pick; + } + /// /// Builds the binary operator, which has to become a trait implementation naming its output. /// diff --git a/Coder/Ast/CallExpression.cs b/Coder/Ast/CallExpression.cs new file mode 100644 index 0000000..c6f71e6 --- /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, JavaScript and Rust, 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 +/// f64::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 +/// 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..4f22c39 --- /dev/null +++ b/Coder/Ast/ConditionalExpression.cs @@ -0,0 +1,92 @@ +// 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: every target can say it as an +/// expression and would need a temporary and a branch to say it any other way. Each spells it +/// differently, which is precisely the kind of difference the AST exists to absorb: ?: in the +/// four C-family targets, go if ready else wait in Python, and if ready { go } else +/// { wait } in Rust, which has no ternary operator at all. +/// +/// Rust is why this is worth a node rather than text. Python only reorders the operands, so a caller +/// spelling the C-family form by hand would be merely unidiomatic there; in Rust the same text does +/// not parse. +/// +/// +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 814ceb7..a7665a9 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -720,6 +720,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..0558d68 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,100 @@ 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 five of the + /// six 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 Rust has no ternary operator at all and writes an if expression; + /// both override 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 +505,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/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index 68cd0ab..bc593ef 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -1067,6 +1067,32 @@ protected override void GenerateCompileTimeAssertion(CompileTimeAssertion assert EndStatement(code); } + /// + /// + /// Rust has no ternary operator at all, so the inherited ?: would not be a different + /// spelling of this — it would not compile. What it has instead is an if that is an + /// expression rather than a statement, which is the same idea reached from the other side: the + /// branches yield the value rather than assigning one. + /// + /// Parenthesised, for a reason the braces do not already cover. An if at the start of a + /// statement is parsed as a statement, so a conditional used for its effect alone would have its + /// branches' values silently discarded; wrapping it keeps it an expression wherever it stands. + /// + /// + protected override void GenerateConditionalExpression(ConditionalExpression conditional, CodeBlocker code) + { + Ensure.NotNull(conditional); + Ensure.NotNull(code); + + code.Write("(if "); + GenerateInternal(conditional.Condition, code); + code.Write(" { "); + GenerateInternal(conditional.WhenTrue, code); + code.Write(" } else { "); + GenerateInternal(conditional.WhenFalse, code); + code.Write(" })"); + } + /// /// /// Three shapes, and which one is written depends on what the expression is rather than on where diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index 87c297f..8fd4871 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -76,6 +76,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 49286a4..b92a81f 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`; `if ready { go } else { wait }` in Rust) +- **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,19 @@ 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 `f64::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 five of the targets becomes `b(&a, c)` in C, which has no member functions and +lowers one to a free function taking the instance. + +A `ConditionalExpression` is the same idea for a different disagreement. The four C-family targets +write `?:`, Python reorders the operands into `go if ready else wait`, and Rust has no ternary +operator at all — it makes `if` an expression instead. That last one is why this is a node rather +than text: the C-family spelling is not merely unidiomatic in Rust, it does not parse. + ### Visibility A `ClassDeclaration`, a `FunctionDeclaration` and a `VariableDeclaration` each carry a `Visibility`: