Skip to main content

omgbase_surface/
select.rs

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