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