Skip to content

Language reference

A Zirql expression transforms data into a result. That data may be a string array, a dictionary, or a selection of native objects supplied by an application. The word tutorial starts with an array and follows it through selection, adjacent pairs and grouped occurrences. This reference gives the rules behind those examples.

The application embedding Zirql is its host. Strings, arrays and dictionaries are constructed values, which Zirql can inspect directly. Native values identify objects kept by the host, such as words in a source document or nodes in a syntax tree. A projection reads properties of those objects into constructed values.

The available data and domain operations depend on the application. The word example accepts a single expression; applications can also support complete programs with parameters and named functions. Each chapter identifies where host support is needed. For the version used by these examples, see the setup guide.

Values include Booleans, signed integers, finite floats, strings, absence, arrays, tuples and dictionaries. Nominal members, typed sets and keyed arrays retain additional type or index information. Native objects keep their host’s identity.

42
"apple"
["ash", "apple", "birch", "apple"]
{"word": "apple", "count": 2}

Arrays and tuples use one-based positions. words[1] is "ash" in the word example; (2, 5).2 selects the second tuple value. Dictionary access accepts constructed keys, such as row["word"] or counts[{"ash", "apple"}]; row.word selects a string field. Ordinary access fails when an entry is missing; lookup(row, "word") returns absence for a valid missing key.

Arithmetic is checked. Integer overflow and division by zero fail, and strings are not coerced to numbers. ++ concatenates strings. Boolean and and or short-circuit. /\ and \/ spell the same Boolean operators. A conditional evaluates only the chosen branch:

if length(words) > 0 { words[1] } else { "no words" }

The same conditional can use if length(words) > 0 then words[1] else "no words" endif, with elseif condition then value for additional branches. All branches still receive scope and known-type checks. See values and comprehensions for exact arithmetic, collection, absence and access rules.

Names may be single-quoted, such as 'row count'. Quotes are omitted from name identity while diagnostics retain the original bytes; 'if' reads a name. Strings use double quotes and JSON escapes. Whitespace and #/% line comments or nonnested /* ... */ comments separate tokens. Integers also accept 0x/0X hexadecimal and 0o octal forms within signed 64-bit bounds. Hexadecimal floats are unsupported.

Natural records such as (word: "apple", count: 2) have the same ordered string fields as the dictionary example above. Indexed values accept a[i, j] as a[(i, j)]; [| 1, 2 | 3, 4 |] constructs a rank-two indexed matrix, and [3: 7, 8] gives its cells keys 3 and 4. See keyed arrays for explicit keys and empty shapes.

row with {limit: row.limit + 4} replaces an existing string field in an immutable record. Static labels may also be quoted, as in {"limit": 12}. The base runs once, then replacements run in written order in surrounding scope. Field order, aliases, native children and declared contracts are retained. Missing or duplicate target fields fail. General dictionaries, keyed arrays and field insertion/removal are outside this form. The capacity scenario runs through the local CSV host and shows both original and updated records.

A let gives a calculated value an immutable name. Later expressions can reuse it. This complete report filters sample counts, then sorts a dictionary by value:

Terminal window
cargo run --locked --example csv -- '
let samples = [
{"label": "pear", "count": 2},
{"label": "apple", "count": 1},
{"label": "ash", "count": 2},
{"label": "birch", "count": 3}
];
let selected = [row for row in samples if row.count > 1];
sort_by(
{row.label: row.count for row in selected},
value,
descending=true
)
' \
examples/csv/people.csv
{"birch":3,"pear":2,"ash":2}

selected contains pear, ash and birch in their input order. The dictionary uses their labels as string keys and their counts as values. descending=true puts the largest count first. Pear and ash both have count 2, so their incoming order stays pear, then ash. Sorting retains the dictionary kind.

The CSV file supplies the example application’s required input; this calculation uses only samples. The CSV walkthrough applies the same operations to file contents. Zirium and Zincite run the same calculation in their own programs, alongside operations on MLIR and MiniZinc source.

