Metaxis

Changelog

Changelog

The one page that says when, for a reader who is not reading the source. Only what shipped and is visible from outside: a directive that appeared, a behaviour that changed, a message somebody will now see. Internal reworking, and the reasoning behind any of it, belongs in the journal instead.

Metaxis has not been released, so there are no version numbers yet. Entries are grouped by the day the work happened, newest first.

2026-09-07

examples/cpp.mx reads #x and a ## b. Stringizing quotes the argument bound to the name; pasting joins the two sides and rescans the result, so GLUE(LIM, IT) is LIMIT and then 10. Three rules, nothing in the tool; nine lines from both programs, and everything roadmap item 11 listed is read.

read(path): the text of a file beside the source. The path is taken beside the file being expanded unless absolute, as @use takes its path; one that cannot be opened is 'read' cannot open '…'. The first builtin whose answer depends on something outside the .mx file, which notation.md's "What it costs" now records. examples/cpp.mx reads #include "cpp.h" with it, the header's text going through the same rules; eight lines from both programs.

examples/cpp.mx reads #ifdef, #ifndef, #else and #endif, nested. Each arm is a raw hole and the arm taken is handed to expand, so a #define inside the arm not taken never fires; a conditional nested in the arm taken fires in its turn. Also #define NAME with no body. Seven lines from both programs.

Two brackets may share a close. @bracket "#ifdef" "#endif" beside @bracket "#ifndef" "#endif" is accepted, since a close balances whichever open stands behind it; an open is still declared once, and a word is never an open of one bracket and a close of another.

A raw hole kind: source text, not expanded. Text mode only; it matches as a text hole does and the template gets it as written, to hand to expand or to leave. examples/cpp.mx keeps a macro's body raw and expands it at each use, which is cpp's order: TOTAL defined as (LIMIT * STEP) reads the STEP that stands where TOTAL is used, and the example prints 70 after #undef STEP made it a variable. In expression mode it is a 'raw' hole belongs to @mode text.

examples/cpp.mx reads function-like macros. #define F(a, b) body and F(x, g(y)), arguments taken whole under the brackets, bound to their parameters through the store at a depth the file counts, and the body rescanned; a nested call's own parameters live one deeper. Nothing in the tool changed for it but one property of text mode below.

In text mode a turn of a repeated group that takes nothing is not a turn. A text hole may be empty, so [ a ]* over () used to read as one turn of an empty a and count(a) said 1 for a call with no arguments. It says 0 now. No recorded output moved.

expand(text): a template runs a text through this file's rules. Text mode only, one level deeper than the rule that is running, under the same 64 cap; refused at the seal under expression mode. examples/cpp.mx uses it to rescan a macro's body at use, so a body that names a macro defined later gets the later value, and a busy: key in the store stops a macro that names itself the way cpp stops it.

examples/cpp.mx: a C preprocessor in three text-mode rules, stage 6 begun. Object-like macros: #define remembers, #undef forgets, and a rule on every identifier recalls. tests/cpp.sh compiles what comes out, runs the same body through the C compiler's own preprocessor, and holds the two programs to the same three lines. make check runs ten scripts now.

Under @mode text a rule may begin with a class hole. x:name => { … } fires on every identifier; it needs no level and no word after it, and is tried after every word-led rule at that position, in declaration order. A rule led by any other kind of hole is still refused there. Under expression mode nothing changed, except that a class-led rule's two checks are made once the header has finished, so that @mode may come after the rule. mx -g prints such a rule as prefix.

The store: remember(key, text), forget(key), recall(key) and known(key). A rule writes a key when it fires and a rule that runs later reads it, in body order; the last write wins, and recall of a key nobody wrote is an error. The first mechanism by which what a rule emits depends on a rule that ran before it. REFERENCE §8.5; five new error cases.

A text hole is expanded once, after its rule has matched. A rule nested inside the hole used to fire once for every stop the search tried, so a contribute or a fresh in it ran for candidates that were then rejected: two contributions came out as six. Recorded outputs are unchanged; fresh names in a nested text rule now count 1, 2, 3.

