Skip to content

An AST that can describe a header: all three sections of the exemplar - #45

Merged
matt-edmondson merged 6 commits into
mainfrom
claude/peaceful-mayer-oo6l2u
Sep 10, 2026
Merged

An AST that can describe a header: all three sections of the exemplar#45
matt-edmondson merged 6 commits into
mainfrom
claude/peaceful-mayer-oo6l2u

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

The exemplar in matt-edmondson/Holotype#5 specifies the schema compiler being moved into this stack by writing its output rather than describing it. Nothing here could express any of it: no struct, no enumeration, no field distinct from a local, no documentation, no namespace, no file, no constructor, no operator, no way to say a declaration has no definition.

All three sections now generate, and the acceptance test is that document:

Section Test
A component ExemplarHeaderTests
A semantic type ExemplarSemanticTypeTests
An interface ExemplarInterfaceTests

Each builds the declaration as an AST and asserts the generated C++ character for character against the document's own text.


1. What a generated header is made of

TypeDeclarationKind on ClassDeclaration (class, struct, interface), EnumDeclaration, FieldDeclaration, IHasDocumentation, NamespaceDeclaration, SourceFile.

FieldDeclaration is deliberately not VariableDeclaration. The two are spelled almost identically and differ in the one way that matters:

BodyKind body_kind{};    // a field with no initialiser is value-initialised
int x;                   // a local with none is ordinary

A default-constructed instance is then the instance the declaration described — the whole point for a type whose values come from a save file or the wire.

Documentation is lines, not prose, because most of what ends up there isn't:

/// Linear velocity in world space.
/// unit: m/s
/// interpolated between states
/// network: quantised to 0.01, delta encoded
holo::Vec3<holo::MetresPerSecond> velocity{};

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 and a file is built for a language. An empty import is a group separator rather than an import of nothing.

2. Declaring behaviour for someone else to implement

FunctionKind (method, constructor, destructor, operator, conversion) and FunctionDefinition (provided, defaulted, deleted) — a declaration with no statements is otherwise ambiguous between a function that does nothing, one the language supplies, and one that exists to be refused.

Four modifiers beside them: IsVirtual, IsAbstract, IsReadOnly, MustUseResult.

IsAbstract is C++'s pure virtual, which is a different thing from IsPure: one says a declaration has no definition, the other that a call has no effect. Both earn [[nodiscard]] in the end, but for different reasons — purity implies it, and so does returning something that may be a failure.

A constructor and destructor are named after the type, so the name comes from the class emitter rather than the declaration holding a second copy that can desynchronise on rename.

3. A distinct name for something already representable

UsingAlias, MemberInitialiser, ConstructionExpression, plus IsExplicit, IsCompileTimeEvaluable, IsNoThrow, IsFriend.

A member is initialised, not assigned. That is the only way to start a member that cannot be assigned at all, and is the difference between building a value and building an empty one and then overwriting it. C#, Python and JavaScript have no initialiser list, so they assign at the top of the constructor in declaration order — which is what the initialiser means there.

ConstructionExpression is the one expression that needs a type rather than a name, which is why it could not exist before TypeReference did. C++ braces it: braces will not narrow silently, and a one-argument construction written with parentheses can be read as a declaration instead.


Each language says as much as it can

C++ says all of it. C# says most, and expresses a defaulted or deleted member by not declaring one — so it declares neither and writes a line naming which member went and why, because a generated file that silently drops one looks complete and is not.

Python and JavaScript have no declaration without a definition at all, so a method a derived type must supply becomes a body that refuses — raise NotImplementedError and throw. Python names a constructor __init__ rather than after its type; neither has any spelling for an operator, which they say rather than drop.

Spacing is a rule, not an accident

A blank line goes between two members when either is documented, when they are different kinds of thing, or where the access changes. That reproduces all three sections: a run of aliases is one group, so is a run of defaulted and deleted declarations, and private: always gets air above it.

The separator is NewLine rather than WriteLine, because a blank line carrying the current indent is trailing whitespace that every formatter strips and every diff then shows.

Two defects the exemplar exposed

Both predate this branch, and neither was visible until there was a specification to compare against:

  • A C++ access label was indented with the members. It sits at the class's own indentation, which is what makes it read as dividing them rather than as one of them.
  • A parameter with an empty name emitted a trailing space. An unnamed parameter is exactly what a deleted copy declaration wants. An empty name is now deliberately unnamed; a null one still has one invented, because nobody said.

Deliberate differences from the document

