Skip to content

Commit 4dfd6d4

Browse files
committed
What a generated type promises that the type itself cannot say
The exemplar's first two sections each end with two static_asserts, and both acceptance tests stripped them. The document recorded that as an open question: they are an assertion about a generated type rather than something the schema asked for, and where they belong was undecided. They belong in the AST, and the decision turns on something already settled. SourceFile.Imports are the one part of the AST that does not translate -- a C++ include path, a C# namespace and a Python module are different kinds of thing that share a position, so each is carried as text for the language the file is for. A compile-time predicate is the same shape of problem: std::is_trivially_copyable_v<T> has no equivalent anywhere else, so there is no shared idea underneath it to model. CompileTimeAssertion.Condition is therefore text, which makes it consistent with an existing rule rather than a new special case. Only C++ has anything checked before the program runs. The other three write a comment saying what was asserted, because a file that quietly loses a guarantee looks exactly like one that still makes it. The message goes on its own line. These are long by nature -- the predicate says what is false and the message says why anyone cared -- and a compiler quoting the whole declaration back is easier to read as two lines than as one very wide one. Both acceptance tests now assert the document's sections in full. That is the point of this change: sections 1 and 2 are byte-identical to the specification rather than byte-identical to it less two lines each. Getting there needed the blank-line rule in one more place. A namespace and a file separated every member unconditionally, which split the two assertions about one type into two paragraphs. They now follow the same rule the members of a type do, hoisted to a virtual on the base: always separate, unless a language says otherwise, and C++ says two of a kind that say nothing about themselves stay together. 485 tests pass, 477 before and 8 new, with 0 warnings across the solution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
1 parent d372fb3 commit 4dfd6d4

15 files changed

Lines changed: 453 additions & 12 deletions

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ source in four target languages. The solution uses:
6868
one that cannot be assigned at all; a language without an initialiser list assigns at the top of
6969
the constructor instead. `ConstructionExpression` is the one expression that needs a type rather
7070
than a name, which is why it could not exist before `TypeReference` did.
71+
- `Coder/Ast/CompileTimeAssertion.cs` — what a generated type promises that the type itself cannot
72+
say. Its `Condition` is text for the same reason `SourceFile.Imports` are: a compile-time predicate
73+
is language-specific in a way most of the AST is not, and there is no shared idea underneath
74+
`std::is_trivially_copyable_v<T>` to model. Only C++ has one; the others write a comment, because a
75+
file that quietly loses a guarantee looks like one that still makes it.
7176
- `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares.
7277
- `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived
7378
generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately

Coder.Graph/AstFields.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ public static IReadOnlyList<AstField> Of(AstNode node)
165165
new(ValueField, AstFieldKind.Text, enumMember.Value ?? string.Empty),
166166
],
167167

