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