sigil_parser/
lint.rs

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