168+
CompileTimeAssertion assertion =>
169+
[
170+
new("Condition", AstFieldKind.Text, assertion.Condition ?? string.Empty),
171+
new("Message", AstFieldKind.Text, assertion.Message ?? string.Empty),
172+
],
173+
168174
UsingAlias usingAlias =>
169175
[
170176
new("Name", AstFieldKind.Text, usingAlias.Name ?? string.Empty),
@@ -361,6 +367,9 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v
361367
(EnumMember enumMember, "Name") => Assign(() => enumMember.Name = OrNull(value)),
362368
(EnumMember enumMember, ValueField) => Assign(() => enumMember.Value = OrNull(value)),
363369

370+
(CompileTimeAssertion assertion, "Condition") => Assign(() => assertion.Condition = OrNull(value)),
371+
(CompileTimeAssertion assertion, "Message") => Assign(() => assertion.Message = OrNull(value)),
372+
364373
(UsingAlias usingAlias, "Name") => Assign(() => usingAlias.Name = OrNull(value)),
365374
(UsingAlias usingAlias, "AliasedType") => Assign(() => usingAlias.AliasedType = OrNull(value)),
366375
(UsingAlias usingAlias, VisibilityField) =>

Coder.Graph/AstSchema.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,8 @@ public static bool Accepts(AstSlot slot, AstNode candidate)
434434
// running at one, so it belongs to a class or to the document rather than inside a body.
435435
AstSlotKind.Statement => candidate is not (Parameter or EntryPoint),
436436
AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or FieldDeclaration
437-
or ClassDeclaration or EnumDeclaration or NamespaceDeclaration or UsingAlias or EntryPoint,
437+
or ClassDeclaration or EnumDeclaration or NamespaceDeclaration or UsingAlias
438+
or CompileTimeAssertion or EntryPoint,
438439
AstSlotKind.EnumMember => candidate is EnumMember,
439440
_ => false,
440441
};
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.Coder.Test.Ast;
4+
5+
using ktsu.Coder.Ast;
6+
using ktsu.Coder.Graph;
7+
using ktsu.Coder.Languages;
8+
using ktsu.Coder.Serialization;
9+
using Microsoft.VisualStudio.TestTools.UnitTesting;
10+
11+
/// <summary>
12+
/// Tests for <see cref="CompileTimeAssertion"/>: what a generated type promises that the type itself
13+
/// cannot say.
14+
/// </summary>
15+
/// <remarks>
16+
/// Only C++ has anything checked before the program runs, so this is the clearest case of the rule
17+
/// the whole AST follows — say what is true, and let each language say as much of it as it can. The
18+
/// other three write a comment rather than dropping it, because a file that quietly loses a
19+
/// guarantee looks exactly like one that still makes it.
20+
/// </remarks>
21+
[TestClass]
22+
public class CompileTimeAssertionTests
23+
{
24+
/// <summary>
25+
/// C++ writes the assertion, with the message on its own line.
26+
/// </summary>
27+
[TestMethod]
28+
public void Cpp_WritesTheAssertion()
29+
{
30+
CompileTimeAssertion assertion = new(
31+
"std::is_trivially_copyable_v<RigidBody>",
32+
"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes");
33+
34+
Assert.AreEqual(
35+
"static_assert(std::is_trivially_copyable_v<RigidBody>,\n"
36+
+ " \"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes\");\n",
37+
new CppGenerator().Generate(assertion).ReplaceLineEndings("\n"));
38+
}
39+
40+
/// <summary>
41+
/// An assertion with nothing to say when it fails is still an assertion.
42+
/// </summary>
43+
[TestMethod]
44+
public void Cpp_WritesAnAssertionWithNoMessage()
45+
{
46+
Assert.AreEqual(
47+
"static_assert(sizeof(Handle) == 8);\n",
48+
new CppGenerator().Generate(new CompileTimeAssertion("sizeof(Handle) == 8")).ReplaceLineEndings("\n"));
49+
}
50+
51+
/// <summary>
52+
/// A message with a quote in it is escaped rather than ending the string early.
53+
/// </summary>
54+
[TestMethod]
55+
public void Cpp_EscapesTheMessage()
56+
{
57+
CompileTimeAssertion assertion = new("sizeof(T) == 8", "a \"T\" is eight bytes");
58+
59+
Assert.Contains("\\\"T\\\"", new CppGenerator().Generate(assertion), StringComparison.Ordinal);
60+
}
61+
62+
/// <summary>
63+
/// The other three have nothing checked before the program runs, so they say what was asserted
64+
/// rather than dropping it.
65+
/// </summary>
66+
[TestMethod]
67+
public void OtherLanguages_SayWhatWasAsserted()
68+
{
69+
CompileTimeAssertion assertion = new("std::is_standard_layout_v<RigidBody>", "layout must be stable");
70+
71+
Assert.Contains(
72+
"// asserted at build time: std::is_standard_layout_v<RigidBody>",
73+
new CSharpGenerator().Generate(assertion),
74+
StringComparison.Ordinal);
75+
Assert.Contains(
76+
"# asserted at build time: std::is_standard_layout_v<RigidBody>",
77+
new PythonGenerator().Generate(assertion),
78+
StringComparison.Ordinal);
79+
Assert.Contains(
80+
"// asserted at build time: std::is_standard_layout_v<RigidBody>",
81+
new JavaScriptGenerator().Generate(assertion),
82+
StringComparison.Ordinal);
83+
}
84+
85+
/// <summary>
86+
/// Assertions about one type stay together, and are separated from the declaration they are about.
87+
/// </summary>
88+
/// <remarks>
89+
/// The same rule the members of a type follow. Two of a kind that say nothing about themselves are
90+
/// one block; a struct followed by an assertion is two.
91+
/// </remarks>
92+
[TestMethod]
93+
public void Cpp_GroupsAssertionsAboutTheSameType()
94+
{
95+
SourceFile file = new("RigidBody.gen.hpp");
96+
file.Members.Add(new ClassDeclaration("RigidBody") { Kind = TypeDeclarationKind.Struct });
97+
file.Members.Add(new CompileTimeAssertion("std::is_trivially_copyable_v<RigidBody>", "bytes"));
98+
file.Members.Add(new CompileTimeAssertion("std::is_standard_layout_v<RigidBody>", "offsets"));
99+
100+
string code = new CppGenerator().Generate(file).ReplaceLineEndings("\n");
101+
102+
Assert.Contains("};\n\nstatic_assert(", code, StringComparison.Ordinal);
103+
Assert.Contains("\"bytes\");\nstatic_assert(", code, StringComparison.Ordinal);
104+
}
105+
106+
/// <summary>
107+
/// An assertion survives a round trip through YAML, and a clone carries it.
108+
/// </summary>
109+
[TestMethod]
110+
public void Yaml_RoundTripsTheAssertion()
111+
{
112+
CompileTimeAssertion original = new("sizeof(Handle) == 8", "a handle is eight bytes");
113+
114+
string yaml = new YamlSerializer().Serialize(original);
115+
CompileTimeAssertion restored = (CompileTimeAssertion)new YamlDeserializer().Deserialize(yaml)!;
116+
117+
Assert.AreEqual("sizeof(Handle) == 8", restored.Condition);
118+
Assert.AreEqual("a handle is eight bytes", restored.Message);
119+
120+
CompileTimeAssertion clone = (CompileTimeAssertion)original.Clone();
121+
122+
Assert.AreEqual(original.Condition, clone.Condition);
123+
Assert.AreEqual(original.Message, clone.Message);
124+
Assert.AreNotSame(original, clone);
125+
}
126+
127+
/// <summary>
128+
/// A document says nothing for an assertion that asserts nothing.
129+
/// </summary>
130+
[TestMethod]
131+
public void Yaml_WritesNothingForAnEmptyAssertion()
132+
{
133+
string yaml = new YamlSerializer().Serialize(new CompileTimeAssertion());
134+
135+
Assert.DoesNotContain("condition", yaml, StringComparison.Ordinal);
136+
Assert.DoesNotContain("message", yaml, StringComparison.Ordinal);
137+
}
138+
139+
/// <summary>
140+
/// The editor can place one beside a declaration and edit both of its halves.
141+
/// </summary>
142+
[TestMethod]
143+
public void Graph_AcceptsAndEditsAnAssertion()
144+
{
145+
NamespaceDeclaration components = new("holo::components");
146+
CompileTimeAssertion assertion = new("sizeof(Handle) == 8");
147+
148+
Assert.IsTrue(AstSchema.TryAttach(components, AstSchema.SlotsOf(components)[0], assertion));
149+
Assert.IsTrue(AstFields.TryWrite(assertion, "Message", "a handle is eight bytes"));
150+
151+
Assert.AreEqual("a handle is eight bytes", assertion.Message);
152+
}
153+
}

Coder.Test/Languages/ExemplarHeaderTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ enum class BodyKind : std::uint8_t
8181
BodyKind body_kind = BodyKind::Dynamic;
8282
};
8383
84+
static_assert(std::is_trivially_copyable_v<RigidBody>,
85+
"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes");
86+
static_assert(std::is_standard_layout_v<RigidBody>,
87+
"RigidBody must be standard layout for its field offsets to be stable");
88+
8489
} // namespace holo::components
8590
""";
8691

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

144+
components.Members.Add(new CompileTimeAssertion(
145+
"std::is_trivially_copyable_v<RigidBody>",
146+
"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes"));
147+
components.Members.Add(new CompileTimeAssertion(
148+
"std::is_standard_layout_v<RigidBody>",
149+
"RigidBody must be standard layout for its field offsets to be stable"));
150+
139151
SourceFile file = new("RigidBody.gen.hpp") { IsHeader = true };
140152
file.HeaderComment.Add("Generated by holo_schemac. Do not edit.");
141153
file.HeaderComment.Add("");

Coder.Test/Languages/ExemplarSemanticTypeTests.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ [[nodiscard]] constexpr underlying value() const noexcept
6060
private:
6161
underlying value_{};
6262
};
63+
64+
static_assert(std::is_trivially_copyable_v<EntityId>,
65+
"EntityId must be trivially copyable: it appears in components");
66+
static_assert(std::is_standard_layout_v<EntityId>,
67+
"EntityId must be standard layout for its field offsets to be stable");
6368
""";
6469