A call wrapped onto a second line reads. Between a declared bracket and its match the lexer treats a newline as whitespace: no separator, and no indent or dedent measured. @bracket is read by expression mode now, and each of its sides is a word; the refusal @bracket belongs to @mode text is gone. examples/python.mx wraps its last print, and docs/languages/toml-wrapped.mx reads whole.

Every message a page quotes is checked against the source, not only §10's. tests/hygiene.sh takes a backticked span outside the errors page as a message when two words of it in a row are in a source literal, and holds it to the same rule as a §10 cell. Two spellings were wrong and are fixed: cannot open … in the ledger and no rule reads 'x' here in the survey.

A roadmap number cannot be given twice. tests/hygiene.sh reads the retired numbers off the roadmap's opening note, Retired so far: …, refuses a heading that reuses one, and refuses a commit that takes an item off the page without adding its number to the list. Two new ok lines, and the note is refused if the sentence is not in the one shape the check reads.

A numbered list with blank lines between its items is one list. The seven properties on the languages page were seven lists, each numbered 1; they are 1 to 7. And a caption line reaches its block by one rule on the site and in tests/docs.sh, which now reads the caption regex out of site/build.py rather than keeping a copy.

The site's front page is under the same check as the documents. tests/docs.sh scans site/index.md as it does README.md and docs/, so the one transcript written for the site is run; the site workflow had said every transcript on the site is one the suite runs, and that page was not scanned. Four sentences that counted things were reworded to name the property instead: the roadmap's error cases, prior-art's scripts, the journal's list of the documents about the thing, which gained prior-art.md and languages.md, and the README's two Reading it sections, now one.

The site renders a nested list, and no longer hangs on one. An item indented under another is a sub-item now, so the four consequences under the lexer property on the languages page are four bullets rather than one paragraph with dashes in it. A list whose first item was indented, which no page has, made site/build.py loop forever; it renders as a list.

A tenth collection is spliced where its own mark was. A splice mark is splice__N, and splice__1 is a prefix of splice__10: the second pass took the first mark that matched, so a template with ten or more collections put the first's text at the head of the tenth's mark and copied the rest of the mark through. It now takes the longest mark that matches. Found by the audit; tests/basic.sh pins it.

2026-09-06

A block that quotes a file is checked against the file. make check now compares the twenty-five fenced blocks that tutorial.md, languages.md and REFERENCE.md label with a filename to the file itself, whole or after its leading comment, and a line inside such a block skips ahead as it does in a transcript. Eight blocks in the tutorial that omit the header lines an earlier section showed now open with that .

The errors page is checked against the source. make check now prints ok hygiene.sh: every message the errors page quotes is one the source prints, and three cells of REFERENCE.md §10 changed on its first run: cannot open path is cannot open …, the at message ends in an ellipsis where the tool chooses is or are, and trailing text after … is one message printed by six directives, @bracket and @template among them, where the page had listed four.

The languages page shows its evidence. languages.md closes with Run, not reasoned: thirteen files under docs/languages/, each quoted with the transcript make check runs. The XML row is sharpened: a character-data class ties with the tag name in a bare tag and the earlier declaration wins, so <p> alone reads, and the first attribute is the wall. REFERENCE.md §6.1 says that tie rule now: two classes that match the same length are settled by declaration order.

Three links inside POSTMORTEM.md resolve on GitHub, spelled as the full slugs of the headings they point at.

@bracket "(" ")", and a text-mode hole that stops only where brackets balance. A new directive declares two words that nest. A text hole now stops only where the declared brackets are balanced from where it began, and a close bracket with no opener behind it ends the hole, so a hole over f(x, g(y)) is the whole call and a template may put text after it. In expression mode the directive is refused: @bracket belongs to @mode text -- in expression mode nothing reads it yet. Two more refusals for a bracket whose sides are the same and a word declared twice. mx -g prints a bracket line. With it the island rule is finished and leaves the roadmap; lib/island.mx declares C's brackets and rewrites the one !( in metaxis/cmd/mx.c with its nested call whole.