Bindings are visible after their initializer. They cannot replace an existing name, refer forward or refer to themselves. A comprehension’s iteration names stay inside that expression. The host decides which statement and output forms its frontend accepts; CSV accepts ordered lets and one final expression.

A comprehension constructs a collection from a source and an optional filter:

[{"word": w} for w in words if starts_with(w, "a")]

The head constructs each result. The generator binds w, and the condition selects which words reach the head. Arrays preserve generation order and repeated values. Multiple for clauses form a nested traversal; later sources can use earlier binders.

The MiniZinc spelling uses a generator pipe and where:

[{"word": w} | w in words where starts_with(w, "a")]

Both forms use the same patterns, scope and evaluator. The head sees every binder. Each source sees only earlier generators; its filter also sees the current generator’s names. Grouped binders such as x, y in [1, 2] form a Cartesian product, with the rightmost binder varying fastest. Commas separate MiniZinc generators. That spelling also supports a single-value binding: [total | x in [1, 2], total = x * 2 where total > 2] returns [4]. The common spelling requires for pattern in source.

Add case before a pattern to skip ordinary pattern mismatches: [n for case present(n) in [1, null, 2]] and [n | case present(n) in [1, null, 2]] both return [1, 2]. For grouped binders, the modifier selects whole combinations. case is not available with pattern = value. Errors and Unknown guards still fail; rejected candidates still spend work. See the runnable mixed-record example.

Parentheses protect a native pipeline in a head or source. A pipe after the closing collection starts its suffix, as in [x * 2 | x in [1, 2, 3] where x > 1] | count.

Bare braces construct a set without an outer colon, or a dictionary with one:

{n mod 3 for n in 1..5}
{w: length([w]) for w in ["pear", "apple"]}

The set keeps 1, 2, 0; the dictionary keeps pear, then apple. Keys and set elements can be any constructed value, including nested collections. Duplicate dictionary keys fail before their values run. Empty {} is a dictionary; set<value>() is an empty general set. Empty scalar sets use set<int>(), set<bool>(), set<float>() or set<string>(). See values for structural equality, general dictionary lookup and typed contracts.

Indexed brackets retain integer or nominal row and column keys:

[2: 20, 5: 50] axes([5, 2, 9])
[(i, j): j for i in [2, 5] for j in [8, 1]] axes([5, 2, 9], [8, 1, 4])
[] axes(set<int>(), [8, 1])

The optional axes(...) suffix declares complete ordered key domains, including keys without cells. Its sources run in surrounding scope before the contents. Without it, nonempty keys establish their dimensions. Empty [] is positional; a keyed empty result needs established domains. See keyed arrays.

Comprehensions are eager. A reducer such as length or sum receives the completed array, and rejected candidates still cost work. See positional comprehensions and keyed arrays for construction and index rules.

The library reference indexes every supported shared function, pipeline stage and operator. Browse its purpose groups for complete input, result, empty-input and error contracts. The table below is a quick selection of common constructed operations.

These operations work on constructed values. Native stages can have similar names but operate on a host’s native selections.

Operation Meaning
contains(s, part), starts_with(s, prefix), ends_with(s, suffix) Exact, case-sensitive string matching
length(a), sum(a) Count present array entries or sum integer entries
forall(a), exists(a) Reduce an array of known Booleans
sort(a), sort_by(d, value), sort_by(rows, "field") Stable same-kind array, set or dictionary ordering, optionally descending=true
min(a), max(a), min_by(rows, "field"), max_by(rows, "field") Select an extremum, or absence on empty input
group_by(rows, "field") First-seen groups, each with key and items
align(left, right, by="field") Full alignment by unique present structural keys; key, optional before and optional after, in left then right-only order
lookup(a, key), has_key(a, key) Optional access and membership
occurs(v), absent(v) Test presence or absence
v default fallback Evaluate the fallback only when v is absent
one(a) Require exactly one element
require(condition, value, "message") Return the value on true; otherwise fail with the message
index_set(a) Return typed keys of a positional or rank-one keyed array
entries(a), zip_exact(a, b) Inspect keyed cells or pair arrays with identical axes and present keys

