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        // Extract word sequence from the body (with span info)
53        let word_infos = self.extract_word_sequence(&word.body);
54
55        // Try each pattern
56        for pattern in &self.patterns {
57            self.find_matches(
58                &word_infos,
59                pattern,
60                word,
61                file,
62                fallback_line,
63                &mut local_diagnostics,
64            );
65        }
66
67        // Check for deeply nested if/else chains
68        let max_depth = Self::max_if_nesting_depth(&word.body);
69        if max_depth >= MAX_NESTING_DEPTH {
70            local_diagnostics.push(LintDiagnostic {
71                id: "deep-nesting".to_string(),
72                message: format!(
73                    "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
74                    max_depth
75                ),
76                severity: Severity::Hint,
77                replacement: String::new(),
78                file: file.to_path_buf(),
79                line: fallback_line,
80                end_line: None,
81                start_column: None,
82                end_column: None,
83                word_name: word.name.clone(),
84                start_index: 0,
85                end_index: 0,
86            });
87        }
88
89        // Recursively lint nested structures (quotations, if branches)
90        self.lint_nested(&word.body, word, file, &mut local_diagnostics);
91
92        // Filter out diagnostics that are allowed via # seq:allow(lint-id) annotation
93        for diagnostic in local_diagnostics {
94            if !word.allowed_lints.contains(&diagnostic.id) {
95                diagnostics.push(diagnostic);
96            }
97        }
98    }
99
100    /// Calculate the maximum if/else nesting depth in a statement list
101    fn max_if_nesting_depth(statements: &[Statement]) -> usize {
102        let mut max_depth = 0;
103        for stmt in statements {
104            let depth = Self::if_nesting_depth(stmt, 0);
105            if depth > max_depth {
106                max_depth = depth;
107            }
108        }
109        max_depth
110    }
111
112    /// Calculate if/else nesting depth for a single statement
113    fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
114        match stmt {
115            Statement::If {
116                then_branch,
117                else_branch,
118                span: _,
119            } => {
120                // This if adds one level of nesting
121                let new_depth = current_depth + 1;
122
123                // Check then branch for further nesting
124                let then_max = then_branch
125                    .iter()
126                    .map(|s| Self::if_nesting_depth(s, new_depth))
127                    .max()
128                    .unwrap_or(new_depth);
129
130                // Check else branch - nested ifs in else are the classic "else if" chain
131                let else_max = else_branch
132                    .as_ref()
133                    .map(|stmts| {
134                        stmts
135                            .iter()
136                            .map(|s| Self::if_nesting_depth(s, new_depth))
137                            .max()
138                            .unwrap_or(new_depth)
139                    })
140                    .unwrap_or(new_depth);
141
142                then_max.max(else_max)
143            }
144            Statement::Quotation { body, .. } => {
145                // Quotations start fresh nesting count (they're separate code blocks)
146                body.iter()
147                    .map(|s| Self::if_nesting_depth(s, 0))
148                    .max()
149                    .unwrap_or(0)
150            }
151            Statement::Match { arms, span: _ } => {
152                // Match arms don't count as if nesting, but check for ifs inside
153                arms.iter()
154                    .flat_map(|arm| arm.body.iter())
155                    .map(|s| Self::if_nesting_depth(s, current_depth))
156                    .max()
157                    .unwrap_or(current_depth)
158            }
159            _ => current_depth,
160        }
161    }
162
163    /// Extract a flat sequence of word names with spans from statements.
164    /// Non-WordCall statements (literals, quotations, etc.) are represented as
165    /// a special marker `<non-word>` to prevent false pattern matches across
166    /// non-consecutive word calls.
167    fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
168        let mut words = Vec::new();
169        for stmt in statements {
170            if let Statement::WordCall { name, span } = stmt {
171                words.push(WordInfo {
172                    name: name.as_str(),
173                    span: span.as_ref(),
174                });
175            } else {
176                // Insert a marker for non-word statements to break up patterns.
177                // This prevents false positives like matching "swap swap" when
178                // there's a literal between them: "swap 0 swap"
179                words.push(WordInfo {
180                    name: "<non-word>",
181                    span: None,
182                });
183            }
184        }
185        words
186    }
187
188    /// Find all matches of a pattern in a word sequence
189    fn find_matches(
190        &self,
191        word_infos: &[WordInfo],
192        pattern: &CompiledPattern,
193        word: &WordDef,
194        file: &Path,
195        fallback_line: usize,
196        diagnostics: &mut Vec<LintDiagnostic>,
197    ) {
198        if word_infos.is_empty() || pattern.elements.is_empty() {
199            return;
200        }
201
202        // Sliding window match
203        let mut i = 0;
204        while i < word_infos.len() {
205            if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
206                // Extract position info from spans if available
207                let first_span = word_infos[i].span;
208                let last_span = word_infos[i + match_len - 1].span;
209
210                // Use span line if available, otherwise fall back to word definition line
211                let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
212
213                // Calculate end line and column range
214                let (end_line, start_column, end_column) =
215                    if let (Some(first), Some(last)) = (first_span, last_span) {
216                        if first.line == last.line {
217                            // Same line: column range spans from first word's start to last word's end
218                            (None, Some(first.column), Some(last.column + last.length))
219                        } else {
220                            // Multi-line match: track end line and end column
221                            (
222                                Some(last.line),
223                                Some(first.column),
224                                Some(last.column + last.length),
225                            )
226                        }
227                    } else {
228                        (None, None, None)
229                    };
230
231                diagnostics.push(LintDiagnostic {
232                    id: pattern.rule.id.clone(),
233                    message: pattern.rule.message.clone(),
234                    severity: pattern.rule.severity,
235                    replacement: pattern.rule.replacement.clone(),
236                    file: file.to_path_buf(),
237                    line,
238                    end_line,
239                    start_column,
240                    end_column,
241                    word_name: word.name.clone(),
242                    start_index: i,
243                    end_index: i + match_len,
244                });
245                // Skip past the match to avoid overlapping matches
246                i += match_len;
247            } else {
248                i += 1;
249            }
250        }
251    }
252
253    /// Try to match pattern at position, returning match length if successful
254    fn try_match_at(
255        word_infos: &[WordInfo],
256        start: usize,
257        elements: &[PatternElement],
258    ) -> Option<usize> {
259        let mut word_idx = start;
260        let mut elem_idx = 0;
261
262        while elem_idx < elements.len() {
263            match &elements[elem_idx] {
264                PatternElement::Word(expected) => {
265                    if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
266                        return None;
267                    }
268                    word_idx += 1;
269                    elem_idx += 1;
270                }
271                PatternElement::SingleWildcard(_) => {
272                    if word_idx >= word_infos.len() {
273                        return None;
274                    }
275                    word_idx += 1;
276                    elem_idx += 1;
277                }
278                PatternElement::MultiWildcard => {
279                    // Multi-wildcard: try all possible lengths
280                    elem_idx += 1;
281                    if elem_idx >= elements.len() {
282                        // Wildcard at end matches rest
283                        return Some(word_infos.len() - start);
284                    }
285                    // Try matching remaining pattern at each position
286                    for try_idx in word_idx..=word_infos.len() {
287                        if let Some(rest_len) =
288                            Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
289                        {
290                            return Some(try_idx - start + rest_len);
291                        }
292                    }
293                    return None;
294                }
295            }
296        }
297
298        Some(word_idx - start)
299    }
300
301    /// Recursively lint nested structures
302    fn lint_nested(
303        &self,
304        statements: &[Statement],
305        word: &WordDef,
306        file: &Path,
307        diagnostics: &mut Vec<LintDiagnostic>,
308    ) {
309        let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
310
311        for stmt in statements {
312            match stmt {
313                Statement::Quotation { body, .. } => {
314                    // Lint the quotation body
315                    let word_infos = self.extract_word_sequence(body);
316                    for pattern in &self.patterns {
317                        self.find_matches(
318                            &word_infos,
319                            pattern,
320                            word,
321                            file,
322                            fallback_line,
323                            diagnostics,
324                        );
325                    }
326                    // Recurse into nested quotations
327                    self.lint_nested(body, word, file, diagnostics);
328                }
329                Statement::If {
330                    then_branch,
331                    else_branch,
332                    span: _,
333                } => {
334                    // Lint both branches
335                    let word_infos = self.extract_word_sequence(then_branch);
336                    for pattern in &self.patterns {
337                        self.find_matches(
338                            &word_infos,
339                            pattern,
340                            word,
341                            file,
342                            fallback_line,
343                            diagnostics,
344                        );
345                    }
346                    self.lint_nested(then_branch, word, file, diagnostics);
347
348                    if let Some(else_stmts) = else_branch {
349                        let word_infos = self.extract_word_sequence(else_stmts);
350                        for pattern in &self.patterns {
351                            self.find_matches(
352                                &word_infos,
353                                pattern,
354                                word,
355                                file,
356                                fallback_line,
357                                diagnostics,
358                            );
359                        }
360                        self.lint_nested(else_stmts, word, file, diagnostics);
361                    }
362                }
363                Statement::Match { arms, span: _ } => {
364                    for arm in arms {
365                        let word_infos = self.extract_word_sequence(&arm.body);
366                        for pattern in &self.patterns {
367                            self.find_matches(
368                                &word_infos,
369                                pattern,
370                                word,
371                                file,
372                                fallback_line,
373                                diagnostics,
374                            );
375                        }
376                        self.lint_nested(&arm.body, word, file, diagnostics);
377                    }
378                }
379                _ => {}
380            }
381        }
382    }
383}