Text mode moves by tokens where a declared class matches. A @token class in a text-mode file now makes the scan take the token whole: a rule's word fires only on a whole identifier, a hole stops only where a token ends, and a string or a comment declared as a class passes through with nothing fired inside it. A class hole, x:name, is honoured in text mode and takes one token; the refusal 'x:name' asks for one token of a class, and text mode has no tokens is gone. A file that declares no class is scanned as before. lib/island.mx declares C's four token classes and renames out to res across metaxis/cmd/mx.c without touching outpath, the usage string or a comment; tests/island.sh counts it and runs the result.

The reference says what a second engine must match. §3.1: a token pattern is POSIX extended, leftmost-longest, the engine's to implement rather than its host's. §8.2: a fresh name is label__N on one counter for the run, pinned as language because it is output. §10: what the language fixes is that a file is refused, on standard error, with status 1, naming file and line; the wording is the engine's own. Exit status had not been documented before. §11: the limits are minimums, and the tab width is a definition. Every document is under a style rule: no em dash in general prose, and every page of the site reads with commas, periods and colons instead. Twenty-five error messages quoted in the reference and the records were typeset with an em dash where the tool prints two hyphens, and are now verbatim. The tutorial's section on -b now shows the fallback in the output and the three refusals around the flag, with one new runnable file. make check holds three more properties: every roadmap citation resolves to an item, no roadmap item vanishes between HEAD and the tree unless SETTLED='N' says this commit moves it, and no em dash stands in general prose.

A tutorial, docs/tutorial.md, with sixteen runnable files under docs/tutorial/: one concept per section, every statement and builtin a code template has in two tables, and every transcript checked by make check. REFERENCE.md gained a contents table at the top and an index of terms at the end. A logo. Hans drew up three candidates and chose the third: a solid block and a wireframe cube interlocked, in isometric, the thing and its declaration. site/logo.png is that drawing with its background made transparent, and it is the site's mark and favicon and the README's; docs/logo3.png is the drawing as chosen.

A website, at hansolovkarlsson.github.io/Metaxis: an introduction, the tutorial, the glossary, the reference, every example with its recorded output, the three essays, this page and the roadmap. It is generated from the documents in the tree by site/build.py and published by GitHub Pages on every push to main that passes the suite.

A glossary, docs/glossary.md. First the concepts in the order they depend on each other: grammar, lexing, parsing and its families, expansion, attribute grammars, context, hygiene, islands. Then every term of art the documents use, defined.

A statement that ends in a word like end needs no separator after it, as the reference always said. It did: the check asked whether the last token was punctuation, which } is and end is not, so end on a line of its own followed by a statement was no rule reads … here. Every example wrote end;, and the tutorial's fifth file was the first not to.

Collections. contribute("vars", text) is a statement in a code template that adds a line to a named collection, once per distinct text; splice("vars") is an expression that marks where the aggregate goes, filled in by a second pass once expansion is over; and a collection nothing splices goes at the start of the output. examples/basic.mx now writes the declarations C wants, from the LET and FOR that mention the names, and tests/basic.sh no longer supplies them. examples/code.mx includes stdio only if a writeln fired. Three new errors for the ways to misuse them.

Stage 5: the tool rewrites its own front end. lib/island.mx is a text-mode rewrite of one fprintf shape into a call, with the definition inserted; tests/island.sh runs it over metaxis/cmd/mx.c, compiles the result against the tree's objects, and runs it. Text mode was an island grammar already and nothing was built; what it lacks is ROADMAP.md 7.

A led rule in text mode is refused: a rule that begins with a hole is infix, and text mode has nothing for it to continue -- it could never fire. It used to be accepted and never fire.

