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