Three, all recorded in the tests:

  1. The two static_asserts below the component and the semantic type. They are an assertion about a generated type rather than something the schema asked for, and where they belong is still open.
  2. Indentation is spaces where the document has tabs — a setting of the writer, and Holotype's .clang-format (UseTab: true) converts it on the way past.
  3. The short accessor. The document writes { return value_; } on the signature's line; the generator writes a braced block. I expected clang-format to reconcile these and it does not — run over each, it leaves both exactly as written. So this is the generator's choice, not the formatter's, and copying the inline style would mean a generator guessing at when a body is short enough to fold.

The expectation in each acceptance test is the document's own text. The semantic type's is the document with exactly one line rewritten — the accessor above — and the test names that substitution so a reader can see it is the only one.

Verification

  • dotnet build -c Release0 warnings, 0 errors across the solution
  • 477 tests pass (413 before, 64 new), 0 failures
  • SonarCloud quality gate passed: 0 new issues, 92.5% coverage on new code, 0 duplication

Everything new round-trips through YAML, appears in AstSchema's slots and AstFields' rows, and writes nothing to a document for what a declaration does not say — so a file written before any of this still opens.

The gate failed three times on coverage before it passed, and the fix was to test the two things that were genuinely untested rather than whatever was cheapest. AstSchema had 37 of its 41 new lines uncovered — the slots the editor reaches every new declaration through were reached by nothing — and the Clone methods were the other gap, which matters because the editor clones to undo and a clone sharing a collection with its original shows up as an edit landing in two places at once, far from where the clone was made. The shim vocabulary was exercised only by the C++ acceptance test, so its C#, Python and JavaScript paths and its whole YAML round trip had no test at all.

Writing those found a real gap: a UsingAlias had no inspector rows, so it could be moved around the graph but its name and type could not be edited.

What is next

The bridge from a ktsu.Schema model to these nodes, and the gaps in holotype/core that the document's type table names: Result and Handle do not exist yet, so an interface returning one cannot be generated until they do.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk

The exemplar in matt-edmondson/Holotype#5 specifies the schema compiler being
moved into this stack by writing its output rather than describing it. Its first
section is a component header, and nothing here could express one: no struct, no
enumeration, no field distinct from a local, no documentation, no namespace, no
file. Six nodes later it can, and the acceptance test is that document.

  ExemplarHeaderTests generates the header the document specifies, and asserts
  it character for character.

Two differences from the document are deliberate and both are recorded in the
test. The two static_asserts below the struct are absent: they are an assertion
about a generated type rather than something the schema asked for, and where
they belong is still open. The indentation is spaces where the document has
tabs, which is a setting of the writer rather than anything the AST decides --
Holotype's own .clang-format converts generated output on the way past.

What the nodes are, and what each language does with them:

  TypeDeclarationKind on ClassDeclaration -- class, struct or interface. One
  node rather than three, because only the keyword and what a language assumes
  about the members differ. C++ has no interface keyword and spells one as a
  class whose members are all public; a struct's members are public already, so
  it writes no access label where a class needs one.

  EnumDeclaration, with an underlying type and ordered members. Nested as often
  as not, because two types in one file may each want a Kind. C++ writes
  enum class, always scoped -- an unscoped one leaks its members into the
  surrounding scope and converts to an integer unasked. Python and JavaScript
  have no enumeration: one becomes an Enum subclass and a frozen object, with a
  member numbered from its position. A class body is not a block, so JavaScript
  nests one as a static member rather than the const it would write at namespace
  scope, which is a syntax error there.

  FieldDeclaration, deliberately not VariableDeclaration. The two are spelled
  almost identically and differ in the one way that matters: a field with no
  initialiser is value-initialised, so a default-constructed instance is the one
  the declaration described, and a local with none is ordinary. Telling them
  apart structurally is what lets a generator emit each correctly without being
  handed its context.

  IHasDocumentation, as lines rather than one string, because most of what ends
  up there is not prose. A schema knows a member's unit, its range and how it is
  quantised on the wire, and a generated type can hold none of it. Each language
  writes its own comment: Python's documentation is a docstring, which is a
  different shape from a line comment, so it writes # and claims nothing it is
  not.

  NamespaceDeclaration, whose name is written with either separator and rejoined
  with the language's own -- the one place a name is taken apart, because here
  the separator genuinely differs rather than merely looking different. C++ does
  not indent for it and names what the closing brace closes. Python and
  JavaScript have no namespace and emit the members alone.

  SourceFile, with a banner, imports and members. 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 and a file is built for a language. An empty import is a group
  separator rather than an import of nothing, which is how the standard headers
  are told apart from the project's own.

Members of a C++ type are now separated by a blank line. A documented member
needs it or its first comment line butts against the member above and reads as
belonging to that one; an undocumented one needs it to stay in the same column
of whitespace as its neighbours. The separator is NewLine rather than WriteLine,
because a blank line carrying the current indent is a line of trailing
whitespace that every formatter strips and every diff then shows.

