1use super::parse::parse_analyzed;
13use super::ParseError;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Lint {
18 pub line: usize,
19 pub col: usize,
20 pub message: String,
21}
22
23impl std::fmt::Display for Lint {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 write!(f, "{}:{}: {}", self.line, self.col, self.message)
26 }
27}
28
29pub fn lint(src: &str, known_series: Option<&[String]>) -> Result<Vec<Lint>, ParseError> {
34 let a = parse_analyzed(src)?;
35 let mut out: Vec<Lint> = a
36 .unused_lets
37 .iter()
38 .map(|(name, line, col)| Lint {
39 line: *line,
40 col: *col,
41 message: format!("unused let binding `{name}`"),
42 })
43 .collect();
44 if let Some(known) = known_series {
45 let mut seen = std::collections::HashSet::new();
46 for (name, line, col) in &a.data_refs {
47 if known.iter().any(|k| k == name) || !seen.insert(name.clone()) {
48 continue;
49 }
50 let message = match closest(name, known) {
51 Some(s) => format!("unknown series `{name}` — did you mean `{s}`?"),
52 None => format!("unknown series `{name}`"),
53 };
54 out.push(Lint {
55 line: *line,
56 col: *col,
57 message,
58 });
59 }
60 }
61 out.sort_by_key(|l| (l.line, l.col));
62 Ok(out)
63}
64
65fn closest<'a>(name: &str, known: &'a [String]) -> Option<&'a str> {
67 known
68 .iter()
69 .map(|k| (levenshtein(name, k), k))
70 .filter(|(d, _)| *d <= 2)
71 .min_by_key(|(d, _)| *d)
72 .map(|(_, k)| k.as_str())
73}
74
75fn levenshtein(a: &str, b: &str) -> usize {
76 let a: Vec<char> = a.chars().collect();
77 let b: Vec<char> = b.chars().collect();
78 let mut prev: Vec<usize> = (0..=b.len()).collect();
79 let mut cur = vec![0usize; b.len() + 1];
80 for i in 1..=a.len() {
81 cur[0] = i;
82 for j in 1..=b.len() {
83 let sub = prev[j - 1] + usize::from(a[i - 1] != b[j - 1]);
84 cur[j] = sub.min(prev[j] + 1).min(cur[j - 1] + 1);
85 }
86 std::mem::swap(&mut prev, &mut cur);
87 }
88 prev[b.len()]
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 fn series(names: &[&str]) -> Vec<String> {
96 names.iter().map(|s| s.to_string()).collect()
97 }
98
99 #[test]
100 fn flags_unknown_series_with_suggestion() {
101 let known = series(&["close", "pe", "market_cap"]);
102 let lints = lint("clsoe > sma(close, 20)", Some(&known)).unwrap();
103 assert_eq!(lints.len(), 1);
104 assert_eq!((lints[0].line, lints[0].col), (1, 1));
105 assert!(
106 lints[0].message.contains("did you mean `close`"),
107 "{}",
108 lints[0].message
109 );
110 assert!(lint("close > sma(close, 20)", Some(&known))
112 .unwrap()
113 .is_empty());
114 assert!(lint("clsoe > 1", None).unwrap().is_empty());
116 }
117
118 #[test]
119 fn flags_unused_let_and_dedupes_unknowns() {
120 let known = series(&["close"]);
121 let src = "let ma = sma(close, 20)\nbogus > 1 and bogus < 2";
122 let lints = lint(src, Some(&known)).unwrap();
123 assert_eq!(lints.len(), 2, "{lints:?}");
125 assert!(lints[0].message.contains("unused let binding `ma`"));
126 assert!(lints[1].message.contains("unknown series `bogus`"));
127 assert!(!lints[1].message.contains("did you mean"));
129 }
130
131 #[test]
132 fn parse_errors_pass_through() {
133 assert!(lint("sma(close,", None).is_err());
134 }
135}