0012. Bracket-quoted key segments in the query-path grammar
- Status: accepted
- Date: 2026-09-04
- Authors: Noyalib contributors
Context
One grammar, parse_query_path in crates/noyalib/src/path.rs, is
behind every path string the crate reads: Value::get_path and
query, the borrowed reads, and every path-taking cst::Document
method (set, set_value, set_path, remove, rename_key,
span_at, key_span, the comment editors, and Entry). It reads .
as a segment separator, [n] as a sequence index, * and [*] as
wildcards, and .. as recursive descent, and it has no way to say
“this text is a key”. A mapping key that contains one of those
characters therefore cannot be named at all.
Such keys are common: Kubernetes labels and annotations
(app.kubernetes.io/name), version buckets (v0.26-dev), URL paths
with an extension, glob patterns. At v0.0.32 the gap is not only a
missing feature but a source of silent wrong writes, because the
grammar is lenient: remove("a[x]") drops the bracket segment and
removes a; set_path("a.") drops the trailing separator and writes
a; insert_entry_value on an existing * appends a second *
entry, because its existing-key guard covered . and [ only.
Issue #288 met this from the insert side. It fixed the insert anchor by reading the span tree directly and explicitly deferred the rest: “that is the wider question of whether the path grammar should grow an escape or quoting form”. Issue #388 asks that question.
Decision
The grammar gains a bracket-quoted key segment: ["key"] or ['key']
is one mapping key, whatever the text between the quotes contains, and
inside either quote style \ escapes the next character. It composes
with the rest of the grammar without a separator, the way an index
does: items[0]["a.b"].c. The JSONPath convention, so the spelling is
already familiar.
Because the change is in the one shared parser, every path-taking API gains it at once, and no method grows a segment-list twin.
Three spelling helpers make the public noyalib::path module, so a
caller never composes a quoted segment by hand: quote_key spells one
key as a quoted segment, push_key appends a key to a path in
whichever form reads back as that key (plain with a . when the
grammar would read it as itself, quoted otherwise), and join_keys
builds a path from literal keys.
Inside the crate the same helpers replace the two places that composed
"{path}.{key}" by hand (insert_entry, insert_entry_value) and the
prefix re-renderer set_path uses when it creates a missing level. The
./[ refusal in the two inserters goes: an existing key of any
spelling is now an upsert. rename_key’s stricter bracket check
accepts a quoted segment and still refuses an unquoted non-index one.
The grammar rejects malformed syntax atomically. An unquoted bracket
segment that is neither an index nor *, an unterminated quoted key,
a dangling separator, or an unmatched bracket can never be interpreted
as a valid prefix. Existing string-based APIs report their ordinary
not-found error and leave mutable inputs unchanged.
Consequences
- Positive: every key is addressable through every API, and the three silent wrong writes above become writes to the named key.
- Positive:
push_keykeeps plain keys plain, so paths the crate composes for itself and shows in error messages do not change for ordinary keys. - Positive: malformed input cannot silently target an ancestor node.
- Negative: callers that relied on partial-path recovery must correct the path before querying or mutating a document.
- Negative: one more form in a grammar that is documented by
example rather than by a spec;
Path::Display(the diagnostics location type) still prints a key plain, so its output is not guaranteed to parse back as a query path. Making it do so is a separate decision. - Neutral:
pathis now a public module.Pathstays re-exported at the crate root;QuerySegmentandparse_query_pathstay crate-private.
Alternatives considered
A segment-list API beside the string one
set_value_at(&[Segment], value) and twins for every mutator, with a
Segment enum of literal key and index. Rejected: it doubles the
surface of a dozen methods and leaves Value::get_path, Entry, and
the comment editors either untouched or doubled too. A quoting form in
the shared parser reaches all of them with one change.
Backslash escapes in dot notation
a\.b for a key holding a dot. Rejected: \ is legal key text today,
so the change would not be additive, and a key holding [, ], or
* would need the same treatment for each character. Bracket quoting
escapes only inside the quotes.
Refuse such keys at the mutators
Detect the misread and error instead of writing. Rejected as the only measure: it stops the wrong writes but leaves the keys unreachable, and callers with such keys on disk (a frontmatter editor, a Kubernetes manifest tool) still have to fall back to a whole-document rewrite.
References
- #288 – the insert-side fix that deferred this question.
- #388 – the ask this ADR answers.
crates/noyalib/src/path.rs–parse_query_path,quote_key,push_key,join_keys.crates/noyalib/tests/cst_quoted_path_segments.rs.- JSONPath bracket notation (RFC 9535 §2.3.1): https://www.rfc-editor.org/rfc/rfc9535#name-name-selector