Skip to main content

seqc/lint/
linter.rs

1//! The `Linter` walks the AST, matches compiled patterns against word-call
2//! sequences, and emits `LintDiagnostic` entries. Also houses the if/else
3//! nesting-depth check.
4
5use std::path::Path;
6
7use crate::ast::{Program, Statement, WordDef};
8
9use super::types::{
10    CompiledPattern, LintConfig, LintDiagnostic, MAX_NESTING_DEPTH, PatternElement, Severity,
11    WordInfo,
12};
13
14pub struct Linter {
15    patterns: Vec<CompiledPattern>,
16}
17
18impl Linter {
19    /// Create a new linter with the given configuration
20    pub fn new(config: &LintConfig) -> Result<Self, String> {
21        let mut patterns = Vec::new();
22        for rule in &config.rules {
23            patterns.push(CompiledPattern::compile(rule.clone())?);
24        }
25        Ok(Linter { patterns })
26    }
27
28    /// Create a linter with default configuration
29    pub fn with_defaults() -> Result<Self, String> {
30        let config = LintConfig::default_config()?;
31        Self::new(&config)
32    }
33
34    /// Lint a program and return all diagnostics
35    pub fn lint_program(&self, program: &Program, file: &Path) -> Vec<LintDiagnostic> {
36        let mut diagnostics = Vec::new();
37
38        for word in &program.words {
39            self.lint_word(word, file, &mut diagnostics);
40        }
41
42        diagnostics
43    }
44
45    /// Lint a single word definition
46    fn lint_word(&self, word: &WordDef, file: &Path, diagnostics: &mut Vec<LintDiagnostic>) {
47        let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
48
49        // Collect diagnostics locally first, then filter by allowed_lints
50        let mut local_diagnostics = Vec::new();
51
52        self.lint_statement_list(
53            &word.body,
54            word,
55            file,
56            fallback_line,
57            &mut local_diagnostics,
58        );
59
60        // Check for deeply nested if/else chains (top-level word body only)
61        let max_depth = Self::max_if_nesting_depth(&word.body);
62        if max_depth >= MAX_NESTING_DEPTH {
63            local_diagnostics.push(LintDiagnostic {
64                id: "deep-nesting".to_string(),
65                message: format!(
66                    "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
67                    max_depth
68                ),
69                severity: Severity::Hint,
70                replacement: String::new(),
71                file: file.to_path_buf(),
72                line: fallback_line,
73                end_line: None,
74                start_column: None,
75                end_column: None,
76                word_name: word.name.clone(),
77                start_index: 0,
78                end_index: 0,
79            });
80        }
81
82        // Filter out diagnostics that are allowed via # seq:allow(lint-id) annotation
83        for diagnostic in local_diagnostics {
84            if !word.allowed_lints.contains(&diagnostic.id) {
85                diagnostics.push(diagnostic);
86            }
87        }
88    }
89
90    /// Calculate the maximum if/else nesting depth in a statement list
91    fn max_if_nesting_depth(statements: &[Statement]) -> usize {
92        let mut max_depth = 0;
93        for stmt in statements {
94            let depth = Self::if_nesting_depth(stmt, 0);
95            if depth > max_depth {
96                max_depth = depth;
97            }
98        }
99        max_depth
100    }
101
102    /// Calculate if/else nesting depth for a single statement
103    fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
104        match stmt {
105            Statement::If {
106                then_branch,
107                else_branch,
108                span: _,
109            } => {
110                // This if adds one level of nesting
111                let new_depth = current_depth + 1;
112
113                // Check then branch for further nesting
114                let then_max = then_branch
115                    .iter()
116                    .map(|s| Self::if_nesting_depth(s, new_depth))
117                    .max()
118                    .unwrap_or(new_depth);
119
120                // Check else branch - nested ifs in else are the classic "else if" chain
121                let else_max = else_branch
122                    .as_ref()
123                    .map(|stmts| {
124                        stmts
125                            .iter()
126                            .map(|s| Self::if_nesting_depth(s, new_depth))
127                            .max()
128                            .unwrap_or(new_depth)
129                    })
130                    .unwrap_or(new_depth);
131
132                then_max.max(else_max)
133            }
134            Statement::Quotation { body, .. } => {
135                // Quotations start fresh nesting count (they're separate code blocks)
136                body.iter()
137                    .map(|s| Self::if_nesting_depth(s, 0))
138                    .max()
139                    .unwrap_or(0)
140            }
141            Statement::Match { arms, span: _ } => {
142                // Match arms don't count as if nesting, but check for ifs inside
143                arms.iter()
144                    .flat_map(|arm| arm.body.iter())
145                    .map(|s| Self::if_nesting_depth(s, current_depth))
146                    .max()
147                    .unwrap_or(current_depth)
148            }
149            _ => current_depth,
150        }
151    }
152
153    /// Extract a flat sequence of word names with spans from statements.
154    /// Non-WordCall statements (literals, quotations, etc.) are represented as
155    /// a special marker `<non-word>` to prevent false pattern matches across
156    /// non-consecutive word calls.
157    fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
158        let mut words = Vec::new();
159        for stmt in statements {
160            if let Statement::WordCall { name, span } = stmt {
161                words.push(WordInfo {
162                    name: name.as_str(),
163                    span: span.as_ref(),
164                });
165            } else {
166                // Insert a marker for non-word statements to break up patterns.
167                // This prevents false positives like matching "swap swap" when
168                // there's a literal between them: "swap 0 swap"
169                words.push(WordInfo {
170                    name: "<non-word>",
171                    span: None,
172                });
173            }
174        }
175        words
176    }
177
178    /// Find all matches of a pattern in a word sequence
179    fn find_matches(
180        &self,
181        word_infos: &[WordInfo],
182        pattern: &CompiledPattern,
183        word: &WordDef,
184        file: &Path,
185        fallback_line: usize,
186        diagnostics: &mut Vec<LintDiagnostic>,
187    ) {
188        if word_infos.is_empty() || pattern.elements.is_empty() {
189            return;
190        }
191
192        // Sliding window match
193        let mut i = 0;
194        while i < word_infos.len() {
195            if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
196                // Extract position info from spans if available
197                let first_span = word_infos[i].span;
198                let last_span = word_infos[i + match_len - 1].span;
199
200                // Use span line if available, otherwise fall back to word definition line
201                let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
202
203                // Calculate end line and column range
204                let (end_line, start_column, end_column) =
205                    if let (Some(first), Some(last)) = (first_span, last_span) {
206                        if first.line == last.line {
207                            // Same line: column range spans from first word's start to last word's end
208                            (None, Some(first.column), Some(last.column + last.length))
209                        } else {
210                            // Multi-line match: track end line and end column
211                            (
212                                Some(last.line),
213                                Some(first.column),
214                                Some(last.column + last.length),
215                            )
216                        }
217                    } else {
218                        (None, None, None)
219                    };
220
221                diagnostics.push(LintDiagnostic {
222                    id: pattern.rule.id.clone(),
223                    message: pattern.rule.message.clone(),
224                    severity: pattern.rule.severity,
225                    replacement: pattern.rule.replacement.clone(),
226                    file: file.to_path_buf(),
227                    line,
228                    end_line,
229                    start_column,
230                    end_column,
231                    word_name: word.name.clone(),
232                    start_index: i,
233                    end_index: i + match_len,
234                });
235                // Skip past the match to avoid overlapping matches
236                i += match_len;
237            } else {
238                i += 1;
239            }
240        }
241    }
242
243    /// Try to match pattern at position, returning match length if successful
244    fn try_match_at(
245        word_infos: &[WordInfo],
246        start: usize,
247        elements: &[PatternElement],
248    ) -> Option<usize> {
249        let mut word_idx = start;
250        let mut elem_idx = 0;
251
252        while elem_idx < elements.len() {
253            match &elements[elem_idx] {
254                PatternElement::Word(expected) => {
255                    if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
256                        return None;
257                    }
258                    word_idx += 1;
259                    elem_idx += 1;
260                }
261                PatternElement::SingleWildcard(_) => {
262                    if word_idx >= word_infos.len() {
263                        return None;
264                    }
265                    word_idx += 1;
266                    elem_idx += 1;
267                }
268                PatternElement::MultiWildcard => {
269                    // Multi-wildcard: try all possible lengths
270                    elem_idx += 1;
271                    if elem_idx >= elements.len() {
272                        // Wildcard at end matches rest
273                        return Some(word_infos.len() - start);
274                    }
275                    // Try matching remaining pattern at each position
276                    for try_idx in word_idx..=word_infos.len() {
277                        if let Some(rest_len) =
278                            Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
279                        {
280                            return Some(try_idx - start + rest_len);
281                        }
282                    }
283                    return None;
284                }
285            }
286        }
287
288        Some(word_idx - start)
289    }
290
291    /// Run all patterns against `statements`, then recurse into any
292    /// nested forms (quotations, if branches, match arms) within them.
293    /// Single source of truth for the "scan + descend" loop used by both
294    /// the top-level word body and every nested body.
295    fn lint_statement_list(
296        &self,
297        statements: &[Statement],
298        word: &WordDef,
299        file: &Path,
300        fallback_line: usize,
301        diagnostics: &mut Vec<LintDiagnostic>,
302    ) {
303        let word_infos = self.extract_word_sequence(statements);
304        for pattern in &self.patterns {
305            self.find_matches(&word_infos, pattern, word, file, fallback_line, diagnostics);
306        }
307        self.lint_nested(statements, word, file, diagnostics);
308    }
309
310    /// Walk `statements`, descending into any nested body (quotation,
311    /// if branch, match arm) by handing it back to `lint_statement_list`.
312    /// The combined recursion does extract → patterns → recurse on every
313    /// reachable body in the AST.
314    fn lint_nested(
315        &self,
316        statements: &[Statement],
317        word: &WordDef,
318        file: &Path,
319        diagnostics: &mut Vec<LintDiagnostic>,
320    ) {
321        let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
322
323        for stmt in statements {
324            match stmt {
325                Statement::Quotation { body, .. } => {
326                    self.lint_statement_list(body, word, file, fallback_line, diagnostics);
327                }
328                Statement::If {
329                    then_branch,
330                    else_branch,
331                    span: _,
332                } => {
333                    self.lint_statement_list(then_branch, word, file, fallback_line, diagnostics);
334                    if let Some(else_stmts) = else_branch {
335                        self.lint_statement_list(
336                            else_stmts,
337                            word,
338                            file,
339                            fallback_line,
340                            diagnostics,
341                        );
342                    }
343                }
344                Statement::Match { arms, span: _ } => {
345                    for arm in arms {
346                        self.lint_statement_list(&arm.body, word, file, fallback_line, diagnostics);
347                    }
348                }
349                _ => {}
350            }
351        }
352    }
353}