An AST that can describe a header: all three sections of the exemplar - #45
Merged
Conversation
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
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
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
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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:
ExemplarHeaderTestsExemplarSemanticTypeTestsExemplarInterfaceTestsEach 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
TypeDeclarationKindonClassDeclaration(class, struct, interface),EnumDeclaration,FieldDeclaration,IHasDocumentation,NamespaceDeclaration,SourceFile.FieldDeclarationis deliberately notVariableDeclaration. 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 ordinaryA 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:
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) andFunctionDefinition(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.IsAbstractis C++'s pure virtual, which is a different thing fromIsPure: 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, plusIsExplicit,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.
ConstructionExpressionis the one expression that needs a type rather than a name, which is why it could not exist beforeTypeReferencedid. 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 NotImplementedErrorandthrow. 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
NewLinerather thanWriteLine, 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:
Deliberate differences from the document
Three, all recorded in the tests:
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..clang-format(UseTab: true) converts it on the way past.{ 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 Release— 0 warnings, 0 errors across the solutionEverything new round-trips through YAML, appears in
AstSchema's slots andAstFields' 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.
AstSchemahad 37 of its 41 new lines uncovered — the slots the editor reaches every new declaration through were reached by nothing — and theClonemethods 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
UsingAliashad 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.Schemamodel to these nodes, and the gaps inholotype/corethat the document's type table names:ResultandHandledo not exist yet, so an interface returning one cannot be generated until they do.🤖 Generated with Claude Code
https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk