A fidelity-first, jq-style CLI for YAML: query and surgically edit with comments, quoting, and formatting preserved byte-for-byte — clean diffs, or it refuses. No lossy JSON round-trip. Written in Rust.
yqr ("YAML query in Rust") is a fidelity-first, jq-style command-line tool
for YAML. It queries and edits YAML while preserving every byte it was not
asked to change — comments, quoting, indentation, key order, and line endings all
survive.
yqr '.' file.yaml reproduces the input
exactly — no flag, no reflow.yqr -i '.spec.replicas = 5' deploy.yaml rewrites only the
bytes the filter targets, or refuses — clean diffs, guaranteed. jq is JSON-only
and cannot preserve YAML formatting at all; yq edits in place but its docs admit
comment and whitespace issues. yqr changes nothing but the edit site, or errors.noyalib engine — the lossless CST behind
both the default read path and the --normalize pipeline; the CLI uses
clap.Install the published crate from crates.io:
cargo install yqr # binary at ~/.cargo/bin/yqr
Or build from a source checkout (requires the Rust 1.97 toolchain, pinned
via rust-toolchain.toml):
cargo build --release # binary at target/release/yqr
yqr [OPTIONS] <FILTER> [FILE] Arguments: <FILTER> The jq-style filter to apply (e.g. '.foo.bar', '.items[]') [FILE] Input YAML file; reads stdin when omitted or '-' Options: -r, --raw-output Emit string results without YAML quoting -N, --normalize Re-serialize output (drop comments, canonicalize scalars) -i, --in-place Edit the input file in place (mutating filters only) --engine <ENGINE> Backend parser for byte-preserving reads (default: noyalib) -h, --help Print help -V, --version Print version
# Field access
echo 'name: yqr
version: 1' | yqr .name
# => yqr # Nested access + array indexing
echo 'authors: - name: ada - name: linus' | yqr -r '.authors[0].name'
# => ada # Negative indexing (from the end)
echo 'tags: [cli, yaml]' | yqr -r '.tags[-1]'
# => yaml # Iterate a collection (one result per line)
echo 'tags: [a, b, c]' | yqr -r '.tags[]'
# => a
# b
# c # Pipe composition
echo 'a: {b: {c: 42}}' | yqr '.a | .b | .c'
# => 42 # Optional `?` suppresses errors
echo 'name: yqr' | yqr '.name[]?' # prints nothing, exits 0
--normalizeyqr preserves formatting by default. Untouched nodes are emitted as their
original source bytes, so the identity filter reproduces the input exactly —
comments, quoting, indentation, and line endings all survive. Pass
--normalize (-N) to re-serialize the output instead, which canonicalizes
scalars and drops comments.
--engine <name> selects which backend parser performs the byte-preserving
read (default noyalib, the always-available lossless CST). Under --normalize
the re-serializing pipeline runs and the engine choice has no observable effect
(an unknown name is still rejected up front).
# Identity reproduces the file byte-for-byte -- comments, blank lines, # quoting, block scalars, CRLF, BOM, and multi-document streams survive yqr '.' config.yaml | diff config.yaml - # no diff (no flag needed) # Projections keep the original spelling echo "zip: 007" | yqr '.zip' # => 007 (not 7) echo "s: 'hi'" | yqr '.s' # => 'hi' (quotes kept) # --normalize re-serializes (lossy: drops comments, canonicalizes scalars) echo "zip: 007" | yqr --normalize '.zip' # => 7 (re-typed) yqr --normalize '.' config.yaml # comments dropped, scalars canonicalized
Results that are computed rather than selected (and nodes an engine cannot
address faithfully — entries merged in via <<, alias references) fall back to
the regular typed rendering. Multi-document inputs run the filter against every
document. -r keeps its usual meaning and prints string values.
Fidelity notes:
--normalize prints null.true:, 8080:) are matched by spelling; distinct keys that collide
after string conversion (1 and "1") are refused with an error. Duplicate
keys resolve last-wins and emit the last occurrence's real bytes. Keep-chomped
(|+) block scalars retain their kept trailing blank lines, alias references
project the anchor's real bytes, block-collection spans start at their first
line's indent, and classic-Mac CR-only line endings are accepted.=, +=, del, -i)yqr can also edit YAML, not just read it — and it changes only the bytes the filter targets, leaving every other byte (comments, indentation, quoting, key order) untouched, or refuses. Edits always run through the fidelity engine, so a mutating filter is byte-exact except at the edit site.
The mutation surface:
| Filter | Meaning |
|---|---|
<path> = <value> |
Replace the scalar at path (style-matched quoting) |
<path>.<newkey> = <value> |
Add a new mapping entry under an existing mapping |
<path> += <value> |
Append an item to the block sequence at path |
del(<path>) |
Remove the block entry at path (single- or multi-line) |
<value> is a scalar literal (5, 1.5, "web", true, false, null) or a
.-rooted path that copies the value found at another location.
# Replace a value; the comment and every other line are preserved verbatim echo 'spec: replicas: 3 # keep me image: web' | yqr '.spec.replicas = 5' # => spec: # replicas: 5 # keep me # image: web # Append to a block sequence at the right indent yqr '.spec.ports += 9090' deploy.yaml # Add a new key, delete an entry (a nested/multi-line block closes up cleanly) yqr '.metadata.env = "prod"' deploy.yaml yqr 'del(.metadata.labels)' deploy.yaml yqr 'del(.spec.template)' deploy.yaml # Edit the file in place (rewritten atomically: temp file + rename) yqr -i '.spec.replicas = 5' deploy.yaml git diff deploy.yaml # touches only that one line
Guarantees and limits:
-i the file is
left unchanged.del(.x) across a batch of files does
not fail the ones that lack .x.-i needs a file. Using --in-place with stdin, or with a read-only
filter, is an error (diagnosed before any input is read). Writes are atomic
(temp file + fsync + rename) and edit through a symlink to the real file;
the original mode is preserved. Owner/group, SELinux context, ACLs, extended
attributes, and hardlinks are not carried across the replace — the same
temp-file+rename tradeoff sed -i makes.=, +=, and new-key values are scalars (number, string,
bool, null) or a path copying a scalar; a collection RHS is refused.del removes multi-line and nested block entries too,
not just single-line ones; it closes up the entry's lines and leaves every
surviving byte identical. Deleting the only entry of a block (which would
empty it) or an item of a flow collection ([a, b]) is refused with a clear
message.|=), key rename, and sequence
reorder / comment edits each fail with a clear message.| Filter | Meaning |
|---|---|
. |
Identity |
.foo |
Field access (.["foo"] for non-bareword keys) |
.a.b |
Nested field access |
.[n] |
Array index (.[-1] counts from the end) |
.[] |
Iterate sequence elements / mapping values |
a | b |
Pipe |
f? |
Suppress errors from f |
Planned: object/array construction, builtins (length, keys, select,
map, …), arithmetic, multi-document/slurp mode, and more. See the spec.
Install paths and recipes for running yqr against kubectl output, baking it
into a container image, and reading CI configs, Compose files, Ansible
playbooks, OpenAPI specs, alerting rules, and app config:
zoosky.github.io/yqr/docs/content/home.html
(source: docs/content/home.html).
filter ──▶ lexer ──▶ parser ──▶ Ast ──▶ evaluator ──▶ Value(s) ──▶ YAML
YAML ──▶ noyalib::from_str ──▶ Value ──┘
| Module | Responsibility |
|---|---|
src/lexer.rs |
Filter string → tokens |
src/parser.rs |
Tokens → Ast |
src/ast.rs |
Filter AST node definitions |
src/eval.rs |
Ast × Value → stream of Value |
src/value.rs |
yqr's Value model (converts to/from noyalib) |
src/fidelity/ |
Byte-preserving read engine (default reads) + write tier (src/fidelity/write.rs) |
src/error.rs |
YqrError + jq-style exit-code mapping |
src/cli.rs |
clap argument parsing |
src/lib.rs |
Public API (eval_str, render) |
src/main.rs |
Binary entry + exit-code mapping |
cargo test # unit + integration + CLI tests cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings
tests/integration.rs exercises the public library API end-to-end.tests/cli.rs runs the compiled binary against piped input.Criterion benchmarks live in benches/ (cargo bench --bench eval). Every push
to main runs them in CI and publishes the results to a tracked history:
Live benchmark dashboard — performance over time, with alerts on >30% regressions.