Skip to main content

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