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