diff --git a/CLAUDE.md b/CLAUDE.md
index 38efd33..efff4a8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,6 +51,23 @@ source in four target languages. The solution uses:
as a string can only paste it, so the caller would have to spell the target language itself.
`Parse` and `ToString` are inverses and text the grammar cannot read becomes a name holding it
verbatim, so a property that used to hold a string still takes and gives one.
+- `Coder/Ast/SourceFile.cs`, `NamespaceDeclaration.cs`, `EnumDeclaration.cs`, `FieldDeclaration.cs` —
+ what a generated *header* is made of rather than a snippet. `FieldDeclaration` is deliberately not
+ `VariableDeclaration`: a field with no initialiser is value-initialised, so a default-constructed
+ instance is the one the declaration described, and a local with none is ordinary. `SourceFile`'s
+ `Imports` are the one part of the AST that does not translate — a C++ include path, a C# namespace
+ and a Python module are different kinds of thing — so a file is built for a language.
+- `Coder/Ast/FunctionKind.cs`, `FunctionDefinition.cs` — what a function declares (method,
+ constructor, destructor, operator, conversion) and where its behaviour comes from (provided,
+ defaulted, deleted). A declaration with no statements is otherwise ambiguous between a function
+ that does nothing, one the language supplies, and one that exists to be refused. `IsAbstract` is
+ C++'s *pure virtual*, which is a different thing from `IsPure`: one says a declaration has no
+ definition, the other that a call has no effect.
+- `Coder/Ast/UsingAlias.cs`, `MemberInitialiser.cs`, `ConstructionExpression.cs` — what a type that
+ shims another needs. A member is *initialised* rather than assigned, which is the only way to start
+ one that cannot be assigned at all; a language without an initialiser list assigns at the top of
+ the constructor instead. `ConstructionExpression` is the one expression that needs a type rather
+ than a name, which is why it could not exist before `TypeReference` did.
- `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 dce3658..149fa56 100644
--- a/Coder.Graph/AstFields.cs
+++ b/Coder.Graph/AstFields.cs
@@ -96,6 +96,40 @@ public static class AstFields
visibility == Visibility.Unspecified ? "(language default)" : visibility.ToString().ToLowerInvariant())),
];
+ ///
+ /// The kinds of type a declaration can be, offered as a menu rather than typed.
+ ///
+ /// The name of the field every declaration that has a visibility exposes.
+ private const string VisibilityField = "Visibility";
+
+ /// The name of the field every node holding one value exposes.
+ private const string ValueField = "Value";
+
+ private static readonly IReadOnlyList FunctionKinds =
+ [
+ .. Enum.GetValues().Select(kind => new AstFieldChoice(kind.ToString(), kind.ToString())),
+ ];
+
+ ///
+ /// Where a function's behaviour comes from, offered as a menu rather than typed.
+ ///
+ private static readonly IReadOnlyList FunctionDefinitions =
+ [
+ .. Enum.GetValues().Select(definition => new AstFieldChoice(
+ definition.ToString(),
+ definition.ToString().ToLowerInvariant())),
+ ];
+
+ ///
+ /// The kinds of type a declaration can be, offered as a menu rather than typed.
+ ///
+ private static readonly IReadOnlyList TypeKinds =
+ [
+ .. Enum.GetValues().Select(kind => new AstFieldChoice(
+ kind.ToString(),
+ kind.ToString().ToLowerInvariant())),
+ ];
+
///
/// Lists the properties of a node the inspector can edit.
///
@@ -107,20 +141,82 @@ public static IReadOnlyList Of(AstNode node)
return node switch
{
+ SourceFile file =>
+ [
+ new("Name", AstFieldKind.Text, file.Name ?? string.Empty),
+ new("Header", AstFieldKind.Flag, Spell(file.IsHeader)),
+ ],
+
+ NamespaceDeclaration namespaceDecl =>
+ [
+ new("Name", AstFieldKind.Text, namespaceDecl.Name ?? string.Empty),
+ ],
+
+ EnumDeclaration enumDecl =>
+ [
+ new("Name", AstFieldKind.Text, enumDecl.Name ?? string.Empty),
+ new("UnderlyingType", AstFieldKind.Text, enumDecl.UnderlyingType?.ToString() ?? string.Empty),
+ new(VisibilityField, AstFieldKind.Choice, enumDecl.Visibility.ToString(), Visibilities),
+ ],
+
+ EnumMember enumMember =>
+ [
+ new("Name", AstFieldKind.Text, enumMember.Name ?? string.Empty),
+ new(ValueField, AstFieldKind.Text, enumMember.Value ?? string.Empty),
+ ],
+
+ UsingAlias usingAlias =>
+ [
+ new("Name", AstFieldKind.Text, usingAlias.Name ?? string.Empty),
+ new("AliasedType", AstFieldKind.Text, usingAlias.AliasedType?.ToString() ?? string.Empty),
+ new(VisibilityField, AstFieldKind.Choice, usingAlias.Visibility.ToString(), Visibilities),
+ ],
+
+ FieldDeclaration fieldDecl =>
+ [
+ new("Name", AstFieldKind.Text, fieldDecl.Name ?? string.Empty),
+ new("Type", AstFieldKind.Text, fieldDecl.Type?.ToString() ?? string.Empty),
+ new(VisibilityField, AstFieldKind.Choice, fieldDecl.Visibility.ToString(), Visibilities),
+ ],
+
ClassDeclaration classDecl =>
[
new("Name", AstFieldKind.Text, classDecl.Name ?? string.Empty),
+ new("Kind", AstFieldKind.Choice, classDecl.Kind.ToString(), TypeKinds),
new("BaseType", AstFieldKind.Text, classDecl.BaseType?.ToString() ?? string.Empty),
- new("Visibility", AstFieldKind.Choice, classDecl.Visibility.ToString(), Visibilities),
+ new(VisibilityField, AstFieldKind.Choice, classDecl.Visibility.ToString(), Visibilities),
],
+ _ => OfCallable(node),
+ };
+ }
+
+ ///
+ /// Lists the properties of a function, a parameter or a variable the inspector can edit.
+ ///
+ /// The node to describe.
+ /// The fields, in the order the inspector should draw them.
+ ///
+ /// Split from the type declarations only because one switch over every node the AST has is more
+ /// branches than the analyzer accepts.
+ ///
+ private static IReadOnlyList OfCallable(AstNode node)
+ {
+ return node switch
+ {
FunctionDeclaration function =>
[
new("Name", AstFieldKind.Text, function.Name ?? string.Empty),
new("ReturnType", AstFieldKind.Text, function.ReturnType?.ToString() ?? string.Empty),
- new("Visibility", AstFieldKind.Choice, function.Visibility.ToString(), Visibilities),
+ new(VisibilityField, AstFieldKind.Choice, function.Visibility.ToString(), Visibilities),
new("Static", AstFieldKind.Flag, Spell(function.IsStatic)),
new("Pure", AstFieldKind.Flag, Spell(function.IsPure)),
+ new("Kind", AstFieldKind.Choice, function.Kind.ToString(), FunctionKinds),
+ new("Definition", AstFieldKind.Choice, function.Definition.ToString(), FunctionDefinitions),
+ new("Virtual", AstFieldKind.Flag, Spell(function.IsVirtual)),
+ new("Abstract", AstFieldKind.Flag, Spell(function.IsAbstract)),
+ new("ReadOnly", AstFieldKind.Flag, Spell(function.IsReadOnly)),
+ new("MustUseResult", AstFieldKind.Flag, Spell(function.MustUseResult)),
],
EntryPoint entryPoint =>
@@ -143,9 +239,26 @@ public static IReadOnlyList Of(AstNode node)
new("Type", AstFieldKind.Text, varDecl.Type?.ToString() ?? string.Empty),
new("Constant", AstFieldKind.Flag, Spell(varDecl.IsConstant)),
new("Inferred", AstFieldKind.Flag, Spell(varDecl.IsTypeInferred)),
- new("Visibility", AstFieldKind.Choice, varDecl.Visibility.ToString(), Visibilities),
+ new(VisibilityField, AstFieldKind.Choice, varDecl.Visibility.ToString(), Visibilities),
],
+ _ => OfExpression(node),
+ };
+ }
+
+ ///
+ /// Lists the properties of an expression or a leaf the inspector can edit.
+ ///
+ /// The node to describe.
+ /// The fields, in the order the inspector should draw them.
+ ///
+ /// Split from the declarations only because one switch over every node the AST has is more
+ /// branches than the analyzer accepts. The line is the same one draws.
+ ///
+ private static IReadOnlyList OfExpression(AstNode node)
+ {
+ return node switch
+ {
VariableReference varRef =>
[
new("Name", AstFieldKind.Text, varRef.Name),
@@ -166,15 +279,15 @@ public static IReadOnlyList Of(AstNode node)
new("Operator", AstFieldKind.Choice, assignment.Operator.ToString(), OperatorChoices()),
],
- LiteralExpression literal => [new("Value", AstFieldKind.Text, literal.Value ?? string.Empty)],
- LiteralExpression literal => [new("Value", AstFieldKind.Number, Spell(literal.Value))],
- LiteralExpression literal => [new("Value", AstFieldKind.Fraction, Spell(literal.Value))],
- LiteralExpression literal => [new("Value", AstFieldKind.Flag, Spell(literal.Value))],
+ LiteralExpression literal => [new(ValueField, AstFieldKind.Text, literal.Value ?? string.Empty)],
+ LiteralExpression literal => [new(ValueField, AstFieldKind.Number, Spell(literal.Value))],
+ LiteralExpression literal => [new(ValueField, AstFieldKind.Fraction, Spell(literal.Value))],
+ LiteralExpression literal => [new(ValueField, AstFieldKind.Flag, Spell(literal.Value))],
- AstLeafNode leaf => [new("Value", AstFieldKind.Text, leaf.Value ?? string.Empty)],
- AstLeafNode leaf => [new("Value", AstFieldKind.Number, Spell(leaf.Value))],
- AstLeafNode leaf => [new("Value", AstFieldKind.Fraction, Spell(leaf.Value))],
- AstLeafNode leaf => [new("Value", AstFieldKind.Flag, Spell(leaf.Value))],
+ AstLeafNode leaf => [new(ValueField, AstFieldKind.Text, leaf.Value ?? string.Empty)],
+ AstLeafNode leaf => [new(ValueField, AstFieldKind.Number, Spell(leaf.Value))],
+ AstLeafNode leaf => [new(ValueField, AstFieldKind.Fraction, Spell(leaf.Value))],
+ AstLeafNode leaf => [new(ValueField, AstFieldKind.Flag, Spell(leaf.Value))],
_ => [],
};
@@ -216,21 +329,90 @@ public static bool TryWrite(AstNode node, string fieldName, string value)
return false;
}
+ return TryWriteDeclaration(node, fieldName, value) || TryWriteExpression(node, fieldName, value);
+ }
+
+ ///
+ /// Writes a field of a declaration.
+ ///
+ /// The node to write to.
+ /// The field to write.
+ /// The value, as text.
+ /// True if the node was changed.
+ ///
+ /// Split from only because one switch over every node the AST
+ /// has is more branches than any analyzer will accept. The line between them is the same one the
+ /// AST already draws: something that declares a name, or something that computes a value.
+ ///
+ private static bool TryWriteDeclaration(AstNode node, string fieldName, string value)
+ {
return (node, fieldName) switch
{
+ (SourceFile file, "Name") => Assign(() => file.Name = OrNull(value)),
+ (SourceFile file, "Header") => TryParseBool(value, out bool isHeader) && Assign(() => file.IsHeader = isHeader),
+
+ (NamespaceDeclaration namespaceDecl, "Name") => Assign(() => namespaceDecl.Name = OrNull(value)),
+
+ (EnumDeclaration enumDecl, "Name") => Assign(() => enumDecl.Name = OrNull(value)),
+ (EnumDeclaration enumDecl, "UnderlyingType") => Assign(() => enumDecl.UnderlyingType = OrNull(value)),
+ (EnumDeclaration enumDecl, VisibilityField) =>
+ TryParseVisibility(value, out Visibility enumVisibility) && Assign(() => enumDecl.Visibility = enumVisibility),
+
+ (EnumMember enumMember, "Name") => Assign(() => enumMember.Name = OrNull(value)),
+ (EnumMember enumMember, ValueField) => Assign(() => enumMember.Value = OrNull(value)),
+
+ (UsingAlias usingAlias, "Name") => Assign(() => usingAlias.Name = OrNull(value)),
+ (UsingAlias usingAlias, "AliasedType") => Assign(() => usingAlias.AliasedType = OrNull(value)),
+ (UsingAlias usingAlias, VisibilityField) =>
+ TryParseVisibility(value, out Visibility aliasVisibility) && Assign(() => usingAlias.Visibility = aliasVisibility),
+
+ (FieldDeclaration fieldDecl, "Name") => Assign(() => fieldDecl.Name = OrNull(value)),
+ (FieldDeclaration fieldDecl, "Type") => Assign(() => fieldDecl.Type = OrNull(value)),
+ (FieldDeclaration fieldDecl, VisibilityField) =>
+ TryParseVisibility(value, out Visibility fieldVisibility) && Assign(() => fieldDecl.Visibility = fieldVisibility),
+
(ClassDeclaration classDecl, "Name") => Assign(() => classDecl.Name = OrNull(value)),
+ (ClassDeclaration classDecl, "Kind") =>
+ Enum.TryParse(value, out TypeDeclarationKind typeKind) && Assign(() => classDecl.Kind = typeKind),
(ClassDeclaration classDecl, "BaseType") => Assign(() => classDecl.BaseType = OrNull(value)),
- (ClassDeclaration classDecl, "Visibility") =>
+ (ClassDeclaration classDecl, VisibilityField) =>
TryParseVisibility(value, out Visibility classVisibility) && Assign(() => classDecl.Visibility = classVisibility),
(FunctionDeclaration function, "Name") => Assign(() => function.Name = OrNull(value)),
(FunctionDeclaration function, "ReturnType") => Assign(() => function.ReturnType = OrNull(value)),
- (FunctionDeclaration function, "Visibility") =>
+ (FunctionDeclaration function, VisibilityField) =>
TryParseVisibility(value, out Visibility functionVisibility) && Assign(() => function.Visibility = functionVisibility),
(FunctionDeclaration function, "Static") =>
TryParseBool(value, out bool isStatic) && Assign(() => function.IsStatic = isStatic),
(FunctionDeclaration function, "Pure") =>
TryParseBool(value, out bool isPure) && Assign(() => function.IsPure = isPure),
+ _ => TryWriteFunctionShape(node, fieldName, value),
+ };
+ }
+
+ ///
+ /// Writes a field describing what a function declares and how.
+ ///
+ /// The node to write to.
+ /// The field to write.
+ /// The value, as text.
+ /// True if the node was changed.
+ private static bool TryWriteFunctionShape(AstNode node, string fieldName, string value)
+ {
+ return (node, fieldName) switch
+ {
+ (FunctionDeclaration function, "Kind") =>
+ Enum.TryParse(value, out FunctionKind kind) && Assign(() => function.Kind = kind),
+ (FunctionDeclaration function, "Definition") =>
+ Enum.TryParse(value, out FunctionDefinition definition) && Assign(() => function.Definition = definition),
+ (FunctionDeclaration function, "Virtual") =>
+ TryParseBool(value, out bool isVirtual) && Assign(() => function.IsVirtual = isVirtual),
+ (FunctionDeclaration function, "Abstract") =>
+ TryParseBool(value, out bool isAbstract) && Assign(() => function.IsAbstract = isAbstract),
+ (FunctionDeclaration function, "ReadOnly") =>
+ TryParseBool(value, out bool isReadOnly) && Assign(() => function.IsReadOnly = isReadOnly),
+ (FunctionDeclaration function, "MustUseResult") =>
+ TryParseBool(value, out bool mustUse) && Assign(() => function.MustUseResult = mustUse),
(EntryPoint entryPoint, "Arguments") =>
TryParseBool(value, out bool acceptsArguments) && Assign(() => entryPoint.AcceptsArguments = acceptsArguments),
@@ -246,9 +428,24 @@ public static bool TryWrite(AstNode node, string fieldName, string value)
(VariableDeclaration varDecl, "Type") => Assign(() => varDecl.Type = OrNull(value)),
(VariableDeclaration varDecl, "Constant") => TryParseBool(value, out bool constant) && Assign(() => varDecl.IsConstant = constant),
(VariableDeclaration varDecl, "Inferred") => TryParseBool(value, out bool inferred) && Assign(() => varDecl.IsTypeInferred = inferred),
- (VariableDeclaration varDecl, "Visibility") =>
+ (VariableDeclaration varDecl, VisibilityField) =>
TryParseVisibility(value, out Visibility varVisibility) && Assign(() => varDecl.Visibility = varVisibility),
+ _ => false,
+ };
+ }
+
+ ///
+ /// Writes a field of an expression or a leaf.
+ ///
+ /// The node to write to.
+ /// The field to write.
+ /// The value, as text.
+ /// True if the node was changed.
+ private static bool TryWriteExpression(AstNode node, string fieldName, string value)
+ {
+ return (node, fieldName) switch
+ {
(VariableReference varRef, "Name") => value.Length > 0 && Assign(() => varRef.Name = value),
(BinaryExpression binary, "Operator") =>
@@ -258,15 +455,15 @@ public static bool TryWrite(AstNode node, string fieldName, string value)
(AssignmentStatement assignment, "Operator") =>
Enum.TryParse(value, out AssignmentOperator assignOp) && Assign(() => assignment.Operator = assignOp),
- (LiteralExpression literal, "Value") => Assign(() => literal.Value = value),
- (LiteralExpression literal, "Value") => TryParseInt(value, out int number) && Assign(() => literal.Value = number),
- (LiteralExpression literal, "Value") => TryParseDouble(value, out double number) && Assign(() => literal.Value = number),
- (LiteralExpression literal, "Value") => TryParseBool(value, out bool flag) && Assign(() => literal.Value = flag),
+ (LiteralExpression literal, ValueField) => Assign(() => literal.Value = value),
+ (LiteralExpression literal, ValueField) => TryParseInt(value, out int number) && Assign(() => literal.Value = number),
+ (LiteralExpression literal, ValueField) => TryParseDouble(value, out double number) && Assign(() => literal.Value = number),
+ (LiteralExpression literal, ValueField) => TryParseBool(value, out bool flag) && Assign(() => literal.Value = flag),
- (AstLeafNode leaf, "Value") => Assign(() => leaf.Value = value),
- (AstLeafNode leaf, "Value") => TryParseInt(value, out int number) && Assign(() => leaf.Value = number),
- (AstLeafNode leaf, "Value") => TryParseDouble(value, out double number) && Assign(() => leaf.Value = number),
- (AstLeafNode leaf, "Value") => TryParseBool(value, out bool flag) && Assign(() => leaf.Value = flag),
+ (AstLeafNode leaf, ValueField) => Assign(() => leaf.Value = value),
+ (AstLeafNode leaf, ValueField) => TryParseInt(value, out int number) && Assign(() => leaf.Value = number),
+ (AstLeafNode leaf, ValueField) => TryParseDouble(value, out double number) && Assign(() => leaf.Value = number),
+ (AstLeafNode leaf, ValueField) => TryParseBool(value, out bool flag) && Assign(() => leaf.Value = flag),
_ => false,
};
diff --git a/Coder.Graph/AstSchema.cs b/Coder.Graph/AstSchema.cs
index 8a411d8..4be230c 100644
--- a/Coder.Graph/AstSchema.cs
+++ b/Coder.Graph/AstSchema.cs
@@ -35,6 +35,11 @@ public static class AstSchema
private static readonly AstSlot ParametersSlot = new("Parameters", AstSlotCardinality.Many, AstSlotKind.Parameter);
private static readonly AstSlot BodySlot = new("Body", AstSlotCardinality.Many, AstSlotKind.Statement);
private static readonly AstSlot MembersSlot = new("Members", AstSlotCardinality.Many, AstSlotKind.Member);
+ /// The name of the slot an expression's arguments sit in.
+ private const string ArgumentsSlotName = "Arguments";
+
+ private static readonly AstSlot ArgumentsSlot = new(ArgumentsSlotName, AstSlotCardinality.Many, AstSlotKind.Expression);
+ private static readonly AstSlot EnumMembersSlot = new("Members", AstSlotCardinality.Many, AstSlotKind.EnumMember);
///
/// Lists the slots a node exposes, in the order the editor should draw them.
@@ -43,7 +48,13 @@ public static class AstSchema
/// The node's slots, empty for a leaf.
public static IReadOnlyList SlotsOf(AstNode node) => node switch
{
+ SourceFile => [MembersSlot],
+ NamespaceDeclaration => [MembersSlot],
ClassDeclaration => [MembersSlot],
+ EnumDeclaration => [EnumMembersSlot],
+ FieldDeclaration => [InitialValueSlot],
+ MemberInitialiser => [ValueSlot],
+ ConstructionExpression => [ArgumentsSlot],
FunctionDeclaration => [ParametersSlot, BodySlot],
EntryPoint => [BodySlot],
ReturnStatement => [ExpressionSlot],
@@ -73,6 +84,8 @@ public static IReadOnlyList ChildrenOf(AstNode node, AstSlot slot)
(BinaryExpression binary, "Right") => binary.Right,
(UnaryExpression unary, "Operand") => unary.Operand,
(VariableDeclaration varDecl, "InitialValue") => varDecl.InitialValue,
+ (FieldDeclaration field, "InitialValue") => field.InitialValue,
+ (MemberInitialiser initialiser, "Value") => initialiser.Value,
(AssignmentStatement assignment, "Target") => assignment.Target,
(AssignmentStatement assignment, "Value") => assignment.Value,
_ => null,
@@ -85,7 +98,11 @@ public static IReadOnlyList ChildrenOf(AstNode node, AstSlot slot)
return (node, slot.Name) switch
{
+ (SourceFile file, "Members") => [.. file.Members],
+ (NamespaceDeclaration namespaceDecl, "Members") => [.. namespaceDecl.Members],
(ClassDeclaration classDecl, "Members") => [.. classDecl.Members],
+ (EnumDeclaration enumDecl, "Members") => [.. enumDecl.Members],
+ (ConstructionExpression construction, ArgumentsSlotName) => [.. construction.Arguments],
(FunctionDeclaration function, "Parameters") => [.. function.Parameters],
(FunctionDeclaration function, "Body") => [.. function.Body],
(EntryPoint entryPoint, "Body") => [.. entryPoint.Body],
@@ -147,6 +164,18 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode
varDecl.InitialValue = initialExpr;
return true;
+ case (FieldDeclaration field, "InitialValue") when child is Expression fieldExpr:
+ field.InitialValue = fieldExpr;
+ return true;
+
+ case (MemberInitialiser initialiser, "Value") when child is Expression initialiserExpr:
+ 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;
@@ -171,6 +200,18 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode
classDecl.Members.Add(child);
return true;
+ case (SourceFile file, "Members"):
+ file.Members.Add(child);
+ return true;
+
+ case (NamespaceDeclaration namespaceDecl, "Members"):
+ namespaceDecl.Members.Add(child);
+ return true;
+
+ case (EnumDeclaration enumDecl, "Members") when child is EnumMember enumMember:
+ enumDecl.Members.Add(enumMember);
+ return true;
+
default:
return false;
}
@@ -204,6 +245,22 @@ private static bool TryReplaceAt(AstNode parent, AstSlot slot, int index, AstNod
classDecl.Members[index] = child;
return true;
+ case (SourceFile file, "Members"):
+ file.Members[index] = child;
+ return true;
+
+ case (NamespaceDeclaration namespaceDecl, "Members"):
+ namespaceDecl.Members[index] = child;
+ return true;
+
+ case (EnumDeclaration enumDecl, "Members") when child is EnumMember enumMember:
+ enumDecl.Members[index] = enumMember;
+ return true;
+
+ case (ConstructionExpression construction, ArgumentsSlotName):
+ construction.Arguments[index] = child;
+ return true;
+
default:
return false;
}
@@ -248,6 +305,16 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index)
varDecl.InitialValue = null;
return hadValue;
+ case (FieldDeclaration field, "InitialValue"):
+ bool hadFieldValue = field.InitialValue is not null;
+ field.InitialValue = null;
+ return hadFieldValue;
+
+ case (MemberInitialiser initialiser, "Value"):
+ bool hadInitialiserValue = initialiser.Value is not null;
+ initialiser.Value = null;
+ return hadInitialiserValue;
+
case (BinaryExpression binary, "Left"):
binary.Left = Unfilled();
return true;
@@ -284,6 +351,22 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index)
classDecl.Members.RemoveAt(index);
return true;
+ case (SourceFile file, "Members") when index < file.Members.Count:
+ file.Members.RemoveAt(index);
+ return true;
+
+ case (NamespaceDeclaration namespaceDecl, "Members") when index < namespaceDecl.Members.Count:
+ namespaceDecl.Members.RemoveAt(index);
+ return true;
+
+ case (EnumDeclaration enumDecl, "Members") when index < enumDecl.Members.Count:
+ enumDecl.Members.RemoveAt(index);
+ return true;
+
+ case (ConstructionExpression construction, ArgumentsSlotName) when index < construction.Arguments.Count:
+ construction.Arguments.RemoveAt(index);
+ return true;
+
default:
return false;
}
@@ -308,6 +391,7 @@ public static AstNode CreateDefaultChild(AstSlot slot)
AstSlotKind.Parameter => new Parameter("value", "int"),
AstSlotKind.Statement => new ReturnStatement(),
AstSlotKind.Member => new FunctionDeclaration("newMethod") { ReturnType = "void" },
+ AstSlotKind.EnumMember => new EnumMember("NewValue"),
_ => Unfilled(),
};
}
@@ -349,7 +433,9 @@ public static bool Accepts(AstSlot slot, AstNode candidate)
// A parameter is not a statement, and neither is an entry point: a program starts
// running at one, so it belongs to a class or to the document rather than inside a body.
AstSlotKind.Statement => candidate is not (Parameter or EntryPoint),
- AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or ClassDeclaration or EntryPoint,
+ AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or FieldDeclaration
+ or ClassDeclaration or EnumDeclaration or NamespaceDeclaration or UsingAlias or EntryPoint,
+ AstSlotKind.EnumMember => candidate is EnumMember,
_ => false,
};
}
diff --git a/Coder.Graph/AstSlot.cs b/Coder.Graph/AstSlot.cs
index 7d9505c..b796ab1 100644
--- a/Coder.Graph/AstSlot.cs
+++ b/Coder.Graph/AstSlot.cs
@@ -44,4 +44,7 @@ public enum AstSlotKind
/// A declaration a class can hold: a method, a field, or a nested class.
Member,
+
+ /// One named value of an enumeration, which is nothing else in the AST.
+ EnumMember,
}
diff --git a/Coder.Test/Ast/DeclarationCloneTests.cs b/Coder.Test/Ast/DeclarationCloneTests.cs
new file mode 100644
index 0000000..b0b80ef
--- /dev/null
+++ b/Coder.Test/Ast/DeclarationCloneTests.cs
@@ -0,0 +1,220 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Ast;
+
+using ktsu.Coder.Ast;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests that cloning a declaration copies what it holds rather than sharing it.
+///
+///
+/// The editor clones to undo, and the graph clones to rebuild itself from the AST. A clone that
+/// shares a collection with its original is the kind of defect that shows up as an edit landing in
+/// two places at once, long after the clone was made and nowhere near it — so every node that holds
+/// anything is checked here for the same property.
+///
+[TestClass]
+public class DeclarationCloneTests
+{
+ ///
+ /// An enumeration's members and documentation are copied, not shared.
+ ///
+ [TestMethod]
+ public void EnumDeclaration_CopiesItsMembers()
+ {
+ EnumDeclaration original = new("BodyKind")
+ {
+ UnderlyingType = "std::uint8_t",
+ Visibility = Visibility.Internal,
+ };
+ original.Documentation.Add("what a body does");
+ original.Members.Add(new EnumMember("Static") { Value = "3" });
+
+ EnumDeclaration clone = (EnumDeclaration)original.Clone();
+
+ Assert.AreEqual("BodyKind", clone.Name);
+ Assert.AreEqual("std::uint8_t", clone.UnderlyingType?.ToString());
+ Assert.AreEqual(Visibility.Internal, clone.Visibility);
+ Assert.AreSequenceEqual(original.Documentation, clone.Documentation);
+ Assert.AreEqual("3", clone.Members[0].Value);
+ Assert.AreNotSame(original.Members[0], clone.Members[0]);
+
+ clone.Members[0].Name = "Dynamic";
+ clone.Documentation.Add("added later");
+
+ Assert.AreEqual("Static", original.Members[0].Name);
+ Assert.HasCount(1, original.Documentation);
+ }
+
+ ///
+ /// A member carries its metadata across, which is where a generator keeps anything the node's own
+ /// properties cannot say.
+ ///
+ [TestMethod]
+ public void EnumMember_CopiesItsMetadata()
+ {
+ EnumMember original = new("Static") { Value = "1" };
+ original.Metadata["origin"] = "schema";
+
+ EnumMember clone = (EnumMember)original.Clone();
+
+ Assert.AreEqual("Static", clone.Name);
+ Assert.AreEqual("1", clone.Value);
+ Assert.AreEqual("schema", clone.Metadata["origin"]);
+ }
+
+ ///
+ /// A field's type and initialiser are copied, so editing one instance's initialiser cannot reach
+ /// the other's.
+ ///
+ [TestMethod]
+ public void FieldDeclaration_CopiesItsTypeAndInitialiser()
+ {
+ FieldDeclaration original = new("mass", "double")
+ {
+ InitialValue = new LiteralExpression(1.0),
+ Visibility = Visibility.Private,
+ };
+ original.Documentation.Add("unit: kg");
+
+ FieldDeclaration clone = (FieldDeclaration)original.Clone();
+
+ Assert.AreEqual("double", clone.Type?.ToString());
+ Assert.AreEqual(Visibility.Private, clone.Visibility);
+ Assert.AreSequenceEqual(original.Documentation, clone.Documentation);
+ Assert.AreNotSame(original.Type, clone.Type);
+ Assert.AreNotSame(original.InitialValue, clone.InitialValue);
+ }
+
+ ///
+ /// A namespace's members are cloned with it, which is what makes cloning one clone a whole
+ /// subtree.
+ ///
+ [TestMethod]
+ public void NamespaceDeclaration_CopiesItsMembers()
+ {
+ NamespaceDeclaration original = new("holo::components");
+ original.Documentation.Add("the engine's components");
+ original.Members.Add(new ClassDeclaration("RigidBody"));
+
+ NamespaceDeclaration clone = (NamespaceDeclaration)original.Clone();
+
+ Assert.AreEqual("holo::components", clone.Name);
+ Assert.AreSequenceEqual(original.Documentation, clone.Documentation);
+ Assert.AreNotSame(original.Members[0], clone.Members[0]);
+
+ ((ClassDeclaration)clone.Members[0]).Name = "Collider";
+
+ Assert.AreEqual("RigidBody", ((ClassDeclaration)original.Members[0]).Name);
+ }
+
+ ///
+ /// A file copies its banner, its imports and everything it declares.
+ ///
+ [TestMethod]
+ public void SourceFile_CopiesEverythingItHolds()
+ {
+ SourceFile original = new("RigidBody.gen.hpp") { IsHeader = true };
+ original.HeaderComment.Add("Generated. Do not edit.");
+ original.Imports.Add("");
+ original.Members.Add(new NamespaceDeclaration("holo"));
+
+ SourceFile clone = (SourceFile)original.Clone();
+
+ Assert.AreEqual("RigidBody.gen.hpp", clone.Name);
+ Assert.IsTrue(clone.IsHeader);
+ Assert.AreSequenceEqual(original.HeaderComment, clone.HeaderComment);
+ Assert.AreSequenceEqual(original.Imports, clone.Imports);
+ Assert.AreNotSame(original.Members[0], clone.Members[0]);
+
+ clone.Imports.Add("");
+
+ Assert.HasCount(1, original.Imports);
+ }
+
+ ///
+ /// An alias copies the type it names.
+ ///
+ [TestMethod]
+ public void UsingAlias_CopiesTheTypeItNames()
+ {
+ UsingAlias original = new("underlying", "std::int64_t") { Visibility = Visibility.Public };
+ original.Documentation.Add("what an EntityId is stored as");
+
+ UsingAlias clone = (UsingAlias)original.Clone();
+
+ Assert.AreEqual("underlying", clone.Name);
+ Assert.AreEqual("std::int64_t", clone.AliasedType?.ToString());
+ Assert.AreEqual(Visibility.Public, clone.Visibility);
+ Assert.AreSequenceEqual(original.Documentation, clone.Documentation);
+ Assert.AreNotSame(original.AliasedType, clone.AliasedType);
+ }
+
+ ///
+ /// An initialiser copies what the member starts at.
+ ///
+ [TestMethod]
+ public void MemberInitialiser_CopiesItsValue()
+ {
+ MemberInitialiser original = new("value_", new VariableReference("value"));
+
+ MemberInitialiser clone = (MemberInitialiser)original.Clone();
+
+ Assert.AreEqual("value_", clone.Name);
+ Assert.AreNotSame(original.Value, clone.Value);
+ Assert.AreEqual("value", ((VariableReference)clone.Value!).Name);
+ }
+
+ ///
+ /// A construction copies its type and its arguments.
+ ///
+ [TestMethod]
+ public void ConstructionExpression_CopiesItsArguments()
+ {
+ ConstructionExpression original = new(new TypeReference("holo::Kilograms"));
+ original.Arguments.Add(new LiteralExpression(1.0));
+
+ ConstructionExpression clone = (ConstructionExpression)original.Clone();
+
+ Assert.AreEqual("holo::Kilograms", clone.Type?.ToString());
+ Assert.AreNotSame(original.Arguments[0], clone.Arguments[0]);
+
+ clone.Arguments.Add(new LiteralExpression(2.0));
+
+ Assert.HasCount(1, original.Arguments);
+ }
+
+ ///
+ /// A function carries its shape and its initialisers across.
+ ///
+ [TestMethod]
+ public void FunctionDeclaration_CopiesItsShape()
+ {
+ FunctionDeclaration original = new("EntityId")
+ {
+ Kind = FunctionKind.Constructor,
+ Definition = FunctionDefinition.Defaulted,
+ IsExplicit = true,
+ IsCompileTimeEvaluable = true,
+ IsNoThrow = true,
+ IsFriend = true,
+ IsVirtual = true,
+ IsAbstract = true,
+ IsReadOnly = true,
+ MustUseResult = true,
+ };
+ original.Initialisers.Add(new MemberInitialiser("value_", new VariableReference("value")));
+
+ FunctionDeclaration clone = (FunctionDeclaration)original.Clone();
+
+ Assert.AreEqual(FunctionKind.Constructor, clone.Kind);
+ Assert.AreEqual(FunctionDefinition.Defaulted, clone.Definition);
+ Assert.IsTrue(clone.IsExplicit);
+ Assert.IsTrue(clone.IsCompileTimeEvaluable);
+ Assert.IsTrue(clone.IsNoThrow);
+ Assert.IsTrue(clone.IsFriend);
+ Assert.AreNotSame(original.Initialisers[0], clone.Initialisers[0]);
+ Assert.AreEqual("value_", clone.Initialisers[0].Name);
+ }
+}
diff --git a/Coder.Test/Ast/FunctionShapeTests.cs b/Coder.Test/Ast/FunctionShapeTests.cs
new file mode 100644
index 0000000..7772bc1
--- /dev/null
+++ b/Coder.Test/Ast/FunctionShapeTests.cs
@@ -0,0 +1,243 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Ast;
+
+using ktsu.Coder.Ast;
+using ktsu.Coder.Graph;
+using ktsu.Coder.Languages;
+using ktsu.Coder.Serialization;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests for what a declares and how: constructors, destructors and
+/// operators, whether a derived type may or must replace it, and where its behaviour comes from.
+///
+///
+/// This is the half of the vocabulary an interface needs, and it is where the four languages diverge
+/// most. C++ says all of it. C# says most of it and expresses a defaulted or deleted member by not
+/// declaring one. Python and JavaScript have no declaration without a definition at all, so a method
+/// a derived type must supply becomes a body that refuses.
+///
+[TestClass]
+public class FunctionShapeTests
+{
+ ///
+ /// Builds a class with a defaulted constructor and a deleted copy constructor.
+ ///
+ /// The class.
+ private static ClassDeclaration SampleClass()
+ {
+ ClassDeclaration handle = new("Handle");
+
+ handle.Members.Add(new FunctionDeclaration("Handle")
+ {
+ Kind = FunctionKind.Constructor,
+ Definition = FunctionDefinition.Defaulted,
+ });
+
+ FunctionDeclaration copy = new("Handle")
+ {
+ Kind = FunctionKind.Constructor,
+ Definition = FunctionDefinition.Deleted,
+ };
+ copy.Parameters.Add(new Parameter("other", "Handle"));
+ handle.Members.Add(copy);
+
+ return handle;
+ }
+
+ ///
+ /// A declaration is an ordinary method whose body is its behaviour unless it says otherwise, so
+ /// nothing that existed before these changed shape.
+ ///
+ [TestMethod]
+ public void Shape_DefaultsToAnOrdinaryMethod()
+ {
+ FunctionDeclaration function = new("area");
+
+ Assert.AreEqual(FunctionKind.Method, function.Kind);
+ Assert.AreEqual(FunctionDefinition.Provided, function.Definition);
+ Assert.IsFalse(function.IsVirtual);
+ Assert.IsFalse(function.IsAbstract);
+ Assert.IsFalse(function.IsReadOnly);
+ Assert.IsFalse(function.MustUseResult);
+ }
+
+ ///
+ /// A constructor and a destructor are named after the type they belong to, which the class emitter
+ /// supplies rather than the declaration holding a second copy of.
+ ///
+ [TestMethod]
+ public void Cpp_NamesAConstructorAfterItsType()
+ {
+ ClassDeclaration handle = SampleClass();
+ handle.Members.Add(new FunctionDeclaration("ignored")
+ {
+ Kind = FunctionKind.Destructor,
+ IsVirtual = true,
+ Definition = FunctionDefinition.Defaulted,
+ });
+
+ string code = new CppGenerator().Generate(handle);
+
+ Assert.Contains("Handle() = default;", code, StringComparison.Ordinal);
+ Assert.Contains("virtual ~Handle() = default;", code, StringComparison.Ordinal);
+ Assert.DoesNotContain("ignored", code, StringComparison.Ordinal);
+ }
+
+ ///
+ /// C++ says all three of defaulted, deleted and abstract.
+ ///
+ [TestMethod]
+ public void Cpp_SpellsEachKindOfDefinition()
+ {
+ string code = new CppGenerator().Generate(SampleClass());
+
+ Assert.Contains("Handle() = default;", code, StringComparison.Ordinal);
+ Assert.Contains("Handle(Handle other) = delete;", code, StringComparison.Ordinal);
+
+ Assert.Contains(
+ "virtual void step() = 0;",
+ new CppGenerator().Generate(new FunctionDeclaration("step") { ReturnType = "void", IsAbstract = true }),
+ StringComparison.Ordinal);
+ }
+
+ ///
+ /// A trailing const says a call leaves the receiver unchanged, which C# spells readonly.
+ ///
+ [TestMethod]
+ public void ReadOnly_IsSpelledWhereALanguageHasIt()
+ {
+ FunctionDeclaration find = new("find") { ReturnType = "int", IsReadOnly = true };
+
+ Assert.Contains("int find() const", new CppGenerator().Generate(find), StringComparison.Ordinal);
+ Assert.Contains("readonly int find(", new CSharpGenerator().Generate(find), StringComparison.Ordinal);
+ }
+
+ ///
+ /// Ignoring a result that may be a failure is a mistake, which C++ spells the same way it spells
+ /// the consequence of purity.
+ ///
+ [TestMethod]
+ public void MustUseResult_EarnsNodiscardWithoutClaimingPurity()
+ {
+ FunctionDeclaration spawn = new("spawn") { ReturnType = "Result", MustUseResult = true };
+ string code = new CppGenerator().Generate(spawn);
+
+ Assert.Contains("[[nodiscard]] Result spawn(", code, StringComparison.Ordinal);
+ Assert.DoesNotContain("Pure", new CSharpGenerator().Generate(spawn), StringComparison.Ordinal);
+ }
+
+ ///
+ /// C# expresses a deleted member by not declaring it, and says which one went rather than dropping
+ /// it silently.
+ ///
+ [TestMethod]
+ public void CSharp_NamesWhatItCannotDeclare()
+ {
+ string code = new CSharpGenerator().Generate(SampleClass());
+
+ // A defaulted constructor is the exception: a type declaring any other constructor stops
+ // getting one for free, so it is written with the empty body that `= default` means there.
+ Assert.Contains("public Handle()", code, StringComparison.Ordinal);
+ Assert.Contains("// Handle is deleted, which C# expresses by not declaring it.", code, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Python has no declaration without a definition, so a method a derived type must supply is one
+ /// whose body refuses.
+ ///
+ [TestMethod]
+ public void Python_RefusesInTheBodyOfAnAbstractMethod()
+ {
+ ClassDeclaration shape = new("Shape");
+ shape.Members.Add(new FunctionDeclaration("area") { ReturnType = "double", IsAbstract = true });
+
+ Assert.Contains("raise NotImplementedError", new PythonGenerator().Generate(shape), StringComparison.Ordinal);
+ }
+
+ ///
+ /// JavaScript does the same, in the only way it has of saying it.
+ ///
+ [TestMethod]
+ public void JavaScript_ThrowsFromTheBodyOfAnAbstractMethod()
+ {
+ ClassDeclaration shape = new("Shape");
+ shape.Members.Add(new FunctionDeclaration("area") { IsAbstract = true });
+
+ Assert.Contains("throw new Error(\"area must be implemented\");", new JavaScriptGenerator().Generate(shape), StringComparison.Ordinal);
+ }
+
+ ///
+ /// Python names a constructor for itself rather than for its type, and has no spelling at all for
+ /// an operator.
+ ///
+ [TestMethod]
+ public void Python_UsesItsOwnNamesAndSaysSoWhereItHasNone()
+ {
+ ClassDeclaration handle = new("Handle");
+ handle.Members.Add(new FunctionDeclaration("Handle") { Kind = FunctionKind.Constructor });
+ handle.Members.Add(new FunctionDeclaration("=") { Kind = FunctionKind.Operator });
+
+ string code = new PythonGenerator().Generate(handle);
+
+ Assert.Contains("def __init__(self)", code, StringComparison.Ordinal);
+ Assert.Contains("# operator = has no Python spelling.", code, StringComparison.Ordinal);
+ }
+
+ ///
+ /// All six survive a round trip through YAML, and a document says nothing for what a declaration
+ /// did not ask for.
+ ///
+ [TestMethod]
+ public void Yaml_RoundTripsTheWholeShape()
+ {
+ FunctionDeclaration original = new("find")
+ {
+ ReturnType = "int",
+ Kind = FunctionKind.Operator,
+ Definition = FunctionDefinition.Deleted,
+ IsVirtual = true,
+ IsAbstract = true,
+ IsReadOnly = true,
+ MustUseResult = true,
+ };
+
+ string yaml = new YamlSerializer().Serialize(original);
+ FunctionDeclaration restored = (FunctionDeclaration)new YamlDeserializer().Deserialize(yaml)!;
+
+ Assert.AreEqual(FunctionKind.Operator, restored.Kind);
+ Assert.AreEqual(FunctionDefinition.Deleted, restored.Definition);
+ Assert.IsTrue(restored.IsVirtual);
+ Assert.IsTrue(restored.IsAbstract);
+ Assert.IsTrue(restored.IsReadOnly);
+ Assert.IsTrue(restored.MustUseResult);
+
+ string plain = new YamlSerializer().Serialize(new FunctionDeclaration("find"));
+
+ Assert.DoesNotContain("kind", plain, StringComparison.Ordinal);
+ Assert.DoesNotContain("definition", plain, StringComparison.Ordinal);
+ Assert.DoesNotContain("isVirtual", plain, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The inspector offers each, so a declaration's shape is something the editor can change.
+ ///
+ [TestMethod]
+ public void Fields_OfferTheWholeShape()
+ {
+ FunctionDeclaration function = new("find");
+
+ Assert.IsTrue(AstFields.TryWrite(function, "Kind", "Destructor"));
+ Assert.IsTrue(AstFields.TryWrite(function, "Definition", "Deleted"));
+ Assert.IsTrue(AstFields.TryWrite(function, "Abstract", "true"));
+ Assert.IsTrue(AstFields.TryWrite(function, "ReadOnly", "true"));
+ Assert.IsTrue(AstFields.TryWrite(function, "MustUseResult", "true"));
+
+ Assert.AreEqual(FunctionKind.Destructor, function.Kind);
+ Assert.AreEqual(FunctionDefinition.Deleted, function.Definition);
+ Assert.IsTrue(function.IsAbstract);
+ Assert.IsTrue(function.IsReadOnly);
+ Assert.IsTrue(function.MustUseResult);
+ }
+}
diff --git a/Coder.Test/Ast/ShimVocabularyTests.cs b/Coder.Test/Ast/ShimVocabularyTests.cs
new file mode 100644
index 0000000..1a6ac34
--- /dev/null
+++ b/Coder.Test/Ast/ShimVocabularyTests.cs
@@ -0,0 +1,245 @@
+// 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 the three nodes a type that shims another needs — an alias, a member initialiser and a
+/// construction — in every language and through YAML.
+///
+///
+/// These are the nodes whose spelling differs most between languages, and the ones where a language
+/// having no spelling at all is the interesting case rather than an oversight. A generated file that
+/// silently drops a member looks complete and is not, so where a language cannot say something it is
+/// checked here that it says so.
+///
+[TestClass]
+public class ShimVocabularyTests
+{
+ ///
+ /// Builds a constructor that initialises one member from its argument.
+ ///
+ /// The class holding it.
+ private static ClassDeclaration EntityId()
+ {
+ ClassDeclaration entity = new("EntityId");
+ entity.Members.Add(new UsingAlias("underlying", "long"));
+
+ FunctionDeclaration constructor = new("EntityId")
+ {
+ Kind = FunctionKind.Constructor,
+ IsExplicit = true,
+ };
+ constructor.Parameters.Add(new Parameter("value", "underlying"));
+ constructor.Initialisers.Add(new MemberInitialiser("value_", new VariableReference("value")));
+ entity.Members.Add(constructor);
+
+ entity.Members.Add(new FieldDeclaration("value_", "underlying") { Visibility = Visibility.Private });
+ return entity;
+ }
+
+ ///
+ /// An alias is a declaration in three languages and a binding in the fourth.
+ ///
+ [TestMethod]
+ public void Alias_IsSpelledByEachLanguage()
+ {
+ // A name no language's mapping table knows, so this is about the alias rather than about how
+ // each spells a built-in type — which TypeReferenceTests already covers.
+ UsingAlias alias = new("underlying", "std::int64_t");
+ alias.Documentation.Add("what an EntityId is stored as");
+
+ Assert.Contains("using underlying = std::int64_t;", new CppGenerator().Generate(alias), StringComparison.Ordinal);
+ Assert.Contains("using underlying = std::int64_t;", new CSharpGenerator().Generate(alias), StringComparison.Ordinal);
+ Assert.Contains("underlying = std::int64_t", new PythonGenerator().Generate(alias), StringComparison.Ordinal);
+ Assert.Contains("const underlying = std::int64_t;", new JavaScriptGenerator().Generate(alias), StringComparison.Ordinal);
+
+ // The documentation reaches every one of them.
+ Assert.Contains("what an EntityId is stored as", new PythonGenerator().Generate(alias), StringComparison.Ordinal);
+ }
+
+ ///
+ /// Building a value is a keyword in three languages and braces in the fourth.
+ ///
+ [TestMethod]
+ public void Construction_IsSpelledByEachLanguage()
+ {
+ ConstructionExpression kilograms = new(new TypeReference("holo::Kilograms"));
+ kilograms.Arguments.Add(new LiteralExpression(1.0));
+
+ FieldDeclaration mass = new("mass", "holo::Kilograms") { InitialValue = kilograms };
+
+ Assert.Contains("holo::Kilograms{ 1", new CppGenerator().Generate(mass), StringComparison.Ordinal);
+ Assert.Contains("new holo::Kilograms(1", new CSharpGenerator().Generate(mass), StringComparison.Ordinal);
+ Assert.Contains("holo::Kilograms(1", new PythonGenerator().Generate(mass), StringComparison.Ordinal);
+ Assert.Contains("new holo::Kilograms(1", new JavaScriptGenerator().Generate(mass), StringComparison.Ordinal);
+ }
+
+ ///
+ /// A construction with no arguments is value-initialisation in C++, which is a different spelling
+ /// rather than an empty argument list.
+ ///
+ [TestMethod]
+ public void Construction_WithNoArgumentsIsValueInitialisation()
+ {
+ FieldDeclaration mass = new("mass", "holo::Kilograms")
+ {
+ InitialValue = new ConstructionExpression(new TypeReference("holo::Kilograms")),
+ };
+
+ Assert.Contains("= holo::Kilograms{};", new CppGenerator().Generate(mass), StringComparison.Ordinal);
+ Assert.Contains("new holo::Kilograms()", new CSharpGenerator().Generate(mass), StringComparison.Ordinal);
+ }
+
+ ///
+ /// More than one argument is separated the same way everywhere.
+ ///
+ [TestMethod]
+ public void Construction_SeparatesItsArguments()
+ {
+ ConstructionExpression point = new(new TypeReference("Point"));
+ point.Arguments.Add(new LiteralExpression(1));
+ point.Arguments.Add(new LiteralExpression(2));
+
+ FieldDeclaration origin = new("origin", "Point") { InitialValue = point };
+
+ Assert.Contains("Point{ 1, 2 }", new CppGenerator().Generate(origin), StringComparison.Ordinal);
+ Assert.Contains("new Point(1, 2)", new CSharpGenerator().Generate(origin), StringComparison.Ordinal);
+ Assert.Contains("Point(1, 2)", new PythonGenerator().Generate(origin), StringComparison.Ordinal);
+ Assert.Contains("new Point(1, 2)", new JavaScriptGenerator().Generate(origin), StringComparison.Ordinal);
+ }
+
+ ///
+ /// C++ initialises a member; the other three assign, at the top of the constructor.
+ ///
+ [TestMethod]
+ public void Initialiser_IsInitialisationInCppAndAssignmentElsewhere()
+ {
+ ClassDeclaration entity = EntityId();
+
+ Assert.Contains(": value_(value)", new CppGenerator().Generate(entity), StringComparison.Ordinal);
+ Assert.Contains("this.value_ = value", new CSharpGenerator().Generate(entity), StringComparison.Ordinal);
+ Assert.Contains("self.value_ = value", new PythonGenerator().Generate(entity), StringComparison.Ordinal);
+ Assert.Contains("this.value_ = value", new JavaScriptGenerator().Generate(entity), StringComparison.Ordinal);
+ }
+
+ ///
+ /// A constructor whose only work is its initialisers still has a body, rather than Python's
+ /// placeholder for one that does nothing.
+ ///
+ [TestMethod]
+ public void Python_DoesNotPassWhenAConstructorInitialisesSomething()
+ {
+ string code = new PythonGenerator().Generate(EntityId());
+
+ Assert.Contains("def __init__(self, value", code, StringComparison.Ordinal);
+ Assert.DoesNotContain("pass", code, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A conversion says which direction it may be taken in, which is the difference between a type
+ /// that shims another and one that merely wraps it.
+ ///
+ [TestMethod]
+ public void Conversion_SaysWhetherItMustBeAskedFor()
+ {
+ FunctionDeclaration widening = new("ignored")
+ {
+ Kind = FunctionKind.ConversionOperator,
+ ReturnType = "ForceMagnitude",
+ IsReadOnly = true,
+ };
+
+ Assert.Contains("operator ForceMagnitude() const", new CppGenerator().Generate(widening), StringComparison.Ordinal);
+ Assert.Contains("implicit operator ForceMagnitude(", new CSharpGenerator().Generate(widening), StringComparison.Ordinal);
+
+ widening.IsExplicit = true;
+
+ Assert.Contains("explicit operator ForceMagnitude(", new CSharpGenerator().Generate(widening), StringComparison.Ordinal);
+ Assert.Contains("explicit ", new CppGenerator().Generate(widening), StringComparison.Ordinal);
+ }
+
+ ///
+ /// Only C++ can say when a call may be evaluated or that it cannot fail, so only C++ writes
+ /// anything for either.
+ ///
+ [TestMethod]
+ public void CompileTimeAndNoThrow_AreWrittenOnlyWhereTheyCanBeSaid()
+ {
+ FunctionDeclaration value = new("value")
+ {
+ ReturnType = "std::int64_t",
+ IsCompileTimeEvaluable = true,
+ IsNoThrow = true,
+ };
+
+ string cpp = new CppGenerator().Generate(value);
+
+ Assert.Contains("constexpr std::int64_t value() noexcept", cpp, StringComparison.Ordinal);
+ Assert.DoesNotContain("constexpr", new CSharpGenerator().Generate(value), StringComparison.Ordinal);
+ Assert.DoesNotContain("noexcept", new PythonGenerator().Generate(value), StringComparison.Ordinal);
+ }
+
+ ///
+ /// All three nodes survive a round trip through YAML, which is what makes a shim a document rather
+ /// than something only the generator can see.
+ ///
+ [TestMethod]
+ public void Yaml_RoundTripsTheShimVocabulary()
+ {
+ ClassDeclaration original = EntityId();
+ ((FunctionDeclaration)original.Members[1]).Initialisers[0].Value =
+ new ConstructionExpression(new TypeReference("long")) { Arguments = { new VariableReference("value") } };
+
+ string yaml = new YamlSerializer().Serialize(original);
+ ClassDeclaration restored = (ClassDeclaration)new YamlDeserializer().Deserialize(yaml)!;
+
+ UsingAlias alias = (UsingAlias)restored.Members[0];
+ Assert.AreEqual("underlying", alias.Name);
+ Assert.AreEqual("long", alias.AliasedType?.ToString());
+
+ FunctionDeclaration constructor = (FunctionDeclaration)restored.Members[1];
+ Assert.AreEqual(FunctionKind.Constructor, constructor.Kind);
+ Assert.IsTrue(constructor.IsExplicit);
+ Assert.HasCount(1, constructor.Initialisers);
+ Assert.AreEqual("value_", constructor.Initialisers[0].Name);
+
+ ConstructionExpression construction = (ConstructionExpression)constructor.Initialisers[0].Value!;
+ Assert.AreEqual("long", construction.Type?.ToString());
+ Assert.HasCount(1, construction.Arguments);
+
+ FieldDeclaration field = (FieldDeclaration)restored.Members[2];
+ Assert.AreEqual(Visibility.Private, field.Visibility);
+ }
+
+ ///
+ /// A document says nothing for a shim that asked for nothing, so a file written before any of this
+ /// existed still opens.
+ ///
+ [TestMethod]
+ public void Yaml_WritesNothingForWhatAShimDoesNotSay()
+ {
+ string yaml = new YamlSerializer().Serialize(new FunctionDeclaration("value"));
+
+ Assert.DoesNotContain("isExplicit", yaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("isNoThrow", yaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("isFriend", yaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("initialisers", yaml, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A file that is not a header says nothing about being included twice, because it is not.
+ ///
+ [TestMethod]
+ public void File_SaysNothingAboutInclusionWhenItIsNotAHeader()
+ {
+ SourceFile source = new("main.cpp");
+ source.Members.Add(new ClassDeclaration("Program"));
+
+ Assert.DoesNotContain("#pragma once", new CppGenerator().Generate(source), StringComparison.Ordinal);
+ }
+}
diff --git a/Coder.Test/Ast/TypeDeclarationTests.cs b/Coder.Test/Ast/TypeDeclarationTests.cs
new file mode 100644
index 0000000..8157ec2
--- /dev/null
+++ b/Coder.Test/Ast/TypeDeclarationTests.cs
@@ -0,0 +1,367 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Ast;
+
+using ktsu.Coder.Ast;
+using ktsu.Coder.Graph;
+using ktsu.Coder.Languages;
+using ktsu.Coder.Serialization;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests for the declarations a generated type is made of: what kind of type it is, the enumerations
+/// and fields it holds, the documentation each carries, and the namespace and file around them.
+///
+///
+/// These are the nodes an AST needs before it can describe a header rather than a snippet. Each is
+/// covered the same way covers its subject — by what each language
+/// spells it as, rather than by asserting one shape four times, because the differences are the
+/// reason the AST holds these as structure at all.
+///
+[TestClass]
+public class TypeDeclarationTests
+{
+ ///
+ /// Builds a struct with a nested enumeration and two fields, one of them initialised.
+ ///
+ /// The declaration.
+ private static ClassDeclaration SampleStruct()
+ {
+ EnumDeclaration kind = new("BodyKind") { UnderlyingType = "std::uint8_t" };
+ kind.Members.Add(new EnumMember("Static"));
+ kind.Members.Add(new EnumMember("Dynamic"));
+
+ ClassDeclaration body = new("RigidBody") { Kind = TypeDeclarationKind.Struct };
+ body.Documentation.Add("Physical state, integrated each frame.");
+ body.Members.Add(kind);
+
+ FieldDeclaration mass = new("mass", "double")
+ {
+ InitialValue = new LiteralExpression(1.0),
+ };
+ mass.Documentation.Add("unit: kg");
+ body.Members.Add(mass);
+
+ body.Members.Add(new FieldDeclaration("kind", "BodyKind"));
+ return body;
+ }
+
+ ///
+ /// A declaration is a class unless it says otherwise, so nothing that existed before these
+ /// changed shape.
+ ///
+ [TestMethod]
+ public void Kind_DefaultsToClass()
+ {
+ Assert.AreEqual(TypeDeclarationKind.Class, new ClassDeclaration("Point").Kind);
+ Assert.StartsWith(
+ "class Point",
+ new CppGenerator().Generate(new ClassDeclaration("Point")),
+ StringComparison.Ordinal);
+ }
+
+ ///
+ /// Each language writes the keyword it has for each kind, and invents nothing for the ones it
+ /// does not.
+ ///
+ [TestMethod]
+ public void Kind_IsSpelledByEachLanguage()
+ {
+ ClassDeclaration interfaceDecl = new("IShape") { Kind = TypeDeclarationKind.Interface };
+ ClassDeclaration structDecl = new("Point") { Kind = TypeDeclarationKind.Struct };
+
+ Assert.StartsWith("public struct Point", new CSharpGenerator().Generate(structDecl), StringComparison.Ordinal);
+ Assert.StartsWith("public interface IShape", new CSharpGenerator().Generate(interfaceDecl), StringComparison.Ordinal);
+ Assert.StartsWith("struct Point", new CppGenerator().Generate(structDecl), StringComparison.Ordinal);
+
+ // C++ has no interface keyword, and Python and JavaScript have neither keyword.
+ Assert.StartsWith("class IShape", new CppGenerator().Generate(interfaceDecl), StringComparison.Ordinal);
+ Assert.StartsWith("class Point", new PythonGenerator().Generate(structDecl), StringComparison.Ordinal);
+ Assert.StartsWith("class Point", new JavaScriptGenerator().Generate(structDecl), StringComparison.Ordinal);
+ }
+
+ ///
+ /// A struct's members are public already, so C++ writes no access label above them. A class's
+ /// members are not, which is the case the label exists for.
+ ///
+ [TestMethod]
+ public void Cpp_LabelsAClassButNotAStruct()
+ {
+ Assert.DoesNotContain("public:", new CppGenerator().Generate(SampleStruct()), StringComparison.Ordinal);
+
+ ClassDeclaration asClass = SampleStruct();
+ asClass.Kind = TypeDeclarationKind.Class;
+
+ Assert.Contains("public:", new CppGenerator().Generate(asClass), StringComparison.Ordinal);
+ }
+
+ ///
+ /// An enumeration is scoped and sized in the languages that can say either.
+ ///
+ [TestMethod]
+ public void Enum_IsSpelledByEachLanguage()
+ {
+ EnumDeclaration kind = new("BodyKind") { UnderlyingType = "std::uint8_t" };
+ kind.Members.Add(new EnumMember("Static"));
+ kind.Members.Add(new EnumMember("Dynamic") { Value = "7" });
+
+ Assert.Contains(
+ "enum class BodyKind : std::uint8_t",
+ new CppGenerator().Generate(kind),
+ StringComparison.Ordinal);
+ Assert.Contains("Dynamic = 7,", new CppGenerator().Generate(kind), StringComparison.Ordinal);
+
+ // Python and JavaScript have no enumeration, so a member with no value of its own is numbered
+ // from its position rather than left to a keyword that does not exist.
+ Assert.Contains("Static = 0", new PythonGenerator().Generate(kind), StringComparison.Ordinal);
+ Assert.Contains("Static: 0,", new JavaScriptGenerator().Generate(kind), StringComparison.Ordinal);
+ Assert.Contains("Object.freeze({", new JavaScriptGenerator().Generate(kind), StringComparison.Ordinal);
+ }
+
+ ///
+ /// A class body is not a block, so the namespace-scope spelling of an enumeration cannot simply
+ /// be nested inside one.
+ ///
+ [TestMethod]
+ public void JavaScript_NestsAnEnumAsAStaticMember()
+ {
+ string code = new JavaScriptGenerator().Generate(SampleStruct());
+
+ Assert.Contains("static BodyKind = Object.freeze({", code, StringComparison.Ordinal);
+ Assert.DoesNotContain("const BodyKind", code, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The difference between a field and a local: C++ initialises the one nobody gave a value to,
+ /// so a default-constructed instance is the instance the declaration described.
+ ///
+ [TestMethod]
+ public void Cpp_ValueInitialisesAFieldWithNoInitialiser()
+ {
+ string code = new CppGenerator().Generate(SampleStruct());
+
+ Assert.Contains("BodyKind kind{};", code, StringComparison.Ordinal);
+ Assert.Contains("double mass = 1", code, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Documentation reaches the reader in every language, in whatever comment each has.
+ ///
+ [TestMethod]
+ public void Documentation_IsWrittenInEachLanguagesComment()
+ {
+ ClassDeclaration body = SampleStruct();
+
+ Assert.Contains("/// Physical state, integrated each frame.", new CppGenerator().Generate(body), StringComparison.Ordinal);
+ Assert.Contains("/// unit: kg", new CSharpGenerator().Generate(body), StringComparison.Ordinal);
+ Assert.Contains("# unit: kg", new PythonGenerator().Generate(body), StringComparison.Ordinal);
+ Assert.Contains("// unit: kg", new JavaScriptGenerator().Generate(body), StringComparison.Ordinal);
+ }
+
+ ///
+ /// A namespace name is written with either separator and comes out with the one its language uses.
+ ///
+ [TestMethod]
+ public void Namespace_IsRejoinedWithEachLanguagesSeparator()
+ {
+ NamespaceDeclaration components = new("holo::components");
+ components.Members.Add(new ClassDeclaration("Point"));
+
+ Assert.Contains("namespace holo::components", new CppGenerator().Generate(components), StringComparison.Ordinal);
+ Assert.Contains("} // namespace holo::components", new CppGenerator().Generate(components), StringComparison.Ordinal);
+ Assert.Contains("namespace holo.components", new CSharpGenerator().Generate(new NamespaceDeclaration("holo.components")), StringComparison.Ordinal);
+ }
+
+ ///
+ /// Python and JavaScript have no namespace: the members come out and nothing is wrapped around
+ /// them.
+ ///
+ [TestMethod]
+ public void Namespace_IsNotSpelledWhereALanguageHasNone()
+ {
+ NamespaceDeclaration components = new("holo::components");
+ components.Members.Add(new ClassDeclaration("Point"));
+
+ Assert.AreEqual(
+ new PythonGenerator().Generate(new ClassDeclaration("Point")),
+ new PythonGenerator().Generate(components));
+ Assert.AreEqual(
+ new JavaScriptGenerator().Generate(new ClassDeclaration("Point")),
+ new JavaScriptGenerator().Generate(components));
+ }
+
+ ///
+ /// A header says that including it twice is including it once, and only C++ has anything to say
+ /// about that.
+ ///
+ [TestMethod]
+ public void File_WritesItsDirectivesAndImports()
+ {
+ SourceFile file = new("Point.hpp") { IsHeader = true };
+ file.HeaderComment.Add("Generated. Do not edit.");
+ file.Imports.Add("");
+ file.Imports.Add("holotype/core/units.hpp");
+ file.Members.Add(new ClassDeclaration("Point"));
+
+ string code = new CppGenerator().Generate(file);
+
+ Assert.Contains("// Generated. Do not edit.", code, StringComparison.Ordinal);
+ Assert.Contains("#pragma once", code, StringComparison.Ordinal);
+ Assert.Contains("#include ", code, StringComparison.Ordinal);
+
+ // An import carrying no delimiters of its own is quoted, which is right for a path inside the
+ // project being generated.
+ Assert.Contains("#include \"holotype/core/units.hpp\"", code, StringComparison.Ordinal);
+
+ file.IsHeader = false;
+ Assert.DoesNotContain("#pragma once", new CppGenerator().Generate(file), StringComparison.Ordinal);
+ }
+
+ ///
+ /// An import is the one part of a file that does not translate, so each language writes its own
+ /// kind of statement for it.
+ ///
+ [TestMethod]
+ public void File_SpellsAnImportPerLanguage()
+ {
+ SourceFile file = new("point");
+ file.Imports.Add("System.Text");
+
+ Assert.Contains("using System.Text;", new CSharpGenerator().Generate(file), StringComparison.Ordinal);
+ Assert.Contains("import System.Text", new PythonGenerator().Generate(file), StringComparison.Ordinal);
+ Assert.Contains("import \"System.Text\";", new JavaScriptGenerator().Generate(file), StringComparison.Ordinal);
+ }
+
+ ///
+ /// An empty import separates groups rather than importing nothing, which is how the standard
+ /// headers are told apart from the project's own.
+ ///
+ [TestMethod]
+ public void File_TreatsAnEmptyImportAsAGroupSeparator()
+ {
+ SourceFile file = new("point") { IsHeader = true };
+ file.Imports.Add("");
+ file.Imports.Add("");
+ file.Imports.Add("point.hpp");
+
+ Assert.Contains(
+ "#include \n\n#include \"point.hpp\"",
+ new CppGenerator().Generate(file).ReplaceLineEndings("\n"),
+ StringComparison.Ordinal);
+ }
+
+ ///
+ /// Every one of these survives a round trip through YAML, which is what makes them a document
+ /// rather than something only the generator can see.
+ ///
+ [TestMethod]
+ public void Yaml_RoundTripsTheWholeFile()
+ {
+ NamespaceDeclaration components = new("holo::components");
+ components.Members.Add(SampleStruct());
+
+ SourceFile original = new("RigidBody.hpp") { IsHeader = true };
+ original.HeaderComment.Add("Generated. Do not edit.");
+ original.Imports.Add("");
+ original.Members.Add(components);
+
+ string yaml = new YamlSerializer().Serialize(original);
+ SourceFile restored = (SourceFile)new YamlDeserializer().Deserialize(yaml)!;
+
+ Assert.IsTrue(restored.IsHeader);
+ Assert.AreEqual("RigidBody.hpp", restored.Name);
+ Assert.AreSequenceEqual(original.HeaderComment, restored.HeaderComment);
+ Assert.AreSequenceEqual(original.Imports, restored.Imports);
+
+ NamespaceDeclaration restoredNamespace = (NamespaceDeclaration)restored.Members[0];
+ Assert.AreEqual("holo::components", restoredNamespace.Name);
+
+ ClassDeclaration restoredStruct = (ClassDeclaration)restoredNamespace.Members[0];
+ Assert.AreEqual(TypeDeclarationKind.Struct, restoredStruct.Kind);
+ Assert.AreSequenceEqual(
+ ["Physical state, integrated each frame."],
+ restoredStruct.Documentation);
+
+ EnumDeclaration restoredEnum = (EnumDeclaration)restoredStruct.Members[0];
+ Assert.AreEqual("std::uint8_t", restoredEnum.UnderlyingType?.ToString());
+ Assert.HasCount(2, restoredEnum.Members);
+ Assert.AreEqual("Static", restoredEnum.Members[0].Name);
+
+ FieldDeclaration restoredField = (FieldDeclaration)restoredStruct.Members[1];
+ Assert.AreEqual("double", restoredField.Type?.ToString());
+ Assert.IsNotNull(restoredField.InitialValue);
+ Assert.AreSequenceEqual(["unit: kg"], restoredField.Documentation);
+ }
+
+ ///
+ /// A document written before these existed still opens, with a class where it says class and no
+ /// documentation where it says nothing.
+ ///
+ [TestMethod]
+ public void Yaml_WritesNothingForWhatADeclarationDoesNotSay()
+ {
+ string yaml = new YamlSerializer().Serialize(new ClassDeclaration("Point"));
+
+ Assert.DoesNotContain("kind", yaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("documentation", yaml, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The inspector offers a row for every property of the new declarations, so each is editable
+ /// rather than only settable in code.
+ ///
+ [TestMethod]
+ public void Fields_CoverTheNewDeclarations()
+ {
+ SourceFile file = new("RigidBody.gen.hpp");
+ NamespaceDeclaration components = new("holo::components");
+ EnumDeclaration kind = new("BodyKind");
+ EnumMember member = new("Static");
+ FieldDeclaration mass = new("mass", "double");
+ UsingAlias alias = new("underlying", "long");
+
+ Assert.IsNotEmpty(AstFields.Of(file));
+ Assert.IsNotEmpty(AstFields.Of(components));
+ Assert.IsNotEmpty(AstFields.Of(kind));
+ Assert.IsNotEmpty(AstFields.Of(member));
+ Assert.IsNotEmpty(AstFields.Of(mass));
+
+ Assert.IsTrue(AstFields.TryWrite(file, "Name", "Other.hpp"));
+ Assert.IsTrue(AstFields.TryWrite(file, "Header", "true"));
+ Assert.IsTrue(AstFields.TryWrite(components, "Name", "holo"));
+ Assert.IsTrue(AstFields.TryWrite(kind, "UnderlyingType", "std::uint8_t"));
+ Assert.IsTrue(AstFields.TryWrite(kind, "Visibility", "Private"));
+ Assert.IsTrue(AstFields.TryWrite(member, "Value", "7"));
+ Assert.IsTrue(AstFields.TryWrite(mass, "Visibility", "Private"));
+
+ Assert.AreEqual("Other.hpp", file.Name);
+ Assert.IsTrue(file.IsHeader);
+ Assert.AreEqual("holo", components.Name);
+ Assert.AreEqual("std::uint8_t", kind.UnderlyingType?.ToString());
+ Assert.AreEqual(Visibility.Private, kind.Visibility);
+ Assert.AreEqual("7", member.Value);
+ Assert.AreEqual(Visibility.Private, mass.Visibility);
+
+ Assert.IsTrue(AstFields.TryWrite(alias, "AliasedType", "std::int64_t"));
+ Assert.AreEqual("std::int64_t", alias.AliasedType?.ToString());
+ }
+
+ ///
+ /// The editor can walk and edit the new declarations, which is what stops a node type existing
+ /// only for whoever builds one in code.
+ ///
+ [TestMethod]
+ public void Graph_ExposesTheNewDeclarations()
+ {
+ ClassDeclaration body = SampleStruct();
+ EnumDeclaration kind = (EnumDeclaration)body.Members[0];
+
+ Assert.HasCount(2, AstSchema.ChildrenOf(kind, AstSchema.SlotsOf(kind)[0]));
+ Assert.IsTrue(AstFields.TryWrite(body, "Kind", "Interface"));
+ Assert.AreEqual(TypeDeclarationKind.Interface, body.Kind);
+
+ FieldDeclaration field = (FieldDeclaration)body.Members[2];
+ Assert.IsTrue(AstFields.TryWrite(field, "Type", "float"));
+ Assert.AreEqual("float", field.Type?.ToString());
+ }
+}
diff --git a/Coder.Test/Graph/DeclarationSlotsTests.cs b/Coder.Test/Graph/DeclarationSlotsTests.cs
new file mode 100644
index 0000000..859bfc4
--- /dev/null
+++ b/Coder.Test/Graph/DeclarationSlotsTests.cs
@@ -0,0 +1,174 @@
+// 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 the declarations a generated header is made of are reachable through
+/// , so the editor can walk and rewire them.
+///
+///
+/// A node type the schema does not know about is one the graph cannot draw and the editor cannot
+/// touch — it exists only for whoever builds one in code. Every slot is exercised the same way the
+/// editor uses it: read what is in it, put something there, swap that for something else, and take it
+/// out again.
+///
+[TestClass]
+public class DeclarationSlotsTests
+{
+ ///
+ /// A file, a namespace and a class each hold members, and each takes one.
+ ///
+ [TestMethod]
+ public void Members_AreReachableOnEveryContainer()
+ {
+ SourceFile file = new("RigidBody.gen.hpp");
+ NamespaceDeclaration components = new("holo::components");
+ EnumDeclaration kind = new("BodyKind");
+
+ Assert.IsTrue(AstSchema.TryAttach(file, AstSchema.SlotsOf(file)[0], components));
+ Assert.IsTrue(AstSchema.TryAttach(components, AstSchema.SlotsOf(components)[0], new ClassDeclaration("RigidBody")));
+ Assert.IsTrue(AstSchema.TryAttach(kind, AstSchema.SlotsOf(kind)[0], new EnumMember("Static")));
+
+ Assert.HasCount(1, AstSchema.ChildrenOf(file, AstSchema.SlotsOf(file)[0]));
+ Assert.HasCount(1, AstSchema.ChildrenOf(components, AstSchema.SlotsOf(components)[0]));
+ Assert.HasCount(1, AstSchema.ChildrenOf(kind, AstSchema.SlotsOf(kind)[0]));
+ }
+
+ ///
+ /// An enumeration takes only its own kind of member, which is what stops a class being dropped
+ /// into one.
+ ///
+ [TestMethod]
+ public void EnumMembers_AreTheOnlyThingAnEnumTakes()
+ {
+ EnumDeclaration kind = new("BodyKind");
+ AstSlot members = AstSchema.SlotsOf(kind)[0];
+
+ Assert.IsTrue(AstSchema.Accepts(members, new EnumMember("Static")));
+ Assert.IsFalse(AstSchema.Accepts(members, new ClassDeclaration("RigidBody")));
+ Assert.IsFalse(AstSchema.TryAttach(kind, members, new ClassDeclaration("RigidBody")));
+ Assert.IsInstanceOfType(AstSchema.CreateDefaultChild(members));
+ }
+
+ ///
+ /// Attaching at a position inside a sequence replaces what is there rather than appending, which
+ /// is what makes connecting to a filled pin swap it.
+ ///
+ [TestMethod]
+ public void Members_AreReplacedInPlace()
+ {
+ EnumDeclaration kind = new("BodyKind");
+ kind.Members.Add(new EnumMember("Static"));
+ kind.Members.Add(new EnumMember("Dynamic"));
+ AstSlot members = AstSchema.SlotsOf(kind)[0];
+
+ Assert.IsTrue(AstSchema.TryAttachAt(kind, members, 0, new EnumMember("Kinematic")));
+
+ Assert.AreEqual("Kinematic", kind.Members[0].Name);
+ Assert.AreEqual("Dynamic", kind.Members[1].Name);
+
+ Assert.IsTrue(AstSchema.TryDetachAt(kind, members, 0));
+ Assert.HasCount(1, kind.Members);
+ }
+
+ ///
+ /// The same for a file's and a namespace's members, which is where a whole subtree is rearranged.
+ ///
+ [TestMethod]
+ public void Containers_ReplaceAndDetachTheirMembers()
+ {
+ SourceFile file = new("RigidBody.gen.hpp");
+ file.Members.Add(new ClassDeclaration("First"));
+ NamespaceDeclaration components = new("holo");
+ components.Members.Add(new ClassDeclaration("First"));
+
+ Assert.IsTrue(AstSchema.TryAttachAt(file, AstSchema.SlotsOf(file)[0], 0, new ClassDeclaration("Second")));
+ Assert.IsTrue(AstSchema.TryAttachAt(components, AstSchema.SlotsOf(components)[0], 0, new ClassDeclaration("Second")));
+
+ Assert.AreEqual("Second", ((ClassDeclaration)file.Members[0]).Name);
+ Assert.AreEqual("Second", ((ClassDeclaration)components.Members[0]).Name);
+
+ Assert.IsTrue(AstSchema.TryDetachAt(file, AstSchema.SlotsOf(file)[0], 0));
+ Assert.IsTrue(AstSchema.TryDetachAt(components, AstSchema.SlotsOf(components)[0], 0));
+
+ Assert.IsEmpty(file.Members);
+ Assert.IsEmpty(components.Members);
+ }
+
+ ///
+ /// A field's initialiser is a single-valued slot: filling it again replaces what was there.
+ ///
+ [TestMethod]
+ public void FieldInitialiser_IsFilledAndEmptied()
+ {
+ FieldDeclaration mass = new("mass", "double");
+ AstSlot initialValue = AstSchema.SlotsOf(mass)[0];
+
+ Assert.IsEmpty(AstSchema.ChildrenOf(mass, initialValue));
+ Assert.IsTrue(AstSchema.TryAttach(mass, initialValue, new LiteralExpression(1.0)));
+ Assert.HasCount(1, AstSchema.ChildrenOf(mass, initialValue));
+
+ Assert.IsTrue(AstSchema.TryDetachAt(mass, initialValue, 0));
+ Assert.IsNull(mass.InitialValue);
+
+ // Emptying one that is already empty changes nothing, so it is not an edit.
+ Assert.IsFalse(AstSchema.TryDetachAt(mass, initialValue, 0));
+ }
+
+ ///
+ /// A member initialiser holds one value, the same way a field does.
+ ///
+ [TestMethod]
+ public void MemberInitialiserValue_IsFilledAndEmptied()
+ {
+ MemberInitialiser initialiser = new("value_");
+ AstSlot value = AstSchema.SlotsOf(initialiser)[0];
+
+ Assert.IsTrue(AstSchema.TryAttach(initialiser, value, new VariableReference("value")));
+ Assert.HasCount(1, AstSchema.ChildrenOf(initialiser, value));
+
+ Assert.IsTrue(AstSchema.TryDetachAt(initialiser, value, 0));
+ Assert.IsNull(initialiser.Value);
+ }
+
+ ///
+ /// A construction's arguments are a sequence, so they are added, swapped and removed in order.
+ ///
+ [TestMethod]
+ public void ConstructionArguments_AreASequence()
+ {
+ ConstructionExpression construction = new(new TypeReference("holo::Kilograms"));
+ AstSlot arguments = AstSchema.SlotsOf(construction)[0];
+
+ Assert.IsTrue(AstSchema.TryAttach(construction, arguments, new LiteralExpression(1.0)));
+ Assert.IsTrue(AstSchema.TryAttach(construction, arguments, new LiteralExpression(2.0)));
+ Assert.HasCount(2, AstSchema.ChildrenOf(construction, arguments));
+
+ Assert.IsTrue(AstSchema.TryAttachAt(construction, arguments, 0, new LiteralExpression(3.0)));
+ Assert.AreEqual(3.0, ((LiteralExpression)construction.Arguments[0]).Value);
+
+ Assert.IsTrue(AstSchema.TryDetachAt(construction, arguments, 0));
+ Assert.HasCount(1, construction.Arguments);
+ }
+
+ ///
+ /// A class takes every kind of declaration a generated type is made of, which is what lets one be
+ /// assembled in the editor rather than only in code.
+ ///
+ [TestMethod]
+ public void Members_AcceptEveryKindOfDeclaration()
+ {
+ ClassDeclaration body = new("RigidBody");
+ AstSlot members = AstSchema.SlotsOf(body)[0];
+
+ Assert.IsTrue(AstSchema.Accepts(members, new FieldDeclaration("mass", "double")));
+ Assert.IsTrue(AstSchema.Accepts(members, new EnumDeclaration("BodyKind")));
+ Assert.IsTrue(AstSchema.Accepts(members, new UsingAlias("underlying", "long")));
+ Assert.IsTrue(AstSchema.Accepts(members, new NamespaceDeclaration("holo")));
+ Assert.IsFalse(AstSchema.Accepts(members, new EnumMember("Static")));
+ }
+}
diff --git a/Coder.Test/Languages/ExemplarHeaderTests.cs b/Coder.Test/Languages/ExemplarHeaderTests.cs
new file mode 100644
index 0000000..0c04380
--- /dev/null
+++ b/Coder.Test/Languages/ExemplarHeaderTests.cs
@@ -0,0 +1,221 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Languages;
+
+using ktsu.Coder.Ast;
+using ktsu.Coder.Languages;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Builds the component from Holotype's docs/generated-cpp-target.md as an AST and checks that
+/// the C++ generator emits the header that document specifies.
+///
+///
+/// That document is the specification for the schema compiler being moved into this stack, written as
+/// its output rather than as a description of it, and its first section is byte-identical to what the
+/// C++ tool it replaces emits today. So this is the acceptance test for the move: it fails if the AST
+/// stops being able to describe a real generated header, rather than only if some emitter changes
+/// shape.
+///
+/// Two differences from the document are deliberate. The static_asserts below the struct are
+/// not here: they are an assertion about a generated type rather than something the schema asked for,
+/// and where they belong is still open. And the indentation is spaces where the document has tabs,
+/// which is a setting of the writer rather than anything the AST decides — Holotype's own
+/// .clang-format reformats generated output to tabs on the way past.
+///
+///
+[TestClass]
+public class ExemplarHeaderTests
+{
+ /// The header the document specifies, less the two assertions and with tabs expanded.
+ private const string Expected =
+ """
+ // Generated by holo_schemac. Do not edit.
+ //
+ // Source: tests/schemas/rigid_body.toml
+ //
+ // Editing this file is editing the wrong thing: it is derived from the schema,
+ // and the next build overwrites it. Change the schema instead.
+
+ #pragma once
+
+ #include
+ #include
+
+ #include "holotype/core/units.hpp"
+ #include "holotype/core/vec.hpp"
+
+ namespace holo::components
+ {
+
+ /// Physical state integrated by the physics system each frame.
+ struct RigidBody
+ {
+ enum class BodyKind : std::uint8_t
+ {
+ Static,
+ Kinematic,
+ Dynamic,
+ };
+
+ /// Linear velocity in world space.
+ /// unit: m/s
+ /// interpolated between states
+ /// network: quantised to 0.01, delta encoded
+ holo::Vec3 velocity{};
+
+ /// unit: kg
+ /// range: [0.001, 1000000]
+ holo::Kilograms mass = holo::Kilograms{ 1.0f };
+
+ /// unit: rad
+ /// range: [0, 6.2831853] (wraps)
+ /// network: quantised to 0.001
+ /// editor: dial
+ holo::Radians heading{};
+
+ /// unit: 1
+ /// range: [0, 1]
+ holo::Scalar restitution = holo::Scalar{ 0.5f };
+
+ BodyKind body_kind = BodyKind::Dynamic;
+ };
+
+ } // namespace holo::components
+ """;
+
+ ///
+ /// Builds the exemplar's component as an AST.
+ ///
+ /// The file.
+ private static SourceFile RigidBodyHeader()
+ {
+ EnumDeclaration bodyKind = new("BodyKind") { UnderlyingType = "std::uint8_t" };
+ bodyKind.Members.Add(new EnumMember("Static"));
+ bodyKind.Members.Add(new EnumMember("Kinematic"));
+ bodyKind.Members.Add(new EnumMember("Dynamic"));
+
+ ClassDeclaration rigidBody = new("RigidBody") { Kind = TypeDeclarationKind.Struct };
+ rigidBody.Documentation.Add("Physical state integrated by the physics system each frame.");
+ rigidBody.Members.Add(bodyKind);
+
+ rigidBody.Members.Add(Field(
+ "velocity",
+ "holo::Vec3",
+ null,
+ "Linear velocity in world space.",
+ "unit: m/s",
+ "interpolated between states",
+ "network: quantised to 0.01, delta encoded"));
+
+ rigidBody.Members.Add(Field(
+ "mass",
+ "holo::Kilograms",
+ "holo::Kilograms{ 1.0f }",
+ "unit: kg",
+ "range: [0.001, 1000000]"));
+
+ rigidBody.Members.Add(Field(
+ "heading",
+ "holo::Radians",
+ null,
+ "unit: rad",
+ "range: [0, 6.2831853] (wraps)",
+ "network: quantised to 0.001",
+ "editor: dial"));
+
+ rigidBody.Members.Add(Field(
+ "restitution",
+ "holo::Scalar",
+ "holo::Scalar{ 0.5f }",
+ "unit: 1",
+ "range: [0, 1]"));
+
+ rigidBody.Members.Add(Field("body_kind", "BodyKind", "BodyKind::Dynamic"));
+
+ NamespaceDeclaration components = new("holo::components");
+ components.Members.Add(rigidBody);
+
+ SourceFile file = new("RigidBody.gen.hpp") { IsHeader = true };
+ file.HeaderComment.Add("Generated by holo_schemac. Do not edit.");
+ file.HeaderComment.Add("");
+ file.HeaderComment.Add("Source: tests/schemas/rigid_body.toml");
+ file.HeaderComment.Add("");
+ file.HeaderComment.Add("Editing this file is editing the wrong thing: it is derived from the schema,");
+ file.HeaderComment.Add("and the next build overwrites it. Change the schema instead.");
+
+ file.Imports.Add("");
+ file.Imports.Add("");
+ file.Imports.Add("");
+ file.Imports.Add("holotype/core/units.hpp");
+ file.Imports.Add("holotype/core/vec.hpp");
+ file.Members.Add(components);
+
+ return file;
+ }
+
+ ///
+ /// Builds one field of the component.
+ ///
+ /// The field's name.
+ /// The field's type.
+ /// What it starts at, or null for the type's own default.
+ /// The lines above it.
+ /// The field.
+ ///
+ /// The initialiser is a holding the construction as written. The
+ /// AST has no node for constructing a value yet, which is the next thing the document asks for and
+ /// the reason the two initialised fields here read as they do.
+ ///
+ private static FieldDeclaration Field(
+ string name,
+ string type,
+ string? initialiser,
+ params string[] documentation)
+ {
+ FieldDeclaration field = new(name, type)
+ {
+ InitialValue = initialiser is null ? null : new VariableReference(initialiser),
+ };
+
+ foreach (string line in documentation)
+ {
+ field.Documentation.Add(line);
+ }
+
+ return field;
+ }
+
+ ///
+ /// The generated header is the one the document specifies.
+ ///
+ [TestMethod]
+ public void Cpp_GeneratesTheHeaderTheDocumentSpecifies() =>
+ Assert.AreEqual(
+ Expected.ReplaceLineEndings("\n").TrimEnd(),
+ new CppGenerator().Generate(RigidBodyHeader()).ReplaceLineEndings("\n").TrimEnd());
+
+ ///
+ /// Field order is struct layout, so it is the one thing about the output that is not cosmetic.
+ ///
+ ///
+ /// Alphabetically these would be body_kind, heading, mass, restitution, velocity. Holotype's own
+ /// schema notes record catching exactly that in a parser whose tables were sorted, and record why
+ /// it matters: renaming a field would silently move it and change the binary layout of every save
+ /// file and every packet carrying the component.
+ ///
+ [TestMethod]
+ public void Cpp_KeepsFieldsInDeclarationOrder()
+ {
+ string code = new CppGenerator().Generate(RigidBodyHeader());
+
+ int previous = -1;
+ foreach (string name in (string[])["velocity", "mass", "heading", "restitution", "body_kind"])
+ {
+ int position = code.IndexOf($" {name}", StringComparison.Ordinal);
+
+ Assert.IsGreaterThan(previous, position, $"{name} is out of declaration order");
+ previous = position;
+ }
+ }
+}
diff --git a/Coder.Test/Languages/ExemplarInterfaceTests.cs b/Coder.Test/Languages/ExemplarInterfaceTests.cs
new file mode 100644
index 0000000..cc31acb
--- /dev/null
+++ b/Coder.Test/Languages/ExemplarInterfaceTests.cs
@@ -0,0 +1,237 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Languages;
+
+using ktsu.Coder.Ast;
+using ktsu.Coder.Languages;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Builds the interface from Holotype's docs/generated-cpp-target.md as an AST and checks that
+/// the C++ generator emits the declaration that document specifies.
+///
+///
+/// The companion to , which covers the component. This one covers
+/// what an implementation is written against, and is where the four conventions that document holds
+/// globally have to survive as C++: fallibility is a Result in the return type, a borrow is a
+/// Span, a parameter's direction is const, and nothing is annotated for lifetime because
+/// nothing owns anything.
+///
+/// Direction on a span describes the elements rather than the view, which is what makes
+/// integrate — a system that reads one component and writes another — an ordinary signature
+/// needing no special concept.
+///
+///
+[TestClass]
+public class ExemplarInterfaceTests
+{
+ /// The declaration the document specifies, with tabs expanded.
+ private const string Expected =
+ """
+ /// The simulation the physics system drives.
+ class IPhysicsWorld
+ {
+ public:
+ IPhysicsWorld() = default;
+ IPhysicsWorld(const IPhysicsWorld&) = delete;
+ IPhysicsWorld& operator=(const IPhysicsWorld&) = delete;
+ virtual ~IPhysicsWorld() = default;
+
+ /// Advance the simulation by one step.
+ virtual void step(holo::Seconds dt) = 0;
+
+ /// Create a body. Fails if the world is at capacity.
+ [[nodiscard]] virtual holo::Result spawn(const holo::components::RigidBody& body) = 0;
+
+ /// Integrate velocities into positions.
+ virtual void integrate(std::span velocities, std::span positions) = 0;
+
+ /// The body for an entity, if it has one.
+ [[nodiscard]] virtual std::optional find(EntityId entity) const = 0;
+ };
+ """;
+
+ ///
+ /// Builds the exemplar's interface as an AST.
+ ///
+ /// The declaration.
+ private static ClassDeclaration PhysicsWorld()
+ {
+ ClassDeclaration world = new("IPhysicsWorld") { Kind = TypeDeclarationKind.Interface };
+ world.Documentation.Add("The simulation the physics system drives.");
+
+ world.Members.Add(new FunctionDeclaration("IPhysicsWorld")
+ {
+ Kind = FunctionKind.Constructor,
+ Definition = FunctionDefinition.Defaulted,
+ });
+
+ // An unnamed parameter, which is what a deleted copy declaration wants: the parameter is
+ // there to make the signature, and naming it would invite someone to look for its use.
+ world.Members.Add(WithParameters(
+ new FunctionDeclaration("IPhysicsWorld")
+ {
+ Kind = FunctionKind.Constructor,
+ Definition = FunctionDefinition.Deleted,
+ },
+ Borrowed(string.Empty, "IPhysicsWorld")));
+
+ world.Members.Add(WithParameters(
+ new FunctionDeclaration("=")
+ {
+ Kind = FunctionKind.Operator,
+ ReturnType = new TypeReference("IPhysicsWorld") { Indirection = TypeIndirection.Reference },
+ Definition = FunctionDefinition.Deleted,
+ },
+ Borrowed(string.Empty, "IPhysicsWorld")));
+
+ world.Members.Add(new FunctionDeclaration("IPhysicsWorld")
+ {
+ Kind = FunctionKind.Destructor,
+ IsVirtual = true,
+ Definition = FunctionDefinition.Defaulted,
+ });
+
+ world.Members.Add(Method(
+ "step",
+ "void",
+ "Advance the simulation by one step.",
+ [new Parameter("dt", "holo::Seconds")]));
+
+ world.Members.Add(Method(
+ "spawn",
+ "holo::Result",
+ "Create a body. Fails if the world is at capacity.",
+ [Borrowed("body", "holo::components::RigidBody")],
+ mustUseResult: true));
+
+ world.Members.Add(Method(
+ "integrate",
+ "void",
+ "Integrate velocities into positions.",
+ [
+ new Parameter("velocities")
+ {
+ Type = new TypeReference("std::span")
+ {
+ TypeArguments = { new TypeReference("Velocity") { IsReadOnly = true } },
+ },
+ },
+ new Parameter("positions")
+ {
+ Type = new TypeReference("std::span") { TypeArguments = { new TypeReference("Position") } },
+ },
+ ]));
+
+ world.Members.Add(Method(
+ "find",
+ "std::optional",
+ "The body for an entity, if it has one.",
+ [new Parameter("entity", "EntityId")],
+ mustUseResult: true,
+ isReadOnly: true));
+
+ return world;
+ }
+
+ ///
+ /// Builds one of the interface's methods: virtual, with no definition of its own.
+ ///
+ /// The method's name.
+ /// What it returns.
+ /// The line above it.
+ /// Its parameters, in order.
+ /// Whether ignoring the result is a mistake.
+ /// Whether calling it leaves the receiver unchanged.
+ /// The method.
+ private static FunctionDeclaration Method(
+ string name,
+ string returnType,
+ string documentation,
+ Parameter[] parameters,
+ bool mustUseResult = false,
+ bool isReadOnly = false)
+ {
+ FunctionDeclaration method = new(name)
+ {
+ ReturnType = returnType,
+ IsAbstract = true,
+ MustUseResult = mustUseResult,
+ IsReadOnly = isReadOnly,
+ };
+
+ method.Documentation.Add(documentation);
+ return WithParameters(method, parameters);
+ }
+
+ ///
+ /// Adds parameters to a declaration.
+ ///
+ /// The declaration to add to.
+ /// The parameters, in order.
+ /// The same declaration.
+ private static FunctionDeclaration WithParameters(FunctionDeclaration declaration, params Parameter[] parameters)
+ {
+ foreach (Parameter parameter in parameters)
+ {
+ declaration.Parameters.Add(parameter);
+ }
+
+ return declaration;
+ }
+
+ ///
+ /// Builds a parameter the callee may read for the length of the call and may not keep.
+ ///
+ /// The parameter's name, empty for one deliberately unnamed.
+ /// The type borrowed.
+ /// The parameter.
+ private static Parameter Borrowed(string name, string type) => new(name)
+ {
+ Type = new TypeReference(type) { IsReadOnly = true, Indirection = TypeIndirection.Reference },
+ };
+
+ ///
+ /// The generated declaration is the one the document specifies.
+ ///
+ [TestMethod]
+ public void Cpp_GeneratesTheInterfaceTheDocumentSpecifies() =>
+ Assert.AreEqual(
+ Expected.ReplaceLineEndings("\n").TrimEnd(),
+ new CppGenerator().Generate(PhysicsWorld()).ReplaceLineEndings("\n").TrimEnd());
+
+ ///
+ /// The declarations that say nothing about themselves stay together, and a documented one gets
+ /// air above it.
+ ///
+ ///
+ /// A run of defaulted and deleted declarations reads as one group rather than as four paragraphs,
+ /// which is the whole reason the rule looks at both members rather than only the one about to be
+ /// written.
+ ///
+ [TestMethod]
+ public void Cpp_GroupsTheDeclarationsThatSayNothing()
+ {
+ string code = new CppGenerator().Generate(PhysicsWorld()).ReplaceLineEndings("\n");
+
+ Assert.Contains(
+ "IPhysicsWorld() = default;\n IPhysicsWorld(const IPhysicsWorld&) = delete;",
+ code,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "virtual ~IPhysicsWorld() = default;\n\n /// Advance",
+ code,
+ StringComparison.Ordinal);
+ }
+
+ ///
+ /// Direction on a span describes the elements, not the view, so a system that reads one component
+ /// and writes another is an ordinary signature.
+ ///
+ [TestMethod]
+ public void Cpp_BorrowsElementsReadOnlyWithoutBorrowingTheViewReadOnly() =>
+ Assert.Contains(
+ "integrate(std::span velocities, std::span positions)",
+ new CppGenerator().Generate(PhysicsWorld()),
+ StringComparison.Ordinal);
+}
diff --git a/Coder.Test/Languages/ExemplarSemanticTypeTests.cs b/Coder.Test/Languages/ExemplarSemanticTypeTests.cs
new file mode 100644
index 0000000..a1865d5
--- /dev/null
+++ b/Coder.Test/Languages/ExemplarSemanticTypeTests.cs
@@ -0,0 +1,191 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Languages;
+
+using ktsu.Coder.Ast;
+using ktsu.Coder.Languages;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Builds the semantic type from Holotype's docs/generated-cpp-target.md as an AST and checks
+/// that the C++ generator emits the declaration that document specifies.
+///
+///
+/// The last of the three sections, and the one whose whole point is what it refuses: an entity id is
+/// a number and so is a texture id, and the type exists so that adding one to the other stops
+/// compiling. Every conversion across the underlying type is explicit in both directions, which is
+/// what the declaration below says and what the assertions at the bottom check by construction.
+///
+/// One difference from the document is deliberate and was measured rather than assumed. It writes the
+/// short accessor on a single line; the generator writes a braced block. Both are stable under
+/// Holotype's own .clang-format — it reformats neither into the other — so this is the
+/// generator's choice rather than something the formatter reconciles, and copying a hand-written
+/// inline style would mean a generator guessing at when a body is short enough.
+///
+///
+[TestClass]
+public class ExemplarSemanticTypeTests
+{
+ ///
+ /// The declaration the document specifies, with tabs expanded and one line changed: the document
+ /// writes value() as { return value_; } on the signature's line, and this is the
+ /// braced form of the same body. That is the only edit — everything else below is the document's
+ /// text.
+ ///
+ private const string Expected =
+ """
+ /// A live entity. Distinct from every other identifier stored as a number.
+ class EntityId
+ {
+ public:
+ using underlying = std::int64_t;
+
+ constexpr EntityId() noexcept = default;
+
+ /// Explicit: a bare number never becomes an EntityId by accident.
+ explicit constexpr EntityId(underlying value) noexcept
+ : value_(value)
+ {
+ }
+
+ /// Named, because getting the number back out is a decision too.
+ [[nodiscard]] constexpr underlying value() const noexcept
+ {
+ return value_;
+ }
+
+ [[nodiscard]] friend constexpr bool operator==(EntityId, EntityId) noexcept = default;
+ [[nodiscard]] friend constexpr auto operator<=>(EntityId, EntityId) noexcept = default;
+
+ private:
+ underlying value_{};
+ };
+ """;
+
+ ///
+ /// Builds the exemplar's semantic type as an AST.
+ ///
+ /// The declaration.
+ private static ClassDeclaration EntityId()
+ {
+ ClassDeclaration entity = new("EntityId");
+ entity.Documentation.Add("A live entity. Distinct from every other identifier stored as a number.");
+
+ entity.Members.Add(new UsingAlias("underlying", "std::int64_t"));
+
+ entity.Members.Add(new FunctionDeclaration("EntityId")
+ {
+ Kind = FunctionKind.Constructor,
+ IsCompileTimeEvaluable = true,
+ IsNoThrow = true,
+ Definition = FunctionDefinition.Defaulted,
+ });
+
+ // Explicit is the whole point: a bare number never becomes an EntityId by accident.
+ FunctionDeclaration fromUnderlying = new("EntityId")
+ {
+ Kind = FunctionKind.Constructor,
+ IsExplicit = true,
+ IsCompileTimeEvaluable = true,
+ IsNoThrow = true,
+ };
+ fromUnderlying.Documentation.Add("Explicit: a bare number never becomes an EntityId by accident.");
+ fromUnderlying.Parameters.Add(new Parameter("value", "underlying"));
+ fromUnderlying.Initialisers.Add(new MemberInitialiser("value_", new VariableReference("value")));
+ entity.Members.Add(fromUnderlying);
+
+ FunctionDeclaration value = new("value")
+ {
+ ReturnType = "underlying",
+ IsPure = true,
+ IsCompileTimeEvaluable = true,
+ IsReadOnly = true,
+ IsNoThrow = true,
+ };
+ value.Documentation.Add("Named, because getting the number back out is a decision too.");
+ value.Body.Add(new ReturnStatement(new VariableReference("value_")));
+ entity.Members.Add(value);
+
+ entity.Members.Add(Comparison("==", "bool"));
+ entity.Members.Add(Comparison("<=>", "auto"));
+
+ entity.Members.Add(new FieldDeclaration("value_", "underlying") { Visibility = Visibility.Private });
+
+ return entity;
+ }
+
+ ///
+ /// Builds one of the comparison operators, which are symmetric and so belong beside the type
+ /// rather than to either operand.
+ ///
+ /// The operator's symbol.
+ /// What it returns.
+ /// The declaration.
+ private static FunctionDeclaration Comparison(string symbol, string returnType)
+ {
+ FunctionDeclaration comparison = new(symbol)
+ {
+ Kind = FunctionKind.Operator,
+ ReturnType = returnType,
+ IsPure = true,
+ IsFriend = true,
+ IsCompileTimeEvaluable = true,
+ IsNoThrow = true,
+ Definition = FunctionDefinition.Defaulted,
+ };
+
+ comparison.Parameters.Add(new Parameter(string.Empty, "EntityId"));
+ comparison.Parameters.Add(new Parameter(string.Empty, "EntityId"));
+ return comparison;
+ }
+
+ ///
+ /// The generated declaration is the one the document specifies.
+ ///
+ [TestMethod]
+ public void Cpp_GeneratesTheSemanticTypeTheDocumentSpecifies() =>
+ Assert.AreEqual(
+ Expected.ReplaceLineEndings("\n").TrimEnd(),
+ new CppGenerator().Generate(EntityId()).ReplaceLineEndings("\n").TrimEnd());
+
+ ///
+ /// A member is initialised rather than assigned, which is the only way to start one that cannot be
+ /// assigned at all.
+ ///
+ [TestMethod]
+ public void Cpp_InitialisesTheMemberRatherThanAssigningToIt()
+ {
+ string code = new CppGenerator().Generate(EntityId()).ReplaceLineEndings("\n");
+
+ Assert.Contains("explicit constexpr EntityId(underlying value) noexcept\n : value_(value)", code, StringComparison.Ordinal);
+ Assert.DoesNotContain("value_ =", code, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A language with no initialiser list assigns instead, at the top of the constructor and in the
+ /// order declared, which is what the initialiser means there.
+ ///
+ [TestMethod]
+ public void OtherLanguages_AssignWhereCppInitialises()
+ {
+ Assert.Contains("this.value_ = value", new CSharpGenerator().Generate(EntityId()), StringComparison.Ordinal);
+ Assert.Contains("self.value_ = value", new PythonGenerator().Generate(EntityId()), StringComparison.Ordinal);
+ }
+
+ ///
+ /// A group of members that say nothing about themselves stays together, and the access label that
+ /// divides them gets air above it.
+ ///
+ [TestMethod]
+ public void Cpp_SeparatesTheGroupsAndNotTheirMembers()
+ {
+ string code = new CppGenerator().Generate(EntityId()).ReplaceLineEndings("\n");
+
+ // The two comparison operators are the same kind of thing and neither is documented, so they
+ // read as one pair.
+ Assert.Contains("operator==(EntityId, EntityId) noexcept = default;\n [[nodiscard]] friend", code, StringComparison.Ordinal);
+
+ // The access changes, which is a divide whatever sits either side of it.
+ Assert.Contains("= default;\n\nprivate:", code, StringComparison.Ordinal);
+ }
+}
diff --git a/Coder/Ast/ClassDeclaration.cs b/Coder/Ast/ClassDeclaration.cs
index dea80ec..0ee35e8 100644
--- a/Coder/Ast/ClassDeclaration.cs
+++ b/Coder/Ast/ClassDeclaration.cs
@@ -16,7 +16,7 @@ namespace ktsu.Coder.Ast;
/// its declaration syntax, and interfaces are a language feature the AST does not model yet.
///
///
-public class ClassDeclaration : AstCompositeNode, IHasVisibility
+public class ClassDeclaration : AstCompositeNode, IHasVisibility, IHasDocumentation
{
///
/// Initializes a new instance of the class.
@@ -36,6 +36,14 @@ public ClassDeclaration()
///
public string? Name { get; set; }
+ ///
+ /// Gets or sets what kind of type this declares.
+ ///
+ public TypeDeclarationKind Kind { get; set; }
+
+ ///
+ public Collection Documentation { get; init; } = [];
+
///
/// Gets or sets the type this class derives from, or null when it derives from nothing.
///
@@ -66,6 +74,7 @@ public override AstNode Clone()
ClassDeclaration clone = new()
{
Name = Name,
+ Kind = Kind,
BaseType = BaseType?.Clone(),
Visibility = Visibility
};
@@ -75,6 +84,11 @@ public override AstNode Clone()
clone.Metadata[key] = value;
}
+ foreach (string line in Documentation)
+ {
+ clone.Documentation.Add(line);
+ }
+
foreach (AstNode member in Members)
{
clone.Members.Add(member.Clone());
diff --git a/Coder/Ast/ConstructionExpression.cs b/Coder/Ast/ConstructionExpression.cs
new file mode 100644
index 0000000..f8e3a1a
--- /dev/null
+++ b/Coder/Ast/ConstructionExpression.cs
@@ -0,0 +1,67 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Builds a value of a type.
+///
+///
+/// The one expression that needs a type rather than a name, which is why it could not exist before
+/// did. Every language spells it differently — a keyword in three of
+/// them and braces in the fourth — so holding it as a type and a list of arguments is what lets each
+/// write its own.
+///
+public class ConstructionExpression : Expression
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ConstructionExpression()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The type to build.
+ public ConstructionExpression(TypeReference? type) => Type = type;
+
+ ///
+ /// Gets or sets the type to build.
+ ///
+ public TypeReference? Type { get; set; }
+
+ ///
+ /// Gets the arguments, in order.
+ ///
+ 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() => "ConstructionExpression";
+
+ ///
+ /// Creates a deep clone of this expression.
+ ///
+ /// A new instance with the same type and cloned arguments.
+ public override AstNode Clone()
+ {
+ ConstructionExpression clone = new() { Type = Type?.Clone() };
+
+ 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/EnumDeclaration.cs b/Coder/Ast/EnumDeclaration.cs
new file mode 100644
index 0000000..9833e74
--- /dev/null
+++ b/Coder/Ast/EnumDeclaration.cs
@@ -0,0 +1,99 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Represents an enumeration declaration in the abstract syntax tree.
+///
+///
+/// Declared inside a type as often as beside one: two types in the same file may each want a member
+/// called Kind, and nesting is what stops the second colliding with the first. So an
+/// enumeration is an ordinary member of as well as a
+/// declaration in its own right.
+///
+public class EnumDeclaration : AstNode, IHasVisibility, IHasDocumentation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public EnumDeclaration()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a name.
+ ///
+ /// The enumeration's name.
+ public EnumDeclaration(string name) => Name = name;
+
+ ///
+ /// Gets or sets the enumeration's name.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the type the values are stored as, or null to let the language choose.
+ ///
+ ///
+ /// Worth saying when the values cross a boundary: an enumeration stored as a machine word can
+ /// dominate the size of the type holding it, and a saved file or a network packet is written in
+ /// whatever width the declaration chose. Only C++ and C# can say it; the others ignore it.
+ ///
+ public TypeReference? UnderlyingType { get; set; }
+
+ ///
+ /// Gets the members, in the order they should be emitted.
+ ///
+ ///
+ /// Order is meaning here in the same way field order is layout: a member with no explicit value
+ /// takes its number from its position, so reordering two of them renumbers both.
+ ///
+ public Collection Members { get; init; } = [];
+
+ ///
+ /// Gets or sets how widely the enumeration is visible.
+ ///
+ public Visibility Visibility { get; set; }
+
+ ///
+ public Collection Documentation { get; init; } = [];
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "EnumDeclaration";
+
+ ///
+ /// Creates a deep clone of this declaration.
+ ///
+ /// A new instance with the same properties and cloned members.
+ public override AstNode Clone()
+ {
+ EnumDeclaration clone = new()
+ {
+ Name = Name,
+ UnderlyingType = UnderlyingType?.Clone(),
+ Visibility = Visibility,
+ };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ foreach (string line in Documentation)
+ {
+ clone.Documentation.Add(line);
+ }
+
+ foreach (EnumMember member in Members)
+ {
+ clone.Members.Add((EnumMember)member.Clone());
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Ast/EnumMember.cs b/Coder/Ast/EnumMember.cs
new file mode 100644
index 0000000..f5b1283
--- /dev/null
+++ b/Coder/Ast/EnumMember.cs
@@ -0,0 +1,63 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+///
+/// One named value of an .
+///
+public class EnumMember : AstNode
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public EnumMember()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a name.
+ ///
+ /// The member's name.
+ public EnumMember(string name) => Name = name;
+
+ ///
+ /// Gets or sets the member's name.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the value the member is fixed to, or null to let it follow the one before it.
+ ///
+ ///
+ /// Text rather than a number: what a language accepts here differs — a literal, another member,
+ /// an expression over them — and the AST has no reason to read it. A member with no value is the
+ /// ordinary case, and pinning one is what a wire format or a saved file needs.
+ ///
+ public string? Value { get; set; }
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "EnumMember";
+
+ ///
+ /// Creates a deep clone of this member.
+ ///
+ /// A new instance with the same properties.
+ public override AstNode Clone()
+ {
+ EnumMember clone = new()
+ {
+ Name = Name,
+ Value = Value,
+ };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Ast/FieldDeclaration.cs b/Coder/Ast/FieldDeclaration.cs
new file mode 100644
index 0000000..82d2b8c
--- /dev/null
+++ b/Coder/Ast/FieldDeclaration.cs
@@ -0,0 +1,96 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Represents a field of a type in the abstract syntax tree.
+///
+///
+/// Distinct from , which is a local. The two are spelled almost
+/// identically and behave differently in the one way that matters here: a field with no initialiser
+/// is a bug waiting to be found in a save file, so C++ writes Type name{}; and the value a
+/// default-constructed instance starts at is the value the declaration described. A local with no
+/// initialiser is ordinary.
+///
+/// Telling them apart structurally rather than by asking where the node happens to sit is what lets
+/// a generator emit each correctly without being handed its context.
+///
+///
+public class FieldDeclaration : AstNode, IHasVisibility, IHasDocumentation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FieldDeclaration()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a name and type.
+ ///
+ /// The field's name.
+ /// The field's type.
+ public FieldDeclaration(string name, TypeReference? type = null)
+ {
+ Name = name;
+ Type = type;
+ }
+
+ ///
+ /// Gets or sets the field's name.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the field's type.
+ ///
+ public TypeReference? Type { get; set; }
+
+ ///
+ /// Gets or sets what the field starts at, or null for the type's own default.
+ ///
+ public Expression? InitialValue { get; set; }
+
+ ///
+ /// Gets or sets how widely the field is visible.
+ ///
+ public Visibility Visibility { get; set; }
+
+ ///
+ public Collection Documentation { get; init; } = [];
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "FieldDeclaration";
+
+ ///
+ /// Creates a deep clone of this declaration.
+ ///
+ /// A new instance with the same properties and a cloned initialiser.
+ public override AstNode Clone()
+ {
+ FieldDeclaration clone = new()
+ {
+ Name = Name,
+ Type = Type?.Clone(),
+ InitialValue = (Expression?)InitialValue?.DeepClone(),
+ Visibility = Visibility,
+ };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ foreach (string line in Documentation)
+ {
+ clone.Documentation.Add(line);
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Ast/FunctionDeclaration.cs b/Coder/Ast/FunctionDeclaration.cs
index fac57ac..1b0840b 100644
--- a/Coder/Ast/FunctionDeclaration.cs
+++ b/Coder/Ast/FunctionDeclaration.cs
@@ -7,7 +7,7 @@ namespace ktsu.Coder.Ast;
///
/// Represents a function declaration in the abstract syntax tree.
///
-public class FunctionDeclaration : AstCompositeNode, IHasVisibility
+public class FunctionDeclaration : AstCompositeNode, IHasVisibility, IHasDocumentation
{
///
/// Initializes a new instance of the class.
@@ -64,6 +64,104 @@ public FunctionDeclaration()
///
public bool IsPure { get; set; }
+ ///
+ /// Gets or sets what this declares.
+ ///
+ public FunctionKind Kind { get; set; }
+
+ ///
+ /// Gets or sets where the behaviour comes from.
+ ///
+ ///
+ /// Named apart from , which holds the statements: this says whether those
+ /// statements are the behaviour at all.
+ ///
+ public FunctionDefinition Definition { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a derived type may replace this.
+ ///
+ public bool IsVirtual { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this has no implementation of its own and a derived
+ /// type must supply one.
+ ///
+ ///
+ /// C++ spells this = 0 and calls it pure virtual, which is a different thing from
+ /// — one says a declaration has no definition, the other says a call has no
+ /// effect. An abstract declaration is virtual whether or not says so,
+ /// since there is nothing else it could be.
+ ///
+ public bool IsAbstract { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether calling this leaves the receiver unchanged.
+ ///
+ ///
+ /// C++ spells this as a trailing const and C# as readonly on a member of a struct.
+ /// Weaker than , which says a call has no effect at all rather than no effect
+ /// on the one object.
+ ///
+ public bool IsReadOnly { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether ignoring the result is a mistake.
+ ///
+ ///
+ /// C++ spells this [[nodiscard]], which is also what earns — purity
+ /// implies it, since a call that does nothing else and whose result is thrown away did nothing at
+ /// all. This says it for a call that does something too: one returning a result that may be a
+ /// failure has to be looked at.
+ ///
+ public bool MustUseResult { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the conversion this declares must be asked for.
+ ///
+ ///
+ /// On a constructor or a conversion operator. C++ spells it explicit and C# spells the
+ /// conversion explicit operator rather than implicit operator. It is how a type that
+ /// shims another says a value never crosses into it by accident.
+ ///
+ public bool IsExplicit { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a call can be evaluated while compiling.
+ ///
+ ///
+ /// C++ spells this constexpr. No other target here can say it of a function, so no other
+ /// writes anything: the call still runs, just not before the program does.
+ ///
+ public bool IsCompileTimeEvaluable { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a call cannot fail.
+ ///
+ ///
+ /// C++ spells this noexcept. Where a schema says fallibility by returning a result rather
+ /// than by throwing, this is what the rest of the declarations get to say about themselves.
+ ///
+ public bool IsNoThrow { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this is declared inside a type but is not a member of
+ /// it.
+ ///
+ ///
+ /// C++ spells this friend, and it is how a symmetric operator is written beside the type it
+ /// is about rather than as a member of one of its operands. Nothing else here has it.
+ ///
+ public bool IsFriend { get; set; }
+
+ ///
+ /// Gets what the type's members start at, as part of constructing it.
+ ///
+ public Collection Initialisers { get; init; } = [];
+
+ ///
+ public Collection Documentation { get; init; } = [];
+
///
/// Gets or sets a list of parameters for the function.
///
@@ -91,8 +189,18 @@ public override AstNode Clone()
Name = Name,
ReturnType = ReturnType?.Clone(),
Visibility = Visibility,
+ Kind = Kind,
+ Definition = Definition,
IsStatic = IsStatic,
- IsPure = IsPure
+ IsPure = IsPure,
+ IsVirtual = IsVirtual,
+ IsAbstract = IsAbstract,
+ IsReadOnly = IsReadOnly,
+ MustUseResult = MustUseResult,
+ IsExplicit = IsExplicit,
+ IsCompileTimeEvaluable = IsCompileTimeEvaluable,
+ IsNoThrow = IsNoThrow,
+ IsFriend = IsFriend
};
// Copy metadata
@@ -101,6 +209,16 @@ public override AstNode Clone()
clone.Metadata[key] = value;
}
+ foreach (string line in Documentation)
+ {
+ clone.Documentation.Add(line);
+ }
+
+ foreach (MemberInitialiser initialiser in Initialisers)
+ {
+ clone.Initialisers.Add((MemberInitialiser)initialiser.Clone());
+ }
+
// Clone parameters
foreach (Parameter parameter in Parameters)
{
diff --git a/Coder/Ast/FunctionDefinition.cs b/Coder/Ast/FunctionDefinition.cs
new file mode 100644
index 0000000..f908098
--- /dev/null
+++ b/Coder/Ast/FunctionDefinition.cs
@@ -0,0 +1,26 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+///
+/// Where a function's behaviour comes from.
+///
+///
+/// A declaration with no statements is ambiguous without this: it could be a function that does
+/// nothing, one whose behaviour the language supplies, or one that exists to be refused. The three
+/// are different things and only the first is an empty body.
+///
+public enum FunctionDefinition
+{
+ /// The statements in are the behaviour.
+ Provided,
+
+ /// The language supplies the behaviour. C++ and C# spell this = default.
+ Defaulted,
+
+ ///
+ /// Calling it is an error the compiler should catch. C++ and C# spell this = delete; a
+ /// language with no such thing has to leave the call to fail at runtime, or not at all.
+ ///
+ Deleted,
+}
diff --git a/Coder/Ast/FunctionKind.cs b/Coder/Ast/FunctionKind.cs
new file mode 100644
index 0000000..a870cd6
--- /dev/null
+++ b/Coder/Ast/FunctionKind.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+///
+/// What a declares.
+///
+///
+/// One node rather than five, for the reason is one: all five have
+/// a name, parameters, a body and the same modifiers, and differ only in how the language spells the
+/// declaration. Splitting them would duplicate the schema, the inspector, the serializer and four
+/// generators to say the same thing five times.
+///
+public enum FunctionKind
+{
+ /// An ordinary function or method.
+ Method,
+
+ /// Builds an instance of the type it belongs to.
+ Constructor,
+
+ /// Runs when an instance of the type it belongs to is finished with.
+ Destructor,
+
+ ///
+ /// Gives an operator a meaning for the type. is the
+ /// operator's symbol.
+ ///
+ Operator,
+
+ ///
+ /// Converts the type to another. is what it converts
+ /// to, and the declaration has no name of its own.
+ ///
+ ConversionOperator,
+}
diff --git a/Coder/Ast/IHasDocumentation.cs b/Coder/Ast/IHasDocumentation.cs
new file mode 100644
index 0000000..c6b3bcb
--- /dev/null
+++ b/Coder/Ast/IHasDocumentation.cs
@@ -0,0 +1,23 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Implemented by a declaration that can carry documentation, so a generator can emit it without
+/// switching on which kind of declaration it is.
+///
+///
+/// Lines rather than one string, because most of what ends up here is not prose: a schema knows a
+/// member's unit, its range and how it is quantised on the wire, and a generated type can hold none
+/// of that, so the facts are written where whoever reads the generated code will see them. Each line
+/// is emitted as one comment.
+///
+public interface IHasDocumentation
+{
+ ///
+ /// Gets the documentation lines, in the order they should be emitted.
+ ///
+ public Collection Documentation { get; }
+}
diff --git a/Coder/Ast/MemberInitialiser.cs b/Coder/Ast/MemberInitialiser.cs
new file mode 100644
index 0000000..e8de776
--- /dev/null
+++ b/Coder/Ast/MemberInitialiser.cs
@@ -0,0 +1,72 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+///
+/// What one member of a type starts at, as part of constructing it.
+///
+///
+/// C++ initialises a member rather than assigning to it, which is the only way to start a member
+/// that cannot be assigned at all — and is the difference between building a value and building an
+/// empty one and then overwriting it.
+///
+/// A language without that distinction assigns instead, at the top of the constructor's body and in
+/// declaration order, which is what the initialiser means there.
+///
+///
+public class MemberInitialiser : AstNode
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MemberInitialiser()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The member being initialised.
+ /// What it starts at.
+ public MemberInitialiser(string name, Expression? value = null)
+ {
+ Name = name;
+ Value = value;
+ }
+
+ ///
+ /// Gets or sets the member being initialised.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets what it starts at.
+ ///
+ public Expression? Value { get; set; }
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "MemberInitialiser";
+
+ ///
+ /// Creates a deep clone of this initialiser.
+ ///
+ /// A new instance with the same properties.
+ public override AstNode Clone()
+ {
+ MemberInitialiser clone = new()
+ {
+ Name = Name,
+ Value = (Expression?)Value?.DeepClone(),
+ };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Ast/NamespaceDeclaration.cs b/Coder/Ast/NamespaceDeclaration.cs
new file mode 100644
index 0000000..53d78cf
--- /dev/null
+++ b/Coder/Ast/NamespaceDeclaration.cs
@@ -0,0 +1,90 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Represents a namespace holding declarations.
+///
+///
+/// is written with either separator — holo::components or
+/// Holo.Components — and each generator splits it and rejoins with its own. That is the one
+/// place a name is taken apart rather than kept whole, because here the separator genuinely differs
+/// between languages rather than merely looking different.
+///
+/// Python and JavaScript have no namespace: a module is the unit of naming in both, and a file is
+/// the module. They emit the members and nothing around them.
+///
+///
+public class NamespaceDeclaration : AstNode, IHasDocumentation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NamespaceDeclaration()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a name.
+ ///
+ /// The namespace's name, in either separator.
+ public NamespaceDeclaration(string name) => Name = name;
+
+ ///
+ /// Gets or sets the namespace's name.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Gets the declarations the namespace holds, in the order they should be emitted.
+ ///
+ public Collection Members { get; init; } = [];
+
+ ///
+ public Collection Documentation { get; init; } = [];
+
+ ///
+ /// Splits a namespace name into its parts, accepting either separator.
+ ///
+ /// The name to split.
+ /// The parts, in order.
+ public static IReadOnlyList Split(string? name) =>
+ string.IsNullOrEmpty(name)
+ ? []
+ : [.. name.Replace("::", ".", StringComparison.Ordinal)
+ .Split('.', StringSplitOptions.RemoveEmptyEntries)];
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "NamespaceDeclaration";
+
+ ///
+ /// Creates a deep clone of this declaration.
+ ///
+ /// A new instance with the same properties and cloned members.
+ public override AstNode Clone()
+ {
+ NamespaceDeclaration clone = new() { Name = Name };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ foreach (string line in Documentation)
+ {
+ clone.Documentation.Add(line);
+ }
+
+ foreach (AstNode member in Members)
+ {
+ clone.Members.Add(member.Clone());
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Ast/SourceFile.cs b/Coder/Ast/SourceFile.cs
new file mode 100644
index 0000000..3e19054
--- /dev/null
+++ b/Coder/Ast/SourceFile.cs
@@ -0,0 +1,112 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Represents a whole source file: what it says about itself, what it depends on, and what it
+/// declares.
+///
+///
+/// is the one part of the AST that does not translate. A C++ include path, a
+/// C# namespace and a Python module are different kinds of thing that happen to appear in the same
+/// position, and no mapping between them exists to be written — so each is carried as the text the
+/// file it is for needs, and a file is built for a language rather than for all of them. Everything
+/// below the imports is language-agnostic as usual.
+///
+/// An empty import separates groups: it emits a blank line rather than an import of nothing, which
+/// is how the standard headers are told apart from the project's own without the AST having to know
+/// which is which.
+///
+///
+public class SourceFile : AstNode
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SourceFile()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a name.
+ ///
+ /// The file's name.
+ public SourceFile(string name) => Name = name;
+
+ ///
+ /// Gets or sets the file's name, which is what it should be written as.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Gets the banner comment lines, emitted above everything else.
+ ///
+ ///
+ /// An ordinary comment rather than a documentation one: this describes the file, and a
+ /// documentation comment describes the declaration that follows it — which, at the top of a file,
+ /// would be whichever declaration happens to come first.
+ ///
+ public Collection HeaderComment { get; init; } = [];
+
+ ///
+ /// Gets what the file depends on, in the order it should be written.
+ ///
+ public Collection Imports { get; init; } = [];
+
+ ///
+ /// Gets the declarations the file holds, in the order they should be emitted.
+ ///
+ public Collection Members { get; init; } = [];
+
+ ///
+ /// Gets or sets a value indicating whether the file exists to be included by others.
+ ///
+ ///
+ /// C++ is the only target here that splits a file in two, and a header needs to say that
+ /// including it twice is including it once. Every other language ignores this.
+ ///
+ public bool IsHeader { get; set; }
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "SourceFile";
+
+ ///
+ /// Creates a deep clone of this file.
+ ///
+ /// A new instance with the same properties and cloned members.
+ public override AstNode Clone()
+ {
+ SourceFile clone = new()
+ {
+ Name = Name,
+ IsHeader = IsHeader,
+ };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ foreach (string line in HeaderComment)
+ {
+ clone.HeaderComment.Add(line);
+ }
+
+ foreach (string import in Imports)
+ {
+ clone.Imports.Add(import);
+ }
+
+ foreach (AstNode member in Members)
+ {
+ clone.Members.Add(member.Clone());
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Ast/TypeDeclarationKind.cs b/Coder/Ast/TypeDeclarationKind.cs
new file mode 100644
index 0000000..f206009
--- /dev/null
+++ b/Coder/Ast/TypeDeclarationKind.cs
@@ -0,0 +1,25 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+///
+/// What kind of type a declares.
+///
+///
+/// One node rather than three, because the three differ only in the keyword and in what a language
+/// assumes about their members — nothing about the shape of the declaration changes. What each
+/// generator does with the difference is its own business: C++ has no interface keyword and spells
+/// one as a class whose members are all public, and Python and JavaScript have neither a struct nor
+/// an interface and spell all three as a class.
+///
+public enum TypeDeclarationKind
+{
+ /// A reference type with private members by default.
+ Class,
+
+ /// A value type with public members by default.
+ Struct,
+
+ /// A set of members an implementation supplies, with no state of its own.
+ Interface,
+}
diff --git a/Coder/Ast/UsingAlias.cs b/Coder/Ast/UsingAlias.cs
new file mode 100644
index 0000000..a6707a7
--- /dev/null
+++ b/Coder/Ast/UsingAlias.cs
@@ -0,0 +1,84 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Gives a type a second name.
+///
+///
+/// Worth declaring rather than repeating: a type that shims another has to say what it is stored as,
+/// and saying it once beside the declaration is what stops every member of it restating the same
+/// thing and one of them eventually disagreeing.
+///
+public class UsingAlias : AstNode, IHasVisibility, IHasDocumentation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public UsingAlias()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a name and a type.
+ ///
+ /// The name being introduced.
+ /// The type it names.
+ public UsingAlias(string name, TypeReference? aliasedType = null)
+ {
+ Name = name;
+ AliasedType = aliasedType;
+ }
+
+ ///
+ /// Gets or sets the name being introduced.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the type it names.
+ ///
+ public TypeReference? AliasedType { get; set; }
+
+ ///
+ /// Gets or sets how widely the alias is visible.
+ ///
+ public Visibility Visibility { get; set; }
+
+ ///
+ public Collection Documentation { get; init; } = [];
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "UsingAlias";
+
+ ///
+ /// Creates a deep clone of this alias.
+ ///
+ /// A new instance with the same properties.
+ public override AstNode Clone()
+ {
+ UsingAlias clone = new()
+ {
+ Name = Name,
+ AliasedType = AliasedType?.Clone(),
+ Visibility = Visibility,
+ };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ foreach (string line in Documentation)
+ {
+ clone.Documentation.Add(line);
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs
index ba679eb..5c9d3f6 100644
--- a/Coder/Languages/CSharpGenerator.cs
+++ b/Coder/Languages/CSharpGenerator.cs
@@ -76,6 +76,24 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
case LiteralExpression doubleLit:
code.Write($"{doubleLit.Value.ToString(CultureInfo.InvariantCulture)}d");
break;
+ case SourceFile file:
+ GenerateSourceFile(file, code);
+ break;
+ case NamespaceDeclaration namespaceDecl:
+ GenerateNamespace(namespaceDecl, code);
+ break;
+ case UsingAlias usingAlias:
+ GenerateUsingAlias(usingAlias, code);
+ break;
+ case ConstructionExpression construction:
+ GenerateConstruction(construction, code);
+ break;
+ case EnumDeclaration enumDecl:
+ GenerateEnum(enumDecl, code);
+ break;
+ case FieldDeclaration field:
+ GenerateField(field, code);
+ break;
case VariableDeclaration varDecl:
GenerateVariableDeclaration(varDecl, code);
break;
@@ -112,7 +130,16 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
///
private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code)
{
- code.Write($"{SpellVisibility(classDecl.Visibility) ?? "public"} class {classDecl.Name ?? "UnnamedClass"}");
+ GenerateDocumentation(classDecl, code);
+
+ string keyword = classDecl.Kind switch
+ {
+ TypeDeclarationKind.Struct => "struct",
+ TypeDeclarationKind.Interface => "interface",
+ _ => "class",
+ };
+
+ code.Write($"{SpellVisibility(classDecl.Visibility) ?? DefaultVisibility} {keyword} {classDecl.Name ?? "UnnamedClass"}");
if (classDecl.BaseType is TypeReference baseType)
{
@@ -125,10 +152,151 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code)
using Scope members = new(code);
foreach (AstNode member in classDecl.Members)
{
+ if (member is FunctionDeclaration method)
+ {
+ GenerateFunction(method, code, classDecl.Name);
+ }
+ else
+ {
+ GenerateInternal(member, code);
+ }
+ }
+ }
+
+ ///
+ protected override string? SpellImport(string import) => $"using {import};";
+
+ ///
+ /// Emits a namespace and its members.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ ///
+ /// Braced rather than file-scoped. A file-scoped namespace has to be the only one in its file and
+ /// cannot contain another, and the AST puts no such restriction on where a namespace may appear —
+ /// so the braced form is the one that is always correct for whatever it is handed.
+ ///
+ private void GenerateNamespace(NamespaceDeclaration namespaceDecl, CodeBlocker code)
+ {
+ GenerateDocumentation(namespaceDecl, code);
+
+ code.WriteLine($"namespace {string.Join(".", NamespaceDeclaration.Split(namespaceDecl.Name))}");
+
+ using Scope members = new(code);
+ bool first = true;
+ foreach (AstNode member in namespaceDecl.Members)
+ {
+ if (!first)
+ {
+ code.NewLine();
+ }
+
+ first = false;
GenerateInternal(member, code);
}
}
+ ///
+ /// Emits an alias giving a type a second name.
+ ///
+ /// The alias to emit.
+ /// The writer to emit into.
+ ///
+ /// C# has a using alias but only at file or namespace scope, never inside a type. One declared as
+ /// a member is therefore said rather than written, since a generated file that silently drops it
+ /// looks complete and is not.
+ ///
+ private void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code)
+ {
+ GenerateDocumentation(usingAlias, code);
+ code.WriteLine($"using {usingAlias.Name} = {MapToCSType(usingAlias.AliasedType ?? new TypeReference(UnknownTypeName))};");
+ }
+
+ ///
+ /// Emits an expression that builds a value.
+ ///
+ /// The expression to emit.
+ /// The writer to emit into.
+ private void GenerateConstruction(ConstructionExpression construction, CodeBlocker code)
+ {
+ code.Write($"new {MapToCSType(construction.Type ?? new TypeReference(UnknownTypeName))}(");
+
+ for (int index = 0; index < construction.Arguments.Count; index++)
+ {
+ if (index > 0)
+ {
+ code.Write(", ");
+ }
+
+ GenerateInternal(construction.Arguments[index], code);
+ }
+
+ code.Write(")");
+ }
+
+ ///
+ /// Emits an enumeration and its members.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ ///
+ /// The underlying type is written only when the declaration names one, because C#'s default is
+ /// int and saying so adds nothing. A member with no value of its own is left unnumbered,
+ /// which is how C# spells "take it from the position" and keeps the declaration readable.
+ ///
+ private void GenerateEnum(EnumDeclaration enumDecl, CodeBlocker code)
+ {
+ GenerateDocumentation(enumDecl, code);
+
+ code.Write($"{SpellVisibility(enumDecl.Visibility) ?? DefaultVisibility} enum {enumDecl.Name ?? "UnnamedEnum"}");
+
+ if (enumDecl.UnderlyingType is TypeReference underlying)
+ {
+ code.Write($" : {MapToCSType(underlying)}");
+ }
+
+ code.WriteLine();
+
+ using Scope members = new(code);
+ foreach (EnumMember member in enumDecl.Members)
+ {
+ code.Write(member.Name ?? "Unnamed");
+
+ if (member.Value is not null)
+ {
+ code.Write($" = {member.Value}");
+ }
+
+ code.WriteLine(",");
+ }
+ }
+
+ ///
+ /// Emits a field of a type.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ ///
+ /// Public unless the declaration says otherwise, matching what every other declaration here does
+ /// with an unset visibility. C#'s own default for a field is private, which is not what someone
+ /// who wrote no modifier on a generated type meant.
+ ///
+ private void GenerateField(FieldDeclaration field, CodeBlocker code)
+ {
+ GenerateDocumentation(field, code);
+
+ code.Write($"{SpellVisibility(field.Visibility) ?? DefaultVisibility} ");
+ code.Write($"{MapToCSType(field.Type ?? new TypeReference(UnknownTypeName))} {field.Name}");
+
+ if (field.InitialValue is not null)
+ {
+ code.Write(" = ");
+ GenerateInternal(field.InitialValue, code);
+ }
+
+ EndStatement(code);
+ }
+
///
/// Emits a function.
///
@@ -139,25 +307,44 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code)
/// hang a using on: a generated file is a fragment, and a short name in it would be one
/// the reader has to arrange for.
///
- private void GenerateFunction(FunctionDeclaration function, CodeBlocker code)
- {
- if (function.IsPure)
- {
- code.WriteLine("[System.Diagnostics.Contracts.Pure]");
- }
+ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code) =>
+ GenerateFunction(function, code, null);
- // Build method signature. A function nobody has given a visibility to is public: an
- // inaccessible method is not what someone who wrote no modifier meant.
- code.Write($"{SpellVisibility(function.Visibility) ?? "public"} ");
+ ///
+ /// Emits a function, which may be a member of a type.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ /// The name of the type it belongs to, when it belongs to one.
+ ///
+ /// C# expresses a defaulted or deleted member by not declaring it: the compiler supplies the one
+ /// and the absence of the other is what makes a call fail to compile. So neither is emitted, and
+ /// a note says which member went and why — a generated file that silently drops one looks
+ /// complete and is not.
+ ///
+ /// A defaulted constructor is the exception, because a type that declares any other constructor
+ /// stops getting one for free. It is written with an empty body, which is exactly what
+ /// = default means there.
+ ///
+ ///
+ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, string? enclosingType)
+ {
+ GenerateDocumentation(function, code);
- if (function.IsStatic)
+ if (function.Definition != FunctionDefinition.Provided
+ && !(function.Kind == FunctionKind.Constructor && function.Definition == FunctionDefinition.Defaulted))
{
- code.Write("static ");
+ string state = function.Definition == FunctionDefinition.Defaulted ? "supplied by the language" : "deleted";
+ WriteInexpressible(code, $"{SpellFunctionName(function, enclosingType)} is {state}, which C# expresses by not declaring it.");
+ return;
}
- code.Write($"{MapToCSType(function.ReturnType ?? new TypeReference("void"))} {function.Name}(");
+ WriteFunctionAttributes(function, code);
+ WriteFunctionModifiers(function, code);
+
+ code.Write(SpellFunctionName(function, enclosingType));
+ code.Write("(");
- // Add parameters
for (int i = 0; i < function.Parameters.Count; i++)
{
if (i > 0)
@@ -168,20 +355,124 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code)
GenerateParameter(function.Parameters[i], code);
}
+ code.Write(")");
+
+ if (function.IsAbstract)
+ {
+ code.WriteLine(";");
+ return;
+ }
+
// The line is ended before the scope opens, so C#'s brace lands on its own line.
- code.WriteLine(")");
+ code.WriteLine();
- // Add body
using Scope body = new(code);
+
+ // C# assigns where C++ initialises. Written before the body's own statements and in the order
+ // declared, which is what the initialiser means where there is no initialiser list.
+ foreach (MemberInitialiser initialiser in function.Initialisers)
+ {
+ code.Write($"this.{initialiser.Name} = ");
+
+ if (initialiser.Value is not null)
+ {
+ GenerateInternal(initialiser.Value, code);
+ }
+
+ EndStatement(code);
+ }
+
foreach (AstNode statement in function.Body)
{
GenerateInternal(statement, code);
}
}
+ ///
+ /// Writes the attributes a declaration earns.
+ ///
+ /// The declaration being emitted.
+ /// The writer to emit into.
+ ///
+ /// Purity already says the result must be used, so a declaration carrying both gets the one
+ /// attribute that says the stronger thing rather than two saying overlapping ones.
+ ///
+ private static void WriteFunctionAttributes(FunctionDeclaration function, CodeBlocker code)
+ {
+ if (function.IsPure)
+ {
+ code.WriteLine("[System.Diagnostics.Contracts.Pure]");
+ return;
+ }
+
+ if (function.MustUseResult)
+ {
+ code.WriteLine("[System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA1806\", Justification = \"The result must be used.\")]");
+ }
+ }
+
+ ///
+ /// Writes the modifiers in front of a declaration, up to and including its return type.
+ ///
+ /// The declaration being emitted.
+ /// The writer to emit into.
+ private static void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker code)
+ {
+ // A function nobody has given a visibility to is public: an inaccessible method is not what
+ // someone who wrote no modifier meant.
+ code.Write($"{SpellVisibility(function.Visibility) ?? DefaultVisibility} ");
+
+ if (function.IsStatic || function.Kind is FunctionKind.Operator or FunctionKind.ConversionOperator)
+ {
+ // A C# operator is always static, whether or not the declaration thought to say so.
+ code.Write("static ");
+ }
+
+ if (function.IsAbstract)
+ {
+ code.Write("abstract ");
+ }
+ else if (function.IsVirtual)
+ {
+ code.Write("virtual ");
+ }
+
+ if (function.IsReadOnly)
+ {
+ code.Write("readonly ");
+ }
+
+ // A constructor, a destructor and a conversion operator have no return type to write.
+ if (function.Kind is FunctionKind.Method or FunctionKind.Operator)
+ {
+ code.Write($"{MapToCSType(function.ReturnType ?? new TypeReference("void"))} ");
+ }
+ }
+
+ ///
+ /// Spells the name a declaration is written under.
+ ///
+ /// The declaration being emitted.
+ /// The name of the type it belongs to, when it belongs to one.
+ /// The name as C# writes it.
+ private static string SpellFunctionName(FunctionDeclaration function, string? enclosingType)
+ {
+ string typeName = enclosingType ?? function.Name ?? "UnnamedType";
+
+ return function.Kind switch
+ {
+ FunctionKind.Constructor => typeName,
+ FunctionKind.Destructor => $"~{typeName}",
+ FunctionKind.Operator => $"operator {function.Name}",
+ FunctionKind.ConversionOperator =>
+ $"{(function.IsExplicit ? "explicit" : "implicit")} operator {MapToCSType(function.ReturnType ?? new TypeReference(UnknownTypeName))}",
+ _ => function.Name ?? "UnnamedFunction",
+ };
+ }
+
private static void GenerateParameter(Parameter parameter, CodeBlocker code)
{
- code.Write($"{MapToCSType(parameter.Type ?? new TypeReference("object"))} {parameter.Name}");
+ code.Write($"{MapToCSType(parameter.Type ?? new TypeReference(UnknownTypeName))} {parameter.Name}");
if (parameter.IsOptional && !string.IsNullOrEmpty(parameter.DefaultValue))
{
@@ -189,6 +480,26 @@ private static void GenerateParameter(Parameter parameter, CodeBlocker code)
}
}
+ ///
+ /// What a declaration nobody gave a visibility to gets.
+ ///
+ ///
+ /// C#'s own default is private for a member and internal for a type. Neither is what someone who
+ /// wrote no modifier on a generated declaration meant: an inaccessible one is not a declaration
+ /// anybody asked for.
+ ///
+ private const string DefaultVisibility = "public";
+
+ ///
+ /// What a declaration that never said what type it is gets.
+ ///
+ ///
+ /// A type is optional on every node that carries one, because a half-built AST is a thing the
+ /// editor has to be able to hold. Emitting the most general type there keeps the output compiling
+ /// while making it obvious which declaration was never finished.
+ ///
+ private const string UnknownTypeName = "object";
+
private static readonly Dictionary TypeMappings = new()
{
{ "str", "string" },
diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs
index 432bc27..f0bbcc3 100644
--- a/Coder/Languages/CppGenerator.cs
+++ b/Coder/Languages/CppGenerator.cs
@@ -22,6 +22,16 @@ public class CppGenerator : StandardLanguageGenerator
///
/// Maps the AST's language-neutral type names onto C++ spellings.
///
+ ///
+ /// What a declaration that never said what type it is gets.
+ ///
+ ///
+ /// A type is optional on every node that carries one, because a half-built AST is a thing the
+ /// editor has to be able to hold. Emitting the most general type there keeps the output compiling
+ /// while making it obvious which declaration was never finished.
+ ///
+ private const string UnknownTypeName = "object";
+
private static readonly Dictionary TypeMappings = new(StringComparer.OrdinalIgnoreCase)
{
{ "str", "std::string" },
@@ -59,26 +69,110 @@ public class CppGenerator : StandardLanguageGenerator
/// compiler-specific __attribute__((pure)) asserts to the optimiser that the call may be
/// elided or duplicated, which is a stronger promise than the AST is in a position to make.
///
- protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl, CodeBlocker code)
+ protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl, CodeBlocker code) =>
+ GenerateFunction(funcDecl, code, null);
+
+ ///
+ /// Emits a function, which may be a member of a type.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ /// The name of the type it belongs to, when it belongs to one.
+ ///
+ /// A constructor and a destructor are named after the type rather than after themselves, so the
+ /// name comes from the class emitter rather than from the declaration. That is what stops the two
+ /// desynchronising when the type is renamed — the alternative is holding the type's name twice
+ /// and hoping.
+ ///
+ private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, string? enclosingType)
{
Ensure.NotNull(funcDecl);
Ensure.NotNull(code);
- if (funcDecl.IsPure)
+ GenerateDocumentation(funcDecl, code);
+
+ // Purity earns [[nodiscard]] on its own: a call that does nothing else and whose result is
+ // thrown away did nothing at all.
+ if (funcDecl.IsPure || funcDecl.MustUseResult)
{
code.Write("[[nodiscard]] ");
}
+ // The order is the one C++ requires and the one it is conventionally written in: what the
+ // caller must not ignore, then where the declaration sits, then how it may be called, then
+ // when it may be evaluated.
+ if (funcDecl.IsFriend)
+ {
+ code.Write("friend ");
+ }
+
+ if (funcDecl.IsExplicit)
+ {
+ code.Write("explicit ");
+ }
+
if (funcDecl.IsStatic)
{
code.Write("static ");
}
- code.Write($"{MapToCppType(funcDecl.ReturnType ?? new TypeReference("void"))} {funcDecl.Name ?? "unnamedFunction"}(");
+ // An abstract declaration is virtual whether or not it was asked to be: there is nothing else
+ // `= 0` could mean.
+ if (funcDecl.IsVirtual || funcDecl.IsAbstract)
+ {
+ code.Write("virtual ");
+ }
+
+ if (funcDecl.IsCompileTimeEvaluable)
+ {
+ code.Write("constexpr ");
+ }
+
+ // A constructor, a destructor and a conversion operator have no return type to write. The
+ // first two have none at all, and the third's is part of its name.
+ if (funcDecl.Kind is FunctionKind.Method or FunctionKind.Operator)
+ {
+ code.Write($"{MapToCppType(funcDecl.ReturnType ?? new TypeReference("void"))} ");
+ }
+
+ code.Write(SpellFunctionName(funcDecl, enclosingType));
+ code.Write("(");
GenerateParameterList(funcDecl.Parameters, code);
+ code.Write(")");
+
+ if (funcDecl.IsReadOnly)
+ {
+ code.Write(" const");
+ }
+
+ if (funcDecl.IsNoThrow)
+ {
+ code.Write(" noexcept");
+ }
+
+ if (funcDecl.IsAbstract)
+ {
+ code.WriteLine(" = 0;");
+ return;
+ }
+
+ switch (funcDecl.Definition)
+ {
+ case FunctionDefinition.Defaulted:
+ code.WriteLine(" = default;");
+ return;
+
+ case FunctionDefinition.Deleted:
+ code.WriteLine(" = delete;");
+ return;
+
+ default:
+ break;
+ }
// The line is ended before the scope opens, so C++'s brace lands on its own line.
- code.WriteLine(")");
+ code.WriteLine();
+ WriteInitialiserList(funcDecl, code);
using Scope body = new(code);
foreach (AstNode statement in funcDecl.Body)
@@ -87,6 +181,140 @@ protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl
}
}
+ ///
+ /// Writes what the type's members start at, between a constructor's signature and its body.
+ ///
+ /// The declaration being emitted.
+ /// The writer to emit into.
+ ///
+ /// Initialising rather than assigning is the only way to start a member that cannot be assigned
+ /// at all, and is the difference between building a value and building an empty one and then
+ /// overwriting it.
+ ///
+ private void WriteInitialiserList(FunctionDeclaration funcDecl, CodeBlocker code)
+ {
+ if (funcDecl.Initialisers.Count == 0)
+ {
+ return;
+ }
+
+ code.Indent();
+ code.Write(": ");
+
+ for (int index = 0; index < funcDecl.Initialisers.Count; index++)
+ {
+ if (index > 0)
+ {
+ code.Write(", ");
+ }
+
+ MemberInitialiser initialiser = funcDecl.Initialisers[index];
+ code.Write($"{initialiser.Name}(");
+
+ if (initialiser.Value is not null)
+ {
+ GenerateInternal(initialiser.Value, code);
+ }
+
+ code.Write(")");
+ }
+
+ code.WriteLine();
+ code.Outdent();
+ }
+
+ ///
+ /// Spells the name a declaration is written under.
+ ///
+ /// The declaration being emitted.
+ /// The name of the type it belongs to, when it belongs to one.
+ /// The name as C++ writes it.
+ private static string SpellFunctionName(FunctionDeclaration funcDecl, string? enclosingType)
+ {
+ string typeName = enclosingType ?? funcDecl.Name ?? "UnnamedType";
+
+ return funcDecl.Kind switch
+ {
+ FunctionKind.Constructor => typeName,
+ FunctionKind.Destructor => $"~{typeName}",
+ FunctionKind.Operator => $"operator{funcDecl.Name}",
+ FunctionKind.ConversionOperator =>
+ $"operator {MapToCppType(funcDecl.ReturnType ?? new TypeReference("void"))}",
+ _ => funcDecl.Name ?? "unnamedFunction",
+ };
+ }
+
+ ///
+ ///
+ /// #pragma once rather than an include guard. Every compiler this targets supports it, and
+ /// a guard needs a macro name unique across the whole program — which the file cannot know it
+ /// has, and which a generator picking one would eventually collide on.
+ ///
+ protected override bool WriteFileDirectives(SourceFile file, CodeBlocker code)
+ {
+ Ensure.NotNull(file);
+ Ensure.NotNull(code);
+
+ if (!file.IsHeader)
+ {
+ return false;
+ }
+
+ code.WriteLine("#pragma once");
+ return true;
+ }
+
+ ///
+ ///
+ /// An import that already carries its own delimiters is written as it stands, because the choice
+ /// between <> and "" says where the compiler should look and only whoever
+ /// wrote the file knows that. One that carries neither is quoted, which is right for a path
+ /// within the project being generated.
+ ///
+ protected override string? SpellImport(string import)
+ {
+ Ensure.NotNull(import);
+
+ bool delimited = (import.StartsWith('<') && import.EndsWith('>'))
+ || (import.StartsWith('"') && import.EndsWith('"'));
+
+ return delimited ? $"#include {import}" : $"#include \"{import}\"";
+ }
+
+ ///
+ ///
+ /// The members are not indented. A namespace usually wraps a whole file, so indenting for it
+ /// would indent everything and buy nothing; the closing brace names what it closes instead, which
+ /// is what tells a reader at the bottom of a long file which one just ended.
+ ///
+ protected override void GenerateNamespaceDeclaration(NamespaceDeclaration namespaceDecl, CodeBlocker code)
+ {
+ Ensure.NotNull(namespaceDecl);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(namespaceDecl, code);
+
+ string name = string.Join("::", NamespaceDeclaration.Split(namespaceDecl.Name));
+ code.WriteLine($"namespace {name}");
+ code.WriteLine("{");
+ code.NewLine();
+
+ bool first = true;
+ foreach (AstNode member in namespaceDecl.Members)
+ {
+ if (!first)
+ {
+ code.NewLine();
+ }
+
+ first = false;
+ GenerateInternal(member, code);
+ }
+
+ code.NewLine();
+ code.WriteLine($"}} // namespace {name}");
+ }
+
///
///
/// Members are grouped under the access label each one asks for, and a member with no visibility
@@ -103,7 +331,13 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod
Ensure.NotNull(classDecl);
Ensure.NotNull(code);
- code.Write($"class {classDecl.Name ?? "UnnamedClass"}");
+ GenerateDocumentation(classDecl, code);
+
+ // A struct's members are public already, so labelling them would be noise. An interface has
+ // no keyword in C++ and is a class whose members are all public.
+ bool isStruct = classDecl.Kind == TypeDeclarationKind.Struct;
+
+ code.Write($"{(isStruct ? "struct" : "class")} {classDecl.Name ?? "UnnamedClass"}");
if (classDecl.BaseType is TypeReference baseType)
{
@@ -115,27 +349,169 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod
// A C++ class declaration is a statement, so its closing brace takes a semicolon.
using ScopeWithTrailingSemicolon body = new(code);
- // Unspecified rather than Public, so the first member always writes its label: an unlabelled
- // C++ class body is private, which is the one thing the label has to rule out.
- Visibility current = Visibility.Unspecified;
+ // Unspecified rather than Public, so the first member of a class always writes its label: an
+ // unlabelled C++ class body is private, which is the one thing the label has to rule out.
+ Visibility current = isStruct ? Visibility.Public : Visibility.Unspecified;
+ bool first = true;
+ AstNode? previous = null;
foreach (AstNode member in classDecl.Members)
{
- Visibility access = AccessOf(member);
+ Visibility access = classDecl.Kind == TypeDeclarationKind.Interface
+ ? Visibility.Public
+ : AccessOf(member);
+
+ // A blank line goes between two members when either says something about itself, when
+ // they are different kinds of thing, or where the access changes. A documented member
+ // needs air above it or its first comment line butts against the member before it and
+ // reads as belonging to that one; the member after a documented one needs the same, or it
+ // is swallowed into that block. Two of the same kind that say nothing stay together,
+ // which is what keeps a run of aliases, or of defaulted and deleted declarations, reading
+ // as one group rather than as four paragraphs.
+ if (!first && (NeedsSeparation(previous!, member) || access != current))
+ {
+ // NewLine rather than WriteLine: a separator carrying the current indent is a line of
+ // trailing whitespace, which every formatter strips and every diff then shows.
+ code.NewLine();
+ }
+
+ first = false;
+ previous = member;
+
if (access != current)
{
+ // An access label sits at the class's own indentation rather than the members', which
+ // is what makes it read as dividing them rather than as one of them.
+ code.Outdent();
code.WriteLine($"{SpellVisibility(access)}:");
+ code.Indent();
current = access;
}
- if (member is VariableDeclaration field)
+ switch (member)
{
- GenerateField(field, code);
+ case VariableDeclaration field:
+ GenerateField(field, code);
+ break;
+
+ case FunctionDeclaration method:
+ GenerateFunction(method, code, classDecl.Name);
+ break;
+
+ default:
+ GenerateInternal(member, code);
+ break;
}
- else
+ }
+ }
+
+ ///
+ protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code)
+ {
+ Ensure.NotNull(usingAlias);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(usingAlias, code);
+ code.Write($"using {usingAlias.Name} = {MapToCppType(usingAlias.AliasedType ?? new TypeReference(UnknownTypeName))}");
+ EndStatement(code);
+ }
+
+ ///
+ ///
+ /// Braced rather than parenthesised. Braces will not narrow a value silently, and a construction
+ /// with one argument written with parentheses can be read as a declaration instead — which is a
+ /// mistake a generator should never be able to make.
+ ///
+ protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code)
+ {
+ Ensure.NotNull(construction);
+ Ensure.NotNull(code);
+
+ code.Write(MapToCppType(construction.Type ?? new TypeReference(UnknownTypeName)));
+
+ if (construction.Arguments.Count == 0)
+ {
+ code.Write("{}");
+ return;
+ }
+
+ code.Write("{ ");
+ for (int index = 0; index < construction.Arguments.Count; index++)
+ {
+ if (index > 0)
{
- GenerateInternal(member, code);
+ code.Write(", ");
}
+
+ GenerateInternal(construction.Arguments[index], code);
}
+
+ code.Write(" }");
+ }
+
+ ///
+ ///
+ /// Always enum class, never the unscoped form: an unscoped enumeration leaks its members
+ /// into the surrounding scope and converts to an integer without being asked, and neither is
+ /// something a generated type should do to the code around it.
+ ///
+ protected override void GenerateEnumDeclaration(EnumDeclaration enumDecl, CodeBlocker code)
+ {
+ Ensure.NotNull(enumDecl);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(enumDecl, code);
+
+ code.Write($"enum class {enumDecl.Name ?? "UnnamedEnum"}");
+
+ if (enumDecl.UnderlyingType is TypeReference underlying)
+ {
+ code.Write($" : {MapToCppType(underlying)}");
+ }
+
+ code.WriteLine();
+
+ using ScopeWithTrailingSemicolon body = new(code);
+ foreach (EnumMember member in enumDecl.Members)
+ {
+ code.Write(member.Name ?? "Unnamed");
+
+ if (member.Value is not null)
+ {
+ code.Write($" = {member.Value}");
+ }
+
+ // A trailing comma on the last member too, so adding one after it is a one-line diff.
+ code.WriteLine(",");
+ }
+ }
+
+ ///
+ ///
+ /// A field with no initialiser is written {} rather than left bare. An uninitialised
+ /// member holds whatever was in that memory, and a generated type is usually one whose values
+ /// come from a file or the wire — so the one place it must be right is the case nobody wrote
+ /// anything for.
+ ///
+ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlocker code)
+ {
+ Ensure.NotNull(field);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(field, code);
+
+ code.Write($"{MapToCppType(field.Type ?? new TypeReference(UnknownTypeName))} {field.Name}");
+
+ if (field.InitialValue is not null)
+ {
+ code.Write(" = ");
+ GenerateInternal(field.InitialValue, code);
+ }
+ else
+ {
+ code.Write("{}");
+ }
+
+ EndStatement(code);
}
///
@@ -150,6 +526,25 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod
_ => Visibility.Public,
};
+ ///
+ /// Reports whether two adjacent members want a blank line between them.
+ ///
+ /// The member already written.
+ /// The member about to be written.
+ /// True when a blank line belongs between them.
+ private static bool NeedsSeparation(AstNode previous, AstNode member) =>
+ previous.GetType() != member.GetType()
+ || IsDocumented(previous)
+ || IsDocumented(member);
+
+ ///
+ /// Reports whether a member carries documentation.
+ ///
+ /// The member to test.
+ /// True when it does.
+ private static bool IsDocumented(AstNode member) =>
+ member is IHasDocumentation documented && documented.Documentation.Count > 0;
+
///
/// Emits a variable declaration as a class member.
///
@@ -217,7 +612,16 @@ protected override void GenerateParameter(Parameter parameter, CodeBlocker code,
Ensure.NotNull(parameter);
Ensure.NotNull(code);
- code.Write($"{MapToCppType(parameter.Type ?? new TypeReference("object"))} {parameter.Name ?? $"param{position}"}");
+ code.Write(MapToCppType(parameter.Type ?? new TypeReference(UnknownTypeName)));
+
+ // An empty name means deliberately unnamed, which C++ allows and a deleted copy constructor
+ // wants: the parameter exists to make the signature, and naming it would only invite someone
+ // to look for where it is used. A null name means nobody said, so one is invented.
+ if (parameter.Name is not "")
+ {
+ code.Write($" {parameter.Name ?? $"param{position}"}");
+ }
+
AppendDefaultValue(parameter, code);
}
diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs
index 768d9d2..0145088 100644
--- a/Coder/Languages/JavaScriptGenerator.cs
+++ b/Coder/Languages/JavaScriptGenerator.cs
@@ -2,6 +2,7 @@
namespace ktsu.Coder.Languages;
+using System.Globalization;
using ktsu.Coder.Ast;
using ktsu.CodeBlocker;
@@ -32,12 +33,144 @@ public class JavaScriptGenerator : StandardLanguageGenerator
///
public override string FileExtension => "js";
+ ///
+ ///
+ /// JavaScript's documentation convention is a /** */ block, which is a shape the shared
+ /// emitter's line-at-a-time form cannot write. A // comment carries the same lines to the
+ /// same reader without pretending to be JSDoc, which a tool would then read and find no tags in.
+ ///
+ protected override string DocumentationPrefix => "//";
+
+ ///
+ protected override string? SpellImport(string import) => $"import \"{import}\";";
+
+ ///
+ /// Emits an enumeration declared inside a class, as a static member of it.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ ///
+ /// A class body is not a block: const is a syntax error inside one, so the namespace-scope
+ /// spelling cannot simply be nested. A static field is the same object reachable by the same
+ /// name, which is what nesting was for.
+ ///
+ private void GenerateNestedEnum(EnumDeclaration enumDecl, CodeBlocker code)
+ {
+ GenerateDocumentation(enumDecl, code);
+ code.WriteLine($"static {enumDecl.Name ?? "UnnamedEnum"} = Object.freeze({{");
+
+ using (IndentScope members = new(code))
+ {
+ WriteEnumMembers(enumDecl, code);
+ }
+
+ code.WriteLine("});");
+ }
+
+ ///
+ /// Writes an enumeration's members as the properties of an object literal.
+ ///
+ /// The declaration whose members to write.
+ /// The writer to emit into.
+ private static void WriteEnumMembers(EnumDeclaration enumDecl, CodeBlocker code)
+ {
+ for (int index = 0; index < enumDecl.Members.Count; index++)
+ {
+ EnumMember member = enumDecl.Members[index];
+ string value = member.Value ?? index.ToString(CultureInfo.InvariantCulture);
+ code.WriteLine($"{member.Name ?? "UNNAMED"}: {value},");
+ }
+ }
+
+ ///
+ ///
+ /// JavaScript has no types to alias. The name is bound to whatever the alias named, which is a
+ /// constructor often enough to be worth writing rather than dropping.
+ ///
+ protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code)
+ {
+ Ensure.NotNull(usingAlias);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(usingAlias, code);
+ code.Write($"const {usingAlias.Name} = {usingAlias.AliasedType?.Name ?? "Object"}");
+ EndStatement(code);
+ }
+
+ ///
+ protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code)
+ {
+ Ensure.NotNull(construction);
+ Ensure.NotNull(code);
+
+ code.Write($"new {construction.Type?.Name ?? "Object"}(");
+
+ for (int index = 0; index < construction.Arguments.Count; index++)
+ {
+ if (index > 0)
+ {
+ code.Write(", ");
+ }
+
+ GenerateInternal(construction.Arguments[index], code);
+ }
+
+ code.Write(")");
+ }
+
+ ///
+ ///
+ /// JavaScript has no enumeration. A frozen object is the convention: the members are reachable by
+ /// name, and freezing is what stops one being reassigned somewhere far from here. A member with
+ /// no value of its own is numbered from its position.
+ ///
+ protected override void GenerateEnumDeclaration(EnumDeclaration enumDecl, CodeBlocker code)
+ {
+ Ensure.NotNull(enumDecl);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(enumDecl, code);
+ code.WriteLine($"const {enumDecl.Name ?? "UnnamedEnum"} = Object.freeze({{");
+
+ using (IndentScope members = new(code))
+ {
+ WriteEnumMembers(enumDecl, code);
+ }
+
+ code.WriteLine("});");
+ }
+
+ ///
+ ///
+ /// A class field, which JavaScript writes without a type since it has none to write. A field with
+ /// no initialiser is still declared: the property then exists on every instance, which is what
+ /// makes the shape of an object predictable rather than growing as it is assigned to.
+ ///
+ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlocker code)
+ {
+ Ensure.NotNull(field);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(field, code);
+ code.Write(MemberName(field.Name ?? "unnamed", field.Visibility));
+
+ if (field.InitialValue is not null)
+ {
+ code.Write(" = ");
+ GenerateInternal(field.InitialValue, code);
+ }
+
+ EndStatement(code);
+ }
+
///
protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl, CodeBlocker code)
{
Ensure.NotNull(funcDecl);
Ensure.NotNull(code);
+ GenerateDocumentation(funcDecl, code);
+
code.Write($"function {funcDecl.Name ?? "unnamedFunction"}(");
GenerateParameterList(funcDecl.Parameters, code);
@@ -86,6 +219,10 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod
GenerateMethod(method, code);
break;
+ case EnumDeclaration nested:
+ GenerateNestedEnum(nested, code);
+ break;
+
// A field is not a variable: `let` is a statement keyword and a syntax error in a
// class body, so the declaration is emitted as the name and its initializer alone.
case VariableDeclaration field:
@@ -132,16 +269,58 @@ private void GenerateField(VariableDeclaration field, CodeBlocker code)
/// The writer to emit into.
private void GenerateMethod(FunctionDeclaration method, CodeBlocker code)
{
+ GenerateDocumentation(method, code);
+
+ if (method.Definition != FunctionDefinition.Provided)
+ {
+ string state = method.Definition == FunctionDefinition.Defaulted ? "supplied by the language" : "deleted";
+ WriteInexpressible(code, $"{method.Name} is {state}, which JavaScript has no way to say.");
+ return;
+ }
+
+ if (method.Kind is FunctionKind.Destructor or FunctionKind.Operator or FunctionKind.ConversionOperator)
+ {
+ WriteInexpressible(code, $"{method.Name} has no JavaScript spelling.");
+ return;
+ }
+
if (method.IsStatic)
{
code.Write("static ");
}
- code.Write($"{MemberName(method.Name ?? "unnamedMethod", method.Visibility)}(");
+ code.Write(method.Kind == FunctionKind.Constructor
+ ? "constructor"
+ : MemberName(method.Name ?? "unnamedMethod", method.Visibility));
+
+ code.Write("(");
GenerateParameterList(method.Parameters, code);
code.Write(") ");
using Scope body = new(code);
+
+ // A method a subclass has to supply is one whose body refuses. JavaScript has no declaration
+ // without a definition, so the definition is where that is said.
+ if (method.IsAbstract)
+ {
+ code.WriteLine($"throw new Error(\"{method.Name} must be implemented\");");
+ return;
+ }
+
+ // JavaScript assigns where C++ initialises, before the body's own statements and in the order
+ // declared, which is what the initialiser means where there is no initialiser list.
+ foreach (MemberInitialiser initialiser in method.Initialisers)
+ {
+ code.Write($"this.{initialiser.Name} = ");
+
+ if (initialiser.Value is not null)
+ {
+ GenerateInternal(initialiser.Value, code);
+ }
+
+ EndStatement(code);
+ }
+
foreach (AstNode statement in method.Body)
{
GenerateInternal(statement, code);
diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs
index 06afe9e..12ec77f 100644
--- a/Coder/Languages/LanguageGeneratorBase.cs
+++ b/Coder/Languages/LanguageGeneratorBase.cs
@@ -151,6 +151,137 @@ protected bool TryGenerateCommonNode(AstNode node, CodeBlocker code)
/// The keyword the language uses.
protected virtual string FormatBoolean(bool value) => value ? "true" : "false";
+ ///
+ /// Gets what a documentation comment starts with in this language.
+ ///
+ ///
+ /// C-family languages that have a documentation comment write ///; one that does not, or a
+ /// language whose documentation is a construct rather than a comment, overrides this with its own
+ /// ordinary comment marker. Writing an ordinary comment is the honest fallback: the lines still
+ /// reach the reader, and nothing pretends to be a docstring that is not one.
+ ///
+ protected virtual string DocumentationPrefix => "///";
+
+ ///
+ /// Writes a note where the language has no way to say what a declaration asked for.
+ ///
+ /// The writer to emit into.
+ /// What was asked for, in the reader's terms.
+ ///
+ /// A generated file that silently drops a member is worse than one that says which member and
+ /// why: the first looks complete and is not, and there is nothing in it to search for.
+ ///
+ protected void WriteInexpressible(CodeBlocker code, string what)
+ {
+ Ensure.NotNull(code);
+ code.WriteLine($"{CommentPrefix} {what}");
+ }
+
+ ///
+ /// Gets what an ordinary comment starts with in this language.
+ ///
+ protected virtual string CommentPrefix => "//";
+
+ ///
+ /// Spells one of a file's imports, or reports that the language has nothing to write for it.
+ ///
+ /// The import as the file carries it.
+ /// The line to write, or null when the language has no import statement.
+ protected virtual string? SpellImport(string import) => null;
+
+ ///
+ /// Emits whatever a file needs before its imports.
+ ///
+ /// The file being emitted.
+ /// The writer to emit into.
+ /// True if anything was written.
+ ///
+ /// Empty for every language but C++, which is the only one here where a file can be included
+ /// twice and has to say what that means.
+ ///
+ protected virtual bool WriteFileDirectives(SourceFile file, CodeBlocker code) => false;
+
+ ///
+ /// Emits a whole source file: its banner, what it depends on, and what it declares.
+ ///
+ /// The file to emit.
+ /// The writer to emit into.
+ protected void GenerateSourceFile(SourceFile file, CodeBlocker code)
+ {
+ Ensure.NotNull(file);
+ Ensure.NotNull(code);
+
+ bool wroteAnything = false;
+
+ foreach (string line in file.HeaderComment)
+ {
+ code.WriteLine(line.Length == 0 ? CommentPrefix : $"{CommentPrefix} {line}");
+ wroteAnything = true;
+ }
+
+ if (wroteAnything)
+ {
+ code.NewLine();
+ }
+
+ if (WriteFileDirectives(file, code))
+ {
+ code.NewLine();
+ }
+
+ bool wroteImport = false;
+ foreach (string import in file.Imports)
+ {
+ // An empty import is a group separator rather than an import of nothing.
+ if (import.Length == 0)
+ {
+ code.NewLine();
+ continue;
+ }
+
+ if (SpellImport(import) is string spelled)
+ {
+ code.WriteLine(spelled);
+ wroteImport = true;
+ }
+ }
+
+ if (wroteImport)
+ {
+ code.NewLine();
+ }
+
+ bool first = true;
+ foreach (AstNode member in file.Members)
+ {
+ if (!first)
+ {
+ code.NewLine();
+ }
+
+ first = false;
+ GenerateInternal(member, code);
+ }
+ }
+
+ ///
+ /// Emits a declaration's documentation, one comment per line.
+ ///
+ /// The declaration whose documentation to emit.
+ /// The writer to emit into.
+ protected void GenerateDocumentation(IHasDocumentation node, CodeBlocker code)
+ {
+ Ensure.NotNull(node);
+ Ensure.NotNull(code);
+
+ foreach (string line in node.Documentation)
+ {
+ // A blank line is written as a bare marker rather than one with a trailing space, which
+ // every formatter and most reviewers would strip anyway.
+ code.WriteLine(line.Length == 0 ? DocumentationPrefix : $"{DocumentationPrefix} {line}");
+ }
+ }
+
///
/// Ends a statement with the terminator and line break the language uses.
///
@@ -263,7 +394,15 @@ protected static bool CanGenerateStandardNodes(AstNode astNode)
{
// No null check: a type pattern never matches null.
return astNode is FunctionDeclaration
+ or UsingAlias
+ or MemberInitialiser
+ or ConstructionExpression
+ or SourceFile
+ or NamespaceDeclaration
or ClassDeclaration
+ or EnumDeclaration
+ or EnumMember
+ or FieldDeclaration
or EntryPoint
or Parameter
or ReturnStatement
diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs
index ef5be25..89dd745 100644
--- a/Coder/Languages/PythonGenerator.cs
+++ b/Coder/Languages/PythonGenerator.cs
@@ -2,6 +2,7 @@
namespace ktsu.Coder.Languages;
+using System.Globalization;
using ktsu.Coder.Ast;
using ktsu.CodeBlocker;
@@ -50,12 +51,130 @@ protected override void EndStatement(CodeBlocker code)
// Python statements end at the newline the caller writes.
}
+ ///
+ ///
+ /// Python's documentation is a docstring rather than a comment, and a docstring belongs inside
+ /// the construct it documents — which is a different shape from every other language here. Rather
+ /// than move the lines somewhere the other three cannot follow, they are emitted as ordinary
+ /// # comments: the reader still gets them, and nothing claims to be a docstring that is
+ /// not one.
+ ///
+ protected override string DocumentationPrefix => "#";
+
+ ///
+ protected override string CommentPrefix => "#";
+
+ ///
+ protected override string? SpellImport(string import) => $"import {import}";
+
+ ///
+ ///
+ /// An alias is an ordinary assignment in Python, which is what a type alias is there.
+ ///
+ protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code)
+ {
+ Ensure.NotNull(usingAlias);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(usingAlias, code);
+ code.WriteLine($"{usingAlias.Name} = {PythonTypeFromGenericType(usingAlias.AliasedType ?? new TypeReference("object"))}");
+ }
+
+ ///
+ protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code)
+ {
+ Ensure.NotNull(construction);
+ Ensure.NotNull(code);
+
+ code.Write($"{PythonTypeFromGenericType(construction.Type ?? new TypeReference("object"))}(");
+ WriteArguments(construction, code);
+ code.Write(")");
+ }
+
+ ///
+ /// Writes a construction's arguments, separated by commas.
+ ///
+ /// The expression whose arguments to write.
+ /// The writer to emit into.
+ private void WriteArguments(ConstructionExpression construction, CodeBlocker code)
+ {
+ for (int index = 0; index < construction.Arguments.Count; index++)
+ {
+ if (index > 0)
+ {
+ code.Write(", ");
+ }
+
+ GenerateInternal(construction.Arguments[index], code);
+ }
+ }
+
+ ///
+ ///
+ /// Python has no enumeration syntax; enum.Enum is a class. A member with no value of its
+ /// own is numbered from its position, matching what a language with real enumerations would give
+ /// it. The from enum import Enum this needs belongs to the file rather than to the
+ /// declaration.
+ ///
+ protected override void GenerateEnumDeclaration(EnumDeclaration enumDecl, CodeBlocker code)
+ {
+ Ensure.NotNull(enumDecl);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(enumDecl, code);
+ code.WriteLine($"class {enumDecl.Name ?? "UnnamedEnum"}(Enum):");
+
+ using IndentScope body = new(code);
+ if (enumDecl.Members.Count == 0)
+ {
+ code.WriteLine("pass");
+ return;
+ }
+
+ for (int index = 0; index < enumDecl.Members.Count; index++)
+ {
+ EnumMember member = enumDecl.Members[index];
+ string value = member.Value ?? index.ToString(CultureInfo.InvariantCulture);
+ code.WriteLine($"{member.Name ?? "UNNAMED"} = {value}");
+ }
+ }
+
+ ///
+ ///
+ /// A field is written as an annotated class attribute. One with no initialiser is left as a bare
+ /// annotation, which is what a dataclass and a type checker both read as "this field exists and
+ /// has this type" without also claiming a value for it.
+ ///
+ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlocker code)
+ {
+ Ensure.NotNull(field);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(field, code);
+ code.Write(field.Name ?? "unnamed");
+
+ if (field.Type is TypeReference type)
+ {
+ code.Write($": {PythonTypeFromGenericType(type)}");
+ }
+
+ if (field.InitialValue is not null)
+ {
+ code.Write(" = ");
+ GenerateInternal(field.InitialValue, code);
+ }
+
+ code.WriteLine();
+ }
+
///
protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl, CodeBlocker code)
{
Ensure.NotNull(funcDecl);
Ensure.NotNull(code);
+ GenerateDocumentation(funcDecl, code);
+
// Function signature
code.Write($"def {funcDecl.Name ?? "unnamed_function"}(");
GenerateParameterList(funcDecl.Parameters, code);
@@ -151,13 +270,66 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod
///
///
private void GenerateMethod(FunctionDeclaration method, CodeBlocker code)
+ {
+ GenerateDocumentation(method, code);
+
+ if (method.Definition != FunctionDefinition.Provided)
+ {
+ string state = method.Definition == FunctionDefinition.Defaulted ? "supplied by the language" : "deleted";
+ WriteInexpressible(code, $"{method.Name} is {state}, which Python has no way to say.");
+ return;
+ }
+
+ if (method.Kind is FunctionKind.Operator or FunctionKind.ConversionOperator)
+ {
+ WriteInexpressible(code, $"operator {method.Name} has no Python spelling.");
+ return;
+ }
+
+ WriteMethodSignature(method, code);
+
+ using IndentScope body = new(code);
+
+ // A method a derived class has to supply is a method whose body is a refusal. Python has no
+ // declaration without a definition, so the definition says what calling it means.
+ if (method.IsAbstract)
+ {
+ code.WriteLine("raise NotImplementedError");
+ return;
+ }
+
+ WriteInitialiserAssignments(method, code);
+
+ if (method.Body.Count == 0 && method.Initialisers.Count == 0)
+ {
+ code.WriteLine("pass");
+ return;
+ }
+
+ foreach (AstNode statement in method.Body)
+ {
+ GenerateInternal(statement, code);
+ code.WriteLine();
+ }
+ }
+
+ ///
+ /// Writes a method's signature, up to and including the colon that opens its suite.
+ ///
+ /// The declaration being emitted.
+ /// The writer to emit into.
+ ///
+ /// The receiver is supplied here rather than carried in the AST, because no other target language
+ /// has one — and it is left out of a static method, which is what @staticmethod means.
+ ///
+ private void WriteMethodSignature(FunctionDeclaration method, CodeBlocker code)
{
if (method.IsStatic)
{
code.WriteLine("@staticmethod");
}
- code.Write($"def {method.Name ?? "unnamed_method"}(");
+ code.Write($"def {SpellMethodName(method)}(");
bool needsSeparator = !method.IsStatic;
if (needsSeparator)
@@ -178,27 +350,54 @@ private void GenerateMethod(FunctionDeclaration method, CodeBlocker code)
code.Write(")");
- if (method.ReturnType is not null)
+ if (method.ReturnType is not null && method.Kind == FunctionKind.Method)
{
code.Write($" -> {PythonTypeFromGenericType(method.ReturnType)}");
}
code.WriteLine(":");
+ }
- using IndentScope body = new(code);
- if (method.Body.Count == 0)
+ ///
+ /// Writes what the type's members start at, as assignments at the top of the body.
+ ///
+ /// The declaration being emitted.
+ /// The writer to emit into.
+ ///
+ /// Python assigns where C++ initialises, in the order declared, which is what the initialiser
+ /// means where there is no initialiser list to put it in.
+ ///
+ private void WriteInitialiserAssignments(FunctionDeclaration method, CodeBlocker code)
+ {
+ foreach (MemberInitialiser initialiser in method.Initialisers)
{
- code.WriteLine("pass");
- return;
- }
+ code.Write($"self.{initialiser.Name} = ");
+
+ if (initialiser.Value is not null)
+ {
+ GenerateInternal(initialiser.Value, code);
+ }
- foreach (AstNode statement in method.Body)
- {
- GenerateInternal(statement, code);
code.WriteLine();
}
}
+ ///
+ /// Spells the name a method is written under.
+ ///
+ /// The declaration being emitted.
+ /// The name as Python writes it.
+ ///
+ /// A constructor and a destructor have fixed names in Python rather than the type's, so whatever
+ /// the declaration is called is ignored for those two.
+ ///
+ private static string SpellMethodName(FunctionDeclaration method) => method.Kind switch
+ {
+ FunctionKind.Constructor => "__init__",
+ FunctionKind.Destructor => "__del__",
+ _ => method.Name ?? "unnamed_method",
+ };
+
///
///
/// The function is emitted with the __main__ guard that runs it, which is how a Python
diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs
index 96bab06..1ec3aee 100644
--- a/Coder/Languages/StandardLanguageGenerator.cs
+++ b/Coder/Languages/StandardLanguageGenerator.cs
@@ -55,6 +55,30 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code)
GenerateFunctionDeclaration(funcDecl, code);
break;
+ case SourceFile file:
+ GenerateSourceFile(file, code);
+ break;
+
+ case NamespaceDeclaration namespaceDecl:
+ GenerateNamespaceDeclaration(namespaceDecl, code);
+ break;
+
+ case UsingAlias usingAlias:
+ GenerateUsingAlias(usingAlias, code);
+ break;
+
+ case ConstructionExpression construction:
+ GenerateConstructionExpression(construction, code);
+ break;
+
+ case EnumDeclaration enumDecl:
+ GenerateEnumDeclaration(enumDecl, code);
+ break;
+
+ case FieldDeclaration field:
+ GenerateFieldDeclaration(field, code);
+ break;
+
case EntryPoint entryPoint:
GenerateEntryPoint(entryPoint, code);
break;
@@ -107,6 +131,63 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code)
/// The writer to emit into.
protected abstract void GenerateClassDeclaration(ClassDeclaration classDecl, CodeBlocker code);
+ ///
+ /// Emits a namespace and its members.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ ///
+ /// The default emits the members and nothing around them, which is right for a language whose
+ /// unit of naming is the file. A language that writes a namespace overrides this.
+ ///
+ protected virtual void GenerateNamespaceDeclaration(NamespaceDeclaration namespaceDecl, CodeBlocker code)
+ {
+ Ensure.NotNull(namespaceDecl);
+ Ensure.NotNull(code);
+
+ GenerateDocumentation(namespaceDecl, code);
+
+ bool first = true;
+ foreach (AstNode member in namespaceDecl.Members)
+ {
+ if (!first)
+ {
+ code.NewLine();
+ }
+
+ first = false;
+ GenerateInternal(member, code);
+ }
+ }
+
+ ///
+ /// Emits an alias giving a type a second name.
+ ///
+ /// The alias to emit.
+ /// The writer to emit into.
+ protected abstract void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code);
+
+ ///
+ /// Emits an expression that builds a value.
+ ///
+ /// The expression to emit.
+ /// The writer to emit into.
+ protected abstract void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code);
+
+ ///
+ /// Emits an enumeration declaration, including its members.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ protected abstract void GenerateEnumDeclaration(EnumDeclaration enumDecl, CodeBlocker code);
+
+ ///
+ /// Emits a field of a type.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ protected abstract void GenerateFieldDeclaration(FieldDeclaration field, CodeBlocker code);
+
///
/// Emits the program's entry point, and whatever else the language needs in order to run it.
///
diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs
index 1a20d2e..aaf2438 100644
--- a/Coder/Serialization/YamlDeserializer.cs
+++ b/Coder/Serialization/YamlDeserializer.cs
@@ -4,6 +4,8 @@ namespace ktsu.Coder.Serialization;
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
using System.Text.RegularExpressions;
using ktsu.Coder.Ast;
using YamlDotNet.Serialization;
@@ -51,6 +53,22 @@ public YamlDeserializer()
{
return nodeType switch
{
+ "sourceFile" => DeserializeSourceFile(nodeData),
+ "SourceFile" => DeserializeSourceFile(nodeData),
+ "namespaceDeclaration" => DeserializeNamespaceDeclaration(nodeData),
+ "NamespaceDeclaration" => DeserializeNamespaceDeclaration(nodeData),
+ "usingAlias" => DeserializeUsingAlias(nodeData),
+ "UsingAlias" => DeserializeUsingAlias(nodeData),
+ "memberInitialiser" => DeserializeMemberInitialiser(nodeData),
+ "MemberInitialiser" => DeserializeMemberInitialiser(nodeData),
+ "constructionExpression" => DeserializeConstructionExpression(nodeData),
+ "ConstructionExpression" => DeserializeConstructionExpression(nodeData),
+ "enumDeclaration" => DeserializeEnumDeclaration(nodeData),
+ "EnumDeclaration" => DeserializeEnumDeclaration(nodeData),
+ "enumMember" => DeserializeEnumMember(nodeData),
+ "EnumMember" => DeserializeEnumMember(nodeData),
+ "fieldDeclaration" => DeserializeFieldDeclaration(nodeData),
+ "FieldDeclaration" => DeserializeFieldDeclaration(nodeData),
"classDeclaration" => DeserializeClassDeclaration(nodeData),
"ClassDeclaration" => DeserializeClassDeclaration(nodeData),
"functionDeclaration" => DeserializeFunctionDeclaration(nodeData),
@@ -113,7 +131,7 @@ private FunctionDeclaration DeserializeFunctionDeclaration(object? nodeData)
return funcDecl;
}
- private static void DeserializeFunctionBasicProperties(FunctionDeclaration funcDecl, Dictionary