1use crate::ast::{Program, Span, Statement, WordDef};
21use serde::Deserialize;
22use std::path::{Path, PathBuf};
23
24pub static DEFAULT_LINTS: &str = include_str!("lints.toml");
26
27pub const MAX_NESTING_DEPTH: usize = 4;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum Severity {
35 Error,
36 Warning,
37 Hint,
38}
39
40#[derive(Debug, Clone, Deserialize)]
42pub struct LintRule {
43 pub id: String,
45 pub pattern: String,
47 #[serde(default)]
49 pub replacement: String,
50 pub message: String,
52 #[serde(default = "default_severity")]
54 pub severity: Severity,
55}
56
57fn default_severity() -> Severity {
58 Severity::Warning
59}
60
61#[derive(Debug, Clone, Deserialize)]
63pub struct LintConfig {
64 #[serde(rename = "lint")]
65 pub rules: Vec<LintRule>,
66}
67
68impl LintConfig {
69 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 pub fn default_config() -> Result<Self, String> {
76 Self::from_toml(DEFAULT_LINTS)
77 }
78
79 pub fn merge(&mut self, other: LintConfig) {
81 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#[derive(Debug, Clone)]
94pub struct CompiledPattern {
95 pub rule: LintRule,
97 pub elements: Vec<PatternElement>,
99}
100
101#[derive(Debug, Clone, PartialEq)]
103pub enum PatternElement {
104 Word(String),
106 SingleWildcard(String),
108 MultiWildcard,
110}
111
112impl CompiledPattern {
113 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 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#[derive(Debug, Clone)]
148pub struct LintDiagnostic {
149 pub id: String,
151 pub message: String,
153 pub severity: Severity,
155 pub replacement: String,
157 pub file: PathBuf,
159 pub line: usize,
161 pub end_line: Option<usize>,
163 pub start_column: Option<usize>,
165 pub end_column: Option<usize>,
167 pub word_name: String,
169 pub start_index: usize,
171 pub end_index: usize,
173}
174
175#[derive(Debug, Clone)]
177struct WordInfo<'a> {
178 name: &'a str,
179 span: Option<&'a Span>,
180}
181
182pub struct Linter {
184 patterns: Vec<CompiledPattern>,
185}
186
187impl Linter {
188 pub fn new(config: &LintConfig) -> Result<Self, String> {
190 let mut patterns = Vec::new();
191 for rule in &config.rules {
192 patterns.push(CompiledPattern::compile(rule.clone())?);
193 }
194 Ok(Linter { patterns })
195 }
196
197 pub fn with_defaults() -> Result<Self, String> {
199 let config = LintConfig::default_config()?;
200 Self::new(&config)
201 }
202
203 pub fn lint_program(&self, program: &Program, file: &Path) -> Vec<LintDiagnostic> {
205 let mut diagnostics = Vec::new();
206
207 for word in &program.words {
208 self.lint_word(word, file, &mut diagnostics);
209 }
210
211 diagnostics
212 }
213
214 fn lint_word(&self, word: &WordDef, file: &Path, diagnostics: &mut Vec<LintDiagnostic>) {
216 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
217
218 let mut local_diagnostics = Vec::new();
220
221 let word_infos = self.extract_word_sequence(&word.body);
223
224 for pattern in &self.patterns {
226 self.find_matches(
227 &word_infos,
228 pattern,
229 word,
230 file,
231 fallback_line,
232 &mut local_diagnostics,
233 );
234 }
235
236 let max_depth = Self::max_if_nesting_depth(&word.body);
238 if max_depth >= MAX_NESTING_DEPTH {
239 local_diagnostics.push(LintDiagnostic {
240 id: "deep-nesting".to_string(),
241 message: format!(
242 "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
243 max_depth
244 ),
245 severity: Severity::Hint,
246 replacement: String::new(),
247 file: file.to_path_buf(),
248 line: fallback_line,
249 end_line: None,
250 start_column: None,
251 end_column: None,
252 word_name: word.name.clone(),
253 start_index: 0,
254 end_index: 0,
255 });
256 }
257
258 self.lint_nested(&word.body, word, file, &mut local_diagnostics);
260
261 for diagnostic in local_diagnostics {
263 if !word.allowed_lints.contains(&diagnostic.id) {
264 diagnostics.push(diagnostic);
265 }
266 }
267 }
268
269 fn max_if_nesting_depth(statements: &[Statement]) -> usize {
271 let mut max_depth = 0;
272 for stmt in statements {
273 let depth = Self::if_nesting_depth(stmt, 0);
274 if depth > max_depth {
275 max_depth = depth;
276 }
277 }
278 max_depth
279 }
280
281 fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
283 match stmt {
284 Statement::If {
285 then_branch,
286 else_branch,
287 } => {
288 let new_depth = current_depth + 1;
290
291 let then_max = then_branch
293 .iter()
294 .map(|s| Self::if_nesting_depth(s, new_depth))
295 .max()
296 .unwrap_or(new_depth);
297
298 let else_max = else_branch
300 .as_ref()
301 .map(|stmts| {
302 stmts
303 .iter()
304 .map(|s| Self::if_nesting_depth(s, new_depth))
305 .max()
306 .unwrap_or(new_depth)
307 })
308 .unwrap_or(new_depth);
309
310 then_max.max(else_max)
311 }
312 Statement::Quotation { body, .. } => {
313 body.iter()
315 .map(|s| Self::if_nesting_depth(s, 0))
316 .max()
317 .unwrap_or(0)
318 }
319 Statement::Match { arms } => {
320 arms.iter()
322 .flat_map(|arm| arm.body.iter())
323 .map(|s| Self::if_nesting_depth(s, current_depth))
324 .max()
325 .unwrap_or(current_depth)
326 }
327 _ => current_depth,
328 }
329 }
330
331 fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
336 let mut words = Vec::new();
337 for stmt in statements {
338 if let Statement::WordCall { name, span } = stmt {
339 words.push(WordInfo {
340 name: name.as_str(),
341 span: span.as_ref(),
342 });
343 } else {
344 words.push(WordInfo {
348 name: "<non-word>",
349 span: None,
350 });
351 }
352 }
353 words
354 }
355
356 fn find_matches(
358 &self,
359 word_infos: &[WordInfo],
360 pattern: &CompiledPattern,
361 word: &WordDef,
362 file: &Path,
363 fallback_line: usize,
364 diagnostics: &mut Vec<LintDiagnostic>,
365 ) {
366 if word_infos.is_empty() || pattern.elements.is_empty() {
367 return;
368 }
369
370 let mut i = 0;
372 while i < word_infos.len() {
373 if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
374 let first_span = word_infos[i].span;
376 let last_span = word_infos[i + match_len - 1].span;
377
378 let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
380
381 let (end_line, start_column, end_column) =
383 if let (Some(first), Some(last)) = (first_span, last_span) {
384 if first.line == last.line {
385 (None, Some(first.column), Some(last.column + last.length))
387 } else {
388 (
390 Some(last.line),
391 Some(first.column),
392 Some(last.column + last.length),
393 )
394 }
395 } else {
396 (None, None, None)
397 };
398
399 diagnostics.push(LintDiagnostic {
400 id: pattern.rule.id.clone(),
401 message: pattern.rule.message.clone(),
402 severity: pattern.rule.severity,
403 replacement: pattern.rule.replacement.clone(),
404 file: file.to_path_buf(),
405 line,
406 end_line,
407 start_column,
408 end_column,
409 word_name: word.name.clone(),
410 start_index: i,
411 end_index: i + match_len,
412 });
413 i += match_len;
415 } else {
416 i += 1;
417 }
418 }
419 }
420
421 fn try_match_at(
423 word_infos: &[WordInfo],
424 start: usize,
425 elements: &[PatternElement],
426 ) -> Option<usize> {
427 let mut word_idx = start;
428 let mut elem_idx = 0;
429
430 while elem_idx < elements.len() {
431 match &elements[elem_idx] {
432 PatternElement::Word(expected) => {
433 if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
434 return None;
435 }
436 word_idx += 1;
437 elem_idx += 1;
438 }
439 PatternElement::SingleWildcard(_) => {
440 if word_idx >= word_infos.len() {
441 return None;
442 }
443 word_idx += 1;
444 elem_idx += 1;
445 }
446 PatternElement::MultiWildcard => {
447 elem_idx += 1;
449 if elem_idx >= elements.len() {
450 return Some(word_infos.len() - start);
452 }
453 for try_idx in word_idx..=word_infos.len() {
455 if let Some(rest_len) =
456 Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
457 {
458 return Some(try_idx - start + rest_len);
459 }
460 }
461 return None;
462 }
463 }
464 }
465
466 Some(word_idx - start)
467 }
468
469 fn lint_nested(
471 &self,
472 statements: &[Statement],
473 word: &WordDef,
474 file: &Path,
475 diagnostics: &mut Vec<LintDiagnostic>,
476 ) {
477 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
478
479 for stmt in statements {
480 match stmt {
481 Statement::Quotation { body, .. } => {
482 let word_infos = self.extract_word_sequence(body);
484 for pattern in &self.patterns {
485 self.find_matches(
486 &word_infos,
487 pattern,
488 word,
489 file,
490 fallback_line,
491 diagnostics,
492 );
493 }
494 self.lint_nested(body, word, file, diagnostics);
496 }
497 Statement::If {
498 then_branch,
499 else_branch,
500 } => {
501 let word_infos = self.extract_word_sequence(then_branch);
503 for pattern in &self.patterns {
504 self.find_matches(
505 &word_infos,
506 pattern,
507 word,
508 file,
509 fallback_line,
510 diagnostics,
511 );
512 }
513 self.lint_nested(then_branch, word, file, diagnostics);
514
515 if let Some(else_stmts) = else_branch {
516 let word_infos = self.extract_word_sequence(else_stmts);
517 for pattern in &self.patterns {
518 self.find_matches(
519 &word_infos,
520 pattern,
521 word,
522 file,
523 fallback_line,
524 diagnostics,
525 );
526 }
527 self.lint_nested(else_stmts, word, file, diagnostics);
528 }
529 }
530 Statement::Match { arms } => {
531 for arm in arms {
532 let word_infos = self.extract_word_sequence(&arm.body);
533 for pattern in &self.patterns {
534 self.find_matches(
535 &word_infos,
536 pattern,
537 word,
538 file,
539 fallback_line,
540 diagnostics,
541 );
542 }
543 self.lint_nested(&arm.body, word, file, diagnostics);
544 }
545 }
546 _ => {}
547 }
548 }
549 }
550}
551
552pub fn format_diagnostics(diagnostics: &[LintDiagnostic]) -> String {
554 let mut output = String::new();
555 for d in diagnostics {
556 let severity_str = match d.severity {
557 Severity::Error => "error",
558 Severity::Warning => "warning",
559 Severity::Hint => "hint",
560 };
561 let location = match d.start_column {
563 Some(col) => format!("{}:{}:{}", d.file.display(), d.line + 1, col + 1),
564 None => format!("{}:{}", d.file.display(), d.line + 1),
565 };
566 output.push_str(&format!(
567 "{}: {} [{}]: {}\n",
568 location, severity_str, d.id, d.message
569 ));
570 if !d.replacement.is_empty() {
571 output.push_str(&format!(" suggestion: replace with `{}`\n", d.replacement));
572 } else if d.replacement.is_empty() && d.message.contains("no effect") {
573 output.push_str(" suggestion: remove this code\n");
574 }
575 }
576 output
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 fn test_config() -> LintConfig {
584 LintConfig::from_toml(
585 r#"
586[[lint]]
587id = "redundant-dup-drop"
588pattern = "dup drop"
589replacement = ""
590message = "`dup drop` has no effect"
591severity = "warning"
592
593[[lint]]
594id = "prefer-nip"
595pattern = "swap drop"
596replacement = "nip"
597message = "prefer `nip` over `swap drop`"
598severity = "hint"
599
600[[lint]]
601id = "redundant-swap-swap"
602pattern = "swap swap"
603replacement = ""
604message = "consecutive swaps cancel out"
605severity = "warning"
606"#,
607 )
608 .unwrap()
609 }
610
611 #[test]
612 fn test_parse_config() {
613 let config = test_config();
614 assert_eq!(config.rules.len(), 3);
615 assert_eq!(config.rules[0].id, "redundant-dup-drop");
616 assert_eq!(config.rules[1].severity, Severity::Hint);
617 }
618
619 #[test]
620 fn test_compile_pattern() {
621 let rule = LintRule {
622 id: "test".to_string(),
623 pattern: "swap drop".to_string(),
624 replacement: "nip".to_string(),
625 message: "test".to_string(),
626 severity: Severity::Warning,
627 };
628 let compiled = CompiledPattern::compile(rule).unwrap();
629 assert_eq!(compiled.elements.len(), 2);
630 assert_eq!(
631 compiled.elements[0],
632 PatternElement::Word("swap".to_string())
633 );
634 assert_eq!(
635 compiled.elements[1],
636 PatternElement::Word("drop".to_string())
637 );
638 }
639
640 #[test]
641 fn test_compile_pattern_with_wildcards() {
642 let rule = LintRule {
643 id: "test".to_string(),
644 pattern: "dup $X drop".to_string(),
645 replacement: "".to_string(),
646 message: "test".to_string(),
647 severity: Severity::Warning,
648 };
649 let compiled = CompiledPattern::compile(rule).unwrap();
650 assert_eq!(compiled.elements.len(), 3);
651 assert_eq!(
652 compiled.elements[1],
653 PatternElement::SingleWildcard("$X".to_string())
654 );
655 }
656
657 #[test]
658 fn test_simple_match() {
659 let config = test_config();
660 let linter = Linter::new(&config).unwrap();
661
662 let program = Program {
664 includes: vec![],
665 unions: vec![],
666 words: vec![WordDef {
667 name: "test".to_string(),
668 effect: None,
669 body: vec![
670 Statement::IntLiteral(1),
671 Statement::IntLiteral(2),
672 Statement::WordCall {
673 name: "swap".to_string(),
674 span: None,
675 },
676 Statement::WordCall {
677 name: "drop".to_string(),
678 span: None,
679 },
680 ],
681 source: None,
682 allowed_lints: vec![],
683 }],
684 };
685
686 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
687 assert_eq!(diagnostics.len(), 1);
688 assert_eq!(diagnostics[0].id, "prefer-nip");
689 assert_eq!(diagnostics[0].replacement, "nip");
690 }
691
692 #[test]
693 fn test_no_false_positives() {
694 let config = test_config();
695 let linter = Linter::new(&config).unwrap();
696
697 let program = Program {
699 includes: vec![],
700 unions: vec![],
701 words: vec![WordDef {
702 name: "test".to_string(),
703 effect: None,
704 body: vec![
705 Statement::WordCall {
706 name: "swap".to_string(),
707 span: None,
708 },
709 Statement::WordCall {
710 name: "dup".to_string(),
711 span: None,
712 },
713 ],
714 source: None,
715 allowed_lints: vec![],
716 }],
717 };
718
719 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
720 assert!(diagnostics.is_empty());
721 }
722
723 #[test]
724 fn test_multiple_matches() {
725 let config = test_config();
726 let linter = Linter::new(&config).unwrap();
727
728 let program = Program {
730 includes: vec![],
731 unions: vec![],
732 words: vec![WordDef {
733 name: "test".to_string(),
734 effect: None,
735 body: vec![
736 Statement::WordCall {
737 name: "swap".to_string(),
738 span: None,
739 },
740 Statement::WordCall {
741 name: "drop".to_string(),
742 span: None,
743 },
744 Statement::WordCall {
745 name: "dup".to_string(),
746 span: None,
747 },
748 Statement::WordCall {
749 name: "swap".to_string(),
750 span: None,
751 },
752 Statement::WordCall {
753 name: "drop".to_string(),
754 span: None,
755 },
756 ],
757 source: None,
758 allowed_lints: vec![],
759 }],
760 };
761
762 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
763 assert_eq!(diagnostics.len(), 2);
764 }
765
766 #[test]
767 fn test_multi_wildcard_validation() {
768 let rule = LintRule {
770 id: "bad-pattern".to_string(),
771 pattern: "$... foo $...".to_string(),
772 replacement: "".to_string(),
773 message: "test".to_string(),
774 severity: Severity::Warning,
775 };
776 let result = CompiledPattern::compile(rule);
777 assert!(result.is_err());
778 assert!(result.unwrap_err().contains("multi-wildcards"));
779 }
780
781 #[test]
782 fn test_single_multi_wildcard_allowed() {
783 let rule = LintRule {
785 id: "ok-pattern".to_string(),
786 pattern: "$... foo".to_string(),
787 replacement: "".to_string(),
788 message: "test".to_string(),
789 severity: Severity::Warning,
790 };
791 let result = CompiledPattern::compile(rule);
792 assert!(result.is_ok());
793 }
794
795 #[test]
796 fn test_literal_breaks_pattern() {
797 let config = test_config();
799 let linter = Linter::new(&config).unwrap();
800
801 let program = Program {
802 includes: vec![],
803 unions: vec![],
804 words: vec![WordDef {
805 name: "test".to_string(),
806 effect: None,
807 body: vec![
808 Statement::WordCall {
809 name: "swap".to_string(),
810 span: None,
811 },
812 Statement::IntLiteral(0), Statement::WordCall {
814 name: "swap".to_string(),
815 span: None,
816 },
817 ],
818 source: None,
819 allowed_lints: vec![],
820 }],
821 };
822
823 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
824 assert!(
826 diagnostics.is_empty(),
827 "Expected no matches, but got: {:?}",
828 diagnostics
829 );
830 }
831
832 #[test]
833 fn test_consecutive_swap_swap_still_matches() {
834 let config = test_config();
836 let linter = Linter::new(&config).unwrap();
837
838 let program = Program {
839 includes: vec![],
840 unions: vec![],
841 words: vec![WordDef {
842 name: "test".to_string(),
843 effect: None,
844 body: vec![
845 Statement::WordCall {
846 name: "swap".to_string(),
847 span: None,
848 },
849 Statement::WordCall {
850 name: "swap".to_string(),
851 span: None,
852 },
853 ],
854 source: None,
855 allowed_lints: vec![],
856 }],
857 };
858
859 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
860 assert_eq!(diagnostics.len(), 1);
861 assert_eq!(diagnostics[0].id, "redundant-swap-swap");
862 }
863}