The value reference defines accepted types, empty inputs and error cases. For example, length accepts arrays, not strings, and sum uses checked integer addition. forall and exists inspect an eager Boolean array; they do not turn its construction into a lazy traversal.

The common reader accepts constructed filtering, mapping and collection stages:

{"beginning_with_a": filter(starts_with(_, "a")) | count, "total": count}
["ash", "apple", "birch", "apple"] | filter(starts_with(_, "a")) | sort | unique | count
[w for w in words if starts_with(w, "a")] | sort | unique
[w for w in words if starts_with(w, "a")] | tally

The first two return {"beginning_with_a": 3, "total": 4} and 2 with the word host. Bare filter/count use the same constructed input independently in each dictionary field; an explicit source supplies its own stages. The filter accepts pattern => Boolean_expression or an expression using _. The subject is one positional element; named and unnamed forms agree with comprehension selection. For example, people | filter(_.active == "yes") and people | filter(p => p.active == "yes") retain the same rows. Nested predicates rebind _; explicit outer names retain lexical captures. Destructured arguments still give _ the whole subject. _ is unavailable outside a predicate argument and keeps its wildcard meaning in patterns. Empty and no-match inputs count as zero. Count returns a scalar, so a later collection stage is invalid. Missing input and incompatible types receive located errors. The last two return ["apple", "ash"] and {"ash": 1, "apple": 2}. Pipes apply left-to-right after ordinary operators, defaults, comprehensions and conditionals. Tally is terminal. Known constructed bindings can pipe directly, such as words | unique. Grouping also works, including for a user call such as (make_words()) | tally. Fields compose with the same stages:

[outer.items | filter(_ > 1) for outer in [{"items": [1, 2, 3]}]]

This returns [[2,3]]; grouping outer.items gives the same result. A nested filter gives _ its current element and keeps named outer arguments available. Native fields retain their host’s stage meanings and native values. See collection pipelines for supported scalar kinds, empty results and grouping in larger expressions.

map(pattern => expression) transforms each positional item once. The unnamed form accepts an expression using _ in the same scope. For example, [2, 3] | map(_ + 1) returns [3, 4]. A body can return a scalar, record, collection or supported native value.

flat_map requires each body result to be a positional sequence and concatenates one level in source order: [2, 3] | flat_map([_, _ + 10]) returns [2, 12, 3, 13]. Neither stage implicitly iterates sets, dictionaries, keyed arrays, tuples or opaque native selections. Native sequences need explicit positional host views. Both stages return eager positional sequences.

Nested arguments rebind _; named outer arguments remain lexical. Patterns use the existing strict mismatch and no-shadowing rules. Every written body receives scope, type and purity checks before iteration, including on empty input. Source traversal, body construction and appended results spend the same request budget; stream limits also apply to the expanded output. Failure returns no partial result. Bare map(...) and flat_map(...) use the host’s constructed input.

The capacity report runs with the word example; the CSV report uses named tables. These are Zirql entry points. Consumer frontend adoption is described separately in embedding programs.

For native data, a pipeline applies stages in written order. A host exposing borrowed words can support:

filter(prefix("a")) | head(1) | count

Here prefix is a host predicate that tests a word’s spelling. It is separate from the built-in starts_with function used with constructed strings. Other hosts can define predicates over syntax nodes or document handles. Zirql supplies the shared sequencing and Boolean composition.

Pipeline semantics cover filtering, ordering, identity, terminal results and the small parse_pipeline reader. The broader native representation also supports host stages and set operations. A host can reuse the common readers or lower an existing parser to that representation.

Sets and dictionaries preserve insertion order and retain their kind through explicit sorting. sort(d) orders dictionary keys; sort_by(d, value) orders values and retains current-order ties. Bare braces accept sets and dictionaries, including computed collection keys. See collection order for syntax and examples. Both native hosts retain dictionary order through saved results and JSON rendering.

