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:
- a normative section in this specification;
- a corresponding entry in
tests/feature_matrix.json; - at least one semantic or conformance gate;
- 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:
- category, reported by
type; - representation details explicitly exposed by a stable API, such as
math.typefor numbers; - 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:
- postfix call, index, and member selection;
- unary operators;
- exponentiation;
- multiplicative arithmetic, shifts, bitwise and, and bit clear;
- additive arithmetic, bitwise or, and bitwise xor;
- concatenation;
- comparisons;
- logical
&&; - 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:
- apply the primitive operation when the operands are already valid for it;
- apply the operator’s stable primitive coercions, if any;
- 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:
- Evaluate
expronce before the loop begins. - 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.
- 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. - 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.
- 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:
- receive-case bindings are scoped to the selected case body;
- a send case evaluates its channel and value expressions before attempting the selected send;
- a
defaultcase is selected immediately when no communication can proceed; - a
selectwith 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 evaluateexposeseval.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:
- 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
nilif it returns no values. - If the final expression is a call, its full result list is available to the enclosing assignment, return, call argument list, or table constructor.
- 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 asreturnor 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 keyk, ornilif absent;rawset(t, k, v)storesvat keykand returnst;rawset(t, k, nil)removes keyk;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:
- If the left operand is a table and its metatable has a non-
nilraw field namedevent, use that value. - Otherwise, if the right operand is a table and its metatable has a non-
nilraw field namedevent, use that value. - 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:
rawgetnever invokes__index;rawsetnever invokes__newindexand can create, replace, or remove raw keys directly;rawequalnever invokes__eq;rawlennever 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
gostatement’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, andheaders; - host automation dialects such as
sh,$,cmd,glob, andenv; - document and tabular dialects such as
xlsxandexcel; - web/server dialects such as
serve; - optional LLM dialects such as
model,tool,turn, andagent.
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 samellm,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
roleandtext; 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
messagesis absent, the first call argument is used asuser. instructionsis copied tosystemunlesssystemis already present.- Prompt field blocks that contain
roleandtextare valid message tables and can be placed directly inmessages. - If argument mapping, branching, dynamic tool selection, or persistent
workflow state is needed, use an explicit
configorflowfunction.
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:
- call the config function for the current agent arguments;
- synthesize
messagesfromsystemanduserwhenmessagesis absent; - perform a provider turn;
- dispatch requested tools through
llm.dispatch; - append assistant-call and tool-result or tool-error messages;
- 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:
- If
package.loaded[name]is non-nil, return it. - If the runtime has an internal loaded-module cache entry for
name, return that entry. This cache is an implementation detail;package.loadedis the portable observable table. - If
nameis an installed standard-library module that remains enabled by the activeLibFlagspolicy, return its module value and store it inpackage.loaded[name]. Disabled standard-library modules are removed from the module cache and frompackage.loaded. - If
namenames a host-registered module, including an explicitly allowlistedgo:module installed by the embedder, return that module and store it inpackage.loaded[name]. Source syntax never loads arbitrary Go packages by path. - 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. - Resolve a filesystem-backed
.leiafile 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. - 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:
- A collection require of the form
prefix:path.to.modulematches a configured collection with the sameprefix; it resolves under the collection root aspath/to/module.leia. Collection matching is explicit; an unmatchedprefix:name continues through the remaining resolver rules as an ordinary module name. - Otherwise, the longest configured
replacepath that equalsnameor is a slash-prefix ofnamewins. An exact replace whose target ends in.leialoads that file; an exact replace whose target is not a.leiafile appends.leiato 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. - Otherwise, the longest local vendor or downloaded-cache entry that equals
nameor is a slash-prefix ofnamewins. Subpaths convert dots to path separators and append.leia; an exact cache or vendor module path loadsbasename(name).leiainside that entry root. Vendor entries are considered before downloaded-cache entries when both are present. - Otherwise,
nameresolves 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; themoduledirective inleia.moddoes 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.ebnfand the relevantdocs/spec/*.mdchapter; - parser or lexer tests covering the syntax;
- conformance or semantic tests proving accepted and rejected behavior;
- a matching
tests/feature_matrix.jsonupdate.
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 "${" ? ;