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