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 mut local_diagnostics = Vec::new();
231
232 let word_infos = self.extract_word_sequence(&word.body);
234
235 for pattern in &self.patterns {
237 self.find_matches(
238 &word_infos,
239 pattern,
240 word,
241 file,
242 fallback_line,
243 &mut local_diagnostics,
244 );
245 }
246
247 let max_depth = Self::max_if_nesting_depth(&word.body);
249 if max_depth >= MAX_NESTING_DEPTH {
250 local_diagnostics.push(LintDiagnostic {
251 id: "deep-nesting".to_string(),
252 message: format!(
253 "deeply nested if/else ({} levels) - consider using `cond` or extracting to helper words",
254 max_depth
255 ),
256 severity: Severity::Hint,
257 replacement: String::new(),
258 file: file.to_path_buf(),
259 line: fallback_line,
260 end_line: None,
261 start_column: None,
262 end_column: None,
263 word_name: word.name.clone(),
264 start_index: 0,
265 end_index: 0,
266 });
267 }
268
269 self.lint_nested(&word.body, word, file, &mut local_diagnostics);
271
272 for diagnostic in local_diagnostics {
274 if !word.allowed_lints.contains(&diagnostic.id) {
275 diagnostics.push(diagnostic);
276 }
277 }
278 }
279
280 fn max_if_nesting_depth(statements: &[Statement]) -> usize {
282 let mut max_depth = 0;
283 for stmt in statements {
284 let depth = Self::if_nesting_depth(stmt, 0);
285 if depth > max_depth {
286 max_depth = depth;
287 }
288 }
289 max_depth
290 }
291
292 fn if_nesting_depth(stmt: &Statement, current_depth: usize) -> usize {
294 match stmt {
295 Statement::If {
296 then_branch,
297 else_branch,
298 } => {
299 let new_depth = current_depth + 1;
301
302 let then_max = then_branch
304 .iter()
305 .map(|s| Self::if_nesting_depth(s, new_depth))
306 .max()
307 .unwrap_or(new_depth);
308
309 let else_max = else_branch
311 .as_ref()
312 .map(|stmts| {
313 stmts
314 .iter()
315 .map(|s| Self::if_nesting_depth(s, new_depth))
316 .max()
317 .unwrap_or(new_depth)
318 })
319 .unwrap_or(new_depth);
320
321 then_max.max(else_max)
322 }
323 Statement::Quotation { body, .. } => {
324 body.iter()
326 .map(|s| Self::if_nesting_depth(s, 0))
327 .max()
328 .unwrap_or(0)
329 }
330 Statement::Match { arms } => {
331 arms.iter()
333 .flat_map(|arm| arm.body.iter())
334 .map(|s| Self::if_nesting_depth(s, current_depth))
335 .max()
336 .unwrap_or(current_depth)
337 }
338 _ => current_depth,
339 }
340 }
341
342 fn extract_word_sequence<'a>(&self, statements: &'a [Statement]) -> Vec<WordInfo<'a>> {
347 let mut words = Vec::new();
348 for stmt in statements {
349 if let Statement::WordCall { name, span } = stmt {
350 words.push(WordInfo {
351 name: name.as_str(),
352 span: span.as_ref(),
353 });
354 } else {
355 words.push(WordInfo {
359 name: "<non-word>",
360 span: None,
361 });
362 }
363 }
364 words
365 }
366
367 fn find_matches(
369 &self,
370 word_infos: &[WordInfo],
371 pattern: &CompiledPattern,
372 word: &WordDef,
373 file: &Path,
374 fallback_line: usize,
375 diagnostics: &mut Vec<LintDiagnostic>,
376 ) {
377 if word_infos.is_empty() || pattern.elements.is_empty() {
378 return;
379 }
380
381 let mut i = 0;
383 while i < word_infos.len() {
384 if let Some(match_len) = Self::try_match_at(word_infos, i, &pattern.elements) {
385 let first_span = word_infos[i].span;
387 let last_span = word_infos[i + match_len - 1].span;
388
389 let line = first_span.map(|s| s.line).unwrap_or(fallback_line);
391
392 let (end_line, start_column, end_column) =
394 if let (Some(first), Some(last)) = (first_span, last_span) {
395 if first.line == last.line {
396 (None, Some(first.column), Some(last.column + last.length))
398 } else {
399 (
401 Some(last.line),
402 Some(first.column),
403 Some(last.column + last.length),
404 )
405 }
406 } else {
407 (None, None, None)
408 };
409
410 diagnostics.push(LintDiagnostic {
411 id: pattern.rule.id.clone(),
412 message: pattern.rule.message.clone(),
413 severity: pattern.rule.severity,
414 replacement: pattern.rule.replacement.clone(),
415 file: file.to_path_buf(),
416 line,
417 end_line,
418 start_column,
419 end_column,
420 word_name: word.name.clone(),
421 start_index: i,
422 end_index: i + match_len,
423 });
424 i += match_len;
426 } else {
427 i += 1;
428 }
429 }
430 }
431
432 fn try_match_at(
434 word_infos: &[WordInfo],
435 start: usize,
436 elements: &[PatternElement],
437 ) -> Option<usize> {
438 let mut word_idx = start;
439 let mut elem_idx = 0;
440
441 while elem_idx < elements.len() {
442 match &elements[elem_idx] {
443 PatternElement::Word(expected) => {
444 if word_idx >= word_infos.len() || word_infos[word_idx].name != expected {
445 return None;
446 }
447 word_idx += 1;
448 elem_idx += 1;
449 }
450 PatternElement::SingleWildcard(_) => {
451 if word_idx >= word_infos.len() {
452 return None;
453 }
454 word_idx += 1;
455 elem_idx += 1;
456 }
457 PatternElement::MultiWildcard => {
458 elem_idx += 1;
460 if elem_idx >= elements.len() {
461 return Some(word_infos.len() - start);
463 }
464 for try_idx in word_idx..=word_infos.len() {
466 if let Some(rest_len) =
467 Self::try_match_at(word_infos, try_idx, &elements[elem_idx..])
468 {
469 return Some(try_idx - start + rest_len);
470 }
471 }
472 return None;
473 }
474 }
475 }
476
477 Some(word_idx - start)
478 }
479
480 fn lint_nested(
482 &self,
483 statements: &[Statement],
484 word: &WordDef,
485 file: &Path,
486 diagnostics: &mut Vec<LintDiagnostic>,
487 ) {
488 let fallback_line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
489
490 for stmt in statements {
491 match stmt {
492 Statement::Quotation { body, .. } => {
493 let word_infos = self.extract_word_sequence(body);
495 for pattern in &self.patterns {
496 self.find_matches(
497 &word_infos,
498 pattern,
499 word,
500 file,
501 fallback_line,
502 diagnostics,
503 );
504 }
505 self.lint_nested(body, word, file, diagnostics);
507 }
508 Statement::If {
509 then_branch,
510 else_branch,
511 } => {
512 let word_infos = self.extract_word_sequence(then_branch);
514 for pattern in &self.patterns {
515 self.find_matches(
516 &word_infos,
517 pattern,
518 word,
519 file,
520 fallback_line,
521 diagnostics,
522 );
523 }
524 self.lint_nested(then_branch, word, file, diagnostics);
525
526 if let Some(else_stmts) = else_branch {
527 let word_infos = self.extract_word_sequence(else_stmts);
528 for pattern in &self.patterns {
529 self.find_matches(
530 &word_infos,
531 pattern,
532 word,
533 file,
534 fallback_line,
535 diagnostics,
536 );
537 }
538 self.lint_nested(else_stmts, word, file, diagnostics);
539 }
540 }
541 Statement::Match { arms } => {
542 for arm in arms {
543 let word_infos = self.extract_word_sequence(&arm.body);
544 for pattern in &self.patterns {
545 self.find_matches(
546 &word_infos,
547 pattern,
548 word,
549 file,
550 fallback_line,
551 diagnostics,
552 );
553 }
554 self.lint_nested(&arm.body, word, file, diagnostics);
555 }
556 }
557 _ => {}
558 }
559 }
560 }
561}
562
563pub fn format_diagnostics(diagnostics: &[LintDiagnostic]) -> String {
565 let mut output = String::new();
566 for d in diagnostics {
567 let severity_str = match d.severity {
568 Severity::Error => "error",
569 Severity::Warning => "warning",
570 Severity::Hint => "hint",
571 };
572 let location = match d.start_column {
574 Some(col) => format!("{}:{}:{}", d.file.display(), d.line + 1, col + 1),
575 None => format!("{}:{}", d.file.display(), d.line + 1),
576 };
577 output.push_str(&format!(
578 "{}: {} [{}]: {}\n",
579 location, severity_str, d.id, d.message
580 ));
581 if !d.replacement.is_empty() {
582 output.push_str(&format!(" suggestion: replace with `{}`\n", d.replacement));
583 } else if d.replacement.is_empty() && d.message.contains("no effect") {
584 output.push_str(" suggestion: remove this code\n");
585 }
586 }
587 output
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 fn test_config() -> LintConfig {
595 LintConfig::from_toml(
596 r#"
597[[lint]]
598id = "redundant-dup-drop"
599pattern = "dup drop"
600replacement = ""
601message = "`dup drop` has no effect"
602severity = "warning"
603
604[[lint]]
605id = "prefer-nip"
606pattern = "swap drop"
607replacement = "nip"
608message = "prefer `nip` over `swap drop`"
609severity = "hint"
610
611[[lint]]
612id = "redundant-swap-swap"
613pattern = "swap swap"
614replacement = ""
615message = "consecutive swaps cancel out"
616severity = "warning"
617"#,
618 )
619 .unwrap()
620 }
621
622 #[test]
623 fn test_parse_config() {
624 let config = test_config();
625 assert_eq!(config.rules.len(), 3);
626 assert_eq!(config.rules[0].id, "redundant-dup-drop");
627 assert_eq!(config.rules[1].severity, Severity::Hint);
628 }
629
630 #[test]
631 fn test_compile_pattern() {
632 let rule = LintRule {
633 id: "test".to_string(),
634 pattern: "swap drop".to_string(),
635 replacement: "nip".to_string(),
636 message: "test".to_string(),
637 severity: Severity::Warning,
638 };
639 let compiled = CompiledPattern::compile(rule).unwrap();
640 assert_eq!(compiled.elements.len(), 2);
641 assert_eq!(
642 compiled.elements[0],
643 PatternElement::Word("swap".to_string())
644 );
645 assert_eq!(
646 compiled.elements[1],
647 PatternElement::Word("drop".to_string())
648 );
649 }
650
651 #[test]
652 fn test_compile_pattern_with_wildcards() {
653 let rule = LintRule {
654 id: "test".to_string(),
655 pattern: "dup $X drop".to_string(),
656 replacement: "".to_string(),
657 message: "test".to_string(),
658 severity: Severity::Warning,
659 };
660 let compiled = CompiledPattern::compile(rule).unwrap();
661 assert_eq!(compiled.elements.len(), 3);
662 assert_eq!(
663 compiled.elements[1],
664 PatternElement::SingleWildcard("$X".to_string())
665 );
666 }
667
668 #[test]
669 fn test_simple_match() {
670 let config = test_config();
671 let linter = Linter::new(&config).unwrap();
672
673 let program = Program {
675 includes: vec![],
676 unions: vec![],
677 words: vec![WordDef {
678 name: "test".to_string(),
679 effect: None,
680 body: vec![
681 Statement::IntLiteral(1),
682 Statement::IntLiteral(2),
683 Statement::WordCall {
684 name: "swap".to_string(),
685 span: None,
686 },
687 Statement::WordCall {
688 name: "drop".to_string(),
689 span: None,
690 },
691 ],
692 source: None,
693 allowed_lints: vec![],
694 }],
695 };
696
697 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
698 assert_eq!(diagnostics.len(), 1);
699 assert_eq!(diagnostics[0].id, "prefer-nip");
700 assert_eq!(diagnostics[0].replacement, "nip");
701 }
702
703 #[test]
704 fn test_no_false_positives() {
705 let config = test_config();
706 let linter = Linter::new(&config).unwrap();
707
708 let program = Program {
710 includes: vec![],
711 unions: vec![],
712 words: vec![WordDef {
713 name: "test".to_string(),
714 effect: None,
715 body: vec![
716 Statement::WordCall {
717 name: "swap".to_string(),
718 span: None,
719 },
720 Statement::WordCall {
721 name: "dup".to_string(),
722 span: None,
723 },
724 ],
725 source: None,
726 allowed_lints: vec![],
727 }],
728 };
729
730 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
731 assert!(diagnostics.is_empty());
732 }
733
734 #[test]
735 fn test_multiple_matches() {
736 let config = test_config();
737 let linter = Linter::new(&config).unwrap();
738
739 let program = Program {
741 includes: vec![],
742 unions: vec![],
743 words: vec![WordDef {
744 name: "test".to_string(),
745 effect: None,
746 body: vec![
747 Statement::WordCall {
748 name: "swap".to_string(),
749 span: None,
750 },
751 Statement::WordCall {
752 name: "drop".to_string(),
753 span: None,
754 },
755 Statement::WordCall {
756 name: "dup".to_string(),
757 span: None,
758 },
759 Statement::WordCall {
760 name: "swap".to_string(),
761 span: None,
762 },
763 Statement::WordCall {
764 name: "drop".to_string(),
765 span: None,
766 },
767 ],
768 source: None,
769 allowed_lints: vec![],
770 }],
771 };
772
773 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
774 assert_eq!(diagnostics.len(), 2);
775 }
776
777 #[test]
778 fn test_multi_wildcard_validation() {
779 let rule = LintRule {
781 id: "bad-pattern".to_string(),
782 pattern: "$... foo $...".to_string(),
783 replacement: "".to_string(),
784 message: "test".to_string(),
785 severity: Severity::Warning,
786 };
787 let result = CompiledPattern::compile(rule);
788 assert!(result.is_err());
789 assert!(result.unwrap_err().contains("multi-wildcards"));
790 }
791
792 #[test]
793 fn test_single_multi_wildcard_allowed() {
794 let rule = LintRule {
796 id: "ok-pattern".to_string(),
797 pattern: "$... foo".to_string(),
798 replacement: "".to_string(),
799 message: "test".to_string(),
800 severity: Severity::Warning,
801 };
802 let result = CompiledPattern::compile(rule);
803 assert!(result.is_ok());
804 }
805
806 #[test]
807 fn test_literal_breaks_pattern() {
808 let config = test_config();
810 let linter = Linter::new(&config).unwrap();
811
812 let program = Program {
813 includes: vec![],
814 unions: vec![],
815 words: vec![WordDef {
816 name: "test".to_string(),
817 effect: None,
818 body: vec![
819 Statement::WordCall {
820 name: "swap".to_string(),
821 span: None,
822 },
823 Statement::IntLiteral(0), Statement::WordCall {
825 name: "swap".to_string(),
826 span: None,
827 },
828 ],
829 source: None,
830 allowed_lints: vec![],
831 }],
832 };
833
834 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
835 assert!(
837 diagnostics.is_empty(),
838 "Expected no matches, but got: {:?}",
839 diagnostics
840 );
841 }
842
843 #[test]
844 fn test_consecutive_swap_swap_still_matches() {
845 let config = test_config();
847 let linter = Linter::new(&config).unwrap();
848
849 let program = Program {
850 includes: vec![],
851 unions: vec![],
852 words: vec![WordDef {
853 name: "test".to_string(),
854 effect: None,
855 body: vec![
856 Statement::WordCall {
857 name: "swap".to_string(),
858 span: None,
859 },
860 Statement::WordCall {
861 name: "swap".to_string(),
862 span: None,
863 },
864 ],
865 source: None,
866 allowed_lints: vec![],
867 }],
868 };
869
870 let diagnostics = linter.lint_program(&program, &PathBuf::from("test.seq"));
871 assert_eq!(diagnostics.len(), 1);
872 assert_eq!(diagnostics[0].id, "redundant-swap-swap");
873 }
874}