Stage 4: BASIC→C. examples/basic.mx reads line-numbered BASIC and writes C with a label per line. The BASIC it reads: LET, PRINT, IF … THEN, GOTO, FOR/NEXT, END, string variables with $. tests/basic.sh compiles it, runs it and checks the numbers, with the declarations C wants supplied by hand, because the translator cannot write them: that is the wall the stage was picked to reach, and it is ROADMAP.md 4. Nothing in the tool changed.

make check runs the transcripts in docs/. A seventh script, tests/docs.sh, finds every fenced $ mx … line in the documents, runs it, and compares the output against the block beneath it; on a line of its own means skip ahead. Five transcripts today. The check exists because one of them was once invented, POSTMORTEM.md 19, and it caught two smaller things on its first run, below.

mx -g prints no trailing space after a rule that has no level, and one space rather than two before terminated. The trace -t writes is unchanged.

The first transcript in REFERENCE.md said pt, the tool's name before 2026-09-05. It says mx.

A page of languages, docs/languages.md, on the site as Languages: which languages the tool can be pointed at and which it cannot, from a notation of your own, Pascal, BASIC, Python, Lua, SQL and JSON to C, C++, the shell, Haskell and Lisp, each verdict resting on one of seven stated properties of the tool, every property run against the tool the day it was written. Two of the runs moved a verdict: an argument list separated by nothing but whitespace is unreadable in expression mode, which puts Lisp on the unfit list, and XML's character data is a token only by position, which puts XML there beside it. Text-mode rewrites over both remain fit.

2026-09-05

indent(s, n) in a code template, and examples/code.mx now emits indented C. Every line moves right, the first included, and an empty line stays empty; nesting composes, because an inner block is already indented when the outer one indents it. The C that examples/code.out records was previously flat: braces opened and nothing moved. tests/pascal.sh still compiles it and gets the same 4 44 80 7 42, so what changed is the reading and not the meaning. The string template has no equivalent and is not getting one until something asks.

mx -t traces the parse to standard error: one line per candidate tried, indented by depth, saying which token it could not get past, and totals at the end. Expression mode only.

Expansion is no longer quadratic in the size of the input. A generated 4985-line Pascal program took 67 seconds and now takes 174ms; 16000 statements expand in 524ms. regexec measures the whole remaining file on every call, so each token cost O(rest of file) across every declared class; class patterns are compiled anchored, so matching now happens against a bounded window that grows only when a match reaches its edge. Two smaller quadratics went with it: the per-token line count and the token array's growth. Nothing about what is emitted changed. One consequence worth knowing: a @token pattern that anchors its end with $ now sees the window rather than the whole file.

as <name> on a template, and mx -b <name>: a rule may now carry more than one template, so one grammar can be read out to more than one target. The untagged template is the default and the fallback, so a second target costs only the rules that actually differ and a one-target file is unchanged. terminated moved from the rule to the template, because one target may brace a branch where another does not. mx -g lists the tags and no longer needs -b; make check and make record run every declared backend against <name>-<backend>.out. New: examples/backends.mx. New messages: this rule already emits 'x', this rule already has an untagged template, expected a name after 'as', no rule emits 'x', every template here is tagged, so there is no default, and this rule emits nothing for 'x'.

@mode declared twice is refused, unless the second says override, as every other repeatable declaration already was. @mode also refuses trailing text now, which @token and @separator began doing earlier the same day and this one was missed by. Together those closed a third silence nobody had listed: @mode expression override parsed and meant nothing, because the word after the mode was ignored. New messages: the mode is already declared at f:n -- write 'override' to mean it, 'override', but no mode was declared before it, and trailing text after @mode.

