Skip to main content

omgbase_surface/
select.rs

1//! Query-directed planning (`spec/mutate/README.md` §11.8, mutate 3.1;
2//! `spec/surface` 4.1): an OQX **selector** plus effect **templates** compiled
3//! into the concrete effect list the planner lowers. The selector is a
4//! producer of effects, never a second write engine — the plan that comes
5//! out has today's shape plus a frozen [`Selection`]. The reference is
6//! `packages/core/src/mutate/select.ts`; every rule and message here mirrors
7//! it.
8//!
9//! **Pipeline.** `parse(select)` → `check_root_spellings` → the static
10//! admission ([`compile`]: consumer, root, clauses, reserved aliases, the
11//! templates' shapes) → the **AST rewrite** (one select item per `{ oqx }`
12//! slot appended **after** the user's items, named `__omg_sel_<t>_<slot>`,
13//! on the raw tree so the runner's `resolve_aliases` inlines a user alias a
14//! template reads) → the runner's AST entry ([`crate::query::query_ast`],
15//! `limit = bound + 1`) → the hits → the row binding ([`bind`]: hits in
16//! result order × templates in order, `k = h·T + t`), which the store's
17//! lowering loop consumes **unchanged**, so the conflict tracker gives the
18//! cross-hit semantics for free (two hits writing one scalar with equal
19//! values dedupe with a diagnostic, different values are
20//! `conflicting_effects`, inserts into one shared target are positional in
21//! hit order).
22//!
23//! **Expressions.** OQX has no expression entry point: each `{ oqx: e }` is
24//! parsed through the wrapper `select __omg_x: <e> from docs` and its one
25//! item taken — a top-level item is scope depth 1, exactly the hit's scope,
26//! so `^` and `N^` validate as they will evaluate. Parse-error offsets are
27//! shifted back by the wrapper's head (16 code points).
28//!
29//! **Binding** (§11.8's table). A template's primary target that is absent
30//! (`target` / `into` / `targets`) is the hit by the root's kind (`document`
31//! / `block` / `node`; `targets` → the one-element list); a `{ kind, id: {
32//! oqx } }` or `{ kind: "document", path: { oqx } }` slot evaluates to a
33//! string (that member as written) or a row-as-value `{ id, path }` (→ `{
34//! kind, id }` whichever member held the expression); absent / `null` / `[]`
35//! → `target_missing { effect_index, template_index, row, expression }`; a
36//! list of one or more → **new** `ambiguous_target { …, count }` (a
37//! one-element list is still a list); any other type → `filter_invalid {
38//! hint: "selection" }`. `at.before` / `at.after`, `markdown`, `to`, `path`,
39//! `separator` must be strings; `value` and a `create`'s top-level
40//! frontmatter values may be any JSON as the runner rendered it (a row value
41//! lands as the record `{ id, path }`), never absent. Every error raised from
42//! an expanded effect carries `effect_index` (the expanded position) plus
43//! `template_index` and `row`.
44//!
45//! **Sugar** (`spec/mutate` §11.8 "Sugar", 3.2). In every expression slot a
46//! JSON string beginning exactly [`LEADER`] (`-> `) desugars to `{ oqx: rest
47//! }` before the admission — typing follows the value as `{ oqx }` does; a
48//! string not beginning `-> ` is literal, always (no escaping, no `${`
49//! scanning; `-> "-> …"` spells a literal that begins with the leader). A
50//! leader whose expression is a bare string literal or a template literal
51//! without an interpolation **folds** to the plain literal before planning
52//! (`-> "x"` and `"x"` give one `plan_id`). A **bare target** string (with or
53//! without a selector) normalizes before resolution: a path-looking string
54//! (a `/` or ending `.md`) is `{ kind: "document", path }`, a `b_` / `d_` /
55//! `n_` id its kind, `"$new.n"` the symbol's kind (an `into` is settled from
56//! the prefix plan's `new_ids`, [`settle_into_symbols`]), `"-> expr"` the
57//! identity the value names (a row → kind by id prefix, a path string → a
58//! document), a `#` locator is refused with a fix. **Without a selector** a
59//! `-> ` / `{ oqx }` slot evaluates in its own target's row scope: the
60//! effects are grouped by resolved target identity and one synthetic query
61//! per identity (`from <root> where id == "<id>"`, the slot items appended as
62//! `__omg_sel_<effectIndex>_<slot>`) runs through the AST runner; the plan
63//! has no `selection`, `planner_v` stays p3 and the intent record gains
64//! `templates` (the effects as written) — a plain request without the leader
65//! produces none, so no existing digest moves. A `-> ` slot on a `create`
66//! without a selector, a `-> ` target without a selector, an unparsable
67//! expression (offsets relative to the slot string, past the leader) and a
68//! leader in `expect` / `field` / `op` are `filter_invalid { hint:
69//! "selection" }`.
70//!
71//! **Cardinality.** `expect.max` (a non-negative integer; required on a
72//! one-shot `apply`, optional on `plan` / `execute`) bounds the selection; the
73//! query runs with `limit = max + 1` (or `SELECTION_CEILING + 1` without a
74//! `max`) and more hits than the bound is `selection_too_large { cardinality,
75//! max, implicit? }` — the engine never plans a partial set. Zero hits are a
76//! plan with no effects (execute / apply → `commit: null`).
77
78use std::collections::HashMap;
79
80use omgbase_mutate::effects::{SELECTION_CEILING, Selection, SelectionExpect, js_number};
81use omgbase_mutate::{Effect, ErrorCode, MutationError, ParentRow, ShapeError, ShapeFixContext};
82use omgbase_store::effects_plan::PlanContext;
83use omgbase_store::mutate::is_id_ref;
84use omgbase_store::{DocStore, Store};
85use oqx::Consumer;
86use oqx::ast::{Expr, Query, SelectItem, TemplatePart};
87use serde_json::{Map, Value as Json, json};
88
89use crate::context::{SemanticVec, Target};
90use crate::dates;
91use crate::error::{Result, SurfaceError};
92use crate::fixes;
93use crate::names;
94use crate::paths::storage_path;
95use crate::planner::root_target;
96use crate::query::{
97    HIT_ID, HIT_PATH, QueryOptions, RESERVED_ALIAS_PREFIXES, TUPLE_HINT, bracket_list_as_tuple,
98    check_reserved_aliases, check_root_spellings, query_ast,
99};
100
101/// The `hint` of every `filter_invalid` the selector admission raises.
102pub const HINT: &str = "selection";
103/// The prefix of the slot projections the rewrite appends: `__omg_sel_<t>_<slot>`.
104pub const SLOT_PREFIX: &str = "__omg_sel_";
105const WRAP_HEAD: &str = "select __omg_x: ";
106const WRAP_TAIL: &str = " from docs";
107/// The stand-in a slot takes in the template's static shape check (the
108/// reference's `$sel`).
109const PROBE: &str = "$sel";
110const OPS: &str = "create/insert/set/unset/move/remove/split/merge";
111/// §11.8 "Sugar" (3.2): the leader a string slot begins with to be an
112/// expression — exactly dash, greater-than, space.
113pub const LEADER: &str = "-> ";
114
115/// The admission pass's refusal: `filter_invalid { reason, hint: "selection" }`
116/// (the reference's `selectionInvalid`), with extra `data` members when given.
117fn refused_with(message: impl Into<String>, extra: Json) -> SurfaceError {
118    let message = message.into();
119    let mut data = json!({ "reason": message, "hint": HINT });
120    if let (Some(d), Some(x)) = (data.as_object_mut(), extra.as_object()) {
121        for (k, v) in x {
122            d.insert(k.clone(), v.clone());
123        }
124    }
125    SurfaceError::with_data("filter_invalid", message, data)
126}
127
128fn refused(message: impl Into<String>) -> SurfaceError {
129    refused_with(message, json!({}))
130}
131
132/// A §11.1 shape error of a template (probe) or an expanded effect: the
133/// ordinary `filter_invalid { hint: "arguments" }` naming `effects[i]`.
134fn shape_error(message: String) -> SurfaceError {
135    SurfaceError::filter_invalid(message, "arguments")
136}
137
138/// A shape error with its `fix` (§8, 3.4) when the shape check spelled one.
139fn shape_error_of(e: ShapeError) -> SurfaceError {
140    let mut err = shape_error(e.message);
141    if let Some(fix) = e.fix
142        && let Some(d) = err.data.as_mut().and_then(Json::as_object_mut)
143    {
144        d.insert("fix".to_owned(), json!(fix));
145    }
146    err
147}
148
149/// The live-row lookup behind the anchor fix (§8, 3.4; the reference's
150/// `parentLookup`): a block's parent id (`None` at the top level) and its
151/// document's path, from the `blocks` row that is not deleted; `None` for an
152/// id the store does not know (a `$new.n`, a typo) — then there is no fix.
153fn parent_lookup(store: &Store) -> impl Fn(&str) -> Option<ParentRow> + '_ {
154    move |id: &str| {
155        store
156            .conn()
157            .query_row(
158                "SELECT b.parent_block, d.path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL",
159                [id],
160                |row| {
161                    Ok(ParentRow {
162                        parent: row.get(0)?,
163                        path: row.get(1)?,
164                    })
165                },
166            )
167            .ok()
168    }
169}
170
171/// A template's `into` is bound per hit, so the anchor fix (a parent lookup)
172/// does not apply under a selector: `parent_of` finds nothing.
173fn no_parent(_: &str) -> Option<ParentRow> {
174    None
175}
176
177/// The shape check's context over a request (the reference's
178/// `ShapeFixContext`): the request as written and a row lookup.
179fn shape_fix<'a>(
180    request: &'a fixes::Request,
181    parent_of: &'a dyn Fn(&str) -> Option<ParentRow>,
182) -> ShapeFixContext<'a> {
183    ShapeFixContext {
184        select: request.select.as_ref(),
185        effects: &request.effects,
186        expect: request.expect.as_ref(),
187        parent_of,
188    }
189}
190
191/// The plain `effects` of a request (no selector, no sugar: the catalog's
192/// `parse_effects` path) shape-checked with the §8 fixes of 3.4 — the
193/// reference's `compilePlain` over effects without an expression, where the
194/// fix context reads the store for the anchor fix.
195pub fn validate_plain(store: &Store, effects: &Json) -> Result<Vec<Effect>> {
196    let list = effects
197        .as_array()
198        .ok_or_else(|| shape_error("`effects` must be an array of effects".to_owned()))?;
199    if list.is_empty() {
200        return Err(shape_error(
201            "`effects` must name at least one effect".to_owned(),
202        ));
203    }
204    let request = fixes::Request {
205        select: None,
206        effects: effects.clone(),
207        expect: None,
208    };
209    let lookup = parent_lookup(store);
210    let fix = shape_fix(&request, &lookup);
211    list.iter()
212        .enumerate()
213        .map(|(i, e)| Effect::validate_with(e, i, Some(&fix)).map_err(shape_error_of))
214        .collect()
215}
216
217// ---- sugar (§11.8 "Sugar", 3.2) --------------------------------------------------------
218
219/// The expression a `-> ` string spells (`None`: not a string, or a string
220/// that does not begin exactly with the leader — `->x`, ` -> x`, `-->` and a
221/// mid-string `-> ` are literals).
222fn leader_of(v: &Json) -> Option<&str> {
223    v.as_str()?.strip_prefix(LEADER)
224}
225
226/// A string anywhere under `v` begins with the leader.
227fn contains_leader(v: &Json) -> bool {
228    match v {
229        Json::String(_) => leader_of(v).is_some(),
230        Json::Array(a) => a.iter().any(contains_leader),
231        Json::Object(o) => o.values().any(contains_leader),
232        _ => false,
233    }
234}
235
236/// A path-looking string: contains `/` or ends `.md` (the reference's
237/// `looksLikePath`).
238fn path_looking(s: &str) -> bool {
239    s.contains('/') || s.ends_with(".md")
240}
241
242/// A node id: `n_` + 12 lower-case hex (the reference's `NODE_ID_RE`).
243fn is_node_id(s: &str) -> bool {
244    s.len() == 14
245        && s.starts_with("n_")
246        && s[2..]
247            .bytes()
248            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
249}
250
251/// A bare id of the three target kinds (the reference's `isTargetId`; the
252/// `b_` / `d_` rule is the reference's `ID_RE` — one to seven alphabet
253/// characters, so the fixture minter's `b_5` is an id too).
254fn is_target_id(s: &str) -> bool {
255    is_id_ref(s, "b") || is_id_ref(s, "d") || is_node_id(s)
256}
257
258/// Which target member a bare string sits in: the member decides the
259/// provisional kind a `$new.n` takes until the symbol table settles it (an
260/// `into` is ambiguous — document or block; `set`'s follows its field;
261/// `targets[k]` is a block).
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum Member {
264    Target,
265    Into,
266    Targets(usize),
267}
268
269/// The provisional kind of a bare `$new.n` in `member` — always one the
270/// slot admits, so the first shape check passes and the settled kind is
271/// what the slot's kind table judges ([`finish`]).
272fn symbol_kind(member: Member, op: &str, field: Option<&str>) -> &'static str {
273    match member {
274        Member::Targets(_) => "block",
275        Member::Into => "document",
276        Member::Target => match op {
277            "split" => "block",
278            "set" => match field {
279                Some(f) if f == "markdown" || f == "attrs.checked" => "block",
280                _ => "document",
281            },
282            _ => "document",
283        },
284    }
285}
286
287/// A bare target string → the target object (the reference's `bareTarget`,
288/// in its order): a `#` anywhere is a locator (`filter_invalid { hint:
289/// "arguments", fix }`), a `$new.n` the symbol (provisional kind), a `b_` /
290/// `d_` id by validity, an `n_` id, a path-looking string a document by
291/// path; anything else is no target (hint `arguments`).
292fn bare_target(s: &str, here: &str, member: Member, op: &str, field: Option<&str>) -> Result<Json> {
293    if s.contains('#') {
294        let message = format!(
295            "{here}: {} is a locator, not a target — a bare target is a document path, a `b_`/`d_`/`n_` id or `$new.n`",
296            Json::String(s.to_owned())
297        );
298        return Err(SurfaceError::with_data(
299            "filter_invalid",
300            message.clone(),
301            json!({ "reason": message, "hint": "arguments", "fix": crate::fixes::LOCATOR }),
302        ));
303    }
304    if omgbase_mutate::parse_new_symbol(s).is_some() {
305        return Ok(json!({ "kind": symbol_kind(member, op, field), "id": s }));
306    }
307    if is_id_ref(s, "b") {
308        return Ok(json!({ "kind": "block", "id": s }));
309    }
310    if is_id_ref(s, "d") {
311        return Ok(json!({ "kind": "document", "id": s }));
312    }
313    if is_node_id(s) {
314        return Ok(json!({ "kind": "node", "id": s }));
315    }
316    if path_looking(s) {
317        return Ok(json!({ "kind": "document", "path": s }));
318    }
319    Err(shape_error(format!(
320        "{here}: a target is {{ kind: …, … }} or a bare document path, a `b_`/`d_`/`n_` id or `$new.n` (got {})",
321        Json::String(s.to_owned())
322    )))
323}
324
325/// `"-> <expr>"` → `{ oqx: "<expr>" }`, or the folded literal when the
326/// expression is a bare string literal or a template literal without an
327/// interpolation (the reference's `desugarLeader`). A parse error is
328/// `filter_invalid { hint: "selection" }` with its offset counted from the
329/// start of the written string (past the leader).
330fn desugar_expression(src: &str, here: &str) -> Result<Json> {
331    let wrapped = format!("{WRAP_HEAD}{src}{WRAP_TAIL}");
332    let head = WRAP_HEAD.chars().count();
333    let shift = LEADER.chars().count() as isize - head as isize;
334    let q = oqx::parse_string(&wrapped)
335        .map_err(|e| refused(format!("{here}: {}", shift_offsets(&e.message, shift))))?;
336    let one = q.select.len() == 1
337        && matches!(&q.source, Expr::Ident { name, .. } if name == "docs")
338        && q.from.is_empty()
339        && q.r#where.is_none()
340        && q.order_by.is_none()
341        && q.follow.is_none()
342        && q.frontier.is_none()
343        && q.limit.is_none()
344        && q.offset.is_none()
345        && !q.distinct
346        && !q.values
347        && q.consumer == Consumer::Collect;
348    if !one {
349        return Err(refused(format!(
350            "{here}: `oqx` must be one expression over the hit, not a query"
351        )));
352    }
353    let folded = match q.select.first() {
354        Some(SelectItem::Field { expr, .. }) => match expr {
355            Expr::Lit {
356                value: oqx::Value::Str(text),
357                ..
358            } => Some(text.clone()),
359            Expr::Template { parts, .. } if parts.len() == 1 => match &parts[0] {
360                TemplatePart::Text(text) => Some(text.clone()),
361                TemplatePart::Expr(_) => None,
362            },
363            _ => None,
364        },
365        _ => None,
366    };
367    Ok(match folded {
368        Some(text) => Json::String(text),
369        None => json!({ "oqx": src }),
370    })
371}
372
373/// The literal a `-> ` string reads as when its leader is dropped (the
374/// reference's `literalOf`): a quoted string or an interpolation-free
375/// template folds to its text; anything else is the rest of the string.
376pub(crate) fn literal_of(src: &str) -> String {
377    match desugar_expression(src, "") {
378        Ok(Json::String(text)) => text,
379        _ => src.to_owned(),
380    }
381}
382
383/// What the desugar of one effect saw: the target members written as a
384/// bare `$new.n` (provisional kinds, settled against the symbol table).
385#[derive(Clone, Debug, Default, PartialEq, Eq)]
386struct Desugared {
387    symbols: Vec<Member>,
388}
389
390/// A `-> ` string where no expression is admitted — anywhere under `v`
391/// (the reference's `refuseLeader`). The fix (3.3): the literal form, the
392/// same request with the leader dropped from that member.
393fn refuse_leader(v: &Json, here: &str, request: Option<&fixes::Request>) -> Result<()> {
394    match v {
395        Json::String(_) if leader_of(v).is_some() => {
396            let message =
397                format!("{here}: `-> ` is not allowed here — this member takes no expression");
398            Err(match request.and_then(|r| fixes::literal_slot(r, here)) {
399                Some(fix) => refused_with(message, json!({ "fix": fix })),
400                None => refused(message),
401            })
402        }
403        Json::Array(a) => a
404            .iter()
405            .enumerate()
406            .try_for_each(|(k, x)| refuse_leader(x, &format!("{here}[{k}]"), request)),
407        Json::Object(o) => o
408            .iter()
409            .try_for_each(|(k, x)| refuse_leader(x, &format!("{here}.{k}"), request)),
410        _ => Ok(()),
411    }
412}
413
414/// The desugar of one effect as written (the reference's `desugarEffect`):
415/// in the slots of its op a leader becomes `{ oqx }` or folds, a bare target
416/// string normalizes ([`bare_target`]; a leader target is the whole-target
417/// directive, a folded one a bare string), and a leader anywhere else —
418/// `op`, `field`, `expect`, a target's other members, a whole `at`, an
419/// unknown key — is refused. An unknown `op` is left for the shape check.
420fn desugar_effect(
421    raw: &Json,
422    index: usize,
423    request: Option<&fixes::Request>,
424) -> Result<(Json, Desugared)> {
425    let mut d = Desugared::default();
426    let Some(o) = raw.as_object() else {
427        return Ok((raw.clone(), d));
428    };
429    let Some(op) = o.get("op").and_then(Json::as_str).map(str::to_owned) else {
430        return Ok((raw.clone(), d));
431    };
432    let here = format!("effects[{index}]");
433    let field = o.get("field").and_then(Json::as_str).map(str::to_owned);
434    let mut out = o.clone();
435    let mut slot_keys: Vec<&str> = vec!["op"];
436
437    fn leader_at(
438        out: &mut Map<String, Json>,
439        slot_keys: &mut Vec<&str>,
440        key: &'static str,
441        here: &str,
442    ) -> Result<()> {
443        slot_keys.push(key);
444        if let Some(src) = out.get(key).and_then(leader_of).map(str::to_owned) {
445            let v = desugar_expression(&src, &format!("{here}.{key}"))?;
446            out.insert(key.to_owned(), v);
447        }
448        Ok(())
449    }
450    // One target member: a bare string normalizes (a leader → the whole-target
451    // directive, or its folded literal normalized); an object's `id` / `path`
452    // take a leader, its other members may not.
453    fn one_target(
454        v: &Json,
455        here: &str,
456        member: Member,
457        op: &str,
458        field: Option<&str>,
459        d: &mut Desugared,
460        request: Option<&fixes::Request>,
461    ) -> Result<Json> {
462        match v {
463            Json::String(s) => {
464                let literal = match leader_of(v) {
465                    Some(src) => match desugar_expression(src, here)? {
466                        Json::String(folded) => folded,
467                        directive => return Ok(directive),
468                    },
469                    None => s.clone(),
470                };
471                let t = bare_target(&literal, here, member, op, field)?;
472                if omgbase_mutate::parse_new_symbol(&literal).is_some() {
473                    d.symbols.push(member);
474                }
475                Ok(t)
476            }
477            Json::Object(t) => {
478                let mut out = t.clone();
479                for m in ["id", "path"] {
480                    if let Some(src) = t.get(m).and_then(leader_of) {
481                        let v = desugar_expression(src, &format!("{here}.{m}"))?;
482                        out.insert(m.to_owned(), v);
483                    }
484                }
485                for (k, x) in t {
486                    if k != "id" && k != "path" {
487                        refuse_leader(x, &format!("{here}.{k}"), request)?;
488                    }
489                }
490                Ok(Json::Object(out))
491            }
492            other => Ok(other.clone()),
493        }
494    }
495    let target = |out: &mut Map<String, Json>,
496                  slot_keys: &mut Vec<&str>,
497                  key: &'static str,
498                  member: Member,
499                  d: &mut Desugared|
500     -> Result<()> {
501        slot_keys.push(key);
502        if let Some(v) = out.get(key).cloned() {
503            let t = one_target(
504                &v,
505                &format!("{here}.{key}"),
506                member,
507                &op,
508                field.as_deref(),
509                d,
510                request,
511            )?;
512            out.insert(key.to_owned(), t);
513        }
514        Ok(())
515    };
516    let targets =
517        |out: &mut Map<String, Json>, slot_keys: &mut Vec<&str>, d: &mut Desugared| -> Result<()> {
518            slot_keys.push("targets");
519            if let Some(Json::Array(list)) = out.get("targets").cloned() {
520                let mut items = Vec::with_capacity(list.len());
521                for (k, v) in list.iter().enumerate() {
522                    items.push(one_target(
523                        v,
524                        &format!("{here}.targets[{k}]"),
525                        Member::Targets(k),
526                        &op,
527                        field.as_deref(),
528                        d,
529                        request,
530                    )?);
531                }
532                out.insert("targets".to_owned(), Json::Array(items));
533            }
534            Ok(())
535        };
536    let at = |out: &mut Map<String, Json>, slot_keys: &mut Vec<&str>| -> Result<()> {
537        slot_keys.push("at");
538        let Some(a) = out.get("at").cloned() else {
539            return Ok(());
540        };
541        let Some(a) = a.as_object() else {
542            return refuse_leader(&a, &format!("{here}.at"), request);
543        };
544        let mut next = a.clone();
545        for m in ["before", "after"] {
546            if let Some(src) = a.get(m).and_then(leader_of) {
547                let v = desugar_expression(src, &format!("{here}.at.{m}"))?;
548                next.insert(m.to_owned(), v);
549            }
550        }
551        for (k, x) in a {
552            if k != "before" && k != "after" {
553                refuse_leader(x, &format!("{here}.at.{k}"), request)?;
554            }
555        }
556        out.insert("at".to_owned(), Json::Object(next));
557        Ok(())
558    };
559    match op.as_str() {
560        "create" => {
561            leader_at(&mut out, &mut slot_keys, "path", &here)?;
562            leader_at(&mut out, &mut slot_keys, "markdown", &here)?;
563            slot_keys.push("frontmatter");
564            if let Some(Json::Object(fm)) = o.get("frontmatter") {
565                let mut next = fm.clone();
566                for (k, v) in fm {
567                    if let Some(src) = leader_of(v) {
568                        let x = desugar_expression(src, &format!("{here}.frontmatter.{k}"))?;
569                        next.insert(k.clone(), x);
570                    }
571                }
572                out.insert("frontmatter".to_owned(), Json::Object(next));
573            }
574        }
575        "insert" => {
576            target(&mut out, &mut slot_keys, "into", Member::Into, &mut d)?;
577            at(&mut out, &mut slot_keys)?;
578            leader_at(&mut out, &mut slot_keys, "markdown", &here)?;
579        }
580        "set" => {
581            target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?;
582            leader_at(&mut out, &mut slot_keys, "value", &here)?;
583        }
584        "unset" => target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?,
585        "move" => {
586            if o.contains_key("targets") || o.contains_key("into") {
587                targets(&mut out, &mut slot_keys, &mut d)?;
588                target(&mut out, &mut slot_keys, "into", Member::Into, &mut d)?;
589                at(&mut out, &mut slot_keys)?;
590            } else {
591                target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?;
592                leader_at(&mut out, &mut slot_keys, "to", &here)?;
593            }
594        }
595        "remove" => {
596            if o.contains_key("targets") {
597                targets(&mut out, &mut slot_keys, &mut d)?;
598            } else {
599                target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?;
600            }
601        }
602        // `at` is byte offsets: no expression
603        "split" => target(&mut out, &mut slot_keys, "target", Member::Target, &mut d)?,
604        "merge" => {
605            targets(&mut out, &mut slot_keys, &mut d)?;
606            leader_at(&mut out, &mut slot_keys, "separator", &here)?;
607        }
608        // not row-shaped: no slot
609        "retarget" => {}
610        // an unknown op: the shape check names it
611        _ => return Ok((Json::Object(out), d)),
612    }
613    for (key, v) in o {
614        if !slot_keys.contains(&key.as_str()) {
615            refuse_leader(v, &format!("{here}.{key}"), request)?;
616        }
617    }
618    Ok((Json::Object(out), d))
619}
620
621/// Does a request's `effects` carry any sugar — a bare string target, a `-> `
622/// leader anywhere, or an `{ oqx }` directive — so that a selector-less
623/// call must take the sugar path instead of the plain `effects` one? (The
624/// reference always desugars; without sugar the desugar is the identity, so
625/// the plain path stays byte-identical.)
626fn needs_sugar(effects: &Json) -> bool {
627    let Some(list) = effects.as_array() else {
628        return false;
629    };
630    list.iter().any(|e| {
631        let Some(o) = e.as_object() else {
632            return false;
633        };
634        o.get("target").is_some_and(Json::is_string)
635            || o.get("into").is_some_and(Json::is_string)
636            || o.get("targets")
637                .and_then(Json::as_array)
638                .is_some_and(|a| a.iter().any(Json::is_string))
639            || contains_leader(e)
640            || contains_oqx(e)
641    })
642}
643
644// ---- the request ----------------------------------------------------------------------
645
646/// `plan` / `apply` / `execute` with a `select` — the selector, the
647/// templates (the call's `effects`) and the optional bound, as given — or,
648/// since 3.2, a selector-less call whose `effects` carry sugar (`select` is
649/// `null`: the effects are desugared and their `-> ` / `{ oqx }` slots
650/// evaluated in their own targets' row scopes).
651#[derive(Clone, Debug, PartialEq)]
652pub struct SelectRequest {
653    pub select: Json,
654    pub templates: Json,
655    pub expect: Option<Json>,
656}
657
658impl SelectRequest {
659    /// Read a tool's or a fixture step's arguments: `None` when the call
660    /// names neither `select` nor `expect` and its `effects` carry no sugar
661    /// (the plain `effects` path, untouched); `expect` without `select` is
662    /// refused as the reference's planner refuses it. Nothing else is
663    /// checked here — [`compile`] does, in the reference's order.
664    pub fn from_args(args: &Json) -> Result<Option<Self>> {
665        let select = args.get("select").filter(|v| !v.is_null());
666        let expect = args.get("expect").filter(|v| !v.is_null());
667        let effects = args.get("effects").cloned().unwrap_or(Json::Null);
668        if select.is_none() && expect.is_none() {
669            if needs_sugar(&effects) {
670                return Ok(Some(Self {
671                    select: Json::Null,
672                    templates: effects,
673                    expect: None,
674                }));
675            }
676            return Ok(None);
677        }
678        let Some(select) = select else {
679            return Err(refused(
680                "`expect` bounds a selection — pass `select` with it",
681            ));
682        };
683        Ok(Some(Self {
684            select: select.clone(),
685            templates: effects,
686            expect: expect.cloned(),
687        }))
688    }
689
690    /// The call names a selector (else it is the selector-less sugar path).
691    #[must_use]
692    pub fn has_selector(&self) -> bool {
693        !self.select.is_null()
694    }
695
696    /// The selector as given, when it is a string.
697    #[must_use]
698    pub fn select_str(&self) -> &str {
699        self.select.as_str().unwrap_or_default()
700    }
701
702    /// How many templates were given (0 unless an array).
703    #[must_use]
704    pub fn templates(&self) -> usize {
705        self.templates.as_array().map_or(0, Vec::len)
706    }
707}
708
709/// `expect`: absent, or `{ max: <non-negative integer> }` and nothing else.
710fn validate_expect(v: Option<&Json>) -> Result<Option<SelectionExpect>> {
711    let Some(v) = v else {
712        return Ok(None);
713    };
714    let obj = v
715        .as_object()
716        .ok_or_else(|| refused("`expect` must be an object { max }"))?;
717    let extra: Vec<String> = obj
718        .keys()
719        .filter(|k| *k != "max")
720        .map(|k| format!("`{k}`"))
721        .collect();
722    if !extra.is_empty() {
723        return Err(refused(format!(
724            "`expect`: unknown key{} {}",
725            if extra.len() > 1 { "s" } else { "" },
726            extra.join(", ")
727        )));
728    }
729    let max = obj
730        .get("max")
731        .and_then(Json::as_u64)
732        .and_then(|n| usize::try_from(n).ok())
733        .ok_or_else(|| refused("`expect.max` must be a non-negative integer"))?;
734    Ok(Some(SelectionExpect { max }))
735}
736
737// ---- templates -------------------------------------------------------------------------
738
739/// What a slot's value must be, and how it lands.
740#[derive(Clone, Debug, PartialEq, Eq)]
741enum SlotKind {
742    /// A target's `id` or `path` member (`target` / `into`): a string lands
743    /// as that member, a row ref as `id`.
744    Identity { key: String, member: String },
745    /// `targets[k].id`.
746    TargetsIdentity { k: usize },
747    /// A whole target (`target` / `into`) as one expression (3.2): the value
748    /// names the identity — a row by its id, a string as [`bare_target`]
749    /// reads it.
750    WholeTarget { key: String, member: Member },
751    /// `targets[k]` as one expression: a block.
752    WholeTargets { k: usize },
753    /// `markdown`, `to`, `path`, `separator` — a top-level string member.
754    Str { key: String },
755    /// `at.before` / `at.after`.
756    At { member: String },
757    /// `value`: any JSON, never absent.
758    Value,
759    /// A `create`'s `frontmatter.<key>`: any JSON, never absent.
760    Frontmatter { key: String },
761}
762
763#[derive(Clone, Debug)]
764struct Slot {
765    /// The suffix of the reserved item name (`target`, `into`, `markdown`,
766    /// `fm_<j>`, `targets_<k>`, `at`, …).
767    name: String,
768    kind: SlotKind,
769    /// `effects[t].<path>` for messages.
770    here: String,
771    expression: String,
772    /// The parsed item (a field or a value-position directive), renamed on
773    /// the rewrite.
774    item: SelectItem,
775}
776
777/// Where the hit's identity lands when the template names no primary target.
778#[derive(Clone, Copy, Debug, PartialEq, Eq)]
779enum DefaultTarget {
780    Target,
781    Into,
782    Targets,
783}
784
785/// One admitted template: the template with every slot removed (the binder
786/// copies it and fills the slots), its slots, the default target and the
787/// target members written as a bare `$new.n`.
788#[derive(Clone, Debug)]
789pub struct Template {
790    index: usize,
791    base: Map<String, Json>,
792    slots: Vec<Slot>,
793    default_target: Option<DefaultTarget>,
794    symbols: Vec<Member>,
795    op: String,
796}
797
798impl Template {
799    /// The slot projections this template appends, in order.
800    #[must_use]
801    pub fn slot_names(&self) -> Vec<String> {
802        self.slots
803            .iter()
804            .map(|s| slot_name(self.index, &s.name))
805            .collect()
806    }
807}
808
809fn slot_name(t: usize, slot: &str) -> String {
810    format!("{SLOT_PREFIX}{t}_{slot}")
811}
812
813/// `{ oqx: "<expression>" }` — exactly that one key.
814fn oqx_of(v: &Json) -> Option<&str> {
815    let obj = v.as_object()?;
816    if obj.len() != 1 {
817        return None;
818    }
819    obj.get("oqx")?.as_str()
820}
821
822fn contains_oqx(v: &Json) -> bool {
823    if oqx_of(v).is_some() {
824        return true;
825    }
826    match v {
827        Json::Array(a) => a.iter().any(contains_oqx),
828        Json::Object(o) => o.values().any(contains_oqx),
829        _ => false,
830    }
831}
832
833/// A row rendered as a value by the runner (`{ id, path }` and nothing else,
834/// `spec/surface` §1.4).
835fn row_ref(v: &Json) -> Option<(&str, &str)> {
836    let o = v.as_object()?;
837    if o.len() != 2 {
838        return None;
839    }
840    Some((o.get(HIT_ID)?.as_str()?, o.get(HIT_PATH)?.as_str()?))
841}
842
843/// The reference's `describeValue`.
844fn describe(v: Option<&Json>) -> String {
845    match v {
846        None | Some(Json::Null) => "absent".to_owned(),
847        Some(Json::String(s)) => format!("a string ({})", Json::String(s.clone())),
848        Some(Json::Number(n)) => format!(
849            "a number ({})",
850            n.as_f64().map_or_else(|| n.to_string(), js_number)
851        ),
852        Some(Json::Bool(b)) => format!("a boolean ({b})"),
853        Some(Json::Array(a)) => format!("a list of {}", a.len()),
854        Some(Json::Object(_)) => "an object".to_owned(),
855    }
856}
857
858/// Shift the `(at offset N)` of a parse error by `delta` (back by the
859/// wrapper's head; a `-> ` slot's forward by the leader).
860fn shift_offsets(message: &str, delta: isize) -> String {
861    const MARK: &str = "(at offset ";
862    let Some(i) = message.find(MARK) else {
863        return message.to_owned();
864    };
865    let rest = &message[i + MARK.len()..];
866    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
867    if digits.is_empty() {
868        return message.to_owned();
869    }
870    let n: isize = digits.parse().unwrap_or(0);
871    format!(
872        "{}{MARK}{}{}",
873        &message[..i],
874        (n + delta).max(0),
875        &rest[digits.len()..]
876    )
877}
878
879/// Parse one `{ oqx }` expression through the wrapper (module docs): the one
880/// select item of an otherwise empty query over `docs`.
881fn parse_expression(source: &str, here: &str) -> Result<SelectItem> {
882    let wrapped = format!("{WRAP_HEAD}{source}{WRAP_TAIL}");
883    let head = WRAP_HEAD.chars().count();
884    let mut q = oqx::parse_string(&wrapped).map_err(|e| {
885        refused(format!(
886            "{here}: {}",
887            shift_offsets(&e.message, -(head as isize))
888        ))
889    })?;
890    let one = q.select.len() == 1
891        && matches!(&q.source, Expr::Ident { name, .. } if name == "docs")
892        && q.from.is_empty()
893        && q.r#where.is_none()
894        && q.order_by.is_none()
895        && q.follow.is_none()
896        && q.frontier.is_none()
897        && q.limit.is_none()
898        && q.offset.is_none()
899        && !q.distinct
900        && !q.values
901        && q.consumer == Consumer::Collect;
902    if !one {
903        return Err(refused(format!(
904            "{here}: `oqx` must be one expression over the hit, not a query"
905        )));
906    }
907    Ok(q.select.pop().expect("one item"))
908}
909
910fn renamed(item: &SelectItem, name: &str) -> SelectItem {
911    match item {
912        SelectItem::Field {
913            expr, lift, span, ..
914        } => SelectItem::Field {
915            name: name.to_owned(),
916            expr: expr.clone(),
917            lift: *lift,
918            span: *span,
919        },
920        SelectItem::Collect { op, span, .. } => SelectItem::Collect {
921            name: name.to_owned(),
922            op: op.clone(),
923            span: *span,
924        },
925    }
926}
927
928/// The kind word of the hit a root yields.
929fn hit_kind(root: Target) -> &'static str {
930    match root {
931        Target::Docs => "document",
932        Target::Blocks => "block",
933        Target::Nodes => "node",
934        Target::Links => "link",
935        Target::Revisions => "revision",
936    }
937}
938
939fn hit_ref(root: Target, id: &str) -> Json {
940    json!({ "kind": hit_kind(root), "id": id })
941}
942
943/// The selector-less refusal of an expression where the row would come from
944/// (the reference's `needsSelector`).
945fn needs_selector(where_: &str) -> SurfaceError {
946    refused(format!(
947        "{where_}: an expression target needs a selector — without `select` there is no row to read it from"
948    ))
949}
950
951/// The reference's `admitTemplate`, member by member.
952struct Admit<'a> {
953    here: String,
954    raw: &'a Map<String, Json>,
955    root: Target,
956    /// A plain effect (no selector, 3.2): an absent target is not the hit's
957    /// (there is none), and an expression as the primary target is refused.
958    plain: bool,
959    base: Map<String, Json>,
960    probe: Map<String, Json>,
961    slots: Vec<Slot>,
962    /// The keys the op reads (an expression anywhere else is refused).
963    slot_keys: Vec<&'static str>,
964}
965
966impl Admit<'_> {
967    fn push(&mut self, name: String, kind: SlotKind, at: &str, expression: &str) -> Result<()> {
968        let here = format!("{}.{at}", self.here);
969        let item = parse_expression(expression, &here)?;
970        self.slots.push(Slot {
971            name,
972            kind,
973            here,
974            expression: expression.to_owned(),
975            item,
976        });
977        Ok(())
978    }
979
980    /// A string-valued member that may be an expression.
981    fn string_slot(&mut self, key: &'static str) -> Result<()> {
982        self.slot_keys.push(key);
983        let Some(src) = self.raw.get(key).and_then(oqx_of) else {
984            return Ok(());
985        };
986        let src = src.to_owned();
987        self.base.remove(key);
988        self.probe.insert(key.to_owned(), json!(PROBE));
989        self.push(
990            key.to_owned(),
991            SlotKind::Str {
992                key: key.to_owned(),
993            },
994            key,
995            &src,
996        )
997    }
998
999    /// `value`: the top-level `{ oqx }` is the expression; nested ones are data.
1000    fn value_slot(&mut self) -> Result<()> {
1001        self.slot_keys.push("value");
1002        let Some(src) = self.raw.get("value").and_then(oqx_of) else {
1003            return Ok(());
1004        };
1005        let src = src.to_owned();
1006        self.base.remove("value");
1007        self.probe.insert("value".to_owned(), Json::Null);
1008        self.push("value".to_owned(), SlotKind::Value, "value", &src)
1009    }
1010
1011    /// A target member: `{ kind, id: { oqx } }` or `{ kind: "document", path:
1012    /// { oqx } }`, or (3.2) the whole target `{ oqx }` — the identity the
1013    /// value names, `probe_kind` standing in for its unknown kind; absent →
1014    /// the hit when `defaultable` (never on a plain effect).
1015    fn target_slot(
1016        &mut self,
1017        key: &'static str,
1018        defaultable: bool,
1019        probe_kind: &str,
1020        primary: bool,
1021    ) -> Result<bool> {
1022        self.slot_keys.push(key);
1023        let Some(t) = self.raw.get(key) else {
1024            if !defaultable || self.plain {
1025                return Ok(false);
1026            }
1027            self.probe.insert(key.to_owned(), hit_ref(self.root, PROBE));
1028            return Ok(true);
1029        };
1030        if let Some(src) = oqx_of(t).map(str::to_owned) {
1031            if self.plain && primary {
1032                return Err(needs_selector(&format!("{}.{key}", self.here)));
1033            }
1034            self.base.remove(key);
1035            self.probe
1036                .insert(key.to_owned(), json!({ "kind": probe_kind, "id": PROBE }));
1037            let member = if key == "into" {
1038                Member::Into
1039            } else {
1040                Member::Target
1041            };
1042            self.push(
1043                key.to_owned(),
1044                SlotKind::WholeTarget {
1045                    key: key.to_owned(),
1046                    member,
1047                },
1048                key,
1049                &src,
1050            )?;
1051            return Ok(false);
1052        }
1053        if let Some(t) = t.as_object() {
1054            let id_x = t.get("id").and_then(oqx_of).map(str::to_owned);
1055            let path_x = t.get("path").and_then(oqx_of).map(str::to_owned);
1056            if id_x.is_some() && path_x.is_some() {
1057                return Err(refused(format!(
1058                    "{}.{key}: give `id` or `path` as an expression, not both",
1059                    self.here
1060                )));
1061            }
1062            if let Some((member, src)) = id_x
1063                .map(|s| ("id", s))
1064                .or_else(|| path_x.map(|s| ("path", s)))
1065            {
1066                if self.plain && primary {
1067                    return Err(needs_selector(&format!("{}.{key}", self.here)));
1068                }
1069                let mut literal = t.clone();
1070                literal.remove(member);
1071                self.base.insert(key.to_owned(), Json::Object(literal));
1072                if let Some(p) = self.probe.get_mut(key).and_then(Json::as_object_mut) {
1073                    p.insert(member.to_owned(), json!(PROBE));
1074                }
1075                self.push(
1076                    key.to_owned(),
1077                    SlotKind::Identity {
1078                        key: key.to_owned(),
1079                        member: member.to_owned(),
1080                    },
1081                    &format!("{key}.{member}"),
1082                    &src,
1083                )?;
1084            }
1085            for (k, v) in t {
1086                if k != "id" && k != "path" && contains_oqx(v) {
1087                    return Err(refused(format!(
1088                        "{}.{key}.{k}: `{{ oqx }}` is not allowed here",
1089                        self.here
1090                    )));
1091                }
1092            }
1093        }
1094        Ok(false)
1095    }
1096
1097    /// `targets[]`: each element `{ kind: "block", id: { oqx } }` (or, 3.2, a
1098    /// whole `{ oqx }`) yields one block; absent → `[the hit]` when
1099    /// `defaultable`. The list is always the primary target of its op.
1100    fn targets_slot(&mut self, defaultable: bool) -> Result<bool> {
1101        self.slot_keys.push("targets");
1102        let Some(list) = self.raw.get("targets") else {
1103            if !defaultable || self.plain {
1104                return Ok(false);
1105            }
1106            self.probe
1107                .insert("targets".to_owned(), json!([hit_ref(self.root, PROBE)]));
1108            return Ok(true);
1109        };
1110        let Some(list) = list.as_array() else {
1111            return Ok(false);
1112        };
1113        for (k, t) in list.iter().enumerate() {
1114            if let Some(src) = oqx_of(t).map(str::to_owned) {
1115                if self.plain {
1116                    return Err(needs_selector(&format!("{}.targets[{k}]", self.here)));
1117                }
1118                if let Some(arr) = self.base.get_mut("targets").and_then(Json::as_array_mut) {
1119                    arr[k] = json!({});
1120                }
1121                if let Some(arr) = self.probe.get_mut("targets").and_then(Json::as_array_mut) {
1122                    arr[k] = json!({ "kind": "block", "id": PROBE });
1123                }
1124                self.push(
1125                    format!("targets_{k}"),
1126                    SlotKind::WholeTargets { k },
1127                    &format!("targets[{k}]"),
1128                    &src,
1129                )?;
1130                continue;
1131            }
1132            let Some(t) = t.as_object() else {
1133                continue;
1134            };
1135            if t.get("path").and_then(oqx_of).is_some() {
1136                return Err(refused(format!(
1137                    "{}.targets[{k}]: a block target has `id`, not `path`",
1138                    self.here
1139                )));
1140            }
1141            if let Some(src) = t.get("id").and_then(oqx_of).map(str::to_owned) {
1142                if self.plain {
1143                    return Err(needs_selector(&format!("{}.targets[{k}].id", self.here)));
1144                }
1145                let mut literal = t.clone();
1146                literal.remove("id");
1147                if let Some(arr) = self.base.get_mut("targets").and_then(Json::as_array_mut) {
1148                    arr[k] = Json::Object(literal);
1149                }
1150                if let Some(p) = self
1151                    .probe
1152                    .get_mut("targets")
1153                    .and_then(Json::as_array_mut)
1154                    .and_then(|a| a.get_mut(k))
1155                    .and_then(Json::as_object_mut)
1156                {
1157                    p.insert("id".to_owned(), json!(PROBE));
1158                }
1159                self.push(
1160                    format!("targets_{k}"),
1161                    SlotKind::TargetsIdentity { k },
1162                    &format!("targets[{k}].id"),
1163                    &src,
1164                )?;
1165            }
1166            for (key, v) in t {
1167                if key != "id" && contains_oqx(v) {
1168                    return Err(refused(format!(
1169                        "{}.targets[{k}].{key}: `{{ oqx }}` is not allowed here",
1170                        self.here
1171                    )));
1172                }
1173            }
1174        }
1175        Ok(false)
1176    }
1177
1178    /// `at.before` / `at.after` may be an expression yielding a block id.
1179    fn at_slot(&mut self) -> Result<()> {
1180        self.slot_keys.push("at");
1181        let Some(at) = self.raw.get("at").and_then(Json::as_object) else {
1182            return Ok(());
1183        };
1184        for member in ["before", "after"] {
1185            let Some(src) = at.get(member).and_then(oqx_of).map(str::to_owned) else {
1186                continue;
1187            };
1188            self.base.insert("at".to_owned(), json!({}));
1189            self.probe.insert("at".to_owned(), json!({ member: PROBE }));
1190            self.push(
1191                "at".to_owned(),
1192                SlotKind::At {
1193                    member: member.to_owned(),
1194                },
1195                &format!("at.{member}"),
1196                &src,
1197            )?;
1198        }
1199        Ok(())
1200    }
1201
1202    fn frontmatter_slots(&mut self) -> Result<()> {
1203        self.slot_keys.push("frontmatter");
1204        let Some(fm) = self.raw.get("frontmatter").and_then(Json::as_object) else {
1205            return Ok(());
1206        };
1207        for (j, (k, v)) in fm.iter().enumerate() {
1208            let Some(src) = oqx_of(v).map(str::to_owned) else {
1209                continue;
1210            };
1211            if let Some(b) = self
1212                .base
1213                .get_mut("frontmatter")
1214                .and_then(Json::as_object_mut)
1215            {
1216                b.remove(k);
1217            }
1218            if let Some(p) = self
1219                .probe
1220                .get_mut("frontmatter")
1221                .and_then(Json::as_object_mut)
1222            {
1223                p.insert(k.clone(), Json::Null);
1224            }
1225            self.push(
1226                format!("fm_{j}"),
1227                SlotKind::Frontmatter { key: k.clone() },
1228                &format!("frontmatter.{k}"),
1229                &src,
1230            )?;
1231        }
1232        Ok(())
1233    }
1234}
1235
1236/// Admit `effects[t]` statically (before the query runs): a row-shaped op,
1237/// the `{ oqx }` directives only in the slots, every expression parseable,
1238/// and the template's shape valid with a probe value in each slot (§11.1's
1239/// `effects[i]` messages, `hint: "arguments"`). `plain` is the
1240/// selector-less mode (3.2); `sugar` what the desugar saw.
1241fn admit_template(
1242    index: usize,
1243    raw: &Json,
1244    root: Target,
1245    plain: bool,
1246    sugar: &Desugared,
1247    fix: Option<&ShapeFixContext<'_>>,
1248) -> Result<Template> {
1249    let here = format!("effects[{index}]");
1250    let Some(op) = raw
1251        .as_object()
1252        .and_then(|o| o.get("op"))
1253        .and_then(Json::as_str)
1254    else {
1255        return Err(refused(format!(
1256            "{here}: a template is an effect object whose `op` is one of {OPS}"
1257        )));
1258    };
1259    let raw = raw.as_object().expect("checked");
1260    if op == "retarget" {
1261        return Err(refused(format!(
1262            "{here}: `retarget` is not row-shaped — it cannot be a selector template"
1263        )));
1264    }
1265    if !matches!(
1266        op,
1267        "create" | "insert" | "set" | "unset" | "move" | "remove" | "split" | "merge"
1268    ) {
1269        return Err(refused(format!(
1270            "{here}: `op` must be one of {OPS} (got \"{op}\")"
1271        )));
1272    }
1273    let mut a = Admit {
1274        here: here.clone(),
1275        raw,
1276        root,
1277        plain,
1278        base: raw.clone(),
1279        probe: raw.clone(),
1280        slots: Vec::new(),
1281        slot_keys: Vec::new(),
1282    };
1283    let mut default_target = None;
1284    match op {
1285        "create" => {
1286            a.string_slot("path")?;
1287            a.string_slot("markdown")?;
1288            a.frontmatter_slots()?;
1289        }
1290        "insert" => {
1291            if a.target_slot("into", true, "document", true)? {
1292                default_target = Some(DefaultTarget::Into);
1293            }
1294            a.at_slot()?;
1295            a.string_slot("markdown")?;
1296        }
1297        "set" => {
1298            if a.target_slot("target", true, "document", true)? {
1299                default_target = Some(DefaultTarget::Target);
1300            }
1301            a.value_slot()?;
1302        }
1303        "unset" => {
1304            if a.target_slot("target", true, "document", true)? {
1305                default_target = Some(DefaultTarget::Target);
1306            }
1307        }
1308        "move" => {
1309            if raw.contains_key("into") {
1310                // the block form: `targets` default to the hit, `into` is the destination
1311                if a.targets_slot(true)? {
1312                    default_target = Some(DefaultTarget::Targets);
1313                }
1314                a.target_slot("into", false, "document", false)?;
1315                a.at_slot()?;
1316            } else {
1317                if a.target_slot("target", true, "document", true)? {
1318                    default_target = Some(DefaultTarget::Target);
1319                }
1320                a.string_slot("to")?;
1321            }
1322        }
1323        "remove" => {
1324            let doc_form = raw.contains_key("target")
1325                || (!raw.contains_key("targets") && root == Target::Docs);
1326            if doc_form {
1327                if a.target_slot("target", true, "document", true)? {
1328                    default_target = Some(DefaultTarget::Target);
1329                }
1330            } else if a.targets_slot(true)? {
1331                default_target = Some(DefaultTarget::Targets);
1332            }
1333        }
1334        "split" => {
1335            if a.target_slot("target", true, "block", true)? {
1336                default_target = Some(DefaultTarget::Target);
1337            }
1338        }
1339        "merge" => {
1340            if !raw.contains_key("targets") {
1341                return Err(refused(format!(
1342                    "{here}: `merge` needs `targets` — one hit cannot be merged with itself"
1343                )));
1344            }
1345            a.targets_slot(false)?;
1346            a.string_slot("separator")?;
1347        }
1348        _ => unreachable!("the ops above"),
1349    }
1350    // Everything else is literal: an expression anywhere else is refused.
1351    for (key, v) in raw {
1352        if key == "op" || a.slot_keys.contains(&key.as_str()) {
1353            continue;
1354        }
1355        if contains_oqx(v) {
1356            return Err(refused(format!(
1357                "{here}.{key}: `{{ oqx }}` is not allowed here"
1358            )));
1359        }
1360    }
1361    let Admit {
1362        base, probe, slots, ..
1363    } = a;
1364    // The shape, with a probe in every slot (the §11.1 messages, naming
1365    // `effects[i]`; the §8 shape fixes rewrite the template as written).
1366    Effect::validate_with(&Json::Object(probe), index, fix).map_err(shape_error_of)?;
1367    Ok(Template {
1368        index,
1369        base,
1370        slots,
1371        default_target,
1372        symbols: sugar.symbols.clone(),
1373        op: op.to_owned(),
1374    })
1375}
1376
1377// ---- the compile -----------------------------------------------------------------------
1378
1379/// A compiled request: the selector as parsed, its root, the admitted
1380/// templates, the rewritten tree and the bound.
1381#[derive(Clone, Debug)]
1382pub struct Compiled {
1383    pub raw: Query,
1384    pub root: Target,
1385    pub templates: Vec<Template>,
1386    pub rewritten: Query,
1387    pub expect: Option<SelectionExpect>,
1388    /// The most hits allowed (`expect.max`, or the ceiling).
1389    pub bound: usize,
1390    /// The request as given (`select`, the templates, `expect?`), for the
1391    /// corrected examples (3.3).
1392    pub request: fixes::Request,
1393}
1394
1395impl Compiled {
1396    /// The bound is the implicit ceiling (no `expect.max`).
1397    #[must_use]
1398    pub fn implicit(&self) -> bool {
1399        self.expect.is_none()
1400    }
1401}
1402
1403/// The static admission of the selector itself (the reference's
1404/// `admitSelector`): every refusal is prefixed `select: `.
1405fn admit_selector(select: &str, request: Option<&fixes::Request>) -> Result<(Query, Target)> {
1406    let raw = oqx::parse_string(select).map_err(|e| {
1407        // (`spec/surface` 4.11) `id in ["a", "b"]` — the bracket list rewritten
1408        // to a tuple is the `fix` (the same request over the corrected selector).
1409        match bracket_list_as_tuple(select, &e.message) {
1410            None => refused(format!("select: {}", e.message)),
1411            Some(rewritten) => refused_with(
1412                format!("select: {}{TUPLE_HINT}", e.message),
1413                match request {
1414                    Some(r) => json!({ "fix": fixes::fix_request(&fixes::Request {
1415                        select: Some(Json::String(rewritten)),
1416                        ..r.clone()
1417                    }) }),
1418                    None => json!({}),
1419                },
1420            ),
1421        }
1422    })?;
1423    // The fix of a refused source (3.3): the same request over an admitted
1424    // root, spliced over the source's span of the written query.
1425    let root_fix = |links: bool| -> Json {
1426        let span = raw.source.span();
1427        match request {
1428            Some(r) => json!({ "fix": fixes::root_fix(r, select, (span.start, span.end), links) }),
1429            None => json!({}),
1430        }
1431    };
1432    check_root_spellings(&raw).map_err(|e| refused(format!("select: {}", e.message)))?;
1433    check_reserved_aliases(&raw).map_err(|e| refused(format!("select: {}", e.message)))?;
1434    if !matches!(
1435        raw.consumer,
1436        Consumer::Collect | Consumer::First | Consumer::Single
1437    ) {
1438        return Err(refused(format!(
1439            "select: `{}` yields no rows — a selector's consumer is collect, first or single",
1440            raw.consumer.as_str()
1441        )));
1442    }
1443    if raw.values {
1444        return Err(refused("select: a selector yields rows, not `values`"));
1445    }
1446    let root = match root_target(&raw.source) {
1447        None => {
1448            return Err(refused_with(
1449                "select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`",
1450                root_fix(false),
1451            ));
1452        }
1453        Some(Target::Links) => {
1454            return Err(refused_with(
1455                "select: `links` rows have no effect target — select docs, blocks or nodes",
1456                root_fix(true),
1457            ));
1458        }
1459        // the history (spec/surface 4.7, spec/mutate §11.8): refused by name, the fix over `docs`
1460        Some(Target::Revisions) => {
1461            return Err(refused_with(
1462                "select: `revisions` rows have no effect target — select docs, blocks or nodes",
1463                root_fix(false),
1464            ));
1465        }
1466        Some(t) => t,
1467    };
1468    if raw.follow.is_some() {
1469        return Err(refused(
1470            "select: `follow` is not allowed in a selector — a hit must be a row of the root",
1471        ));
1472    }
1473    if !raw.from.is_empty() {
1474        return Err(refused(
1475            "select: a body `from` re-projects the rows — a hit must be a row of the root",
1476        ));
1477    }
1478    if raw.offset.is_some() {
1479        return Err(refused(
1480            "select: `offset` is not allowed — a selection is a set, not a page",
1481        ));
1482    }
1483    if raw.distinct {
1484        return Err(refused("select: `distinct` is not allowed in a selector"));
1485    }
1486    Ok((raw, root))
1487}
1488
1489/// The static half (§11.8 steps 1 and 2; the reference's `validateExpect` +
1490/// `admitRequest`): `expect`, then `select` and the templates, then the
1491/// selector's admission, the templates' desugar (3.2) and admission, and
1492/// the rewrite. Nothing is read.
1493pub fn compile(req: &SelectRequest) -> Result<Compiled> {
1494    let expect = validate_expect(req.expect.as_ref())?;
1495    let select = req
1496        .select
1497        .as_str()
1498        .filter(|s| !s.trim().is_empty())
1499        .ok_or_else(|| refused("`select` must be an OQX query (a non-empty string)"))?;
1500    let templates = req
1501        .templates
1502        .as_array()
1503        .ok_or_else(|| refused("`effects` must be an array of effect templates"))?;
1504    if templates.is_empty() {
1505        return Err(refused("`effects` must name at least one template"));
1506    }
1507    // The request as given, for every fix the compile composes (3.3).
1508    let request = fixes::Request {
1509        select: Some(req.select.clone()),
1510        effects: req.templates.clone(),
1511        expect: req.expect.clone(),
1512    };
1513    let (raw, root) = admit_selector(select, Some(&request))?;
1514    // Under a selector a template's `into` is bound per hit, so the anchor
1515    // fix (a parent lookup) does not apply: `parent_of` finds nothing.
1516    let fix = shape_fix(&request, &no_parent);
1517    let templates = templates
1518        .iter()
1519        .enumerate()
1520        .map(|(t, v)| {
1521            let (v, d) = desugar_effect(v, t, Some(&request))?;
1522            admit_template(t, &v, root, false, &d, Some(&fix))
1523        })
1524        .collect::<Result<Vec<_>>>()?;
1525    let mut rewritten = raw.clone();
1526    for tpl in &templates {
1527        for slot in &tpl.slots {
1528            rewritten
1529                .select
1530                .push(renamed(&slot.item, &slot_name(tpl.index, &slot.name)));
1531        }
1532    }
1533    let bound = expect.map_or(SELECTION_CEILING, |e| e.max);
1534    Ok(Compiled {
1535        raw,
1536        root,
1537        templates,
1538        rewritten,
1539        expect,
1540        bound,
1541        request,
1542    })
1543}
1544
1545// ---- the binding -----------------------------------------------------------------------
1546
1547/// A hit's `(id, path)`.
1548pub type Row = (String, String);
1549
1550/// `{ effect_index, template_index?, row, expression }` — what every error
1551/// from a slot carries (`template_index` only under a selector).
1552fn slot_data(k: usize, t: Option<usize>, row: &Row, expression: &str) -> Json {
1553    let mut d = json!({
1554        "effect_index": k,
1555        "row": { "id": row.0, "path": row.1 },
1556        "expression": expression,
1557    });
1558    if let Some(t) = t {
1559        d["template_index"] = json!(t);
1560    }
1561    d
1562}
1563
1564/// Where a slot's binding reports: the expanded index, the template index
1565/// under a selector, the row.
1566struct Origin<'a> {
1567    k: usize,
1568    template: Option<usize>,
1569    row: &'a Row,
1570    /// The root the row belongs to (the selector's; a plain effect's scope).
1571    root: Target,
1572    /// The request as given, for an `ambiguous_target` fix (3.3).
1573    request: Option<&'a fixes::Request>,
1574}
1575
1576/// A bound identity slot's value → the identity (a string or a row) or the
1577/// §11.8 refusals (`target_missing`, `ambiguous_target`, a typed
1578/// `filter_invalid`).
1579fn identity_value<'v>(v: Option<&'v Json>, s: &Slot, o: &Origin<'_>) -> Result<&'v Json> {
1580    let data = || slot_data(o.k, o.template, o.row, &s.expression);
1581    let absent = v.is_none_or(Json::is_null);
1582    if absent || v.and_then(Json::as_array).is_some_and(Vec::is_empty) {
1583        return Err(MutationError::with_data(
1584            ErrorCode::TargetMissing,
1585            format!(
1586                "{}: the expression yielded no identity for row {}",
1587                s.here, o.row.0
1588            ),
1589            data(),
1590        )
1591        .into());
1592    }
1593    let v = v.expect("not absent");
1594    if let Some(a) = v.as_array() {
1595        let mut d = data();
1596        d["count"] = json!(a.len());
1597        // The fix (3.3): the slot spelled as the FIRST identity the
1598        // expression yielded (a row by its id); under a selector the
1599        // selector narrowed to this row.
1600        let first = a
1601            .first()
1602            .and_then(|h| row_ref(h).map(|(id, _)| id).or_else(|| h.as_str()));
1603        if let (Some(req), Some(first)) = (o.request, first)
1604            && let Some(fix) =
1605                fixes::ambiguous_slot(req, &s.here, first, (o.root.as_str(), &o.row.0))
1606        {
1607            d["fix"] = json!(fix);
1608        }
1609        return Err(MutationError::with_data(
1610            ErrorCode::AmbiguousTarget,
1611            format!(
1612                "{}: the expression yielded {} identities for row {}; a target resolves to exactly one",
1613                s.here,
1614                a.len(),
1615                o.row.0
1616            ),
1617            d,
1618        )
1619        .into());
1620    }
1621    if v.as_str().is_none() && row_ref(v).is_none() {
1622        return Err(refused_with(
1623            format!(
1624                "{}: the expression yielded {}, not an identity (a string or a row)",
1625                s.here,
1626                describe(Some(v))
1627            ),
1628            data(),
1629        ));
1630    }
1631    Ok(v)
1632}
1633
1634/// A whole-target slot's value → the target object (3.2): a row by its id,
1635/// a string as [`bare_target`] reads it (a path → a document, an id → its
1636/// kind, a `$new.n` → the symbol, provisional). `Ok((target, symbol))`.
1637fn whole_target(
1638    v: &Json,
1639    s: &Slot,
1640    o: &Origin<'_>,
1641    member: Member,
1642    op: &str,
1643    field: Option<&str>,
1644) -> Result<(Json, bool)> {
1645    let is_row = row_ref(v).is_some();
1646    let r = match row_ref(v) {
1647        Some((id, _)) => id,
1648        None => v
1649            .as_str()
1650            .expect("identity_value admitted a string or a row"),
1651    };
1652    if !is_row
1653        && !path_looking(r)
1654        && !is_target_id(r)
1655        && omgbase_mutate::parse_new_symbol(r).is_none()
1656    {
1657        return Err(refused_with(
1658            format!(
1659                "{}: the expression yielded {}, not an identity (a document path, an id or a row)",
1660                s.here,
1661                describe(Some(v))
1662            ),
1663            slot_data(o.k, o.template, o.row, &s.expression),
1664        ));
1665    }
1666    let t = bare_target(r, &s.here, member, op, field)?;
1667    Ok((t, omgbase_mutate::parse_new_symbol(r).is_some()))
1668}
1669
1670/// One hit × one template → the effect JSON with every slot filled and the
1671/// target members holding a bare `$new.n` (§11.8's table; the reference's
1672/// `bind`). `root` is the selector's (the hit's kind for a defaulted
1673/// target). The shape is checked once here with the provisional kinds;
1674/// [`finish`] settles the symbols and checks again.
1675fn bind_one(root: Target, hit: &Json, t: &Template, o: &Origin<'_>) -> Result<(Json, Vec<Member>)> {
1676    let mut e = Json::Object(t.base.clone());
1677    let mut symbols = t.symbols.clone();
1678    match t.default_target {
1679        Some(DefaultTarget::Target) => e["target"] = hit_ref(root, &o.row.0),
1680        Some(DefaultTarget::Into) => e["into"] = hit_ref(root, &o.row.0),
1681        Some(DefaultTarget::Targets) => e["targets"] = json!([hit_ref(root, &o.row.0)]),
1682        None => {}
1683    }
1684    let field = t
1685        .base
1686        .get("field")
1687        .and_then(Json::as_str)
1688        .map(str::to_owned);
1689    for s in &t.slots {
1690        let v = hit.get(slot_name(t.index, &s.name));
1691        let absent = v.is_none_or(Json::is_null);
1692        let data = || slot_data(o.k, o.template, o.row, &s.expression);
1693        match &s.kind {
1694            SlotKind::Identity { .. } | SlotKind::TargetsIdentity { .. } => {
1695                let v = identity_value(v, s, o)?;
1696                // A string lands as the member that held the expression; a
1697                // row ref as `id` (the reference's `identityMember`).
1698                let (member, value) = match (v.as_str(), row_ref(v)) {
1699                    (Some(s), _) => (None, json!(s)),
1700                    (None, Some((id, _))) => (Some("id".to_owned()), json!(id)),
1701                    (None, None) => unreachable!("identity_value admitted it"),
1702                };
1703                let (target, written) = match &s.kind {
1704                    SlotKind::Identity { key, member } => (e.get_mut(key), member.clone()),
1705                    SlotKind::TargetsIdentity { k } => (
1706                        e.get_mut("targets")
1707                            .and_then(Json::as_array_mut)
1708                            .and_then(|a| a.get_mut(*k)),
1709                        "id".to_owned(),
1710                    ),
1711                    _ => unreachable!("identity kinds"),
1712                };
1713                if let Some(obj) = target.and_then(Json::as_object_mut) {
1714                    obj.insert(member.unwrap_or(written), value);
1715                }
1716            }
1717            SlotKind::WholeTarget { key, member } => {
1718                let v = identity_value(v, s, o)?;
1719                let (target, symbol) = whole_target(v, s, o, *member, &t.op, field.as_deref())?;
1720                if symbol {
1721                    symbols.push(*member);
1722                }
1723                e[key] = target;
1724            }
1725            SlotKind::WholeTargets { k } => {
1726                let v = identity_value(v, s, o)?;
1727                let (target, symbol) = whole_target(v, s, o, Member::Targets(*k), &t.op, None)?;
1728                if symbol {
1729                    symbols.push(Member::Targets(*k));
1730                }
1731                if let Some(slot) = e
1732                    .get_mut("targets")
1733                    .and_then(Json::as_array_mut)
1734                    .and_then(|a| a.get_mut(*k))
1735                {
1736                    *slot = target;
1737                }
1738            }
1739            SlotKind::Str { .. } | SlotKind::At { .. } => {
1740                if absent {
1741                    return Err(refused_with(
1742                        format!(
1743                            "{}: the expression yielded absent for row {}",
1744                            s.here, o.row.0
1745                        ),
1746                        data(),
1747                    ));
1748                }
1749                let Some(text) = v.and_then(Json::as_str) else {
1750                    return Err(refused_with(
1751                        format!(
1752                            "{}: the expression yielded {}, expected a string",
1753                            s.here,
1754                            describe(v)
1755                        ),
1756                        data(),
1757                    ));
1758                };
1759                match &s.kind {
1760                    SlotKind::At { member } => e["at"] = json!({ member: text }),
1761                    SlotKind::Str { key } => e[key] = json!(text),
1762                    _ => unreachable!("string kinds"),
1763                }
1764            }
1765            SlotKind::Value | SlotKind::Frontmatter { .. } => {
1766                if absent {
1767                    return Err(refused_with(
1768                        format!(
1769                            "{}: the expression yielded absent for row {}{}",
1770                            s.here,
1771                            o.row.0,
1772                            if s.kind == SlotKind::Value {
1773                                " — use `unset` to remove a key"
1774                            } else {
1775                                ""
1776                            }
1777                        ),
1778                        data(),
1779                    ));
1780                }
1781                let v = v.expect("not absent").clone();
1782                match &s.kind {
1783                    SlotKind::Frontmatter { key } => {
1784                        if let Some(fm) = e.get_mut("frontmatter").and_then(Json::as_object_mut) {
1785                            fm.insert(key.clone(), v);
1786                        }
1787                    }
1788                    _ => e["value"] = v,
1789                }
1790            }
1791        }
1792    }
1793    Effect::validate(&e, o.k).map_err(shape_error)?;
1794    Ok((e, symbols))
1795}
1796
1797/// The settlement of bare `$new.n` targets (3.2): given the effect JSON, the
1798/// members holding a symbol and the concrete effects before it, rewrite
1799/// each symbol's `kind` to the one the symbol table records — the kind
1800/// `plan` over that prefix allocated to `$new.n` (`new_ids[n].kind`).
1801pub type Settle<'a> = dyn FnMut(&mut Json, &[Member], &[Effect]) + 'a;
1802
1803/// A settlement that leaves every provisional kind in place (tests; a path
1804/// with no store at hand).
1805pub fn no_settle(_: &mut Json, _: &[Member], _: &[Effect]) {}
1806
1807/// The settlement over a store: `plan` the prefix (nothing written, no id
1808/// minted) and read the symbols' kinds off its `new_ids`. A prefix that does
1809/// not plan, or a symbol it did not mint, is left provisional — the real
1810/// plan reports it (`target_missing { symbol }` for one nothing created).
1811pub fn settle_symbols(
1812    store: &mut Store,
1813    ctx: &PlanContext,
1814    doc_store: &dyn DocStore,
1815    e: &mut Json,
1816    members: &[Member],
1817    prefix: &[Effect],
1818) {
1819    if prefix.is_empty() {
1820        return;
1821    }
1822    let Ok(plan) = store.plan_selected(ctx, prefix, None, doc_store) else {
1823        return;
1824    };
1825    for m in members {
1826        let slot = match m {
1827            Member::Target => e.get_mut("target"),
1828            Member::Into => e.get_mut("into"),
1829            Member::Targets(k) => e
1830                .get_mut("targets")
1831                .and_then(Json::as_array_mut)
1832                .and_then(|a| a.get_mut(*k)),
1833        };
1834        let Some(obj) = slot.and_then(Json::as_object_mut) else {
1835            continue;
1836        };
1837        let Some(n) = obj
1838            .get("id")
1839            .and_then(Json::as_str)
1840            .and_then(omgbase_mutate::parse_new_symbol)
1841        else {
1842            continue;
1843        };
1844        if let Some(x) = plan.new_ids.get(n) {
1845            obj.insert("kind".to_owned(), json!(x.kind.as_str()));
1846        }
1847    }
1848}
1849
1850/// The concrete effect of a bound (or literal) effect JSON: settle its bare
1851/// `$new.n` members against the effects before it, then the shape check
1852/// again — a settled kind the slot does not admit, or a `field` the settled
1853/// kind does not take, is the §11.1 message with `effect_index` (the
1854/// reference resolves the symbol in the lowering, where every error gains
1855/// it).
1856fn finish(
1857    e: &mut Json,
1858    k: usize,
1859    symbols: &[Member],
1860    prefix: &[Effect],
1861    settle: &mut Settle<'_>,
1862    first: Effect,
1863) -> Result<Effect> {
1864    if symbols.is_empty() || prefix.is_empty() {
1865        return Ok(first);
1866    }
1867    settle(e, symbols, prefix);
1868    Effect::validate(e, k).map_err(|m| {
1869        let mut err = shape_error(m);
1870        if let Some(d) = err.data.as_mut().and_then(Json::as_object_mut) {
1871            d.insert("effect_index".to_owned(), json!(k));
1872        }
1873        err
1874    })
1875}
1876
1877/// The hit's `(id, path)` as the envelope spells it.
1878fn row_of(hit: &Json) -> Row {
1879    (
1880        hit.get(HIT_ID)
1881            .and_then(Json::as_str)
1882            .unwrap_or_default()
1883            .to_owned(),
1884        hit.get(HIT_PATH)
1885            .and_then(Json::as_str)
1886            .unwrap_or_default()
1887            .to_owned(),
1888    )
1889}
1890
1891/// What [`bind`] produced: the concrete effects and each hit's `(id, path)`
1892/// in result order.
1893#[derive(Clone, Debug, Default)]
1894pub struct Bound {
1895    pub effects: Vec<Effect>,
1896    pub rows: Vec<Row>,
1897}
1898
1899/// The concrete effects of `hits × templates` (§11.8's expansion rule);
1900/// `settle` resolves the bare `$new.n` kinds ([`settle_symbols`]).
1901pub fn bind(c: &Compiled, hits: &[Json], settle: &mut Settle<'_>) -> Result<Bound> {
1902    let mut b = Bound::default();
1903    for hit in hits {
1904        let row = row_of(hit);
1905        for t in &c.templates {
1906            let k = b.effects.len();
1907            let o = Origin {
1908                k,
1909                template: Some(t.index),
1910                row: &row,
1911                root: c.root,
1912                request: Some(&c.request),
1913            };
1914            let (mut e, symbols) = bind_one(c.root, hit, t, &o)?;
1915            let first = Effect::validate(&e, k).map_err(shape_error)?;
1916            let effect = finish(&mut e, k, &symbols, &b.effects, settle, first)?;
1917            b.effects.push(effect);
1918        }
1919        b.rows.push(row);
1920    }
1921    Ok(b)
1922}
1923
1924/// `selection_too_large { cardinality, max, implicit? }`: `cardinality` is
1925/// `">max"` when the page was truncated at the bound, the exact count when
1926/// it was `bound + 1` (`first`/`single` under `max: 0`).
1927fn too_large(truncated: bool, hits: usize, c: &Compiled, count: usize) -> SurfaceError {
1928    let mut data = json!({
1929        "cardinality": if truncated { json!(format!(">{}", c.bound)) } else { json!(hits) },
1930        "max": c.bound,
1931    });
1932    if c.implicit() {
1933        data["implicit"] = json!(true);
1934    }
1935    // The fix (3.3): the same request bounded at the selection's actual size.
1936    data["fix"] = json!(fixes::bounded(&c.request, count));
1937    let message = format!(
1938        "the selection has {} hits, over the bound of {}{} — narrow the selector or raise `expect.max`",
1939        if truncated {
1940            format!("more than {}", c.bound)
1941        } else {
1942            hits.to_string()
1943        },
1944        c.bound,
1945        if c.implicit() {
1946            " (the implicit ceiling; pass expect.max to raise it)"
1947        } else {
1948            ""
1949        }
1950    );
1951    MutationError::with_data(ErrorCode::SelectionTooLarge, message, data).into()
1952}
1953
1954/// What the selector (or the selector-less sugar) produced: the concrete
1955/// effects, the frozen record (a selector's), the hits' identities for error
1956/// annotation, and the effects as written when a selector-less request
1957/// evaluated an expression (the plan's and the intent record's
1958/// `templates`).
1959#[derive(Clone, Debug)]
1960pub struct Expansion {
1961    pub effects: Vec<Effect>,
1962    pub selection: Option<Selection>,
1963    pub rows: Vec<Row>,
1964    pub templates: Option<Vec<Json>>,
1965}
1966
1967/// Run the selector and expand it (module docs; the reference's
1968/// `compileSelection`), or — without a selector (3.2) — desugar the effects
1969/// and evaluate their slots in their own targets' row scopes
1970/// ([`expand_plain`]). `semantic` is the embedded phrases of the selector
1971/// ([`crate::query::embed_phrases`]). The store is borrowed mutably only for
1972/// the prefix plans that settle bare `$new.n` kinds; nothing is written.
1973pub fn expand(
1974    store: &mut Store,
1975    ctx: &PlanContext,
1976    doc_store: &dyn DocStore,
1977    req: &SelectRequest,
1978    semantic: HashMap<String, SemanticVec>,
1979) -> Result<Expansion> {
1980    if !req.has_selector() {
1981        return expand_plain(store, ctx, doc_store, req);
1982    }
1983    let c = compile(req)?;
1984    // Unknown names (spec/surface §1.5, 4.6; spec/mutate §11.8): a name no row
1985    // of the selector's kind carries would select every row or none, so the
1986    // selector's OWN text is judged before the run and refused under the
1987    // admission's prefix and hint like every other refusal (the reference's
1988    // `runQuery` rewraps the runner's `filter_invalid`). The slot items the
1989    // rewrite appended are not the selector. The vocabulary read here is
1990    // handed to the run, whose own `Refuse` pass (the AST entry's default)
1991    // then finds nothing new to read.
1992    let vocabulary = names::VocabularyCache::default();
1993    {
1994        let own = Query {
1995            select: c
1996                .rewritten
1997                .select
1998                .iter()
1999                .filter(|it| {
2000                    !RESERVED_ALIAS_PREFIXES
2001                        .iter()
2002                        .any(|p| it.name().starts_with(p))
2003                })
2004                .cloned()
2005                .collect(),
2006            ..c.rewritten.clone()
2007        };
2008        names::check_names(
2009            store,
2010            &ctx.repo_id,
2011            &own,
2012            names::UnknownNames::Refuse,
2013            &vocabulary,
2014        )
2015        .map_err(|e| refused(format!("select: {}", e.message)))?;
2016    }
2017    // `date("today")` in a selector reads the plan's clock (§1.3, 4.8).
2018    let now_ms = Some(dates::stamp_ms(&ctx.ts));
2019    let opts = QueryOptions {
2020        limit: Some(c.bound.saturating_add(1)),
2021        cursor: None,
2022        provider: None,
2023        in_memory: false,
2024        bare: false,
2025        unknown_names: None,
2026        vocabulary: Some(&vocabulary),
2027        now_ms,
2028        request: None,
2029    };
2030    let res = query_ast(store, &ctx.repo_id, &c.rewritten, opts, semantic)?;
2031    if res.truncated || res.hits.len() > c.bound {
2032        // The exact size for the fix: a second, unbounded run when the first
2033        // page was cut (the engine never plans a partial set; the count is
2034        // for the example).
2035        let count = if res.truncated {
2036            let unbounded = QueryOptions {
2037                limit: Some(usize::MAX >> 2),
2038                cursor: None,
2039                provider: None,
2040                in_memory: false,
2041                bare: false,
2042                unknown_names: None,
2043                vocabulary: Some(&vocabulary),
2044                now_ms,
2045                request: None,
2046            };
2047            query_ast(store, &ctx.repo_id, &c.rewritten, unbounded, HashMap::new())?
2048                .hits
2049                .len()
2050        } else {
2051            res.hits.len()
2052        };
2053        return Err(too_large(res.truncated, res.hits.len(), &c, count));
2054    }
2055    let mut settle = |e: &mut Json, m: &[Member], p: &[Effect]| {
2056        settle_symbols(store, ctx, doc_store, e, m, p);
2057    };
2058    let b = bind(&c, &res.hits, &mut settle)?;
2059    let selection = Selection {
2060        query: req.select_str().to_owned(),
2061        expect: c.expect,
2062        templates: req.templates.as_array().cloned().unwrap_or_default(),
2063        cardinality: b.rows.len(),
2064        identities: b.rows.iter().map(|(id, _)| id.clone()).collect(),
2065    };
2066    Ok(Expansion {
2067        effects: b.effects,
2068        selection: Some(selection),
2069        rows: b.rows,
2070        templates: None,
2071    })
2072}
2073
2074// ---- the selector-less path (3.2) -----------------------------------------------------
2075
2076/// The error when a plain effect's row is gone (the reference's
2077/// `Scope.missing`).
2078#[derive(Clone, Debug, PartialEq, Eq)]
2079enum Missing {
2080    Doc(String),
2081    Block(String),
2082    Parent(String),
2083    Node(String),
2084}
2085
2086impl Missing {
2087    fn error(&self) -> SurfaceError {
2088        let (code, message, data) = match self {
2089            Missing::Doc(id) => (
2090                ErrorCode::DocMissing,
2091                format!("doc {id} not found"),
2092                json!({ "doc": id }),
2093            ),
2094            Missing::Block(id) => (
2095                ErrorCode::BlockMissing,
2096                format!("block {id} not found"),
2097                json!({ "block": id }),
2098            ),
2099            Missing::Parent(id) => (
2100                ErrorCode::ParentMissing,
2101                format!("parent {id} not found"),
2102                json!({}),
2103            ),
2104            Missing::Node(id) => (
2105                ErrorCode::BlockMissing,
2106                format!("node {id} not found"),
2107                json!({}),
2108            ),
2109        };
2110        MutationError::with_data(code, message, data).into()
2111    }
2112}
2113
2114/// The row a plain effect's expressions read: its primary target's root and
2115/// id, and the error when that row is gone.
2116#[derive(Clone, Debug, PartialEq, Eq)]
2117struct Scope {
2118    root: Target,
2119    id: String,
2120    missing: Missing,
2121}
2122
2123/// The primary target of a plain effect (the §11.8 table's: `target` of
2124/// set/unset/split and of a document move/remove, `into` of insert, the
2125/// first of `targets` for the block forms) as a row (the reference's
2126/// `scopeOf`): a `create` has none; an expression there needs a selector; a
2127/// bare `$new.n` has no row yet; a document is looked up (`doc_missing`).
2128fn scope_of(
2129    store: &Store,
2130    repo_id: &str,
2131    e: &Map<String, Json>,
2132    i: usize,
2133    sugar: &Desugared,
2134) -> Result<Scope> {
2135    let here = format!("effects[{i}]");
2136    let op = e.get("op").and_then(Json::as_str).unwrap_or_default();
2137    let first = |kinds: &'static [&'static str]| -> Result<(String, Option<Json>, &'static [&'static str], Member)> {
2138        let list = e.get("targets").and_then(Json::as_array);
2139        if list.is_none_or(Vec::is_empty) {
2140            // the shape check's `targets` message
2141            Effect::validate(&Json::Object(e.clone()), i).map_err(shape_error)?;
2142        }
2143        Ok((
2144            "targets[0]".to_owned(),
2145            list.and_then(|l| l.first()).cloned(),
2146            kinds,
2147            Member::Targets(0),
2148        ))
2149    };
2150    const PLACEMENT: &[&str] = &["document", "section", "block"];
2151    const DOCUMENT: &[&str] = &["document"];
2152    const BLOCK: &[&str] = &["block"];
2153    const SET: &[&str] = &["document", "block", "node"];
2154    let target = |kinds: &'static [&'static str]| {
2155        (
2156            "target".to_owned(),
2157            e.get("target").cloned(),
2158            kinds,
2159            Member::Target,
2160        )
2161    };
2162    let mut placement = false;
2163    let (key, v, kinds, member) = match op {
2164        "create" => {
2165            return Err(refused(format!(
2166                "{here}: a `create` has no row — an expression on a create needs a selector (`select`)"
2167            )));
2168        }
2169        "insert" => {
2170            placement = true;
2171            (
2172                "into".to_owned(),
2173                e.get("into").cloned(),
2174                PLACEMENT,
2175                Member::Into,
2176            )
2177        }
2178        "set" => target(SET),
2179        "unset" => target(DOCUMENT),
2180        "move" => {
2181            if e.contains_key("targets") || e.contains_key("into") {
2182                first(BLOCK)?
2183            } else {
2184                target(DOCUMENT)
2185            }
2186        }
2187        "remove" => {
2188            if e.contains_key("targets") {
2189                first(BLOCK)?
2190            } else {
2191                target(DOCUMENT)
2192            }
2193        }
2194        "split" => target(BLOCK),
2195        "merge" => first(BLOCK)?,
2196        _ => {
2197            // an unknown op (or `retarget`): the shape check refuses it
2198            Effect::validate(&Json::Object(e.clone()), i).map_err(shape_error)?;
2199            return Err(shape_error(format!(
2200                "{here}: `op` {} is not row-shaped",
2201                Json::String(op.to_owned())
2202            )));
2203        }
2204    };
2205    let where_ = format!("{here}.{key}");
2206    if v.as_ref().is_some_and(|v| {
2207        oqx_of(v).is_some()
2208            || v.as_object().is_some_and(|o| {
2209                o.get("id").is_some_and(|x| oqx_of(x).is_some())
2210                    || o.get("path").is_some_and(|x| oqx_of(x).is_some())
2211            })
2212    }) {
2213        return Err(needs_selector(&where_));
2214    }
2215    let kinds_text = kinds
2216        .iter()
2217        .map(|k| format!("\"{k}\""))
2218        .collect::<Vec<_>>()
2219        .join(" | ");
2220    let Some(t) = v
2221        .as_ref()
2222        .and_then(Json::as_object)
2223        .filter(|o| o.get("kind").is_some_and(Json::is_string))
2224    else {
2225        return Err(shape_error(format!(
2226            "{where_}: a target is {{ kind: {kinds_text}, … }}"
2227        )));
2228    };
2229    let kind = t["kind"].as_str().expect("checked");
2230    let id_of = |key: &str| t.get(key).and_then(Json::as_str).map(str::to_owned);
2231    // A bare `$new.n` (the desugar's record) has no row yet.
2232    if sugar.symbols.contains(&member)
2233        && let Some(sym) = id_of("id").filter(|id| omgbase_mutate::parse_new_symbol(id).is_some())
2234    {
2235        return Err(refused(format!(
2236            "{where_}: {sym} has no row yet — it is created by this plan; use a literal target or a selector"
2237        )));
2238    }
2239    if !kinds.contains(&kind) {
2240        return Err(shape_error(format!(
2241            "{where_}: `kind` must be one of {} (got \"{kind}\")",
2242            kinds.join(", ")
2243        )));
2244    }
2245    Ok(match kind {
2246        "document" => {
2247            let id = id_of("id");
2248            let path = id_of("path");
2249            if id.is_none() && path.is_none() {
2250                return Err(shape_error(format!(
2251                    "{where_}: a document target needs `id` or `path`"
2252                )));
2253            }
2254            let by_id = match &id {
2255                Some(id) => {
2256                    let found = store.find_doc_by_ref(repo_id, id)?;
2257                    Some(found.ok_or_else(|| Missing::Doc(id.clone()).error())?)
2258                }
2259                None => None,
2260            };
2261            let by_path = match &path {
2262                Some(path) => {
2263                    let found = store.find_doc_by_ref(repo_id, storage_path(path))?;
2264                    Some(found.ok_or_else(|| Missing::Doc(path.clone()).error())?)
2265                }
2266                None => None,
2267            };
2268            if let (Some(a), Some(b)) = (&by_id, &by_path)
2269                && a.doc_id != b.doc_id
2270            {
2271                return Err(shape_error(format!(
2272                    "id {} and path {} name different documents",
2273                    Json::String(id.clone().unwrap_or_default()),
2274                    Json::String(path.clone().unwrap_or_default())
2275                )));
2276            }
2277            let info = by_id.or(by_path).expect("one of the two");
2278            Scope {
2279                root: Target::Docs,
2280                id: info.doc_id.clone(),
2281                missing: Missing::Doc(info.doc_id),
2282            }
2283        }
2284        "block" => {
2285            let id = id_of("id").unwrap_or_default();
2286            Scope {
2287                root: Target::Blocks,
2288                id: id.clone(),
2289                missing: if placement {
2290                    Missing::Parent(id)
2291                } else {
2292                    Missing::Block(id)
2293                },
2294            }
2295        }
2296        "section" => {
2297            let id = id_of("heading_id").unwrap_or_default();
2298            Scope {
2299                root: Target::Blocks,
2300                id: id.clone(),
2301                missing: Missing::Parent(id),
2302            }
2303        }
2304        _ => {
2305            let id = id_of("id").unwrap_or_default();
2306            Scope {
2307                root: Target::Nodes,
2308                id: id.clone(),
2309                missing: Missing::Node(id),
2310            }
2311        }
2312    })
2313}
2314
2315/// What a plain effect becomes before the final shape check.
2316enum Plain {
2317    /// Validated as written (no expression): the JSON and its bare symbols.
2318    Literal(Json, Vec<Member>),
2319    /// Bound from its scope's row.
2320    Bound(Json, Vec<Member>),
2321}
2322
2323/// Compile a plain `effects` list (no selector; the reference's
2324/// `compilePlain`): desugar the `-> ` leaders, then — when nothing is an
2325/// expression — the shape check as before (3.1-identical, no `templates`).
2326/// Otherwise each effect with expression slots is admitted as a template
2327/// over its own primary target's row ([`scope_of`]): the effects are grouped
2328/// by that row's identity, one synthetic query per identity — `from <root>
2329/// where id == "<id>"` with the slot items appended, named
2330/// `__omg_sel_<effectIndex>_<slot>` — runs (`limit 2`), and the row binds the
2331/// slots exactly as a selector's hit would. Bare `$new.n` kinds are settled
2332/// last, in request order, each against the effects before it.
2333pub fn expand_plain(
2334    store: &mut Store,
2335    ctx: &PlanContext,
2336    doc_store: &dyn DocStore,
2337    req: &SelectRequest,
2338) -> Result<Expansion> {
2339    let raw = req
2340        .templates
2341        .as_array()
2342        .ok_or_else(|| shape_error("`effects` must be an array of effects".to_owned()))?;
2343    if raw.is_empty() {
2344        return Err(shape_error(
2345            "`effects` must name at least one effect".to_owned(),
2346        ));
2347    }
2348    // The request as given (the effects alone), for every fix (3.3).
2349    let request = fixes::Request {
2350        select: None,
2351        effects: Json::Array(raw.clone()),
2352        expect: None,
2353    };
2354    let mut desugared = Vec::with_capacity(raw.len());
2355    for (i, v) in raw.iter().enumerate() {
2356        desugared.push(desugar_effect(v, i, Some(&request))?);
2357    }
2358    let any_expression = desugared.iter().any(|(v, _)| contains_oqx(v));
2359    let mut staged: Vec<Option<Plain>> = (0..raw.len()).map(|_| None).collect();
2360    if any_expression {
2361        // Scope and admit the effects with expressions; group them by row.
2362        struct Group {
2363            scope: Scope,
2364            members: Vec<Template>,
2365        }
2366        let mut groups: Vec<Group> = Vec::new();
2367        for (i, (v, d)) in desugared.iter().enumerate() {
2368            let Some(o) = v.as_object().filter(|_| contains_oqx(v)) else {
2369                staged[i] = Some(Plain::Literal(v.clone(), d.symbols.clone()));
2370                continue;
2371            };
2372            let scope = scope_of(store, &ctx.repo_id, o, i, d)?;
2373            let lookup = parent_lookup(store);
2374            let fix = shape_fix(&request, &lookup);
2375            let t = admit_template(i, v, scope.root, true, d, Some(&fix))?;
2376            if t.slots.is_empty() {
2377                // `{ oqx }` only inside data (a nested `value`): literal, as for a template.
2378                staged[i] = Some(Plain::Literal(v.clone(), d.symbols.clone()));
2379                continue;
2380            }
2381            match groups.iter_mut().find(|g| g.scope == scope) {
2382                Some(g) => g.members.push(t),
2383                None => groups.push(Group {
2384                    scope,
2385                    members: vec![t],
2386                }),
2387            }
2388        }
2389        for g in &groups {
2390            let src = format!(
2391                "from {} where id == {}",
2392                g.scope.root.as_str(),
2393                Json::String(g.scope.id.clone())
2394            );
2395            let mut q = oqx::parse_string(&src)?;
2396            for t in &g.members {
2397                for s in &t.slots {
2398                    q.select
2399                        .push(renamed(&s.item, &slot_name(t.index, &s.name)));
2400                }
2401            }
2402            let opts = QueryOptions {
2403                limit: Some(2),
2404                cursor: None,
2405                provider: None,
2406                in_memory: false,
2407                bare: false,
2408                unknown_names: None,
2409                vocabulary: None,
2410                now_ms: Some(dates::stamp_ms(&ctx.ts)),
2411                request: None,
2412            };
2413            let res = query_ast(store, &ctx.repo_id, &q, opts, HashMap::new())?;
2414            let Some(hit) = res.hits.first() else {
2415                return Err(g.scope.missing.error());
2416            };
2417            let row = row_of(hit);
2418            for t in &g.members {
2419                let o = Origin {
2420                    k: t.index,
2421                    template: None,
2422                    row: &row,
2423                    root: g.scope.root,
2424                    request: Some(&request),
2425                };
2426                let (e, symbols) = bind_one(g.scope.root, hit, t, &o)?;
2427                staged[t.index] = Some(Plain::Bound(e, symbols));
2428            }
2429        }
2430    } else {
2431        for (i, (v, d)) in desugared.into_iter().enumerate() {
2432            staged[i] = Some(Plain::Literal(v, d.symbols));
2433        }
2434    }
2435    // The final shape check in request order, each effect's bare `$new.n`
2436    // settled against the effects before it.
2437    let mut effects: Vec<Effect> = Vec::with_capacity(raw.len());
2438    for (i, p) in staged.into_iter().enumerate() {
2439        let (mut e, symbols) = match p.expect("every effect staged") {
2440            Plain::Literal(e, s) | Plain::Bound(e, s) => (e, s),
2441        };
2442        // The shape check with the §8 fixes (3.4) reads the store for the
2443        // anchor fix; the settlement that follows plans through it.
2444        let first = {
2445            let lookup = parent_lookup(store);
2446            let fix = shape_fix(&request, &lookup);
2447            Effect::validate_with(&e, i, Some(&fix)).map_err(shape_error_of)?
2448        };
2449        let mut settle = |e: &mut Json, m: &[Member], p: &[Effect]| {
2450            settle_symbols(store, ctx, doc_store, e, m, p);
2451        };
2452        let effect = finish(&mut e, i, &symbols, &effects, &mut settle, first)?;
2453        effects.push(effect);
2454    }
2455    Ok(Expansion {
2456        effects,
2457        selection: None,
2458        rows: Vec::new(),
2459        templates: any_expression.then(|| raw.clone()),
2460    })
2461}
2462
2463/// An error from the lowering of an expanded effect gains `template_index`
2464/// and `row` next to the planner's `effect_index` (module docs).
2465#[must_use]
2466pub fn annotate(mut e: SurfaceError, rows: &[Row], templates: usize) -> SurfaceError {
2467    if templates == 0 {
2468        return e;
2469    }
2470    if let Some(data) = e.data.as_mut().and_then(Json::as_object_mut)
2471        && let Some(k) = data
2472            .get("effect_index")
2473            .and_then(Json::as_u64)
2474            .and_then(|k| usize::try_from(k).ok())
2475        && !data.contains_key("template_index")
2476        && let Some((id, path)) = rows.get(k / templates)
2477    {
2478        data.insert("template_index".to_owned(), json!(k % templates));
2479        data.insert("row".to_owned(), json!({ "id": id, "path": path }));
2480    }
2481    e
2482}
2483
2484/// Expand and plan in one write scope: the store callback the catalog hands
2485/// to [`Store::plan_selected`], [`Store::execute_replanned`] and
2486/// [`Store::apply_planned`]. Without a selector (3.2) the plan carries no
2487/// `selection` but records the effects as written as `templates` when one
2488/// evaluated an expression.
2489pub fn plan_selected(
2490    store: &mut Store,
2491    doc_store: &dyn DocStore,
2492    ctx: &PlanContext,
2493    req: &SelectRequest,
2494    semantic: HashMap<String, SemanticVec>,
2495) -> Result<omgbase_mutate::Plan> {
2496    let x = expand(store, ctx, doc_store, req, semantic)?;
2497    let templates = if req.has_selector() {
2498        req.templates()
2499    } else {
2500        0
2501    };
2502    let concrete = x.effects.len();
2503    let plan = store
2504        .plan_selected(ctx, &x.effects, x.selection, doc_store)
2505        .map_err(|e| annotate(SurfaceError::from(e), &x.rows, templates));
2506    let mut plan = match plan {
2507        Ok(plan) => plan,
2508        // §8 "fix" (3.3): the corrected example, from the request as given
2509        // — under a selector with the hits' origins; the selector-less
2510        // sugar as a plain request, its symbols counted by a prefix plan.
2511        Err(e) => {
2512            let req_fix = fixes::Request {
2513                select: req.has_selector().then(|| req.select.clone()),
2514                effects: req.templates.clone(),
2515                expect: req.expect.clone(),
2516            };
2517            let origins: Option<Vec<fixes::Origin>> = (templates > 0).then(|| {
2518                (0..concrete)
2519                    .map(|k| fixes::Origin {
2520                        row_id: x
2521                            .rows
2522                            .get(k / templates)
2523                            .map(|(id, _)| id.clone())
2524                            .unwrap_or_default(),
2525                        template_index: k % templates,
2526                    })
2527                    .collect()
2528            });
2529            let allocated = |i: usize| -> Option<usize> {
2530                if i == 0 {
2531                    return Some(0);
2532                }
2533                store
2534                    .plan_selected(ctx, &x.effects[..i.min(concrete)], None, doc_store)
2535                    .ok()
2536                    .map(|p| p.new_ids.len())
2537            };
2538            return Err(fixes::planning_fix(
2539                &req_fix,
2540                e,
2541                origins.as_deref(),
2542                allocated,
2543            ));
2544        }
2545    };
2546    plan.templates = x.templates;
2547    Ok(plan)
2548}
2549
2550/// The one-shot `apply` rule's refusal with its corrected example (3.3): the
2551/// same call bounded at the selection's actual size — the caller never saw
2552/// the hits, so the engine counts them (the selector compiled, nothing
2553/// written) and spells the bound.
2554#[must_use]
2555pub fn unbounded_apply(req: &SelectRequest, cardinality: usize) -> SurfaceError {
2556    refused_with(
2557        UNBOUNDED,
2558        json!({
2559            "fix": fixes::bounded(
2560                &fixes::Request {
2561                    select: Some(req.select.clone()),
2562                    effects: req.templates.clone(),
2563                    expect: None,
2564                },
2565                cardinality,
2566            )
2567        }),
2568    )
2569}
2570
2571const UNBOUNDED: &str = "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`";
2572
2573/// The one-shot `apply` rule (§11.8): a selector must be bounded — the
2574/// caller never saw the hits (a preview is a `plan`, which needs no bound).
2575/// The selector-less sugar path names its targets and needs none.
2576pub fn require_max(req: &SelectRequest) -> Result<()> {
2577    if req.has_selector() && req.expect.is_none() {
2578        return Err(refused(UNBOUNDED));
2579    }
2580    Ok(())
2581}
2582
2583#[cfg(test)]
2584mod tests {
2585    use super::*;
2586
2587    fn req(select: &str, templates: Json, max: Option<usize>) -> SelectRequest {
2588        SelectRequest {
2589            select: json!(select),
2590            templates,
2591            expect: max.map(|max| json!({ "max": max })),
2592        }
2593    }
2594
2595    const ROLLUP: &str = "from docs where type == \"workstream\" and status == \"completed\"";
2596
2597    fn rollup_templates() -> Json {
2598        json!([
2599            { "op": "insert", "into": { "kind": "document", "path": { "oqx": "project" } }, "at": "end",
2600              "markdown": { "oqx": "\"- completed: [\" + title + \"](\" + path + \")\"" } },
2601            { "op": "set", "field": "frontmatter.rolled_up", "value": true },
2602        ])
2603    }
2604
2605    fn hit(id: &str, path: &str, slots: Json) -> Json {
2606        let mut h = json!({ "id": id, "path": path });
2607        if let Some(o) = slots.as_object() {
2608            for (k, v) in o {
2609                h[k] = v.clone();
2610            }
2611        }
2612        h
2613    }
2614
2615    fn data(e: &SurfaceError) -> &Json {
2616        e.data.as_ref().expect("data")
2617    }
2618
2619    /// `spec/mutate` §11.8's worked example: slot items appended after the
2620    /// user's, named `__omg_sel_<t>_<slot>`, the all-literal template
2621    /// contributing none; a user alias resolves into a slot.
2622    #[test]
2623    fn the_rewrite_appends_slot_projections_after_the_users_items() {
2624        let c = compile(&req(ROLLUP, rollup_templates(), None)).unwrap();
2625        assert_eq!(
2626            oqx::print_query(&c.rewritten).unwrap(),
2627            "select __omg_sel_0_into: project, __omg_sel_0_markdown: \"- completed: [\" + title + \"](\" + path + \")\" from docs where type == \"workstream\" and status == \"completed\""
2628        );
2629        assert_eq!(
2630            c.templates[0].slot_names(),
2631            ["__omg_sel_0_into", "__omg_sel_0_markdown"]
2632        );
2633        assert!(c.templates[1].slot_names().is_empty());
2634        assert_eq!(c.templates[1].default_target, Some(DefaultTarget::Target));
2635        assert_eq!((c.bound, c.implicit()), (SELECTION_CEILING, true));
2636        let c = compile(&req(
2637            "select hub: project from docs",
2638            json!([{ "op": "insert", "into": { "kind": "document", "path": { "oqx": "hub" } }, "markdown": "x" }]),
2639            Some(5),
2640        ))
2641        .unwrap();
2642        assert_eq!(
2643            oqx::print_query(&c.rewritten).unwrap(),
2644            "select hub: project, __omg_sel_0_into: hub from docs"
2645        );
2646        assert_eq!(
2647            oqx::print_query(&oqx::resolve_aliases(&c.rewritten).unwrap()).unwrap(),
2648            "select hub: project, __omg_sel_0_into: project from docs"
2649        );
2650        assert_eq!((c.bound, c.implicit()), (5, false));
2651        // A value-position directive is an expression too; `^` validates at depth 1.
2652        let c = compile(&req(
2653            "from docs",
2654            json!([{ "op": "set", "target": { "kind": "document", "id": { "oqx": "^docs single { id values where data.slug == ^hub_slug }" } }, "field": "frontmatter.x", "value": 1 }]),
2655            None,
2656        ))
2657        .unwrap();
2658        assert!(
2659            oqx::print_query(&c.rewritten)
2660                .unwrap()
2661                .starts_with("select __omg_sel_0_target: ^docs single {"),
2662        );
2663    }
2664
2665    /// `id in ["a", "b"]` in a selector (`spec/surface` 4.11; `spec/mutate`
2666    /// §8): the parser's error under `select: ` gains the tuple hint and the
2667    /// same request over the rewritten selector as its `fix`.
2668    #[test]
2669    fn a_bracket_list_in_a_selector_is_fixed_to_a_tuple() {
2670        let t = json!([{ "op": "set", "field": "frontmatter.rolled_up", "value": true }]);
2671        let e = compile(&req(
2672            "from docs where stage in [\"nigredo\", \"albedo\"]",
2673            t.clone(),
2674            None,
2675        ))
2676        .unwrap_err();
2677        assert_eq!(e.code, "filter_invalid");
2678        assert_eq!(
2679            e.message,
2680            "select: unexpected '[' — expected a value (at offset 25); a list is written as a tuple: (…)"
2681        );
2682        let d = e.data.unwrap();
2683        assert_eq!(d["hint"], HINT);
2684        assert_eq!(
2685            d["fix"],
2686            r#"{"effects":[{"field":"frontmatter.rolled_up","op":"set","value":true}],"select":"from docs where stage in (\"nigredo\", \"albedo\")"}"#
2687        );
2688        // `expect` rides along; one member is a grouping — no hint, no fix
2689        let e = compile(&req(
2690            "from docs where stage in [\"a\", \"b\"]",
2691            t.clone(),
2692            Some(2),
2693        ))
2694        .unwrap_err();
2695        assert!(
2696            e.data.unwrap()["fix"]
2697                .as_str()
2698                .unwrap()
2699                .contains("\"expect\":{\"max\":2}")
2700        );
2701        let e = compile(&req("from docs where stage in [\"nigredo\"]", t, None)).unwrap_err();
2702        assert_eq!(
2703            e.message,
2704            "select: unexpected '[' — expected a value (at offset 25)"
2705        );
2706        assert!(e.data.unwrap().get("fix").is_none());
2707    }
2708
2709    #[test]
2710    fn the_static_pass_refuses_what_cannot_be_a_selection() {
2711        let t = json!([{ "op": "set", "field": "frontmatter.x", "value": 1 }]);
2712        let refuse = |select: &str, templates: Json, max: Option<usize>| -> SurfaceError {
2713            let e = compile(&req(select, templates, max)).unwrap_err();
2714            assert_eq!(e.code, "filter_invalid", "{select}");
2715            e
2716        };
2717        let selector: [(&str, &str); 11] = [
2718            (
2719                "docs count { }",
2720                "select: `count` yields no rows — a selector's consumer is collect, first or single",
2721            ),
2722            (
2723                "select title values from docs",
2724                "select: a selector yields rows, not `values`",
2725            ),
2726            (
2727                "from links",
2728                "select: `links` rows have no effect target — select docs, blocks or nodes",
2729            ),
2730            (
2731                "from revisions where origin == \"api\"",
2732                "select: `revisions` rows have no effect target — select docs, blocks or nodes",
2733            ),
2734            (
2735                "entries(docs) collect { }",
2736                "select: a selector scans a root collection — `from docs`, `from blocks` or `from nodes`",
2737            ),
2738            (
2739                "from docs follow refs(\"a\")",
2740                "select: `follow` is not allowed in a selector — a hit must be a row of the root",
2741            ),
2742            (
2743                "from docs offset 2",
2744                "select: `offset` is not allowed — a selection is a set, not a page",
2745            ),
2746            (
2747                "select distinct type from docs",
2748                "select: `distinct` is not allowed in a selector",
2749            ),
2750            (
2751                "select __omg_x: 1 from docs",
2752                "select: projection `__omg_x` uses the reserved prefix `__omg_` — choose another alias",
2753            ),
2754            (
2755                "select __oqx_id: 1 from docs",
2756                "select: projection `__oqx_id` uses the reserved prefix `__oqx_` — choose another alias",
2757            ),
2758            (
2759                "from docs where $repo",
2760                "select: `$repo` was removed in surface 2.0 — reach the repository's collections through the root row: `^docs` from a top-level row (one caret per enclosing block, or the absolute `0^docs`), and the repository id as `^id` / `0^id`",
2761            ),
2762        ];
2763        for (select, message) in selector {
2764            let e = refuse(select, t.clone(), None);
2765            assert_eq!(data(&e)["hint"], HINT, "{select}");
2766            assert_eq!(e.message, message, "{select}");
2767        }
2768        // a parse error of the selector is prefixed too
2769        let e = refuse("from docs where (", t.clone(), None);
2770        assert!(e.message.starts_with("select: "), "{}", e.message);
2771        assert_eq!(data(&e)["hint"], HINT);
2772        // a filter, an order, a limit, first/single: admitted
2773        for ok in [
2774            "from docs where x exists { } order by title limit 3",
2775            "docs first { where status == \"a\" }",
2776            "docs single { }",
2777        ] {
2778            compile(&req(ok, t.clone(), None)).unwrap_or_else(|e| panic!("{ok}: {e}"));
2779        }
2780        // the request
2781        let e = compile(&SelectRequest {
2782            select: json!(" "),
2783            templates: t.clone(),
2784            expect: None,
2785        })
2786        .unwrap_err();
2787        assert_eq!(
2788            e.message,
2789            "`select` must be an OQX query (a non-empty string)"
2790        );
2791        let e = compile(&SelectRequest {
2792            select: json!("from docs"),
2793            templates: json!({}),
2794            expect: None,
2795        })
2796        .unwrap_err();
2797        assert_eq!(e.message, "`effects` must be an array of effect templates");
2798        let e = refuse("from docs", json!([]), None);
2799        assert_eq!(e.message, "`effects` must name at least one template");
2800        for (expect, message) in [
2801            (json!(3), "`expect` must be an object { max }"),
2802            (json!({ "max": 1, "x": 2 }), "`expect`: unknown key `x`"),
2803            (
2804                json!({ "max": 1, "x": 2, "y": 3 }),
2805                "`expect`: unknown keys `x`, `y`",
2806            ),
2807            (json!({}), "`expect.max` must be a non-negative integer"),
2808            (
2809                json!({ "max": -1 }),
2810                "`expect.max` must be a non-negative integer",
2811            ),
2812            (
2813                json!({ "max": 1.5 }),
2814                "`expect.max` must be a non-negative integer",
2815            ),
2816        ] {
2817            let e = compile(&SelectRequest {
2818                select: json!("from docs"),
2819                templates: t.clone(),
2820                expect: Some(expect),
2821            })
2822            .unwrap_err();
2823            assert_eq!(e.message, message);
2824        }
2825        // templates
2826        let templates: [(Json, &str); 9] = [
2827            (
2828                json!([{ "op": "retarget", "repairs": [] }]),
2829                "effects[0]: `retarget` is not row-shaped — it cannot be a selector template",
2830            ),
2831            (
2832                json!([{ "op": "merge", "separator": " " }]),
2833                "effects[0]: `merge` needs `targets` — one hit cannot be merged with itself",
2834            ),
2835            (
2836                json!([{ "op": "nope" }]),
2837                "effects[0]: `op` must be one of create/insert/set/unset/move/remove/split/merge (got \"nope\")",
2838            ),
2839            (
2840                json!(["x"]),
2841                "effects[0]: a template is an effect object whose `op` is one of create/insert/set/unset/move/remove/split/merge",
2842            ),
2843            (
2844                json!([{ "op": "set", "field": { "oqx": "\"frontmatter.x\"" }, "value": 1 }]),
2845                "effects[0].field: `{ oqx }` is not allowed here",
2846            ),
2847            (
2848                json!([{ "op": "set", "target": { "kind": "document", "id": { "oqx": "a" }, "path": { "oqx": "b" } }, "field": "frontmatter.x", "value": 1 }]),
2849                "effects[0].target: give `id` or `path` as an expression, not both",
2850            ),
2851            (
2852                json!([{ "op": "set", "target": { "kind": { "oqx": "k" }, "id": "d_1" }, "field": "frontmatter.x", "value": 1 }]),
2853                "effects[0].target.kind: `{ oqx }` is not allowed here",
2854            ),
2855            (
2856                json!([{ "op": "remove", "targets": [{ "kind": "block", "path": { "oqx": "p" } }] }]),
2857                "effects[0].targets[0]: a block target has `id`, not `path`",
2858            ),
2859            (
2860                json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "a, b" } }]),
2861                "effects[0].value: `oqx` must be one expression over the hit, not a query",
2862            ),
2863        ];
2864        for (templates, message) in templates {
2865            let e = refuse("from docs", templates, None);
2866            assert_eq!(e.message, message);
2867            assert_eq!(data(&e)["hint"], HINT);
2868        }
2869        // a nested `{ oqx }` under `value` or a frontmatter value is data
2870        compile(&req(
2871            "from docs",
2872            json!([{ "op": "set", "field": "frontmatter.x", "value": { "k": { "oqx": "x" } } }]),
2873            None,
2874        ))
2875        .unwrap();
2876        compile(&req("from docs", json!([{ "op": "create", "path": "n.md", "markdown": "x", "frontmatter": { "k": { "deep": { "oqx": "x" } } } }]), None)).unwrap();
2877        // the shape with the probe is the ordinary §11.1 error (hint: arguments)
2878        let e = refuse(
2879            "from nodes",
2880            json!([{ "op": "insert", "markdown": "x" }]),
2881            None,
2882        );
2883        assert_eq!(
2884            e.message,
2885            "effects[0].into: `kind` must be one of document, section, block (got \"node\")"
2886        );
2887        assert_eq!(data(&e)["hint"], "arguments");
2888        let e = refuse("from docs", json!([{ "op": "split", "at": [1] }]), None);
2889        assert_eq!(data(&e)["hint"], "arguments");
2890        let e = refuse(
2891            "from blocks",
2892            json!([{ "op": "unset", "field": "frontmatter.x" }]),
2893            None,
2894        );
2895        assert!(e.message.contains("(got \"block\")"), "{}", e.message);
2896        // (3.2) a whole `{ oqx }` element of `targets` is a whole-target slot
2897        let c = compile(&req(
2898            "from blocks",
2899            json!([{ "op": "remove", "targets": [{ "oqx": "x" }] }]),
2900            None,
2901        ))
2902        .unwrap();
2903        assert_eq!(c.templates[0].slot_names(), ["__omg_sel_0_targets_0"]);
2904        assert!(matches!(
2905            c.templates[0].slots[0].kind,
2906            SlotKind::WholeTargets { k: 0 }
2907        ));
2908        // an expression that does not parse names the slot; the offset is shifted by 16
2909        let e = refuse(
2910            "from docs",
2911            json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "1 +" } }]),
2912            None,
2913        );
2914        assert_eq!(
2915            e.message,
2916            "effects[0].value: unexpected 'from' — expected a value (at offset 4)"
2917        );
2918        let e = refuse(
2919            "from docs",
2920            json!([{ "op": "set", "field": "frontmatter.x", "value": { "oqx": "a from blocks" } }]),
2921            None,
2922        );
2923        assert_eq!(
2924            e.message,
2925            "effects[0].value: duplicate `from` clause (at offset 14)"
2926        );
2927    }
2928
2929    #[test]
2930    fn the_request_reads_expect_only_with_a_select() {
2931        assert_eq!(
2932            SelectRequest::from_args(&json!({ "effects": [] })).unwrap(),
2933            None
2934        );
2935        let r = SelectRequest::from_args(
2936            &json!({ "select": "from docs", "effects": [{ "op": "x" }], "expect": { "max": 3 } }),
2937        )
2938        .unwrap()
2939        .unwrap();
2940        assert_eq!(r.expect, Some(json!({ "max": 3 })));
2941        assert_eq!(r.templates(), 1);
2942        let e = SelectRequest::from_args(&json!({ "effects": [], "expect": { "max": 1 } }))
2943            .unwrap_err();
2944        assert_eq!(
2945            e.message,
2946            "`expect` bounds a selection — pass `select` with it"
2947        );
2948        assert!(require_max(&r.clone()).is_ok());
2949        let e = require_max(&SelectRequest { expect: None, ..r }).unwrap_err();
2950        assert_eq!(
2951            e.message,
2952            "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`"
2953        );
2954        assert_eq!(data(&e)["hint"], HINT);
2955    }
2956
2957    #[test]
2958    fn binding_types_each_slot_and_names_the_row() {
2959        let c = compile(&req(ROLLUP, rollup_templates(), Some(10))).unwrap();
2960        let hits = vec![
2961            hit(
2962                "d_2",
2963                "/workstreams/a.md",
2964                json!({ "__omg_sel_0_into": "/projects/x.md", "__omg_sel_0_markdown": "- completed: [A](/workstreams/a.md)" }),
2965            ),
2966            hit(
2967                "d_5",
2968                "/workstreams/b.md",
2969                json!({ "__omg_sel_0_into": { "id": "d_9", "path": "/projects/y.md" }, "__omg_sel_0_markdown": "- completed: [B](/workstreams/b.md)" }),
2970            ),
2971        ];
2972        let Bound { effects, rows, .. } = bind(&c, &hits, &mut no_settle).unwrap();
2973        assert_eq!(
2974            rows,
2975            [
2976                ("d_2".to_owned(), "/workstreams/a.md".to_owned()),
2977                ("d_5".to_owned(), "/workstreams/b.md".to_owned())
2978            ]
2979        );
2980        let json: Vec<Json> = effects.iter().map(Effect::to_json).collect();
2981        assert_eq!(
2982            json,
2983            [
2984                json!({ "op": "insert", "into": { "kind": "document", "path": "/projects/x.md" }, "at": "end", "markdown": "- completed: [A](/workstreams/a.md)" }),
2985                json!({ "op": "set", "target": { "kind": "document", "id": "d_2" }, "field": "frontmatter.rolled_up", "value": true }),
2986                // a row-as-value binds by its id, whichever member held the expression
2987                json!({ "op": "insert", "into": { "kind": "document", "id": "d_9" }, "at": "end", "markdown": "- completed: [B](/workstreams/b.md)" }),
2988                json!({ "op": "set", "target": { "kind": "document", "id": "d_5" }, "field": "frontmatter.rolled_up", "value": true }),
2989            ]
2990        );
2991        // absent / null / [] → target_missing with the row and the expression
2992        for absent in [
2993            json!({}),
2994            json!({ "__omg_sel_0_into": null }),
2995            json!({ "__omg_sel_0_into": [] }),
2996        ] {
2997            let mut h = hit("d_2", "/w.md", absent);
2998            h["__omg_sel_0_markdown"] = json!("x");
2999            let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3000            assert_eq!(e.code, "target_missing");
3001            assert_eq!(
3002                e.message,
3003                "effects[0].into.path: the expression yielded no identity for row d_2"
3004            );
3005            assert_eq!(
3006                *data(&e),
3007                json!({ "effect_index": 0, "template_index": 0, "row": { "id": "d_2", "path": "/w.md" }, "expression": "project" })
3008            );
3009        }
3010        // a list of one or more → ambiguous_target with the count
3011        let h = hit(
3012            "d_2",
3013            "/w.md",
3014            json!({ "__omg_sel_0_into": ["/a.md"], "__omg_sel_0_markdown": "x" }),
3015        );
3016        let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3017        assert_eq!(e.code, "ambiguous_target");
3018        assert_eq!(
3019            e.message,
3020            "effects[0].into.path: the expression yielded 1 identities for row d_2; a target resolves to exactly one"
3021        );
3022        assert_eq!(data(&e)["count"], 1);
3023        assert_eq!(data(&e)["expression"], "project");
3024        // a number as a target → filter_invalid { hint: selection } with the origin
3025        let h = hit(
3026            "d_2",
3027            "/w.md",
3028            json!({ "__omg_sel_0_into": 3, "__omg_sel_0_markdown": "x" }),
3029        );
3030        let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3031        assert_eq!(e.code, "filter_invalid");
3032        assert_eq!(
3033            e.message,
3034            "effects[0].into.path: the expression yielded a number (3), not an identity (a string or a row)"
3035        );
3036        assert_eq!(
3037            *data(&e),
3038            json!({ "reason": e.message, "hint": HINT, "effect_index": 0, "template_index": 0, "row": { "id": "d_2", "path": "/w.md" }, "expression": "project" })
3039        );
3040        // markdown must be a string
3041        let h = hit(
3042            "d_2",
3043            "/w.md",
3044            json!({ "__omg_sel_0_into": "/p.md", "__omg_sel_0_markdown": 7.5 }),
3045        );
3046        let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3047        assert_eq!(
3048            e.message,
3049            "effects[0].markdown: the expression yielded a number (7.5), expected a string"
3050        );
3051        let h = hit("d_2", "/w.md", json!({ "__omg_sel_0_into": "/p.md" }));
3052        let e = bind(&c, &[h], &mut no_settle).unwrap_err();
3053        assert_eq!(
3054            e.message,
3055            "effects[0].markdown: the expression yielded absent for row d_2"
3056        );
3057        // an object that is not a row ref
3058        let c = compile(&req("from docs", json!([{ "op": "set", "field": "frontmatter.x", "value": 1 }, { "op": "insert", "into": { "kind": "document", "path": { "oqx": "p" } }, "markdown": "x" }]), None)).unwrap();
3059        let e = bind(
3060            &c,
3061            &[hit(
3062                "d_1",
3063                "/a.md",
3064                json!({ "__omg_sel_1_into": { "id": "d_2", "path": "/p.md", "extra": 1 } }),
3065            )],
3066            &mut no_settle,
3067        )
3068        .unwrap_err();
3069        assert_eq!(
3070            e.message,
3071            "effects[1].into.path: the expression yielded an object, not an identity (a string or a row)"
3072        );
3073        assert_eq!(data(&e)["effect_index"], 1);
3074        assert_eq!(data(&e)["template_index"], 1);
3075    }
3076
3077    #[test]
3078    fn binding_the_hit_by_kind_and_the_other_slots() {
3079        // blocks: a targetless block move/remove is `targets: [hit]`; `at`
3080        // anchors are strings; `value` takes any JSON but never absent;
3081        // `targets[k].id` takes a string or a row ref's id.
3082        let c = compile(&req(
3083            "from blocks",
3084            json!([
3085                { "op": "move", "into": { "kind": "block", "id": { "oqx": "parent" } }, "at": { "after": { "oqx": "anchor" } } },
3086                { "op": "set", "field": "attrs.checked", "value": { "oqx": "done" } },
3087                { "op": "remove", "targets": [{ "kind": "block", "id": { "oqx": "sibling" } }, { "kind": "block", "id": { "oqx": "other" } }] },
3088                { "op": "remove" },
3089                { "op": "merge", "targets": [{ "kind": "block", "id": { "oqx": "a" } }, { "kind": "block", "id": "b_5" }], "separator": { "oqx": "sep" } },
3090                { "op": "split", "at": [2] },
3091            ]),
3092            Some(1),
3093        ))
3094        .unwrap();
3095        assert_eq!(c.templates[0].default_target, Some(DefaultTarget::Targets));
3096        assert_eq!(c.templates[1].default_target, Some(DefaultTarget::Target));
3097        assert_eq!(c.templates[2].default_target, None);
3098        assert_eq!(c.templates[3].default_target, Some(DefaultTarget::Targets));
3099        assert_eq!(c.templates[5].default_target, Some(DefaultTarget::Target));
3100        assert_eq!(
3101            c.templates
3102                .iter()
3103                .flat_map(Template::slot_names)
3104                .collect::<Vec<_>>(),
3105            [
3106                "__omg_sel_0_into",
3107                "__omg_sel_0_at",
3108                "__omg_sel_1_value",
3109                "__omg_sel_2_targets_0",
3110                "__omg_sel_2_targets_1",
3111                "__omg_sel_4_targets_0",
3112                "__omg_sel_4_separator",
3113            ]
3114        );
3115        let h = hit(
3116            "b_1",
3117            "/a.md",
3118            json!({
3119                "__omg_sel_0_into": { "id": "b_9", "path": "/a.md" }, "__omg_sel_0_at": "b_8",
3120                "__omg_sel_1_value": false,
3121                "__omg_sel_2_targets_0": "b_2", "__omg_sel_2_targets_1": { "id": "b_3", "path": "/a.md" },
3122                "__omg_sel_4_targets_0": "b_4", "__omg_sel_4_separator": " / ",
3123            }),
3124        );
3125        let Bound { effects, .. } = bind(&c, &[h], &mut no_settle).unwrap();
3126        let json: Vec<Json> = effects.iter().map(Effect::to_json).collect();
3127        assert_eq!(
3128            json[0],
3129            json!({ "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "block", "id": "b_9" }, "at": { "after": "b_8" } })
3130        );
3131        assert_eq!(
3132            json[1],
3133            json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "attrs.checked", "value": false })
3134        );
3135        assert_eq!(
3136            json[2],
3137            json!({ "op": "remove", "targets": [{ "kind": "block", "id": "b_2" }, { "kind": "block", "id": "b_3" }] })
3138        );
3139        assert_eq!(
3140            json[3],
3141            json!({ "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }] })
3142        );
3143        assert_eq!(
3144            json[4],
3145            json!({ "op": "merge", "targets": [{ "kind": "block", "id": "b_4" }, { "kind": "block", "id": "b_5" }], "separator": " / " })
3146        );
3147        assert_eq!(
3148            json[5],
3149            json!({ "op": "split", "target": { "kind": "block", "id": "b_1" }, "at": [2] })
3150        );
3151        // over docs a targetless `remove`/`move` is the document form
3152        let c = compile(&req("from docs", json!([{ "op": "remove" }, { "op": "move", "to": { "oqx": "\"archive/\" + title + \".md\"" } }]), None)).unwrap();
3153        assert_eq!(c.templates[0].default_target, Some(DefaultTarget::Target));
3154        assert_eq!(c.templates[1].slot_names(), ["__omg_sel_1_to"]);
3155        let Bound { effects, .. } = bind(
3156            &c,
3157            &[hit(
3158                "d_1",
3159                "/a.md",
3160                json!({ "__omg_sel_1_to": "archive/A.md" }),
3161            )],
3162            &mut no_settle,
3163        )
3164        .unwrap();
3165        assert_eq!(
3166            effects[0].to_json(),
3167            json!({ "op": "remove", "target": { "kind": "document", "id": "d_1" } })
3168        );
3169        assert_eq!(
3170            effects[1].to_json(),
3171            json!({ "op": "move", "target": { "kind": "document", "id": "d_1" }, "to": "archive/A.md", "retarget_inbound": true })
3172        );
3173        // an absent value is refused (use `unset`); a row ref lands as a record
3174        let c = compile(&req(
3175            "from docs",
3176            json!([{ "op": "set", "field": "frontmatter.hub", "value": { "oqx": "hub" } }]),
3177            None,
3178        ))
3179        .unwrap();
3180        let e = bind(&c, &[hit("d_1", "/a.md", json!({}))], &mut no_settle).unwrap_err();
3181        assert_eq!(
3182            e.message,
3183            "effects[0].value: the expression yielded absent for row d_1 — use `unset` to remove a key"
3184        );
3185        let Bound { effects, .. } = bind(
3186            &c,
3187            &[hit(
3188                "d_1",
3189                "/a.md",
3190                json!({ "__omg_sel_0_value": { "id": "d_2", "path": "/p.md" } }),
3191            )],
3192            &mut no_settle,
3193        )
3194        .unwrap();
3195        assert_eq!(
3196            effects[0].to_json()["value"],
3197            json!({ "id": "d_2", "path": "/p.md" })
3198        );
3199        // a `create` per hit: path / markdown / frontmatter values (an absent one is refused too)
3200        let c = compile(&req(
3201            "from docs",
3202            json!([{ "op": "create", "path": { "oqx": "\"/notes/\" + slug + \".md\"" }, "markdown": { "oqx": "\"# \" + title" }, "frontmatter": { "source": { "oqx": "path" }, "layer": "draft" } }]),
3203            None,
3204        ))
3205        .unwrap();
3206        assert_eq!(
3207            c.templates[0].slot_names(),
3208            [
3209                "__omg_sel_0_path",
3210                "__omg_sel_0_markdown",
3211                "__omg_sel_0_fm_0"
3212            ]
3213        );
3214        let Bound { effects, .. } = bind(&c, &[hit("d_1", "/a.md", json!({ "__omg_sel_0_path": "/notes/a.md", "__omg_sel_0_markdown": "# A", "__omg_sel_0_fm_0": "/a.md" }))], &mut no_settle).unwrap();
3215        assert_eq!(
3216            effects[0].to_json(),
3217            json!({ "op": "create", "path": "/notes/a.md", "markdown": "# A", "frontmatter": { "layer": "draft", "source": "/a.md" } })
3218        );
3219        let e = bind(
3220            &c,
3221            &[hit(
3222                "d_1",
3223                "/a.md",
3224                json!({ "__omg_sel_0_path": "/notes/a.md", "__omg_sel_0_markdown": "# A" }),
3225            )],
3226            &mut no_settle,
3227        )
3228        .unwrap_err();
3229        assert_eq!(
3230            e.message,
3231            "effects[0].frontmatter.source: the expression yielded absent for row d_1"
3232        );
3233        // zero hits bind to nothing
3234        let Bound { effects, rows, .. } = bind(&c, &[], &mut no_settle).unwrap();
3235        assert!(effects.is_empty() && rows.is_empty());
3236    }
3237
3238    #[test]
3239    fn lowering_errors_gain_the_template_and_the_row() {
3240        let rows = vec![
3241            ("d_1".to_owned(), "/a.md".to_owned()),
3242            ("d_2".to_owned(), "/b.md".to_owned()),
3243        ];
3244        let e = SurfaceError::with_data(
3245            "conflicting_effects",
3246            "x",
3247            json!({ "effects": [1, 3], "effect_index": 3 }),
3248        );
3249        let a = annotate(e, &rows, 2);
3250        let d = a.data.unwrap();
3251        assert_eq!(d["template_index"], 1);
3252        assert_eq!(d["row"], json!({ "id": "d_2", "path": "/b.md" }));
3253        // already annotated, or no effect index: untouched
3254        let e = SurfaceError::with_data("doc_missing", "x", json!({ "doc": "d" }));
3255        assert_eq!(annotate(e.clone(), &rows, 2), e);
3256        let e = SurfaceError::new("filter_invalid", "x");
3257        assert_eq!(annotate(e.clone(), &rows, 0), e);
3258    }
3259
3260    #[test]
3261    fn offsets_shift_back_by_the_wrapper() {
3262        assert_eq!(
3263            shift_offsets("unexpected ')' (at offset 20)", -16),
3264            "unexpected ')' (at offset 4)"
3265        );
3266        assert_eq!(shift_offsets("no offset here", -16), "no offset here");
3267        assert_eq!(shift_offsets("x (at offset 3)", -16), "x (at offset 0)");
3268        assert_eq!(shift_offsets("x (at offset 3)", 3), "x (at offset 6)");
3269        assert_eq!(describe(Some(&json!(2.0))), "a number (2)");
3270        assert_eq!(describe(Some(&json!([1, 2]))), "a list of 2");
3271        assert_eq!(describe(Some(&json!("q"))), "a string (\"q\")");
3272        assert_eq!(describe(None), "absent");
3273    }
3274
3275    // ---- §11.8 "Sugar" (3.2) ----------------------------------------------------------
3276
3277    fn desugar(v: Json) -> Result<Json> {
3278        desugar_effect(&v, 0, None).map(|(v, _)| v)
3279    }
3280
3281    /// The leader is exactly `-> ` at the start; `->x`, ` -> x`, `-->`, a
3282    /// bare `->` and a mid-string `-> ` are literals; `-> "-> …"` spells a
3283    /// literal that begins with the leader.
3284    #[test]
3285    fn the_leader_is_exact_and_everything_else_is_literal() {
3286        for literal in ["->x", " -> x", "-->", "->", "a -> b", "${HOME}", "x"] {
3287            let e = json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": literal });
3288            assert_eq!(desugar(e.clone()).unwrap(), e, "{literal}");
3289            assert!(!needs_sugar(&json!([e])), "{literal}");
3290        }
3291        assert_eq!(
3292            desugar(json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> \"-> note\"" })).unwrap(),
3293            json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> note" })
3294        );
3295        assert_eq!(
3296            desugar(json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> title" })).unwrap(),
3297            json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": { "oqx": "title" } })
3298        );
3299        // typing follows the value: `-> 1` stays an expression (a number lands as one)
3300        assert_eq!(
3301            desugar(json!({ "op": "set", "target": "d_0", "field": "frontmatter.priority", "value": "-> priority + 1" })).unwrap(),
3302            json!({ "op": "set", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.priority", "value": { "oqx": "priority + 1" } })
3303        );
3304        // nested strings of a `value` are data
3305        let nested = json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": { "a": "-> b" } });
3306        assert_eq!(desugar(nested.clone()).unwrap()["value"], nested["value"]);
3307        // the request routes through the sugar path exactly when something is sugar
3308        assert!(needs_sugar(
3309            &json!([{ "op": "insert", "into": "hub.md", "markdown": "x" }])
3310        ));
3311        assert!(needs_sugar(
3312            &json!([{ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "-> title" }])
3313        ));
3314        assert!(needs_sugar(
3315            &json!([{ "op": "remove", "targets": ["b_1"] }])
3316        ));
3317        let plain = SelectRequest::from_args(&json!({ "effects": [{ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "->x" }] })).unwrap();
3318        assert!(plain.is_none());
3319        let sugar = SelectRequest::from_args(
3320            &json!({ "effects": [{ "op": "insert", "into": "hub.md", "markdown": "-> x" }] }),
3321        )
3322        .unwrap()
3323        .unwrap();
3324        assert!(!sugar.has_selector());
3325        assert!(require_max(&sugar).is_ok());
3326    }
3327
3328    /// A bare string literal or an interpolation-free template literal folds
3329    /// to the plain literal (no `{ oqx }`); anything else is an expression.
3330    #[test]
3331    fn constant_folding_of_literal_leaders() {
3332        assert_eq!(desugar_expression("\"x\"", "h").unwrap(), json!("x"));
3333        assert_eq!(desugar_expression("`x`", "h").unwrap(), json!("x"));
3334        assert_eq!(desugar_expression("`a\\`b`", "h").unwrap(), json!("a`b"));
3335        assert_eq!(
3336            desugar_expression("`${title} (${era})`", "h").unwrap(),
3337            json!({ "oqx": "`${title} (${era})`" })
3338        );
3339        assert_eq!(desugar_expression("1", "h").unwrap(), json!({ "oqx": "1" }));
3340        assert_eq!(
3341            desugar_expression("\"a\" + \"b\"", "h").unwrap(),
3342            json!({ "oqx": "\"a\" + \"b\"" })
3343        );
3344        // a folded leader in a target is a bare string, then normalized
3345        assert_eq!(
3346            desugar(json!({ "op": "insert", "into": "-> \"hub.md\"", "markdown": "x" })).unwrap(),
3347            json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "x" })
3348        );
3349        // an unparsable expression: the offset counts from the written string
3350        let e =
3351            desugar(json!({ "op": "insert", "into": "hub.md", "markdown": "-> 1 +" })).unwrap_err();
3352        assert_eq!(e.code, "filter_invalid");
3353        assert_eq!(data(&e)["hint"], HINT);
3354        assert_eq!(
3355            e.message,
3356            "effects[0].markdown: unexpected 'from' — expected a value (at offset 7)"
3357        );
3358        let e =
3359            desugar(json!({ "op": "insert", "into": "hub.md", "markdown": "-> a from blocks" }))
3360                .unwrap_err();
3361        assert!(e.message.ends_with("(at offset 17)"), "{}", e.message);
3362    }
3363
3364    /// Bare targets: a path, an id by its prefix, a `$new.n` (provisional
3365    /// kind, recorded for the settlement), a `-> ` expression as the whole
3366    /// target; a locator is refused with the fix; anything else is no target.
3367    #[test]
3368    fn bare_targets_normalize_before_the_shape_check() {
3369        let d = |v: Json| desugar(v).unwrap();
3370        assert_eq!(
3371            d(json!({ "op": "insert", "into": "hub.md", "markdown": "x" }))["into"],
3372            json!({ "kind": "document", "path": "hub.md" })
3373        );
3374        assert_eq!(
3375            d(json!({ "op": "insert", "into": "/notes/a", "markdown": "x" }))["into"],
3376            json!({ "kind": "document", "path": "/notes/a" })
3377        );
3378        assert_eq!(
3379            d(json!({ "op": "insert", "into": "d_2", "markdown": "x" }))["into"],
3380            json!({ "kind": "document", "id": "d_2" })
3381        );
3382        assert_eq!(
3383            d(json!({ "op": "set", "target": "b_5", "field": "attrs.checked", "value": true }))["target"],
3384            json!({ "kind": "block", "id": "b_5" })
3385        );
3386        assert_eq!(
3387            d(json!({ "op": "set", "target": "n_0123456789ab", "field": "value", "value": 1 }))["target"],
3388            json!({ "kind": "node", "id": "n_0123456789ab" })
3389        );
3390        assert_eq!(
3391            d(json!({ "op": "remove", "targets": ["b_8", { "kind": "block", "id": "b_9" }] }))["targets"],
3392            json!([{ "kind": "block", "id": "b_8" }, { "kind": "block", "id": "b_9" }])
3393        );
3394        // `$new.n`: a provisional kind the slot admits, and the member recorded
3395        let (v, sugar) = desugar_effect(
3396            &json!({ "op": "insert", "into": "$new.0", "markdown": "x" }),
3397            0,
3398            None,
3399        )
3400        .unwrap();
3401        assert_eq!(v["into"], json!({ "kind": "document", "id": "$new.0" }));
3402        assert_eq!(sugar.symbols, [Member::Into]);
3403        let (v, sugar) = desugar_effect(
3404            &json!({ "op": "set", "target": "$new.2", "field": "markdown", "value": "x" }),
3405            0,
3406            None,
3407        )
3408        .unwrap();
3409        assert_eq!(v["target"], json!({ "kind": "block", "id": "$new.2" }));
3410        assert_eq!(sugar.symbols, [Member::Target]);
3411        let (v, sugar) =
3412            desugar_effect(&json!({ "op": "remove", "targets": ["$new.1"] }), 0, None).unwrap();
3413        assert_eq!(v["targets"], json!([{ "kind": "block", "id": "$new.1" }]));
3414        assert_eq!(sugar.symbols, [Member::Targets(0)]);
3415        // a `-> ` whole target is the directive; a leader in `id` / `path` too
3416        assert_eq!(
3417            d(json!({ "op": "insert", "into": "-> project", "markdown": "x" }))["into"],
3418            json!({ "oqx": "project" })
3419        );
3420        assert_eq!(
3421            d(
3422                json!({ "op": "insert", "into": { "kind": "document", "path": "-> project" }, "markdown": "x" })
3423            )["into"],
3424            json!({ "kind": "document", "path": { "oqx": "project" } })
3425        );
3426        // a locator: hint arguments, with the fix
3427        let e = desugar(json!({ "op": "insert", "into": "hub.md#Status", "markdown": "x" }))
3428            .unwrap_err();
3429        assert_eq!(e.code, "filter_invalid");
3430        assert_eq!(data(&e)["hint"], "arguments");
3431        assert_eq!(data(&e)["fix"], crate::fixes::LOCATOR);
3432        assert_eq!(
3433            e.message,
3434            "effects[0].into: \"hub.md#Status\" is a locator, not a target — a bare target is a document path, a `b_`/`d_`/`n_` id or `$new.n`"
3435        );
3436        // anything else is no target
3437        let e = desugar(json!({ "op": "insert", "into": "hub", "markdown": "x" })).unwrap_err();
3438        assert_eq!(data(&e)["hint"], "arguments");
3439        assert_eq!(
3440            e.message,
3441            "effects[0].into: a target is { kind: …, … } or a bare document path, a `b_`/`d_`/`n_` id or `$new.n` (got \"hub\")"
3442        );
3443        // the op's kind table still applies to the normalized kind (the probe check)
3444        let e = compile(&req(
3445            "from docs",
3446            json!([{ "op": "split", "target": "d_0", "at": [1] }]),
3447            None,
3448        ))
3449        .unwrap_err();
3450        assert_eq!(
3451            e.message,
3452            "effects[0].target: `kind` must be one of block (got \"document\")"
3453        );
3454        assert_eq!(data(&e)["hint"], "arguments");
3455    }
3456
3457    /// A leader where no expression is admitted — `op`, `field`, `expect`, a
3458    /// target's other members, a whole `at`, `split.at`, an unknown key — is
3459    /// refused with `hint: "selection"`.
3460    #[test]
3461    fn a_leader_in_a_non_expression_slot_is_refused() {
3462        let refuse = |v: Json| -> SurfaceError {
3463            let e = desugar(v).unwrap_err();
3464            assert_eq!(e.code, "filter_invalid");
3465            assert_eq!(data(&e)["hint"], HINT);
3466            e
3467        };
3468        for (v, here) in [
3469            (
3470                json!({ "op": "set", "target": "d_0", "field": "-> f", "value": 1 }),
3471                "effects[0].field",
3472            ),
3473            (
3474                json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": 1, "expect": { "revision": "-> r" } }),
3475                "effects[0].expect.revision",
3476            ),
3477            (
3478                json!({ "op": "insert", "into": { "kind": "section", "heading_id": "-> h" }, "markdown": "x" }),
3479                "effects[0].into.heading_id",
3480            ),
3481            (
3482                json!({ "op": "insert", "into": "hub.md", "at": "-> end", "markdown": "x" }),
3483                "effects[0].at",
3484            ),
3485            (
3486                json!({ "op": "insert", "into": "hub.md", "at": { "before": "b_1", "x": "-> y" }, "markdown": "x" }),
3487                "effects[0].at.x",
3488            ),
3489            (
3490                json!({ "op": "split", "target": "b_1", "at": ["-> 2"] }),
3491                "effects[0].at[0]",
3492            ),
3493            (
3494                json!({ "op": "set", "target": "d_0", "field": "frontmatter.x", "value": 1, "bogus": "-> z" }),
3495                "effects[0].bogus",
3496            ),
3497            (
3498                json!({ "op": "retarget", "repairs": [{ "from": "-> a", "to": "b" }] }),
3499                "effects[0].repairs[0].from",
3500            ),
3501        ] {
3502            let e = refuse(v);
3503            assert_eq!(
3504                e.message,
3505                format!("{here}: `-> ` is not allowed here — this member takes no expression")
3506            );
3507        }
3508        // an unknown op — `-> set` included — is left for the shape check (hint arguments)
3509        for v in [
3510            json!({ "op": "frobnicate", "x": "-> y" }),
3511            json!({ "op": "-> set", "target": "d_0", "field": "frontmatter.x", "value": 1 }),
3512        ] {
3513            assert_eq!(desugar(v.clone()).unwrap(), v);
3514            let e = Effect::validate(&v, 0).map_err(shape_error).unwrap_err();
3515            assert!(e.message.contains("`op` must be one of"), "{}", e.message);
3516            assert_eq!(data(&e)["hint"], "arguments");
3517        }
3518    }
3519
3520    /// The whole-target binding: a row by its id, a string as a bare target,
3521    /// anything else refused; a `$new.n` string is a symbol to settle.
3522    #[test]
3523    fn binding_a_whole_target() {
3524        let c = compile(&req(
3525            "from docs",
3526            json!([{ "op": "insert", "into": "-> project", "markdown": "x" }, { "op": "set", "target": "-> hub", "field": "frontmatter.x", "value": 1 }]),
3527            None,
3528        ))
3529        .unwrap();
3530        let Bound { effects, .. } = bind(
3531            &c,
3532            &[hit("d_1", "/a.md", json!({ "__omg_sel_0_into": "hub.md", "__omg_sel_1_target": { "id": "d_7", "path": "/hub.md" } }))],
3533            &mut no_settle,
3534        )
3535        .unwrap();
3536        assert_eq!(
3537            effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
3538            [
3539                json!({ "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "at": "end", "markdown": "x" }),
3540                json!({ "op": "set", "target": { "kind": "document", "id": "d_7" }, "field": "frontmatter.x", "value": 1 }),
3541            ]
3542        );
3543        let Bound { effects, .. } = bind(
3544            &c,
3545            &[hit(
3546                "d_1",
3547                "/a.md",
3548                json!({ "__omg_sel_0_into": "b_3", "__omg_sel_1_target": "d_2" }),
3549            )],
3550            &mut no_settle,
3551        )
3552        .unwrap();
3553        assert_eq!(
3554            effects[0].to_json()["into"],
3555            json!({ "kind": "block", "id": "b_3" })
3556        );
3557        let e = bind(
3558            &c,
3559            &[hit(
3560                "d_1",
3561                "/a.md",
3562                json!({ "__omg_sel_0_into": "hub", "__omg_sel_1_target": "d_2" }),
3563            )],
3564            &mut no_settle,
3565        )
3566        .unwrap_err();
3567        assert_eq!(
3568            e.message,
3569            "effects[0].into: the expression yielded a string (\"hub\"), not an identity (a document path, an id or a row)"
3570        );
3571        assert_eq!(data(&e)["hint"], HINT);
3572        assert_eq!(data(&e)["template_index"], 0);
3573        // a `$new.n` is a symbol: provisional kind, settled by the caller
3574        let mut seen: Vec<(Vec<Member>, usize)> = Vec::new();
3575        let mut settle =
3576            |_: &mut Json, m: &[Member], p: &[Effect]| seen.push((m.to_vec(), p.len()));
3577        let Bound { effects, .. } = bind(
3578            &c,
3579            &[hit(
3580                "d_1",
3581                "/a.md",
3582                json!({ "__omg_sel_0_into": "$new.0", "__omg_sel_1_target": "d_2" }),
3583            )],
3584            &mut settle,
3585        )
3586        .unwrap();
3587        assert_eq!(
3588            effects[0].to_json()["into"],
3589            json!({ "kind": "document", "id": "$new.0" })
3590        );
3591        // the prefix is empty for the first effect: nothing to settle against yet
3592        assert!(seen.is_empty());
3593    }
3594
3595    // ---- the selector-less path, over a store ----------------------------------------
3596
3597    use omgbase_store::{Config, MemDocStore, SequentialMinter};
3598
3599    const TS: &str = "2026-10-13T10:00:00.000Z";
3600
3601    fn seeded() -> (Store, PlanContext, MemDocStore) {
3602        let mut store =
3603            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
3604        let repo = store.create_repo("fixture").unwrap();
3605        for (path, source) in [
3606            (
3607                "hub.md",
3608                "---\ntype: project\n---\n\n# Hub\n\n## Status\n\nNothing yet.\n",
3609            ),
3610            (
3611                "ws/alpha.md",
3612                "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Alpha\n",
3613            ),
3614        ] {
3615            store
3616                .observe_one(&repo, path, source, TS, &Config::default())
3617                .unwrap();
3618        }
3619        let ctx = PlanContext {
3620            repo_id: repo,
3621            ts: "2026-10-13T10:01:00.000Z".to_owned(),
3622            config: Config::default(),
3623        };
3624        (store, ctx, MemDocStore::new())
3625    }
3626
3627    fn plan_plain(
3628        store: &mut Store,
3629        ctx: &PlanContext,
3630        ds: &MemDocStore,
3631        effects: Json,
3632    ) -> Result<omgbase_mutate::Plan> {
3633        let req = SelectRequest::from_args(&json!({ "effects": effects }))
3634            .unwrap()
3635            .expect("sugar");
3636        plan_selected(store, ds, ctx, &req, HashMap::new())
3637    }
3638
3639    /// Without a selector a `-> ` slot evaluates in its own target's row:
3640    /// the plan has no `selection`, records the effects as written as
3641    /// `templates`, and its core equals the literal plan's (`plan_id`); a
3642    /// folded leader needs no row and yields no `templates`.
3643    #[test]
3644    fn the_selector_less_path_evaluates_in_the_targets_row() {
3645        let (mut store, ctx, ds) = seeded();
3646        let plan = plan_plain(
3647            &mut store,
3648            &ctx,
3649            &ds,
3650            json!([
3651                { "op": "insert", "into": "hub.md", "markdown": "-> `## ${title} (${type})`" },
3652                { "op": "set", "target": "/ws/alpha.md", "field": "frontmatter.hub", "value": "-> project" },
3653                { "op": "set", "target": "d_1", "field": "frontmatter.n", "value": "-> 1 + 1" },
3654            ]),
3655        )
3656        .unwrap();
3657        assert!(plan.selection.is_none());
3658        assert_eq!(plan.planner_v, "p3");
3659        let effects: Vec<Json> = plan.effects.iter().map(Effect::to_json).collect();
3660        assert_eq!(effects[0]["markdown"], "## Hub (project)");
3661        assert_eq!(effects[1]["value"], "hub.md");
3662        assert_eq!(
3663            effects[1]["target"],
3664            json!({ "kind": "document", "id": "d_1", "path": "ws/alpha.md" })
3665        );
3666        assert_eq!(effects[2]["value"], 2);
3667        let templates = plan.templates.clone().expect("templates");
3668        assert_eq!(templates[0]["markdown"], "-> `## ${title} (${type})`");
3669        assert_eq!(templates[1]["target"], "/ws/alpha.md");
3670        let wire = plan.to_json(false);
3671        assert!(wire.get("templates").is_some());
3672        assert!(plan.core_json().get("templates").is_none());
3673        // the literal spelling (no sugar: the plain path) digests identically
3674        let literal = store
3675            .plan_effects(
3676                &ctx,
3677                &omgbase_mutate::Effect::list_from_json(&json!([
3678                    { "op": "insert", "into": { "kind": "document", "path": "hub.md" }, "markdown": "## Hub (project)" },
3679                    { "op": "set", "target": { "kind": "document", "path": "ws/alpha.md" }, "field": "frontmatter.hub", "value": "hub.md" },
3680                    { "op": "set", "target": { "kind": "document", "id": "d_1" }, "field": "frontmatter.n", "value": 2 },
3681                ]))
3682                .unwrap(),
3683                &ds,
3684            )
3685            .unwrap();
3686        assert_eq!(literal.plan_id, plan.plan_id);
3687        assert!(literal.templates.is_none());
3688        // folded leaders and bare targets alone: no expression, no `templates`, same digest
3689        let folded = plan_plain(
3690            &mut store,
3691            &ctx,
3692            &ds,
3693            json!([
3694                { "op": "insert", "into": "hub.md", "markdown": "-> \"## Hub (project)\"" },
3695                { "op": "set", "target": "/ws/alpha.md", "field": "frontmatter.hub", "value": "-> `hub.md`" },
3696                { "op": "set", "target": "d_1", "field": "frontmatter.n", "value": 2 },
3697            ]),
3698        )
3699        .unwrap();
3700        assert_eq!(folded.plan_id, plan.plan_id);
3701        assert!(folded.templates.is_none());
3702        // the intent record carries `templates` only when the plan does
3703        let record = omgbase_mutate::effects::intent_record(
3704            &plan.plan_id,
3705            &plan.effects,
3706            None,
3707            plan.templates.as_deref(),
3708        );
3709        assert!(
3710            record.contains("\"templates\":[{\"into\":\"hub.md\""),
3711            "{record}"
3712        );
3713    }
3714
3715    /// The selector-less refusals and the row scope's errors.
3716    #[test]
3717    fn the_selector_less_path_refuses_what_has_no_row() {
3718        let (mut store, ctx, ds) = seeded();
3719        let refuse = |store: &mut Store, effects: Json| -> SurfaceError {
3720            plan_plain(store, &ctx, &ds, effects).unwrap_err()
3721        };
3722        let e = refuse(
3723            &mut store,
3724            json!([{ "op": "create", "path": "n.md", "markdown": "-> title" }]),
3725        );
3726        assert_eq!(e.code, "filter_invalid");
3727        assert_eq!(data(&e)["hint"], HINT);
3728        assert_eq!(
3729            e.message,
3730            "effects[0]: a `create` has no row — an expression on a create needs a selector (`select`)"
3731        );
3732        // a folded leader on a create is a literal and fine
3733        let plan = plan_plain(
3734            &mut store,
3735            &ctx,
3736            &ds,
3737            json!([{ "op": "create", "path": "n.md", "markdown": "-> \"# N\"" }]),
3738        )
3739        .unwrap();
3740        assert_eq!(plan.effects[0].to_json()["markdown"], "# N");
3741        assert!(plan.templates.is_none());
3742        let e = refuse(
3743            &mut store,
3744            json!([{ "op": "insert", "into": "-> project", "markdown": "-> title" }]),
3745        );
3746        assert_eq!(
3747            e.message,
3748            "effects[0].into: an expression target needs a selector — without `select` there is no row to read it from"
3749        );
3750        let e = refuse(
3751            &mut store,
3752            json!([{ "op": "set", "target": { "kind": "document", "id": "-> x" }, "field": "frontmatter.a", "value": "-> title" }]),
3753        );
3754        assert!(
3755            e.message
3756                .starts_with("effects[0].target: an expression target needs a selector"),
3757            "{}",
3758            e.message
3759        );
3760        let e = refuse(
3761            &mut store,
3762            json!([
3763                { "op": "create", "path": "n.md", "markdown": "# N" },
3764                { "op": "insert", "into": "$new.0", "markdown": "-> title" },
3765            ]),
3766        );
3767        assert_eq!(
3768            e.message,
3769            "effects[1].into: $new.0 has no row yet — it is created by this plan; use a literal target or a selector"
3770        );
3771        // a literal target that names no row is the lowering's own error
3772        let e = refuse(
3773            &mut store,
3774            json!([{ "op": "set", "target": "gone.md", "field": "frontmatter.a", "value": "-> title" }]),
3775        );
3776        assert_eq!(e.code, "doc_missing");
3777        assert_eq!(data(&e)["doc"], "gone.md");
3778        let e = refuse(
3779            &mut store,
3780            json!([{ "op": "insert", "into": "b_99", "markdown": "-> text" }]),
3781        );
3782        assert_eq!(e.code, "parent_missing");
3783        let e = refuse(
3784            &mut store,
3785            json!([{ "op": "set", "target": "b_99", "field": "markdown", "value": "-> text" }]),
3786        );
3787        assert_eq!(e.code, "block_missing");
3788        // a binding error names the effect and the row, never a template
3789        let e = refuse(
3790            &mut store,
3791            json!([{ "op": "insert", "into": "hub.md", "markdown": "-> nope" }]),
3792        );
3793        assert_eq!(
3794            e.message,
3795            "effects[0].markdown: the expression yielded absent for row d_0"
3796        );
3797        assert_eq!(
3798            *data(&e),
3799            json!({ "reason": e.message, "hint": HINT, "effect_index": 0, "row": { "id": "d_0", "path": "/hub.md" }, "expression": "nope" })
3800        );
3801    }
3802
3803    /// A bare `$new.n` takes the kind the symbol table recorded; the slot's
3804    /// kind table then applies to it (with `effect_index`); a symbol nothing
3805    /// created is the planner's `target_missing { symbol }`.
3806    #[test]
3807    fn bare_new_symbols_settle_against_the_prefix_plan() {
3808        let (mut store, ctx, ds) = seeded();
3809        let plan = plan_plain(
3810            &mut store,
3811            &ctx,
3812            &ds,
3813            json!([
3814                { "op": "create", "path": "n.md", "markdown": "# N\n\nBody." },
3815                { "op": "insert", "into": "$new.0", "markdown": "Tail." },
3816                { "op": "set", "target": "$new.2", "field": "markdown", "value": "Body, edited." },
3817                { "op": "remove", "targets": ["$new.1"] },
3818            ]),
3819        )
3820        .unwrap();
3821        let effects: Vec<Json> = plan.effects.iter().map(Effect::to_json).collect();
3822        assert_eq!(
3823            effects[1]["into"],
3824            json!({ "kind": "document", "id": "$new.0", "path": "n.md" })
3825        );
3826        assert_eq!(
3827            effects[2]["target"],
3828            json!({ "kind": "block", "id": "$new.2" })
3829        );
3830        assert_eq!(
3831            effects[3]["targets"],
3832            json!([{ "kind": "block", "id": "$new.1" }])
3833        );
3834        assert!(plan.templates.is_none());
3835        let e = plan_plain(
3836            &mut store,
3837            &ctx,
3838            &ds,
3839            json!([
3840                { "op": "create", "path": "n.md", "markdown": "# N" },
3841                { "op": "unset", "target": "$new.1", "field": "frontmatter.k" },
3842            ]),
3843        )
3844        .unwrap_err();
3845        assert_eq!(e.code, "filter_invalid");
3846        assert_eq!(
3847            e.message,
3848            "effects[1].target: `kind` must be one of document (got \"block\")"
3849        );
3850        assert_eq!(data(&e)["effect_index"], 1);
3851        assert_eq!(data(&e)["hint"], "arguments");
3852        let e = plan_plain(
3853            &mut store,
3854            &ctx,
3855            &ds,
3856            json!([{ "op": "insert", "into": "$new.3", "markdown": "x" }]),
3857        )
3858        .unwrap_err();
3859        assert_eq!(e.code, "target_missing");
3860        assert_eq!(data(&e)["symbol"], "$new.3");
3861    }
3862}