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