Aether is Ichor's own grammar language. This page documents every feature in detail. If you're new to Aether, read the tutorial first — this page is a reference, not an introduction.
File structure
Every grammar starts with two required pragmas, in order:
@grammar "name"
@root rule_name@grammar names the grammar (informational). @root names the rule
where matching begins. After those, zero or more of the optional
pragmas below may follow, in any order, each at most once. Then the
grammar's token and rule definitions follow, in any order — a
definition may reference a name declared later in the file.
Comments run from ; to the end of the line.
Tokens vs. rules
This is the one syntactic distinction Aether's entire design hinges on:
- A name in
ALL_CAPS(matching[A-Z_][A-Z0-9_]*) declares a token. Tokens are matched by the lexer, directly against raw characters. - A name in
snake-caseorkebab-case(matching[a-z][a-z0-9_-]*) declares a rule. Rules are matched by the parser, over the token stream the lexer already produced — never over raw characters directly.
Every Aether grammar compiles to a genuine two-stage Lexer -> Parser, never a single scannerless pass. This has real consequences:
- A rule body may only reference tokens and other rules — never an
inline character class,
/pattern/regex literal, or bare.("any character"). Those are lexer-only constructs; give the pattern a name as a token instead, and reference that token from the rule. - A token body may reference other tokens, but never a rule.
- Writing a bare string literal (
"SELECT") directly inside a rule body is allowed as a convenience — Aether automatically promotes it to a compiler-generated anonymous token behind the scenes, since only tokens ever participate in lexing. The same literal text (with the same case-sensitivity) reuses the same anonymous token wherever it appears. - A bare string literal directly inside a token body is just an ordinary sub-match — no promotion needed, since it's already inside a token.
Maximal munch
At every position, the lexer tries every token the grammar's rules can
actually reach (directly referenced by some rule, or the active @skip
token) and picks the longest match. Ties are broken by declaration
order — the token declared first in the file wins. A token that can
match zero characters is never accepted as "the next token" (it would
never advance, and tokenizing would never finish); such a token can
still exist and be referenced from inside another token's own
definition, it just can never win the top-level race on its own.
This is why declaration order sometimes matters even between tokens that look unrelated: if two tokens can match the same text at the same position with the same length, whichever is declared first in the file wins.
Rule parsing: ordered PEG choice
Once tokenized, rules are matched using ordinary PEG semantics: within
a first | second | third, the first alternative that matches wins,
even if a later alternative could also have matched (possibly matching
more, or less). There's no most-specific-wins or longest-match rule at
the parser level the way there is at the lexer level — order in the
source is the only thing that decides among rule alternatives, so put
more specific alternatives first.
Expression syntax
From lowest to highest precedence:
choice := sequence ("|" sequence)*
sequence := term+
term := ("~")? (name ":")? postfix
postfix := ("&" | "!")? primary ("*" | "+" | "?" | "{" bound "}")?
primary := STRING | CHAR_CLASS | REGEX | "." | UPPER_IDENT | lower_ident
| "(" choice ")" | "@indent(" choice ")" | "@samecol(" choice ")"| Operator | Meaning |
|---|---|
a b | sequence -- a then b |
| <code>a | b</code> | ordered choice -- a, or (if a fails) b |
a* | zero or more, greedy |
a+ | one or more, greedy |
a? | zero or one |
a{3} | exactly 3 |
a{3,} | 3 or more |
a{3,7} | between 3 and 7 |
&a | positive lookahead -- consumes nothing |
!a | negative lookahead -- consumes nothing |
name:a | named capture |
~a | suppress @skip splicing before this term (rule bodies only) |
( a ) | grouping |
. | any single character (tokens only) |
Quantifiers, predicates, and grouping all work identically in both token and rule bodies (subject to the token/rule leaf restrictions above).
Whitespace: @skip / @noskip / ~
By default (no @skip/@noskip pragma given at all), every rule's
sequence elements — after the first — get an automatic SPACE* spliced
in front of them, where SPACE is the built-in predefined token
(overridable). This is what lets expr := term "+" term tolerate
"1 + 2" and "1+2" alike without writing whitespace tolerance into
every single rule by hand.
@skip TOKENuses a different token in place ofSPACE— useful when comments should also be treated as skippable trivia (a common pattern: declare aTRIVIA := (SPACE | COMMENT)*token and@skip TRIVIA).@noskipdisables splicing entirely. Required for any grammar where whitespace is meaningful syntax, not filler — HTTP headers, regex patterns, Markdown.~name(only valid in rule bodies, only directly prefixing a bare token or rule reference with no capture and no quantifier) suppresses splicing before that one specific term, for the rare case where two adjacent things must not have skippable trivia between them even though the rest of the rule does.
Splicing also applies to the repeated body of */+/{n,m} — form*
tolerates whitespace between repetitions, not just around the whole
group, since each repetition is itself another sequence element.
Predefined tokens
Five tokens exist in every grammar even if never declared:
| Token | Default definition |
|---|---|
DIGIT | [0-9] |
ALPHA | [a-zA-Z] |
ALNUM | DIGIT | ALPHA |
SPACE | [ \t\r\n] |
HEX | [a-fA-F0-9] |
Any of the five may be redeclared with NAME := ..., as long as the
redeclaration happens before the token is used anywhere in the file
(including implicitly, e.g. @skip's own default use of SPACE, or a
POSIX bracket class referencing one of the other four). Using one first
and then trying to redeclare it is a compile error — the two orderings
would otherwise mean different things depending on where in the file
the use occurred, which Aether rejects outright rather than resolving
by File order.
POSIX bracket classes
Inside a character class ([...]), [:alpha:], [:alnum:],
[:digit:], [:space:], and [:hex:] reference the identically-named
predefined token, and can be freely mixed with ordinary ranges and
characters in the same brackets:
IDENT := [[:alpha:]_][[:alnum:]_]*Character classes
[...] matches one character from the given set; [^...] negates it
(matches one character not in the set — but still requires a
character to be present; it never matches at end of input). Contents
are a mix of single characters, a-z-style ranges, POSIX names, and
escape sequences. A - immediately before the closing ] is a literal
dash, not a range operator.
String literals
Double-quoted, with these escapes: \n \r \t \\ \" \[ \]
\/, \xHH (exactly two hex digits), \u{H+} (one or more hex
digits), and any other punctuation character escapes to itself (\^,
\-, \*, and so on) — convenient for writing character-class-like
escapes consistently in both contexts.
A quote suffix controls case sensitivity for that one literal,
overriding the grammar-level @case_insensitive setting either way:
"lit"— case-sensitive, unless@case_insensitiveis set for the whole grammar (then case-insensitive)."lit"i— always case-insensitive, regardless of the grammar-level setting."lit"cs— always case-sensitive, regardless of the grammar-level setting.
@case_insensitive
A grammar-wide pragma: every bare ("lit", no suffix) string literal
matches either case. Doesn't affect character classes or [:posix:]
classes — only quoted string literals.
Regex literals
Token bodies only (never valid in a rule body) — /pattern/ is a
convenience shorthand that desugars entirely into ordinary Aether
primitives before reaching Grammar.IR. It is not a real regex
engine wired in underneath; support is deliberately partial:
Supported: literal characters, . (any character), character classes
([...]/[^...], POSIX names included), alternation (|), grouping
((...)), quantifiers (* + ? {n} {n,} {n,m}), positive/
negative lookahead ((?=...) / (?!...)), and the shorthand classes
\d \w \s \h (referencing DIGIT/ALNUM/SPACE/HEX) with
their negated forms \D \W \S \H.
Explicitly rejected, with a normal Ichor.Error rather than silently
doing the wrong thing: anchors (^ $), backreferences (\1-\9),
and named/unnamed capturing groups ((?<name>...)) — none of these have
a meaningful desugaring into PEG-style matching primitives, so Aether
refuses them outright instead of pretending to support them.
NUMBER := /\d+(\.\d+)?/Indentation sensitivity
Two pragmas, valid only inside rule bodies, for layout-sensitive grammars (YAML-style structure) without requiring a scannerless parser to special-case columns everywhere:
@indent(expr)—exprmust start at a column strictly greater than the currently active reference column, and its own starting column becomes the new reference column for anything nested inside it.@samecol(expr)—exprmust start at exactly the currently active reference column (no descending further).
Both also accept a single bare term with no parens as sugar for
wrapping just that one term: @samecol pair is shorthand for
@samecol(pair).
@samecol always checks against the nearest enclosing @indent's
reference column — with no enclosing @indent anywhere, the reference
column defaults to 0, so @samecol at the very top of a grammar only
succeeds on something starting in the first column. In practice
@samecol is used nested inside an @indent, as below, not standalone.
mapping := pair (NEWLINE @samecol pair)*
pair := SCALAR COLON (inline_value | NEWLINE @indent(block_value))Named captures
name:expr captures expr's matched value under name, available to
an Ichor.Actions module as captures.name (as a
%{name => Ichor.Capture.t()} — see the tutorial for
how Actions modules consume these). A bare reference to a named
token or rule (no explicit name: prefix) is implicitly captured under
its own name automatically — factor in expr := term (op:("+" | "-") term)* needs no term:term, it already shows up as :term. A bare
reference to a compiler-generated anonymous token (an inline literal
like "(", never a name the grammar author chose) gets no implicit
capture — writing a bare literal is precisely how a grammar author says
"I don't care about matching this," and an explicit paren:"(" still
works if that literal's value actually is wanted.
A capture whose expression sits under a */+/{n,m} anywhere in
the rule always arrives as a list in Ichor.Actions, even if it
matched zero or exactly one time — never a bare value, and never a
missing key, so pattern-matching on capture shape is reliable regardless
of how many times something actually matched.
@engine
A grammar-wide pragma picking the parsing algorithm for @root:
@engine peg ; the default -- ordered-choice recursive descent
@engine lr ; deterministic shift-reduce, requires a conflict-free grammar
@engine glr ; Tomita-style GLR, forks at genuine ambiguityIndependent of which backend runs the grammar (Grammar.VM or
Grammar.Native, i.e. use Ichor) — both support all three engines.
peg (the default, so every grammar written before @engine existed
keeps behaving identically) is ordinary top-down recursive descent: the
first matching alternative wins, and left recursion is rewritten away by
Grammar.Analysis automatically. lr/glr are bottom-up shift-reduce
parsers built from a from-scratch SLR(1) table instead — the natural fit
for left-recursive rules (expr := expr "+" term | term, not the
PEG-idiomatic term ("+" term)*), which PEG can't parse at all but
bottom-up parsing handles natively. Grammar.Analysis skips its
left-recursion rewrite entirely for lr/glr grammars, since it would
undo the very shape they want.
lrrequires the resulting table be conflict-free — an ambiguous or genuinely context-free (non-regular-per-rule) grammar is rejected at compile/analysis time, naming the conflicting state and productions, rather than silently picking one arbitrarily.glraccepts conflicts and forks at them, exploring every viable parse in parallel via a graph-structured stack that shares/merges history at points where forks reconverge — this is what recovers inputs plain PEG's greedy, never-reconsidered commit can't (arule := "ab" | "a"alternative that "wins" too early, with nothing left over for what follows), and what lets a grammar express genuine ambiguity (natural-language-style, or the classic dangling-elseproblem) instead of being forced to silently favor one reading. When more than one derivation survives to the end of input, the earliest point the derivations disagree decides the winner, by declared order — same convention ordinary PEG choice already uses (whichever alternative/production was written first in the source wins), with one refinement: at a conflict cell offering both a shift and a reduce, the shift always wins (matching yacc/bison's own default), which is what makes the dangling-elsecase resolve the way every real language resolves it (elsebinds to the nearest openif) with no special-casing anywhere.
Only Seq/Choice/Star/Plus/Opt/Rep/RuleRef/Capture are
supported inside an lr/glr rule — &/! lookahead predicates,
@indent/@samecol, and rule-position @native(...) all need
arbitrary backtracking or side-channel state a shift-reduce table has no
room for, and are rejected with a build-time error naming the rule and
construct. Token-level lexing (including token-position
@native(...)) is unaffected either way — lr/glr still parse the
same Tokenizer/Lexer-produced token stream a peg grammar would.
@grammar "calculator"
@root expr
@engine lr
NUMBER := /\d+(\.\d+)?/
PLUS := "+"
expr := expr PLUS term | term
term := NUMBERSee Grammar.LR/Grammar.GLR (interpreted) and Grammar.Native.LR/
Grammar.Native.GLR (compiled) for the engines themselves, and
Grammar.LRTable for the shared SLR(1) table construction both draw on.
@keywords / @refine
Reclassify a token after the Tokenizer matches it, before the Parser
ever sees it — for a lexical distinction maximal munch alone can't make,
because it depends on the token's own text (a keyword vs. an ordinary
identifier with the same shape) or on what came immediately before it
(JavaScript's /, which starts either a regex literal or a division
operator depending on the preceding token).
@keywords is sugar for the common table-lookup case:
WORD := [a-zA-Z_][a-zA-Z0-9_]*
@keywords WORD { "if" -> KEYWORD_IF, "return" -> KEYWORD_RETURN }Every WORD token whose exact text matches a key in the table is
renamed to the corresponding target token (KEYWORD_IF, KEYWORD_RETURN);
anything else stays WORD. The target names never need their own
TOKEN := ... declaration — @keywords is what tells Grammar.Analysis
and the lexable-candidate-set computation they exist.
@refine("Module", "function", ...) is the escape hatch for anything
needing real logic instead of a fixed table — attached directly to a
token's own definition:
SLASH := "/" @refine("JS.SlashDisambiguator", "refine", DIV, REGEX_START)The callback (Ichor.TokenRefiner.refine/4: raw_name, raw_text,
pos :: {line, column}, and preceding — every token already
reclassified so far, in final form, which is what makes the
lookbehind-dependent JS case work) returns {:ok, new_name, value} (the
new token name plus a capture-value override, e.g. a decoded escape
sequence reaching Ichor.Actions instead of raw source text) or
{:error, reason}, surfaced as an Ichor.Error with stage: :lexer.
@native / @hint: the escape hatch
For the rare case a grammar's own meaning is mutated mid-file by
something declared earlier — Prolog's runtime-extensible operator table
(op/3), Haskell fixity declarations, a heredoc whose terminator is
read off the source itself, a string literal with embedded interpolated
expressions — no static grammar (PEG, LR, or GLR) can express the rule
or token directly. @native("Module", "function", dep, ...) hands that
one rule or token to hand-written Elixir instead.
At rule position (Grammar.IR.Custom)
expr := @native("Prolog.Grammar", "parse_term", primary) @hint(nullable: false, leading: (primary))deps (primary above) lists the only other rules the callback is
allowed to call back into, via a rule_matchers map restricted to
exactly those names — kept explicit so Grammar.Analysis's reference
check still sees this node's real dependencies even though its own body
is opaque Elixir code. The callback (matching Ichor.CustomRule.match/4,
though it doesn't have to be named match — @native's own second
argument names it explicitly) is:
@callback match(stream, pos, context, rule_matchers) ::
{:ok, new_pos, Ichor.Capture.node_t()} | :failstream/pos— the same compiled token stream and position any ordinary rule matcher works over.context— read-only: whatever the previous top-level form's own evaluation produced (viaGrammar.VM.run_sequence/4/Grammar.Native's generatedrun_sequence/2), orinitial_contextoutside a sequence. This is how a:- op(700, xfx, is).directive parsed earlier in the same file can extend the operator table a later clause's own@nativecallback parses against.rule_matchers—%{primary: (stream, pos -> {:ok, new_pos, node} | :fail)}for the example above: call straight back into the grammar's own compiledprimaryrule.
@hint(nullable: bool, leading: (dep, ...)) supplies the two facts
Grammar.Analysis would otherwise compute by walking the rule's own
structure, impossible here since the body is arbitrary code: nullable
defaults to false (most custom recognizers consume something),
leading defaults to every declared dependency (conservative, for
left-recursion-cycle detection). Ichor.Toolkit.Pratt is built exactly
for writing this kind of callback — precedence-climbing over a
runtime-mutable operator table is the single most common reason to reach
for rule-position @native at all.
At token position (Grammar.IR.CustomLexeme)
STRING := @native("JS.StringInterp", "scan", expr) @hint(nullable: false)Always stands alone as an entire token definition — it can't be
referenced from inside another token's body, and can't be composed
inside a larger token expression. deps here names rules (never other
tokens — the one narrow exception to "a token body may only reference
other tokens"), reachable through a re-lex-from-a-character-position
primitive rather than the token-stream-based rule_matchers rule
position gets:
@callback scan(input, context, rule_matchers) ::
{:ok, text, rest, Ichor.Capture.node_t() | nil} | :failinput— the remaining suffix of the source text (not a position into some original string).context— same meaning asIchor.CustomRule.match/4's.rule_matchers—%{expr: (input -> {:ok, text, rest, capture} | :fail)}for the example above: re-lexesinputfrom scratch and matches the named rule, for a token that needs to recurse into full rule-level parsing mid-scan (an embedded#{expr}inside a string literal) — something a fixed maximal-munch tokenizer has no way to express on its own.
Returns {:ok, text, rest, capture} with text <> rest == input.
capture is normally nil (this token behaves like any other — a plain
{:token, name, text} downstream); a string-interpolation token
overrides it with an explicit Ichor.Capture.node_t() instead, so its
embedded expressions' own parsed structure reaches Ichor.Actions
intact rather than collapsing into flat text. @hint(nullable: bool) is
the only hint here (false by default) — there's no leading, since
left-recursion-cycle detection is a rule-level concept only.