How to structurally search and rewrite Cadenza programs with the `cdz query` / `rewrite` codemod tool, and how to ask the COMPILER for a semantic fact with `cdz type` / `cdz uses` / `cdz check` (all in the unified `cdz` binary). Read this whenever the task is finding or transforming code by SHAPE rather than text — structural search-and-replace, a rename/peephole/wrap refactor, a multi-rule simplifier pass, running a codemod across files/directories (apply in place, diff preview, or JSON), structurally diffing two programs (which subtrees changed), finding duplicated subtrees (exact or near-clone / anti-unification), counting occurrences of a form, extracting spans of matching nodes, or building on the query/Tree matcher API — OR when the task is a SEMANTIC query the shape layer can't answer: the type of a definition (`cdz type`), every source location that references a name (`cdz uses`, a span-mapped go-to-references), every well-formedness fault (`cdz check`, "diagnostics as you type"), a name's definition (`cdz def`, go-to-definition), the bindings visible at a point (`cdz scope`, variable scope tracking), a module's exported interface (`cdz exports`), a definition's/built-in's documentation (`cdz doc` / `cdz doc-at`), or a definition's compilation DISPOSITION and concrete monomorphizations (`cdz instantiations` — whether a def was inlined, specialized, emitted as a standalone function, or transformed into an accumulator loop, and at which concrete types / dictionaries a generic was specialized). Covers the `,x`/`,@xs` pattern language, structural guards (`is-literal`/`head-is`/`matches`/`not`), relational context (`inside`/`has`), multi-rule sets + traversal strategy, multi-file/`--write`/`--diff`/`--json`, the `diff` (structural tree-diff) and `clones` (content-hash duplicate + `--near` anti-unification) subcommands, `lint` mode (anti-pattern checker / CI gate), the semantic `type`/`uses` compiler queries, the COMBINED `cdz query --where 'type-of(x) = T'` (a structural match filtered by a compiler type predicate), the CLI, the library API, and the self-hosted sidecar map.
Install
npx skillscat add camshaft/cadenza/codemod Install via the SkillsCat registry.
Structural query & rewrite (codemod) for Cadenza
A codemod here is structural search-and-replace over the homoiconic AST, not a text patch.
Because every Cadenza form is (head child…) data, a pattern that matches code is itself code — a
rewrite rule reads in the shape of what it rewrites. The structural tool lives in cadenza-syntax
(the query module + the query/rewrite/diff/lint/clones subcommands). It is Rung 2 ofimplementation/design/DESIGN-query-engine.md (a built-in Rust driver) standing in for the eventual
self-hosted sidecar — see implementation/PROTOTYPE-codemod.md for the full write-up.
The binary is now
cdz, notcdz-syntax. The front-end (convert + this codemod) and the
compiler (compile, plus the semantic queries below) were unified into ONE tool,cdz, over both
libraries (cadenza-syntax+rcdzc); the standalonecdz-syntax/rcdzcbins were retired. Everycdz-syntax query …is nowcdz query …(the subcommands and flags are unchanged — same code). See
[[cdz-unified-binary-cli]].One consequence is a genuinely new capability. Because
cdzholds BOTH libraries in one
process, it also offers the SEMANTIC queries the structural layer deliberately can't —cdz type
(a definition's inferred type) andcdz uses(every reference, asfile:line:col). Those reach
into the compiler (rcdzc), so they are NOT codemod guards; they are a sibling surface documented
in §Semantic queries below. The
structural pattern language stays purely shape-based (no type/scope guards); the semantic answer is
a separate command.
The pattern language (not a new language)
A pattern and a rewrite template are ordinary s-expression text with two metavariable sigils the
reader already produces — no grammar is invented:
| Sigil | Reads as | Meaning |
|---|---|---|
,x |
(unquote x) |
bind one node to x |
,@xs |
(unquote-splicing) |
bind a run of zero-or-more sibling nodes to xs |
,_ |
wildcard: match one node, bind nothing | |
,@_ |
wildcard run |
Everything else is a structural literal that must match exactly. (+ ,x 0) matches an addition
whose second operand is the integer 0, binding the first operand to x.
Rules to know:
- Consistency (non-linear): a repeated metavar must bind structurally-equal subtrees —
(+ ,x ,x)
matches(+ a a)and(+ (f 1) (f 1)), not(+ a b). Wildcards,_are exempt. - Several splices per list, as long as no two are ADJACENT — a fixed element between them anchors
each run boundary:(call ,head ,@mid ,last), and the clause-delete idiom(F ,@before X ,@after)
(deleteXfrom ANY position of a variadic form). Only directly-adjacent splices (,@a ,@b) are
rejected (nothing divides the run). - Unbound template var ⇒ that site is left unchanged (reject-don't-corrupt).
These are the same quote-pattern shapes (`(+ ,x 0)) that spec/semantics/20-structural-editing.sexp
pins as the self-hosted end state, so a rule written today reads identically later.
Guards constrain a metavar structurally: ,(name guard…) (conjunctive). Guards areis-literal, is-name, is-int/is-float/is-str/is-bool, is-atom/is-list,(head-is NAME), (matches PAT), (not GUARD). E.g. (+ ,(x is-literal) ,y), (f ,(g (head-is *))).
An unknown guard is rejected at compile time. All guards are purely structural — there are NO
scope/binding or type guards (refs/defines/type-of) inside a pattern; binding/type analysis
is the compiler's job, not the matcher's, to avoid duplicating the resolver. When you need a semantic
fact — "what is the type of this node", "where is this name used" — use the cdz type / cdz uses
compiler queries (§Semantic queries),
which ask rcdzc directly rather than re-deriving it in the pattern layer. To COMBINE the two — a
structural selector filtered by a type predicate — use cdz query PATTERN --where 'type-of(x) = T'
(§Combined query); the pattern stays purely
structural, the --where clause adds the semantic filter.
CLI
The binary is cdz (at target/<profile>/cdz, or cargo run -p cdz --). --from/--to infer from
a FILE extension (.cdz/.ml→ml, .sexp→sexpr, .bin→binary); stdin needs an explicit --from.
The codemod subcommands (query/rewrite/diff/lint/clones) are the front-end surface; cdz
also has convert, compile, and the semantic type/uses queries (below).
# find every additive-identity site; prints "byte START-END: <form>" + "$var = …" bindings
$ printf 'f(a + 0, b * 1)' | cdz query '(+ ,x 0)' --from ml
byte 2-7: (+ a 0)
$x = a
# just the count
$ printf 'g(x + 0) + (y + 0)' | cdz query '(+ ,e 0)' --from ml --count
2
# rewrite: (+ ,x 0) -> ,x (result on stdout, "rewrote N site(s)" on stderr)
$ printf 'f(a + 0, b + 0)' | cdz rewrite '(+ ,x 0)' ',x' --from ml --to ml
cdz: rewrote 2 site(s)
f(a, b)
# wrap a call with a splice template
$ printf '(risky a b)' | cdz rewrite '(risky ,@args)' '(log (risky ,@args))' --from sexpr
(log (risky a b))
# delete a clause from ANY position of a variadic form (two splices around a fixed anchor)
$ printf '(case foo (doc "d") (needs bar) (result 1))' \
| cdz rewrite '(case ,@a (needs ,_) ,@b)' '(case ,@a ,@b)' --from sexpr
(case foo (doc "d") (result 1))
# a guard + a relational constraint
$ printf '(do (+ 1 a) (+ b c))' | cdz query '(+ ,(x is-literal) ,y)' --from sexpr --count
1
$ printf '(do (safe x) (danger (g x)))' | cdz query 'x' --from sexpr --inside '(danger ,@_)'
byte 24-25: x
# a multi-rule peephole set (first match wins), applied in one bottom-up pass
$ printf '(f (+ a 0) (* b 1) (* c 0))' | cdz rewrite --rules peephole.rules --from sexpr
(f a b 0)
# run over a whole directory; --json for machine-readable output
$ cdz query '(+ ,e 0)' src/ --json
[{"file":"src/a.ml","span":{"start":2,"end":7},"matched":"(+ x 0)","bindings":{"e":"x"}}, …]
# preview a rewrite (--diff, file untouched), then apply in place across a dir (--write).
# --write/--diff are FORMATTING-PRESERVING by default: only changed subtrees are spliced at their
# spans, so a hand-formatted file keeps its layout/comments (the diff is minimal, line-by-line).
$ cdz rewrite '(+ ,x 0)' ',x' src/a.ml --diff
$ cdz rewrite '(+ ,x 0)' ',x' src/ --write
# force a canonical whole-file reflow instead (opt out of preserving):
$ cdz rewrite '(+ ,x 0)' ',x' src/a.ml --write --reprint
# STRUCTURAL diff of two programs — which subtrees changed (not text lines)
$ cdz diff before.ml after.ml
1: replace (+ a 0) => a
# LINT: flag anti-patterns; exits non-zero on any `error` (a CI gate)
$ cdz lint src/ --rule '(lint (deprecated ,@_) "avoid" error)'
src/a.ml:2:3: error: avoid
# CLONES: find duplicated subtrees (copy-paste) within/across files
$ cdz clones src/ --min-size 4
clone: 3 occurrences, 4 nodes: (validate config strict)
src/a.ml:1:11
src/a.ml:2:11
src/b.ml:1:11
# NEAR-CLONES: same shape, differing leaves — INFERS the pattern (feed straight into rewrite)
$ cdz clones src/ --near --min-size 3
near-clone: 3 occurrences, 1 hole(s): (scale x ,m0)
src/a.ml:1:11 …- Multiple FILEs and directories are accepted (a DIR is recursed and FILTERED to source
extensions — READMEs/dotfiles skipped;--fromoverrides the format, not which files are included;
a named file always honors--from); with no FILE, input is stdin. Empty dir warns; in a sweep, one
bad file is skipped-with-warning (a single named target is a hard error). Human output over several
files is grouped by=== file ===. queryprints matches (span + bindings),--countthe number (per file + atotal:), or--json
a flat array[{file?, span, matched, bindings}]. No match ⇒ empty, exit 0. Filter by structural
context:--inside/--has/--not-inside/--not-has PAT(repeatable, conjunctive; ancestry/
containment only — no scope).rewrite PATTERN TEMPLATE(orrewrite --rules FILE) prints the rewritten program to stdout and
the count to stderr (stdout stays a clean, pipeable program; the count line is prefixedcdz:).--rules FILE=(rule PAT TMPL)
forms (first match wins);--top-down(default bottom-up);--fixpoint(bounded). Output modes:--diffpreviews a unified diff (file untouched),--writeapplies in place (FILE inputs only,
changed files only),--jsonemits{file?, count, rewritten}(mutually exclusive with--write).
FORMATTING-PRESERVING by default: when the output surface matches the input and the input carries
spans (ml/sexpr), only the changed subtrees are spliced at their source spans — all other bytes
(whitespace, newlines, comments, hand-alignment) are copied verbatim, so a bulk edit of a
hand-formatted file gives a minimal, reviewable diff instead of a whole-file reflow. A deleted
list child removes its own line cleanly (no dangling blank line).--reprintopts out (canonical
whole-tree reflow, the old behavior — for deliberate normalization); a cross-surface--toalways
reprints. Always validates as a transaction: the edited text is re-parsed and checked
structurally-equal to the rewritten tree; a splice that can't be validated falls back to a reprint
(with a warning), and a result that doesn't round-trip is rejected — never a half-applied edit.diff FILE-A FILE-Bis a structural (subtree) diff, not a line diff: it reports each changed
node by path —PATH: replace OLD => NEW/add NEW/remove OLD, or--json[{path, kind, old?, new?}]. Same-head lists recurse positionally (a changed operand is one
point-change), differing arity aligns by LCS. Use it to review what a rewrite/edit changed to the
tree, independent of formatting. (Distinct fromrewrite --diff, which is a line-based unified diff.)lint [FILE|DIR…] --rules FILE | --rule '(lint …)'flags structural anti-patterns. A rule is(lint PATTERN "message" [severity]), severity ∈error/warning/info(defaultwarning),
patterns use the full language (guards/splices). Each match →FILE:line:col: SEVERITY: message
(or--json). Exits non-zero iff anyerror-severity diagnostic fired — a CI gate; warnings
don't fail. Semgrep-lite for the AST.clones [FILE|DIR…] [--min-size N]finds duplicated subtrees (copy-paste) within and across
files — the refactoring signal for "extract a shared def". Each subtree gets a Merkle content hash;
a clone class is ≥2 structurally-equal subtrees (hash-bucketed,tree_eq-verified).--min-size N
(node-count floor, default 3) drops trivial dupes; only maximal clones are reported, ranked
biggest-first. Output:clone: N occurrences, M nodes: <exemplar>+LABEL:line:colper site, or--json. Purely structural (no α-equivalence).clones --nearfinds near-clones (Type-2): subtrees sharing a shape but differing in leaves.
It buckets by a shape hash then anti-unifies — the inverse of matching — INFERRING the pattern
with,mKholes where sites differ (shared when positions vary together). Output:near-clone: N occurrences, H hole(s): (scale x ,m0). That pattern is arewritepattern — it
re-matches every site — so a near-clone report feeds straight back intocdz rewriteto
factor the duplication into one call.- Because the parser recovers from errors,
queryworks over broken input too: it warns on stderr
and still runs the query over the recovered tree.
Semantic queries (cdz type / cdz uses / cdz check / cdz def / cdz scope / cdz exports / cdz doc / cdz instantiations) — the compiler as oracle
The codemod above is a shape layer: it never resolves a name or infers a type (that would
duplicate the compiler's resolver). When you need a fact only the compiler knows, cdz — because it
holds both the front-end AND rcdzc in one process — exposes a family of semantic queries. They
parse the program keeping its span table, ask the compiler (via its sidecar query engine), and map the
answer back to source. They are TOTAL: an unknown name yields a defined answer, not an error, and a
query answers even for a program that would not fully compile.
# cdz type NAME FILE — the inferred type of a definition, rendered (the same text an annotation uses)
$ cdz type main prog.cdz
Int64
$ cdz type add prog.cdz # a function renders as its arrow type
(-> Int64 Int64)
$ cdz type ghost prog.cdz # total: unknown name is a defined answer, not a failure
no such definition `ghost`
# cdz uses NAME FILE — every source location that references a definition/type, as file:line:col
# (a span-mapped go-to-references; the DECLARATION site is excluded — only references)
$ cdz uses helper prog.cdz
prog.cdz:3:12
prog.cdz:4:5
# cdz check FILE — every well-formedness fault, "diagnostics as you type" (no export/run needed).
# Exits non-zero on any error; a clean file prints nothing. An editor's inline squiggles / a CI gate.
$ cdz check prog.cdz
prog.cdz:2:16: error [CDZ0203]: if condition must be Bool, found Int64
# cdz def FILE OFFSET — go-to-definition: the definition of the name at the cursor, as file:line:col.
$ cdz def prog.cdz 49
prog.cdz:1:25
# cdz scope FILE OFFSET — variable scope tracking: every binding visible at the cursor, innermost
# first, as `file:line:col: name : type` (params, let-bindings, match-arm binders).
$ cdz scope prog.cdz 54
prog.cdz:1:41: q : Int64
prog.cdz:1:22: p : Int64
# cdz exports FILE — the module's interface: each exported name and its type, at its definition.
$ cdz exports prog.cdz
prog.cdz:1:17: inc : (-> Int64 Int64)
prog.cdz:1:49: v : Int64
# cdz doc NAME FILE — a definition's documentation (its (doc "…") text), or a built-in's help.
$ cdz doc inc prog.cdz
increments its argument
$ cdz doc List prog.cdz # a built-in module: read off its (meta doc) channel
A persistent, immutable sequence indexed from 0. …
$ cdz doc if prog.cdz # a grammar keyword: from the keyword doc table
Conditional: (if cond then else). …
$ cdz doc ghost prog.cdz # total: a name that documents nothing is a defined answer
no documentation for `ghost`
# cdz doc-at FILE OFFSET — documentation at cursor: the doc of the definition the node is/references.
$ cdz doc-at prog.cdz 49
increments its argument
# cdz instantiations NAME FILE — a def's DISPOSITION (what the compiler did with it) plus, if it is
# specialized, every concrete monomorphization. One source def may become 0/1/many emitted functions
# (generics don't cross the component boundary; the default is always-inline), so this reports which:
# specialized / inlined / emitted / transformed→copy / unreferenced.
$ cdz instantiations loopn prog.cdz # a recursive generic → specialized, its instances listed
prog.cdz:1:7: loopn — specialized (monomorphized — one function per instantiation, listed below)
prog.cdz:1:7: loopn[n: Int64, x: Int64] → loopn#mono2
prog.cdz:1:7: loopn[n: Int64, x: String] → loopn#mono3
$ cdz instantiations fold-n dict.cdz # ad-hoc poly: a `const` dictionary inlined + erased per call
dict.cdz:1:7: fold-n — specialized (…)
dict.cdz:1:7: fold-n[const d = (record (op (fn (x) (+ x 10)))), n: Int64, acc: Int64] → fold-n#mono2
dict.cdz:1:7: fold-n[const d = (record (op (fn (x) (* x 2)))), n: Int64, acc: Int64] → fold-n#mono3
$ cdz instantiations ident prog.cdz # a non-recursive fn → β-reduced away, no function emitted
prog.cdz:1:7: ident — inlined (β-reduced into each call site; no standalone function emitted)
$ cdz instantiations sum prog.cdz # a linear recursion → rewritten into an accumulator loop
prog.cdz:1:7: sum — transformed→sum$acc (its recursion was rewritten into the named accumulator loop)
$ cdz instantiations main prog.cdz # an export / called recursive fn → a standalone function
prog.cdz:1:7: main — emitted (emitted as a standalone function and called)Hover reads as a presentation, not a raw type (
type-at): a grammar keyword showskeyword def
(notAny); a DEFINITION shows its signaturename : (-> A B)(not just the body's return type); a
reference/use shows the value's type; an untypeable node showsunknown; an operator no longer leaks
its internal(record …).
- Why these are here and not codemod guards.
type/usesreach intorcdzc(inference,
resolution); the structural matcher stays dependency-free. Keeping them a separate command (not
a,(x type-of Int64)guard) is the deliberate split — shape queries inquery, semantic facts intype/uses. This realizesspec/capabilities/tooling-and-lsp.md§The Compiler Is A Queryable
Oracle: an agent learns a static fact by asking, and the answer equals what a full compile
determines (it's the same column read; see [[rcdzc-sidecar-request-list-abi]]). typereads the type column (infer::def_scheme→Ty::render_name).usesis the
transpose of the resolution column: every occurrence resolving to the named def/type, in ascending
order, each mapped tofile:line:colvia the span table this process kept (the cross-process CLI
could only report raw node ids — this is the in-process win). A name with no references (or none
such) prints nothing and exits 0.checkdrivesQuery::Diagnostics— the full fault set (type mismatch, unbound name, duplicate
def/field, …) read WITHOUT gating on export/emit, so a mid-edit buffer with no(export …)still
reports. Each fault →file:line:col: severity [CODE]: message; exits non-zero iff any error-severity
fault. This is the "as you type" primitive an editor's inline diagnostics (and a CI lint) ride on.
It also reports WARNINGS (non-error, don't fail the build): a dead computation (CDZ0305) and an
unused binding (CDZ0306) — aletbinding,fn/defparameter, or non-exported def that is
never referenced. Prefix the name with_to silence it (_x/_, as in Rust).defdrivesQuery::ResolveOf— the go-to-definition counterpart ofuses: the reference node
at the cursor → its defining occurrence (resolve::resolved_of→Ref/Lambda), mapped tofile:line:col. A non-navigable token (a literal, an unbound name) reports no definition.scopedrivesQuery::ScopeAt— variable scope tracking: walks the lexical scope from the
cursor collecting every binding in scope (enclosingfn/defparams, alet's bindings visible at
the point — sequential, so an initializer sees earlier bindings not itself, a match-arm binder),
each with its type; INNERMOST first (a shadowed name appears once). What an editor's autocomplete /
scope panel rides on.exportsdrivesQuery::Exports— the module's interface: each(export …)name paired with
its def's type (signature), at the def's location. The "what does this module offer" view.doc/doc-atdriveQuery::DocOf/Query::DocAt— the DOCUMENTATION companion oftype/type-at. A definition may carry a leading(doc "…")form; the compiler strips it off the
def body at load but CAPTURES its text in a doc column keyed by the def's signature, sodoc NAME
anddoc-at OFFSETboth read it (by name, or by resolving a cursor node to its def). When the name
is not a user def,docfalls back — a built-in module is just a record, so its documentation is
a(meta doc)channel read GENERICALLY off that record (never a name match, honoring
no-keys-outside-the-prelude), and a grammar keyword (if/let/match/…), not a binding, gets
its help from a small keyword table (the doc analogue of the hardcoded grammar set). Total: a name
that documents nothing is a defined "no documentation" line.instantiationsdrivesQuery::Instantiations— a definition's DISPOSITION (what the compiler DID
with it) plus, if specialized, every concrete monomorphization. One source def may become 0/1/many
emitted functions (generics don't cross the component boundary; the default is always-inline), so this
is the reverse index of "one source def, one function". Thedispline reports one of:specialized(monomorphized into per-instantiation copies — the instances follow asinstlines),inlined(β-reduced into each call site, no function emitted — a non-recursive def/generic),emitted(one standalone function under its own name, called or exported — a recursive def with a
runtime arg, aninline-neverdef, a boundary entry),transformed→copy(a linear recursion the
accumulator pass rewrote into a tail-recursive loopf$acc), orunreferenced(dead / never
instantiated); a+-joined set when more than one applies. The specialized case covers all three
monomorphization flavors: a recursive generic at each type (loopn), a type-valued-parameter
def ((: t Type), the erased type shownconst t = T), and the ad-hoc-polymorphism case — aconstdictionary inlined + erased at each call, rendered as the concrete dictionary source so you see
WHICH instance each call baked in. It readsdb.instantiations(specializations),db.inlined
(β-reduce sites),db.called/db.exports(emitted), anddb.transformed(accumulator rewrites),
after FORCING monomorphization over the whole program (layout::force_monomorphize) so the disposition
is complete regardless of exports. Two calls at the SAME instantiation dedup to one instance (the
memoized specialization). Total: an unknown name reports "no such definition".- Format is inferred from the file extension (
.cdz/.ml→ml,.sexp/.sexpr→sexpr), like the
codemod subcommands.
Combined query (cdz query … --where …) — shape ∧ meaning
The structural query finds matches by SHAPE; --where filters them by a COMPILER fact. This is the
one query neither the front-end nor the compiler can answer alone — cdz runs the structural search,
then types each match's binding node (a batch of TypeAt), keeping only matches whose binding relates
to the asked-for type. It needs a single FILE (a compiler query is per unit).
# every call `(foo x)` whose argument x is Int64 — structural (foo ,x) AND semantic type-of(x)=Int64
$ cdz query '(foo ,x)' --where 'type-of(x) = Int64' prog.sexp
prog.sexp:3:30: (foo (: 42 Int64))
$x = (: 42 Int64)
# the complement, with != ; and --count works too
$ cdz query '(foo ,x)' --where 'type-of(x) != Int64' --count prog.sexp
2- Predicate grammar (minimal by design):
type-of(VAR) = TYPEortype-of(VAR) != TYPE. VAR is a
metavariable the PATTERN binds (,x→x); TYPE is a rendered type taken verbatim, so a compound(-> Int64 Int64)/(List Int64)works. A malformed predicate is a hard error. - Only
cdzhonors--where(it needs the compiler); the purecdz-syntaxfront-end ignores the
flag.--inside/--has/… relational context composes with it. Output is the surviving matches withfile:line:col(or--count), same asquery. - Reach: one FILE for now (a dir sweep would be a per-unit fan-out). Only
type-ofis wired as a
predicate; other facts (uses, effect row) are the natural extension.
Library API — cadenza_syntax::query
Reach for this when scripting a codemod in Rust (a multi-rule pass, a custom report). Everything
operates on an owned homoiconic Tree (Atom | List), the mirror of the built-in Ast sum; convert
at the edges and each node keeps its source StructId so a match reports a span.
use cadenza_syntax::query::{self, Pattern, Template, Tree};
let arena = /* from parser::read_ml / sexpr::read / codec::decode */;
let tree = Tree::of(&arena);
let pat = Pattern::compile("(+ ,x 0)")?;
let tmpl = Template::compile(",x")?;
let hits = query::search(&pat, &tree, Some(&spans)); // Vec<Match { node, span, bindings }>
let n = query::count(&pat, &tree);
let out = query::rewrite(&pat, &tmpl, &tree); // Rewrite { tree, count }; bottom-up
let sat = query::rewrite_fixpoint(&pat, &tmpl, &tree, 64);
// relational context (structural only): search filtered by ancestry/containment
use query::Query;
let q = Query::new().inside(Pattern::compile("(danger ,@_)")?).not_has(Pattern::compile("(ok)")?);
let hits2 = query::search_with(&pat, &q, &tree, Some(&spans));
// multi-rule set + strategy
use query::{Rule, RuleSet, Strategy};
let rules = RuleSet::compile("(rule (+ ,x 0) ,x) (rule (* ,x 1) ,x)")?; // or RuleSet::new(vec![Rule::new(p, t)])
let out2 = query::rewrite_rules(&rules, &tree, Strategy::BottomUp); // or Strategy::TopDown
let sat2 = query::rewrite_rules_fixpoint(&rules, &tree, Strategy::BottomUp, 64);
// or the whole driver (what the CLI uses): load a target + project output, with validation
let (target, warnings) = query::driver::load(bytes, Format::Ml)?; // Target carries a SpanTable (ml + sexpr)
let report = query::driver::report_matches(&pat, &q, &target);
let outcome = query::driver::apply_rewrite(&rules, Strategy::BottomUp, &target, Format::Ml, 100, false)?;
// FORMATTING-PRESERVING rewrite: splice changed subtrees into the original `src` at their spans
// (layout/comments verbatim); validated, else Err (the CLI falls back to a reprint on Err).
let kept = query::driver::apply_rewrite_preserving(&rules, Strategy::BottomUp, &target, src, Format::Sexpr, false)?;
// low-level: the span-guided minimal-edit engine + the sexpr reader that now records spans
let (arena, spans) = cadenza_syntax::sexpr::read_spanned(src)?; // s-expr with a SpanTable (was span-free)
let te = query::textedit::rewrite_preserving(src, &old_tree, &new_tree, &span_of, Format::Sexpr); // TextRewrite { output, edits }
// machine-readable output + diff (dependency-free helpers)
let mjson = query::driver::matches_json(&pat, &q, &target, Some("a.ml")); // [{file?,span,matched,bindings}]
let rjson = query::driver::rewrite_json(Some("a.ml"), outcome.count, &outcome.output);
let before = query::driver::project_target(&target, Format::Ml, 100)?; // "before" side of a diff
let d = query::diff::unified(&before, &outcome.output, "a/a.ml", "b/a.ml"); // unified LINE diff text
// structural (subtree) diff of two trees
let changes = query::treediff::diff(&tree_a, &tree_b); // Vec<Change { path: Vec<usize>, kind }>
let human = query::driver::changes_report(&tree_a, &tree_b); // "PATH: replace OLD => NEW" …
let cjson = query::driver::changes_json(&tree_a, &tree_b); // [{path, kind, old?, new?}]
// structural lint (pattern + message + severity; error-severity fails a run)
use query::lint::{self, LintSet};
let set = LintSet::compile("(lint (deprecated ,@_) \"avoid\" error)")?;
let diags = lint::run(&set, &tree, Some(&spans)); // Vec<Diagnostic { message, severity, span, matched }>
let gate = lint::has_error(&diags); // true → CI should fail
// content hash + clone detection
let h = query::hash::hash_tree(&tree); // u64 Merkle hash; == iff tree_eq (fast eq filter)
let classes = query::clones::find_clones(&tree, 3, Some(&spans)); // Vec<CloneClass { exemplar, size, sites }>
// cross-file: query::clones::find_clones_multi(&[Source { tree, spans, file }], min_size)
// anti-unification (inverse of matching) + near-clone detection
let g = query::antiunify::anti_unify(&[&a, &b]); // Generalization { pattern, holes }
let p = query::antiunify::render_pattern(&g.pattern); // ",mK"-sugar pattern → feed to Pattern::compile
let near = query::clones::find_near_clones_one(&tree, 3, Some(&spans)); // Vec<NearCloneClass { pattern, size, hole_count, sites }>Multi-file / --write / directory-walk plumbing lives in the CLI (the bin), not the library — the
library stays pure. Reach for the driver + std::fs if scripting a batch run in Rust.
search is top-down (nested matches included). rewrite is bottom-up — a node is matched against
its already-rewritten children, so a rule that exposes a new match collapses in one pass
((+ ,x 0) → ,x fully reduces (+ (+ x 0) 0)).
What is NOT here (and why)
- Type/binding guards INSIDE a pattern (
,(x type-of Int64),,(x refs …), typed metavars) — the
structural matcher stays dependency-free, so a pattern never resolves a name or infers a type. The
type FILTER is available a different way — as the--whereclause oncdz query
(§Combined query): the pattern is pure shape, the--whereadds the semantic predicate, kept as separate layers rather than a guard that reaches into
the checker. (Arefs-based--whereand typed metavars are the natural extension; onlytype-ofis
wired today.) - Scope-aware rename (rename a binding + every reference, respecting shadowing) —
cdz usesgives
you the reference set; a validated multi-site rename built on it is not yet a single command. - Addressed edits (
insert/replace/delete/moveby node path/content-id) — theoptions/structural-interface/content-addressed-nodes.mdlayer, above these primitives. - Type-checking a rewrite result — the codemod validates well-formedness (re-parse + round-trip),
not types. A full typed transaction (re-check the edited tree withrcdzc) is reachable now that the
binary links both libraries, but is not yet wired intorewrite.
Gotchas
- Patterns are the s-expr surface, always — write
(+ ,x 0), notx + 0. (The subject can be any
surface via--from; the pattern/template text is s-expr.) rewritewrites the program to stdout, the count to stderr — capture stdout to get a clean
result; don't grep stdout for "rewrote".- A repeated metavar is a constraint, not just a name (
,x … ,xdemands equal subtrees). Use a
fresh name or,_when you don't want that. --fixpointis bounded (64 passes) precisely because a rule whose output re-matches its input
(e.g.,x → (w ,x)) would otherwise loop; a bounded, non-fixed result is returned, not an error.--write/--diffpreserve layout by default — the output keeps the source's exact formatting
(only changed subtrees are respliced). If you WANT a canonical reflow (normalize a file), pass--reprint. A cross-surface--to(e.g..sexpin,--to ml) can't splice into the original text,
so it always reprints.- Two
,@splices must have a fixed element between them —(f ,@a X ,@b)is fine (delete/anchor
aroundX),(f ,@a ,@b)is rejected (no anchor to divide the run).