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