Skip to content

Zirium query language

A query transforms an ordered stream. It starts with every operation in the input document. A pipe passes the current stream to the next stage:

filter(op("arith.addi")) | users | filter(has_attr("analysis.tag"))

This finds arith.addi operations, follows their direct users, and keeps users with an analysis.tag attribute. Navigation replaces the stream and preserves duplicates; edits keep the selection. Operation set operators, closure, backward_slice, forward_slice, and reachable produce source-ordered sets. slice is an alias for backward_slice. Use unique to remove duplicates from other streams.

For Rust and Python builders with native results, see the structured query guide.

input and a final emit are implicit. These programs print the same document:

# An empty program is valid.
input
input | emit

The CLI accepts a query argument or a program file (-f / --program-file). With no arguments, it runs the empty program on standard input. An input path of - also reads standard input, so zirium 'count' - works in a pipeline. Use an empty quoted argument to run the empty program on files:

Terminal window
zirium '' input.mlir
zirium 'filter(op("arith.addi")) | count' input.mlir
zirium -f query.zirium input.mlir
zirium --preset stablehlo --strict 'filter(op("stablehlo.dot_general")) | count' model.mlir

Use zirium --help for CLI usage and zirium --list-presets for bundled dialects. --preset NAME and --registry FILE are repeatable and combine their registries. Options can appear before or after the query; -- ends option processing. -f replaces the inline query rather than adding another program. --silent evaluates the complete program but suppresses all query results on stdout; warnings and errors still appear on stderr, and failures still return a nonzero exit status. Value-taking long options accept both separated and equals forms, such as --preset stablehlo and --preset=stablehlo. -f - is a literal query filename, and standard input may be supplied as an MLIR input only once.

Without a registry option, Zirium uses the baseline registry. Unsupported custom operations may still support name and structural queries, but their operands, attributes, and types can be incomplete. The CLI warns on stderr when it recovers these operations. Use --strict to reject recovery with no stdout, especially in scripts. Strict mode requires supported parsing; it does not enable full dialect verification or reconstruct operations implicit in custom assembly.

Input files are independent documents. input never combines different files. Output is buffered until all inputs and statements succeed; a later error leaves stdout empty. Multiple results are concatenated, so multiple json outputs are separate JSON values rather than one combined JSON document. The reserved document binding holds the input path, or stdin. Use it in print("{document}") and interpolated JSON strings and keys. Redefining it is an error. Library callers opt into this context with Query::parse_with_document_context and evaluate_with_context_options_and_limits; ordinary Query::parse reports {document} as unavailable. Closing an output pipe early (for example with head) exits quietly. The selected-fragment printer may change formatting even when printing the whole input. Use the library’s original-output API to reproduce input bytes.

Bindings make a longer query easier to read and let several parts reuse the same selection. Each binding is evaluated once for each input document:

adds = filter(op("stablehlo.add"));
matmuls = filter(op("stablehlo.dot_general"));
(adds union matmuls) | names | tally | json

A binding saves a result. Using adds later restores that stream regardless of the current selection. Each right-hand side starts from the whole document and may use earlier bindings. The final expression also starts from the whole document.

Names use ASCII letters, digits, and underscores, starting with a letter or underscore. Stage names, predicate names, and language keywords are reserved. Bindings cannot be reassigned, refer forward, or refer to themselves. Each binding needs a trailing semicolon. Query statements may follow bindings or appear between them.

Bindings preserve order and duplicates. Saved operations refer to the live document, so later edits are visible through those operations. Projected strings and aggregates retain their saved values. Bindings cannot edit or emit; put those stages in a query statement. Use union to combine saved selections; or combines predicates inside filter.

tally counts how many times each value occurs and returns a map from strings to counts. For example, names | tally counts operations by their full names. An empty value stream produces {}. Maps print as JSON objects, with keys sorted lexically. json can also emit a map explicitly.

To map function names to their number of matmuls:

matmuls = filter(op("stablehlo.dot_general"));
matmuls | root(op("func.func")) | attr("sym_name") | tally | json

Each matmul contributes its enclosing function’s name once. Keep the duplicates: adding unique would lose the information needed for the count. Functions without matmuls are absent. On examples/cli/stablelm-decode.mlir, this produces {"main": 19} when run with --preset stablehlo.

