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 let word_infos = self.extract_word_sequence(&word.body);
54
55 for pattern in &self.patterns {
57 self.find_matches(
58 &word_infos,
59 pattern,
60 word,
61 file,
62 fallback_line,
63 &mut local_diagnostics,
64 );
65 }
66
67 let max_depth = Self::max_if_nesting_depth(&word.body);
69 if max_depth >= MAX_NESTING_DEPTH {
70 local_diagnostics.push(LintDiagnostic {
71 id: "deep-nesting".to_string(),
72 message: format!(
73 "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
74 max_depth
75 ),
76 severity: Severity::Hint,
77 replacement: String::new(),
78 file: file.to_path_buf(),
79 line: fallback_line,
80 end_line: None,
81 start_column: None,
82 end_column: None,
83 word_name: word.name.clone(),
84 start_index: 0,
85 end_index: 0,
86 });
87 }
88
89 self.lint_nested(&word.body, word, file, &mut local_diagnostics);
91
92 for diagnostic in local_diagnostics {
94 if !word.allowed_lints.contains(&diagnostic.id) {
95 diagnostics.push(diagnostic);
96 }
97 }
98 }
99
100 fn max_if_nesting_depth(statements: &[Statement]) -> usize {
102 let mut max_depth = 0;
103 for stmt in statements {
104 let depth = Self::if_nesting_depth(stmt, 0);
105 if depth > max_depth {
106 max_depth = depth;
107 }
108 }
109 max_depth
110 }
111
112 fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
114 match stmt {
115 Statement::If {
116 then_branch,
117 else_branch,
118 span: _,
119 } => {
120 let new_depth = current_depth + 1;
122
123 let then_max = then_branch
125 .iter()
126 .map(|s| Self::if_nesting_depth(s, new_depth))
127 .max()
128 .unwrap_or(new_depth);
129
130 let else_max = else_branch
132 .as_ref()
133 .map(|stmts| {
134 stmts
135 .iter()
136 .map(|s| Self::if_nesting_depth(s, new_depth))
137 .max()
138 .unwrap_or(new_depth)
139 })
140 .unwrap_or(new_depth);
141
142 then_max.max(else_max)
143 }
144 Statement::Quotation { body, .. } => {
145 body.iter()
147 .map(|s| Self::if_nesting_depth(s, 0))
148 .max()
149 .unwrap_or(0)
150 }
151 Statement::Match { arms, span: _ } => {
152 arms.iter()
154 .flat_map(|arm| arm.body.iter())
155 .map(|s| Self::if_nesting_depth(s, current_depth))
156 .max()
157 .unwrap_or(current_depth)
158 }
159 _ => current_depth,
160 }
161 }
162
163 fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
168 let mut words = Vec::new();
169 for stmt in statements {
170 if let Statement::WordCall { name, span } = stmt {
171 words.push(WordInfo {
172 name: name.as_str(),
173 span: span.as_ref(),
174 });
175 } else {
176 words.push(WordInfo {
180 name: "<non-word>",
181 span: None,
182 });
183 }
184 }
185 words
186 }
187
188 fn find_matches(
190 &self,
191 word_infos: &[WordInfo],
192 pattern: &CompiledPattern,
193 word: &WordDef,
194 file: &Path,
195 fallback_line: usize,
196 diagnostics: &mut Vec<LintDiagnostic>,
197 ) {
198 if word_infos.is_empty() || pattern.elements.is_empty() {
199 return;
200 }
201
202 let mut i = 0;
204 while i < word_infos.len() {
205 if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
206 let first_span = word_infos[i].span;
208 let last_span = word_infos[i + match_len - 1].span;
209
210 let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
212
213 let (end_line, start_column, end_column) =
215 if let (Some(first), Some(last)) = (first_span, last_span) {
216 if first.line == last.line {
217 (None, Some(first.column), Some(last.column + last.length))
219 } else {
220 (
222 Some(last.line),
223 Some(first.column),
224 Some(last.column + last.length),
225 )
226 }
227 } else {
228 (None, None, None)
229 };
230
231 diagnostics.push(LintDiagnostic {
232 id: pattern.rule.id.clone(),
233 message: pattern.rule.message.clone(),
234 severity: pattern.rule.severity,
235 replacement: pattern.rule.replacement.clone(),
236 file: file.to_path_buf(),
237 line,
238 end_line,
239 start_column,
240 end_column,
241 word_name: word.name.clone(),
242 start_index: i,
243 end_index: i + match_len,
244 });
245 i += match_len;
247 } else {
248 i += 1;
249 }
250 }
251 }
252
253 fn try_match_at(
255 word_infos: &[WordInfo],
256 start: usize,
257 elements: &[PatternElement],
258 ) -> Option<usize> {
259 let mut word_idx = start;
260 let mut elem_idx = 0;
261
262 while elem_idx < elements.len() {
263 match &elements[elem_idx] {
264 PatternElement::Word(expected) => {
265 if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
266 return None;
267 }
268 word_idx += 1;
269 elem_idx += 1;
270 }
271 PatternElement::SingleWildcard(_) => {
272 if word_idx >= word_infos.len() {
273 return None;
274 }
275 word_idx += 1;
276 elem_idx += 1;
277 }
278 PatternElement::MultiWildcard => {
279 elem_idx += 1;
281 if elem_idx >= elements.len() {
282 return Some(word_infos.len() - start);
284 }
285 for try_idx in word_idx..=word_infos.len() {
287 if let Some(rest_len) =
288 Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
289 {
290 return Some(try_idx - start + rest_len);
291 }
292 }
293 return None;
294 }
295 }
296 }
297
298 Some(word_idx - start)
299 }
300
301 fn lint_nested(
303 &self,
304 statements: &[Statement],
305 word: &WordDef,
306 file: &Path,
307 diagnostics: &mut Vec<LintDiagnostic>,
308 ) {
309 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
310
311 for stmt in statements {
312 match stmt {
313 Statement::Quotation { body, .. } => {
314 let word_infos = self.extract_word_sequence(body);
316 for pattern in &self.patterns {
317 self.find_matches(
318 &word_infos,
319 pattern,
320 word,
321 file,
322 fallback_line,
323 diagnostics,
324 );
325 }
326 self.lint_nested(body, word, file, diagnostics);
328 }
329 Statement::If {
330 then_branch,
331 else_branch,
332 span: _,
333 } => {
334 let word_infos = self.extract_word_sequence(then_branch);
336 for pattern in &self.patterns {
337 self.find_matches(
338 &word_infos,
339 pattern,
340 word,
341 file,
342 fallback_line,
343 diagnostics,
344 );
345 }
346 self.lint_nested(then_branch, word, file, diagnostics);
347
348 if let Some(else_stmts) = else_branch {
349 let word_infos = self.extract_word_sequence(else_stmts);
350 for pattern in &self.patterns {
351 self.find_matches(
352 &word_infos,
353 pattern,
354 word,
355 file,
356 fallback_line,
357 diagnostics,
358 );
359 }
360 self.lint_nested(else_stmts, word, file, diagnostics);
361 }
362 }
363 Statement::Match { arms, span: _ } => {
364 for arm in arms {
365 let word_infos = self.extract_word_sequence(&arm.body);
366 for pattern in &self.patterns {
367 self.find_matches(
368 &word_infos,
369 pattern,
370 word,
371 file,
372 fallback_line,
373 diagnostics,
374 );
375 }
376 self.lint_nested(&arm.body, word, file, diagnostics);
377 }
378 }
379 _ => {}
380 }
381 }
382 }
383}