Use project(subject, {fields}) to turn native subjects into report dictionaries. Explicit projections produce constructed values; native captures and paths can retain the original objects. See native programs for projection, match { from ...; bind ...; where ...; } and bounded paths.

Programs execute statements in order and save values in immutable bindings. A name must be available before use. Required external parameters have int, string or bool types and are checked before domain execution.

The common read_program reader assembles pattern let, param name: type, report and ordinary evaluation statements. Bindings and parameters require ;; native hosts accept an optional final expression semicolon. Function declarations have a balanced body and no separator. A host supplies native expressions/types, domain statements and output policy. A plain expression needs no declarations. CSV uses the same reader’s let-plus-expression subset, with no final semicolon, parameters, functions or report.

Parameters and bindings are sequential: a later declaration cannot satisfy an earlier use, and repeated names fail at their written definition. All function names are available before bodies and statements are read. A function receives its arguments explicitly and cannot capture program bindings, parameters or current input. Strings, comments and nested bodies do not introduce top-level functions. See text program embedding for the callable reader boundary.

Named pure functions have typed parameters and a result type:

fn twice(n: int) -> int { n * 2 }

Functions can call themselves or each other recursively. They can inspect native data through registered pure operations, but cannot emit output or edit the source. See function declarations for parameter types, scope and recursion limits.

Value matching selects the first matching arm and requires a final unguarded wildcard:

match (length(words)) {
0 => "empty",
1 => "one word",
_ => "several words"
}

Patterns destructure tuples, positional lists and records, bind names and test optional presence. [a, b] requires exactly two list elements; [a, b, ..] discards a remainder after at least two. [] requires empty and [..] accepts any positional list. Tuples keep exact tuple arity; keyed arrays and sets do not match list patterns. Lets, typed parameters, predicate arguments and comprehension binders use these same patterns. Ordinary bindings report located mismatch errors. A case generator skips the candidate; value-match arms try the next pattern. A native capture match enumerates correlated domain objects; a value match selects one result. Their forms and results are different.

The functions and control flow chapter describes matching, guarded arms, local bindings, loops and named reducers. The common reader supports these forms; each host connects them to its program and native-type handling.

A missing field, incompatible type, failed requirement or exhausted allowance is an error with a location. An absent value is ordinary data. An unknown native predicate is a third truth state, used when the host lacks a fact; negation preserves that uncertainty.

The application chooses finite limits for parsing and execution. Nested queries, comprehensions and host callbacks share the active budget. See diagnostics and limits for the contract.

parse_int(text), parse_float(text) and to_string(value) are pure common one-argument calls, usable in reports and typed functions. Their fixed names cannot be redeclared as functions. Scalars and strings defines the exact decimal grammar, finite numeric boundaries and scalar formatting. Known incompatible operands fail preflight; malformed text in an unchosen branch is not parsed.

format(value, spec) formats a Boolean, integer, finite float or string. An empty specification matches to_string; [align][0][width][.precision][type] selects alignment, numeric zero padding, Unicode-scalar width and explicit numeric formatting. See scalar formats for the supported types and strict operand rules.

Use lowercase f"..." for interpolation: f"count={2:03d}" returns "count=002". Each {expression} evaluates once, from left to right, in the current scope; :spec supplies a literal format specification. Nested expressions, collections, pipelines, strings and f-strings use the shared reader. {{ and }} produce literal braces, and JSON escapes apply to literal text. Escapes do not create fields. Empty fields, bare closing braces and malformed specifications fail while reading. Dynamic nested specifications, conversion flags and debug fields are unsupported. Plain strings keep their literal meaning.

Known types receive preflight checks even in skipped branches and unused functions. Dynamic specification contents and value-dependent formatting remain lazy. Native values require explicit scalar projection. Parsing and output expansion share the request budget, and errors retain original byte ranges.