ass_editor/utils/
validator.rs

1//! Lazy validation wrapper around ass-core's ScriptAnalysis
2//!
3//! Provides on-demand validation and linting for editor documents,
4//! wrapping ass-core's analysis capabilities with caching and
5//! incremental update support for better editor performance.
6
7use crate::core::{errors::EditorError, EditorDocument, Result};
8
9#[cfg(feature = "analysis")]
10use ass_core::analysis::{AnalysisConfig, ScriptAnalysis, ScriptAnalysisOptions};
11
12#[cfg(feature = "analysis")]
13use ass_core::analysis::linting::IssueSeverity;
14
15#[cfg(not(feature = "std"))]
16use alloc::{
17    format,
18    string::{String, ToString},
19    vec::Vec,
20};
21
22#[cfg(feature = "std")]
23use std::time::Instant;
24
25/// Validation severity levels
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum ValidationSeverity {
28    /// Informational message
29    Info,
30    /// Warning that doesn't prevent script execution
31    Warning,
32    /// Error that may cause rendering issues
33    Error,
34    /// Critical error that prevents script execution
35    Critical,
36}
37
38impl Default for ValidationSeverity {
39    fn default() -> Self {
40        Self::Info
41    }
42}
43
44/// A validation issue found in the document
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ValidationIssue {
47    /// Severity of the issue
48    pub severity: ValidationSeverity,
49
50    /// Line number where the issue occurs (1-indexed)
51    pub line: Option<usize>,
52
53    /// Column number where the issue occurs (1-indexed)  
54    pub column: Option<usize>,
55
56    /// Human-readable description of the issue
57    pub message: String,
58
59    /// Rule or check that generated this issue
60    pub rule: String,
61
62    /// Suggested fix for the issue (if available)
63    pub suggestion: Option<String>,
64}
65
66impl ValidationIssue {
67    /// Create a new validation issue
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use ass_editor::utils::validator::{ValidationIssue, ValidationSeverity};
73    ///
74    /// let issue = ValidationIssue::new(
75    ///     ValidationSeverity::Warning,
76    ///     "Missing subtitle end time".to_string(),
77    ///     "timing_check".to_string()
78    /// )
79    /// .at_location(10, 25)
80    /// .with_suggestion("Add explicit end time".to_string());
81    ///
82    /// assert_eq!(issue.line, Some(10));
83    /// assert_eq!(issue.column, Some(25));
84    /// assert!(!issue.is_error());
85    /// ```
86    pub fn new(severity: ValidationSeverity, message: String, rule: String) -> Self {
87        Self {
88            severity,
89            line: None,
90            column: None,
91            message,
92            rule,
93            suggestion: None,
94        }
95    }
96
97    /// Set the location of this issue
98    #[must_use]
99    pub fn at_location(mut self, line: usize, column: usize) -> Self {
100        self.line = Some(line);
101        self.column = Some(column);
102        self
103    }
104
105    /// Add a suggestion for fixing this issue
106    #[must_use]
107    pub fn with_suggestion(mut self, suggestion: String) -> Self {
108        self.suggestion = Some(suggestion);
109        self
110    }
111
112    /// Check if this is an error or critical issue
113    #[must_use]
114    pub const fn is_error(&self) -> bool {
115        matches!(
116            self.severity,
117            ValidationSeverity::Error | ValidationSeverity::Critical
118        )
119    }
120
121    /// Check if this is a warning or higher
122    #[must_use]
123    pub const fn is_warning_or_higher(&self) -> bool {
124        matches!(
125            self.severity,
126            ValidationSeverity::Warning | ValidationSeverity::Error | ValidationSeverity::Critical
127        )
128    }
129}
130
131/// Configuration for the lazy validator
132#[derive(Debug, Clone)]
133pub struct ValidatorConfig {
134    /// Enable automatic validation after document changes
135    pub auto_validate: bool,
136
137    /// Minimum time between validations
138    #[cfg(feature = "std")]
139    pub min_validation_interval: std::time::Duration,
140
141    /// Maximum number of issues to report
142    pub max_issues: usize,
143
144    /// Severity threshold for reporting issues
145    pub severity_threshold: ValidationSeverity,
146
147    /// Enable specific validation rules
148    pub enable_performance_hints: bool,
149    pub enable_accessibility_checks: bool,
150    pub enable_spec_compliance: bool,
151    pub enable_unicode_checks: bool,
152}
153
154impl Default for ValidatorConfig {
155    fn default() -> Self {
156        Self {
157            auto_validate: true,
158            #[cfg(feature = "std")]
159            min_validation_interval: std::time::Duration::from_millis(500),
160            max_issues: 100,
161            severity_threshold: ValidationSeverity::Info,
162            enable_performance_hints: true,
163            enable_accessibility_checks: true,
164            enable_spec_compliance: true,
165            enable_unicode_checks: true,
166        }
167    }
168}
169
170/// Validation results with caching and statistics
171#[derive(Debug, Clone)]
172pub struct ValidationResult {
173    /// All validation issues found
174    pub issues: Vec<ValidationIssue>,
175
176    /// Time taken for validation in microseconds
177    #[cfg(feature = "std")]
178    pub validation_time_us: u64,
179
180    /// Whether the document passed validation
181    pub is_valid: bool,
182
183    /// Number of warnings found
184    pub warning_count: usize,
185
186    /// Number of errors found
187    pub error_count: usize,
188
189    /// Validation timestamp for cache invalidation
190    #[cfg(feature = "std")]
191    pub timestamp: Instant,
192}
193
194impl ValidationResult {
195    /// Create a new validation result
196    pub fn new(issues: Vec<ValidationIssue>) -> Self {
197        let warning_count = issues
198            .iter()
199            .filter(|i| i.severity == ValidationSeverity::Warning)
200            .count();
201        let error_count = issues.iter().filter(|i| i.is_error()).count();
202        let is_valid = error_count == 0;
203
204        Self {
205            issues,
206            #[cfg(feature = "std")]
207            validation_time_us: 0,
208            is_valid,
209            warning_count,
210            error_count,
211            #[cfg(feature = "std")]
212            timestamp: Instant::now(),
213        }
214    }
215
216    /// Filter issues by severity
217    pub fn issues_with_severity(&self, min_severity: ValidationSeverity) -> Vec<&ValidationIssue> {
218        self.issues
219            .iter()
220            .filter(|i| i.severity >= min_severity)
221            .collect()
222    }
223
224    /// Get summary statistics
225    pub fn summary(&self) -> String {
226        if self.is_valid {
227            if self.warning_count > 0 {
228                format!("{} warnings", self.warning_count)
229            } else {
230                "Valid".to_string()
231            }
232        } else {
233            format!(
234                "{} errors, {} warnings",
235                self.error_count, self.warning_count
236            )
237        }
238    }
239}
240
241/// Lazy validator that wraps ass-core's ScriptAnalysis
242///
243/// Provides on-demand validation with caching and incremental updates
244/// as specified in the architecture (line 164).
245#[derive(Debug)]
246pub struct LazyValidator {
247    /// Configuration for validation behavior
248    config: ValidatorConfig,
249
250    /// Cached validation result
251    cached_result: Option<ValidationResult>,
252
253    /// Hash of last validated content
254    content_hash: u64,
255
256    /// Last validation timestamp
257    #[cfg(feature = "std")]
258    last_validation: Option<Instant>,
259
260    /// Core analysis configuration
261    #[cfg(feature = "analysis")]
262    analysis_config: AnalysisConfig,
263}
264
265impl LazyValidator {
266    /// Create a new lazy validator with default configuration
267    pub fn new() -> Self {
268        Self::with_config(ValidatorConfig::default())
269    }
270
271    /// Create a new lazy validator with custom configuration
272    pub fn with_config(config: ValidatorConfig) -> Self {
273        Self {
274            #[cfg(feature = "analysis")]
275            analysis_config: AnalysisConfig {
276                options: {
277                    let mut options = ScriptAnalysisOptions::empty();
278                    if config.enable_unicode_checks {
279                        options |= ScriptAnalysisOptions::UNICODE_LINEBREAKS;
280                    }
281                    if config.enable_performance_hints {
282                        options |= ScriptAnalysisOptions::PERFORMANCE_HINTS;
283                    }
284                    if config.enable_spec_compliance {
285                        options |= ScriptAnalysisOptions::STRICT_COMPLIANCE;
286                    }
287                    if config.enable_accessibility_checks {
288                        options |= ScriptAnalysisOptions::BIDI_ANALYSIS;
289                    }
290                    options
291                },
292                max_events_threshold: 1000,
293            },
294            config,
295            cached_result: None,
296            content_hash: 0,
297            #[cfg(feature = "std")]
298            last_validation: None,
299        }
300    }
301
302    /// Validate document using ass-core's ScriptAnalysis
303    pub fn validate(&mut self, document: &EditorDocument) -> Result<&ValidationResult> {
304        let content = document.text();
305        let content_hash = self.calculate_hash(&content);
306
307        // Check if we can use cached result
308        if self.should_use_cache(content_hash) {
309            return self.cached_result.as_ref().ok_or_else(|| {
310                EditorError::command_failed(
311                    "Cache validation inconsistency: cached result expected but not found",
312                )
313            });
314        }
315
316        #[cfg(feature = "std")]
317        let start_time = Instant::now();
318
319        // Perform validation using ass-core
320        let issues = self.validate_with_core(&content, document)?;
321
322        // Update cache
323        #[cfg(feature = "std")]
324        let mut result = ValidationResult::new(issues);
325        #[cfg(not(feature = "std"))]
326        let result = ValidationResult::new(issues);
327
328        #[cfg(feature = "std")]
329        {
330            result.validation_time_us = start_time.elapsed().as_micros() as u64;
331        }
332
333        self.cached_result = Some(result);
334        self.content_hash = content_hash;
335
336        #[cfg(feature = "std")]
337        {
338            self.last_validation = Some(Instant::now());
339        }
340
341        self.cached_result.as_ref().ok_or_else(|| {
342            EditorError::command_failed("Validation completed but cached result is missing")
343        })
344    }
345
346    /// Force validation even if cached result exists
347    pub fn force_validate(&mut self, document: &EditorDocument) -> Result<&ValidationResult> {
348        self.cached_result = None; // Clear cache
349        self.validate(document)
350    }
351
352    /// Check if document is valid (quick check using cache if available)
353    pub fn is_valid(&mut self, document: &EditorDocument) -> Result<bool> {
354        Ok(self.validate(document)?.is_valid)
355    }
356
357    /// Get cached validation result without revalidating
358    pub fn cached_result(&self) -> Option<&ValidationResult> {
359        self.cached_result.as_ref()
360    }
361
362    /// Clear validation cache
363    pub fn clear_cache(&mut self) {
364        self.cached_result = None;
365        self.content_hash = 0;
366        #[cfg(feature = "std")]
367        {
368            self.last_validation = None;
369        }
370    }
371
372    /// Update configuration
373    pub fn set_config(&mut self, config: ValidatorConfig) {
374        self.config = config;
375        self.clear_cache(); // Config change invalidates cache
376
377        #[cfg(feature = "analysis")]
378        {
379            self.analysis_config = AnalysisConfig {
380                options: {
381                    let mut options = ScriptAnalysisOptions::empty();
382                    if self.config.enable_unicode_checks {
383                        options |= ScriptAnalysisOptions::UNICODE_LINEBREAKS;
384                    }
385                    if self.config.enable_performance_hints {
386                        options |= ScriptAnalysisOptions::PERFORMANCE_HINTS;
387                    }
388                    if self.config.enable_spec_compliance {
389                        options |= ScriptAnalysisOptions::STRICT_COMPLIANCE;
390                    }
391                    if self.config.enable_accessibility_checks {
392                        options |= ScriptAnalysisOptions::BIDI_ANALYSIS;
393                    }
394                    options
395                },
396                max_events_threshold: 1000,
397            };
398        }
399    }
400
401    /// Validate using ass-core's ScriptAnalysis
402    #[cfg(feature = "analysis")]
403    fn validate_with_core(
404        &self,
405        content: &str,
406        document: &EditorDocument,
407    ) -> Result<Vec<ValidationIssue>> {
408        let mut issues = Vec::new();
409
410        // Parse and analyze with ass-core
411        document.parse_script_with(|script| {
412            // Create ScriptAnalysis with our configuration
413            match ScriptAnalysis::analyze_with_config(script, self.analysis_config.clone()) {
414                Ok(analysis) => {
415                    // Convert core lint issues to our format
416                    for lint_issue in analysis.lint_issues() {
417                        let severity = match lint_issue.severity() {
418                            IssueSeverity::Hint => ValidationSeverity::Info,
419                            IssueSeverity::Info => ValidationSeverity::Info,
420                            IssueSeverity::Warning => ValidationSeverity::Warning,
421                            IssueSeverity::Error => ValidationSeverity::Error,
422                            IssueSeverity::Critical => ValidationSeverity::Critical,
423                        };
424
425                        let (line, column) = if let Some(location) = lint_issue.location() {
426                            (Some(location.line), Some(location.column))
427                        } else {
428                            (None, None)
429                        };
430
431                        let issue = ValidationIssue {
432                            severity,
433                            line,
434                            column,
435                            message: lint_issue.message().to_string(),
436                            rule: lint_issue.rule_id().to_string(),
437                            suggestion: lint_issue.suggested_fix().map(|s| s.to_string()),
438                        };
439
440                        issues.push(issue);
441                    }
442                }
443                Err(_) => {
444                    // If analysis fails, add a basic error
445                    issues.push(ValidationIssue::new(
446                        ValidationSeverity::Error,
447                        "Failed to analyze script".to_string(),
448                        "analyzer".to_string(),
449                    ));
450                }
451            }
452        })?;
453
454        // Add basic structural checks even with analysis feature
455        self.add_basic_checks(content, &mut issues);
456
457        // Apply severity threshold filter
458        issues.retain(|issue| issue.severity >= self.config.severity_threshold);
459
460        // Apply max issues limit
461        if self.config.max_issues > 0 && issues.len() > self.config.max_issues {
462            issues.truncate(self.config.max_issues);
463        }
464
465        Ok(issues)
466    }
467
468    /// Fallback validation without ass-core analysis
469    #[cfg(not(feature = "analysis"))]
470    fn validate_with_core(
471        &self,
472        content: &str,
473        _document: &EditorDocument,
474    ) -> Result<Vec<ValidationIssue>> {
475        let mut issues = Vec::new();
476
477        // Basic validation without core analysis
478        // Note: We can't do full parsing validation without the analysis feature,
479        // so we do basic structural checks only
480        self.add_basic_checks(content, &mut issues);
481
482        // Apply severity threshold filter
483        issues.retain(|issue| issue.severity >= self.config.severity_threshold);
484
485        // Apply max issues limit
486        if self.config.max_issues > 0 && issues.len() > self.config.max_issues {
487            issues.truncate(self.config.max_issues);
488        }
489
490        Ok(issues)
491    }
492
493    /// Add basic structural checks that work regardless of analysis feature
494    fn add_basic_checks(&self, content: &str, issues: &mut Vec<ValidationIssue>) {
495        // Basic checks
496        if content.is_empty() {
497            issues.push(ValidationIssue::new(
498                ValidationSeverity::Warning,
499                "Document is empty".to_string(),
500                "basic".to_string(),
501            ));
502        }
503
504        if !content.contains("[Script Info]") {
505            issues.push(ValidationIssue::new(
506                ValidationSeverity::Warning,
507                "Missing [Script Info] section".to_string(),
508                "structure".to_string(),
509            ));
510        }
511
512        if !content.contains("[Events]") {
513            issues.push(ValidationIssue::new(
514                ValidationSeverity::Warning,
515                "Missing [Events] section".to_string(),
516                "structure".to_string(),
517            ));
518        }
519    }
520
521    /// Check if cached result can be used
522    fn should_use_cache(&self, content_hash: u64) -> bool {
523        if self.cached_result.is_none() || self.content_hash != content_hash {
524            return false;
525        }
526
527        #[cfg(feature = "std")]
528        {
529            if let Some(last_validation) = self.last_validation {
530                return last_validation.elapsed() < self.config.min_validation_interval;
531            }
532        }
533
534        true
535    }
536
537    /// Calculate hash of content for cache invalidation
538    fn calculate_hash(&self, content: &str) -> u64 {
539        // Simple FNV hash
540        let mut hash = 0xcbf29ce484222325u64;
541        for byte in content.bytes() {
542            hash ^= byte as u64;
543            hash = hash.wrapping_mul(0x100000001b3);
544        }
545        hash
546    }
547}
548
549impl Default for LazyValidator {
550    fn default() -> Self {
551        Self::new()
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::EditorDocument;
559    #[cfg(not(feature = "std"))]
560    use alloc::{string::ToString, vec};
561
562    #[test]
563    fn test_validation_issue_creation() {
564        let issue = ValidationIssue::new(
565            ValidationSeverity::Warning,
566            "Test issue".to_string(),
567            "test_rule".to_string(),
568        )
569        .at_location(10, 5)
570        .with_suggestion("Fix this".to_string());
571
572        assert_eq!(issue.severity, ValidationSeverity::Warning);
573        assert_eq!(issue.line, Some(10));
574        assert_eq!(issue.column, Some(5));
575        assert_eq!(issue.suggestion, Some("Fix this".to_string()));
576        assert!(issue.is_warning_or_higher());
577        assert!(!issue.is_error());
578    }
579
580    #[test]
581    fn test_validation_result() {
582        let issues = vec![
583            ValidationIssue::new(
584                ValidationSeverity::Warning,
585                "Warning".to_string(),
586                "rule1".to_string(),
587            ),
588            ValidationIssue::new(
589                ValidationSeverity::Error,
590                "Error".to_string(),
591                "rule2".to_string(),
592            ),
593        ];
594
595        let result = ValidationResult::new(issues);
596        assert!(!result.is_valid);
597        assert_eq!(result.warning_count, 1);
598        assert_eq!(result.error_count, 1);
599        assert!(result.summary().contains("1 errors"));
600    }
601
602    #[test]
603    fn test_lazy_validator() {
604        let content = r#"[Script Info]
605Title: Test
606
607[V4+ Styles]
608Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
609Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
610
611[Events]
612Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
613Dialogue: 0,0:00:05.00,0:00:10.00,Default,John,0,0,0,,Hello"#;
614
615        let document = EditorDocument::from_content(content).unwrap();
616        let mut validator = LazyValidator::new();
617
618        let result = validator.validate(&document).unwrap();
619        // Should pass basic validation
620        assert!(result.is_valid);
621        let issues_count = result.issues.len();
622
623        // Test caching
624        let result2 = validator.validate(&document).unwrap();
625        assert_eq!(issues_count, result2.issues.len());
626    }
627
628    #[test]
629    fn test_validator_config() {
630        let config = ValidatorConfig {
631            enable_performance_hints: false,
632            max_issues: 5,
633            severity_threshold: ValidationSeverity::Warning,
634            ..Default::default()
635        };
636
637        let mut validator = LazyValidator::with_config(config);
638
639        // Test config update
640        let new_config = ValidatorConfig {
641            max_issues: 10,
642            ..Default::default()
643        };
644        validator.set_config(new_config);
645
646        // Cache should be cleared
647        assert!(validator.cached_result().is_none());
648    }
649
650    #[test]
651    fn test_validation_with_missing_sections() {
652        let content = "Title: Incomplete";
653        let document = EditorDocument::from_content(content).unwrap();
654        let mut validator = LazyValidator::new();
655
656        let result = validator.validate(&document).unwrap();
657        // Should have warnings about missing sections
658        assert!(result.warning_count > 0);
659        let warnings = result.issues_with_severity(ValidationSeverity::Warning);
660        assert!(!warnings.is_empty());
661    }
662}