Skip to main content

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