Generation reference
Rendered from
docs/reference-generation.md
in the engine repository, where a correction belongs, and where the
test suite executes every example on this page.
A component tree is the value a generator answers: nested nodes that name every output file, every folder above it, and every span of text inside it.
This page is normative for the tree’s shape, for each component’s props
and admitted children, and for what each verb writes when handed one.
The constructs that compute a tree are specified in the language
reference and not here: Generating children: pack and
each,
Selecting: filter and match,
The placeholder _,
Transforming: emit, and
Generation for what generate
requires of a model. Each component function’s one-entry summary is in
that page’s Functions index, and the
arity and argument modes of every name are in The call
surface.
The option lists and synopses for the verbs belong to the API
reference, under aontu render and
aontu trace, and the error codes
named below to The codes. Behaviour
stated here is pinned by test/spec/cmp.tsv and
test/spec/trace.tsv, which both
implementations run.
Contents
- The component tree
- The components
- What
aontu renderwrites - What
aontu tracereports - Order and determinism
- Related
The component tree
Every component function answers a map of exactly three keys: cmp,
the string a generator runtime looks the component up by; props, a map
holding that component’s declared props; and children, a list of
further nodes. A node built by a component function is
closed, so a fourth
key is a closed refusal rather than an ignored field:
out: file("a.txt", ["x" ["y" "z"]])
{
"out": {
"children": [
{ "children": [], "cmp": "Line", "props": { "src": "x" } },
{ "children": [], "cmp": "Line", "props": { "src": "y" } },
{ "children": [], "cmp": "Line", "props": { "src": "z" } }
],
"cmp": "File",
"props": { "name": "a.txt" }
}
}
A bare string child is a line, terminator included, which is what
a template body line desugars to; an explicit content() is still a
span with no terminator (cmp-bare-string-child,
cmp-bare-string-spliced). The conversion happens only where line is
an admitted child, so file, fragment, slot, inject, and
listitems take a bare string and folder and project refuse one
(cmp-bare-string-needs-line, cmp-bare-string-not-in-project). The
children argument is flattened in place, recursively
(cmp-children-splice), under the rule Transforming:
emit states for a body.
A node is an ordinary map addressed by path, printing its keys in the
order every value prints in. Write a
tree.aon:
out: project(".", [folder("src", [file("main.js", ["const x = 1"])])])
and address a node inside it by path:
$ aontu model get '$.out.children.0.cmp' tree.aon
"Folder"
$ aontu model get '$.out.children.0.children.0.children.0' tree.aon
{
"children": [],
"cmp": "Line",
"props": {
"src": "const x = 1"
}
}
Generate code from a model reads a whole tree with the same verb.
The prop schema is enforced by the component function, not by the
tree. A hand-written map carrying a cmp key that names a component
is admitted as a child, and its props are never checked against the
schema below: an undeclared prop reaches the runtime, where eight of
the ten components drop it in silence. A prop a component does not
declare is refused at the call.
The components
Ten functions build nodes. A leaf handed a children list is a
func_arity error (Class parse)
rather than a bad argument (cmp-leaf-takes-no-children). The
shorthand column names the prop a bare string spec sets.
| signature | node | shorthand | children | props |
|---|---|---|---|---|
project(spec?: string|map, children?: list) : map | Project | folder, optional | project, folder, file, copyfiles | name, folder |
folder(spec: string|map, children?: list) : map | Folder | name | folder, file, copyfiles | name |
file(spec: string|map, children?: list) : map | File | name | content, line, fragment, inject, listitems, copyfiles | name, exclude, mode |
content(spec: string|map) : map | Content | src, a span | none | arg, src, name, indent, extra, replace, raw |
line(spec: string|map) : map | Line | src, a span | none | arg, src, name, indent, extra, replace, raw |
fragment(spec: string|map, children?: list) : map | Fragment | from | slot, content, line, listitems | from, indent, replace, eject |
slot(spec: string|map, children?: list) : map | Slot | name | content, line, fragment, listitems | name |
inject(spec: string|map, children?: list) : map | Inject | name | content, line, listitems | name, markers, exclude |
copyfiles(spec: string|map) : map | CopyFiles | from | none | from, to, replace, exclude |
listitems(spec: map, children?: list) : map | ListItems | none | content, line, fragment | item, line, indent |
Every bad call answers the same error code, invalid-arg (Class
conflict). The message’s attempt
word is the offending name rather than a fixed verb, and it is one of
five:
- the prop that is not declared;
- the text prop’s own name, where that prop is required and missing, or
is present and is not a non-empty string, so
project({folder: 1})andproject({folder: ""})both answerCannot folder; item, where alistitemsbag is absent or is not a list, solistitems({})andlistitems({item: "a"})both answerCannot item;children, for a child the component does not admit;spec, where the argument is not a form the component’s spec takes, which covers the stringlistitems("a").
Where more than one prop is wrong, the one named is the first
written, not the first in sorted order, and both ports walk the
written key list to keep that promise (cmp-props-names-first-written).
A child the component does not admit:
$ echo 'out: folder("src", [line("x")])' | aontu
[aontu/invalid-arg]: Cannot children values at path $.out
...
$ echo $?
1
What each component adds to the table:
project: the one component whose text prop is optional, soproject()answers a node with empty props. Afolderwritten as anything but a non-empty string is refused by that name (cmp-project-folder-kind,cmp-project-folder-not-empty).folder:nameis required and non-empty (cmp-folder-needs-a-name,cmp-folder-name-not-empty).file:nameis required and may hold/, and the folders on the way are made.exclude: trueleaves the file alone when it is already there, and is the only form each runtime honours: a path or a list of paths is matched against the COMPONENT path rather than the output path, and the Go runtime skips nothing for either.modeis the permission bits as a number:mode: 493andmode: 0o755are the same value, both spellings being numeric literals, andtest/spec/cmp.tsvwritesmode: 420for0o644.content: the text prop may be the empty string, socontent("")is a node wherefolder("")is a refusal. The text is taken fromarg, thensrc, then a string child, and a node holding none of the three writes nothing.indentis a count of spaces or a literal prefix.line: the same seven props ascontent, from the same alias. The name is also alistitemsprop, which is a different thing spelled the same way.fragment:fromresolves against the output folder.ejectnames a start and end marker pair, and only the region between them is read. The source file must exist.fragmentandcopyfilesare the two components whose props the runtime validates against a closed set, so an undeclared prop on a hand-writtenFragmentorCopyFilesnode is refused by name at render time rather than dropped, andejectis refused unless it is a list of two markers:eject: truebuilds a node (cmp-props-fragment-all) and does not render.slot:nameis required and non-empty, and fills the<[SLOT:name]>marker of the enclosingfragment. The unnamed<[SLOT]>marker takes that fragment’s non-slotchildren instead, so a bare string,content,line, orlistitemswritten directly under afragmentlands there; afragmentcarrying such children whose source has no unnamed marker is a refusal at exit 2.inject: the default marker pair is#--START--#followed by a newline, and a newline followed by#--END--#. Amarkerspair with exactly one empty member is refused at render, and both empty reads as unset.injectrewrites a file that already exists and never creates one, so a missing target is a refusal at exit 2.copyfiles:fromresolves against the process working directory.replacesubstitutes in copied text, and a binary file is copied through unchanged. Thisreplace, here and in the span set, is a substitution the runtime applies, and notemit’sreplacekey.listitems: the one component with no text prop, so it takes no bare string spec (cmp-listitems-takes-no-string), and the one with a bag:itemmust be present and must be a list, whatever else it holds (cmp-listitems-needs-item,cmp-listitems-item-is-a-list).linehere is a prop and not the component of that name.
What aontu render writes
Per node, on disk:
| node | effect |
|---|---|
Project | Joins folder under the run’s output folder. name writes nothing. |
Folder | One or more path segments below the enclosing folder, so folder("a/b") is two of them. |
File | One output file at name below the enclosing folder, with mode and exclude applied. |
Content | A span of text, with no terminator. |
Line | The same span, with a newline after it. |
Fragment | The file at from, resolved against the output folder, with its markers filled by the slots beneath. |
Slot | One marker of the enclosing Fragment. |
Inject | The region between a marker pair, in a file that already exists. |
CopyFiles | A copy of from, resolved against the process working directory, at to below the enclosing folder. |
ListItems | Its children once per element of item, then a blank line unless line: false. |
Where a root lands is stated under aontu render. One segment of the path is the
tree’s own: a Project’s folder is joined under <path> as a further
segment, so project("pkg", [file("a.txt")]) against build2 writes
build2/pkg/a.txt, and the project’s name adds nothing to the path.
Text reaches the file verbatim. render sets raw on every
Content and Line node, so the substitution the runtime would
otherwise apply to a span does not run, and a $$…$$ sequence in a shell
script, a doc comment, or a regex is written as it stands. Two props in
the span set follow from that: extra and replace are inert unless
the node writes raw: false itself. indent is placement rather than
substitution and applies either way.
The write is not atomic. The runtime writes as it walks, so a refusal part way through leaves the files written before it on disk. Two generators in one set claiming a single output path are refused that way, at exit 2, with the first file already written.
--check writes nothing, and its flag entry is under aontu render. What it holds is the tree’s own
surface: the files the generator emits and not the directory, so a file
that stops being generated is not reported. It answers the question
render answers, so it reports no difference for a file render
leaves alone: a file carrying exclude: true is reported neither for
its bytes nor for its mode. The one difference it still reports for
such a file is missing, because exclude is consulted only when the
target is already there, and render writes an absent one. A reported
path is the one the generator names, relative to <path>, rather than
the path the command was given.
Write a gen.aon answering a project of one file:
out: project(".", [folder("src", [file("main.js", ["const x = 1" ""])])])
and a build/src/main.js holding different bytes:
const x = 2
$ aontu render --check gen.aon build
content: src/main.js
$ echo $?
1
The same edit under exclude: true is not a difference, because
render does not make it. Write a generator that excludes its one
file, as gen.aon:
out: project(".", [file({ name:"keep.txt" exclude:true }, ["generated"])])
and a hand-written build/keep.txt:
hand written
render leaves those bytes where they are, and the check then
reports nothing:
$ aontu render gen.aon build
$ aontu render --check gen.aon build
$ echo $?
0
Exit codes for the verb are listed under aontu render, and the five values the engine
uses under Exit codes. Two of them are
decided by the tree rather than by the command:
| refusal | exit |
|---|---|
the tree root is a File with no name | 4 |
the tree is refused before any write: an absolute or climbing Project folder, or a props that is not a map | 4 |
A climbing Project folder is refused while the tree is still data, at
exit 4; a climbing File or Folder name is refused by the write, at
exit 2. The nameless-File guard reads the tree root alone, so a
hand-written nameless File nested inside a Project renders and
writes a file called undefined.
What aontu trace reports
trace reports one row per piece an
emit rule stamped,
attributed to the file it reached. The text form is four tab-separated
columns, in this order:
file, thenameprop of the innermost enclosingFilenode.at, the address of the stamped piece in the document.node, the address of the model node the dispatch matched, and the empty string where the selection was written inline at the call and so has no address (test/spec/trace.tsv).rule, the rule table’s address,#, and the template’s index, addressed asaontu tracestates.
--format json answers one object under a trace key, whose entries
carry the same four fields keyed at, file, node, and rule. Both
ports print the four in those two orders.
Write a gen.aon whose fields come from a rule set:
fields: [n:"id" n:"name"]
%field = emit(_, { match:n:string body: [line(" " + .n + ": string")] })
out: file("t.ts", ["type T = {" emit($.fields, %field) "}"])
Ask what wrote each line:
$ aontu trace gen.aon
t.ts $.children.1 $.fields.0 $.%field#0
t.ts $.children.2 $.fields.1 $.%field#0
$ aontu trace --format json gen.aon
{"trace":[{"at":"$.children.1","file":"t.ts","node":"$.fields.0","rule":"$.%field#0"},{"at":"$.children.2","file":"t.ts","node":"$.fields.1","rule":"$.%field#0"}]}
Four rules decide what has no row:
- A piece no rule stamped, which covers every hand-written child
(
trace-no-rule-no-entry). - A piece under no
Filenode at all, and a piece under aFile-shaped map whosepropsis not a map, which names no file. - The descendants of a stamped piece. Only the top level of a spliced
result is stamped, so a rule whose body is
[file(.n + ".txt", [line(.n)])]puts a row on theFilenode and none on theLineinside it. - Anything outside the anchor, which is
$.outunless--atnames another path.
The file column is the innermost enclosing File, by longest matching
address prefix rather than by first match, and a File node that a rule
stamped itself gets a row naming itself.
trace reads a <file> whose name does not end in .aon as a
generator in the target’s own syntax, desugared by its marker. That
includes a .aontu file, which aontu render
and aontu fmt both read as plain aontu;
traced, it has no $.out and answers no_path (Class
reference) at exit 4. Exit codes
are 0 for a report, empty or not, 2 for usage or I/O, and 4 where
the document does not stand up or --at names nothing.
Order and determinism
children is a list and keeps document order. Each spliced child is
checked against the same parent’s admitted children
(cmp-splice-refuses-inside).
A node’s own three keys print in code-point order, children, cmp,
then props, and a props map prints its keys sorted the same way,
whatever order they were written in: cmp-map-spec writes
file({name: "a.ts", mode: 420}) and pins the printed props as mode
then name. List elements print in index order.
emit visits its selection in the order every bag
reader uses.
pack answers a map, and no component accepts a map as children, so a
pack result reaches a file as Cannot children. each and emit
answer lists and feed children directly. Generate code from a
model makes the practical point about which to
reach for.
Where rules nest, the innermost owns its pieces. A nested emit
flattens its result into the parent’s piece list in place, and the
stamp is applied only where a piece carries none, so an outer rule never
overwrites an inner rule’s attribution. The stamps themselves are opt
in: they are built only for a run that asks for them, which trace
does and ordinary evaluation does not, so no stamp appears in the tree
generate or model get answers.
Both implementations promise the same tree and the same report, under
Behavioural parity, and the Go
component table mirrors the TypeScript one entry for entry. What
render writes is one step further out: the two ports call two separate
builds of the generator runtime, and it is the goldens in
use-cases/15-code-generation/, held
by render --check, that hold the bytes to each other.
Related
- Language reference, Generation.
What
generaterequires of a model, and the per-function index entry for each of the ten components. - The call surface. The arity, argument modes, and result word of every declared name.
- The codes.
invalid-arg,func_arity, andno_pathby class, with the exit code each run ends on. aontu render. The flags, the synopsis, where a root lands, and the groups--format jsonsorts written files into.- Generate code from a model. The worked recipe: a rule set over the records, a tree of files and lines, and the bytes held against goldens.
- Trust and determinism. Why nothing in
the engine writes a file, and which tree-shape refusals stand before
renderhands the tree on.