1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum UnknownNames {
47 Hint,
48 Refuse,
49}
50
51#[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#[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 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
98fn 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#[derive(Debug)]
118pub struct Vocabulary {
119 pub present: bool,
121 keys: HashSet<String>,
123 pub list: Vec<String>,
125}
126
127struct RepoCache {
128 head: i64,
129 vocab: HashMap<Target, Rc<Vocabulary>>,
130}
131
132#[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
153fn 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 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
189pub 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
240enum RowKind {
241 Root,
242 Of(Target),
243}
244
245type ByDepth = HashMap<usize, Option<RowKind>>;
246
247fn 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 Expr::Call {
275 recv: None, name, ..
276 } if name == "refs" => Some(Target::Docs),
277 Expr::Op(op) => kind_of_op(op, on, read_depth, by_depth),
279 _ => None, }
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
299fn 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
308fn 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
321fn 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
347fn 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 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#[must_use]
373pub fn kind_of_scope(steps: &[ReferenceStep<'_>]) -> Option<Target> {
374 if steps.is_empty() {
375 return None; }
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
401struct Verdict {
404 target: Target,
405 name: String,
407 closed: bool,
408 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 fn judge(&self, target: Target, path: &[&str]) -> Result<Option<Verdict>> {
425 let (head, rest) = path.split_first().expect("a name has a head");
426 if attributes(target).contains(head) {
428 return Ok(None);
429 }
430 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 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); }
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 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 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 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#[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#[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
649pub 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; }
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 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 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 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}