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..6931d03 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,26 @@ 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; + } + + // 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) + { + 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);