ass_core/analysis/linting/
mod.rs

1//! Linting and validation for ASS subtitle scripts.
2//!
3//! Provides comprehensive linting capabilities to detect common issues, spec violations,
4//! and performance problems in ASS scripts. Designed for editor integration with
5//! configurable severity levels and extensible rule system.
6//!
7//! # Features
8//!
9//! - **Comprehensive validation**: Timing, styling, formatting, and spec compliance
10//! - **Configurable severity**: Error, warning, info, and hint levels
11//! - **Extensible rules**: Trait-based system for custom linting rules
12//! - **Performance optimized**: Zero-copy analysis with <1ms per rule
13//! - **Editor integration**: Rich diagnostic information with precise locations
14//!
15//! # Built-in Rules
16//!
17//! - Timing validation: Overlaps, negative durations, unrealistic timing
18//! - Style validation: Missing styles, invalid colors, font issues
19//! - Text validation: Encoding issues, malformed tags, accessibility
20//! - Performance: Complex animations, large fonts, excessive overlaps
21//! - Spec compliance: Invalid sections, deprecated features, compatibility
22
23use crate::{
24    analysis::{AnalysisConfig, ScriptAnalysis},
25    parser::Script,
26    Result,
27};
28use alloc::{string::String, vec::Vec};
29use core::fmt;
30
31pub mod rules;
32
33pub use rules::BuiltinRules;
34
35/// Severity level for lint issues.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum IssueSeverity {
38    /// Informational message - no action required
39    Info,
40    /// Hint for improvement - optional fix
41    Hint,
42    /// Warning - should be addressed but not critical
43    Warning,
44    /// Error - must be fixed for proper functionality
45    Error,
46    /// Critical error - script may not work at all
47    Critical,
48}
49
50impl fmt::Display for IssueSeverity {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Info => write!(f, "info"),
54            Self::Hint => write!(f, "hint"),
55            Self::Warning => write!(f, "warning"),
56            Self::Error => write!(f, "error"),
57            Self::Critical => write!(f, "critical"),
58        }
59    }
60}
61
62/// Category of lint issue.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum IssueCategory {
65    /// Timing-related issues
66    Timing,
67    /// Style definition problems
68    Styling,
69    /// Text content issues
70    Content,
71    /// Performance concerns
72    Performance,
73    /// Spec compliance violations
74    Compliance,
75    /// Accessibility concerns
76    Accessibility,
77    /// Encoding or character issues
78    Encoding,
79}
80
81impl fmt::Display for IssueCategory {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Timing => write!(f, "timing"),
85            Self::Styling => write!(f, "styling"),
86            Self::Content => write!(f, "content"),
87            Self::Performance => write!(f, "performance"),
88            Self::Compliance => write!(f, "compliance"),
89            Self::Accessibility => write!(f, "accessibility"),
90            Self::Encoding => write!(f, "encoding"),
91        }
92    }
93}
94
95/// Location information for a lint issue.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct IssueLocation {
98    /// Line number (1-based)
99    pub line: usize,
100    /// Column number (1-based)
101    pub column: usize,
102    /// Byte offset in source
103    pub offset: usize,
104    /// Length of the problematic span
105    pub length: usize,
106    /// The problematic text span
107    pub span: String,
108}
109
110/// A single lint issue found in the script.
111#[derive(Debug, Clone)]
112pub struct LintIssue {
113    /// Severity level
114    severity: IssueSeverity,
115    /// Category of issue
116    category: IssueCategory,
117    /// Human-readable message
118    message: String,
119    /// Optional detailed description
120    description: Option<String>,
121    /// Location in source (if available)
122    location: Option<IssueLocation>,
123    /// Rule ID that generated this issue
124    rule_id: &'static str,
125    /// Suggested fix (if available)
126    suggested_fix: Option<String>,
127}
128
129impl LintIssue {
130    /// Create a new lint issue.
131    #[must_use]
132    pub const fn new(
133        severity: IssueSeverity,
134        category: IssueCategory,
135        rule_id: &'static str,
136        message: String,
137    ) -> Self {
138        Self {
139            severity,
140            category,
141            message,
142            description: None,
143            location: None,
144            rule_id,
145            suggested_fix: None,
146        }
147    }
148
149    /// Add detailed description.
150    #[must_use]
151    pub fn with_description(mut self, description: String) -> Self {
152        self.description = Some(description);
153        self
154    }
155
156    /// Add location information.
157    #[must_use]
158    pub fn with_location(mut self, location: IssueLocation) -> Self {
159        self.location = Some(location);
160        self
161    }
162
163    /// Add suggested fix.
164    #[must_use]
165    pub fn with_suggested_fix(mut self, fix: String) -> Self {
166        self.suggested_fix = Some(fix);
167        self
168    }
169
170    /// Get severity level.
171    #[must_use]
172    pub const fn severity(&self) -> IssueSeverity {
173        self.severity
174    }
175
176    /// Get issue category.
177    #[must_use]
178    pub const fn category(&self) -> IssueCategory {
179        self.category
180    }
181
182    /// Get issue message.
183    #[must_use]
184    pub fn message(&self) -> &str {
185        &self.message
186    }
187
188    /// Get detailed description.
189    #[must_use]
190    pub fn description(&self) -> Option<&str> {
191        self.description.as_deref()
192    }
193
194    /// Get location information.
195    #[must_use]
196    pub const fn location(&self) -> Option<&IssueLocation> {
197        self.location.as_ref()
198    }
199
200    /// Get rule ID.
201    #[must_use]
202    pub const fn rule_id(&self) -> &'static str {
203        self.rule_id
204    }
205
206    /// Get suggested fix.
207    #[must_use]
208    pub fn suggested_fix(&self) -> Option<&str> {
209        self.suggested_fix.as_deref()
210    }
211}
212
213/// Configuration for linting behavior.
214#[derive(Debug, Clone)]
215pub struct LintConfig {
216    /// Minimum severity level to report
217    pub min_severity: IssueSeverity,
218    /// Maximum number of issues to report (0 = unlimited)
219    pub max_issues: usize,
220    /// Enable strict compliance mode
221    pub strict_mode: bool,
222    /// Enabled rule IDs (empty = all enabled)
223    pub enabled_rules: Vec<&'static str>,
224    /// Disabled rule IDs
225    pub disabled_rules: Vec<&'static str>,
226}
227
228impl Default for LintConfig {
229    fn default() -> Self {
230        Self {
231            min_severity: IssueSeverity::Info,
232            max_issues: 0, // Unlimited
233            strict_mode: false,
234            enabled_rules: Vec::new(),
235            disabled_rules: Vec::new(),
236        }
237    }
238}
239
240impl LintConfig {
241    /// Set minimum severity level.
242    #[must_use]
243    pub const fn with_min_severity(mut self, severity: IssueSeverity) -> Self {
244        self.min_severity = severity;
245        self
246    }
247
248    /// Set maximum number of issues.
249    #[must_use]
250    pub const fn with_max_issues(mut self, max: usize) -> Self {
251        self.max_issues = max;
252        self
253    }
254
255    /// Enable strict compliance checking.
256    #[must_use]
257    pub const fn with_strict_compliance(mut self, enabled: bool) -> Self {
258        self.strict_mode = enabled;
259        self
260    }
261
262    /// Check if a rule is enabled.
263    #[must_use]
264    pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
265        if self.disabled_rules.contains(&rule_id) {
266            return false;
267        }
268        self.enabled_rules.is_empty() || self.enabled_rules.contains(&rule_id)
269    }
270
271    /// Check if severity should be reported.
272    #[must_use]
273    pub fn should_report_severity(&self, severity: IssueSeverity) -> bool {
274        severity >= self.min_severity
275    }
276}
277
278/// Trait for implementing custom lint rules.
279pub trait LintRule: Send + Sync {
280    /// Unique identifier for this rule.
281    fn id(&self) -> &'static str;
282
283    /// Human-readable name.
284    fn name(&self) -> &'static str;
285
286    /// Rule description.
287    fn description(&self) -> &'static str;
288
289    /// Default severity level.
290    fn default_severity(&self) -> IssueSeverity;
291
292    /// Issue category this rule checks for.
293    fn category(&self) -> IssueCategory;
294
295    /// Check script and return issues.
296    fn check_script(&self, analysis: &ScriptAnalysis) -> Vec<LintIssue>;
297}
298
299/// Lint a script with the given configuration.
300/// Lint script with existing analysis
301///
302/// Runs all enabled rules against the provided analysis and returns found issues,
303/// respecting the configuration limits and filters.
304/// Lint script using existing analysis
305///
306/// # Errors
307///
308/// Returns an error if linting rule execution fails.
309pub fn lint_script_with_analysis(
310    analysis: &ScriptAnalysis,
311    config: &LintConfig,
312) -> Result<Vec<LintIssue>> {
313    let mut issues = Vec::new();
314    let rules = BuiltinRules::all_rules();
315
316    for rule in rules {
317        if !config.is_rule_enabled(rule.id()) {
318            continue;
319        }
320
321        let mut rule_issues = rule.check_script(analysis);
322        rule_issues.retain(|issue| config.should_report_severity(issue.severity()));
323
324        issues.extend(rule_issues);
325
326        if config.max_issues > 0 && issues.len() >= config.max_issues {
327            issues.truncate(config.max_issues);
328            break;
329        }
330    }
331
332    Ok(issues)
333}
334
335/// Lint script with configuration
336///
337/// Creates a minimal analysis without linting, then runs all enabled rules
338/// against the script and returns found issues, respecting the configuration
339/// limits and filters.
340///
341/// # Errors
342///
343/// Returns an error if script analysis or linting rule execution fails.
344pub fn lint_script(script: &Script, config: &LintConfig) -> Result<Vec<LintIssue>> {
345    // Create analysis without linting to avoid circular dependency
346    let mut analysis = ScriptAnalysis {
347        script,
348        lint_issues: Vec::new(),
349        resolved_styles: Vec::new(),
350        dialogue_info: Vec::new(),
351        config: AnalysisConfig::default(),
352        #[cfg(feature = "plugins")]
353        registry: None,
354    };
355
356    // Run only style resolution and event analysis (no linting)
357    analysis.resolve_all_styles();
358    analysis.analyze_events();
359
360    // Now run linting with the prepared analysis
361    lint_script_with_analysis(&analysis, config)
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::parser::Script;
368    #[cfg(not(feature = "std"))]
369    use alloc::string::ToString;
370
371    #[test]
372    fn issue_severity_display() {
373        assert_eq!(IssueSeverity::Info.to_string(), "info");
374        assert_eq!(IssueSeverity::Hint.to_string(), "hint");
375        assert_eq!(IssueSeverity::Warning.to_string(), "warning");
376        assert_eq!(IssueSeverity::Error.to_string(), "error");
377        assert_eq!(IssueSeverity::Critical.to_string(), "critical");
378    }
379
380    #[test]
381    fn issue_severity_ordering() {
382        assert!(IssueSeverity::Info < IssueSeverity::Hint);
383        assert!(IssueSeverity::Hint < IssueSeverity::Warning);
384        assert!(IssueSeverity::Warning < IssueSeverity::Error);
385        assert!(IssueSeverity::Error < IssueSeverity::Critical);
386    }
387
388    #[test]
389    fn issue_category_display() {
390        assert_eq!(IssueCategory::Timing.to_string(), "timing");
391        assert_eq!(IssueCategory::Styling.to_string(), "styling");
392        assert_eq!(IssueCategory::Content.to_string(), "content");
393        assert_eq!(IssueCategory::Performance.to_string(), "performance");
394        assert_eq!(IssueCategory::Compliance.to_string(), "compliance");
395        assert_eq!(IssueCategory::Accessibility.to_string(), "accessibility");
396        assert_eq!(IssueCategory::Encoding.to_string(), "encoding");
397    }
398
399    #[test]
400    fn issue_location_creation() {
401        let location = IssueLocation {
402            line: 42,
403            column: 10,
404            offset: 1000,
405            length: 5,
406            span: "error".to_string(),
407        };
408
409        assert_eq!(location.line, 42);
410        assert_eq!(location.column, 10);
411        assert_eq!(location.offset, 1000);
412        assert_eq!(location.length, 5);
413        assert_eq!(location.span, "error");
414    }
415
416    #[test]
417    fn lint_issue_creation() {
418        let issue = LintIssue::new(
419            IssueSeverity::Warning,
420            IssueCategory::Timing,
421            "test_rule",
422            "Test message".to_string(),
423        );
424
425        assert_eq!(issue.severity(), IssueSeverity::Warning);
426        assert_eq!(issue.category(), IssueCategory::Timing);
427        assert_eq!(issue.message(), "Test message");
428        assert_eq!(issue.rule_id(), "test_rule");
429        assert!(issue.description().is_none());
430        assert!(issue.location().is_none());
431        assert!(issue.suggested_fix().is_none());
432    }
433
434    #[test]
435    fn lint_issue_with_description() {
436        let issue = LintIssue::new(
437            IssueSeverity::Error,
438            IssueCategory::Styling,
439            "style_rule",
440            "Style error".to_string(),
441        )
442        .with_description("Detailed description".to_string());
443
444        assert_eq!(issue.description(), Some("Detailed description"));
445    }
446
447    #[test]
448    fn lint_issue_with_location() {
449        let location = IssueLocation {
450            line: 5,
451            column: 2,
452            offset: 100,
453            length: 3,
454            span: "bad".to_string(),
455        };
456
457        let issue = LintIssue::new(
458            IssueSeverity::Critical,
459            IssueCategory::Content,
460            "content_rule",
461            "Content error".to_string(),
462        )
463        .with_location(location);
464
465        let loc = issue.location().unwrap();
466        assert_eq!(loc.line, 5);
467        assert_eq!(loc.column, 2);
468        assert_eq!(loc.span, "bad");
469    }
470
471    #[test]
472    fn lint_issue_with_suggested_fix() {
473        let issue = LintIssue::new(
474            IssueSeverity::Hint,
475            IssueCategory::Performance,
476            "perf_rule",
477            "Performance hint".to_string(),
478        )
479        .with_suggested_fix("Use simpler approach".to_string());
480
481        assert_eq!(issue.suggested_fix(), Some("Use simpler approach"));
482    }
483
484    #[test]
485    fn lint_config_default() {
486        let config = LintConfig::default();
487        assert_eq!(config.min_severity, IssueSeverity::Info);
488        assert_eq!(config.max_issues, 0);
489        assert!(!config.strict_mode);
490        assert!(config.enabled_rules.is_empty());
491        assert!(config.disabled_rules.is_empty());
492    }
493
494    #[test]
495    fn lint_config_with_min_severity() {
496        let config = LintConfig::default().with_min_severity(IssueSeverity::Warning);
497        assert_eq!(config.min_severity, IssueSeverity::Warning);
498    }
499
500    #[test]
501    fn lint_config_with_max_issues() {
502        let config = LintConfig::default().with_max_issues(100);
503        assert_eq!(config.max_issues, 100);
504    }
505
506    #[test]
507    fn lint_config_with_strict_compliance() {
508        let config = LintConfig::default().with_strict_compliance(true);
509        assert!(config.strict_mode);
510    }
511
512    #[test]
513    fn lint_config_is_rule_enabled_all_disabled() {
514        let mut config = LintConfig::default();
515        config.disabled_rules.push("test_rule");
516
517        assert!(!config.is_rule_enabled("test_rule"));
518        assert!(config.is_rule_enabled("other_rule"));
519    }
520
521    #[test]
522    fn lint_config_is_rule_enabled_specific_enabled() {
523        let mut config = LintConfig::default();
524        config.enabled_rules.push("test_rule");
525
526        assert!(config.is_rule_enabled("test_rule"));
527        assert!(!config.is_rule_enabled("other_rule"));
528    }
529
530    #[test]
531    fn lint_config_should_report_severity() {
532        let config = LintConfig::default().with_min_severity(IssueSeverity::Warning);
533
534        assert!(!config.should_report_severity(IssueSeverity::Info));
535        assert!(!config.should_report_severity(IssueSeverity::Hint));
536        assert!(config.should_report_severity(IssueSeverity::Warning));
537        assert!(config.should_report_severity(IssueSeverity::Error));
538        assert!(config.should_report_severity(IssueSeverity::Critical));
539    }
540
541    #[test]
542    fn lint_script_empty_script() {
543        let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
544
545        let script = Script::parse(script_content).unwrap();
546        let config = LintConfig::default();
547
548        let issues = lint_script(&script, &config);
549        assert!(issues.is_ok());
550    }
551
552    #[test]
553    fn lint_script_with_analysis_empty() {
554        let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
555
556        let script = Script::parse(script_content).unwrap();
557        let analysis = ScriptAnalysis {
558            script: &script,
559            lint_issues: Vec::new(),
560            resolved_styles: Vec::new(),
561            dialogue_info: Vec::new(),
562            config: AnalysisConfig::default(),
563            #[cfg(feature = "plugins")]
564            registry: None,
565        };
566
567        let config = LintConfig::default();
568        let issues = lint_script_with_analysis(&analysis, &config);
569        assert!(issues.is_ok());
570    }
571
572    #[test]
573    fn lint_script_with_max_issues() {
574        let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
575
576        let script = Script::parse(script_content).unwrap();
577        let config = LintConfig::default().with_max_issues(1);
578
579        let issues = lint_script(&script, &config);
580        assert!(issues.is_ok());
581        if let Ok(issues) = issues {
582            assert!(issues.len() <= 1);
583        }
584    }
585
586    #[test]
587    fn lint_script_with_disabled_rule() {
588        // Test to cover the continue statement when rules are disabled (line 318)
589        let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
590
591        let script = Script::parse(script_content).unwrap();
592        let analysis = ScriptAnalysis {
593            script: &script,
594            lint_issues: Vec::new(),
595            resolved_styles: Vec::new(),
596            dialogue_info: Vec::new(),
597            config: AnalysisConfig::default(),
598            #[cfg(feature = "plugins")]
599            registry: None,
600        };
601
602        // Create config with specific rules disabled to trigger continue path
603        let mut config = LintConfig::default();
604        config.disabled_rules.push("accessibility_contrast");
605        config.disabled_rules.push("encoding_format");
606        config.disabled_rules.push("invalid_color");
607
608        let issues = lint_script_with_analysis(&analysis, &config);
609        assert!(issues.is_ok());
610    }
611}