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 => &[
90 ("doc", Target::Docs),
91 ("dst", Target::Docs),
92 ("block", Target::Blocks),
93 ],
94 Target::Revisions => &[("doc", Target::Docs)],
96 }
97}
98
99fn lookup(table: &[(&str, Target)], name: &str) -> Option<Target> {
100 table.iter().find(|(n, _)| *n == name).map(|(_, t)| *t)
101}
102
103fn closed(t: Target) -> bool {
106 matches!(t, Target::Links | Target::Revisions)
107}
108
109fn noun(t: Target) -> &'static str {
110 match t {
111 Target::Docs => "document",
112 Target::Blocks => "block",
113 Target::Nodes => "node",
114 Target::Links => "link",
115 Target::Revisions => "revision",
116 }
117}
118
119#[derive(Debug)]
123pub struct Vocabulary {
124 pub present: bool,
126 keys: HashSet<String>,
128 pub list: Vec<String>,
130}
131
132struct RepoCache {
133 head: i64,
134 vocab: HashMap<Target, Rc<Vocabulary>>,
135}
136
137#[derive(Default)]
140pub struct VocabularyCache {
141 repos: RefCell<HashMap<String, RepoCache>>,
142}
143
144impl std::fmt::Debug for VocabularyCache {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("VocabularyCache").finish_non_exhaustive()
147 }
148}
149
150fn head_seq(store: &Store, repo_id: &str) -> Result<i64> {
151 Ok(store.conn().query_row(
152 "SELECT COALESCE(MAX(seq), 0) AS seq FROM commits WHERE repo_id = ?",
153 params![repo_id],
154 |r| r.get::<_, i64>(0),
155 )?)
156}
157
158fn authored_keys(store: &Store, repo_id: &str, target: Target) -> Result<Vec<String>> {
163 let sql = match target {
164 Target::Docs => "SELECT DISTINCT key FROM properties WHERE repo_id = ? AND deleted_commit IS NULL AND source IN ('frontmatter', 'inline') ORDER BY key".to_owned(),
165 Target::Blocks | Target::Nodes => {
166 let alias = if target == Target::Blocks { "b" } else { "n" };
167 format!(
168 "SELECT DISTINCT j.key AS key FROM {}, json_each({alias}.attrs) j WHERE {} ORDER BY key",
169 planner::from_clause(target),
170 planner::guards(target)
171 )
172 }
173 Target::Links | Target::Revisions => return Ok(Vec::new()),
174 };
175 let mut stmt = store.conn().prepare(&sql)?;
176 let rows = stmt.query_map(params![repo_id], |r| r.get::<_, String>(0))?;
177 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
178}
179
180fn present(store: &Store, repo_id: &str, target: Target) -> Result<bool> {
181 if target == Target::Revisions {
183 return Ok(true);
184 }
185 let sql = format!(
186 "SELECT 1 FROM {} WHERE {} LIMIT 1",
187 planner::from_clause(target),
188 planner::guards(target)
189 );
190 let mut stmt = store.conn().prepare(&sql)?;
191 Ok(stmt.exists(params![repo_id])?)
192}
193
194pub fn vocabulary(store: &Store, repo_id: &str, target: Target) -> Result<(bool, Vec<String>)> {
197 let cache = VocabularyCache::default();
198 let v = cache.vocabulary_of(store, repo_id, target)?;
199 Ok((v.present, v.list.clone()))
200}
201
202impl VocabularyCache {
203 pub fn vocabulary_of(
205 &self,
206 store: &Store,
207 repo_id: &str,
208 target: Target,
209 ) -> Result<Rc<Vocabulary>> {
210 let head = head_seq(store, repo_id)?;
211 let mut repos = self.repos.borrow_mut();
212 let c = repos.entry(repo_id.to_owned()).or_insert(RepoCache {
213 head,
214 vocab: HashMap::new(),
215 });
216 if c.head != head {
217 c.head = head;
218 c.vocab.clear();
219 }
220 if let Some(v) = c.vocab.get(&target) {
221 return Ok(Rc::clone(v));
222 }
223 let list = authored_keys(store, repo_id, target)?;
224 let mut keys = HashSet::new();
225 for k in &list {
226 let segs: Vec<&str> = k.split('.').collect();
227 for i in 1..=segs.len() {
228 keys.insert(segs[..i].join("."));
229 }
230 }
231 let present = !list.is_empty() || present(store, repo_id, target)?;
232 let v = Rc::new(Vocabulary {
233 present,
234 keys,
235 list,
236 });
237 c.vocab.insert(target, Rc::clone(&v));
238 Ok(v)
239 }
240}
241
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
248enum RowKind {
249 Root,
250 Of(Target),
251 Group(Option<Target>),
252}
253
254type ByDepth = HashMap<usize, Option<RowKind>>;
255
256fn kind_of_expr(
259 e: &Expr,
260 on: Option<RowKind>,
261 read_depth: usize,
262 by_depth: &ByDepth,
263) -> Option<Target> {
264 match e {
265 Expr::Ident { name, .. } => kind_of_path(on, &[name]),
266 Expr::Outer { levels, name, .. } => {
267 kind_of_path(outer_base(read_depth, *levels, by_depth), &[name])
268 }
269 Expr::Member { .. } => {
270 let (head, rest) = dotted(e)?;
271 let (base, name) = match head {
272 Expr::Ident { name, .. } => (on, name.as_str()),
273 Expr::Outer { levels, name, .. } => {
274 (outer_base(read_depth, *levels, by_depth), name.as_str())
275 }
276 _ => return None,
277 };
278 let mut path = vec![name];
279 path.extend(rest);
280 kind_of_path(base, &path)
281 }
282 Expr::Call {
284 recv: None, name, ..
285 } if name == "refs" => Some(Target::Docs),
286 Expr::Op(op) => kind_of_op(op, on, read_depth, by_depth),
288 _ => None, }
290}
291
292fn kind_of_op(
293 op: &OpNode,
294 on: Option<RowKind>,
295 read_depth: usize,
296 by_depth: &ByDepth,
297) -> Option<Target> {
298 if op.sub.select.iter().any(|it| it.name() != "$key") || op.sub.values {
299 return None;
300 }
301 let mut k = kind_of_expr(&op.receiver, on, read_depth, by_depth);
302 for f in &op.sub.from {
303 k = k.and_then(|k| kind_of_expr(f, Some(RowKind::Of(k)), read_depth + 1, by_depth));
304 }
305 k
306}
307
308fn outer_base(read_depth: usize, levels: usize, by_depth: &ByDepth) -> Option<RowKind> {
310 if levels >= read_depth {
311 Some(RowKind::Root)
312 } else {
313 by_depth.get(&(read_depth - levels)).copied().flatten()
314 }
315}
316
317fn dotted(e: &Expr) -> Option<(&Expr, Vec<&str>)> {
319 match e {
320 Expr::Ident { .. } | Expr::Outer { .. } => Some((e, Vec::new())),
321 Expr::Member { recv, name, .. } => {
322 let (head, mut rest) = dotted(recv)?;
323 rest.push(name);
324 Some((head, rest))
325 }
326 _ => None,
327 }
328}
329
330fn kind_of_path(on: Option<RowKind>, path: &[&str]) -> Option<Target> {
332 let mut cur = on;
333 for (i, seg) in path.iter().enumerate() {
334 match cur? {
335 RowKind::Root => {
336 if *seg == "$it" {
337 continue;
338 }
339 cur = Some(RowKind::Of(Target::parse(seg)?));
340 }
341 RowKind::Of(t) => {
342 if *seg == "$it" && i == 0 {
343 continue;
344 }
345 let next = lookup(handles(t), seg).or_else(|| lookup(relations(t), seg))?;
346 cur = Some(RowKind::Of(next));
347 }
348 RowKind::Group(k) => {
351 if *seg != "rows" || i != 0 {
352 return None;
353 }
354 cur = k.map(RowKind::Of);
355 }
356 }
357 }
358 match cur? {
359 RowKind::Root | RowKind::Group(_) => None,
360 RowKind::Of(t) => Some(t),
361 }
362}
363
364fn kind_of_frontier(
367 seed: Option<Target>,
368 f: &Follow,
369 read_depth: usize,
370 by_depth: &ByDepth,
371) -> Option<Target> {
372 let seed = seed?;
373 let on = Some(RowKind::Of(seed));
374 for d in &f.destinations {
376 let k = match d {
377 FollowDestination::Relation(e) => kind_of_expr(e, on, read_depth, by_depth),
378 FollowDestination::Block(op) => kind_of_op(op, on, read_depth, by_depth),
379 };
380 if k != Some(seed) {
381 return None;
382 }
383 }
384 Some(seed)
385}
386
387#[must_use]
390pub fn kind_of_scope(steps: &[ReferenceStep<'_>]) -> Option<Target> {
391 if steps.is_empty() {
392 return None; }
394 let mut by_depth: ByDepth = HashMap::from([(0, Some(RowKind::Root))]);
395 let mut cur: Option<RowKind> = Some(RowKind::Root);
396 let mut read_depth = 0;
397 for step in steps {
398 let rows_kind = match cur {
399 Some(RowKind::Of(t)) => Some(t),
400 _ => None,
401 };
402 cur = match step.expr {
403 StepExpr::Follow(f) => {
404 kind_of_frontier(rows_kind, f, read_depth, &by_depth).map(RowKind::Of)
405 }
406 StepExpr::GroupBy(_) => Some(RowKind::Group(rows_kind)),
408 StepExpr::Expr(e) => kind_of_expr(e, cur, read_depth, &by_depth).map(RowKind::Of),
409 };
410 by_depth.insert(step.depth, cur);
411 read_depth = step.depth;
412 }
413 match cur? {
414 RowKind::Root | RowKind::Group(_) => None,
415 RowKind::Of(t) => Some(t),
416 }
417}
418
419struct Verdict {
422 target: Target,
423 name: String,
425 closed: bool,
426 handle: Option<String>,
428}
429
430struct Judge<'a> {
431 store: &'a Store,
432 repo_id: &'a str,
433 cache: &'a VocabularyCache,
434}
435
436impl Judge<'_> {
437 fn vocabulary(&self, target: Target) -> Result<Rc<Vocabulary>> {
438 self.cache.vocabulary_of(self.store, self.repo_id, target)
439 }
440
441 fn judge(&self, target: Target, path: &[&str]) -> Result<Option<Verdict>> {
443 let (head, rest) = path.split_first().expect("a name has a head");
444 if attributes(target).contains(head) {
446 return Ok(None);
447 }
448 if target == Target::Docs && SOURCE_HANDLES.contains(head) {
450 return if rest.is_empty() {
451 Ok(None)
452 } else {
453 self.judge_authored(Target::Docs, rest, Some(head))
454 };
455 }
456 if let Some(via) = lookup(handles(target), head).or_else(|| lookup(relations(target), head))
457 {
458 return if rest.is_empty() {
459 Ok(None)
460 } else {
461 self.judge(via, rest)
462 };
463 }
464 if Target::parse(head).is_some() {
466 return Ok(None);
467 }
468 self.judge_authored(target, path, None)
469 }
470
471 fn judge_authored(
472 &self,
473 target: Target,
474 path: &[&str],
475 handle: Option<&str>,
476 ) -> Result<Option<Verdict>> {
477 let name = path.join(".");
478 if closed(target) {
479 return Ok(Some(Verdict {
480 target,
481 name,
482 closed: true,
483 handle: None,
484 }));
485 }
486 let v = self.vocabulary(target)?;
487 if !v.present {
488 return Ok(None); }
490 if v.keys.contains(&name) {
491 return Ok(None);
492 }
493 Ok(Some(Verdict {
494 target,
495 name,
496 closed: false,
497 handle: handle.map(str::to_owned),
498 }))
499 }
500
501 fn candidates(&self, target: Target) -> Result<Vec<String>> {
504 let mut out: Vec<String> = attributes(target).iter().map(|s| (*s).to_owned()).collect();
505 out.extend(handles(target).iter().map(|(n, _)| (*n).to_owned()));
506 if target == Target::Docs {
507 out.extend(SOURCE_HANDLES.iter().map(|s| (*s).to_owned()));
508 }
509 out.extend(relations(target).iter().map(|(n, _)| (*n).to_owned()));
510 if !closed(target) {
511 out.extend(self.vocabulary(target)?.list.iter().cloned());
512 }
513 Ok(out)
514 }
515
516 fn field_error_message(&self, target: Target, name: &str) -> Result<String> {
518 Ok(format!(
519 "`{name}` is not a field of {} (fields: {}){}",
520 target.as_str(),
521 attributes(target).join(", "),
522 suggestion(name, &self.candidates(target)?)
523 ))
524 }
525
526 fn property_hint_message(
533 &self,
534 target: Target,
535 name: &str,
536 handle: Option<&str>,
537 ) -> Result<String> {
538 if let Some(h) = handle {
539 return Ok(format!(
540 "no {} in this repository carries a property `{name}` (`{h}.{name}`){}",
541 noun(target),
542 suggestion(name, &self.vocabulary(target)?.list)
543 ));
544 }
545 Ok(format!(
546 "`{name}` is not a field of {} and no {} in this repository carries a property by that name{}",
547 target.as_str(),
548 noun(target),
549 suggestion(name, &self.candidates(target)?)
550 ))
551 }
552}
553
554#[must_use]
559pub fn edit_distance(a: &str, b: &str) -> usize {
560 let s: Vec<char> = a.chars().collect();
561 let t: Vec<char> = b.chars().collect();
562 let (m, n) = (s.len(), t.len());
563 let mut d = vec![vec![0usize; n + 1]; m + 1];
564 for (i, row) in d.iter_mut().enumerate() {
565 row[0] = i;
566 }
567 for (j, cell) in d[0].iter_mut().enumerate() {
568 *cell = j;
569 }
570 for i in 1..=m {
571 for j in 1..=n {
572 let cost = usize::from(s[i - 1] != t[j - 1]);
573 let mut v = (d[i - 1][j] + 1)
574 .min(d[i][j - 1] + 1)
575 .min(d[i - 1][j - 1] + cost);
576 if i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1] {
577 v = v.min(d[i - 2][j - 2] + 1);
578 }
579 d[i][j] = v;
580 }
581 }
582 d[m][n]
583}
584
585#[must_use]
595pub fn nearest<'c>(name: &str, candidates: impl IntoIterator<Item = &'c str>) -> Option<String> {
596 let needle = name.to_lowercase();
597 let needle_len = needle.chars().count();
598 if needle_len < 3 {
599 return None;
600 }
601 struct Best {
602 c: String,
603 d: usize,
604 sub: bool,
605 }
606 let rank = |d: usize, sub: bool| -> u8 {
607 if d == 1 {
608 0
609 } else if sub {
610 1
611 } else {
612 2
613 }
614 };
615 let mut best: Option<Best> = None;
616 let mut seen: HashSet<&str> = HashSet::new();
617 for c in candidates {
618 if !seen.insert(c) {
619 continue;
620 }
621 let hay = c.to_lowercase();
622 if hay == needle {
623 continue;
624 }
625 let d = edit_distance(&needle, &hay);
626 let hay_len = hay.chars().count();
627 let (shorter, shorter_len, longer) = if needle_len <= hay_len {
628 (needle.as_str(), needle_len, hay.as_str())
629 } else {
630 (hay.as_str(), hay_len, needle.as_str())
631 };
632 let sub = shorter_len >= 3 && longer.contains(shorter);
633 if !(d <= 2 || sub) {
634 continue;
635 }
636 let better = match &best {
637 None => true,
638 Some(b) => {
639 let (r, rb) = (rank(d, sub), rank(b.d, b.sub));
640 r < rb
641 || (r == rb
642 && (d < b.d
643 || (d == b.d
644 && (c.chars().count() < b.c.chars().count()
645 || (c.chars().count() == b.c.chars().count()
646 && c < b.c.as_str())))))
647 }
648 };
649 if better {
650 best = Some(Best {
651 c: c.to_owned(),
652 d,
653 sub,
654 });
655 }
656 }
657 best.map(|b| b.c)
658}
659
660fn suggestion(name: &str, candidates: &[String]) -> String {
661 match nearest(name, candidates.iter().map(String::as_str)) {
662 Some(c) => format!("; did you mean `{c}`?"),
663 None => String::new(),
664 }
665}
666
667pub fn check_names(
675 store: &Store,
676 repo_id: &str,
677 raw: &Query,
678 mode: UnknownNames,
679 cache: &VocabularyCache,
680) -> Result<Vec<String>> {
681 let judge = Judge {
682 store,
683 repo_id,
684 cache,
685 };
686 let mut hints: Vec<String> = Vec::new();
687 let mut seen: HashSet<String> = HashSet::new();
688 for r in references(raw) {
689 if r.scope.steps.is_empty() {
690 continue; }
692 let Some(kind) = kind_of_scope(&r.scope.steps) else {
693 continue;
694 };
695 let path: Vec<&str> = r.name.split('.').collect();
696 let Some(verdict) = judge.judge(kind, &path)? else {
697 continue;
698 };
699 if verdict.closed {
700 return Err(SurfaceError::filter_invalid(
701 judge.field_error_message(verdict.target, &verdict.name)?,
702 "OQX",
703 ));
704 }
705 let message = judge.property_hint_message(
706 verdict.target,
707 &verdict.name,
708 verdict.handle.as_deref(),
709 )?;
710 if mode == UnknownNames::Refuse {
711 return Err(SurfaceError::filter_invalid(message, "OQX"));
712 }
713 if seen.insert(message.clone()) {
714 hints.push(message);
715 }
716 }
717 Ok(hints)
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use omgbase_reconcile::Config;
724 use omgbase_store::BatchItem;
725
726 const TS: &str = "2026-10-13T00:00:00.000Z";
727
728 fn repo_with(docs: &[(&str, &str)]) -> (Store, String) {
729 let mut store = Store::open_in_memory().unwrap();
730 let repo = store.create_repo("names").unwrap();
731 if !docs.is_empty() {
732 let items: Vec<BatchItem> = docs
733 .iter()
734 .map(|(p, s)| BatchItem::observed(p, s))
735 .collect();
736 store
737 .observe_batch(&repo, &items, TS, &Config::default())
738 .unwrap();
739 }
740 (store, repo)
741 }
742
743 fn fixture() -> (Store, String) {
744 repo_with(&[
745 (
746 "a.md",
747 "---\nkind: x\nmeta:\n owner: bb\n---\n\n# A\n\n- [ ] a task\n\n```js\nlet x;\n```\n",
748 ),
749 ("b.md", "---\nkind: y\n---\n\n# B\n\nstage:: calcination\n"),
750 ])
751 }
752
753 fn hints(store: &Store, repo: &str, q: &str) -> Vec<String> {
754 let raw = oqx::parse_string(q).unwrap();
755 check_names(
756 store,
757 repo,
758 &raw,
759 UnknownNames::Hint,
760 &VocabularyCache::default(),
761 )
762 .unwrap()
763 }
764
765 fn refusal(store: &Store, repo: &str, q: &str, mode: UnknownNames) -> SurfaceError {
766 let raw = oqx::parse_string(q).unwrap();
767 check_names(store, repo, &raw, mode, &VocabularyCache::default()).unwrap_err()
768 }
769
770 #[test]
771 fn vocabulary_per_kind() {
772 let (store, repo) = fixture();
773 assert_eq!(
774 vocabulary(&store, &repo, Target::Docs).unwrap(),
775 (
776 true,
777 vec![
778 "kind".to_owned(),
779 "meta.owner".to_owned(),
780 "stage".to_owned()
781 ]
782 )
783 );
784 assert_eq!(
785 vocabulary(&store, &repo, Target::Blocks).unwrap(),
786 (
787 true,
788 ["checked", "lang", "level", "ordered"]
789 .map(str::to_owned)
790 .to_vec()
791 )
792 );
793 assert_eq!(
794 vocabulary(&store, &repo, Target::Nodes).unwrap(),
795 (
796 true,
797 ["checked", "first_ordinal", "last_ordinal", "level"]
798 .map(str::to_owned)
799 .to_vec()
800 )
801 );
802 assert_eq!(
803 vocabulary(&store, &repo, Target::Links).unwrap(),
804 (false, vec![])
805 );
806 }
807
808 #[test]
809 fn cache_refreshes_on_a_new_commit() {
810 let (mut store, repo) = repo_with(&[("a.md", "---\nkind: x\n---\n\n# A\n")]);
811 let cache = VocabularyCache::default();
812 assert_eq!(
813 cache
814 .vocabulary_of(&store, &repo, Target::Docs)
815 .unwrap()
816 .list,
817 ["kind"]
818 );
819 store
820 .observe_batch(
821 &repo,
822 &[BatchItem::observed("b.md", "---\ncolor: red\n---\n\n# B\n")],
823 TS,
824 &Config::default(),
825 )
826 .unwrap();
827 assert_eq!(
828 cache
829 .vocabulary_of(&store, &repo, Target::Docs)
830 .unwrap()
831 .list,
832 ["color", "kind"]
833 );
834 }
835
836 #[test]
837 fn tiers() {
838 let (store, repo) = fixture();
839 assert!(hints(&store, &repo, "select title, tags, path, blocks, d: doc.id from docs where nodes exists { where kind == \"md:task\" }").is_empty());
841 assert!(
842 hints(
843 &store,
844 &repo,
845 "select type, text, attrs, children, section.level, doc.title from blocks"
846 )
847 .is_empty()
848 );
849 assert!(
850 hints(
851 &store,
852 &repo,
853 "select dst_path, s: doc.title, t: dst.title from links"
854 )
855 .is_empty()
856 );
857 assert!(
859 hints(
860 &store,
861 &repo,
862 "select stage, kind from docs where meta.owner == \"bb\""
863 )
864 .is_empty()
865 );
866 assert!(hints(&store, &repo, "select meta from docs").is_empty());
867 assert!(
868 hints(
869 &store,
870 &repo,
871 "select checked from blocks where lang == \"js\""
872 )
873 .is_empty()
874 );
875 assert_eq!(
877 hints(
878 &store,
879 &repo,
880 "select stag, kynd from docs where stag == \"x\" order by kynd"
881 ),
882 [
883 "`stag` is not a field of docs and no document in this repository carries a property by that name; did you mean `stage`?",
884 "`kynd` is not a field of docs and no document in this repository carries a property by that name; did you mean `kind`?",
885 ]
886 );
887 assert_eq!(
888 hints(&store, &repo, "select meta.ownr, stage.x from docs"),
889 [
890 "`meta.ownr` is not a field of docs and no document in this repository carries a property by that name; did you mean `meta.owner`?",
891 "`stage.x` is not a field of docs and no document in this repository carries a property by that name; did you mean `stage`?",
892 ]
893 );
894 assert_eq!(
895 hints(
896 &store,
897 &repo,
898 "select p: data.path, k: frontmatter.kynd from docs"
899 ),
900 [
901 "no document in this repository carries a property `path` (`data.path`)",
902 "no document in this repository carries a property `kynd` (`frontmatter.kynd`); did you mean `kind`?",
903 ]
904 );
905 }
906
907 #[test]
908 fn links_are_closed_and_dst_reaches_docs() {
909 let (store, repo) = fixture();
910 let err = "`target` is not a field of links (fields: id, path, doc_id, block_id, via_id, dst_id, dst_path, uri, field, dst_kind, predicate, provenance, anchor)";
911 let e = refusal(
912 &store,
913 &repo,
914 "select target from links",
915 UnknownNames::Hint,
916 );
917 assert_eq!(
918 (e.code.as_str(), e.message.as_str()),
919 ("filter_invalid", err)
920 );
921 let e = refusal(
922 &store,
923 &repo,
924 "from docs where out_links exists { where target == \"x\" }",
925 UnknownNames::Hint,
926 );
927 assert_eq!(e.message, err);
928 assert_eq!(
929 hints(&store, &repo, "select dst.kynd from links"),
930 [
931 "`kynd` is not a field of docs and no document in this repository carries a property by that name; did you mean `kind`?"
932 ]
933 );
934 assert!(
938 hints(
939 &store,
940 &repo,
941 "select block.text, block.type, block.doc.path from links"
942 )
943 .is_empty()
944 );
945 assert_eq!(
946 hints(&store, &repo, "select block.typ from links"),
947 [
948 "`typ` is not a field of blocks and no block in this repository carries a property by that name; did you mean `type`?"
949 ]
950 );
951 }
952
953 #[test]
954 fn refuse_mode_is_the_hint_text_as_filter_invalid() {
955 let (store, repo) = fixture();
956 let e = refusal(&store, &repo, "select stag from docs", UnknownNames::Refuse);
957 assert_eq!(e.code, "filter_invalid");
958 assert_eq!(
959 e.message,
960 "`stag` is not a field of docs and no document in this repository carries a property by that name; did you mean `stage`?"
961 );
962 assert_eq!(e.data.unwrap()["hint"], "OQX");
963 }
964
965 #[test]
966 fn what_is_not_judged() {
967 let (store, repo) = fixture();
968 for q in [
969 "select s: stage, n: s from docs where s == \"x\"",
970 "select stage, n: count(), kynd: collect(kind) from docs group by stage having n > 1 order by count() desc",
973 "select kind, open: rows count { where nodes exists { where kind == \"md:task\" } } from docs group by kind",
974 "select p: path, open from docs where nodes collect { ^open: value where kind == \"md:task\" }",
975 "select $key: id from docs where $depth > 0 and has(stage) and size(list(kind)) > 0",
976 "select a: attrs.whatever from blocks",
977 "select n: tags.size() from docs",
978 "select t: (blocks, nodes) collect { select kynd } from docs",
979 ] {
980 assert!(hints(&store, &repo, q).is_empty(), "{q}");
981 }
982 assert_eq!(
983 hints(
984 &store,
985 &repo,
986 "select r: refs(stage) collect { select kynd } from docs"
987 )
988 .len(),
989 1
990 );
991 assert_eq!(
993 hints(
994 &store,
995 &repo,
996 "select stage, t: rows collect { select kynd } from docs group by stage"
997 ),
998 [
999 "`kynd` is not a field of docs and no document in this repository carries a property by that name; did you mean `kind`?"
1000 ]
1001 );
1002 assert_eq!(
1003 hints(
1004 &store,
1005 &repo,
1006 "select lang, n: rows count { where chekced } from blocks group by lang"
1007 ),
1008 [
1009 "`chekced` is not a field of blocks and no block in this repository carries a property by that name; did you mean `checked`?"
1010 ]
1011 );
1012 }
1013
1014 #[test]
1015 fn an_empty_repository_has_no_vocabulary() {
1016 let (store, repo) = repo_with(&[]);
1017 assert!(hints(&store, &repo, "select titel from docs where layr == \"x\"").is_empty());
1018 assert!(hints(&store, &repo, "select preview from blocks").is_empty());
1019 assert!(
1020 refusal(
1021 &store,
1022 &repo,
1023 "select target from links",
1024 UnknownNames::Hint
1025 )
1026 .message
1027 .contains("is not a field of links")
1028 );
1029 }
1030
1031 #[test]
1032 fn edit_distance_is_optimal_string_alignment() {
1033 assert_eq!(edit_distance("title", "titel"), 1);
1034 assert_eq!(edit_distance("stag", "stage"), 1);
1035 assert_eq!(edit_distance("value", "text"), 5);
1036 assert_eq!(edit_distance("via", "via_id"), 3);
1037 assert_eq!(edit_distance("x", "id"), 2);
1038 }
1039
1040 #[test]
1041 fn nearest_match_rule() {
1042 let links = [
1043 "id",
1044 "path",
1045 "doc_id",
1046 "block_id",
1047 "via_id",
1048 "dst_id",
1049 "dst_path",
1050 "uri",
1051 "field",
1052 "dst_kind",
1053 "predicate",
1054 "provenance",
1055 "anchor",
1056 ];
1057 let n = |name: &str, c: &[&str]| nearest(name, c.iter().copied());
1058 assert_eq!(n("dst_pth", &links).as_deref(), Some("dst_path"));
1059 assert_eq!(n("target", &links), None);
1060 assert_eq!(n("DST", &links).as_deref(), Some("dst_id"));
1061 assert_eq!(n("via", &links).as_deref(), Some("via_id"));
1062 assert_eq!(n("x", &links), None);
1063 assert_eq!(n("in", &["id", "in_links", "out_links"]), None);
1064 assert_eq!(
1065 n("tag", &["id", "path", "tags", "title"]).as_deref(),
1066 Some("tags")
1067 );
1068 assert_eq!(
1069 n(
1070 "preview",
1071 &[
1072 "id", "path", "type", "text", "attrs", "depth", "ordinal", "children"
1073 ]
1074 ),
1075 None
1076 );
1077 assert_eq!(n("predicat", &links).as_deref(), Some("predicate"));
1078 assert_eq!(n("abc", &["acb", "abc_x", "abd"]).as_deref(), Some("abd"));
1079 assert_eq!(n("abcd", &["abxy", "abcd_x"]).as_deref(), Some("abcd_x"));
1080 assert_eq!(n("abc", &["abcd", "xabc"]).as_deref(), Some("abcd"));
1081 }
1082}