Skip to main content

lemon/dsl/
lint.rs

1//! Semantic lints over a parsed strategy. The parser already rejects syntax
2//! errors, unknown ops, and bad arities; the two mistakes it *cannot* catch
3//! are exactly what this module reports:
4//!
5//! - **Unknown series** — a typo'd bare identifier silently becomes a `Data`
6//!   leaf (`clsoe` parses fine and fails at engine eval). With a known-series
7//!   list, the linter flags it at its source position, with a did-you-mean
8//!   suggestion.
9//! - **Unused `let` bindings** — `let` is parse-time inlining, so a binding
10//!   that is never referenced silently vanishes from the tree.
11
12use super::parse::parse_analyzed;
13use super::ParseError;
14
15/// One lint warning, at a 1-based source position.
16#[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
29/// Lint `src`. Parse errors surface as `Err`; a clean parse yields warnings
30/// (possibly none). `known_series = None` skips the unknown-series check
31/// (unused-`let` is always checked); pass the engine's series list to enable
32/// it. Repeated references to the same unknown name warn once.
33pub 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
65/// Closest known name within edit distance 2, if any.
66fn 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        // known names are clean
111        assert!(lint("close > sma(close, 20)", Some(&known))
112            .unwrap()
113            .is_empty());
114        // without a series list the check is skipped
115        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        // one unused let + ONE unknown-series (bogus referenced twice)
124        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        // no suggestion when nothing is close
128        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}