1use std::cell::RefCell;
39use std::collections::{HashMap, HashSet};
40use std::rc::Rc;
41
42use omgbase_store::Store;
43use oqx::{Expr, Follow, FollowDestination, OpNode, Query, ReferenceStep, StepExpr, references};
44use rusqlite::params;
45
46use crate::context::{SOURCE_HANDLES, Target, attributes, reach_throughs};
47use crate::error::{Result, SurfaceError};
48use crate::planner;
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum UnknownNames {
54 Hint,
55 Refuse,
56}
57
58#[must_use]
60pub fn relations(t: Target) -> &'static [(&'static str, Target)] {
61 match t {
62 Target::Docs => &[
63 ("_blocks", Target::Blocks),
64 ("_nodes", Target::Nodes),
65 ("_out_links", Target::Links),
66 ("_in_links", Target::Links),
67 ("_revisions", Target::Revisions),
68 ],
69 Target::Blocks => &[
70 ("_children", Target::Blocks),
71 ("_nodes", Target::Nodes),
72 ("_out_links", Target::Links),
73 ("_section", Target::Nodes),
74 ],
75 Target::Nodes => &[
76 ("_blocks", Target::Blocks),
77 ("_subsections", Target::Nodes),
78 ("_children", Target::Nodes),
79 ],
80 Target::Links | Target::Revisions => &[],
81 }
82}
83
84#[must_use]
87pub fn handles(t: Target) -> &'static [(&'static str, Target)] {
88 match t {
89 Target::Docs => &[],
90 Target::Blocks => &[("_doc", Target::Docs)],
91 Target::Nodes => &[("_doc", Target::Docs), ("_block", Target::Blocks)],
92 Target::Links => &[
94 ("_doc", Target::Docs),
95 ("_dst", Target::Docs),
96 ("_block", Target::Blocks),
97 ],
98 Target::Revisions => &[("_doc", Target::Docs)],
100 }
101}
102
103#[must_use]
107pub fn engine_names(t: Target) -> Vec<&'static str> {
108 let mut out: Vec<&'static str> = attributes(t).to_vec();
109 out.extend(reach_throughs(t));
110 if t == Target::Docs {
111 out.extend(SOURCE_HANDLES);
112 }
113 out.extend(relations(t).iter().map(|(n, _)| *n));
114 out
115}
116
117fn lookup(table: &[(&str, Target)], name: &str) -> Option<Target> {
118 table.iter().find(|(n, _)| *n == name).map(|(_, t)| *t)
119}
120
121fn closed(t: Target) -> bool {
124 matches!(t, Target::Links | Target::Revisions)
125}
126
127fn noun(t: Target) -> &'static str {
128 match t {
129 Target::Docs => "document",
130 Target::Blocks => "block",
131 Target::Nodes => "node",
132 Target::Links => "link",
133 Target::Revisions => "revision",
134 }
135}
136
137#[derive(Debug)]
141pub struct Vocabulary {
142 pub present: bool,
144 keys: HashSet<String>,
146 pub list: Vec<String>,
148}
149
150struct RepoCache {
151 head: i64,
152 vocab: HashMap<Target, Rc<Vocabulary>>,
153}
154
155#[derive(Default)]
158pub struct VocabularyCache {
159 repos: RefCell<HashMap<String, RepoCache>>,
160}
161
162impl std::fmt::Debug for VocabularyCache {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.debug_struct("VocabularyCache").finish_non_exhaustive()
165 }
166}
167
168fn head_seq(store: &Store, repo_id: &str) -> Result<i64> {
169 Ok(store.conn().query_row(
170 "SELECT COALESCE(MAX(seq), 0) AS seq FROM commits WHERE repo_id = ?",
171 params![repo_id],
172 |r| r.get::<_, i64>(0),
173 )?)
174}
175
176fn authored_keys(store: &Store, repo_id: &str, target: Target) -> Result<Vec<String>> {
181 let sql = match target {
182 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(),
183 Target::Blocks | Target::Nodes => {
184 let alias = if target == Target::Blocks { "b" } else { "n" };
185 format!(
186 "SELECT DISTINCT j.key AS key FROM {}, json_each({alias}.attrs) j WHERE {} ORDER BY key",
187 planner::from_clause(target),
188 planner::guards(target)
189 )
190 }
191 Target::Links | Target::Revisions => return Ok(Vec::new()),
192 };
193 let mut stmt = store.conn().prepare(&sql)?;
194 let rows = stmt.query_map(params![repo_id], |r| r.get::<_, String>(0))?;
195 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
196}
197
198fn present(store: &Store, repo_id: &str, target: Target) -> Result<bool> {
199 if target == Target::Revisions {
201 return Ok(true);
202 }
203 let sql = format!(
204 "SELECT 1 FROM {} WHERE {} LIMIT 1",
205 planner::from_clause(target),
206 planner::guards(target)
207 );
208 let mut stmt = store.conn().prepare(&sql)?;
209 Ok(stmt.exists(params![repo_id])?)
210}
211
212pub fn vocabulary(store: &Store, repo_id: &str, target: Target) -> Result<(bool, Vec<String>)> {
215 let cache = VocabularyCache::default();
216 let v = cache.vocabulary_of(store, repo_id, target)?;
217 Ok((v.present, v.list.clone()))
218}
219
220impl VocabularyCache {
221 pub fn vocabulary_of(
223 &self,
224 store: &Store,
225 repo_id: &str,
226 target: Target,
227 ) -> Result<Rc<Vocabulary>> {
228 let head = head_seq(store, repo_id)?;
229 let mut repos = self.repos.borrow_mut();
230 let c = repos.entry(repo_id.to_owned()).or_insert(RepoCache {
231 head,
232 vocab: HashMap::new(),
233 });
234 if c.head != head {
235 c.head = head;
236 c.vocab.clear();
237 }
238 if let Some(v) = c.vocab.get(&target) {
239 return Ok(Rc::clone(v));
240 }
241 let list = authored_keys(store, repo_id, target)?;
242 let mut keys = HashSet::new();
243 for k in &list {
244 let segs: Vec<&str> = k.split('.').collect();
245 for i in 1..=segs.len() {
246 keys.insert(segs[..i].join("."));
247 }
248 }
249 let present = !list.is_empty() || present(store, repo_id, target)?;
250 let v = Rc::new(Vocabulary {
251 present,
252 keys,
253 list,
254 });
255 c.vocab.insert(target, Rc::clone(&v));
256 Ok(v)
257 }
258}
259
260#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266enum RowKind {
267 Root,
268 Of(Target),
269 Group(Option<Target>),
270}
271
272type ByDepth = HashMap<usize, Option<RowKind>>;
273
274fn kind_of_expr(
277 e: &Expr,
278 on: Option<RowKind>,
279 read_depth: usize,
280 by_depth: &ByDepth,
281) -> Option<Target> {
282 match e {
283 Expr::Ident { name, .. } => kind_of_path(on, &[name]),
284 Expr::Outer { levels, name, .. } => {
285 kind_of_path(outer_base(read_depth, *levels, by_depth), &[name])
286 }
287 Expr::Member { .. } => {
288 let (head, rest) = dotted(e)?;
289 let (base, name) = match head {
290 Expr::Ident { name, .. } => (on, name.as_str()),
291 Expr::Outer { levels, name, .. } => {
292 (outer_base(read_depth, *levels, by_depth), name.as_str())
293 }
294 _ => return None,
295 };
296 let mut path = vec![name];
297 path.extend(rest);
298 kind_of_path(base, &path)
299 }
300 Expr::Call {
302 recv: None, name, ..
303 } if name == "refs" => Some(Target::Docs),
304 Expr::Op(op) => kind_of_op(op, on, read_depth, by_depth),
306 _ => None, }
308}
309
310fn kind_of_op(
311 op: &OpNode,
312 on: Option<RowKind>,
313 read_depth: usize,
314 by_depth: &ByDepth,
315) -> Option<Target> {
316 if op.sub.select.iter().any(|it| it.name() != "$key") || op.sub.values {
317 return None;
318 }
319 let mut k = kind_of_expr(&op.receiver, on, read_depth, by_depth);
320 for f in &op.sub.from {
321 k = k.and_then(|k| kind_of_expr(f, Some(RowKind::Of(k)), read_depth + 1, by_depth));
322 }
323 k
324}
325
326fn outer_base(read_depth: usize, levels: usize, by_depth: &ByDepth) -> Option<RowKind> {
328 if levels >= read_depth {
329 Some(RowKind::Root)
330 } else {
331 by_depth.get(&(read_depth - levels)).copied().flatten()
332 }
333}
334
335fn dotted(e: &Expr) -> Option<(&Expr, Vec<&str>)> {
337 match e {
338 Expr::Ident { .. } | Expr::Outer { .. } => Some((e, Vec::new())),
339 Expr::Member { recv, name, .. } => {
340 let (head, mut rest) = dotted(recv)?;
341 rest.push(name);
342 Some((head, rest))
343 }
344 _ => None,
345 }
346}
347
348fn kind_of_path(on: Option<RowKind>, path: &[&str]) -> Option<Target> {
351 let mut cur = on;
352 for (i, seg) in path.iter().enumerate() {
353 match cur? {
354 RowKind::Root => {
355 if *seg == "$it" {
356 continue;
357 }
358 cur = Some(RowKind::Of(Target::parse(seg)?));
359 }
360 RowKind::Of(t) => {
361 if *seg == "$it" && i == 0 {
362 continue;
363 }
364 let next = lookup(handles(t), seg).or_else(|| lookup(relations(t), seg))?;
365 cur = Some(RowKind::Of(next));
366 }
367 RowKind::Group(k) => {
370 if *seg != "rows" || i != 0 {
371 return None;
372 }
373 cur = k.map(RowKind::Of);
374 }
375 }
376 }
377 match cur? {
378 RowKind::Root | RowKind::Group(_) => None,
379 RowKind::Of(t) => Some(t),
380 }
381}
382
383fn kind_of_frontier(
386 seed: Option<Target>,
387 f: &Follow,
388 read_depth: usize,
389 by_depth: &ByDepth,
390) -> Option<Target> {
391 let seed = seed?;
392 let on = Some(RowKind::Of(seed));
393 for d in &f.destinations {
395 let k = match d {
396 FollowDestination::Relation(e) => kind_of_expr(e, on, read_depth, by_depth),
397 FollowDestination::Block(op) => kind_of_op(op, on, read_depth, by_depth),
398 };
399 if k != Some(seed) {
400 return None;
401 }
402 }
403 Some(seed)
404}
405
406#[must_use]
409pub fn kind_of_scope(steps: &[ReferenceStep<'_>]) -> Option<Target> {
410 if steps.is_empty() {
411 return None; }
413 let mut by_depth: ByDepth = HashMap::from([(0, Some(RowKind::Root))]);
414 let mut cur: Option<RowKind> = Some(RowKind::Root);
415 let mut read_depth = 0;
416 for step in steps {
417 let rows_kind = match cur {
418 Some(RowKind::Of(t)) => Some(t),
419 _ => None,
420 };
421 cur = match step.expr {
422 StepExpr::Follow(f) => {
423 kind_of_frontier(rows_kind, f, read_depth, &by_depth).map(RowKind::Of)
424 }
425 StepExpr::GroupBy(_) => Some(RowKind::Group(rows_kind)),
427 StepExpr::Expr(e) => kind_of_expr(e, cur, read_depth, &by_depth).map(RowKind::Of),
428 };
429 by_depth.insert(step.depth, cur);
430 read_depth = step.depth;
431 }
432 match cur? {
433 RowKind::Root | RowKind::Group(_) => None,
434 RowKind::Of(t) => Some(t),
435 }
436}
437
438#[derive(Clone, Copy, Debug, PartialEq, Eq)]
442enum Why {
443 Engine,
445 Closed,
447 Authored,
449}
450
451struct Verdict {
452 target: Target,
453 name: String,
455 why: Why,
456 handle: Option<String>,
459}
460
461struct Judge<'a> {
462 store: &'a Store,
463 repo_id: &'a str,
464 cache: &'a VocabularyCache,
465}
466
467impl Judge<'_> {
468 fn vocabulary(&self, target: Target) -> Result<Rc<Vocabulary>> {
469 self.cache.vocabulary_of(self.store, self.repo_id, target)
470 }
471
472 fn judge(&self, target: Target, path: &[&str]) -> Result<Option<Verdict>> {
474 let (head, rest) = path.split_first().expect("a name has a head");
475 if head.starts_with('_') {
476 if attributes(target).contains(head) {
479 return Ok(None);
480 }
481 if target == Target::Docs && SOURCE_HANDLES.contains(head) {
483 return if rest.is_empty() {
484 Ok(None)
485 } else {
486 self.judge_authored(Target::Docs, rest, Some(head))
487 };
488 }
489 if let Some(via) =
490 lookup(handles(target), head).or_else(|| lookup(relations(target), head))
491 {
492 return if rest.is_empty() {
493 Ok(None)
494 } else {
495 self.judge(via, rest)
496 };
497 }
498 return Ok(Some(Verdict {
500 target,
501 name: (*head).to_owned(),
502 why: Why::Engine,
503 handle: None,
504 }));
505 }
506 if Target::parse(head).is_some() {
509 return Ok(None);
510 }
511 self.judge_authored(target, path, None)
512 }
513
514 fn judge_authored(
515 &self,
516 target: Target,
517 path: &[&str],
518 handle: Option<&str>,
519 ) -> Result<Option<Verdict>> {
520 let name = path.join(".");
521 if closed(target) {
522 return Ok(Some(Verdict {
523 target,
524 name,
525 why: Why::Closed,
526 handle: handle.map(str::to_owned),
527 }));
528 }
529 let v = self.vocabulary(target)?;
530 if !v.present {
531 return Ok(None); }
533 if v.keys.contains(&name) {
534 return Ok(None);
535 }
536 Ok(Some(Verdict {
537 target,
538 name,
539 why: Why::Authored,
540 handle: handle.map(str::to_owned),
541 }))
542 }
543
544 fn candidates(&self, target: Target) -> Result<Vec<String>> {
547 let mut out: Vec<String> = engine_names(target)
548 .into_iter()
549 .map(str::to_owned)
550 .collect();
551 if !closed(target) {
552 out.extend(self.vocabulary(target)?.list.iter().cloned());
553 }
554 Ok(out)
555 }
556
557 fn engine_name_error_message(&self, target: Target, name: &str) -> Result<String> {
562 Ok(format!(
563 "`{name}` is not an engine name of {} (engine names: {}){}",
564 target.as_str(),
565 engine_names(target).join(", "),
566 suggestion(name, &self.candidates(target)?)
567 ))
568 }
569
570 fn property_hint_message(
576 &self,
577 target: Target,
578 name: &str,
579 handle: Option<&str>,
580 ) -> Result<String> {
581 if let Some(h) = handle {
582 return Ok(format!(
583 "no {} in this repository carries a property `{name}` (`{h}.{name}`){}",
584 noun(target),
585 suggestion(name, &self.vocabulary(target)?.list)
586 ));
587 }
588 Ok(format!(
589 "no {} in this repository carries a property `{name}`{}",
590 noun(target),
591 suggestion(name, &self.candidates(target)?)
592 ))
593 }
594}
595
596#[must_use]
600pub fn closed_kind_error_message(target: Target, name: &str, handle: Option<&str>) -> String {
601 let shown = handle.map_or(String::new(), |h| format!(" (`{h}.{name}`)"));
602 let names: Vec<String> = engine_names(target)
603 .into_iter()
604 .map(str::to_owned)
605 .collect();
606 format!(
607 "`{name}`{shown} reads an authored property, and a {} carries none (engine names: {}){}",
608 noun(target),
609 names.join(", "),
610 suggestion(name, &names)
611 )
612}
613
614#[must_use]
619pub fn edit_distance(a: &str, b: &str) -> usize {
620 let s: Vec<char> = a.chars().collect();
621 let t: Vec<char> = b.chars().collect();
622 let (m, n) = (s.len(), t.len());
623 let mut d = vec![vec![0usize; n + 1]; m + 1];
624 for (i, row) in d.iter_mut().enumerate() {
625 row[0] = i;
626 }
627 for (j, cell) in d[0].iter_mut().enumerate() {
628 *cell = j;
629 }
630 for i in 1..=m {
631 for j in 1..=n {
632 let cost = usize::from(s[i - 1] != t[j - 1]);
633 let mut v = (d[i - 1][j] + 1)
634 .min(d[i][j - 1] + 1)
635 .min(d[i - 1][j - 1] + cost);
636 if i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1] {
637 v = v.min(d[i - 2][j - 2] + 1);
638 }
639 d[i][j] = v;
640 }
641 }
642 d[m][n]
643}
644
645#[must_use]
661pub fn nearest<'c>(name: &str, candidates: impl IntoIterator<Item = &'c str>) -> Option<String> {
662 let candidates: Vec<&str> = candidates.into_iter().collect();
663 let lower = name.to_lowercase();
664 let prefixed = format!("_{lower}");
665 let unprefixed = lower.strip_prefix('_');
666 for c in &candidates {
667 let hay = c.to_lowercase();
668 if hay == prefixed || unprefixed == Some(hay.as_str()) {
669 return Some((*c).to_owned());
670 }
671 }
672 let needle = lower.strip_prefix('_').unwrap_or(&lower).to_owned();
673 let needle_len = needle.chars().count();
674 if needle_len < 3 {
675 return None;
676 }
677 struct Best {
678 c: String,
679 d: usize,
680 sub: bool,
681 }
682 let rank = |d: usize, sub: bool| -> u8 {
683 if d == 1 {
684 0
685 } else if sub {
686 1
687 } else {
688 2
689 }
690 };
691 let mut best: Option<Best> = None;
692 let mut seen: HashSet<&str> = HashSet::new();
693 for c in candidates {
694 if !seen.insert(c) {
695 continue;
696 }
697 let hay_full = c.to_lowercase();
698 let hay = hay_full.strip_prefix('_').unwrap_or(&hay_full);
699 if hay == needle {
700 continue;
701 }
702 let d = edit_distance(&needle, hay);
703 let hay_len = hay.chars().count();
704 let (shorter, shorter_len, longer) = if needle_len <= hay_len {
705 (needle.as_str(), needle_len, hay)
706 } else {
707 (hay, hay_len, needle.as_str())
708 };
709 let sub = shorter_len >= 3 && longer.contains(shorter);
710 if !(d <= 2 || sub) {
711 continue;
712 }
713 let better = match &best {
714 None => true,
715 Some(b) => {
716 let (r, rb) = (rank(d, sub), rank(b.d, b.sub));
717 r < rb
718 || (r == rb
719 && (d < b.d
720 || (d == b.d
721 && (c.chars().count() < b.c.chars().count()
722 || (c.chars().count() == b.c.chars().count()
723 && c < b.c.as_str())))))
724 }
725 };
726 if better {
727 best = Some(Best {
728 c: c.to_owned(),
729 d,
730 sub,
731 });
732 }
733 }
734 best.map(|b| b.c)
735}
736
737fn suggestion(name: &str, candidates: &[String]) -> String {
738 match nearest(name, candidates.iter().map(String::as_str)) {
739 Some(c) => format!("; did you mean `{c}`?"),
740 None => String::new(),
741 }
742}
743
744pub fn check_names(
752 store: &Store,
753 repo_id: &str,
754 raw: &Query,
755 mode: UnknownNames,
756 cache: &VocabularyCache,
757) -> Result<Vec<String>> {
758 let judge = Judge {
759 store,
760 repo_id,
761 cache,
762 };
763 let mut hints: Vec<String> = Vec::new();
764 let mut seen: HashSet<String> = HashSet::new();
765 for r in references(raw) {
766 if r.scope.steps.is_empty() {
767 continue; }
769 let Some(kind) = kind_of_scope(&r.scope.steps) else {
770 continue;
771 };
772 let path: Vec<&str> = r.name.split('.').collect();
773 let Some(verdict) = judge.judge(kind, &path)? else {
774 continue;
775 };
776 match verdict.why {
777 Why::Engine => {
778 return Err(SurfaceError::filter_invalid(
779 judge.engine_name_error_message(verdict.target, &verdict.name)?,
780 "OQX",
781 ));
782 }
783 Why::Closed => {
784 return Err(SurfaceError::filter_invalid(
785 closed_kind_error_message(
786 verdict.target,
787 &verdict.name,
788 verdict.handle.as_deref(),
789 ),
790 "OQX",
791 ));
792 }
793 Why::Authored => {}
794 }
795 let message = judge.property_hint_message(
796 verdict.target,
797 &verdict.name,
798 verdict.handle.as_deref(),
799 )?;
800 if mode == UnknownNames::Refuse {
801 return Err(SurfaceError::filter_invalid(message, "OQX"));
802 }
803 if seen.insert(message.clone()) {
804 hints.push(message);
805 }
806 }
807 Ok(hints)
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use omgbase_reconcile::Config;
814 use omgbase_store::BatchItem;
815
816 const TS: &str = "2026-10-13T00:00:00.000Z";
817
818 fn repo_with(docs: &[(&str, &str)]) -> (Store, String) {
819 let mut store = Store::open_in_memory().unwrap();
820 let repo = store.create_repo("names").unwrap();
821 if !docs.is_empty() {
822 let items: Vec<BatchItem> = docs
823 .iter()
824 .map(|(p, s)| BatchItem::observed(p, s))
825 .collect();
826 store
827 .observe_batch(&repo, &items, TS, &Config::default())
828 .unwrap();
829 }
830 (store, repo)
831 }
832
833 fn fixture() -> (Store, String) {
834 repo_with(&[
835 (
836 "a.md",
837 "---\nkind: x\nmeta:\n owner: bb\n---\n\n# A\n\n- [ ] a task\n\n```js\nlet x;\n```\n",
838 ),
839 ("b.md", "---\nkind: y\n---\n\n# B\n\nstage:: calcination\n"),
840 ])
841 }
842
843 fn hints(store: &Store, repo: &str, q: &str) -> Vec<String> {
844 let raw = oqx::parse_string(q).unwrap();
845 check_names(
846 store,
847 repo,
848 &raw,
849 UnknownNames::Hint,
850 &VocabularyCache::default(),
851 )
852 .unwrap()
853 }
854
855 fn refusal(store: &Store, repo: &str, q: &str, mode: UnknownNames) -> SurfaceError {
856 let raw = oqx::parse_string(q).unwrap();
857 check_names(store, repo, &raw, mode, &VocabularyCache::default()).unwrap_err()
858 }
859
860 const DOCS_NAMES: &str = "_id, _path, _raw, _hash, _updated_at, _created_at, _title, _tags, _format, _data, _frontmatter, _inline, _blocks, _nodes, _out_links, _in_links, _revisions";
861 const BLOCKS_NAMES: &str = "_id, _path, _raw, _hash, _updated_at, _created_at, _doc_id, _type, _text, _attrs, _depth, _ordinal, _doc, _children, _nodes, _out_links, _section";
862 const LINKS_NAMES: &str = "_id, _path, _doc_id, _block_id, _via_id, _dst_id, _dst_path, _uri, _field, _dst_kind, _predicate, _provenance, _anchor, _doc, _dst, _block";
863 const REVISIONS_NAMES: &str = "_id, _path, _rev, _seq, _doc_id, _commit, _ts, _origin, _actor, _reason, _hash, _is_current, _archived, _doc";
864
865 #[test]
866 fn engine_names_are_the_table_in_order() {
867 assert_eq!(engine_names(Target::Docs).join(", "), DOCS_NAMES);
868 assert_eq!(engine_names(Target::Blocks).join(", "), BLOCKS_NAMES);
869 assert_eq!(
870 engine_names(Target::Nodes).join(", "),
871 "_id, _path, _doc_id, _block_id, _kind, _name, _value, _attrs, _doc, _block, _blocks, _subsections, _children"
872 );
873 assert_eq!(engine_names(Target::Links).join(", "), LINKS_NAMES);
874 assert_eq!(engine_names(Target::Revisions).join(", "), REVISIONS_NAMES);
875 }
876
877 #[test]
878 fn vocabulary_per_kind() {
879 let (store, repo) = fixture();
880 assert_eq!(
881 vocabulary(&store, &repo, Target::Docs).unwrap(),
882 (
883 true,
884 vec![
885 "kind".to_owned(),
886 "meta.owner".to_owned(),
887 "stage".to_owned()
888 ]
889 )
890 );
891 assert_eq!(
892 vocabulary(&store, &repo, Target::Blocks).unwrap(),
893 (
894 true,
895 ["checked", "lang", "level", "ordered"]
896 .map(str::to_owned)
897 .to_vec()
898 )
899 );
900 assert_eq!(
901 vocabulary(&store, &repo, Target::Nodes).unwrap(),
902 (
903 true,
904 ["checked", "first_ordinal", "last_ordinal", "level"]
905 .map(str::to_owned)
906 .to_vec()
907 )
908 );
909 assert_eq!(
910 vocabulary(&store, &repo, Target::Links).unwrap(),
911 (false, vec![])
912 );
913 }
914
915 #[test]
916 fn cache_refreshes_on_a_new_commit() {
917 let (mut store, repo) = repo_with(&[("a.md", "---\nkind: x\n---\n\n# A\n")]);
918 let cache = VocabularyCache::default();
919 assert_eq!(
920 cache
921 .vocabulary_of(&store, &repo, Target::Docs)
922 .unwrap()
923 .list,
924 ["kind"]
925 );
926 store
927 .observe_batch(
928 &repo,
929 &[BatchItem::observed("b.md", "---\ncolor: red\n---\n\n# B\n")],
930 TS,
931 &Config::default(),
932 )
933 .unwrap();
934 assert_eq!(
935 cache
936 .vocabulary_of(&store, &repo, Target::Docs)
937 .unwrap()
938 .list,
939 ["color", "kind"]
940 );
941 }
942
943 #[test]
944 fn the_name_rule() {
945 let (store, repo) = fixture();
946 assert!(hints(&store, &repo, "select _title, _tags, _path, _blocks, d: $it._id from docs where _nodes exists { where _kind == \"md:task\" }").is_empty());
948 assert!(
949 hints(
950 &store,
951 &repo,
952 "select _type, _text, _attrs, _children, _section.level, _doc._title from blocks"
953 )
954 .is_empty()
955 );
956 assert!(
957 hints(
958 &store,
959 &repo,
960 "select _dst_path, s: _doc._title, t: _dst._title from links"
961 )
962 .is_empty()
963 );
964 assert!(
966 hints(
967 &store,
968 &repo,
969 "select stage, kind from docs where meta.owner == \"bb\""
970 )
971 .is_empty()
972 );
973 assert!(hints(&store, &repo, "select meta from docs").is_empty());
974 assert!(
975 hints(
976 &store,
977 &repo,
978 "select checked from blocks where lang == \"js\""
979 )
980 .is_empty()
981 );
982 assert_eq!(
985 hints(
986 &store,
987 &repo,
988 "select stag, kynd from docs where stag == \"x\" order by kynd"
989 ),
990 [
991 "no document in this repository carries a property `stag`; did you mean `stage`?",
992 "no document in this repository carries a property `kynd`; did you mean `kind`?",
993 ]
994 );
995 assert_eq!(
996 hints(&store, &repo, "select meta.ownr, stage.x from docs"),
997 [
998 "no document in this repository carries a property `meta.ownr`; did you mean `meta.owner`?",
999 "no document in this repository carries a property `stage.x`; did you mean `stage`?",
1000 ]
1001 );
1002 assert_eq!(
1005 hints(
1006 &store,
1007 &repo,
1008 "select id, title, path, tags, format from docs"
1009 ),
1010 [
1011 "no document in this repository carries a property `id`; did you mean `_id`?",
1012 "no document in this repository carries a property `title`; did you mean `_title`?",
1013 "no document in this repository carries a property `path`; did you mean `_path`?",
1014 "no document in this repository carries a property `tags`; did you mean `_tags`?",
1015 "no document in this repository carries a property `format`; did you mean `_format`?",
1016 ]
1017 );
1018 assert_eq!(
1019 hints(&store, &repo, "select text, preview from blocks"),
1020 [
1021 "no block in this repository carries a property `text`; did you mean `_text`?",
1022 "no block in this repository carries a property `preview`",
1023 ]
1024 );
1025 assert_eq!(
1028 hints(
1029 &store,
1030 &repo,
1031 "select p: _data.path, k: _frontmatter.kynd from docs"
1032 ),
1033 [
1034 "no document in this repository carries a property `path` (`_data.path`)",
1035 "no document in this repository carries a property `kynd` (`_frontmatter.kynd`); did you mean `kind`?",
1036 ]
1037 );
1038 }
1039
1040 #[test]
1041 fn an_unknown_engine_name_is_loud_in_either_mode() {
1042 let (store, repo) = fixture();
1043 let docs_err = format!(
1044 "`_nope` is not an engine name of docs (engine names: {DOCS_NAMES}); did you mean `_nodes`?"
1045 );
1046 for mode in [UnknownNames::Hint, UnknownNames::Refuse] {
1047 let e = refusal(&store, &repo, "select _nope from docs", mode);
1048 assert_eq!(
1049 (e.code.as_str(), e.message.as_str()),
1050 ("filter_invalid", docs_err.as_str())
1051 );
1052 }
1053 assert_eq!(
1056 refusal(
1057 &store,
1058 &repo,
1059 "from docs where _kind == \"x\"",
1060 UnknownNames::Hint
1061 )
1062 .message,
1063 format!(
1064 "`_kind` is not an engine name of docs (engine names: {DOCS_NAMES}); did you mean `kind`?"
1065 )
1066 );
1067 assert_eq!(
1068 refusal(
1069 &store,
1070 &repo,
1071 "select _tittle from blocks",
1072 UnknownNames::Hint
1073 )
1074 .message,
1075 format!("`_tittle` is not an engine name of blocks (engine names: {BLOCKS_NAMES})")
1076 );
1077 assert!(
1079 refusal(
1080 &store,
1081 &repo,
1082 "select _doc._nope from blocks",
1083 UnknownNames::Hint
1084 )
1085 .message
1086 .starts_with("`_nope` is not an engine name of docs")
1087 );
1088 }
1089
1090 #[test]
1091 fn closed_kinds_refuse_a_bare_name_and_dst_reaches_docs() {
1092 let (store, repo) = fixture();
1093 let err = format!(
1094 "`target` reads an authored property, and a link carries none (engine names: {LINKS_NAMES})"
1095 );
1096 let e = refusal(
1097 &store,
1098 &repo,
1099 "select target from links",
1100 UnknownNames::Hint,
1101 );
1102 assert_eq!(
1103 (e.code.as_str(), e.message.as_str()),
1104 ("filter_invalid", err.as_str())
1105 );
1106 let e = refusal(
1107 &store,
1108 &repo,
1109 "from docs where _out_links exists { where target == \"x\" }",
1110 UnknownNames::Hint,
1111 );
1112 assert_eq!(e.message, err);
1113 assert_eq!(
1114 refusal(
1115 &store,
1116 &repo,
1117 "select dst_path from links",
1118 UnknownNames::Hint
1119 )
1120 .message,
1121 format!(
1122 "`dst_path` reads an authored property, and a link carries none (engine names: {LINKS_NAMES}); did you mean `_dst_path`?"
1123 )
1124 );
1125 assert_eq!(
1126 refusal(
1127 &store,
1128 &repo,
1129 "select rev from revisions",
1130 UnknownNames::Hint
1131 )
1132 .message,
1133 format!(
1134 "`rev` reads an authored property, and a revision carries none (engine names: {REVISIONS_NAMES}); did you mean `_rev`?"
1135 )
1136 );
1137 assert_eq!(
1138 hints(&store, &repo, "select _dst.kynd from links"),
1139 ["no document in this repository carries a property `kynd`; did you mean `kind`?"]
1140 );
1141 assert!(
1145 hints(
1146 &store,
1147 &repo,
1148 "select _block._text, _block._type, _block._doc._path from links"
1149 )
1150 .is_empty()
1151 );
1152 assert_eq!(
1153 hints(&store, &repo, "select _block.typ from links"),
1154 ["no block in this repository carries a property `typ`; did you mean `_type`?"]
1155 );
1156 }
1157
1158 #[test]
1159 fn refuse_mode_is_the_hint_text_as_filter_invalid() {
1160 let (store, repo) = fixture();
1161 let e = refusal(&store, &repo, "select stag from docs", UnknownNames::Refuse);
1162 assert_eq!(e.code, "filter_invalid");
1163 assert_eq!(
1164 e.message,
1165 "no document in this repository carries a property `stag`; did you mean `stage`?"
1166 );
1167 assert_eq!(e.data.unwrap()["hint"], "OQX");
1168 }
1169
1170 #[test]
1171 fn what_is_not_judged() {
1172 let (store, repo) = fixture();
1173 for q in [
1174 "select s: stage, n: s from docs where s == \"x\"",
1175 "select stage, n: count(), kynd: collect(kind) from docs group by stage having n > 1 order by count() desc",
1178 "select kind, open: rows count { where _nodes exists { where _kind == \"md:task\" } } from docs group by kind",
1179 "select p: _path, open from docs where _nodes collect { ^open: _value where _kind == \"md:task\" }",
1180 "select $key: _id from docs where $depth > 0 and has(stage) and size(list(kind)) > 0",
1181 "select a: _attrs.whatever from blocks",
1182 "select n: _tags.size() from docs",
1183 "select t: (_blocks, _nodes) collect { select kynd } from docs",
1184 ] {
1185 assert!(hints(&store, &repo, q).is_empty(), "{q}");
1186 }
1187 assert_eq!(
1188 hints(
1189 &store,
1190 &repo,
1191 "select r: refs(stage) collect { select kynd } from docs"
1192 )
1193 .len(),
1194 1
1195 );
1196 assert_eq!(
1198 hints(
1199 &store,
1200 &repo,
1201 "select stage, t: rows collect { select kynd } from docs group by stage"
1202 ),
1203 ["no document in this repository carries a property `kynd`; did you mean `kind`?"]
1204 );
1205 assert_eq!(
1206 hints(
1207 &store,
1208 &repo,
1209 "select lang, n: rows count { where chekced } from blocks group by lang"
1210 ),
1211 ["no block in this repository carries a property `chekced`; did you mean `checked`?"]
1212 );
1213 }
1214
1215 #[test]
1216 fn an_empty_repository_has_no_vocabulary() {
1217 let (store, repo) = repo_with(&[]);
1218 assert!(hints(&store, &repo, "select titel from docs where layr == \"x\"").is_empty());
1219 assert!(hints(&store, &repo, "select preview from blocks").is_empty());
1220 assert!(
1221 refusal(
1222 &store,
1223 &repo,
1224 "select target from links",
1225 UnknownNames::Hint
1226 )
1227 .message
1228 .contains("reads an authored property, and a link carries none")
1229 );
1230 assert!(
1232 refusal(&store, &repo, "select _nope from docs", UnknownNames::Hint)
1233 .message
1234 .starts_with("`_nope` is not an engine name of docs")
1235 );
1236 }
1237
1238 #[test]
1239 fn edit_distance_is_optimal_string_alignment() {
1240 assert_eq!(edit_distance("title", "titel"), 1);
1241 assert_eq!(edit_distance("stag", "stage"), 1);
1242 assert_eq!(edit_distance("value", "text"), 5);
1243 assert_eq!(edit_distance("via", "via_id"), 3);
1244 assert_eq!(edit_distance("x", "id"), 2);
1245 }
1246
1247 #[test]
1248 fn nearest_match_rule() {
1249 let links = [
1250 "_id",
1251 "_path",
1252 "_doc_id",
1253 "_block_id",
1254 "_via_id",
1255 "_dst_id",
1256 "_dst_path",
1257 "_uri",
1258 "_field",
1259 "_dst_kind",
1260 "_predicate",
1261 "_provenance",
1262 "_anchor",
1263 ];
1264 let n = |name: &str, c: &[&str]| nearest(name, c.iter().copied());
1265 assert_eq!(n("id", &links).as_deref(), Some("_id"));
1268 assert_eq!(n("dst_path", &links).as_deref(), Some("_dst_path"));
1269 assert_eq!(n("title", &["titel", "_title"]).as_deref(), Some("_title"));
1270 assert_eq!(n("_kind", &["kinds", "kind"]).as_deref(), Some("kind"));
1271 assert_eq!(n("dst_pth", &links).as_deref(), Some("_dst_path"));
1272 assert_eq!(n("target", &links), None);
1273 assert_eq!(n("DST", &links).as_deref(), Some("_dst_id"));
1274 assert_eq!(n("via", &links).as_deref(), Some("_via_id"));
1275 assert_eq!(n("x", &links), None);
1276 assert_eq!(n("in", &["_id", "_in_links", "_out_links"]), None);
1277 assert_eq!(
1278 n("tag", &["_id", "_path", "_tags", "_title"]).as_deref(),
1279 Some("_tags")
1280 );
1281 assert_eq!(
1282 n(
1283 "preview",
1284 &[
1285 "_id",
1286 "_path",
1287 "_type",
1288 "_text",
1289 "_attrs",
1290 "_depth",
1291 "_ordinal",
1292 "_children"
1293 ]
1294 ),
1295 None
1296 );
1297 assert_eq!(n("predicat", &links).as_deref(), Some("_predicate"));
1298 assert_eq!(n("titel", &["_title", "title"]).as_deref(), Some("title"));
1300 assert_eq!(n("commited", &["_commit"]).as_deref(), Some("_commit"));
1301 assert_eq!(n("abc", &["acb", "abc_x", "abd"]).as_deref(), Some("abd"));
1302 assert_eq!(n("abcd", &["abxy", "abcd_x"]).as_deref(), Some("abcd_x"));
1303 assert_eq!(n("abc", &["abcd", "xabc"]).as_deref(), Some("abcd"));
1304 }
1305}