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        // Extract word sequence from the body (with span info)
230        let word_infos = self.extract_word_sequence(&word.body);
231
232        // Try each pattern
233        for pattern in &self.patterns {
234            self.find_matches(&word_infos, pattern, word, file, fallback_line, diagnostics);
235        }
236
237        // Check for deeply nested if/else chains
238        let max_depth = Self::max_if_nesting_depth(&word.body);
239        if max_depth >= MAX_NESTING_DEPTH {
240            diagnostics.push(LintDiagnostic {
241                id: "deep-nesting".to_string(),
242                message: format!(
243                    "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
244                    max_depth
245                ),
246                severity: Severity::Hint,
247                replacement: String::new(),
248                file: file.to_path_buf(),
249                line: fallback_line,
250                end_line: None,
251                start_column: None,
252                end_column: None,
253                word_name: word.name.clone(),
254                start_index: 0,
255                end_index: 0,
256            });
257        }
258
259        // Recursively lint nested structures (quotations, if branches)
260        self.lint_nested(&word.body, word, file, diagnostics);
261    }
262
263    /// Calculate the maximum if/else nesting depth in a statement list
264    fn max_if_nesting_depth(statements: &[Statement]) -> usize {
265        let mut max_depth = 0;
266        for stmt in statements {
267            let depth = Self::if_nesting_depth(stmt, 0);
268            if depth > max_depth {
269                max_depth = depth;
270            }
271        }
272        max_depth
273    }
274
275    /// Calculate if/else nesting depth for a single statement
276    fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
277        match stmt {
278            Statement::If {
279                then_branch,
280                else_branch,
281            } => {
282                // This if adds one level of nesting
283                let new_depth = current_depth + 1;
284
285                // Check then branch for further nesting
286                let then_max = then_branch
287                    .iter()
288                    .map(|s| Self::if_nesting_depth(s, new_depth))
289                    .max()
290                    .unwrap_or(new_depth);
291
292                // Check else branch - nested ifs in else are the classic "else if" chain
293                let else_max = else_branch
294                    .as_ref()
295                    .map(|stmts| {
296                        stmts
297                            .iter()
298                            .map(|s| Self::if_nesting_depth(s, new_depth))
299                            .max()
300                            .unwrap_or(new_depth)
301                    })
302                    .unwrap_or(new_depth);
303
304                then_max.max(else_max)
305            }
306            Statement::Quotation { body, .. } => {
307                // Quotations start fresh nesting count (they're separate code blocks)
308                body.iter()
309                    .map(|s| Self::if_nesting_depth(s, 0))
310                    .max()
311                    .unwrap_or(0)
312            }
313            Statement::Match { arms } => {
314                // Match arms don't count as if nesting, but check for ifs inside
315                arms.iter()
316                    .flat_map(|arm| arm.body.iter())
317                    .map(|s| Self::if_nesting_depth(s, current_depth))
318                    .max()
319                    .unwrap_or(current_depth)
320            }
321            _ => current_depth,
322        }
323    }
324
325    /// Extract a flat sequence of word names with spans from statements.
326    /// Non-WordCall statements (literals, quotations, etc.) are represented as
327    /// a special marker `<non-word>` to prevent false pattern matches across
328    /// non-consecutive word calls.
329    fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
330        let mut words = Vec::new();
331        for stmt in statements {
332            if let Statement::WordCall { name, span } = stmt {
333                words.push(WordInfo {
334                    name: name.as_str(),
335                    span: span.as_ref(),
336                });
337            } else {
338                // Insert a marker for non-word statements to break up patterns.
339                // This prevents false positives like matching "swap swap" when
340                // there's a literal between them: "swap 0 swap"
341                words.push(WordInfo {
342                    name: "<non-word>",
343                    span: None,
344                });
345            }
346        }
347        words
348    }
349
350    /// Find all matches of a pattern in a word sequence
351    fn find_matches(
352        &self,
353        word_infos: &[WordInfo],
354        pattern: &CompiledPattern,
355        word: &WordDef,
356        file: &Path,
357        fallback_line: usize,
358        diagnostics: &mut Vec<LintDiagnostic>,
359    ) {
360        if word_infos.is_empty() || pattern.elements.is_empty() {
361            return;
362        }
363
364        // Sliding window match
365        let mut i = 0;
366        while i < word_infos.len() {
367            if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
368                // Extract position info from spans if available
369                let first_span = word_infos[i].span;
370                let last_span = word_infos[i + match_len - 1].span;
371
372                // Use span line if available, otherwise fall back to word definition line
373                let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
374
375                // Calculate end line and column range
376                let (end_line, start_column, end_column) =
377                    if let (Some(first), Some(last)) = (first_span, last_span) {
378                        if first.line == last.line {
379                            // Same line: column range spans from first word's start to last word's end
380                            (None, Some(first.column), Some(last.column + last.length))
381                        } else {
382                            // Multi-line match: track end line and end column
383                            (
384                                Some(last.line),
385                                Some(first.column),
386                                Some(last.column + last.length),
387                            )
388                        }
389                    } else {
390                        (None, None, None)
391                    };
392
393                diagnostics.push(LintDiagnostic {
394                    id: pattern.rule.id.clone(),
395                    message: pattern.rule.message.clone(),
396                    severity: pattern.rule.severity,
397                    replacement: pattern.rule.replacement.clone(),
398                    file: file.to_path_buf(),
399                    line,
400                    end_line,
401                    start_column,
402                    end_column,
403                    word_name: word.name.clone(),
404                    start_index: i,
405                    end_index: i + match_len,
406                });
407                // Skip past the match to avoid overlapping matches
408                i += match_len;
409            } else {
410                i += 1;
411            }
412        }
413    }
414
415    /// Try to match pattern at position, returning match length if successful
416    fn try_match_at(
417        word_infos: &[WordInfo],
418        start: usize,
419        elements: &[PatternElement],
420    ) -> Option<usize> {
421        let mut word_idx = start;
422        let mut elem_idx = 0;
423
424        while elem_idx < elements.len() {
425            match &elements[elem_idx] {
426                PatternElement::Word(expected) => {
427                    if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
428                        return None;
429                    }
430                    word_idx += 1;
431                    elem_idx += 1;
432                }
433                PatternElement::SingleWildcard(_) => {
434                    if word_idx >= word_infos.len() {
435                        return None;
436                    }
437                    word_idx += 1;
438                    elem_idx += 1;
439                }
440                PatternElement::MultiWildcard => {
441                    // Multi-wildcard: try all possible lengths
442                    elem_idx += 1;
443                    if elem_idx >= elements.len() {
444                        // Wildcard at end matches rest
445                        return Some(word_infos.len() - start);
446                    }
447                    // Try matching remaining pattern at each position
448                    for try_idx in word_idx..=word_infos.len() {
449                        if let Some(rest_len) =
450                            Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
451                        {
452                            return Some(try_idx - start + rest_len);
453                        }
454                    }
455                    return None;
456                }
457            }
458        }
459
460        Some(word_idx - start)
461    }
462
463    /// Recursively lint nested structures
464    fn lint_nested(
465        &self,
466        statements: &[Statement],
467        word: &WordDef,
468        file: &Path,
469        diagnostics: &mut Vec<LintDiagnostic>,
470    ) {
471        let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
472
473        for stmt in statements {
474            match stmt {
475                Statement::Quotation { body, .. } => {
476                    // Lint the quotation body
477                    let word_infos = self.extract_word_sequence(body);
478                    for pattern in &self.patterns {
479                        self.find_matches(
480                            &word_infos,
481                            pattern,
482                            word,
483                            file,
484                            fallback_line,
485                            diagnostics,
486                        );
487                    }
488                    // Recurse into nested quotations
489                    self.lint_nested(body, word, file, diagnostics);
490                }
491                Statement::If {
492                    then_branch,
493                    else_branch,
494                } => {
495                    // Lint both branches
496                    let word_infos = self.extract_word_sequence(then_branch);
497                    for pattern in &self.patterns {
498                        self.find_matches(
499                            &word_infos,
500                            pattern,
501                            word,
502                            file,
503                            fallback_line,
504                            diagnostics,
505                        );
506                    }
507                    self.lint_nested(then_branch, word, file, diagnostics);
508
509                    if let Some(else_stmts) = else_branch {
510                        let word_infos = self.extract_word_sequence(else_stmts);
511                        for pattern in &self.patterns {
512                            self.find_matches(
513                                &word_infos,
514                                pattern,
515                                word,
516                                file,
517                                fallback_line,
518                                diagnostics,
519                            );
520                        }
521                        self.lint_nested(else_stmts, word, file, diagnostics);
522                    }
523                }
524                Statement::Match { arms } => {
525                    for arm in arms {
526                        let word_infos = self.extract_word_sequence(&arm.body);
527                        for pattern in &self.patterns {
528                            self.find_matches(
529                                &word_infos,
530                                pattern,
531                                word,
532                                file,
533                                fallback_line,
534                                diagnostics,
535                            );
536                        }
537                        self.lint_nested(&arm.body, word, file, diagnostics);
538                    }
539                }
540                _ => {}
541            }
542        }
543    }
544}
545
546/// Format diagnostics for CLI output
547pub fn format_diagnostics(diagnostics: &[LintDiagnostic]) -> String {
548    let mut output = String::new();
549    for d in diagnostics {
550        let severity_str = match d.severity {
551            Severity::Error => "error",
552            Severity::Warning => "warning",
553            Severity::Hint => "hint",
554        };
555        // Include column info in output if available
556        let location = match d.start_column {
557            Some(col) => format!("{}:{}:{}", d.file.display(), d.line + 1, col + 1),
558            None => format!("{}:{}", d.file.display(), d.line + 1),
559        };
560        output.push_str(&format!(
561            "{}: {} [{}]: {}\n",
562            location, severity_str, d.id, d.message
563        ));
564        if !d.replacement.is_empty() {
565            output.push_str(&format!("  suggestion: replace with `{}`\n", d.replacement));
566        } else if d.replacement.is_empty() && d.message.contains("no effect") {
567            output.push_str("  suggestion: remove this code\n");
568        }
569    }
570    output
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn test_config() -> LintConfig {
578        LintConfig::from_toml(
579            r#"
580[[lint]]
581id = "redundant-dup-drop"
582pattern = "dup drop"
583replacement = ""
584message = "`dup drop` has no effect"
585severity = "warning"
586
587[[lint]]
588id = "prefer-nip"
589pattern = "swap drop"
590replacement = "nip"
591message = "prefer `nip` over `swap drop`"
592severity = "hint"
593
594[[lint]]
595id = "redundant-swap-swap"
596pattern = "swap swap"
597replacement = ""
598message = "consecutive swaps cancel out"
599severity = "warning"
600"#,
601        )
602        .unwrap()
603    }
604
605    #[test]
606    fn test_parse_config() {
607        let config = test_config();
608        assert_eq!(config.rules.len(), 3);
609        assert_eq!(config.rules[0].id, "redundant-dup-drop");
610        assert_eq!(config.rules[1].severity, Severity::Hint);
611    }
612
613    #[test]
614    fn test_compile_pattern() {
615        let rule = LintRule {
616            id: "test".to_string(),
617            pattern: "swap drop".to_string(),
618            replacement: "nip".to_string(),
619            message: "test".to_string(),
620            severity: Severity::Warning,
621        };
622        let compiled = CompiledPattern::compile(rule).unwrap();
623        assert_eq!(compiled.elements.len(), 2);
624        assert_eq!(
625            compiled.elements[0],
626            PatternElement::Word("swap".to_string())
627        );
628        assert_eq!(
629            compiled.elements[1],
630            PatternElement::Word("drop".to_string())
631        );
632    }
633
634    #[test]
635    fn test_compile_pattern_with_wildcards() {
636        let rule = LintRule {
637            id: "test".to_string(),
638            pattern: "dup $X drop".to_string(),
639            replacement: "".to_string(),
640            message: "test".to_string(),
641            severity: Severity::Warning,
642        };
643        let compiled = CompiledPattern::compile(rule).unwrap();
644        assert_eq!(compiled.elements.len(), 3);
645        assert_eq!(
646            compiled.elements[1],
647            PatternElement::SingleWildcard("$X".to_string())
648        );
649    }
650
651    #[test]
652    fn test_simple_match() {
653        let config = test_config();
654        let linter = Linter::new(&config).unwrap();
655
656        // Create a simple program with "swap drop"
657        let program = Program {
658            includes: vec![],
659            unions: vec![],
660            words: vec![WordDef {
661                name: "test".to_string(),
662                effect: None,
663                body: vec![
664                    Statement::IntLiteral(1),
665                    Statement::IntLiteral(2),
666                    Statement::WordCall {
667                        name: "swap".to_string(),
668                        span: None,
669                    },
670                    Statement::WordCall {
671                        name: "drop".to_string(),
672                        span: None,
673                    },
674                ],
675                source: None,
676            }],
677        };
678
679        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
680        assert_eq!(diagnostics.len(), 1);
681        assert_eq!(diagnostics[0].id, "prefer-nip");
682        assert_eq!(diagnostics[0].replacement, "nip");
683    }
684
685    #[test]
686    fn test_no_false_positives() {
687        let config = test_config();
688        let linter = Linter::new(&config).unwrap();
689
690        // "swap" followed by something other than "drop"
691        let program = Program {
692            includes: vec![],
693            unions: vec![],
694            words: vec![WordDef {
695                name: "test".to_string(),
696                effect: None,
697                body: vec![
698                    Statement::WordCall {
699                        name: "swap".to_string(),
700                        span: None,
701                    },
702                    Statement::WordCall {
703                        name: "dup".to_string(),
704                        span: None,
705                    },
706                ],
707                source: None,
708            }],
709        };
710
711        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
712        assert!(diagnostics.is_empty());
713    }
714
715    #[test]
716    fn test_multiple_matches() {
717        let config = test_config();
718        let linter = Linter::new(&config).unwrap();
719
720        // Two instances of "swap drop"
721        let program = Program {
722            includes: vec![],
723            unions: vec![],
724            words: vec![WordDef {
725                name: "test".to_string(),
726                effect: None,
727                body: vec![
728                    Statement::WordCall {
729                        name: "swap".to_string(),
730                        span: None,
731                    },
732                    Statement::WordCall {
733                        name: "drop".to_string(),
734                        span: None,
735                    },
736                    Statement::WordCall {
737                        name: "dup".to_string(),
738                        span: None,
739                    },
740                    Statement::WordCall {
741                        name: "swap".to_string(),
742                        span: None,
743                    },
744                    Statement::WordCall {
745                        name: "drop".to_string(),
746                        span: None,
747                    },
748                ],
749                source: None,
750            }],
751        };
752
753        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
754        assert_eq!(diagnostics.len(), 2);
755    }
756
757    #[test]
758    fn test_multi_wildcard_validation() {
759        // Pattern with two multi-wildcards should be rejected
760        let rule = LintRule {
761            id: "bad-pattern".to_string(),
762            pattern: "$... foo $...".to_string(),
763            replacement: "".to_string(),
764            message: "test".to_string(),
765            severity: Severity::Warning,
766        };
767        let result = CompiledPattern::compile(rule);
768        assert!(result.is_err());
769        assert!(result.unwrap_err().contains("multi-wildcards"));
770    }
771
772    #[test]
773    fn test_single_multi_wildcard_allowed() {
774        // Pattern with one multi-wildcard should be accepted
775        let rule = LintRule {
776            id: "ok-pattern".to_string(),
777            pattern: "$... foo".to_string(),
778            replacement: "".to_string(),
779            message: "test".to_string(),
780            severity: Severity::Warning,
781        };
782        let result = CompiledPattern::compile(rule);
783        assert!(result.is_ok());
784    }
785
786    #[test]
787    fn test_literal_breaks_pattern() {
788        // "swap 0 swap" should NOT match "swap swap" because the literal breaks the pattern
789        let config = test_config();
790        let linter = Linter::new(&config).unwrap();
791
792        let program = Program {
793            includes: vec![],
794            unions: vec![],
795            words: vec![WordDef {
796                name: "test".to_string(),
797                effect: None,
798                body: vec![
799                    Statement::WordCall {
800                        name: "swap".to_string(),
801                        span: None,
802                    },
803                    Statement::IntLiteral(0), // This should break the pattern
804                    Statement::WordCall {
805                        name: "swap".to_string(),
806                        span: None,
807                    },
808                ],
809                source: None,
810            }],
811        };
812
813        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
814        // Should NOT find "swap swap" because there's a literal in between
815        assert!(
816            diagnostics.is_empty(),
817            "Expected no matches, but got: {:?}",
818            diagnostics
819        );
820    }
821
822    #[test]
823    fn test_consecutive_swap_swap_still_matches() {
824        // Actual consecutive "swap swap" should still be detected
825        let config = test_config();
826        let linter = Linter::new(&config).unwrap();
827
828        let program = Program {
829            includes: vec![],
830            unions: vec![],
831            words: vec![WordDef {
832                name: "test".to_string(),
833                effect: None,
834                body: vec![
835                    Statement::WordCall {
836                        name: "swap".to_string(),
837                        span: None,
838                    },
839                    Statement::WordCall {
840                        name: "swap".to_string(),
841                        span: None,
842                    },
843                ],
844                source: None,
845            }],
846        };
847
848        let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
849        assert_eq!(diagnostics.len(), 1);
850        assert_eq!(diagnostics[0].id, "redundant-swap-swap");
851    }
852}