← Playground Grammar
raw

Grammar (spec/grammar.ebnf)

(* NURL — Neural Unified Representation Language
   Grammar v2.7 — complete language specification
   File extension: .nu

   All expressions use PREFIX NOTATION.
   Every operator has fixed, known arity — no grouping parentheses needed.
   The parser is LL(k≤4) — recursive-descent with up to 4 tokens of
   lookahead (peek/peek2/peek3/peek4 in the lexer). It is NOT LL(1):
   generic-header disambiguation in scan_fn_sigs, foreach detection
   (`~ IDENT IDENT …`), and `\`-closure / lambda disambiguation each
   require 2- to 4-token lookahead to commit to a production.
   Tokens are separated by whitespace; there are no comma separators
   anywhere in the language (params, fields, args, enum variants all use
   whitespace).

   See per-section comments below and the v0.X / v1.X snapshot
   files in this directory for historical snapshots of the grammar. *)


(* ── PROGRAM ──────────────────────────────────────────────────── *)

program = decl* EOF ;


(* ── TOP-LEVEL DECLARATIONS ───────────────────────────────────── *)

(* Visibility prefix. Any top-level decl other than import_decl may
   carry a leading `pub`.

   The lexer recognises the bare identifier `pub` as a reserved
   token; `pub` cannot be used as a variable name.

   Per-file strict-vis mode is OPT-IN: a source file enters strict
   mode the first time any of its top-level decls carries the `pub`
   prefix. In strict mode, every UNMARKED @-function is private to
   that source file; calls from a different file are rejected with:

       file:line:col: private function 'X' is not visible across
       files; defined in 'Y'

   Files that contain no `pub` decls at all remain in legacy mode —
   every top-level @-function stays globally callable.

   Visibility is currently only enforced for @-defined function
   calls (gen_call). Trait/impl methods, FFI symbols, runtime
   helpers, generic mangled call_names, and types remain globally
   addressable; the `pub` parse-prefix is accepted on every decl
   kind for forward-compat.

   `import_decl` is intentionally excluded, and writing `pub` on one is
   a diagnostic rather than a silent no-op: an import defines no symbol
   of its own, and a file enters strict-vis at its FIRST `pub`, so a
   `pub` accepted and discarded there left the author believing the file
   was strict when it was not. `simd` and `inline` on a non-`@`
   declaration are rejected for the same reason. *)

decl = import_decl                 (*  $                             *)
     | ('pub')? ffi_decl           (*  &                             *)
     | ('pub')? trait_decl         (*  %  IDENT  {                   *)
     | ('pub')? impl_decl          (*  %  IDENT  type                *)
     | decl_prefix* fn_decl        (*  @  (only kind enforced in v2.0)*)
     | ('pub')? struct_decl        (*  :  IDENT   {                  *)
     | ('pub')? enum_decl          (*  :  |                          *)
     | ('pub')? const_decl         (*  :  type                       *)
     ;

(* A function declaration may carry any of these prefixes, in any order
   (`simd` grammar v2.6, `inline` v2.7). `simd` and `inline` are accepted
   ONLY on `@` — on any other decl either is a diagnostic, not a silent
   no-op, because a prefix left pending drifts onto the next `@` the
   parser reaches, which after an import boundary is a function in a
   different file. `pub` had exactly that bug before v2.0 made it
   read-and-clear.

   `inline` puts LLVM's `alwaysinline` on the definition. It is rejected
   on a generic (the prefix does not survive monomorphisation) and in
   combination with `simd` (which replaces the function with a
   CPU-dispatching stub — the opaque call `inline` asks to remove). *)
decl_prefix = 'pub' | 'simd' | 'inline' ;


(* $ `path` IDENT?
   Inline-compiles the .nu file at `path` into the current module. The
   path is resolved relative to the compiler's current working directory,
   NOT relative to the importing file. A path without a `.nu` extension
   is first tried as written, then with `.nu` appended — so
   `stdlib/core/string` and `stdlib/core/string.nu` name the same file.
   The same `path` is imported at most once per compilation
   (duplicate-include guard).

   Without alias:
     All top-level names from the imported file land in the global
     namespace unchanged.
     Example:  $ `stdlib/core/string`

   With alias:
     Every top-level @-function defined in the imported file is renamed
     to `alias__name` by a pre-tokenisation source rewrite; internal
     cross-calls inside the imported file are rewritten to match. The
     renamed functions are reached from the importer via `alias::name`,
     which the lexer fuses into the same single IDENT `alias__name`.
     FFI declarations (`& STR @ name …`), trait / impl methods and
     struct / enum / const names are NOT renamed in this iteration —
     they remain in the global namespace.
     Example:  $ `stdlib/core/mem` m
               ( m::alloc 16 )       — call alloc from the `m` module
     Nested aliased imports compose: an alias applied here is in effect
     only for names defined directly in `path`; any further `$` declared
     inside `path` is handled by its own alias (or lack thereof). *)
   The alias is a plain identifier. A type keyword (`i`, `u`, `f`, …),
   a bool literal, `Z`, or a prefix keyword in that slot is not an
   alias and is reported where it lands, not silently taken as one. *)
import_decl = '$' STR IDENT? ;


(* & `libname` @ name ffi_param* ('...')? → type
   Declares an external C symbol (LLVM declare).
   FFI parameters may omit the identifier (the name is cosmetic at
   call sites — only the type matters for the IR declare).
   The optional trailing literal `...` token marks the C function as
   variadic. The number of named params before the `...` is the
   "fixed" prefix; every argument passed beyond that count at a call
   site undergoes C default argument promotion (see Changes since
   v1.8 in the prelude). TRAILING is part of the rule and so is "at
   most one": a parameter after the marker, or a second marker, emitted
   `declare i64 @f(i8*, ..., i64)` / `(i8*, ..., ...)` — IR llvm-as
   rejects — and a LEADING marker survived in the call-site signature
   while being overwritten in the `declare`, so the module declared a
   non-variadic callee and emitted a variadic call to it.
   The fixed prefix is a MINIMUM at every call site. '...' makes the
   tail optional, never the parameters ahead of it; a call passing fewer
   than the fixed count emits `call i64 (i8*, ...) @f()`, which the LLVM
   verifier rejects, and where such a call assembles the callee reads an
   ABI register nobody set.
   The library name may not be empty. It is what checks, at compile
   time, that the library is installed — an empty one skipped that
   check rather than passing it.
   Example:  & `libc` @ puts s msg → i
   Example:  & `libm` @ sqrt f    → f                     — no param name
   Example:  & `libc` @ printf s fmt ... → i32             — variadic *)
ffi_decl   = '&' STR '@' IDENT ffi_param* ( '...' )? '→' type ;
ffi_param  = type IDENT? ;


(* [T], [K V] — type variable list for generic declarations.
   Declaration-site list of single-letter (or multi-letter) type names.
   The type variable 'T' lexes as the boolean literal token and is
   disambiguated by context (in a [...] list and in parameter position).

   A type variable may carry one or more TRAIT BOUNDS via `: Trait`,
   e.g. `[A: Ord]` or `[K: Hash V]` or `[A: Ord: Show]`. A bound is
   checked at every instantiation: the concrete type substituted for the
   variable must have an `impl` of the named trait, else a compile error
   names the unsatisfied bound. (Method dispatch inside the body already
   resolves to the concrete impl through monomorphisation; the bound adds
   the up-front guarantee + documentation.) Disambiguation from a slice
   parameter relies on the colon — a slice param type never contains one.
   Example:  @ my_max [A: Ord] A x A y → A { … }  — A must implement Ord *)
type_params = '[' ( IDENT ( ':' IDENT )* )+ ']' ;


(* @ name [T]? param* → ret { body }
   Function parameters always bind an identifier. A parameter may
   carry an optional passing-convention marker:
     in     — immutable borrow, by value (the default; may be omitted)
     inout  — exclusive mutable borrow: the callee mutates the
              caller's binding in place. Lowers to a `<T>*` parameter;
              the argument must be a mutable (`: ~`) binding and is
              passed by address. An `inout` function must be defined
              before it is called.
     sink   — move/consume (reserved; not yet implemented)
   `in` / `inout` / `sink` are contextual keywords: recognised only as
   a parameter's leading token. `inout` is additionally banned as a
   parameter name.
   A parameter name may not be `r` followed only by digits (`r0`, `r12`).
   A by-value parameter keeps its source name as its LLVM SSA register and
   the code generator's temporaries are `%r0`, `%r1`, … in the same local
   namespace, so the two would collide. Locals carry no such rule: the
   generator names them. Closure parameter lists follow the same rule.
   Example:  @ add i a i b → i { ^ + a b }
   Example:  @ id [T] T x → T { ^ x }          — generic
   Example:  @ bump inout Counter c → v { = . c n + . c n 1 } *)
fn_decl   = '@' IDENT type_params? param* '→' type block ;
param     = param_conv? type IDENT ( '=' atom )? ;
param_conv = 'in' | 'inout' | 'sink' ;

(* Default parameter value (keyword args). `= atom` gives a parameter a
   default; `atom` is a single token (literal / const name / T / F).
   Defaults are filled at the CALL SITE (the callee receives a full
   argument list), so a function with a default is an ordinary
   fixed-arity function.

   A POSITIONAL call fills defaults from the end and stops at the first
   omitted parameter that has none, so a default is usable positionally
   only when every parameter after it also has one. A NAMED call
   (`( f b: 5 )`) can omit any defaulted parameter, wherever it sits.

   A default is an ARGUMENT, and carries an argument's obligations: its
   type must be the parameter's, checked at every call that omits it
   (no implicit integer↔float conversion, no pointer-vs-scalar
   reinterpretation) — the same battery a written argument goes through.

   Not available on generic functions, FFI / variadic decls, or
   parameters carrying the `inout` / `sink` convention. All four are
   diagnostics at the declaration: `inout` is passed by ADDRESS and a
   default is a value with no address, and `sink` consumes its argument,
   which a value spliced into every call cannot give more than once. *)


(* : Name type_params? { field* }
   Fields are written as  type IDENT?  (the name is optional when only
   the type matters — rare, but supported).
   Disambiguation from const_decl: IDENT after ':' (or ':' '~') then '{'.

   Generic form: an optional `[T+]` type-parameter list immediately
   after the struct name declares a template. The template body is NOT
   emitted as IR at declaration time; each distinct type-argument list
   used in a `( Name T1 T2 ... )` instantiation (type_paren) is
   monomorphised to a named LLVM type `%Name__T1[__T2...]` by a
   pre-scan pass before any function body is emitted.

   Example:  : Point { i x  i y }                 — concrete
   Example:  : String { s sb }                    — concrete
   Example:  : Vec [T] { *T data  i len  i cap }  — generic 1-param
   Example:  : Pair [A B] { A first  B second }   — generic 2-param *)
struct_decl = ':' IDENT type_params? '{' field* '}' ;
field       = type IDENT? ;


(* : | Name { Variant payload* ... }
   Each variant compiles to an i64 tag global named by the variant.
   Enum values are represented as  { i64, ptr, ptr, ... }  sized for the
   variant with the most payloads. Multiple payload types per variant
   are supported; a payload is "anything that looks like a type" (type
   keyword, sigil-prefixed type, or known struct name) encountered
   before the next variant name.
   Example:  : | Color { Red  Green  Blue }
   Example:  : | Event { Click i i  KeyPress i s  Close }
   Example:  : | Json { JNull  JBool b  JNum i } *)
enum_decl    = ':' '|' IDENT '{' enum_variant* '}' ;
enum_variant = IDENT type* ;


(* : ~? type IDENT literal
   Global variable with a compile-time literal initializer.
   Optional ~ prefix makes it mutable (default immutable).
   Supported types: i, u (i64), f (double), s (i8*), b (i1).
   Mutable globals can be updated:  = Name expr.
   Example:  : i MAX_CONN 100       — immutable global constant
   Example:  : ~ i counter 0        — mutable global variable
   Example:  : s GREETING `hello`   — immutable string
   Example:  : ~ b debug_mode F     — mutable boolean flag

   An INTEGER-typed const (i / u / sized ints, not b) may instead take a
   compile-time-foldable prefix expression over integer literals — the
   operators '+ - * / << >> & | ^^' (NOT '%', which collides with the
   trait/impl decl sigil). Folded to a single value by const_eval_int.
   Example:  : i SECS_PER_DAY * * 60 60 24            — 86400
   Example:  : i INT_MIN - -9223372036854775807 1     — two's-complement min
   Example:  : i PAGE << 1 12                         — 4096 *)
const_decl  = ':' '~'? type IDENT const_value ;
const_value = literal | const_int_expr ;
const_int_expr = INT
              | ( '+' | '-' | '*' | '/' | '<<' | '>>' | '&' | '|' | '^^' )
                const_int_expr const_int_expr ;


(* % Name [T]? { ( fn_header | fn_decl )* }
   Interface definition. Produces no IR directly; registers method signatures.
   Methods may be required (fn_header — just signature) or may provide a
   DEFAULT implementation (fn_decl — header + body). An impl_decl that omits
   a method with a default gets a monomorphised copy of the default body,
   with the trait's type parameter substituted by the impl's type.
   Required methods are a compile error if an impl doesn't provide them.
   Example:  % Shape [T] {
                 @ area T obj → i                  — required
                 @ print_info T obj → i {          — default
                   ( nurl_print ( nurl_str_int ( area obj ) ) )
                   ^ ( area obj )
                 }
             }
   A trait body holds methods and associated types, and nothing else;
   any other token is a diagnostic rather than a skip. `fn_header`'s
   arrow and return type are required at the DECLARATION, like every
   other function header's — a header without them records a signature
   nothing can dispatch through, and used to be reported (when it was
   reported at all) only from the `dyn` path's re-parse of it, from a
   synthetic location, and only if the program happened to use the trait
   as an object. *)
trait_decl = '%' IDENT type_params? '{' ( fn_header | fn_decl )* '}' ;
fn_header  = '@' IDENT type_params? param* '→' type ;


(* % TraitName [T]? impl_type { fn_decl* }
   Monomorphised dispatch: each method is emitted as  method__TypeMangle.
   Dispatch at call sites is based on the first argument's LLVM type.
   Example:  % Stringify i { @ stringify i n → s { ^ ( nurl_str_int n ) } }
             ( stringify 42 )  →  call @stringify__i64

   `impl_type` is a TYPE POSITION and carries the declared-type check
   every other one does: a name that is neither a declared struct/enum,
   a generic template (`% NotSend Rc { }` covers every Rc monomorph), a
   monomorphised instantiation, nor a type parameter is a diagnostic.
   The marker traits are why it matters — `% NotSend Db { }` asserts a
   property the compiler cannot derive, and on a misspelled subject it
   asserted it about nothing while the real type kept crossing the
   boundary the marker forbade.

   The TRAIT name is deliberately NOT required to be declared: `Drop`,
   `Ord` and `Show` are implemented with no declaration, which is how
   the built-in protocols work. An impl of an undeclared trait has no
   contract to check and emits its methods under the mangled name; a
   typo is caught at the call site instead. *)
impl_decl = '%' IDENT type_params? type '{' fn_decl* '}' ;


(* ── BLOCK & STATEMENTS ───────────────────────────────────────── *)

block = '{' stmt* '}' ;

stmt = let_stmt      (*  :              *)
     | set_stmt      (*  =              *)
     | defer_stmt    (*  ;              *)
     | tilde_stmt    (*  ~              *)
     | jump_stmt     (*  break/continue *)
     | expr          (*  side-effect    *)
     ;


(* break | continue  (grammar v2.4)
   Loop control for the innermost enclosing `~` loop body: `break`
   leaves it, `continue` re-evaluates its condition. Both terminate the
   block they appear in, exactly like `^` — anything after one in the
   same block is unreachable.

   Both are RESERVED IDENTIFIERS, not symbols. Every two-character
   prefix spelling collides with an existing program: `~>` would swallow
   the loops written `~ > cond { … }`, and the sequences left free carry
   no meaning a reader could guess. The cost is two identifier names.

   Using either outside a `~` body is a compile error, not a no-op.

   Example:  ~ < i n { ? ( skip i ) { continue } {}
                       ? ( done i ) { break    } {}
                       = i + i 1 }                          *)
jump_stmt = 'break' | 'continue' ;


(* : ~? type? IDENT expr
   Optional ~ prefix makes the variable mutable (default immutable).
   Type annotation is optional when inferable from the expression.
   An IDENT that is already registered as a named type is taken as the
   type annotation; otherwise the plain-IDENT form is type-inferred.
   Example:  : i n 0            — immutable, explicit type
   Example:  : ~ i x 0          — mutable, explicit type
   Example:  : String s ( string_new )    — explicit named type
   Example:  : n ( add 1 2 )    — immutable, inferred *)
let_stmt = ':' '~'? type? IDENT expr ;


(* = IDENT expr
   = '.' expr index expr
     where index ∈ ( IDENT | INT | expr )
   Assigns to an existing binding (local or global), or to a struct field /
   array / slice / pointer element via GEP + store.
   Immutable locals, parameters and globals are rejected at compile time.
   The index form is chosen by the object type:
     - struct pointer '%T*' + IDENT field name
     - raw pointer  'T*'   + INT literal → array slot
     - raw pointer  'T*'   + expr       → array slot (variable idx)
     - slice '{ T*, i64 }' + INT | expr → slice element via data-ptr
   Example:  = n + n 1
   Example:  = . p x 42          — p.x = 42       (struct field)
   Example:  = . buf 0 val       — buf[0] = val   (pointer, literal idx)
   Example:  = . xs i  val       — xs[i] = val    (slice element) *)
set_stmt = '=' IDENT expr
         | '=' '.' expr ( IDENT | INT | expr ) expr
         ;


(* ; { body }
   Executes body when the enclosing function returns.
   Multiple defers run in LIFO order.
   Example:  ; { ( nurl_sym_pop syms ) } *)
defer_stmt = ';' block ;


(* ~ at statement position is speculatively parsed, in this order:
     1. '~' IDENT IDENT …   →  foreach_stmt           (3-token lookahead)
     2. '~' expr '{'         →  loop_stmt (while)
     3. otherwise            →  complement_expr used as a statement
   The grammar reflects this as a single tilde_stmt alternative whose
   concrete shape depends on what follows. *)
tilde_stmt   = loop_stmt | foreach_stmt | complement_expr ;

(* ~ cond { body }
   While loop: repeats body as long as cond is truthy. *)
loop_stmt    = '~' expr block ;

(* ~ IDENT expr { body }
   For-each loop: iterates over a slice, binding each element to IDENT.
   Disambiguation from while: two consecutive IDENTs after '~' → for-each.
   The iterated expression must have slice type  [T (compiles to { T*, i64 }).
   Example:  ~ val nums { = total + total val }
   Example:  ~ w words { ( nurl_print w ) } *)
foreach_stmt = '~' IDENT expr block ;


(* ── EXPRESSIONS (all PREFIX notation) ───────────────────────── *)

expr = literal
     | IDENT
     | bin_expr
     | not_expr
     | ret_expr
     | complement_expr
     | try_expr
     | closure_expr
     | sizeof_expr
     | agg_expr
     | slice_literal
     | cond_expr
     | block_expr
     | dyn_construct
     | call_expr
     | member_expr
     | cast_expr
     | match_expr
     ;


(* OP left right
   Operand types must match. Comparison ops yield b (i1).

   '&' and '|' are dispatched by the LEFT operand's LLVM type:
     - i1        →  logical with short-circuit evaluation
     - i64 / i32 →  bitwise AND / OR
   All other operand types are a compile error.

   '<<' and '>>' are integer-only:
     - i64 / i32 →  LLVM `shl` (left) / `ashr` (arithmetic right)
     - any other operand type is a compile error.
   The shift count is an i64 by convention; only the low 6 bits matter
   for i64 operands. Behaviour for negative or out-of-range counts
   matches LLVM's `shl`/`ashr` (poison for >= bitwidth).

   '^^' (XOR) maps directly to LLVM `xor`:
     - integer operands →  bitwise XOR
     - b (i1) operands  →  logical XOR (no short-circuit — XOR cannot)
     - a float operand is a compile error.
   Unlike '&' / '|', '^^' has no logical-vs-bitwise dispatch: `xor` is
   the same instruction for both, so it is always a plain binary op.

   There are NO unary arithmetic operators, but negative literals ARE
   directly supported at the lexer level: a '-' immediately followed by
   a digit (no intervening whitespace) is lexed as a single negative
   INT / FLOAT token. Binary MINUS is disambiguated by whitespace:
        -5    →  INT token, value -5
        - a b →  MINUS  IDENT a  IDENT b      (binary subtraction)
   For non-literal negation use the pattern  - 0 x  or  ~ 0  (bit flip
   of zero yields -1).

   Example:  + a b    * x 2    == n 0
   Example:  - x 5    — binary minus: x - 5
   Example:  * -3 n   — unary negative literal (lexed as -3)
   Example:  & > x 0 < x 10    — logical AND, short-circuits
   Example:  & 255 n           — bitwise AND on i64 (hex literals not lexed)
   Example:  << 1 n            — 2^n  (n < 63)
   Example:  >> x 8             — arithmetic shift right by 8
   Example:  ^^ a b             — bitwise / logical XOR *)
(* Every operator below is STRICTLY BINARY (exactly 2 operands). For
   n-ary boolean chains use n-1 operators:

       & a & b & c d        — (a && b && c && d)
       | | | a b c d        — (a || b || c || d)

   The compiler warns on the most common foot-gun shape — `? & a b c d
   { ... } { ... }` — where the bare `c`/`d` were consumed as the
   ternary's then/else and the `{ ... }` blocks became side-effect
   statements. Other contexts (function args, while conditions) still
   need user awareness — count operands left-to-right.

   '||' and '&&' (the two-char forms) are an alternative spelling for
   the bool-only short-circuit cases of '|' / '&'. They are strict
   binary (no N-ary chaining at the parse level — write `( a || b )`
   not `|| a || b c`), require both operands to be `b` (i1), and emit
   the same IR as the single-char operators on an i1 left operand.
   The two-char form lets code that is more readable as a `||`/`&&`
   chain stay that way. *)
bin_expr = BIN_OP expr expr ;
BIN_OP   = '+' | '-' | '*' | '/' | '%'
         | '<' | '>' | '==' | '!=' | '<=' | '>='
         | '<<' | '>>'
         | '&' | '|' | '^^'
         | '&&' | '||'
         ;

(* ! expr  — logical NOT, yields b *)
not_expr        = '!' expr ;

(* '^^' expr expr  — XOR. The native bitwise / logical exclusive-or
   operator (LLVM `xor`): on integer operands it is bitwise XOR, on
   `b` operands it is logical XOR. Integer/bool only — there is no
   float `xor`. The lexer pairs two adjacent carets into one `^^`
   token, so `^^` (no space) is XOR while `^ ^` (with a space, never
   meaningful) is two return tokens.
   Example:  ^^ a b           — XOR of a and b
   Example:  ^^ flag1 flag2    — logical XOR of two b values

   NOTE: `^` alone is still the RETURN operator (ret_expr below); it
   is NOT XOR. `^ a b` parses as `return (a b …)`. *)

(* ^ expr  — explicit RETURN from the enclosing function. Distinct
   from the XOR operator `^^` above — a single `^` is return.
   Example:  ^ + a b *)
ret_expr        = '^' expr ;

(* ~ expr  — bitwise complement for integers (xor -1); float negation for f.
   '~' in expression position is always complement (not loop). At statement
   position, a '~ expr' with no trailing '{' block is silently reinterpreted
   as a complement expression used for side effects (see tilde_stmt).
   Example:  ~ 0  →  -1    ~ 3.0  →  -3.0 *)
complement_expr = '~' expr ;

(* \ expr  — try / propagate
   If expr is Some(v) / Ok(v): unwraps to v.
   If expr is None / Err(e):   immediately returns the same shape from
                               the enclosing function, propagating the
                               error value unchanged.
   The enclosing function must therefore be able to SAY that it failed:
   its return type must itself be an option or a result, and of the same
   KIND as the operand. Neither half was checked before 2026-09-12. A
   function returning anything else fell to a `zeroinitializer` fail
   path, which discards what the callee reported and hands the caller a
   zero of the declared type — 0 for `→ i`, a null pointer for `→ s`
   (a clean compile and a segfault). Mixing the two kinds zeroes the
   OTHER shape: an option tried inside `!T E` returns Err with an error
   payload of 0 no callee produced, and a result tried inside `?T`
   returns None with the Err payload gone. Both conversions are real and
   explicit — `( res_ok r )` drops the error, `( opt_ok_or o err )`
   supplies one — and '\' is propagation, not conversion.
   At a site that cannot propagate (a `→ i` main, a callback with a
   fixed signature), take the payload with `res_expect` / `res_unwrap` /
   `opt_unwrap_or`, which say what happens on failure.
   For Result types, the error payload's NURL type is compared against
   the enclosing function's declared error type; a mismatch is a compile
   error.
   Example:  : val \ ( find map key )        — Option propagation
   Example:  : n   \ ( parse_int src )       — Result propagation

   NOTE: '\' is overloaded and disambiguated by 1–3 token lookahead (see
   closure_expr). If none of the closure-start patterns match, '\' is
   a try-expression. *)
try_expr = '\\' expr ;

(* \ param* → type { body }  — closure / lambda expression
   Creates a function value that captures variables from the enclosing
   scope. Captures are stored in an environment struct allocated on the
   heap. Closures compile to  { fn_ptr, env_ptr }  (16 bytes).

   Disambiguation from try_expr uses the first 1–3 tokens after '\':
     1. '→'                                        →  closure, zero params
     2. TYPE_KW | '*' | '?' | '[' | '!'            →  closure, param types
     3. '(' '@'                                    →  closure, fn-type param
     4. IDENT IDENT '→'                            →  closure, one named param
   Any other form is parsed as try_expr.

   Zero parameters:   \ → type { body }
   With parameters:   \ type name type name → type { body }

   Example:  : (@ i i) square \ i x → i { * x x }
   Example:  : (@ v) printer \ → v { ( nurl_print msg ) }   — captures 'msg'
   Example:  : (@ i i) adder \ i y → i { + x y }            — captures 'x' *)
closure_expr = '\\' param* '→' type block ;

(* Z type  — byte size of type as i64
   The fold is keyed on the LLVM type: void→0, i64→8 (i, u64),
   double→8 (f), i1→1 (b), any pointer→8 (s, *T) fold to a constant.
   Every other type (u→1, i8→1, i16/u16→2, i32/u32→4, f32→4, and
   named/aggregate types) uses a getelementptr-null trick so LLVM
   computes the size at emission time.
   Example:  Z i  →  8    Z u  →  1    Z Point  →  sizeof(Point) *)
sizeof_expr = 'Z' type ;

(* ? cond then else  — ternary conditional
   'then' and 'else' are full expressions; a block expression { … } is
   also a valid form and is commonly used as the "block" branch.
   Example:  ? > x 0  `positive`  `non-positive`
   Example:  ? > x 0  { ( nurl_print `+` ) }  {} *)
cond_expr = '?' expr expr expr ;

(* { stmt* }  — block used as expression, yields last value *)
block_expr = '{' stmt* '}' ;

(* ( fn [generic_arg+]? arg* )  — function call; optional [generic_arg+]
   for generic instantiation. A type argument is a `generic_arg` (see
   generic_inst, below): a base IDENT (type keyword or named type), a
   pointer / option (`* T`, `? T`, `?? T`), or a nested generic / closure
   application (`( Name … )`, `( @ R P* )`). The compiler monomorphises by
   mangling each argument's lowered type into the call name. The one shape
   NOT accepted as a call type-argument is a bare anonymous slice / opt / res
   literal type (`[ T`); name it via a struct if needed.
   Example:  ( add 3 4 )              ( nurl_print `hello\n` )
   Example:  ( id [i] 42 )            — monomorphise generic id with T=i
   Example:  ( alloc [Point] 16 )     — Point is a struct name
   Example:  ( id [*Point] p )        — compound (pointer) type argument
   Example:  ( box [( Pair i s )] x ) — compound (nested generic) argument

   A call argument may be NAMED with an `IDENT ':'` label (keyword
   args). Named arguments may appear in any order and follow any leading
   positional ones; omitted parameters fall back to their defaults. A
   bare `:` never begins a positional argument, so `IDENT ':'` at a
   call's top level is unambiguously a label.
   Example:  ( create_issue key summary issue_type: `Bug` )
   Example:  ( greet greeting: `Hi` name: `Bob` )                       *)
call_arg  = ( IDENT ':' )? expr ;
call_expr = '(' IDENT ( '[' generic_arg+ ']' )? call_arg* ')' ;

(* dyn_construct: `( dyn Trait value )` (grammar v2.3, docs/spec.md §4.9) boxes
   `value` — whose concrete type must impl `Trait` — into a `%Trait` object
   (see dyn_type). `dyn` is a CONTEXTUAL keyword: it is only special as the head
   of a call immediately followed by a known trait name, so an ordinary function
   named `dyn` still calls through.
   Example:  : %Speaker s ( dyn Speaker d )   — d : Dog, Dog impls Speaker *)
dyn_construct = '(' 'dyn' IDENT expr ')' ;

(* [ type | expr* ]  — slice literal
   Allocates a heap array, stores values, returns { T*, i64 } slice struct.
   Layout: field 0 = T* ptr, field 1 = i64 length.
   Example:  [ i | 10 20 30 ]   — slice of 3 i64 values
   Access:   . slice ptr        — extractvalue 0 → T*
             . slice length     — extractvalue 1 → i64 *)
slice_literal = '[' type '|' expr* ']' ;

(* @ type { expr* }  — aggregate / enum constructor
   Builds a struct, enum value, opt_type, slice_type, or res_type field by
   field. Compiles to a chain of LLVM insertvalue instructions.
   Example:  @ ? i { T 42 }          — Some(42)
   Example:  @ Rect { 3 7 }          — struct by field order
   Example:  @ Rect { Pos 3 7 }      — enum variant with two payloads
   Example:  @ Packet { Ping }       — enum variant with no payload *)
agg_expr = '@' type '{' expr* '}' ;

(* . obj index   — field access / array indexing
   The compilation rule is chosen by the LLVM type of obj:

     - struct pointer '%T*' + IDENT  →  GEP field lookup + load
     - raw pointer 'T*'     + INT    →  GEP + load (array[literal])
     - raw pointer 'T*'     + expr   →  GEP + load (array[variable])
     - aggregate '{ i1, T }' (opt / res) + INT
           idx 0 → whole value (tag consumed by ??)
           idx 1 → payload via extractvalue
     - slice '{ T*, i64 }' + INT
           idx 0 → data pointer (T*)
           idx 1 → length (i64)
     - named struct  '%T'  + IDENT   →  extractvalue by registered field idx
     - enum value    '%T'  + 0       →  whole value (for ?? match input)

   Example:  . p x            — p.x (struct field)
   Example:  . buf 0          — buf[0] (pointer, literal idx)
   Example:  . data idx       — data[idx] (pointer, variable idx)
   Example:  . slice ptr      — first slice field
   Example:  . opt 1          — Option payload *)
member_expr = '.' expr ( IDENT | INT | expr ) ;

(* # target_type expr [INT]  — type cast
   Used for explicit type coercion (e.g. cast nurl_alloc's i8* to *T).
   Example:  # *Point ( nurl_alloc 16 )
   Example:  # i ( some_fn )
   Example:  # *u closure 0   — closure-field-extract: extract fn ptr
   Example:  # *u closure 1   — closure-field-extract: extract env ptr
   The trailing INT (0 or 1) is consumed only when the source expr is a
   closure-shaped struct ({ R (i8*…)*, i8* }) and the destination type
   is a pointer; otherwise the cast follows the standard form. Used to
   feed C-runtime callback APIs (thread_spawn, signal handlers, etc.)
   the raw fn-ptr/env-ptr pair NURL closures decompose into.

   '#' converts a VALUE. It does not reinterpret one aggregate as
   another: NURL named types are nominal, and `# Q pt` for two distinct
   structs used to hand the operand register back wearing Q's type —
   `ret %Q %r1` with %r1 a %Pt, which only clang objected to. A named
   aggregate SOURCE with an integer destination still recovers field 0
   (an enum's tag, a struct's first field), which is what makes
   `# E e` work for a bare-variant error payload. *)
cast_expr = '#' type expr [ INT ] ;

(* ?? expr { match_arm* }
   Pattern match on enum values, Option (?T), or Result (!T E).
   Exhaustiveness is checked at compile time: every variant must be
   covered OR a '_' wildcard arm must be present. An option and a result
   each have exactly two arms, 'T' and 'F'; before 2026-09-12 only the
   ENUM spelling of this rule was enforced, and the uncovered path of an
   option or result match reached the join as `undef`. Duplicate variant arms
   (without literal constraints) are rejected at compile time.
   Literal-constrained arms (e.g. `Ok 200 → …`) do NOT satisfy
   exhaustiveness on their own — a catch-all arm for the same variant is
   still required.

   Each non-wildcard arm names a variant (or a BOOL for ?T tag) and then
   one slot per payload (no fixed cap). A payload slot is either:
     - IDENT   → binds the payload at that position
     - INT     → compares equality with the payload value
   The pattern name may be a BOOL literal (T/F) when matching an ?T whose
   tag is i1 (Some / None).

   Example:  ?? val {
                 JNull        → `null`
                 JNum n       → ( nurl_str_int n )
                 KeyPress c m → ( key_event c m )     — 2-payload binding
                 Ok 200       → `ok`                   — literal-constrained
                 _            → `other`
             }
   Example:  ?? some_opt {
                 T v → v
                 F   → 0                               — BOOL pattern on ?T *)
match_expr    = '??' expr '{' match_arm* '}' ;
match_arm     = pattern match_payload* guard? '→' expr ;
pattern       = ( IDENT | BOOL | '_' )
              | IDENT ( '|' IDENT )+ ;   (* or-pattern: tag-only variants,
                                            no payload / literal / guard *)
match_payload = IDENT | INT ;
guard         = '?' expr ;   (* evaluated AFTER payload binding; a false
                                guard falls through to the next arm, so a
                                guarded arm does NOT satisfy exhaustiveness
                                for its variant — a catch-all is still
                                required. Not allowed on a '_' wildcard
                                arm or combined with an or-pattern. *)

(* ?? { select_arm* }
   Go-style select over channels. A '??' whose scrutinee is immediately
   '{' has no value to match, so it is unambiguously a channel select.
   Each channel arm receives from one channel; the construct proceeds
   with the FIRST ready arm. With no '_' default arm it BLOCKS until some
   channel becomes ready (a value is sent OR the channel is closed);
   with a '_' default arm it never blocks — the default runs when no
   channel is ready. Arms are tried in source order (deterministic
   priority).

   Each channel arm is '[' type ']' chan_expr '→' IDENT '{' body '}'.
   The bound IDENT is the '? T' option that a receive yields: T v ⇒ a
   value, F ⇒ the channel is closed and drained. chan_expr must be a
   simple read (an identifier or a parenthesised call); it is evaluated
   under the borrow checker as a borrow, not a move.

   Example:  ?? {
                 [i]      jobs    → o { ?? o { T n → … F → … } }
                 [String] control → o { ?? o { T s → … F → … } }
                 _                → { /* nothing ready */ }
             }

   Lowers to the chan_raw_* / select_waiter_* rendezvous in
   stdlib/std/channel.nu (arm every channel, block on a shared waiter,
   disarm on wake) — see gen_select in compiler/nurlc.nu. *)
select_expr   = '??' '{' select_arm* '}' ;
select_arm    = ( '[' type ']' expr '→' IDENT block )
              | ( '_' '→' block ) ;


(* ── TYPES ────────────────────────────────────────────────────── *)

type = base_type     (*  i u f b s v                                  *)
     | ptr_type      (*  * T           →  T*    (*void → i8*)         *)
     | opt_type      (*  ? T           →  { i1, T }                   *)
     | slice_type    (*  [ T           →  { T*, i64 }                 *)
     | res_type      (*  ! T E         →  { i1, i64 }                 *)
     | fn_type       (*  (@ R P*)      →  { R (i8*, P…)*, i8* }       *)
     | dyn_type      (*  % Trait       →  { i8*, i8* } (trait object)  *)
     | generic_inst  (*  ( Name IDENT+ )  — generic type application   *)
     | IDENT         (*  named struct, enum, or type variable         *)
     ;

(* The sized type keywords (i8 i16 i32, u16 u32 u64, f32) and 'v128' lex
   as TT_TYPE_KW exactly like the single-char forms — see the reserved-
   identifier list under § LEXICAL. *)
base_type = 'i'  (*  signed 64-bit integer  →  i64    *)
          | 'u'  (*  unsigned 8-bit byte    →  i8     (since v1.6) *)
          | 'f'  (*  64-bit IEEE 754 float  →  double *)
          | 'b'  (*  boolean                →  i1     *)
          | 's'  (*  UTF-8 string           →  i8*    *)
          | 'v'  (*  void                            *)
          | sized_type
          | 'v128'  (*  128-bit SIMD vector →  <4 x i32>  (v2.5, docs/spec.md §4.1b)
                        By-value like any scalar: bindings, params, returns.
                        No arithmetic operator applies — every operation is a
                        `nurl_v128_*` primitive, because `+` on a vector must
                        say which lane width it means. *)
          | 'v256'  (*  256-bit SIMD vector →  <4 x i64>  (v2.6, docs/spec.md §4.1c)
                        Same rules, one register wider. 256 bits is baseline
                        NOWHERE, so a `v256` kernel is correct everywhere (LLVM
                        legalises `<4 x i64>` into whatever the machine has) but
                        fast only where AVX2 exists — pair it with the `simd`
                        prefix to get a wide clone and a baseline one. *)
          ;

sized_type = 'i8' | 'i16' | 'i32' | 'u16' | 'u32' | 'u64' | 'f32' ;

(* dyn_type: a DYNAMIC trait object (grammar v2.3, docs/spec.md §4.9). The '%'
   sigil — otherwise the trait/impl declaration sigil and the modulo operator —
   in a TYPE position names a fat pointer `%dyn.<Trait> = { i8*, i8* }` (a
   heap-boxed value + a per-impl vtable).

   The name must be a trait DECLARED with a body — a type position runs
   the same declared-type check every other one does, so a typo or a
   struct name written here is reported at the type, not left to clang
   as a reference to an undefined `%dyn.<name>`.

   The trait must be object-safe: it has a Self type parameter and no
   method (a) lacks a Self receiver, (b) names Self beyond the receiver,
   (c) consumes self by value, or (d) mentions an associated type. Each
   clause is about a METHOD: a trait that declares an associated type no
   method's signature mentions is object-safe like any other, because
   nothing in the erased ABI depends on it. Constructed with
   `dyn_construct`; a bare-name call whose first argument is a `%Trait`
   dispatches through the vtable. *)
dyn_type     = '%' IDENT ;
(* '* void' is rewritten to 'i8*' in the IR (LLVM forbids void*). *)
ptr_type     = '*' type ;
opt_type     = '?' type ;
slice_type   = '[' type ;
(* res_type: the success-payload T and error-payload E are stored in a
   single i64 slot (integers direct, pointers via ptrtoint, enums via
   extractvalue of their i64 tag). The source-level NURL types of T and
   E are preserved separately for compile-time try-propagation checking. *)
res_type     = '!' type type ;
(* fn_type: values of function type are CLOSURES — a 16-byte struct
   holding the function pointer and its environment pointer.
   The function pointer takes an implicit leading i8* env argument. *)
fn_type      = '(' '@' type type* ')' ;
(* generic_inst: type application at TYPE position. The leading IDENT names
   a generic struct; each following `generic_arg` is a TYPE, monomorphised by
   name mangling. Arguments MAY be compound — a nested generic application
   (`( Vec ( Pair K V ) )`), a pointer (`* T`), an option (`? T` / `?? T`),
   or a closure / fn type (`( @ R P* )`) — and recurse through `type`. A
   compound arg is mangled into a single ident-shaped word so nesting
   composes deterministically. The one type shape the monomorphiser does NOT
   accept as an argument is a bare anonymous slice (`[ T`); wrap it in a
   named struct if you need a slice-of-generic.

   `generic_arg` is the shared argument grammar for BOTH the type-position
   form here AND the function call-site `[ … ]` form (see call_expr).

   Applies to both generic FUNCTIONS (call-site type args, e.g.
   `( id [i] 42 )` — see call_expr) AND generic STRUCT types
   (`( Vec i )`, `( Pair i s )`, `( Pair ( Box i ) i )`) used in any type
   position (param, return, let annotation, aggregate constructor). Each
   distinct instantiation yields one `%Name__T1[__T2...]` named type.

   The number of type arguments must equal the number of type parameters
   the generic declares, and a different count is a diagnostic at the
   type. Neither direction is benign: too few leaves the surplus
   parameter unsubstituted in the emitted type (`%Pair__i64 =
   type { i64, %V }`), and too many mangles the DEFINITION from the
   declared parameters while every reference carries all of them, so the
   reference names a type nothing defines. *)
generic_arg  = IDENT | ptr_type | opt_type | generic_inst | fn_type ;
generic_inst = '(' IDENT generic_arg+ ')' ;


(* ── LITERALS ─────────────────────────────────────────────────── *)

literal = INT | FLOAT | STR | BOOL ;

(* Decimal integer, one or more digits, with an optional leading '-'.
   A '-' is consumed as part of the literal ONLY when it is immediately
   followed by a digit with no intervening whitespace; otherwise the '-'
   tokenises as the binary MINUS operator. Underscore separators are NOT
   supported by the lexer.
   Example:  0    42    -7
   Non-literal negation:  - 0 x    (binary minus)    ~ 0  →  -1        *)
INT   = '-'? DIGIT+ ;

(* Floating-point: mandatory decimal point, optional exponent. The same
   optional leading '-' rule as INT applies.
   Example:  3.14    1.0e10    6.022e23    1.5e-3    -0.5 *)
FLOAT = '-'? DIGIT+ '.' DIGIT+ ( [eE] ( '+' | '-' )? DIGIT+ )? ;

(* Backtick-delimited string. The lexer recognises four escape sequences:
   \n (LF, U+000A), \t (HT, U+0009), \r (CR, U+000D) and \\ (backslash).
   Any other \X pair is passed through verbatim (so `\d` stays as the
   two bytes `\` `d`, useful for embedding regex source). The backtick
   delimiter itself cannot be escaped — strings cannot contain a literal
   backtick character.
   Example:  `hello\n`        — newline-terminated greeting
             `CRLF\r\n`        — HTTP-style line ending
             `use \\ for a backslash` *)
STR = '`' [^`]* '`' ;

(* Boolean literals. The single-letter identifier 'T' also serves as the
   conventional type-variable name in generics; disambiguation is
   contextual (type-param list, param type position → type variable;
   otherwise → boolean true). *)
BOOL = 'T'    (*  true  *)
     | 'F'    (*  false *)
     ;


(* ── LEXICAL ───────────────────────────────────────────────────── *)

(* A plain identifier is alpha/underscore followed by alpha/digit/underscore.
   Adjacent identifiers joined by `::` (with no intervening whitespace) are
   MERGED by the lexer into a single IDENT with `__` as separator — this is
   the syntactic form used to reach names imported through an alias:
        alias::name        →  single IDENT token `alias__name`
        outer::inner::leaf →  single IDENT token `outer__inner__leaf`
   `::` produces no token on its own; it is purely a lexer glue for
   word chains: the merge is LEXICAL and does not consult the class of
   the words it joins, so `i::x` and `T::x` fuse into the single IDENTs
   `i__x` and `T__x` exactly as `m::x` does. (The reserved word alone
   keeps its own class — `i` is still a type keyword, `T` still `true`.
   Only the joined form is an identifier.) *)
IDENT_PLAIN = [a-zA-Z_] [a-zA-Z0-9_]* ;
IDENT       = IDENT_PLAIN ( '::' IDENT_PLAIN )* ;

(* Reserved identifiers (classified by the lexer, not usable as
   variable names):
     i u f b s v             →  single-char type keywords (TT_TYPE_KW)
     i8 i16 i32              →  signed fixed-width ints   (TT_TYPE_KW)
     u16 u32 u64             →  unsigned fixed-width ints (TT_TYPE_KW)
     f32                     →  32-bit float              (TT_TYPE_KW)
     v128                    →  SIMD vector    (v2.5)     (TT_TYPE_KW)
     v256                    →  wide SIMD vec  (v2.6)     (TT_TYPE_KW)
     T F                     →  boolean literals          (TT_BOOL)
     Z                       →  sizeof keyword            (TT_SIZEOF)
     pub                     →  visibility prefix         (TT_PUB)
     simd                    →  CPU-dispatch prefix (v2.6) (TT_SIMD)
     inline                  →  always-inline prefix (v2.7) (TT_INLINE)
     break                   →  leave innermost loop      (TT_BREAK)
     continue                →  next iteration            (TT_CONTINUE)

   Multi-byte operator tokens (lexed greedily — longest match wins;
   the 3-byte forms below precede their shorter prefixes in the lex
   driver so e.g. `...` cannot mis-parse as three TT_DOTs):
     ...                     →  variadic-FFI marker       (TT_ELLIPSIS)
     →                       →  return arrow              (TT_ARROW)
     ==  !=  <=  >=
     <<  >>  ??              →  see § BIN_OP / match_expr *)

DIGIT      = [0-9] ;
WHITESPACE = [ \t\n\r]+ ;     (* skipped *)
COMMENT    = '//' [^\n]* ;    (* skipped *)
EOF        = (* end of input *) ;


(* ── MEMORY MODEL (informative — not a grammar rule) ───────────────

   Single-owner with compiler-inserted auto-drop at scope exit.
   A binding OWNS a value when it is produced by a fresh allocation
   (string concat/slice/int/float, slice literal, aggregate with owned
   fields, a call returning an owned value) and is released exactly once
   at the end of the enclosing scope in reverse declaration order.

   1. String + slice owners
      : s s ( nurl_str_cat a b )     — owned string, freed at scope exit
      : [ i  xs [ i | 1 2 3 ]        — owned slice, freed at scope exit

   2. Struct-field owners
      Owned fields reached through a `.`-path (including nested structs
      via a multi-index extractvalue chain) are released recursively when
      the enclosing binding goes out of scope.

   3. Reassignment drop
      `= x <owned-call>` frees the previous value of x before assigning
      the new one. Parameters and immutable bindings cannot be reassigned.

   4. Parameter ownership
      Callers grant owned-string arguments to callees on a per-call basis.
      The callee must `strdup` any argument it intends to retain; the
      caller frees the temporary immediately after the call returns.

   5. Arm-local fall-through drop
      Values allocated inside a `?`, `??`, `~` (while) or `~ IDENT …`
      (foreach) arm that are not part of the arm's result type (i.e. the
      arm yields `v`) are dropped when control flows out of that arm.
      Arms whose result type is non-`v` defer ownership transfer to the
      enclosing binding per rule 2.

   6. Foreach element borrow
      In `~ IDENT expr { body }` the element IDENT is a BORROW from the
      iterated slice. It is not owned, is not dropped at iteration end,
      and the underlying slice owner remains responsible for freeing the
      backing allocation.

   7. User-defined Drop
      An impl that implements the `Drop` trait (any trait named `Drop`
      with an `@ drop T self → v` method) is recognised by convention:
      whenever an owned binding of type T reaches its scope-exit point,
      the compiler inserts a call to `drop__<T-mangle>(self)` before
      freeing any owned fields of T. User `drop` methods must not panic
      and should not free the self pointer themselves — that responsibility
      belongs to the auto-drop machinery.

   8. Return ownership transfer
      Returning a fresh allocation transfers ownership to the caller. The
      callee emits no drop for the returned value; the caller's binding
      becomes the new owner.                                         *)