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);