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)]
243enum RowKind {
244 Root,
245 Of(Target),
246 Group(Option<Target>),
247}
248
249type ByDepth = HashMap<usize, Option<RowKind>>;
250
251fn 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 Expr::Call {
279 recv: None, name, ..
280 } if name == "refs" => Some(Target::Docs),
281 Expr::Op(op) => kind_of_op(op, on, read_depth, by_depth),
283 _ => None, }
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
303fn 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
312fn 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
325fn 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 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
359fn 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 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#[must_use]
385pub fn kind_of_scope(steps: &[ReferenceStep<'_>]) -> Option<Target> {
386 if steps.is_empty() {
387 return None; }
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 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
414struct Verdict {
417 target: Target,
418 name: String,
420 closed: bool,
421 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 fn judge(&self, target: Target, path: &[&str]) -> Result<Option<Verdict>> {
438 let (head, rest) = path.split_first().expect("a name has a head");
439 if attributes(target).contains(head) {
441 return Ok(None);
442 }
443 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 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); }
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 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 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 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#[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#[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
662pub 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; }
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 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 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 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 "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 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}