Skip to main content

omgbase_surface/
names.rs

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