From 020eebdee0ea0c0fa9acbc56c569f6c826b317bf Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Fri, 11 Sep 2026 22:05:46 +0000 Subject: [PATCH 1/2] Let a class declaration be for a type rather than of one [minor] A generated reflection table has to be reachable from the type it describes, and C++ does that by specialising a template on it: template<> struct Describe. The AST could say everything such a table contains - namespace-scope inline constexpr arrays of braced lists, designated initialisers, nested lists - and could not say the declaration that anchors them, which made the whole artefact unreachable from the thing it is about. The alternative was naming. A DescribeRigidBody beside RigidBody works, and it is exactly what a lookup by type exists to avoid: every consumer has to spell the convention for itself, and nothing checks that it got it right. ClassDeclaration gains SpecialisationArguments, an ordered list that is empty on an ordinary declaration. Empty is what makes this safe to add: IsSpecialisation is false for every type this library has ever emitted, so nothing moves. The arguments are TypeReference rather than text, which is the one place this departs from CompileTimeAssertion's precedent. An assertion's condition is opaque to everything but the compiler, so a string costs nothing; a specialisation argument is a type, the AST already knows how to be a type, and the comma in Result belongs to that type rather than separating two arguments - which text would have to guess at, and a list does not. Only C++ honours it. The other three write the type it was specialised for as a comment above an ordinary declaration, because a generated file that quietly drops what it was for looks like one that still means it. That is now the second node with this shape and the reason is the same both times. Seven tests, and the one that matters as much as the positive cases is that a declaration with no arguments still writes no template: unconditional would have broken every type this library emits. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu --- CLAUDE.md | 8 ++ Coder.Test/Ast/SpecialisationTests.cs | 179 ++++++++++++++++++++++++ Coder/Ast/ClassDeclaration.cs | 36 +++++ Coder/Languages/CSharpGenerator.cs | 8 ++ Coder/Languages/CppGenerator.cs | 16 +++ Coder/Languages/JavaScriptGenerator.cs | 8 ++ Coder/Languages/PythonGenerator.cs | 8 ++ Coder/Serialization/YamlDeserializer.cs | 18 +++ Coder/Serialization/YamlSerializer.cs | 9 ++ 9 files changed, 290 insertions(+) create mode 100644 Coder.Test/Ast/SpecialisationTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index e857732..56e4bdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,14 @@ source in four target languages. The solution uses: `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/ClassDeclaration.cs`'s `SpecialisationArguments` — what makes a declaration be *for* a + type rather than *of* one. `template<> struct Describe` is how C++ attaches a fact to a + type without touching the type, which is what a generated reflection table needs: the alternative + is naming, and a `DescribeRigidBody` every consumer has to spell for itself is the thing a lookup + by type exists to avoid. Only C++ has it and the other three write a comment, the same as + `CompileTimeAssertion`; the arguments are `TypeReference` rather than text, though, because a + specialisation argument is a type and the comma in `Result` belongs to one of them + rather than separating two. - `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.Test/Ast/SpecialisationTests.cs b/Coder.Test/Ast/SpecialisationTests.cs new file mode 100644 index 0000000..23530d9 --- /dev/null +++ b/Coder.Test/Ast/SpecialisationTests.cs @@ -0,0 +1,179 @@ +// 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 : a declaration that attaches +/// itself to a type rather than introducing one. +/// +/// +/// The second case of the rule established -- say what is true, +/// and let each language say as much of it as it can. It is here because a generated table has to +/// be reachable from the type it describes, and Describe<RigidBody> is how C++ does +/// that without touching RigidBody. +/// +[TestClass] +public class SpecialisationTests +{ + /// + /// C++ writes the empty parameter list above the declaration and the arguments after its name. + /// + [TestMethod] + public void Cpp_WritesTheSpecialisation() + { + ClassDeclaration describe = new("Describe") + { + Kind = TypeDeclarationKind.Struct, + SpecialisationArguments = { new TypeReference("holo::components::RigidBody") }, + }; + + describe.Members.Add(new FieldDeclaration("info", "ComponentInfo") { IsStatic = true, IsConstant = true }); + + Assert.AreEqual( + "template <>\n" + + "struct Describe\n" + + "{\n" + + " static constexpr ComponentInfo info{};\n" + + "};\n", + new CppGenerator().Generate(describe).ReplaceLineEndings("\n")); + } + + /// + /// A declaration with no arguments is an ordinary one, and says nothing about templates. + /// + /// + /// The property is what distinguishes the two, so the absence has to be tested as well as the + /// presence: a generator that wrote template <> unconditionally would break every + /// type this library has ever emitted. + /// + [TestMethod] + public void Cpp_WritesAnOrdinaryDeclarationWithNone() + { + ClassDeclaration body = new("RigidBody") { Kind = TypeDeclarationKind.Struct }; + + string code = new CppGenerator().Generate(body).ReplaceLineEndings("\n"); + + Assert.DoesNotContain("template", code, StringComparison.Ordinal); + Assert.Contains("struct RigidBody\n", code, StringComparison.Ordinal); + Assert.IsFalse(body.IsSpecialisation); + } + + /// + /// Several arguments are written in the order they were given. + /// + [TestMethod] + public void Cpp_WritesEveryArgumentInOrder() + { + ClassDeclaration converter = new("Convert") + { + Kind = TypeDeclarationKind.Struct, + SpecialisationArguments = + { + new TypeReference("Metres"), + new TypeReference("Feet"), + }, + }; + + Assert.Contains( + "struct Convert", + new CppGenerator().Generate(converter), + StringComparison.Ordinal); + } + + /// + /// An argument that is itself a generic type keeps its own arguments. + /// + /// + /// The reason the arguments are rather than text: a type argument + /// has structure, and the comma in Result<Handle, Error> belongs to that type + /// rather than separating two of them. + /// + [TestMethod] + public void Cpp_WritesANestedArgumentWhole() + { + ClassDeclaration describe = new("Describe") + { + Kind = TypeDeclarationKind.Struct, + SpecialisationArguments = { TypeReference.Parse("holo::Result") }, + }; + + Assert.Contains( + "struct Describe>", + new CppGenerator().Generate(describe), + StringComparison.Ordinal); + } + + /// + /// The other three cannot attach a declaration to a type, so they say which type it was for + /// rather than emitting something that reads as an unrelated class. + /// + [TestMethod] + public void OtherLanguages_SayWhatItWasSpecialisedFor() + { + ClassDeclaration describe = new("Describe") + { + Kind = TypeDeclarationKind.Struct, + SpecialisationArguments = { new TypeReference("holo::components::RigidBody") }, + }; + + Assert.Contains( + "// specialised for holo::components::RigidBody", + new CSharpGenerator().Generate(describe), + StringComparison.Ordinal); + Assert.Contains( + "# specialised for holo::components::RigidBody", + new PythonGenerator().Generate(describe), + StringComparison.Ordinal); + Assert.Contains( + "// specialised for holo::components::RigidBody", + new JavaScriptGenerator().Generate(describe), + StringComparison.Ordinal); + } + + /// + /// The arguments survive a round trip through YAML, nested ones included, and a clone carries + /// them without sharing them. + /// + [TestMethod] + public void Yaml_RoundTripsTheArguments() + { + ClassDeclaration original = new("Describe") + { + Kind = TypeDeclarationKind.Struct, + SpecialisationArguments = + { + new TypeReference("holo::components::RigidBody"), + TypeReference.Parse("holo::Result"), + }, + }; + + string yaml = new YamlSerializer().Serialize(original); + ClassDeclaration restored = (ClassDeclaration)new YamlDeserializer().Deserialize(yaml)!; + + Assert.HasCount(2, restored.SpecialisationArguments); + Assert.AreEqual("holo::components::RigidBody", restored.SpecialisationArguments[0].ToString()); + Assert.AreEqual("holo::Result", restored.SpecialisationArguments[1].ToString()); + + ClassDeclaration clone = (ClassDeclaration)original.Clone(); + + Assert.HasCount(2, clone.SpecialisationArguments); + Assert.AreEqual(original.SpecialisationArguments[1].ToString(), clone.SpecialisationArguments[1].ToString()); + Assert.AreNotSame(original.SpecialisationArguments[0], clone.SpecialisationArguments[0]); + } + + /// + /// A document says nothing for a declaration that specialises nothing. + /// + [TestMethod] + public void Yaml_WritesNothingForAnOrdinaryDeclaration() + { + string yaml = new YamlSerializer().Serialize(new ClassDeclaration("RigidBody")); + + Assert.DoesNotContain("specialisationArguments", yaml, StringComparison.Ordinal); + } +} diff --git a/Coder/Ast/ClassDeclaration.cs b/Coder/Ast/ClassDeclaration.cs index 0ee35e8..b68b27c 100644 --- a/Coder/Ast/ClassDeclaration.cs +++ b/Coder/Ast/ClassDeclaration.cs @@ -49,6 +49,37 @@ public ClassDeclaration() /// public TypeReference? BaseType { get; set; } + /// + /// Gets the type arguments this declaration is the specialisation for, or nothing when it is + /// an ordinary declaration. + /// + /// + /// An explicit specialisation is C++ and only C++, which is why this is a property on the + /// ordinary declaration rather than a node of its own: the thing being declared is still a + /// class, with the same members, the same visibility and the same documentation. What changes + /// is which type it is the declaration for. + /// + /// It exists because a generated table has to be reachable from the type it describes. + /// template<> struct Describe<RigidBody> is how C++ attaches a fact to a type + /// without touching the type, and a generator that could not say it would have to fall back on + /// naming — a DescribeRigidBody that every consumer has to spell for itself, which is + /// the thing a lookup by type exists to avoid. + /// + /// + /// The precedent is and : + /// one generator honours it and the others write a comment, because a generated file that + /// quietly drops what it was for looks like one that still means it. The arguments are + /// rather than text, though, which those two are not — a + /// specialisation argument is a type, and the AST already knows how to be a type. + /// + /// + public Collection SpecialisationArguments { get; init; } = []; + + /// + /// Gets a value indicating whether this declares a specialisation rather than a type. + /// + public bool IsSpecialisation => SpecialisationArguments.Count > 0; + /// /// Gets or sets how widely the class is visible. /// @@ -79,6 +110,11 @@ public override AstNode Clone() Visibility = Visibility }; + foreach (TypeReference argument in SpecialisationArguments) + { + clone.SpecialisationArguments.Add(argument.Clone()); + } + foreach ((string key, object? value) in Metadata) { clone.Metadata[key] = value; diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 2b5a23a..4b2c868 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -154,6 +154,14 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) { GenerateDocumentation(classDecl, code); + // C++ can attach a declaration to a type it does not own, by specialising a template on it. + // Nothing here can, so the fact is written down rather than lost: what follows is an + // ordinary declaration, and the comment says which type it was the declaration for. + if (classDecl.IsSpecialisation) + { + WriteInexpressible(code, $"specialised for {string.Join(", ", classDecl.SpecialisationArguments)}"); + } + string keyword = classDecl.Kind switch { TypeDeclarationKind.Struct => "struct", diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index 5c34db7..9939e56 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -350,8 +350,24 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod // no keyword in C++ and is a class whose members are all public. bool isStruct = classDecl.Kind == TypeDeclarationKind.Struct; + // An explicit specialisation says up front that what follows declares nothing new: the + // template it specialises is already declared somewhere, and this fills it in for one set + // of arguments. The empty list is what distinguishes a full specialisation from a partial + // one, and the generator only writes full ones -- a partial specialisation would need + // parameters of its own, which is a different thing and not one the AST models. + if (classDecl.IsSpecialisation) + { + code.WriteLine("template <>"); + } + code.Write($"{(isStruct ? "struct" : "class")} {classDecl.Name ?? "UnnamedClass"}"); + if (classDecl.IsSpecialisation) + { + IEnumerable arguments = classDecl.SpecialisationArguments.Select(MapToCppType); + code.Write($"<{string.Join(", ", arguments)}>"); + } + if (classDecl.BaseType is TypeReference baseType) { code.Write($" : public {MapToCppType(baseType)}"); diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index 188ef05..8e37d3c 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -264,6 +264,14 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Ensure.NotNull(classDecl); Ensure.NotNull(code); + // C++ can attach a declaration to a type it does not own, by specialising a template on it. + // Nothing here can, so the fact is written down rather than lost: what follows is an + // ordinary declaration, and the comment says which type it was the declaration for. + if (classDecl.IsSpecialisation) + { + WriteInexpressible(code, $"specialised for {string.Join(", ", classDecl.SpecialisationArguments)}"); + } + code.Write($"class {classDecl.Name ?? "UnnamedClass"}"); if (classDecl.BaseType is TypeReference baseType) diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 5ff2a85..6f2eba3 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -282,6 +282,14 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Ensure.NotNull(classDecl); Ensure.NotNull(code); + // C++ can attach a declaration to a type it does not own, by specialising a template on it. + // Nothing here can, so the fact is written down rather than lost: what follows is an + // ordinary declaration, and the comment says which type it was the declaration for. + if (classDecl.IsSpecialisation) + { + WriteInexpressible(code, $"specialised for {string.Join(", ", classDecl.SpecialisationArguments)}"); + } + code.Write($"class {classDecl.Name ?? "UnnamedClass"}"); if (classDecl.BaseType is TypeReference baseType) diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index 43344b5..5389bbb 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -658,6 +658,7 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) DeserializeVisibility(classDecl, dict); ReadStrings(dict, DocumentationKey, classDecl.Documentation); + DeserializeSpecialisationArguments(classDecl, dict); DeserializeClassMembers(classDecl, dict); DeserializeMetadata(classDecl, dict); @@ -665,6 +666,23 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) return classDecl; } + private static void DeserializeSpecialisationArguments(ClassDeclaration classDecl, Dictionary dict) + { + if (!dict.TryGetValue("specialisationArguments", out object? argumentsObj) || + argumentsObj is not List argumentList) + { + return; + } + + foreach (object argument in argumentList) + { + if (argument?.ToString() is string text && text.Length > 0) + { + classDecl.SpecialisationArguments.Add(TypeReference.Parse(text)); + } + } + } + private void DeserializeClassMembers(ClassDeclaration classDecl, Dictionary dict) { if (!dict.TryGetValue("members", out object? membersObj) || membersObj is not List memberList) diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index 7a603ef..318c2e7 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -3,6 +3,7 @@ namespace ktsu.Coder.Serialization; using System.Collections.Generic; +using System.Linq; using ktsu.Coder.Ast; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -535,6 +536,14 @@ private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictio nodeData["baseType"] = classDecl.BaseType.ToString(); } + if (classDecl.SpecialisationArguments.Count > 0) + { + // Each argument on its own, rather than joined: a type argument can itself have type + // arguments, so a comma is part of one of them as often as it is a separator. + nodeData["specialisationArguments"] = + classDecl.SpecialisationArguments.Select(argument => argument.ToString()).ToList(); + } + SerializeVisibility(classDecl, nodeData); SerializeDocumentation(classDecl, nodeData); From 075ad65b01b7cd8574e065f64d527c9e9f7f86f2 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Fri, 11 Sep 2026 22:10:06 +0000 Subject: [PATCH 2/2] Filter the specialisation arguments before the loop reads them A null or empty entry in the document is not an argument, and saying so in a Where rather than in an if inside the loop leaves the loop doing one thing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu --- Coder/Serialization/YamlDeserializer.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index 5389bbb..6931d03 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -674,12 +674,15 @@ private static void DeserializeSpecialisationArguments(ClassDeclaration classDec return; } - foreach (object argument in argumentList) + // A null or empty entry is not an argument. Filtering before the loop rather than inside it + // so that what the loop takes is what the loop does. + IEnumerable written = argumentList + .Select(argument => argument?.ToString() ?? string.Empty) + .Where(text => text.Length > 0); + + foreach (string text in written) { - if (argument?.ToString() is string text && text.Length > 0) - { - classDecl.SpecialisationArguments.Add(TypeReference.Parse(text)); - } + classDecl.SpecialisationArguments.Add(TypeReference.Parse(text)); } }