For a separate analysis of each function, use map_by(key_query, value_query):

functions = filter(op("func.func"));
functions
| map_by(
attr("sym_name"),
children | subtree | names | tally
)
| json

Both queries run independently on each single input operation. Here the key is the function’s name, and the value is a histogram of its body. children | subtree includes explicitly represented nested regions and excludes the function operation itself. An empty body produces an empty histogram.

The key must produce exactly one string; missing or duplicate keys are errors. If function names repeat across scopes, select one scope or use a unique attribute as the key. tally combines equal strings across scopes.

The value may be a count, a map, or a stream. Streams become JSON arrays, using the same representation as json. For example, replace names | tally with count for a total body size, or with result_types | tally for a type histogram. Maps can be nested. Key and value queries cannot edit or emit. Bindings used inside them still restore their saved results; they do not become queries relative to the current function.

sort orders a value stream lexically. reverse reverses an operation stream, value stream, or explicitly ranked map. head(n) keeps the first n items, while tail(n) keeps the last n; both preserve the retained items’ order. These stages truncate an already computed stream. They do not avoid parsing the whole file or evaluating earlier stages.

Use sort_by(query) to order operations by a derived key. The selector runs once for each operation and must return exactly one string or count. Sorting is stable, so operations with equal keys retain their previous order:

filter(op("func.func")) | sort_by(attr("sym_name")) | head(10)

min and max return the lexical minimum or maximum of a non-empty value stream. min_by(query) and max_by(query) return one operation using the same selector rules as sort_by; the first operation wins a tie. The corresponding min_all, max_all, min_all_by(query), and max_all_by(query) stages retain every tied extreme in its previous order. All extrema stages report an error on an empty stream. Use reverse after sort or sort_by for descending order.

Unranked maps remain sorted lexically by key. Use sort_by(value) on a tally or map_by map whose values are comparable scalars of one type. The result is an explicitly ranked map, and reverse, head, and tail preserve that order through bindings, JSON, and Markdown output. Equal values use lexical key order; reverse reverses the whole order, including ties. Nested map values are not implicitly ranked: project a scalar first. Here value is a map-entry selector, not a general pipeline stage.

Object and array literals combine saved results into a larger report:

functions = filter(op("func.func"));
n = functions | count;
counts = functions
| map_by(attr("sym_name"), children | subtree | names | tally);
{
"title": "Report for {n} functions",
"function_count": n,
"operation_counts": counts,
"metadata": {"schema_version": 1, "static_counts": true},
"notes": ["Shared callees count once when using reachable", null]
} | json

A bare binding inserts its saved result as structured data. A count stays a number, a map stays an object, and a stream becomes an array. Operation streams use the same inspection objects as json. Inserted data is copied into the new structure; subsequent edits do not change it.

Strings and object keys use the same {name} interpolation as print. For example, "count": n stores a number, while "count": "{n}" stores a string. Interpolation accepts counts and single strings, including a literal array containing exactly one string. Use {{ and }} for literal braces. Quotes, newlines, and other characters in inserted strings are escaped when JSON is emitted; inserted text is never parsed as JSON.

Objects require quoted keys and reject duplicate keys, including duplicates created by interpolation. Values may be objects, arrays, strings, JSON numbers, true, false, null, or earlier bindings. Trailing commas and forward references are errors. Values cannot contain query pipelines; bind the result of a query first. All bindings retain their saved meaning inside literals.

