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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ source in four target languages. The solution uses:
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.
- `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
`std::is_trivially_copyable_v<T>` to model. Only C++ has one; the others write a comment, because a
file that quietly loses a guarantee looks like one that still makes it.
- `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares.
- `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived
generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately
Expand Down
9 changes: 9 additions & 0 deletions Coder.Graph/AstFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ public static IReadOnlyList<AstField> Of(AstNode node)
new(ValueField, AstFieldKind.Text, enumMember.Value ?? string.Empty),
],

CompileTimeAssertion assertion =>
[
new("Condition", AstFieldKind.Text, assertion.Condition ?? string.Empty),
new("Message", AstFieldKind.Text, assertion.Message ?? string.Empty),
],

UsingAlias usingAlias =>
[
new("Name", AstFieldKind.Text, usingAlias.Name ?? string.Empty),
Expand Down Expand Up @@ -361,6 +367,9 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v
(EnumMember enumMember, "Name") => Assign(() => enumMember.Name = OrNull(value)),
(EnumMember enumMember, ValueField) => Assign(() => enumMember.Value = OrNull(value)),

(CompileTimeAssertion assertion, "Condition") => Assign(() => assertion.Condition = OrNull(value)),
(CompileTimeAssertion assertion, "Message") => Assign(() => assertion.Message = OrNull(value)),

(UsingAlias usingAlias, "Name") => Assign(() => usingAlias.Name = OrNull(value)),
(UsingAlias usingAlias, "AliasedType") => Assign(() => usingAlias.AliasedType = OrNull(value)),
(UsingAlias usingAlias, VisibilityField) =>
Expand Down
3 changes: 2 additions & 1 deletion Coder.Graph/AstSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,8 @@ public static bool Accepts(AstSlot slot, AstNode candidate)
// running at one, so it belongs to a class or to the document rather than inside a body.
AstSlotKind.Statement => candidate is not (Parameter or EntryPoint),
AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or FieldDeclaration
or ClassDeclaration or EnumDeclaration or NamespaceDeclaration or UsingAlias or EntryPoint,
or ClassDeclaration or EnumDeclaration or NamespaceDeclaration or UsingAlias
or CompileTimeAssertion or EntryPoint,
AstSlotKind.EnumMember => candidate is EnumMember,
_ => false,
};
Expand Down
153 changes: 153 additions & 0 deletions Coder.Test/Ast/CompileTimeAssertionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Test.Ast;

using ktsu.Coder.Ast;
using ktsu.Coder.Graph;
using ktsu.Coder.Languages;
using ktsu.Coder.Serialization;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests for <see cref="CompileTimeAssertion"/>: what a generated type promises that the type itself
/// cannot say.
/// </summary>
/// <remarks>
/// Only C++ has anything checked before the program runs, so this is the clearest case of the rule
/// the whole AST follows — say what is true, and let each language say as much of it as it can. The
/// other three write a comment rather than dropping it, because a file that quietly loses a
/// guarantee looks exactly like one that still makes it.
/// </remarks>
[TestClass]
public class CompileTimeAssertionTests
{
/// <summary>
/// C++ writes the assertion, with the message on its own line.
/// </summary>
[TestMethod]
public void Cpp_WritesTheAssertion()
{
CompileTimeAssertion assertion = new(
"std::is_trivially_copyable_v<RigidBody>",
"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes");

Assert.AreEqual(
"static_assert(std::is_trivially_copyable_v<RigidBody>,\n"
+ " \"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes\");\n",
new CppGenerator().Generate(assertion).ReplaceLineEndings("\n"));
}

/// <summary>
/// An assertion with nothing to say when it fails is still an assertion.
/// </summary>
[TestMethod]
public void Cpp_WritesAnAssertionWithNoMessage()
{
Assert.AreEqual(
"static_assert(sizeof(Handle) == 8);\n",
new CppGenerator().Generate(new CompileTimeAssertion("sizeof(Handle) == 8")).ReplaceLineEndings("\n"));
}

/// <summary>
/// A message with a quote in it is escaped rather than ending the string early.
/// </summary>
[TestMethod]
public void Cpp_EscapesTheMessage()
{
CompileTimeAssertion assertion = new("sizeof(T) == 8", "a \"T\" is eight bytes");

Assert.Contains("\\\"T\\\"", new CppGenerator().Generate(assertion), StringComparison.Ordinal);
}

/// <summary>
/// The other three have nothing checked before the program runs, so they say what was asserted
/// rather than dropping it.
/// </summary>
[TestMethod]
public void OtherLanguages_SayWhatWasAsserted()
{
CompileTimeAssertion assertion = new("std::is_standard_layout_v<RigidBody>", "layout must be stable");

Assert.Contains(
"// asserted at build time: std::is_standard_layout_v<RigidBody>",
new CSharpGenerator().Generate(assertion),
StringComparison.Ordinal);
Assert.Contains(
"# asserted at build time: std::is_standard_layout_v<RigidBody>",
new PythonGenerator().Generate(assertion),
StringComparison.Ordinal);
Assert.Contains(
"// asserted at build time: std::is_standard_layout_v<RigidBody>",
new JavaScriptGenerator().Generate(assertion),
StringComparison.Ordinal);
}

/// <summary>
/// Assertions about one type stay together, and are separated from the declaration they are about.
/// </summary>
/// <remarks>
/// The same rule the members of a type follow. Two of a kind that say nothing about themselves are
/// one block; a struct followed by an assertion is two.
/// </remarks>
[TestMethod]
public void Cpp_GroupsAssertionsAboutTheSameType()
{
SourceFile file = new("RigidBody.gen.hpp");
file.Members.Add(new ClassDeclaration("RigidBody") { Kind = TypeDeclarationKind.Struct });
file.Members.Add(new CompileTimeAssertion("std::is_trivially_copyable_v<RigidBody>", "bytes"));
file.Members.Add(new CompileTimeAssertion("std::is_standard_layout_v<RigidBody>", "offsets"));

string code = new CppGenerator().Generate(file).ReplaceLineEndings("\n");

Assert.Contains("};\n\nstatic_assert(", code, StringComparison.Ordinal);
Assert.Contains("\"bytes\");\nstatic_assert(", code, StringComparison.Ordinal);
}

/// <summary>
/// An assertion survives a round trip through YAML, and a clone carries it.
/// </summary>
[TestMethod]
public void Yaml_RoundTripsTheAssertion()
{
CompileTimeAssertion original = new("sizeof(Handle) == 8", "a handle is eight bytes");

string yaml = new YamlSerializer().Serialize(original);
CompileTimeAssertion restored = (CompileTimeAssertion)new YamlDeserializer().Deserialize(yaml)!;

Assert.AreEqual("sizeof(Handle) == 8", restored.Condition);
Assert.AreEqual("a handle is eight bytes", restored.Message);

CompileTimeAssertion clone = (CompileTimeAssertion)original.Clone();

Assert.AreEqual(original.Condition, clone.Condition);
Assert.AreEqual(original.Message, clone.Message);
Assert.AreNotSame(original, clone);
}

/// <summary>
/// A document says nothing for an assertion that asserts nothing.
/// </summary>
[TestMethod]
public void Yaml_WritesNothingForAnEmptyAssertion()
{
string yaml = new YamlSerializer().Serialize(new CompileTimeAssertion());

Assert.DoesNotContain("condition", yaml, StringComparison.Ordinal);
Assert.DoesNotContain("message", yaml, StringComparison.Ordinal);
}

/// <summary>
/// The editor can place one beside a declaration and edit both of its halves.
/// </summary>
[TestMethod]
public void Graph_AcceptsAndEditsAnAssertion()
{
NamespaceDeclaration components = new("holo::components");
CompileTimeAssertion assertion = new("sizeof(Handle) == 8");

Assert.IsTrue(AstSchema.TryAttach(components, AstSchema.SlotsOf(components)[0], assertion));
Assert.IsTrue(AstFields.TryWrite(assertion, "Message", "a handle is eight bytes"));

Assert.AreEqual("a handle is eight bytes", assertion.Message);
}
}
12 changes: 12 additions & 0 deletions Coder.Test/Languages/ExemplarHeaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ enum class BodyKind : std::uint8_t
BodyKind body_kind = BodyKind::Dynamic;
};

static_assert(std::is_trivially_copyable_v<RigidBody>,
"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes");
static_assert(std::is_standard_layout_v<RigidBody>,
"RigidBody must be standard layout for its field offsets to be stable");

} // namespace holo::components
""";

Expand Down Expand Up @@ -136,6 +141,13 @@ private static SourceFile RigidBodyHeader()
NamespaceDeclaration components = new("holo::components");
components.Members.Add(rigidBody);

components.Members.Add(new CompileTimeAssertion(
"std::is_trivially_copyable_v<RigidBody>",
"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes"));
components.Members.Add(new CompileTimeAssertion(
"std::is_standard_layout_v<RigidBody>",
"RigidBody must be standard layout for its field offsets to be stable"));

SourceFile file = new("RigidBody.gen.hpp") { IsHeader = true };
file.HeaderComment.Add("Generated by holo_schemac. Do not edit.");
file.HeaderComment.Add("");
Expand Down
30 changes: 29 additions & 1 deletion Coder.Test/Languages/ExemplarSemanticTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ [[nodiscard]] constexpr underlying value() const noexcept
private:
underlying value_{};
};

static_assert(std::is_trivially_copyable_v<EntityId>,
"EntityId must be trivially copyable: it appears in components");
static_assert(std::is_standard_layout_v<EntityId>,
"EntityId must be standard layout for its field offsets to be stable");
""";

/// <summary>
Expand Down Expand Up @@ -114,6 +119,29 @@ private static ClassDeclaration EntityId()
return entity;
}

/// <summary>
/// The semantic type together with what is asserted about it.
/// </summary>
/// <returns>The file.</returns>
/// <remarks>
/// A file with no banner and no imports, because the document's section is the declaration and
/// the assertions beside it rather than a whole header. What the assertions say is the reason the
/// type can appear in a component at all.
/// </remarks>
private static SourceFile EntityIdWithAssertions()
{
SourceFile file = new("EntityId.gen.hpp");
file.Members.Add(EntityId());
file.Members.Add(new CompileTimeAssertion(
"std::is_trivially_copyable_v<EntityId>",
"EntityId must be trivially copyable: it appears in components"));
file.Members.Add(new CompileTimeAssertion(
"std::is_standard_layout_v<EntityId>",
"EntityId must be standard layout for its field offsets to be stable"));

return file;
}

/// <summary>
/// Builds one of the comparison operators, which are symmetric and so belong beside the type
/// rather than to either operand.
Expand Down Expand Up @@ -146,7 +174,7 @@ private static FunctionDeclaration Comparison(string symbol, string returnType)
public void Cpp_GeneratesTheSemanticTypeTheDocumentSpecifies() =>
Assert.AreEqual(
Expected.ReplaceLineEndings("\n").TrimEnd(),
new CppGenerator().Generate(EntityId()).ReplaceLineEndings("\n").TrimEnd());
new CppGenerator().Generate(EntityIdWithAssertions()).ReplaceLineEndings("\n").TrimEnd());

/// <summary>
/// A member is initialised rather than assigned, which is the only way to start one that cannot be
Expand Down
84 changes: 84 additions & 0 deletions Coder/Ast/CompileTimeAssertion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Ast;

/// <summary>
/// Something that must be true when the program is built rather than when it runs.
/// </summary>
/// <remarks>
/// What a generated type promises is often not something the type itself can say. A struct that
/// crosses a language boundary and the wire as raw bytes has to be trivially copyable and standard
/// layout, and the moment that stops being true is the moment a saved file starts being wrong — so
/// it is asserted where the type is declared, and the build fails rather than the save file.
/// <para>
/// <see cref="Condition"/> is text, and deliberately. A compile-time predicate is language-specific
/// in a way most of the AST is not: <c>std::is_trivially_copyable_v&lt;T&gt;</c> has no equivalent
/// anywhere else, so there is no shared idea underneath to model. It is carried the same way
/// <see cref="SourceFile.Imports"/> are, and for the same reason — an assertion, like an import, is
/// written for the language the file is for.
/// </para>
/// <para>
/// Only C++ has this. Every other target here writes a comment saying what was asserted, because a
/// generated file that silently drops a guarantee looks like one that still makes it.
/// </para>
/// </remarks>
public class CompileTimeAssertion : AstNode
{
/// <summary>
/// Initializes a new instance of the <see cref="CompileTimeAssertion"/> class.
/// </summary>
public CompileTimeAssertion()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="CompileTimeAssertion"/> class.
/// </summary>
/// <param name="condition">The predicate that must hold, as the target language writes it.</param>
/// <param name="message">What to say when it does not.</param>
public CompileTimeAssertion(string condition, string? message = null)
{
Condition = condition;
Message = message;
}

/// <summary>
/// Gets or sets the predicate that must hold, as the target language writes it.
/// </summary>
public string? Condition { get; set; }

/// <summary>
/// Gets or sets what to say when the predicate does not hold.
/// </summary>
/// <remarks>
/// Worth writing rather than leaving to the compiler: the predicate says what is false and the
/// message says why anyone cared, and only the second tells whoever hits it what to do.
/// </remarks>
public string? Message { get; set; }

/// <summary>
/// Gets the type name of this node for serialization purposes.
/// </summary>
/// <returns>The name of the node type.</returns>
public override string GetNodeTypeName() => "CompileTimeAssertion";

/// <summary>
/// Creates a deep clone of this assertion.
/// </summary>
/// <returns>A new instance with the same properties.</returns>
public override AstNode Clone()
{
CompileTimeAssertion clone = new()
{
Condition = Condition,
Message = Message,
};

foreach ((string key, object? value) in Metadata)
{
clone.Metadata[key] = value;
}

return clone;
}
}
22 changes: 22 additions & 0 deletions Coder/Languages/CSharpGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,25 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
case UnaryExpression unaryExpr:
GenerateUnaryExpression(unaryExpr, code, GetUnaryOperator(unaryExpr.Operator));
break;
default:
GenerateExpressionOrLeaf(node, code);
break;
}
}

/// <summary>
/// Emits an expression or a leaf.
/// </summary>
/// <param name="node">The node to emit.</param>
/// <param name="code">The writer to emit into.</param>
/// <remarks>
/// Split from the declarations only because one switch over every node the AST has is more
/// branches than the analyzer accepts. The line is the same one the AST already draws.
/// </remarks>
private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code)
{
switch (node)
{
case VariableReference varRef:
code.Write(varRef.Name);
break;
Expand All @@ -82,6 +101,9 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
case NamespaceDeclaration namespaceDecl:
GenerateNamespace(namespaceDecl, code);
break;
case CompileTimeAssertion assertion:
WriteInexpressible(code, $"asserted at build time: {assertion.Condition}");
break;
case UsingAlias usingAlias:
GenerateUsingAlias(usingAlias, code);
break;
Expand Down
Loading