Design decisions

This document records significant language and generation decisions, including alternatives that may be reconsidered as the language develops.

Value storage for structured fields

Context

Node and choice fields normally generate owning std::unique_ptr members. Some structured types, such as a function head or signature, are better embedded directly as value members.

Decision

Use a value modifier at the field usage site:

node FunctionDefinition
    head: value FunctionHead
    parameters: *value Parameter
end

This generates members equivalent to:

function_head head;
std::vector<parameter> parameters;

The modifier affects node and choice references. Built-in and enum fields already use value storage, so it is permitted but redundant for those types. value follows the optional * modifier, allowing multiplicity and storage to remain separate concepts.

Value members require their C++ types to be complete. The generator therefore orders node definitions according to value dependencies and rejects recursive value-member cycles. Pointer-based recursive relationships remain supported.

Alternatives considered

A separate value-oriented definition kind

A record declaration could distinguish value-like records from identity-bearing nodes. References to records would always be embedded, while references to nodes would remain owned pointers. This provides strong type-level semantics but expands the language and prevents the same structured type from naturally using different storage at different usage sites.

General type expressions

Types could express representation explicitly, for example vector<value<Parameter>>, owned<Expr>, or optional<Type>. This is extensible but adds a substantial generic type system and exposes backend storage details throughout definitions.

Symbol-based usage modifiers

Symbols such as &, @, or ! could mark embedded values. They are concise but less self-documenting and carry unrelated meanings in C++ and other languages. The value keyword was selected for clarity.

Context-aware dump customization

Context

Generated YAML-like dump functions need to handle ordinary scalar values while permitting application-specific values, such as a type_id, to use external context during formatting. The context type cannot be fixed by the generator.

Decision

Generate dump and dump_value as function templates. The generic dump_value implementation uses operator<<. Applications customize a value by defining a more-specific dump_value overload in the value type's namespace; an unqualified generated call finds it through argument-dependent lookup. This avoids runtime type erasure and lets compilers inline the customization call. If a value is not stream-insertable and no overload is found, the generic fallback issues a targeted compile-time diagnostic requesting a dump_value overload.

Template declarations live in _dump.hpp; definitions live in _dump.ipp, which the header includes. A _dump.cpp file is still generated as the conventional translation-unit entry point, but generic template implementations necessarily remain visible to header consumers.

Generated dump output uses four spaces per nesting level, quoted and escaped strings, null for empty owning pointers, original enum entry names, and an artificial _type property on every node object.

C++ backend header dependencies

Context

Mapped C++ types may require standard headers or project-specific definitions. Requiring every consumer to include those dependencies before a generated model header would make that header order-dependent and not self-contained.

Decision

Allow explicit include directives in backend_cpp.map:

@include <cstddef>
@include "project/symbol.hpp"

index: std::size_t
type: project::type_id

The @ distinguishes backend configuration from type mappings and leaves room for future backend directives. Both system and quoted project headers are accepted. The generator emits the headers before model declarations, preserves their order, and removes duplicates. Backend selection remains statically fixed to backend_cpp.map.

Alternatives considered

  • Associating a from <header> clause with each mapping would enable selective inclusion but complicate the intentionally free-form C++ type spelling and repeat shared headers.
  • A separate TOML or include-list file would separate configuration from mappings but add another discovery convention and make dependencies less local.
  • .ndef include declarations would permit module-specific dependencies but leak C++ backend concerns into the backend-neutral node language.
  • CLI include flags would avoid format changes but make generation less reproducible and place required configuration in build scripts.
  • Inferring headers from C++ spellings would be convenient for a few standard types but brittle and incapable of resolving project-specific definitions.

Optional fields

The ? field-type prefix represents an optional value and is mutually exclusive with the * repetition prefix. Built-in types, enums, and structured fields marked value generate as std::optional<T>. Node and choice references without value remain std::unique_ptr<T>; wrapping those pointers in another optional layer would represent the same absent/present state twice without a current semantic need. Generated dumpers emit both disengaged optionals and null owning pointers as null.

External mutable visitor

Each module generates a visitor class in _visitor.hpp and _visitor.cpp. Traversal remains external to model structs, avoiding generated accept methods and coupling data objects to a visitor hierarchy. Public nonvirtual visit(T&) overloads provide mutable entry points for nodes and choices. Choice overloads dispatch with std::visit.

For each node type, protected virtual enter(T&) and leave(T&) hooks run before and after its children. Their base implementations are empty, so derived passes override only relevant events without reimplementing traversal. Only pointer-backed node and choice fields are children: scalar fields and fields explicitly marked value are not traversed. The fixed class name reserves the generated C++ name visitor within the module namespace.

Visitor overloads and hooks are omitted for definitions referenced exclusively through value fields. A choice used only as a value also does not pull its alternatives into the visitor API. Definitions with a non-value use remain visitable, and definitions with no incoming use remain available as traversal roots. This keeps generated visitor APIs aligned with actual traversal semantics without requiring another language annotation.

Reusable node traits

Reusable data-bearing characteristics use trait declarations and node-level with lists:

trait Location
    location: source_range
end

node FunctionHead with Location
    name: identifier
end

Traits generate public C++ base structs, while nodes generate public inheritance from each listed trait. Traits cannot inherit, appear in choices, or receive visitor overloads. Their fields are flattened into the derived node's dump output. Generation rejects unknown or repeated traits and any field-name collision among applied traits and local node fields.

trait was selected over interface because traits carry data, and over concept because that term has a distinct C++ meaning. with reads as capability application without suggesting ordinary field composition. The trait can use a natural characteristic name such as Location; this does not conflict with its lowercase location field because type and member names occupy different generated contexts.

Transient tooling fields

Fields that are required by later compiler stages but are not structural node data use the transient modifier:

node FunctionDefinition
    function_scope: transient scope
end

The field is emitted normally in the C++ struct, while dump generation and visitor traversal ignore it. Its representation remains a backend concern; for example, backend_cpp.map may map scope to std::unique_ptr<scope> and include the project header defining scope.

Alternatives considered were an analysis-data trait, external side tables keyed by node identity, handwritten derived wrappers, and generic type-erased attachment storage. Traits would still need a non-structural marker, side tables add lifetime and synchronization costs, derived wrappers interact poorly with variant values, and generic attachments lose static type visibility. transient was selected as the smallest general mechanism that keeps tooling state strongly typed and directly accessible without treating it as part of structural operations.