Skip to main content

seqc/lint/
types.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::Span;
21use serde::Deserialize;
22use std::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)]
177pub(super) struct WordInfo<'a> {
178    pub(super) name: &'a str,
179    pub(super) span: Option<&'a Span>,
180}
181
182pub fn format_diagnostics(diagnostics: &[LintDiagnostic]) -> String {
183    let mut output = String::new();
184    for d in diagnostics {
185        let severity_str = match d.severity {
186            Severity::Error => "error",
187            Severity::Warning => "warning",
188            Severity::Hint => "hint",
189        };
190        // Include column info in output if available
191        let location = match d.start_column {
192            Some(col) => format!("{}:{}:{}", d.file.display(), d.line + 1, col + 1),
193            None => format!("{}:{}", d.file.display(), d.line + 1),
194        };
195        output.push_str(&format!(
196            "{}: {} [{}]: {}\n",
197            location, severity_str, d.id, d.message
198        ));
199        if !d.replacement.is_empty() {
200            output.push_str(&format!("  suggestion: replace with `{}`\n", d.replacement));
201        } else if d.replacement.is_empty() && d.message.contains("no effect") {
202            output.push_str("  suggestion: remove this code\n");
203        }
204    }
205    output
206}