Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RigidBody>` 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<Handle, Error>` 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
Expand Down
179 changes: 179 additions & 0 deletions Coder.Test/Ast/SpecialisationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="ClassDeclaration.SpecialisationArguments"/>: a declaration that attaches
/// itself to a type rather than introducing one.
/// </summary>
/// <remarks>
/// The second case of the rule <see cref="CompileTimeAssertion"/> 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 <c>Describe&lt;RigidBody&gt;</c> is how C++ does
/// that without touching <c>RigidBody</c>.
/// </remarks>
[TestClass]
public class SpecialisationTests
{
/// <summary>
/// C++ writes the empty parameter list above the declaration and the arguments after its name.
/// </summary>
[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<holo::components::RigidBody>\n"
+ "{\n"
+ " static constexpr ComponentInfo info{};\n"
+ "};\n",
new CppGenerator().Generate(describe).ReplaceLineEndings("\n"));
}

/// <summary>
/// A declaration with no arguments is an ordinary one, and says nothing about templates.
/// </summary>
/// <remarks>
/// The property is what distinguishes the two, so the absence has to be tested as well as the
/// presence: a generator that wrote <c>template &lt;&gt;</c> unconditionally would break every
/// type this library has ever emitted.
/// </remarks>
[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);
}

/// <summary>
/// Several arguments are written in the order they were given.
/// </summary>
[TestMethod]
public void Cpp_WritesEveryArgumentInOrder()
{
ClassDeclaration converter = new("Convert")
{
Kind = TypeDeclarationKind.Struct,
SpecialisationArguments =
{
new TypeReference("Metres"),
new TypeReference("Feet"),
},
};

Assert.Contains(
"struct Convert<Metres, Feet>",
new CppGenerator().Generate(converter),
StringComparison.Ordinal);
}

/// <summary>
/// An argument that is itself a generic type keeps its own arguments.
/// </summary>
/// <remarks>
/// The reason the arguments are <see cref="TypeReference"/> rather than text: a type argument
/// has structure, and the comma in <c>Result&lt;Handle, Error&gt;</c> belongs to that type
/// rather than separating two of them.
/// </remarks>
[TestMethod]
public void Cpp_WritesANestedArgumentWhole()
{
ClassDeclaration describe = new("Describe")
{
Kind = TypeDeclarationKind.Struct,
SpecialisationArguments = { TypeReference.Parse("holo::Result<holo::BodyHandle, holo::Error>") },
};

Assert.Contains(
"struct Describe<holo::Result<holo::BodyHandle, holo::Error>>",
new CppGenerator().Generate(describe),
StringComparison.Ordinal);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// The arguments survive a round trip through YAML, nested ones included, and a clone carries
/// them without sharing them.
/// </summary>
[TestMethod]
public void Yaml_RoundTripsTheArguments()
{
ClassDeclaration original = new("Describe")
{
Kind = TypeDeclarationKind.Struct,
SpecialisationArguments =
{
new TypeReference("holo::components::RigidBody"),
TypeReference.Parse("holo::Result<holo::BodyHandle, holo::Error>"),
},
};

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<holo::BodyHandle, holo::Error>", 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]);
}

/// <summary>
/// A document says nothing for a declaration that specialises nothing.
/// </summary>
[TestMethod]
public void Yaml_WritesNothingForAnOrdinaryDeclaration()
{
string yaml = new YamlSerializer().Serialize(new ClassDeclaration("RigidBody"));

Assert.DoesNotContain("specialisationArguments", yaml, StringComparison.Ordinal);
}
}
36 changes: 36 additions & 0 deletions Coder/Ast/ClassDeclaration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ public ClassDeclaration()
/// </summary>
public TypeReference? BaseType { get; set; }

/// <summary>
/// Gets the type arguments this declaration is the specialisation for, or nothing when it is
/// an ordinary declaration.
/// </summary>
/// <remarks>
/// 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 <em>for</em>.
/// <para>
/// It exists because a generated table has to be reachable from the type it describes.
/// <c>template&lt;&gt; struct Describe&lt;RigidBody&gt;</c> 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 <c>DescribeRigidBody</c> that every consumer has to spell for itself, which is
/// the thing a lookup by type exists to avoid.
/// </para>
/// <para>
/// The precedent is <see cref="CompileTimeAssertion"/> and <see cref="SourceFile.Imports"/>:
/// 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
/// <see cref="TypeReference"/> 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.
/// </para>
/// </remarks>
public Collection<TypeReference> SpecialisationArguments { get; init; } = [];

