Skip to content

Vocabulary the AST was missing: a type that is a type, and two modifiers on a function - #44

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

Vocabulary the AST was missing: a type that is a type, and two modifiers on a function#44
matt-edmondson merged 3 commits into
mainfrom
claude/peaceful-mayer-oo6l2u

Conversation

@matt-edmondson

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

Copy link
Copy Markdown
Contributor

Two things the AST could not say. Both in the mould Visibility already set — the AST says what is true, and each generator says as much of it as its language can.


1. A type is a type, not a string that happens to spell one

Every type in the AST was a bare string — ClassDeclaration.BaseType, FunctionDeclaration.ReturnType, Parameter.Type, VariableDeclaration.Type.

That is enough for Foo and hopeless for std::span<const Velocity>. A generator handed that string can only paste it, so whoever built the node had to spell the target language themselves — which is the generator writing C++ by hand again, one layer up.

TypeReference carries a name, the types it is parameterised by in order, whether the value may be written through, and how it is reached:

C++ C# Python
span<readonly Velocity> std::span<const Velocity> span<Velocity> span[Velocity]
List<int> std::vector<int> List<int> List[int]

C# writes neither const nor *: read-only-ness of an argument is in, which belongs on the parameter rather than on the type. Python parameterises with brackets, which is only expressible now that the arguments are a list rather than part of a name.

IsReadOnly rather than IsConst because the property is named for what it means, not for how one language spells it.

Nothing that held a string stops working

Parse and ToString are inverses, and text the grammar cannot read becomes a name holding that text verbatim — so a string that means nothing to this grammar survives a round trip intact rather than being dropped or half-read. That property is what lets the string-shaped API stay:

new Parameter("scale", "int")             // implicit conversion, unchanged
classDecl.BaseType = "Shape";             // unchanged
nodeData["type"] = param.Type.ToString(); // YAML file format unchanged

The editor's inspector row is still a text field, and what a user types there now parses into structure.

It found a bug on the way in

Both type tables mapped a name to a whole typeliststd::vector<std::any> — so a name and its arguments could not both be honoured:

  • list<int> used to miss the lookup entirely and pass through as list<int>, which is not C++.
  • Mapping the name alone produced std::vector<std::any><int>, which the new test caught.

The tables now map a name to a name, with the argument a container named without one is a container of kept separately. So list is still std::vector<std::any> and list<int> is now std::vector<int>.


2. A function can say it takes no receiver and changes nothing

IsStatic

The one modifier that changes a signature rather than decorating it. C++, C# and JavaScript spell it static alongside the declaration; Python spells it @staticmethod and drops self — which the generator supplies precisely because no other target language has a receiver in its parameter list.

class Points:
    @staticmethod
    def distance(scale: float) -> float:
        return scale

    def reset(self) -> None:      # an instance method still takes one
        pass

IsPure

The result depends only on the arguments and calling it changes nothing. C++ gets [[nodiscard]], C# gets [Pure]; Python and JavaScript get nothing, because they have nothing to say about it and inventing a spelling would be worse than silence.

[[nodiscard]] static double distance(double scale)

[[nodiscard]] rather than __attribute__((pure)), deliberately. Both start from the same observation — discarding the result of a call that does nothing else is always a mistake — but the attribute additionally tells the optimiser the call may be elided or duplicated, which is a stronger promise than the AST is in a position to make about a body it did not read. The standard spelling says the part that is certainly true.

[Pure] is written out as [System.Diagnostics.Contracts.Pure] because the AST has no import node to hang a using on: generated C# is a fragment, and a short name in it would be one the reader has to arrange for.

IsPure is not C++'s pure virtual, which means a declaration has no definition. That is a different property, it is what the Holotype target header needs for an interface, and the AST does not model it yet.

Both are written to YAML only when true — a document says nothing rather than saying false twice on every function ever written — and a document written before they existed still opens with both off. The inspector offers each as a flag.


Verification

  • dotnet build -c Release0 warnings, 0 errors across the solution
  • 413 tests pass (378 before, 35 new), 0 failures

The tests worth reading are the ones that pin a spelling per language: In Span<Velocity> generating std::span<const Velocity>, one type spelled three different ways by three generators, and Python keeping self on an instance method while dropping it from a static one.

Two targeted suppressions, no global ones: CA1720 on TypeIndirection.Pointer (a pointer is what the member means; naming around the rule would leave the enum unable to say the one thing it exists to distinguish) and CA2225 on the string conversion (Parse is the named alternate one way, ToString the other).

What this does not do

It does not decide the node set. The struct, enum, field, namespace, using-alias, constructor and conversion-operator nodes come from the target header in matt-edmondson/Holotype#5, which is still open for redlining. What is here is downstream of nothing in that document: std::span<const Velocity> is unspellable however the exemplar settles, and so are these two modifiers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk

Every type in the AST was a bare string: ClassDeclaration.BaseType,
FunctionDeclaration.ReturnType, Parameter.Type, VariableDeclaration.Type. That
is enough for Foo and hopeless for std::span<const Velocity> -- a generator
handed that string can only paste it, so whoever built the node had to spell
the target language themselves, which is the generator writing C++ by hand
again.

TypeReference carries a name, the types it is parameterised by in order, whether
the value may be written through, and how it is reached. Each generator then
decides the spelling: C++ writes const, & and *; C# writes none of them, because
read-only-ness of an argument is `in` and belongs on the parameter; Python
parameterises with brackets, which is only expressible now that the arguments
are a list rather than part of a name.

Parse and ToString are inverses, and text the grammar cannot read becomes a name
holding that text verbatim. So a string that means nothing to this grammar
survives a round trip intact rather than being dropped or half-read -- which is
what lets the string-shaped API keep working. An implicit conversion from string
keeps the common case short, so `new Parameter("scale", "int")` and the editor's
text row are untouched, and YAML still stores the text form.

The change found a bug in the C++ and C# type tables. Both mapped a name to a
whole type -- list to std::vector<std::any> -- so a name and its arguments could
not both be honoured: list<int> used to come out as neither (it missed the
lookup and passed through as `list<int>`), and mapping the name alone would have
produced std::vector<std::any><int>. The tables now map a name to a name, with
the argument a container is named without kept separately. So list is still
std::vector<std::any> and list<int> is now std::vector<int>.

400 tests pass, 378 before and 22 new, with 0 warnings across the solution. The
last two cover the case this exists for: In Span<Velocity> generating
std::span<const Velocity>, and one type spelled three different ways by three
generators.

Nothing here decides the node set. That comes from the target header in
matt-edmondson/Holotype#5, which this is the first change derived from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
Two modifiers on FunctionDeclaration, both in the mould Visibility already set:
the AST says what is true and each generator says as much of it as its language
can.

IsStatic is the more interesting of the two, because it is the one modifier that
changes a signature rather than decorating it. C++, C# and JavaScript spell it
`static` alongside the declaration; Python spells it @staticmethod and drops
`self`, which the generator supplies precisely because no other target language
has a receiver in its parameter list. An instance method still takes one.

IsPure means the result depends only on the arguments and calling it changes
nothing. C++ gets [[nodiscard]] and C# gets [Pure]; Python and JavaScript get
nothing, because they have nothing to say about it and inventing a spelling
would be worse than silence.

[[nodiscard]] rather than __attribute__((pure)) deliberately. Both start from
the same observation -- discarding the result of a call that does nothing else
is always a mistake -- but the attribute also tells the optimiser the call may
be elided or duplicated, which is a stronger promise than the AST is in a
position to make about a body it did not read. The standard spelling says the
part that is certainly true.

[Pure] is written in full as [System.Diagnostics.Contracts.Pure], because the
AST has no import node to hang a `using` on: generated C# is a fragment, and a
short name in it would be one the reader has to arrange for.

Worth being explicit that IsPure is not C++'s pure virtual, which means a
declaration has no definition. That is a different property, it is what the
Holotype target header needs for an interface, and the AST does not model it
yet.

Both are written to YAML only when true, so a document says nothing rather than
saying false twice on every function ever written, and a document written before
they existed still opens with both off. The editor's inspector offers each as a
flag.

413 tests pass, 400 before and 13 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 A type is a type, not a string that happens to spell one Vocabulary the AST was missing: a type that is a type, and two modifiers on a function Sep 10, 2026
SonarCloud reported three S3358s on the two type mappers: a ternary whose false
branch is another ternary, which reads as one expression and is three.

Each is now a statement. The C++ mapper's argument list is its own method, since
"the arguments, or the one a bare container does not name" is a decision worth a
name; the C# one splits the name lookup out for the same reason, which also gives
the void case somewhere to explain itself -- it is matched case-insensitively
where the rest of that table is not, and nothing said why.

One of the three predates this branch. The line changed, so the rule fired on it,
and it is the same fix as the other two.

Found by querying the analysis on the pull request rather than by building
locally, which is the check that works.

413 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 e49ec76 into main Sep 10, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/peaceful-mayer-oo6l2u branch September 10, 2026 09:40
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