6570
/// <summary>
@@ -114,6 +119,29 @@ private static ClassDeclaration EntityId()
114119
return entity;
115120
}
116121

122+
/// <summary>
123+
/// The semantic type together with what is asserted about it.
124+
/// </summary>
125+
/// <returns>The file.</returns>
126+
/// <remarks>
127+
/// A file with no banner and no imports, because the document's section is the declaration and
128+
/// the assertions beside it rather than a whole header. What the assertions say is the reason the
129+
/// type can appear in a component at all.
130+
/// </remarks>
131+
private static SourceFile EntityIdWithAssertions()
132+
{
133+
SourceFile file = new("EntityId.gen.hpp");
134+
file.Members.Add(EntityId());
135+
file.Members.Add(new CompileTimeAssertion(
136+
"std::is_trivially_copyable_v<EntityId>",
137+
"EntityId must be trivially copyable: it appears in components"));
138+
file.Members.Add(new CompileTimeAssertion(
139+
"std::is_standard_layout_v<EntityId>",
140+
"EntityId must be standard layout for its field offsets to be stable"));
141+
142+
return file;
143+
}
144+
117145
/// <summary>
118146
/// Builds one of the comparison operators, which are symmetric and so belong beside the type
119147
/// rather than to either operand.
@@ -146,7 +174,7 @@ private static FunctionDeclaration Comparison(string symbol, string returnType)
146174
public void Cpp_GeneratesTheSemanticTypeTheDocumentSpecifies() =>
147175
Assert.AreEqual(
148176
Expected.ReplaceLineEndings("\n").TrimEnd(),
149-
new CppGenerator().Generate(EntityId()).ReplaceLineEndings("\n").TrimEnd());
177+
new CppGenerator().Generate(EntityIdWithAssertions()).ReplaceLineEndings("\n").TrimEnd());
150178

151179
/// <summary>
152180
/// A member is initialised rather than assigned, which is the only way to start one that cannot be

