use crate::tokenql::{parse, Node};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
pub enum AtomStatus {
Known,
Wildcard(usize),
Unknown(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct LintError {
pub atom: String,
pub message: String,
pub suggestions: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct LintReport {
pub ok: bool,
pub errors: Vec<LintError>,
pub repaired: Option<String>,
}
pub struct Linter {
tokens: HashSet<String>,
facets: HashSet<String>,
by_facet: HashMap<String, Vec<String>>, numeric: HashSet<String>,
}
pub const STRUCTURAL_HEADS: &[&str] = &[
"and", "or", "not", "num", "evidence", "combine-ds", "s-path",
"stream", "mass", "source", "target", "constraint",
];
pub const SUB_FORMS: &[&str] = &["stream", "mass", "source", "target", "constraint"];
fn is_structural(a: &str) -> bool {
STRUCTURAL_HEADS.contains(&a)
|| a.starts_with(':')
|| a.parse::<f64>().is_ok()
|| matches!(a, "ge" | "gt" | "le" | "lt" | "eq" | "ne" | "true" | "false")
}
fn facet_of(token: &str) -> &str {
token.split('/').next().unwrap_or(token)
}
fn leaf_of(token: &str) -> &str {
match token.find('/') {
Some(i) => &token[i + 1..],
None => token,
}
}
fn edit_distance(a: &str, b: &str) -> usize {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
for j in 1..=b.len() {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
impl Linter {
pub fn from_tokens<I: IntoIterator<Item = String>>(tokens: I) -> Linter {
let mut set = HashSet::new();
let mut facets = HashSet::new();
let mut by_facet: HashMap<String, Vec<String>> = HashMap::new();
for t in tokens {
facets.insert(facet_of(&t).to_string());
by_facet.entry(facet_of(&t).to_string()).or_default().push(leaf_of(&t).to_string());
set.insert(t);
}
Linter { tokens: set, facets, by_facet, numeric: HashSet::new() }
}
pub fn with_numeric_fields<I: IntoIterator<Item = String>>(mut self, fields: I) -> Self {
self.numeric = fields.into_iter().collect();
self
}
pub fn numeric_fields(&self) -> Vec<String> {
let mut v: Vec<String> = self.numeric.iter().cloned().collect();
v.sort();
v
}
pub fn validate_atom(&self, atom: &str) -> AtomStatus {
if atom.contains('*') {
let prefix = &atom[..atom.find('*').unwrap()];
let n = self.tokens.iter().filter(|t| t.starts_with(prefix)).count();
return AtomStatus::Wildcard(n);
}
if self.tokens.contains(atom) {
return AtomStatus::Known;
}
AtomStatus::Unknown(self.suggest(atom))
}
pub fn has_facet(&self, facet: &str) -> bool {
self.facets.contains(facet)
}
pub fn facet_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.facets.iter().cloned().collect();
v.sort();
v
}
pub fn suggest(&self, atom: &str) -> Vec<String> {
let facet = facet_of(atom);
let leaf = leaf_of(atom);
let real_facet = match self.facets.get(facet) {
Some(f) => f.clone(),
None => return Vec::new(),
};
let mut cands: Vec<(usize, &String)> =
self.by_facet.get(&real_facet).map(|ls| ls.iter().map(|l| (edit_distance(l, leaf), l)).collect()).unwrap_or_default();
cands.sort_by_key(|(d, _)| *d);
cands.dedup_by(|a, b| a.1 == b.1);
cands.into_iter().take(3).map(|(_, l)| format!("{real_facet}/{l}")).collect()
}
pub fn lint(&self, ikl: &str) -> LintReport {
let (expr, repaired) = balance_parens(ikl);
let mut errors = Vec::new();
self.walk(&parse(&expr), &mut errors);
LintReport { ok: errors.is_empty(), errors, repaired }
}
fn walk(&self, node: &Node, errors: &mut Vec<LintError>) {
match node {
Node::Atom(a) => {
if is_structural(a) {
return;
}
if let AtomStatus::Unknown(sug) = self.validate_atom(a) {
let facet = facet_of(a);
let msg = if !sug.is_empty() {
format!("term '{a}' not found; did you mean {}?", sug.join(", "))
} else if a.contains('/') && !self.has_facet(facet) {
format!("dimension '{facet}' is not in this corpus; facets are: {}", self.facet_names().join(", "))
} else {
format!("term '{a}' is not in the vocabulary")
};
errors.push(LintError { atom: a.clone(), message: msg, suggestions: sug });
}
}
Node::List(items) => {
if let Some(Node::Atom(head)) = items.first() {
if head == "num" {
if let Some(Node::Atom(field)) = items.get(1) {
if !self.numeric.is_empty() && !self.numeric.contains(field.as_str()) {
let mut sug: Vec<String> = self.numeric.iter().cloned().collect();
sug.sort_by_key(|f| edit_distance(f, field));
sug.truncate(3);
errors.push(LintError {
atom: field.clone(),
message: format!(
"'{field}' is not a numeric field; numeric fields are: {}",
self.numeric_fields().join(", ")
),
suggestions: sug,
});
}
}
return; }
if head == "combine-ds" {
for it in &items[1..] {
match it {
Node::Atom(_) => {}
other => self.walk(other, errors),
}
}
return;
}
if head == "stream" {
let mut i = 1;
while i < items.len() {
if matches!(&items[i], Node::Atom(k) if k == ":mass-assignments") {
if let Some(list) = items.get(i + 1) {
self.walk(list, errors);
}
i += 2;
continue;
}
i += 1;
}
return;
}
if head == "mass" {
if let Some(atoms) = items.get(1) {
self.walk(atoms, errors);
}
return;
}
}
let skip_first = matches!(items.first(), Some(Node::Atom(a)) if is_structural(a));
for (i, it) in items.iter().enumerate() {
if i == 0 && skip_first {
continue;
}
self.walk(it, errors);
}
}
}
}
}
pub fn balance_parens(expr: &str) -> (String, Option<String>) {
let mut depth: i32 = 0;
let mut out = String::with_capacity(expr.len());
for c in expr.chars() {
match c {
'(' => {
depth += 1;
out.push(c);
}
')' => {
if depth > 0 {
depth -= 1;
out.push(c);
} }
_ => out.push(c),
}
}
for _ in 0..depth {
out.push(')'); }
if out == expr {
(out, None)
} else {
let r = out.clone();
(out, Some(r))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn linter() -> Linter {
Linter::from_tokens(
["org/toyota", "org/honda", "artifact/battery_cell", "geo/apac", "powertrain/electric", "powertrain/diesel"]
.into_iter()
.map(String::from),
)
}
#[test]
fn exact_and_wildcard() {
let l = linter();
assert_eq!(l.validate_atom("org/toyota"), AtomStatus::Known);
assert!(matches!(l.validate_atom("powertrain/*"), AtomStatus::Wildcard(n) if n == 2));
}
#[test]
fn did_you_mean_scoped_to_facet() {
let l = linter();
match l.validate_atom("artifact/battery_cel") {
AtomStatus::Unknown(s) => assert_eq!(s, vec!["artifact/battery_cell".to_string()]),
other => panic!("expected Unknown, got {other:?}"),
}
match l.validate_atom("artifact/power_cube") {
AtomStatus::Unknown(s) => assert!(s.iter().all(|x| x.starts_with("artifact/"))),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn numeric_predicates_validate_against_the_numeric_namespace() {
let l = linter().with_numeric_fields(["range_km".to_string(), "year".to_string()]);
assert!(l.lint("(num range_km gt 500)").ok, "{:?}", l.lint("(num range_km gt 500)").errors);
assert!(l.lint("(and powertrain/electric (num year ge 2020))").ok);
let bad = l.lint("(num rnge_km gt 500)");
assert!(!bad.ok);
assert_eq!(bad.errors[0].suggestions[0], "range_km");
assert!(bad.errors[0].message.contains("numeric field"), "{}", bad.errors[0].message);
assert!(linter().lint("(num anything gt 1)").ok);
}
#[test]
fn unknown_dimension_reports_facets_not_bogus_suggestions() {
let l = linter();
let r = l.lint("artifact/battery_cel");
assert!(!r.ok);
assert_eq!(r.errors[0].suggestions[0], "artifact/battery_cell");
let r2 = l.lint("gene/brca1");
assert!(!r2.ok);
assert!(r2.errors[0].suggestions.is_empty(), "must not suggest values from another dimension");
assert!(r2.errors[0].message.contains("dimension 'gene' is not in this corpus"), "{}", r2.errors[0].message);
assert!(r2.errors[0].message.contains("artifact"), "should list real facets: {}", r2.errors[0].message);
}
#[test]
fn lint_catches_bad_atom_and_repairs_parens() {
let l = linter();
let r = l.lint("(and org/toyota (not powertrain/diesel)"); assert!(r.repaired.is_some());
assert!(r.ok, "all atoms valid: {:?}", r.errors);
let bad = l.lint("(and org/tyota powertrain/electric)");
assert!(!bad.ok);
assert_eq!(bad.errors[0].suggestions[0], "org/toyota"); }
#[test]
fn evidential_fusion_forms_lint_clean() {
let l = Linter::from_tokens(
["artifact/battery_cell", "artifact/power_cube"].into_iter().map(String::from));
let q = "(combine-ds :max-conflict 0.20 \
(stream :id sensor :mass-assignments ((mass (artifact/battery_cell) 0.7) \
(mass (artifact/battery_cell artifact/power_cube) 0.3))))";
let r = l.lint(q);
assert!(r.ok, "should lint clean, got {:?}", r.errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>());
assert!(l.lint("(evidence artifact/battery_cell :min-bel 0.8 :max-pl 0.95)").ok);
let bad = l.lint("(combine-ds :max-conflict 0.2 \
(stream :id s :mass-assignments ((mass (artifact/nonexistent) 1.0))))");
assert!(!bad.ok, "an unknown focal atom must still be reported");
assert!(bad.errors.iter().any(|e| e.atom.contains("nonexistent")), "{:?}", bad.errors);
}
}