Skip to main content

omgbase_surface/
select.rs

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