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
40impl Severity {
41 pub fn to_lsp_severity(&self) -> u32 {
43 match self {
44 Severity::Error => 1,
45 Severity::Warning => 2,
46 Severity::Hint => 4,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Deserialize)]
53pub struct LintRule {
54 pub id: String,
56 pub pattern: String,
58 #[serde(default)]
60 pub replacement: String,
61 pub message: String,
63 #[serde(default = "default_severity")]
65 pub severity: Severity,
66}
67
68fn default_severity() -> Severity {
69 Severity::Warning
70}
71
72#[derive(Debug, Clone, Deserialize)]
74pub struct LintConfig {
75 #[serde(rename = "lint")]
76 pub rules: Vec<LintRule>,
77}
78
79impl LintConfig {
80 pub fn from_toml(toml_str: &str) -> Result<Self, String> {
82 toml::from_str(toml_str).map_err(|e| format!("Failed to parse lint config: {}", e))
83 }
84
85 pub fn default_config() -> Result<Self, String> {
87 Self::from_toml(DEFAULT_LINTS)
88 }
89
90 pub fn merge(&mut self, other: LintConfig) {
92 for rule in other.rules {
94 if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule.id) {
95 *existing = rule;
96 } else {
97 self.rules.push(rule);
98 }
99 }
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct CompiledPattern {
106 pub rule: LintRule,
108 pub elements: Vec<PatternElement>,
110}
111
112#[derive(Debug, Clone, PartialEq)]
114pub enum PatternElement {
115 Word(String),
117 SingleWildcard(String),
119 MultiWildcard,
121}
122
123impl CompiledPattern {
124 pub fn compile(rule: LintRule) -> Result<Self, String> {
126 let mut elements = Vec::new();
127 let mut multi_wildcard_count = 0;
128
129 for token in rule.pattern.split_whitespace() {
130 if token == "$..." {
131 multi_wildcard_count += 1;
132 elements.push(PatternElement::MultiWildcard);
133 } else if token.starts_with('$') {
134 elements.push(PatternElement::SingleWildcard(token.to_string()));
135 } else {
136 elements.push(PatternElement::Word(token.to_string()));
137 }
138 }
139
140 if elements.is_empty() {
141 return Err(format!("Empty pattern in lint rule '{}'", rule.id));
142 }
143
144 if multi_wildcard_count > 1 {
147 return Err(format!(
148 "Pattern in lint rule '{}' has {} multi-wildcards ($...), but at most 1 is allowed",
149 rule.id, multi_wildcard_count
150 ));
151 }
152
153 Ok(CompiledPattern { rule, elements })
154 }
155}
156
157#[derive(Debug, Clone)]
159pub struct LintDiagnostic {
160 pub id: String,
162 pub message: String,
164 pub severity: Severity,
166 pub replacement: String,
168 pub file: PathBuf,
170 pub line: usize,
172 pub end_line: Option<usize>,
174 pub start_column: Option<usize>,
176 pub end_column: Option<usize>,
178 pub word_name: String,
180 pub start_index: usize,
182 pub end_index: usize,
184}
185
186#[derive(Debug, Clone)]
188struct WordInfo<'a> {
189 name: &'a str,
190 span: Option<&'a Span>,
191}
192
193pub struct Linter {
195 patterns: Vec<CompiledPattern>,
196}
197
198impl Linter {
199 pub fn new(config: &LintConfig) -> Result<Self, String> {
201 let mut patterns = Vec::new();
202 for rule in &config.rules {
203 patterns.push(CompiledPattern::compile(rule.clone())?);
204 }
205 Ok(Linter { patterns })
206 }
207
208 pub fn with_defaults() -> Result<Self, String> {
210 let config = LintConfig::default_config()?;
211 Self::new(&config)
212 }
213
214 pub fn lint_program(&self, program: &Program, file: &Path) -> Vec<LintDiagnostic> {
216 let mut diagnostics = Vec::new();
217
218 for word in &program.words {
219 self.lint_word(word, file, &mut diagnostics);
220 }
221
222 diagnostics
223 }
224
225 fn lint_word(&self, word: &WordDef, file: &Path, diagnostics: &mut Vec<LintDiagnostic>) {
227 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
228
229 let word_infos = self.extract_word_sequence(&word.body);
231
232 for pattern in &self.patterns {
234 self.find_matches(&word_infos, pattern, word, file, fallback_line, diagnostics);
235 }
236
237 let max_depth = Self::max_if_nesting_depth(&word.body);
239 if max_depth >= MAX_NESTING_DEPTH {
240 diagnostics.push(LintDiagnostic {
241 id: "deep-nesting".to_string(),
242 message: format!(
243 "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
244 max_depth
245 ),
246 severity: Severity::Hint,
247 replacement: String::new(),
248 file: file.to_path_buf(),
249 line: fallback_line,
250 end_line: None,
251 start_column: None,
252 end_column: None,
253 word_name: word.name.clone(),
254 start_index: 0,
255 end_index: 0,
256 });
257 }
258
259 self.lint_nested(&word.body, word, file, diagnostics);
261 }
262
263 fn max_if_nesting_depth(statements: &[Statement]) -> usize {
265 let mut max_depth = 0;
266 for stmt in statements {
267 let depth = Self::if_nesting_depth(stmt, 0);
268 if depth > max_depth {
269 max_depth = depth;
270 }
271 }
272 max_depth
273 }
274
275 fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
277 match stmt {
278 Statement::If {
279 then_branch,
280 else_branch,
281 } => {
282 let new_depth = current_depth + 1;
284
285 let then_max = then_branch
287 .iter()
288 .map(|s| Self::if_nesting_depth(s, new_depth))
289 .max()
290 .unwrap_or(new_depth);
291
292 let else_max = else_branch
294 .as_ref()
295 .map(|stmts| {
296 stmts
297 .iter()
298 .map(|s| Self::if_nesting_depth(s, new_depth))
299 .max()
300 .unwrap_or(new_depth)
301 })
302 .unwrap_or(new_depth);
303
304 then_max.max(else_max)
305 }
306 Statement::Quotation { body, .. } => {
307 body.iter()
309 .map(|s| Self::if_nesting_depth(s, 0))
310 .max()
311 .unwrap_or(0)
312 }
313 Statement::Match { arms } => {
314 arms.iter()
316 .flat_map(|arm| arm.body.iter())
317 .map(|s| Self::if_nesting_depth(s, current_depth))
318 .max()
319 .unwrap_or(current_depth)
320 }
321 _ => current_depth,
322 }
323 }
324
325 fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
330 let mut words = Vec::new();
331 for stmt in statements {
332 if let Statement::WordCall { name, span } = stmt {
333 words.push(WordInfo {
334 name: name.as_str(),
335 span: span.as_ref(),
336 });
337 } else {
338 words.push(WordInfo {
342 name: "<non-word>",
343 span: None,
344 });
345 }
346 }
347 words
348 }
349
350 fn find_matches(
352 &self,
353 word_infos: &[WordInfo],
354 pattern: &CompiledPattern,
355 word: &WordDef,
356 file: &Path,
357 fallback_line: usize,
358 diagnostics: &mut Vec<LintDiagnostic>,
359 ) {
360 if word_infos.is_empty() || pattern.elements.is_empty() {
361 return;
362 }
363
364 let mut i = 0;
366 while i < word_infos.len() {
367 if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
368 let first_span = word_infos[i].span;
370 let last_span = word_infos[i + match_len - 1].span;
371
372 let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
374
375 let (end_line, start_column, end_column) =
377 if let (Some(first), Some(last)) = (first_span, last_span) {
378 if first.line == last.line {
379 (None, Some(first.column), Some(last.column + last.length))
381 } else {
382 (
384 Some(last.line),
385 Some(first.column),
386 Some(last.column + last.length),
387 )
388 }
389 } else {
390 (None, None, None)
391 };
392
393 diagnostics.push(LintDiagnostic {
394 id: pattern.rule.id.clone(),
395 message: pattern.rule.message.clone(),
396 severity: pattern.rule.severity,
397 replacement: pattern.rule.replacement.clone(),
398 file: file.to_path_buf(),
399 line,
400 end_line,
401 start_column,
402 end_column,
403 word_name: word.name.clone(),
404 start_index: i,
405 end_index: i + match_len,
406 });
407 i += match_len;
409 } else {
410 i += 1;
411 }
412 }
413 }
414
415 fn try_match_at(
417 word_infos: &[WordInfo],
418 start: usize,
419 elements: &[PatternElement],
420 ) -> Option<usize> {
421 let mut word_idx = start;
422 let mut elem_idx = 0;
423
424 while elem_idx < elements.len() {
425 match &elements[elem_idx] {
426 PatternElement::Word(expected) => {
427 if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
428 return None;
429 }
430 word_idx += 1;
431 elem_idx += 1;
432 }
433 PatternElement::SingleWildcard(_) => {
434 if word_idx >= word_infos.len() {
435 return None;
436 }
437 word_idx += 1;
438 elem_idx += 1;
439 }
440 PatternElement::MultiWildcard => {
441 elem_idx += 1;
443 if elem_idx >= elements.len() {
444 return Some(word_infos.len() - start);
446 }
447 for try_idx in word_idx..=word_infos.len() {
449 if let Some(rest_len) =
450 Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
451 {
452 return Some(try_idx - start + rest_len);
453 }
454 }
455 return None;
456 }
457 }
458 }
459
460 Some(word_idx - start)
461 }
462
463 fn lint_nested(
465 &self,
466 statements: &[Statement],
467 word: &WordDef,
468 file: &Path,
469 diagnostics: &mut Vec<LintDiagnostic>,
470 ) {
471 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
472
473 for stmt in statements {
474 match stmt {
475 Statement::Quotation { body, .. } => {
476 let word_infos = self.extract_word_sequence(body);
478 for pattern in &self.patterns {
479 self.find_matches(
480 &word_infos,
481 pattern,
482 word,
483 file,
484 fallback_line,
485 diagnostics,
486 );
487 }
488 self.lint_nested(body, word, file, diagnostics);
490 }
491 Statement::If {
492 then_branch,
493 else_branch,
494 } => {
495 let word_infos = self.extract_word_sequence(then_branch);
497 for pattern in &self.patterns {
498 self.find_matches(
499 &word_infos,
500 pattern,
501 word,
502 file,
503 fallback_line,
504 diagnostics,
505 );
506 }
507 self.lint_nested(then_branch, word, file, diagnostics);
508
509 if let Some(else_stmts) = else_branch {
510 let word_infos = self.extract_word_sequence(else_stmts);
511 for pattern in &self.patterns {
512 self.find_matches(
513 &word_infos,
514 pattern,
515 word,
516 file,
517 fallback_line,
518 diagnostics,
519 );
520 }
521 self.lint_nested(else_stmts, word, file, diagnostics);
522 }
523 }
524 Statement::Match { arms } => {
525 for arm in arms {
526 let word_infos = self.extract_word_sequence(&arm.body);
527 for pattern in &self.patterns {
528 self.find_matches(
529 &word_infos,
530 pattern,
531 word,
532 file,
533 fallback_line,
534 diagnostics,
535 );
536 }
537 self.lint_nested(&arm.body, word, file, diagnostics);
538 }
539 }
540 _ => {}
541 }
542 }
543 }
544}
545
546pub fn format_diagnostics(diagnostics: &[LintDiagnostic]) -> String {
548 let mut output = String::new();
549 for d in diagnostics {
550 let severity_str = match d.severity {
551 Severity::Error => "error",
552 Severity::Warning => "warning",
553 Severity::Hint => "hint",
554 };
555 let location = match d.start_column {
557 Some(col) => format!("{}:{}:{}", d.file.display(), d.line + 1, col + 1),
558 None => format!("{}:{}", d.file.display(), d.line + 1),
559 };
560 output.push_str(&format!(
561 "{}: {} [{}]: {}\n",
562 location, severity_str, d.id, d.message
563 ));
564 if !d.replacement.is_empty() {
565 output.push_str(&format!(" suggestion: replace with `{}`\n", d.replacement));
566 } else if d.replacement.is_empty() && d.message.contains("no effect") {
567 output.push_str(" suggestion: remove this code\n");
568 }
569 }
570 output
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 fn test_config() -> LintConfig {
578 LintConfig::from_toml(
579 r#"
580[[lint]]
581id = "redundant-dup-drop"
582pattern = "dup drop"
583replacement = ""
584message = "`dup drop` has no effect"
585severity = "warning"
586
587[[lint]]
588id = "prefer-nip"
589pattern = "swap drop"
590replacement = "nip"
591message = "prefer `nip` over `swap drop`"
592severity = "hint"
593
594[[lint]]
595id = "redundant-swap-swap"
596pattern = "swap swap"
597replacement = ""
598message = "consecutive swaps cancel out"
599severity = "warning"
600"#,
601 )
602 .unwrap()
603 }
604
605 #[test]
606 fn test_parse_config() {
607 let config = test_config();
608 assert_eq!(config.rules.len(), 3);
609 assert_eq!(config.rules[0].id, "redundant-dup-drop");
610 assert_eq!(config.rules[1].severity, Severity::Hint);
611 }
612
613 #[test]
614 fn test_compile_pattern() {
615 let rule = LintRule {
616 id: "test".to_string(),
617 pattern: "swap drop".to_string(),
618 replacement: "nip".to_string(),
619 message: "test".to_string(),
620 severity: Severity::Warning,
621 };
622 let compiled = CompiledPattern::compile(rule).unwrap();
623 assert_eq!(compiled.elements.len(), 2);
624 assert_eq!(
625 compiled.elements[0],
626 PatternElement::Word("swap".to_string())
627 );
628 assert_eq!(
629 compiled.elements[1],
630 PatternElement::Word("drop".to_string())
631 );
632 }
633
634 #[test]
635 fn test_compile_pattern_with_wildcards() {
636 let rule = LintRule {
637 id: "test".to_string(),
638 pattern: "dup $X drop".to_string(),
639 replacement: "".to_string(),
640 message: "test".to_string(),
641 severity: Severity::Warning,
642 };
643 let compiled = CompiledPattern::compile(rule).unwrap();
644 assert_eq!(compiled.elements.len(), 3);
645 assert_eq!(
646 compiled.elements[1],
647 PatternElement::SingleWildcard("$X".to_string())
648 );
649 }
650
651 #[test]
652 fn test_simple_match() {
653 let config = test_config();
654 let linter = Linter::new(&config).unwrap();
655
656 let program = Program {
658 includes: vec![],
659 unions: vec![],
660 words: vec![WordDef {
661 name: "test".to_string(),
662 effect: None,
663 body: vec![
664 Statement::IntLiteral(1),
665 Statement::IntLiteral(2),
666 Statement::WordCall {
667 name: "swap".to_string(),
668 span: None,
669 },
670 Statement::WordCall {
671 name: "drop".to_string(),
672 span: None,
673 },
674 ],
675 source: None,
676 }],
677 };
678
679 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
680 assert_eq!(diagnostics.len(), 1);
681 assert_eq!(diagnostics[0].id, "prefer-nip");
682 assert_eq!(diagnostics[0].replacement, "nip");
683 }
684
685 #[test]
686 fn test_no_false_positives() {
687 let config = test_config();
688 let linter = Linter::new(&config).unwrap();
689
690 let program = Program {
692 includes: vec![],
693 unions: vec![],
694 words: vec![WordDef {
695 name: "test".to_string(),
696 effect: None,
697 body: vec![
698 Statement::WordCall {
699 name: "swap".to_string(),
700 span: None,
701 },
702 Statement::WordCall {
703 name: "dup".to_string(),
704 span: None,
705 },
706 ],
707 source: None,
708 }],
709 };
710
711 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
712 assert!(diagnostics.is_empty());
713 }
714
715 #[test]
716 fn test_multiple_matches() {
717 let config = test_config();
718 let linter = Linter::new(&config).unwrap();
719
720 let program = Program {
722 includes: vec![],
723 unions: vec![],
724 words: vec![WordDef {
725 name: "test".to_string(),
726 effect: None,
727 body: vec![
728 Statement::WordCall {
729 name: "swap".to_string(),
730 span: None,
731 },
732 Statement::WordCall {
733 name: "drop".to_string(),
734 span: None,
735 },
736 Statement::WordCall {
737 name: "dup".to_string(),
738 span: None,
739 },
740 Statement::WordCall {
741 name: "swap".to_string(),
742 span: None,
743 },
744 Statement::WordCall {
745 name: "drop".to_string(),
746 span: None,
747 },
748 ],
749 source: None,
750 }],
751 };
752
753 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
754 assert_eq!(diagnostics.len(), 2);
755 }
756
757 #[test]
758 fn test_multi_wildcard_validation() {
759 let rule = LintRule {
761 id: "bad-pattern".to_string(),
762 pattern: "$... foo $...".to_string(),
763 replacement: "".to_string(),
764 message: "test".to_string(),
765 severity: Severity::Warning,
766 };
767 let result = CompiledPattern::compile(rule);
768 assert!(result.is_err());
769 assert!(result.unwrap_err().contains("multi-wildcards"));
770 }
771
772 #[test]
773 fn test_single_multi_wildcard_allowed() {
774 let rule = LintRule {
776 id: "ok-pattern".to_string(),
777 pattern: "$... foo".to_string(),
778 replacement: "".to_string(),
779 message: "test".to_string(),
780 severity: Severity::Warning,
781 };
782 let result = CompiledPattern::compile(rule);
783 assert!(result.is_ok());
784 }
785
786 #[test]
787 fn test_literal_breaks_pattern() {
788 let config = test_config();
790 let linter = Linter::new(&config).unwrap();
791
792 let program = Program {
793 includes: vec![],
794 unions: vec![],
795 words: vec![WordDef {
796 name: "test".to_string(),
797 effect: None,
798 body: vec![
799 Statement::WordCall {
800 name: "swap".to_string(),
801 span: None,
802 },
803 Statement::IntLiteral(0), Statement::WordCall {
805 name: "swap".to_string(),
806 span: None,
807 },
808 ],
809 source: None,
810 }],
811 };
812
813 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
814 assert!(
816 diagnostics.is_empty(),
817 "Expected no matches, but got: {:?}",
818 diagnostics
819 );
820 }
821
822 #[test]
823 fn test_consecutive_swap_swap_still_matches() {
824 let config = test_config();
826 let linter = Linter::new(&config).unwrap();
827
828 let program = Program {
829 includes: vec![],
830 unions: vec![],
831 words: vec![WordDef {
832 name: "test".to_string(),
833 effect: None,
834 body: vec![
835 Statement::WordCall {
836 name: "swap".to_string(),
837 span: None,
838 },
839 Statement::WordCall {
840 name: "swap".to_string(),
841 span: None,
842 },
843 ],
844 source: None,
845 }],
846 };
847
848 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
849 assert_eq!(diagnostics.len(), 1);
850 assert_eq!(diagnostics[0].id, "redundant-swap-swap");
851 }
852}