All six round-trip through YAML, appear in AstSchema's slots and AstFields'
rows, and write nothing to a document for what a declaration does not say -- so
a file written before they existed still opens. AstFields.TryWrite split in two
along the line the AST already draws, declaration or expression, because one
switch over every node it has is more branches than the analyzer accepts.

430 tests pass, 413 before and 17 new, with 0 warnings across the solution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
Comment thread Coder/Serialization/YamlDeserializer.cs Fixed
Comment thread Coder/Serialization/YamlDeserializer.cs Fixed
Section 3 of the exemplar in matt-edmondson/Holotype#5 is an interface: the
header an implementation is written against. Nothing here could express one, so
this is what a function declaration had to learn to say.

  ExemplarInterfaceTests generates the declaration the document specifies, and
  asserts it character for character.

FunctionKind says what a declaration declares -- method, constructor,
destructor, operator, conversion operator -- and FunctionDefinition says where
its behaviour comes from: the statements in the body, the language, or nowhere
because calling it is meant to be an error. A declaration with no statements is
ambiguous between those three, and only the first is an empty body.

Four modifiers beside them. IsVirtual and IsAbstract, the second being C++'s
pure virtual, which is a different thing from IsPure that this branch added
earlier: one says a declaration has no definition, the other that a call has no
effect. IsReadOnly, which C++ spells as a trailing const and C# as readonly on a
member of a struct, and which is weaker than purity -- no effect on the one
object rather than none at all. MustUseResult, which C++ spells [[nodiscard]],
the same thing purity earns: a call returning something that may be a failure
has to be looked at.

A constructor and a destructor are named after the type rather than after
themselves, so the name comes from the class emitter rather than from the
declaration holding a second copy of it. That is what stops the two
desynchronising when the type is renamed.

Each language says as much as it can:

  C++ says all of it. C# says most, and expresses a defaulted or deleted member
  by not declaring one -- so it declares neither and writes a line naming which
  member went and why, because a generated file that silently drops one looks
  complete and is not. A defaulted constructor is the exception, since a type
  declaring any other constructor stops getting one for free; it is written with
  the empty body that `= default` means there.

  Python and JavaScript have no declaration without a definition at all, so a
  method a derived type must supply becomes a body that refuses -- raise
  NotImplementedError and throw. Python names a constructor __init__ rather than
  after its type, and neither language has any spelling for an operator, which
  they say rather than drop.

Two defects the exemplar exposed, both in code that predates this change:

  A C++ access label was indented with the members. It sits at the class's own
  indentation, which is what makes it read as dividing them rather than as one
  of them.

  A parameter with an empty name emitted a trailing space. An unnamed parameter
  is what a deleted copy declaration wants -- it is there to make the signature,
  and naming it would invite someone to look for its use. An empty name is now
  deliberately unnamed; a null one still has one invented, because nobody said.

The blank-line rule between members now looks at both of them rather than only
the one about to be written. A run of defaulted and deleted declarations reads
as one group rather than as four paragraphs, which is what the exemplar's
interface does and what the previous rule could not produce.

444 tests pass, 430 before and 14 new, with 0 warnings across the solution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
Section 2 of the exemplar in matt-edmondson/Holotype#5 is a semantic type: an
entity id is a number and so is a texture id, and the type exists so that adding
one to the other stops compiling. It is the last of the three sections, and the
one that needed the most the AST could not say.

  ExemplarSemanticTypeTests generates the declaration the document specifies,
  and asserts it character for character.

Three nodes and four modifiers.

  UsingAlias gives a type a second name. A type that shims another has to say
  what it is stored as, and saying it once beside the declaration stops every
  member restating it and one of them eventually disagreeing. C# has a using
  alias but only at file or namespace scope, so one declared as a member is said
  rather than written.

  MemberInitialiser is what a member starts at as part of constructing the type.
  C++ initialises rather than assigns, which is the only way to start a member
  that cannot be assigned at all, and is the difference between building a value
  and building an empty one and then overwriting it. C#, Python and JavaScript
  have no initialiser list, so they assign at the top of the constructor in the
  order declared, which is what the initialiser means there.

  ConstructionExpression builds a value. The one expression that needs a type
  rather than a name, which is why it could not exist before TypeReference did.
  C++ braces it: braces will not narrow a value silently, and a construction
  with one argument written with parentheses can be read as a declaration
  instead, which is a mistake a generator should never be able to make.

  IsExplicit, which is the whole point of a shim -- a value never crosses into
  the type by accident, and C# spells the same decision on a conversion as
  explicit rather than implicit. IsCompileTimeEvaluable and IsNoThrow, which
  only C++ can say and which the others write nothing for. IsFriend, which is
  how a symmetric operator is written beside the type it is about rather than as
  a member of one of its operands.

