1use crate::ast::*;
23use crate::diagnostic::{Diagnostic, Diagnostics, FixSuggestion, Severity};
24use crate::span::Span;
25use serde::{Deserialize, Serialize};
26use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(default)]
36pub struct LintConfigFile {
37 pub lint: LintSettings,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(default)]
44pub struct LintSettings {
45 pub suggest_unicode: bool,
47 pub check_naming: bool,
49 pub max_nesting_depth: usize,
51 pub levels: HashMap<String, String>,
53}
54
55impl Default for LintSettings {
56 fn default() -> Self {
57 Self {
58 suggest_unicode: true,
59 check_naming: true,
60 max_nesting_depth: 6,
61 levels: HashMap::new(),
62 }
63 }
64}
65
66impl Default for LintConfigFile {
67 fn default() -> Self {
68 Self {
69 lint: LintSettings::default(),
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct LintConfig {
77 pub levels: HashMap<String, LintLevel>,
79 pub suggest_unicode: bool,
81 pub check_naming: bool,
83 pub reserved_words: HashSet<String>,
85 pub max_nesting_depth: usize,
87}
88
89impl Default for LintConfig {
90 fn default() -> Self {
91 let mut reserved = HashSet::new();
92 for word in &[
93 "from", "split", "ref", "location", "save", "type", "move", "match", "loop", "if",
94 "else", "while", "for", "in", "return", "break", "continue", "fn", "let", "mut",
95 "const", "static", "struct", "enum", "trait", "impl", "pub", "mod", "use", "as",
96 "where", "async", "await", "dyn", "unsafe", "extern", "crate", "self", "super", "true",
97 "false",
98 ] {
99 reserved.insert(word.to_string());
100 }
101
102 Self {
103 levels: HashMap::new(),
104 suggest_unicode: true,
105 check_naming: true,
106 reserved_words: reserved,
107 max_nesting_depth: 6,
108 }
109 }
110}
111
112impl LintConfig {
113 pub fn from_file(path: &Path) -> Result<Self, String> {
115 let content = std::fs::read_to_string(path)
116 .map_err(|e| format!("Failed to read config file: {}", e))?;
117 Self::from_toml(&content)
118 }
119
120 pub fn from_toml(content: &str) -> Result<Self, String> {
122 let file: LintConfigFile =
123 toml::from_str(content).map_err(|e| format!("Failed to parse config: {}", e))?;
124
125 let mut config = Self::default();
126 config.suggest_unicode = file.lint.suggest_unicode;
127 config.check_naming = file.lint.check_naming;
128 config.max_nesting_depth = file.lint.max_nesting_depth;
129
130 for (name, level_str) in file.lint.levels {
132 let level = match level_str.to_lowercase().as_str() {
133 "allow" => LintLevel::Allow,
134 "warn" => LintLevel::Warn,
135 "deny" => LintLevel::Deny,
136 _ => return Err(format!("Invalid lint level '{}' for '{}'", level_str, name)),
137 };
138 config.levels.insert(name, level);
139 }
140
141 Ok(config)
142 }
143
144 pub fn find_and_load() -> Self {
146 let config_names = [".sigillint.toml", "sigillint.toml"];
147
148 if let Ok(mut dir) = std::env::current_dir() {
149 loop {
150 for name in &config_names {
151 let config_path = dir.join(name);
152 if config_path.exists() {
153 if let Ok(config) = Self::from_file(&config_path) {
154 return config;
155 }
156 }
157 }
158 if !dir.pop() {
159 break;
160 }
161 }
162 }
163
164 Self::default()
165 }
166
167 pub fn default_toml() -> String {
169 r#"# Sigil Linter Configuration
170# Place this file as .sigillint.toml in your project root
171
172[lint]
173# Suggest Unicode morphemes (→ instead of ->, etc.)
174suggest_unicode = true
175
176# Check naming conventions (PascalCase, snake_case, etc.)
177check_naming = true
178
179# Maximum nesting depth before warning (default: 6)
180max_nesting_depth = 6
181
182# Lint level overrides (allow, warn, or deny)
183[lint.levels]
184# unused_variable = "allow"
185# shadowing = "warn"
186# deep_nesting = "deny"
187# empty_block = "warn"
188# bool_comparison = "warn"
189"#
190 .to_string()
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum LintLevel {
198 Allow,
199 Warn,
200 Deny,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
205pub enum LintCategory {
206 Correctness,
208 Style,
210 Performance,
212 Complexity,
214 Sigil,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
224pub enum LintId {
225 ReservedIdentifier, NestedGenerics, PreferUnicodeMorpheme, NamingConvention, UnusedVariable, UnusedImport, Shadowing, DeepNesting, EmptyBlock, BoolComparison, RedundantElse, UnusedParameter, MagicNumber, MissingDocComment, HighComplexity, ConstantCondition, PreferIfLet, TodoWithoutIssue, LongFunction, TooManyParameters, NeedlessReturn, MissingReturn, PreferMorphemePipeline, EvidentialityViolation, UnvalidatedExternalData, CertaintyDowngrade, UnreachableCode, InfiniteLoop, DivisionByZero, EvidentialityMismatch, UncertaintyUnhandled, ReportedWithoutAttribution, BrokenMorphemePipeline, MorphemeTypeIncompatibility, InconsistentMorphemeStyle, InvalidHexagramNumber, InvalidTarotNumber, InvalidChakraIndex, InvalidZodiacIndex, InvalidGematriaValue, FrequencyOutOfRange, MissingEvidentialityMarker, PreferNamedEsotericConstant, EmotionIntensityOutOfRange, }
280
281impl LintId {
282 pub fn code(&self) -> &'static str {
283 match self {
284 LintId::ReservedIdentifier => "W0101",
285 LintId::NestedGenerics => "W0104",
286 LintId::PreferUnicodeMorpheme => "W0200",
287 LintId::NamingConvention => "W0201",
288 LintId::UnusedVariable => "W0202",
289 LintId::UnusedImport => "W0203",
290 LintId::Shadowing => "W0204",
291 LintId::DeepNesting => "W0205",
292 LintId::EmptyBlock => "W0206",
293 LintId::BoolComparison => "W0207",
294 LintId::RedundantElse => "W0208",
295 LintId::UnusedParameter => "W0209",
296 LintId::MagicNumber => "W0210",
297 LintId::MissingDocComment => "W0211",
298 LintId::HighComplexity => "W0212",
299 LintId::ConstantCondition => "W0213",
300 LintId::PreferIfLet => "W0214",
301 LintId::TodoWithoutIssue => "W0215",
302 LintId::LongFunction => "W0216",
303 LintId::TooManyParameters => "W0217",
304 LintId::NeedlessReturn => "W0218",
305 LintId::MissingReturn => "W0300",
306 LintId::PreferMorphemePipeline => "W0500",
307 LintId::EvidentialityViolation => "E0600",
308 LintId::UnvalidatedExternalData => "E0601",
309 LintId::CertaintyDowngrade => "E0602",
310 LintId::UnreachableCode => "E0700",
311 LintId::InfiniteLoop => "E0701",
312 LintId::DivisionByZero => "E0702",
313
314 LintId::EvidentialityMismatch => "E0603",
316 LintId::UncertaintyUnhandled => "E0604",
317 LintId::ReportedWithoutAttribution => "E0605",
318 LintId::BrokenMorphemePipeline => "W0501",
319 LintId::MorphemeTypeIncompatibility => "W0502",
320 LintId::InconsistentMorphemeStyle => "W0503",
321 LintId::InvalidHexagramNumber => "W0600",
322 LintId::InvalidTarotNumber => "W0601",
323 LintId::InvalidChakraIndex => "W0602",
324 LintId::InvalidZodiacIndex => "W0603",
325 LintId::InvalidGematriaValue => "W0604",
326 LintId::FrequencyOutOfRange => "W0605",
327 LintId::MissingEvidentialityMarker => "W0700",
328 LintId::PreferNamedEsotericConstant => "W0701",
329 LintId::EmotionIntensityOutOfRange => "W0702",
330 }
331 }
332
333 pub fn name(&self) -> &'static str {
334 match self {
335 LintId::ReservedIdentifier => "reserved_identifier",
336 LintId::NestedGenerics => "nested_generics_unsupported",
337 LintId::PreferUnicodeMorpheme => "prefer_unicode_morpheme",
338 LintId::NamingConvention => "naming_convention",
339 LintId::UnusedVariable => "unused_variable",
340 LintId::UnusedImport => "unused_import",
341 LintId::Shadowing => "shadowing",
342 LintId::DeepNesting => "deep_nesting",
343 LintId::EmptyBlock => "empty_block",
344 LintId::BoolComparison => "bool_comparison",
345 LintId::RedundantElse => "redundant_else",
346 LintId::UnusedParameter => "unused_parameter",
347 LintId::MagicNumber => "magic_number",
348 LintId::MissingDocComment => "missing_doc_comment",
349 LintId::HighComplexity => "high_complexity",
350 LintId::ConstantCondition => "constant_condition",
351 LintId::PreferIfLet => "prefer_if_let",
352 LintId::TodoWithoutIssue => "todo_without_issue",
353 LintId::LongFunction => "long_function",
354 LintId::TooManyParameters => "too_many_parameters",
355 LintId::NeedlessReturn => "needless_return",
356 LintId::MissingReturn => "missing_return",
357 LintId::PreferMorphemePipeline => "prefer_morpheme_pipeline",
358 LintId::EvidentialityViolation => "evidentiality_violation",
359 LintId::UnvalidatedExternalData => "unvalidated_external_data",
360 LintId::CertaintyDowngrade => "certainty_downgrade",
361 LintId::UnreachableCode => "unreachable_code",
362 LintId::InfiniteLoop => "infinite_loop",
363 LintId::DivisionByZero => "division_by_zero",
364
365 LintId::EvidentialityMismatch => "evidentiality_mismatch",
367 LintId::UncertaintyUnhandled => "uncertainty_unhandled",
368 LintId::ReportedWithoutAttribution => "reported_without_attribution",
369 LintId::BrokenMorphemePipeline => "broken_morpheme_pipeline",
370 LintId::MorphemeTypeIncompatibility => "morpheme_type_incompatibility",
371 LintId::InconsistentMorphemeStyle => "inconsistent_morpheme_style",
372 LintId::InvalidHexagramNumber => "invalid_hexagram_number",
373 LintId::InvalidTarotNumber => "invalid_tarot_number",
374 LintId::InvalidChakraIndex => "invalid_chakra_index",
375 LintId::InvalidZodiacIndex => "invalid_zodiac_index",
376 LintId::InvalidGematriaValue => "invalid_gematria_value",
377 LintId::FrequencyOutOfRange => "frequency_out_of_range",
378 LintId::MissingEvidentialityMarker => "missing_evidentiality_marker",
379 LintId::PreferNamedEsotericConstant => "prefer_named_esoteric_constant",
380 LintId::EmotionIntensityOutOfRange => "emotion_intensity_out_of_range",
381 }
382 }
383
384 pub fn default_level(&self) -> LintLevel {
385 match self {
386 LintId::ReservedIdentifier => LintLevel::Warn,
387 LintId::NestedGenerics => LintLevel::Warn,
388 LintId::PreferUnicodeMorpheme => LintLevel::Allow,
389 LintId::NamingConvention => LintLevel::Warn,
390 LintId::UnusedVariable => LintLevel::Warn,
391 LintId::UnusedImport => LintLevel::Warn,
392 LintId::Shadowing => LintLevel::Warn,
393 LintId::DeepNesting => LintLevel::Warn,
394 LintId::EmptyBlock => LintLevel::Warn,
395 LintId::BoolComparison => LintLevel::Warn,
396 LintId::RedundantElse => LintLevel::Warn,
397 LintId::UnusedParameter => LintLevel::Warn,
398 LintId::MagicNumber => LintLevel::Allow, LintId::MissingDocComment => LintLevel::Allow, LintId::HighComplexity => LintLevel::Warn,
401 LintId::ConstantCondition => LintLevel::Warn,
402 LintId::PreferIfLet => LintLevel::Allow, LintId::TodoWithoutIssue => LintLevel::Allow, LintId::LongFunction => LintLevel::Warn,
405 LintId::TooManyParameters => LintLevel::Warn,
406 LintId::NeedlessReturn => LintLevel::Allow, LintId::MissingReturn => LintLevel::Warn,
408 LintId::PreferMorphemePipeline => LintLevel::Allow, LintId::EvidentialityViolation => LintLevel::Deny,
410 LintId::UnvalidatedExternalData => LintLevel::Deny,
411 LintId::CertaintyDowngrade => LintLevel::Warn,
412 LintId::UnreachableCode => LintLevel::Warn,
413 LintId::InfiniteLoop => LintLevel::Warn,
414 LintId::DivisionByZero => LintLevel::Deny,
415
416 LintId::EvidentialityMismatch => LintLevel::Deny, LintId::UncertaintyUnhandled => LintLevel::Warn, LintId::ReportedWithoutAttribution => LintLevel::Warn, LintId::BrokenMorphemePipeline => LintLevel::Deny, LintId::MorphemeTypeIncompatibility => LintLevel::Deny, LintId::InconsistentMorphemeStyle => LintLevel::Allow, LintId::InvalidHexagramNumber => LintLevel::Warn, LintId::InvalidTarotNumber => LintLevel::Warn, LintId::InvalidChakraIndex => LintLevel::Warn, LintId::InvalidZodiacIndex => LintLevel::Warn, LintId::InvalidGematriaValue => LintLevel::Warn, LintId::FrequencyOutOfRange => LintLevel::Warn, LintId::MissingEvidentialityMarker => LintLevel::Allow, LintId::PreferNamedEsotericConstant => LintLevel::Allow, LintId::EmotionIntensityOutOfRange => LintLevel::Warn, }
433 }
434
435 pub fn description(&self) -> &'static str {
436 match self {
437 LintId::ReservedIdentifier => "This identifier is a reserved word in Sigil",
438 LintId::NestedGenerics => "Nested generic parameters may not parse correctly",
439 LintId::PreferUnicodeMorpheme => "Consider using Unicode morphemes for idiomatic Sigil",
440 LintId::NamingConvention => "Identifier does not follow Sigil naming conventions",
441 LintId::UnusedVariable => "Variable is declared but never used",
442 LintId::UnusedImport => "Import is never used",
443 LintId::Shadowing => "Variable shadows another variable from an outer scope",
444 LintId::DeepNesting => "Code has excessive nesting depth, consider refactoring",
445 LintId::EmptyBlock => "Empty block does nothing, consider adding code or removing",
446 LintId::BoolComparison => "Comparison to boolean literal is redundant",
447 LintId::RedundantElse => "Else branch after return/break/continue is redundant",
448 LintId::UnusedParameter => "Function parameter is never used",
449 LintId::MagicNumber => "Consider using a named constant instead of magic number",
450 LintId::MissingDocComment => "Public item should have a documentation comment",
451 LintId::HighComplexity => {
452 "Function has high cyclomatic complexity, consider refactoring"
453 }
454 LintId::ConstantCondition => "Condition is always true or always false",
455 LintId::PreferIfLet => "Consider using if-let instead of match with single arm",
456 LintId::TodoWithoutIssue => "TODO comment without issue reference",
457 LintId::LongFunction => "Function exceeds maximum line count",
458 LintId::TooManyParameters => "Function has too many parameters",
459 LintId::NeedlessReturn => "Unnecessary return statement at end of function",
460 LintId::MissingReturn => "Function may not return a value on all code paths",
461 LintId::PreferMorphemePipeline => {
462 "Consider using morpheme pipeline (|τ{}, |φ{}) instead of method chain"
463 }
464 LintId::EvidentialityViolation => "Evidence level mismatch in assignment or call",
465 LintId::UnvalidatedExternalData => "External data (~) used without validation",
466 LintId::CertaintyDowngrade => "Certain (!) data being downgraded to uncertain (?)",
467 LintId::UnreachableCode => "Code will never be executed",
468 LintId::InfiniteLoop => "Loop has no exit condition",
469 LintId::DivisionByZero => "Division by zero detected",
470
471 LintId::EvidentialityMismatch => {
473 "Assigning between incompatible evidentiality levels (!, ?, ~)"
474 }
475 LintId::UncertaintyUnhandled => {
476 "Uncertain (?) value used without error handling or unwrap"
477 }
478 LintId::ReportedWithoutAttribution => "Reported (~) data lacks source attribution",
479 LintId::BrokenMorphemePipeline => "Morpheme pipeline has invalid or missing operators",
480 LintId::MorphemeTypeIncompatibility => "Type mismatch between morpheme pipeline stages",
481 LintId::InconsistentMorphemeStyle => {
482 "Mixing morpheme pipeline (|τ{}) with method chain (.map())"
483 }
484 LintId::InvalidHexagramNumber => "I Ching hexagram number must be between 1 and 64",
485 LintId::InvalidTarotNumber => "Major Arcana number must be between 0 and 21",
486 LintId::InvalidChakraIndex => "Chakra index must be between 0 and 6",
487 LintId::InvalidZodiacIndex => "Zodiac sign index must be between 0 and 11",
488 LintId::InvalidGematriaValue => "Gematria value is negative or exceeds maximum",
489 LintId::FrequencyOutOfRange => "Audio frequency outside audible range (20Hz-20kHz)",
490 LintId::MissingEvidentialityMarker => {
491 "Type declaration lacks evidentiality marker (!, ?, ~)"
492 }
493 LintId::PreferNamedEsotericConstant => {
494 "Use named constant for esoteric value (e.g., GOLDEN_RATIO)"
495 }
496 LintId::EmotionIntensityOutOfRange => "Emotion intensity must be between 0.0 and 1.0",
497 }
498 }
499
500 pub fn category(&self) -> LintCategory {
502 match self {
503 LintId::DivisionByZero => LintCategory::Correctness,
505 LintId::InfiniteLoop => LintCategory::Correctness,
506 LintId::UnreachableCode => LintCategory::Correctness,
507 LintId::ConstantCondition => LintCategory::Correctness,
508
509 LintId::NamingConvention => LintCategory::Style,
511 LintId::BoolComparison => LintCategory::Style,
512 LintId::RedundantElse => LintCategory::Style,
513 LintId::EmptyBlock => LintCategory::Style,
514 LintId::PreferIfLet => LintCategory::Style,
515 LintId::MissingDocComment => LintCategory::Style,
516 LintId::NeedlessReturn => LintCategory::Style,
517
518 LintId::MissingReturn => LintCategory::Correctness,
520
521 LintId::PreferMorphemePipeline => LintCategory::Sigil,
523
524 LintId::DeepNesting => LintCategory::Complexity,
526 LintId::HighComplexity => LintCategory::Complexity,
527 LintId::MagicNumber => LintCategory::Complexity,
528 LintId::LongFunction => LintCategory::Complexity,
529 LintId::TooManyParameters => LintCategory::Complexity,
530 LintId::TodoWithoutIssue => LintCategory::Complexity,
531
532 LintId::UnusedVariable => LintCategory::Performance,
534 LintId::UnusedImport => LintCategory::Performance,
535 LintId::UnusedParameter => LintCategory::Performance,
536 LintId::Shadowing => LintCategory::Performance,
537
538 LintId::ReservedIdentifier => LintCategory::Sigil,
540 LintId::NestedGenerics => LintCategory::Sigil,
541 LintId::PreferUnicodeMorpheme => LintCategory::Sigil,
542 LintId::EvidentialityViolation => LintCategory::Sigil,
543 LintId::UnvalidatedExternalData => LintCategory::Sigil,
544 LintId::CertaintyDowngrade => LintCategory::Sigil,
545
546 LintId::EvidentialityMismatch => LintCategory::Sigil,
548 LintId::UncertaintyUnhandled => LintCategory::Sigil,
549 LintId::ReportedWithoutAttribution => LintCategory::Sigil,
550
551 LintId::BrokenMorphemePipeline => LintCategory::Sigil,
553 LintId::MorphemeTypeIncompatibility => LintCategory::Sigil,
554 LintId::InconsistentMorphemeStyle => LintCategory::Style,
555
556 LintId::InvalidHexagramNumber => LintCategory::Correctness,
558 LintId::InvalidTarotNumber => LintCategory::Correctness,
559 LintId::InvalidChakraIndex => LintCategory::Correctness,
560 LintId::InvalidZodiacIndex => LintCategory::Correctness,
561 LintId::InvalidGematriaValue => LintCategory::Correctness,
562 LintId::FrequencyOutOfRange => LintCategory::Correctness,
563 LintId::EmotionIntensityOutOfRange => LintCategory::Correctness,
564
565 LintId::MissingEvidentialityMarker => LintCategory::Sigil,
567 LintId::PreferNamedEsotericConstant => LintCategory::Complexity,
568 }
569 }
570
571 pub fn extended_docs(&self) -> &'static str {
573 match self {
574 LintId::ReservedIdentifier => {
575 r#"
576This lint detects use of identifiers that are reserved words in Sigil.
577Reserved words have special meaning in the language and cannot be used
578as variable, function, or type names.
579
580Example:
581 let location = "here"; // Error: 'location' is reserved
582
583Fix:
584 let place = "here"; // Use an alternative name
585
586Common alternatives:
587 - location -> place
588 - save -> slot, store
589 - from -> source, origin
590"#
591 }
592 LintId::NestedGenerics => {
593 r#"
594This lint warns about nested generic parameters which may not parse
595correctly in the current version of Sigil.
596
597Example:
598 fn process(data: Vec<Option<i32>>) { } // May not parse
599
600Fix:
601 type OptInt = Option<i32>;
602 fn process(data: Vec<OptInt>) { } // Use type alias
603"#
604 }
605 LintId::UnusedVariable => {
606 r#"
607This lint detects variables that are declared but never used.
608Unused variables may indicate incomplete code or typos.
609
610Example:
611 let x = 42;
612 println(y); // 'x' is never used, 'y' may be a typo
613
614Fix:
615 let x = 42;
616 println(x); // Use the variable
617
618 // Or prefix with underscore to indicate intentionally unused:
619 let _x = 42;
620"#
621 }
622 LintId::Shadowing => {
623 r#"
624This lint warns when a variable shadows another variable from an
625outer scope. While sometimes intentional, shadowing can make code
626harder to understand.
627
628Example:
629 let x = 1;
630 {
631 let x = 2; // Shadows outer 'x'
632 }
633
634Fix:
635 let x = 1;
636 {
637 let x_inner = 2; // Use distinct name
638 }
639
640 // Or prefix with underscore if intentional:
641 let _x = 2;
642"#
643 }
644 LintId::DeepNesting => {
645 r#"
646This lint warns about excessively nested code structures.
647Deep nesting makes code hard to read and maintain.
648
649Example:
650 if a {
651 if b {
652 if c {
653 if d { // Too deep!
654 }
655 }
656 }
657 }
658
659Fix:
660 // Use early returns
661 if !a { return; }
662 if !b { return; }
663 if !c { return; }
664 if d { ... }
665
666 // Or extract into functions
667 fn check_conditions() { ... }
668"#
669 }
670 LintId::HighComplexity => {
671 r#"
672This lint warns about functions with high cyclomatic complexity.
673High complexity makes code harder to test and maintain.
674
675Complexity is calculated by counting:
676 - Each if/while/for/loop adds 1
677 - Each match arm (except first) adds 1
678 - Each && or || operator adds 1
679 - Each guard condition adds 1
680
681Fix:
682 // Extract complex logic into smaller functions
683 // Use early returns to reduce nesting
684 // Consider using match instead of if-else chains
685"#
686 }
687 LintId::DivisionByZero => {
688 r#"
689This lint detects division by a literal zero, which will cause
690a runtime panic.
691
692Example:
693 let result = x / 0; // Will panic!
694
695Fix:
696 if divisor != 0 {
697 let result = x / divisor;
698 }
699"#
700 }
701 LintId::ConstantCondition => {
702 r#"
703This lint detects conditions that are always true or always false,
704indicating likely bugs or unnecessary code.
705
706Example:
707 if true { ... } // Always executes
708 while false { ... } // Never executes
709
710Fix:
711 // Remove unnecessary conditions
712 // Or use the correct variable in the condition
713"#
714 }
715 LintId::TodoWithoutIssue => {
716 r#"
717This lint warns about TODO comments that don't reference an issue tracker.
718
719Example:
720 // TODO: fix this later
721
722Fix:
723 // TODO(#123): fix this later
724 // TODO(GH-456): address edge case
725
726Configure via .sigillint.toml:
727 [lint.levels]
728 todo_without_issue = "warn"
729"#
730 }
731 LintId::LongFunction => {
732 r#"
733This lint warns about functions that exceed a maximum line count.
734Long functions are harder to understand, test, and maintain.
735
736Default threshold: 50 lines
737
738Fix:
739 // Break into smaller, focused functions
740 // Extract helper functions for distinct operations
741 // Use early returns to reduce nesting
742"#
743 }
744 LintId::TooManyParameters => {
745 r#"
746This lint warns about functions with too many parameters.
747Many parameters indicate a function may be doing too much.
748
749Default threshold: 7 parameters
750
751Fix:
752 // Group related parameters into a struct
753 // Use builder pattern for complex construction
754 // Consider if function should be split
755"#
756 }
757 LintId::NeedlessReturn => {
758 r#"
759This lint suggests removing unnecessary return statements.
760In Sigil, the last expression is the return value.
761
762Example:
763 fn add(a: i32, b: i32) -> i32 {
764 return a + b; // Unnecessary return
765 }
766
767Fix:
768 fn add(a: i32, b: i32) -> i32 {
769 a + b // Implicit return
770 }
771"#
772 }
773 LintId::MissingReturn => {
774 r#"
775This lint warns when a function with a return type may not return
776a value on all execution paths.
777
778Example:
779 fn maybe_return(x: i32) -> i32 {
780 if x > 0 {
781 return x;
782 }
783 // Missing return for x <= 0!
784 }
785
786Fix:
787 fn maybe_return(x: i32) -> i32 {
788 if x > 0 {
789 x
790 } else {
791 0 // Default value
792 }
793 }
794
795The linter checks:
796 - If all branches return a value
797 - If match arms all produce values
798 - If loops with breaks produce consistent values
799"#
800 }
801 LintId::PreferMorphemePipeline => {
802 r#"
803This lint suggests using Sigil's morpheme pipeline syntax instead
804of method chains. Morpheme pipelines are more idiomatic in Sigil
805and provide clearer data flow semantics.
806
807Example (method chain):
808 let result = data.iter().map(|x| x * 2).filter(|x| *x > 10).collect();
809
810Preferred (morpheme pipeline):
811 let result = data
812 |τ{_ * 2} // τ (tau) = transform/map
813 |φ{_ > 10} // φ (phi) = filter
814 |σ; // σ (sigma) = collect/sort
815
816Common morpheme operators:
817 - τ (tau) : Transform/map
818 - φ (phi) : Filter
819 - σ (sigma) : Sort/collect/sum
820 - ρ (rho) : Reduce/fold
821 - α (alpha) : First element
822 - ω (omega) : Last element
823 - ζ (zeta) : Zip/combine
824
825This lint is off by default. Enable with:
826 [lint.levels]
827 prefer_morpheme_pipeline = "warn"
828"#
829 }
830 _ => self.description(),
831 }
832 }
833
834 pub fn all() -> &'static [LintId] {
836 &[
837 LintId::ReservedIdentifier,
838 LintId::NestedGenerics,
839 LintId::PreferUnicodeMorpheme,
840 LintId::NamingConvention,
841 LintId::UnusedVariable,
842 LintId::UnusedImport,
843 LintId::Shadowing,
844 LintId::DeepNesting,
845 LintId::EmptyBlock,
846 LintId::BoolComparison,
847 LintId::RedundantElse,
848 LintId::UnusedParameter,
849 LintId::MagicNumber,
850 LintId::MissingDocComment,
851 LintId::HighComplexity,
852 LintId::ConstantCondition,
853 LintId::PreferIfLet,
854 LintId::TodoWithoutIssue,
855 LintId::LongFunction,
856 LintId::TooManyParameters,
857 LintId::NeedlessReturn,
858 LintId::MissingReturn,
859 LintId::PreferMorphemePipeline,
860 LintId::EvidentialityViolation,
861 LintId::UnvalidatedExternalData,
862 LintId::CertaintyDowngrade,
863 LintId::UnreachableCode,
864 LintId::InfiniteLoop,
865 LintId::DivisionByZero,
866 LintId::EvidentialityMismatch,
868 LintId::UncertaintyUnhandled,
869 LintId::ReportedWithoutAttribution,
870 LintId::BrokenMorphemePipeline,
871 LintId::MorphemeTypeIncompatibility,
872 LintId::InconsistentMorphemeStyle,
873 LintId::InvalidHexagramNumber,
874 LintId::InvalidTarotNumber,
875 LintId::InvalidChakraIndex,
876 LintId::InvalidZodiacIndex,
877 LintId::InvalidGematriaValue,
878 LintId::FrequencyOutOfRange,
879 LintId::MissingEvidentialityMarker,
880 LintId::PreferNamedEsotericConstant,
881 LintId::EmotionIntensityOutOfRange,
882 ]
883 }
884
885 pub fn from_str(s: &str) -> Option<LintId> {
887 for lint in Self::all() {
888 if lint.code() == s || lint.name() == s {
889 return Some(*lint);
890 }
891 }
892 None
893 }
894}
895
896#[derive(Debug, Clone)]
902pub struct Suppression {
903 pub line: usize,
905 pub lints: Vec<LintId>,
907 pub next_line: bool,
909}
910
911pub fn parse_suppressions(source: &str) -> Vec<Suppression> {
917 let mut suppressions = Vec::new();
918
919 for (line_num, line) in source.lines().enumerate() {
920 let line_1indexed = line_num + 1;
921
922 if let Some(comment_start) = line.find("// sigil-lint:") {
924 let comment = &line[comment_start + 14..].trim();
925
926 if let Some(rest) = comment.strip_prefix("allow-next-line") {
927 if let Some(lints) = parse_lint_list(rest) {
929 suppressions.push(Suppression {
930 line: line_1indexed + 1,
931 lints,
932 next_line: true,
933 });
934 }
935 } else if let Some(rest) = comment.strip_prefix("allow") {
936 if let Some(lints) = parse_lint_list(rest) {
938 suppressions.push(Suppression {
939 line: line_1indexed,
940 lints,
941 next_line: false,
942 });
943 }
944 }
945 }
946 }
947
948 suppressions
949}
950
951fn parse_lint_list(s: &str) -> Option<Vec<LintId>> {
953 let s = s.trim();
954 if !s.starts_with('(') || !s.contains(')') {
955 return Some(Vec::new()); }
957
958 let start = s.find('(')? + 1;
959 let end = s.find(')')?;
960 let list = &s[start..end];
961
962 let mut lints = Vec::new();
963 for item in list.split(',') {
964 let item = item.trim();
965 if !item.is_empty() {
966 if let Some(lint) = LintId::from_str(item) {
967 lints.push(lint);
968 }
969 }
970 }
971
972 Some(lints)
973}
974
975#[derive(Debug, Clone, Default)]
981pub struct LintStats {
982 pub lint_counts: HashMap<LintId, usize>,
984 pub category_counts: HashMap<LintCategory, usize>,
986 pub total_diagnostics: usize,
988 pub suppressed: usize,
990 pub duration_us: u64,
992}
993
994impl LintStats {
995 pub fn record(&mut self, lint: LintId) {
997 *self.lint_counts.entry(lint).or_insert(0) += 1;
998 *self.category_counts.entry(lint.category()).or_insert(0) += 1;
999 self.total_diagnostics += 1;
1000 }
1001
1002 pub fn record_suppressed(&mut self) {
1004 self.suppressed += 1;
1005 }
1006}
1007
1008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1014pub struct BaselineEntry {
1015 pub file: String,
1017 pub code: String,
1019 pub line: usize,
1021 pub message_hash: u64,
1023 #[serde(skip_serializing_if = "Option::is_none")]
1025 pub message: Option<String>,
1026}
1027
1028impl BaselineEntry {
1029 pub fn from_diagnostic(file: &str, diag: &Diagnostic, source: &str) -> Self {
1031 let line = Self::offset_to_line(diag.span.start, source);
1032 let message_hash = Self::hash_message(&diag.message);
1033
1034 Self {
1035 file: file.to_string(),
1036 code: diag.code.clone().unwrap_or_default(),
1037 line,
1038 message_hash,
1039 message: Some(diag.message.clone()),
1040 }
1041 }
1042
1043 fn offset_to_line(offset: usize, source: &str) -> usize {
1045 source[..offset.min(source.len())]
1046 .chars()
1047 .filter(|&c| c == '\n')
1048 .count()
1049 + 1
1050 }
1051
1052 fn hash_message(message: &str) -> u64 {
1054 use std::hash::{Hash, Hasher};
1055 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1056 message.hash(&mut hasher);
1057 hasher.finish()
1058 }
1059
1060 pub fn matches(&self, file: &str, diag: &Diagnostic, source: &str) -> bool {
1062 if self.file != file {
1064 return false;
1065 }
1066 if let Some(ref code) = diag.code {
1067 if &self.code != code {
1068 return false;
1069 }
1070 }
1071
1072 let msg_hash = Self::hash_message(&diag.message);
1074 if self.message_hash == msg_hash {
1075 return true;
1076 }
1077
1078 let diag_line = Self::offset_to_line(diag.span.start, source);
1080 if self.line > 0 && diag_line > 0 {
1081 let line_diff = (self.line as i64 - diag_line as i64).abs();
1083 if line_diff <= 3 && self.code == diag.code.as_deref().unwrap_or("") {
1084 return true;
1085 }
1086 }
1087
1088 false
1089 }
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1094pub struct Baseline {
1095 pub version: u32,
1097 #[serde(skip_serializing_if = "Option::is_none")]
1099 pub created: Option<String>,
1100 pub count: usize,
1102 pub entries: HashMap<String, Vec<BaselineEntry>>,
1104}
1105
1106impl Baseline {
1107 pub fn new() -> Self {
1109 Self {
1110 version: 1,
1111 created: Some(chrono_lite_now()),
1112 count: 0,
1113 entries: HashMap::new(),
1114 }
1115 }
1116
1117 pub fn from_file(path: &Path) -> Result<Self, String> {
1119 let content = std::fs::read_to_string(path)
1120 .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1121 Self::from_json(&content)
1122 }
1123
1124 pub fn from_json(content: &str) -> Result<Self, String> {
1126 serde_json::from_str(content).map_err(|e| format!("Failed to parse baseline: {}", e))
1127 }
1128
1129 pub fn to_file(&self, path: &Path) -> Result<(), String> {
1131 let content = self.to_json()?;
1132 std::fs::write(path, content).map_err(|e| format!("Failed to write baseline file: {}", e))
1133 }
1134
1135 pub fn to_json(&self) -> Result<String, String> {
1137 serde_json::to_string_pretty(self)
1138 .map_err(|e| format!("Failed to serialize baseline: {}", e))
1139 }
1140
1141 pub fn add(&mut self, file: &str, diag: &Diagnostic, source: &str) {
1143 let entry = BaselineEntry::from_diagnostic(file, diag, source);
1144 self.entries
1145 .entry(file.to_string())
1146 .or_default()
1147 .push(entry);
1148 self.count += 1;
1149 }
1150
1151 pub fn contains(&self, file: &str, diag: &Diagnostic, source: &str) -> bool {
1153 if let Some(entries) = self.entries.get(file) {
1154 entries.iter().any(|e| e.matches(file, diag, source))
1155 } else {
1156 false
1157 }
1158 }
1159
1160 pub fn filter(
1163 &self,
1164 file: &str,
1165 diagnostics: &Diagnostics,
1166 source: &str,
1167 ) -> (Diagnostics, usize) {
1168 let mut filtered = Diagnostics::new();
1169 let mut baseline_matches = 0;
1170
1171 for diag in diagnostics.iter() {
1172 if self.contains(file, diag, source) {
1173 baseline_matches += 1;
1174 } else {
1175 filtered.add(diag.clone());
1176 }
1177 }
1178
1179 (filtered, baseline_matches)
1180 }
1181
1182 pub fn from_directory_result(
1184 result: &DirectoryLintResult,
1185 sources: &HashMap<String, String>,
1186 ) -> Self {
1187 let mut baseline = Self::new();
1188
1189 for (path, diag_result) in &result.files {
1190 if let Ok(diagnostics) = diag_result {
1191 if let Some(source) = sources.get(path) {
1192 for diag in diagnostics.iter() {
1193 baseline.add(path, diag, source);
1194 }
1195 }
1196 }
1197 }
1198
1199 baseline
1200 }
1201
1202 pub fn update(&mut self, file: &str, diagnostics: &Diagnostics, source: &str) {
1204 let mut new_entries = Vec::new();
1205
1206 if let Some(old_entries) = self.entries.get(file) {
1208 for old in old_entries {
1209 let still_exists = diagnostics.iter().any(|d| old.matches(file, d, source));
1211 if still_exists {
1212 new_entries.push(old.clone());
1213 }
1214 }
1215 }
1216
1217 for diag in diagnostics.iter() {
1219 let already_exists = new_entries.iter().any(|e| e.matches(file, diag, source));
1220 if !already_exists {
1221 new_entries.push(BaselineEntry::from_diagnostic(file, diag, source));
1222 }
1223 }
1224
1225 let old_count = self.entries.get(file).map(|v| v.len()).unwrap_or(0);
1227 self.count = self.count - old_count + new_entries.len();
1228
1229 if new_entries.is_empty() {
1230 self.entries.remove(file);
1231 } else {
1232 self.entries.insert(file.to_string(), new_entries);
1233 }
1234
1235 self.created = Some(chrono_lite_now());
1236 }
1237
1238 pub fn summary(&self) -> BaselineSummary {
1240 let mut by_code: HashMap<String, usize> = HashMap::new();
1241
1242 for entries in self.entries.values() {
1243 for entry in entries {
1244 *by_code.entry(entry.code.clone()).or_insert(0) += 1;
1245 }
1246 }
1247
1248 BaselineSummary {
1249 total_files: self.entries.len(),
1250 total_issues: self.count,
1251 by_code,
1252 }
1253 }
1254}
1255
1256#[derive(Debug, Clone)]
1258pub struct BaselineSummary {
1259 pub total_files: usize,
1261 pub total_issues: usize,
1263 pub by_code: HashMap<String, usize>,
1265}
1266
1267fn chrono_lite_now() -> String {
1269 use std::time::{SystemTime, UNIX_EPOCH};
1270 let duration = SystemTime::now()
1271 .duration_since(UNIX_EPOCH)
1272 .unwrap_or_default();
1273 let secs = duration.as_secs();
1274
1275 let days = secs / 86400;
1277 let years = 1970 + days / 365;
1278 let remaining_days = days % 365;
1279 let months = remaining_days / 30 + 1;
1280 let day = remaining_days % 30 + 1;
1281 let hours = (secs % 86400) / 3600;
1282 let minutes = (secs % 3600) / 60;
1283 let seconds = secs % 60;
1284
1285 format!(
1286 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1287 years,
1288 months.min(12),
1289 day.min(31),
1290 hours,
1291 minutes,
1292 seconds
1293 )
1294}
1295
1296pub fn find_baseline() -> Option<Baseline> {
1303 let baseline_names = [
1304 ".sigillint-baseline.json",
1305 "sigillint-baseline.json",
1306 ".lint-baseline.json",
1307 ];
1308
1309 if let Ok(mut dir) = std::env::current_dir() {
1310 loop {
1311 for name in &baseline_names {
1312 let path = dir.join(name);
1313 if path.exists() {
1314 if let Ok(baseline) = Baseline::from_file(&path) {
1315 return Some(baseline);
1316 }
1317 }
1318 }
1319 if !dir.pop() {
1320 break;
1321 }
1322 }
1323 }
1324
1325 None
1326}
1327
1328#[derive(Debug)]
1330pub struct BaselineLintResult {
1331 pub new_issues: Diagnostics,
1333 pub baseline_matches: usize,
1335 pub total_before: usize,
1337}
1338
1339pub fn lint_with_baseline(
1341 source: &str,
1342 filename: &str,
1343 config: LintConfig,
1344 baseline: &Baseline,
1345) -> Result<BaselineLintResult, String> {
1346 let diagnostics = lint_source_with_config(source, filename, config)?;
1347 let total_before = diagnostics.iter().count();
1348 let (new_issues, baseline_matches) = baseline.filter(filename, &diagnostics, source);
1349
1350 Ok(BaselineLintResult {
1351 new_issues,
1352 baseline_matches,
1353 total_before,
1354 })
1355}
1356
1357#[derive(Debug, Clone, Default)]
1377pub struct CliOverrides {
1378 pub deny: Vec<String>,
1380 pub warn: Vec<String>,
1382 pub allow: Vec<String>,
1384 pub deny_category: Vec<LintCategory>,
1386 pub warn_category: Vec<LintCategory>,
1388 pub allow_category: Vec<LintCategory>,
1390}
1391
1392impl CliOverrides {
1393 pub fn new() -> Self {
1395 Self::default()
1396 }
1397
1398 pub fn deny(mut self, lint: impl Into<String>) -> Self {
1400 self.deny.push(lint.into());
1401 self
1402 }
1403
1404 pub fn warn(mut self, lint: impl Into<String>) -> Self {
1406 self.warn.push(lint.into());
1407 self
1408 }
1409
1410 pub fn allow(mut self, lint: impl Into<String>) -> Self {
1412 self.allow.push(lint.into());
1413 self
1414 }
1415
1416 pub fn deny_cat(mut self, category: LintCategory) -> Self {
1418 self.deny_category.push(category);
1419 self
1420 }
1421
1422 pub fn warn_cat(mut self, category: LintCategory) -> Self {
1424 self.warn_category.push(category);
1425 self
1426 }
1427
1428 pub fn allow_cat(mut self, category: LintCategory) -> Self {
1430 self.allow_category.push(category);
1431 self
1432 }
1433
1434 pub fn apply(&self, config: &mut LintConfig) {
1440 for cat in &self.allow_category {
1442 for lint in LintId::all() {
1443 if lint.category() == *cat {
1444 config
1445 .levels
1446 .insert(lint.name().to_string(), LintLevel::Allow);
1447 }
1448 }
1449 }
1450 for cat in &self.warn_category {
1451 for lint in LintId::all() {
1452 if lint.category() == *cat {
1453 config
1454 .levels
1455 .insert(lint.name().to_string(), LintLevel::Warn);
1456 }
1457 }
1458 }
1459 for cat in &self.deny_category {
1460 for lint in LintId::all() {
1461 if lint.category() == *cat {
1462 config
1463 .levels
1464 .insert(lint.name().to_string(), LintLevel::Deny);
1465 }
1466 }
1467 }
1468
1469 for lint_str in &self.allow {
1471 if let Some(lint) = LintId::from_str(lint_str) {
1472 config
1473 .levels
1474 .insert(lint.name().to_string(), LintLevel::Allow);
1475 } else {
1476 config.levels.insert(lint_str.clone(), LintLevel::Allow);
1478 }
1479 }
1480 for lint_str in &self.warn {
1481 if let Some(lint) = LintId::from_str(lint_str) {
1482 config
1483 .levels
1484 .insert(lint.name().to_string(), LintLevel::Warn);
1485 } else {
1486 config.levels.insert(lint_str.clone(), LintLevel::Warn);
1487 }
1488 }
1489 for lint_str in &self.deny {
1490 if let Some(lint) = LintId::from_str(lint_str) {
1491 config
1492 .levels
1493 .insert(lint.name().to_string(), LintLevel::Deny);
1494 } else {
1495 config.levels.insert(lint_str.clone(), LintLevel::Deny);
1496 }
1497 }
1498 }
1499
1500 pub fn parse_category(s: &str) -> Option<LintCategory> {
1502 match s.to_lowercase().as_str() {
1503 "correctness" => Some(LintCategory::Correctness),
1504 "style" => Some(LintCategory::Style),
1505 "performance" => Some(LintCategory::Performance),
1506 "complexity" => Some(LintCategory::Complexity),
1507 "sigil" => Some(LintCategory::Sigil),
1508 _ => None,
1509 }
1510 }
1511
1512 pub fn is_empty(&self) -> bool {
1514 self.deny.is_empty()
1515 && self.warn.is_empty()
1516 && self.allow.is_empty()
1517 && self.deny_category.is_empty()
1518 && self.warn_category.is_empty()
1519 && self.allow_category.is_empty()
1520 }
1521}
1522
1523pub fn config_with_overrides(base: LintConfig, overrides: &CliOverrides) -> LintConfig {
1525 let mut config = base;
1526 overrides.apply(&mut config);
1527 config
1528}
1529
1530pub fn lint_source_with_overrides(
1532 source: &str,
1533 filename: &str,
1534 overrides: &CliOverrides,
1535) -> Result<Diagnostics, String> {
1536 let mut config = LintConfig::find_and_load();
1537 overrides.apply(&mut config);
1538 lint_source_with_config(source, filename, config)
1539}
1540
1541#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1550pub struct LintCache {
1551 pub version: u32,
1553 pub config_hash: u64,
1555 pub entries: HashMap<String, CacheEntry>,
1557}
1558
1559#[derive(Debug, Clone, Serialize, Deserialize)]
1561pub struct CacheEntry {
1562 pub content_hash: String,
1564 pub mtime: u64,
1566 pub size: u64,
1568 pub warning_count: usize,
1570 pub error_count: usize,
1572 #[serde(skip_serializing_if = "Option::is_none")]
1574 pub diagnostics: Option<Vec<CachedDiagnostic>>,
1575}
1576
1577#[derive(Debug, Clone, Serialize, Deserialize)]
1579pub struct CachedDiagnostic {
1580 pub code: Option<String>,
1581 pub message: String,
1582 pub severity: String,
1583 pub start: usize,
1584 pub end: usize,
1585}
1586
1587impl CachedDiagnostic {
1588 pub fn from_diagnostic(diag: &Diagnostic) -> Self {
1590 Self {
1591 code: diag.code.clone(),
1592 message: diag.message.clone(),
1593 severity: format!("{:?}", diag.severity),
1594 start: diag.span.start,
1595 end: diag.span.end,
1596 }
1597 }
1598
1599 pub fn to_diagnostic(&self) -> Diagnostic {
1601 let severity = match self.severity.as_str() {
1602 "Error" => Severity::Error,
1603 "Warning" => Severity::Warning,
1604 "Info" => Severity::Info,
1605 "Hint" => Severity::Hint,
1606 _ => Severity::Warning,
1607 };
1608
1609 Diagnostic {
1610 severity,
1611 code: self.code.clone(),
1612 message: self.message.clone(),
1613 span: Span::new(self.start, self.end),
1614 labels: Vec::new(),
1615 notes: Vec::new(),
1616 suggestions: Vec::new(),
1617 related: Vec::new(),
1618 }
1619 }
1620}
1621
1622impl LintCache {
1623 pub fn new() -> Self {
1625 Self {
1626 version: 1,
1627 config_hash: 0,
1628 entries: HashMap::new(),
1629 }
1630 }
1631
1632 pub fn with_config(config: &LintConfig) -> Self {
1634 Self {
1635 version: 1,
1636 config_hash: Self::hash_config(config),
1637 entries: HashMap::new(),
1638 }
1639 }
1640
1641 fn hash_config(config: &LintConfig) -> u64 {
1643 use std::hash::{Hash, Hasher};
1644 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1645
1646 config.suggest_unicode.hash(&mut hasher);
1648 config.check_naming.hash(&mut hasher);
1649 config.max_nesting_depth.hash(&mut hasher);
1650
1651 let mut levels: Vec<_> = config.levels.iter().collect();
1653 levels.sort_by_key(|(k, _)| *k);
1654 for (name, level) in levels {
1655 name.hash(&mut hasher);
1656 std::mem::discriminant(level).hash(&mut hasher);
1657 }
1658
1659 hasher.finish()
1660 }
1661
1662 pub fn from_file(path: &Path) -> Result<Self, String> {
1664 let content = std::fs::read_to_string(path)
1665 .map_err(|e| format!("Failed to read cache file: {}", e))?;
1666 Self::from_json(&content)
1667 }
1668
1669 pub fn from_json(content: &str) -> Result<Self, String> {
1671 serde_json::from_str(content).map_err(|e| format!("Failed to parse cache: {}", e))
1672 }
1673
1674 pub fn to_file(&self, path: &Path) -> Result<(), String> {
1676 let content = self.to_json()?;
1677 std::fs::write(path, content).map_err(|e| format!("Failed to write cache file: {}", e))
1678 }
1679
1680 pub fn to_json(&self) -> Result<String, String> {
1682 serde_json::to_string(self).map_err(|e| format!("Failed to serialize cache: {}", e))
1683 }
1684
1685 pub fn hash_content(content: &str) -> String {
1687 let hash = blake3::hash(content.as_bytes());
1688 hash.to_hex().to_string()
1689 }
1690
1691 pub fn needs_lint(
1698 &self,
1699 path: &str,
1700 content: &str,
1701 metadata: Option<&std::fs::Metadata>,
1702 ) -> bool {
1703 let Some(entry) = self.entries.get(path) else {
1704 return true; };
1706
1707 if let Some(meta) = metadata {
1709 if entry.size != meta.len() {
1710 return true;
1711 }
1712 }
1713
1714 let current_hash = Self::hash_content(content);
1716 entry.content_hash != current_hash
1717 }
1718
1719 pub fn get_cached(&self, path: &str, content: &str) -> Option<Diagnostics> {
1721 let entry = self.entries.get(path)?;
1722
1723 let current_hash = Self::hash_content(content);
1725 if entry.content_hash != current_hash {
1726 return None;
1727 }
1728
1729 let cached = entry.diagnostics.as_ref()?;
1731 let mut diagnostics = Diagnostics::new();
1732 for cd in cached {
1733 diagnostics.add(cd.to_diagnostic());
1734 }
1735
1736 Some(diagnostics)
1737 }
1738
1739 pub fn update(
1741 &mut self,
1742 path: &str,
1743 content: &str,
1744 diagnostics: &Diagnostics,
1745 metadata: Option<&std::fs::Metadata>,
1746 ) {
1747 let content_hash = Self::hash_content(content);
1748
1749 let mtime = metadata
1750 .and_then(|m| m.modified().ok())
1751 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1752 .map(|d| d.as_secs())
1753 .unwrap_or(0);
1754
1755 let size = metadata.map(|m| m.len()).unwrap_or(0);
1756
1757 let warning_count = diagnostics
1758 .iter()
1759 .filter(|d| d.severity == Severity::Warning)
1760 .count();
1761 let error_count = diagnostics
1762 .iter()
1763 .filter(|d| d.severity == Severity::Error)
1764 .count();
1765
1766 let cached_diags: Vec<CachedDiagnostic> = diagnostics
1767 .iter()
1768 .map(CachedDiagnostic::from_diagnostic)
1769 .collect();
1770
1771 self.entries.insert(
1772 path.to_string(),
1773 CacheEntry {
1774 content_hash,
1775 mtime,
1776 size,
1777 warning_count,
1778 error_count,
1779 diagnostics: Some(cached_diags),
1780 },
1781 );
1782 }
1783
1784 pub fn prune(&mut self, existing_files: &HashSet<String>) {
1786 self.entries.retain(|path, _| existing_files.contains(path));
1787 }
1788
1789 pub fn is_valid_for(&self, config: &LintConfig) -> bool {
1791 self.config_hash == Self::hash_config(config)
1792 }
1793
1794 pub fn stats(&self) -> CacheStats {
1796 let mut total_warnings = 0;
1797 let mut total_errors = 0;
1798
1799 for entry in self.entries.values() {
1800 total_warnings += entry.warning_count;
1801 total_errors += entry.error_count;
1802 }
1803
1804 CacheStats {
1805 cached_files: self.entries.len(),
1806 total_warnings,
1807 total_errors,
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Clone)]
1814pub struct CacheStats {
1815 pub cached_files: usize,
1817 pub total_warnings: usize,
1819 pub total_errors: usize,
1821}
1822
1823pub const CACHE_FILE: &str = ".sigillint-cache.json";
1825
1826pub fn find_cache() -> Option<LintCache> {
1828 if let Ok(dir) = std::env::current_dir() {
1829 let cache_path = dir.join(CACHE_FILE);
1830 if cache_path.exists() {
1831 return LintCache::from_file(&cache_path).ok();
1832 }
1833 }
1834 None
1835}
1836
1837#[derive(Debug)]
1839pub struct IncrementalLintResult {
1840 pub result: DirectoryLintResult,
1842 pub linted_files: usize,
1844 pub cached_files: usize,
1846 pub cache: LintCache,
1848}
1849
1850pub fn lint_directory_incremental(
1858 dir: &Path,
1859 config: LintConfig,
1860 cache: Option<LintCache>,
1861) -> IncrementalLintResult {
1862 use rayon::prelude::*;
1863 use std::fs;
1864 use std::sync::atomic::{AtomicUsize, Ordering};
1865 use std::sync::Mutex;
1866
1867 let files = collect_sigil_files(dir);
1868
1869 let mut cache = cache
1871 .filter(|c| c.is_valid_for(&config))
1872 .unwrap_or_else(|| LintCache::with_config(&config));
1873
1874 let linted_count = AtomicUsize::new(0);
1875 let cached_count = AtomicUsize::new(0);
1876 let total_warnings = AtomicUsize::new(0);
1877 let total_errors = AtomicUsize::new(0);
1878 let parse_errors = AtomicUsize::new(0);
1879
1880 let cache_updates: Mutex<
1882 Vec<(
1883 String,
1884 String,
1885 Vec<CachedDiagnostic>,
1886 Option<std::fs::Metadata>,
1887 )>,
1888 > = Mutex::new(Vec::new());
1889
1890 let file_results: Vec<(String, Result<Diagnostics, String>)> = files
1891 .par_iter()
1892 .filter_map(|path| {
1893 let source = fs::read_to_string(path).ok()?;
1894 let path_str = path.display().to_string();
1895 let metadata = fs::metadata(path).ok();
1896
1897 if let Some(cached_diags) = cache.get_cached(&path_str, &source) {
1899 cached_count.fetch_add(1, Ordering::Relaxed);
1900 let warnings = cached_diags
1901 .iter()
1902 .filter(|d| d.severity == Severity::Warning)
1903 .count();
1904 let errors = cached_diags
1905 .iter()
1906 .filter(|d| d.severity == Severity::Error)
1907 .count();
1908 total_warnings.fetch_add(warnings, Ordering::Relaxed);
1909 total_errors.fetch_add(errors, Ordering::Relaxed);
1910 return Some((path_str, Ok(cached_diags)));
1911 }
1912
1913 linted_count.fetch_add(1, Ordering::Relaxed);
1915 match lint_source_with_config(&source, &path_str, config.clone()) {
1916 Ok(diagnostics) => {
1917 let warnings = diagnostics
1918 .iter()
1919 .filter(|d| d.severity == Severity::Warning)
1920 .count();
1921 let errors = diagnostics
1922 .iter()
1923 .filter(|d| d.severity == Severity::Error)
1924 .count();
1925 total_warnings.fetch_add(warnings, Ordering::Relaxed);
1926 total_errors.fetch_add(errors, Ordering::Relaxed);
1927
1928 let cached_diags: Vec<CachedDiagnostic> = diagnostics
1930 .iter()
1931 .map(CachedDiagnostic::from_diagnostic)
1932 .collect();
1933
1934 if let Ok(mut updates) = cache_updates.lock() {
1936 updates.push((path_str.clone(), source.clone(), cached_diags, metadata));
1937 }
1938
1939 Some((path_str, Ok(diagnostics)))
1940 }
1941 Err(e) => {
1942 parse_errors.fetch_add(1, Ordering::Relaxed);
1943 Some((path_str, Err(e)))
1944 }
1945 }
1946 })
1947 .collect();
1948
1949 if let Ok(updates) = cache_updates.into_inner() {
1951 for (path, source, cached_diags, meta) in updates {
1952 let mut diagnostics = Diagnostics::new();
1954 for cd in &cached_diags {
1955 diagnostics.add(cd.to_diagnostic());
1956 }
1957 cache.update(&path, &source, &diagnostics, meta.as_ref());
1958 }
1959 }
1960
1961 let existing: HashSet<String> = file_results.iter().map(|(p, _)| p.clone()).collect();
1963 cache.prune(&existing);
1964
1965 IncrementalLintResult {
1966 result: DirectoryLintResult {
1967 files: file_results,
1968 total_warnings: total_warnings.load(Ordering::Relaxed),
1969 total_errors: total_errors.load(Ordering::Relaxed),
1970 parse_errors: parse_errors.load(Ordering::Relaxed),
1971 },
1972 linted_files: linted_count.load(Ordering::Relaxed),
1973 cached_files: cached_count.load(Ordering::Relaxed),
1974 cache,
1975 }
1976}
1977
1978pub struct Linter {
1984 config: LintConfig,
1985 diagnostics: Diagnostics,
1986 declared_vars: HashMap<String, (Span, bool)>,
1987 declared_imports: HashMap<String, (Span, bool)>,
1988 scope_stack: Vec<HashSet<String>>,
1990 nesting_depth: usize,
1992 current_fn_params: HashMap<String, (Span, bool)>,
1994 current_complexity: usize,
1996 max_complexity: usize,
1998 max_function_lines: usize,
2000 max_parameters: usize,
2002 current_fn_lines: usize,
2004 source_text: String,
2006 suppressions: Vec<Suppression>,
2008 stats: LintStats,
2010}
2011
2012impl Linter {
2013 pub fn new(config: LintConfig) -> Self {
2014 Self {
2015 config,
2016 diagnostics: Diagnostics::new(),
2017 declared_vars: HashMap::new(),
2018 declared_imports: HashMap::new(),
2019 scope_stack: vec![HashSet::new()], nesting_depth: 0,
2021 current_fn_params: HashMap::new(),
2022 current_complexity: 0,
2023 max_complexity: 10, max_function_lines: 50, max_parameters: 7, current_fn_lines: 0,
2027 source_text: String::new(),
2028 suppressions: Vec::new(),
2029 stats: LintStats::default(),
2030 }
2031 }
2032
2033 pub fn with_suppressions(config: LintConfig, source: &str) -> Self {
2035 let mut linter = Self::new(config);
2036 linter.suppressions = parse_suppressions(source);
2037 linter.source_text = source.to_string();
2038 linter
2039 }
2040
2041 pub fn stats(&self) -> &LintStats {
2043 &self.stats
2044 }
2045
2046 fn is_suppressed(&self, lint: LintId, line: usize) -> bool {
2048 for suppression in &self.suppressions {
2049 if suppression.line == line {
2050 if suppression.lints.is_empty() || suppression.lints.contains(&lint) {
2051 return true;
2052 }
2053 }
2054 }
2055 false
2056 }
2057
2058 fn span_to_line(&self, span: Span) -> usize {
2060 0
2063 }
2064
2065 fn push_scope(&mut self) {
2067 self.scope_stack.push(HashSet::new());
2068 }
2069
2070 fn pop_scope(&mut self) {
2072 self.scope_stack.pop();
2073 }
2074
2075 fn check_shadowing(&mut self, name: &str, span: Span) {
2077 if name.starts_with('_') {
2079 return;
2080 }
2081
2082 for scope in self.scope_stack.iter().rev().skip(1) {
2084 if scope.contains(name) {
2085 self.emit(
2086 LintId::Shadowing,
2087 format!("`{}` shadows a variable from an outer scope", name),
2088 span,
2089 );
2090 break;
2091 }
2092 }
2093
2094 if let Some(current_scope) = self.scope_stack.last_mut() {
2096 current_scope.insert(name.to_string());
2097 }
2098 }
2099
2100 fn push_nesting(&mut self, span: Span) {
2102 self.nesting_depth += 1;
2103 let max_depth = self.config.max_nesting_depth;
2104 if self.nesting_depth > max_depth {
2105 self.emit(
2106 LintId::DeepNesting,
2107 format!(
2108 "nesting depth {} exceeds maximum of {}",
2109 self.nesting_depth, max_depth
2110 ),
2111 span,
2112 );
2113 }
2114 }
2115
2116 fn pop_nesting(&mut self) {
2118 self.nesting_depth = self.nesting_depth.saturating_sub(1);
2119 }
2120
2121 pub fn lint(&mut self, file: &SourceFile, source: &str) -> &Diagnostics {
2122 self.source_text = source.to_string();
2124
2125 self.visit_source_file(file);
2126 self.check_unused();
2127
2128 self.check_todo_comments();
2130
2131 &self.diagnostics
2132 }
2133
2134 fn lint_level(&self, lint: LintId) -> LintLevel {
2135 self.config
2136 .levels
2137 .get(lint.name())
2138 .copied()
2139 .unwrap_or_else(|| lint.default_level())
2140 }
2141
2142 fn emit(&mut self, lint: LintId, message: impl Into<String>, span: Span) {
2143 let level = self.lint_level(lint);
2144 if level == LintLevel::Allow {
2145 return;
2146 }
2147
2148 let line = self.span_to_line(span);
2150 if line > 0 && self.is_suppressed(lint, line) {
2151 self.stats.record_suppressed();
2152 return;
2153 }
2154
2155 self.stats.record(lint);
2157
2158 let severity = match level {
2159 LintLevel::Allow => return,
2160 LintLevel::Warn => Severity::Warning,
2161 LintLevel::Deny => Severity::Error,
2162 };
2163
2164 let diag = Diagnostic {
2165 severity,
2166 code: Some(lint.code().to_string()),
2167 message: message.into(),
2168 span,
2169 labels: Vec::new(),
2170 notes: vec![lint.description().to_string()],
2171 suggestions: Vec::new(),
2172 related: Vec::new(),
2173 };
2174
2175 self.diagnostics.add(diag);
2176 }
2177
2178 fn emit_with_fix(
2179 &mut self,
2180 lint: LintId,
2181 message: impl Into<String>,
2182 span: Span,
2183 fix_message: impl Into<String>,
2184 replacement: impl Into<String>,
2185 ) {
2186 let level = self.lint_level(lint);
2187 if level == LintLevel::Allow {
2188 return;
2189 }
2190
2191 let line = self.span_to_line(span);
2193 if line > 0 && self.is_suppressed(lint, line) {
2194 self.stats.record_suppressed();
2195 return;
2196 }
2197
2198 self.stats.record(lint);
2200
2201 let severity = match level {
2202 LintLevel::Allow => return,
2203 LintLevel::Warn => Severity::Warning,
2204 LintLevel::Deny => Severity::Error,
2205 };
2206
2207 let diag = Diagnostic {
2208 severity,
2209 code: Some(lint.code().to_string()),
2210 message: message.into(),
2211 span,
2212 labels: Vec::new(),
2213 notes: vec![lint.description().to_string()],
2214 suggestions: vec![FixSuggestion {
2215 message: fix_message.into(),
2216 span,
2217 replacement: replacement.into(),
2218 }],
2219 related: Vec::new(),
2220 };
2221
2222 self.diagnostics.add(diag);
2223 }
2224
2225 fn check_unused(&mut self) {
2226 let mut unused_vars: Vec<(String, Span)> = Vec::new();
2227 let mut unused_imports: Vec<(String, Span)> = Vec::new();
2228
2229 for (name, (span, used)) in &self.declared_vars {
2230 if !used && !name.starts_with('_') {
2231 unused_vars.push((name.clone(), *span));
2232 }
2233 }
2234
2235 for (name, (span, used)) in &self.declared_imports {
2236 if !used {
2237 unused_imports.push((name.clone(), *span));
2238 }
2239 }
2240
2241 for (name, span) in unused_vars {
2242 self.emit(
2243 LintId::UnusedVariable,
2244 format!("unused variable: `{}`", name),
2245 span,
2246 );
2247 }
2248
2249 for (name, span) in unused_imports {
2250 self.emit(
2251 LintId::UnusedImport,
2252 format!("unused import: `{}`", name),
2253 span,
2254 );
2255 }
2256 }
2257
2258 fn check_reserved(&mut self, name: &str, span: Span) {
2259 let reserved_suggestions: &[(&str, &str)] = &[
2260 ("location", "place"),
2261 ("save", "slot"),
2262 ("from", "source"),
2263 ("split", "divide"),
2264 ];
2265
2266 for (reserved, suggestion) in reserved_suggestions {
2267 if name == *reserved {
2268 self.emit_with_fix(
2269 LintId::ReservedIdentifier,
2270 format!("`{}` is a reserved word in Sigil", reserved),
2271 span,
2272 format!("rename to `{}`", suggestion),
2273 suggestion.to_string(),
2274 );
2275 return;
2276 }
2277 }
2278 }
2279
2280 fn check_nested_generics(&mut self, ty: &TypeExpr, span: Span) {
2281 if let TypeExpr::Path(path) = ty {
2282 for segment in &path.segments {
2283 if let Some(ref generics) = segment.generics {
2284 for arg in generics {
2285 if let TypeExpr::Path(inner_path) = arg {
2286 for inner_seg in &inner_path.segments {
2287 if inner_seg.generics.is_some() {
2288 self.emit(
2289 LintId::NestedGenerics,
2290 "nested generic parameters may not parse correctly",
2291 span,
2292 );
2293 return;
2294 }
2295 }
2296 }
2297 }
2298 }
2299 }
2300 }
2301 }
2302
2303 fn check_division(&mut self, op: &BinOp, right: &Expr, span: Span) {
2304 if let BinOp::Div = op {
2305 if let Expr::Literal(Literal::Int { value, .. }) = right {
2306 if value == "0" {
2307 self.emit(LintId::DivisionByZero, "division by zero", span);
2308 }
2309 }
2310 }
2311 }
2312
2313 fn check_infinite_loop(&mut self, body: &Block, span: Span) {
2314 if !Self::block_contains_break(body) {
2315 self.emit(
2316 LintId::InfiniteLoop,
2317 "loop has no `break` statement and may run forever",
2318 span,
2319 );
2320 }
2321 }
2322
2323 fn block_contains_break(block: &Block) -> bool {
2324 for stmt in &block.stmts {
2325 if Self::stmt_contains_break(stmt) {
2326 return true;
2327 }
2328 }
2329 if let Some(ref expr) = block.expr {
2330 if Self::expr_contains_break(expr) {
2331 return true;
2332 }
2333 }
2334 false
2335 }
2336
2337 fn stmt_contains_break(stmt: &Stmt) -> bool {
2338 match stmt {
2339 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_contains_break(e),
2340 Stmt::Let { init, .. } => init.as_ref().map_or(false, Self::expr_contains_break),
2341 Stmt::LetElse {
2342 init, else_branch, ..
2343 } => Self::expr_contains_break(init) || Self::expr_contains_break(else_branch),
2344 Stmt::Item(_) => false,
2345 }
2346 }
2347
2348 fn expr_contains_break(expr: &Expr) -> bool {
2349 match expr {
2350 Expr::Break { .. } => true,
2351 Expr::Return(_) => true,
2352 Expr::Block(b) => Self::block_contains_break(b),
2353 Expr::If {
2354 then_branch,
2355 else_branch,
2356 ..
2357 } => {
2358 Self::block_contains_break(then_branch)
2359 || else_branch
2360 .as_ref()
2361 .map_or(false, |e| Self::expr_contains_break(e))
2362 }
2363 Expr::Match { arms, .. } => arms.iter().any(|arm| Self::expr_contains_break(&arm.body)),
2364 Expr::Loop { .. } | Expr::While { .. } | Expr::For { .. } => false,
2365 _ => false,
2366 }
2367 }
2368
2369 fn check_empty_block(&mut self, block: &Block, span: Span) {
2371 if block.stmts.is_empty() && block.expr.is_none() {
2372 self.emit(LintId::EmptyBlock, "empty block", span);
2373 }
2374 }
2375
2376 fn check_bool_comparison(&mut self, op: &BinOp, left: &Expr, right: &Expr, span: Span) {
2379 let is_eq_or_ne = matches!(op, BinOp::Eq | BinOp::Ne);
2380 if !is_eq_or_ne {
2381 return;
2382 }
2383
2384 let has_bool_literal = |expr: &Expr| -> Option<bool> {
2385 if let Expr::Literal(Literal::Bool(value)) = expr {
2386 Some(*value)
2387 } else {
2388 None
2389 }
2390 };
2391
2392 if let Some(val) = has_bool_literal(right) {
2393 let suggestion = match (op, val) {
2394 (BinOp::Eq, true) | (BinOp::Ne, false) => "use the expression directly",
2395 (BinOp::Eq, false) | (BinOp::Ne, true) => "use `!expr` instead",
2396 _ => "simplify the comparison",
2397 };
2398 self.emit(
2399 LintId::BoolComparison,
2400 format!("comparison to `{}` is redundant; {}", val, suggestion),
2401 span,
2402 );
2403 } else if let Some(val) = has_bool_literal(left) {
2404 let suggestion = match (op, val) {
2405 (BinOp::Eq, true) | (BinOp::Ne, false) => "use the expression directly",
2406 (BinOp::Eq, false) | (BinOp::Ne, true) => "use `!expr` instead",
2407 _ => "simplify the comparison",
2408 };
2409 self.emit(
2410 LintId::BoolComparison,
2411 format!("comparison to `{}` is redundant; {}", val, suggestion),
2412 span,
2413 );
2414 }
2415 }
2416
2417 fn check_redundant_else(
2420 &mut self,
2421 then_branch: &Block,
2422 else_branch: &Option<Box<Expr>>,
2423 span: Span,
2424 ) {
2425 if else_branch.is_none() {
2426 return;
2427 }
2428
2429 let then_terminates = if let Some(ref expr) = then_branch.expr {
2431 Self::expr_terminates(expr).is_some()
2432 } else if let Some(last) = then_branch.stmts.last() {
2433 Self::stmt_terminates(last).is_some()
2434 } else {
2435 false
2436 };
2437
2438 if then_terminates {
2439 self.emit(
2440 LintId::RedundantElse,
2441 "else branch is redundant after return/break/continue",
2442 span,
2443 );
2444 }
2445 }
2446
2447 fn check_magic_number(&mut self, value: &str, span: Span) {
2450 let allowed = [
2452 "0", "1", "2", "-1", "10", "100", "1000", "0.0", "1.0", "0.5",
2453 ];
2454 if allowed.contains(&value) {
2455 return;
2456 }
2457
2458 if let Ok(n) = value.parse::<i64>() {
2460 if n >= 0 && n <= 10 {
2461 return;
2462 }
2463 }
2464
2465 self.emit(
2466 LintId::MagicNumber,
2467 format!("magic number `{}` should be a named constant", value),
2468 span,
2469 );
2470 }
2471
2472 fn add_complexity(&mut self, amount: usize) {
2474 self.current_complexity += amount;
2475 }
2476
2477 fn check_complexity(&mut self, func_name: &str, span: Span) {
2479 if self.current_complexity > self.max_complexity {
2480 self.emit(
2481 LintId::HighComplexity,
2482 format!(
2483 "function `{}` has cyclomatic complexity of {} (max: {})",
2484 func_name, self.current_complexity, self.max_complexity
2485 ),
2486 span,
2487 );
2488 }
2489 }
2490
2491 fn check_unused_params(&mut self) {
2493 let unused: Vec<(String, Span)> = self
2495 .current_fn_params
2496 .iter()
2497 .filter(|(name, (_, used))| !name.starts_with('_') && !used)
2498 .map(|(name, (span, _))| (name.clone(), *span))
2499 .collect();
2500
2501 for (name, span) in unused {
2502 self.emit_with_fix(
2503 LintId::UnusedParameter,
2504 format!("parameter `{}` is never used", name),
2505 span,
2506 "prefix with underscore to indicate intentionally unused",
2507 format!("_{}", name),
2508 );
2509 }
2510 }
2511
2512 fn mark_param_used(&mut self, name: &str) {
2514 if let Some((_, used)) = self.current_fn_params.get_mut(name) {
2515 *used = true;
2516 }
2517 }
2518
2519 fn check_missing_doc(&mut self, vis: &Visibility, name: &str, span: Span) {
2521 if !matches!(vis, Visibility::Public) {
2523 return;
2524 }
2525
2526 self.emit(
2530 LintId::MissingDocComment,
2531 format!("public item `{}` should have a documentation comment", name),
2532 span,
2533 );
2534 }
2535
2536 fn check_todo_comments(&mut self) {
2538 let issue_pattern = regex::Regex::new(r"TODO\s*\([#A-Z]+-?\d+\)").unwrap();
2540 let todo_pattern = regex::Regex::new(r"//.*\bTODO\b").unwrap();
2541
2542 let source = self.source_text.clone();
2544 for line in source.lines() {
2545 if todo_pattern.is_match(line) && !issue_pattern.is_match(line) {
2546 self.emit(
2548 LintId::TodoWithoutIssue,
2549 "TODO comment should reference an issue (e.g., TODO(#123):)",
2550 Span::default(),
2551 );
2552 }
2553 }
2554 }
2555
2556 fn check_function_length(&mut self, func_name: &str, span: Span, line_count: usize) {
2558 if line_count > self.max_function_lines {
2559 self.emit(
2560 LintId::LongFunction,
2561 format!(
2562 "function `{}` has {} lines (max: {})",
2563 func_name, line_count, self.max_function_lines
2564 ),
2565 span,
2566 );
2567 }
2568 }
2569
2570 fn check_parameter_count(&mut self, func_name: &str, span: Span, param_count: usize) {
2572 if param_count > self.max_parameters {
2573 self.emit(
2574 LintId::TooManyParameters,
2575 format!(
2576 "function `{}` has {} parameters (max: {})",
2577 func_name, param_count, self.max_parameters
2578 ),
2579 span,
2580 );
2581 }
2582 }
2583
2584 fn check_needless_return(&mut self, body: &Block, span: Span) {
2586 if let Some(ref expr) = body.expr {
2588 if let Expr::Return(Some(_)) = &**expr {
2589 self.emit(
2590 LintId::NeedlessReturn,
2591 "unnecessary return statement; the last expression is automatically returned",
2592 span,
2593 );
2594 }
2595 } else if let Some(last) = body.stmts.last() {
2596 match last {
2597 Stmt::Semi(Expr::Return(Some(_))) | Stmt::Expr(Expr::Return(Some(_))) => {
2598 self.emit(
2599 LintId::NeedlessReturn,
2600 "unnecessary return statement; the last expression is automatically returned",
2601 span,
2602 );
2603 }
2604 _ => {}
2605 }
2606 }
2607 }
2608
2609 fn check_missing_return(
2616 &mut self,
2617 body: &Block,
2618 has_return_type: bool,
2619 func_name: &str,
2620 span: Span,
2621 ) {
2622 if !has_return_type {
2623 return; }
2625
2626 if !Self::block_always_returns(body) {
2628 self.emit(
2629 LintId::MissingReturn,
2630 format!(
2631 "function `{}` may not return a value on all code paths",
2632 func_name
2633 ),
2634 span,
2635 );
2636 }
2637 }
2638
2639 fn block_always_returns(block: &Block) -> bool {
2641 if let Some(ref expr) = block.expr {
2643 return Self::expr_always_returns(expr);
2644 }
2645
2646 for stmt in &block.stmts {
2649 if Self::stmt_always_returns(stmt) {
2650 return true;
2651 }
2652 }
2653
2654 false
2655 }
2656
2657 fn stmt_always_returns(stmt: &Stmt) -> bool {
2659 match stmt {
2660 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_always_returns(e),
2661 _ => false,
2662 }
2663 }
2664
2665 fn expr_always_returns(expr: &Expr) -> bool {
2667 match expr {
2668 Expr::Return(_) => true,
2670 Expr::Break { .. } => true, Expr::Continue { .. } => true, Expr::Block(b) => Self::block_always_returns(b),
2675
2676 Expr::If {
2678 then_branch,
2679 else_branch,
2680 ..
2681 } => {
2682 if let Some(ref else_expr) = else_branch {
2683 Self::block_always_returns(then_branch) && Self::expr_always_returns(else_expr)
2684 } else {
2685 false }
2687 }
2688
2689 Expr::Match { arms, .. } => {
2691 if arms.is_empty() {
2692 false
2693 } else {
2694 arms.iter().all(|arm| Self::expr_always_returns(&arm.body))
2695 }
2696 }
2697
2698 Expr::Loop { .. } => false,
2701 Expr::While { .. } => false,
2702 Expr::For { .. } => false,
2703
2704 Expr::Literal(_) => true,
2706 Expr::Path(_) => true,
2707 Expr::Binary { .. } => true,
2708 Expr::Unary { .. } => true,
2709 Expr::Call { .. } => true,
2710 Expr::MethodCall { .. } => true,
2711 Expr::Field { .. } => true,
2712 Expr::Index { .. } => true,
2713 Expr::Array(_) => true,
2714 Expr::Tuple(_) => true,
2715 Expr::Struct { .. } => true,
2716 Expr::Range { .. } => true,
2717 Expr::Cast { .. } => true,
2718 Expr::AddrOf { .. } => true,
2719 Expr::Deref(_) => true,
2720 Expr::Closure { .. } => true,
2721 Expr::Await { .. } => true,
2722 Expr::Try(_) => true,
2723 Expr::Morpheme { .. } => true,
2724 Expr::Pipe { .. } => true,
2725 Expr::Unsafe(b) => Self::block_always_returns(b),
2726 Expr::Evidential { .. } => true,
2727 Expr::Incorporation { .. } => true,
2728 Expr::Let { .. } => true,
2729
2730 Expr::Assign { .. } => false,
2732
2733 _ => false,
2735 }
2736 }
2737
2738 fn check_prefer_morpheme_pipeline(&mut self, expr: &Expr, span: Span) {
2743 let chain_length = Self::method_chain_length(expr);
2745
2746 if chain_length >= 2 {
2748 let transformable_methods = Self::count_transformable_methods(expr);
2750 if transformable_methods >= 2 {
2751 self.emit(
2752 LintId::PreferMorphemePipeline,
2753 format!(
2754 "consider using morpheme pipeline (|τ{{}}, |φ{{}}) for this {}-method chain",
2755 chain_length
2756 ),
2757 span,
2758 );
2759 }
2760 }
2761 }
2762
2763 fn method_chain_length(expr: &Expr) -> usize {
2765 match expr {
2766 Expr::MethodCall { receiver, .. } => 1 + Self::method_chain_length(receiver),
2767 _ => 0,
2768 }
2769 }
2770
2771 fn count_transformable_methods(expr: &Expr) -> usize {
2773 let transformable = [
2774 "map", "filter", "fold", "reduce", "collect", "sort", "first", "last", "zip", "iter",
2775 ];
2776
2777 match expr {
2778 Expr::MethodCall {
2779 receiver, method, ..
2780 } => {
2781 let count = if transformable.contains(&method.name.as_str()) {
2782 1
2783 } else {
2784 0
2785 };
2786 count + Self::count_transformable_methods(receiver)
2787 }
2788 _ => 0,
2789 }
2790 }
2791
2792 fn check_constant_condition(&mut self, condition: &Expr, span: Span) {
2794 let is_constant = match condition {
2795 Expr::Literal(Literal::Bool(val)) => Some(*val),
2796 Expr::Path(p) if p.segments.len() == 1 => {
2797 let name = &p.segments[0].ident.name;
2798 if name == "true" {
2799 Some(true)
2800 } else if name == "false" {
2801 Some(false)
2802 } else {
2803 None
2804 }
2805 }
2806 _ => None,
2807 };
2808
2809 if let Some(val) = is_constant {
2810 self.emit(
2811 LintId::ConstantCondition,
2812 format!("condition is always `{}`", val),
2813 span,
2814 );
2815 }
2816 }
2817
2818 fn check_prefer_if_let(&mut self, arms: &[MatchArm], span: Span) {
2820 if arms.len() == 2 {
2822 let has_wildcard = arms
2823 .iter()
2824 .any(|arm| matches!(&arm.pattern, Pattern::Wildcard));
2825 if has_wildcard {
2826 self.emit(
2827 LintId::PreferIfLet,
2828 "consider using `if let` instead of `match` with wildcard",
2829 span,
2830 );
2831 }
2832 }
2833 }
2834
2835 fn check_hexagram_number(&mut self, value: i64, span: Span) {
2841 if value < 1 || value > 64 {
2842 self.emit(
2843 LintId::InvalidHexagramNumber,
2844 format!("hexagram number {} is invalid (must be 1-64)", value),
2845 span,
2846 );
2847 }
2848 }
2849
2850 fn check_tarot_number(&mut self, value: i64, span: Span) {
2852 if value < 0 || value > 21 {
2853 self.emit(
2854 LintId::InvalidTarotNumber,
2855 format!("Major Arcana number {} is invalid (must be 0-21)", value),
2856 span,
2857 );
2858 }
2859 }
2860
2861 fn check_chakra_index(&mut self, value: i64, span: Span) {
2863 if value < 0 || value > 6 {
2864 self.emit(
2865 LintId::InvalidChakraIndex,
2866 format!("chakra index {} is invalid (must be 0-6)", value),
2867 span,
2868 );
2869 }
2870 }
2871
2872 fn check_zodiac_index(&mut self, value: i64, span: Span) {
2874 if value < 0 || value > 11 {
2875 self.emit(
2876 LintId::InvalidZodiacIndex,
2877 format!("zodiac index {} is invalid (must be 0-11)", value),
2878 span,
2879 );
2880 }
2881 }
2882
2883 fn check_gematria_value(&mut self, value: i64, span: Span) {
2885 if value < 0 {
2886 self.emit(
2887 LintId::InvalidGematriaValue,
2888 format!("gematria value {} is invalid (must be non-negative)", value),
2889 span,
2890 );
2891 }
2892 }
2893
2894 fn check_frequency_range(&mut self, value: f64, span: Span) {
2896 if value < 20.0 || value > 20000.0 {
2897 self.emit(
2898 LintId::FrequencyOutOfRange,
2899 format!(
2900 "frequency {:.2}Hz is outside audible range (20Hz-20kHz)",
2901 value
2902 ),
2903 span,
2904 );
2905 }
2906 }
2907
2908 fn check_emotion_intensity(&mut self, value: f64, span: Span) {
2910 if value < 0.0 || value > 1.0 {
2911 self.emit(
2912 LintId::EmotionIntensityOutOfRange,
2913 format!(
2914 "emotion intensity {:.2} is invalid (must be 0.0-1.0)",
2915 value
2916 ),
2917 span,
2918 );
2919 }
2920 }
2921
2922 fn check_esoteric_constant(&mut self, value: &str, span: Span) {
2924 let esoteric_values = [
2926 ("1.618", "GOLDEN_RATIO or PHI"),
2927 ("0.618", "GOLDEN_RATIO_INVERSE"),
2928 ("1.414", "SQRT_2 or SILVER_RATIO"),
2929 ("2.414", "SILVER_RATIO"),
2930 ("3.14159", "PI"),
2931 ("2.71828", "E or EULER"),
2932 ("432", "VERDI_PITCH or A432"),
2933 ("440", "CONCERT_PITCH or A440"),
2934 ("528", "SOLFEGGIO_MI or LOVE_FREQUENCY"),
2935 ("396", "SOLFEGGIO_UT"),
2936 ("639", "SOLFEGGIO_FA"),
2937 ("741", "SOLFEGGIO_SOL"),
2938 ("852", "SOLFEGGIO_LA"),
2939 ("963", "SOLFEGGIO_SI"),
2940 ];
2941
2942 for (pattern, suggestion) in esoteric_values {
2943 if value.starts_with(pattern) {
2944 self.emit(
2945 LintId::PreferNamedEsotericConstant,
2946 format!(
2947 "consider using named constant {} instead of {}",
2948 suggestion, value
2949 ),
2950 span,
2951 );
2952 return;
2953 }
2954 }
2955 }
2956
2957 fn check_morpheme_style_consistency(&mut self, expr: &Expr, span: Span) {
2959 let has_morpheme = Self::has_morpheme_pipeline(expr);
2960 let has_method_chain = Self::method_chain_length(expr) >= 2;
2961
2962 if has_morpheme && has_method_chain {
2963 self.emit(
2964 LintId::InconsistentMorphemeStyle,
2965 "mixing morpheme pipeline (|τ{}) with method chains; prefer one style",
2966 span,
2967 );
2968 }
2969 }
2970
2971 fn has_morpheme_pipeline(expr: &Expr) -> bool {
2973 match expr {
2974 Expr::Morpheme { .. } => true,
2975 Expr::Pipe { .. } => true,
2976 Expr::MethodCall { receiver, .. } => Self::has_morpheme_pipeline(receiver),
2977 Expr::Binary { left, right, .. } => {
2978 Self::has_morpheme_pipeline(left) || Self::has_morpheme_pipeline(right)
2979 }
2980 _ => false,
2981 }
2982 }
2983
2984 fn check_domain_literal(&mut self, func_name: &str, value: i64, span: Span) {
2986 let name_lower = func_name.to_lowercase();
2988
2989 if name_lower.contains("hexagram") || name_lower.contains("iching") {
2990 self.check_hexagram_number(value, span);
2991 } else if name_lower.contains("arcana") || name_lower.contains("tarot") {
2992 self.check_tarot_number(value, span);
2993 } else if name_lower.contains("chakra") {
2994 self.check_chakra_index(value, span);
2995 } else if name_lower.contains("zodiac") || name_lower.contains("sign") {
2996 self.check_zodiac_index(value, span);
2997 } else if name_lower.contains("gematria") {
2998 self.check_gematria_value(value, span);
2999 }
3000 }
3001
3002 fn check_domain_float_literal(&mut self, func_name: &str, value: f64, span: Span) {
3004 let name_lower = func_name.to_lowercase();
3005
3006 if name_lower.contains("frequency")
3007 || name_lower.contains("hz")
3008 || name_lower.contains("hertz")
3009 {
3010 self.check_frequency_range(value, span);
3011 } else if name_lower.contains("intensity") || name_lower.contains("emotion") {
3012 self.check_emotion_intensity(value, span);
3013 }
3014 }
3015
3016 fn visit_source_file(&mut self, file: &SourceFile) {
3018 for item in &file.items {
3019 self.visit_item(&item.node);
3020 }
3021 }
3022
3023 fn visit_item(&mut self, item: &Item) {
3024 match item {
3025 Item::Function(f) => self.visit_function(f),
3026 Item::Struct(s) => self.visit_struct(s),
3027 Item::Module(m) => self.visit_module(m),
3028 _ => {}
3029 }
3030 }
3031
3032 fn visit_function(&mut self, func: &Function) {
3033 self.check_reserved(&func.name.name, func.name.span);
3034
3035 self.check_missing_doc(&func.visibility, &func.name.name, func.name.span);
3037
3038 self.check_parameter_count(&func.name.name, func.name.span, func.params.len());
3040
3041 self.current_complexity = 1; self.current_fn_params.clear();
3046
3047 self.push_scope();
3049
3050 for param in &func.params {
3051 if let Pattern::Ident { name, .. } = ¶m.pattern {
3053 if let Some(scope) = self.scope_stack.last_mut() {
3054 scope.insert(name.name.clone());
3055 }
3056 self.current_fn_params
3058 .insert(name.name.clone(), (name.span, false));
3059 }
3060 self.visit_pattern(¶m.pattern);
3061 }
3062
3063 if let Some(ref body) = func.body {
3064 self.check_needless_return(body, func.name.span);
3066
3067 let has_return_type = func.return_type.is_some();
3069 self.check_missing_return(body, has_return_type, &func.name.name, func.name.span);
3070
3071 let line_estimate = body.stmts.len() + if body.expr.is_some() { 1 } else { 0 } + 2; self.check_function_length(&func.name.name, func.name.span, line_estimate);
3074
3075 self.visit_block(body);
3076 }
3077
3078 self.check_unused_params();
3080
3081 self.check_complexity(&func.name.name, func.name.span);
3083
3084 self.pop_scope();
3085 }
3086
3087 fn visit_struct(&mut self, s: &StructDef) {
3088 self.check_reserved(&s.name.name, s.name.span);
3089
3090 if let StructFields::Named(ref fields) = s.fields {
3091 for field in fields {
3092 self.check_reserved(&field.name.name, field.name.span);
3093 self.check_nested_generics(&field.ty, field.name.span);
3094 }
3095 }
3096 }
3097
3098 fn visit_module(&mut self, m: &Module) {
3099 if let Some(ref items) = m.items {
3100 for item in items {
3101 self.visit_item(&item.node);
3102 }
3103 }
3104 }
3105
3106 fn visit_block(&mut self, block: &Block) {
3107 self.push_scope();
3108
3109 let mut found_terminator = false;
3110
3111 for stmt in &block.stmts {
3112 if found_terminator {
3114 if let Some(span) = Self::stmt_span(stmt) {
3115 self.emit(
3116 LintId::UnreachableCode,
3117 "unreachable statement after return/break/continue",
3118 span,
3119 );
3120 }
3121 }
3122
3123 self.visit_stmt(stmt);
3124
3125 if !found_terminator {
3127 if Self::stmt_terminates(stmt).is_some() {
3128 found_terminator = true;
3129 }
3130 }
3131 }
3132
3133 if let Some(ref expr) = block.expr {
3135 if found_terminator {
3136 if let Some(span) = Self::expr_span(expr) {
3137 self.emit(
3138 LintId::UnreachableCode,
3139 "unreachable expression after return/break/continue",
3140 span,
3141 );
3142 }
3143 }
3144 self.visit_expr(expr);
3145 }
3146
3147 self.pop_scope();
3148 }
3149
3150 fn stmt_span(stmt: &Stmt) -> Option<Span> {
3152 match stmt {
3153 Stmt::Let { pattern, .. } => {
3154 if let Pattern::Ident { name, .. } = pattern {
3155 Some(name.span)
3156 } else {
3157 None
3158 }
3159 }
3160 Stmt::LetElse { pattern, .. } => {
3161 if let Pattern::Ident { name, .. } = pattern {
3162 Some(name.span)
3163 } else {
3164 None
3165 }
3166 }
3167 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_span(e),
3168 Stmt::Item(_) => None,
3169 }
3170 }
3171
3172 fn expr_span(expr: &Expr) -> Option<Span> {
3174 match expr {
3175 Expr::Return(_) => Some(Span::default()),
3176 Expr::Break { .. } => Some(Span::default()),
3177 Expr::Continue { .. } => Some(Span::default()),
3178 Expr::Path(p) if !p.segments.is_empty() => Some(p.segments[0].ident.span),
3179 Expr::Literal(_) => Some(Span::default()),
3181 _ => Some(Span::default()), }
3183 }
3184
3185 fn stmt_terminates(stmt: &Stmt) -> Option<Span> {
3187 match stmt {
3188 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_terminates(e),
3189 _ => None,
3190 }
3191 }
3192
3193 fn expr_terminates(expr: &Expr) -> Option<Span> {
3195 match expr {
3196 Expr::Return(_) => Some(Span::default()),
3197 Expr::Break { .. } => Some(Span::default()),
3198 Expr::Continue { .. } => Some(Span::default()),
3199 Expr::Block(b) => {
3200 if let Some(ref e) = b.expr {
3202 Self::expr_terminates(e)
3203 } else if let Some(last) = b.stmts.last() {
3204 Self::stmt_terminates(last)
3205 } else {
3206 None
3207 }
3208 }
3209 _ => None,
3210 }
3211 }
3212
3213 fn visit_stmt(&mut self, stmt: &Stmt) {
3214 match stmt {
3215 Stmt::Let { pattern, init, .. } => {
3216 if let Pattern::Ident { name, .. } = pattern {
3217 self.check_reserved(&name.name, name.span);
3218 self.check_shadowing(&name.name, name.span);
3219 self.declared_vars
3220 .insert(name.name.clone(), (name.span, false));
3221 }
3222 self.visit_pattern(pattern);
3223 if let Some(ref e) = init {
3224 self.visit_expr(e);
3225 }
3226 }
3227 Stmt::LetElse {
3228 pattern,
3229 init,
3230 else_branch,
3231 ..
3232 } => {
3233 if let Pattern::Ident { name, .. } = pattern {
3234 self.check_reserved(&name.name, name.span);
3235 self.check_shadowing(&name.name, name.span);
3236 self.declared_vars
3237 .insert(name.name.clone(), (name.span, false));
3238 }
3239 self.visit_pattern(pattern);
3240 self.visit_expr(init);
3241 self.visit_expr(else_branch);
3242 }
3243 Stmt::Expr(e) | Stmt::Semi(e) => self.visit_expr(e),
3244 Stmt::Item(item) => self.visit_item(item),
3245 }
3246 }
3247
3248 fn visit_expr(&mut self, expr: &Expr) {
3249 match expr {
3250 Expr::Path(path) => {
3251 if path.segments.len() == 1 {
3252 let name = &path.segments[0].ident.name;
3253 if let Some((_, used)) = self.declared_vars.get_mut(name) {
3254 *used = true;
3255 }
3256 self.mark_param_used(name);
3258 }
3259 }
3260 Expr::Literal(lit) => {
3261 match lit {
3263 Literal::Int { value, .. } => {
3264 self.check_magic_number(value, Span::default());
3265 }
3266 Literal::Float { value, .. } => {
3267 self.check_magic_number(value, Span::default());
3268 }
3269 _ => {}
3270 }
3271 }
3272 Expr::Binary {
3273 op, left, right, ..
3274 } => {
3275 self.check_division(op, right, Span::default());
3276 self.check_bool_comparison(op, left, right, Span::default());
3277 if matches!(op, BinOp::And | BinOp::Or) {
3279 self.add_complexity(1);
3280 }
3281 self.visit_expr(left);
3282 self.visit_expr(right);
3283 }
3284 Expr::Loop { body, .. } => {
3285 self.push_nesting(Span::default());
3286 self.add_complexity(1); self.check_infinite_loop(body, Span::default());
3288 self.check_empty_block(body, Span::default());
3289 self.visit_block(body);
3290 self.pop_nesting();
3291 }
3292 Expr::Block(b) => {
3293 self.check_empty_block(b, Span::default());
3294 self.visit_block(b);
3295 }
3296 Expr::If {
3297 condition,
3298 then_branch,
3299 else_branch,
3300 ..
3301 } => {
3302 self.push_nesting(Span::default());
3303 self.add_complexity(1); self.check_constant_condition(condition, Span::default());
3305 self.check_redundant_else(then_branch, else_branch, Span::default());
3306 self.check_empty_block(then_branch, Span::default());
3307 self.visit_expr(condition);
3308 self.visit_block(then_branch);
3309 if let Some(ref e) = else_branch {
3310 self.visit_expr(e);
3311 }
3312 self.pop_nesting();
3313 }
3314 Expr::Match {
3315 expr: match_expr,
3316 arms,
3317 ..
3318 } => {
3319 self.push_nesting(Span::default());
3320 self.check_prefer_if_let(arms, Span::default());
3321 if !arms.is_empty() {
3323 self.add_complexity(arms.len().saturating_sub(1));
3324 }
3325 self.visit_expr(match_expr);
3326 for arm in arms {
3327 self.visit_pattern(&arm.pattern);
3328 if let Some(ref guard) = arm.guard {
3329 self.add_complexity(1); self.visit_expr(guard);
3331 }
3332 self.visit_expr(&arm.body);
3333 }
3334 self.pop_nesting();
3335 }
3336 Expr::While {
3337 condition, body, ..
3338 } => {
3339 self.push_nesting(Span::default());
3340 self.add_complexity(1); self.check_constant_condition(condition, Span::default());
3342 self.visit_expr(condition);
3343 self.visit_block(body);
3344 self.pop_nesting();
3345 }
3346 Expr::For {
3347 pattern,
3348 iter,
3349 body,
3350 ..
3351 } => {
3352 self.push_nesting(Span::default());
3353 self.add_complexity(1); self.visit_pattern(pattern);
3355 self.visit_expr(iter);
3356 self.visit_block(body);
3357 self.pop_nesting();
3358 }
3359 Expr::Call { func, args, .. } => {
3360 self.visit_expr(func);
3361 for arg in args {
3362 self.visit_expr(arg);
3363 }
3364 }
3365 Expr::MethodCall { receiver, args, .. } => {
3366 self.check_prefer_morpheme_pipeline(expr, Span::default());
3368 self.visit_expr(receiver);
3369 for arg in args {
3370 self.visit_expr(arg);
3371 }
3372 }
3373 Expr::Field {
3374 expr: field_expr, ..
3375 } => self.visit_expr(field_expr),
3376 Expr::Index {
3377 expr: idx_expr,
3378 index,
3379 ..
3380 } => {
3381 self.visit_expr(idx_expr);
3382 self.visit_expr(index);
3383 }
3384 Expr::Array(elements) | Expr::Tuple(elements) => {
3385 for e in elements {
3386 self.visit_expr(e);
3387 }
3388 }
3389 Expr::Struct { fields, rest, .. } => {
3390 for field in fields {
3391 if let Some(ref value) = field.value {
3392 self.visit_expr(value);
3393 }
3394 }
3395 if let Some(ref b) = rest {
3396 self.visit_expr(b);
3397 }
3398 }
3399 Expr::Range { start, end, .. } => {
3400 if let Some(ref s) = start {
3401 self.visit_expr(s);
3402 }
3403 if let Some(ref e) = end {
3404 self.visit_expr(e);
3405 }
3406 }
3407 Expr::Return(e) => {
3408 if let Some(ref ret_expr) = e {
3409 self.visit_expr(ret_expr);
3410 }
3411 }
3412 Expr::Break { value, .. } => {
3413 if let Some(ref brk_expr) = value {
3414 self.visit_expr(brk_expr);
3415 }
3416 }
3417 Expr::Assign { target, value, .. } => {
3418 self.visit_expr(target);
3419 self.visit_expr(value);
3420 }
3421 Expr::AddrOf {
3422 expr: addr_expr, ..
3423 } => self.visit_expr(addr_expr),
3424 Expr::Deref(e) => self.visit_expr(e),
3425 Expr::Cast {
3426 expr: cast_expr, ..
3427 } => self.visit_expr(cast_expr),
3428 Expr::Closure { params, body, .. } => {
3429 for param in params {
3430 self.visit_pattern(¶m.pattern);
3431 }
3432 self.visit_expr(body);
3433 }
3434 Expr::Await {
3435 expr: await_expr, ..
3436 } => self.visit_expr(await_expr),
3437 Expr::Try(e) => self.visit_expr(e),
3438 Expr::Morpheme { body, .. } => self.visit_expr(body),
3439 Expr::Pipe {
3440 expr: pipe_expr, ..
3441 } => self.visit_expr(pipe_expr),
3442 Expr::Unsafe(block) => self.visit_block(block),
3443 Expr::Async { block, .. } => self.visit_block(block),
3444 Expr::Unary {
3445 expr: unary_expr, ..
3446 } => self.visit_expr(unary_expr),
3447 Expr::Evidential { expr: ev_expr, .. } => self.visit_expr(ev_expr),
3448 Expr::Let { value, pattern, .. } => {
3449 self.visit_pattern(pattern);
3450 self.visit_expr(value);
3451 }
3452 Expr::Incorporation { segments } => {
3453 for seg in segments {
3454 if let Some(ref args) = seg.args {
3455 for arg in args {
3456 self.visit_expr(arg);
3457 }
3458 }
3459 }
3460 }
3461 _ => {}
3462 }
3463 }
3464
3465 fn visit_pattern(&mut self, _pattern: &Pattern) {}
3466}
3467
3468pub fn lint_file(file: &SourceFile, source: &str) -> Diagnostics {
3474 let mut linter = Linter::new(LintConfig::default());
3475 linter.lint(file, source);
3476 linter.diagnostics
3477}
3478
3479pub fn lint_source(source: &str, filename: &str) -> Result<Diagnostics, String> {
3481 use crate::parser::Parser;
3482
3483 let mut parser = Parser::new(source);
3484
3485 match parser.parse_file() {
3486 Ok(file) => {
3487 let diagnostics = lint_file(&file, source);
3488 Ok(diagnostics)
3489 }
3490 Err(e) => Err(format!("Parse error in {}: {:?}", filename, e)),
3491 }
3492}
3493
3494pub fn lint_source_with_config(
3496 source: &str,
3497 filename: &str,
3498 config: LintConfig,
3499) -> Result<Diagnostics, String> {
3500 use crate::parser::Parser;
3501
3502 let mut parser = Parser::new(source);
3503
3504 match parser.parse_file() {
3505 Ok(file) => {
3506 let mut linter = Linter::new(config);
3507 linter.lint(&file, source);
3508 Ok(linter.diagnostics)
3509 }
3510 Err(e) => Err(format!("Parse error in {}: {:?}", filename, e)),
3511 }
3512}
3513
3514#[derive(Debug)]
3516pub struct DirectoryLintResult {
3517 pub files: Vec<(String, Result<Diagnostics, String>)>,
3519 pub total_warnings: usize,
3521 pub total_errors: usize,
3523 pub parse_errors: usize,
3525}
3526
3527fn collect_sigil_files(dir: &Path) -> Vec<std::path::PathBuf> {
3529 use std::fs;
3530 let mut files = Vec::new();
3531
3532 fn visit_dir(dir: &Path, files: &mut Vec<std::path::PathBuf>) {
3533 if let Ok(entries) = fs::read_dir(dir) {
3534 for entry in entries.flatten() {
3535 let path = entry.path();
3536 if path.is_dir() {
3537 visit_dir(&path, files);
3538 } else if path
3539 .extension()
3540 .map_or(false, |ext| ext == "sigil" || ext == "sg")
3541 {
3542 files.push(path);
3543 }
3544 }
3545 }
3546 }
3547
3548 visit_dir(dir, &mut files);
3549 files
3550}
3551
3552pub fn lint_directory(dir: &Path, config: LintConfig) -> DirectoryLintResult {
3554 use std::fs;
3555
3556 let files = collect_sigil_files(dir);
3557 let mut result = DirectoryLintResult {
3558 files: Vec::new(),
3559 total_warnings: 0,
3560 total_errors: 0,
3561 parse_errors: 0,
3562 };
3563
3564 for path in files {
3565 if let Ok(source) = fs::read_to_string(&path) {
3566 let path_str = path.display().to_string();
3567 match lint_source_with_config(&source, &path_str, config.clone()) {
3568 Ok(diagnostics) => {
3569 let warnings = diagnostics
3570 .iter()
3571 .filter(|d| d.severity == crate::diagnostic::Severity::Warning)
3572 .count();
3573 let errors = diagnostics
3574 .iter()
3575 .filter(|d| d.severity == crate::diagnostic::Severity::Error)
3576 .count();
3577 result.total_warnings += warnings;
3578 result.total_errors += errors;
3579 result.files.push((path_str, Ok(diagnostics)));
3580 }
3581 Err(e) => {
3582 result.parse_errors += 1;
3583 result.files.push((path_str, Err(e)));
3584 }
3585 }
3586 }
3587 }
3588
3589 result
3590}
3591
3592pub fn lint_directory_parallel(dir: &Path, config: LintConfig) -> DirectoryLintResult {
3597 use rayon::prelude::*;
3598 use std::fs;
3599 use std::sync::atomic::{AtomicUsize, Ordering};
3600
3601 let files = collect_sigil_files(dir);
3602 let total_warnings = AtomicUsize::new(0);
3603 let total_errors = AtomicUsize::new(0);
3604 let parse_errors = AtomicUsize::new(0);
3605
3606 let file_results: Vec<(String, Result<Diagnostics, String>)> = files
3607 .par_iter()
3608 .filter_map(|path| {
3609 let source = fs::read_to_string(path).ok()?;
3610 let path_str = path.display().to_string();
3611 match lint_source_with_config(&source, &path_str, config.clone()) {
3612 Ok(diagnostics) => {
3613 let warnings = diagnostics
3614 .iter()
3615 .filter(|d| d.severity == crate::diagnostic::Severity::Warning)
3616 .count();
3617 let errors = diagnostics
3618 .iter()
3619 .filter(|d| d.severity == crate::diagnostic::Severity::Error)
3620 .count();
3621 total_warnings.fetch_add(warnings, Ordering::Relaxed);
3622 total_errors.fetch_add(errors, Ordering::Relaxed);
3623 Some((path_str, Ok(diagnostics)))
3624 }
3625 Err(e) => {
3626 parse_errors.fetch_add(1, Ordering::Relaxed);
3627 Some((path_str, Err(e)))
3628 }
3629 }
3630 })
3631 .collect();
3632
3633 DirectoryLintResult {
3634 files: file_results,
3635 total_warnings: total_warnings.load(Ordering::Relaxed),
3636 total_errors: total_errors.load(Ordering::Relaxed),
3637 parse_errors: parse_errors.load(Ordering::Relaxed),
3638 }
3639}
3640
3641#[derive(Debug, Clone)]
3643pub struct WatchConfig {
3644 pub poll_interval_ms: u64,
3646 pub clear_screen: bool,
3648 pub run_on_start: bool,
3650}
3651
3652impl Default for WatchConfig {
3653 fn default() -> Self {
3654 Self {
3655 poll_interval_ms: 500,
3656 clear_screen: true,
3657 run_on_start: true,
3658 }
3659 }
3660}
3661
3662#[derive(Debug)]
3664pub struct WatchResult {
3665 pub changed_files: Vec<String>,
3667 pub lint_result: DirectoryLintResult,
3669}
3670
3671pub fn watch_directory(
3676 dir: &Path,
3677 config: LintConfig,
3678 watch_config: WatchConfig,
3679) -> impl Iterator<Item = WatchResult> {
3680 use std::collections::HashMap;
3681 use std::fs;
3682 use std::time::{Duration, SystemTime};
3683
3684 let dir = dir.to_path_buf();
3685 let poll_interval = Duration::from_millis(watch_config.poll_interval_ms);
3686 let mut file_times: HashMap<std::path::PathBuf, SystemTime> = HashMap::new();
3687 let mut first_run = watch_config.run_on_start;
3688
3689 std::iter::from_fn(move || {
3690 loop {
3691 let files = collect_sigil_files(&dir);
3692 let mut changed = Vec::new();
3693
3694 for path in &files {
3695 if let Ok(metadata) = fs::metadata(path) {
3696 if let Ok(modified) = metadata.modified() {
3697 let prev = file_times.get(path);
3698 if prev.is_none() || prev.is_some_and(|t| *t != modified) {
3699 changed.push(path.display().to_string());
3700 file_times.insert(path.clone(), modified);
3701 }
3702 }
3703 }
3704 }
3705
3706 let current_paths: std::collections::HashSet<_> = files.iter().collect();
3708 file_times.retain(|p, _| current_paths.contains(p));
3709
3710 if first_run || !changed.is_empty() {
3711 first_run = false;
3712 let lint_result = lint_directory_parallel(&dir, config.clone());
3713 return Some(WatchResult {
3714 changed_files: changed,
3715 lint_result,
3716 });
3717 }
3718
3719 std::thread::sleep(poll_interval);
3720 }
3721 })
3722}
3723
3724#[derive(Debug)]
3730pub struct FixResult {
3731 pub source: String,
3733 pub fixes_applied: usize,
3735 pub fixes_skipped: usize,
3737}
3738
3739pub fn apply_fixes(source: &str, diagnostics: &Diagnostics) -> FixResult {
3744 let mut fixes: Vec<(&FixSuggestion, Span)> = diagnostics
3746 .iter()
3747 .flat_map(|d| d.suggestions.iter().map(move |s| (s, s.span)))
3748 .collect();
3749
3750 fixes.sort_by(|a, b| b.1.start.cmp(&a.1.start));
3752
3753 let mut result = source.to_string();
3754 let mut applied = 0;
3755 let mut skipped = 0;
3756 let mut last_end = usize::MAX;
3757
3758 for (fix, span) in fixes {
3759 if span.end > last_end {
3761 skipped += 1;
3762 continue;
3763 }
3764
3765 if span.start > span.end || span.end > result.len() {
3767 skipped += 1;
3768 continue;
3769 }
3770
3771 let before = &result[..span.start];
3773 let after = &result[span.end..];
3774 result = format!("{}{}{}", before, fix.replacement, after);
3775
3776 applied += 1;
3777 last_end = span.start;
3778 }
3779
3780 FixResult {
3781 source: result,
3782 fixes_applied: applied,
3783 fixes_skipped: skipped,
3784 }
3785}
3786
3787pub fn lint_and_fix(
3791 source: &str,
3792 filename: &str,
3793 config: LintConfig,
3794) -> Result<(String, Diagnostics, FixResult), String> {
3795 let diagnostics = lint_source_with_config(source, filename, config)?;
3796 let fix_result = apply_fixes(source, &diagnostics);
3797 Ok((fix_result.source.clone(), diagnostics, fix_result))
3798}
3799
3800#[derive(Debug, Clone, Serialize)]
3809pub struct SarifReport {
3810 #[serde(rename = "$schema")]
3811 pub schema: String,
3812 pub version: String,
3813 pub runs: Vec<SarifRun>,
3814}
3815
3816#[derive(Debug, Clone, Serialize)]
3817pub struct SarifRun {
3818 pub tool: SarifTool,
3819 pub results: Vec<SarifResult>,
3820}
3821
3822#[derive(Debug, Clone, Serialize)]
3823pub struct SarifTool {
3824 pub driver: SarifDriver,
3825}
3826
3827#[derive(Debug, Clone, Serialize)]
3828pub struct SarifDriver {
3829 pub name: String,
3830 pub version: String,
3831 #[serde(rename = "informationUri")]
3832 pub information_uri: String,
3833 pub rules: Vec<SarifRule>,
3834}
3835
3836#[derive(Debug, Clone, Serialize)]
3837pub struct SarifRule {
3838 pub id: String,
3839 pub name: String,
3840 #[serde(rename = "shortDescription")]
3841 pub short_description: SarifMessage,
3842 #[serde(rename = "fullDescription")]
3843 pub full_description: SarifMessage,
3844 #[serde(rename = "defaultConfiguration")]
3845 pub default_configuration: SarifConfiguration,
3846 pub properties: SarifRuleProperties,
3847}
3848
3849#[derive(Debug, Clone, Serialize)]
3850pub struct SarifMessage {
3851 pub text: String,
3852}
3853
3854#[derive(Debug, Clone, Serialize)]
3855pub struct SarifConfiguration {
3856 pub level: String,
3857}
3858
3859#[derive(Debug, Clone, Serialize)]
3860pub struct SarifRuleProperties {
3861 pub category: String,
3862}
3863
3864#[derive(Debug, Clone, Serialize)]
3865pub struct SarifResult {
3866 #[serde(rename = "ruleId")]
3867 pub rule_id: String,
3868 pub level: String,
3869 pub message: SarifMessage,
3870 pub locations: Vec<SarifLocation>,
3871 #[serde(skip_serializing_if = "Vec::is_empty")]
3872 pub fixes: Vec<SarifFix>,
3873}
3874
3875#[derive(Debug, Clone, Serialize)]
3876pub struct SarifLocation {
3877 #[serde(rename = "physicalLocation")]
3878 pub physical_location: SarifPhysicalLocation,
3879}
3880
3881#[derive(Debug, Clone, Serialize)]
3882pub struct SarifPhysicalLocation {
3883 #[serde(rename = "artifactLocation")]
3884 pub artifact_location: SarifArtifactLocation,
3885 pub region: SarifRegion,
3886}
3887
3888#[derive(Debug, Clone, Serialize)]
3889pub struct SarifArtifactLocation {
3890 pub uri: String,
3891}
3892
3893#[derive(Debug, Clone, Serialize)]
3894pub struct SarifRegion {
3895 #[serde(rename = "startLine")]
3896 pub start_line: usize,
3897 #[serde(rename = "startColumn")]
3898 pub start_column: usize,
3899 #[serde(rename = "endLine")]
3900 pub end_line: usize,
3901 #[serde(rename = "endColumn")]
3902 pub end_column: usize,
3903}
3904
3905#[derive(Debug, Clone, Serialize)]
3906pub struct SarifFix {
3907 pub description: SarifMessage,
3908 #[serde(rename = "artifactChanges")]
3909 pub artifact_changes: Vec<SarifArtifactChange>,
3910}
3911
3912#[derive(Debug, Clone, Serialize)]
3913pub struct SarifArtifactChange {
3914 #[serde(rename = "artifactLocation")]
3915 pub artifact_location: SarifArtifactLocation,
3916 pub replacements: Vec<SarifReplacement>,
3917}
3918
3919#[derive(Debug, Clone, Serialize)]
3920pub struct SarifReplacement {
3921 #[serde(rename = "deletedRegion")]
3922 pub deleted_region: SarifRegion,
3923 #[serde(rename = "insertedContent")]
3924 pub inserted_content: SarifContent,
3925}
3926
3927#[derive(Debug, Clone, Serialize)]
3928pub struct SarifContent {
3929 pub text: String,
3930}
3931
3932impl SarifReport {
3933 pub fn new() -> Self {
3935 let rules: Vec<SarifRule> = LintId::all()
3936 .iter()
3937 .map(|lint| SarifRule {
3938 id: lint.code().to_string(),
3939 name: lint.name().to_string(),
3940 short_description: SarifMessage {
3941 text: lint.description().to_string(),
3942 },
3943 full_description: SarifMessage {
3944 text: lint.extended_docs().trim().to_string(),
3945 },
3946 default_configuration: SarifConfiguration {
3947 level: match lint.default_level() {
3948 LintLevel::Allow => "none".to_string(),
3949 LintLevel::Warn => "warning".to_string(),
3950 LintLevel::Deny => "error".to_string(),
3951 },
3952 },
3953 properties: SarifRuleProperties {
3954 category: format!("{:?}", lint.category()),
3955 },
3956 })
3957 .collect();
3958
3959 Self {
3960 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
3961 version: "2.1.0".to_string(),
3962 runs: vec![SarifRun {
3963 tool: SarifTool {
3964 driver: SarifDriver {
3965 name: "sigil-lint".to_string(),
3966 version: env!("CARGO_PKG_VERSION").to_string(),
3967 information_uri: "https://github.com/Daemoniorum-LLC/styx".to_string(),
3968 rules,
3969 },
3970 },
3971 results: Vec::new(),
3972 }],
3973 }
3974 }
3975
3976 pub fn add_file(&mut self, filename: &str, diagnostics: &Diagnostics, source: &str) {
3978 let line_starts: Vec<usize> = std::iter::once(0)
3979 .chain(source.match_indices('\n').map(|(i, _)| i + 1))
3980 .collect();
3981
3982 let offset_to_line_col = |offset: usize| -> (usize, usize) {
3983 let line = line_starts.partition_point(|&start| start <= offset);
3984 let col = if line > 0 {
3985 offset - line_starts[line - 1] + 1
3986 } else {
3987 offset + 1
3988 };
3989 (line.max(1), col)
3990 };
3991
3992 for diag in diagnostics.iter() {
3993 let (start_line, start_col) = offset_to_line_col(diag.span.start);
3994 let (end_line, end_col) = offset_to_line_col(diag.span.end);
3995
3996 let level = match diag.severity {
3997 Severity::Error => "error",
3998 Severity::Warning => "warning",
3999 Severity::Info | Severity::Hint => "note",
4000 };
4001
4002 let fixes: Vec<SarifFix> = diag
4003 .suggestions
4004 .iter()
4005 .map(|fix| {
4006 let (fix_start_line, fix_start_col) = offset_to_line_col(fix.span.start);
4007 let (fix_end_line, fix_end_col) = offset_to_line_col(fix.span.end);
4008
4009 SarifFix {
4010 description: SarifMessage {
4011 text: fix.message.clone(),
4012 },
4013 artifact_changes: vec![SarifArtifactChange {
4014 artifact_location: SarifArtifactLocation {
4015 uri: filename.to_string(),
4016 },
4017 replacements: vec![SarifReplacement {
4018 deleted_region: SarifRegion {
4019 start_line: fix_start_line,
4020 start_column: fix_start_col,
4021 end_line: fix_end_line,
4022 end_column: fix_end_col,
4023 },
4024 inserted_content: SarifContent {
4025 text: fix.replacement.clone(),
4026 },
4027 }],
4028 }],
4029 }
4030 })
4031 .collect();
4032
4033 if let Some(ref mut run) = self.runs.first_mut() {
4034 run.results.push(SarifResult {
4035 rule_id: diag.code.clone().unwrap_or_default(),
4036 level: level.to_string(),
4037 message: SarifMessage {
4038 text: diag.message.clone(),
4039 },
4040 locations: vec![SarifLocation {
4041 physical_location: SarifPhysicalLocation {
4042 artifact_location: SarifArtifactLocation {
4043 uri: filename.to_string(),
4044 },
4045 region: SarifRegion {
4046 start_line,
4047 start_column: start_col,
4048 end_line,
4049 end_column: end_col,
4050 },
4051 },
4052 }],
4053 fixes,
4054 });
4055 }
4056 }
4057 }
4058
4059 pub fn to_json(&self) -> Result<String, String> {
4061 serde_json::to_string_pretty(self).map_err(|e| format!("Failed to serialize SARIF: {}", e))
4062 }
4063}
4064
4065impl Default for SarifReport {
4066 fn default() -> Self {
4067 Self::new()
4068 }
4069}
4070
4071pub fn generate_sarif(filename: &str, diagnostics: &Diagnostics, source: &str) -> SarifReport {
4073 let mut report = SarifReport::new();
4074 report.add_file(filename, diagnostics, source);
4075 report
4076}
4077
4078pub fn generate_sarif_for_directory(
4080 result: &DirectoryLintResult,
4081 sources: &HashMap<String, String>,
4082) -> SarifReport {
4083 let mut report = SarifReport::new();
4084
4085 for (path, diag_result) in &result.files {
4086 if let Ok(diagnostics) = diag_result {
4087 if let Some(source) = sources.get(path) {
4088 report.add_file(path, diagnostics, source);
4089 }
4090 }
4091 }
4092
4093 report
4094}
4095
4096pub fn explain_lint(lint: LintId) -> String {
4102 format!(
4103 r#"
4104╔══════════════════════════════════════════════════════════════╗
4105║ {code}: {name}
4106╠══════════════════════════════════════════════════════════════╣
4107║ Category: {category:?}
4108║ Default: {level:?}
4109╚══════════════════════════════════════════════════════════════╝
4110
4111{description}
4112
4113{extended}
4114
4115Configuration:
4116 In .sigillint.toml:
4117 [lint.levels]
4118 {name} = "allow" # or "warn" or "deny"
4119
4120 Inline suppression:
4121 // sigil-lint: allow({code})
4122 let code = here;
4123
4124 Next-line suppression:
4125 // sigil-lint: allow-next-line({code})
4126 let code = here;
4127"#,
4128 code = lint.code(),
4129 name = lint.name(),
4130 category = lint.category(),
4131 level = lint.default_level(),
4132 description = lint.description(),
4133 extended = lint.extended_docs().trim(),
4134 )
4135}
4136
4137pub fn list_lints() -> String {
4139 use std::collections::BTreeMap;
4140
4141 let mut by_category: BTreeMap<LintCategory, Vec<LintId>> = BTreeMap::new();
4142
4143 for lint in LintId::all() {
4144 by_category.entry(lint.category()).or_default().push(*lint);
4145 }
4146
4147 let mut output =
4148 String::from("\n╔══════════════════════════════════════════════════════════════╗\n");
4149 output.push_str("║ Sigil Linter Rules ║\n");
4150 output.push_str("╚══════════════════════════════════════════════════════════════╝\n\n");
4151
4152 for (category, lints) in by_category {
4153 output.push_str(&format!("── {:?} ──\n", category));
4154 for lint in lints {
4155 let level_char = match lint.default_level() {
4156 LintLevel::Allow => '○',
4157 LintLevel::Warn => '◐',
4158 LintLevel::Deny => '●',
4159 };
4160 output.push_str(&format!(
4161 " {} {} {}: {}\n",
4162 level_char,
4163 lint.code(),
4164 lint.name(),
4165 lint.description()
4166 ));
4167 }
4168 output.push('\n');
4169 }
4170
4171 output.push_str("Legend: ○ = allow by default, ◐ = warn by default, ● = deny by default\n");
4172 output
4173}
4174
4175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4181pub enum LspSeverity {
4182 Error = 1,
4183 Warning = 2,
4184 Information = 3,
4185 Hint = 4,
4186}
4187
4188impl From<Severity> for LspSeverity {
4189 fn from(sev: Severity) -> Self {
4190 match sev {
4191 Severity::Error => LspSeverity::Error,
4192 Severity::Warning => LspSeverity::Warning,
4193 Severity::Info => LspSeverity::Information,
4194 Severity::Hint => LspSeverity::Hint,
4195 }
4196 }
4197}
4198
4199#[derive(Debug, Clone, Serialize, Deserialize)]
4201pub struct LspDiagnostic {
4202 pub line: u32,
4204 pub character: u32,
4206 pub end_line: u32,
4208 pub end_character: u32,
4210 pub severity: u32,
4212 pub code: Option<String>,
4214 pub source: String,
4216 pub message: String,
4218 #[serde(skip_serializing_if = "Vec::is_empty")]
4220 pub related_information: Vec<LspRelatedInfo>,
4221 #[serde(skip_serializing_if = "Vec::is_empty")]
4223 pub code_actions: Vec<LspCodeAction>,
4224}
4225
4226#[derive(Debug, Clone, Serialize, Deserialize)]
4228pub struct LspRelatedInfo {
4229 pub uri: String,
4230 pub line: u32,
4231 pub character: u32,
4232 pub message: String,
4233}
4234
4235#[derive(Debug, Clone, Serialize, Deserialize)]
4237pub struct LspCodeAction {
4238 pub title: String,
4239 pub kind: String,
4240 pub edit: LspTextEdit,
4241}
4242
4243#[derive(Debug, Clone, Serialize, Deserialize)]
4245pub struct LspTextEdit {
4246 pub line: u32,
4247 pub character: u32,
4248 pub end_line: u32,
4249 pub end_character: u32,
4250 pub new_text: String,
4251}
4252
4253impl LspDiagnostic {
4254 pub fn from_diagnostic(diag: &Diagnostic, source: &str) -> Self {
4256 let (line, character) = offset_to_position(diag.span.start, source);
4257 let (end_line, end_character) = offset_to_position(diag.span.end, source);
4258
4259 let mut code_actions = Vec::new();
4260
4261 for suggestion in &diag.suggestions {
4263 let (fix_line, fix_char) = offset_to_position(suggestion.span.start, source);
4264 let (fix_end_line, fix_end_char) = offset_to_position(suggestion.span.end, source);
4265
4266 code_actions.push(LspCodeAction {
4267 title: suggestion.message.clone(),
4268 kind: "quickfix".to_string(),
4269 edit: LspTextEdit {
4270 line: fix_line,
4271 character: fix_char,
4272 end_line: fix_end_line,
4273 end_character: fix_end_char,
4274 new_text: suggestion.replacement.clone(),
4275 },
4276 });
4277 }
4278
4279 Self {
4280 line,
4281 character,
4282 end_line,
4283 end_character,
4284 severity: LspSeverity::from(diag.severity) as u32,
4285 code: diag.code.clone(),
4286 source: "sigil-lint".to_string(),
4287 message: diag.message.clone(),
4288 related_information: Vec::new(),
4289 code_actions,
4290 }
4291 }
4292}
4293
4294fn offset_to_position(offset: usize, source: &str) -> (u32, u32) {
4296 let mut line = 0u32;
4297 let mut col = 0u32;
4298
4299 for (i, ch) in source.char_indices() {
4300 if i >= offset {
4301 break;
4302 }
4303 if ch == '\n' {
4304 line += 1;
4305 col = 0;
4306 } else {
4307 col += 1;
4308 }
4309 }
4310
4311 (line, col)
4312}
4313
4314#[derive(Debug, Clone, Serialize, Deserialize)]
4316pub struct LspLintResult {
4317 pub uri: String,
4319 pub version: Option<i32>,
4321 pub diagnostics: Vec<LspDiagnostic>,
4323}
4324
4325pub fn lint_for_lsp(source: &str, uri: &str, config: LintConfig) -> LspLintResult {
4327 let diagnostics = match lint_source_with_config(source, uri, config) {
4328 Ok(diags) => diags
4329 .iter()
4330 .map(|d| LspDiagnostic::from_diagnostic(d, source))
4331 .collect(),
4332 Err(_) => Vec::new(),
4333 };
4334
4335 LspLintResult {
4336 uri: uri.to_string(),
4337 version: None,
4338 diagnostics,
4339 }
4340}
4341
4342#[derive(Debug, Default)]
4344pub struct LspServerState {
4345 pub documents: HashMap<String, (i32, String)>,
4347 pub config: LintConfig,
4349 pub baseline: Option<Baseline>,
4351}
4352
4353impl LspServerState {
4354 pub fn new() -> Self {
4356 Self {
4357 documents: HashMap::new(),
4358 config: LintConfig::find_and_load(),
4359 baseline: find_baseline(),
4360 }
4361 }
4362
4363 pub fn update_document(&mut self, uri: &str, version: i32, content: String) {
4365 self.documents.insert(uri.to_string(), (version, content));
4366 }
4367
4368 pub fn remove_document(&mut self, uri: &str) {
4370 self.documents.remove(uri);
4371 }
4372
4373 pub fn lint_document(&self, uri: &str) -> Option<LspLintResult> {
4375 let (version, content) = self.documents.get(uri)?;
4376
4377 let mut result = lint_for_lsp(content, uri, self.config.clone());
4378 result.version = Some(*version);
4379
4380 if let Some(ref baseline) = self.baseline {
4382 result.diagnostics.retain(|lsp_diag| {
4383 let span = Span::new(0, 0); let diag = Diagnostic {
4386 severity: match lsp_diag.severity {
4387 1 => Severity::Error,
4388 2 => Severity::Warning,
4389 3 => Severity::Info,
4390 _ => Severity::Hint,
4391 },
4392 code: lsp_diag.code.clone(),
4393 message: lsp_diag.message.clone(),
4394 span,
4395 labels: Vec::new(),
4396 notes: Vec::new(),
4397 suggestions: Vec::new(),
4398 related: Vec::new(),
4399 };
4400 !baseline.contains(uri, &diag, content)
4401 });
4402 }
4403
4404 Some(result)
4405 }
4406}
4407
4408#[derive(Debug, Clone)]
4414pub struct GitIntegration {
4415 pub repo_root: PathBuf,
4417}
4418
4419impl GitIntegration {
4420 pub fn from_current_dir() -> Result<Self, String> {
4422 let output = std::process::Command::new("git")
4423 .args(["rev-parse", "--show-toplevel"])
4424 .output()
4425 .map_err(|e| format!("Failed to run git: {}", e))?;
4426
4427 if !output.status.success() {
4428 return Err("Not in a git repository".to_string());
4429 }
4430
4431 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
4432 Ok(Self {
4433 repo_root: PathBuf::from(root),
4434 })
4435 }
4436
4437 pub fn get_changed_files(&self) -> Result<Vec<PathBuf>, String> {
4439 let mut files = HashSet::new();
4440
4441 let staged = self.run_git(&["diff", "--cached", "--name-only"])?;
4443 for line in staged.lines() {
4444 if line.ends_with(".sigil") {
4445 files.insert(self.repo_root.join(line));
4446 }
4447 }
4448
4449 let unstaged = self.run_git(&["diff", "--name-only"])?;
4451 for line in unstaged.lines() {
4452 if line.ends_with(".sigil") {
4453 files.insert(self.repo_root.join(line));
4454 }
4455 }
4456
4457 let untracked = self.run_git(&["ls-files", "--others", "--exclude-standard"])?;
4459 for line in untracked.lines() {
4460 if line.ends_with(".sigil") {
4461 files.insert(self.repo_root.join(line));
4462 }
4463 }
4464
4465 Ok(files.into_iter().collect())
4466 }
4467
4468 pub fn get_changed_since(&self, base: &str) -> Result<Vec<PathBuf>, String> {
4470 let output = self.run_git(&["diff", "--name-only", base])?;
4471 let files: Vec<PathBuf> = output
4472 .lines()
4473 .filter(|line| line.ends_with(".sigil"))
4474 .map(|line| self.repo_root.join(line))
4475 .collect();
4476 Ok(files)
4477 }
4478
4479 fn run_git(&self, args: &[&str]) -> Result<String, String> {
4481 let output = std::process::Command::new("git")
4482 .current_dir(&self.repo_root)
4483 .args(args)
4484 .output()
4485 .map_err(|e| format!("Failed to run git: {}", e))?;
4486
4487 if !output.status.success() {
4488 let stderr = String::from_utf8_lossy(&output.stderr);
4489 return Err(format!("Git command failed: {}", stderr));
4490 }
4491
4492 Ok(String::from_utf8_lossy(&output.stdout).to_string())
4493 }
4494}
4495
4496pub fn lint_changed_files(config: LintConfig) -> Result<DirectoryLintResult, String> {
4498 let git = GitIntegration::from_current_dir()?;
4499 let changed = git.get_changed_files()?;
4500
4501 if changed.is_empty() {
4502 return Ok(DirectoryLintResult {
4503 files: Vec::new(),
4504 total_warnings: 0,
4505 total_errors: 0,
4506 parse_errors: 0,
4507 });
4508 }
4509
4510 lint_files(&changed, config)
4511}
4512
4513pub fn lint_changed_since(base: &str, config: LintConfig) -> Result<DirectoryLintResult, String> {
4515 let git = GitIntegration::from_current_dir()?;
4516 let changed = git.get_changed_since(base)?;
4517
4518 if changed.is_empty() {
4519 return Ok(DirectoryLintResult {
4520 files: Vec::new(),
4521 total_warnings: 0,
4522 total_errors: 0,
4523 parse_errors: 0,
4524 });
4525 }
4526
4527 lint_files(&changed, config)
4528}
4529
4530pub fn lint_files(files: &[PathBuf], config: LintConfig) -> Result<DirectoryLintResult, String> {
4532 use std::fs;
4533
4534 let mut total_warnings = 0;
4535 let mut total_errors = 0;
4536 let mut parse_errors = 0;
4537 let mut results = Vec::new();
4538
4539 for path in files {
4540 let path_str = path.display().to_string();
4541
4542 match fs::read_to_string(path) {
4543 Ok(source) => match lint_source_with_config(&source, &path_str, config.clone()) {
4544 Ok(diagnostics) => {
4545 for diag in diagnostics.iter() {
4546 match diag.severity {
4547 Severity::Error => total_errors += 1,
4548 Severity::Warning => total_warnings += 1,
4549 _ => {}
4550 }
4551 }
4552 results.push((path_str, Ok(diagnostics)));
4553 }
4554 Err(e) => {
4555 parse_errors += 1;
4556 results.push((path_str, Err(e)));
4557 }
4558 },
4559 Err(e) => {
4560 parse_errors += 1;
4561 results.push((path_str, Err(format!("Failed to read file: {}", e))));
4562 }
4563 }
4564 }
4565
4566 Ok(DirectoryLintResult {
4567 files: results,
4568 total_warnings,
4569 total_errors,
4570 parse_errors,
4571 })
4572}
4573
4574pub const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
4576# Sigil lint pre-commit hook
4577# Generated by sigil lint --generate-hook
4578
4579# Get list of staged .sigil files
4580STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sigil$')
4581
4582if [ -z "$STAGED_FILES" ]; then
4583 exit 0
4584fi
4585
4586echo "Running Sigil linter on staged files..."
4587
4588# Run linter on staged files
4589RESULT=0
4590for FILE in $STAGED_FILES; do
4591 if [ -f "$FILE" ]; then
4592 sigil lint "$FILE"
4593 if [ $? -ne 0 ]; then
4594 RESULT=1
4595 fi
4596 fi
4597done
4598
4599if [ $RESULT -ne 0 ]; then
4600 echo ""
4601 echo "Commit blocked: Please fix lint errors before committing."
4602 echo "Use 'git commit --no-verify' to bypass (not recommended)."
4603 exit 1
4604fi
4605
4606exit 0
4607"#;
4608
4609pub fn generate_pre_commit_hook() -> Result<PathBuf, String> {
4611 let git = GitIntegration::from_current_dir()?;
4612 let hook_path = git.repo_root.join(".git/hooks/pre-commit");
4613
4614 std::fs::write(&hook_path, PRE_COMMIT_HOOK)
4615 .map_err(|e| format!("Failed to write hook: {}", e))?;
4616
4617 #[cfg(unix)]
4619 {
4620 use std::os::unix::fs::PermissionsExt;
4621 let mut perms = std::fs::metadata(&hook_path)
4622 .map_err(|e| format!("Failed to get permissions: {}", e))?
4623 .permissions();
4624 perms.set_mode(0o755);
4625 std::fs::set_permissions(&hook_path, perms)
4626 .map_err(|e| format!("Failed to set permissions: {}", e))?;
4627 }
4628
4629 Ok(hook_path)
4630}
4631
4632#[derive(Debug, Clone, Serialize, Deserialize)]
4638pub struct CustomRule {
4639 pub id: String,
4641 pub name: String,
4643 pub description: String,
4645 pub level: LintLevel,
4647 pub category: String,
4649 pub pattern: CustomPattern,
4651 #[serde(skip_serializing_if = "Option::is_none")]
4653 pub suggestion: Option<String>,
4654 #[serde(skip_serializing_if = "Option::is_none")]
4656 pub docs: Option<String>,
4657}
4658
4659#[derive(Debug, Clone, Serialize, Deserialize)]
4661#[serde(tag = "type")]
4662pub enum CustomPattern {
4663 Regex { pattern: String },
4665 FunctionCall { names: Vec<String> },
4667 Identifier { names: Vec<String> },
4669 Import { modules: Vec<String> },
4671 StringContains { patterns: Vec<String> },
4673 AstNode {
4675 node_type: String,
4676 conditions: HashMap<String, String>,
4677 },
4678}
4679
4680#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4682pub struct CustomRulesFile {
4683 #[serde(default = "default_version")]
4685 pub version: u32,
4686 #[serde(default)]
4688 pub rules: Vec<CustomRule>,
4689 #[serde(default)]
4691 pub rulesets: HashMap<String, Vec<String>>,
4692}
4693
4694fn default_version() -> u32 {
4695 1
4696}
4697
4698impl CustomRulesFile {
4699 pub fn from_file(path: &Path) -> Result<Self, String> {
4701 let content = std::fs::read_to_string(path)
4702 .map_err(|e| format!("Failed to read custom rules: {}", e))?;
4703
4704 if path.extension().map(|e| e == "json").unwrap_or(false) {
4705 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
4706 } else {
4707 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
4708 }
4709 }
4710
4711 pub fn find_and_load() -> Option<Self> {
4713 let names = [
4714 ".sigillint-rules.toml",
4715 ".sigillint-rules.json",
4716 "sigillint-rules.toml",
4717 ];
4718
4719 if let Ok(mut dir) = std::env::current_dir() {
4720 loop {
4721 for name in &names {
4722 let path = dir.join(name);
4723 if path.exists() {
4724 if let Ok(rules) = Self::from_file(&path) {
4725 return Some(rules);
4726 }
4727 }
4728 }
4729 if !dir.pop() {
4730 break;
4731 }
4732 }
4733 }
4734
4735 None
4736 }
4737}
4738
4739#[derive(Debug)]
4741pub struct CustomRuleMatch {
4742 pub rule_id: String,
4744 pub span: Span,
4746 pub matched_text: String,
4748}
4749
4750pub struct CustomRuleChecker {
4752 rules: Vec<CustomRule>,
4753 compiled_patterns: HashMap<String, regex::Regex>,
4754}
4755
4756impl CustomRuleChecker {
4757 pub fn new(rules: Vec<CustomRule>) -> Self {
4759 let mut compiled = HashMap::new();
4760
4761 for rule in &rules {
4762 if let CustomPattern::Regex { pattern } = &rule.pattern {
4763 if let Ok(re) = regex::Regex::new(pattern) {
4764 compiled.insert(rule.id.clone(), re);
4765 }
4766 }
4767 }
4768
4769 Self {
4770 rules,
4771 compiled_patterns: compiled,
4772 }
4773 }
4774
4775 pub fn check(&self, source: &str) -> Vec<(CustomRule, CustomRuleMatch)> {
4777 let mut matches = Vec::new();
4778
4779 for rule in &self.rules {
4780 match &rule.pattern {
4781 CustomPattern::Regex { .. } => {
4782 if let Some(re) = self.compiled_patterns.get(&rule.id) {
4783 for m in re.find_iter(source) {
4784 matches.push((
4785 rule.clone(),
4786 CustomRuleMatch {
4787 rule_id: rule.id.clone(),
4788 span: Span::new(m.start(), m.end()),
4789 matched_text: m.as_str().to_string(),
4790 },
4791 ));
4792 }
4793 }
4794 }
4795 CustomPattern::FunctionCall { names } => {
4796 for name in names {
4797 let pattern = format!(r"\b{}\s*\(", regex::escape(name));
4798 if let Ok(re) = regex::Regex::new(&pattern) {
4799 for m in re.find_iter(source) {
4800 matches.push((
4801 rule.clone(),
4802 CustomRuleMatch {
4803 rule_id: rule.id.clone(),
4804 span: Span::new(m.start(), m.end() - 1),
4805 matched_text: name.clone(),
4806 },
4807 ));
4808 }
4809 }
4810 }
4811 }
4812 CustomPattern::Identifier { names } => {
4813 for name in names {
4814 let pattern = format!(r"\b{}\b", regex::escape(name));
4815 if let Ok(re) = regex::Regex::new(&pattern) {
4816 for m in re.find_iter(source) {
4817 matches.push((
4818 rule.clone(),
4819 CustomRuleMatch {
4820 rule_id: rule.id.clone(),
4821 span: Span::new(m.start(), m.end()),
4822 matched_text: name.clone(),
4823 },
4824 ));
4825 }
4826 }
4827 }
4828 }
4829 CustomPattern::StringContains { patterns } => {
4830 let string_re = regex::Regex::new(r#""([^"\\]|\\.)*""#).unwrap();
4832 for string_match in string_re.find_iter(source) {
4833 let string_content = string_match.as_str();
4834 for pattern in patterns {
4835 if string_content.contains(pattern) {
4836 matches.push((
4837 rule.clone(),
4838 CustomRuleMatch {
4839 rule_id: rule.id.clone(),
4840 span: Span::new(string_match.start(), string_match.end()),
4841 matched_text: string_content.to_string(),
4842 },
4843 ));
4844 break;
4845 }
4846 }
4847 }
4848 }
4849 CustomPattern::Import { modules } => {
4850 for module in modules {
4851 let pattern = format!(r"use\s+{}", regex::escape(module));
4852 if let Ok(re) = regex::Regex::new(&pattern) {
4853 for m in re.find_iter(source) {
4854 matches.push((
4855 rule.clone(),
4856 CustomRuleMatch {
4857 rule_id: rule.id.clone(),
4858 span: Span::new(m.start(), m.end()),
4859 matched_text: module.clone(),
4860 },
4861 ));
4862 }
4863 }
4864 }
4865 }
4866 CustomPattern::AstNode { .. } => {
4867 }
4869 }
4870 }
4871
4872 matches
4873 }
4874
4875 pub fn to_diagnostics(&self, source: &str) -> Diagnostics {
4877 let mut diagnostics = Diagnostics::new();
4878
4879 for (rule, m) in self.check(source) {
4880 let severity = match rule.level {
4881 LintLevel::Deny => Severity::Error,
4882 LintLevel::Warn => Severity::Warning,
4883 LintLevel::Allow => continue,
4884 };
4885
4886 let mut diag = Diagnostic {
4887 severity,
4888 code: Some(format!("CUSTOM:{}", rule.id)),
4889 message: rule.description.clone(),
4890 span: m.span,
4891 labels: Vec::new(),
4892 notes: Vec::new(),
4893 suggestions: Vec::new(),
4894 related: Vec::new(),
4895 };
4896
4897 if let Some(ref suggestion) = rule.suggestion {
4898 diag.notes.push(format!("Suggestion: {}", suggestion));
4899 }
4900
4901 diagnostics.add(diag);
4902 }
4903
4904 diagnostics
4905 }
4906}
4907
4908pub fn lint_with_custom_rules(
4910 source: &str,
4911 filename: &str,
4912 config: LintConfig,
4913 custom_rules: &[CustomRule],
4914) -> Result<Diagnostics, String> {
4915 let mut diagnostics = lint_source_with_config(source, filename, config)?;
4917
4918 let checker = CustomRuleChecker::new(custom_rules.to_vec());
4920 let custom_diags = checker.to_diagnostics(source);
4921
4922 for diag in custom_diags.iter() {
4924 diagnostics.add(diag.clone());
4925 }
4926
4927 Ok(diagnostics)
4928}
4929
4930#[derive(Debug, Clone, Default)]
4936pub struct IgnorePatterns {
4937 patterns: Vec<globset::GlobMatcher>,
4939 raw_patterns: Vec<String>,
4941}
4942
4943impl IgnorePatterns {
4944 pub fn new() -> Self {
4946 Self::default()
4947 }
4948
4949 pub fn from_file(path: &Path) -> Result<Self, String> {
4951 let content = std::fs::read_to_string(path)
4952 .map_err(|e| format!("Failed to read ignore file: {}", e))?;
4953 Self::from_string(&content)
4954 }
4955
4956 pub fn from_string(content: &str) -> Result<Self, String> {
4958 let mut patterns = Vec::new();
4959 let mut raw_patterns = Vec::new();
4960
4961 for line in content.lines() {
4962 let line = line.trim();
4963
4964 if line.is_empty() || line.starts_with('#') {
4966 continue;
4967 }
4968
4969 match globset::Glob::new(line) {
4971 Ok(glob) => {
4972 patterns.push(glob.compile_matcher());
4973 raw_patterns.push(line.to_string());
4974 }
4975 Err(e) => {
4976 return Err(format!("Invalid pattern '{}': {}", line, e));
4977 }
4978 }
4979 }
4980
4981 Ok(Self {
4982 patterns,
4983 raw_patterns,
4984 })
4985 }
4986
4987 pub fn find_and_load() -> Option<Self> {
4989 let names = [".sigillintignore", ".lintignore"];
4990
4991 if let Ok(mut dir) = std::env::current_dir() {
4992 loop {
4993 for name in &names {
4994 let path = dir.join(name);
4995 if path.exists() {
4996 if let Ok(patterns) = Self::from_file(&path) {
4997 return Some(patterns);
4998 }
4999 }
5000 }
5001 if !dir.pop() {
5002 break;
5003 }
5004 }
5005 }
5006
5007 None
5008 }
5009
5010 pub fn is_ignored(&self, path: &Path) -> bool {
5012 let path_str = path.to_string_lossy();
5013
5014 for pattern in &self.patterns {
5015 if pattern.is_match(path) || pattern.is_match(path_str.as_ref()) {
5016 return true;
5017 }
5018 }
5019
5020 false
5021 }
5022
5023 pub fn is_ignored_str(&self, path: &str) -> bool {
5025 self.is_ignored(Path::new(path))
5026 }
5027
5028 pub fn patterns(&self) -> &[String] {
5030 &self.raw_patterns
5031 }
5032
5033 pub fn is_empty(&self) -> bool {
5035 self.patterns.is_empty()
5036 }
5037}
5038
5039pub fn filter_ignored(files: Vec<PathBuf>, ignore: &IgnorePatterns) -> Vec<PathBuf> {
5041 files
5042 .into_iter()
5043 .filter(|f| !ignore.is_ignored(f))
5044 .collect()
5045}
5046
5047pub fn collect_sigil_files_filtered(dir: &Path, ignore: &IgnorePatterns) -> Vec<PathBuf> {
5049 let all_files = collect_sigil_files(dir);
5050 filter_ignored(all_files, ignore)
5051}
5052
5053pub fn lint_directory_filtered(
5055 dir: &Path,
5056 config: LintConfig,
5057 ignore: Option<&IgnorePatterns>,
5058) -> DirectoryLintResult {
5059 let files = if let Some(patterns) = ignore {
5060 collect_sigil_files_filtered(dir, patterns)
5061 } else if let Some(loaded) = IgnorePatterns::find_and_load() {
5062 collect_sigil_files_filtered(dir, &loaded)
5063 } else {
5064 collect_sigil_files(dir)
5065 };
5066
5067 use rayon::prelude::*;
5069 use std::sync::atomic::{AtomicUsize, Ordering};
5070
5071 let total_warnings = AtomicUsize::new(0);
5072 let total_errors = AtomicUsize::new(0);
5073 let parse_errors = AtomicUsize::new(0);
5074
5075 let file_results: Vec<(String, Result<Diagnostics, String>)> = files
5076 .par_iter()
5077 .filter_map(|path| {
5078 let source = std::fs::read_to_string(path).ok()?;
5079 let path_str = path.display().to_string();
5080
5081 match lint_source_with_config(&source, &path_str, config.clone()) {
5082 Ok(diagnostics) => {
5083 let warnings = diagnostics
5084 .iter()
5085 .filter(|d| d.severity == Severity::Warning)
5086 .count();
5087 let errors = diagnostics
5088 .iter()
5089 .filter(|d| d.severity == Severity::Error)
5090 .count();
5091 total_warnings.fetch_add(warnings, Ordering::Relaxed);
5092 total_errors.fetch_add(errors, Ordering::Relaxed);
5093 Some((path_str, Ok(diagnostics)))
5094 }
5095 Err(e) => {
5096 parse_errors.fetch_add(1, Ordering::Relaxed);
5097 Some((path_str, Err(e)))
5098 }
5099 }
5100 })
5101 .collect();
5102
5103 DirectoryLintResult {
5104 files: file_results,
5105 total_warnings: total_warnings.load(Ordering::Relaxed),
5106 total_errors: total_errors.load(Ordering::Relaxed),
5107 parse_errors: parse_errors.load(Ordering::Relaxed),
5108 }
5109}
5110
5111#[derive(Debug, Clone, Serialize, Deserialize)]
5117pub struct LintReport {
5118 pub timestamp: String,
5120 #[serde(skip_serializing_if = "Option::is_none")]
5122 pub commit: Option<String>,
5123 #[serde(skip_serializing_if = "Option::is_none")]
5125 pub branch: Option<String>,
5126 pub total_files: usize,
5128 pub total_warnings: usize,
5130 pub total_errors: usize,
5132 pub parse_errors: usize,
5134 pub by_rule: HashMap<String, usize>,
5136 pub by_category: HashMap<String, usize>,
5138 pub by_file: Vec<(String, usize)>,
5140}
5141
5142impl LintReport {
5143 pub fn from_result(result: &DirectoryLintResult) -> Self {
5145 let mut by_rule: HashMap<String, usize> = HashMap::new();
5146 let mut by_category: HashMap<String, usize> = HashMap::new();
5147 let mut by_file: Vec<(String, usize)> = Vec::new();
5148
5149 for (path, diag_result) in &result.files {
5150 if let Ok(diagnostics) = diag_result {
5151 let count = diagnostics.iter().count();
5152 if count > 0 {
5153 by_file.push((path.clone(), count));
5154 }
5155
5156 for diag in diagnostics.iter() {
5157 if let Some(ref code) = diag.code {
5158 *by_rule.entry(code.clone()).or_insert(0) += 1;
5159
5160 let category = if code.starts_with('E') {
5162 "error"
5163 } else if code.starts_with('W') {
5164 match &code[1..3] {
5165 "01" | "02" => "style",
5166 "03" | "04" | "05" => "correctness",
5167 _ => "other",
5168 }
5169 } else {
5170 "other"
5171 };
5172 *by_category.entry(category.to_string()).or_insert(0) += 1;
5173 }
5174 }
5175 }
5176 }
5177
5178 by_file.sort_by(|a, b| b.1.cmp(&a.1));
5180 by_file.truncate(20); let (commit, branch) = Self::get_git_info();
5184
5185 Self {
5186 timestamp: chrono_lite_now(),
5187 commit,
5188 branch,
5189 total_files: result.files.len(),
5190 total_warnings: result.total_warnings,
5191 total_errors: result.total_errors,
5192 parse_errors: result.parse_errors,
5193 by_rule,
5194 by_category,
5195 by_file,
5196 }
5197 }
5198
5199 fn get_git_info() -> (Option<String>, Option<String>) {
5201 let commit = std::process::Command::new("git")
5202 .args(["rev-parse", "--short", "HEAD"])
5203 .output()
5204 .ok()
5205 .filter(|o| o.status.success())
5206 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
5207
5208 let branch = std::process::Command::new("git")
5209 .args(["rev-parse", "--abbrev-ref", "HEAD"])
5210 .output()
5211 .ok()
5212 .filter(|o| o.status.success())
5213 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
5214
5215 (commit, branch)
5216 }
5217
5218 pub fn save_json(&self, path: &Path) -> Result<(), String> {
5220 let content = serde_json::to_string_pretty(self)
5221 .map_err(|e| format!("Failed to serialize report: {}", e))?;
5222 std::fs::write(path, content).map_err(|e| format!("Failed to write report: {}", e))
5223 }
5224
5225 pub fn load_json(path: &Path) -> Result<Self, String> {
5227 let content =
5228 std::fs::read_to_string(path).map_err(|e| format!("Failed to read report: {}", e))?;
5229 serde_json::from_str(&content).map_err(|e| format!("Failed to parse report: {}", e))
5230 }
5231}
5232
5233#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5235pub struct TrendData {
5236 pub reports: Vec<LintReport>,
5238 pub max_reports: usize,
5240}
5241
5242impl TrendData {
5243 pub fn new(max_reports: usize) -> Self {
5245 Self {
5246 reports: Vec::new(),
5247 max_reports,
5248 }
5249 }
5250
5251 pub fn from_file(path: &Path) -> Result<Self, String> {
5253 let content = std::fs::read_to_string(path)
5254 .map_err(|e| format!("Failed to read trend data: {}", e))?;
5255 serde_json::from_str(&content).map_err(|e| format!("Failed to parse trend data: {}", e))
5256 }
5257
5258 pub fn save(&self, path: &Path) -> Result<(), String> {
5260 let content = serde_json::to_string_pretty(self)
5261 .map_err(|e| format!("Failed to serialize trend data: {}", e))?;
5262 std::fs::write(path, content).map_err(|e| format!("Failed to write trend data: {}", e))
5263 }
5264
5265 pub fn add_report(&mut self, report: LintReport) {
5267 self.reports.push(report);
5268
5269 if self.reports.len() > self.max_reports {
5271 self.reports.remove(0);
5272 }
5273 }
5274
5275 pub fn summary(&self) -> TrendSummary {
5277 if self.reports.is_empty() {
5278 return TrendSummary::default();
5279 }
5280
5281 let latest = self.reports.last().unwrap();
5282 let previous = if self.reports.len() > 1 {
5283 Some(&self.reports[self.reports.len() - 2])
5284 } else {
5285 None
5286 };
5287
5288 let warning_delta = previous
5289 .map(|p| latest.total_warnings as i64 - p.total_warnings as i64)
5290 .unwrap_or(0);
5291 let error_delta = previous
5292 .map(|p| latest.total_errors as i64 - p.total_errors as i64)
5293 .unwrap_or(0);
5294
5295 TrendSummary {
5296 total_reports: self.reports.len(),
5297 latest_warnings: latest.total_warnings,
5298 latest_errors: latest.total_errors,
5299 warning_delta,
5300 error_delta,
5301 trend_direction: if warning_delta + error_delta < 0 {
5302 TrendDirection::Improving
5303 } else if warning_delta + error_delta > 0 {
5304 TrendDirection::Degrading
5305 } else {
5306 TrendDirection::Stable
5307 },
5308 }
5309 }
5310}
5311
5312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5314pub enum TrendDirection {
5315 Improving,
5316 Stable,
5317 Degrading,
5318}
5319
5320#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5322pub struct TrendSummary {
5323 pub total_reports: usize,
5324 pub latest_warnings: usize,
5325 pub latest_errors: usize,
5326 pub warning_delta: i64,
5327 pub error_delta: i64,
5328 pub trend_direction: TrendDirection,
5329}
5330
5331impl Default for TrendDirection {
5332 fn default() -> Self {
5333 TrendDirection::Stable
5334 }
5335}
5336
5337pub fn generate_html_report(result: &DirectoryLintResult, title: &str) -> String {
5339 let report = LintReport::from_result(result);
5340
5341 let mut html = String::new();
5342
5343 html.push_str(&format!(
5345 r#"<!DOCTYPE html>
5346<html lang="en">
5347<head>
5348 <meta charset="UTF-8">
5349 <meta name="viewport" content="width=device-width, initial-scale=1.0">
5350 <title>{} - Sigil Lint Report</title>
5351 <style>
5352 :root {{
5353 --bg-primary: #1a1a2e;
5354 --bg-secondary: #16213e;
5355 --bg-card: #0f3460;
5356 --text-primary: #eee;
5357 --text-secondary: #aaa;
5358 --accent: #e94560;
5359 --success: #4ecca3;
5360 --warning: #ffc107;
5361 --error: #e94560;
5362 }}
5363 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
5364 body {{
5365 font-family: 'Segoe UI', system-ui, sans-serif;
5366 background: var(--bg-primary);
5367 color: var(--text-primary);
5368 line-height: 1.6;
5369 padding: 2rem;
5370 }}
5371 .container {{ max-width: 1200px; margin: 0 auto; }}
5372 h1 {{ color: var(--accent); margin-bottom: 0.5rem; }}
5373 .meta {{ color: var(--text-secondary); margin-bottom: 2rem; }}
5374 .stats {{
5375 display: grid;
5376 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
5377 gap: 1rem;
5378 margin-bottom: 2rem;
5379 }}
5380 .stat-card {{
5381 background: var(--bg-card);
5382 padding: 1.5rem;
5383 border-radius: 8px;
5384 text-align: center;
5385 }}
5386 .stat-value {{ font-size: 2.5rem; font-weight: bold; }}
5387 .stat-label {{ color: var(--text-secondary); }}
5388 .stat-value.errors {{ color: var(--error); }}
5389 .stat-value.warnings {{ color: var(--warning); }}
5390 .stat-value.success {{ color: var(--success); }}
5391 .section {{ margin-bottom: 2rem; }}
5392 .section h2 {{
5393 color: var(--accent);
5394 border-bottom: 2px solid var(--bg-card);
5395 padding-bottom: 0.5rem;
5396 margin-bottom: 1rem;
5397 }}
5398 table {{
5399 width: 100%;
5400 border-collapse: collapse;
5401 background: var(--bg-secondary);
5402 border-radius: 8px;
5403 overflow: hidden;
5404 }}
5405 th, td {{
5406 padding: 0.75rem 1rem;
5407 text-align: left;
5408 border-bottom: 1px solid var(--bg-card);
5409 }}
5410 th {{ background: var(--bg-card); color: var(--accent); }}
5411 tr:hover {{ background: var(--bg-card); }}
5412 .bar {{
5413 height: 8px;
5414 background: var(--bg-card);
5415 border-radius: 4px;
5416 overflow: hidden;
5417 }}
5418 .bar-fill {{
5419 height: 100%;
5420 background: var(--accent);
5421 transition: width 0.3s ease;
5422 }}
5423 .chart {{
5424 display: flex;
5425 align-items: flex-end;
5426 gap: 0.5rem;
5427 height: 150px;
5428 padding: 1rem;
5429 background: var(--bg-secondary);
5430 border-radius: 8px;
5431 }}
5432 .chart-bar {{
5433 flex: 1;
5434 background: var(--accent);
5435 border-radius: 4px 4px 0 0;
5436 min-width: 20px;
5437 position: relative;
5438 }}
5439 .chart-bar:hover {{ opacity: 0.8; }}
5440 .chart-label {{
5441 position: absolute;
5442 bottom: -1.5rem;
5443 left: 50%;
5444 transform: translateX(-50%);
5445 font-size: 0.75rem;
5446 color: var(--text-secondary);
5447 }}
5448 </style>
5449</head>
5450<body>
5451 <div class="container">
5452 <h1>🔮 {}</h1>
5453 <p class="meta">Generated: {} | Commit: {} | Branch: {}</p>
5454
5455 <div class="stats">
5456 <div class="stat-card">
5457 <div class="stat-value">{}</div>
5458 <div class="stat-label">Files Analyzed</div>
5459 </div>
5460 <div class="stat-card">
5461 <div class="stat-value errors">{}</div>
5462 <div class="stat-label">Errors</div>
5463 </div>
5464 <div class="stat-card">
5465 <div class="stat-value warnings">{}</div>
5466 <div class="stat-label">Warnings</div>
5467 </div>
5468 <div class="stat-card">
5469 <div class="stat-value success">{}</div>
5470 <div class="stat-label">Clean Files</div>
5471 </div>
5472 </div>
5473"#,
5474 title,
5475 title,
5476 report.timestamp,
5477 report.commit.as_deref().unwrap_or("N/A"),
5478 report.branch.as_deref().unwrap_or("N/A"),
5479 report.total_files,
5480 report.total_errors,
5481 report.total_warnings,
5482 report.total_files - report.by_file.len()
5483 ));
5484
5485 if !report.by_rule.is_empty() {
5487 let max_count = *report.by_rule.values().max().unwrap_or(&1);
5488 let mut rules: Vec<_> = report.by_rule.iter().collect();
5489 rules.sort_by(|a, b| b.1.cmp(a.1));
5490
5491 html.push_str(
5492 r#" <div class="section">
5493 <h2>Issues by Rule</h2>
5494 <table>
5495 <thead>
5496 <tr><th>Rule</th><th>Count</th><th>Distribution</th></tr>
5497 </thead>
5498 <tbody>
5499"#,
5500 );
5501
5502 for (rule, count) in rules.iter().take(15) {
5503 let pct = (**count as f64 / max_count as f64) * 100.0;
5504 html.push_str(&format!(
5505 r#" <tr>
5506 <td><code>{}</code></td>
5507 <td>{}</td>
5508 <td><div class="bar"><div class="bar-fill" style="width: {:.1}%"></div></div></td>
5509 </tr>
5510"#,
5511 rule, count, pct
5512 ));
5513 }
5514
5515 html.push_str(" </tbody>\n </table>\n </div>\n\n");
5516 }
5517
5518 if !report.by_file.is_empty() {
5520 html.push_str(
5521 r#" <div class="section">
5522 <h2>Files with Most Issues</h2>
5523 <table>
5524 <thead>
5525 <tr><th>File</th><th>Issues</th></tr>
5526 </thead>
5527 <tbody>
5528"#,
5529 );
5530
5531 for (file, count) in report.by_file.iter().take(10) {
5532 let short_file = if file.len() > 60 {
5533 format!("...{}", &file[file.len() - 57..])
5534 } else {
5535 file.clone()
5536 };
5537 html.push_str(&format!(
5538 " <tr><td><code>{}</code></td><td>{}</td></tr>\n",
5539 short_file, count
5540 ));
5541 }
5542
5543 html.push_str(" </tbody>\n </table>\n </div>\n\n");
5544 }
5545
5546 html.push_str(r#" <div class="section" style="text-align: center; color: var(--text-secondary); margin-top: 3rem;">
5548 <p>Generated by Sigil Linter v0.2.1</p>
5549 </div>
5550 </div>
5551</body>
5552</html>
5553"#);
5554
5555 html
5556}
5557
5558pub fn save_html_report(
5560 result: &DirectoryLintResult,
5561 path: &Path,
5562 title: &str,
5563) -> Result<(), String> {
5564 let html = generate_html_report(result, title);
5565 std::fs::write(path, html).map_err(|e| format!("Failed to write HTML report: {}", e))
5566}
5567
5568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5570pub enum CiFormat {
5571 GitHub,
5573 GitLab,
5575 AzureDevOps,
5577 Generic,
5579}
5580
5581pub fn generate_ci_annotations(result: &DirectoryLintResult, format: CiFormat) -> String {
5583 let mut output = String::new();
5584
5585 for (path, diag_result) in &result.files {
5586 if let Ok(diagnostics) = diag_result {
5587 for diag in diagnostics.iter() {
5588 let line = 1; match format {
5591 CiFormat::GitHub => {
5592 let level = match diag.severity {
5593 Severity::Error => "error",
5594 Severity::Warning => "warning",
5595 _ => "notice",
5596 };
5597 output.push_str(&format!(
5598 "::{} file={},line={}::{}\n",
5599 level,
5600 path,
5601 line,
5602 diag.message.replace('\n', "%0A")
5603 ));
5604 }
5605 CiFormat::GitLab => {
5606 output.push_str(&format!(
5607 "{}:{}:{}: {}\n",
5608 path,
5609 line,
5610 if diag.severity == Severity::Error {
5611 "error"
5612 } else {
5613 "warning"
5614 },
5615 diag.message
5616 ));
5617 }
5618 CiFormat::AzureDevOps => {
5619 let level = match diag.severity {
5620 Severity::Error => "error",
5621 Severity::Warning => "warning",
5622 _ => "debug",
5623 };
5624 output.push_str(&format!(
5625 "##vso[task.logissue type={};sourcepath={};linenumber={}]{}\n",
5626 level, path, line, diag.message
5627 ));
5628 }
5629 CiFormat::Generic => {
5630 output.push_str(&format!(
5631 "{}:{}: {}: {}\n",
5632 path,
5633 line,
5634 if diag.severity == Severity::Error {
5635 "error"
5636 } else {
5637 "warning"
5638 },
5639 diag.message
5640 ));
5641 }
5642 }
5643 }
5644 }
5645 }
5646
5647 output
5648}
5649
5650#[cfg(test)]
5655mod tests {
5656 use super::*;
5657
5658 #[test]
5659 fn test_lint_level_defaults() {
5660 assert_eq!(LintId::ReservedIdentifier.default_level(), LintLevel::Warn);
5661 assert_eq!(
5662 LintId::EvidentialityViolation.default_level(),
5663 LintLevel::Deny
5664 );
5665 assert_eq!(
5666 LintId::PreferUnicodeMorpheme.default_level(),
5667 LintLevel::Allow
5668 );
5669 }
5670
5671 #[test]
5672 fn test_lint_codes() {
5673 assert_eq!(LintId::ReservedIdentifier.code(), "W0101");
5674 assert_eq!(LintId::EvidentialityViolation.code(), "E0600");
5675 }
5676
5677 #[test]
5678 fn test_reserved_words() {
5679 let config = LintConfig::default();
5680 assert!(config.reserved_words.contains("location"));
5681 assert!(config.reserved_words.contains("save"));
5682 assert!(config.reserved_words.contains("from"));
5683 }
5684
5685 #[test]
5690 fn test_aether_lint_codes() {
5691 assert_eq!(LintId::EvidentialityMismatch.code(), "E0603");
5693 assert_eq!(LintId::UncertaintyUnhandled.code(), "E0604");
5694 assert_eq!(LintId::ReportedWithoutAttribution.code(), "E0605");
5695
5696 assert_eq!(LintId::BrokenMorphemePipeline.code(), "W0501");
5698 assert_eq!(LintId::MorphemeTypeIncompatibility.code(), "W0502");
5699 assert_eq!(LintId::InconsistentMorphemeStyle.code(), "W0503");
5700
5701 assert_eq!(LintId::InvalidHexagramNumber.code(), "W0600");
5703 assert_eq!(LintId::InvalidTarotNumber.code(), "W0601");
5704 assert_eq!(LintId::InvalidChakraIndex.code(), "W0602");
5705 assert_eq!(LintId::InvalidZodiacIndex.code(), "W0603");
5706 assert_eq!(LintId::InvalidGematriaValue.code(), "W0604");
5707 assert_eq!(LintId::FrequencyOutOfRange.code(), "W0605");
5708
5709 assert_eq!(LintId::MissingEvidentialityMarker.code(), "W0700");
5711 assert_eq!(LintId::PreferNamedEsotericConstant.code(), "W0701");
5712 assert_eq!(LintId::EmotionIntensityOutOfRange.code(), "W0702");
5713 }
5714
5715 #[test]
5716 fn test_aether_lint_names() {
5717 assert_eq!(
5718 LintId::EvidentialityMismatch.name(),
5719 "evidentiality_mismatch"
5720 );
5721 assert_eq!(
5722 LintId::InvalidHexagramNumber.name(),
5723 "invalid_hexagram_number"
5724 );
5725 assert_eq!(LintId::FrequencyOutOfRange.name(), "frequency_out_of_range");
5726 assert_eq!(
5727 LintId::PreferNamedEsotericConstant.name(),
5728 "prefer_named_esoteric_constant"
5729 );
5730 }
5731
5732 #[test]
5733 fn test_aether_lint_levels() {
5734 assert_eq!(
5736 LintId::EvidentialityMismatch.default_level(),
5737 LintLevel::Deny
5738 );
5739 assert_eq!(
5740 LintId::BrokenMorphemePipeline.default_level(),
5741 LintLevel::Deny
5742 );
5743 assert_eq!(
5744 LintId::MorphemeTypeIncompatibility.default_level(),
5745 LintLevel::Deny
5746 );
5747
5748 assert_eq!(
5750 LintId::InvalidHexagramNumber.default_level(),
5751 LintLevel::Warn
5752 );
5753 assert_eq!(LintId::InvalidTarotNumber.default_level(), LintLevel::Warn);
5754 assert_eq!(LintId::InvalidChakraIndex.default_level(), LintLevel::Warn);
5755 assert_eq!(LintId::InvalidZodiacIndex.default_level(), LintLevel::Warn);
5756 assert_eq!(LintId::FrequencyOutOfRange.default_level(), LintLevel::Warn);
5757
5758 assert_eq!(
5760 LintId::InconsistentMorphemeStyle.default_level(),
5761 LintLevel::Allow
5762 );
5763 assert_eq!(
5764 LintId::MissingEvidentialityMarker.default_level(),
5765 LintLevel::Allow
5766 );
5767 assert_eq!(
5768 LintId::PreferNamedEsotericConstant.default_level(),
5769 LintLevel::Allow
5770 );
5771 }
5772
5773 #[test]
5774 fn test_aether_lint_categories() {
5775 assert_eq!(
5777 LintId::EvidentialityMismatch.category(),
5778 LintCategory::Sigil
5779 );
5780 assert_eq!(LintId::UncertaintyUnhandled.category(), LintCategory::Sigil);
5781 assert_eq!(
5782 LintId::BrokenMorphemePipeline.category(),
5783 LintCategory::Sigil
5784 );
5785 assert_eq!(
5786 LintId::MissingEvidentialityMarker.category(),
5787 LintCategory::Sigil
5788 );
5789
5790 assert_eq!(
5792 LintId::InvalidHexagramNumber.category(),
5793 LintCategory::Correctness
5794 );
5795 assert_eq!(
5796 LintId::InvalidTarotNumber.category(),
5797 LintCategory::Correctness
5798 );
5799 assert_eq!(
5800 LintId::FrequencyOutOfRange.category(),
5801 LintCategory::Correctness
5802 );
5803
5804 assert_eq!(
5806 LintId::InconsistentMorphemeStyle.category(),
5807 LintCategory::Style
5808 );
5809 }
5810
5811 #[test]
5812 fn test_aether_lint_descriptions() {
5813 assert!(!LintId::EvidentialityMismatch.description().is_empty());
5815 assert!(!LintId::InvalidHexagramNumber.description().is_empty());
5816 assert!(!LintId::FrequencyOutOfRange.description().is_empty());
5817
5818 assert!(
5820 LintId::InvalidHexagramNumber.description().contains("1")
5821 && LintId::InvalidHexagramNumber.description().contains("64")
5822 );
5823 assert!(
5824 LintId::InvalidTarotNumber.description().contains("0")
5825 && LintId::InvalidTarotNumber.description().contains("21")
5826 );
5827 assert!(
5828 LintId::FrequencyOutOfRange.description().contains("20Hz")
5829 || LintId::FrequencyOutOfRange.description().contains("20kHz")
5830 );
5831 }
5832
5833 #[test]
5834 fn test_all_includes_aether_rules() {
5835 let all = LintId::all();
5836
5837 assert!(all.contains(&LintId::EvidentialityMismatch));
5839 assert!(all.contains(&LintId::UncertaintyUnhandled));
5840 assert!(all.contains(&LintId::ReportedWithoutAttribution));
5841 assert!(all.contains(&LintId::BrokenMorphemePipeline));
5842 assert!(all.contains(&LintId::InvalidHexagramNumber));
5843 assert!(all.contains(&LintId::InvalidTarotNumber));
5844 assert!(all.contains(&LintId::InvalidChakraIndex));
5845 assert!(all.contains(&LintId::InvalidZodiacIndex));
5846 assert!(all.contains(&LintId::FrequencyOutOfRange));
5847 assert!(all.contains(&LintId::PreferNamedEsotericConstant));
5848 assert!(all.contains(&LintId::EmotionIntensityOutOfRange));
5849 }
5850
5851 #[test]
5852 fn test_lint_count() {
5853 let all = LintId::all();
5855 assert_eq!(all.len(), 44);
5856 }
5857
5858 #[test]
5859 fn test_from_str_aether_rules() {
5860 assert_eq!(
5862 LintId::from_str("E0603"),
5863 Some(LintId::EvidentialityMismatch)
5864 );
5865 assert_eq!(
5866 LintId::from_str("W0600"),
5867 Some(LintId::InvalidHexagramNumber)
5868 );
5869 assert_eq!(LintId::from_str("W0605"), Some(LintId::FrequencyOutOfRange));
5870
5871 assert_eq!(
5873 LintId::from_str("evidentiality_mismatch"),
5874 Some(LintId::EvidentialityMismatch)
5875 );
5876 assert_eq!(
5877 LintId::from_str("invalid_hexagram_number"),
5878 Some(LintId::InvalidHexagramNumber)
5879 );
5880 assert_eq!(
5881 LintId::from_str("frequency_out_of_range"),
5882 Some(LintId::FrequencyOutOfRange)
5883 );
5884 }
5885}