/// <summary>
/// Gets a value indicating whether this declares a specialisation rather than a type.
/// </summary>
public bool IsSpecialisation => SpecialisationArguments.Count > 0;

/// <summary>
/// Gets or sets how widely the class is visible.
/// </summary>
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions Coder/Languages/CSharpGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions Coder/Languages/CppGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@
/// describing a scope.
/// </para>
/// </remarks>
protected override void GenerateClassDeclaration(ClassDeclaration classDecl, CodeBlocker code)

Check warning on line 342 in Coder/Languages/CppGenerator.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check failure on line 342 in Coder/Languages/CppGenerator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCSjD0lmkk5PHA_SbWf&open=AaCSjD0lmkk5PHA_SbWf&pullRequest=53
{
Ensure.NotNull(classDecl);
Ensure.NotNull(code);
Expand All @@ -350,8 +350,24 @@
// 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<string> arguments = classDecl.SpecialisationArguments.Select(MapToCppType);
code.Write($"<{string.Join(", ", arguments)}>");
}

if (classDecl.BaseType is TypeReference baseType)
{
code.Write($" : public {MapToCppType(baseType)}");
Expand Down
8 changes: 8 additions & 0 deletions Coder/Languages/JavaScriptGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions Coder/Languages/PythonGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions Coder/Serialization/YamlDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -658,13 +658,34 @@

DeserializeVisibility(classDecl, dict);
ReadStrings(dict, DocumentationKey, classDecl.Documentation);
DeserializeSpecialisationArguments(classDecl, dict);

DeserializeClassMembers(classDecl, dict);
DeserializeMetadata(classDecl, dict);

return classDecl;
}

private static void DeserializeSpecialisationArguments(ClassDeclaration classDecl, Dictionary<object, object> dict)
{
if (!dict.TryGetValue("specialisationArguments", out object? argumentsObj) ||
argumentsObj is not List<object> 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<string> 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<object, object> dict)
{
if (!dict.TryGetValue("members", out object? membersObj) || membersObj is not List<object> memberList)
Expand Down Expand Up @@ -782,7 +803,7 @@
return returnStmt;
}

private BinaryExpression DeserializeBinaryExpression(object? nodeData)

Check warning on line 806 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
{
BinaryExpression binaryExpr = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down Expand Up @@ -823,7 +844,7 @@
}

// Deserialize expected type
if (dict.TryGetValue("expectedType", out object? typeObj))

Check warning on line 847 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 4 times.

Check warning on line 847 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 4 times.
{
binaryExpr.ExpectedType = typeObj.ToString();
}
Expand Down Expand Up @@ -929,7 +950,7 @@
return varRef;
}

private VariableDeclaration DeserializeVariableDeclaration(object? nodeData)

Check warning on line 953 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
VariableDeclaration varDecl = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down Expand Up @@ -975,7 +996,7 @@
return varDecl;
}

private AssignmentStatement DeserializeAssignmentStatement(object? nodeData)

Check warning on line 999 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
AssignmentStatement assignment = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down
9 changes: 9 additions & 0 deletions Coder/Serialization/YamlSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -535,6 +536,14 @@
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);

Expand Down Expand Up @@ -631,7 +640,7 @@

if (binaryExpr.ExpectedType != null)
{
nodeData["expectedType"] = binaryExpr.ExpectedType;

Check warning on line 643 in Coder/Serialization/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 4 times.

Check warning on line 643 in Coder/Serialization/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 4 times.
}
}

Expand All @@ -651,7 +660,7 @@

private static void SerializeLiteralExpression<T>(LiteralExpression<T> literal, Dictionary<string, object> nodeData)
{
if (literal.Value != null)

Check warning on line 663 in Coder/Serialization/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Use a comparison to 'default(T)' instead or add a constraint to 'T' so that it can't be a value type.

Check warning on line 663 in Coder/Serialization/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Use a comparison to 'default(T)' instead or add a constraint to 'T' so that it can't be a value type.
{
nodeData[ValueKey] = literal.Value;
}
Expand Down