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 dict) + private void DeserializeFunctionBasicProperties(FunctionDeclaration funcDecl, Dictionary dict) { if (dict.TryGetValue("name", out object? nameObj)) { @@ -137,9 +155,70 @@ private static void DeserializeFunctionBasicProperties(FunctionDeclaration funcD funcDecl.IsPure = isPure; } + if (dict.TryGetValue("kind", out object? kindObj) && + Enum.TryParse(kindObj?.ToString(), out FunctionKind kind)) + { + funcDecl.Kind = kind; + } + + if (dict.TryGetValue("definition", out object? definitionObj) && + Enum.TryParse(definitionObj?.ToString(), out FunctionDefinition definition)) + { + funcDecl.Definition = definition; + } + + ReadFunctionShape(funcDecl, dict); + DeserializeVisibility(funcDecl, dict); + ReadStrings(dict, DocumentationKey, funcDecl.Documentation); + } + + /// + /// Reads what a function declares and how, leaving each as it was when the document is silent. + /// + /// The declaration being read into. + /// The mapping the node was written as. + /// + /// Separate from the name and the return type only because one method reading every property a + /// declaration has is more branches than the analyzer accepts. These are the ones that say what + /// kind of declaration it is rather than what it is called. + /// + private void ReadFunctionShape(FunctionDeclaration funcDecl, Dictionary dict) + { + funcDecl.IsVirtual = ReadFlag(dict, "isVirtual", funcDecl.IsVirtual); + funcDecl.IsAbstract = ReadFlag(dict, "isAbstract", funcDecl.IsAbstract); + funcDecl.IsReadOnly = ReadFlag(dict, "isReadOnly", funcDecl.IsReadOnly); + funcDecl.MustUseResult = ReadFlag(dict, "mustUseResult", funcDecl.MustUseResult); + funcDecl.IsExplicit = ReadFlag(dict, "isExplicit", funcDecl.IsExplicit); + funcDecl.IsCompileTimeEvaluable = ReadFlag(dict, "isCompileTimeEvaluable", funcDecl.IsCompileTimeEvaluable); + funcDecl.IsNoThrow = ReadFlag(dict, "isNoThrow", funcDecl.IsNoThrow); + funcDecl.IsFriend = ReadFlag(dict, "isFriend", funcDecl.IsFriend); + + if (dict.TryGetValue("initialisers", out object? initialisersObj) && initialisersObj is List initialisers) + { + foreach (Dictionary initialiserDict in Mappings(initialisers)) + { + (object initialiserType, object initialiserData) = initialiserDict.First(); + if (DeserializeNode(initialiserType.ToString() ?? string.Empty, initialiserData) is MemberInitialiser member) + { + funcDecl.Initialisers.Add(member); + } + } + } } + /// + /// Reads a boolean, leaving it as it was when the document does not say. + /// + /// The mapping the node was written as. + /// The key to read. + /// What to return when the document is silent. + /// The value read, or the fallback. + private static bool ReadFlag(Dictionary dict, string key, bool fallback) => + dict.TryGetValue(key, out object? value) && bool.TryParse(value?.ToString(), out bool flag) + ? flag + : fallback; + /// /// Reads a declaration's visibility, leaving it when the /// document does not say. @@ -248,6 +327,284 @@ private void DeserializeFunctionBody(FunctionDeclaration funcDecl, Dictionary dict) + { + return file; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + file.Name = nameObj?.ToString(); + } + + if (dict.TryGetValue("isHeader", out object? headerObj) && + bool.TryParse(headerObj?.ToString(), out bool isHeader)) + { + file.IsHeader = isHeader; + } + + ReadStrings(dict, "headerComment", file.HeaderComment); + ReadStrings(dict, "imports", file.Imports); + DeserializeMembersInto(dict, file.Members); + DeserializeMetadata(file, dict); + + return file; + } + + private NamespaceDeclaration DeserializeNamespaceDeclaration(object? nodeData) + { + NamespaceDeclaration namespaceDecl = new(); + if (nodeData is not Dictionary dict) + { + return namespaceDecl; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + namespaceDecl.Name = nameObj?.ToString(); + } + + ReadStrings(dict, DocumentationKey, namespaceDecl.Documentation); + DeserializeMembersInto(dict, namespaceDecl.Members); + DeserializeMetadata(namespaceDecl, dict); + + return namespaceDecl; + } + + private static UsingAlias DeserializeUsingAlias(object? nodeData) + { + UsingAlias usingAlias = new(); + if (nodeData is not Dictionary dict) + { + return usingAlias; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + usingAlias.Name = nameObj?.ToString(); + } + + if (dict.TryGetValue("aliasedType", out object? typeObj)) + { + usingAlias.AliasedType = typeObj?.ToString(); + } + + DeserializeVisibility(usingAlias, dict); + ReadStrings(dict, DocumentationKey, usingAlias.Documentation); + DeserializeMetadata(usingAlias, dict); + + return usingAlias; + } + + private MemberInitialiser DeserializeMemberInitialiser(object? nodeData) + { + MemberInitialiser initialiser = new(); + if (nodeData is not Dictionary dict) + { + return initialiser; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + initialiser.Name = nameObj?.ToString(); + } + + if (dict.TryGetValue(ValueKey, out object? valueObj) && + valueObj is Dictionary valueDict && valueDict.Count > 0) + { + (object valueType, object valueData) = valueDict.First(); + initialiser.Value = DeserializeNode(valueType.ToString() ?? string.Empty, valueData) as Expression; + } + + DeserializeMetadata(initialiser, dict); + return initialiser; + } + + private ConstructionExpression DeserializeConstructionExpression(object? nodeData) + { + ConstructionExpression construction = new(); + if (nodeData is not Dictionary dict) + { + return construction; + } + + if (dict.TryGetValue("type", out object? typeObj)) + { + construction.Type = typeObj?.ToString(); + } + + if (dict.TryGetValue("arguments", out object? argumentsObj) && argumentsObj is List arguments) + { + foreach (Dictionary argumentDict in Mappings(arguments)) + { + (object argumentType, object argumentData) = argumentDict.First(); + if (DeserializeNode(argumentType.ToString() ?? string.Empty, argumentData) is AstNode node) + { + construction.Arguments.Add(node); + } + } + } + + DeserializeMetadata(construction, dict); + return construction; + } + + private EnumDeclaration DeserializeEnumDeclaration(object? nodeData) + { + EnumDeclaration enumDecl = new(); + if (nodeData is not Dictionary dict) + { + return enumDecl; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + enumDecl.Name = nameObj?.ToString(); + } + + if (dict.TryGetValue("underlyingType", out object? underlyingObj)) + { + enumDecl.UnderlyingType = underlyingObj?.ToString(); + } + + DeserializeVisibility(enumDecl, dict); + ReadStrings(dict, DocumentationKey, enumDecl.Documentation); + + if (dict.TryGetValue(MembersKey, out object? membersObj) && membersObj is List members) + { + foreach (Dictionary memberDict in Mappings(members)) + { + (object memberType, object memberData) = memberDict.First(); + if (DeserializeNode(memberType.ToString() ?? string.Empty, memberData) is EnumMember value) + { + enumDecl.Members.Add(value); + } + } + } + + DeserializeMetadata(enumDecl, dict); + return enumDecl; + } + + private static EnumMember DeserializeEnumMember(object? nodeData) + { + EnumMember member = new(); + if (nodeData is not Dictionary dict) + { + return member; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + member.Name = nameObj?.ToString(); + } + + if (dict.TryGetValue(ValueKey, out object? valueObj)) + { + member.Value = valueObj?.ToString(); + } + + DeserializeMetadata(member, dict); + return member; + } + + private FieldDeclaration DeserializeFieldDeclaration(object? nodeData) + { + FieldDeclaration field = new(); + if (nodeData is not Dictionary dict) + { + return field; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + field.Name = nameObj?.ToString(); + } + + if (dict.TryGetValue("type", out object? typeObj)) + { + field.Type = typeObj?.ToString(); + } + + DeserializeVisibility(field, dict); + ReadStrings(dict, DocumentationKey, field.Documentation); + + if (dict.TryGetValue("initialValue", out object? initialObj) && + initialObj is Dictionary initialDict && initialDict.Count > 0) + { + (object valueType, object valueData) = initialDict.First(); + field.InitialValue = DeserializeNode(valueType.ToString() ?? string.Empty, valueData) as Expression; + } + + DeserializeMetadata(field, dict); + return field; + } + + /// The key a node's members are written under. + private const string MembersKey = "members"; + + /// The key a node's single value is written under. + private const string ValueKey = "value"; + + /// The key a declaration's documentation is written under. + private const string DocumentationKey = "documentation"; + + /// + /// Keeps the entries of a sequence that are non-empty mappings, which is the only shape a node + /// can have been written as. + /// + /// The sequence read from the document. + /// The entries that are nodes. + /// + /// Filtering here rather than inside each loop says out loud that anything else in the sequence is + /// skipped — a document can hold whatever someone typed, and a loop that quietly steps over half + /// its input reads as though it does not. + /// + private static IEnumerable> Mappings(List entries) => + entries.OfType>().Where(entry => entry.Count > 0); + + /// + /// Reads a sequence of strings into a collection, leaving it alone when the key is absent. + /// + /// The mapping the node was written as. + /// The key to read. + /// The collection to fill. + private static void ReadStrings(Dictionary dict, string key, Collection into) + { + if (dict.TryGetValue(key, out object? value) && value is List lines) + { + foreach (object line in lines) + { + into.Add(line?.ToString() ?? string.Empty); + } + } + } + + /// + /// Reads a node's members into a collection. + /// + /// The mapping the node was written as. + /// The collection to fill. + private void DeserializeMembersInto(Dictionary dict, Collection into) + { + if (!dict.TryGetValue(MembersKey, out object? membersObj) || membersObj is not List members) + { + return; + } + + foreach (Dictionary memberDict in Mappings(members)) + { + (object memberType, object memberData) = memberDict.First(); + if (DeserializeNode(memberType.ToString() ?? string.Empty, memberData) is AstNode node) + { + into.Add(node); + } + } + } + private ClassDeclaration DeserializeClassDeclaration(object? nodeData) { ClassDeclaration classDecl = new(); @@ -266,7 +623,14 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) classDecl.BaseType = baseTypeObj?.ToString(); } + if (dict.TryGetValue("kind", out object? kindObj) && + Enum.TryParse(kindObj?.ToString(), out TypeDeclarationKind kind)) + { + classDecl.Kind = kind; + } + DeserializeVisibility(classDecl, dict); + ReadStrings(dict, DocumentationKey, classDecl.Documentation); DeserializeClassMembers(classDecl, dict); DeserializeMetadata(classDecl, dict); @@ -495,7 +859,7 @@ private UnaryExpression DeserializeUnaryExpression(object? nodeData) return null; } - if (!dict.TryGetValue("value", out object? value)) + if (!dict.TryGetValue(ValueKey, out object? value)) { return null; } @@ -604,7 +968,7 @@ private AssignmentStatement DeserializeAssignmentStatement(object? nodeData) } // Deserialize value - if (dict.TryGetValue("value", out object? valueObj) && valueObj is Dictionary valueDict) + if (dict.TryGetValue(ValueKey, out object? valueObj) && valueObj is Dictionary valueDict) { foreach ((object valueType, object valueData) in valueDict) { diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index 990658c..c4964eb 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -81,6 +81,21 @@ private static void SerializeCompositeNode(AstNode node, Dictionary + /// Writes the nodes that are not declarations. + /// + /// The node being serialized. + /// The mapping to write into. + /// + /// 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 the AST already draws. + /// + private static void SerializeOtherNode(AstNode node, Dictionary nodeData) + { + switch (node) + { + case UsingAlias usingAlias: + SerializeUsingAlias(usingAlias, nodeData); + break; + case MemberInitialiser initialiser: + SerializeMemberInitialiser(initialiser, nodeData); + break; + case ConstructionExpression construction: + SerializeConstructionExpression(construction, nodeData); + break; case ReturnStatement returnStmt: SerializeReturnStatement(returnStmt, nodeData); break; @@ -154,6 +197,7 @@ private static void SerializeFunctionDeclaration(FunctionDeclaration funcDecl, D } SerializeVisibility(funcDecl, nodeData); + SerializeDocumentation(funcDecl, nodeData); // Written only when true: a modifier nobody asked for should not appear in the document, the // same way an unspecified visibility does not. @@ -167,6 +211,8 @@ private static void SerializeFunctionDeclaration(FunctionDeclaration funcDecl, D nodeData["isPure"] = funcDecl.IsPure; } + SerializeFunctionShape(funcDecl, nodeData); + if (funcDecl.Parameters.Count > 0) { nodeData["parameters"] = SerializeParameters(funcDecl.Parameters); @@ -215,6 +261,237 @@ private static void SerializeEntryPoint(EntryPoint entryPoint, Dictionary nodeData) + { + if (file.Name != null) + { + nodeData["name"] = file.Name; + } + + if (file.IsHeader) + { + nodeData["isHeader"] = file.IsHeader; + } + + if (file.HeaderComment.Count > 0) + { + nodeData["headerComment"] = file.HeaderComment.ToList(); + } + + if (file.Imports.Count > 0) + { + nodeData["imports"] = file.Imports.ToList(); + } + + if (file.Members.Count > 0) + { + nodeData[MembersKey] = SerializeBodyStatements(file.Members); + } + } + + private static void SerializeNamespaceDeclaration(NamespaceDeclaration namespaceDecl, Dictionary nodeData) + { + if (namespaceDecl.Name != null) + { + nodeData["name"] = namespaceDecl.Name; + } + + SerializeDocumentation(namespaceDecl, nodeData); + + if (namespaceDecl.Members.Count > 0) + { + nodeData[MembersKey] = SerializeBodyStatements(namespaceDecl.Members); + } + } + + /// The key a node's single value is written under. + private const string ValueKey = "value"; + + /// The key a node's members are written under. + private const string MembersKey = "members"; + + private static void SerializeUsingAlias(UsingAlias usingAlias, Dictionary nodeData) + { + if (usingAlias.Name != null) + { + nodeData["name"] = usingAlias.Name; + } + + if (usingAlias.AliasedType != null) + { + nodeData["aliasedType"] = usingAlias.AliasedType.ToString(); + } + + SerializeVisibility(usingAlias, nodeData); + SerializeDocumentation(usingAlias, nodeData); + } + + private static void SerializeMemberInitialiser(MemberInitialiser initialiser, Dictionary nodeData) + { + if (initialiser.Name != null) + { + nodeData["name"] = initialiser.Name; + } + + if (initialiser.Value != null) + { + Dictionary valueData = []; + SerializeNode(initialiser.Value, valueData); + nodeData[ValueKey] = valueData; + } + } + + private static void SerializeConstructionExpression(ConstructionExpression construction, Dictionary nodeData) + { + if (construction.Type != null) + { + nodeData["type"] = construction.Type.ToString(); + } + + if (construction.Arguments.Count > 0) + { + nodeData["arguments"] = SerializeBodyStatements(construction.Arguments); + } + } + + private static void SerializeEnumDeclaration(EnumDeclaration enumDecl, Dictionary nodeData) + { + if (enumDecl.Name != null) + { + nodeData["name"] = enumDecl.Name; + } + + if (enumDecl.UnderlyingType != null) + { + nodeData["underlyingType"] = enumDecl.UnderlyingType.ToString(); + } + + SerializeVisibility(enumDecl, nodeData); + SerializeDocumentation(enumDecl, nodeData); + + if (enumDecl.Members.Count > 0) + { + nodeData[MembersKey] = SerializeBodyStatements(enumDecl.Members); + } + } + + private static void SerializeEnumMember(EnumMember member, Dictionary nodeData) + { + if (member.Name != null) + { + nodeData["name"] = member.Name; + } + + if (member.Value != null) + { + nodeData[ValueKey] = member.Value; + } + } + + private static void SerializeFieldDeclaration(FieldDeclaration field, Dictionary nodeData) + { + if (field.Name != null) + { + nodeData["name"] = field.Name; + } + + if (field.Type != null) + { + nodeData["type"] = field.Type.ToString(); + } + + SerializeVisibility(field, nodeData); + SerializeDocumentation(field, nodeData); + + if (field.InitialValue != null) + { + Dictionary initialValueData = []; + SerializeNode(field.InitialValue, initialValueData); + nodeData["initialValue"] = initialValueData; + } + } + + /// + /// Writes what a function declares and how, omitting whatever it did not ask for. + /// + /// The declaration being serialized. + /// The mapping to write into. + /// + /// Separate from the name and the return type only because one method writing every property a + /// declaration has is more branches than the analyzer accepts. Nothing is written for a property + /// left at its default, so a document says only what someone chose. + /// + private static void SerializeFunctionShape(FunctionDeclaration funcDecl, Dictionary nodeData) + { + if (funcDecl.Kind != FunctionKind.Method) + { + nodeData["kind"] = funcDecl.Kind.ToString(); + } + + if (funcDecl.Definition != FunctionDefinition.Provided) + { + nodeData["definition"] = funcDecl.Definition.ToString(); + } + + if (funcDecl.IsVirtual) + { + nodeData["isVirtual"] = funcDecl.IsVirtual; + } + + if (funcDecl.IsAbstract) + { + nodeData["isAbstract"] = funcDecl.IsAbstract; + } + + if (funcDecl.IsReadOnly) + { + nodeData["isReadOnly"] = funcDecl.IsReadOnly; + } + + if (funcDecl.MustUseResult) + { + nodeData["mustUseResult"] = funcDecl.MustUseResult; + } + + if (funcDecl.IsExplicit) + { + nodeData["isExplicit"] = funcDecl.IsExplicit; + } + + if (funcDecl.IsCompileTimeEvaluable) + { + nodeData["isCompileTimeEvaluable"] = funcDecl.IsCompileTimeEvaluable; + } + + if (funcDecl.IsNoThrow) + { + nodeData["isNoThrow"] = funcDecl.IsNoThrow; + } + + if (funcDecl.IsFriend) + { + nodeData["isFriend"] = funcDecl.IsFriend; + } + + if (funcDecl.Initialisers.Count > 0) + { + nodeData["initialisers"] = SerializeBodyStatements(funcDecl.Initialisers); + } + } + + /// + /// Writes a declaration's documentation, when it has any. + /// + /// The declaration being serialized. + /// The mapping to write into. + private static void SerializeDocumentation(IHasDocumentation declaration, Dictionary nodeData) + { + if (declaration.Documentation.Count > 0) + { + nodeData["documentation"] = declaration.Documentation.ToList(); + } + } + private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictionary nodeData) { if (classDecl.Name != null) @@ -222,17 +499,23 @@ private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictio nodeData["name"] = classDecl.Name; } + if (classDecl.Kind != TypeDeclarationKind.Class) + { + nodeData["kind"] = classDecl.Kind.ToString(); + } + if (classDecl.BaseType != null) { nodeData["baseType"] = classDecl.BaseType.ToString(); } SerializeVisibility(classDecl, nodeData); + SerializeDocumentation(classDecl, nodeData); if (classDecl.Members.Count > 0) { // Members are serialized the same way a function body is: each is a node in its own right. - nodeData["members"] = SerializeBodyStatements(classDecl.Members); + nodeData[MembersKey] = SerializeBodyStatements(classDecl.Members); } } @@ -344,7 +627,7 @@ private static void SerializeLiteralExpression(LiteralExpression literal, { if (literal.Value != null) { - nodeData["value"] = literal.Value; + nodeData[ValueKey] = literal.Value; } if (literal.ExpectedType != null) @@ -400,7 +683,7 @@ private static void SerializeAssignmentStatement(AssignmentStatement assignment, Dictionary valueData = []; SerializeNode(assignment.Value, valueData); - nodeData["value"] = valueData; + nodeData[ValueKey] = valueData; nodeData["operator"] = assignment.Operator.ToString(); }