The blank-line rule between members now also separates two members of different
kinds, and puts air above a changed access label. Two aliases in a row are one
group and so are two comparison operators; an alias followed by a constructor is
not, and neither is anything followed by `private:`. That is what the document's
three sections do, and the previous rule could produce none of it.

One difference from the document, measured rather than assumed. It writes the
short accessor on a single line and the generator writes a braced block. I had
expected clang-format to reconcile them; it does not -- run over each, it leaves
both exactly as written, so the difference is the generator's choice and the
test says so. Copying the inline style would mean a generator guessing at when a
body is short enough to fold, which is a rule worth not having.

The expectation in each of the three acceptance tests is the document's own
text. The semantic type's is the document with exactly one line rewritten, the
accessor above, and the test names that substitution so a reader can see it is
the only one.

448 tests pass, 444 before and 4 new, with 0 warnings across the solution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
@matt-edmondson matt-edmondson changed the title An AST can describe a header, not only a snippet An AST that can describe a header: all three sections of the exemplar Sep 10, 2026
Comment thread Coder/Serialization/YamlDeserializer.cs Fixed
Comment thread Coder/Serialization/YamlDeserializer.cs Fixed
SonarCloud's gate failed the branch on coverage of new code -- 79.7% against a
threshold of 80 -- and reported six issues beside it. Both are fixed, and the
coverage half is fixed by testing the two things that were untested rather than
by testing whatever was cheapest.

AstSchema had 37 of its 41 new lines uncovered, which is to say the slots the
editor reaches every new declaration through were reached by nothing. The clone
methods were the other gap: the editor clones to undo and the graph clones to
rebuild itself, and a clone that shares a collection with its original is the
kind of defect that shows up as an edit landing in two places at once, long
after the clone was made and nowhere near it. Every node that holds anything is
now checked for that property.

Writing those found a real gap: a UsingAlias had no inspector rows at all, so it
could be moved around the graph but its name and type could not be edited. Six
lines, so closed rather than recorded.

The six issues:

  S3267 twice, both on loops filtering their input inside the body. Filtering in
  the sequence says out loud that anything else is skipped -- a document can hold
  whatever someone typed, and a loop that quietly steps over half its input reads
  as though it does not. One shared Mappings helper now does it for all four
  places that read a list of nodes.

  S1192 five times: 'members', 'documentation', 'Visibility', 'Value' and
  'public' repeated. Each is now a constant, and naming the last of them was
  worth a sentence about why a generated declaration nobody gave a visibility to
  is public rather than what C# would default it to.

  S3776 on the C# function emitter, whose attributes and modifiers are now
  written by two methods rather than inline. Splitting them also made it obvious
  that purity and must-use were both being written when purity alone says the
  stronger thing.

AstFields.Of split three ways for the same reason TryWrite split two ways
earlier: one switch over every node the AST has is more branches than the
analyzer accepts, and the line between a type declaration, a callable and an
expression is one the AST already draws.

466 tests pass, 448 before and 18 new, with 0 warnings across the solution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
The three nodes section 2 of the exemplar needed -- an alias, a member
initialiser and a construction -- were exercised only by the acceptance test,
which generates C++ and nothing else. So the C#, Python and JavaScript paths
through them, and the whole YAML round trip for them, were reached by no test at
all.

They are also the nodes whose spelling differs most, and the ones where a
language having no spelling is the interesting case rather than an oversight: a
construction is a keyword in three languages and braces in the fourth, one with
no arguments is value-initialisation in C++ rather than an empty argument list,
and a member is initialised in C++ and assigned everywhere else. Each of those
is now pinned in each language.

Two of the expectations were wrong when first written, and the code was right:
C++ maps `long` to `long long`, so the alias tests use a name no mapping table
knows. That keeps them about the alias rather than about how each language
spells a built-in, which TypeReferenceTests already covers.

477 tests pass, 466 before and 11 new, with 0 warnings across the solution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
The quality gate passed at 92.4% coverage on new code, and left eight findings
open behind it. All eight are in code this branch adds.

Five S1192s, each a literal repeated four or five times: the fallback type name
in both C-family generators, the arguments slot's name in the schema, and the
key a single value is written under in both serializers. Naming the first was
worth a sentence about why there is a fallback at all -- a type is optional on
every node that carries one, because a half-built AST is a thing the editor has
to be able to hold, and emitting the most general type there keeps the output
compiling while making it obvious which declaration was never finished.

Three S3776s, each a method that grew a branch at a time as the exemplar's three
sections landed: Python's method emitter, and the function reader and writer.
Each is now two, split where the AST already draws a line -- what a declaration
is called and returns, and what kind of declaration it is.

477 tests still pass, 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit d372fb3 into main Sep 10, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/peaceful-mayer-oo6l2u branch September 10, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants