From 5b7bf90c25e7248ef2c9935cb289bbc0bbd58071 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 08:23:29 +0000 Subject: [PATCH] Say what a constant table is, so a reflection table can be one [minor] The target document says the other backends "become the same exercise once the vocabulary exists". They are not. A header of structs and a table of rows need different things, and a spike that tried to build the reflection table generate_reflection.cpp emits found four of them missing: there was no way to say a declaration was an array, no way to say its value was fixed where it was written, no way to write a braced list without naming a type in front of it, and no way for an element to say which member it was for. So TypeReference gains IsArray - only that it is one, with no bound, because where the brackets go is the generator's business and C++ is the one language here that puts them on the declarator rather than the type. FieldDeclaration gains IsStatic and IsConstant, the second of which is the intent rather than the keyword, since C++ says inline constexpr at namespace scope and static constexpr inside a type while C# says static readonly and Python says nothing at all. A ConstructionExpression with no type is the braced list alone, and a MemberInitialiser among its arguments is an element that names its member: a designated initialiser in C++, an object initialiser in C#, a keyword argument in Python, an object literal in JavaScript. A list whose elements are themselves lists is a table rather than a value, so it is written one element per line with a trailing comma. A table on one line is a diff nobody can read, and a generated file is read. What is still not expressible is the explicit template specialisation that sits below that table. That is C++ and only C++, and whether the AST should learn it or the generator should write it is the same question static_assert raised, with the same answer available. Also: a name may no longer swallow a bracket, or int[] would parse as a type called "int[]" rather than an array of int. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0165XQS7s11ssp36AEY8tvCF --- CLAUDE.md | 14 +- Coder.Graph/AstFields.cs | 6 + Coder.Test/Ast/TypeReferenceTests.cs | 47 +++++ .../Languages/ExemplarReflectionTableTests.cs | 192 ++++++++++++++++++ Coder/Ast/FieldDeclaration.cs | 23 +++ Coder/Ast/TypeReference.cs | 33 ++- Coder/Languages/CSharpGenerator.cs | 117 ++++++++++- Coder/Languages/CppGenerator.cs | 146 ++++++++++++- Coder/Languages/JavaScriptGenerator.cs | 53 ++++- Coder/Languages/PythonGenerator.cs | 58 +++++- Coder/Serialization/YamlDeserializer.cs | 3 + Coder/Serialization/YamlSerializer.cs | 10 + 12 files changed, 689 insertions(+), 13 deletions(-) create mode 100644 Coder.Test/Languages/ExemplarReflectionTableTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 68b25c5..e857732 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,19 @@ source in four target languages. The solution uses: 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. + than a name, which is why it could not exist before `TypeReference` did. It is also what a braced + list is: with no type it is the list alone, which is what initialises a declaration that has + already said its type, and a `MemberInitialiser` among its arguments is an element that names the + member it is for — a designated initialiser in C++, an object initialiser in C#, a keyword + argument in Python, an object literal in JavaScript. A list whose own elements are lists is a + table and is written one per line; a list of plain values stays on one. +- `Coder/Ast/TypeReference.cs`'s `IsArray` and `Coder/Ast/FieldDeclaration.cs`'s `IsStatic` and + `IsConstant` — what a generated constant table needs beyond a type and a name. `IsArray` says only + that it is an array, with no bound, because where the brackets go is the generator's business and + C++ is the one language here that puts them on the declarator rather than the type. + `IsConstant` is the intent rather than the keyword: C++ writes `inline constexpr` at namespace + scope and `static constexpr` inside a type, C# writes `static readonly`, and a language with no + spelling for it omits it the way it omits an indirection. - `Coder/Ast/CompileTimeAssertion.cs` — what a generated type promises that the type itself cannot say. Its `Condition` is text for the same reason `SourceFile.Imports` are: a compile-time predicate is language-specific in a way most of the AST is not, and there is no shared idea underneath diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index eb0f0cb..314a1d6 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -183,6 +183,8 @@ public static IReadOnlyList Of(AstNode node) 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), + new("Static", AstFieldKind.Flag, Spell(fieldDecl.IsStatic)), + new("Constant", AstFieldKind.Flag, Spell(fieldDecl.IsConstant)), ], ClassDeclaration classDecl => @@ -379,6 +381,10 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v (FieldDeclaration fieldDecl, "Type") => Assign(() => fieldDecl.Type = OrNull(value)), (FieldDeclaration fieldDecl, VisibilityField) => TryParseVisibility(value, out Visibility fieldVisibility) && Assign(() => fieldDecl.Visibility = fieldVisibility), + (FieldDeclaration fieldDecl, "Static") => + TryParseBool(value, out bool fieldIsStatic) && Assign(() => fieldDecl.IsStatic = fieldIsStatic), + (FieldDeclaration fieldDecl, "Constant") => + TryParseBool(value, out bool fieldIsConstant) && Assign(() => fieldDecl.IsConstant = fieldIsConstant), (ClassDeclaration classDecl, "Name") => Assign(() => classDecl.Name = OrNull(value)), (ClassDeclaration classDecl, "Kind") => diff --git a/Coder.Test/Ast/TypeReferenceTests.cs b/Coder.Test/Ast/TypeReferenceTests.cs index 0cdb0c9..a3978a5 100644 --- a/Coder.Test/Ast/TypeReferenceTests.cs +++ b/Coder.Test/Ast/TypeReferenceTests.cs @@ -43,6 +43,53 @@ public void Parse_KeepsAQualifiedNameWhole() Assert.AreEqual("System.Collections.Generic.List", TypeReference.Parse("System.Collections.Generic.List").Name); } + /// + /// An array is the type with [] after it, and it round-trips like everything else the + /// grammar reads. + /// + /// + /// Before this, int[] parsed as a name holding that text verbatim — lossless, and useless + /// to a generator that has to decide where its own language puts the brackets. + /// + [TestMethod] + public void Parse_ReadsAnArray() + { + TypeReference type = TypeReference.Parse("int[]"); + + Assert.AreEqual("int", type.Name); + Assert.IsTrue(type.IsArray); + Assert.AreEqual("int[]", type.ToString()); + } + + /// + /// An array of a parameterised type reads both parts, and the brackets go outside the arguments + /// where a reader expects them. + /// + [TestMethod] + public void Parse_ReadsAnArrayOfAParameterisedType() + { + TypeReference type = TypeReference.Parse("std::span[]"); + + Assert.AreEqual("std::span", type.Name); + Assert.IsTrue(type.IsArray); + Assert.HasCount(1, type.TypeArguments); + Assert.AreEqual("std::span[]", type.ToString()); + } + + /// + /// Being an array is part of what a type is, so two types that differ only in it are different + /// types. A set keyed on one would otherwise treat them as the same. + /// + [TestMethod] + public void AnArrayIsNotEqualToItsElementType() + { + TypeReference element = new("int"); + TypeReference array = new("int") { IsArray = true }; + + Assert.AreNotEqual(element, array); + Assert.IsTrue(array.Clone().IsArray); + } + /// /// The argument list is a list, which is the whole difference from a string. /// diff --git a/Coder.Test/Languages/ExemplarReflectionTableTests.cs b/Coder.Test/Languages/ExemplarReflectionTableTests.cs new file mode 100644 index 0000000..0a91d69 --- /dev/null +++ b/Coder.Test/Languages/ExemplarReflectionTableTests.cs @@ -0,0 +1,192 @@ +// 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 reflection table Holotype's generate_reflection.cpp emits and checks that each +/// generator writes it in its own language. +/// +/// +/// docs/generated-cpp-target.md says the other backends "become the same exercise once the +/// vocabulary exists". They did not: a table of rows needs four things a header of structs does not +/// — an array type, a field whose value is fixed where it is written, a braced list with no type +/// name in front of it, and an element that says which member it is for. This is the test that says +/// the AST can express all four, and it is written against the real table rather than a toy so that +/// it fails when a generated table would. +/// +/// What it does not cover is the template<> struct Describe<T> specialisation that +/// sits below the table in that file. An explicit specialisation is C++ and only C++, and whether +/// this AST should learn it or the generator should write it — the same question +/// static_assert raised and the same answer available — is open. +/// +/// +[TestClass] +public class ExemplarReflectionTableTests +{ + /// The table as C++ wants it: a namespace-scope constant array of designated rows. + private const string ExpectedCpp = + """ + #include + #include "holotype/core/reflect.hpp" + + namespace holo::components::reflection + { + + /// Every field RigidBody declares, in declaration order. + inline constexpr holo::reflect::FieldInfo kRigidBodyFields[] = { + holo::reflect::FieldInfo{ .name = "velocity", .offset = offsetof(holo::components::RigidBody, velocity), .lerp = true }, + holo::reflect::FieldInfo{ .name = "mass", .offset = offsetof(holo::components::RigidBody, mass), .lerp = false }, + }; + + } // namespace holo::components::reflection + """; + + /// + /// Builds the table. + /// + /// The file holding it. + private static SourceFile ReflectionTable() + { + SourceFile file = new() { Name = "RigidBody.reflect.gen" }; + file.Imports.Add(""); + file.Imports.Add("holotype/core/reflect.hpp"); + + ConstructionExpression table = new(); + table.Arguments.Add(Row("velocity", true)); + table.Arguments.Add(Row("mass", false)); + + NamespaceDeclaration declaration = new("holo::components::reflection"); + declaration.Members.Add(new FieldDeclaration( + "kRigidBodyFields", + new TypeReference("holo::reflect::FieldInfo") { IsArray = true }) + { + IsConstant = true, + InitialValue = table, + Documentation = { "Every field RigidBody declares, in declaration order." }, + }); + + file.Members.Add(declaration); + return file; + } + + /// + /// Builds one row of the table. + /// + /// The field the row describes. + /// Whether that field is interpolated between states. + /// The row. + private static ConstructionExpression Row(string name, bool lerp) + { + ConstructionExpression row = new() { Type = new TypeReference("holo::reflect::FieldInfo") }; + row.Arguments.Add(new MemberInitialiser("name", Literal.Text(name))); + row.Arguments.Add(new MemberInitialiser("offset", + new VariableReference($"offsetof(holo::components::RigidBody, {name})"))); + row.Arguments.Add(new MemberInitialiser("lerp", Literal.Bool(lerp))); + return row; + } + + /// + /// The whole table, which is the point of all four additions at once. + /// + [TestMethod] + public void Cpp_GeneratesTheTableTheReflectionGeneratorEmits() => + Assert.AreEqual( + ExpectedCpp.ReplaceLineEndings("\n").TrimEnd(), + new CppGenerator().Generate(ReflectionTable()).ReplaceLineEndings("\n").TrimEnd()); + + /// + /// A constant at namespace scope has to say inline or every translation unit including + /// the header defines it again. + /// + [TestMethod] + public void Cpp_WritesANamespaceScopeConstantInline() => + Assert.Contains("inline constexpr", new CppGenerator().Generate(ReflectionTable())); + + /// + /// A static data member is already implicitly inline, so inside a type the same field says + /// static instead — which is the only reason the generator tracks where it is. + /// + [TestMethod] + public void Cpp_WritesAMemberConstantStatic() + { + ClassDeclaration type = new() { Name = "Registry", Kind = TypeDeclarationKind.Struct }; + type.Members.Add(new FieldDeclaration("kCount", new TypeReference("int")) + { + IsConstant = true, + InitialValue = Literal.Number(2), + }); + + string code = new CppGenerator().Generate(type); + + Assert.Contains("static constexpr", code); + Assert.DoesNotContain("inline", code); + } + + /// + /// C++ puts an array's brackets on the name being declared, not on the type. Everything else + /// here puts them on the type, which is why the AST says only that it is an array. + /// + [TestMethod] + public void ArrayBracketsGoWhereEachLanguagePutsThem() + { + Assert.Contains("FieldInfo kRigidBodyFields[]", new CppGenerator().Generate(ReflectionTable())); + Assert.Contains("FieldInfo[] kRigidBodyFields", new CSharpGenerator().Generate(ReflectionTable())); + Assert.Contains("list[", new PythonGenerator().Generate(ReflectionTable())); + } + + /// + /// C# has no designated initialiser; an object initialiser is the same idea and the same order + /// freedom, and static readonly is what constexpr means where const is + /// reserved for primitives. + /// + [TestMethod] + public void CSharp_WritesAnObjectInitialiserAndAStaticReadonlyField() + { + string code = new CSharpGenerator().Generate(ReflectionTable()); + + Assert.Contains("static readonly", code); + Assert.Contains("new holo::reflect::FieldInfo { name = \"velocity\"", code); + } + + /// + /// Python's keyword argument is the designated initialiser, so the names survive the trip + /// rather than collapsing into position. + /// + [TestMethod] + public void Python_WritesKeywordArguments() => + Assert.Contains("name=\"velocity\"", new PythonGenerator().Generate(ReflectionTable())); + + /// + /// JavaScript has nothing that names an argument's member, so the names go into an object + /// passed to the constructor. That is a convention rather than a translation, and it is the + /// only shape here that keeps them at all. + /// + [TestMethod] + public void JavaScript_WritesAnObjectLiteral() => + Assert.Contains("{ name: \"velocity\"", new JavaScriptGenerator().Generate(ReflectionTable())); + + /// + /// A list of values is a value; a list of lists is a table. Only the second is worth breaking + /// across lines, and a table that is not is a diff nobody can read. + /// + [TestMethod] + public void ABracedListOfPlainValuesStaysOnOneLine() + { + ConstructionExpression list = new(); + list.Arguments.Add(Literal.Number(1)); + list.Arguments.Add(Literal.Number(2)); + + NamespaceDeclaration declaration = new("holo"); + declaration.Members.Add(new FieldDeclaration("kSteps", new TypeReference("int") { IsArray = true }) + { + IsConstant = true, + InitialValue = list, + }); + + Assert.Contains("kSteps[] = { 1, 2 }", new CppGenerator().Generate(declaration)); + } +} diff --git a/Coder/Ast/FieldDeclaration.cs b/Coder/Ast/FieldDeclaration.cs index 82d2b8c..596d3a9 100644 --- a/Coder/Ast/FieldDeclaration.cs +++ b/Coder/Ast/FieldDeclaration.cs @@ -58,6 +58,27 @@ public FieldDeclaration(string name, TypeReference? type = null) /// public Visibility Visibility { get; set; } + /// + /// Gets or sets a value indicating whether the field belongs to the type rather than to an + /// instance of it. + /// + public bool IsStatic { get; set; } + + /// + /// Gets or sets a value indicating whether the field's value is fixed and known where it is + /// written. + /// + /// + /// The intent rather than the keyword, because no two of these languages spell it the same way + /// and one of them spells it differently depending on where the field sits: C++ writes + /// inline constexpr at namespace scope and static constexpr inside a class, since + /// only the first of those has to say out loud that one definition is meant. C# writes + /// static readonly, which is legal for every type where const is legal for a + /// handful. A constant field is static whether or not says so - there is + /// no per-instance copy of a value fixed at compile time. + /// + public bool IsConstant { get; set; } + /// public Collection Documentation { get; init; } = []; @@ -79,6 +100,8 @@ public override AstNode Clone() Type = Type?.Clone(), InitialValue = (Expression?)InitialValue?.DeepClone(), Visibility = Visibility, + IsStatic = IsStatic, + IsConstant = IsConstant, }; foreach ((string key, object? value) in Metadata) diff --git a/Coder/Ast/TypeReference.cs b/Coder/Ast/TypeReference.cs index f03ee4f..d728ca2 100644 --- a/Coder/Ast/TypeReference.cs +++ b/Coder/Ast/TypeReference.cs @@ -72,6 +72,19 @@ public TypeReference() /// public TypeIndirection Indirection { get; set; } + /// + /// Gets or sets a value indicating whether this is an array of the type rather than one of it. + /// + /// + /// A bound is deliberately not modelled. What a generated table needs is an array whose length + /// is its initialiser's, which every language writes by leaving the bound out; a fixed bound is + /// a different thing that would have to be an expression rather than a number, and nothing asks + /// for it yet. Where the brackets go is the generator's business - C++ puts them after the name + /// being declared and C# after the type - which is exactly the kind of difference this class + /// exists to absorb. + /// + public bool IsArray { get; set; } + /// /// Reads a type from its text form. /// @@ -82,7 +95,8 @@ public TypeReference() /// /// /// The grammar is deliberately small: an optional const or readonly, a name, an - /// optional angle-bracketed argument list, and any number of & or * suffixes. + /// optional angle-bracketed argument list, an optional [], and any number of + /// & or * suffixes. /// It exists to read what the string-shaped properties already hold, not to parse a language. /// public static TypeReference Parse(string text) @@ -149,6 +163,11 @@ public override string ToString() text.Append('>'); } + if (IsArray) + { + text.Append("[]"); + } + return text.Append(Indirection switch { TypeIndirection.Reference => "&", @@ -168,6 +187,7 @@ public TypeReference Clone() Name = Name, IsReadOnly = IsReadOnly, Indirection = Indirection, + IsArray = IsArray, }; foreach (TypeReference argument in TypeArguments) @@ -194,6 +214,7 @@ public bool Equals(TypeReference? other) return string.Equals(Name, other.Name, StringComparison.Ordinal) && IsReadOnly == other.IsReadOnly && Indirection == other.Indirection + && IsArray == other.IsArray && TypeArguments.SequenceEqual(other.TypeArguments); } @@ -207,6 +228,7 @@ public override int GetHashCode() hash.Add(Name, StringComparer.Ordinal); hash.Add(IsReadOnly); hash.Add(Indirection); + hash.Add(IsArray); foreach (TypeReference argument in TypeArguments) { hash.Add(argument); @@ -254,6 +276,13 @@ public override int GetHashCode() return null; } + SkipWhitespace(text, ref position); + if (position + 1 < text.Length && text[position] == '[' && text[position + 1] == ']') + { + type.IsArray = true; + position += 2; + } + SkipWhitespace(text, ref position); if (position < text.Length && (text[position] == '&' || text[position] == '*')) { @@ -354,7 +383,7 @@ private static string ReadName(string text, ref int position) /// The character to test. /// when the character ends a name. private static bool IsPunctuation(char character) => - character is '<' or '>' or ',' or '&' or '*'; + character is '<' or '>' or ',' or '&' or '*' or '[' or ']'; /// /// Advances past any whitespace. diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 3bce503..2b5a23a 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -239,9 +239,34 @@ private void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code) /// /// The expression to emit. /// The writer to emit into. + /// + /// Three shapes, decided by what the expression holds rather than by a flag. Arguments that name + /// the member they are for become an object initialiser — new T { Name = value } — which + /// is what C# has in place of a designated initialiser, and it is written in the order given + /// because C# does not care about declaration order the way C++ does. A construction with no + /// type at all is the braced list on its own, which C# accepts where the declaration has + /// already named an array type. Everything else is a constructor call. + /// private void GenerateConstruction(ConstructionExpression construction, CodeBlocker code) { - code.Write($"new {MapToCSType(construction.Type ?? new TypeReference(UnknownTypeName))}("); + bool designated = construction.Arguments.Any(argument => argument is MemberInitialiser); + + if (construction.Type is null) + { + WriteBracedList(construction, code); + return; + } + + code.Write($"new {MapToCSType(construction.Type)}"); + + if (designated) + { + code.Write(" "); + WriteBracedList(construction, code); + return; + } + + code.Write("("); for (int index = 0; index < construction.Arguments.Count; index++) { @@ -256,6 +281,75 @@ private void GenerateConstruction(ConstructionExpression construction, CodeBlock code.Write(")"); } + /// + /// Emits a construction's arguments as a braced list. + /// + /// The expression whose arguments to write. + /// The writer to emit into. + /// + /// A list whose elements are themselves lists is a table, and a table on one line is a diff + /// nobody can read, so it goes one element per line with a trailing comma. A list of plain + /// values is a value and stays where it is. + /// + private void WriteBracedList(ConstructionExpression construction, CodeBlocker code) + { + if (construction.Arguments.Count == 0) + { + code.Write("{ }"); + return; + } + + bool stacked = construction.Arguments.Any(argument => + argument is ConstructionExpression or MemberInitialiser { Value: ConstructionExpression }); + + if (stacked) + { + code.WriteLine("{"); + code.Indent(); + + foreach (AstNode argument in construction.Arguments) + { + WriteListElement(argument, code); + code.WriteLine(","); + } + + code.Outdent(); + code.Write("}"); + return; + } + + code.Write("{ "); + + for (int index = 0; index < construction.Arguments.Count; index++) + { + if (index > 0) + { + code.Write(", "); + } + + WriteListElement(construction.Arguments[index], code); + } + + code.Write(" }"); + } + + /// + /// Emits one element of a braced list, which may name the member it is for. + /// + /// The element to write. + /// The writer to emit into. + private void WriteListElement(AstNode argument, CodeBlocker code) + { + if (argument is MemberInitialiser designated) + { + code.Write($"{designated.Name} = "); + GenerateInternal(designated.Value ?? new VariableReference(string.Empty), code); + return; + } + + GenerateInternal(argument, code); + } + /// /// Emits an enumeration and its members. /// @@ -308,6 +402,19 @@ private void GenerateField(FieldDeclaration field, CodeBlocker code) GenerateDocumentation(field, code); code.Write($"{SpellVisibility(field.Visibility) ?? DefaultVisibility} "); + + // static readonly rather than const: const is legal only for the primitives and strings, + // and a generated table is neither. The two mean the same thing to a reader and only one of + // them is always available. + if (field.IsConstant) + { + code.Write("static readonly "); + } + else if (field.IsStatic) + { + code.Write("static "); + } + code.Write($"{MapToCSType(field.Type ?? new TypeReference(UnknownTypeName))} {field.Name}"); if (field.InitialValue is not null) @@ -562,12 +669,16 @@ private static string MapToCSType(TypeReference type) { string name = MapTypeName(type.Name); + string array = type.IsArray ? "[]" : string.Empty; + if (type.TypeArguments.Count > 0) { - return $"{name}<{string.Join(", ", type.TypeArguments.Select(MapToCSType))}>"; + return $"{name}<{string.Join(", ", type.TypeArguments.Select(MapToCSType))}>{array}"; } - return DefaultTypeArguments.TryGetValue(type.Name, out string? fallback) ? $"{name}{fallback}" : name; + return DefaultTypeArguments.TryGetValue(type.Name, out string? fallback) + ? $"{name}{fallback}{array}" + : $"{name}{array}"; } /// diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index ace62f0..5c34db7 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -32,6 +32,16 @@ public class CppGenerator : StandardLanguageGenerator /// private const string UnknownTypeName = "object"; + /// + /// How many type declarations enclose what is being written. + /// + /// + /// A depth rather than a flag, so a type declared inside a type leaves the count right when it + /// closes. The one thing it decides is whether a constant field says inline or + /// static; see . + /// + private int insideType; + private static readonly Dictionary TypeMappings = new(StringComparer.OrdinalIgnoreCase) { { "str", "std::string" }, @@ -357,6 +367,8 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Visibility current = isStruct ? Visibility.Public : Visibility.Unspecified; bool first = true; AstNode? previous = null; + + insideType++; foreach (AstNode member in classDecl.Members) { Visibility access = classDecl.Kind == TypeDeclarationKind.Interface @@ -405,6 +417,8 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod break; } } + + insideType--; } /// @@ -448,13 +462,27 @@ protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker co /// 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. + /// + /// A construction with no type is the braced list on its own, which is what initialises a + /// declaration that has already said what its type is — an array of rows most of all, where + /// naming the array's type again would be wrong rather than merely redundant. + /// + /// + /// An argument that is a is a designated initialiser, so a row + /// says which member each value is for instead of depending on the order the members happen to + /// be declared in. C++20 requires designators to appear in declaration order, which is the + /// caller's business: the generator writes the order it is given. + /// /// protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code) { Ensure.NotNull(construction); Ensure.NotNull(code); - code.Write(MapToCppType(construction.Type ?? new TypeReference(UnknownTypeName))); + if (construction.Type is not null) + { + code.Write(MapToCppType(construction.Type)); + } if (construction.Arguments.Count == 0) { @@ -462,6 +490,12 @@ protected override void GenerateConstructionExpression(ConstructionExpression co return; } + if (SpansLines(construction)) + { + WriteStacked(construction, code); + return; + } + code.Write("{ "); for (int index = 0; index < construction.Arguments.Count; index++) { @@ -470,12 +504,68 @@ protected override void GenerateConstructionExpression(ConstructionExpression co code.Write(", "); } - GenerateInternal(construction.Arguments[index], code); + WriteArgument(construction.Arguments[index], code); } code.Write(" }"); } + /// + /// Writes a braced list one element per line. + /// + /// The expression whose arguments to write. + /// The writer to emit into. + /// + /// A trailing comma after the last element, which C++ allows in a braced list and which keeps + /// adding a row to a generated table from touching the row above it in the diff. + /// + private void WriteStacked(ConstructionExpression construction, CodeBlocker code) + { + code.WriteLine("{"); + code.Indent(); + + foreach (AstNode argument in construction.Arguments) + { + WriteArgument(argument, code); + code.WriteLine(","); + } + + code.Outdent(); + code.Write("}"); + } + + /// + /// Writes one element of a braced list, which may name the member it is for. + /// + /// The element to write. + /// The writer to emit into. + private void WriteArgument(AstNode argument, CodeBlocker code) + { + if (argument is MemberInitialiser designated) + { + code.Write($".{designated.Name} = "); + GenerateInternal(designated.Value ?? new VariableReference(string.Empty), code); + return; + } + + GenerateInternal(argument, code); + } + + /// + /// Reports whether a braced list is worth breaking across lines. + /// + /// The expression to judge. + /// when it should be written one element per line. + /// + /// A list of values is a value and belongs on one line; a list whose elements are themselves + /// lists is a table, and a table written on one line is a row of a diff nobody can read. The + /// test is the shape of the data rather than a column count, because a generated file has no + /// idea how wide anyone's editor is and a rule about that would have to be guessed. + /// + private static bool SpansLines(ConstructionExpression construction) => + construction.Arguments.Any(argument => + argument is ConstructionExpression or MemberInitialiser { Value: ConstructionExpression }); + /// /// /// Always enum class, never the unscoped form: an unscoped enumeration leaks its members @@ -527,7 +617,8 @@ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlo GenerateDocumentation(field, code); - code.Write($"{MapToCppType(field.Type ?? new TypeReference(UnknownTypeName))} {field.Name}"); + code.Write(SpellStorage(field)); + code.Write(SpellDeclarator(field.Type ?? new TypeReference(UnknownTypeName), field.Name ?? string.Empty)); if (field.InitialValue is not null) { @@ -740,7 +831,54 @@ private static string MapToCppType(TypeReference type) _ => string.Empty, }; - return $"{(type.IsReadOnly ? "const " : string.Empty)}{name}{arguments}{indirection}"; + string array = type.IsArray ? "[]" : string.Empty; + + return $"{(type.IsReadOnly ? "const " : string.Empty)}{name}{arguments}{array}{indirection}"; + } + + /// + /// Spells a declaration of with that type. + /// + /// The declared type. + /// The name being declared. + /// The declaration, without an initialiser or a terminator. + /// + /// C++ puts an array's brackets on the declarator rather than on the type — T name[], + /// never T[] name — so a declaration cannot be built by writing the type and the name in + /// that order, which is what every other language here does. This is the one place that + /// difference lives. + /// + private static string SpellDeclarator(TypeReference type, string name) + { + TypeReference element = type.IsArray ? type.Clone() : type; + if (type.IsArray) + { + element.IsArray = false; + } + + return $"{MapToCppType(element)} {name}{(type.IsArray ? "[]" : string.Empty)}"; + } + + /// + /// Spells what a field says about where it lives and when its value is fixed. + /// + /// The field. + /// The keywords, with a trailing space, or empty when there are none. + /// + /// inline is what makes a namespace-scope constant safe to define in a header, which is + /// the only place a generated one ever appears; a static data member is already implicitly + /// inline, so saying it inside a class would be noise at best. is what + /// tells the two apart, and it is a depth rather than a flag so a type nested in a type stays + /// balanced. + /// + private string SpellStorage(FieldDeclaration field) + { + if (field.IsConstant) + { + return insideType > 0 ? "static constexpr " : "inline constexpr "; + } + + return field.IsStatic ? "static " : string.Empty; } /// diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index a57a12d..188ef05 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -111,12 +111,33 @@ protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker co } /// + /// + /// JavaScript has nothing that names the member an argument is for, so arguments that do become + /// an object literal — passed to the constructor when a type is named, on their own when one is + /// not. That is the options-object convention rather than a translation of the C++ form, and it + /// is the only shape in this language where the names survive at all. + /// protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code) { Ensure.NotNull(construction); Ensure.NotNull(code); - code.Write($"new {construction.Type?.Name ?? "Object"}("); + bool named = construction.Arguments.Any(argument => argument is MemberInitialiser); + + if (construction.Type is null) + { + WriteLiteral(construction, code, named); + return; + } + + code.Write($"new {construction.Type.Name}("); + + if (named) + { + WriteLiteral(construction, code, true); + code.Write(")"); + return; + } for (int index = 0; index < construction.Arguments.Count; index++) { @@ -131,6 +152,36 @@ protected override void GenerateConstructionExpression(ConstructionExpression co code.Write(")"); } + /// + /// Writes a construction's arguments as an object or array literal. + /// + /// The expression whose arguments to write. + /// The writer to emit into. + /// Whether the arguments name the members they are for. + private void WriteLiteral(ConstructionExpression construction, CodeBlocker code, bool named) + { + code.Write(named ? "{ " : "["); + + for (int index = 0; index < construction.Arguments.Count; index++) + { + if (index > 0) + { + code.Write(", "); + } + + if (construction.Arguments[index] is MemberInitialiser member) + { + code.Write($"{member.Name}: "); + GenerateInternal(member.Value ?? new VariableReference(string.Empty), code); + continue; + } + + GenerateInternal(construction.Arguments[index], code); + } + + code.Write(named ? " }" : "]"); + } + /// /// /// JavaScript has no enumeration. A frozen object is the convention: the members are reachable by diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 29ed7f5..5ff2a85 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -94,16 +94,59 @@ protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker co } /// + /// + /// An argument that names the member it is for becomes a keyword argument, which is what Python + /// has in place of a designated initialiser and reads the same way. A construction with no type + /// is a literal rather than a call: a dictionary when its arguments are named, a list when they + /// are not, which is what a language with no aggregate type to name uses instead. + /// protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code) { Ensure.NotNull(construction); Ensure.NotNull(code); - code.Write($"{PythonTypeFromGenericType(construction.Type ?? new TypeReference("object"))}("); + if (construction.Type is null) + { + WriteLiteral(construction, code); + return; + } + + code.Write($"{PythonTypeFromGenericType(construction.Type)}("); WriteArguments(construction, code); code.Write(")"); } + /// + /// Writes a typeless construction as the literal Python builds that value with. + /// + /// The expression whose arguments to write. + /// The writer to emit into. + private void WriteLiteral(ConstructionExpression construction, CodeBlocker code) + { + bool named = construction.Arguments.Any(argument => argument is MemberInitialiser); + + code.Write(named ? "{" : "["); + + for (int index = 0; index < construction.Arguments.Count; index++) + { + if (index > 0) + { + code.Write(", "); + } + + if (construction.Arguments[index] is MemberInitialiser member) + { + code.Write($"\"{member.Name}\": "); + GenerateInternal(member.Value ?? new VariableReference(string.Empty), code); + continue; + } + + GenerateInternal(construction.Arguments[index], code); + } + + code.Write(named ? "}" : "]"); + } + /// /// Writes a construction's arguments, separated by commas. /// @@ -118,6 +161,13 @@ private void WriteArguments(ConstructionExpression construction, CodeBlocker cod code.Write(", "); } + if (construction.Arguments[index] is MemberInitialiser member) + { + code.Write($"{member.Name}="); + GenerateInternal(member.Value ?? new VariableReference(string.Empty), code); + continue; + } + GenerateInternal(construction.Arguments[index], code); } } @@ -509,9 +559,13 @@ private static string PythonTypeFromGenericType(TypeReference type) _ => type.Name }; - return type.TypeArguments.Count == 0 + string spelled = type.TypeArguments.Count == 0 ? name : $"{name}[{string.Join(", ", type.TypeArguments.Select(PythonTypeFromGenericType))}]"; + + // Python spells an array as a list of the element type, since the bracketed form after a + // name is a subscript rather than a declarator here. + return type.IsArray ? $"list[{spelled}]" : spelled; } /// diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index e96e870..43344b5 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -553,6 +553,9 @@ private FieldDeclaration DeserializeFieldDeclaration(object? nodeData) field.Type = typeObj?.ToString(); } + field.IsStatic = ReadFlag(dict, "isStatic", field.IsStatic); + field.IsConstant = ReadFlag(dict, "isConstant", field.IsConstant); + DeserializeVisibility(field, dict); ReadStrings(dict, DocumentationKey, field.Documentation); diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index 235103c..7a603ef 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -416,6 +416,16 @@ private static void SerializeFieldDeclaration(FieldDeclaration field, Dictionary nodeData["type"] = field.Type.ToString(); } + if (field.IsStatic) + { + nodeData["isStatic"] = field.IsStatic; + } + + if (field.IsConstant) + { + nodeData["isConstant"] = field.IsConstant; + } + SerializeVisibility(field, nodeData); SerializeDocumentation(field, nodeData);