Coder/Ast/CompileTimeAssertion.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.Coder.Ast;
4+
5+
/// <summary>
6+
/// Something that must be true when the program is built rather than when it runs.
7+
/// </summary>
8+
/// <remarks>
9+
/// What a generated type promises is often not something the type itself can say. A struct that
10+
/// crosses a language boundary and the wire as raw bytes has to be trivially copyable and standard
11+
/// layout, and the moment that stops being true is the moment a saved file starts being wrong — so
12+
/// it is asserted where the type is declared, and the build fails rather than the save file.
13+
/// <para>
14+
/// <see cref="Condition"/> is text, and deliberately. A compile-time predicate is language-specific
15+
/// in a way most of the AST is not: <c>std::is_trivially_copyable_v&lt;T&gt;</c> has no equivalent
16+
/// anywhere else, so there is no shared idea underneath to model. It is carried the same way
17+
/// <see cref="SourceFile.Imports"/> are, and for the same reason — an assertion, like an import, is
18+
/// written for the language the file is for.
19+
/// </para>
20+
/// <para>
21+
/// Only C++ has this. Every other target here writes a comment saying what was asserted, because a
22+
/// generated file that silently drops a guarantee looks like one that still makes it.
23+
/// </para>
24+
/// </remarks>
25+
public class CompileTimeAssertion : AstNode
26+
{
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="CompileTimeAssertion"/> class.
29+
/// </summary>
30+
public CompileTimeAssertion()
31+
{
32+
}
33+
34+
/// <summary>
35+
/// Initializes a new instance of the <see cref="CompileTimeAssertion"/> class.
36+
/// </summary>
37+
/// <param name="condition">The predicate that must hold, as the target language writes it.</param>
38+
/// <param name="message">What to say when it does not.</param>
39+
public CompileTimeAssertion(string condition, string? message = null)
40+
{
41+
Condition = condition;
42+
Message = message;
43+
}
44+
45+
/// <summary>
46+
/// Gets or sets the predicate that must hold, as the target language writes it.
47+
/// </summary>
48+
public string? Condition { get; set; }
49+
50+
/// <summary>
51+
/// Gets or sets what to say when the predicate does not hold.
52+
/// </summary>
53+
/// <remarks>
54+
/// Worth writing rather than leaving to the compiler: the predicate says what is false and the
55+
/// message says why anyone cared, and only the second tells whoever hits it what to do.
56+
/// </remarks>
57+
public string? Message { get; set; }
58+
59+
/// <summary>
60+
/// Gets the type name of this node for serialization purposes.
61+
/// </summary>
62+
/// <returns>The name of the node type.</returns>
63+
public override string GetNodeTypeName() => "CompileTimeAssertion";
64+
65+
/// <summary>
66+
/// Creates a deep clone of this assertion.
67+
/// </summary>
68+
/// <returns>A new instance with the same properties.</returns>
69+
public override AstNode Clone()
70+
{
71+
CompileTimeAssertion clone = new()
72+
{
73+
Condition = Condition,
74+
Message = Message,
75+
};
76+
77+
foreach ((string key, object? value) in Metadata)
78+
{
79+
clone.Metadata[key] = value;
80+
}
81+
82+
return clone;
83+
}
84+
}

Coder/Languages/CSharpGenerator.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
6161
case UnaryExpression unaryExpr:
6262
GenerateUnaryExpression(unaryExpr, code, GetUnaryOperator(unaryExpr.Operator));
6363
break;
64+
default:
65+
GenerateExpressionOrLeaf(node, code);
66+
break;
67+
}
68+
}
69+
70+
/// <summary>
71+
/// Emits an expression or a leaf.
72+
/// </summary>
73+
/// <param name="node">The node to emit.</param>
74+
/// <param name="code">The writer to emit into.</param>
75+
/// <remarks>
76+
/// Split from the declarations only because one switch over every node the AST has is more
77+
/// branches than the analyzer accepts. The line is the same one the AST already draws.
78+
/// </remarks>
79+
private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code)
80+
{
81+
switch (node)
82+
{
6483
case VariableReference varRef:
6584
code.Write(varRef.Name);
6685
break;
@@ -82,6 +101,9 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
82101
case NamespaceDeclaration namespaceDecl:
83102
GenerateNamespace(namespaceDecl, code);
84103
break;
104+
case CompileTimeAssertion assertion:
105+
WriteInexpressible(code, $"asserted at build time: {assertion.Condition}");
106+
break;
85107
case UsingAlias usingAlias:
86108
GenerateUsingAlias(usingAlias, code);
87109
break;

0 commit comments

Comments
 (0)