1use std::path::Path;
6
7use crate::ast::{Program, Statement, WordDef};
8
9use super::types::{
10 CompiledPattern, LintConfig, LintDiagnostic, MAX_NESTING_DEPTH, PatternElement, Severity,
11 WordInfo,
12};
13
14pub struct Linter {
15 patterns: Vec<CompiledPattern>,
16}
17
18impl Linter {
19 pub fn new(config: &LintConfig) -> Result<Self, String> {
21 let mut patterns = Vec::new();
22 for rule in &config.rules {
23 patterns.push(CompiledPattern::compile(rule.clone())?);
24 }
25 Ok(Linter { patterns })
26 }
27
28 pub fn with_defaults() -> Result<Self, String> {
30 let config = LintConfig::default_config()?;
31 Self::new(&config)
32 }
33
34 pub fn lint_program(&self, program: &Program, file: &Path) -> Vec<LintDiagnostic> {
36 let mut diagnostics = Vec::new();
37
38 for word in &program.words {
39 self.lint_word(word, file, &mut diagnostics);
40 }
41
42 diagnostics
43 }
44
45 fn lint_word(&self, word: &WordDef, file: &Path, diagnostics: &mut Vec<LintDiagnostic>) {
47 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
48
49 let mut local_diagnostics = Vec::new();
51
52 self.lint_statement_list(
53 &word.body,
54 word,
55 file,
56 fallback_line,
57 &mut local_diagnostics,
58 );
59
60 let max_depth = Self::max_if_nesting_depth(&word.body);
62 if max_depth >= MAX_NESTING_DEPTH {
63 local_diagnostics.push(LintDiagnostic {
64 id: "deep-nesting".to_string(),
65 message: format!(
66 "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
67 max_depth
68 ),
69 severity: Severity::Hint,
70 replacement: String::new(),
71 file: file.to_path_buf(),
72 line: fallback_line,
73 end_line: None,
74 start_column: None,
75 end_column: None,
76 word_name: word.name.clone(),
77 start_index: 0,
78 end_index: 0,
79 });
80 }
81
82 for diagnostic in local_diagnostics {
84 if !word.allowed_lints.contains(&diagnostic.id) {
85 diagnostics.push(diagnostic);
86 }
87 }
88 }
89
90 fn max_if_nesting_depth(statements: &[Statement]) -> usize {
92 let mut max_depth = 0;
93 for stmt in statements {
94 let depth = Self::if_nesting_depth(stmt, 0);
95 if depth > max_depth {
96 max_depth = depth;
97 }
98 }
99 max_depth
100 }
101
102 fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
104 match stmt {
105 Statement::If {
106 then_branch,
107 else_branch,
108 span: _,
109 } => {
110 let new_depth = current_depth + 1;
112
113 let then_max = then_branch
115 .iter()
116 .map(|s| Self::if_nesting_depth(s, new_depth))
117 .max()
118 .unwrap_or(new_depth);
119
120 let else_max = else_branch
122 .as_ref()
123 .map(|stmts| {
124 stmts
125 .iter()
126 .map(|s| Self::if_nesting_depth(s, new_depth))
127 .max()
128 .unwrap_or(new_depth)
129 })
130 .unwrap_or(new_depth);
131
132 then_max.max(else_max)
133 }
134 Statement::Quotation { body, .. } => {
135 body.iter()
137 .map(|s| Self::if_nesting_depth(s, 0))
138 .max()
139 .unwrap_or(0)
140 }
141 Statement::Match { arms, span: _ } => {
142 arms.iter()
144 .flat_map(|arm| arm.body.iter())
145 .map(|s| Self::if_nesting_depth(s, current_depth))
146 .max()
147 .unwrap_or(current_depth)
148 }
149 _ => current_depth,
150 }
151 }
152
153 fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
158 let mut words = Vec::new();
159 for stmt in statements {
160 if let Statement::WordCall { name, span } = stmt {
161 words.push(WordInfo {
162 name: name.as_str(),
163 span: span.as_ref(),
164 });
165 } else {
166 words.push(WordInfo {
170 name: "<non-word>",
171 span: None,
172 });
173 }
174 }
175 words
176 }
177
178 fn find_matches(
180 &self,
181 word_infos: &[WordInfo],
182 pattern: &CompiledPattern,
183 word: &WordDef,
184 file: &Path,
185 fallback_line: usize,
186 diagnostics: &mut Vec<LintDiagnostic>,
187 ) {
188 if word_infos.is_empty() || pattern.elements.is_empty() {
189 return;
190 }
191
192 let mut i = 0;
194 while i < word_infos.len() {
195 if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
196 let first_span = word_infos[i].span;
198 let last_span = word_infos[i + match_len - 1].span;
199
200 let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
202
203 let (end_line, start_column, end_column) =
205 if let (Some(first), Some(last)) = (first_span, last_span) {
206 if first.line == last.line {
207 (None, Some(first.column), Some(last.column + last.length))
209 } else {
210 (
212 Some(last.line),
213 Some(first.column),
214 Some(last.column + last.length),
215 )
216 }
217 } else {
218 (None, None, None)
219 };
220
221 diagnostics.push(LintDiagnostic {
222 id: pattern.rule.id.clone(),
223 message: pattern.rule.message.clone(),
224 severity: pattern.rule.severity,
225 replacement: pattern.rule.replacement.clone(),
226 file: file.to_path_buf(),
227 line,
228 end_line,
229 start_column,
230 end_column,
231 word_name: word.name.clone(),
232 start_index: i,
233 end_index: i + match_len,
234 });
235 i += match_len;
237 } else {
238 i += 1;
239 }
240 }
241 }
242
243 fn try_match_at(
245 word_infos: &[WordInfo],
246 start: usize,
247 elements: &[PatternElement],
248 ) -> Option<usize> {
249 let mut word_idx = start;
250 let mut elem_idx = 0;
251
252 while elem_idx < elements.len() {
253 match &elements[elem_idx] {
254 PatternElement::Word(expected) => {
255 if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
256 return None;
257 }
258 word_idx += 1;
259 elem_idx += 1;
260 }
261 PatternElement::SingleWildcard(_) => {
262 if word_idx >= word_infos.len() {
263 return None;
264 }
265 word_idx += 1;
266 elem_idx += 1;
267 }
268 PatternElement::MultiWildcard => {
269 elem_idx += 1;
271 if elem_idx >= elements.len() {
272 return Some(word_infos.len() - start);
274 }
275 for try_idx in word_idx..=word_infos.len() {
277 if let Some(rest_len) =
278 Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
279 {
280 return Some(try_idx - start + rest_len);
281 }
282 }
283 return None;
284 }
285 }
286 }
287
288 Some(word_idx - start)
289 }
290
291 fn lint_statement_list(
296 &self,
297 statements: &[Statement],
298 word: &WordDef,
299 file: &Path,
300 fallback_line: usize,
301 diagnostics: &mut Vec<LintDiagnostic>,
302 ) {
303 let word_infos = self.extract_word_sequence(statements);
304 for pattern in &self.patterns {
305 self.find_matches(&word_infos, pattern, word, file, fallback_line, diagnostics);
306 }
307 self.lint_nested(statements, word, file, diagnostics);
308 }
309
310 fn lint_nested(
315 &self,
316 statements: &[Statement],
317 word: &WordDef,
318 file: &Path,
319 diagnostics: &mut Vec<LintDiagnostic>,
320 ) {
321 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
322
323 for stmt in statements {
324 match stmt {
325 Statement::Quotation { body, .. } => {
326 self.lint_statement_list(body, word, file, fallback_line, diagnostics);
327 }
328 Statement::If {
329 then_branch,
330 else_branch,
331 span: _,
332 } => {
333 self.lint_statement_list(then_branch, word, file, fallback_line, diagnostics);
334 if let Some(else_stmts) = else_branch {
335 self.lint_statement_list(
336 else_stmts,
337 word,
338 file,
339 fallback_line,
340 diagnostics,
341 );
342 }
343 }
344 Statement::Match { arms, span: _ } => {
345 for arm in arms {
346 self.lint_statement_list(&arm.body, word, file, fallback_line, diagnostics);
347 }
348 }
349 _ => {}
350 }
351 }
352 }
353}