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/// One lint ID known to the compiler, with metadata for editor surfaces.
93///
94/// Returned from [`known_lint_ids`] as the single source of truth for
95/// what `# seq:allow(<id>)` accepts. Adding a new lint — whether to
96/// `lints.toml` or as a hard-coded analyzer that honors `allowed_lints` —
97/// must flow through this function.
98#[derive(Debug, Clone)]
99pub struct KnownLint {
100    pub id: String,
101    pub message: String,
102    pub severity: Severity,
103}
104
105/// Every lint ID that `# seq:allow(<id>)` actually suppresses.
106///
107/// Suppression points today:
108/// - `Linter::lint_word` filters all TOML-pattern diagnostics plus
109///   `deep-nesting` by `word.allowed_lints` (`lint/linter.rs`).
110/// - `ErrorFlagAnalyzer::analyze_program` skips words allowing
111///   `unchecked-error-flag` (`error_flag_lint/analyzer.rs`).
112///
113/// Lint IDs that exist but ignore `allowed_lints` (`unreachable-chan-yield`,
114/// `resource-leak-*`, `resource-branch-inconsistent`) are intentionally
115/// omitted — listing them would let users write annotations that
116/// silently do nothing.
117pub fn known_lint_ids() -> Vec<KnownLint> {
118    let mut out = Vec::new();
119
120    if let Ok(config) = LintConfig::default_config() {
121        for rule in config.rules {
122            out.push(KnownLint {
123                id: rule.id,
124                message: rule.message,
125                severity: rule.severity,
126            });
127        }
128    }
129
130    out.push(KnownLint {
131        id: "deep-nesting".to_string(),
132        message: format!(
133            "deeply nested if/else ({}+ levels) - consider `cond` or extracting to helper words",
134            MAX_NESTING_DEPTH
135        ),
136        severity: Severity::Hint,
137    });
138
139    out.push(KnownLint {
140        id: "unchecked-error-flag".to_string(),
141        message: "operation returns a Bool success flag that is dropped without being checked"
142            .to_string(),
143        severity: Severity::Warning,
144    });
145
146    out
147}
148
149/// A compiled pattern for efficient matching
150#[derive(Debug, Clone)]
151pub struct CompiledPattern {
152    /// The original rule
153    pub rule: LintRule,
154    /// Pattern elements (words or wildcards)
155    pub elements: Vec<PatternElement>,
156}
157
158/// Element in a compiled pattern
159#[derive(Debug, Clone, PartialEq)]
160pub enum PatternElement {
161    /// Exact word match
162    Word(String),
163    /// Single-word wildcard ($X, $Y, etc.)
164    SingleWildcard(String),
165    /// Multi-word wildcard ($...)
166    MultiWildcard,
167}
168
169impl CompiledPattern {
170    /// Compile a pattern string into elements
171    pub fn compile(rule: LintRule) -> Result<Self, String> {
172        let mut elements = Vec::new();
173        let mut multi_wildcard_count = 0;
174
175        for token in rule.pattern.split_whitespace() {
176            if token == "$..." {
177                multi_wildcard_count += 1;
178                elements.push(PatternElement::MultiWildcard);
179            } else if token.starts_with('$') {
180                elements.push(PatternElement::SingleWildcard(token.to_string()));
181            } else {
182                elements.push(PatternElement::Word(token.to_string()));
183            }
184        }
185
186        if elements.is_empty() {
187            return Err(format!("Empty pattern in lint rule '{}'", rule.id));
188        }
189
190        // Validate: at most one multi-wildcard per pattern to avoid
191        // exponential backtracking complexity
192        if multi_wildcard_count > 1 {
193            return Err(format!(
194                "Pattern in lint rule '{}' has {} multi-wildcards ($...), but at most 1 is allowed",
195                rule.id, multi_wildcard_count
196            ));
197        }
198
199        Ok(CompiledPattern { rule, elements })
200    }
201}
202
203/// A lint diagnostic (match found)
204#[derive(Debug, Clone)]
205pub struct LintDiagnostic {
206    /// Lint rule ID
207    pub id: String,
208    /// Human-readable message
209    pub message: String,
210    /// Severity level
211    pub severity: Severity,
212    /// Suggested replacement
213    pub replacement: String,
214    /// File where the match was found
215    pub file: PathBuf,
216    /// Start line number (0-indexed)
217    pub line: usize,
218    /// End line number (0-indexed), for multi-line matches
219    pub end_line: Option<usize>,
220    /// Start column (0-indexed), if available from source spans
221    pub start_column: Option<usize>,
222    /// End column (0-indexed, exclusive), if available from source spans
223    pub end_column: Option<usize>,
224    /// Word name where the match was found
225    pub word_name: String,
226    /// Start index in the word body
227    pub start_index: usize,
228    /// End index in the word body (exclusive)
229    pub end_index: usize,
230}
231
232/// Word call info extracted from a statement, including optional span
233#[derive(Debug, Clone)]
234pub(super) struct WordInfo<'a> {
235    pub(super) name: &'a str,
236    pub(super) span: Option<&'a Span>,
237}
238
239pub fn format_diagnostics(diagnostics: &[LintDiagnostic]) -> String {
240    let mut output = String::new();
241    for d in diagnostics {
242        let severity_str = match d.severity {
243            Severity::Error => "error",
244            Severity::Warning => "warning",
245            Severity::Hint => "hint",
246        };
247        // Include column info in output if available
248        let location = match d.start_column {
249            Some(col) => format!("{}:{}:{}", d.file.display(), d.line + 1, col + 1),
250            None => format!("{}:{}", d.file.display(), d.line + 1),
251        };
252        output.push_str(&format!(
253            "{}: {} [{}]: {}\n",
254            location, severity_str, d.id, d.message
255        ));
256        if !d.replacement.is_empty() {
257            output.push_str(&format!("  suggestion: replace with `{}`\n", d.replacement));
258        } else if d.replacement.is_empty() && d.message.contains("no effect") {
259            output.push_str("  suggestion: remove this code\n");
260        }
261    }
262    output
263}