@separator "…" indent, and the block kind: a language whose blocks are an indentation can now be read. indent makes the lexer keep a stack of columns and emit two tokens no file spells, an indent where a line is deeper than the one before it and one dedent per level closed where it is shallower; a hole written b:block reads them, taking the indented run of statements and both of its delimiters. It needs a separator with a newline in it. Blank lines and comment-only lines close nothing, a tab is 8 columns, and a dedent counts as ending a statement in a word, so nothing has to separate a block from the line after it. Unlike a stmts hole a block needs no word to stop at and may be followed by one, which is what lets "if" c ":" b:block "else" ":" e:block be a rule. examples/python.pt reads Python into C, and tests/python.sh compiles that C, runs it, and also runs the same text under python3 and compares the two answers.

New messages: 'indent' needs a separator with a newline in it, 'b:block' wants a block, and nothing here opens one, this line is indented and no rule opened a block here, this line ends a block but lines up with nothing that opened one, and a block hole is refused in text mode. @token block joins expr, stmts and text as a class name that could never be used.

@fragment name = pattern: a piece of pattern with a name, spliced into a rule with @name. It brings its own holes, so a rule that splices it can write {p} without declaring p; it takes no arguments and has no scope, because it is spliced at declaration rather than called at expansion. It must be declared before it is spliced, which makes a cycle inexpressible and the order a file is written in unable to change the answer. override sits before the =, which is the opposite of every other directive and is forced: a fragment's pattern runs to the end of the directive, so a trailing override would be read as a hole of that name. examples/pascal.pt and examples/code.pt each wrote one parameter list twice and now write it once; both expand byte-identically to what they did before. examples/code.pt also shares the body the two rules had in common through a @template, so the two ways of naming a fragment now meet in one file.

make check now reports a hang instead of waiting on one. Every place the suite runs pt goes through tests/limit.sh, which kills a command that outlives a wall-clock limit and reports it as its own kind of failure. Previously a .pt file that never terminated stopped the suite rather than failing it, which no recorded output could express. make check LIMIT=30 raises the limit, which defaults to 10 seconds; the full run takes 2.3.

@token expr, @token stmts and @token text are now refused: 'expr' is a kind, so a class called that could never be used. A hole written x:expr resolves as the kind, so a class of that name was never consulted: expr said nothing at all, and text reached a 'text' hole belongs to @mode text, an error about the wrong thing.

A for inside a @template body no longer crashes. It read through a null rule at seal and segfaulted, so no template that looped had ever run; the check it crashed in, a loop variable shadowing a hole, does not apply to a template, which has no holes to shadow. POSTMORTEM.md 12.

A pattern that declares one hole name twice is now refused: two holes called 'p': a template splices a hole by name, so only one of them could ever be reached. It was always a mistake and nothing had ever written one; splicing a fragment twice into a rule makes it easy to make by accident, which is what asked for the check.

@template name(x) { … }: a piece of template with a name, called as a statement from a code template and emitting into whatever called it. Its body sees its parameters and its own loop variables and nothing else, so it can be read on its own; calls resolve once the header has finished, so a rule may call one declared after it or brought in by @use; a template may call a template, 64 deep. At most 8 parameters, and a duplicate name is refused unless it says override. examples/asm.pt has one load against eight call sites.

A statement may now be a call, so expected 'emit', 'if' or 'for' is now expected 'emit', 'if', 'for' or a template call.

fresh(label) in a code template now gives one name per label per application, which is what {~label} in a string template has always done and what this page and the reference had both said it did. It returned a new name on every call, so a template could not put a label at a branch and at the place the branch jumps to. Exhausting the name space is now an error rather than a crash.

examples/asm.pt: C in, arm64 assembly out, assembled and run by tests/asm.sh.

Arithmetic in a code template, and num(h). -, *, / and % are new; * / % bind tighter than + -. They want two numbers and are an error otherwise, and num(h) reads a hole's text as one: the whole text or none of it. + changes: it adds when both sides are already numbers and joins when they are not, which is the rule comparison has always used. count(a) + count(b) therefore writes 3 where it used to write 12; nothing in examples/ did that. Division or remainder by zero is an error.

examples/calc.pt is the first file here that does not translate its language but runs it.

