1use crate::tokenql::{parse, Node};
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone, PartialEq)]
16pub enum AtomStatus {
17 Known,
19 Wildcard(usize),
21 Unknown(Vec<String>),
23}
24
25#[derive(Debug, Clone)]
26pub struct LintError {
27 pub atom: String,
28 pub message: String,
29 pub suggestions: Vec<String>,
30}
31
32#[derive(Debug, Clone)]
33pub struct LintReport {
34 pub ok: bool,
35 pub errors: Vec<LintError>,
36 pub repaired: Option<String>,
38}
39
40pub struct Linter {
43 tokens: HashSet<String>,
44 facets: HashSet<String>,
45 by_facet: HashMap<String, Vec<String>>, numeric: HashSet<String>,
50}
51
52pub const STRUCTURAL_HEADS: &[&str] = &[
59 "and", "or", "not", "num", "evidence", "combine-ds", "s-path",
61 "stream", "mass", "source", "target", "constraint",
63];
64
65pub const SUB_FORMS: &[&str] = &["stream", "mass", "source", "target", "constraint"];
67
68fn is_structural(a: &str) -> bool {
71 STRUCTURAL_HEADS.contains(&a)
72 || a.starts_with(':')
73 || a.parse::<f64>().is_ok()
74 || matches!(a, "ge" | "gt" | "le" | "lt" | "eq" | "ne" | "true" | "false")
75}
76
77fn facet_of(token: &str) -> &str {
78 token.split('/').next().unwrap_or(token)
79}
80fn leaf_of(token: &str) -> &str {
81 match token.find('/') {
82 Some(i) => &token[i + 1..],
83 None => token,
84 }
85}
86
87fn edit_distance(a: &str, b: &str) -> usize {
89 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
90 let mut prev: Vec<usize> = (0..=b.len()).collect();
91 let mut cur = vec![0usize; b.len() + 1];
92 for i in 1..=a.len() {
93 cur[0] = i;
94 for j in 1..=b.len() {
95 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
96 cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
97 }
98 std::mem::swap(&mut prev, &mut cur);
99 }
100 prev[b.len()]
101}
102
103impl Linter {
104 pub fn from_tokens<I: IntoIterator<Item = String>>(tokens: I) -> Linter {
106 let mut set = HashSet::new();
107 let mut facets = HashSet::new();
108 let mut by_facet: HashMap<String, Vec<String>> = HashMap::new();
109 for t in tokens {
110 facets.insert(facet_of(&t).to_string());
111 by_facet.entry(facet_of(&t).to_string()).or_default().push(leaf_of(&t).to_string());
112 set.insert(t);
113 }
114 Linter { tokens: set, facets, by_facet, numeric: HashSet::new() }
115 }
116
117 pub fn with_numeric_fields<I: IntoIterator<Item = String>>(mut self, fields: I) -> Self {
120 self.numeric = fields.into_iter().collect();
121 self
122 }
123
124 pub fn numeric_fields(&self) -> Vec<String> {
125 let mut v: Vec<String> = self.numeric.iter().cloned().collect();
126 v.sort();
127 v
128 }
129
130 pub fn validate_atom(&self, atom: &str) -> AtomStatus {
132 if atom.contains('*') {
133 let prefix = &atom[..atom.find('*').unwrap()];
134 let n = self.tokens.iter().filter(|t| t.starts_with(prefix)).count();
135 if n > 0 {
136 return AtomStatus::Wildcard(n);
137 }
138 return AtomStatus::Unknown(self.suggest(atom));
143 }
144 if self.tokens.contains(atom) {
145 return AtomStatus::Known;
146 }
147 AtomStatus::Unknown(self.suggest(atom))
148 }
149
150 pub fn has_facet(&self, facet: &str) -> bool {
152 self.facets.contains(facet)
153 }
154
155 pub fn facet_names(&self) -> Vec<String> {
158 let mut v: Vec<String> = self.facets.iter().cloned().collect();
159 v.sort();
160 v
161 }
162
163 pub fn suggest(&self, atom: &str) -> Vec<String> {
167 let facet = facet_of(atom);
168 let leaf = leaf_of(atom);
169 let real_facet = match self.facets.get(facet) {
171 Some(f) => f.clone(),
172 None => return Vec::new(),
173 };
174 let mut cands: Vec<(usize, &String)> =
175 self.by_facet.get(&real_facet).map(|ls| ls.iter().map(|l| (edit_distance(l, leaf), l)).collect()).unwrap_or_default();
176 cands.sort_by_key(|(d, _)| *d);
177 cands.dedup_by(|a, b| a.1 == b.1);
178 cands.into_iter().take(3).map(|(_, l)| format!("{real_facet}/{l}")).collect()
179 }
180
181 pub fn lint(&self, ikl: &str) -> LintReport {
183 let (expr, repaired) = balance_parens(ikl);
184 let mut errors = Vec::new();
185 self.walk(&parse(&expr), &mut errors);
186 LintReport { ok: errors.is_empty(), errors, repaired }
187 }
188
189 fn walk(&self, node: &Node, errors: &mut Vec<LintError>) {
190 match node {
191 Node::Atom(a) => {
192 if is_structural(a) {
193 return;
194 }
195 if let AtomStatus::Unknown(sug) = self.validate_atom(a) {
196 let facet = facet_of(a);
197 let msg = if !sug.is_empty() {
198 format!("term '{a}' not found; did you mean {}?", sug.join(", "))
199 } else if a.contains('/') && !self.has_facet(facet) {
200 format!("dimension '{facet}' is not in this corpus; facets are: {}", self.facet_names().join(", "))
201 } else {
202 format!("term '{a}' is not in the vocabulary")
203 };
204 errors.push(LintError { atom: a.clone(), message: msg, suggestions: sug });
205 }
206 }
207 Node::List(items) => {
208 if let Some(Node::Atom(head)) = items.first() {
212 if head == "num" {
213 if let Some(Node::Atom(field)) = items.get(1) {
214 if !self.numeric.is_empty() && !self.numeric.contains(field.as_str()) {
215 let mut sug: Vec<String> = self.numeric.iter().cloned().collect();
216 sug.sort_by_key(|f| edit_distance(f, field));
217 sug.truncate(3);
218 errors.push(LintError {
219 atom: field.clone(),
220 message: format!(
221 "'{field}' is not a numeric field; numeric fields are: {}",
222 self.numeric_fields().join(", ")
223 ),
224 suggestions: sug,
225 });
226 }
227 }
228 return; }
230 if head == "combine-ds" {
234 for it in &items[1..] {
235 match it {
236 Node::Atom(_) => {}
238 other => self.walk(other, errors),
240 }
241 }
242 return;
243 }
244 if head == "stream" {
245 let mut i = 1;
247 while i < items.len() {
248 if matches!(&items[i], Node::Atom(k) if k == ":mass-assignments") {
249 if let Some(list) = items.get(i + 1) {
250 self.walk(list, errors);
251 }
252 i += 2;
253 continue;
254 }
255 i += 1;
256 }
257 return;
258 }
259 if head == "mass" {
260 if let Some(atoms) = items.get(1) {
262 self.walk(atoms, errors);
263 }
264 return;
265 }
266 }
267 let skip_first = matches!(items.first(), Some(Node::Atom(a)) if is_structural(a));
269 for (i, it) in items.iter().enumerate() {
270 if i == 0 && skip_first {
271 continue;
272 }
273 self.walk(it, errors);
274 }
275 }
276 }
277 }
278}
279
280pub fn balance_parens(expr: &str) -> (String, Option<String>) {
283 let mut depth: i32 = 0;
284 let mut out = String::with_capacity(expr.len());
285 for c in expr.chars() {
286 match c {
287 '(' => {
288 depth += 1;
289 out.push(c);
290 }
291 ')' => {
292 if depth > 0 {
293 depth -= 1;
294 out.push(c);
295 } }
297 _ => out.push(c),
298 }
299 }
300 for _ in 0..depth {
301 out.push(')'); }
303 if out == expr {
304 (out, None)
305 } else {
306 let r = out.clone();
307 (out, Some(r))
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn linter() -> Linter {
316 Linter::from_tokens(
317 ["org/toyota", "org/honda", "artifact/battery_cell", "geo/apac", "powertrain/electric", "powertrain/diesel"]
318 .into_iter()
319 .map(String::from),
320 )
321 }
322
323 #[test]
324 fn exact_and_wildcard() {
325 let l = linter();
326 assert_eq!(l.validate_atom("org/toyota"), AtomStatus::Known);
327 assert!(matches!(l.validate_atom("powertrain/*"), AtomStatus::Wildcard(n) if n == 2));
328 }
329
330 #[test]
331 fn did_you_mean_scoped_to_facet() {
332 let l = linter();
333 match l.validate_atom("artifact/battery_cel") {
335 AtomStatus::Unknown(s) => assert_eq!(s, vec!["artifact/battery_cell".to_string()]),
336 other => panic!("expected Unknown, got {other:?}"),
337 }
338 match l.validate_atom("artifact/power_cube") {
340 AtomStatus::Unknown(s) => assert!(s.iter().all(|x| x.starts_with("artifact/"))),
341 other => panic!("expected Unknown, got {other:?}"),
342 }
343 }
344
345 #[test]
346 fn numeric_predicates_validate_against_the_numeric_namespace() {
347 let l = linter().with_numeric_fields(["range_km".to_string(), "year".to_string()]);
348 assert!(l.lint("(num range_km gt 500)").ok, "{:?}", l.lint("(num range_km gt 500)").errors);
350 assert!(l.lint("(and powertrain/electric (num year ge 2020))").ok);
351 let bad = l.lint("(num rnge_km gt 500)");
353 assert!(!bad.ok);
354 assert_eq!(bad.errors[0].suggestions[0], "range_km");
355 assert!(bad.errors[0].message.contains("numeric field"), "{}", bad.errors[0].message);
356 assert!(linter().lint("(num anything gt 1)").ok);
358 }
359
360 #[test]
361 fn unknown_dimension_reports_facets_not_bogus_suggestions() {
362 let l = linter();
363 let r = l.lint("artifact/battery_cel");
365 assert!(!r.ok);
366 assert_eq!(r.errors[0].suggestions[0], "artifact/battery_cell");
367 let r2 = l.lint("gene/brca1");
369 assert!(!r2.ok);
370 assert!(r2.errors[0].suggestions.is_empty(), "must not suggest values from another dimension");
371 assert!(r2.errors[0].message.contains("dimension 'gene' is not in this corpus"), "{}", r2.errors[0].message);
372 assert!(r2.errors[0].message.contains("artifact"), "should list real facets: {}", r2.errors[0].message);
373 }
374
375 #[test]
376 fn lint_catches_bad_atom_and_repairs_parens() {
377 let l = linter();
378 let r = l.lint("(and org/toyota (not powertrain/diesel)"); assert!(r.repaired.is_some());
380 assert!(r.ok, "all atoms valid: {:?}", r.errors);
381 let bad = l.lint("(and org/tyota powertrain/electric)");
382 assert!(!bad.ok);
383 assert_eq!(bad.errors[0].suggestions[0], "org/toyota"); }
385
386 #[test]
387 fn evidential_fusion_forms_lint_clean() {
388 let l = Linter::from_tokens(
389 ["artifact/battery_cell", "artifact/power_cube"].into_iter().map(String::from));
390
391 let q = "(combine-ds :max-conflict 0.20 \
394 (stream :id sensor :mass-assignments ((mass (artifact/battery_cell) 0.7) \
395 (mass (artifact/battery_cell artifact/power_cube) 0.3))))";
396 let r = l.lint(q);
397 assert!(r.ok, "should lint clean, got {:?}", r.errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>());
398
399 assert!(l.lint("(evidence artifact/battery_cell :min-bel 0.8 :max-pl 0.95)").ok);
400
401 let bad = l.lint("(combine-ds :max-conflict 0.2 \
403 (stream :id s :mass-assignments ((mass (artifact/nonexistent) 1.0))))");
404 assert!(!bad.ok, "an unknown focal atom must still be reported");
405 assert!(bad.errors.iter().any(|e| e.atom.contains("nonexistent")), "{:?}", bad.errors);
406 }
407
408 #[test]
409 fn a_wildcard_on_a_missing_category_is_refused() {
410 let l = Linter::from_tokens(["battle/defeated", "survey/elevation"].into_iter().map(String::from));
413
414 assert!(l.lint("battle/*").ok, "a real category must still expand");
415 assert!(l.lint("survey/*").ok);
416
417 let bad = l.lint("gene/*");
418 assert!(!bad.ok, "a wildcard cannot bypass the facet check");
419 assert!(
420 bad.errors[0].message.contains("battle") && bad.errors[0].message.contains("survey"),
421 "and must name what does exist: {}",
422 bad.errors[0].message
423 );
424
425 assert!(!l.lint("(and battle/* gene/*)").ok);
427 assert!(!l.lint("(or (not gene/*) battle/*)").ok);
428 }
429}