A literal is a pipeline stage that replaces the current result. It must start with { or [; scalars occur inside objects and arrays. Literals may be saved in bindings or embedded within one another:

totals = names | tally;
report = {"totals": totals, "complete": true};
[report, {"note": "Counts include container operations"}] | json

Objects and arrays also print as JSON through implicit output or emit. count counts outer object keys or array elements. markdown accepts literal objects with its supported table shapes and arrays of scalars; deeper structures produce its usual shape diagnostic. Literal construction is not a selection query, so it cannot be used directly as a set operand or fixed-point body.

The shared nesting, item, and work limits apply to literal construction. Inserted copies count toward the resulting structure’s size, and empty containers count too. Numbers use the JSON representation’s integer and floating-point ranges; use strings for numbers requiring arbitrary precision.

Use [expression for name in source if condition] to construct a report from projected values. The filter is optional. For this complete input:

"alpha"() : () -> ()
"beta"() : () -> ()

this query projects operation names, constructs records, then emits JSON:

labels = names;
[{"name": n, "label": "data:" ++ n} for n in labels] | json

The output is:

[
{"label": "data:alpha", "name": "alpha"},
{"label": "data:beta", "name": "beta"}
]

Repeated for clauses can depend on earlier bindings. The rightmost generator varies fastest; each if filters its own clause before later clauses run:

labels = names;
[{"name": n, "label": "data:" ++ s}
for n in labels
for s in [n, n ++ "!"] if s != "beta!"] | json

The rows have labels data:alpha, data:alpha!, and data:beta, in that order. A generator sees earlier names in its source, then installs its binder for its filter and the head. Duplicate binders, shadowing, forward references, and undefined names fail at their query locations. _ discards a value and cannot be read. Nested comprehensions retain their own array shape.

Inside a comprehension, object labels are static quoted strings and bare names are values. {"n": n} uses the label n and the bound value of n. Strings are decoded literals: "{n}" contains braces and the letter n. Outside comprehensions, plain JSON-shaped literals keep their existing binding and interpolation rules. for and if remain ordinary binding names elsewhere, including a literal such as [for] when for is bound.

The supported expression forms are:

  • Boolean, signed integer, finite decimal float, and string literals; earlier bindings and generator names; grouping with parentheses.
  • Positional arrays, records with quoted labels, and nested array comprehensions.
  • Numeric sign, not, checked +, -, *, /, integer div and mod; numeric <, <=, >, >=; typed == (also =) and !=.
  • Boolean and and or; string or positional-array ++; record access r.field, array access a[i], and inclusive integer ranges lo..hi.

Array positions start at one. Integer arithmetic uses signed 64-bit values and refuses overflow or division by zero. Floats use finite binary64 values; nonzero literals or arithmetic results rounded to zero fail. Mixed numeric operations require exact integer-to-float conversion. Strings receive no numeric coercion. Boolean operands short-circuit, so false and (1 div 0 == 1) skips the runtime division. Every branch still receives scope and type validation before execution; a skipped branch cannot hide incompatible types or an unsupported call. All expression calls and unlisted forms are refused.

Use an explicit projection such as names, attr, or count before constructing scalar reports from operations. Native operation, type, and attribute handles retain their identity and cannot become comprehension values through their printed spelling. Saved JSON arrays and objects can supply scalar or structured values; null and integers outside i64 fail when projected into this subset.

Rust callbacks receive QueryOutput::Native(NativeValue::Constructed(report)) before rendering. report.value() borrows the ordered Zirql value, including record field order. Clone shares this immutable result. Evaluator binding reads and emission snapshots use charged copies. report.render_json_with_budget() and report.render_ndjson_with_budget() render under the active allowance; Query::evaluate_with_budget() supplies that allowance to callbacks. The CLI prints constructed values as JSON and supports the usual --jsonl envelope. json and emit pass the constructed value onward. MLIR and collection stages that do not support this report kind fail before execution.

Comprehension requests use one budget for bounded parsing/lowering, validation, source projections, candidates before filters, nested construction, copies, snapshots, and JSON rendering. The default aggregate allowance is 1,000,000 produced or copied entries; --max-results changes it. --max-work and --max-items retain their work and per-stream bounds. Reusing a parsed Rust query charges its retained parse spending on each evaluation. Rust callers can bound parsing with Query::parse_with_program_limits() and choose execution bounds with Query::evaluate_with_program_limits(); callbacks that render can use Query::evaluate_with_budget() with Budget::for_program().

Use markdown to emit a table from a histogram or a map of histograms. Use print("text") for headings and explanatory text between results:

functions = filter(op("func.func"));
n = functions | count;
print("# Function operation report");
print("");
print("Functions inspected: {n}");
functions
| map_by(attr("sym_name"), children | subtree | names | tally)
| markdown;
print("Counts describe explicitly represented operations.");

Each semicolon ends a statement. Statements run in order, and each query statement starts with every operation in the current document. Its result is implicitly emitted unless it ends with an explicit emitter. Bindings may appear between query statements; they are evaluated where written and never emit. The final query statement may omit its semicolon. A program containing bindings still needs a query statement after its last binding.

Prefix a query with do to evaluate it while suppressing its implicit result. Use it for an edit followed by whole-document output:

do filter(op("arith.addi"))
| set_attr("analysis.tag", "review");
emit

The edit remains visible to later statements, and the final emit prints the complete edited document once. A do statement requires its trailing semicolon, including at the end of a program. Explicit emitters inside its query still emit; do suppresses only the implicit result.

check fails evaluation when the current stream is empty. check(n) instead requires exactly n items, including zero. Either form accepts a message as check("message") or check(n, "message"); a failure includes that message after the expected and actual cardinalities. A successful check leaves the stream unchanged, so it can appear anywhere in a pipeline. Prefix a check statement with do when only the exit status matters:

do filter(op("linalg.matmul")) | check("expected a lowered matmul");
do filter(op("bufferization.alloc_tensor"))
| check(0, "tensor allocations must be eliminated");

This makes a Zirium program usable as a structural MLIR test: the first check requires a matmul, the second rejects any remaining tensor allocation, and the CLI exits unsuccessfully if either condition is false. As with other evaluation errors, output is buffered and stdout stays empty when any check fails. Use check(n) after unique when duplicate navigation paths should not count as separate matches.

For a check program whose statements need not use do, suppress every result at the CLI boundary:

Terminal window
zirium --silent --strict -f checks.zirium input.mlir

print appends a newline. It leaves the current stream unchanged, so it also works inside a pipeline. A final print suppresses implicit output, just like a final json or markdown. Use print("") for a blank line. Template strings can span actual lines; the string escape rules below still apply.

Interpolation uses {name}, where name is an earlier binding containing a count or exactly one string. Empty or multiple strings, maps, and operation selections produce a diagnostic: project or count them first. Interpolation does not evaluate expressions or traverse map fields. Write {{ and }} for literal braces. Inserted values are plain text, with no Markdown escaping and no further interpolation.

The Markdown emitter supports these shapes:

Input Output
String stream or scalar array A one-column table headed Value, preserving order and duplicates.
Map of scalars A Key / Value table.
Map of maps of scalars Outer keys become rows; the union of inner keys becomes columns.
Saved count The number as a paragraph.
Empty stream or map The paragraph No entries.

For example, {"a": {"add": 2}, "b": {"add": 1, "mul": 3}} renders as:

Key add mul
a 2
b 1 3

Map rows and columns are sorted lexically, except that ranked maps retain their row order. Missing cells and null values are blank. Other scalars are strings, numbers, or booleans. Empty inner maps contribute blank rows; if every inner map is empty, only the Key column remains.

Operation streams, mixed scalar/map rows, arrays inside maps, and deeper maps produce a shape diagnostic. Use names, tally, or map_by to form a table, or use json for deeper data. The diagnostic identifies the offending entry or cell when possible.

markdown preserves the current result, as json does. It surrounds its output with blank lines, escapes Markdown and HTML characters in cells, and renders embedded newlines as <br>. The table uses GitHub-flavored Markdown. The expanded table, including missing cells and headers, must fit --max-items; emitted text also consumes --max-work. A late shape or interpolation error leaves CLI stdout empty, including earlier print output.

Adding reachable includes supported dependencies and referenced bodies:

functions = filter(op("func.func"));
functions
| map_by(
attr("sym_name"),
children | subtree | reachable | names | tally
)
| json

If main calls helper twice, its body histogram counts both calls. The reachable histogram also counts the helper’s body once. Recursive calls still terminate because each static operation is counted once. These counts describe the represented code, not execution frequency.

reachable includes seeds, their explicit nested bodies, and transitive SSA definitions. Block arguments are boundaries. Direct func.call and registered call_like operations resolve their callee symbol and include the target’s body. The target declaration itself is not added merely because it is called. Registered func_like operations participate in symbol lookup. Supported cf.br and cf.cond_br edges include their target blocks without widening the selection to the enclosing function. The result is a source-ordered set.

Named functions and direct calls can represent lambdas under these rules. Indirect calls and dialect-specific captures need additional reference support. An operation with unknown reference semantics is retained as a leaf: traversal skips its regions, operands, successors, and symbols. Already-selected operations inside its regions remain selected. Registering its edges may expand the result.

--strict rejects unknown reference semantics by operation name. Unresolved or external callees, malformed known calls, and declared unsupported references are errors in either mode.

As with body counts, compact assembly can omit implicit operations from the represented structure. reachable does not synthesize those operations. Use closure when retaining enclosing scopes for a printable fragment; reachable deliberately stops at block arguments for analysis. closure, including inside fixpoint, remains strict about unknown reference semantics.

Predicates test one operation. They appear inside filter(...).

Predicate Matches
true / false Every operation / no operation.
op("name") An operation with exactly this full name.
dialect("name") An operation whose name starts with exactly this dialect followed by a dot.
result_type("type") An operation with at least one result whose retained type spelling equals this string.
has_attr("name") An operation with this attribute, regardless of its value.
string_attr_eq("name", "value") An operation whose named attribute is a string equal to this decoded value.

Combine predicates with not, and, and or, in that precedence order. Parentheses override precedence:

filter((op("arith.addi") or op("arith.muli")) and not has_attr("skip"))

Use filter(not predicate) to exclude matches. filter(true) leaves the selection unchanged. count alone counts every operation, including container operations such as builtin.module.

String equality compares decoded values. For example, string_attr_eq("message", "say \"hi\"") matches the MLIR string attribute message = "say \22hi\22". It does not compare numeric attributes or their printed spellings.

For example, filter(dialect("stablehlo") and result_type("tensor<32xf32>")) finds StableHLO operations producing that tensor type. Type matching is exact spelling matching; use result_types or operation JSON to inspect shapes and element types when more involved analysis is needed.

Operation names must be non-empty. Attribute names use dotted ASCII identifiers: each component starts with a letter or underscore, followed by letters, digits, or underscores. analysis.tag and _zirium.state_2 are valid.

Stage Resulting selection
input Every operation in the current document, including earlier edits.
defs Operations directly defining operands of the selected operations. A block argument resolves to the operation owning its region.
users Operations directly using results of the selected operations.
defs(index) The definition of one zero-based operand of each selected operation.
users(index) Uses of one zero-based result of each selected operation.
parent Each selected operation’s immediate enclosing operation.
children Operations directly contained in the selected operations’ regions and blocks.
root(predicate) The nearest operation on each selected operation’s ancestor chain that matches the predicate.
subtree Each selected operation and all its descendants.
closure The selection plus one step of supported dependency expansion.
slice, backward_slice The selection and its transitive SSA definitions, stopping at block arguments.
forward_slice The selection and its transitive SSA users.
reachable The selection, explicit bodies, SSA definitions, and supported referenced bodies, each operation once.

defs, users, parent, and children move one step and replace the input selection. They do not automatically keep it. parent drops operations with no enclosing operation. defs and users are not inverse relationships: a block argument belongs to an enclosing operation rather than an operation result. An out-of-range navigation index drops that input item. Indexed navigation preserves duplicates just like its unindexed form.

root(predicate) tests each selected operation, then walks toward the document root until it finds a match. It returns at most one match for each input item. Repeated matches remain repeated, so unique commonly follows root:

filter(op("linalg.matmul"))
| root(op("func.func"))
| unique

subtree expands each selected operation independently. Overlapping subtrees therefore contain duplicates. An empty stream stays empty.

For example, find the function containing a call, expand its body, and tag its returns:

filter(op("func.call")) | parent
| subtree
| filter(op("func.return"))
| set_attr("analysis.tag", "review")

Returns in sibling functions are unaffected. Append input to print the complete edited document.

Selection and printing are distinct. Selecting a function counts as one operation, but printing it includes its body. Printing a nested operation also retains the enclosing syntax needed to represent it. Neither behavior adds those operations to the query selection. Use subtree when descendants must participate in filtering, counting, or editing.

Fragments may omit required SSA definitions. Select dependencies explicitly; printing a fragment does not verify that it is valid standalone MLIR.

Use backward_slice to inspect a computation without expanding the whole function when an operand is a function input. The shorter slice spelling is an alias:

# Follow the first returned value, for example logits rather than cache outputs.
filter(op("func.return")) | defs(0) | backward_slice

backward_slice follows explicit SSA operands only. It does not expand callees, successors, or region bodies, and does not infer which operands affect individual results of a multi-result operation. Generic quoted operations need no registration for this traversal; recovered unparsed operations and invalid operands are errors. Use closure when retaining complete scopes and supported symbol dependencies is the desired behavior.

forward_slice follows uses of every result of the selected operations, then uses of every operation it reaches, until no new users remain. It retains the seed and returns each operation once in source order:

filter(op("stablehlo.dot_general")) | forward_slice

Like users, it follows explicit SSA uses, including successor arguments. It does not infer control-flow, symbol, region, memory, or effect dependencies.

defs(index) still maps a block argument directly to its owning operation. If the selected return operand is itself a function argument, that navigation selects the function; backward_slice does not undo that selection. Starting backward_slice at the return instead retains the return and stops at its argument operands.

Compact StableHLO reductions using applies stablehlo.add are represented as a single operation. Their implicit reducer body is not synthesized, so children is empty and operation counts differ from the explicit-region spelling. Counts describe the parsed structural representation, not normalized MLIR.

union, intersect, and except are infix operators between selection queries. Both operands receive the same incoming selection.

filter(op("arith.addi")) union filter(op("arith.muli"))

For this example, filter(op("arith.addi") or op("arith.muli")) is shorter. Set operators are useful when the operands navigate differently:

filter(op("arith.addi")) | (defs union users)

This combines the definitions and users of the selected adds. It does not run users on the output of defs.

Operator Result
left union right Operations in either result.
left intersect right Operations in both results.
left except right Operations in the left result but not the right.

Set operators also accept value streams. Value union keeps first appearance from left then right; intersection and difference preserve left-side order. Both operands must produce the same stream kind.

Pipes bind more tightly than set operators. All set operators have equal precedence and associate left to right. Parenthesize a set expression before applying a stage to its combined result:

(filter(op("arith.addi")) | users union filter(op("arith.muli")) | defs)
| count

An operand searches the complete document only when it receives the initial selection or explicitly uses input. This query adds every tagged operation in the document to the current selection:

filter(op("arith.addi")) | (filter(true) union (input | filter(has_attr("tag"))))

Set operands must return operation or value streams; they cannot edit, count, or construct maps. Apply edits and counting after grouping the set expression. Operands may use emit for inspection; emissions occur from left to right. Both operands still see the same document and incoming selection.

closure retains the selection and expands dependencies once. It adds direct SSA definitions. Block arguments add their owning operation and its subtree; func.call adds its resolved callee and subtree; cf.br and cf.cond_br add the successor region’s owning operation and subtree. These subtree expansions retain the corresponding scope, but dependencies of newly added operations are followed on subsequent applications.

Use fixpoint(closure) for the complete supported dependency slice:

filter(op("func.call")) | fixpoint(closure)

Closure requires registered reference semantics. Unregistered operations and unsupported symbol or successor references produce errors.

fixpoint(query) repeatedly replaces the selection with the query’s result until the selection is unchanged. Its body can contain navigation, filters, set expressions, grouping, and nested fixed points. It cannot edit, count, or construct maps. Use fixpoint(closure | emit) to inspect each iteration, including the final unchanged result. If selections cycle without becoming unchanged, evaluation fails. Emission does not affect convergence.

Fixed points do not implicitly accumulate intermediate selections:

# Retain the seed and add users until no more are found.
filter(op("arith.addi")) | fixpoint(filter(true) union users)
# Follow definitions until unchanged; on an acyclic chain this becomes empty.
filter(op("arith.addi")) | fixpoint(defs)

The same rule applies to parent: repeatedly moving beyond the document’s roots eventually gives an empty selection. Use root(predicate) to find a matching ancestor and subtree to expand a selected fragment.

Duplicate-preserving expansion need not converge: fixpoint(subtree) keeps adding copies when an ancestor and its descendant are selected. Use fixpoint(subtree | unique) when a set is intended. Cycle detection catches repeating selections; evaluation limits also stop streams that keep growing.

By default, each evaluation permits 10,000,000 work units and 1,000,000 items per stream. Work counts stage input items (at least one per stage), fixed-point iterations, and dependency, ancestor, and subtree visits. These limits do not bound time or memory bytes. CLI callers can set --max-work N and --max-items N; Rust callers can use Query::evaluate_with_limits and EvaluationLimits. A limit error follows the usual no-stdout CLI contract. The initial stream contains every operation, so it must fit max_items even when the first stage filters down to a small selection. The work limit does not bound MLIR parsing, printing, or the total bytes buffered across emissions.

--max-results N adds a whole-program allowance for produced and copied native entries, binding reads, emission snapshots, nested JSON containers/fields and rendered report storage. It is separate from the per-stream --max-items ceiling. With this option, copied strings and encoded JSON/Markdown also consume work; escaped Markdown reserves a byte bound before construction. Rust callers can use Query::evaluate_with_program_limits with zirium::query::ProgramLimits. Existing EvaluationLimits APIs keep their established work and stream limits for programs without comprehensions. Comprehension requests also use the default aggregate report allowance described above. An aggregate failure carries a query range and follows the same no-stdout CLI rule. Native MLIR printing remains a caller-owned emission boundary.

The common fixpoint(closure) query uses a worklist and visits dependencies once. A body with additional stages, including emit or json, runs step by step and retains its per-iteration output. Deep slices with per-iteration output can still be expensive and may need a larger work limit.

attr("name") replaces each operation with its named attribute value and drops operations without that attribute. It decodes string and symbol attributes. For other attribute kinds, it returns the MLIR spelling. Operation-only stages, including navigation, filtering, and edits, reject value streams. emit and the implicit final emission print one projected value per line.

An attribute string that cannot be decoded as UTF-8 produces an error rather than disappearing from the stream. Use operation json to inspect its escaped spelling. Paired StableHLO dot dimensions preserve both sides: for example, attr("contracting_dims") returns [0] x [1]. Generic fragment printing represents this custom clause as nested arrays [[0], [1]].

names projects operation names. result_types and operand_types project retained type spellings in result or operand order, flattening across selected operations. These stages produce value streams and preserve duplicates. For example, filter(dialect("stablehlo")) | names | unique lists the StableHLO operation kinds present in the input.

unique keeps the first copy of each operation or value. It preserves stream order. For example, this prints the names of functions that contain a matrix multiplication:

filter(op("linalg.matmul"))
| root(op("func.func"))
| unique
| attr("sym_name")
| json

set_attr("name", "value") adds or replaces a string attribute on every selected operation. remove_attr("name") removes an attribute; missing attributes are ignored. Both preserve the selection. Later filters and input see the edits. Attribute values passed to set_attr cannot contain control characters. Each edit stage commits atomically.

Use a do statement when an edit should not itself produce output. A following query starts from the complete edited document, so do EDIT; emit prints one whole document without first printing the edited selection.

emit prints the current fragment and passes the same selection onward:

filter(op("arith.addi")) | emit | users

This prints the adds, then their users. Each emission captures the document before later edits. A statement emits its final result implicitly unless it ends in an explicit emitter, including inside a final group. Thus emit | emit prints twice and emit prints once.

format applies source-aware layout to the current fragment and also passes the selection onward. By default it retains the source assembly form for each operation, including custom operation syntax, SSA names, aliases, and comments when retained syntax is available:

format

Formatting options use named arguments. width controls the preferred line width and indent controls spaces per indentation level. Both must be positive; indent may not exceed 256. Use MLIR’s quoted generic assembly form explicitly when source spelling should be replaced:

format(assembly = "generic", width = 100, indent = 2)

For edited hybrid documents, unchanged source is reused and dirty operations prefer registered custom printers. A fragment or structurally edited document without reusable source prefers registered custom syntax and falls back to generic syntax. Unknown recovered custom operations remain verbatim in source mode.

Nested queries neither reset to input nor emit implicitly. A fixed-point body ending in emit prints each iteration; the enclosing statement still follows the final-emission rule above.

count prints the stream’s size followed by a newline. It is terminal; no stage may follow it. To emit a fragment and then count it, use emit | count.

Selected fragments normally retain every attribute on the ancestor operations needed as enclosing shells. CLI --fragment-scope minimal instead prints those shell-only ancestors in generic syntax, retaining properties and structural identity/signature attributes while omitting incidental attributes and locations. Selected operations and their bodies keep all metadata. The option affects selection output only.

Minimal scope does not supply missing SSA definitions, callees, or terminators. The fragment may still be incomplete.

json emits streams as arrays and maps as objects, then passes the result onward. For value streams, the array contains strings. For operation streams, each entry contains the operation name, an object of attribute spellings, and operand_types and result_types arrays. Incomplete type information may appear as a placeholder or null. Zirium cannot read this inspection format back as MLIR. Like emit, a final json suppresses the implicit final emission.

Statement outputs and results from multiple input files are concatenated without an automatic separator. Use print when a report needs headings, spacing, or a delimiter. Counts are plain lines. The CLI buffers all emissions across all input files until processing succeeds: a query, evaluation, or printing error produces no standard output. Input files are never overwritten. Rust callers use the Query::evaluate emission callback and can choose their own buffering policy.

Pass --jsonl to emit one compact JSON object per result in input and statement order. Each record has document and result fields. A json emitter supplies the JSON value in result. Text and Markdown are strings with escaped newlines, counts are numbers, and operation selections are MLIR strings. The same buffering rule applies: any later failure leaves stdout empty. --ndjson is an alias.

program = empty | { statement } final
statement = binding | do-statement | query ";"
final = query [ ";" ] | do-statement
binding = identifier "=" query ";"
do-statement = "do" query ";"
query = pipeline { ("union" | "intersect" | "except") pipeline }
pipeline = stage { "|" stage }
stage = object | array | identifier | "markdown" | "print" "(" string ")"
| "format" [ "(" format-option { "," format-option } ")" ]
| "tally" | "map_by" "(" query "," query ")"
| "sort" | "sort_by" "(" query ")" | "value" | "reverse"
| ("head" | "tail") "(" integer ")"
| "min" | "min_all" | ("min_by" | "min_all_by") "(" query ")"
| "max" | "max_all" | ("max_by" | "max_all_by") "(" query ")"
| "input" | "filter" "(" predicate ")"
| ("defs" | "users") [ "(" integer ")" ]
| "parent" | "children" | "closure" | "slice"
| "backward_slice" | "forward_slice" | "reachable"
| "root" "(" predicate ")" | "subtree" | "unique"
| "attr" "(" string ")" | "names" | "result_types" | "operand_types" | "json"
| "fixpoint" "(" query ")" | "(" query ")"
| "set_attr" "(" string "," string ")"
| "remove_attr" "(" string ")" | "emit" | "count"
format-option = "assembly" "=" ("source" | "generic")
| ("width" | "indent") "=" integer
predicate = and-expr { "or" and-expr }
and-expr = not-expr { "and" not-expr }
not-expr = { "not" } primary
primary = "true" | "false" | "op" "(" string ")"
| "dialect" "(" string ")" | "result_type" "(" string ")"
| "has_attr" "(" string ")"
| "string_attr_eq" "(" string "," string ")"
| "(" predicate ")"
object = "{" [ string ":" value { "," string ":" value } ] "}"
array = "[" [ value { "," value } ] "]"
value = object | array | string | number | "true" | "false" | "null" | identifier
integer = digit { digit }
number = a JSON number

Whitespace is insignificant between tokens. # begins a comment extending to the end of the line; inside a string it is a literal character. Strings use double quotes and support JSON escapes: \", \\, \/, \b, \f, \n, \r, \t, and \uXXXX (including valid surrogate pairs). Actual newlines and tabs are also allowed in query strings. Other escapes are errors.

Query and predicate nesting share a 64-level limit. Materialized objects and arrays have a separate 64-level limit, including structures built through bindings. Nested contents count toward --max-items, and copying saved results counts toward --max-work. Flat boolean chains, pipelines, and set chains do not consume a nesting level per term. The CLI reports query errors with a byte offset, line, column, and source caret. Program-file positions include leading whitespace and comments.

For implementation timing and scaling measurements, see query profiling.

--diff BEFORE AFTER changes the initial stream from operations to change records. change("added") selects a primary kind and changed("operands") selects a direct changed field. Ordinary predicates inspect the after endpoint, or the before endpoint for removals. Use before or after before operation navigation. The two sides remain distinct even for an empty selection, and diff programs reject edits. Nested expressions keep their current input; explicit input resets to every change record. Projected operations support the ordinary graph traversals and same-side fixed points. Change streams support keyed ordering and map_by, and the document labels are available as before_document and after_document for interpolation. See semantic diff for the full policy.