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",
94 "loop", "if", "else", "while", "for", "in", "return", "break",
95 "continue", "fn", "let", "mut", "const", "static", "struct", "enum",
96 "trait", "impl", "pub", "mod", "use", "as", "where", "async", "await",
97 "dyn", "unsafe", "extern", "crate", "self", "super", "true", "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 = toml::from_str(content)
123 .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"#.to_string()
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum LintLevel {
197 Allow,
198 Warn,
199 Deny,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
204pub enum LintCategory {
205 Correctness,
207 Style,
209 Performance,
211 Complexity,
213 Sigil,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
223pub enum LintId {
224 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, }
279
280impl LintId {
281 pub fn code(&self) -> &'static str {
282 match self {
283 LintId::ReservedIdentifier => "W0101",
284 LintId::NestedGenerics => "W0104",
285 LintId::PreferUnicodeMorpheme => "W0200",
286 LintId::NamingConvention => "W0201",
287 LintId::UnusedVariable => "W0202",
288 LintId::UnusedImport => "W0203",
289 LintId::Shadowing => "W0204",
290 LintId::DeepNesting => "W0205",
291 LintId::EmptyBlock => "W0206",
292 LintId::BoolComparison => "W0207",
293 LintId::RedundantElse => "W0208",
294 LintId::UnusedParameter => "W0209",
295 LintId::MagicNumber => "W0210",
296 LintId::MissingDocComment => "W0211",
297 LintId::HighComplexity => "W0212",
298 LintId::ConstantCondition => "W0213",
299 LintId::PreferIfLet => "W0214",
300 LintId::TodoWithoutIssue => "W0215",
301 LintId::LongFunction => "W0216",
302 LintId::TooManyParameters => "W0217",
303 LintId::NeedlessReturn => "W0218",
304 LintId::MissingReturn => "W0300",
305 LintId::PreferMorphemePipeline => "W0500",
306 LintId::EvidentialityViolation => "E0600",
307 LintId::UnvalidatedExternalData => "E0601",
308 LintId::CertaintyDowngrade => "E0602",
309 LintId::UnreachableCode => "E0700",
310 LintId::InfiniteLoop => "E0701",
311 LintId::DivisionByZero => "E0702",
312
313 LintId::EvidentialityMismatch => "E0603",
315 LintId::UncertaintyUnhandled => "E0604",
316 LintId::ReportedWithoutAttribution => "E0605",
317 LintId::BrokenMorphemePipeline => "W0501",
318 LintId::MorphemeTypeIncompatibility => "W0502",
319 LintId::InconsistentMorphemeStyle => "W0503",
320 LintId::InvalidHexagramNumber => "W0600",
321 LintId::InvalidTarotNumber => "W0601",
322 LintId::InvalidChakraIndex => "W0602",
323 LintId::InvalidZodiacIndex => "W0603",
324 LintId::InvalidGematriaValue => "W0604",
325 LintId::FrequencyOutOfRange => "W0605",
326 LintId::MissingEvidentialityMarker => "W0700",
327 LintId::PreferNamedEsotericConstant => "W0701",
328 LintId::EmotionIntensityOutOfRange => "W0702",
329 }
330 }
331
332 pub fn name(&self) -> &'static str {
333 match self {
334 LintId::ReservedIdentifier => "reserved_identifier",
335 LintId::NestedGenerics => "nested_generics_unsupported",
336 LintId::PreferUnicodeMorpheme => "prefer_unicode_morpheme",
337 LintId::NamingConvention => "naming_convention",
338 LintId::UnusedVariable => "unused_variable",
339 LintId::UnusedImport => "unused_import",
340 LintId::Shadowing => "shadowing",
341 LintId::DeepNesting => "deep_nesting",
342 LintId::EmptyBlock => "empty_block",
343 LintId::BoolComparison => "bool_comparison",
344 LintId::RedundantElse => "redundant_else",
345 LintId::UnusedParameter => "unused_parameter",
346 LintId::MagicNumber => "magic_number",
347 LintId::MissingDocComment => "missing_doc_comment",
348 LintId::HighComplexity => "high_complexity",
349 LintId::ConstantCondition => "constant_condition",
350 LintId::PreferIfLet => "prefer_if_let",
351 LintId::TodoWithoutIssue => "todo_without_issue",
352 LintId::LongFunction => "long_function",
353 LintId::TooManyParameters => "too_many_parameters",
354 LintId::NeedlessReturn => "needless_return",
355 LintId::MissingReturn => "missing_return",
356 LintId::PreferMorphemePipeline => "prefer_morpheme_pipeline",
357 LintId::EvidentialityViolation => "evidentiality_violation",
358 LintId::UnvalidatedExternalData => "unvalidated_external_data",
359 LintId::CertaintyDowngrade => "certainty_downgrade",
360 LintId::UnreachableCode => "unreachable_code",
361 LintId::InfiniteLoop => "infinite_loop",
362 LintId::DivisionByZero => "division_by_zero",
363
364 LintId::EvidentialityMismatch => "evidentiality_mismatch",
366 LintId::UncertaintyUnhandled => "uncertainty_unhandled",
367 LintId::ReportedWithoutAttribution => "reported_without_attribution",
368 LintId::BrokenMorphemePipeline => "broken_morpheme_pipeline",
369 LintId::MorphemeTypeIncompatibility => "morpheme_type_incompatibility",
370 LintId::InconsistentMorphemeStyle => "inconsistent_morpheme_style",
371 LintId::InvalidHexagramNumber => "invalid_hexagram_number",
372 LintId::InvalidTarotNumber => "invalid_tarot_number",
373 LintId::InvalidChakraIndex => "invalid_chakra_index",
374 LintId::InvalidZodiacIndex => "invalid_zodiac_index",
375 LintId::InvalidGematriaValue => "invalid_gematria_value",
376 LintId::FrequencyOutOfRange => "frequency_out_of_range",
377 LintId::MissingEvidentialityMarker => "missing_evidentiality_marker",
378 LintId::PreferNamedEsotericConstant => "prefer_named_esoteric_constant",
379 LintId::EmotionIntensityOutOfRange => "emotion_intensity_out_of_range",
380 }
381 }
382
383 pub fn default_level(&self) -> LintLevel {
384 match self {
385 LintId::ReservedIdentifier => LintLevel::Warn,
386 LintId::NestedGenerics => LintLevel::Warn,
387 LintId::PreferUnicodeMorpheme => LintLevel::Allow,
388 LintId::NamingConvention => LintLevel::Warn,
389 LintId::UnusedVariable => LintLevel::Warn,
390 LintId::UnusedImport => LintLevel::Warn,
391 LintId::Shadowing => LintLevel::Warn,
392 LintId::DeepNesting => LintLevel::Warn,
393 LintId::EmptyBlock => LintLevel::Warn,
394 LintId::BoolComparison => LintLevel::Warn,
395 LintId::RedundantElse => LintLevel::Warn,
396 LintId::UnusedParameter => LintLevel::Warn,
397 LintId::MagicNumber => LintLevel::Allow, LintId::MissingDocComment => LintLevel::Allow, LintId::HighComplexity => LintLevel::Warn,
400 LintId::ConstantCondition => LintLevel::Warn,
401 LintId::PreferIfLet => LintLevel::Allow, LintId::TodoWithoutIssue => LintLevel::Allow, LintId::LongFunction => LintLevel::Warn,
404 LintId::TooManyParameters => LintLevel::Warn,
405 LintId::NeedlessReturn => LintLevel::Allow, LintId::MissingReturn => LintLevel::Warn,
407 LintId::PreferMorphemePipeline => LintLevel::Allow, LintId::EvidentialityViolation => LintLevel::Deny,
409 LintId::UnvalidatedExternalData => LintLevel::Deny,
410 LintId::CertaintyDowngrade => LintLevel::Warn,
411 LintId::UnreachableCode => LintLevel::Warn,
412 LintId::InfiniteLoop => LintLevel::Warn,
413 LintId::DivisionByZero => LintLevel::Deny,
414
415 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, }
432 }
433
434 pub fn description(&self) -> &'static str {
435 match self {
436 LintId::ReservedIdentifier => "This identifier is a reserved word in Sigil",
437 LintId::NestedGenerics => "Nested generic parameters may not parse correctly",
438 LintId::PreferUnicodeMorpheme => "Consider using Unicode morphemes for idiomatic Sigil",
439 LintId::NamingConvention => "Identifier does not follow Sigil naming conventions",
440 LintId::UnusedVariable => "Variable is declared but never used",
441 LintId::UnusedImport => "Import is never used",
442 LintId::Shadowing => "Variable shadows another variable from an outer scope",
443 LintId::DeepNesting => "Code has excessive nesting depth, consider refactoring",
444 LintId::EmptyBlock => "Empty block does nothing, consider adding code or removing",
445 LintId::BoolComparison => "Comparison to boolean literal is redundant",
446 LintId::RedundantElse => "Else branch after return/break/continue is redundant",
447 LintId::UnusedParameter => "Function parameter is never used",
448 LintId::MagicNumber => "Consider using a named constant instead of magic number",
449 LintId::MissingDocComment => "Public item should have a documentation comment",
450 LintId::HighComplexity => "Function has high cyclomatic complexity, consider refactoring",
451 LintId::ConstantCondition => "Condition is always true or always false",
452 LintId::PreferIfLet => "Consider using if-let instead of match with single arm",
453 LintId::TodoWithoutIssue => "TODO comment without issue reference",
454 LintId::LongFunction => "Function exceeds maximum line count",
455 LintId::TooManyParameters => "Function has too many parameters",
456 LintId::NeedlessReturn => "Unnecessary return statement at end of function",
457 LintId::MissingReturn => "Function may not return a value on all code paths",
458 LintId::PreferMorphemePipeline => "Consider using morpheme pipeline (|τ{}, |φ{}) instead of method chain",
459 LintId::EvidentialityViolation => "Evidence level mismatch in assignment or call",
460 LintId::UnvalidatedExternalData => "External data (~) used without validation",
461 LintId::CertaintyDowngrade => "Certain (!) data being downgraded to uncertain (?)",
462 LintId::UnreachableCode => "Code will never be executed",
463 LintId::InfiniteLoop => "Loop has no exit condition",
464 LintId::DivisionByZero => "Division by zero detected",
465
466 LintId::EvidentialityMismatch => "Assigning between incompatible evidentiality levels (!, ?, ~)",
468 LintId::UncertaintyUnhandled => "Uncertain (?) value used without error handling or unwrap",
469 LintId::ReportedWithoutAttribution => "Reported (~) data lacks source attribution",
470 LintId::BrokenMorphemePipeline => "Morpheme pipeline has invalid or missing operators",
471 LintId::MorphemeTypeIncompatibility => "Type mismatch between morpheme pipeline stages",
472 LintId::InconsistentMorphemeStyle => "Mixing morpheme pipeline (|τ{}) with method chain (.map())",
473 LintId::InvalidHexagramNumber => "I Ching hexagram number must be between 1 and 64",
474 LintId::InvalidTarotNumber => "Major Arcana number must be between 0 and 21",
475 LintId::InvalidChakraIndex => "Chakra index must be between 0 and 6",
476 LintId::InvalidZodiacIndex => "Zodiac sign index must be between 0 and 11",
477 LintId::InvalidGematriaValue => "Gematria value is negative or exceeds maximum",
478 LintId::FrequencyOutOfRange => "Audio frequency outside audible range (20Hz-20kHz)",
479 LintId::MissingEvidentialityMarker => "Type declaration lacks evidentiality marker (!, ?, ~)",
480 LintId::PreferNamedEsotericConstant => "Use named constant for esoteric value (e.g., GOLDEN_RATIO)",
481 LintId::EmotionIntensityOutOfRange => "Emotion intensity must be between 0.0 and 1.0",
482 }
483 }
484
485 pub fn category(&self) -> LintCategory {
487 match self {
488 LintId::DivisionByZero => LintCategory::Correctness,
490 LintId::InfiniteLoop => LintCategory::Correctness,
491 LintId::UnreachableCode => LintCategory::Correctness,
492 LintId::ConstantCondition => LintCategory::Correctness,
493
494 LintId::NamingConvention => LintCategory::Style,
496 LintId::BoolComparison => LintCategory::Style,
497 LintId::RedundantElse => LintCategory::Style,
498 LintId::EmptyBlock => LintCategory::Style,
499 LintId::PreferIfLet => LintCategory::Style,
500 LintId::MissingDocComment => LintCategory::Style,
501 LintId::NeedlessReturn => LintCategory::Style,
502
503 LintId::MissingReturn => LintCategory::Correctness,
505
506 LintId::PreferMorphemePipeline => LintCategory::Sigil,
508
509 LintId::DeepNesting => LintCategory::Complexity,
511 LintId::HighComplexity => LintCategory::Complexity,
512 LintId::MagicNumber => LintCategory::Complexity,
513 LintId::LongFunction => LintCategory::Complexity,
514 LintId::TooManyParameters => LintCategory::Complexity,
515 LintId::TodoWithoutIssue => LintCategory::Complexity,
516
517 LintId::UnusedVariable => LintCategory::Performance,
519 LintId::UnusedImport => LintCategory::Performance,
520 LintId::UnusedParameter => LintCategory::Performance,
521 LintId::Shadowing => LintCategory::Performance,
522
523 LintId::ReservedIdentifier => LintCategory::Sigil,
525 LintId::NestedGenerics => LintCategory::Sigil,
526 LintId::PreferUnicodeMorpheme => LintCategory::Sigil,
527 LintId::EvidentialityViolation => LintCategory::Sigil,
528 LintId::UnvalidatedExternalData => LintCategory::Sigil,
529 LintId::CertaintyDowngrade => LintCategory::Sigil,
530
531 LintId::EvidentialityMismatch => LintCategory::Sigil,
533 LintId::UncertaintyUnhandled => LintCategory::Sigil,
534 LintId::ReportedWithoutAttribution => LintCategory::Sigil,
535
536 LintId::BrokenMorphemePipeline => LintCategory::Sigil,
538 LintId::MorphemeTypeIncompatibility => LintCategory::Sigil,
539 LintId::InconsistentMorphemeStyle => LintCategory::Style,
540
541 LintId::InvalidHexagramNumber => LintCategory::Correctness,
543 LintId::InvalidTarotNumber => LintCategory::Correctness,
544 LintId::InvalidChakraIndex => LintCategory::Correctness,
545 LintId::InvalidZodiacIndex => LintCategory::Correctness,
546 LintId::InvalidGematriaValue => LintCategory::Correctness,
547 LintId::FrequencyOutOfRange => LintCategory::Correctness,
548 LintId::EmotionIntensityOutOfRange => LintCategory::Correctness,
549
550 LintId::MissingEvidentialityMarker => LintCategory::Sigil,
552 LintId::PreferNamedEsotericConstant => LintCategory::Complexity,
553 }
554 }
555
556 pub fn extended_docs(&self) -> &'static str {
558 match self {
559 LintId::ReservedIdentifier => r#"
560This lint detects use of identifiers that are reserved words in Sigil.
561Reserved words have special meaning in the language and cannot be used
562as variable, function, or type names.
563
564Example:
565 let location = "here"; // Error: 'location' is reserved
566
567Fix:
568 let place = "here"; // Use an alternative name
569
570Common alternatives:
571 - location -> place
572 - save -> slot, store
573 - from -> source, origin
574"#,
575 LintId::NestedGenerics => r#"
576This lint warns about nested generic parameters which may not parse
577correctly in the current version of Sigil.
578
579Example:
580 fn process(data: Vec<Option<i32>>) { } // May not parse
581
582Fix:
583 type OptInt = Option<i32>;
584 fn process(data: Vec<OptInt>) { } // Use type alias
585"#,
586 LintId::UnusedVariable => r#"
587This lint detects variables that are declared but never used.
588Unused variables may indicate incomplete code or typos.
589
590Example:
591 let x = 42;
592 println(y); // 'x' is never used, 'y' may be a typo
593
594Fix:
595 let x = 42;
596 println(x); // Use the variable
597
598 // Or prefix with underscore to indicate intentionally unused:
599 let _x = 42;
600"#,
601 LintId::Shadowing => r#"
602This lint warns when a variable shadows another variable from an
603outer scope. While sometimes intentional, shadowing can make code
604harder to understand.
605
606Example:
607 let x = 1;
608 {
609 let x = 2; // Shadows outer 'x'
610 }
611
612Fix:
613 let x = 1;
614 {
615 let x_inner = 2; // Use distinct name
616 }
617
618 // Or prefix with underscore if intentional:
619 let _x = 2;
620"#,
621 LintId::DeepNesting => r#"
622This lint warns about excessively nested code structures.
623Deep nesting makes code hard to read and maintain.
624
625Example:
626 if a {
627 if b {
628 if c {
629 if d { // Too deep!
630 }
631 }
632 }
633 }
634
635Fix:
636 // Use early returns
637 if !a { return; }
638 if !b { return; }
639 if !c { return; }
640 if d { ... }
641
642 // Or extract into functions
643 fn check_conditions() { ... }
644"#,
645 LintId::HighComplexity => r#"
646This lint warns about functions with high cyclomatic complexity.
647High complexity makes code harder to test and maintain.
648
649Complexity is calculated by counting:
650 - Each if/while/for/loop adds 1
651 - Each match arm (except first) adds 1
652 - Each && or || operator adds 1
653 - Each guard condition adds 1
654
655Fix:
656 // Extract complex logic into smaller functions
657 // Use early returns to reduce nesting
658 // Consider using match instead of if-else chains
659"#,
660 LintId::DivisionByZero => r#"
661This lint detects division by a literal zero, which will cause
662a runtime panic.
663
664Example:
665 let result = x / 0; // Will panic!
666
667Fix:
668 if divisor != 0 {
669 let result = x / divisor;
670 }
671"#,
672 LintId::ConstantCondition => r#"
673This lint detects conditions that are always true or always false,
674indicating likely bugs or unnecessary code.
675
676Example:
677 if true { ... } // Always executes
678 while false { ... } // Never executes
679
680Fix:
681 // Remove unnecessary conditions
682 // Or use the correct variable in the condition
683"#,
684 LintId::TodoWithoutIssue => r#"
685This lint warns about TODO comments that don't reference an issue tracker.
686
687Example:
688 // TODO: fix this later
689
690Fix:
691 // TODO(#123): fix this later
692 // TODO(GH-456): address edge case
693
694Configure via .sigillint.toml:
695 [lint.levels]
696 todo_without_issue = "warn"
697"#,
698 LintId::LongFunction => r#"
699This lint warns about functions that exceed a maximum line count.
700Long functions are harder to understand, test, and maintain.
701
702Default threshold: 50 lines
703
704Fix:
705 // Break into smaller, focused functions
706 // Extract helper functions for distinct operations
707 // Use early returns to reduce nesting
708"#,
709 LintId::TooManyParameters => r#"
710This lint warns about functions with too many parameters.
711Many parameters indicate a function may be doing too much.
712
713Default threshold: 7 parameters
714
715Fix:
716 // Group related parameters into a struct
717 // Use builder pattern for complex construction
718 // Consider if function should be split
719"#,
720 LintId::NeedlessReturn => r#"
721This lint suggests removing unnecessary return statements.
722In Sigil, the last expression is the return value.
723
724Example:
725 fn add(a: i32, b: i32) -> i32 {
726 return a + b; // Unnecessary return
727 }
728
729Fix:
730 fn add(a: i32, b: i32) -> i32 {
731 a + b // Implicit return
732 }
733"#,
734 LintId::MissingReturn => r#"
735This lint warns when a function with a return type may not return
736a value on all execution paths.
737
738Example:
739 fn maybe_return(x: i32) -> i32 {
740 if x > 0 {
741 return x;
742 }
743 // Missing return for x <= 0!
744 }
745
746Fix:
747 fn maybe_return(x: i32) -> i32 {
748 if x > 0 {
749 x
750 } else {
751 0 // Default value
752 }
753 }
754
755The linter checks:
756 - If all branches return a value
757 - If match arms all produce values
758 - If loops with breaks produce consistent values
759"#,
760 LintId::PreferMorphemePipeline => r#"
761This lint suggests using Sigil's morpheme pipeline syntax instead
762of method chains. Morpheme pipelines are more idiomatic in Sigil
763and provide clearer data flow semantics.
764
765Example (method chain):
766 let result = data.iter().map(|x| x * 2).filter(|x| *x > 10).collect();
767
768Preferred (morpheme pipeline):
769 let result = data
770 |τ{_ * 2} // τ (tau) = transform/map
771 |φ{_ > 10} // φ (phi) = filter
772 |σ; // σ (sigma) = collect/sort
773
774Common morpheme operators:
775 - τ (tau) : Transform/map
776 - φ (phi) : Filter
777 - σ (sigma) : Sort/collect/sum
778 - ρ (rho) : Reduce/fold
779 - α (alpha) : First element
780 - ω (omega) : Last element
781 - ζ (zeta) : Zip/combine
782
783This lint is off by default. Enable with:
784 [lint.levels]
785 prefer_morpheme_pipeline = "warn"
786"#,
787 _ => self.description(),
788 }
789 }
790
791 pub fn all() -> &'static [LintId] {
793 &[
794 LintId::ReservedIdentifier,
795 LintId::NestedGenerics,
796 LintId::PreferUnicodeMorpheme,
797 LintId::NamingConvention,
798 LintId::UnusedVariable,
799 LintId::UnusedImport,
800 LintId::Shadowing,
801 LintId::DeepNesting,
802 LintId::EmptyBlock,
803 LintId::BoolComparison,
804 LintId::RedundantElse,
805 LintId::UnusedParameter,
806 LintId::MagicNumber,
807 LintId::MissingDocComment,
808 LintId::HighComplexity,
809 LintId::ConstantCondition,
810 LintId::PreferIfLet,
811 LintId::TodoWithoutIssue,
812 LintId::LongFunction,
813 LintId::TooManyParameters,
814 LintId::NeedlessReturn,
815 LintId::MissingReturn,
816 LintId::PreferMorphemePipeline,
817 LintId::EvidentialityViolation,
818 LintId::UnvalidatedExternalData,
819 LintId::CertaintyDowngrade,
820 LintId::UnreachableCode,
821 LintId::InfiniteLoop,
822 LintId::DivisionByZero,
823
824 LintId::EvidentialityMismatch,
826 LintId::UncertaintyUnhandled,
827 LintId::ReportedWithoutAttribution,
828 LintId::BrokenMorphemePipeline,
829 LintId::MorphemeTypeIncompatibility,
830 LintId::InconsistentMorphemeStyle,
831 LintId::InvalidHexagramNumber,
832 LintId::InvalidTarotNumber,
833 LintId::InvalidChakraIndex,
834 LintId::InvalidZodiacIndex,
835 LintId::InvalidGematriaValue,
836 LintId::FrequencyOutOfRange,
837 LintId::MissingEvidentialityMarker,
838 LintId::PreferNamedEsotericConstant,
839 LintId::EmotionIntensityOutOfRange,
840 ]
841 }
842
843 pub fn from_str(s: &str) -> Option<LintId> {
845 for lint in Self::all() {
846 if lint.code() == s || lint.name() == s {
847 return Some(*lint);
848 }
849 }
850 None
851 }
852}
853
854#[derive(Debug, Clone)]
860pub struct Suppression {
861 pub line: usize,
863 pub lints: Vec<LintId>,
865 pub next_line: bool,
867}
868
869pub fn parse_suppressions(source: &str) -> Vec<Suppression> {
875 let mut suppressions = Vec::new();
876
877 for (line_num, line) in source.lines().enumerate() {
878 let line_1indexed = line_num + 1;
879
880 if let Some(comment_start) = line.find("// sigil-lint:") {
882 let comment = &line[comment_start + 14..].trim();
883
884 if let Some(rest) = comment.strip_prefix("allow-next-line") {
885 if let Some(lints) = parse_lint_list(rest) {
887 suppressions.push(Suppression {
888 line: line_1indexed + 1,
889 lints,
890 next_line: true,
891 });
892 }
893 } else if let Some(rest) = comment.strip_prefix("allow") {
894 if let Some(lints) = parse_lint_list(rest) {
896 suppressions.push(Suppression {
897 line: line_1indexed,
898 lints,
899 next_line: false,
900 });
901 }
902 }
903 }
904 }
905
906 suppressions
907}
908
909fn parse_lint_list(s: &str) -> Option<Vec<LintId>> {
911 let s = s.trim();
912 if !s.starts_with('(') || !s.contains(')') {
913 return Some(Vec::new()); }
915
916 let start = s.find('(')? + 1;
917 let end = s.find(')')?;
918 let list = &s[start..end];
919
920 let mut lints = Vec::new();
921 for item in list.split(',') {
922 let item = item.trim();
923 if !item.is_empty() {
924 if let Some(lint) = LintId::from_str(item) {
925 lints.push(lint);
926 }
927 }
928 }
929
930 Some(lints)
931}
932
933#[derive(Debug, Clone, Default)]
939pub struct LintStats {
940 pub lint_counts: HashMap<LintId, usize>,
942 pub category_counts: HashMap<LintCategory, usize>,
944 pub total_diagnostics: usize,
946 pub suppressed: usize,
948 pub duration_us: u64,
950}
951
952impl LintStats {
953 pub fn record(&mut self, lint: LintId) {
955 *self.lint_counts.entry(lint).or_insert(0) += 1;
956 *self.category_counts.entry(lint.category()).or_insert(0) += 1;
957 self.total_diagnostics += 1;
958 }
959
960 pub fn record_suppressed(&mut self) {
962 self.suppressed += 1;
963 }
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
972pub struct BaselineEntry {
973 pub file: String,
975 pub code: String,
977 pub line: usize,
979 pub message_hash: u64,
981 #[serde(skip_serializing_if = "Option::is_none")]
983 pub message: Option<String>,
984}
985
986impl BaselineEntry {
987 pub fn from_diagnostic(file: &str, diag: &Diagnostic, source: &str) -> Self {
989 let line = Self::offset_to_line(diag.span.start, source);
990 let message_hash = Self::hash_message(&diag.message);
991
992 Self {
993 file: file.to_string(),
994 code: diag.code.clone().unwrap_or_default(),
995 line,
996 message_hash,
997 message: Some(diag.message.clone()),
998 }
999 }
1000
1001 fn offset_to_line(offset: usize, source: &str) -> usize {
1003 source[..offset.min(source.len())]
1004 .chars()
1005 .filter(|&c| c == '\n')
1006 .count() + 1
1007 }
1008
1009 fn hash_message(message: &str) -> u64 {
1011 use std::hash::{Hash, Hasher};
1012 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1013 message.hash(&mut hasher);
1014 hasher.finish()
1015 }
1016
1017 pub fn matches(&self, file: &str, diag: &Diagnostic, source: &str) -> bool {
1019 if self.file != file {
1021 return false;
1022 }
1023 if let Some(ref code) = diag.code {
1024 if &self.code != code {
1025 return false;
1026 }
1027 }
1028
1029 let msg_hash = Self::hash_message(&diag.message);
1031 if self.message_hash == msg_hash {
1032 return true;
1033 }
1034
1035 let diag_line = Self::offset_to_line(diag.span.start, source);
1037 if self.line > 0 && diag_line > 0 {
1038 let line_diff = (self.line as i64 - diag_line as i64).abs();
1040 if line_diff <= 3 && self.code == diag.code.as_deref().unwrap_or("") {
1041 return true;
1042 }
1043 }
1044
1045 false
1046 }
1047}
1048
1049#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1051pub struct Baseline {
1052 pub version: u32,
1054 #[serde(skip_serializing_if = "Option::is_none")]
1056 pub created: Option<String>,
1057 pub count: usize,
1059 pub entries: HashMap<String, Vec<BaselineEntry>>,
1061}
1062
1063impl Baseline {
1064 pub fn new() -> Self {
1066 Self {
1067 version: 1,
1068 created: Some(chrono_lite_now()),
1069 count: 0,
1070 entries: HashMap::new(),
1071 }
1072 }
1073
1074 pub fn from_file(path: &Path) -> Result<Self, String> {
1076 let content = std::fs::read_to_string(path)
1077 .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1078 Self::from_json(&content)
1079 }
1080
1081 pub fn from_json(content: &str) -> Result<Self, String> {
1083 serde_json::from_str(content)
1084 .map_err(|e| format!("Failed to parse baseline: {}", e))
1085 }
1086
1087 pub fn to_file(&self, path: &Path) -> Result<(), String> {
1089 let content = self.to_json()?;
1090 std::fs::write(path, content)
1091 .map_err(|e| format!("Failed to write baseline file: {}", e))
1092 }
1093
1094 pub fn to_json(&self) -> Result<String, String> {
1096 serde_json::to_string_pretty(self)
1097 .map_err(|e| format!("Failed to serialize baseline: {}", e))
1098 }
1099
1100 pub fn add(&mut self, file: &str, diag: &Diagnostic, source: &str) {
1102 let entry = BaselineEntry::from_diagnostic(file, diag, source);
1103 self.entries
1104 .entry(file.to_string())
1105 .or_default()
1106 .push(entry);
1107 self.count += 1;
1108 }
1109
1110 pub fn contains(&self, file: &str, diag: &Diagnostic, source: &str) -> bool {
1112 if let Some(entries) = self.entries.get(file) {
1113 entries.iter().any(|e| e.matches(file, diag, source))
1114 } else {
1115 false
1116 }
1117 }
1118
1119 pub fn filter(&self, file: &str, diagnostics: &Diagnostics, source: &str) -> (Diagnostics, usize) {
1122 let mut filtered = Diagnostics::new();
1123 let mut baseline_matches = 0;
1124
1125 for diag in diagnostics.iter() {
1126 if self.contains(file, diag, source) {
1127 baseline_matches += 1;
1128 } else {
1129 filtered.add(diag.clone());
1130 }
1131 }
1132
1133 (filtered, baseline_matches)
1134 }
1135
1136 pub fn from_directory_result(result: &DirectoryLintResult, sources: &HashMap<String, String>) -> Self {
1138 let mut baseline = Self::new();
1139
1140 for (path, diag_result) in &result.files {
1141 if let Ok(diagnostics) = diag_result {
1142 if let Some(source) = sources.get(path) {
1143 for diag in diagnostics.iter() {
1144 baseline.add(path, diag, source);
1145 }
1146 }
1147 }
1148 }
1149
1150 baseline
1151 }
1152
1153 pub fn update(&mut self, file: &str, diagnostics: &Diagnostics, source: &str) {
1155 let mut new_entries = Vec::new();
1156
1157 if let Some(old_entries) = self.entries.get(file) {
1159 for old in old_entries {
1160 let still_exists = diagnostics.iter().any(|d| old.matches(file, d, source));
1162 if still_exists {
1163 new_entries.push(old.clone());
1164 }
1165 }
1166 }
1167
1168 for diag in diagnostics.iter() {
1170 let already_exists = new_entries.iter().any(|e| e.matches(file, diag, source));
1171 if !already_exists {
1172 new_entries.push(BaselineEntry::from_diagnostic(file, diag, source));
1173 }
1174 }
1175
1176 let old_count = self.entries.get(file).map(|v| v.len()).unwrap_or(0);
1178 self.count = self.count - old_count + new_entries.len();
1179
1180 if new_entries.is_empty() {
1181 self.entries.remove(file);
1182 } else {
1183 self.entries.insert(file.to_string(), new_entries);
1184 }
1185
1186 self.created = Some(chrono_lite_now());
1187 }
1188
1189 pub fn summary(&self) -> BaselineSummary {
1191 let mut by_code: HashMap<String, usize> = HashMap::new();
1192
1193 for entries in self.entries.values() {
1194 for entry in entries {
1195 *by_code.entry(entry.code.clone()).or_insert(0) += 1;
1196 }
1197 }
1198
1199 BaselineSummary {
1200 total_files: self.entries.len(),
1201 total_issues: self.count,
1202 by_code,
1203 }
1204 }
1205}
1206
1207#[derive(Debug, Clone)]
1209pub struct BaselineSummary {
1210 pub total_files: usize,
1212 pub total_issues: usize,
1214 pub by_code: HashMap<String, usize>,
1216}
1217
1218fn chrono_lite_now() -> String {
1220 use std::time::{SystemTime, UNIX_EPOCH};
1221 let duration = SystemTime::now()
1222 .duration_since(UNIX_EPOCH)
1223 .unwrap_or_default();
1224 let secs = duration.as_secs();
1225
1226 let days = secs / 86400;
1228 let years = 1970 + days / 365;
1229 let remaining_days = days % 365;
1230 let months = remaining_days / 30 + 1;
1231 let day = remaining_days % 30 + 1;
1232 let hours = (secs % 86400) / 3600;
1233 let minutes = (secs % 3600) / 60;
1234 let seconds = secs % 60;
1235
1236 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1237 years, months.min(12), day.min(31), hours, minutes, seconds)
1238}
1239
1240pub fn find_baseline() -> Option<Baseline> {
1247 let baseline_names = [
1248 ".sigillint-baseline.json",
1249 "sigillint-baseline.json",
1250 ".lint-baseline.json",
1251 ];
1252
1253 if let Ok(mut dir) = std::env::current_dir() {
1254 loop {
1255 for name in &baseline_names {
1256 let path = dir.join(name);
1257 if path.exists() {
1258 if let Ok(baseline) = Baseline::from_file(&path) {
1259 return Some(baseline);
1260 }
1261 }
1262 }
1263 if !dir.pop() {
1264 break;
1265 }
1266 }
1267 }
1268
1269 None
1270}
1271
1272#[derive(Debug)]
1274pub struct BaselineLintResult {
1275 pub new_issues: Diagnostics,
1277 pub baseline_matches: usize,
1279 pub total_before: usize,
1281}
1282
1283pub fn lint_with_baseline(
1285 source: &str,
1286 filename: &str,
1287 config: LintConfig,
1288 baseline: &Baseline,
1289) -> Result<BaselineLintResult, String> {
1290 let diagnostics = lint_source_with_config(source, filename, config)?;
1291 let total_before = diagnostics.iter().count();
1292 let (new_issues, baseline_matches) = baseline.filter(filename, &diagnostics, source);
1293
1294 Ok(BaselineLintResult {
1295 new_issues,
1296 baseline_matches,
1297 total_before,
1298 })
1299}
1300
1301#[derive(Debug, Clone, Default)]
1321pub struct CliOverrides {
1322 pub deny: Vec<String>,
1324 pub warn: Vec<String>,
1326 pub allow: Vec<String>,
1328 pub deny_category: Vec<LintCategory>,
1330 pub warn_category: Vec<LintCategory>,
1332 pub allow_category: Vec<LintCategory>,
1334}
1335
1336impl CliOverrides {
1337 pub fn new() -> Self {
1339 Self::default()
1340 }
1341
1342 pub fn deny(mut self, lint: impl Into<String>) -> Self {
1344 self.deny.push(lint.into());
1345 self
1346 }
1347
1348 pub fn warn(mut self, lint: impl Into<String>) -> Self {
1350 self.warn.push(lint.into());
1351 self
1352 }
1353
1354 pub fn allow(mut self, lint: impl Into<String>) -> Self {
1356 self.allow.push(lint.into());
1357 self
1358 }
1359
1360 pub fn deny_cat(mut self, category: LintCategory) -> Self {
1362 self.deny_category.push(category);
1363 self
1364 }
1365
1366 pub fn warn_cat(mut self, category: LintCategory) -> Self {
1368 self.warn_category.push(category);
1369 self
1370 }
1371
1372 pub fn allow_cat(mut self, category: LintCategory) -> Self {
1374 self.allow_category.push(category);
1375 self
1376 }
1377
1378 pub fn apply(&self, config: &mut LintConfig) {
1384 for cat in &self.allow_category {
1386 for lint in LintId::all() {
1387 if lint.category() == *cat {
1388 config.levels.insert(lint.name().to_string(), LintLevel::Allow);
1389 }
1390 }
1391 }
1392 for cat in &self.warn_category {
1393 for lint in LintId::all() {
1394 if lint.category() == *cat {
1395 config.levels.insert(lint.name().to_string(), LintLevel::Warn);
1396 }
1397 }
1398 }
1399 for cat in &self.deny_category {
1400 for lint in LintId::all() {
1401 if lint.category() == *cat {
1402 config.levels.insert(lint.name().to_string(), LintLevel::Deny);
1403 }
1404 }
1405 }
1406
1407 for lint_str in &self.allow {
1409 if let Some(lint) = LintId::from_str(lint_str) {
1410 config.levels.insert(lint.name().to_string(), LintLevel::Allow);
1411 } else {
1412 config.levels.insert(lint_str.clone(), LintLevel::Allow);
1414 }
1415 }
1416 for lint_str in &self.warn {
1417 if let Some(lint) = LintId::from_str(lint_str) {
1418 config.levels.insert(lint.name().to_string(), LintLevel::Warn);
1419 } else {
1420 config.levels.insert(lint_str.clone(), LintLevel::Warn);
1421 }
1422 }
1423 for lint_str in &self.deny {
1424 if let Some(lint) = LintId::from_str(lint_str) {
1425 config.levels.insert(lint.name().to_string(), LintLevel::Deny);
1426 } else {
1427 config.levels.insert(lint_str.clone(), LintLevel::Deny);
1428 }
1429 }
1430 }
1431
1432 pub fn parse_category(s: &str) -> Option<LintCategory> {
1434 match s.to_lowercase().as_str() {
1435 "correctness" => Some(LintCategory::Correctness),
1436 "style" => Some(LintCategory::Style),
1437 "performance" => Some(LintCategory::Performance),
1438 "complexity" => Some(LintCategory::Complexity),
1439 "sigil" => Some(LintCategory::Sigil),
1440 _ => None,
1441 }
1442 }
1443
1444 pub fn is_empty(&self) -> bool {
1446 self.deny.is_empty()
1447 && self.warn.is_empty()
1448 && self.allow.is_empty()
1449 && self.deny_category.is_empty()
1450 && self.warn_category.is_empty()
1451 && self.allow_category.is_empty()
1452 }
1453}
1454
1455pub fn config_with_overrides(base: LintConfig, overrides: &CliOverrides) -> LintConfig {
1457 let mut config = base;
1458 overrides.apply(&mut config);
1459 config
1460}
1461
1462pub fn lint_source_with_overrides(
1464 source: &str,
1465 filename: &str,
1466 overrides: &CliOverrides,
1467) -> Result<Diagnostics, String> {
1468 let mut config = LintConfig::find_and_load();
1469 overrides.apply(&mut config);
1470 lint_source_with_config(source, filename, config)
1471}
1472
1473#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1482pub struct LintCache {
1483 pub version: u32,
1485 pub config_hash: u64,
1487 pub entries: HashMap<String, CacheEntry>,
1489}
1490
1491#[derive(Debug, Clone, Serialize, Deserialize)]
1493pub struct CacheEntry {
1494 pub content_hash: String,
1496 pub mtime: u64,
1498 pub size: u64,
1500 pub warning_count: usize,
1502 pub error_count: usize,
1504 #[serde(skip_serializing_if = "Option::is_none")]
1506 pub diagnostics: Option<Vec<CachedDiagnostic>>,
1507}
1508
1509#[derive(Debug, Clone, Serialize, Deserialize)]
1511pub struct CachedDiagnostic {
1512 pub code: Option<String>,
1513 pub message: String,
1514 pub severity: String,
1515 pub start: usize,
1516 pub end: usize,
1517}
1518
1519impl CachedDiagnostic {
1520 pub fn from_diagnostic(diag: &Diagnostic) -> Self {
1522 Self {
1523 code: diag.code.clone(),
1524 message: diag.message.clone(),
1525 severity: format!("{:?}", diag.severity),
1526 start: diag.span.start,
1527 end: diag.span.end,
1528 }
1529 }
1530
1531 pub fn to_diagnostic(&self) -> Diagnostic {
1533 let severity = match self.severity.as_str() {
1534 "Error" => Severity::Error,
1535 "Warning" => Severity::Warning,
1536 "Info" => Severity::Info,
1537 "Hint" => Severity::Hint,
1538 _ => Severity::Warning,
1539 };
1540
1541 Diagnostic {
1542 severity,
1543 code: self.code.clone(),
1544 message: self.message.clone(),
1545 span: Span::new(self.start, self.end),
1546 labels: Vec::new(),
1547 notes: Vec::new(),
1548 suggestions: Vec::new(),
1549 related: Vec::new(),
1550 }
1551 }
1552}
1553
1554impl LintCache {
1555 pub fn new() -> Self {
1557 Self {
1558 version: 1,
1559 config_hash: 0,
1560 entries: HashMap::new(),
1561 }
1562 }
1563
1564 pub fn with_config(config: &LintConfig) -> Self {
1566 Self {
1567 version: 1,
1568 config_hash: Self::hash_config(config),
1569 entries: HashMap::new(),
1570 }
1571 }
1572
1573 fn hash_config(config: &LintConfig) -> u64 {
1575 use std::hash::{Hash, Hasher};
1576 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1577
1578 config.suggest_unicode.hash(&mut hasher);
1580 config.check_naming.hash(&mut hasher);
1581 config.max_nesting_depth.hash(&mut hasher);
1582
1583 let mut levels: Vec<_> = config.levels.iter().collect();
1585 levels.sort_by_key(|(k, _)| *k);
1586 for (name, level) in levels {
1587 name.hash(&mut hasher);
1588 std::mem::discriminant(level).hash(&mut hasher);
1589 }
1590
1591 hasher.finish()
1592 }
1593
1594 pub fn from_file(path: &Path) -> Result<Self, String> {
1596 let content = std::fs::read_to_string(path)
1597 .map_err(|e| format!("Failed to read cache file: {}", e))?;
1598 Self::from_json(&content)
1599 }
1600
1601 pub fn from_json(content: &str) -> Result<Self, String> {
1603 serde_json::from_str(content)
1604 .map_err(|e| format!("Failed to parse cache: {}", e))
1605 }
1606
1607 pub fn to_file(&self, path: &Path) -> Result<(), String> {
1609 let content = self.to_json()?;
1610 std::fs::write(path, content)
1611 .map_err(|e| format!("Failed to write cache file: {}", e))
1612 }
1613
1614 pub fn to_json(&self) -> Result<String, String> {
1616 serde_json::to_string(self)
1617 .map_err(|e| format!("Failed to serialize cache: {}", e))
1618 }
1619
1620 pub fn hash_content(content: &str) -> String {
1622 let hash = blake3::hash(content.as_bytes());
1623 hash.to_hex().to_string()
1624 }
1625
1626 pub fn needs_lint(&self, path: &str, content: &str, metadata: Option<&std::fs::Metadata>) -> bool {
1633 let Some(entry) = self.entries.get(path) else {
1634 return true; };
1636
1637 if let Some(meta) = metadata {
1639 if entry.size != meta.len() {
1640 return true;
1641 }
1642 }
1643
1644 let current_hash = Self::hash_content(content);
1646 entry.content_hash != current_hash
1647 }
1648
1649 pub fn get_cached(&self, path: &str, content: &str) -> Option<Diagnostics> {
1651 let entry = self.entries.get(path)?;
1652
1653 let current_hash = Self::hash_content(content);
1655 if entry.content_hash != current_hash {
1656 return None;
1657 }
1658
1659 let cached = entry.diagnostics.as_ref()?;
1661 let mut diagnostics = Diagnostics::new();
1662 for cd in cached {
1663 diagnostics.add(cd.to_diagnostic());
1664 }
1665
1666 Some(diagnostics)
1667 }
1668
1669 pub fn update(
1671 &mut self,
1672 path: &str,
1673 content: &str,
1674 diagnostics: &Diagnostics,
1675 metadata: Option<&std::fs::Metadata>,
1676 ) {
1677 let content_hash = Self::hash_content(content);
1678
1679 let mtime = metadata
1680 .and_then(|m| m.modified().ok())
1681 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1682 .map(|d| d.as_secs())
1683 .unwrap_or(0);
1684
1685 let size = metadata.map(|m| m.len()).unwrap_or(0);
1686
1687 let warning_count = diagnostics.iter()
1688 .filter(|d| d.severity == Severity::Warning)
1689 .count();
1690 let error_count = diagnostics.iter()
1691 .filter(|d| d.severity == Severity::Error)
1692 .count();
1693
1694 let cached_diags: Vec<CachedDiagnostic> = diagnostics
1695 .iter()
1696 .map(CachedDiagnostic::from_diagnostic)
1697 .collect();
1698
1699 self.entries.insert(path.to_string(), CacheEntry {
1700 content_hash,
1701 mtime,
1702 size,
1703 warning_count,
1704 error_count,
1705 diagnostics: Some(cached_diags),
1706 });
1707 }
1708
1709 pub fn prune(&mut self, existing_files: &HashSet<String>) {
1711 self.entries.retain(|path, _| existing_files.contains(path));
1712 }
1713
1714 pub fn is_valid_for(&self, config: &LintConfig) -> bool {
1716 self.config_hash == Self::hash_config(config)
1717 }
1718
1719 pub fn stats(&self) -> CacheStats {
1721 let mut total_warnings = 0;
1722 let mut total_errors = 0;
1723
1724 for entry in self.entries.values() {
1725 total_warnings += entry.warning_count;
1726 total_errors += entry.error_count;
1727 }
1728
1729 CacheStats {
1730 cached_files: self.entries.len(),
1731 total_warnings,
1732 total_errors,
1733 }
1734 }
1735}
1736
1737#[derive(Debug, Clone)]
1739pub struct CacheStats {
1740 pub cached_files: usize,
1742 pub total_warnings: usize,
1744 pub total_errors: usize,
1746}
1747
1748pub const CACHE_FILE: &str = ".sigillint-cache.json";
1750
1751pub fn find_cache() -> Option<LintCache> {
1753 if let Ok(dir) = std::env::current_dir() {
1754 let cache_path = dir.join(CACHE_FILE);
1755 if cache_path.exists() {
1756 return LintCache::from_file(&cache_path).ok();
1757 }
1758 }
1759 None
1760}
1761
1762#[derive(Debug)]
1764pub struct IncrementalLintResult {
1765 pub result: DirectoryLintResult,
1767 pub linted_files: usize,
1769 pub cached_files: usize,
1771 pub cache: LintCache,
1773}
1774
1775pub fn lint_directory_incremental(
1783 dir: &Path,
1784 config: LintConfig,
1785 cache: Option<LintCache>,
1786) -> IncrementalLintResult {
1787 use rayon::prelude::*;
1788 use std::fs;
1789 use std::sync::atomic::{AtomicUsize, Ordering};
1790 use std::sync::Mutex;
1791
1792 let files = collect_sigil_files(dir);
1793
1794 let mut cache = cache
1796 .filter(|c| c.is_valid_for(&config))
1797 .unwrap_or_else(|| LintCache::with_config(&config));
1798
1799 let linted_count = AtomicUsize::new(0);
1800 let cached_count = AtomicUsize::new(0);
1801 let total_warnings = AtomicUsize::new(0);
1802 let total_errors = AtomicUsize::new(0);
1803 let parse_errors = AtomicUsize::new(0);
1804
1805 let cache_updates: Mutex<Vec<(String, String, Vec<CachedDiagnostic>, Option<std::fs::Metadata>)>> = Mutex::new(Vec::new());
1807
1808 let file_results: Vec<(String, Result<Diagnostics, String>)> = files
1809 .par_iter()
1810 .filter_map(|path| {
1811 let source = fs::read_to_string(path).ok()?;
1812 let path_str = path.display().to_string();
1813 let metadata = fs::metadata(path).ok();
1814
1815 if let Some(cached_diags) = cache.get_cached(&path_str, &source) {
1817 cached_count.fetch_add(1, Ordering::Relaxed);
1818 let warnings = cached_diags.iter()
1819 .filter(|d| d.severity == Severity::Warning)
1820 .count();
1821 let errors = cached_diags.iter()
1822 .filter(|d| d.severity == Severity::Error)
1823 .count();
1824 total_warnings.fetch_add(warnings, Ordering::Relaxed);
1825 total_errors.fetch_add(errors, Ordering::Relaxed);
1826 return Some((path_str, Ok(cached_diags)));
1827 }
1828
1829 linted_count.fetch_add(1, Ordering::Relaxed);
1831 match lint_source_with_config(&source, &path_str, config.clone()) {
1832 Ok(diagnostics) => {
1833 let warnings = diagnostics.iter()
1834 .filter(|d| d.severity == Severity::Warning)
1835 .count();
1836 let errors = diagnostics.iter()
1837 .filter(|d| d.severity == Severity::Error)
1838 .count();
1839 total_warnings.fetch_add(warnings, Ordering::Relaxed);
1840 total_errors.fetch_add(errors, Ordering::Relaxed);
1841
1842 let cached_diags: Vec<CachedDiagnostic> = diagnostics
1844 .iter()
1845 .map(CachedDiagnostic::from_diagnostic)
1846 .collect();
1847
1848 if let Ok(mut updates) = cache_updates.lock() {
1850 updates.push((path_str.clone(), source.clone(), cached_diags, metadata));
1851 }
1852
1853 Some((path_str, Ok(diagnostics)))
1854 }
1855 Err(e) => {
1856 parse_errors.fetch_add(1, Ordering::Relaxed);
1857 Some((path_str, Err(e)))
1858 }
1859 }
1860 })
1861 .collect();
1862
1863 if let Ok(updates) = cache_updates.into_inner() {
1865 for (path, source, cached_diags, meta) in updates {
1866 let mut diagnostics = Diagnostics::new();
1868 for cd in &cached_diags {
1869 diagnostics.add(cd.to_diagnostic());
1870 }
1871 cache.update(&path, &source, &diagnostics, meta.as_ref());
1872 }
1873 }
1874
1875 let existing: HashSet<String> = file_results.iter().map(|(p, _)| p.clone()).collect();
1877 cache.prune(&existing);
1878
1879 IncrementalLintResult {
1880 result: DirectoryLintResult {
1881 files: file_results,
1882 total_warnings: total_warnings.load(Ordering::Relaxed),
1883 total_errors: total_errors.load(Ordering::Relaxed),
1884 parse_errors: parse_errors.load(Ordering::Relaxed),
1885 },
1886 linted_files: linted_count.load(Ordering::Relaxed),
1887 cached_files: cached_count.load(Ordering::Relaxed),
1888 cache,
1889 }
1890}
1891
1892pub struct Linter {
1898 config: LintConfig,
1899 diagnostics: Diagnostics,
1900 declared_vars: HashMap<String, (Span, bool)>,
1901 declared_imports: HashMap<String, (Span, bool)>,
1902 scope_stack: Vec<HashSet<String>>,
1904 nesting_depth: usize,
1906 current_fn_params: HashMap<String, (Span, bool)>,
1908 current_complexity: usize,
1910 max_complexity: usize,
1912 max_function_lines: usize,
1914 max_parameters: usize,
1916 current_fn_lines: usize,
1918 source_text: String,
1920 suppressions: Vec<Suppression>,
1922 stats: LintStats,
1924}
1925
1926impl Linter {
1927 pub fn new(config: LintConfig) -> Self {
1928 Self {
1929 config,
1930 diagnostics: Diagnostics::new(),
1931 declared_vars: HashMap::new(),
1932 declared_imports: HashMap::new(),
1933 scope_stack: vec![HashSet::new()], nesting_depth: 0,
1935 current_fn_params: HashMap::new(),
1936 current_complexity: 0,
1937 max_complexity: 10, max_function_lines: 50, max_parameters: 7, current_fn_lines: 0,
1941 source_text: String::new(),
1942 suppressions: Vec::new(),
1943 stats: LintStats::default(),
1944 }
1945 }
1946
1947 pub fn with_suppressions(config: LintConfig, source: &str) -> Self {
1949 let mut linter = Self::new(config);
1950 linter.suppressions = parse_suppressions(source);
1951 linter.source_text = source.to_string();
1952 linter
1953 }
1954
1955 pub fn stats(&self) -> &LintStats {
1957 &self.stats
1958 }
1959
1960 fn is_suppressed(&self, lint: LintId, line: usize) -> bool {
1962 for suppression in &self.suppressions {
1963 if suppression.line == line {
1964 if suppression.lints.is_empty() || suppression.lints.contains(&lint) {
1965 return true;
1966 }
1967 }
1968 }
1969 false
1970 }
1971
1972 fn span_to_line(&self, span: Span) -> usize {
1974 0
1977 }
1978
1979 fn push_scope(&mut self) {
1981 self.scope_stack.push(HashSet::new());
1982 }
1983
1984 fn pop_scope(&mut self) {
1986 self.scope_stack.pop();
1987 }
1988
1989 fn check_shadowing(&mut self, name: &str, span: Span) {
1991 if name.starts_with('_') {
1993 return;
1994 }
1995
1996 for scope in self.scope_stack.iter().rev().skip(1) {
1998 if scope.contains(name) {
1999 self.emit(
2000 LintId::Shadowing,
2001 format!("`{}` shadows a variable from an outer scope", name),
2002 span,
2003 );
2004 break;
2005 }
2006 }
2007
2008 if let Some(current_scope) = self.scope_stack.last_mut() {
2010 current_scope.insert(name.to_string());
2011 }
2012 }
2013
2014 fn push_nesting(&mut self, span: Span) {
2016 self.nesting_depth += 1;
2017 let max_depth = self.config.max_nesting_depth;
2018 if self.nesting_depth > max_depth {
2019 self.emit(
2020 LintId::DeepNesting,
2021 format!("nesting depth {} exceeds maximum of {}", self.nesting_depth, max_depth),
2022 span,
2023 );
2024 }
2025 }
2026
2027 fn pop_nesting(&mut self) {
2029 self.nesting_depth = self.nesting_depth.saturating_sub(1);
2030 }
2031
2032 pub fn lint(&mut self, file: &SourceFile, source: &str) -> &Diagnostics {
2033 self.source_text = source.to_string();
2035
2036 self.visit_source_file(file);
2037 self.check_unused();
2038
2039 self.check_todo_comments();
2041
2042 &self.diagnostics
2043 }
2044
2045 fn lint_level(&self, lint: LintId) -> LintLevel {
2046 self.config
2047 .levels
2048 .get(lint.name())
2049 .copied()
2050 .unwrap_or_else(|| lint.default_level())
2051 }
2052
2053 fn emit(&mut self, lint: LintId, message: impl Into<String>, span: Span) {
2054 let level = self.lint_level(lint);
2055 if level == LintLevel::Allow {
2056 return;
2057 }
2058
2059 let line = self.span_to_line(span);
2061 if line > 0 && self.is_suppressed(lint, line) {
2062 self.stats.record_suppressed();
2063 return;
2064 }
2065
2066 self.stats.record(lint);
2068
2069 let severity = match level {
2070 LintLevel::Allow => return,
2071 LintLevel::Warn => Severity::Warning,
2072 LintLevel::Deny => Severity::Error,
2073 };
2074
2075 let diag = Diagnostic {
2076 severity,
2077 code: Some(lint.code().to_string()),
2078 message: message.into(),
2079 span,
2080 labels: Vec::new(),
2081 notes: vec![lint.description().to_string()],
2082 suggestions: Vec::new(),
2083 related: Vec::new(),
2084 };
2085
2086 self.diagnostics.add(diag);
2087 }
2088
2089 fn emit_with_fix(
2090 &mut self,
2091 lint: LintId,
2092 message: impl Into<String>,
2093 span: Span,
2094 fix_message: impl Into<String>,
2095 replacement: impl Into<String>,
2096 ) {
2097 let level = self.lint_level(lint);
2098 if level == LintLevel::Allow {
2099 return;
2100 }
2101
2102 let line = self.span_to_line(span);
2104 if line > 0 && self.is_suppressed(lint, line) {
2105 self.stats.record_suppressed();
2106 return;
2107 }
2108
2109 self.stats.record(lint);
2111
2112 let severity = match level {
2113 LintLevel::Allow => return,
2114 LintLevel::Warn => Severity::Warning,
2115 LintLevel::Deny => Severity::Error,
2116 };
2117
2118 let diag = Diagnostic {
2119 severity,
2120 code: Some(lint.code().to_string()),
2121 message: message.into(),
2122 span,
2123 labels: Vec::new(),
2124 notes: vec![lint.description().to_string()],
2125 suggestions: vec![FixSuggestion {
2126 message: fix_message.into(),
2127 span,
2128 replacement: replacement.into(),
2129 }],
2130 related: Vec::new(),
2131 };
2132
2133 self.diagnostics.add(diag);
2134 }
2135
2136 fn check_unused(&mut self) {
2137 let mut unused_vars: Vec<(String, Span)> = Vec::new();
2138 let mut unused_imports: Vec<(String, Span)> = Vec::new();
2139
2140 for (name, (span, used)) in &self.declared_vars {
2141 if !used && !name.starts_with('_') {
2142 unused_vars.push((name.clone(), *span));
2143 }
2144 }
2145
2146 for (name, (span, used)) in &self.declared_imports {
2147 if !used {
2148 unused_imports.push((name.clone(), *span));
2149 }
2150 }
2151
2152 for (name, span) in unused_vars {
2153 self.emit(
2154 LintId::UnusedVariable,
2155 format!("unused variable: `{}`", name),
2156 span,
2157 );
2158 }
2159
2160 for (name, span) in unused_imports {
2161 self.emit(
2162 LintId::UnusedImport,
2163 format!("unused import: `{}`", name),
2164 span,
2165 );
2166 }
2167 }
2168
2169 fn check_reserved(&mut self, name: &str, span: Span) {
2170 let reserved_suggestions: &[(&str, &str)] = &[
2171 ("location", "place"),
2172 ("save", "slot"),
2173 ("from", "source"),
2174 ("split", "divide"),
2175 ];
2176
2177 for (reserved, suggestion) in reserved_suggestions {
2178 if name == *reserved {
2179 self.emit_with_fix(
2180 LintId::ReservedIdentifier,
2181 format!("`{}` is a reserved word in Sigil", reserved),
2182 span,
2183 format!("rename to `{}`", suggestion),
2184 suggestion.to_string(),
2185 );
2186 return;
2187 }
2188 }
2189 }
2190
2191 fn check_nested_generics(&mut self, ty: &TypeExpr, span: Span) {
2192 if let TypeExpr::Path(path) = ty {
2193 for segment in &path.segments {
2194 if let Some(ref generics) = segment.generics {
2195 for arg in generics {
2196 if let TypeExpr::Path(inner_path) = arg {
2197 for inner_seg in &inner_path.segments {
2198 if inner_seg.generics.is_some() {
2199 self.emit(
2200 LintId::NestedGenerics,
2201 "nested generic parameters may not parse correctly",
2202 span,
2203 );
2204 return;
2205 }
2206 }
2207 }
2208 }
2209 }
2210 }
2211 }
2212 }
2213
2214 fn check_division(&mut self, op: &BinOp, right: &Expr, span: Span) {
2215 if let BinOp::Div = op {
2216 if let Expr::Literal(Literal::Int { value, .. }) = right {
2217 if value == "0" {
2218 self.emit(LintId::DivisionByZero, "division by zero", span);
2219 }
2220 }
2221 }
2222 }
2223
2224 fn check_infinite_loop(&mut self, body: &Block, span: Span) {
2225 if !Self::block_contains_break(body) {
2226 self.emit(
2227 LintId::InfiniteLoop,
2228 "loop has no `break` statement and may run forever",
2229 span,
2230 );
2231 }
2232 }
2233
2234 fn block_contains_break(block: &Block) -> bool {
2235 for stmt in &block.stmts {
2236 if Self::stmt_contains_break(stmt) {
2237 return true;
2238 }
2239 }
2240 if let Some(ref expr) = block.expr {
2241 if Self::expr_contains_break(expr) {
2242 return true;
2243 }
2244 }
2245 false
2246 }
2247
2248 fn stmt_contains_break(stmt: &Stmt) -> bool {
2249 match stmt {
2250 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_contains_break(e),
2251 Stmt::Let { init, .. } => init.as_ref().map_or(false, Self::expr_contains_break),
2252 Stmt::LetElse { init, else_branch, .. } => {
2253 Self::expr_contains_break(init) || Self::expr_contains_break(else_branch)
2254 }
2255 Stmt::Item(_) => false,
2256 }
2257 }
2258
2259 fn expr_contains_break(expr: &Expr) -> bool {
2260 match expr {
2261 Expr::Break { .. } => true,
2262 Expr::Return(_) => true,
2263 Expr::Block(b) => Self::block_contains_break(b),
2264 Expr::If { then_branch, else_branch, .. } => {
2265 Self::block_contains_break(then_branch)
2266 || else_branch.as_ref().map_or(false, |e| Self::expr_contains_break(e))
2267 }
2268 Expr::Match { arms, .. } => arms.iter().any(|arm| Self::expr_contains_break(&arm.body)),
2269 Expr::Loop { .. } | Expr::While { .. } | Expr::For { .. } => false,
2270 _ => false,
2271 }
2272 }
2273
2274 fn check_empty_block(&mut self, block: &Block, span: Span) {
2276 if block.stmts.is_empty() && block.expr.is_none() {
2277 self.emit(
2278 LintId::EmptyBlock,
2279 "empty block",
2280 span,
2281 );
2282 }
2283 }
2284
2285 fn check_bool_comparison(&mut self, op: &BinOp, left: &Expr, right: &Expr, span: Span) {
2288 let is_eq_or_ne = matches!(op, BinOp::Eq | BinOp::Ne);
2289 if !is_eq_or_ne {
2290 return;
2291 }
2292
2293 let has_bool_literal = |expr: &Expr| -> Option<bool> {
2294 if let Expr::Literal(Literal::Bool(value)) = expr {
2295 Some(*value)
2296 } else {
2297 None
2298 }
2299 };
2300
2301 if let Some(val) = has_bool_literal(right) {
2302 let suggestion = match (op, val) {
2303 (BinOp::Eq, true) | (BinOp::Ne, false) => "use the expression directly",
2304 (BinOp::Eq, false) | (BinOp::Ne, true) => "use `!expr` instead",
2305 _ => "simplify the comparison",
2306 };
2307 self.emit(
2308 LintId::BoolComparison,
2309 format!("comparison to `{}` is redundant; {}", val, suggestion),
2310 span,
2311 );
2312 } else if let Some(val) = has_bool_literal(left) {
2313 let suggestion = match (op, val) {
2314 (BinOp::Eq, true) | (BinOp::Ne, false) => "use the expression directly",
2315 (BinOp::Eq, false) | (BinOp::Ne, true) => "use `!expr` instead",
2316 _ => "simplify the comparison",
2317 };
2318 self.emit(
2319 LintId::BoolComparison,
2320 format!("comparison to `{}` is redundant; {}", val, suggestion),
2321 span,
2322 );
2323 }
2324 }
2325
2326 fn check_redundant_else(&mut self, then_branch: &Block, else_branch: &Option<Box<Expr>>, span: Span) {
2329 if else_branch.is_none() {
2330 return;
2331 }
2332
2333 let then_terminates = if let Some(ref expr) = then_branch.expr {
2335 Self::expr_terminates(expr).is_some()
2336 } else if let Some(last) = then_branch.stmts.last() {
2337 Self::stmt_terminates(last).is_some()
2338 } else {
2339 false
2340 };
2341
2342 if then_terminates {
2343 self.emit(
2344 LintId::RedundantElse,
2345 "else branch is redundant after return/break/continue",
2346 span,
2347 );
2348 }
2349 }
2350
2351 fn check_magic_number(&mut self, value: &str, span: Span) {
2354 let allowed = ["0", "1", "2", "-1", "10", "100", "1000", "0.0", "1.0", "0.5"];
2356 if allowed.contains(&value) {
2357 return;
2358 }
2359
2360 if let Ok(n) = value.parse::<i64>() {
2362 if n >= 0 && n <= 10 {
2363 return;
2364 }
2365 }
2366
2367 self.emit(
2368 LintId::MagicNumber,
2369 format!("magic number `{}` should be a named constant", value),
2370 span,
2371 );
2372 }
2373
2374 fn add_complexity(&mut self, amount: usize) {
2376 self.current_complexity += amount;
2377 }
2378
2379 fn check_complexity(&mut self, func_name: &str, span: Span) {
2381 if self.current_complexity > self.max_complexity {
2382 self.emit(
2383 LintId::HighComplexity,
2384 format!(
2385 "function `{}` has cyclomatic complexity of {} (max: {})",
2386 func_name, self.current_complexity, self.max_complexity
2387 ),
2388 span,
2389 );
2390 }
2391 }
2392
2393 fn check_unused_params(&mut self) {
2395 let unused: Vec<(String, Span)> = self.current_fn_params
2397 .iter()
2398 .filter(|(name, (_, used))| !name.starts_with('_') && !used)
2399 .map(|(name, (span, _))| (name.clone(), *span))
2400 .collect();
2401
2402 for (name, span) in unused {
2403 self.emit_with_fix(
2404 LintId::UnusedParameter,
2405 format!("parameter `{}` is never used", name),
2406 span,
2407 "prefix with underscore to indicate intentionally unused",
2408 format!("_{}", name),
2409 );
2410 }
2411 }
2412
2413 fn mark_param_used(&mut self, name: &str) {
2415 if let Some((_, used)) = self.current_fn_params.get_mut(name) {
2416 *used = true;
2417 }
2418 }
2419
2420 fn check_missing_doc(&mut self, vis: &Visibility, name: &str, span: Span) {
2422 if !matches!(vis, Visibility::Public) {
2424 return;
2425 }
2426
2427 self.emit(
2431 LintId::MissingDocComment,
2432 format!("public item `{}` should have a documentation comment", name),
2433 span,
2434 );
2435 }
2436
2437 fn check_todo_comments(&mut self) {
2439 let issue_pattern = regex::Regex::new(r"TODO\s*\([#A-Z]+-?\d+\)").unwrap();
2441 let todo_pattern = regex::Regex::new(r"//.*\bTODO\b").unwrap();
2442
2443 let source = self.source_text.clone();
2445 for line in source.lines() {
2446 if todo_pattern.is_match(line) && !issue_pattern.is_match(line) {
2447 self.emit(
2449 LintId::TodoWithoutIssue,
2450 "TODO comment should reference an issue (e.g., TODO(#123):)",
2451 Span::default(),
2452 );
2453 }
2454 }
2455 }
2456
2457 fn check_function_length(&mut self, func_name: &str, span: Span, line_count: usize) {
2459 if line_count > self.max_function_lines {
2460 self.emit(
2461 LintId::LongFunction,
2462 format!(
2463 "function `{}` has {} lines (max: {})",
2464 func_name, line_count, self.max_function_lines
2465 ),
2466 span,
2467 );
2468 }
2469 }
2470
2471 fn check_parameter_count(&mut self, func_name: &str, span: Span, param_count: usize) {
2473 if param_count > self.max_parameters {
2474 self.emit(
2475 LintId::TooManyParameters,
2476 format!(
2477 "function `{}` has {} parameters (max: {})",
2478 func_name, param_count, self.max_parameters
2479 ),
2480 span,
2481 );
2482 }
2483 }
2484
2485 fn check_needless_return(&mut self, body: &Block, span: Span) {
2487 if let Some(ref expr) = body.expr {
2489 if let Expr::Return(Some(_)) = &**expr {
2490 self.emit(
2491 LintId::NeedlessReturn,
2492 "unnecessary return statement; the last expression is automatically returned",
2493 span,
2494 );
2495 }
2496 } else if let Some(last) = body.stmts.last() {
2497 match last {
2498 Stmt::Semi(Expr::Return(Some(_))) | Stmt::Expr(Expr::Return(Some(_))) => {
2499 self.emit(
2500 LintId::NeedlessReturn,
2501 "unnecessary return statement; the last expression is automatically returned",
2502 span,
2503 );
2504 }
2505 _ => {}
2506 }
2507 }
2508 }
2509
2510 fn check_missing_return(&mut self, body: &Block, has_return_type: bool, func_name: &str, span: Span) {
2517 if !has_return_type {
2518 return; }
2520
2521 if !Self::block_always_returns(body) {
2523 self.emit(
2524 LintId::MissingReturn,
2525 format!("function `{}` may not return a value on all code paths", func_name),
2526 span,
2527 );
2528 }
2529 }
2530
2531 fn block_always_returns(block: &Block) -> bool {
2533 if let Some(ref expr) = block.expr {
2535 return Self::expr_always_returns(expr);
2536 }
2537
2538 for stmt in &block.stmts {
2541 if Self::stmt_always_returns(stmt) {
2542 return true;
2543 }
2544 }
2545
2546 false
2547 }
2548
2549 fn stmt_always_returns(stmt: &Stmt) -> bool {
2551 match stmt {
2552 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_always_returns(e),
2553 _ => false,
2554 }
2555 }
2556
2557 fn expr_always_returns(expr: &Expr) -> bool {
2559 match expr {
2560 Expr::Return(_) => true,
2562 Expr::Break { .. } => true, Expr::Continue { .. } => true, Expr::Block(b) => Self::block_always_returns(b),
2567
2568 Expr::If { then_branch, else_branch, .. } => {
2570 if let Some(ref else_expr) = else_branch {
2571 Self::block_always_returns(then_branch) && Self::expr_always_returns(else_expr)
2572 } else {
2573 false }
2575 }
2576
2577 Expr::Match { arms, .. } => {
2579 if arms.is_empty() {
2580 false
2581 } else {
2582 arms.iter().all(|arm| Self::expr_always_returns(&arm.body))
2583 }
2584 }
2585
2586 Expr::Loop { .. } => false,
2589 Expr::While { .. } => false,
2590 Expr::For { .. } => false,
2591
2592 Expr::Literal(_) => true,
2594 Expr::Path(_) => true,
2595 Expr::Binary { .. } => true,
2596 Expr::Unary { .. } => true,
2597 Expr::Call { .. } => true,
2598 Expr::MethodCall { .. } => true,
2599 Expr::Field { .. } => true,
2600 Expr::Index { .. } => true,
2601 Expr::Array(_) => true,
2602 Expr::Tuple(_) => true,
2603 Expr::Struct { .. } => true,
2604 Expr::Range { .. } => true,
2605 Expr::Cast { .. } => true,
2606 Expr::AddrOf { .. } => true,
2607 Expr::Deref(_) => true,
2608 Expr::Closure { .. } => true,
2609 Expr::Await { .. } => true,
2610 Expr::Try(_) => true,
2611 Expr::Morpheme { .. } => true,
2612 Expr::Pipe { .. } => true,
2613 Expr::Unsafe(b) => Self::block_always_returns(b),
2614 Expr::Evidential { .. } => true,
2615 Expr::Incorporation { .. } => true,
2616 Expr::Let { .. } => true,
2617
2618 Expr::Assign { .. } => false,
2620
2621 _ => false,
2623 }
2624 }
2625
2626 fn check_prefer_morpheme_pipeline(&mut self, expr: &Expr, span: Span) {
2631 let chain_length = Self::method_chain_length(expr);
2633
2634 if chain_length >= 2 {
2636 let transformable_methods = Self::count_transformable_methods(expr);
2638 if transformable_methods >= 2 {
2639 self.emit(
2640 LintId::PreferMorphemePipeline,
2641 format!(
2642 "consider using morpheme pipeline (|τ{{}}, |φ{{}}) for this {}-method chain",
2643 chain_length
2644 ),
2645 span,
2646 );
2647 }
2648 }
2649 }
2650
2651 fn method_chain_length(expr: &Expr) -> usize {
2653 match expr {
2654 Expr::MethodCall { receiver, .. } => {
2655 1 + Self::method_chain_length(receiver)
2656 }
2657 _ => 0,
2658 }
2659 }
2660
2661 fn count_transformable_methods(expr: &Expr) -> usize {
2663 let transformable = ["map", "filter", "fold", "reduce", "collect", "sort", "first", "last", "zip", "iter"];
2664
2665 match expr {
2666 Expr::MethodCall { receiver, method, .. } => {
2667 let count = if transformable.contains(&method.name.as_str()) { 1 } else { 0 };
2668 count + Self::count_transformable_methods(receiver)
2669 }
2670 _ => 0,
2671 }
2672 }
2673
2674 fn check_constant_condition(&mut self, condition: &Expr, span: Span) {
2676 let is_constant = match condition {
2677 Expr::Literal(Literal::Bool(val)) => Some(*val),
2678 Expr::Path(p) if p.segments.len() == 1 => {
2679 let name = &p.segments[0].ident.name;
2680 if name == "true" {
2681 Some(true)
2682 } else if name == "false" {
2683 Some(false)
2684 } else {
2685 None
2686 }
2687 }
2688 _ => None,
2689 };
2690
2691 if let Some(val) = is_constant {
2692 self.emit(
2693 LintId::ConstantCondition,
2694 format!("condition is always `{}`", val),
2695 span,
2696 );
2697 }
2698 }
2699
2700 fn check_prefer_if_let(&mut self, arms: &[MatchArm], span: Span) {
2702 if arms.len() == 2 {
2704 let has_wildcard = arms.iter().any(|arm| {
2705 matches!(&arm.pattern, Pattern::Wildcard)
2706 });
2707 if has_wildcard {
2708 self.emit(
2709 LintId::PreferIfLet,
2710 "consider using `if let` instead of `match` with wildcard",
2711 span,
2712 );
2713 }
2714 }
2715 }
2716
2717 fn check_hexagram_number(&mut self, value: i64, span: Span) {
2723 if value < 1 || value > 64 {
2724 self.emit(
2725 LintId::InvalidHexagramNumber,
2726 format!("hexagram number {} is invalid (must be 1-64)", value),
2727 span,
2728 );
2729 }
2730 }
2731
2732 fn check_tarot_number(&mut self, value: i64, span: Span) {
2734 if value < 0 || value > 21 {
2735 self.emit(
2736 LintId::InvalidTarotNumber,
2737 format!("Major Arcana number {} is invalid (must be 0-21)", value),
2738 span,
2739 );
2740 }
2741 }
2742
2743 fn check_chakra_index(&mut self, value: i64, span: Span) {
2745 if value < 0 || value > 6 {
2746 self.emit(
2747 LintId::InvalidChakraIndex,
2748 format!("chakra index {} is invalid (must be 0-6)", value),
2749 span,
2750 );
2751 }
2752 }
2753
2754 fn check_zodiac_index(&mut self, value: i64, span: Span) {
2756 if value < 0 || value > 11 {
2757 self.emit(
2758 LintId::InvalidZodiacIndex,
2759 format!("zodiac index {} is invalid (must be 0-11)", value),
2760 span,
2761 );
2762 }
2763 }
2764
2765 fn check_gematria_value(&mut self, value: i64, span: Span) {
2767 if value < 0 {
2768 self.emit(
2769 LintId::InvalidGematriaValue,
2770 format!("gematria value {} is invalid (must be non-negative)", value),
2771 span,
2772 );
2773 }
2774 }
2775
2776 fn check_frequency_range(&mut self, value: f64, span: Span) {
2778 if value < 20.0 || value > 20000.0 {
2779 self.emit(
2780 LintId::FrequencyOutOfRange,
2781 format!("frequency {:.2}Hz is outside audible range (20Hz-20kHz)", value),
2782 span,
2783 );
2784 }
2785 }
2786
2787 fn check_emotion_intensity(&mut self, value: f64, span: Span) {
2789 if value < 0.0 || value > 1.0 {
2790 self.emit(
2791 LintId::EmotionIntensityOutOfRange,
2792 format!("emotion intensity {:.2} is invalid (must be 0.0-1.0)", value),
2793 span,
2794 );
2795 }
2796 }
2797
2798 fn check_esoteric_constant(&mut self, value: &str, span: Span) {
2800 let esoteric_values = [
2802 ("1.618", "GOLDEN_RATIO or PHI"),
2803 ("0.618", "GOLDEN_RATIO_INVERSE"),
2804 ("1.414", "SQRT_2 or SILVER_RATIO"),
2805 ("2.414", "SILVER_RATIO"),
2806 ("3.14159", "PI"),
2807 ("2.71828", "E or EULER"),
2808 ("432", "VERDI_PITCH or A432"),
2809 ("440", "CONCERT_PITCH or A440"),
2810 ("528", "SOLFEGGIO_MI or LOVE_FREQUENCY"),
2811 ("396", "SOLFEGGIO_UT"),
2812 ("639", "SOLFEGGIO_FA"),
2813 ("741", "SOLFEGGIO_SOL"),
2814 ("852", "SOLFEGGIO_LA"),
2815 ("963", "SOLFEGGIO_SI"),
2816 ];
2817
2818 for (pattern, suggestion) in esoteric_values {
2819 if value.starts_with(pattern) {
2820 self.emit(
2821 LintId::PreferNamedEsotericConstant,
2822 format!("consider using named constant {} instead of {}", suggestion, value),
2823 span,
2824 );
2825 return;
2826 }
2827 }
2828 }
2829
2830 fn check_morpheme_style_consistency(&mut self, expr: &Expr, span: Span) {
2832 let has_morpheme = Self::has_morpheme_pipeline(expr);
2833 let has_method_chain = Self::method_chain_length(expr) >= 2;
2834
2835 if has_morpheme && has_method_chain {
2836 self.emit(
2837 LintId::InconsistentMorphemeStyle,
2838 "mixing morpheme pipeline (|τ{}) with method chains; prefer one style",
2839 span,
2840 );
2841 }
2842 }
2843
2844 fn has_morpheme_pipeline(expr: &Expr) -> bool {
2846 match expr {
2847 Expr::Morpheme { .. } => true,
2848 Expr::Pipe { .. } => true,
2849 Expr::MethodCall { receiver, .. } => Self::has_morpheme_pipeline(receiver),
2850 Expr::Binary { left, right, .. } => {
2851 Self::has_morpheme_pipeline(left) || Self::has_morpheme_pipeline(right)
2852 }
2853 _ => false,
2854 }
2855 }
2856
2857 fn check_domain_literal(&mut self, func_name: &str, value: i64, span: Span) {
2859 let name_lower = func_name.to_lowercase();
2861
2862 if name_lower.contains("hexagram") || name_lower.contains("iching") {
2863 self.check_hexagram_number(value, span);
2864 } else if name_lower.contains("arcana") || name_lower.contains("tarot") {
2865 self.check_tarot_number(value, span);
2866 } else if name_lower.contains("chakra") {
2867 self.check_chakra_index(value, span);
2868 } else if name_lower.contains("zodiac") || name_lower.contains("sign") {
2869 self.check_zodiac_index(value, span);
2870 } else if name_lower.contains("gematria") {
2871 self.check_gematria_value(value, span);
2872 }
2873 }
2874
2875 fn check_domain_float_literal(&mut self, func_name: &str, value: f64, span: Span) {
2877 let name_lower = func_name.to_lowercase();
2878
2879 if name_lower.contains("frequency") || name_lower.contains("hz") || name_lower.contains("hertz") {
2880 self.check_frequency_range(value, span);
2881 } else if name_lower.contains("intensity") || name_lower.contains("emotion") {
2882 self.check_emotion_intensity(value, span);
2883 }
2884 }
2885
2886 fn visit_source_file(&mut self, file: &SourceFile) {
2888 for item in &file.items {
2889 self.visit_item(&item.node);
2890 }
2891 }
2892
2893 fn visit_item(&mut self, item: &Item) {
2894 match item {
2895 Item::Function(f) => self.visit_function(f),
2896 Item::Struct(s) => self.visit_struct(s),
2897 Item::Module(m) => self.visit_module(m),
2898 _ => {}
2899 }
2900 }
2901
2902 fn visit_function(&mut self, func: &Function) {
2903 self.check_reserved(&func.name.name, func.name.span);
2904
2905 self.check_missing_doc(&func.visibility, &func.name.name, func.name.span);
2907
2908 self.check_parameter_count(&func.name.name, func.name.span, func.params.len());
2910
2911 self.current_complexity = 1; self.current_fn_params.clear();
2916
2917 self.push_scope();
2919
2920 for param in &func.params {
2921 if let Pattern::Ident { name, .. } = ¶m.pattern {
2923 if let Some(scope) = self.scope_stack.last_mut() {
2924 scope.insert(name.name.clone());
2925 }
2926 self.current_fn_params.insert(name.name.clone(), (name.span, false));
2928 }
2929 self.visit_pattern(¶m.pattern);
2930 }
2931
2932 if let Some(ref body) = func.body {
2933 self.check_needless_return(body, func.name.span);
2935
2936 let has_return_type = func.return_type.is_some();
2938 self.check_missing_return(body, has_return_type, &func.name.name, func.name.span);
2939
2940 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);
2943
2944 self.visit_block(body);
2945 }
2946
2947 self.check_unused_params();
2949
2950 self.check_complexity(&func.name.name, func.name.span);
2952
2953 self.pop_scope();
2954 }
2955
2956 fn visit_struct(&mut self, s: &StructDef) {
2957 self.check_reserved(&s.name.name, s.name.span);
2958
2959 if let StructFields::Named(ref fields) = s.fields {
2960 for field in fields {
2961 self.check_reserved(&field.name.name, field.name.span);
2962 self.check_nested_generics(&field.ty, field.name.span);
2963 }
2964 }
2965 }
2966
2967 fn visit_module(&mut self, m: &Module) {
2968 if let Some(ref items) = m.items {
2969 for item in items {
2970 self.visit_item(&item.node);
2971 }
2972 }
2973 }
2974
2975 fn visit_block(&mut self, block: &Block) {
2976 self.push_scope();
2977
2978 let mut found_terminator = false;
2979
2980 for stmt in &block.stmts {
2981 if found_terminator {
2983 if let Some(span) = Self::stmt_span(stmt) {
2984 self.emit(
2985 LintId::UnreachableCode,
2986 "unreachable statement after return/break/continue",
2987 span,
2988 );
2989 }
2990 }
2991
2992 self.visit_stmt(stmt);
2993
2994 if !found_terminator {
2996 if Self::stmt_terminates(stmt).is_some() {
2997 found_terminator = true;
2998 }
2999 }
3000 }
3001
3002 if let Some(ref expr) = block.expr {
3004 if found_terminator {
3005 if let Some(span) = Self::expr_span(expr) {
3006 self.emit(
3007 LintId::UnreachableCode,
3008 "unreachable expression after return/break/continue",
3009 span,
3010 );
3011 }
3012 }
3013 self.visit_expr(expr);
3014 }
3015
3016 self.pop_scope();
3017 }
3018
3019 fn stmt_span(stmt: &Stmt) -> Option<Span> {
3021 match stmt {
3022 Stmt::Let { pattern, .. } => {
3023 if let Pattern::Ident { name, .. } = pattern {
3024 Some(name.span)
3025 } else {
3026 None
3027 }
3028 }
3029 Stmt::LetElse { pattern, .. } => {
3030 if let Pattern::Ident { name, .. } = pattern {
3031 Some(name.span)
3032 } else {
3033 None
3034 }
3035 }
3036 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_span(e),
3037 Stmt::Item(_) => None,
3038 }
3039 }
3040
3041 fn expr_span(expr: &Expr) -> Option<Span> {
3043 match expr {
3044 Expr::Return(_) => Some(Span::default()),
3045 Expr::Break { .. } => Some(Span::default()),
3046 Expr::Continue { .. } => Some(Span::default()),
3047 Expr::Path(p) if !p.segments.is_empty() => Some(p.segments[0].ident.span),
3048 Expr::Literal(_) => Some(Span::default()),
3050 _ => Some(Span::default()), }
3052 }
3053
3054 fn stmt_terminates(stmt: &Stmt) -> Option<Span> {
3056 match stmt {
3057 Stmt::Expr(e) | Stmt::Semi(e) => Self::expr_terminates(e),
3058 _ => None,
3059 }
3060 }
3061
3062 fn expr_terminates(expr: &Expr) -> Option<Span> {
3064 match expr {
3065 Expr::Return(_) => Some(Span::default()),
3066 Expr::Break { .. } => Some(Span::default()),
3067 Expr::Continue { .. } => Some(Span::default()),
3068 Expr::Block(b) => {
3069 if let Some(ref e) = b.expr {
3071 Self::expr_terminates(e)
3072 } else if let Some(last) = b.stmts.last() {
3073 Self::stmt_terminates(last)
3074 } else {
3075 None
3076 }
3077 }
3078 _ => None,
3079 }
3080 }
3081
3082 fn visit_stmt(&mut self, stmt: &Stmt) {
3083 match stmt {
3084 Stmt::Let { pattern, init, .. } => {
3085 if let Pattern::Ident { name, .. } = pattern {
3086 self.check_reserved(&name.name, name.span);
3087 self.check_shadowing(&name.name, name.span);
3088 self.declared_vars.insert(name.name.clone(), (name.span, false));
3089 }
3090 self.visit_pattern(pattern);
3091 if let Some(ref e) = init {
3092 self.visit_expr(e);
3093 }
3094 }
3095 Stmt::LetElse { pattern, init, else_branch, .. } => {
3096 if let Pattern::Ident { name, .. } = pattern {
3097 self.check_reserved(&name.name, name.span);
3098 self.check_shadowing(&name.name, name.span);
3099 self.declared_vars.insert(name.name.clone(), (name.span, false));
3100 }
3101 self.visit_pattern(pattern);
3102 self.visit_expr(init);
3103 self.visit_expr(else_branch);
3104 }
3105 Stmt::Expr(e) | Stmt::Semi(e) => self.visit_expr(e),
3106 Stmt::Item(item) => self.visit_item(item),
3107 }
3108 }
3109
3110 fn visit_expr(&mut self, expr: &Expr) {
3111 match expr {
3112 Expr::Path(path) => {
3113 if path.segments.len() == 1 {
3114 let name = &path.segments[0].ident.name;
3115 if let Some((_, used)) = self.declared_vars.get_mut(name) {
3116 *used = true;
3117 }
3118 self.mark_param_used(name);
3120 }
3121 }
3122 Expr::Literal(lit) => {
3123 match lit {
3125 Literal::Int { value, .. } => {
3126 self.check_magic_number(value, Span::default());
3127 }
3128 Literal::Float { value, .. } => {
3129 self.check_magic_number(value, Span::default());
3130 }
3131 _ => {}
3132 }
3133 }
3134 Expr::Binary { op, left, right, .. } => {
3135 self.check_division(op, right, Span::default());
3136 self.check_bool_comparison(op, left, right, Span::default());
3137 if matches!(op, BinOp::And | BinOp::Or) {
3139 self.add_complexity(1);
3140 }
3141 self.visit_expr(left);
3142 self.visit_expr(right);
3143 }
3144 Expr::Loop { body, .. } => {
3145 self.push_nesting(Span::default());
3146 self.add_complexity(1); self.check_infinite_loop(body, Span::default());
3148 self.check_empty_block(body, Span::default());
3149 self.visit_block(body);
3150 self.pop_nesting();
3151 }
3152 Expr::Block(b) => {
3153 self.check_empty_block(b, Span::default());
3154 self.visit_block(b);
3155 }
3156 Expr::If { condition, then_branch, else_branch, .. } => {
3157 self.push_nesting(Span::default());
3158 self.add_complexity(1); self.check_constant_condition(condition, Span::default());
3160 self.check_redundant_else(then_branch, else_branch, Span::default());
3161 self.check_empty_block(then_branch, Span::default());
3162 self.visit_expr(condition);
3163 self.visit_block(then_branch);
3164 if let Some(ref e) = else_branch {
3165 self.visit_expr(e);
3166 }
3167 self.pop_nesting();
3168 }
3169 Expr::Match { expr: match_expr, arms, .. } => {
3170 self.push_nesting(Span::default());
3171 self.check_prefer_if_let(arms, Span::default());
3172 if !arms.is_empty() {
3174 self.add_complexity(arms.len().saturating_sub(1));
3175 }
3176 self.visit_expr(match_expr);
3177 for arm in arms {
3178 self.visit_pattern(&arm.pattern);
3179 if let Some(ref guard) = arm.guard {
3180 self.add_complexity(1); self.visit_expr(guard);
3182 }
3183 self.visit_expr(&arm.body);
3184 }
3185 self.pop_nesting();
3186 }
3187 Expr::While { condition, body, .. } => {
3188 self.push_nesting(Span::default());
3189 self.add_complexity(1); self.check_constant_condition(condition, Span::default());
3191 self.visit_expr(condition);
3192 self.visit_block(body);
3193 self.pop_nesting();
3194 }
3195 Expr::For { pattern, iter, body, .. } => {
3196 self.push_nesting(Span::default());
3197 self.add_complexity(1); self.visit_pattern(pattern);
3199 self.visit_expr(iter);
3200 self.visit_block(body);
3201 self.pop_nesting();
3202 }
3203 Expr::Call { func, args, .. } => {
3204 self.visit_expr(func);
3205 for arg in args {
3206 self.visit_expr(arg);
3207 }
3208 }
3209 Expr::MethodCall { receiver, args, .. } => {
3210 self.check_prefer_morpheme_pipeline(expr, Span::default());
3212 self.visit_expr(receiver);
3213 for arg in args {
3214 self.visit_expr(arg);
3215 }
3216 }
3217 Expr::Field { expr: field_expr, .. } => self.visit_expr(field_expr),
3218 Expr::Index { expr: idx_expr, index, .. } => {
3219 self.visit_expr(idx_expr);
3220 self.visit_expr(index);
3221 }
3222 Expr::Array(elements) | Expr::Tuple(elements) => {
3223 for e in elements {
3224 self.visit_expr(e);
3225 }
3226 }
3227 Expr::Struct { fields, rest, .. } => {
3228 for field in fields {
3229 if let Some(ref value) = field.value {
3230 self.visit_expr(value);
3231 }
3232 }
3233 if let Some(ref b) = rest {
3234 self.visit_expr(b);
3235 }
3236 }
3237 Expr::Range { start, end, .. } => {
3238 if let Some(ref s) = start {
3239 self.visit_expr(s);
3240 }
3241 if let Some(ref e) = end {
3242 self.visit_expr(e);
3243 }
3244 }
3245 Expr::Return(e) => {
3246 if let Some(ref ret_expr) = e {
3247 self.visit_expr(ret_expr);
3248 }
3249 }
3250 Expr::Break { value, .. } => {
3251 if let Some(ref brk_expr) = value {
3252 self.visit_expr(brk_expr);
3253 }
3254 }
3255 Expr::Assign { target, value, .. } => {
3256 self.visit_expr(target);
3257 self.visit_expr(value);
3258 }
3259 Expr::AddrOf { expr: addr_expr, .. } => self.visit_expr(addr_expr),
3260 Expr::Deref(e) => self.visit_expr(e),
3261 Expr::Cast { expr: cast_expr, .. } => self.visit_expr(cast_expr),
3262 Expr::Closure { params, body, .. } => {
3263 for param in params {
3264 self.visit_pattern(¶m.pattern);
3265 }
3266 self.visit_expr(body);
3267 }
3268 Expr::Await { expr: await_expr, .. } => self.visit_expr(await_expr),
3269 Expr::Try(e) => self.visit_expr(e),
3270 Expr::Morpheme { body, .. } => self.visit_expr(body),
3271 Expr::Pipe { expr: pipe_expr, .. } => self.visit_expr(pipe_expr),
3272 Expr::Unsafe(block) => self.visit_block(block),
3273 Expr::Async { block, .. } => self.visit_block(block),
3274 Expr::Unary { expr: unary_expr, .. } => self.visit_expr(unary_expr),
3275 Expr::Evidential { expr: ev_expr, .. } => self.visit_expr(ev_expr),
3276 Expr::Let { value, pattern, .. } => {
3277 self.visit_pattern(pattern);
3278 self.visit_expr(value);
3279 }
3280 Expr::Incorporation { segments } => {
3281 for seg in segments {
3282 if let Some(ref args) = seg.args {
3283 for arg in args {
3284 self.visit_expr(arg);
3285 }
3286 }
3287 }
3288 }
3289 _ => {}
3290 }
3291 }
3292
3293 fn visit_pattern(&mut self, _pattern: &Pattern) {}
3294}
3295
3296pub fn lint_file(file: &SourceFile, source: &str) -> Diagnostics {
3302 let mut linter = Linter::new(LintConfig::default());
3303 linter.lint(file, source);
3304 linter.diagnostics
3305}
3306
3307pub fn lint_source(source: &str, filename: &str) -> Result<Diagnostics, String> {
3309 use crate::parser::Parser;
3310
3311 let mut parser = Parser::new(source);
3312
3313 match parser.parse_file() {
3314 Ok(file) => {
3315 let diagnostics = lint_file(&file, source);
3316 Ok(diagnostics)
3317 }
3318 Err(e) => Err(format!("Parse error in {}: {:?}", filename, e)),
3319 }
3320}
3321
3322pub fn lint_source_with_config(source: &str, filename: &str, config: LintConfig) -> Result<Diagnostics, String> {
3324 use crate::parser::Parser;
3325
3326 let mut parser = Parser::new(source);
3327
3328 match parser.parse_file() {
3329 Ok(file) => {
3330 let mut linter = Linter::new(config);
3331 linter.lint(&file, source);
3332 Ok(linter.diagnostics)
3333 }
3334 Err(e) => Err(format!("Parse error in {}: {:?}", filename, e)),
3335 }
3336}
3337
3338#[derive(Debug)]
3340pub struct DirectoryLintResult {
3341 pub files: Vec<(String, Result<Diagnostics, String>)>,
3343 pub total_warnings: usize,
3345 pub total_errors: usize,
3347 pub parse_errors: usize,
3349}
3350
3351fn collect_sigil_files(dir: &Path) -> Vec<std::path::PathBuf> {
3353 use std::fs;
3354 let mut files = Vec::new();
3355
3356 fn visit_dir(dir: &Path, files: &mut Vec<std::path::PathBuf>) {
3357 if let Ok(entries) = fs::read_dir(dir) {
3358 for entry in entries.flatten() {
3359 let path = entry.path();
3360 if path.is_dir() {
3361 visit_dir(&path, files);
3362 } else if path.extension().map_or(false, |ext| ext == "sigil" || ext == "sg") {
3363 files.push(path);
3364 }
3365 }
3366 }
3367 }
3368
3369 visit_dir(dir, &mut files);
3370 files
3371}
3372
3373pub fn lint_directory(dir: &Path, config: LintConfig) -> DirectoryLintResult {
3375 use std::fs;
3376
3377 let files = collect_sigil_files(dir);
3378 let mut result = DirectoryLintResult {
3379 files: Vec::new(),
3380 total_warnings: 0,
3381 total_errors: 0,
3382 parse_errors: 0,
3383 };
3384
3385 for path in files {
3386 if let Ok(source) = fs::read_to_string(&path) {
3387 let path_str = path.display().to_string();
3388 match lint_source_with_config(&source, &path_str, config.clone()) {
3389 Ok(diagnostics) => {
3390 let warnings = diagnostics.iter()
3391 .filter(|d| d.severity == crate::diagnostic::Severity::Warning)
3392 .count();
3393 let errors = diagnostics.iter()
3394 .filter(|d| d.severity == crate::diagnostic::Severity::Error)
3395 .count();
3396 result.total_warnings += warnings;
3397 result.total_errors += errors;
3398 result.files.push((path_str, Ok(diagnostics)));
3399 }
3400 Err(e) => {
3401 result.parse_errors += 1;
3402 result.files.push((path_str, Err(e)));
3403 }
3404 }
3405 }
3406 }
3407
3408 result
3409}
3410
3411pub fn lint_directory_parallel(dir: &Path, config: LintConfig) -> DirectoryLintResult {
3416 use rayon::prelude::*;
3417 use std::fs;
3418 use std::sync::atomic::{AtomicUsize, Ordering};
3419
3420 let files = collect_sigil_files(dir);
3421 let total_warnings = AtomicUsize::new(0);
3422 let total_errors = AtomicUsize::new(0);
3423 let parse_errors = AtomicUsize::new(0);
3424
3425 let file_results: Vec<(String, Result<Diagnostics, String>)> = files
3426 .par_iter()
3427 .filter_map(|path| {
3428 let source = fs::read_to_string(path).ok()?;
3429 let path_str = path.display().to_string();
3430 match lint_source_with_config(&source, &path_str, config.clone()) {
3431 Ok(diagnostics) => {
3432 let warnings = diagnostics.iter()
3433 .filter(|d| d.severity == crate::diagnostic::Severity::Warning)
3434 .count();
3435 let errors = diagnostics.iter()
3436 .filter(|d| d.severity == crate::diagnostic::Severity::Error)
3437 .count();
3438 total_warnings.fetch_add(warnings, Ordering::Relaxed);
3439 total_errors.fetch_add(errors, Ordering::Relaxed);
3440 Some((path_str, Ok(diagnostics)))
3441 }
3442 Err(e) => {
3443 parse_errors.fetch_add(1, Ordering::Relaxed);
3444 Some((path_str, Err(e)))
3445 }
3446 }
3447 })
3448 .collect();
3449
3450 DirectoryLintResult {
3451 files: file_results,
3452 total_warnings: total_warnings.load(Ordering::Relaxed),
3453 total_errors: total_errors.load(Ordering::Relaxed),
3454 parse_errors: parse_errors.load(Ordering::Relaxed),
3455 }
3456}
3457
3458#[derive(Debug, Clone)]
3460pub struct WatchConfig {
3461 pub poll_interval_ms: u64,
3463 pub clear_screen: bool,
3465 pub run_on_start: bool,
3467}
3468
3469impl Default for WatchConfig {
3470 fn default() -> Self {
3471 Self {
3472 poll_interval_ms: 500,
3473 clear_screen: true,
3474 run_on_start: true,
3475 }
3476 }
3477}
3478
3479#[derive(Debug)]
3481pub struct WatchResult {
3482 pub changed_files: Vec<String>,
3484 pub lint_result: DirectoryLintResult,
3486}
3487
3488pub fn watch_directory(
3493 dir: &Path,
3494 config: LintConfig,
3495 watch_config: WatchConfig,
3496) -> impl Iterator<Item = WatchResult> {
3497 use std::collections::HashMap;
3498 use std::fs;
3499 use std::time::{Duration, SystemTime};
3500
3501 let dir = dir.to_path_buf();
3502 let poll_interval = Duration::from_millis(watch_config.poll_interval_ms);
3503 let mut file_times: HashMap<std::path::PathBuf, SystemTime> = HashMap::new();
3504 let mut first_run = watch_config.run_on_start;
3505
3506 std::iter::from_fn(move || {
3507 loop {
3508 let files = collect_sigil_files(&dir);
3509 let mut changed = Vec::new();
3510
3511 for path in &files {
3512 if let Ok(metadata) = fs::metadata(path) {
3513 if let Ok(modified) = metadata.modified() {
3514 let prev = file_times.get(path);
3515 if prev.is_none() || prev.is_some_and(|t| *t != modified) {
3516 changed.push(path.display().to_string());
3517 file_times.insert(path.clone(), modified);
3518 }
3519 }
3520 }
3521 }
3522
3523 let current_paths: std::collections::HashSet<_> = files.iter().collect();
3525 file_times.retain(|p, _| current_paths.contains(p));
3526
3527 if first_run || !changed.is_empty() {
3528 first_run = false;
3529 let lint_result = lint_directory_parallel(&dir, config.clone());
3530 return Some(WatchResult {
3531 changed_files: changed,
3532 lint_result,
3533 });
3534 }
3535
3536 std::thread::sleep(poll_interval);
3537 }
3538 })
3539}
3540
3541#[derive(Debug)]
3547pub struct FixResult {
3548 pub source: String,
3550 pub fixes_applied: usize,
3552 pub fixes_skipped: usize,
3554}
3555
3556pub fn apply_fixes(source: &str, diagnostics: &Diagnostics) -> FixResult {
3561 let mut fixes: Vec<(&FixSuggestion, Span)> = diagnostics
3563 .iter()
3564 .flat_map(|d| d.suggestions.iter().map(move |s| (s, s.span)))
3565 .collect();
3566
3567 fixes.sort_by(|a, b| b.1.start.cmp(&a.1.start));
3569
3570 let mut result = source.to_string();
3571 let mut applied = 0;
3572 let mut skipped = 0;
3573 let mut last_end = usize::MAX;
3574
3575 for (fix, span) in fixes {
3576 if span.end > last_end {
3578 skipped += 1;
3579 continue;
3580 }
3581
3582 if span.start > span.end || span.end > result.len() {
3584 skipped += 1;
3585 continue;
3586 }
3587
3588 let before = &result[..span.start];
3590 let after = &result[span.end..];
3591 result = format!("{}{}{}", before, fix.replacement, after);
3592
3593 applied += 1;
3594 last_end = span.start;
3595 }
3596
3597 FixResult {
3598 source: result,
3599 fixes_applied: applied,
3600 fixes_skipped: skipped,
3601 }
3602}
3603
3604pub fn lint_and_fix(source: &str, filename: &str, config: LintConfig) -> Result<(String, Diagnostics, FixResult), String> {
3608 let diagnostics = lint_source_with_config(source, filename, config)?;
3609 let fix_result = apply_fixes(source, &diagnostics);
3610 Ok((fix_result.source.clone(), diagnostics, fix_result))
3611}
3612
3613#[derive(Debug, Clone, Serialize)]
3622pub struct SarifReport {
3623 #[serde(rename = "$schema")]
3624 pub schema: String,
3625 pub version: String,
3626 pub runs: Vec<SarifRun>,
3627}
3628
3629#[derive(Debug, Clone, Serialize)]
3630pub struct SarifRun {
3631 pub tool: SarifTool,
3632 pub results: Vec<SarifResult>,
3633}
3634
3635#[derive(Debug, Clone, Serialize)]
3636pub struct SarifTool {
3637 pub driver: SarifDriver,
3638}
3639
3640#[derive(Debug, Clone, Serialize)]
3641pub struct SarifDriver {
3642 pub name: String,
3643 pub version: String,
3644 #[serde(rename = "informationUri")]
3645 pub information_uri: String,
3646 pub rules: Vec<SarifRule>,
3647}
3648
3649#[derive(Debug, Clone, Serialize)]
3650pub struct SarifRule {
3651 pub id: String,
3652 pub name: String,
3653 #[serde(rename = "shortDescription")]
3654 pub short_description: SarifMessage,
3655 #[serde(rename = "fullDescription")]
3656 pub full_description: SarifMessage,
3657 #[serde(rename = "defaultConfiguration")]
3658 pub default_configuration: SarifConfiguration,
3659 pub properties: SarifRuleProperties,
3660}
3661
3662#[derive(Debug, Clone, Serialize)]
3663pub struct SarifMessage {
3664 pub text: String,
3665}
3666
3667#[derive(Debug, Clone, Serialize)]
3668pub struct SarifConfiguration {
3669 pub level: String,
3670}
3671
3672#[derive(Debug, Clone, Serialize)]
3673pub struct SarifRuleProperties {
3674 pub category: String,
3675}
3676
3677#[derive(Debug, Clone, Serialize)]
3678pub struct SarifResult {
3679 #[serde(rename = "ruleId")]
3680 pub rule_id: String,
3681 pub level: String,
3682 pub message: SarifMessage,
3683 pub locations: Vec<SarifLocation>,
3684 #[serde(skip_serializing_if = "Vec::is_empty")]
3685 pub fixes: Vec<SarifFix>,
3686}
3687
3688#[derive(Debug, Clone, Serialize)]
3689pub struct SarifLocation {
3690 #[serde(rename = "physicalLocation")]
3691 pub physical_location: SarifPhysicalLocation,
3692}
3693
3694#[derive(Debug, Clone, Serialize)]
3695pub struct SarifPhysicalLocation {
3696 #[serde(rename = "artifactLocation")]
3697 pub artifact_location: SarifArtifactLocation,
3698 pub region: SarifRegion,
3699}
3700
3701#[derive(Debug, Clone, Serialize)]
3702pub struct SarifArtifactLocation {
3703 pub uri: String,
3704}
3705
3706#[derive(Debug, Clone, Serialize)]
3707pub struct SarifRegion {
3708 #[serde(rename = "startLine")]
3709 pub start_line: usize,
3710 #[serde(rename = "startColumn")]
3711 pub start_column: usize,
3712 #[serde(rename = "endLine")]
3713 pub end_line: usize,
3714 #[serde(rename = "endColumn")]
3715 pub end_column: usize,
3716}
3717
3718#[derive(Debug, Clone, Serialize)]
3719pub struct SarifFix {
3720 pub description: SarifMessage,
3721 #[serde(rename = "artifactChanges")]
3722 pub artifact_changes: Vec<SarifArtifactChange>,
3723}
3724
3725#[derive(Debug, Clone, Serialize)]
3726pub struct SarifArtifactChange {
3727 #[serde(rename = "artifactLocation")]
3728 pub artifact_location: SarifArtifactLocation,
3729 pub replacements: Vec<SarifReplacement>,
3730}
3731
3732#[derive(Debug, Clone, Serialize)]
3733pub struct SarifReplacement {
3734 #[serde(rename = "deletedRegion")]
3735 pub deleted_region: SarifRegion,
3736 #[serde(rename = "insertedContent")]
3737 pub inserted_content: SarifContent,
3738}
3739
3740#[derive(Debug, Clone, Serialize)]
3741pub struct SarifContent {
3742 pub text: String,
3743}
3744
3745impl SarifReport {
3746 pub fn new() -> Self {
3748 let rules: Vec<SarifRule> = LintId::all()
3749 .iter()
3750 .map(|lint| SarifRule {
3751 id: lint.code().to_string(),
3752 name: lint.name().to_string(),
3753 short_description: SarifMessage {
3754 text: lint.description().to_string(),
3755 },
3756 full_description: SarifMessage {
3757 text: lint.extended_docs().trim().to_string(),
3758 },
3759 default_configuration: SarifConfiguration {
3760 level: match lint.default_level() {
3761 LintLevel::Allow => "none".to_string(),
3762 LintLevel::Warn => "warning".to_string(),
3763 LintLevel::Deny => "error".to_string(),
3764 },
3765 },
3766 properties: SarifRuleProperties {
3767 category: format!("{:?}", lint.category()),
3768 },
3769 })
3770 .collect();
3771
3772 Self {
3773 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
3774 version: "2.1.0".to_string(),
3775 runs: vec![SarifRun {
3776 tool: SarifTool {
3777 driver: SarifDriver {
3778 name: "sigil-lint".to_string(),
3779 version: env!("CARGO_PKG_VERSION").to_string(),
3780 information_uri: "https://github.com/Daemoniorum-LLC/styx".to_string(),
3781 rules,
3782 },
3783 },
3784 results: Vec::new(),
3785 }],
3786 }
3787 }
3788
3789 pub fn add_file(&mut self, filename: &str, diagnostics: &Diagnostics, source: &str) {
3791 let line_starts: Vec<usize> = std::iter::once(0)
3792 .chain(source.match_indices('\n').map(|(i, _)| i + 1))
3793 .collect();
3794
3795 let offset_to_line_col = |offset: usize| -> (usize, usize) {
3796 let line = line_starts.partition_point(|&start| start <= offset);
3797 let col = if line > 0 {
3798 offset - line_starts[line - 1] + 1
3799 } else {
3800 offset + 1
3801 };
3802 (line.max(1), col)
3803 };
3804
3805 for diag in diagnostics.iter() {
3806 let (start_line, start_col) = offset_to_line_col(diag.span.start);
3807 let (end_line, end_col) = offset_to_line_col(diag.span.end);
3808
3809 let level = match diag.severity {
3810 Severity::Error => "error",
3811 Severity::Warning => "warning",
3812 Severity::Info | Severity::Hint => "note",
3813 };
3814
3815 let fixes: Vec<SarifFix> = diag.suggestions.iter().map(|fix| {
3816 let (fix_start_line, fix_start_col) = offset_to_line_col(fix.span.start);
3817 let (fix_end_line, fix_end_col) = offset_to_line_col(fix.span.end);
3818
3819 SarifFix {
3820 description: SarifMessage {
3821 text: fix.message.clone(),
3822 },
3823 artifact_changes: vec![SarifArtifactChange {
3824 artifact_location: SarifArtifactLocation {
3825 uri: filename.to_string(),
3826 },
3827 replacements: vec![SarifReplacement {
3828 deleted_region: SarifRegion {
3829 start_line: fix_start_line,
3830 start_column: fix_start_col,
3831 end_line: fix_end_line,
3832 end_column: fix_end_col,
3833 },
3834 inserted_content: SarifContent {
3835 text: fix.replacement.clone(),
3836 },
3837 }],
3838 }],
3839 }
3840 }).collect();
3841
3842 if let Some(ref mut run) = self.runs.first_mut() {
3843 run.results.push(SarifResult {
3844 rule_id: diag.code.clone().unwrap_or_default(),
3845 level: level.to_string(),
3846 message: SarifMessage {
3847 text: diag.message.clone(),
3848 },
3849 locations: vec![SarifLocation {
3850 physical_location: SarifPhysicalLocation {
3851 artifact_location: SarifArtifactLocation {
3852 uri: filename.to_string(),
3853 },
3854 region: SarifRegion {
3855 start_line,
3856 start_column: start_col,
3857 end_line,
3858 end_column: end_col,
3859 },
3860 },
3861 }],
3862 fixes,
3863 });
3864 }
3865 }
3866 }
3867
3868 pub fn to_json(&self) -> Result<String, String> {
3870 serde_json::to_string_pretty(self)
3871 .map_err(|e| format!("Failed to serialize SARIF: {}", e))
3872 }
3873}
3874
3875impl Default for SarifReport {
3876 fn default() -> Self {
3877 Self::new()
3878 }
3879}
3880
3881pub fn generate_sarif(filename: &str, diagnostics: &Diagnostics, source: &str) -> SarifReport {
3883 let mut report = SarifReport::new();
3884 report.add_file(filename, diagnostics, source);
3885 report
3886}
3887
3888pub fn generate_sarif_for_directory(result: &DirectoryLintResult, sources: &HashMap<String, String>) -> SarifReport {
3890 let mut report = SarifReport::new();
3891
3892 for (path, diag_result) in &result.files {
3893 if let Ok(diagnostics) = diag_result {
3894 if let Some(source) = sources.get(path) {
3895 report.add_file(path, diagnostics, source);
3896 }
3897 }
3898 }
3899
3900 report
3901}
3902
3903pub fn explain_lint(lint: LintId) -> String {
3909 format!(
3910 r#"
3911╔══════════════════════════════════════════════════════════════╗
3912║ {code}: {name}
3913╠══════════════════════════════════════════════════════════════╣
3914║ Category: {category:?}
3915║ Default: {level:?}
3916╚══════════════════════════════════════════════════════════════╝
3917
3918{description}
3919
3920{extended}
3921
3922Configuration:
3923 In .sigillint.toml:
3924 [lint.levels]
3925 {name} = "allow" # or "warn" or "deny"
3926
3927 Inline suppression:
3928 // sigil-lint: allow({code})
3929 let code = here;
3930
3931 Next-line suppression:
3932 // sigil-lint: allow-next-line({code})
3933 let code = here;
3934"#,
3935 code = lint.code(),
3936 name = lint.name(),
3937 category = lint.category(),
3938 level = lint.default_level(),
3939 description = lint.description(),
3940 extended = lint.extended_docs().trim(),
3941 )
3942}
3943
3944pub fn list_lints() -> String {
3946 use std::collections::BTreeMap;
3947
3948 let mut by_category: BTreeMap<LintCategory, Vec<LintId>> = BTreeMap::new();
3949
3950 for lint in LintId::all() {
3951 by_category.entry(lint.category()).or_default().push(*lint);
3952 }
3953
3954 let mut output = String::from("\n╔══════════════════════════════════════════════════════════════╗\n");
3955 output.push_str("║ Sigil Linter Rules ║\n");
3956 output.push_str("╚══════════════════════════════════════════════════════════════╝\n\n");
3957
3958 for (category, lints) in by_category {
3959 output.push_str(&format!("── {:?} ──\n", category));
3960 for lint in lints {
3961 let level_char = match lint.default_level() {
3962 LintLevel::Allow => '○',
3963 LintLevel::Warn => '◐',
3964 LintLevel::Deny => '●',
3965 };
3966 output.push_str(&format!(
3967 " {} {} {}: {}\n",
3968 level_char,
3969 lint.code(),
3970 lint.name(),
3971 lint.description()
3972 ));
3973 }
3974 output.push('\n');
3975 }
3976
3977 output.push_str("Legend: ○ = allow by default, ◐ = warn by default, ● = deny by default\n");
3978 output
3979}
3980
3981#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3987pub enum LspSeverity {
3988 Error = 1,
3989 Warning = 2,
3990 Information = 3,
3991 Hint = 4,
3992}
3993
3994impl From<Severity> for LspSeverity {
3995 fn from(sev: Severity) -> Self {
3996 match sev {
3997 Severity::Error => LspSeverity::Error,
3998 Severity::Warning => LspSeverity::Warning,
3999 Severity::Info => LspSeverity::Information,
4000 Severity::Hint => LspSeverity::Hint,
4001 }
4002 }
4003}
4004
4005#[derive(Debug, Clone, Serialize, Deserialize)]
4007pub struct LspDiagnostic {
4008 pub line: u32,
4010 pub character: u32,
4012 pub end_line: u32,
4014 pub end_character: u32,
4016 pub severity: u32,
4018 pub code: Option<String>,
4020 pub source: String,
4022 pub message: String,
4024 #[serde(skip_serializing_if = "Vec::is_empty")]
4026 pub related_information: Vec<LspRelatedInfo>,
4027 #[serde(skip_serializing_if = "Vec::is_empty")]
4029 pub code_actions: Vec<LspCodeAction>,
4030}
4031
4032#[derive(Debug, Clone, Serialize, Deserialize)]
4034pub struct LspRelatedInfo {
4035 pub uri: String,
4036 pub line: u32,
4037 pub character: u32,
4038 pub message: String,
4039}
4040
4041#[derive(Debug, Clone, Serialize, Deserialize)]
4043pub struct LspCodeAction {
4044 pub title: String,
4045 pub kind: String,
4046 pub edit: LspTextEdit,
4047}
4048
4049#[derive(Debug, Clone, Serialize, Deserialize)]
4051pub struct LspTextEdit {
4052 pub line: u32,
4053 pub character: u32,
4054 pub end_line: u32,
4055 pub end_character: u32,
4056 pub new_text: String,
4057}
4058
4059impl LspDiagnostic {
4060 pub fn from_diagnostic(diag: &Diagnostic, source: &str) -> Self {
4062 let (line, character) = offset_to_position(diag.span.start, source);
4063 let (end_line, end_character) = offset_to_position(diag.span.end, source);
4064
4065 let mut code_actions = Vec::new();
4066
4067 for suggestion in &diag.suggestions {
4069 let (fix_line, fix_char) = offset_to_position(suggestion.span.start, source);
4070 let (fix_end_line, fix_end_char) = offset_to_position(suggestion.span.end, source);
4071
4072 code_actions.push(LspCodeAction {
4073 title: suggestion.message.clone(),
4074 kind: "quickfix".to_string(),
4075 edit: LspTextEdit {
4076 line: fix_line,
4077 character: fix_char,
4078 end_line: fix_end_line,
4079 end_character: fix_end_char,
4080 new_text: suggestion.replacement.clone(),
4081 },
4082 });
4083 }
4084
4085 Self {
4086 line,
4087 character,
4088 end_line,
4089 end_character,
4090 severity: LspSeverity::from(diag.severity) as u32,
4091 code: diag.code.clone(),
4092 source: "sigil-lint".to_string(),
4093 message: diag.message.clone(),
4094 related_information: Vec::new(),
4095 code_actions,
4096 }
4097 }
4098}
4099
4100fn offset_to_position(offset: usize, source: &str) -> (u32, u32) {
4102 let mut line = 0u32;
4103 let mut col = 0u32;
4104
4105 for (i, ch) in source.char_indices() {
4106 if i >= offset {
4107 break;
4108 }
4109 if ch == '\n' {
4110 line += 1;
4111 col = 0;
4112 } else {
4113 col += 1;
4114 }
4115 }
4116
4117 (line, col)
4118}
4119
4120#[derive(Debug, Clone, Serialize, Deserialize)]
4122pub struct LspLintResult {
4123 pub uri: String,
4125 pub version: Option<i32>,
4127 pub diagnostics: Vec<LspDiagnostic>,
4129}
4130
4131pub fn lint_for_lsp(source: &str, uri: &str, config: LintConfig) -> LspLintResult {
4133 let diagnostics = match lint_source_with_config(source, uri, config) {
4134 Ok(diags) => diags
4135 .iter()
4136 .map(|d| LspDiagnostic::from_diagnostic(d, source))
4137 .collect(),
4138 Err(_) => Vec::new(),
4139 };
4140
4141 LspLintResult {
4142 uri: uri.to_string(),
4143 version: None,
4144 diagnostics,
4145 }
4146}
4147
4148#[derive(Debug, Default)]
4150pub struct LspServerState {
4151 pub documents: HashMap<String, (i32, String)>,
4153 pub config: LintConfig,
4155 pub baseline: Option<Baseline>,
4157}
4158
4159impl LspServerState {
4160 pub fn new() -> Self {
4162 Self {
4163 documents: HashMap::new(),
4164 config: LintConfig::find_and_load(),
4165 baseline: find_baseline(),
4166 }
4167 }
4168
4169 pub fn update_document(&mut self, uri: &str, version: i32, content: String) {
4171 self.documents.insert(uri.to_string(), (version, content));
4172 }
4173
4174 pub fn remove_document(&mut self, uri: &str) {
4176 self.documents.remove(uri);
4177 }
4178
4179 pub fn lint_document(&self, uri: &str) -> Option<LspLintResult> {
4181 let (version, content) = self.documents.get(uri)?;
4182
4183 let mut result = lint_for_lsp(content, uri, self.config.clone());
4184 result.version = Some(*version);
4185
4186 if let Some(ref baseline) = self.baseline {
4188 result.diagnostics.retain(|lsp_diag| {
4189 let span = Span::new(0, 0); let diag = Diagnostic {
4192 severity: match lsp_diag.severity {
4193 1 => Severity::Error,
4194 2 => Severity::Warning,
4195 3 => Severity::Info,
4196 _ => Severity::Hint,
4197 },
4198 code: lsp_diag.code.clone(),
4199 message: lsp_diag.message.clone(),
4200 span,
4201 labels: Vec::new(),
4202 notes: Vec::new(),
4203 suggestions: Vec::new(),
4204 related: Vec::new(),
4205 };
4206 !baseline.contains(uri, &diag, content)
4207 });
4208 }
4209
4210 Some(result)
4211 }
4212}
4213
4214#[derive(Debug, Clone)]
4220pub struct GitIntegration {
4221 pub repo_root: PathBuf,
4223}
4224
4225impl GitIntegration {
4226 pub fn from_current_dir() -> Result<Self, String> {
4228 let output = std::process::Command::new("git")
4229 .args(["rev-parse", "--show-toplevel"])
4230 .output()
4231 .map_err(|e| format!("Failed to run git: {}", e))?;
4232
4233 if !output.status.success() {
4234 return Err("Not in a git repository".to_string());
4235 }
4236
4237 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
4238 Ok(Self {
4239 repo_root: PathBuf::from(root),
4240 })
4241 }
4242
4243 pub fn get_changed_files(&self) -> Result<Vec<PathBuf>, String> {
4245 let mut files = HashSet::new();
4246
4247 let staged = self.run_git(&["diff", "--cached", "--name-only"])?;
4249 for line in staged.lines() {
4250 if line.ends_with(".sigil") {
4251 files.insert(self.repo_root.join(line));
4252 }
4253 }
4254
4255 let unstaged = self.run_git(&["diff", "--name-only"])?;
4257 for line in unstaged.lines() {
4258 if line.ends_with(".sigil") {
4259 files.insert(self.repo_root.join(line));
4260 }
4261 }
4262
4263 let untracked = self.run_git(&["ls-files", "--others", "--exclude-standard"])?;
4265 for line in untracked.lines() {
4266 if line.ends_with(".sigil") {
4267 files.insert(self.repo_root.join(line));
4268 }
4269 }
4270
4271 Ok(files.into_iter().collect())
4272 }
4273
4274 pub fn get_changed_since(&self, base: &str) -> Result<Vec<PathBuf>, String> {
4276 let output = self.run_git(&["diff", "--name-only", base])?;
4277 let files: Vec<PathBuf> = output
4278 .lines()
4279 .filter(|line| line.ends_with(".sigil"))
4280 .map(|line| self.repo_root.join(line))
4281 .collect();
4282 Ok(files)
4283 }
4284
4285 fn run_git(&self, args: &[&str]) -> Result<String, String> {
4287 let output = std::process::Command::new("git")
4288 .current_dir(&self.repo_root)
4289 .args(args)
4290 .output()
4291 .map_err(|e| format!("Failed to run git: {}", e))?;
4292
4293 if !output.status.success() {
4294 let stderr = String::from_utf8_lossy(&output.stderr);
4295 return Err(format!("Git command failed: {}", stderr));
4296 }
4297
4298 Ok(String::from_utf8_lossy(&output.stdout).to_string())
4299 }
4300}
4301
4302pub fn lint_changed_files(config: LintConfig) -> Result<DirectoryLintResult, String> {
4304 let git = GitIntegration::from_current_dir()?;
4305 let changed = git.get_changed_files()?;
4306
4307 if changed.is_empty() {
4308 return Ok(DirectoryLintResult {
4309 files: Vec::new(),
4310 total_warnings: 0,
4311 total_errors: 0,
4312 parse_errors: 0,
4313 });
4314 }
4315
4316 lint_files(&changed, config)
4317}
4318
4319pub fn lint_changed_since(base: &str, config: LintConfig) -> Result<DirectoryLintResult, String> {
4321 let git = GitIntegration::from_current_dir()?;
4322 let changed = git.get_changed_since(base)?;
4323
4324 if changed.is_empty() {
4325 return Ok(DirectoryLintResult {
4326 files: Vec::new(),
4327 total_warnings: 0,
4328 total_errors: 0,
4329 parse_errors: 0,
4330 });
4331 }
4332
4333 lint_files(&changed, config)
4334}
4335
4336pub fn lint_files(files: &[PathBuf], config: LintConfig) -> Result<DirectoryLintResult, String> {
4338 use std::fs;
4339
4340 let mut total_warnings = 0;
4341 let mut total_errors = 0;
4342 let mut parse_errors = 0;
4343 let mut results = Vec::new();
4344
4345 for path in files {
4346 let path_str = path.display().to_string();
4347
4348 match fs::read_to_string(path) {
4349 Ok(source) => {
4350 match lint_source_with_config(&source, &path_str, config.clone()) {
4351 Ok(diagnostics) => {
4352 for diag in diagnostics.iter() {
4353 match diag.severity {
4354 Severity::Error => total_errors += 1,
4355 Severity::Warning => total_warnings += 1,
4356 _ => {}
4357 }
4358 }
4359 results.push((path_str, Ok(diagnostics)));
4360 }
4361 Err(e) => {
4362 parse_errors += 1;
4363 results.push((path_str, Err(e)));
4364 }
4365 }
4366 }
4367 Err(e) => {
4368 parse_errors += 1;
4369 results.push((path_str, Err(format!("Failed to read file: {}", e))));
4370 }
4371 }
4372 }
4373
4374 Ok(DirectoryLintResult {
4375 files: results,
4376 total_warnings,
4377 total_errors,
4378 parse_errors,
4379 })
4380}
4381
4382pub const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
4384# Sigil lint pre-commit hook
4385# Generated by sigil lint --generate-hook
4386
4387# Get list of staged .sigil files
4388STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sigil$')
4389
4390if [ -z "$STAGED_FILES" ]; then
4391 exit 0
4392fi
4393
4394echo "Running Sigil linter on staged files..."
4395
4396# Run linter on staged files
4397RESULT=0
4398for FILE in $STAGED_FILES; do
4399 if [ -f "$FILE" ]; then
4400 sigil lint "$FILE"
4401 if [ $? -ne 0 ]; then
4402 RESULT=1
4403 fi
4404 fi
4405done
4406
4407if [ $RESULT -ne 0 ]; then
4408 echo ""
4409 echo "Commit blocked: Please fix lint errors before committing."
4410 echo "Use 'git commit --no-verify' to bypass (not recommended)."
4411 exit 1
4412fi
4413
4414exit 0
4415"#;
4416
4417pub fn generate_pre_commit_hook() -> Result<PathBuf, String> {
4419 let git = GitIntegration::from_current_dir()?;
4420 let hook_path = git.repo_root.join(".git/hooks/pre-commit");
4421
4422 std::fs::write(&hook_path, PRE_COMMIT_HOOK)
4423 .map_err(|e| format!("Failed to write hook: {}", e))?;
4424
4425 #[cfg(unix)]
4427 {
4428 use std::os::unix::fs::PermissionsExt;
4429 let mut perms = std::fs::metadata(&hook_path)
4430 .map_err(|e| format!("Failed to get permissions: {}", e))?
4431 .permissions();
4432 perms.set_mode(0o755);
4433 std::fs::set_permissions(&hook_path, perms)
4434 .map_err(|e| format!("Failed to set permissions: {}", e))?;
4435 }
4436
4437 Ok(hook_path)
4438}
4439
4440#[derive(Debug, Clone, Serialize, Deserialize)]
4446pub struct CustomRule {
4447 pub id: String,
4449 pub name: String,
4451 pub description: String,
4453 pub level: LintLevel,
4455 pub category: String,
4457 pub pattern: CustomPattern,
4459 #[serde(skip_serializing_if = "Option::is_none")]
4461 pub suggestion: Option<String>,
4462 #[serde(skip_serializing_if = "Option::is_none")]
4464 pub docs: Option<String>,
4465}
4466
4467#[derive(Debug, Clone, Serialize, Deserialize)]
4469#[serde(tag = "type")]
4470pub enum CustomPattern {
4471 Regex { pattern: String },
4473 FunctionCall { names: Vec<String> },
4475 Identifier { names: Vec<String> },
4477 Import { modules: Vec<String> },
4479 StringContains { patterns: Vec<String> },
4481 AstNode { node_type: String, conditions: HashMap<String, String> },
4483}
4484
4485#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4487pub struct CustomRulesFile {
4488 #[serde(default = "default_version")]
4490 pub version: u32,
4491 #[serde(default)]
4493 pub rules: Vec<CustomRule>,
4494 #[serde(default)]
4496 pub rulesets: HashMap<String, Vec<String>>,
4497}
4498
4499fn default_version() -> u32 { 1 }
4500
4501impl CustomRulesFile {
4502 pub fn from_file(path: &Path) -> Result<Self, String> {
4504 let content = std::fs::read_to_string(path)
4505 .map_err(|e| format!("Failed to read custom rules: {}", e))?;
4506
4507 if path.extension().map(|e| e == "json").unwrap_or(false) {
4508 serde_json::from_str(&content)
4509 .map_err(|e| format!("Failed to parse JSON: {}", e))
4510 } else {
4511 toml::from_str(&content)
4512 .map_err(|e| format!("Failed to parse TOML: {}", e))
4513 }
4514 }
4515
4516 pub fn find_and_load() -> Option<Self> {
4518 let names = [
4519 ".sigillint-rules.toml",
4520 ".sigillint-rules.json",
4521 "sigillint-rules.toml",
4522 ];
4523
4524 if let Ok(mut dir) = std::env::current_dir() {
4525 loop {
4526 for name in &names {
4527 let path = dir.join(name);
4528 if path.exists() {
4529 if let Ok(rules) = Self::from_file(&path) {
4530 return Some(rules);
4531 }
4532 }
4533 }
4534 if !dir.pop() {
4535 break;
4536 }
4537 }
4538 }
4539
4540 None
4541 }
4542}
4543
4544#[derive(Debug)]
4546pub struct CustomRuleMatch {
4547 pub rule_id: String,
4549 pub span: Span,
4551 pub matched_text: String,
4553}
4554
4555pub struct CustomRuleChecker {
4557 rules: Vec<CustomRule>,
4558 compiled_patterns: HashMap<String, regex::Regex>,
4559}
4560
4561impl CustomRuleChecker {
4562 pub fn new(rules: Vec<CustomRule>) -> Self {
4564 let mut compiled = HashMap::new();
4565
4566 for rule in &rules {
4567 if let CustomPattern::Regex { pattern } = &rule.pattern {
4568 if let Ok(re) = regex::Regex::new(pattern) {
4569 compiled.insert(rule.id.clone(), re);
4570 }
4571 }
4572 }
4573
4574 Self {
4575 rules,
4576 compiled_patterns: compiled,
4577 }
4578 }
4579
4580 pub fn check(&self, source: &str) -> Vec<(CustomRule, CustomRuleMatch)> {
4582 let mut matches = Vec::new();
4583
4584 for rule in &self.rules {
4585 match &rule.pattern {
4586 CustomPattern::Regex { .. } => {
4587 if let Some(re) = self.compiled_patterns.get(&rule.id) {
4588 for m in re.find_iter(source) {
4589 matches.push((
4590 rule.clone(),
4591 CustomRuleMatch {
4592 rule_id: rule.id.clone(),
4593 span: Span::new(m.start(), m.end()),
4594 matched_text: m.as_str().to_string(),
4595 },
4596 ));
4597 }
4598 }
4599 }
4600 CustomPattern::FunctionCall { names } => {
4601 for name in names {
4602 let pattern = format!(r"\b{}\s*\(", regex::escape(name));
4603 if let Ok(re) = regex::Regex::new(&pattern) {
4604 for m in re.find_iter(source) {
4605 matches.push((
4606 rule.clone(),
4607 CustomRuleMatch {
4608 rule_id: rule.id.clone(),
4609 span: Span::new(m.start(), m.end() - 1),
4610 matched_text: name.clone(),
4611 },
4612 ));
4613 }
4614 }
4615 }
4616 }
4617 CustomPattern::Identifier { names } => {
4618 for name in names {
4619 let pattern = format!(r"\b{}\b", regex::escape(name));
4620 if let Ok(re) = regex::Regex::new(&pattern) {
4621 for m in re.find_iter(source) {
4622 matches.push((
4623 rule.clone(),
4624 CustomRuleMatch {
4625 rule_id: rule.id.clone(),
4626 span: Span::new(m.start(), m.end()),
4627 matched_text: name.clone(),
4628 },
4629 ));
4630 }
4631 }
4632 }
4633 }
4634 CustomPattern::StringContains { patterns } => {
4635 let string_re = regex::Regex::new(r#""([^"\\]|\\.)*""#).unwrap();
4637 for string_match in string_re.find_iter(source) {
4638 let string_content = string_match.as_str();
4639 for pattern in patterns {
4640 if string_content.contains(pattern) {
4641 matches.push((
4642 rule.clone(),
4643 CustomRuleMatch {
4644 rule_id: rule.id.clone(),
4645 span: Span::new(string_match.start(), string_match.end()),
4646 matched_text: string_content.to_string(),
4647 },
4648 ));
4649 break;
4650 }
4651 }
4652 }
4653 }
4654 CustomPattern::Import { modules } => {
4655 for module in modules {
4656 let pattern = format!(r"use\s+{}", regex::escape(module));
4657 if let Ok(re) = regex::Regex::new(&pattern) {
4658 for m in re.find_iter(source) {
4659 matches.push((
4660 rule.clone(),
4661 CustomRuleMatch {
4662 rule_id: rule.id.clone(),
4663 span: Span::new(m.start(), m.end()),
4664 matched_text: module.clone(),
4665 },
4666 ));
4667 }
4668 }
4669 }
4670 }
4671 CustomPattern::AstNode { .. } => {
4672 }
4674 }
4675 }
4676
4677 matches
4678 }
4679
4680 pub fn to_diagnostics(&self, source: &str) -> Diagnostics {
4682 let mut diagnostics = Diagnostics::new();
4683
4684 for (rule, m) in self.check(source) {
4685 let severity = match rule.level {
4686 LintLevel::Deny => Severity::Error,
4687 LintLevel::Warn => Severity::Warning,
4688 LintLevel::Allow => continue,
4689 };
4690
4691 let mut diag = Diagnostic {
4692 severity,
4693 code: Some(format!("CUSTOM:{}", rule.id)),
4694 message: rule.description.clone(),
4695 span: m.span,
4696 labels: Vec::new(),
4697 notes: Vec::new(),
4698 suggestions: Vec::new(),
4699 related: Vec::new(),
4700 };
4701
4702 if let Some(ref suggestion) = rule.suggestion {
4703 diag.notes.push(format!("Suggestion: {}", suggestion));
4704 }
4705
4706 diagnostics.add(diag);
4707 }
4708
4709 diagnostics
4710 }
4711}
4712
4713pub fn lint_with_custom_rules(
4715 source: &str,
4716 filename: &str,
4717 config: LintConfig,
4718 custom_rules: &[CustomRule],
4719) -> Result<Diagnostics, String> {
4720 let mut diagnostics = lint_source_with_config(source, filename, config)?;
4722
4723 let checker = CustomRuleChecker::new(custom_rules.to_vec());
4725 let custom_diags = checker.to_diagnostics(source);
4726
4727 for diag in custom_diags.iter() {
4729 diagnostics.add(diag.clone());
4730 }
4731
4732 Ok(diagnostics)
4733}
4734
4735#[derive(Debug, Clone, Default)]
4741pub struct IgnorePatterns {
4742 patterns: Vec<globset::GlobMatcher>,
4744 raw_patterns: Vec<String>,
4746}
4747
4748impl IgnorePatterns {
4749 pub fn new() -> Self {
4751 Self::default()
4752 }
4753
4754 pub fn from_file(path: &Path) -> Result<Self, String> {
4756 let content = std::fs::read_to_string(path)
4757 .map_err(|e| format!("Failed to read ignore file: {}", e))?;
4758 Self::from_string(&content)
4759 }
4760
4761 pub fn from_string(content: &str) -> Result<Self, String> {
4763 let mut patterns = Vec::new();
4764 let mut raw_patterns = Vec::new();
4765
4766 for line in content.lines() {
4767 let line = line.trim();
4768
4769 if line.is_empty() || line.starts_with('#') {
4771 continue;
4772 }
4773
4774 match globset::Glob::new(line) {
4776 Ok(glob) => {
4777 patterns.push(glob.compile_matcher());
4778 raw_patterns.push(line.to_string());
4779 }
4780 Err(e) => {
4781 return Err(format!("Invalid pattern '{}': {}", line, e));
4782 }
4783 }
4784 }
4785
4786 Ok(Self { patterns, raw_patterns })
4787 }
4788
4789 pub fn find_and_load() -> Option<Self> {
4791 let names = [
4792 ".sigillintignore",
4793 ".lintignore",
4794 ];
4795
4796 if let Ok(mut dir) = std::env::current_dir() {
4797 loop {
4798 for name in &names {
4799 let path = dir.join(name);
4800 if path.exists() {
4801 if let Ok(patterns) = Self::from_file(&path) {
4802 return Some(patterns);
4803 }
4804 }
4805 }
4806 if !dir.pop() {
4807 break;
4808 }
4809 }
4810 }
4811
4812 None
4813 }
4814
4815 pub fn is_ignored(&self, path: &Path) -> bool {
4817 let path_str = path.to_string_lossy();
4818
4819 for pattern in &self.patterns {
4820 if pattern.is_match(path) || pattern.is_match(path_str.as_ref()) {
4821 return true;
4822 }
4823 }
4824
4825 false
4826 }
4827
4828 pub fn is_ignored_str(&self, path: &str) -> bool {
4830 self.is_ignored(Path::new(path))
4831 }
4832
4833 pub fn patterns(&self) -> &[String] {
4835 &self.raw_patterns
4836 }
4837
4838 pub fn is_empty(&self) -> bool {
4840 self.patterns.is_empty()
4841 }
4842}
4843
4844pub fn filter_ignored(files: Vec<PathBuf>, ignore: &IgnorePatterns) -> Vec<PathBuf> {
4846 files
4847 .into_iter()
4848 .filter(|f| !ignore.is_ignored(f))
4849 .collect()
4850}
4851
4852pub fn collect_sigil_files_filtered(dir: &Path, ignore: &IgnorePatterns) -> Vec<PathBuf> {
4854 let all_files = collect_sigil_files(dir);
4855 filter_ignored(all_files, ignore)
4856}
4857
4858pub fn lint_directory_filtered(
4860 dir: &Path,
4861 config: LintConfig,
4862 ignore: Option<&IgnorePatterns>,
4863) -> DirectoryLintResult {
4864 let files = if let Some(patterns) = ignore {
4865 collect_sigil_files_filtered(dir, patterns)
4866 } else if let Some(loaded) = IgnorePatterns::find_and_load() {
4867 collect_sigil_files_filtered(dir, &loaded)
4868 } else {
4869 collect_sigil_files(dir)
4870 };
4871
4872 use rayon::prelude::*;
4874 use std::sync::atomic::{AtomicUsize, Ordering};
4875
4876 let total_warnings = AtomicUsize::new(0);
4877 let total_errors = AtomicUsize::new(0);
4878 let parse_errors = AtomicUsize::new(0);
4879
4880 let file_results: Vec<(String, Result<Diagnostics, String>)> = files
4881 .par_iter()
4882 .filter_map(|path| {
4883 let source = std::fs::read_to_string(path).ok()?;
4884 let path_str = path.display().to_string();
4885
4886 match lint_source_with_config(&source, &path_str, config.clone()) {
4887 Ok(diagnostics) => {
4888 let warnings = diagnostics.iter()
4889 .filter(|d| d.severity == Severity::Warning)
4890 .count();
4891 let errors = diagnostics.iter()
4892 .filter(|d| d.severity == Severity::Error)
4893 .count();
4894 total_warnings.fetch_add(warnings, Ordering::Relaxed);
4895 total_errors.fetch_add(errors, Ordering::Relaxed);
4896 Some((path_str, Ok(diagnostics)))
4897 }
4898 Err(e) => {
4899 parse_errors.fetch_add(1, Ordering::Relaxed);
4900 Some((path_str, Err(e)))
4901 }
4902 }
4903 })
4904 .collect();
4905
4906 DirectoryLintResult {
4907 files: file_results,
4908 total_warnings: total_warnings.load(Ordering::Relaxed),
4909 total_errors: total_errors.load(Ordering::Relaxed),
4910 parse_errors: parse_errors.load(Ordering::Relaxed),
4911 }
4912}
4913
4914#[derive(Debug, Clone, Serialize, Deserialize)]
4920pub struct LintReport {
4921 pub timestamp: String,
4923 #[serde(skip_serializing_if = "Option::is_none")]
4925 pub commit: Option<String>,
4926 #[serde(skip_serializing_if = "Option::is_none")]
4928 pub branch: Option<String>,
4929 pub total_files: usize,
4931 pub total_warnings: usize,
4933 pub total_errors: usize,
4935 pub parse_errors: usize,
4937 pub by_rule: HashMap<String, usize>,
4939 pub by_category: HashMap<String, usize>,
4941 pub by_file: Vec<(String, usize)>,
4943}
4944
4945impl LintReport {
4946 pub fn from_result(result: &DirectoryLintResult) -> Self {
4948 let mut by_rule: HashMap<String, usize> = HashMap::new();
4949 let mut by_category: HashMap<String, usize> = HashMap::new();
4950 let mut by_file: Vec<(String, usize)> = Vec::new();
4951
4952 for (path, diag_result) in &result.files {
4953 if let Ok(diagnostics) = diag_result {
4954 let count = diagnostics.iter().count();
4955 if count > 0 {
4956 by_file.push((path.clone(), count));
4957 }
4958
4959 for diag in diagnostics.iter() {
4960 if let Some(ref code) = diag.code {
4961 *by_rule.entry(code.clone()).or_insert(0) += 1;
4962
4963 let category = if code.starts_with('E') {
4965 "error"
4966 } else if code.starts_with('W') {
4967 match &code[1..3] {
4968 "01" | "02" => "style",
4969 "03" | "04" | "05" => "correctness",
4970 _ => "other",
4971 }
4972 } else {
4973 "other"
4974 };
4975 *by_category.entry(category.to_string()).or_insert(0) += 1;
4976 }
4977 }
4978 }
4979 }
4980
4981 by_file.sort_by(|a, b| b.1.cmp(&a.1));
4983 by_file.truncate(20); let (commit, branch) = Self::get_git_info();
4987
4988 Self {
4989 timestamp: chrono_lite_now(),
4990 commit,
4991 branch,
4992 total_files: result.files.len(),
4993 total_warnings: result.total_warnings,
4994 total_errors: result.total_errors,
4995 parse_errors: result.parse_errors,
4996 by_rule,
4997 by_category,
4998 by_file,
4999 }
5000 }
5001
5002 fn get_git_info() -> (Option<String>, Option<String>) {
5004 let commit = std::process::Command::new("git")
5005 .args(["rev-parse", "--short", "HEAD"])
5006 .output()
5007 .ok()
5008 .filter(|o| o.status.success())
5009 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
5010
5011 let branch = std::process::Command::new("git")
5012 .args(["rev-parse", "--abbrev-ref", "HEAD"])
5013 .output()
5014 .ok()
5015 .filter(|o| o.status.success())
5016 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
5017
5018 (commit, branch)
5019 }
5020
5021 pub fn save_json(&self, path: &Path) -> Result<(), String> {
5023 let content = serde_json::to_string_pretty(self)
5024 .map_err(|e| format!("Failed to serialize report: {}", e))?;
5025 std::fs::write(path, content)
5026 .map_err(|e| format!("Failed to write report: {}", e))
5027 }
5028
5029 pub fn load_json(path: &Path) -> Result<Self, String> {
5031 let content = std::fs::read_to_string(path)
5032 .map_err(|e| format!("Failed to read report: {}", e))?;
5033 serde_json::from_str(&content)
5034 .map_err(|e| format!("Failed to parse report: {}", e))
5035 }
5036}
5037
5038#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5040pub struct TrendData {
5041 pub reports: Vec<LintReport>,
5043 pub max_reports: usize,
5045}
5046
5047impl TrendData {
5048 pub fn new(max_reports: usize) -> Self {
5050 Self {
5051 reports: Vec::new(),
5052 max_reports,
5053 }
5054 }
5055
5056 pub fn from_file(path: &Path) -> Result<Self, String> {
5058 let content = std::fs::read_to_string(path)
5059 .map_err(|e| format!("Failed to read trend data: {}", e))?;
5060 serde_json::from_str(&content)
5061 .map_err(|e| format!("Failed to parse trend data: {}", e))
5062 }
5063
5064 pub fn save(&self, path: &Path) -> Result<(), String> {
5066 let content = serde_json::to_string_pretty(self)
5067 .map_err(|e| format!("Failed to serialize trend data: {}", e))?;
5068 std::fs::write(path, content)
5069 .map_err(|e| format!("Failed to write trend data: {}", e))
5070 }
5071
5072 pub fn add_report(&mut self, report: LintReport) {
5074 self.reports.push(report);
5075
5076 if self.reports.len() > self.max_reports {
5078 self.reports.remove(0);
5079 }
5080 }
5081
5082 pub fn summary(&self) -> TrendSummary {
5084 if self.reports.is_empty() {
5085 return TrendSummary::default();
5086 }
5087
5088 let latest = self.reports.last().unwrap();
5089 let previous = if self.reports.len() > 1 {
5090 Some(&self.reports[self.reports.len() - 2])
5091 } else {
5092 None
5093 };
5094
5095 let warning_delta = previous
5096 .map(|p| latest.total_warnings as i64 - p.total_warnings as i64)
5097 .unwrap_or(0);
5098 let error_delta = previous
5099 .map(|p| latest.total_errors as i64 - p.total_errors as i64)
5100 .unwrap_or(0);
5101
5102 TrendSummary {
5103 total_reports: self.reports.len(),
5104 latest_warnings: latest.total_warnings,
5105 latest_errors: latest.total_errors,
5106 warning_delta,
5107 error_delta,
5108 trend_direction: if warning_delta + error_delta < 0 {
5109 TrendDirection::Improving
5110 } else if warning_delta + error_delta > 0 {
5111 TrendDirection::Degrading
5112 } else {
5113 TrendDirection::Stable
5114 },
5115 }
5116 }
5117}
5118
5119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5121pub enum TrendDirection {
5122 Improving,
5123 Stable,
5124 Degrading,
5125}
5126
5127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5129pub struct TrendSummary {
5130 pub total_reports: usize,
5131 pub latest_warnings: usize,
5132 pub latest_errors: usize,
5133 pub warning_delta: i64,
5134 pub error_delta: i64,
5135 pub trend_direction: TrendDirection,
5136}
5137
5138impl Default for TrendDirection {
5139 fn default() -> Self {
5140 TrendDirection::Stable
5141 }
5142}
5143
5144pub fn generate_html_report(result: &DirectoryLintResult, title: &str) -> String {
5146 let report = LintReport::from_result(result);
5147
5148 let mut html = String::new();
5149
5150 html.push_str(&format!(r#"<!DOCTYPE html>
5152<html lang="en">
5153<head>
5154 <meta charset="UTF-8">
5155 <meta name="viewport" content="width=device-width, initial-scale=1.0">
5156 <title>{} - Sigil Lint Report</title>
5157 <style>
5158 :root {{
5159 --bg-primary: #1a1a2e;
5160 --bg-secondary: #16213e;
5161 --bg-card: #0f3460;
5162 --text-primary: #eee;
5163 --text-secondary: #aaa;
5164 --accent: #e94560;
5165 --success: #4ecca3;
5166 --warning: #ffc107;
5167 --error: #e94560;
5168 }}
5169 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
5170 body {{
5171 font-family: 'Segoe UI', system-ui, sans-serif;
5172 background: var(--bg-primary);
5173 color: var(--text-primary);
5174 line-height: 1.6;
5175 padding: 2rem;
5176 }}
5177 .container {{ max-width: 1200px; margin: 0 auto; }}
5178 h1 {{ color: var(--accent); margin-bottom: 0.5rem; }}
5179 .meta {{ color: var(--text-secondary); margin-bottom: 2rem; }}
5180 .stats {{
5181 display: grid;
5182 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
5183 gap: 1rem;
5184 margin-bottom: 2rem;
5185 }}
5186 .stat-card {{
5187 background: var(--bg-card);
5188 padding: 1.5rem;
5189 border-radius: 8px;
5190 text-align: center;
5191 }}
5192 .stat-value {{ font-size: 2.5rem; font-weight: bold; }}
5193 .stat-label {{ color: var(--text-secondary); }}
5194 .stat-value.errors {{ color: var(--error); }}
5195 .stat-value.warnings {{ color: var(--warning); }}
5196 .stat-value.success {{ color: var(--success); }}
5197 .section {{ margin-bottom: 2rem; }}
5198 .section h2 {{
5199 color: var(--accent);
5200 border-bottom: 2px solid var(--bg-card);
5201 padding-bottom: 0.5rem;
5202 margin-bottom: 1rem;
5203 }}
5204 table {{
5205 width: 100%;
5206 border-collapse: collapse;
5207 background: var(--bg-secondary);
5208 border-radius: 8px;
5209 overflow: hidden;
5210 }}
5211 th, td {{
5212 padding: 0.75rem 1rem;
5213 text-align: left;
5214 border-bottom: 1px solid var(--bg-card);
5215 }}
5216 th {{ background: var(--bg-card); color: var(--accent); }}
5217 tr:hover {{ background: var(--bg-card); }}
5218 .bar {{
5219 height: 8px;
5220 background: var(--bg-card);
5221 border-radius: 4px;
5222 overflow: hidden;
5223 }}
5224 .bar-fill {{
5225 height: 100%;
5226 background: var(--accent);
5227 transition: width 0.3s ease;
5228 }}
5229 .chart {{
5230 display: flex;
5231 align-items: flex-end;
5232 gap: 0.5rem;
5233 height: 150px;
5234 padding: 1rem;
5235 background: var(--bg-secondary);
5236 border-radius: 8px;
5237 }}
5238 .chart-bar {{
5239 flex: 1;
5240 background: var(--accent);
5241 border-radius: 4px 4px 0 0;
5242 min-width: 20px;
5243 position: relative;
5244 }}
5245 .chart-bar:hover {{ opacity: 0.8; }}
5246 .chart-label {{
5247 position: absolute;
5248 bottom: -1.5rem;
5249 left: 50%;
5250 transform: translateX(-50%);
5251 font-size: 0.75rem;
5252 color: var(--text-secondary);
5253 }}
5254 </style>
5255</head>
5256<body>
5257 <div class="container">
5258 <h1>🔮 {}</h1>
5259 <p class="meta">Generated: {} | Commit: {} | Branch: {}</p>
5260
5261 <div class="stats">
5262 <div class="stat-card">
5263 <div class="stat-value">{}</div>
5264 <div class="stat-label">Files Analyzed</div>
5265 </div>
5266 <div class="stat-card">
5267 <div class="stat-value errors">{}</div>
5268 <div class="stat-label">Errors</div>
5269 </div>
5270 <div class="stat-card">
5271 <div class="stat-value warnings">{}</div>
5272 <div class="stat-label">Warnings</div>
5273 </div>
5274 <div class="stat-card">
5275 <div class="stat-value success">{}</div>
5276 <div class="stat-label">Clean Files</div>
5277 </div>
5278 </div>
5279"#,
5280 title,
5281 title,
5282 report.timestamp,
5283 report.commit.as_deref().unwrap_or("N/A"),
5284 report.branch.as_deref().unwrap_or("N/A"),
5285 report.total_files,
5286 report.total_errors,
5287 report.total_warnings,
5288 report.total_files - report.by_file.len()
5289 ));
5290
5291 if !report.by_rule.is_empty() {
5293 let max_count = *report.by_rule.values().max().unwrap_or(&1);
5294 let mut rules: Vec<_> = report.by_rule.iter().collect();
5295 rules.sort_by(|a, b| b.1.cmp(a.1));
5296
5297 html.push_str(r#" <div class="section">
5298 <h2>Issues by Rule</h2>
5299 <table>
5300 <thead>
5301 <tr><th>Rule</th><th>Count</th><th>Distribution</th></tr>
5302 </thead>
5303 <tbody>
5304"#);
5305
5306 for (rule, count) in rules.iter().take(15) {
5307 let pct = (**count as f64 / max_count as f64) * 100.0;
5308 html.push_str(&format!(
5309 r#" <tr>
5310 <td><code>{}</code></td>
5311 <td>{}</td>
5312 <td><div class="bar"><div class="bar-fill" style="width: {:.1}%"></div></div></td>
5313 </tr>
5314"#,
5315 rule, count, pct
5316 ));
5317 }
5318
5319 html.push_str(" </tbody>\n </table>\n </div>\n\n");
5320 }
5321
5322 if !report.by_file.is_empty() {
5324 html.push_str(r#" <div class="section">
5325 <h2>Files with Most Issues</h2>
5326 <table>
5327 <thead>
5328 <tr><th>File</th><th>Issues</th></tr>
5329 </thead>
5330 <tbody>
5331"#);
5332
5333 for (file, count) in report.by_file.iter().take(10) {
5334 let short_file = if file.len() > 60 {
5335 format!("...{}", &file[file.len() - 57..])
5336 } else {
5337 file.clone()
5338 };
5339 html.push_str(&format!(
5340 " <tr><td><code>{}</code></td><td>{}</td></tr>\n",
5341 short_file, count
5342 ));
5343 }
5344
5345 html.push_str(" </tbody>\n </table>\n </div>\n\n");
5346 }
5347
5348 html.push_str(r#" <div class="section" style="text-align: center; color: var(--text-secondary); margin-top: 3rem;">
5350 <p>Generated by Sigil Linter v0.2.1</p>
5351 </div>
5352 </div>
5353</body>
5354</html>
5355"#);
5356
5357 html
5358}
5359
5360pub fn save_html_report(result: &DirectoryLintResult, path: &Path, title: &str) -> Result<(), String> {
5362 let html = generate_html_report(result, title);
5363 std::fs::write(path, html)
5364 .map_err(|e| format!("Failed to write HTML report: {}", e))
5365}
5366
5367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5369pub enum CiFormat {
5370 GitHub,
5372 GitLab,
5374 AzureDevOps,
5376 Generic,
5378}
5379
5380pub fn generate_ci_annotations(result: &DirectoryLintResult, format: CiFormat) -> String {
5382 let mut output = String::new();
5383
5384 for (path, diag_result) in &result.files {
5385 if let Ok(diagnostics) = diag_result {
5386 for diag in diagnostics.iter() {
5387 let line = 1; match format {
5390 CiFormat::GitHub => {
5391 let level = match diag.severity {
5392 Severity::Error => "error",
5393 Severity::Warning => "warning",
5394 _ => "notice",
5395 };
5396 output.push_str(&format!(
5397 "::{} file={},line={}::{}\n",
5398 level,
5399 path,
5400 line,
5401 diag.message.replace('\n', "%0A")
5402 ));
5403 }
5404 CiFormat::GitLab => {
5405 output.push_str(&format!(
5406 "{}:{}:{}: {}\n",
5407 path,
5408 line,
5409 if diag.severity == Severity::Error { "error" } else { "warning" },
5410 diag.message
5411 ));
5412 }
5413 CiFormat::AzureDevOps => {
5414 let level = match diag.severity {
5415 Severity::Error => "error",
5416 Severity::Warning => "warning",
5417 _ => "debug",
5418 };
5419 output.push_str(&format!(
5420 "##vso[task.logissue type={};sourcepath={};linenumber={}]{}\n",
5421 level, path, line, diag.message
5422 ));
5423 }
5424 CiFormat::Generic => {
5425 output.push_str(&format!(
5426 "{}:{}: {}: {}\n",
5427 path,
5428 line,
5429 if diag.severity == Severity::Error { "error" } else { "warning" },
5430 diag.message
5431 ));
5432 }
5433 }
5434 }
5435 }
5436 }
5437
5438 output
5439}
5440
5441#[cfg(test)]
5446mod tests {
5447 use super::*;
5448
5449 #[test]
5450 fn test_lint_level_defaults() {
5451 assert_eq!(LintId::ReservedIdentifier.default_level(), LintLevel::Warn);
5452 assert_eq!(LintId::EvidentialityViolation.default_level(), LintLevel::Deny);
5453 assert_eq!(LintId::PreferUnicodeMorpheme.default_level(), LintLevel::Allow);
5454 }
5455
5456 #[test]
5457 fn test_lint_codes() {
5458 assert_eq!(LintId::ReservedIdentifier.code(), "W0101");
5459 assert_eq!(LintId::EvidentialityViolation.code(), "E0600");
5460 }
5461
5462 #[test]
5463 fn test_reserved_words() {
5464 let config = LintConfig::default();
5465 assert!(config.reserved_words.contains("location"));
5466 assert!(config.reserved_words.contains("save"));
5467 assert!(config.reserved_words.contains("from"));
5468 }
5469
5470 #[test]
5475 fn test_aether_lint_codes() {
5476 assert_eq!(LintId::EvidentialityMismatch.code(), "E0603");
5478 assert_eq!(LintId::UncertaintyUnhandled.code(), "E0604");
5479 assert_eq!(LintId::ReportedWithoutAttribution.code(), "E0605");
5480
5481 assert_eq!(LintId::BrokenMorphemePipeline.code(), "W0501");
5483 assert_eq!(LintId::MorphemeTypeIncompatibility.code(), "W0502");
5484 assert_eq!(LintId::InconsistentMorphemeStyle.code(), "W0503");
5485
5486 assert_eq!(LintId::InvalidHexagramNumber.code(), "W0600");
5488 assert_eq!(LintId::InvalidTarotNumber.code(), "W0601");
5489 assert_eq!(LintId::InvalidChakraIndex.code(), "W0602");
5490 assert_eq!(LintId::InvalidZodiacIndex.code(), "W0603");
5491 assert_eq!(LintId::InvalidGematriaValue.code(), "W0604");
5492 assert_eq!(LintId::FrequencyOutOfRange.code(), "W0605");
5493
5494 assert_eq!(LintId::MissingEvidentialityMarker.code(), "W0700");
5496 assert_eq!(LintId::PreferNamedEsotericConstant.code(), "W0701");
5497 assert_eq!(LintId::EmotionIntensityOutOfRange.code(), "W0702");
5498 }
5499
5500 #[test]
5501 fn test_aether_lint_names() {
5502 assert_eq!(LintId::EvidentialityMismatch.name(), "evidentiality_mismatch");
5503 assert_eq!(LintId::InvalidHexagramNumber.name(), "invalid_hexagram_number");
5504 assert_eq!(LintId::FrequencyOutOfRange.name(), "frequency_out_of_range");
5505 assert_eq!(LintId::PreferNamedEsotericConstant.name(), "prefer_named_esoteric_constant");
5506 }
5507
5508 #[test]
5509 fn test_aether_lint_levels() {
5510 assert_eq!(LintId::EvidentialityMismatch.default_level(), LintLevel::Deny);
5512 assert_eq!(LintId::BrokenMorphemePipeline.default_level(), LintLevel::Deny);
5513 assert_eq!(LintId::MorphemeTypeIncompatibility.default_level(), LintLevel::Deny);
5514
5515 assert_eq!(LintId::InvalidHexagramNumber.default_level(), LintLevel::Warn);
5517 assert_eq!(LintId::InvalidTarotNumber.default_level(), LintLevel::Warn);
5518 assert_eq!(LintId::InvalidChakraIndex.default_level(), LintLevel::Warn);
5519 assert_eq!(LintId::InvalidZodiacIndex.default_level(), LintLevel::Warn);
5520 assert_eq!(LintId::FrequencyOutOfRange.default_level(), LintLevel::Warn);
5521
5522 assert_eq!(LintId::InconsistentMorphemeStyle.default_level(), LintLevel::Allow);
5524 assert_eq!(LintId::MissingEvidentialityMarker.default_level(), LintLevel::Allow);
5525 assert_eq!(LintId::PreferNamedEsotericConstant.default_level(), LintLevel::Allow);
5526 }
5527
5528 #[test]
5529 fn test_aether_lint_categories() {
5530 assert_eq!(LintId::EvidentialityMismatch.category(), LintCategory::Sigil);
5532 assert_eq!(LintId::UncertaintyUnhandled.category(), LintCategory::Sigil);
5533 assert_eq!(LintId::BrokenMorphemePipeline.category(), LintCategory::Sigil);
5534 assert_eq!(LintId::MissingEvidentialityMarker.category(), LintCategory::Sigil);
5535
5536 assert_eq!(LintId::InvalidHexagramNumber.category(), LintCategory::Correctness);
5538 assert_eq!(LintId::InvalidTarotNumber.category(), LintCategory::Correctness);
5539 assert_eq!(LintId::FrequencyOutOfRange.category(), LintCategory::Correctness);
5540
5541 assert_eq!(LintId::InconsistentMorphemeStyle.category(), LintCategory::Style);
5543 }
5544
5545 #[test]
5546 fn test_aether_lint_descriptions() {
5547 assert!(!LintId::EvidentialityMismatch.description().is_empty());
5549 assert!(!LintId::InvalidHexagramNumber.description().is_empty());
5550 assert!(!LintId::FrequencyOutOfRange.description().is_empty());
5551
5552 assert!(LintId::InvalidHexagramNumber.description().contains("1") &&
5554 LintId::InvalidHexagramNumber.description().contains("64"));
5555 assert!(LintId::InvalidTarotNumber.description().contains("0") &&
5556 LintId::InvalidTarotNumber.description().contains("21"));
5557 assert!(LintId::FrequencyOutOfRange.description().contains("20Hz") ||
5558 LintId::FrequencyOutOfRange.description().contains("20kHz"));
5559 }
5560
5561 #[test]
5562 fn test_all_includes_aether_rules() {
5563 let all = LintId::all();
5564
5565 assert!(all.contains(&LintId::EvidentialityMismatch));
5567 assert!(all.contains(&LintId::UncertaintyUnhandled));
5568 assert!(all.contains(&LintId::ReportedWithoutAttribution));
5569 assert!(all.contains(&LintId::BrokenMorphemePipeline));
5570 assert!(all.contains(&LintId::InvalidHexagramNumber));
5571 assert!(all.contains(&LintId::InvalidTarotNumber));
5572 assert!(all.contains(&LintId::InvalidChakraIndex));
5573 assert!(all.contains(&LintId::InvalidZodiacIndex));
5574 assert!(all.contains(&LintId::FrequencyOutOfRange));
5575 assert!(all.contains(&LintId::PreferNamedEsotericConstant));
5576 assert!(all.contains(&LintId::EmotionIntensityOutOfRange));
5577 }
5578
5579 #[test]
5580 fn test_lint_count() {
5581 let all = LintId::all();
5583 assert_eq!(all.len(), 44);
5584 }
5585
5586 #[test]
5587 fn test_from_str_aether_rules() {
5588 assert_eq!(LintId::from_str("E0603"), Some(LintId::EvidentialityMismatch));
5590 assert_eq!(LintId::from_str("W0600"), Some(LintId::InvalidHexagramNumber));
5591 assert_eq!(LintId::from_str("W0605"), Some(LintId::FrequencyOutOfRange));
5592
5593 assert_eq!(LintId::from_str("evidentiality_mismatch"), Some(LintId::EvidentialityMismatch));
5595 assert_eq!(LintId::from_str("invalid_hexagram_number"), Some(LintId::InvalidHexagramNumber));
5596 assert_eq!(LintId::from_str("frequency_out_of_range"), Some(LintId::FrequencyOutOfRange));
5597 }
5598}