seqc/
lint.rs

1//! Lint Engine for Seq
2//!
3//! A clippy-inspired lint tool that detects common patterns and suggests improvements.
4//! Phase 1: Syntactic pattern matching on word sequences.
5//!
6//! # Architecture
7//!
8//! - `LintConfig` - Parsed lint rules from TOML
9//! - `Pattern` - Compiled pattern for matching
10//! - `Linter` - Walks AST and finds matches
11//! - `LintDiagnostic` - Output format compatible with LSP
12//!
13//! # Known Limitations (Phase 1)
14//!
15//! - **No quotation boundary awareness**: Patterns match across statement boundaries
16//!   within a word body. Patterns like `[ drop` would incorrectly match `[` followed
17//!   by `drop` anywhere, not just at quotation start. Such patterns should be avoided
18//!   until Phase 2 adds quotation-aware matching.
19
20use crate::ast::{Program, Span, Statement, WordDef};
21use serde::Deserialize;
22use std::path::{Path, PathBuf};
23
24/// Embedded default lint rules
25pub static DEFAULT_LINTS: &str = include_str!("lints.toml");
26
27/// Maximum if/else nesting depth before warning (structural lint)
28/// 4 levels deep is the threshold - beyond this, consider `cond` or helper words
29pub const MAX_NESTING_DEPTH: usize = 4;
30
31/// Severity level for lint diagnostics
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum Severity {
35    Error,
36    Warning,
37    Hint,
38}
39
40impl Severity {
41    /// Convert to LSP DiagnosticSeverity number
42    pub fn to_lsp_severity(&self) -> u32 {
43        match self {
44            Severity::Error => 1,
45            Severity::Warning => 2,
46            Severity::Hint => 4,
47        }
48    }
49}
50
51/// A single lint rule from configuration
52#[derive(Debug, Clone, Deserialize)]
53pub struct LintRule {
54    /// Unique identifier for the lint
55    pub id: String,
56    /// Pattern to match (space-separated words, $X for wildcards)
57    pub pattern: String,
58    /// Suggested replacement (empty string means "remove")
59    #[serde(default)]
60    pub replacement: String,
61    /// Human-readable message
62    pub message: String,
63    /// Severity level
64    #[serde(default = "default_severity")]
65    pub severity: Severity,
66}
67
68fn default_severity() -> Severity {
69    Severity::Warning
70}
71
72/// Lint configuration containing all rules
73#[derive(Debug, Clone, Deserialize)]
74pub struct LintConfig {
75    #[serde(rename = "lint")]
76    pub rules: Vec<LintRule>,
77}
78
79impl LintConfig {
80    /// Parse lint configuration from TOML string
81    pub fn from_toml(toml_str: &str) -> Result<Self, String> {
82        toml::from_str(toml_str).map_err(|e| format!("Failed to parse lint config: {}", e))
83    }
84
85    /// Load default embedded lint configuration
86    pub fn default_config() -> Result<Self, String> {
87        Self::from_toml(DEFAULT_LINTS)
88    }
89
90    /// Merge another config into this one (user overrides)
91    pub fn merge(&mut self, other: LintConfig) {
92        // User rules override defaults with same id
93        for rule in other.rules {
94            if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule.id) {
95                *existing = rule;
96            } else {
97                self.rules.push(rule);
98            }
99        }
100    }
101}
102
103/// A compiled pattern for efficient matching
104#[derive(Debug, Clone)]
105pub struct CompiledPattern {
106    /// The original rule
107    pub rule: LintRule,
108    /// Pattern elements (words or wildcards)
109    pub elements: Vec<PatternElement>,
110}
111
112/// Element in a compiled pattern
113#[derive(Debug, Clone, PartialEq)]
114pub enum PatternElement {
115    /// Exact word match
116    Word(String),
117    /// Single-word wildcard ($X, $Y, etc.)
118    SingleWildcard(String),
119    /// Multi-word wildcard ($...)
120    MultiWildcard,
121}
122
123impl CompiledPattern {
124    /// Compile a pattern string into elements
125    pub fn compile(rule: LintRule) -> Result<Self, String> {
126        let mut elements = Vec::new();
127        let mut multi_wildcard_count = 0;
128
129        for token in rule.pattern.split_whitespace() {
130            if token == "$..." {
131                multi_wildcard_count += 1;
132                elements.push(PatternElement::MultiWildcard);
133            } else if token.starts_with('$') {
134                elements.push(PatternElement::SingleWildcard(token.to_string()));
135            } else {
136                elements.push(PatternElement::Word(token.to_string()));
137            }
138        }
139
140        if elements.is_empty() {
141            return Err(format!("Empty pattern in lint rule '{}'", rule.id));
142        }
143
144        // Validate: at most one multi-wildcard per pattern to avoid
145        // exponential backtracking complexity
146        if multi_wildcard_count > 1 {
147            return Err(format!(
148                "Pattern in lint rule '{}' has {} multi-wildcards ($...), but at most 1 is allowed",
149                rule.id, multi_wildcard_count
150            ));
151        }
152
153        Ok(CompiledPattern { rule, elements })
154    }
155}
156
157/// A lint diagnostic (match found)
158#[derive(Debug, Clone)]
159pub struct LintDiagnostic {
160    /// Lint rule ID
161    pub id: String,
162    /// Human-readable message
163    pub message: String,
164    /// Severity level
165    pub severity: Severity,
166    /// Suggested replacement
167    pub replacement: String,
168    /// File where the match was found
169    pub file: PathBuf,
170    /// Start line number (0-indexed)
171    pub line: usize,
172    /// End line number (0-indexed), for multi-line matches
173    pub end_line: Option<usize>,
174    /// Start column (0-indexed), if available from source spans
175    pub start_column: Option<usize>,
176    /// End column (0-indexed, exclusive), if available from source spans
177    pub end_column: Option<usize>,
178    /// Word name where the match was found
179    pub word_name: String,
180    /// Start index in the word body
181    pub start_index: usize,
182    /// End index in the word body (exclusive)
183    pub end_index: usize,
184}
185
186/// Word call info extracted from a statement, including optional span
187#[derive(Debug, Clone)]
188struct WordInfo<'a> {
189    name: &'a str,
190    span: Option<&'a Span>,
191}
192
193/// The linter engine
194pub struct Linter {
195    patterns: Vec<CompiledPattern>,
196}
197
198impl Linter {
199    /// Create a new linter with the given configuration
200    pub fn new(config: &LintConfig) -> Result<Self, String> {
201        let mut patterns = Vec::new();
202        for rule in &config.rules {
203            patterns.push(CompiledPattern::compile(rule.clone())?);
204        }
205        Ok(Linter { patterns })
206    }
207
208    /// Create a linter with default configuration
209    pub fn with_defaults() -> Result<Self, String> {
210        let config = LintConfig::default_config()?;
211        Self::new(&config)
212    }
213
214    /// Lint a program and return all diagnostics
215    pub fn lint_program(&self, program: &Program, file: &Path) -> Vec<LintDiagnostic> {
216        let mut diagnostics = Vec::new();
217
218        for word in &program.words {
219            self.lint_word(word, file, &mut diagnostics);
220        }
221
222        diagnostics
223    }
224
225    /// Lint a single word definition
226    fn lint_word(&self, word: &WordDef, file: &Path, diagnostics: &mut Vec<LintDiagnostic>) {
227        let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
228
229        // Collect diagnostics locally first, then filter by allowed_lints
230        let mut local_diagnostics = Vec::new();
231
232        // Extract word sequence from the body (with span info)
233        let word_infos = self.extract_word_sequence(&word.body);
234
235        // Try each pattern
236        for pattern in &self.patterns {
237            self.find_matches(
238                &word_infos,
239                pattern,
240                word,
241                file,
242                fallback_line,
243                &mut local_diagnostics,
244            );
245        }
246
247        // Check for deeply nested if/else chains
248        let max_depth = Self::max_if_nesting_depth(&word.body);
249        if max_depth >= MAX_NESTING_DEPTH {
250            local_diagnostics.push(LintDiagnostic {
251                id: "deep-nesting".to_string(),
252                message: format!(
253                    "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
254                    max_depth
255                ),
256                severity: Severity::Hint,
257                replacement: String::new(),
258                file: file.to_path_buf(),
259                line: fallback_line,
260                end_line: None,
261                start_column: None,
262                end_column: None,
263                word_name: word.name.clone(),
264                start_index: 0,
265                end_index: 0,
266            });
267        }
268
269        // Recursively lint nested structures (quotations, if branches)
270        self.lint_nested(&word.body, word, file, &mut local_diagnostics);
271
272        // Filter out diagnostics that are allowed via # seq:allow(lint-id) annotation
273        for diagnostic in local_diagnostics {
274            if !word.allowed_lints.contains(&diagnostic.id) {
275                diagnostics.push(diagnostic);
276            }
277        }
278    }
279
280    /// Calculate the maximum if/else nesting depth in a statement list
281    fn max_if_nesting_depth(statements: &[Statement]) -> usize {
282        let mut max_depth = 0;
283        for stmt in statements {
284            let depth = Self::if_nesting_depth(stmt, 0);
285            if depth > max_depth {
286                max_depth = depth;
287            }
288        }
289        max_depth
290    }
291
292    /// Calculate if/else nesting depth for a single statement
293    fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
294        match stmt {
295            Statement::If {
296                then_branch,
297                else_branch,
298            } => {
299                // This if adds one level of nesting
300                let new_depth = current_depth + 1;
301
302                // Check then branch for further nesting
303                let then_max = then_branch
304                    .iter()
305                    .map(|s| Self::if_nesting_depth(s, new_depth))
306                    .max()
307                    .unwrap_or(new_depth);
308
309                // Check else branch - nested ifs in else are the classic "else if" chain
310                let else_max = else_branch
311                    .as_ref()
312                    .map(|stmts| {
313                        stmts
314                            .iter()
315                            .map(|s| Self::if_nesting_depth(s, new_depth))
316                            .max()
317                            .unwrap_or(new_depth)
318                    })
319                    .unwrap_or(new_depth);
320
321                then_max.max(else_max)
322            }
323            Statement::Quotation { body, .. } => {
324                // Quotations start fresh nesting count (they're separate code blocks)
325                body.iter()
326                    .map(|s| Self::if_nesting_depth(s, 0))
327                    .max()
328                    .unwrap_or(0)
329            }
330            Statement::Match { arms } => {
331                // Match arms don't count as if nesting, but check for ifs inside
332                arms.iter()
333                    .flat_map(|arm| arm.body.iter())
334                    .map(|s| Self::if_nesting_depth(s, current_depth))
335                    .max()
336                    .unwrap_or(current_depth)
337            }
338            _ => current_depth,
339        }
340    }
341
342    /// Extract a flat sequence of word names with spans from statements.
343    /// Non-WordCall statements (literals, quotations, etc.) are represented as
344    /// a special marker `<non-word>` to prevent false pattern matches across
345    /// non-consecutive word calls.
346    fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
347        let mut words = Vec::new();
348        for stmt in statements {
349            if let Statement::WordCall { name, span } = stmt {
350                words.push(WordInfo {
351                    name: name.as_str(),
352                    span: span.as_ref(),
353                });
354            } else {
355                // Insert a marker for non-word statements to break up patterns.
356                // This prevents false positives like matching "swap swap" when
357                // there's a literal between them: "swap 0 swap"
358                words.push(WordInfo {
359                    name: "<non-word>",
360                    span: None,
361                });
362            }
363        }
364        words
365    }
366
367    /// Find all matches of a pattern in a word sequence
368    fn find_matches(
369        &self,
370        word_infos: &[WordInfo],
371        pattern: &CompiledPattern,
372        word: &WordDef,
373        file: &Path,
374        fallback_line: usize,
375        diagnostics: &mut Vec<LintDiagnostic>,
376    ) {
377        if word_infos.is_empty() || pattern.elements.is_empty() {
378            return;
379        }
380
381        // Sliding window match
382        let mut i = 0;
383        while i < word_infos.len() {
384            if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
385                // Extract position info from spans if available
386                let first_span = word_infos[i].span;
387                let last_span = word_infos[i + match_len - 1].span;
388
389                // Use span line if available, otherwise fall back to word definition line
390                let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
391
392                // Calculate end line and column range
393                let (end_line, start_column, end_column) =
394                    if let (Some(first), Some(last)) = (first_span, last_span) {
395                        if first.line == last.line {
396                            // Same line: column range spans from first word's start to last word's end
397                            (None, Some(first.column), Some(last.column + last.length))
398                        } else {
399                            // Multi-line match: track end line and end column
400                            (
401                                Some(last.line),
402                                Some(first.column),
403                                Some(last.column + last.length),
404                            )
405                        }
406                    } else {
407                        (None, None, None)
408                    };
409
410                diagnostics.push(LintDiagnostic {
411                    id: pattern.rule.id.clone(),
412                    message: pattern.rule.message.clone(),
413                    severity: pattern.rule.severity,
414                    replacement: pattern.rule.replacement.clone(),
415                    file: file.to_path_buf(),
416                    line,
417                    end_line,
418                    start_column,
419                    end_column,
420                    word_name: word.name.clone(),
421                    start_index: i,
422                    end_index: i + match_len,
423                });
424                // Skip past the match to avoid overlapping matches
425                i += match_len;
426            } else {
427                i += 1;
428            }
429        }
430    }
431
432    /// Try to match pattern at position, returning match length if successful
433    fn try_match_at(
434        word_infos: &[WordInfo],
435        start: usize,
436        elements: &[PatternElement],
437    ) -> Option<usize> {
438        let mut word_idx = start;
439        let mut elem_idx = 0;
440
441        while elem_idx < elements.len() {
442            match &elements[elem_idx] {
443                PatternElement::Word(expected) => {
444                    if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
445                        return None;
446                    }
447                    word_idx += 1;
448                    elem_idx += 1;
449                }
450                PatternElement::SingleWildcard(_) => {
451                    if word_idx >= word_infos.len() {
452                        return None;
453                    }
454                    word_idx += 1;
455                    elem_idx += 1;
456                }
457                PatternElement::MultiWildcard => {
458                    // Multi-wildcard: try all possible lengths
459                    elem_idx += 1;
460                    if elem_idx >= elements.len() {
461                        // Wildcard at end matches rest
462                        return Some(word_infos.len() - start);
463                    }
464                    // Try matching remaining pattern at each position
465                    for try_idx in word_idx..=word_infos.len() {
466                        if let Some(rest_len) =
467                            Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
468                        {
469                            return Some(try_idx - start + rest_len);
470                        }
471                    }
472                    return None;
473                }
474            }
475        }
476
477        Some(word_idx - start)
478    }
479
480    /// Recursively lint nested structures
481    fn lint_nested(
482        &self,
483        statements: &[Statement],
484        word: &WordDef,
485        file: &Path,
486        diagnostics: &mut Vec<LintDiagnostic>,
487    ) {
488        let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
489
490        for stmt in statements {
491            match stmt {
492                Statement::Quotation { body, .. } => {
493                    // Lint the quotation body
494                    let word_infos = self.extract_word_sequence(body);
495                    for pattern in &self.patterns {
496                        self.find_matches(
497                            &word_infos,
498                            pattern,
499                            word,
500                            file,
501                            fallback_line,
502                            diagnostics,
503                        );
504                    }
505                    // Recurse into nested quotations
506                    self.lint_nested(body, word, file, diagnostics);
507                }
508                Statement::If {
509                    then_branch,
510                    else_branch,
511                } => {
512                    // Lint both branches
513                    let word_infos = self.extract_word_sequence(then_branch);
514                    for pattern in &self.patterns {
515                        self.find_matches(
516                            &word_infos,
517                            pattern,
518                            word,
519                            file,
520                            fallback_line,
521                            diagnostics,
522                        );
523                    }
524                    self.lint_nested(then_branch, word, file, diagnostics);
525
526                    if let Some(else_stmts) = else_branch {
527                        let word_infos = self.extract_word_sequence(else_stmts);
528                        for pattern in &self.patterns {
529                            self.find_matches(
530                                &word_infos,
531                                pattern,
532                                word,
533                                file,
534                                fallback_line,
535                                diagnostics,
536                            );
537                        }
538                        self.lint_nested(else_stmts, word, file, diagnostics);
539                    }
540                }
541                Statement::Match { arms } => {
542                    for arm in arms {
543                        let word_infos = self.extract_word_sequence(&arm.body);
544                        for pattern in &self.patterns {
545                            self.find_matches(
546                                &word_infos,
547                                pattern,
548                                word,
549                                file,
550                                fallback_line,
551                                diagnostics,
552                            );
553                        }
554                        self.lint_nested(&arm.body, word, file, diagnostics);
555                    }
556                }
557                _ => {}
558            }
559        }
560    }
561}
562
563/// Format diagnostics for CLI output
564pub fn format_diagnostics(diagnostics: &[LintDiagnostic]) -> String {
565    let mut output = String::new();
566    for d in diagnostics {
567        let severity_str = match d.severity {
568            Severity::Error => "error",
569            Severity::Warning => "warning",
570            Severity::Hint => "hint",
571        };
572        // Include column info in output if available
573        let location = match d.start_column {
574            Some(col) => format!("{}:{}:{}", d.file.display(), d.line + 1, col + 1),
575            None => format!("{}:{}", d.file.display(), d.line + 1),
576        };
577        output.push_str(&format!(
578            "{}: {} [{}]: {}\n",
579            location, severity_str, d.id, d.message
580        ));
581        if !d.replacement.is_empty() {
582            output.push_str(&format!("  suggestion: replace with `{}`\n", d.replacement));
583        } else if d.replacement.is_empty() && d.message.contains("no effect") {
584            output.push_str("  suggestion: remove this code\n");
585        }
586    }
587    output
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    fn test_config() -> LintConfig {
595        LintConfig::from_toml(
596            r#"
597[[lint]]
598id = "redundant-dup-drop"
599pattern = "dup drop"
600replacement = ""
601message = "`dup drop` has no effect"
602severity = "warning"
603
604[[lint]]
605id = "prefer-nip"
606pattern = "swap drop"
607replacement = "nip"
608message = "prefer `nip` over `swap drop`"
609severity = "hint"
610
611[[lint]]
612id = "redundant-swap-swap"
613pattern = "swap swap"
614replacement = ""
615message = "consecutive swaps cancel out"
616severity = "warning"
617"#,
618        )
619        .unwrap()
620    }
621
622    #[test]
623    fn test_parse_config() {
624        let config = test_config();
625        assert_eq!(config.rules.len(), 3);
626        assert_eq!(config.rules[0].id, "redundant-dup-drop");
627        assert_eq!(config.rules[1].severity, Severity::Hint);
628    }
629
630    #[test]
631    fn test_compile_pattern() {
632        let rule = LintRule {
633            id: "test".to_string(),
634            pattern: "swap drop".to_string(),
635            replacement: "nip".to_string(),
636            message: "test".to_string(),
637            severity: Severity::Warning,
638        };
639        let compiled = CompiledPattern::compile(rule).unwrap();
640        assert_eq!(compiled.elements.len(), 2);
641        assert_eq!(
642            compiled.elements[0],
643            PatternElement::Word("swap".to_string())
644        );
645        assert_eq!(
646            compiled.elements[1],
647            PatternElement::Word("drop".to_string())
648        );
649    }
650
651    #[test]
652    fn test_compile_pattern_with_wildcards() {
653        let rule = LintRule {
654            id: "test".to_string(),
655            pattern: "dup $X drop".to_string(),
656            replacement: "".to_string(),
657            message: "test".to_string(),
658            severity: Severity::Warning,
659        };
660        let compiled = CompiledPattern::compile(rule).unwrap();
661        assert_eq!(compiled.elements.len(), 3);
662        assert_eq!(
663            compiled.elements[1],
664            PatternElement::SingleWildcard("$X".to_string())
665        );
666    }
667
668    #[test]
669    fn test_simple_match() {
670        let config = test_config();
671        let linter = Linter::new(&config).unwrap();
672
673        // Create a simple program with "swap drop"
674        let program = Program {
675            includes: vec![],
676            unions: vec![],
677            words: vec![WordDef {
678                name: "test".to_string(),
679                effect: None,
680                body: vec![
681                    Statement::IntLiteral(1),
682                    Statement::IntLiteral(2),
683                    Statement::WordCall {
684                        name: "swap".to_string(),
685                        span: None,
686                    },
687                    Statement::WordCall {
688                        name: "drop".to_string(),
689                        span: None,
690                    },
691                ],
692                source: None,
693                allowed_lints: vec![],
694            }],
695        };
696
697        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
698        assert_eq!(diagnostics.len(), 1);
699        assert_eq!(diagnostics[0].id, "prefer-nip");
700        assert_eq!(diagnostics[0].replacement, "nip");
701    }
702
703    #[test]
704    fn test_no_false_positives() {
705        let config = test_config();
706        let linter = Linter::new(&config).unwrap();
707
708        // "swap" followed by something other than "drop"
709        let program = Program {
710            includes: vec![],
711            unions: vec![],
712            words: vec![WordDef {
713                name: "test".to_string(),
714                effect: None,
715                body: vec![
716                    Statement::WordCall {
717                        name: "swap".to_string(),
718                        span: None,
719                    },
720                    Statement::WordCall {
721                        name: "dup".to_string(),
722                        span: None,
723                    },
724                ],
725                source: None,
726                allowed_lints: vec![],
727            }],
728        };
729
730        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
731        assert!(diagnostics.is_empty());
732    }
733
734    #[test]
735    fn test_multiple_matches() {
736        let config = test_config();
737        let linter = Linter::new(&config).unwrap();
738
739        // Two instances of "swap drop"
740        let program = Program {
741            includes: vec![],
742            unions: vec![],
743            words: vec![WordDef {
744                name: "test".to_string(),
745                effect: None,
746                body: vec![
747                    Statement::WordCall {
748                        name: "swap".to_string(),
749                        span: None,
750                    },
751                    Statement::WordCall {
752                        name: "drop".to_string(),
753                        span: None,
754                    },
755                    Statement::WordCall {
756                        name: "dup".to_string(),
757                        span: None,
758                    },
759                    Statement::WordCall {
760                        name: "swap".to_string(),
761                        span: None,
762                    },
763                    Statement::WordCall {
764                        name: "drop".to_string(),
765                        span: None,
766                    },
767                ],
768                source: None,
769                allowed_lints: vec![],
770            }],
771        };
772
773        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
774        assert_eq!(diagnostics.len(), 2);
775    }
776
777    #[test]
778    fn test_multi_wildcard_validation() {
779        // Pattern with two multi-wildcards should be rejected
780        let rule = LintRule {
781            id: "bad-pattern".to_string(),
782            pattern: "$... foo $...".to_string(),
783            replacement: "".to_string(),
784            message: "test".to_string(),
785            severity: Severity::Warning,
786        };
787        let result = CompiledPattern::compile(rule);
788        assert!(result.is_err());
789        assert!(result.unwrap_err().contains("multi-wildcards"));
790    }
791
792    #[test]
793    fn test_single_multi_wildcard_allowed() {
794        // Pattern with one multi-wildcard should be accepted
795        let rule = LintRule {
796            id: "ok-pattern".to_string(),
797            pattern: "$... foo".to_string(),
798            replacement: "".to_string(),
799            message: "test".to_string(),
800            severity: Severity::Warning,
801        };
802        let result = CompiledPattern::compile(rule);
803        assert!(result.is_ok());
804    }
805
806    #[test]
807    fn test_literal_breaks_pattern() {
808        // "swap 0 swap" should NOT match "swap swap" because the literal breaks the pattern
809        let config = test_config();
810        let linter = Linter::new(&config).unwrap();
811
812        let program = Program {
813            includes: vec![],
814            unions: vec![],
815            words: vec![WordDef {
816                name: "test".to_string(),
817                effect: None,
818                body: vec![
819                    Statement::WordCall {
820                        name: "swap".to_string(),
821                        span: None,
822                    },
823                    Statement::IntLiteral(0), // This should break the pattern
824                    Statement::WordCall {
825                        name: "swap".to_string(),
826                        span: None,
827                    },
828                ],
829                source: None,
830                allowed_lints: vec![],
831            }],
832        };
833
834        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
835        // Should NOT find "swap swap" because there's a literal in between
836        assert!(
837            diagnostics.is_empty(),
838            "Expected no matches, but got: {:?}",
839            diagnostics
840        );
841    }
842
843    #[test]
844    fn test_consecutive_swap_swap_still_matches() {
845        // Actual consecutive "swap swap" should still be detected
846        let config = test_config();
847        let linter = Linter::new(&config).unwrap();
848
849        let program = Program {
850            includes: vec![],
851            unions: vec![],
852            words: vec![WordDef {
853                name: "test".to_string(),
854                effect: None,
855                body: vec![
856                    Statement::WordCall {
857                        name: "swap".to_string(),
858                        span: None,
859                    },
860                    Statement::WordCall {
861                        name: "swap".to_string(),
862                        span: None,
863                    },
864                ],
865                source: None,
866                allowed_lints: vec![],
867            }],
868        };
869
870        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
871        assert_eq!(diagnostics.len(), 1);
872        assert_eq!(diagnostics[0].id, "redundant-swap-swap");
873    }
874}