.mx in, .out out. What this changes, against Proto, is the mention of foreign text inside a directive, which is where Proto's grammar and the grammars it declares keep meeting.
Everything on this page is implemented and is exercised by examples/. What is not is under Not done, at the bottom.
This page argues; REFERENCE.md states; ROADMAP.md says what to do about what is missing; direction.md says where the whole thing could go and what it would stop being. Every directive, kind, level, template form, lexer decision and error message is there, exhaustively. Read that one to write a .mx; read this one to know why it is shaped the way it is, and what it cost.
The one rule
Everything a directive says about text is inside a string: the text it recognises and the text it emits. Everything outside a string is Metaxis's own fixed vocabulary.
A directive mentions foreign text; the body is foreign text. Quoting is what tells the two apart, and it is the only thing that has to. Strings already have exactly one escaping rule, understood before anything else in the file, so there is nowhere left for an ambiguity to live.
It applies to both sides of a rule, which is what makes it a rule and not a convention:
@syntax "if" c "then" t => "if ({c}) {{ {t} }}" ~~~~ ~~~~~~ ~~~~~~~~~~~~~~~~~~~ input, quoted output, quoted c t {c} {t} holes, bare splices, braced
- Left of
=>: quoted means a literal word of the input; bare means a hole. - Right of
=>: one string;{name}splices a hole;{~name}is a name nobody else has;{{and}}are a literal brace.
The header's own strings are always Metaxis's, spelled Metaxis's way. The body's strings are whatever @token string said they are. The two never meet.
What falls out of it
One directive instead of four. Proto needs @infix, @infixr, @prefix and @syntax because the directive's name is what says which positions are operands. Once words are quoted, position says it:
| pattern | what it is | why |
|---|---|---|
a "+" b | infix | hole, word, hole |
"-" a | prefix | begins with a word |
a "++" | postfix | begins with a hole, ends with a word |
"(" e ")" | circumfix | words at both ends |
"~" | a word alone | no holes at all |
"if" c "then" t | mixfix | the general case |
The Pratt distinction is already in the shape: a pattern that begins with a hole is a led rule and needs a level; one that begins with a word is a nud rule and does not. Nobody declares which: it is read off, in header.c:rule_syntax, in one line. Text mode has one exception, since 2026-09-07: a pattern that begins with a class hole is a nud rule there, firing on a token of that class (REFERENCE §7), because a scan has no left operand to continue and a class names a token the scan can stand on.
< and > stop being needed. A hole is a bare name because a word is a quoted string, so a pattern's own punctuation no longer comes out of the pool of characters the file is busy declaring.
The lexer stops being a fixed budget. Proto's operator characters are a closed set, because its lexer runs before it knows what the file declared: . , : | ; are spoken for and ; opens a comment. Here the header is read first and lex.c runs second, so ; as a statement separator, | as bitwise or and . as field access are all writable: none of them is anything until a string says so.
Literals become declarable too. @token number "[0-9]+" is what lets 42 be an integer without a sigil: the one thing on Proto's impossible list that is the lexer's rule about literals rather than a collision. Pascal's 'it''s' and C's 0x1f are the same directive with a different string in it.
Alphabetic words still are not reserved. See Ties, below. then is a form's word where a form wants one and a name everywhere else, including in the same file: examples/tour.mx uses then both ways four lines apart.
Directives stop needing a terminator. Proto ends one with ., which is also the statement separator inside the template. Here a directive ends at a newline, and continues only onto a line that is indented.
The directives
module = header body .
header = { directive } [ "@end" ] .
directive = "@use" string
| "@mode" ( "expression" | "text" )
| "@token" name string [ "override" ]
| "@comment" string ( string | "eol" )
| "@separator" string [ "=>" string ] [ "indent" ] [ "override" ]
| "@syntax" pattern [ level ] "=>" template
{ "terminated" | "override" }
| "@template" name "(" [ name { "," name } ] ")" code [ "override" ]
| "@fragment" name [ "override" ] "=" pattern
| "@end" .
pattern = element { element } .
element = string | hole | group | splice .
hole = name [ ":" kind ] .
kind = "expr" | "stmts" | "text" | "block"
| a class named by @token .
group = "[" element { element } "]" [ rep ] .
rep = ( "*" | "+" ) [ "sep" string ] [ "join" string ] .
splice = "@" a name declared by @fragment .
level = integer [ "left" | "right" ] .
template = string, with "{" name "}" splices, "{~" name "}" fresh names,
and "{{" "}}" for a literal brace
| code, a block in Metaxis's own language .Every string above is a Metaxis string: "…" with \" \\ \n \t \r and no other escape. That never varies, in any file, whatever the file declares.
@token names a class and gives a POSIX extended regular expression for it. Redeclaring a name replaces it.
@comment adds an opener. It does not replace anything: ; to end of line is Metaxis's own header comment and always works, and a declared comment joins it. Below the body, only the declared ones are left.
@separator gives the input separator and, after =>, what to join the output with. Without the second half the input text is reused. A separator of "\n" makes newline a token instead of whitespace: examples/reserved.mx uses it. With indent after that, indentation nests as well: the lexer keeps a stack of columns and a block hole reads what it emits. examples/python.mx uses it, and the section above argues for why that hole is a bare word rather than a pair of strings.
@use reads another file's directives into this header. It is looked for beside the file that used it, holds directives and nothing else, is read once however many times it is reached, and stops at 64 deep. examples/use.mx takes its arithmetic from lib/arith.mx and keeps its own comment and separator, which is the division that file is for.
@template and @fragment are the only things besides a rule that can be named: a piece of template and a piece of pattern. They are two directives because they are two mechanics: a template is called at expansion, takes arguments and has a scope; a fragment is spliced at declaration, brings its own holes and takes nothing. One word for both would have been one word meaning two things.
That distinction is also why a fragment is spliced with @name rather than written where a hole's kind goes, which was the first spelling proposed for it. A kind says what one hole holds. A fragment says what sequence of elements goes here, and arrives carrying holes of its own. Putting both after a : would have made one position mean two unrelated things, and it would have capped a fragment at a single hole, which the next customer for one, a parameter list whose type varies, already breaks.
@end ends the header. Without it the header ends at the first line that does not begin a directive. Either way it is one-way: below it nothing is a directive, so a body may begin a line with @ and mean it, which examples/reserved.mx does, having declared @ as an operator.
Fresh names
{~t} in a template is a name nobody else has. One expansion, one name: two {~t} in a template are the same name, and the next use of the rule is a different one. examples/hygiene.mx calls one swap twice and the output has t__1 and t__2.
A name is taken if it occurs anywhere in the source being expanded or in any template any rule declared, @use included. That is a substring test, so it is conservative in the safe direction: t__1 is refused while t__12 is in the file. It costs one scan per name.
A label is not a hole and cannot share a name with one: {~a} beside {a} would read as one thing and is refused where it is written, not where it is used. Every splice in a template is checked at the @syntax that wrote it.
This closes exactly half of the hygiene problem, and the half it does not close is not a missing feature. See A template can name its own temporary and nothing else, below.
The rules the implementation had to settle
Six things quoting does not decide by itself. Each is a decision, and each is one line to state.
A hole's kind is how far it reaches. expr parses an expression, stmts a run of statements up to the pattern's next word, text raw source, and a class name exactly one token of that class. examples/pascal.mx needs i:name in "for" i:name ":=" … so that the := is the for's and not the infix rule's: a name hole takes one token and stops, where an expr hole would take the assignment. This is the one thing quoting does not do by itself.
Which bracket a group uses is a readability question and not a structural one, and that it can be is the rule working. [, (, { and < were all equally free, because foreign text is quoted and a pattern's punctuation can never collide with it. The same question in Proto would have been painful, since < and > are operator characters a file might want, which is what <x> holes cost there. [ … ] was taken because ISO EBNF already spells an optional part that way and a reader arrives knowing it, and because braces were spoken for by templates and reading a pattern beside its template is the common act. The suffixes * and + are regex's rather than EBNF's, which buys one bracket and three forms in a series instead of two brackets: [ … ], [ … ]*, [ … ]+, one thing to learn.
Swapping the pair for ( … ) was offered and declined, and the brackets are settled. It leaves ( … ) unspent, which is worth something on its own: a bracket nothing has claimed is cheap to keep and expensive to get back.
A group needs no new syntax to be safe from the body. [ … ], *, +, sep and join are Metaxis's vocabulary and live outside the strings, so a file that wants [ and ] in its own language quotes them and the two never meet: examples/clike.mx declares a "[" i "]" for an index in the same tool that reads [ x ]* sep ",". Proto declined repetition and optional parts three times, in conventions.md, on the grounds that no program had asked; a language-agnostic tool has argument lists everywhere and asks on the first file.
What a group does not buy is an output that differs on whether a part matched. Every hole is bound, to its turns or to nothing, and a splice is the only thing a string template can vary, so examples/groups.mx still writes if (!0) { ; } where its optional part was absent. That is the plainest customer for the code template (§ What it costs), and the reason join covers only the easy half of per-element output: examples/code.mx writes the other half with a for … sep loop.
The sharp edge of that is join writes one text and cannot vary it. A parameter list is the case, and it stopped being hypothetical on 2026-09-05: Scale(n: integer; k: real) needs int in front of one turn and double in front of the next, and join ", int " can only write the same word before every one. So examples/pascal.mx emits void Scale(int n, int k): output that compiles, links, runs, and is wrong. It is recorded in examples/pascal.out and pinned by tests/pascal.sh for exactly that reason: a failure a compiler will not mention is the kind that has to be written down. examples/code.mx holds the types as a second list and walks the two in step, and gets double k.
Underneath it is one limitation, not two: a string template splices each list joined and has no way to interleave two. The case arms met it first and were worked around by folding the pair into one value before the group saw it; a parameter list cannot be folded that way, because the two lists have to come back apart in the output.
Two holes may not sit next to each other. Proto's rule, kept, and for Proto's reason: given a b and f x + y the first hole takes the sum and the second finds nothing. Proto allows the pair when the second is a block; here a rule takes its own braces instead, "if" "(" c ")" "{" t:stmts "}", so the exception is not needed and does not exist. The refusal is narrowed to a greedy first hole, since a class-kind hole takes one token and stops, which is what lets [ p:name ]* be a parameter list.
Ties go to the token class. At each position the lexer takes the longest match from a declared class and the longest match from a declared word, and the class wins a tie. That is the whole of an alphabetic word is not reserved: div is a name token whose text happens to be div, a rule that wants the word compares text, and divisor is a longer class match than the word so it never splits.
Comments are looked for before words. A file whose comment opener is also an operator gets the comment. It is the only precedence in the lexer the file did not set, and it is stated because it is not derivable.
Candidates under one leading word are tried longest first, with the token cursor restored on failure. That is what makes if c then t else f win over if c then t, and it is the whole of the dangling else: the inner if takes the else because it is asked first. Proto matches its candidates in lockstep and needs no backtracking; this backtracks, which is shorter and slower, and the inputs are files.
A separator is wanted between two statements, and not after one that ended in a word. That is what lets } stand on its own without a rule having to declare itself terminating: C's for (…) { … } takes no ; after it, and Pascal's end takes none either.
Two modes
@mode expression, the default, parses the whole body with the declared grammar and calls anything unmatched an error. @mode text scans the body, fires a rule where one matches and copies everything else through verbatim; a hole's text is expanded in its turn, so **a //slanted// claim** nests. Same directives, same rule about quoting; the difference is only what happens to text no rule claimed.
Running it
make # bin/mx
make check # every example against the .out beside it, then the six
# scripts in tests/ -- four of which run what they produced
make record # re-record those .out files; read the diff before committing it
bin/mx examples/clike.mx # to stdout
bin/mx -o out.sol examples/clike.mx
bin/mx -g examples/pascal.mx # the grammar the header declared, and stopC11 and make, plus POSIX <regex.h> for @token, which is in libc and is the only thing here Proto does not also need.
The one exception, and what buying it cost
Everything a pattern matches is a quoted word, and that is the rule. There is exactly one thing it does not match: an indentation, which Python ends its blocks with, and which is not text somebody wrote.
There was a spelling that kept the rule's letter. Have the lexer synthesise two tokens and give them a text, say "⇥" and "⇤", and a pattern writes "⇥" b:stmts "⇤" with no new vocabulary at all. It needs nothing in the tool; it is how the whole feature was rehearsed before any of it was built.
It was declined, and the reason is the rule itself. The premise is not a delimiter is quoted. It is that a quoted thing is foreign text you can find in the file, which is what makes quoting able to tell a mention from a declaration, and the reason a directive can never be read as the thing it declares. A synthetic marker is a string quoting text the source does not contain. The file has to pick a spelling its own language never uses, and nothing checks that it did; the day one collides, a program's own character is read as the tool's structure and there is nothing in the notation that could have caught it. That is the rule kept in letter and spent in meaning, and it cannot be taken back: once a quoted word may name a token nobody wrote, quoting has stopped being the thing that tells the two apart.
So the block is spelled the other way, outside the quotes, where Metaxis's own vocabulary lives:
@separator "\n" => ";\n" indent @syntax "if" c ":" b:block "else" ":" e:block
The cost is that the tool now owns a delimiter, and one is more than none. block means nothing a file can redefine; a language whose blocks nest some other way cannot say so, and the next such wish will arrive as a request for a second kind. Against that: the rule that made the notation worth having is intact, and it is intact because the exception was spelled as one. A bare word outside the quotes is how this notation has always said this one is Metaxis's, the same way stmts and expr and [ … ] say it. Reading it, you cannot mistake what it is.
And it is a smaller exception than it looks. The rule that reads a block still sees nothing but its own pattern: the state that makes the two tokens lives in the lexer, below the rules, where nothing composes with anything. direction.md says why that matters and why the other six entries in its table are not like this one.
What it costs
The tool has a second pass, and it is blind on purpose. Everything else here is one pass: the body is read once and each rule's output goes up to the rule above it. A collection (REFERENCE §8.4) cannot be, because the rule that splices the aggregate usually runs before the rules that contribute to it: a program's head is expanded before its body. So splice leaves a placeholder and a pass over the finished output replaces it. What that pass may know was settled before it was written: nothing. It replaces marks and reads nothing between them, and every line of the aggregate after the first is padded with the whitespace the mark had in front of it, which is a fact about the text and not about the language. The day this pass needs to know where in C a declaration may go is the day the mechanism is wrong, and that sentence is written down so that it can be checked.
The store compromises rule locality, and by exactly as much as a collection does. Until 2026-09-07 a rule's output depended on its own holes and on nothing that had run before it, which is what let a @used file be read without reading the file that uses it. remember and recall (REFERENCE §8.5) end that: a rule may now emit differently because of a rule that fired earlier in the body. The bill has two lines. A file that recalls a key is only right if something remembered it first, so the order of the source now matters to the output in a way the notation cannot show at the rule, only at the key. And two files that spell one key interfere without either being told, since a write is a body event and nothing in the header can be asked to refuse it; the defence is the one collections have, a prefix in the key that says whose it is, and it is a convention rather than a check. What was bought is the half of context that flows down, which every row of direction.md's table of failures wanted, with the tool still knowing nothing about types, scopes or macros. The day a .mx file cannot say which of its rules wrote a key is the day this wants a declaration, and that sentence is written down so that it can be checked.
read(path) makes the output depend on a file the .mx did not declare. Until 2026-09-07 a .mx file and its @used files were the whole input: the same file gave the same output on any machine. read (REFERENCE §8.3) ends that for a file that calls it, since the text it gives is whatever stands beside the source when it runs, and nothing in the header says so. It is bought for #include "file", which cannot be a preprocessor without it, and it is kept as small as the thing it buys: a path beside the source or absolute, no search path and no environment variable, the same rule @use has, and the text unexpanded until a template hands it to expand. What it does not buy is a way to write a file, or to read one whose name the header does not spell in a string; a rule that computes the path from the body can, and the file that does that has given up saying what it reads.
Output parenthesisation is the author's problem, in a string template. Metaxis knows the input grammar because the file declared it, and a string template can splice and nothing else. So examples/pascal.mx writes "({a} + {b})" on every arithmetic rule, and examples/pascal.out is checked in with the parenthesis noise that produces:
if (((((i % mod) == 0)) && ((i != 9)))) total = (total + i) else …
A code template does not pay it. examples/code.mx is the same file with => { emit group(a, 60) + " + " + group(b, 61) } in place of the string, and group(h, n) asks an operand what level it was parsed at and brackets it only where it must. Both outputs are recorded, and the diff between them is why the second form exists. The cost is real and it is now a choice: it is what a string template costs, and the price of a string template is that it is four times shorter.
A template can name its own temporary and nothing else. {~t}, and fresh("t") in a code template, closed the half of hygiene where a template introduces a name. The half where it reaches out for one stays open, and is not an unimplemented feature: it is the price. examples/hygiene.mx declares a bump whose template means the file-scope total that was in scope where the rule was written; a caller that shadows total gets the shadow updated and the real one left alone, and both numbers are wrong and neither is an error:
swap: 2 1 the template's own name, twice over -- {~t}
again: 4 3 and the second call site did not get the first's
bump: 105 0 would be bump: 100 5There is nothing for a fresh name to invent here. What is wanted is a way to say the outer one, and neither kind of template can see a scope, let alone reach past a caller's. The code template was built and this did not move, which was predicted in the roadmap before it was written and is the one prediction there that held. Proto can, because its expander works on trees in a language whose scopes it knows; closing it here would mean Metaxis learning the output language's binding rules, which is the one thing being agnostic gave up. tests/hygiene.sh compiles that output and runs it, so the line stays a number.
The output separator was joined between every pair of statements, including after one that ended in a word, so examples/hygiene.out used to carry a }; at file scope where C wants none. This is settled: a rule may be declared terminated, and then nothing is joined after it. A code template can read the same flag back off a hole with terminated(h), which is what lets one rule punctuate another rule's output without either of them knowing the other, and for i, x in h with at(g, i) walks two of a group's holes together, which is what a repeated group with more than one hole in it otherwise cannot say.
What settled it is worth more than the fix. The obvious cheap answer, look at the last character emitted and skip the separator after a }, would have been wrong, and wrong in both directions at once. examples/clike.mx reads C's braces and emits Solveig, where a . is wanted between two statements however the one before ended; examples/groups.mx reads the same braces and emits JavaScript, where it is not; and C's own struct { … }; wants the semicolon after the brace. So the input rule and the output rule are about two different languages and had to be two rules. Guessing is what this tool declines to do about precedence and about scopes, and it declines here for the same reason.
A literal is moved, not understood, again in a string template. examples/pascal.mx emits puts('it''s middling') into C, because a string hole splices the source text it matched and a splice is all there is. examples/code.mx writes replace(drop(x, 1, 1), "''", "'") and gets puts("it's middling"). Both are recorded, for the same reason as the parentheses above.
Verbosity, on the common case. @infix + 60 add. becomes @syntax a "+" b 60 => "{a}:add({b})": half again as long, on the line a dialect writes forty times. Sugar should be derived from @syntax rather than primitive, so the general form stays the thing that is true. None is implemented.
The lexer cannot run alone. It needs the header, so a file cannot be tokenised for an editor, a highlighter or an error message before its @use chain has been resolved. Proto can. This is the real price of declared tokens.
An undeclared character has no good error. In Proto ; is a comment and says so. Here it is nothing until declared, and nothing here is anything this file declared is all that is left to say.
Longest match is now the file's business. Declaring "<" and "<<" is fine; declaring "<" and then @use-ing a file that adds "<<" re-lexes every a < <b that was already written. Reading the whole header before the body contains this, and does not remove it.
Collision between two used files is a separate problem, and quoting does not touch it. Quoting settles directive against declaration. It says nothing about two declarations of "+", which is the other problem and a different one, and it was answered separately: unmarked, the second is refused naming both lines; marked override, it wins and nothing is said. REFERENCE.md §3.10.
That answer cost a word, and the word could only go in one place. Everywhere left of the => a bare word is a hole, which is the notation's whole premise, and a rule whose first hole is called override is a legal rule. So the one place a bare word cannot be mistaken for a hole is after the template, where terminated already lives, and that is where it went. The premise decided the syntax rather than being bent around it, which is the case it is supposed to earn its keep on; the cost is that a word about the declaration reads at the far end of the line from the declaration it is about.
And on @fragment the same premise put the same word at the opposite end. A fragment's pattern runs to the end of the directive, so there is no after: a trailing override would sit exactly where the notation says a bare word is a hole, and would be silently read as one. So it goes before the =:
@fragment params override = "(" [ p:name ":" "integer" ]* sep ";" ")"
Which is worth recording as a cost rather than a curiosity. One word now appears at two ends of two directives, and neither position was chosen: both were forced by the same rule, applied to two shapes. A notation that derives its syntax from one premise gets consistency where the shapes agree and this where they do not, and the honest description is that override goes wherever a bare word cannot be a hole, which is a rule about the reader's parse and not a place on the line.
Not done
Source maps. The output has no way back to the line that produced it, so an error in a downstream compiler points into text nobody wrote. Proto emits a .map beside its output.
A rule's own words winning inside its brackets. Proto has one place where a context outranks a declaration: #[k = v], where the pair separator shadows a declared = at the top level of a key. Here a bracketed rule's interior words are ordinary pattern elements, so the case does not arise in the same shape; but nothing has been written that tests it, and a list kind with a declared separator is the shape that would.
Expression-mode backtracking has no budget. Candidates are retried with the cursor restored and only a recursion depth of 400 stops it; a pathological header would be slow and nothing measures it. Text mode is no longer in that position: its matcher became a search on 2026-09-04 and was given a budget of 200000 attempts per rule at the same time, and it is measured: 113KB of markdown, 2000 lines, 60ms, with the ** and [[…]] rules of examples/poem.mx. The expression side has had no such measurement and no such budget. See ROADMAP.md.
A line that continues inside brackets is not read. f(a, newline b) is a parse error under a newline separator until 2026-09-07: Python's lexer suppresses the newline between an opening bracket and its match and this one did not. It wanted a bracket depth beside the indent stack, and the thing that made it more than a copy of that work is that the lexer cannot know what a bracket is: everything else it knows came out of a directive, and no directive said these two words nest. @bracket is that directive now, one declaration read by both modes, and the depth is COMPLETED.md's The wrapped line.
left is accepted and does nothing, since left is the default. It stays because writing it is sometimes clearer than leaving it out. (The other half of this entry, that a level on a nud rule sets its trailing hole's binding power and was documented nowhere but here, stopped being true when REFERENCE.md §5 was written.)
Metaxis