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
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Coder.Graph/AstFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@
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)),

Check warning on line 186 in Coder.Graph/AstFields.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCPmQlLh0_HH2k9376N&open=AaCPmQlLh0_HH2k9376N&pullRequest=50
new("Constant", AstFieldKind.Flag, Spell(fieldDecl.IsConstant)),

Check warning on line 187 in Coder.Graph/AstFields.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCPmQlLh0_HH2k9376O&open=AaCPmQlLh0_HH2k9376O&pullRequest=50
],

ClassDeclaration classDecl =>
Expand Down Expand Up @@ -379,6 +381,10 @@
(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") =>
Expand Down
47 changes: 47 additions & 0 deletions Coder.Test/Ast/TypeReferenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,53 @@ public void Parse_KeepsAQualifiedNameWhole()
Assert.AreEqual("System.Collections.Generic.List", TypeReference.Parse("System.Collections.Generic.List").Name);
}

/// <summary>
/// An array is the type with <c>[]</c> after it, and it round-trips like everything else the
/// grammar reads.
/// </summary>
/// <remarks>
/// Before this, <c>int[]</c> 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.
/// </remarks>
[TestMethod]
public void Parse_ReadsAnArray()
{
TypeReference type = TypeReference.Parse("int[]");

Assert.AreEqual("int", type.Name);
Assert.IsTrue(type.IsArray);
Assert.AreEqual("int[]", type.ToString());
}

/// <summary>
/// An array of a parameterised type reads both parts, and the brackets go outside the arguments
/// where a reader expects them.
/// </summary>
[TestMethod]
public void Parse_ReadsAnArrayOfAParameterisedType()
{
TypeReference type = TypeReference.Parse("std::span<const Velocity>[]");

Assert.AreEqual("std::span", type.Name);
Assert.IsTrue(type.IsArray);
Assert.HasCount(1, type.TypeArguments);
Assert.AreEqual("std::span<const Velocity>[]", type.ToString());
}

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public void AnArrayIsNotEqualToItsElementType()
{
TypeReference element = new("int");
TypeReference array = new("int") { IsArray = true };

Assert.AreNotEqual(element, array);
Assert.IsTrue(array.Clone().IsArray);
}

/// <summary>
/// The argument list is a list, which is the whole difference from a string.
/// </summary>
Expand Down
192 changes: 192 additions & 0 deletions Coder.Test/Languages/ExemplarReflectionTableTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Builds the reflection table Holotype's <c>generate_reflection.cpp</c> emits and checks that each
/// generator writes it in its own language.
/// </summary>
/// <remarks>
/// <c>docs/generated-cpp-target.md</c> 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.
/// <para>
/// What it does not cover is the <c>template&lt;&gt; struct Describe&lt;T&gt;</c> 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
/// <c>static_assert</c> raised and the same answer available — is open.
/// </para>
/// </remarks>
[TestClass]
public class ExemplarReflectionTableTests
{
/// <summary>The table as C++ wants it: a namespace-scope constant array of designated rows.</summary>
private const string ExpectedCpp =
"""
#include <cstddef>
#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
""";

/// <summary>
/// Builds the table.
/// </summary>
/// <returns>The file holding it.</returns>
private static SourceFile ReflectionTable()
{
SourceFile file = new() { Name = "RigidBody.reflect.gen" };
file.Imports.Add("<cstddef>");
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;
}

/// <summary>
/// Builds one row of the table.
/// </summary>
/// <param name="name">The field the row describes.</param>
/// <param name="lerp">Whether that field is interpolated between states.</param>
/// <returns>The row.</returns>
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;
}

/// <summary>
/// The whole table, which is the point of all four additions at once.
/// </summary>
[TestMethod]
public void Cpp_GeneratesTheTableTheReflectionGeneratorEmits() =>
Assert.AreEqual(
ExpectedCpp.ReplaceLineEndings("\n").TrimEnd(),
new CppGenerator().Generate(ReflectionTable()).ReplaceLineEndings("\n").TrimEnd());

/// <summary>
/// A constant at namespace scope has to say <c>inline</c> or every translation unit including
/// the header defines it again.
/// </summary>
[TestMethod]
public void Cpp_WritesANamespaceScopeConstantInline() =>
Assert.Contains("inline constexpr", new CppGenerator().Generate(ReflectionTable()));

/// <summary>
/// A static data member is already implicitly inline, so inside a type the same field says
/// <c>static</c> instead — which is the only reason the generator tracks where it is.
/// </summary>
[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);
}

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

/// <summary>
/// C# has no designated initialiser; an object initialiser is the same idea and the same order
/// freedom, and <c>static readonly</c> is what <c>constexpr</c> means where <c>const</c> is
/// reserved for primitives.
/// </summary>
[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);
}

/// <summary>
/// Python's keyword argument is the designated initialiser, so the names survive the trip
/// rather than collapsing into position.
/// </summary>
[TestMethod]
public void Python_WritesKeywordArguments() =>
Assert.Contains("name=\"velocity\"", new PythonGenerator().Generate(ReflectionTable()));

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public void JavaScript_WritesAnObjectLiteral() =>
Assert.Contains("{ name: \"velocity\"", new JavaScriptGenerator().Generate(ReflectionTable()));

/// <summary>
/// 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.
/// </summary>
[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));
}
}
23 changes: 23 additions & 0 deletions Coder/Ast/FieldDeclaration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ public FieldDeclaration(string name, TypeReference? type = null)
/// </summary>
public Visibility Visibility { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the field belongs to the type rather than to an
/// instance of it.
/// </summary>
public bool IsStatic { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the field's value is fixed and known where it is
/// written.
/// </summary>
/// <remarks>
/// 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
/// <c>inline constexpr</c> at namespace scope and <c>static constexpr</c> inside a class, since
/// only the first of those has to say out loud that one definition is meant. C# writes
/// <c>static readonly</c>, which is legal for every type where <c>const</c> is legal for a
/// handful. A constant field is static whether or not <see cref="IsStatic"/> says so - there is
/// no per-instance copy of a value fixed at compile time.
/// </remarks>
public bool IsConstant { get; set; }

/// <inheritdoc/>
public Collection<string> Documentation { get; init; } = [];

Expand All @@ -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)
Expand Down
Loading