for i, x in h, and at(h, n). A for in a code template may name the position as well as the turn: first name is the index, counting from 0. at(h, n) takes the turn at a position. Together they walk two holes of one repeated group in step, which is the only way to write [ v ":" s ]* and emit the pairs; a position past the end of a list is an error rather than an empty string, because two groups of different lengths is the mistake worth catching. examples/code.pt's case is the customer.

repeat … until and case … of in the Pascal examples, inverting the condition for C's do … while and giving every case arm the break Pascal does not need. No change to the tool.

procedure and function in the Pascal examples. With parameter lists, calls, and Free Pascal's Result := for the return value. tests/pascal.sh compiles and runs what comes out and now checks four values rather than two. No change to the tool: the parameter list is a repeated group with sep ";", the body is a stmts hole stopping at end, and a call is a led "(" … ")" at 95.

terminated(h) in a code template. A new builtin beside level(h): whether the rule that filled a hole was declared terminated, that is, whether the hole's text already ends a statement. For a stmts hole it is the last statement that answers. It is what lets a rule decide whether what came out of a hole needs a semicolon, and examples/code.pt uses it to emit if (c) x = 1; else y = 2 and if (c) { … } else y = 2 from one rule.

override, and two files declaring one thing. A rule's pattern, a @token class name and @separator may each be declared only once; a second is an error naming both lines. override says the second means to displace the first, and then it wins silently. It sits after the template for a rule, after the declaration for the other two. override with nothing to displace is also an error. Two rules that only share a leading word are unaffected. A hole may still be called override.

@token and @separator refuse trailing text, as @syntax always has. @token name "…" garbage used to be accepted and the extra word ignored; it is now trailing text after @token. This is what makes the optional override after them unambiguous.

@use reads a file once, however many times it is reached, so a diamond costs nothing and a cycle ends rather than hitting the depth guard.

A class-kind hole is refused in text mode. @syntax "[" x:name "]" under @mode text used to take everything up to the ] and ignore the kind. It now fails: 'x:name' asks for one token of a class, and text mode has no tokens -- every hole there is text. expr, stmts, text and a bare hole are unaffected, and expression mode is unaffected. The check runs once the whole header has been read, so the order a file writes @mode and @syntax in, and whether the rule came in through @use, does not change the answer.

2026-09-04

The first day. The notation, the tool, and everything below.

Groups in text mode. [ … ], [ … ]* and [ … ]+ work under @mode text. Matching there is now a search with backtracking rather than a single forward scan, and a hole takes the shortest run that lets the rest of the pattern match without spanning the word that closes the rule.

terminated. A word after a template, saying the rule's output already ends a statement so no separator is joined after it. A hole may still be called terminated.

Two kinds of template. => "…" splices. => { … } is a small interpreted language: emit, if/else, for … in … sep …, text with +, comparison, and/or/not, and the builtins matched, count, level, group, replace, drop and fresh. One character after the => says which form it is. A directive no longer ends at a newline while a brace is open.

The notation. A .pt file declares its own grammar in a header and is then read with it. Every mention of foreign text inside a directive is a string: quoted words on the pattern side, a quoted template with {hole} splices on the output side.

Directives. @syntax, @token, @comment, @separator, @use, @mode, @end.

Patterns. Infix, prefix, postfix, circumfix and mixfix, told apart by shape rather than by four directives. Hole kinds expr, stmts, text and any @token class. Levels with left and right.

Groups. [ … ], [ … ]* and [ … ]+, with sep and join on a repeated one: a part that may repeat and a part that need not be there.

Templates. {hole} splices, {{ and }} for a literal brace, and {~t} for a name nobody else has.

Two modes. @mode expression parses the whole body; @mode text fires a rule where one matches and copies the rest through.

The command line. pt [-o output] [-g] file.pt. -g prints the grammar the header declared and stops.

Errors. Every splice in a template is checked at the @syntax that wrote it rather than at the first use of the rule. Twenty error cases are pinned in tests/errors.sh.