Skip to main content

omgbase_surface/
names.rs

1//! Unknown names (`spec/surface` §1.5, 4.6; one tier since 5.0; port of the
2//! reference's `oqx-js/names.ts`). A query that projects, filters or orders by
3//! a name no row of the scope's kind can carry — `select _path, target from
4//! links` (links have `_dst_path`), `where preview != ""` on blocks — used to
5//! run silently: an absent column in every row, a predicate true or false of
6//! every row. This pass judges every name the query reads (`oqx`'s
7//! `references`, AST.md §5b) against the row kind it is read from, statically
8//! — before any row is read, so the verdict never depends on the query's own
9//! filter — and either records a hint or refuses the query.
10//!
11//! The name rule (§1.2, 5.0) makes the judgement two-sided:
12//!   * an UNDERSCORE-prefixed name is an engine name — an attribute, a
13//!     reach-through, a source handle or a relation of the kind, a CLOSED set
14//!     — so a `_x` that is none of them is `filter_invalid` naming the kind's
15//!     engine names (the one loud error of this pass; both modes);
16//!   * a BARE name is the author's — on a document a frontmatter or inline
17//!     key, on a block or node a key of its `attrs` — and is judged against
18//!     the VOCABULARY of the kind in this repository: a name at least one live
19//!     object carries is known (however partial: `stage` on three processes
20//!     out of twenty documents is the data model working); a name no object
21//!     carries is the complaint — on a read the result carries one hint per
22//!     offending name and the rows still come back; a write selector (the AST
23//!     entry, [`UnknownNames::Refuse`]) is refused, since a name no row
24//!     carries would select every row. The hint's nearest match is drawn from
25//!     the authored keys AND the engine names, so a bare `title` / `id` /
26//!     `path` / `tags` / `format` that spells an engine attribute without its
27//!     prefix says "did you mean `_title`?". A `links` or `revisions` row
28//!     carries no authored data at all, so a bare name there is
29//!     `filter_invalid` either way (closed row shape).
30//!
31//! A repository with no live object of an open kind has no vocabulary: the
32//! check is skipped for that kind. The vocabulary is read once per
33//! (repository, kind) and cached on the repository's head commit
34//! (`MAX(commits.seq)`), the one thing every property or attrs change moves —
35//! the cache ([`VocabularyCache`]) lives beside the store it describes (the
36//! `Surface` holds one; a bare `query` call judges with a cache of its own).
37
38use std::cell::RefCell;
39use std::collections::{HashMap, HashSet};
40use std::rc::Rc;
41
42use omgbase_store::Store;
43use oqx::{Expr, Follow, FollowDestination, OpNode, Query, ReferenceStep, StepExpr, references};
44use rusqlite::params;
45
46use crate::context::{SOURCE_HANDLES, Target, attributes, reach_throughs};
47use crate::error::{Result, SurfaceError};
48use crate::planner;
49
50/// What to do with an unknown bare name on an open kind: record it in `hints`
51/// (every read) or refuse the query (a write selector).
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum UnknownNames {
54    Hint,
55    Refuse,
56}
57
58/// The relations of a kind, in the §1.2 order, and the kind of the rows each yields.
59#[must_use]
60pub fn relations(t: Target) -> &'static [(&'static str, Target)] {
61    match t {
62        Target::Docs => &[
63            ("_blocks", Target::Blocks),
64            ("_nodes", Target::Nodes),
65            ("_out_links", Target::Links),
66            ("_in_links", Target::Links),
67            ("_revisions", Target::Revisions),
68        ],
69        Target::Blocks => &[
70            ("_children", Target::Blocks),
71            ("_nodes", Target::Nodes),
72            ("_out_links", Target::Links),
73            ("_section", Target::Nodes),
74        ],
75        Target::Nodes => &[
76            ("_blocks", Target::Blocks),
77            ("_subsections", Target::Nodes),
78            ("_children", Target::Nodes),
79        ],
80        Target::Links | Target::Revisions => &[],
81    }
82}
83
84/// The reach-through handles of a kind and the kind of the row each reaches
85/// (§1.2; the self aliases are gone in 5.0 — `$it` is the self).
86#[must_use]
87pub fn handles(t: Target) -> &'static [(&'static str, Target)] {
88    match t {
89        Target::Docs => &[],
90        Target::Blocks => &[("_doc", Target::Docs)],
91        Target::Nodes => &[("_doc", Target::Docs), ("_block", Target::Blocks)],
92        // `_block` reaches the SOURCE block (4.11), absent for a frontmatter link
93        Target::Links => &[
94            ("_doc", Target::Docs),
95            ("_dst", Target::Docs),
96            ("_block", Target::Blocks),
97        ],
98        // `_doc` reaches the document's current row (4.7)
99        Target::Revisions => &[("_doc", Target::Docs)],
100    }
101}
102
103/// Every engine name of a kind, in the §1.2 table's order: attributes,
104/// reach-throughs, source handles (docs), relations — the list the messages
105/// carry and the candidates a nearest match is drawn from.
106#[must_use]
107pub fn engine_names(t: Target) -> Vec<&'static str> {
108    let mut out: Vec<&'static str> = attributes(t).to_vec();
109    out.extend(reach_throughs(t));
110    if t == Target::Docs {
111        out.extend(SOURCE_HANDLES);
112    }
113    out.extend(relations(t).iter().map(|(n, _)| *n));
114    out
115}
116
117fn lookup(table: &[(&str, Target)], name: &str) -> Option<Target> {
118    table.iter().find(|(n, _)| *n == name).map(|(_, t)| *t)
119}
120
121/// The kinds whose row shape is closed: no authored data reaches a row by bare
122/// name — `links`, and `revisions` since 4.7.
123fn closed(t: Target) -> bool {
124    matches!(t, Target::Links | Target::Revisions)
125}
126
127fn noun(t: Target) -> &'static str {
128    match t {
129        Target::Docs => "document",
130        Target::Blocks => "block",
131        Target::Nodes => "node",
132        Target::Links => "link",
133        Target::Revisions => "revision",
134    }
135}
136
137// ---- vocabulary ------------------------------------------------------------
138
139/// The authored vocabulary of one kind in one repository.
140#[derive(Debug)]
141pub struct Vocabulary {
142    /// Whether the repository has a live object of the kind at all.
143    pub present: bool,
144    /// The authored keys (flattened, dotted) and every dotted prefix of them.
145    keys: HashSet<String>,
146    /// The authored keys alone, in `key` order — the nearest-match candidates.
147    pub list: Vec<String>,
148}
149
150struct RepoCache {
151    head: i64,
152    vocab: HashMap<Target, Rc<Vocabulary>>,
153}
154
155/// The vocabulary cache of one store: per repository, keyed on the head
156/// commit. Keep it beside the store it was read from.
157#[derive(Default)]
158pub struct VocabularyCache {
159    repos: RefCell<HashMap<String, RepoCache>>,
160}
161
162impl std::fmt::Debug for VocabularyCache {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("VocabularyCache").finish_non_exhaustive()
165    }
166}
167
168fn head_seq(store: &Store, repo_id: &str) -> Result<i64> {
169    Ok(store.conn().query_row(
170        "SELECT COALESCE(MAX(seq), 0) AS seq FROM commits WHERE repo_id = ?",
171        params![repo_id],
172        |r| r.get::<_, i64>(0),
173    )?)
174}
175
176/// The authored vocabulary of a kind: docs — every live frontmatter or inline
177/// key (the merged read a bare name performs); blocks and nodes — every key a
178/// live row's `attrs` holds (the bare-name read of those kinds). Computed keys
179/// (`$title`, `$tags`) are the attributes `_title`/`_tags`, not authored keys.
180fn authored_keys(store: &Store, repo_id: &str, target: Target) -> Result<Vec<String>> {
181    let sql = match target {
182        Target::Docs => "SELECT DISTINCT key FROM properties WHERE repo_id = ? AND deleted_commit IS NULL AND source IN ('frontmatter', 'inline') ORDER BY key".to_owned(),
183        Target::Blocks | Target::Nodes => {
184            let alias = if target == Target::Blocks { "b" } else { "n" };
185            format!(
186                "SELECT DISTINCT j.key AS key FROM {}, json_each({alias}.attrs) j WHERE {} ORDER BY key",
187                planner::from_clause(target),
188                planner::guards(target)
189            )
190        }
191        Target::Links | Target::Revisions => return Ok(Vec::new()),
192    };
193    let mut stmt = store.conn().prepare(&sql)?;
194    let rows = stmt.query_map(params![repo_id], |r| r.get::<_, String>(0))?;
195    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
196}
197
198fn present(store: &Store, repo_id: &str, target: Target) -> Result<bool> {
199    // A closed kind has no authored vocabulary to be absent; the question never reaches it.
200    if target == Target::Revisions {
201        return Ok(true);
202    }
203    let sql = format!(
204        "SELECT 1 FROM {} WHERE {} LIMIT 1",
205        planner::from_clause(target),
206        planner::guards(target)
207    );
208    let mut stmt = store.conn().prepare(&sql)?;
209    Ok(stmt.exists(params![repo_id])?)
210}
211
212/// The vocabulary of a kind in a repository: `(present, keys)` — the authored
213/// keys in `key` order (the SQL runs only when a name is not an engine name).
214pub fn vocabulary(store: &Store, repo_id: &str, target: Target) -> Result<(bool, Vec<String>)> {
215    let cache = VocabularyCache::default();
216    let v = cache.vocabulary_of(store, repo_id, target)?;
217    Ok((v.present, v.list.clone()))
218}
219
220impl VocabularyCache {
221    /// The vocabulary of `target` in `repo_id`, read once per head commit.
222    pub fn vocabulary_of(
223        &self,
224        store: &Store,
225        repo_id: &str,
226        target: Target,
227    ) -> Result<Rc<Vocabulary>> {
228        let head = head_seq(store, repo_id)?;
229        let mut repos = self.repos.borrow_mut();
230        let c = repos.entry(repo_id.to_owned()).or_insert(RepoCache {
231            head,
232            vocab: HashMap::new(),
233        });
234        if c.head != head {
235            c.head = head;
236            c.vocab.clear();
237        }
238        if let Some(v) = c.vocab.get(&target) {
239            return Ok(Rc::clone(v));
240        }
241        let list = authored_keys(store, repo_id, target)?;
242        let mut keys = HashSet::new();
243        for k in &list {
244            let segs: Vec<&str> = k.split('.').collect();
245            for i in 1..=segs.len() {
246                keys.insert(segs[..i].join("."));
247            }
248        }
249        let present = !list.is_empty() || present(store, repo_id, target)?;
250        let v = Rc::new(Vocabulary {
251            present,
252            keys,
253            list,
254        });
255        c.vocab.insert(target, Rc::clone(&v));
256        Ok(v)
257    }
258}
259
260// ---- the kind of a scope ---------------------------------------------------
261
262/// `Root` is the root scope; `Group(k)` a GROUP scope (oqx 0.24): the groups
263/// of rows of kind `k` (`None` when the rows' kind is unknown), whose only
264/// row-yielding name is `rows`.
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266enum RowKind {
267    Root,
268    Of(Target),
269    Group(Option<Target>),
270}
271
272type ByDepth = HashMap<usize, Option<RowKind>>;
273
274/// The kind of the rows an expression yields when read on rows of `on`
275/// (`Root`: the root scope), or `None` when unknown.
276fn kind_of_expr(
277    e: &Expr,
278    on: Option<RowKind>,
279    read_depth: usize,
280    by_depth: &ByDepth,
281) -> Option<Target> {
282    match e {
283        Expr::Ident { name, .. } => kind_of_path(on, &[name]),
284        Expr::Outer { levels, name, .. } => {
285            kind_of_path(outer_base(read_depth, *levels, by_depth), &[name])
286        }
287        Expr::Member { .. } => {
288            let (head, rest) = dotted(e)?;
289            let (base, name) = match head {
290                Expr::Ident { name, .. } => (on, name.as_str()),
291                Expr::Outer { levels, name, .. } => {
292                    (outer_base(read_depth, *levels, by_depth), name.as_str())
293                }
294                _ => return None,
295            };
296            let mut path = vec![name];
297            path.extend(rest);
298            kind_of_path(base, &path)
299        }
300        // `refs(x)` resolves document references to live document rows (§1.3).
301        Expr::Call {
302            recv: None, name, ..
303        } if name == "refs" => Some(Target::Docs),
304        // A value-position block with no projection passes its receiver's rows through (then its own `from`).
305        Expr::Op(op) => kind_of_op(op, on, read_depth, by_depth),
306        _ => None, // a literal, a binding, a tuple (mixed), a template, an operator, a call
307    }
308}
309
310fn kind_of_op(
311    op: &OpNode,
312    on: Option<RowKind>,
313    read_depth: usize,
314    by_depth: &ByDepth,
315) -> Option<Target> {
316    if op.sub.select.iter().any(|it| it.name() != "$key") || op.sub.values {
317        return None;
318    }
319    let mut k = kind_of_expr(&op.receiver, on, read_depth, by_depth);
320    for f in &op.sub.from {
321        k = k.and_then(|k| kind_of_expr(f, Some(RowKind::Of(k)), read_depth + 1, by_depth));
322    }
323    k
324}
325
326/// The scope an outer reference reads: `levels` out of the read depth; past the root is the root.
327fn outer_base(read_depth: usize, levels: usize, by_depth: &ByDepth) -> Option<RowKind> {
328    if levels >= read_depth {
329        Some(RowKind::Root)
330    } else {
331        by_depth.get(&(read_depth - levels)).copied().flatten()
332    }
333}
334
335/// A dotted navigation headed by a name: the head and the path after it.
336fn dotted(e: &Expr) -> Option<(&Expr, Vec<&str>)> {
337    match e {
338        Expr::Ident { .. } | Expr::Outer { .. } => Some((e, Vec::new())),
339        Expr::Member { recv, name, .. } => {
340            let (head, mut rest) = dotted(recv)?;
341            rest.push(name);
342            Some((head, rest))
343        }
344        _ => None,
345    }
346}
347
348/// Follow a dotted path from a row kind through reach-throughs and relations
349/// (`_doc._blocks` on a link → blocks).
350fn kind_of_path(on: Option<RowKind>, path: &[&str]) -> Option<Target> {
351    let mut cur = on;
352    for (i, seg) in path.iter().enumerate() {
353        match cur? {
354            RowKind::Root => {
355                if *seg == "$it" {
356                    continue;
357                }
358                cur = Some(RowKind::Of(Target::parse(seg)?));
359            }
360            RowKind::Of(t) => {
361                if *seg == "$it" && i == 0 {
362                    continue;
363                }
364                let next = lookup(handles(t), seg).or_else(|| lookup(relations(t), seg))?;
365                cur = Some(RowKind::Of(next));
366            }
367            // At a group scope `rows` yields the grouped rows (their kind); a
368            // key is a value the language built, not a row of any kind.
369            RowKind::Group(k) => {
370                if *seg != "rows" || i != 0 {
371                    return None;
372                }
373                cur = k.map(RowKind::Of);
374            }
375        }
376    }
377    match cur? {
378        RowKind::Root | RowKind::Group(_) => None,
379        RowKind::Of(t) => Some(t),
380    }
381}
382
383/// The kind of the frontier rows of a walk: the seeds' kind when every
384/// destination yields rows of that same kind, else unknown.
385fn kind_of_frontier(
386    seed: Option<Target>,
387    f: &Follow,
388    read_depth: usize,
389    by_depth: &ByDepth,
390) -> Option<Target> {
391    let seed = seed?;
392    let on = Some(RowKind::Of(seed));
393    // A destination block is a value-position `op`: its receiver on the frontier row, then its own `from`.
394    for d in &f.destinations {
395        let k = match d {
396            FollowDestination::Relation(e) => kind_of_expr(e, on, read_depth, by_depth),
397            FollowDestination::Block(op) => kind_of_op(op, on, read_depth, by_depth),
398        };
399        if k != Some(seed) {
400            return None;
401        }
402    }
403    Some(seed)
404}
405
406/// The kind of the rows a scope holds, from its row chain (AST.md §5b); `None`
407/// when the chain reaches a value no kind describes.
408#[must_use]
409pub fn kind_of_scope(steps: &[ReferenceStep<'_>]) -> Option<Target> {
410    if steps.is_empty() {
411        return None; // the root scope: root names are judged elsewhere
412    }
413    let mut by_depth: ByDepth = HashMap::from([(0, Some(RowKind::Root))]);
414    let mut cur: Option<RowKind> = Some(RowKind::Root);
415    let mut read_depth = 0;
416    for step in steps {
417        let rows_kind = match cur {
418            Some(RowKind::Of(t)) => Some(t),
419            _ => None,
420        };
421        cur = match step.expr {
422            StepExpr::Follow(f) => {
423                kind_of_frontier(rows_kind, f, read_depth, &by_depth).map(RowKind::Of)
424            }
425            // A `group by` step (oqx 0.24): the rows at this depth become groups of them.
426            StepExpr::GroupBy(_) => Some(RowKind::Group(rows_kind)),
427            StepExpr::Expr(e) => kind_of_expr(e, cur, read_depth, &by_depth).map(RowKind::Of),
428        };
429        by_depth.insert(step.depth, cur);
430        read_depth = step.depth;
431    }
432    match cur? {
433        RowKind::Root | RowKind::Group(_) => None,
434        RowKind::Of(t) => Some(t),
435    }
436}
437
438// ---- judging a name --------------------------------------------------------
439
440/// Why a judged name is unknown.
441#[derive(Clone, Copy, Debug, PartialEq, Eq)]
442enum Why {
443    /// An underscore name that is no engine name of the kind.
444    Engine,
445    /// A bare name on a kind that carries no authored data.
446    Closed,
447    /// A bare name no object of the kind carries.
448    Authored,
449}
450
451struct Verdict {
452    target: Target,
453    /// The dotted name judged (the path after any reach-through or relation).
454    name: String,
455    why: Why,
456    /// The source handle the name was read through (`_data`, `_frontmatter`,
457    /// `_inline`), when it was.
458    handle: Option<String>,
459}
460
461struct Judge<'a> {
462    store: &'a Store,
463    repo_id: &'a str,
464    cache: &'a VocabularyCache,
465}
466
467impl Judge<'_> {
468    fn vocabulary(&self, target: Target) -> Result<Rc<Vocabulary>> {
469        self.cache.vocabulary_of(self.store, self.repo_id, target)
470    }
471
472    /// Judge a dotted name read on rows of `target`; `None` = known.
473    fn judge(&self, target: Target, path: &[&str]) -> Result<Option<Verdict>> {
474        let (head, rest) = path.split_first().expect("a name has a head");
475        if head.starts_with('_') {
476            // An engine attribute: what follows it (`_attrs.checked`,
477            // `_tags.size`) is the value's own navigation.
478            if attributes(target).contains(head) {
479                return Ok(None);
480            }
481            // The source handles reach the authored data under them.
482            if target == Target::Docs && SOURCE_HANDLES.contains(head) {
483                return if rest.is_empty() {
484                    Ok(None)
485                } else {
486                    self.judge_authored(Target::Docs, rest, Some(head))
487                };
488            }
489            if let Some(via) =
490                lookup(handles(target), head).or_else(|| lookup(relations(target), head))
491            {
492                return if rest.is_empty() {
493                    Ok(None)
494                } else {
495                    self.judge(via, rest)
496                };
497            }
498            // Not an engine name of this kind: the closed set is the whole set.
499            return Ok(Some(Verdict {
500                target,
501                name: (*head).to_owned(),
502                why: Why::Engine,
503                handle: None,
504            }));
505        }
506        // A bare target name on a row is the context's own loud error
507        // (`crate::query`); not doubled here.
508        if Target::parse(head).is_some() {
509            return Ok(None);
510        }
511        self.judge_authored(target, path, None)
512    }
513
514    fn judge_authored(
515        &self,
516        target: Target,
517        path: &[&str],
518        handle: Option<&str>,
519    ) -> Result<Option<Verdict>> {
520        let name = path.join(".");
521        if closed(target) {
522            return Ok(Some(Verdict {
523                target,
524                name,
525                why: Why::Closed,
526                handle: handle.map(str::to_owned),
527            }));
528        }
529        let v = self.vocabulary(target)?;
530        if !v.present {
531            return Ok(None); // no object of the kind: no vocabulary to judge by
532        }
533        if v.keys.contains(&name) {
534            return Ok(None);
535        }
536        Ok(Some(Verdict {
537            target,
538            name,
539            why: Why::Authored,
540            handle: handle.map(str::to_owned),
541        }))
542    }
543
544    /// The candidates of a bare name: the kind's engine names (so `title`
545    /// finds `_title`) and, on an open kind, its authored keys.
546    fn candidates(&self, target: Target) -> Result<Vec<String>> {
547        let mut out: Vec<String> = engine_names(target)
548            .into_iter()
549            .map(str::to_owned)
550            .collect();
551        if !closed(target) {
552            out.extend(self.vocabulary(target)?.list.iter().cloned());
553        }
554        Ok(out)
555    }
556
557    /// The error of an unknown engine name: `` `_target` is not an engine name
558    /// of links (engine names: _id, _path, …); did you mean `_dst_path`? `` —
559    /// the nearest match drawn from the engine names and the authored keys, so
560    /// a `_layer` written for the authored `layer` is pointed at it.
561    fn engine_name_error_message(&self, target: Target, name: &str) -> Result<String> {
562        Ok(format!(
563            "`{name}` is not an engine name of {} (engine names: {}){}",
564            target.as_str(),
565            engine_names(target).join(", "),
566            suggestion(name, &self.candidates(target)?)
567        ))
568    }
569
570    /// The hint of an open kind: `` no block in this repository carries a
571    /// property `preview`; did you mean `_text`? ``. Under a source handle the
572    /// name is shown with it — `` no document in this repository carries a
573    /// property `x` (`_data.x`); did you mean `y`? `` — and the nearest match
574    /// is drawn from the authored keys alone.
575    fn property_hint_message(
576        &self,
577        target: Target,
578        name: &str,
579        handle: Option<&str>,
580    ) -> Result<String> {
581        if let Some(h) = handle {
582            return Ok(format!(
583                "no {} in this repository carries a property `{name}` (`{h}.{name}`){}",
584                noun(target),
585                suggestion(name, &self.vocabulary(target)?.list)
586            ));
587        }
588        Ok(format!(
589            "no {} in this repository carries a property `{name}`{}",
590            noun(target),
591            suggestion(name, &self.candidates(target)?)
592        ))
593    }
594}
595
596/// The error of a bare name on a closed kind: `` `target` reads an authored
597/// property, and a link carries none (engine names: _id, _path, …); did you
598/// mean `_dst_path`? `` — the nearest match drawn from the engine names.
599#[must_use]
600pub fn closed_kind_error_message(target: Target, name: &str, handle: Option<&str>) -> String {
601    let shown = handle.map_or(String::new(), |h| format!(" (`{h}.{name}`)"));
602    let names: Vec<String> = engine_names(target)
603        .into_iter()
604        .map(str::to_owned)
605        .collect();
606    format!(
607        "`{name}`{shown} reads an authored property, and a {} carries none (engine names: {}){}",
608        noun(target),
609        names.join(", "),
610        suggestion(name, &names)
611    )
612}
613
614// ---- messages --------------------------------------------------------------
615
616/// Optimal string alignment distance (Damerau–Levenshtein with adjacent
617/// transpositions, each edit 1), over code points.
618#[must_use]
619pub fn edit_distance(a: &str, b: &str) -> usize {
620    let s: Vec<char> = a.chars().collect();
621    let t: Vec<char> = b.chars().collect();
622    let (m, n) = (s.len(), t.len());
623    let mut d = vec![vec![0usize; n + 1]; m + 1];
624    for (i, row) in d.iter_mut().enumerate() {
625        row[0] = i;
626    }
627    for (j, cell) in d[0].iter_mut().enumerate() {
628        *cell = j;
629    }
630    for i in 1..=m {
631        for j in 1..=n {
632            let cost = usize::from(s[i - 1] != t[j - 1]);
633            let mut v = (d[i - 1][j] + 1)
634                .min(d[i][j - 1] + 1)
635                .min(d[i - 1][j - 1] + cost);
636            if i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1] {
637                v = v.min(d[i - 2][j - 2] + 1);
638            }
639            d[i][j] = v;
640        }
641    }
642    d[m][n]
643}
644
645/// The nearest match of `name` among `candidates` (§1.5). First, the one hint
646/// the name rule makes certain: a candidate that is exactly `name` with the
647/// engine prefix added or removed (`id` → `_id`, `_layer` → `layer`) wins
648/// outright, whatever its length. Otherwise case-insensitive and
649/// **prefix-blind** — the `_` is a declaration, not spelling, so `commited` is
650/// one edit from `_commit` and `titel` one from `_title` as from `title`: a
651/// candidate qualifies when its distance is ≤ 2, or when one of the two is a
652/// substring of the other and the shorter has at least three characters. One
653/// typo (distance 1) wins first; then a substring match outranks a distance-2
654/// match (`_via` → `_via_id`, not `_id`; `_dst_pth` → `_dst_path`, not
655/// `_dst`); within a class the lowest distance, then the shorter candidate (so
656/// the bare authored `title` beats `_title` when both are one edit away), then
657/// alphabetical. A name under three characters gets no suggestion apart from
658/// the prefix rule (it is within distance 2 of nearly anything, and too short
659/// for the substring rule). `None` when nothing qualifies.
660#[must_use]
661pub fn nearest<'c>(name: &str, candidates: impl IntoIterator<Item = &'c str>) -> Option<String> {
662    let candidates: Vec<&str> = candidates.into_iter().collect();
663    let lower = name.to_lowercase();
664    let prefixed = format!("_{lower}");
665    let unprefixed = lower.strip_prefix('_');
666    for c in &candidates {
667        let hay = c.to_lowercase();
668        if hay == prefixed || unprefixed == Some(hay.as_str()) {
669            return Some((*c).to_owned());
670        }
671    }
672    let needle = lower.strip_prefix('_').unwrap_or(&lower).to_owned();
673    let needle_len = needle.chars().count();
674    if needle_len < 3 {
675        return None;
676    }
677    struct Best {
678        c: String,
679        d: usize,
680        sub: bool,
681    }
682    let rank = |d: usize, sub: bool| -> u8 {
683        if d == 1 {
684            0
685        } else if sub {
686            1
687        } else {
688            2
689        }
690    };
691    let mut best: Option<Best> = None;
692    let mut seen: HashSet<&str> = HashSet::new();
693    for c in candidates {
694        if !seen.insert(c) {
695            continue;
696        }
697        let hay_full = c.to_lowercase();
698        let hay = hay_full.strip_prefix('_').unwrap_or(&hay_full);
699        if hay == needle {
700            continue;
701        }
702        let d = edit_distance(&needle, hay);
703        let hay_len = hay.chars().count();
704        let (shorter, shorter_len, longer) = if needle_len <= hay_len {
705            (needle.as_str(), needle_len, hay)
706        } else {
707            (hay, hay_len, needle.as_str())
708        };
709        let sub = shorter_len >= 3 && longer.contains(shorter);
710        if !(d <= 2 || sub) {
711            continue;
712        }
713        let better = match &best {
714            None => true,
715            Some(b) => {
716                let (r, rb) = (rank(d, sub), rank(b.d, b.sub));
717                r < rb
718                    || (r == rb
719                        && (d < b.d
720                            || (d == b.d
721                                && (c.chars().count() < b.c.chars().count()
722                                    || (c.chars().count() == b.c.chars().count()
723                                        && c < b.c.as_str())))))
724            }
725        };
726        if better {
727            best = Some(Best {
728                c: c.to_owned(),
729                d,
730                sub,
731            });
732        }
733    }
734    best.map(|b| b.c)
735}
736
737fn suggestion(name: &str, candidates: &[String]) -> String {
738    match nearest(name, candidates.iter().map(String::as_str)) {
739        Some(c) => format!("; did you mean `{c}`?"),
740        None => String::new(),
741    }
742}
743
744// ---- the pass --------------------------------------------------------------
745
746/// Judge every name the query reads. Returns the hints (one per unknown bare
747/// name of an open kind, first-reference order, deduplicated) under
748/// [`UnknownNames::Hint`]; fails with `filter_invalid` for an unknown engine
749/// name (`_x`) and for a bare name on a closed kind in either mode, and for an
750/// unknown bare name on an open kind under [`UnknownNames::Refuse`].
751pub fn check_names(
752    store: &Store,
753    repo_id: &str,
754    raw: &Query,
755    mode: UnknownNames,
756    cache: &VocabularyCache,
757) -> Result<Vec<String>> {
758    let judge = Judge {
759        store,
760        repo_id,
761        cache,
762    };
763    let mut hints: Vec<String> = Vec::new();
764    let mut seen: HashSet<String> = HashSet::new();
765    for r in references(raw) {
766        if r.scope.steps.is_empty() {
767            continue; // a root name
768        }
769        let Some(kind) = kind_of_scope(&r.scope.steps) else {
770            continue;
771        };
772        let path: Vec<&str> = r.name.split('.').collect();
773        let Some(verdict) = judge.judge(kind, &path)? else {
774            continue;
775        };
776        match verdict.why {
777            Why::Engine => {
778                return Err(SurfaceError::filter_invalid(
779                    judge.engine_name_error_message(verdict.target, &verdict.name)?,
780                    "OQX",
781                ));
782            }
783            Why::Closed => {
784                return Err(SurfaceError::filter_invalid(
785                    closed_kind_error_message(
786                        verdict.target,
787                        &verdict.name,
788                        verdict.handle.as_deref(),
789                    ),
790                    "OQX",
791                ));
792            }
793            Why::Authored => {}
794        }
795        let message = judge.property_hint_message(
796            verdict.target,
797            &verdict.name,
798            verdict.handle.as_deref(),
799        )?;
800        if mode == UnknownNames::Refuse {
801            return Err(SurfaceError::filter_invalid(message, "OQX"));
802        }
803        if seen.insert(message.clone()) {
804            hints.push(message);
805        }
806    }
807    Ok(hints)
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use omgbase_reconcile::Config;
814    use omgbase_store::BatchItem;
815
816    const TS: &str = "2026-10-13T00:00:00.000Z";
817
818    fn repo_with(docs: &[(&str, &str)]) -> (Store, String) {
819        let mut store = Store::open_in_memory().unwrap();
820        let repo = store.create_repo("names").unwrap();
821        if !docs.is_empty() {
822            let items: Vec<BatchItem> = docs
823                .iter()
824                .map(|(p, s)| BatchItem::observed(p, s))
825                .collect();
826            store
827                .observe_batch(&repo, &items, TS, &Config::default())
828                .unwrap();
829        }
830        (store, repo)
831    }
832
833    fn fixture() -> (Store, String) {
834        repo_with(&[
835            (
836                "a.md",
837                "---\nkind: x\nmeta:\n  owner: bb\n---\n\n# A\n\n- [ ] a task\n\n```js\nlet x;\n```\n",
838            ),
839            ("b.md", "---\nkind: y\n---\n\n# B\n\nstage:: calcination\n"),
840        ])
841    }
842
843    fn hints(store: &Store, repo: &str, q: &str) -> Vec<String> {
844        let raw = oqx::parse_string(q).unwrap();
845        check_names(
846            store,
847            repo,
848            &raw,
849            UnknownNames::Hint,
850            &VocabularyCache::default(),
851        )
852        .unwrap()
853    }
854
855    fn refusal(store: &Store, repo: &str, q: &str, mode: UnknownNames) -> SurfaceError {
856        let raw = oqx::parse_string(q).unwrap();
857        check_names(store, repo, &raw, mode, &VocabularyCache::default()).unwrap_err()
858    }
859
860    const DOCS_NAMES: &str = "_id, _path, _raw, _hash, _updated_at, _created_at, _title, _tags, _format, _data, _frontmatter, _inline, _blocks, _nodes, _out_links, _in_links, _revisions";
861    const BLOCKS_NAMES: &str = "_id, _path, _raw, _hash, _updated_at, _created_at, _doc_id, _type, _text, _attrs, _depth, _ordinal, _doc, _children, _nodes, _out_links, _section";
862    const LINKS_NAMES: &str = "_id, _path, _doc_id, _block_id, _via_id, _dst_id, _dst_path, _uri, _field, _dst_kind, _predicate, _provenance, _anchor, _doc, _dst, _block";
863    const REVISIONS_NAMES: &str = "_id, _path, _rev, _seq, _doc_id, _commit, _ts, _origin, _actor, _reason, _hash, _is_current, _archived, _doc";
864
865    #[test]
866    fn engine_names_are_the_table_in_order() {
867        assert_eq!(engine_names(Target::Docs).join(", "), DOCS_NAMES);
868        assert_eq!(engine_names(Target::Blocks).join(", "), BLOCKS_NAMES);
869        assert_eq!(
870            engine_names(Target::Nodes).join(", "),
871            "_id, _path, _doc_id, _block_id, _kind, _name, _value, _attrs, _doc, _block, _blocks, _subsections, _children"
872        );
873        assert_eq!(engine_names(Target::Links).join(", "), LINKS_NAMES);
874        assert_eq!(engine_names(Target::Revisions).join(", "), REVISIONS_NAMES);
875    }
876
877    #[test]
878    fn vocabulary_per_kind() {
879        let (store, repo) = fixture();
880        assert_eq!(
881            vocabulary(&store, &repo, Target::Docs).unwrap(),
882            (
883                true,
884                vec![
885                    "kind".to_owned(),
886                    "meta.owner".to_owned(),
887                    "stage".to_owned()
888                ]
889            )
890        );
891        assert_eq!(
892            vocabulary(&store, &repo, Target::Blocks).unwrap(),
893            (
894                true,
895                ["checked", "lang", "level", "ordered"]
896                    .map(str::to_owned)
897                    .to_vec()
898            )
899        );
900        assert_eq!(
901            vocabulary(&store, &repo, Target::Nodes).unwrap(),
902            (
903                true,
904                ["checked", "first_ordinal", "last_ordinal", "level"]
905                    .map(str::to_owned)
906                    .to_vec()
907            )
908        );
909        assert_eq!(
910            vocabulary(&store, &repo, Target::Links).unwrap(),
911            (false, vec![])
912        );
913    }
914
915    #[test]
916    fn cache_refreshes_on_a_new_commit() {
917        let (mut store, repo) = repo_with(&[("a.md", "---\nkind: x\n---\n\n# A\n")]);
918        let cache = VocabularyCache::default();
919        assert_eq!(
920            cache
921                .vocabulary_of(&store, &repo, Target::Docs)
922                .unwrap()
923                .list,
924            ["kind"]
925        );
926        store
927            .observe_batch(
928                &repo,
929                &[BatchItem::observed("b.md", "---\ncolor: red\n---\n\n# B\n")],
930                TS,
931                &Config::default(),
932            )
933            .unwrap();
934        assert_eq!(
935            cache
936                .vocabulary_of(&store, &repo, Target::Docs)
937                .unwrap()
938                .list,
939            ["color", "kind"]
940        );
941    }
942
943    #[test]
944    fn the_name_rule() {
945        let (store, repo) = fixture();
946        // engine names — attributes, handles and relations — are silent
947        assert!(hints(&store, &repo, "select _title, _tags, _path, _blocks, d: $it._id from docs where _nodes exists { where _kind == \"md:task\" }").is_empty());
948        assert!(
949            hints(
950                &store,
951                &repo,
952                "select _type, _text, _attrs, _children, _section.level, _doc._title from blocks"
953            )
954            .is_empty()
955        );
956        assert!(
957            hints(
958                &store,
959                &repo,
960                "select _dst_path, s: _doc._title, t: _dst._title from links"
961            )
962            .is_empty()
963        );
964        // a bare property some objects carry is silent; a dotted prefix of a key is known
965        assert!(
966            hints(
967                &store,
968                &repo,
969                "select stage, kind from docs where meta.owner == \"bb\""
970            )
971            .is_empty()
972        );
973        assert!(hints(&store, &repo, "select meta from docs").is_empty());
974        assert!(
975            hints(
976                &store,
977                &repo,
978                "select checked from blocks where lang == \"js\""
979            )
980            .is_empty()
981        );
982        // a bare name no object carries is one hint per name, first-reference
983        // order, deduplicated
984        assert_eq!(
985            hints(
986                &store,
987                &repo,
988                "select stag, kynd from docs where stag == \"x\" order by kynd"
989            ),
990            [
991                "no document in this repository carries a property `stag`; did you mean `stage`?",
992                "no document in this repository carries a property `kynd`; did you mean `kind`?",
993            ]
994        );
995        assert_eq!(
996            hints(&store, &repo, "select meta.ownr, stage.x from docs"),
997            [
998                "no document in this repository carries a property `meta.ownr`; did you mean `meta.owner`?",
999                "no document in this repository carries a property `stage.x`; did you mean `stage`?",
1000            ]
1001        );
1002        // a bare name that spells an engine attribute without its prefix is
1003        // pointed at the engine name outright
1004        assert_eq!(
1005            hints(
1006                &store,
1007                &repo,
1008                "select id, title, path, tags, format from docs"
1009            ),
1010            [
1011                "no document in this repository carries a property `id`; did you mean `_id`?",
1012                "no document in this repository carries a property `title`; did you mean `_title`?",
1013                "no document in this repository carries a property `path`; did you mean `_path`?",
1014                "no document in this repository carries a property `tags`; did you mean `_tags`?",
1015                "no document in this repository carries a property `format`; did you mean `_format`?",
1016            ]
1017        );
1018        assert_eq!(
1019            hints(&store, &repo, "select text, preview from blocks"),
1020            [
1021                "no block in this repository carries a property `text`; did you mean `_text`?",
1022                "no block in this repository carries a property `preview`",
1023            ]
1024        );
1025        // under a source handle the name is shown with the handle, the
1026        // authored keys alone as candidates
1027        assert_eq!(
1028            hints(
1029                &store,
1030                &repo,
1031                "select p: _data.path, k: _frontmatter.kynd from docs"
1032            ),
1033            [
1034                "no document in this repository carries a property `path` (`_data.path`)",
1035                "no document in this repository carries a property `kynd` (`_frontmatter.kynd`); did you mean `kind`?",
1036            ]
1037        );
1038    }
1039
1040    #[test]
1041    fn an_unknown_engine_name_is_loud_in_either_mode() {
1042        let (store, repo) = fixture();
1043        let docs_err = format!(
1044            "`_nope` is not an engine name of docs (engine names: {DOCS_NAMES}); did you mean `_nodes`?"
1045        );
1046        for mode in [UnknownNames::Hint, UnknownNames::Refuse] {
1047            let e = refusal(&store, &repo, "select _nope from docs", mode);
1048            assert_eq!(
1049                (e.code.as_str(), e.message.as_str()),
1050                ("filter_invalid", docs_err.as_str())
1051            );
1052        }
1053        // the nearest match is drawn from the engine names and the authored
1054        // keys: a `_kind` written for the authored `kind` is pointed at it
1055        assert_eq!(
1056            refusal(
1057                &store,
1058                &repo,
1059                "from docs where _kind == \"x\"",
1060                UnknownNames::Hint
1061            )
1062            .message,
1063            format!(
1064                "`_kind` is not an engine name of docs (engine names: {DOCS_NAMES}); did you mean `kind`?"
1065            )
1066        );
1067        assert_eq!(
1068            refusal(
1069                &store,
1070                &repo,
1071                "select _tittle from blocks",
1072                UnknownNames::Hint
1073            )
1074            .message,
1075            format!("`_tittle` is not an engine name of blocks (engine names: {BLOCKS_NAMES})")
1076        );
1077        // behind a reach-through the name is judged against the reached kind
1078        assert!(
1079            refusal(
1080                &store,
1081                &repo,
1082                "select _doc._nope from blocks",
1083                UnknownNames::Hint
1084            )
1085            .message
1086            .starts_with("`_nope` is not an engine name of docs")
1087        );
1088    }
1089
1090    #[test]
1091    fn closed_kinds_refuse_a_bare_name_and_dst_reaches_docs() {
1092        let (store, repo) = fixture();
1093        let err = format!(
1094            "`target` reads an authored property, and a link carries none (engine names: {LINKS_NAMES})"
1095        );
1096        let e = refusal(
1097            &store,
1098            &repo,
1099            "select target from links",
1100            UnknownNames::Hint,
1101        );
1102        assert_eq!(
1103            (e.code.as_str(), e.message.as_str()),
1104            ("filter_invalid", err.as_str())
1105        );
1106        let e = refusal(
1107            &store,
1108            &repo,
1109            "from docs where _out_links exists { where target == \"x\" }",
1110            UnknownNames::Hint,
1111        );
1112        assert_eq!(e.message, err);
1113        assert_eq!(
1114            refusal(
1115                &store,
1116                &repo,
1117                "select dst_path from links",
1118                UnknownNames::Hint
1119            )
1120            .message,
1121            format!(
1122                "`dst_path` reads an authored property, and a link carries none (engine names: {LINKS_NAMES}); did you mean `_dst_path`?"
1123            )
1124        );
1125        assert_eq!(
1126            refusal(
1127                &store,
1128                &repo,
1129                "select rev from revisions",
1130                UnknownNames::Hint
1131            )
1132            .message,
1133            format!(
1134                "`rev` reads an authored property, and a revision carries none (engine names: {REVISIONS_NAMES}); did you mean `_rev`?"
1135            )
1136        );
1137        assert_eq!(
1138            hints(&store, &repo, "select _dst.kynd from links"),
1139            ["no document in this repository carries a property `kynd`; did you mean `kind`?"]
1140        );
1141        // `_block` (4.11) reaches the source block: its engine names read
1142        // clean, a misspelling is judged as a block's; the handle is not an
1143        // attribute, so the closed-row list names it after the attributes.
1144        assert!(
1145            hints(
1146                &store,
1147                &repo,
1148                "select _block._text, _block._type, _block._doc._path from links"
1149            )
1150            .is_empty()
1151        );
1152        assert_eq!(
1153            hints(&store, &repo, "select _block.typ from links"),
1154            ["no block in this repository carries a property `typ`; did you mean `_type`?"]
1155        );
1156    }
1157
1158    #[test]
1159    fn refuse_mode_is_the_hint_text_as_filter_invalid() {
1160        let (store, repo) = fixture();
1161        let e = refusal(&store, &repo, "select stag from docs", UnknownNames::Refuse);
1162        assert_eq!(e.code, "filter_invalid");
1163        assert_eq!(
1164            e.message,
1165            "no document in this repository carries a property `stag`; did you mean `stage`?"
1166        );
1167        assert_eq!(e.data.unwrap()["hint"], "OQX");
1168    }
1169
1170    #[test]
1171    fn what_is_not_judged() {
1172        let (store, repo) = fixture();
1173        for q in [
1174            "select s: stage, n: s from docs where s == \"x\"",
1175            // a group scope (oqx 0.24): a key is never a row property; `rows`
1176            // yields the grouped rows, so names under it are judged as docs/blocks
1177            "select stage, n: count(), kynd: collect(kind) from docs group by stage having n > 1 order by count() desc",
1178            "select kind, open: rows count { where _nodes exists { where _kind == \"md:task\" } } from docs group by kind",
1179            "select p: _path, open from docs where _nodes collect { ^open: _value where _kind == \"md:task\" }",
1180            "select $key: _id from docs where $depth > 0 and has(stage) and size(list(kind)) > 0",
1181            "select a: _attrs.whatever from blocks",
1182            "select n: _tags.size() from docs",
1183            "select t: (_blocks, _nodes) collect { select kynd } from docs",
1184        ] {
1185            assert!(hints(&store, &repo, q).is_empty(), "{q}");
1186        }
1187        assert_eq!(
1188            hints(
1189                &store,
1190                &repo,
1191                "select r: refs(stage) collect { select kynd } from docs"
1192            )
1193            .len(),
1194            1
1195        );
1196        // under a group scope, `rows` is still the docs: an unknown bare name through it hints
1197        assert_eq!(
1198            hints(
1199                &store,
1200                &repo,
1201                "select stage, t: rows collect { select kynd } from docs group by stage"
1202            ),
1203            ["no document in this repository carries a property `kynd`; did you mean `kind`?"]
1204        );
1205        assert_eq!(
1206            hints(
1207                &store,
1208                &repo,
1209                "select lang, n: rows count { where chekced } from blocks group by lang"
1210            ),
1211            ["no block in this repository carries a property `chekced`; did you mean `checked`?"]
1212        );
1213    }
1214
1215    #[test]
1216    fn an_empty_repository_has_no_vocabulary() {
1217        let (store, repo) = repo_with(&[]);
1218        assert!(hints(&store, &repo, "select titel from docs where layr == \"x\"").is_empty());
1219        assert!(hints(&store, &repo, "select preview from blocks").is_empty());
1220        assert!(
1221            refusal(
1222                &store,
1223                &repo,
1224                "select target from links",
1225                UnknownNames::Hint
1226            )
1227            .message
1228            .contains("reads an authored property, and a link carries none")
1229        );
1230        // the engine-name check still runs
1231        assert!(
1232            refusal(&store, &repo, "select _nope from docs", UnknownNames::Hint)
1233                .message
1234                .starts_with("`_nope` is not an engine name of docs")
1235        );
1236    }
1237
1238    #[test]
1239    fn edit_distance_is_optimal_string_alignment() {
1240        assert_eq!(edit_distance("title", "titel"), 1);
1241        assert_eq!(edit_distance("stag", "stage"), 1);
1242        assert_eq!(edit_distance("value", "text"), 5);
1243        assert_eq!(edit_distance("via", "via_id"), 3);
1244        assert_eq!(edit_distance("x", "id"), 2);
1245    }
1246
1247    #[test]
1248    fn nearest_match_rule() {
1249        let links = [
1250            "_id",
1251            "_path",
1252            "_doc_id",
1253            "_block_id",
1254            "_via_id",
1255            "_dst_id",
1256            "_dst_path",
1257            "_uri",
1258            "_field",
1259            "_dst_kind",
1260            "_predicate",
1261            "_provenance",
1262            "_anchor",
1263        ];
1264        let n = |name: &str, c: &[&str]| nearest(name, c.iter().copied());
1265        // the one certain hint: a name that is a candidate with the engine
1266        // prefix added or removed wins outright, whatever its length
1267        assert_eq!(n("id", &links).as_deref(), Some("_id"));
1268        assert_eq!(n("dst_path", &links).as_deref(), Some("_dst_path"));
1269        assert_eq!(n("title", &["titel", "_title"]).as_deref(), Some("_title"));
1270        assert_eq!(n("_kind", &["kinds", "kind"]).as_deref(), Some("kind"));
1271        assert_eq!(n("dst_pth", &links).as_deref(), Some("_dst_path"));
1272        assert_eq!(n("target", &links), None);
1273        assert_eq!(n("DST", &links).as_deref(), Some("_dst_id"));
1274        assert_eq!(n("via", &links).as_deref(), Some("_via_id"));
1275        assert_eq!(n("x", &links), None);
1276        assert_eq!(n("in", &["_id", "_in_links", "_out_links"]), None);
1277        assert_eq!(
1278            n("tag", &["_id", "_path", "_tags", "_title"]).as_deref(),
1279            Some("_tags")
1280        );
1281        assert_eq!(
1282            n(
1283                "preview",
1284                &[
1285                    "_id",
1286                    "_path",
1287                    "_type",
1288                    "_text",
1289                    "_attrs",
1290                    "_depth",
1291                    "_ordinal",
1292                    "_children"
1293                ]
1294            ),
1295            None
1296        );
1297        assert_eq!(n("predicat", &links).as_deref(), Some("_predicate"));
1298        // prefix-blind: the bare authored key wins a tie by length
1299        assert_eq!(n("titel", &["_title", "title"]).as_deref(), Some("title"));
1300        assert_eq!(n("commited", &["_commit"]).as_deref(), Some("_commit"));
1301        assert_eq!(n("abc", &["acb", "abc_x", "abd"]).as_deref(), Some("abd"));
1302        assert_eq!(n("abcd", &["abxy", "abcd_x"]).as_deref(), Some("abcd_x"));
1303        assert_eq!(n("abc", &["abcd", "xabc"]).as_deref(), Some("abcd"));
1304    }
1305}