LeiaLanguage Specification

Leia Language Specification

This is the normative reference for Leia, a Go-embedded scripting language with Go-like syntax. It defines the source syntax, value model, execution semantics, module behavior, tagged dialect syntax, and implementation obligations that user-facing tools, interpreters, bytecode VMs, JITs, and embedding APIs must preserve.

Leia uses Go-like syntax with dynamic values, Lua-compatible table and multi-return behavior where useful, explicit host capabilities, Go-native embedding, source-level hot reload, and a generic tagged-dialect mechanism for domain-specific syntax. Shell/data/web forms, spreadsheets, optional AI workflows, and host-defined extensions use the same dialect boundary.

Version

This specification describes the language surface implemented by the current repository version. A release may label a subset as stable, experimental, or implementation-defined in release notes. Stable behavior is behavior covered by this specification and by the release feature matrix.

GitHub Pages publishes this file as the single-page language specification. The chapter files remain the editable sources for each section, and the grammar appendix remains the syntax source of truth.

Normative Documents

  • Notation: EBNF notation, terminology, and normative wording.
  • Source Code Representation: source text, comments, directives, and lexical preprocessing.
  • Lexical Elements: identifiers, keywords, literals, operators, punctuation, and tokenization rules.
  • Declarations And Scope: declarations, lexical scope, constants, functions, imports, and module-level bindings.
  • Values And Types: dynamic values, truthiness, numbers, strings, tables, functions, channels, coroutines, and host values.
  • Expressions: operands, operators, calls, indexing, member selection, literals, multi-return adjustment, and evaluation order.
  • Statements: blocks, conditionals, loops, select, go, defer, labels, goto, return, break, and continue.
  • Functions: functions, closures, varargs, multi-return, tail behavior, and callable values.
  • Tables And Metatables: table identity, constructors, raw access, sequence behavior, metatables, and metamethods.
  • Concurrency: goroutine-like tasks, channels, select, sync, cancellation, and host scheduling boundaries.
  • Tagged Dialects: generic DSL extension syntax, interpolation, bang forms, runtime boundaries, registration, and standard dialect families.
  • AI Dialect Syntax: model, tool, agent, and turn dialects as one optional standard-library dialect implementation; messages, budgets, output validation, providers, trace, replay, and evaluation. AI is not a privileged language mode.
  • Modules And Loading: require, import "go:...", leia.mod, leia.sum, vendoring, module caches, and capabilities.
  • Errors And Diagnostics: runtime errors, recoverable errors, diagnostics, protected calls, and host failure reporting.
  • Implementation Requirements: interpreter baseline, bytecode VM, JIT, optimization correctness, sandboxing, and release gates.
  • Grammar Appendix: stable user-facing EBNF grammar.

Compatibility Entry Point

The previous single-page overview remains available as language.md. New normative changes should be made in the chaptered specification and mirrored into language.md only when a concise overview is useful.

Stability Contract

Stable behavior requires:

  1. a normative section in this specification;
  2. a corresponding entry in tests/feature_matrix.json;
  3. at least one semantic or conformance gate;
  4. release notes or migration notes for user-visible changes.

Experimental behavior may exist in examples, feature flags, or implementation packages, but it must not be advertised as stable.

Before the first stable release, compatibility is explicit rather than implied. The interpreter is the semantic baseline. Bytecode VM and JIT execution must preserve observable interpreter behavior unless tests/feature_matrix.json marks a surface as unsupported for that mode. Optimizations, caches, typed kernels, and native code generation are implementation details, not independent language guarantees.

Notation

Leia syntax is specified with Extended Backus-Naur Form.

Production  = production_name "=" Expression "." ;
Expression  = Term { "|" Term } ;
Term        = Factor { Factor } ;
Factor      = production_name | token | Group | Option | Repetition ;
Group       = "(" Expression ")" ;
Option      = "[" Expression "]" ;
Repetition  = "{" Expression "}" ;

The grammar appendix uses semicolons at the end of productions:

program = { separator | statement } EOF ;

Lowercase production names denote lexical tokens or helper productions. Double-quoted strings denote literal tokens. Informal prose may use ellipses for omitted examples; an ellipsis is not a source token unless written as the three-character token ....

The words must, must not, may, implementation-defined, stable, and experimental have their ordinary specification meaning:

  • must and must not describe required behavior;
  • may describes permitted behavior;
  • implementation-defined behavior must be documented by an implementation when exposed to users;
  • stable behavior is part of the release compatibility contract;
  • experimental behavior may change without compatibility guarantees.

Examples are explanatory unless a surrounding section says they are normative.

Source Code Representation

Leia source files are UTF-8 text. The stable source contract excludes invalid UTF-8 byte sequences and the NUL character. Implementations may reject either before lexing and tools may report them as source encoding errors rather than language syntax errors.

Source text is not normalized. Distinct Unicode code point sequences remain distinct in comments and string literal contents, and implementations must not apply Unicode normalization before tokenization or before comparing string values.

Stable identifiers are ASCII. Non-ASCII code points may appear in comments and strings. Future revisions may extend identifier characters, but portable source should use ASCII identifiers.

// Non-ASCII text is stable in comments and strings.
text := "cafe"
assert(text == "cafe")
cafeé := 1

Whitespace separates tokens but does not by itself terminate statements. Semicolons are optional separators in the public grammar.

Comments have two forms:

line_comment  = "//" { any_char_except_newline } ;
block_comment = "/*" { any_char } "*/" ;

Line comments that begin with the exact prefix //leia: and are attached to the first parsed token in a file are file directives. A file directive must appear before imports, declarations, statements, and any non-directive leading comment that separates it from the first token. //@leia:, /* //leia:... */, and // leia: are ordinary comments, not file directives.

File directives are metadata for tooling, sandbox policy, build selection, and tests. They do not execute, import modules, skip tests, grant host capabilities, enable providers, or change expression and statement semantics by themselves. Tools and host applications decide how to interpret the metadata they recognize.

The stable file directive kinds are the ones specified in File Directives: build, test, cap, and feature. Implementations may report malformed arguments, unsupported argument values, duplicates, or unrecognized //leia: names as diagnostics when a tool or host policy validates directives. Unknown file directive names are not reserved extension points for user programs: portable source must not depend on them being accepted, ignored, preserved, rejected, or reported in inspection output. Regardless of those diagnostics, an unknown file directive must not gain execution behavior or silently grant capabilities.

//leia:build linux,darwin
//leia:test smoke
//leia:cap fs.read
//leia:feature source-directives
//@leia:build ignored
ran := true
assert(ran)

//leia: comments outside the file-directive position remain comments for this chapter. Other chapters may define declaration-local directive comments, such as AI tool metadata, but those comments are not file directives.

Lexical Elements

Tokens are identifiers, keywords, literals, operators, and punctuation. Whitespace and comments separate tokens but are not tokens themselves.

Implementations must scan the longest valid token from the current byte position. For example, ... is one ellipsis token, not .. plus ., and <= is one comparison token, not < plus =.

concat := "a" .. "b"
ellipsis_seen := false

func collect(...) {
    ellipsis_seen = true
}

collect(1, 2, 3)

assert(concat == "ab")
assert(ellipsis_seen)

The parser may reject a token sequence even when every token is lexically valid.

Identifiers

Identifiers are ASCII-only. They begin with an ASCII letter or underscore and continue with ASCII letters, ASCII digits, or underscores.

identifier = ident_start { ident_continue } ;
ident_start = "A".."Z" | "a".."z" | "_" ;
ident_continue = ident_start | "0".."9" ;

Identifiers name variables, functions, labels, tools, agents, module aliases, and fields. Identifiers are case-sensitive. Non-ASCII letters are not identifier characters in v1.0 source.

name := "leia"
Name := "different binding"
_scratch := 42
agent_name := "summarizer"

assert(name == "leia")
assert(Name == "different binding")
assert(_scratch == 42)
assert(agent_name == "summarizer")

The identifiers name and Name are distinct. A digit may not be the first character of an identifier.

1name := "not an identifier"

Keywords

Reserved keywords are recognized only when the complete identifier spelling matches a keyword. Longer names that contain a keyword remain identifiers, so func_name, returning, and iffoo are ordinary identifiers.

The following keywords are reserved and may not be used as identifiers:

func return if else elseif for range break continue in var go chan defer const goto
true false nil
func_name := "ordinary identifier"
returning := true
iffoo := 7

assert(func_name == "ordinary identifier")
assert(returning == true)
assert(iffoo == 7)
func := "reserved"

Dialect words such as model, tool, turn, agent, and evaluate are contextual syntax words, not lexical keywords. They scan as identifiers outside grammar positions that assign them dialect meaning.

agent := "identifier"
tool := "identifier"
turn := 1
model := 2

assert(agent == "identifier")
assert(tool == "identifier")
assert(turn + model == 3)

In grammar positions that define tagged dialect forms or evaluate blocks, contextual words are consumed by that syntax rather than bound as ordinary identifiers. See Expressions for the generic tagged-dialect boundary and AI Dialect Syntax for one standard dialect family.

Numeric Literals

Numeric literals are unsigned tokens. Unary + and - are separate operators. The stable v1.0 numeric forms are decimal integers, decimal floats, and base-prefixed integers.

number_lit  = decimal_int [ fraction ] [ exponent ]
            | decimal_int exponent
            | based_int ;
decimal_int = digit { [ "_" ] digit } ;
fraction    = "." digit { [ "_" ] digit } ;
exponent    = ( "e" | "E" ) [ "+" | "-" ] digit { [ "_" ] digit } ;
based_int   = "0" ( "x" | "X" ) hex_digits
            | "0" ( "b" | "B" ) bin_digits
            | "0" ( "o" | "O" ) oct_digits ;
hex_digits  = hex_digit { [ "_" ] hex_digit } ;
bin_digits  = bin_digit { [ "_" ] bin_digit } ;
oct_digits  = oct_digit { [ "_" ] oct_digit } ;
hex_digit   = digit | "A".."F" | "a".."f" ;
bin_digit   = "0" | "1" ;
oct_digit   = "0".."7" ;
digit       = "0".."9" ;

For valid programs, an underscore may appear only between digits within the same digit sequence. It may not start or end a number, appear next to ., appear immediately after an exponent marker or exponent sign, or appear twice in a row. Current implementations may scan some wider base-prefixed spellings before numeric conversion rejects or accepts them; portable v1.0 source should use digits valid for the base and the underscore placement above.

Decimal float forms include 1.25, 1e3, 1.25e2, and 1_2.3_4e5_6. A dot belongs to a number only when it is not the start of ..; therefore 1..2 scans as 1, .., 2.

decimal := 1_000_000
hex := 0xff
binary := 0b1010
octal := 0o755
float := 1.25e2
spread := "1" .. "2"

assert(decimal == 1000000)
assert(hex == 255)
assert(binary == 10)
assert(octal == 493)
assert(float == 125)
assert(spread == "12")
bad := 1__2
bad := 1e_2

String Literals

String literals have three untagged source forms. Double-quoted strings process escapes and support ${expr} interpolation. Single-quoted strings process escapes but do not interpolate; ${ has no special meaning inside them. Backtick raw strings use Go raw-string behavior: they preserve their contents and do not process escapes or interpolation.

The same raw delimiter syntax is also used by tagged dialect expressions. For example, a shell dialect expression places the raw body immediately after $, and host-defined tags place their raw body immediately after the tag name. Tagged raw bodies may use one backtick delimiter or a fenced three-backtick delimiter. Both tagged forms may contain ${expr} interpolation tokens that are evaluated by Leia and encoded according to the dialect contract. The fenced form is for embedded DSL source that contains single backticks or spans multiple lines.

double_quoted_string = '"' { any_char_except_quote_newline_dollar_backslash
                           | escape
                           | interpolation } '"' ;
single_quoted_string = "'" { any_char_except_single_quote_newline_backslash
                           | escape } "'" ;
raw_string           = short_raw_body | fenced_raw_body ;
short_raw_body       = '`' { any_byte_except_backtick } '`' ;
fenced_raw_body      = '```' { any_byte_except_three_backticks } '```' ;
tagged_raw_body      = tagged_short_raw_body | tagged_fenced_raw_body ;
tagged_short_raw_body = '`' { any_byte_except_backtick_or_interpolation
                            | interpolation } '`' ;
tagged_fenced_raw_body = '```' { any_byte_except_three_backticks_or_interpolation
                               | interpolation } '```' ;
interpolation = "${" expr "}" ;
escape        = backslash ( backslash | '"' | "'" | "a" | "b" | "f" | "n"
                          | "r" | "t" | "v"
                          | "x" hex hex
                          | "u" hex hex hex hex
                          | "U" hex hex hex hex hex hex hex hex
                          | decimal_escape ) ;
decimal_escape = digit [ digit [ digit ] ] ;
hex           = "0".."9" | "A".."F" | "a".."f" ;
backslash     = "\\" ;

Stable quoted-string escapes are \\, \", \', \a, \b, \f, \n, \r, \t, \v, \xNN, \uNNNN, \UNNNNNNNN, and decimal byte escapes from \0 through \255. Hex and Unicode escapes require exactly the specified number of hex digits. Unicode escapes must encode a valid Unicode scalar value.

Strings are byte strings; UTF-8 interpretation is provided by library helpers. "\uNNNN" and "\UNNNNNNNN" append the UTF-8 bytes for the escaped rune.

Untagged raw strings preserve every byte between the delimiters. Tagged raw bodies preserve uninterpolated bytes exactly and replace ${expr} segments with dialect-encoded values. A short raw body cannot contain a backtick. A fenced raw body can contain single or double backticks, but not three consecutive backticks. Quoted strings may not contain an unescaped newline.

quoted := "line\n"
literal := 'line\n'
raw := `line\n`
name := "Leia"
interpolated := "hello ${name}"
literal_interp := 'hello ${name}'
unicode := "\u0041\U00000042"
byte_escape := "\065"

assert(#quoted == 5)
assert(#literal == 5)
assert(#raw == 6)
assert(interpolated == "hello Leia")
assert(literal_interp == 'hello ${name}')
assert(unicode == "AB")
assert(byte_escape == "A")

The first two strings contain a newline byte because quoted strings process escapes. The raw string contains the two bytes backslash and n. The double-quoted string interpolates name; the single-quoted spelling contains the literal bytes ${name}.

bad := "line
break"

Boolean and nil literals are true, false, and nil.

Comments And Directives

Line comments begin with // and continue to the end of the line. Block comments begin with /* and end at the next */; block comments do not nest. Comments are otherwise ignored for parsing.

// A full-line comment.
x := 1 /* inline block comment */ + 2

assert(x == 3)

File directives are line comments whose text begins with leia: after //. Directives attach to the following token when they are in the leading comment group for that token; blank-line separation starts a new group. Stable directive names and their semantic effects are specified by the directive and dialect chapters. Lexically, a directive is still a line comment.

// leia:requires docs.read
// leia:cap net.client
func summarize(text) {
    return llm.turn({
        messages: [llm.user(text)]
    })
}
/* block comments do not nest
   /* inner */
*/
x := 1

Operators And Punctuation

The stable operator and punctuation tokens are:

++ -- + - * / % ** .. # !
 += -= *= /=
 = := == != < <= > >=
 && || & | ^ &^ << >>
 <- ... . , ; : ( ) [ ] { }

Longest-token scanning applies to this table. For example, += is one token, ++ is one token, ** is one token, ... is one token, and <- is one token.

x := 1
x += 2
y := 2 ** 3
bits := (1 << 4) | 3

assert(x == 3)
assert(y == 8)
assert(bits == 19)

The parser may reject a token sequence even when each token is lexically valid. For example, a + * b is lexically valid as tokens but invalid as an expression.

a := 1 + * 2

Declarations And Scope

Leia uses lexical scope. A declaration creates a binding for an identifier in the innermost enclosing lexical block, or in module scope when the declaration appears at top level.

For :=, const, func, and import, the declaration point is the end of the declaration statement. The new binding is visible after that point through the end of the block that owns it, including nested blocks and function literals created in that region. The initializer or right-hand side of a declaration is evaluated before the new binding is visible, so names in that expression resolve exactly as they would immediately before the declaration. Module-scope declarations are visible to later module-scope code and to nested blocks in the same module.

x := 10

if true {
    x := x + 1 // RHS uses the outer x; the inner x starts after this statement
    assert(x == 11)
}

assert(x == 10)

An inner declaration may shadow an outer binding with the same name. While the inner binding is in scope, unqualified uses resolve to the inner binding. The outer binding remains alive if it is captured or otherwise still reachable, but it is not directly addressable by that name until the inner scope ends.

Variables

Assignment with := introduces local bindings. Assignment with = updates existing targets. Multiple assignment adjusts values according to the multi-return rules in Expressions.

x := 1
x = x + 1

if true {
    x := "inner"
    assert(x == "inner")
}

assert(x == 2)

The inner x shadows the outer x only inside the block.

:= creates bindings in the current lexical block. Reusing a name that is already declared in an outer block creates a new shadowing binding; it does not update the outer binding. Use = to update an existing visible binding.

value := 1

if true {
    value := 2 // new binding in this block
    value = 3  // updates the inner binding
    assert(value == 3)
}

assert(value == 1)

Assigning to an identifier that does not resolve to an existing binding is a runtime error. Leia does not create global variables implicitly with =. Top-level declarations, imports, builtins, enabled standard-library modules, and host-provided globals are all existing bindings for this rule.

missing = 1

Dynamic script APIs that execute code with a host-provided environment follow the same rule: code may update names that are already present in that environment, but must declare new names with :=.

script := require("script")

env := {count: 1}
assert(script.eval("count = count + 1; return count", {env: env}) == 2)
assert(env.count == 2)

Same-block redeclaration is reserved by the v1.0 contract: portable programs must not declare the same identifier twice in one lexical block unless a later spec section explicitly permits a narrower form. The current implementation accepts some same-block redeclarations, including repeated := and repeated func declarations, but interpreter and VM capture behavior is not part of the stable contract for those programs. Implementations may reject same-block redeclarations in stable mode.

Constants

const_decl = "const" identifier ( "=" | ":=" ) expr ;

A const binding may not be rebound. Const does not freeze the internals of a mutable value such as a table.

const limit := 10
// limit = 11       // invalid: const binding

const settings := { retries: 3 }
settings.retries = 4 // valid: table contents remain mutable
assert(limit == 10)
assert(settings.retries == 4)

Const visibility and shadowing follow the ordinary lexical binding rules. An inner declaration may shadow an outer const, but code that resolves to the const binding may not assign to that binding with =, compound assignment, or increment/decrement forms.

const limit := 10
limit = 11

The read-only property belongs to the binding, not to the identifier spelling. A shadowing declaration creates a separate binding.

const n := 1

if true {
    n := 2
    assert(n == 2)
}

assert(n == 1)

Functions

func_decl = "func" identifier param_list block ;

A function declaration binds the function value to the declared name in the enclosing scope. Function bodies capture lexical bindings by reference.

func add(a, b) {
    return a + b
}

total := 0
func bump() {
    total = total + 1
    return total
}

assert(add(2, 3) == 5)
assert(bump() == 1)
assert(bump() == 2)

The function declaration’s binding is visible to code after the declaration statement. The function body is evaluated later, in an environment where the function name resolves to that binding, so the body may refer to the function for recursion. Parameters are local bindings in the function body. They shadow outer bindings with the same names for the entire body.

func factorial(n) {
    if n <= 1 {
        return 1
    }
    return n * factorial(n - 1)
}

assert(factorial(5) == 120)

Function declarations follow the same same-block redeclaration portability rule as other declarations. A nested function declaration may shadow an outer function or variable name in its own block.

Imports

import_decl = "import" ( import_spec | "(" { import_spec } ")" ) ;
import_spec = [ identifier ] string_lit | string_lit "as" identifier ;

An import declaration declares one lexical binding whose value is the module result for the import path. The canonical forms are Go-style:

import "json"
import http "go:net/http"
import (
    "json"
    host "go:host/safe"
)

When no alias is written, the alias is inferred from the final path element. For example, import "json" binds json, and import "path/filepath" binds filepath. An implementation must reject paths whose alias cannot be inferred.

Leia also accepts import "path" as name for explicit aliases in existing source. New source should prefer the alias-first form import name "path".

An import path beginning with go: names an explicit host-provided Go binding. The source path does not reflect arbitrary Go packages by itself; the embedder must provide an allowlisted binding through the Go API.

The import alias is a lexical binding in the declaration’s scope. It is visible only after the import declaration statement completes, and it may be shadowed by an inner declaration. Portable programs must avoid same-block alias conflicts with any other declaration.

An import path names a capability provided by the module loader or embedder. The command-line runner rejects unallowlisted Go imports.

import math "go:math"

Labels

Labels are declared with name:. Label names live in a function-level label namespace that is separate from lexical value bindings. A label may have the same spelling as a variable, constant, function, parameter, or import alias. Labels do not shadow lexical bindings, and lexical bindings do not shadow labels. Within a function, each label name may be declared at most once.

A goto must not jump into a deeper lexical scope or over a local declaration.

i := 0
again:
i = i + 1
if i < 3 {
    goto again
}
assert(i == 3)
done:
done := true
assert(done)
again:
again:
goto inner
if true {
    x := 1
    inner:
}

AI Declarations

AI model, tool, turn, and agent forms are tagged dialect expressions, not declarations with special binding rules. Assign their result to a name when a script needs a reusable value.

evaluate "name" { ... } is a source-level regression declaration discovered by leia evaluate; it has no effect during ordinary script execution. Detailed semantics are specified in AI Dialect Syntax.

Values And Types

Leia is dynamically typed. Types are properties of runtime values, not declared static variable slots.

Stable value categories are:

  • nil;
  • booleans;
  • numbers;
  • strings;
  • tables;
  • functions;
  • coroutines;
  • channels;
  • host-backed values represented through tables or native functions.

The v1.0 value model has three observable attributes:

  1. category, reported by type;
  2. representation details explicitly exposed by a stable API, such as math.type for numbers;
  3. identity, only for tables, functions, coroutines, channels, and documented host-backed values.

Assignment, Identity, And Aliasing

Variables, parameters, table fields, return slots, channel messages, and protected-call result slots hold values. Assignment copies the value reference, not the contents of an identity-bearing value. Reassigning a variable does not mutate the old value; mutating an aliased table does.

a := {count: 1}
b := a
b.count = 2
assert(a.count == 2)
assert(a == b)

b = {count: 3}
assert(a.count == 2)
assert(b.count == 3)
assert(a != b)

x := 1
y := x
y = 2
assert(x == 1)
assert(y == 2)

Truthiness

Only nil and false are falsy. Numbers, including 0, empty strings, empty tables, functions, coroutines, and channels are truthy.

seenZero := false
seenEmpty := false
if 0 {
    seenZero = true
}
if "" {
    seenEmpty = true
}
assert(seenZero)
assert(seenEmpty)

Numbers

Numbers have one script-visible category, number, with integer and floating-point subtypes observable through math.type. The subtype is part of the v1.0 observable contract for math.type, tostring, table-key behavior, and host APIs that expose value kinds. The subtype is a property of the runtime value, not of a variable or table slot.

Numeric literal syntax determines the initial runtime subtype. An integer literal token is a decimal numeral without a decimal point or exponent, or a prefixed numeral using 0x, 0b, or 0o as specified in Lexical Elements. An integer literal denotes an integer value when its mathematical value is in the portable integer subtype range; otherwise it denotes the nearest representable float selected by the current runtime conversion. A floating-point literal token has a decimal point or exponent and always denotes a float value, even when its mathematical value is integral. The unary - operator is not part of any literal token.

The portable integer subtype range for ordinary values is signed 48-bit two’s-complement: -140737488355328 through 140737488355327. Portable programs may rely on integer subtype preservation inside that closed range. Portable programs must not rely on integer subtype preservation outside that range unless a module or host contract explicitly promises a wider range. A negative boundary value can be produced by arithmetic even when the corresponding positive source literal would be outside the range.

Float values use IEEE-754 binary64 semantics. Finite binary64 values have 53 bits of integer precision, so every integer whose magnitude is at most 9007199254740992 is exactly representable as a float; not every larger integer is. Decimal conversion for float literals is implementation-defined only within the ordinary binary64 nearest-representable result. math.huge is positive infinity; negative infinity is -math.huge; NaN is produced by math operations such as math.sqrt(-1).

Arithmetic may preserve integer representation when all operands that determine the result are integers and the result fits the integer subtype range. Integer overflow promotes the result to float. Mixed integer/float arithmetic produces float. Division produces float. Exact float-to-integer conversion is exposed by math.tointeger; conversion succeeds only for finite integral values in the host integer range, and the resulting value still obeys the ordinary runtime subtype range. JIT raw integer representations are not observable.

Numeric equality compares numeric values, so 1 == 1.0 is true even though math.type(1) and math.type(1.0) differ. Ordered numeric comparison converts both operands to their numeric value. NaN is unordered: it is not equal to itself, and all primitive ordered comparisons involving NaN are false. Positive and negative infinity compare according to IEEE-754 ordering.

Other primitive equality compares booleans and strings by value; nil is equal only to nil. Strings order lexicographically by byte sequence. Values of other categories do not have primitive ordering. Identity-bearing values compare by identity unless metatable or host contracts say otherwise.

The primitive equality relation is total and never raises an error. Primitive ordering is partial: it is defined only for numeric operands and for string operands, plus any metatable or host comparison contract documented elsewhere. Ordering values from unsupported categories raises a runtime error.

assert(nil == nil)
assert(true != false)
assert("a" < "b")
assert(2 < 3.5)

ok := pcall(func() {
    return {} < {}
})
assert(!ok)

Primitive string conversion for numbers is stable: integer values format as decimal without a suffix; floats use binary64 shortest-round-trip formatting, with a .0 suffix for finite whole-number floats so their subtype remains visible. NaN stringifies as NaN, positive infinity as +Inf, and negative infinity as -Inf.

assert(type(1) == "number")
assert(math.type(1) == "integer")
assert(math.type(1.5) == "float")
assert(math.type(1e3) == "float")
assert(0xff == 255)
assert(0b1010 == 10)
assert(0o755 == 493)
assert(0.2e2 == 20)
assert(1 == 1.0)
assert(math.type(1.0) == "float")
assert(tostring(1) == "1")
assert(tostring(1.0) == "1.0")

maxPortableInt := 140737488355327
minPortableInt := -140737488355327 - 1
assert(math.type(maxPortableInt) == "integer")
assert(math.type(minPortableInt) == "integer")
assert(math.type(maxPortableInt + 1) == "float")
assert(math.tointeger(1.0) == 1)
assert(math.tointeger(1.5) == nil)
assert(9007199254740992.0 == 9007199254740992.0 + 1.0)
assert(9007199254740992.0 + 2.0 == 9007199254740994.0)

fromFloat := math.tointeger(140737488355327.0)
assert(fromFloat == 140737488355327)
assert(math.type(fromFloat) == "integer")
assert(math.tointeger(math.huge) == nil)
nan := math.sqrt(-1)
assert(math.isnan(nan))
assert(math.isinf(math.huge))
assert(math.isinf(-math.huge))
assert(nan != nan)
assert(!(nan == nan))
assert(!(nan < 0))
assert(!(nan <= 0))
assert(-math.huge < 0)
assert(math.huge > 0)
assert(tostring(nan) == "NaN")
assert(tostring(math.huge) == "+Inf")
assert(tostring(-math.huge) == "-Inf")

Strings

Strings are immutable byte strings. A string value is its byte sequence; strings do not have script-visible identity separate from that sequence. Equality and ordering compare byte sequences, and #s reports the byte length. Assignment, argument passing, return, table storage, and channel transfer of a string value cannot create aliases through which the original bytes can be mutated. String operations produce new strings or views specified by their library contract; they do not mutate existing string values. Library functions may interpret strings as UTF-8, paths, JSON, or protocol data when their module contract says so.

s := "abc"
alias := s
t := s .. "d"
assert(s == "abc")
assert(alias == "abc")
assert(t == "abcd")
assert(#"A" == 1)
assert(string.sub(t, 1, 3) == s)

Tables

Tables are mutable identity-bearing key/value objects. Raw table equality is identity equality. Table keys may be any non-nil runtime value. Assigning nil removes a key. Arrays, records, dense vectors, matrices, and SOA layouts are optimized representations or standard library structures unless a separate stable spec section explicitly promotes them to primitive value categories.

Value transport does not deep-copy tables, functions, channels, or coroutines. A module, host API, or standard-library function may explicitly document that it clones or serializes a value; absent that contract, ordinary argument passing, returns, table storage, and channel transfer preserve identity.

The v1.0 numeric table-key contract follows the current runtime exactly. An integer key is stored as an integer key. A float key whose value is finite and integral is normalized to the corresponding integer key when written. Ordinary float-key lookup is not normalized before lookup. Therefore 1 == 1.0 is true, but the table hash slot used for a stored integer key is not found by an ordinary 1.0 lookup; t[1] and t[1.0] do not behave as interchangeable lookup expressions. Non-integral float keys are stored and looked up as float keys. NaN may be used as a raw key by the current runtime, but it still does not compare equal with ==; portable programs should avoid NaN table keys unless a module explicitly documents that convention.

t := {}
t[1] = "int"
assert(1 == 1.0)
assert(t[1] == "int")
assert(t[1.0] == nil)

t[1.0] = "written as int"
assert(t[1] == "written as int")
assert(t[1.0] == nil)

t[1.5] = "float"
assert(t[1.5] == "float")
assert(t[1] != t[1.5])

Functions

Functions are callable identity-bearing values. Script functions close over their lexical environment; each evaluation of a function expression creates a distinct function identity even if it closes over equal values or the same source body. A function declaration initializes its binding with one function value when the declaration executes. Aliasing preserves identity; copying a function value into a variable, table field, return slot, or channel message does not clone the function or its captured environment. Host functions are also functions: their identity is the host-provided callable object, not the display name returned by tostring and not structural equivalence of native code. Host functions and script functions share script-visible call, argument adjustment, multi-return, and protected-call semantics, but may differ in performance, resource accounting, and recoverable host error behavior. Functions compare by identity unless a host-backed value documents a narrower comparison rule.

func makeCounter() {
    n := 0
    return func() {
        n = n + 1
        return n
    }
}

counter := makeCounter()
same := counter
other := makeCounter()
expr1 := func() { return 1 }
expr2 := func() { return 1 }
tableAlias := {fn: expr1}
assert(counter == same)
assert(counter != other)
assert(expr1 != expr2)
assert(tableAlias.fn == expr1)
assert(counter() == 1)
assert(counter() == 2)

host := tostring
assert(host == tostring)
assert(host != print)

Channels

Channels are identity-bearing synchronization values created by the runtime or host libraries. Leia v1.0 has one script-visible channel category, type(ch) == "channel"; it does not have distinct send-only, receive-only, or direction-parameterized channel types. Channel direction is not a value subtype, not part of equality, and not reported by type. A host API may document that a parameter will only be sent to or only received from, but that direction is an API precondition or capability contract, not a separate language-level value type and not a castable value form.

Channel sends and receives transfer ordinary Leia values without copying table/function/channel identity. Equality compares channel identity. A receive from a closed channel yields the channel contract’s closed result; sends to a closed channel raise a runtime error unless protected.

ch := make(chan, 1)
same := ch
other := make(chan, 1)
assert(type(ch) == "channel")
assert(ch == same)
assert(ch != other)

box := {value: 7}
func id(x) { return x }
fn := id
ch <- box
received := <-ch
assert(received == box)
received.value = 8
assert(box.value == 8)

ch <- fn
receivedFn := <-ch
assert(receivedFn == fn)
assert(receivedFn(9) == 9)

Type Queries

The type function reports the stable script-visible category for ordinary values.

type(nil)     // "nil"
type(true)    // "boolean"
type(1)       // "number"
type("x")     // "string"
type({})      // "table"
type(func() {}) // "function"
assert(type(nil) == "nil")
assert(type(true) == "boolean")
assert(type(1) == "number")
assert(type("x") == "string")
assert(type({}) == "table")
assert(type(func() {}) == "function")

Expressions

Expressions compute values. Evaluation proceeds according to the expression form and may raise runtime errors unless protected by a recovery construct.

Precedence

Operators follow this precedence, from highest to lowest:

  1. postfix call, index, and member selection;
  2. unary operators;
  3. exponentiation;
  4. multiplicative arithmetic, shifts, bitwise and, and bit clear;
  5. additive arithmetic, bitwise or, and bitwise xor;
  6. concatenation;
  7. comparisons;
  8. logical &&;
  9. logical ||.

Parentheses override precedence. .. and ** are right-associative; other binary operators in the table above are left-associative. && and || short-circuit and return operand values rather than coerced booleans. Unary logical negation is !. Unary ^ is bitwise not; binary ^ is bitwise xor. Assignment forms are statements, not expressions.

x := 1 + 2 * 3      // 7
y := (1 + 2) * 3    // 9
z := false || "ok"  // "ok"
w := nil && fail()  // nil; fail is not called

assert(x == 7)
assert(y == 9)
assert(z == "ok")
assert(w == nil)
assert(1 | 2 + 4 == 7)
assert(1 << 2 * 3 == 12)
assert("a" .. "b" .. "c" == "abc")
x := 1
y := (x = 2)

Calls

A call expression invokes a callable value. Calls may produce zero or more results. A call used where exactly one expression value is required contributes its first result, or nil when it produces no results. Expression-list positions are adjusted by the multi-return rules in Functions.

func pair() {
    return "a", "b"
}

x, y := pair()
first := pair()

assert(x == "a")
assert(y == "b")
assert(first == "a")

The built-in spread(x) form preserves multiple results from a call in argument and table-constructor positions where explicit expansion is required.

func pair() {
    return "a", "b"
}

values := [1, spread(pair()), 4]

assert(values[1] == 1)
assert(values[2] == "a")
assert(values[3] == "b")
assert(values[4] == 4)

Calls evaluate the callee expression before arguments, then evaluate arguments left-to-right. Index and member selection evaluate the receiver before the key or field lookup. These forms return the value produced or stored by the target; they do not create a fresh identity by themselves.

Indexing And Member Selection

x[y] indexes a table-like or host-backed value. x.name is member selection and is equivalent to a string-key field lookup where supported.

user := { name: "Ada" }
same := user.name == user["name"]
user["score"] = 10

assert(same)
assert(user.score == 10)

x:name(args) is a method call. It evaluates x once, looks up field name on that receiver, then calls the result with the receiver inserted as the first argument. Therefore x:name(a, b) has the same stable call semantics as x.name(x, a, b), except the receiver expression is evaluated only once.

counter := {
    value: 0,
    add: func(self, delta) {
        self.value += delta
        return self.value
    },
}

assert(counter:add(2) == 2)
assert(counter.add(counter, 3) == 5)
assert(counter.value == 5)

Metamethods may affect indexing, assignment, calls, arithmetic, comparison, and length behavior as specified in Tables And Metatables.

Operators

Operator dispatch has three layers:

  1. apply the primitive operation when the operands are already valid for it;
  2. apply the operator’s stable primitive coercions, if any;
  3. otherwise consult the matching metamethod where metatables support that operator, then raise a runtime error if no applicable metamethod exists.

Numeric arithmetic operators accept numbers and strings that tonumber can parse as numbers. Numeric string coercion is attempted before metamethod lookup for primitive strings. Invalid numeric strings and unsupported operand types raise runtime errors unless a matching metamethod applies to a non-primitive operand. Library functions such as tonumber, tostring, math.tointeger, and formatting helpers expose related conversions explicitly.

Bitwise operators first apply the numeric conversion used by tonumber, then convert the numeric result to an integer operand. Float values, including numeric strings that parse as floats, are truncated toward zero by the current runtime conversion. Negative shift counts raise runtime errors. Bitwise operators do not dispatch metamethods.

Operator Operands and coercions Result and errors Metamethod
+ Numbers or numeric strings. Addition. Exact integer operands may produce an integer; mixed or inexact results may be float. Invalid operands error. __add
binary - Numbers or numeric strings. Subtraction with the same numeric representation rule as +. Invalid operands error. __sub
* Numbers or numeric strings. Multiplication with the same numeric representation rule as +. Invalid operands error. __mul
/ Numbers or numeric strings. Division produces numeric runtime division semantics; integer inputs may produce a float. Invalid operands error. __div
% Numbers or numeric strings. Modulo follows Leia numeric modulo semantics. Invalid operands error. __mod
** Numbers or numeric strings. Exponentiation follows the math library’s power semantics. Invalid operands error. __pow
unary - Number or numeric string. Numeric negation. Invalid operands error. __unm
.. Strings and numbers. Concatenation returns a string for primitive operands. Invalid operands error unless a metamethod applies. __concat
# String, table-like value, or host-backed value with length support. Strings return byte length. Tables use ordinary sequence/metatable length behavior. Invalid operands error. __len; rawlen bypasses it
== Any values. Primitive values compare by value. Tables, functions, channels, coroutines, and host values compare by identity unless stable equality metamethod dispatch applies. __eq
!= Any values. Logical negation of ==, including any stable __eq dispatch. __eq
< Compatible numbers or compatible strings. Numeric strings are not coerced for primitive string ordering; use tonumber explicitly. Ordered comparison. Incompatible operands error. __lt
<= Compatible numbers or compatible strings. Numeric strings are not coerced for primitive string ordering. Ordered comparison. Incompatible operands error. __le; no fallback to __lt
> Compatible numbers or compatible strings. Numeric strings are not coerced for primitive string ordering. Equivalent to reversed < after dispatch. Incompatible operands error. __lt with reversed operands
>= Compatible numbers or compatible strings. Numeric strings are not coerced for primitive string ordering. Equivalent to reversed <= after dispatch. Incompatible operands error. __le with reversed operands; no fallback to __lt
&& Any values. Returns the left operand when it is falsy; otherwise evaluates and returns the right operand. none
|| Any values. Returns the left operand when it is truthy; otherwise evaluates and returns the right operand. none
! Any value. Returns true for nil and false; returns false for every other value. none
& Numbers or numeric strings accepted by bitwise conversion. Bitwise and. Invalid operands error. none
| Numbers or numeric strings accepted by bitwise conversion. Bitwise or. Invalid operands error. none
binary ^ Numbers or numeric strings accepted by bitwise conversion. Bitwise xor. Invalid operands error. none
unary ^ Number or numeric string accepted by bitwise conversion. Bitwise not. Invalid operands error. none
&^ Numbers or numeric strings accepted by bitwise conversion. Bit clear. Invalid operands error. none
<< Number or numeric-string left operand and shift count accepted by bitwise conversion. Left shift. Negative shift count errors; shift counts of 64 or more produce 0. Invalid operands error. none
>> Number or numeric-string left operand and shift count accepted by bitwise conversion. Logical right shift of the 64-bit operand representation. Negative shift count errors; shift counts of 64 or more produce 0. Invalid operands error. none
<- Channel value. Receive blocks until a value is received, the channel is closed, or the host cancels execution. Invalid operands error. none

Raw helpers bypass their corresponding metamethods: for example, rawget, rawset, rawequal, and rawlen use raw table/string behavior where they are defined.

Non-executable operator result summary:

1 + 2        // 3
"5" + 3      // 8
"x" + 3      // runtime error
"a" .. 3     // "a3"
(1 << 8)     // 256
assert(1 + 2 == 3)
assert("5" + 3 == 8)
assert("a" .. 3 == "a3")
assert((1 << 8) == 256)
assert(("3.9" & 1) == 1)
assert((^"0") == -1)
return "x" + 3
return 1 << -1
boxed := setmetatable({value: 4}, {
    __add: func(left, right) {
        return left.value + right
    },
    __len: func(_) {
        return 9
    },
})

assert(boxed + 3 == 7)
assert(#boxed == 9)
assert(rawlen(boxed) == 0)

Literals

List literals, record/map literals, dense array literals, function literals, and tagged dialect forms are expressions. Their specific syntax is listed in grammar.ebnf.

Literal operands are evaluated left-to-right. A literal that constructs an identity-bearing value creates a fresh identity each time the literal is evaluated. This applies to list literals, record/map literals, function literals, and dense arrays. Calls, tagged dialect evaluations, index expressions, and member selections do not imply freshness by themselves: they return the callee or dialect result, and selection returns the stored member or indexed value.

Table fields are evaluated in source order. For stable v1.0 programs, avoid depending on duplicate keys or on subtle interleaving between list-style and keyed fields; Tables And Metatables defines the portable constructor subset. List literals evaluate each element in order and store the results as a new 1-based array table. Dense array literals evaluate each element in order, convert each element according to the dense element type, and raise a runtime error if a value cannot be represented by that element type. Function literals capture their lexical environment by reference. Tagged dialect forms evaluate according to their registered dialect contract.

events := []
func mark(name, value) {
    append(events, name)
    return value
}

rec := {
    first: mark("field", 1),
}
list := [mark("a", 10), mark("b", 20)]
dense := []i64{mark("d1", 3), mark("d2", 4)}

assert(events[1] == "field")
assert(events[2] == "a")
assert(events[3] == "b")
assert(events[4] == "d1")
assert(events[5] == "d2")
assert(rec.first == 1)
assert(list[1] == 10 && list[2] == 20)
assert(dense[1] == 3 && dense[2] == 4)
assert({} != {})

child := {}
holder := { child: child }
assert(holder.child == child)
assert(holder["child"] == child)

Tagged Dialect Forms

Tagged dialect forms are expressions. The generic syntax, interpolation rules, bang behavior, registration model, and runtime boundary are specified in Tagged Dialects. The optional standard AI dialect family is specified in AI Dialect Syntax. Other dialects may be provided by standard-library packages, host applications, or external extensions.

Evaluation Order

Within an expression list, subexpressions are evaluated left-to-right unless a specific expression form short-circuits. Implementations may optimize execution but must preserve observable side effects and error behavior.

events := []
func mark(name) {
    append(events, name)
    return name
}

result := mark("left") .. mark("right")
assert(result == "leftright")
assert(events[1] == "left")
assert(events[2] == "right")

events = []
holder := { [mark("key")]: mark("value") }
assert(events[1] == "key")
assert(events[2] == "value")
assert(holder.key == "value")

func fail_if_called() {
    error("should not be called")
}

short_and := false && fail_if_called()
short_or := true || fail_if_called()
assert(short_and == false)
assert(short_or == true)

Statements

Statements control execution.

Blocks

Blocks introduce lexical scope. A block is a sequence of statements enclosed in braces. Locals declared inside the block are not visible after the closing brace. A block evaluates its statements in source order until control transfers, an error unwinds, or the block ends normally.

if true {
    hidden := 1
}
assert(hidden == 1)

If Statements

if evaluates its condition and executes the first matching branch. Conditions use Leia truthiness: only nil and false are false. elseif branches are tested left-to-right after earlier conditions are false. At most one branch runs, and branch blocks have their own lexical scopes.

seen := {}
if 0 {
    seen.zero = true
}

if nil {
    seen.nilBranch = true
} else {
    seen.elseBranch = true
}

assert(seen.zero)
assert(seen.elseBranch)
assert(seen.nilBranch == nil)

Simple Statements

Simple statements are assignments, compound assignments, increment and decrement statements, send statements, calls, and expression statements. Expression statements evaluate their expression for side effects and discard all results.

Assignment

An assignment evaluates the right-hand side expression list left-to-right, then evaluates any address subexpressions needed by non-variable targets, then stores adjusted values into the targets from left to right. A target may be a visible variable binding, a table or host-backed index expression, or a member selection. Assignment to an invalid target raises a runtime error. := creates fresh lexical bindings in the current block for identifier targets; = updates existing targets. Multi-value adjustment for the right-hand side follows the rules in Functions.

events := []
func mark(name, value) {
    append(events, name)
    return value
}

box := {slot: 0}
box[mark("key", "slot")] = mark("value", 7)
assert(box.slot == 7)
assert(events[1] == "value")
assert(events[2] == "key")

func pair() {
    return "left", "right"
}
a, b, c := pair()
assert(a == "left")
assert(b == "right")
assert(c == nil)

Compound Assignment And Inc/Dec

Compound assignment x += y, x -= y, x *= y, and x /= y is equivalent to evaluating the target once, reading its current value, applying the corresponding binary operator to that value and the right-hand expression, then storing the result back into the same target. The operator may use ordinary primitive coercions or metamethod dispatch. x++ and x-- are statements equivalent to adding or subtracting integer 1 from the target once.

count := 1
count += 2
count *= 3
count--
assert(count == 8)

log := []
t := setmetatable({}, {
    __index: func(_, key) {
        append(log, "read:" .. key)
        return 10
    },
    __newindex: func(_, key, value) {
        append(log, "write:" .. key .. "=" .. value)
    },
})
t.score += 5
assert(log[1] == "read:score")
assert(log[2] == "write:score=15")
1 = 2
name := "x"
name++

Send Statements

ch <- value as a statement sends value to channel ch. Send blocking, close, ordering, and cancellation semantics are specified in Concurrency.

ch := make(chan, 1)
ch <- "sent"
assert(<-ch == "sent")

For Statements

for supports indefinite loops, condition loops, C-style loops, and range loops. Loop bodies may use break and continue.

sum := 0
for i := 1; i <= 3; i++ {
    sum += i
}
assert(sum == 6)

valueSum := 0
for key, value := range pairs({ a: 1, b: 2 }) {
    assert(#key == 1)
    valueSum += value
}
assert(valueSum == 3)

for { ... } repeats until break, return, goto, an error, or host cancellation exits the loop. for condition { ... } evaluates the condition before every iteration using ordinary truthiness.

In for init; condition; post { ... }, the init statement runs once before the first condition check. The condition is checked before each iteration. The post statement runs after each normal iteration and after continue; it does not run after break, return, goto, or an unwind out of the loop body.

postCount := 0
bodyCount := 0

for i := 0; i < 4; postCount++ {
    if i == 1 {
        i++
        continue
    }
    if i == 3 {
        break
    }
    bodyCount += 1
    i++
}

assert(postCount == 3)
assert(bodyCount == 2)

Range Loops

for key := range expr { ... } and for key, value := range expr { ... } evaluate expr once, then iterate according to the resulting value category. The range variables are fresh lexical bindings for each iteration. Closures created inside the loop capture that iteration’s bindings, not one shared loop slot.

The v1.0 range algorithm is:

  1. Evaluate expr once before the loop begins.
  2. If the value is a table, repeatedly call the table’s next-entry operation. With one range variable, bind the key. With two range variables, bind the key and value. Sequence-style table entries use 1-based integer keys. Non-sequence table iteration order is unspecified and must not be used for stable program behavior.
  3. If the value is a function, call it with no arguments before each iteration. If the call returns no values or first returns nil, stop. Bind the first result to the first range variable. Portable programs should not depend on a second range variable for function iterators in v1.0; return a table or tuple value when an iterator needs to carry multiple fields.
  4. If the value is a channel, receive until the channel is closed and drained. With one range variable, bind each received value. With two range variables, the second variable is currently not assigned by the stable channel range contract and portable programs should use one variable.
  5. Any other value raises a runtime error.

break exits the loop. continue skips to the next iteration and causes the range source to be advanced again according to the same algorithm. A return from the body exits the enclosing function.

items := [10, 20, 30]
sum := 0
keySum := 0

for k, v := range pairs(items) {
    keySum += k
    sum += v
}

assert(keySum == 6)
assert(sum == 60)
calls := []

for _, value := range pairs([10, 20, 30]) {
    append(calls, func() {
        return value
    })
}

assert(calls[1]() == 10)
assert(calls[2]() == 20)
assert(calls[3]() == 30)
func counter(limit) {
    n := 0
    return func() {
        n = n + 1
        if n > limit {
            return nil
        }
        return {value: n, square: n * n}
    }
}

sum := 0
for pair := range counter(3) {
    sum += pair.value + pair.square
}
assert(sum == 20)

ch := make(chan, 2)
ch <- "a"
ch <- "b"
close(ch)

text := ""
for value := range ch {
    text = text .. value
}
assert(text == "ab")

ok := pcall(func() {
    for _ := range 123 {
    }
})
assert(!ok)

Break And Continue

break exits the innermost enclosing loop. continue starts the next iteration of the innermost enclosing loop. Outside a loop they are invalid.

values := []
for i := 1; i <= 6; i++ {
    if i % 2 == 0 {
        continue
    }
    if i > 5 {
        break
    }
    append(values, i)
}

assert(#values == 3)
assert(values[1] == 1)
assert(values[2] == 3)
assert(values[3] == 5)

Select Statements

select waits on channel send or receive cases. Its full communication semantics are specified in Concurrency. Statement-level rules are:

  1. receive-case bindings are scoped to the selected case body;
  2. a send case evaluates its channel and value expressions before attempting the selected send;
  3. a default case is selected immediately when no communication can proceed;
  4. a select with no cases raises a runtime error.
ch := make(chan, 1)
observed := ""
select {
case value := <-ch:
    observed = "recv:" .. value
default:
    observed = "default"
}
assert(observed == "default")

ch <- "ready"
select {
case value := <-ch:
    observed = "recv:" .. value
default:
    observed = "default"
}
assert(observed == "recv:ready")

Go And Defer Statements

go call() starts a goroutine-like task. defer call() schedules cleanup to run when the current function returns or unwinds through a protected boundary.

func work(ready, done) {
    defer func() { done <- "done" }()
    ready <- "ready"
}

ready := make(chan, 1)
done := make(chan, 1)
go work(ready, done)
assert(<-ready == "ready")
assert(<-done == "done")

Return Statements

return exits the current function and returns zero or more values. Return values use the multi-return adjustment rules in Functions. At module top level, return stops execution of the current chunk or module and produces the module result used by loaders such as require.

func pair() {
    return "left", "right"
}

func forward() {
    return pair()
}

a, b := forward()
assert(a == "left")
assert(b == "right")

Deferred Calls

defer evaluates the callee and arguments when the defer statement executes, then invokes the deferred call later. Deferred calls run in last-in, first-out order when the current function exits normally, returns explicitly, or unwinds through a protected boundary. A deferred call cannot change the already computed return values unless it mutates an identity-bearing value reachable from those return values.

The evaluated deferred call is a call plus its already adjusted argument values. Later assignment to a variable that was used as an argument does not change that deferred argument value. A deferred closure with no argument, however, still closes over lexical bindings in the ordinary way; when it runs, it observes the current value of those captured bindings.

events := []
func scoped() {
    defer func() { append(events, "last") }()
    defer func() { append(events, "first") }()
    append(events, "body")
}

scoped()
assert(events[1] == "body")
assert(events[2] == "first")
assert(events[3] == "last")
events := []
func record(value) {
    append(events, value)
}

func scoped() {
    value := "initial"
    defer record(value) // argument value captured now
    defer func() {
        record("closure:" .. value) // lexical binding read when defer runs
    }()
    value = "changed"
}

scoped()
assert(events[1] == "closure:changed")
assert(events[2] == "initial")

Labels And Goto

Labels are declared as name: statements. Label names live in a function-level namespace separate from ordinary lexical variables. goto name transfers control to a label in the same function. It must not jump into a deeper lexical scope, into a loop body from outside that loop, or over a local declaration that would be in scope at the target. It may jump forward or backward within the same scope when doing so does not bypass such declarations.

i := 0
again:
i = i + 1
if i < 3 {
    goto again
}
assert(i == 3)
goto inside
if true {
    inside:
    print("unreachable")
}

Budget Control

Leia does not define a standalone budget { ... } { ... } statement. Budget control is expressed through ordinary values at the boundary that consumes the budget:

  • Go embedding options enforce VM, native-call, host-result, module, and concurrency budgets.
  • AI turn, agent, and loop option tables may carry AI budget fields.
  • leia evaluate exposes eval.budget(table) for per-case evaluation gates.

Because budgets are ordinary option tables rather than a statement form, their scope and error behavior are defined by the host API, AI runtime, or evaluation harness that receives the table.

Functions

Functions are first-class callable values. They may be named declarations or anonymous literals.

Parameters

Parameters are lexical bindings initialized from call arguments. Missing arguments become nil; extra arguments are discarded unless the function has a vararg parameter. The call does not expose an argument count to fixed parameters; a fixed parameter that receives an explicit nil is indistinguishable from one filled because the argument is missing.

func first(a, b) {
    return a, b
}

one, two := first(1, 2, 3)
missing_a, missing_b := first()
explicit_nil, after_nil := first(nil, "x")

assert(one == 1 && two == 2)                  // extra argument discarded
assert(missing_a == nil && missing_b == nil)  // missing arguments become nil
assert(explicit_nil == nil && after_nil == "x")

Varargs

The parameter ... accepts any remaining arguments. Inside the function, [...] constructs a list containing those arguments. A function may have at most one vararg parameter, and it must be the final parameter. Fixed parameters are filled before the vararg list is formed.

func count(...) {
    args := [...]
    return #args
}

assert(count(1, 2, 3) == 3)
func rest(head, ...) {
    tail := [...]
    return [head, tail[1], tail[2]]
}

values := rest("a", "b", "c")
assert(values[1] == "a")
assert(values[2] == "b")
assert(values[3] == "c")

Multiple Results

Function calls and some built-ins may produce multiple results. Leia adjusts those results according to the syntactic position where the call appears.

The stable adjustment rule is:

  1. In an expression list, every non-final expression contributes exactly one value. If that expression is a call, only its first result is used, or nil if it returns no values.
  2. If the final expression is a call, its full result list is available to the enclosing assignment, return, call argument list, or table constructor.
  3. The enclosing form then consumes the available values. Missing assignment targets or parameters receive nil; surplus values are discarded unless the enclosing form preserves them, such as return or vararg forwarding.

In assignment and return positions, a final call expands. Missing assignment values become nil; extra assignment values are discarded.

func triple() {
    return 10, 20, 30
}

a, b, c := triple() // 10, 20, 30
x, y := triple()    // 10, 20
func forward() {
    return triple()
}
first, second, third := forward()

assert(a == 10 && b == 20 && c == 30)
assert(x == 10 && y == 20)
assert(first == 10 && second == 20 && third == 30)

A parenthesized call is no longer in an expanding position and contributes exactly one value.

func triple() {
    return 10, 20, 30
}

a, b := (triple()) // a == 10, b == nil
assert(a == 10)
assert(b == nil)

When a call appears before the final expression in an expression list, it contributes exactly one value. A final call may expand.

func triple() {
    return 10, 20, 30
}

a, b, c, d := triple(), triple()
// a == 10; b == 10; c == 20; d == 30
assert(a == 10)
assert(b == 10)
assert(c == 20)
assert(d == 30)

Function-call arguments, including calls to host functions, and table constructors use the same expression-list rule: non-final calls contribute one value; final calls expand. Use spread(call()) to expand a call in a non-final position.

func triple() {
    return 10, 20, 30
}

func pack(...) { return table.pack(...) }

plain := pack(triple(), "x")              // receives 10, "x"
expanded := pack(spread(triple()), "x")   // receives 10, 20, 30, "x"
host_expanded := table.pack(triple())     // host calls use the same rule
list := [triple()]                        // [10, 20, 30]
single := [(triple())]                    // [10]

assert(plain[1] == 10 && plain[2] == "x")
assert(expanded[1] == 10 && expanded[2] == 20 && expanded[3] == 30 && expanded[4] == "x")
assert(host_expanded[1] == 10 && host_expanded[2] == 20 && host_expanded[3] == 30)
assert(list[1] == 10 && list[2] == 20 && list[3] == 30)
assert(single[1] == 10 && single[2] == nil)

If a function has fixed parameters followed by ..., fixed parameters are filled first and only the remaining adjusted argument values are captured by the vararg binding. When the final argument expression expands, all of its remaining results may enter ....

func triple() {
    return 10, 20, 30
}

func rest(a, ...) {
    return a, [...]
}

first, tail := rest(triple()) // first == 10; tail == [20, 30]
assert(first == 10)
assert(tail[1] == 20)
assert(tail[2] == 30)

Multi-return adjustment is not transitive through variables or table fields. A variable holding the first result of a call is an ordinary single value.

func triple() {
    return 10, 20, 30
}

v := triple() // v == 10
a, b := v     // a == 10; b == nil
assert(v == 10)
assert(a == 10)
assert(b == nil)
func triple() {
    return 10, 20, 30
}

a, b, c := triple()
assert(a == 10)
assert(b == 20)
assert(c == 30)

func sum3(x, y, z) {
    return x + y + z
}

assert(sum3(triple()) == 60)
assert(sum3(triple(), 1, 2) == 13)

expanded := [1, spread(triple()), 40]
assert(expanded[1] == 1)
assert(expanded[2] == 10)
assert(expanded[3] == 20)
assert(expanded[4] == 30)
assert(expanded[5] == 40)

v := triple()
x, y := v
assert(x == 10)
assert(y == nil)

Closures

Closures capture lexical variables by reference. Mutating a captured variable is visible to all closures that share the binding. Each evaluation of a function literal creates a distinct function value. Returning or assigning an existing function value preserves that value’s identity and captured environment.

func counter() {
    n := 0
    inc := func() {
        n = n + 1
        return n
    }
    get := func() {
        return n
    }
    return inc, get
}

next, current := counter()
other, other_current := counter()
same_next := next

assert(next() == 1)
assert(current() == 1)        // sibling closure sees the same binding
assert(next() == 2)
assert(current() == 2)
assert(other() == 1)          // a separate call has separate captures
assert(other_current() == 1)
assert(next == same_next)
assert(next != other)

Host Functions

Script functions and host functions share the same script-visible call result model: argument adjustment, result adjustment, protected-call behavior, and vararg collection are defined at the script boundary rather than by the implementation language of the callee. Host functions may return structured recoverable errors where their module contract specifies nil, err behavior. Uncaught host failures surface as ordinary runtime errors to script code.

Tail Calls

Tail-call optimization is not part of the stable function contract. A call in tail position has the same observable result adjustment as any other returned call, but the specification does not promise stack reuse, frame elision, debug-frame shape, or unbounded recursion unless a section explicitly promises stack behavior for a feature.

Tables And Metatables

Tables are mutable identity-bearing maps. Keys and values are runtime values. Implementations may use optimized array, record, or typed layouts when this does not change observable behavior.

Constructors

Record/map constructors create fresh table identities. Named fields and explicit keyed fields assign the specified key. Sequence values should use list literals, which construct the same table value shape with consecutive 1-based integer keys. Positional fields inside {...} are retained as compatibility syntax, but new source should use [...] for lists and reserve {...} for keyed records.

Portable programs must not depend on duplicate constructor fields that write the same normalized key; use explicit assignments when that order matters. A constructor expression itself never reuses an existing table identity.

values := ["first", "second"]
user := {name: "Ada"}

assert(values[1] == "first")
assert(user.name == "Ada")
assert(user["name"] == "Ada")
a := ["x", "y"]
b := ["x", "y"]
assert(a != b)
assert(a[1] == "x")
assert(a[2] == "y")

a[3] = "third"
assert(a[3] == "third")

Assigning nil to a table field removes that field for ordinary table lookup. Tables compare by identity unless a metatable supplies comparison behavior.

Raw Operations

Raw table operations are the primitive map operations:

  • rawget(t, k) returns the stored value for key k, or nil if absent;
  • rawset(t, k, v) stores v at key k and returns t;
  • rawset(t, k, nil) removes key k;
  • rawequal(a, b) compares primitive equality without invoking __eq;
  • rawlen(x) returns the primitive length for strings and tables.

Raw helpers bypass metamethods by contract. Non-raw operations may consult metatables when the corresponding operation names an event below.

t := {}
assert(rawset(t, "x", 1) == t)
assert(rawget(t, "x") == 1)
t.x = nil
assert(rawget(t, "x") == nil)

a := {}
b := {}
assert(rawequal(a, a))
assert(!rawequal(a, b))
log := []
t := setmetatable({present: 11}, {
    __index: func(_, key) {
        append(log, "index:" .. key)
        return "fallback"
    },
    __newindex: func(_, key, value) {
        append(log, "new:" .. key)
    },
})

assert(t.present == 11)
assert(t.missing == "fallback")
assert(log[1] == "index:missing")
assert(rawget(t, "missing") == nil)

t.created = 12
assert(log[2] == "new:created")
assert(rawget(t, "created") == nil)
rawset(t, "created", 13)
assert(rawget(t, "created") == 13)

Metamethod Dispatch

Stable table/metamethod behavior is defined by the events below. A metamethod is looked up by raw string key in the value’s metatable. The lookup never invokes __index on the metatable itself. Each metamethod receives the listed arguments; unless otherwise stated, only its first return value is used.

Only table values have user-controlled metatables in the v1.0 stable contract. Strings have implementation-provided method lookup through __index, but portable programs must not assume that setmetatable can change string behavior.

For a binary event named event, Leia uses this lookup algorithm after any primitive operation has failed:

  1. If the left operand is a table and its metatable has a non-nil raw field named event, use that value.
  2. Otherwise, if the right operand is a table and its metatable has a non-nil raw field named event, use that value.
  3. Otherwise, the operation raises the normal type error for that operator.

The chosen binary metamethod is called as (left, right) even when it came from the right operand. Operators > and >= are normalized before lookup: left > right dispatches as right < left, and left >= right dispatches as right <= left. Leia does not require the two operands to share the same metatable or metamethod function.

Stable Metamethods

Metamethod Trigger Arity Return contract
__index t[k] or t.k when table t has no raw key k. Strings use an implementation-provided __index table for methods. If function: (t, k). If table: no call; lookup continues in that table with key k. Function result or redirected table lookup result. Missing chains produce nil; chains deeper than 50 redirects raise a runtime error.
__newindex t[k] = v when table t has no raw key k. If function: (t, k, v). If table: no call; assignment continues in that table. Return values are ignored. Existing raw keys are updated directly. Chains deeper than 50 redirects raise a runtime error.
__add left + right when primitive numeric addition is not applicable. (left, right) First return value is the operator result.
__sub left - right when primitive numeric subtraction is not applicable. (left, right) First return value is the operator result.
__mul left * right when primitive numeric multiplication is not applicable. (left, right) First return value is the operator result.
__div left / right when primitive numeric division is not applicable. (left, right) First return value is the operator result.
__mod left % right when primitive numeric modulo is not applicable. (left, right) First return value is the operator result.
__pow left ** right when primitive numeric exponentiation is not applicable. (left, right) First return value is the operator result.
__unm Unary -x when primitive numeric negation is not applicable. (x) First return value is the operator result.
__eq left == right or left != right when both operands are tables and are not the same table identity. (left, right) Truthiness of the first return value determines equality; != negates it. Primitive values and same-identity tables use raw equality and do not call __eq.
__lt left < right, or left > right after operand reversal. (left, right) for <; (right, left) for >. Truthiness of the first return value determines the comparison.
__le left <= right, or left >= right after operand reversal. (left, right) for <=; (right, left) for >=. Truthiness of the first return value determines the comparison. There is no fallback to __lt; if neither operand supplies __le, the operation raises a comparison error.
__concat left .. right when primitive string/number concatenation is not applicable. (left, right) First return value is the concatenation result.
__len #x for a table x. (x) First return value is the length result. Library APIs that require an integer length may reject non-integer or negative results.
__call Calling a non-function table value, t(...). (t, ...) All return values become the call result.
__tostring tostring(x) for a table with this metamethod. (x) Must return a string; other results raise a runtime error.
__metatable getmetatable(x) and setmetatable(x, mt) for a table whose current metatable has a non-nil raw __metatable field. Not called; read as a field. getmetatable returns this value instead of the real metatable. Attempts to change the protected metatable raise a runtime error.

Raw operations bypass metamethod dispatch by contract:

  • rawget never invokes __index;
  • rawset never invokes __newindex and can create, replace, or remove raw keys directly;
  • rawequal never invokes __eq;
  • rawlen never invokes __len.

__metatable protects the metatable slot, not arbitrary table contents. If a program already holds a reference to the metatable table, ordinary writes to that table remain ordinary table writes.

Implementation Extensions

The current implementation also supports __pairs for pairs(x) and __name as a tostring fallback prefix. These are implementation extensions, not part of the v1.0 stable table/metamethod contract above. Metamethods for integer floor division, bitwise operators, finalizers, weak tables, binary chunks, and Lua debug-slot protocols are not v1.0 stable contract unless a later spec revision names them explicitly.

vec := {x: 3}
mt := {
    __add: func(a, b) { return {x: a.x + b.x} },
    __eq: func(a, b) { return a.x == b.x },
    __tostring: func(a) { return "vec(" .. a.x .. ")" },
}
setmetatable(vec, mt)
other := setmetatable({x: 4}, mt)
sum := vec + other
assert(sum.x == 7)
assert(vec == setmetatable({x: 3}, mt))
assert(tostring(vec) == "vec(3)")
num_mt := {}
num := func(value) {
    return setmetatable({value: value}, num_mt)
}

num_mt.__add = func(a, b) { return num(a.value + b.value) }
num_mt.__sub = func(a, b) { return num(a.value - b.value) }
num_mt.__mul = func(a, b) { return num(a.value * b.value) }
num_mt.__div = func(a, b) { return num(a.value / b.value) }
num_mt.__mod = func(a, b) { return num(a.value % b.value) }
num_mt.__pow = func(a, b) { return num(a.value ** b.value) }
num_mt.__unm = func(a) { return num(-a.value) }
num_mt.__concat = func(a, b) { return "num:" .. a.value .. ":" .. b.value }

a := num(5)
b := num(2)
assert((a + b).value == 7)
assert((a - b).value == 3)
assert((a * b).value == 10)
assert((a / b).value == 2.5)
assert((a % b).value == 1)
assert((a ** b).value == 25)
assert((-a).value == -5)
assert(a .. b == "num:5:2")
callable := setmetatable({base: 10}, {
    __call: func(self, value) {
        return self.base + value, self.base - value
    },
})

sum, diff := callable(3)
assert(sum == 13)
assert(diff == 7)

mt := {__metatable: "locked"}
protected := setmetatable({}, mt)
assert(getmetatable(protected) == "locked")

ok, err := pcall(func() {
    setmetatable(protected, nil)
})
assert(!ok)
assert(type(err) == "string")

mt.__metatable = nil
assert(setmetatable(protected, nil) == protected)
left := setmetatable({name: "left"}, {
    __add: func(a, b) { return a.name .. "+" .. b.name },
})
right := setmetatable({name: "right"}, {
    __add: func(a, b) { return a.name .. "->" .. b.name },
})
plain := {name: "plain"}

assert(left + right == "left+right")
assert(plain + right == "plain->right")
mt := {}
mt.__eq = func(a, b) { return a.key == b.key }
mt.__lt = func(a, b) { return a.key < b.key }

a := setmetatable({key: 1}, mt)
b := setmetatable({key: 1}, mt)
c := a

assert(a == b)
assert(rawequal(a, c))
assert(!rawequal(a, b))
assert(pcall(func() { return a <= b }) == false)

mt.__le = func(a, b) { return a.key <= b.key }
assert(a <= b)

__index and __newindex table redirects are ordinary table operations on the redirect target. They can chain through more metatables, and the same raw-key rule is applied at each hop. A function-valued __index or __newindex stops the chain by handling the operation directly.

base := {answer: 42}
middle := setmetatable({}, {__index: base})
obj := setmetatable({}, {__index: middle})
assert(obj.answer == 42)

log := []
sink := setmetatable({}, {
    __newindex: func(_, key, value) {
        append(log, key .. ":" .. value)
    },
})
proxy := setmetatable({}, {__newindex: sink})
proxy.event = "saved"
assert(log[1] == "event:saved")
assert(rawget(proxy, "event") == nil)
t := {}
setmetatable(t, {__index: t})
return t.missing
t := {}
setmetatable(t, {__newindex: t})
t.missing = 1

Length And Sequence Traversal

The length operator #x uses the value’s ordinary length behavior and may consult __len. rawlen(x) bypasses __len for tables and strings.

t := setmetatable({}, { __len: func(_) { return 99 } })
assert(#t == 99)
assert(rawlen(t) == 0)

Sequence length on sparse tables follows Leia runtime behavior. Programs that depend on sparse length edge cases should pin that behavior with tests.

Iteration order for hash-like table keys is not a v1.0 stable contract. Programs may depend on pairs visiting each key that remains present during an ordinary traversal, but not on the order of those visits. ipairs is the portable sequence traversal form for consecutive positive integer keys starting at 1; it stops at the first missing or nil element.

t := [10, 20]
t[4] = 40
t.name = "Ada"
seen := []
for _, value := range ipairs(t) {
    append(seen, value)
}
assert(#seen == 2)
assert(seen[1] == 10)
assert(seen[2] == 20)

count := 0
for _ := range pairs(t) {
    count = count + 1
}
assert(count == 4)

Concurrency

Leia supports Go-style concurrent tasks and channels. The stable contract is intentionally small: tasks run independently, channels are the synchronization primitive, and host policies control cancellation and resource limits.

Tasks

A go statement starts a concurrent task:

go_stmt = "go" call_expr ;

The callee and all arguments are evaluated exactly once in the current task before the new task is started. Argument evaluation is sequenced before the task start: side effects needed to compute the call happen in the spawning task, and an error while evaluating the callee or an argument prevents the task from being created. The new task then calls the evaluated function or method with those evaluated arguments. Later changes to variables used to compute the callee or arguments do not change that call, though captured tables and other reference values remain shared values.

func worker(input, output) {
    value := <-input
    output <- value * 2
}

input := make(chan)
output := make(chan)
go worker(input, output)
input <- 21
answer := <-output
assert(answer == 42)
func fail_arg() {
    error("argument failed")
}

go func(value) {
}(fail_arg())

A go statement accepts only function-call and method-call forms. Starting a task may raise a runtime error if a host goroutine budget is exhausted. Return values from the task are discarded.

Each task has its own error boundary. An uncaught error in a spawned task does not become a return value of the spawning task and is not caught by a pcall or xpcall that only protected the go statement. Implementations report uncaught task errors through their runtime diagnostic path. The stable contract does not require such an error to synchronously stop the spawning task at a particular source location.

Tasks are scheduled by the implementation and host runtime. Implementations may run tasks on host threads where safe, but Leia does not promise a particular thread, interleaving, fairness policy, or instruction-level preemption point. Programs that need completion should communicate explicitly, usually by sending a result on a channel.

Channels

A channel is created with make(chan) or make(chan, capacity). The capacity must be a non-negative integer. make(chan) is equivalent to make(chan, 0) and creates an unbuffered channel. type(ch) returns "channel"; v1.0 has no script-visible send-only, receive-only, or direction-parameterized channel types.

ch := make(chan, 2)
assert(type(ch) == "channel")
assert(cap(ch) == 2)
assert(rawlen(ch) == 0)

ch <- "a"
ch <- "b"
assert(rawlen(ch) == 2)

Channels carry ordinary Leia values. Sending uses ch <- value; receiving uses <-ch. A receive can be used as a single value or in comma-ok form:

ch := make(chan, 2)
ch <- "a"
ch <- "b"
close(ch)

first, ok1 := <-ch
second, ok2 := <-ch
third, ok3 := <-ch

assert(first == "a" && ok1)
assert(second == "b" && ok2)
assert(third == nil && ok3 == false)

For a single channel, successful sends are received in send order. With one sender this is program order. With multiple senders, the order is the order in which sends successfully commit to that channel; scheduling determines which sender commits first. Buffered channels preserve FIFO order among values already queued in that buffer.

Unbuffered channels rendezvous: a send and its matching receive complete together. A buffered send may complete before a receiver accepts the value if the buffer has space. A send blocks while the channel buffer is full. A receive blocks while the channel buffer is empty and the channel is still open.

Closing a channel prevents future sends. Receives continue to drain buffered values before reporting closure. Once the channel is closed and empty, a receive expression yields nil; comma-ok receive yields nil, false. Sending on a closed channel or closing an already closed channel is a runtime error.

ch := make(chan, 1)
close(ch)

value, ok := <-ch
assert(value == nil)
assert(ok == false)

sent, err := pcall(func() {
    ch <- "late"
})
assert(sent == false)
assert(string.find(err, "send on closed channel", 1, true) != nil)

closed, err := pcall(close, ch)
assert(closed == false)
assert(string.find(err, "closed channel", 1, true) != nil)
ch := make(chan)
close(ch)
ch <- "late"

Closing a channel is not a broadcast of a value. It only makes future receives ready after the channel has no queued values left. If multiple tasks receive from the same closed and empty channel, each receive observes nil or nil, false; no task owns the closed state exclusively.

Receiving from or sending to a non-channel value is a runtime error. Creating a channel with a negative or non-integer capacity is a runtime error. Host options may impose a maximum channel capacity.

Select

select waits on channel send or receive cases:

select_stmt = "select" "{" { select_case } "}" ;
select_case = ( "case" recv_clause | "case" send_clause | "default" ) ":" block ;
recv_clause = [ identifier [ "," identifier ] ":=" ] "<-" expression ;
send_clause = expression "<-" expression ;

A receive case may bind the received value, or the received value and the receive-ok boolean. Those bindings are scoped to the selected case body. A send case evaluates and sends the case value when that case is selected. The stable contract does not require expressions belonging to unselected send or receive cases to be evaluated before the selection decision, except for work needed by the implementation to determine whether a communication can proceed. Programs must not depend on side effects in unselected cases.

If one or more communication cases can proceed, one ready case is chosen. If no communication case can proceed and a default case exists, default is chosen immediately. If no case can proceed and no default exists, select blocks until a case can proceed. If multiple cases are ready, the implementation may choose any ready case; portable programs must not assume fairness or round-robin ordering.

ch := make(chan, 1)
selected := ""

select {
case value := <-ch:
    selected = "receive"
default:
    selected = "default"
}
assert(selected == "default")

ch <- 7
select {
case value, ok := <-ch:
    selected = "ready"
    assert(value == 7)
    assert(ok == true)
default:
    selected = "wrong"
}
assert(selected == "ready")

A receive case on a closed and empty channel is ready and receives nil, false. A send case on a closed channel raises a runtime error if selected or if a blocking select discovers the closed send case. A select with no cases is a runtime error.

ch := make(chan, 1)
close(ch)

state := "unset"
select {
case value, ok := <-ch:
    state = "closed"
    assert(value == nil)
    assert(ok == false)
default:
    state = "default"
}
assert(state == "closed")
select {
}

Timeouts and cancellation are library protocols built on channels. For example, time.after(duration) returns a channel that becomes ready after the duration. The language-level select semantics do not require a special timeout case.

Defer, Errors, And Task Boundaries

defer call() is function-scoped. It evaluates the callee and arguments when the defer statement executes, and runs deferred calls in last-in, first-out order when the current function returns or unwinds through a protected boundary. Concurrent tasks have their own defer stack; a task started by go does not inherit the spawning task’s pending defers, and deferred calls registered inside that task do not run in the spawning task.

events := []
func record(x) {
    append(events, x)
}

func work() {
    defer record("outer")
    defer record("inner")
}

work()
assert(events[1] == "inner")
assert(events[2] == "outer")
done := make(chan, 1)

func work() {
    defer func() {
        done <- "deferred in task"
    }()
}

go work()
assert(<-done == "deferred in task")

Protected calls (pcall and xpcall) catch runtime errors that occur while the protected function is executing in the same task, including deferred-call errors from that protected function. They do not catch uncaught errors from other tasks started with go; use channels or runtime diagnostics to report those errors.

ok, err := pcall(func() {
    defer error("cleanup failed")
})
assert(ok == false)
assert(string.find(err, "cleanup failed", 1, true) != nil)

A task that needs to report failure to another task should send an explicit result value, such as {ok: false, err: message}, on a channel. This keeps error ownership visible in the script and avoids depending on scheduler timing.

Cancellation

The embedding host may run programs with a cancellation context, deadline, step budget, goroutine budget, or other resource budget. Cancellation is observed at implementation-defined checkpoints such as statement boundaries, loop checks, blocking channel helpers, and cancellation-aware library calls. The language does not promise that every primitive operation is preemptible.

Script-level cancellation APIs, such as context.withCancel, are ordinary library values. They become useful when a task or library call selects on the context’s done channel or explicitly checks the context state.

ctx, cancel := context.withCancel()
done := ctx.done
out := make(chan, 1)

go func() {
    total := 0
    for {
        select {
        case signal := <-done:
            out <- total
            return;
        default:
            total = total + 1
            if total == 3 {
                cancel()
            }
        }
    }
}()

result := <-out
assert(result >= 3)
assert(ctx.cancelled())

Cancellation, budget exhaustion, process termination, and host shutdown may stop execution without running arbitrary future script steps. Defers that are already on the stack run when execution unwinds through a Leia protected boundary; host termination outside such a boundary is controlled by the embedding contract.

Memory Ordering

Channel communication is the synchronization primitive in the stable language contract:

  • The side effects used to evaluate a go statement’s callee and arguments are sequenced before the started task begins executing that call.
  • A successful send synchronizes with the receive that obtains that value.
  • Closing a channel synchronizes with receives that observe the closed state after queued values have been consumed.
  • For unbuffered channels, send and receive complete as a rendezvous; neither side completes before the other side is ready.
  • For buffered channels, a send may complete before a receiver accepts the value, but the value and writes sequenced before the send become visible to the receiver that later obtains that value.
  • A receive that observes channel closure is ordered after the successful close(ch) call and after any queued values that receive is required to drain.

Outside those synchronization edges, concurrent tasks have no specified memory ordering. Programs must not depend on the relative timing of ordinary reads and writes performed by different tasks unless they are ordered by channels or by a host-provided synchronization object whose module contract says so.

Mutable tables are shared by identity. Concurrent unsynchronized mutation of the same table has unspecified script-level results: reads may observe any interleaving allowed by the implementation, and compound operations such as x = x + 1 are not atomic. Implementations must protect host memory safety, but programs that require deterministic results should transfer ownership through channels or guard shared tables with a synchronization library.

requests := make(chan, 4)
replies := make(chan, 4)

go func() {
    state := {count: 0}
    for i := 1; i <= 4; i++ {
        delta := <-requests
        state.count = state.count + delta
        replies <- state.count
    }
}()

for i := 1; i <= 4; i++ {
    requests <- 1
}

last := 0
for i := 1; i <= 4; i++ {
    last = <-replies
}
assert(last == 4)

The contract intentionally does not define atomics, volatile variables, data-race diagnostics, or visibility guarantees for unsynchronized table or global variable accesses. Host modules may expose additional synchronization APIs, but those APIs must document their own ordering rules.

Optimization Contract

JIT, bytecode, and interpreter execution must preserve concurrency-visible behavior: communication order, channel close behavior, synchronization results, select readiness, protected error boundaries, and host cancellation boundaries must not change when optimizations are enabled. Optimizers must not move ordinary reads, writes, calls, defers, channel operations, select, close, task start, or protected-call boundaries across a synchronization or error boundary in a way that changes the behavior described in this chapter.

Optimizations may change timing, scheduling, batching, or whether a task reaches a cancellation checkpoint before another task, except where the program has established ordering through channels or a specified synchronization library. JIT side exits and deoptimization must resume with the same observable channel, defer, cancellation, and protected-error state as interpreter execution.

Tagged Dialects

Tagged dialects are Leia’s generic DSL extension mechanism. They let a source file embed domain syntax while keeping the language core, evaluator, VM, JIT, security model, and Go embedding API small and uniform.

Dialect syntax is part of the language. Dialect semantics are supplied by a registered dialect implementation. A dialect implementation must return ordinary Leia values, (value, err) result pairs, or runtime errors according to the contract described in this chapter.

Forms

A tagged raw expression has one of these forms:

tag`body`
tag!`body`
tag```body```
tag!```body```

The fenced forms may span lines and may contain single backticks inside the body. The short raw form cannot contain a backtick inside the body. $ is the standard shorthand tag for the shell dialect.

A tagged block has one of these forms:

tag { fields }
tag! { fields }

Tagged blocks use normal Leia block/table syntax at the outer boundary. The dialect owns the meaning of the fields inside its registered block contract.

An extension may define additional tagged block contracts, but such contracts must still enter the runtime through the registered dialect boundary. Core Leia does not assign domain-specific meaning to a tag name by itself.

Interpolation

Untagged raw strings do not interpolate. Tagged raw strings and fenced tagged raw strings may contain ${expr} interpolation segments.

For each interpolation segment, Leia evaluates expr in the surrounding lexical scope before the dialect receives the body. The dialect receives the raw body plus the evaluated values. The dialect decides whether an interpolated value is escaped as text, encoded as JSON, bound as a parameter, converted to a domain value, or rejected.

Literal source text and interpolated values are distinct at the dialect boundary. Source text is preserved byte-for-byte; only ${expr} results pass through the dialect’s value encoder. This lets a dialect safely treat a tagged body such as sum ${xs} as raw dialect source sum plus an encoded Leia value, instead of forcing every value through generic string conversion.

Interpolation must preserve normal Leia evaluation order and error behavior. If evaluating an interpolation expression fails, the dialect implementation is not invoked.

name := "Ada"
profile := json`{"name": "${name}", "score": ${40 + 2}}`
assert(profile.name == "Ada")
assert(profile.score == 42)

Bang Form

The non-bang form returns according to the dialect’s ordinary result contract. For dialects with recoverable parse or execution errors, this usually means a result plus a structured error value.

The bang form is fail-fast. When the dialect reports an error that is recoverable in the non-bang form, the bang form raises a runtime error instead. Successful bang and non-bang evaluation must return equivalent result values.

Runtime Boundary

A dialect boundary never creates a second language runtime. Dialects do not get private lexical scope, hidden prompt memory, ambient host capabilities, or separate scheduling semantics. They execute through registered runtime helpers, host callbacks, and capability checks.

Dialect implementations must obey:

  • the active host capability policy;
  • resource budgets and cancellation;
  • module and import policy;
  • interpreter-visible error behavior;
  • VM and JIT equivalence requirements.

Optimizations may recognize stable dialect shapes, but optimized execution must preserve the same visible result, error, capability, and budget behavior as the interpreter path.

Registration

Built-in dialects are installed by the standard runtime configuration. Embedders may register host-defined dialects through the Go API or through standard library helpers when those helpers are enabled.

Registration defines:

  • the tag name and aliases;
  • whether raw expressions are accepted;
  • whether block forms are accepted;
  • the required capabilities;
  • the result and error shape;
  • whether bang fail-fast behavior is supported.

Source syntax alone does not import or enable a dialect. A source tag such as json, sh, turn, or agent selects a registered implementation; if no implementation is installed, evaluation fails.

Standard Dialect Families

Leia’s standard dialect families include:

  • optional data dialects for high-throughput in-memory analytics;
  • data and protocol dialects such as json, yaml, csv, urlquery, and headers;
  • host automation dialects such as sh, $, cmd, glob, and env;
  • document and tabular dialects such as xlsx and excel;
  • web/server dialects such as serve;
  • optional LLM dialects such as model, tool, turn, and agent.

The standard dialect list is discoverable through the runtime capabilities surface. Each dialect family may have its own reference and, for stable core dialects, its own normative specification chapter.

Conformance

Stable dialect syntax requires parser coverage, interpreter coverage, and at least one semantic gate that exercises accepted and rejected forms. A stable dialect family must document its result shape and error behavior before it is advertised as public surface.

Adding a new standard dialect family requires:

  • lexer or parser coverage for any new syntax;
  • runtime tests for raw, bang, interpolation, and block forms that the dialect accepts;
  • capability-boundary tests when the dialect reaches host resources;
  • reference documentation for user-facing result shapes;
  • a feature-matrix entry when the dialect is part of the stable release surface.

AI Dialect Syntax

Leia’s AI dialect is an optional standard-library runtime exposed through tagged dialect forms and ordinary modules. The language implementation must route these forms through the same llm, msg, history, dialect, and host-provider paths as direct library calls. There is no separate AI execution engine, and Leia is not an AI-intrinsic language. AI is one DSL implementation on top of the generic tagged-dialect mechanism.

An AI operation is deterministic until it reaches a provider, host callback, or tool body with side effects. Conformance tests that need stable behavior should use host-side replay records or mock providers.

Design Principle

AI is a dialect layer, not language intrinsic. The grammar may provide small tagged forms for common AI workflows, yet the language core does not gain model-specific evaluation rules, hidden prompt state, or a privileged AI scheduler. Every AI dialect form must lower to ordinary runtime helpers and ordinary values before provider I/O occurs. This is the same extension boundary used by other standard and host-defined dialects.

This principle has four consequences:

  • Dialect syntax is a convenience boundary, not a semantic fork. agent, turn, tool, model, prompt message objects, output schemas, budgets, trace, and replay all use the same llm, msg, history, and host-provider paths that direct library calls use.
  • Prompts and messages are data. A prompt object is a normalized message table when it contains role and text; it is not a special string literal, hidden compiler directive, or lexical scope.
  • Structured output is validation over provider results. Request fields may ask the provider for a shape and the runtime may validate that shape, but Leia does not treat model text as typed language syntax unless a helper explicitly parses and validates it.
  • Operational controls are host-visible. Budgets, cancellation, approval, trace, record, and replay must remain observable through runtime options and host hooks instead of being embedded as unreachable language behavior.

Stable Contract

The stable AI dialect contract is the following lowering boundary:

Source construct Stable runtime meaning
model { ... } Register model aliases through llm.register_models.
tool { ... } Build a tool descriptor through llm.tool.
turn { ... } Perform exactly one provider request through llm.turn.
agent { ... } Build a callable agent through llm.agent.
evaluate "name" { ... } Declare a regression case discovered by leia evaluate.
llm.*, msg.*, history.* Lower-level helpers for custom flows and message history.

The dialect forms are not declarations with hidden lexical rules. Each form evaluates to an ordinary Leia value or result pair according to its runtime helper. Tools and agents are ordinary values that can be assigned, passed, returned, inspected, and stored in tables.

The implementation must not fork behavior between dialect syntax and stdlib calls. For example, a tool created with tool { ... } and a tool created with llm.tool(...) must go through the same tool validation, capability metadata, dispatch, tracing, and replay paths.

Lower-level AI helpers such as llm.workflow, llm.step, llm.stage, llm.workflow_graph, llm.doc, llm.collection, llm.retrieve, llm.context, llm.evidence, llm.schema, llm.output_schema, llm.schema_info, llm.handoff, llm.delegate, and llm.sections, llm.tool_info, llm.tool_schema, llm.validate_tools, llm.policy, llm.check_policy, llm.approval_trace, and llm.report_artifact_contract are part of the same runtime layer. They are ordinary functions that return ordinary tables, callables, or (value, err) pairs. They must not introduce separate prompt memory, provider bypasses, hidden network access, global document stores, hidden approval stores, report renderers, or a second orchestration engine.

This stable lowering is observable without a live provider. A tagged tool must preserve helper-visible capability metadata:

search_runbook := tool {
    name: "search_runbook"
    params: ["service"]
    description: "Search local runbooks."
    requires: ["docs.read", "runbooks.read"]
    fn: func(service) {
        return "runbook:" .. service, nil
    }
}

caps := llm.tool_caps([search_runbook])
assert(#caps == 2)
assert(caps[1] == "docs.read")
assert(caps[2] == "runbooks.read")

ok, err := llm.check_tools([search_runbook], ["docs.read", "runbooks.read"])
assert(ok == true)
assert(err == nil)

Models

model { ... } registers script-visible aliases for the current VM.

model {
    default: "fast"
    fast: {
        protocol: "anthropic_compatible"
        base_url: os.getenv("LEIA_LLM_BASE_URL")
        api_key: os.getenv("LEIA_LLM_API_KEY")
        provider_model: os.getenv("LEIA_LLM_MODEL")
    }
}

A model field value may be a string alias or a provider configuration table. The alias named default is used when a request does not specify a model and the host has not installed a more specific default. Alias cycles are invalid.

Source code must not embed API keys as string literals. Credentials belong in environment variables, host-provided configuration, or a host-owned provider factory. Hosts decide whether script-declared provider configuration is honored.

The normalized host provider configuration shape is llm.ProviderConfig with Name, Protocol, BaseURL, APIKey, ProviderModel, and Provider fields. Script model aliases are stable routing names. They must not be treated as secret scopes, authorization grants, or guarantees that a specific remote provider will be used. A host may ignore, rewrite, or reject script-declared provider configuration according to policy.

Messages And History

Provider requests carry an ordered array of normalized message tables. Leia does not require a special message block syntax. Scripts may build message arrays directly or through the llm and msg helper modules.

history := [
    llm.system("Answer concisely."),
    llm.user("Summarize this incident."),
]

append(history, msg.assistant("draft"))
append(history, msg.user("Now include the owner."))

Stable roles are system, user, assistant, and tool. Tool-call and tool-result messages are paired by provider call id. A later turn observes prior work only through the messages value supplied to that turn.

Prompt field blocks are message object shorthand. A table produced by prompt { role: "system", text: "..." } is valid anywhere a normalized message table is valid:

messages := [
    prompt { role: "system", text: "Answer from the runbook only." }
    prompt { role: "user", text: "How do I restart search?" }
]

The shorthand has no separate prompt lifetime. It must preserve the same role, text, name, metadata, tool-call, and tool-result fields that the msg helpers produce, subject to provider support and normalization rules. Hosts and helper modules may redact prompt text from trace or replay artifacts according to their own policies; the source-level object remains ordinary data.

Tools

tool { ... } returns a tool descriptor backed by a Leia function.

search_runbook := tool {
    name: "search_runbook"
    params: ["service"]
    description: "Search local runbooks."
    requires: ["docs.read"]
    fn: func(service) {
        return "runbook:" .. service, nil
    }
}

llm.tool_outcome(call_or_tool_message, result_or_err, opts) projects tool dispatch metadata into an ordinary table with kind: "tool_outcome", schema_version: 1, version: "tool_outcome.v1", tool_call_id, tool_name, status, result_status, result_type, result_ref, arg_names, error_kind, redaction metadata, and correlation fields. llm.tool_outcome_event(outcome, opts) projects that table into the generic trace event shape; llm.tool_outcome_trace_event is an equivalent alias. These helpers must not copy raw tool arguments, raw result values, prompt text, provider completions, response bodies, stack traces, or credential-bearing metadata into the outcome payload.

The required fields are:

Field Meaning
name Provider-visible tool name.
fn Function called when the tool is dispatched.

The optional fields are:

Field Meaning
params Ordered parameter names.
description Provider-visible description.
requires Capability labels for host policy and audit.

The tool function should return (value, nil) on success or (nil, err) on a recoverable tool failure. Tool descriptors may be placed in tools lists, passed to llm.dispatch, inspected with llm.tool_caps, or generated from ordinary functions with lower-level helpers.

Tool descriptors may also carry contract metadata for validation, inventory, and replay:

Field Meaning
capabilities Lower-level option-table alias for requires.
schema Optional tool input schema metadata.
result / output Script-visible success shape.
error Script-visible structured error shape.
replay_key Stable key template for deterministic fixture lookup.

llm.tool_schema(tool_or_tools) and llm.tool_info(tool_or_tools) must expose normalized contract tables without executing tool functions. llm.validate_tools must return (true, nil) when every supplied tool has the required contract metadata for contract-checked workflows and (nil, err) when metadata is missing or invalid. The error must be structured and include at least kind: "validation" plus the failing tool and field when known.

Tool contracts are advisory metadata. They must not authorize side effects, coerce return values, or hide runtime tool errors. Host policy, capability checks, and explicit validation remain separate steps.

llm.tool_registry(tools, opts) projects a tool list into a provider-free registry table with normalized descriptors, capability inventory, effect and approval counts, redaction metadata, and a compact summary. The registry helper does not execute tools, register host callbacks, contact providers, or authorize side effects. llm.validate_tool_registry(registry, opts) returns a validation table with ok, status, findings, and finding_count; it must flag live-network/provider-credential boundaries, duplicate tool names, missing descriptor fields, provider wire formats other than none, secret parameters, and effectful tools without explicit approval policy. llm.tool_invocation_trace projects a tool call/result boundary into the generic registry trace shape with ordered registered, schema_validated, approval_checked, invoked, and result_recorded events plus metadata-only result and provenance envelopes.

Turns

turn { ... } performs exactly one provider request and returns (result, err). It does not dispatch tools by itself.

result, err := turn {
    model: "fast"
    messages: [llm.user("Reply exactly: ok")]
    tools: [search_runbook]
    max_tokens: 32
    temperature: 0
}

Important request fields are:

Field Meaning
model Alias or provider model name.
messages Ordered message array.
user Shorthand for a one-message user request when messages is absent.
system System prompt used with user when messages is absent.
tools Tool descriptors or supported agent-as-tool values.
force_tool Force one tool by name when supported by the provider.
max_tokens Output token limit.
temperature, top_p Sampling controls.
response_format Provider response-format hint.
stream Request incremental provider output when supported.
on_stream, onStream Optional callback for streaming events.
stop Stop sequences.
metadata String metadata passed to the provider.

With streaming, callbacks and trace events may receive incremental token data. The script still receives one complete final result table after the provider finishes. A provider that does not support streaming may ignore stream or return a provider error according to its implementation.

Result fields include status, text, calls, reason, and usage. Provider or validation failures are ordinary error values, usually tables with at least kind and message.

Agents

agent { ... } returns a callable workflow value. The config function builds the request configuration for each call.

answer := agent {
    name: "answer"
    params: ["question"]
    description: "Answer with local documentation when useful."
    config: func(question) {
        return {
            model: "fast"
            system: "Use tool evidence when it helps."
            user: question
            tools: [search_runbook]
        }, nil
    }
}

result, err := answer("How do I restart search?")

As a shorthand for common prompt capsules, agent { ... } may omit config and provide request fields directly:

answer := agent {
    name: "answer"
    params: ["question"]
    model: "fast"
    instructions: prompt { role: "system", text: "Use tool evidence." }
    tools: [search_runbook]
    output: {answer: "short"}
}

The shorthand lowers to a generated config function passed to llm.agent. It does not create a second agent type. The generated function copies declarative request fields into a fresh request table for each call and then applies the same normalization as an explicit config function:

  • model, tools, output, response_format, sampling controls, metadata, budgets, and other request fields are copied as ordinary table fields.
  • When messages is absent, the first call argument is used as user.
  • instructions is copied to system unless system is already present.
  • Prompt field blocks that contain role and text are valid message tables and can be placed directly in messages.
  • If argument mapping, branching, dynamic tool selection, or persistent workflow state is needed, use an explicit config or flow function.

The required fields are:

Field Meaning
name Runtime and provider-visible agent name.
config or fn Optional function that returns an agent request table and optional error. Omit it to use declarative request fields.

Optional fields include params, description, output, and flow. Without a custom flow function, Leia executes the built-in loop:

  1. call the config function for the current agent arguments;
  2. synthesize messages from system and user when messages is absent;
  3. perform a provider turn;
  4. dispatch requested tools through llm.dispatch;
  5. append assistant-call and tool-result or tool-error messages;
  6. repeat until a final answer, provider stop, budget error, approval pause, cancellation, or unrecoverable provider/tool error.

When the flow field is present, its function owns the workflow. No hidden turn or tool dispatch happens. Custom flows should call llm.turn, llm.dispatch, msg.assistant_call, msg.tool_result, and msg.tool_error explicitly.

Custom flows that need auditable tool evidence should also use llm.tool_outcome and llm.tool_outcome_event rather than placing raw tool inputs or outputs in trace payloads.

Custom flows that need resumable or auditable agent state should project state explicitly through llm.agent_state_checkpoint(state, opts). The checkpoint is a provider-free, ref-only table with kind: "agent_state_checkpoint", schema_version: 1, version: "agent_state_checkpoint.v1", identifiers such as agent_run_id, session_id, state_version, and turn_id, ref tables such as input_refs, output_refs, and memory_refs, deterministic checkpoint_key and cache_key values, a resume_token, redaction metadata, and trace correlation fields. llm.agent_state_checkpoint_event(checkpoint, opts) projects the checkpoint into the generic trace event shape; llm.agent_state_checkpoint_trace_event is an equivalent explicit trace-named alias.

Agent state checkpoints are observational metadata. They must not persist state, perform provider I/O, import live dependencies, or copy raw prompts, raw provider completions, raw input/output values, credentials, tokens, cookies, or API keys into the checkpoint payload. Durable storage, locking, and resume execution remain host or application responsibilities.

For complex flows that need exact control over metadata or call parameters, scripts may use the lower-level helper directly:

incident := llm.agent("incident", incident_config, incident_flow, {
    params: ["service"]
})

Workflow Helpers

llm.workflow(steps) creates a sequential workflow value from llm.step(name, fn, opts), llm.stage(name, fn, opts), or plain functions. The workflow exposes run(input, opts) and mock(fixtures). A step may call llm.turn, call an agent, dispatch tools, or perform non-AI work.

Workflow execution is deterministic except for the operations performed inside steps. Each step receives a context table with input, initial_input, previous, accumulated results, and named context. The helper records ordered step results and a name-indexed context table, feeds the prior step’s text or value to the next step, and returns a final workflow result plus an error value when a step fails. Mock fixtures replace matching steps for tests and examples. Fixture lookup is deterministic: fixture_key when declared, then step or stage name, then 1-based numeric step index.

llm.stage has the same execution semantics as llm.step, but marks the value as a stage and preserves stage-oriented metadata in opts, such as depends_on, input_ref, output_ref, input_schema, output_schema, capability, and fixture_key.

llm.workflow_graph(config) wraps the same runner with a static graph contract. It accepts ordered stages and optional edges, validates that dependencies and edges reference known earlier stages, exposes graph metadata, and then executes sequentially in the supplied order. Stage metadata that is useful for replay and audit evidence, including capability, fixture_key, input_ref, output_ref, input_schema, output_schema, and depends_on, is preserved in graph metadata and in each executed step’s trace.metadata.

For graph contracts that should not execute anything, llm.plan_node(id, opts), llm.planning_graph(nodes, edges, opts), llm.validate_planning_graph(graph, opts), and llm.planning_trace(graph, events, opts) build provider-free planning metadata only. These helpers normalize plan nodes, dependency edges, retry policy, branch/merge metadata, trace evidence refs, validation findings, and trace summaries. They do not schedule work, sleep for retries, run branches in parallel, call providers, or persist state. planning_graph is therefore a portable contract and replay-evidence shape; workflow_graph remains the sequential execution helper.

These helpers are sequencing helpers, not durable orchestration. They do not imply parallelism, retry policy, persistence, transactions, approval storage, or provider calls outside the step functions. Provider replay still observes only the turns actually made by step bodies.

Agent As Tool

An agent can be exposed as a tool with llm.agent_as_tool(agent_value). The wrapper preserves the agent’s name and metadata where available.

supervisor := agent {
    name: "supervisor"
    params: ["question"]
    config: func(question) {
        return {
            model: "fast"
            user: question
            tools: [llm.agent_as_tool(answer)]
        }, nil
    }
}

llm.handoff(agent, opts) and llm.delegate(agent, opts) are aliases for the same agent-as-tool composition boundary. They may copy tool metadata from opts and agent metadata, including name, description, params, requires, and script-visible output. The provider-facing tool input schema must describe tool arguments only; an agent’s output shape must not be sent as the tool input schema. If a nested agent returns a pending approval result, tool dispatch must surface a structured pending error instead of converting it to ordinary tool text.

Agent-as-tool wrappers must expose trace_contract: "agent_tool.v1" and must attach generic trace nodes to successful or structured-error results when available. Trace nodes are ordinary tables with:

Field Meaning
type Node kind such as agent_tool, agent, workflow, or workflow_step.
name Node name.
status Node status.
parent Optional parent reference.
children Ordered child trace nodes.
error Structured error table, if any.
budget Budget error copy when the node failed on budget.
cancel Cancellation error copy when the node was cancelled.
metadata Metadata table.

This result-level trace contract is distinct from host trace sinks. It must not require prompt text or tool result text to be present.

Output Validation

Agents and turns may request structured output through request fields such as output or provider response-format hints. output is a script-visible shape contract and response_format is a provider-facing hint; either may be present without changing Leia’s core type system. Built-in agent execution validates known output shapes when configured to do so. Custom flows that return arbitrary values are responsible for calling llm.validate_output(value, schema) if they want the same check.

Validation failures are runtime errors or (nil, err) results according to the helper being used. They must not be silently converted to provider text. When validation succeeds, the validated value is still an ordinary Leia value. When parsing or validation fails, callers must handle a structured error rather than relying on best-effort text coercion.

llm.schema(spec) normalizes lightweight schema specs and JSON Schema-like tables to JSON Schema tables. llm.schema_info(spec) and llm.schemaInfo(spec) return inspection metadata including kind. llm.output_schema(name, spec, opts) returns a provider-facing response_format table using JSON Schema, with strict mode enabled unless options override it. These helpers define request and validation metadata only. They do not add a language-level type checker for model text.

Retrieval Context And Evidence

llm.doc/llm.document, llm.collection/llm.docs, llm.retrieve/llm.search, llm.context, and llm.evidence package local source text for AI requests. Documents are ordinary tables with text and optional metadata. Collections are ordinary tables with ordered docs and count. Retrieval returns local matches according to the runtime helper’s ranking rules and options such as limit and label.

Document metadata may include id, title, source, tags, sections, and additional script fields. Section maps are ordinary source text fields used by retrieval and report assembly helpers; they do not create a global document index or hidden memory.

llm.context and llm.evidence create message wrappers. When a turn, agent, or section request contains context or evidence, the runtime appends the corresponding normalized messages before the provider request. These helpers do not provide persistence, embeddings, vector search, citation verification, secret filtering, or authorization. Hosts and tools must still enforce access policy before source text is added to a request.

llm.memory_outcome(context_or_matches, opts) and its alias llm.retrieval_outcome(...) project retrieval/context results into ordinary tables with kind: "memory_outcome", version: "memory_outcome.v1", source_kind, status, result_status, query, label, match_count, top_id, top_score, top_match, and ordered match_refs. llm.memory_outcome_event(outcome, opts) projects the outcome into the generic trace event shape; llm.memory_outcome_trace_event and llm.retrieval_outcome_event are equivalent aliases. The projection is ref-only by default and must not copy raw memory text, snippets, prompt text, provider completions, or hidden store state into trace payloads.

llm.evidence_outcome(evidence_or_refs, opts) and its alias llm.citation_outcome(...) project evidence refs into ordinary tables with kind: "evidence_outcome", schema_version: 1, version: "evidence_outcome.v1", source_kind, status, result_status, evidence_count, citation_count, source_count, artifact_count, top evidence identity fields, ordered evidence_refs, redaction metadata, and correlation fields. llm.evidence_outcome_event(outcome, opts) projects the outcome into the generic trace event shape; llm.evidence_outcome_trace_event and llm.citation_outcome_event are equivalent aliases. The helper is observational only: it does not verify citations, persist documents, authorize source access, call providers, or copy raw evidence text, snippets, prompts, completions, or credential-bearing metadata into trace payloads.

llm.model_io_envelope(spec, opts) projects a model request/response boundary into an ordinary provider-free metadata table with kind: "model_io_envelope", schema_version: 1, version: "model_io_envelope.v1", operation/capability fields, provider/model routing metadata, redacted request headers/auth, message/tool summaries, response completion summaries, usage totals, schema hints, refs, redaction metadata, and a compact summary. llm.model_call_envelope(...) and llm.turn_envelope(...) are aliases. The helper never calls a provider, executes tools, stores raw prompts/messages/completions, or requires provider credentials. It is the shared shape for dialects and packages that need model-call provenance without making model I/O a built-in language concept.

Section Generation

llm.sections(config) and llm.generate_sections(config) run a shared request configuration once per section descriptor in config.sections. Each section must have a non-empty name and may provide instructions or prompt, output, evidence, messages, or other request fields. Top-level request fields such as model, messages, tools, context, and evidence are copied into each section request unless section-local fields override them according to the helper’s normalization rules.

The result is (generated, nil) on success. generated.sections preserves section order, generated.results indexes raw agent results by section name, and generated.values indexes parsed or validated values by section name. Validation and provider failures are returned as ordinary errors. Section generation is a convenience for independent report parts; it does not guarantee cross-section consistency, automatic citations, hidden shared memory, or a document rendering pipeline.

Report Artifact Contracts

llm.report_artifact_contract(opts) returns an ordinary contract table for report-producing workflows. The helper must accept an optional options table with name and version string fields and return a table containing:

Field Meaning
name Contract name.
version Contract version.
offline_verifiable Boolean indicating that the contract can be checked without a live provider.
renderer_required Boolean indicating whether the contract itself requires rendering.
schemas Tables for report sections, chart specs, artifact manifests, and source annotations.
manifest_template Starting manifest table with contract/version metadata.
required_markers Ordered marker names expected in report outputs or manifests.

The schemas define generic report data boundaries: ordered sections, chart intent, artifact metadata, source annotations, freshness warnings, and AI disclosure. The helper must not render reports, fetch data, verify citations, or encode a product-specific API. Generated report data remains ordinary Leia tables and should be validated with llm.validate_output, schemas, or ordinary assertions.

Budgets, Cancellation, And Approval

Budgets may be attached to agent or turn option tables and may also be enforced by the host. Stable dimensions include turn count, call count, token count, and time. Provider usage may include cost metadata, but Leia does not promise money accounting as a stable script-level budget dimension.

A budget check gates runtime work; it does not change expression evaluation outside AI helpers. A budget may stop a built-in agent loop before the next provider request, before the next tool dispatch, or after usage is reported by a provider. Custom flows that bypass the built-in loop should use lower-level budget helpers when they need the same accounting.

Hosts may cancel a running AI operation through the VM context or provider context. Cancellation should stop future provider/tool calls and return a recoverable error when possible.

Human approval and pause/resume state are runtime features of the llm helper layer. Dialect syntax must preserve those hooks by lowering through the helper layer instead of bypassing it.

llm.policy(opts) returns a capability policy table with kind: "capability_policy", version: "capability_policy.v1", default: "deny_high_risk", deny, and optional allow. The default denied capability classes are trading, portfolio, generated-code, network, credential, and publish. llm.check_policy(tool_or_tools, policy) must inspect tool capability labels and return (true, nil) or (nil, err). A denied error must have kind: "policy" and include the denied capability, denied class, and policy version when known.

llm.policy_outcome(tool_or_tools, policy, opts) returns the same policy decision as a non-throwing table with kind: "policy_outcome", version: "policy_outcome.v1", status, result_status, capabilities, policy metadata, and booleans such as allowed, denied, clean_skip, approval_required, and side_effect_allowed. opts.clean_skip represents a host or dependency gate that skipped execution without granting the capability.

llm.policy_outcome_event(outcome, opts) projects a policy outcome into the generic trace event shape. llm.policy_outcome_trace_event is an equivalent alias for callers that prefer explicit trace naming. The default event type is policy_outcome. The projection may include safe policy decision fields, capabilities, dependency skip metadata, and correlation IDs, but must not clone executable tool values or raw error tables into the event payload.

llm.budget_outcome(err, opts) projects a budget or deadline error table into a portable, non-throwing outcome with kind: "budget_outcome", version: "budget_outcome.v1", source_kind, status, result_status, ok, blocked, and safe fields such as dimension, limit, used, and message. llm.budget_outcome_event(outcome, opts) projects that result into the generic trace event shape; llm.budget_outcome_trace_event is an equivalent explicit trace-named alias. These helpers are observational only: they do not reserve budget, mutate accounting, call providers, or promise money accounting.

Policy checks are runtime gates over declared capabilities. They are not credential management, sandboxing, network enforcement, or proof that a tool body is harmless. Hosts must still enforce real resource boundaries.

llm.approval_trace(table) returns a portable approval replay table with kind: "approval_replay_trace" and version: "approval_replay.v1". It may copy token, pending, approval, result, and policy fields from the input. If the approval contains ok: true, the derived decision status is approved; otherwise it is denied and may include reason. Approval traces are audit/replay data. They must not by themselves resume execution or mutate a host approval store.

llm.approval_trace_event(trace, opts) projects an approval replay trace into the generic trace event shape. The default event type is approval_replay_trace; opts.event_type may override it for package-specific projections. The projection must copy only audit identity and summary fields such as decision status, result status, tool or operation name, capability, policy version/default, and correlation IDs. It must not clone raw pending arguments, approval tokens, or raw result values into the event payload.

Trace, Record, And Replay

Hosts can install trace sinks, recorders, or replay providers. Trace events are metadata-oriented and should avoid prompt text and tool result values unless an explicit host policy permits capture.

Recorders observe normalized provider requests and results after dialect lowering, so replay does not depend on whether the source used turn { ... }, declarative agent { ... }, or direct llm.turn. Replay fixtures are strict: a request mismatch, exhausted fixture, or leftover unconsumed turn is a failure. Updating a replay fixture is an explicit command or host action, not an ordinary script side effect.

Trace and replay serve different purposes. Trace is for audit and operational visibility and may redact content. Replay is for deterministic execution and must contain enough normalized request/result data to satisfy the replay provider under the host’s chosen recording policy.

llm.replay_record(spec) and llm.replay_fixture(records, opts) are script helpers for provider-free fixtures. They construct ordinary tables around the same identity fields used by the strict ordered matcher: operation, capability, replay_key, and request_hash. They must not create hidden provider state, bypass host policy, or introduce a second matching algorithm. The fixture matcher must reuse the same llm.replay_index semantics as direct record/replay tests. fixture.replay(request, replay_key) is a convenience over that matcher: it derives the request hash from the normalized turn request, consumes according to the fixture policy, and returns replay options for llm.turn or a validation error table.

llm.fixture_index(spec, opts) normalizes offline fixture index metadata into an ordinary provider-free table with kind: "fixture_index", schema_version: 1, version: "fixture_index.v1", fixture_id, provider/offline flags, mode, strategy, normalized fixtures, matching, deterministic summary order, and summary. Fixture entries may use fixture_key, key, or id for identity and may carry path, schema, schema_path, schemas, capability, and metadata. llm.validate_fixture_index(index, opts) returns a gate table with ok, status, result_status, fixture_count, finding_count, and findings.

Fixture index helpers must remain metadata-only. They may validate table shape, provider-free/offline flags, replay-ready metadata when requested, and safe relative reference strings, but they must not open fixture files, resolve paths, read schemas, scan package directories, require a specific filename, or embed a project-specific package layout.

llm.provider_free_package_contract(spec, opts) normalizes package boundary metadata into an ordinary table with kind: "provider_free_package_contract", schema_version: 1, version: "provider_free_package_contract.v1", package_id, package_name, provider-free/offline flags, default_policy, credentials, entrypoint/schema/fixture/capability counts, and summary. llm.validate_package_contract(contract, opts) returns a gate table with ok, status, result_status, finding_count, findings, and summary.

Package contract helpers must remain metadata-only. They may validate offline defaults, credential-free policy, empty required credential refs, optional fixture-index references, and safe relative reference strings, but they must not load package files, resolve directories, read schemas, require a specific entrypoint name, or embed a project-specific package layout.

llm.replay_http_record(spec, opts) normalizes HTTP/API replay metadata into an ordinary table with kind: "replay_http_record", schema_version: 1, version: "replay_http_record.v1", replay identity, provider-free/offline flags, redacted request, metadata-only response, optional retry, pagination, rate_limit, error, terms, redaction, and summary. llm.replay_artifact_record(spec, opts) does the same for downloaded artifact metadata with kind: "replay_artifact_record" and an artifact metadata table.

Replay metadata record helpers must not perform HTTP requests, open artifact files, resolve paths, compute file hashes from disk, or require referenced resources to exist. They may redact sensitive headers and auth references and may record caller-provided hashes, byte counts, mock URIs, rate-limit metadata, pagination metadata, and terms metadata. Raw response bodies and raw artifact content must not be copied into normalized records by default.

Scripts may validate trace evidence without a host trace sink. llm.trace_summary(envelope_or_events) returns deterministic event counts, ordered event types, replay keys, status counts, and ordering/correlation diagnostics. llm.trace_assert(input, opts) returns {ok, status, summary, findings} and may require provider-free traces, deny live network/model flags, require event types, require correlation fields, require payload fields on all events, or require payload fields for specific event types via require_event_payload_fields or the equivalent required_payload_fields_by_event_type. It may require minimum status counts with require_status_counts and maximum status counts with limit_status_counts or max_status_counts. It may also deny truthy redaction states on each event with deny_secret_values_present (alias: deny_secret_values), deny_raw_prompt_stored, and deny_raw_completion_stored. These helpers are provider-free; they inspect ordinary trace tables and must not persist telemetry, redact content, or call providers.

llm.replay_trace_event(match, opts) projects the result of llm.replay_index(...).match(...) or a replay fixture matcher into a standard trace event. It must preserve the replay matcher’s status without re-matching: matched, mismatch, and exhausted become redacted replay trace events with safe identity fields such as replay_key, request_hash, response_hash, record_id, summary counters, and finding metadata. It must not clone raw request messages, raw records, or replay responses into the trace payload, and must not call providers, persist telemetry, or introduce a second replay matching algorithm.

Evaluate Blocks

evaluate "case name" { ... } declares a source-level regression case. The case name is a required string literal. In ordinary script execution an evaluate block has no runtime effect. The leia evaluate command owns discovery and evaluation semantics.

During evaluation, the harness installs an eval module for dataset-style regression tests:

Function Meaning
eval.case(id, fn) Run one named subcase. Runtime errors inside fn fail that subcase but do not stop later subcases.
eval.metric(name, value) Record a bool, number, string, nil, or JSON-like metric.
eval.load_jsonl(path) Load a JSON Lines corpus relative to the evaluate source file.
eval.skip_if(cond, reason) Mark the active subcase skipped when cond is truthy.
eval.fail_if(cond, message) Raise a runtime error when cond is truthy.
eval.usage() Return current case LLM usage.
eval.budget(table) Raise when current usage exceeds a positive limit.
eval.judge(options) Run a bounded judge turn by calling llm.turn(options).
evaluate "answer corpus" {
    rows := eval.load_jsonl("answer_cases.jsonl")
    for _, row := range rows {
        ok, err := eval.case(row.id, func() {
            result, err := answer(row.question)
            assert(err == nil)
            eval.metric("correct", result.text == row.expected)
        })
        assert(ok || err != nil)
    }
}

Evaluation reports preserve raw metrics on each case and include top-level metric summaries. Boolean metrics are summarized as pass rates. Numeric metrics are summarized as count, mean, min, and max. String metrics are summarized as category counts. Skipped subcases do not contribute to metric summaries.

The leia evaluate CLI may render reports as JSON, text, or HTML; list cases without executing them; filter selected cases; record, replay, or update LLM replay fixtures; compare against a baseline; and run in gate mode. JSON is the stable exchange format for baselines and comparisons. Fixture replay must use normalized provider requests after dialect lowering and must fail on request mismatch, exhausted fixtures, or leftover unconsumed turns. Evaluation harness functions are available only while evaluate blocks run under the harness and must not become ordinary runtime globals.

Capability Checks

AI dialects declare llm.turn capability usage. Tool descriptors may declare additional requires labels. A host may reject scripts or tool exposure when the requested capabilities exceed policy.

Capability failure is a host/runtime error. It must not be hidden as a provider answer.

Live Provider Tests

Live LLM tests must be opt-in and must not commit tokens. Integration tests should read credentials from environment variables and skip when credentials are absent.

Modules And Loading

Require

require(name) loads enabled standard-library modules, host-registered modules, and filesystem-backed project modules according to runtime module options. name must be a string. Loaded module results are cached in package.loaded.

json := require("json")
text := json.encode({ok: true})
same := require("json")
assert(type(text) == "string")
assert(same == json)

The result of require(name) is a single value. Standard-library and host modules define their own module value, usually a table or callable function. If a filesystem-backed module returns at least one value, the first value is the module value and additional return values are discarded. If it returns no values, the module value is true.

Standard Library Default Imports

Installing an enabled standard-library module may also install selected default-import aliases as ordinary global bindings. These aliases are conveniences for stable, frequently used operations; require(name) still loads real modules only. A host or SDK WithLibs policy disables an alias when the module that owns it is disabled. append is available with the core table surface because it is the stable sequence-growth helper.

Default-import aliases may be shadowed by lexical declarations exactly like other globals.

xs := ones(4)
noise := randn(3, 0, 1)
m := mat([[1, 2], [3, 4]])
summary := describe(xs)

assert(sum(xs) == 4)
assert(summary.mean == 1)
assert(trace(m) == 5)
assert(describe(noise).count == 3)

sqrt := func(x) { return x }
assert(sqrt(9) == 9)

Stable numeric and data aliases include:

  • table: append;
  • math: abs, sqrt, exp, sin, cos, tan, asin, acos, atan, floor, ceil, round, min, max, clamp, near, pow;
  • linalg: vector, vec, mat, row, col, eye, diag, zeros, ones, at, norm, dot, matvec, matmul, axpy, solve, trace, transpose;
  • stats: sum, mean, avg, variance, std, describe, rms, rmse, cumsum, diff, normalize;
  • rand: randn, sample.

matrix remains the standard-library module name. The short matrix-constructor alias is mat so default imports do not replace the module binding.

The observable require(name) algorithm is:

  1. If package.loaded[name] is non-nil, return it.
  2. If the runtime has an internal loaded-module cache entry for name, return that entry. This cache is an implementation detail; package.loaded is the portable observable table.
  3. If name is an installed standard-library module that remains enabled by the active LibFlags policy, return its module value and store it in package.loaded[name]. Disabled standard-library modules are removed from the module cache and from package.loaded.
  4. If name names a host-registered module, including an explicitly allowlisted go: module installed by the embedder, return that module and store it in package.loaded[name]. Source syntax never loads arbitrary Go packages by path.
  5. If filesystem module loading is disabled, raise module loading disabled. This does not disable already loaded modules, enabled standard-library modules, or registered host modules.
  6. Resolve a filesystem-backed .leia file with the resolver below. If the resolved path is outside the configured filesystem root, violates module byte or module depth limits, cannot be opened, or does not exist, raise a runtime error.
  7. Execute the resolved file. If execution succeeds, normalize the return value to one module value, store that value in the internal cache where present and in package.loaded[name], and return it.

A failed load does not produce a stable cached module value. A later successful require(name) must still return the successful module value.

Filesystem Resolution

Filesystem-backed modules are cached only after successful execution. The current implementation does not prefill package.loaded[name] with an in-progress placeholder before running the file. A direct or indirect cycle such as a requiring b requiring a therefore recurses until a configured module depth limit or another runtime resource limit fails; it does not return a partially initialized module.

Resolution uses the original string name as the package.loaded key. Path normalization during filesystem resolution does not make different spelling forms interchangeable cache keys unless an embedding policy documents that aliasing explicitly.

Filesystem-backed project module resolution is deterministic:

  1. A collection require of the form prefix:path.to.module matches a configured collection with the same prefix; it resolves under the collection root as path/to/module.leia. Collection matching is explicit; an unmatched prefix: name continues through the remaining resolver rules as an ordinary module name.
  2. Otherwise, the longest configured replace path that equals name or is a slash-prefix of name wins. An exact replace whose target ends in .leia loads that file; an exact replace whose target is not a .leia file appends .leia to the target path. Subpaths below a replace root convert dots to path separators and append .leia. Only local replace targets are used by runtime module setup.
  3. Otherwise, the longest local vendor or downloaded-cache entry that equals name or is a slash-prefix of name wins. Subpaths convert dots to path separators and append .leia; an exact cache or vendor module path loads basename(name).leia inside that entry root. Vendor entries are considered before downloaded-cache entries when both are present.
  4. Otherwise, name resolves relative to the active require root or script directory. Names containing / or starting with . keep path syntax and add .leia; other names convert dots to path separators and add .leia. This is local module path lookup; the module directive in leia.mod does not by itself create a filesystem file.

The resolved file is still checked by filesystem root, module byte, module depth, readonly, vendor, and capability controls before execution.

json1 := require("json")
json2 := require("json")
assert(json1 == json2)
assert(package.loaded["json"] == json1)
fs := require("fs")
path := require("path")
script := require("script")

modulePath := path.join(script.dir(), "local_helper.leia")
assert(fs.writefile(modulePath, `return {value: 7}`))

helper := require("local_helper")
assert(helper.value == 7)
assert(require("local_helper") == helper)
assert(package.loaded["local_helper"] == helper)
fs := require("fs")
path := require("path")
script := require("script")

root := script.dir()
pkgDir := path.join(root, "pkg")
if !fs.exists(pkgDir) {
    assert(fs.mkdir(pkgDir))
}
assert(fs.writefile(path.join(pkgDir, "tool.leia"), `return {name: "dot"}`))
assert(fs.writefile(path.join(root, "path_tool.leia"), `return {name: "path"}`))

dot := require("pkg.tool")
byPath := require("./path_tool")
assert(dot.name == "dot")
assert(byPath.name == "path")
assert(package.loaded["pkg.tool"] == dot)
assert(package.loaded["./path_tool"] == byPath)

The package.loaded Table

The package.loaded table is part of the lookup contract. A non-nil entry short-circuits resolution and is returned directly. This is mainly useful for embedders, tests, and compatibility shims; ordinary modules should prefer returning a value from the module file.

package.loaded["example.preloaded"] = {answer: 42}
mod := require("example.preloaded")
assert(mod.answer == 42)
assert(require("example.preloaded") == mod)

Because package.loaded is an ordinary table, any non-nil Leia value can be used as a preloaded module value. A false module value is still loaded: only nil means absent.

package.loaded["example.false"] = false
assert(require("example.false") == false)

fn := func(x) { return x + 1 }
package.loaded["example.callable"] = fn
assert(require("example.callable")(4) == 5)

If a package.loaded[name] entry is replaced with another non-nil value, the next require(name) returns that replacement. Assigning nil removes the table entry but does not specify a full unload operation: an implementation may still have an internal loaded-module cache entry for name, and the normal resolution order applies again.

For example, after removal, a later require may return an internal cached value or may attempt normal resolution again; the exact behavior is not a runnable stable spec example:

package.loaded["example.override"] = {value: 1}
first := require("example.override")
assert(first.value == 1)

package.loaded["example.override"] = {value: 2}
second := require("example.override")
assert(second.value == 2)

package.loaded["example.override"] = nil
// A later require may return an internal cached value or resolve again.
third := require("example.override")

The module name must be a string.

ok, err := pcall(require, 123)
assert(!ok)
assert(type(err) == "string")

Module Manifests

leia.mod describes a module path, Leia language/module format version, dependencies, replacements, capability summaries, source collections, and optional Go-native binding metadata. The current manifest grammar is line oriented. Comments start with //; paths may contain letters, digits, ., -, _, /, and :, but not .. or \.

module example.com/app
leia 0.1

capability fs.read net.client
capability tool.exec

require github.com/example/lib v1.2.3
replace github.com/example/lib v1.2.3 => ./third_party/lib
collection assets ./assets

go 1.25
go require example.com/native v1.0.0
go replace example.com/native => ./native

The module directive is required. leia records the Leia language/module format version; if it is absent, the current parser defaults it to 0.1. require stores a module path and a version string. Versions are opaque to the language contract. Current module tooling can download GitHub tag archives for github.com/owner/repo[/subdir] requirements, but ordinary script execution does not perform network access. replace maps a module path, optionally for one version, to another path; runtime module options only use local replacement targets (./... or absolute paths). Non-local replacements are metadata for module tooling, not runtime fetch instructions. go, go require, and go replace are metadata for leia mod gomod; they do not enable source-level go: imports by themselves.

capability (or cap) is a declarative summary of host capabilities the module expects. Capabilities may be written as separate fields or comma separated values. The module loader does not grant permissions from this field; hosts still enforce the active capability policy. leia mod capability reads the main manifest and locally available dependency manifests, then reports a capability universe and per-module matrix. Missing dependency manifests are warnings, not proof that a module needs no capabilities.

Capability names in leia.mod are documentation and tooling input. They are not ambient authority, and they do not weaken Cap* runtime options, sandbox roots, library flags, or host-specific policy. A host may reject code whose declared capability summary is incomplete, excessive, or inconsistent with the host policy, but that decision is outside require resolution.

Capability Summaries

For example, this manifest says the module expects filesystem reads and network client access:

module example.com/report
leia 0.1

capability fs.read,net.client

Running leia mod capability --json from that module produces a machine-readable summary of declared capabilities. The exact output order is not part of the language contract, but the data model is a universe of capability names plus a per-module matrix.

collection name path configures collection requires such as require("assets:icons.logo"). The name may contain letters, digits, _, and -. Relative collection paths are resolved from the directory containing the nearest leia.mod. Collections are local source roots; they do not imply dependency versions, downloads, or registry lookup.

Sum Files

leia.sum records hashes produced by module tooling. leia mod lock writes entries for local collections, local replaces, and locally available downloaded or vendored requirements. leia mod download and leia mod vendor update remote module entries after the dependency is present locally. The current file format is:

collection NAME TARGET h1:BASE64_SHA256
replace PATH VERSION_OR_- TARGET h1:BASE64_SHA256
module PATH VERSION TARGET h1:BASE64_SHA256

Hashes are computed over stable path/data pairs. Directory hashes include only .leia files and leia.mod, skip VCS directories, sort paths, and use the h1: prefix with base64-encoded SHA-256 bytes. leia mod verify and leia mod check compare current local content with leia.sum; if no leia.sum exists, sum verification is skipped.

leia.sum is not a package index, registry, or permission grant. It records content that module tooling has already made available locally. Runtime require does not consult a central registry, discover new versions, download missing requirements, or repair mismatched sums.

The stable package-management boundary is local-first. Module paths may be GitHub-style repository paths because current tooling can cache GitHub archives, but a GitHub-looking path is still only a string in leia.mod until tooling or the user has placed content in a local replace, vendor directory, or module cache. Leia does not require or define a central registry for basic module use.

Go Host Imports

import name "go:..." is explicit host binding syntax. It does not automatically reflect arbitrary Go packages; embedders must provide bindings through the public Go API and the active capability policy may still reject use. The compatibility spelling import "go:..." as name is also accepted, but new source should prefer the alias-first form.

Leia’s module system is designed for Go embedding: scripts can name explicit host bindings, but the host owns registration, capability checks, sandboxing, and lifetime. Tagged dialects follow the same rule. A source tag such as sh, json, or turn selects a registered dialect implementation; it does not import ambient packages, grant host access, or create a private runtime.

The following declaration is host-only syntax, not a runnable local spec example:

import http "go:net/http"

The declaration above is only valid when the embedder has allowlisted and registered the go:net/http binding. Source syntax alone never grants host access.

ok, err := pcall(require, "go:net/http")
assert(!ok)
assert(type(err) == "string")

Go import allowlisting is an embedder contract. WithGoImports and RegisterModule("go:...", ...) expose only named host-provided modules. A go require directive in leia.mod, a Go module path in go.mod, or source syntax such as import http "go:net/http" never loads arbitrary Go code by package path.

Module Modes

Module mode controls how a discovered manifest is applied at runtime. Runtime module setup is offline: it uses local replaces, already present vendor directories, and already present cache directories; it does not run git, download modules, or rewrite leia.mod.

Mode Runtime behavior
mod Use local replaces, existing vendor entries, and existing module-cache entries. It does not download or mutate files. Vendor entries win over cache entries for the same required module.
readonly Same offline resolution behavior as mod. It is the mode hosts should choose when manifest, cache, vendor, or sum-file mutation is disallowed.
vendor Ignore the module cache and resolve required remote modules from vendor/PATH@VERSION. Missing remote vendor entries stay visible to resolution and fail normally when the module file is unavailable. Local replaces and collections still apply.

When ModuleOptionsForScriptMode is used, the nearest ancestor leia.mod sets the require root, collections, local replaces, module mode, and local vendor/cache entries already present. Invalid manifests are ignored by that runtime helper rather than repaired. Manifest discovery does not make the declared module path a new filesystem root; it sets metadata and dependency rules for the project rooted at the manifest directory.

The CLI module commands are the mutating surface: leia mod add, tidy, download, vendor, and lock may update leia.mod, vendor/, the module cache, or leia.sum; ordinary script execution does not. leia mod list, graph, explain, capability, gomod, verify, and check report or validate local module state under their documented command options.

Module Execution

Top-level module code executes in the same language as ordinary scripts. Lexical declarations inside the module are private to that module execution unless the module returns or stores a value that exposes them. Mutating globals or host resources during module loading is a side effect of require; portable modules should make those side effects explicit in their documented contract and keep the returned module value stable across repeated requires.

Errors And Diagnostics

Leia distinguishes runtime errors from recoverable structured failures. A runtime error aborts the current expression or statement and unwinds the active call stack until it reaches a protected-call boundary or the top-level execution boundary. Deferred calls run according to the statement semantics for defer; errors raised while unwinding replace or report through the same runtime error path as other deferred-call failures.

Error Values

An error value is any Leia value carried by an error path. Script code can raise strings, numbers, booleans, nil, tables, functions, and other values. Leia does not normalize a script-raised table into a standard diagnostic shape and does not stringify script-raised non-strings before handing them to pcall or an xpcall handler.

The v1.0 contract for script-raised error values is identity-preserving for identity-bearing values. A table raised with error(t) is the same table received by pcall or by an xpcall handler. If the table already has fields such as kind, message, code, source, or retryable, those fields are ordinary user data unless a specific library documents that table as a structured result.

payload := {kind: "demo", message: "boom"}
ok, err := pcall(func() {
    error(payload)
})
assert(!ok)
assert(err == payload)
assert(err.kind == "demo")
ok, err := pcall(error, nil)
assert(!ok)
assert(err == nil)

ok, err = pcall(error, false)
assert(!ok)
assert(err == false)

Runtime errors raised by invalid operations, host callbacks, parser/compiler operations called from script, resource budgets, and capability checks are represented inside pcall and xpcall as diagnostic strings.

ok, err := pcall(func() {
    return "x" + 3
})
assert(!ok)
assert(type(err) == "string")

error

error(value) raises exactly value. error() raises a string error value. Extra arguments to error are evaluated by the call expression but ignored by the v1.0 error contract; the first argument is the only raised value.

ok, err := pcall(func() {
    error()
})
assert(!ok)
assert(type(err) == "string")

ok, err = pcall(error, "first", "ignored")
assert(!ok)
assert(err == "first")

assert(false, value) and assert(nil, value) raise value when a second argument is present. If no message argument is present, the failure is a runtime diagnostic string.

payload := {kind: "assertion"}
ok, err := pcall(func() {
    assert(false, payload)
})
assert(!ok)
assert(err == payload)

ok, err = pcall(assert, false)
assert(!ok)
assert(type(err) == "string")

Protected Calls

pcall(fn, ...) calls fn(...) in protected mode. On success it returns true followed by all results from the call. On failure it returns false and one error object. If the failure came from error(value) or assert(false, value), the object is value; if it came from a runtime, host, parser, budget, or capability failure, the protected error object is the current diagnostic string.

ok, a, b := pcall(func() {
    return "a", "b"
})

assert(ok)
assert(a == "a")
assert(b == "b")

The first argument to pcall is evaluated before the protected call begins. The protected region includes the attempt to call that value, so a non-callable first argument is returned as false, err. With no first argument, pcall() raises an unprotected argument error at the call site.

Arguments after the function are evaluated before entering the callee, then passed normally. Once the callable is entered, both script-raised errors and runtime errors are converted to the protected result form.

ok, err := pcall(17)
assert(!ok)
assert(type(err) == "string")

xpcall(fn, handler, ...) calls fn(...) in protected mode. On success it returns true followed by all results from fn. On failure it calls handler(err) and returns false plus the handler’s first result. Extra handler results are discarded. If the handler returns no values, the second xpcall result is nil. If the handler itself fails, xpcall returns false plus the handler failure’s error object.

ok, value := pcall(func(a, b) {
    return a + b, "kept"
}, 2, 5)
assert(ok && value == 7)

ok, handled := xpcall(error, func(err) {
    return "handled:" .. tostring(err)
}, "boom")
assert(!ok && handled == "handled:boom")
ok, a, b := xpcall(func() {
    error("bad")
}, func(err) {
    return "handled:" .. err, "discarded"
})
assert(!ok)
assert(a == "handled:bad")
assert(b == nil)
ok, value := xpcall(func() {
    error("bad")
}, func(err) {
})
assert(!ok)
assert(value == nil)

Like pcall, xpcall() and xpcall(fn) raise unprotected argument errors because the protected function and handler arguments are missing.

pcall()
xpcall(print)

Protected calls are recovery boundaries for ordinary script failures. They do not make process termination, cancellation, host shutdown, or bugs in the host process safe to continue unless the embedding API explicitly reports those conditions as ordinary errors.

Runtime Errors

The following conditions must raise runtime errors when they occur during execution:

Error class Required triggers
Missing or invalid arguments Calling a builtin without a required argument; passing a value of the wrong type; passing an invalid option, index, range, mode, pattern, or table shape required by that API.
Invalid calls Calling a non-function value with no __call metamethod; calling pcall or xpcall without their required protected-call arguments.
Invalid operators Arithmetic, bitwise, concatenation, length, or ordering over unsupported values when no matching metamethod applies.
Invalid assignment and table protocol Assigning through an invalid target; indexing or assigning through a table/metatable protocol that exceeds supported __index or __newindex chains; using an invalid metamethod result such as a non-string __tostring; attempting to change a protected metatable.
Invalid control flow and coroutine use Yielding outside a yieldable coroutine boundary; resuming an invalid coroutine state; invalid channel send, receive, close, or select operations.
Module and script loading failures Module not found; module loading disabled; module path escaping the configured filesystem root; malformed source passed to a script compile/load API.
Resource and capability failures Step, native-call, call-depth, goroutine, channel-capacity, host-result, module-byte, or module-depth budget exhaustion; filesystem, network, process, debug, testkit, dynamic-eval, or module-loading capability denial.
Host failures surfaced as runtime errors Registered callback errors, callback panics recovered by the embedding API, provider errors, filesystem errors, network errors, and sandbox denials when the API is not designed to return a recoverable structured result.

Runtime error diagnostic strings are for humans. The stable script-level contract is their type (string), recovery boundary, and association with the failing operation, not exact wording. Tests and portable programs must not parse runtime diagnostic prose unless a specific CLI JSON field or library result table documents that text as machine-readable.

func fails(f) {
    ok, err := pcall(f)
    assert(!ok)
    assert(type(err) == "string")
}

fails(func() { return {}() })
fails(func() { return "a" .. false })
fails(func() { return math.sin() })
fails(func() { coroutine.yield() })

Structured Recoverable Failures

Recoverable host and provider failures should return nil, err or a structured result when the API is designed for recovery. Error result tables use stable, lowercase string fields when structured recovery is intended:

Field Meaning
kind Machine-readable category such as validation, provider, network, rate_limit, budget, capability, or an API-specific kind.
message Human-readable diagnostic string safe to expose under the active capability policy.
code Optional machine-readable API/provider code.
source Optional subsystem or provider name.
retryable Optional boolean hint for transient failures.

Host and provider APIs should use stable kind and code values when callers need branching behavior:

Kind Typical codes
host API-specific codes for callback, provider, process, filesystem, network, or environment failures. Public Go callback failures surface as *leia.HostCallbackError; callback panics surface as *leia.HostCallbackPanicError.
capability A denied capability name or API-specific denial code, such as disabled filesystem read/write, network, process execution, debug, testkit, dynamic eval, or module loading. Capability denials are currently runtime diagnostics unless a library intentionally returns a structured error table.
budget One of the current public budget resources: steps, native_calls, call_depth, goroutines, channel_capacity, host_result_bytes, module_bytes, or module_depth. Public execution APIs expose these through *leia.BudgetError.Resource and Limit.
provider Provider-specific API codes, model errors, rate limits, authentication failures, or validation failures.

LLM provider failures, filesystem failures, network failures, and sandbox denials must not leak data forbidden by the active capability policy.

Inside Leia code, typed diagnostics are not automatically reified as tables. The current boundary is:

Boundary Error representation
Public Go APIs Typed Go errors such as *leia.Error, *leia.BudgetError, *leia.HostCallbackError, *leia.HostCallbackPanicError, and *leia.ExitError.
pcall / xpcall for error(value) The original Leia value.
pcall / xpcall for runtime, parser, host, budget, or capability failures A diagnostic string.
Recoverable library/provider APIs API-specific results, commonly nil, err or a table with fields such as kind, message, code, source, and retryable.
CLI diagnostics Command-specific text, JSON, or SARIF. JSON field names documented for a command are the stable interface; prose messages may change.

Public API Diagnostics

Public Go execution APIs expose typed errors before text formatting. *leia.Error has stable fields Kind, Message, Line, Col, File, Err, and Value. Kind is lex, parse, runtime, or script; script is used for error(value) and carries the converted original value in Value when conversion succeeds. Runtime, parser, and lexer errors use Message as a human diagnostic. Err unwraps to the underlying cause, including *leia.BudgetError, *leia.ExitError, host callback errors, and host callback panic errors when available.

Diagnostics should include source location when available. Stable source fields are source name or file path, line, and column. Public Go APIs map those to File, Line, and Col. Script-side debug APIs may expose sourceName, line, and column fields for frames and functions when debug access is enabled. String diagnostics may include the same coordinates in text, but callers that need stability should use typed Go errors, documented debug table fields, or CLI JSON.

Stack and traceback text is diagnostic output, not a stable machine interface. Frame ordering, formatting, function names, native/script labels, and stack depth are not stable unless a specific command or API documents them as structured fields.

Implementation Requirements

This chapter defines implementation constraints for stable language behavior. It is normative for interpreter, VM, JIT, standard-library, documentation, and release-gate work.

Interpreter Baseline

The interpreter is the semantic baseline for all stable language features. A stable feature is not implemented until the interpreter behavior is defined, tested, and listed in tests/feature_matrix.json with the relevant coverage references.

Bytecode VM and JIT execution must preserve interpreter-visible behavior. The following observable results must be equivalent across execution modes unless a feature entry explicitly records an unsupported mode:

  • returned values and side effects;
  • control-flow behavior, including errors, protected-call results, defers, coroutine state, channel operations, and synchronization behavior;
  • source locations and diagnostics that are specified by the language or CLI contract;
  • standard-library module identity and visible module state.

Implementation details are not observable language behavior and must not be used by user code as compatibility guarantees:

  • table layout;
  • dense numeric representation;
  • raw integer JIT representation;
  • inline caches;
  • native call ABI;
  • garbage collection timing;
  • scheduling details except for specified channel and synchronization behavior.

VM And JIT Equivalence

VM and JIT support for a stable feature must be justified by equivalence tests, not by benchmark success alone. A change that alters parsing, bytecode generation, interpreter execution, VM execution, or JIT execution for a stable feature must update the corresponding tests/feature_matrix.json row and must include conformance, semantic-gate, or mode-equivalence coverage appropriate to the changed behavior.

Runnable examples in the spec are part of this contract. Stable spec examples must use leia run all for accepted programs or leia fail all for rejected programs, and are validated by tests/spec_examples_test.go across the interpreter, VM, and default execution modes. Any normative example added to docs/spec/*.md that is intended to demonstrate accepted or rejected behavior must use one of those all-mode runnable fence tags unless the example is explicitly non-executable syntax notation.

Runtime Specialization

Runtime specialization is permitted only behind guards that check the facts required by the specialized path. A specialized path must either:

  • prove that the specialized facts still hold before executing behavior that depends on them; or
  • deoptimize, side-exit, or recover to an unspecialized path before user-visible behavior can diverge.

Guard failure, side exit, deoptimization, fallback, or recovery must preserve the same visible result as interpreter execution for the same program state. Recovery must not replay visible side effects, skip required side effects, alter error values, or change coroutine/channel/synchronization state.

Specialization must be derived from runtime-observed program facts, static language facts, or explicit implementation configuration. It must not depend on benchmark names, source file names, directory names, fixed benchmark input sizes, or other source-name based selectors. Benchmark fixtures may exercise specialized behavior, but they must not be the condition that enables that behavior.

Release Gates

Stable syntax and runtime behavior must be represented in tests/feature_matrix.json. The matrix is a release gate: a stable feature row must carry parser, bytecode, interpreter, tier1, tier2, semantic_gate, conformance_case, and perf_hot_case status according to the matrix schema. The schema and referenced files are checked by go test ./tests -run 'TestFeatureMatrix|TestReleaseMatrix' -count=1.

Changes to stable syntax require:

  • grammar and spec updates in docs/spec/grammar.ebnf and the relevant docs/spec/*.md chapter;
  • parser or lexer tests covering the syntax;
  • conformance or semantic tests proving accepted and rejected behavior;
  • a matching tests/feature_matrix.json update.

Changes to stable runtime behavior require:

  • interpreter coverage for the baseline behavior;
  • VM coverage when bytecode execution is affected;
  • JIT semantic-gate or equivalence coverage when tier1 or tier2 execution is affected;
  • release-matrix coverage for any changed stable spec section.

Documentation release checks are enforced by scripts/run.sh docs. That gate checks Markdown links, documented release-script references, release-readiness snippets, generated reference documentation, and generated spec HTML freshness.

Standard Library And Catalog Synchronization

The public standard-library surface must stay synchronized across implementation registration, catalog metadata, generated reference documentation, and tests. Adding, removing, renaming, or changing the public behavior of a standard library module requires updating internal/stdlib/catalog/catalog.go, docs/reference/stdlib/index.md, and the corresponding stdlib tests. Generated stdlib documentation must remain reproducible by the docs generation command checked by scripts/run.sh docs.

Catalog metadata is part of the release surface. Module names, layers, capabilities, and safe-default classification must match the implementation and the documented embedding/security behavior. Host-capability modules must not be documented or cataloged as safe defaults unless their implementation is safe without host authority.

Performance-Sensitive Changes

A performance-sensitive change is any implementation change that affects hot loops, numeric operations, table access, calls, strings, concurrency, data oriented operations, standard-library dispatch, tiering, guards, deopt, or JIT code generation. Such a change requires correctness evidence and performance evidence. Benchmark output is not a substitute for semantic coverage.

Performance-sensitive changes must use the repository performance gate relevant to the touched path. The repository provides scripts/run.sh perf, which wraps go run ./cmd/leia bench compare and go run ./cmd/leia bench strict. Release documentation also references scripts/run.sh perf --feature-smoke for feature-smoke performance evidence.

Architecture And Package Boundaries

Implementation changes must preserve package ownership boundaries. Runtime, VM, JIT, method-JIT, stdlib catalog, stdlib binding, stdlib installation, and stdlib module packages must not gain imports that collapse their separation of responsibilities. Internal package boundaries are checked by tests/architecture/package_boundary_test.go.

The standard-library catalog must remain metadata-only and must not depend on runtime values, module constructors, VM, JIT, method-JIT, stdlib binding, or stdlib installation packages. Standard-library module implementations must not import VM, JIT, method-JIT, binding, installation, or runtime internals unless a specific architecture test and spec update authorize the dependency.

Grammar appendix

(* Leia user-facing grammar.

   This file is the syntax appendix for docs/spec/index.md. It describes
   stable source syntax, not parser helper productions or internal AST shapes.
   Dialect tags such as sh, json, re, prompt, AI workflow tags, and
   user-defined names are ordinary identifiers until they appear directly
   before a tagged string or tagged block.
*)

program        = { separator | statement } EOF ;
separator      = ";" ;
block          = "{" { separator | statement } "}" ;

statement      = func_decl
               | import_decl
               | if_stmt
               | for_stmt
               | select_stmt
               | return_stmt
               | break_stmt
               | continue_stmt
               | goto_stmt
               | label_stmt
               | go_stmt
               | defer_stmt
               | const_decl
               | simple_stmt ;

func_decl      = "func" identifier param_list block ;
import_decl    = "import" ( import_spec | "(" { import_spec } ")" ) ;
import_spec    = [ identifier ] string_lit
               | string_lit "as" identifier ;

param_list     = "(" [ param { "," param } [ "," vararg_param ]
                     | vararg_param ] ")" ;
param          = identifier ;
vararg_param   = "..." | identifier "..." ;

if_stmt        = "if" expr block { "elseif" expr block } [ "else" block ] ;
for_stmt       = "for" block
               | "for" expr block
               | "for" simple_stmt ";" expr ";" simple_stmt block
               | "for" identifier [ "," identifier ] ":=" "range" expr block ;

select_stmt    = "select" "{" { select_case } "}" ;
select_case    = ( "case" ( recv_clause | send_clause ) | "default" )
                 ":" { separator | statement } ;
recv_clause    = "<-" expr
               | identifier ":=" "<-" expr
               | identifier "," identifier ":=" "<-" expr ;
send_clause    = expr "<-" expr ;

return_stmt    = "return" [ expr_list ] ;
break_stmt     = "break" ;
continue_stmt  = "continue" ;
goto_stmt      = "goto" identifier ;
label_stmt     = identifier ":" ;
go_stmt        = "go" call_expr ;
defer_stmt     = "defer" call_expr ;
const_decl     = "const" identifier ( "=" | ":=" ) expr ;

simple_stmt    = assignment
               | compound_assignment
               | inc_dec_stmt
               | send_clause
               | call_expr
               | expr ;
assignment     = expr_list ( "=" | ":=" ) expr_list ;
compound_assignment
               = expr ( "+=" | "-=" | "*=" | "/=" ) expr ;
inc_dec_stmt   = expr ( "++" | "--" ) ;
expr_list      = expr { "," expr } ;

expr           = logical_or ;
logical_or     = logical_and { "||" logical_and } ;
logical_and    = comparison { "&&" comparison } ;
comparison     = concat { ( "==" | "!=" | "<" | "<=" | ">" | ">=" ) concat } ;
concat         = additive [ ".." concat ] ;
additive       = multiplicative { ( "+" | "-" | "|" | "^" ) multiplicative } ;
multiplicative = unary { ( "*" | "/" | "%" | "<<" | ">>" | "&" | "&^" ) unary } ;
unary          = ( "!" | "-" | "#" | "<-" | "^" ) unary | power ;
power          = postfix { "**" unary } ;

postfix        = primary { call_suffix | method_suffix | index_suffix | member_suffix } ;
call_expr      = postfix ( call_suffix | method_suffix ) ;
call_suffix    = "(" [ expr_list ] ")" ;
method_suffix  = ":" identifier "(" [ expr_list ] ")" ;
index_suffix   = "[" expr "]" ;
member_suffix  = "." identifier ;

primary        = identifier
               | number_lit
               | string_lit
               | "true"
               | "false"
               | "nil"
               | "..."
               | "(" expr ")"
               | func_lit
               | tagged_string
               | tagged_block
               | table_lit
               | list_lit
               | dense_lit ;

func_lit       = "func" param_list block ;
tagged_string  = ( identifier | "$" ) [ "!" ] tagged_raw_string_lit ;
tagged_block   = identifier [ "!" ] config_block ;

config_block   = "{" [ config_field { field_sep config_field } [ field_sep ] ] "}" ;
config_field   = identifier ":" expr
               | string_lit ":" expr
               | "[" expr "]" ":" expr ;

table_lit      = "{" [ table_field { field_sep table_field } [ field_sep ] ] "}" ;
table_field    = identifier ":" expr
               | "[" expr "]" ":" expr
               | expr ;
list_lit       = "[" [ expr { field_sep expr } [ field_sep ] ] "]" ;
dense_lit      = "[" [ integer_lit ] "]" dense_type "{" [ expr { field_sep expr } [ field_sep ] ] "}" ;
dense_type     = "i32" | "i64" | "f32" | "f64" | "bool" ;
field_sep      = "," | ";" ;

identifier     = ( "A".."Z" | "a".."z" | "_" )
                 { "A".."Z" | "a".."z" | "0".."9" | "_" } ;
number_lit     = integer_lit | float_lit ;
integer_lit    = decimal_lit | hex_lit | binary_lit | octal_lit ;
decimal_lit    = digit { digit | "_" } ;
hex_lit        = "0" ( "x" | "X" ) hex_digit { hex_digit | "_" } ;
binary_lit     = "0" ( "b" | "B" ) ( "0" | "1" ) { "0" | "1" | "_" } ;
octal_lit      = "0" ( "o" | "O" ) ( "0".."7" ) { "0".."7" | "_" } ;
float_lit      = decimal_lit "." decimal_lit [ exponent ]
               | decimal_lit exponent ;
exponent       = ( "e" | "E" ) [ "+" | "-" ] decimal_lit ;
digit          = "0".."9" ;
hex_digit      = digit | "A".."F" | "a".."f" ;
string_lit     = double_quoted_string | single_quoted_string | raw_string_lit ;
double_quoted_string
               = '"' { escaped_char | interpolation | any_char_except_double_quote_or_newline } '"' ;
single_quoted_string
               = "'" { escaped_char | any_char_except_single_quote_or_newline } "'" ;
raw_string_lit = short_raw_string | fenced_raw_string ;
short_raw_string
               = "`" { any_char_except_backtick } "`" ;
fenced_raw_string
               = "```" { any_char_except_three_backticks } "```" ;
tagged_raw_string_lit
               = tagged_short_raw_string | tagged_fenced_raw_string ;
tagged_short_raw_string
               = "`" { tagged_raw_char | interpolation } "`" ;
tagged_fenced_raw_string
               = "```" { tagged_fenced_raw_char | interpolation } "```" ;
interpolation  = "${" expr "}" ;
escaped_char   = "\\" ( "\\" | '"' | "'" | "a" | "b" | "f" | "n" | "r" | "t" | "v"
                    | "x" hex_digit hex_digit
                    | "u" hex_digit hex_digit hex_digit hex_digit
                    | "U" hex_digit hex_digit hex_digit hex_digit hex_digit hex_digit hex_digit hex_digit
                    | decimal_escape ) ;
decimal_escape = digit [ digit [ digit ] ] ;
any_char_except_double_quote_or_newline
               = ? any Unicode scalar value except '"' and newline ? ;
any_char_except_single_quote_or_newline
               = ? any Unicode scalar value except "'" and newline ? ;
any_char_except_backtick
               = ? any Unicode scalar value except "`" ? ;
any_char_except_three_backticks
               = ? any Unicode scalar sequence not containing "```" ? ;
tagged_raw_char
               = ? any Unicode scalar value except "`" and the start of "${" ? ;
tagged_fenced_raw_char
               = ? any Unicode scalar sequence not containing "```" or the start of "${" ? ;