1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum ValidationSeverity {
28 Info,
30 Warning,
32 Error,
34 Critical,
36}
37
38impl Default for ValidationSeverity {
39 fn default() -> Self {
40 Self::Info
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ValidationIssue {
47 pub severity: ValidationSeverity,
49
50 pub line: Option<usize>,
52
53 pub column: Option<usize>,
55
56 pub message: String,
58
59 pub rule: String,
61
62 pub suggestion: Option<String>,
64}
65
66impl ValidationIssue {
67 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 #[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 #[must_use]
107 pub fn with_suggestion(mut self, suggestion: String) -> Self {
108 self.suggestion = Some(suggestion);
109 self
110 }
111
112 #[must_use]
114 pub const fn is_error(&self) -> bool {
115 matches!(
116 self.severity,
117 ValidationSeverity::Error | ValidationSeverity::Critical
118 )
119 }
120
121 #[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#[derive(Debug, Clone)]
133pub struct ValidatorConfig {
134 pub auto_validate: bool,
136
137 #[cfg(feature = "std")]
139 pub min_validation_interval: std::time::Duration,
140
141 pub max_issues: usize,
143
144 pub severity_threshold: ValidationSeverity,
146
147 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#[derive(Debug, Clone)]
172pub struct ValidationResult {
173 pub issues: Vec<ValidationIssue>,
175
176 #[cfg(feature = "std")]
178 pub validation_time_us: u64,
179
180 pub is_valid: bool,
182
183 pub warning_count: usize,
185
186 pub error_count: usize,
188
189 #[cfg(feature = "std")]
191 pub timestamp: Instant,
192}
193
194impl ValidationResult {
195 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 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 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#[derive(Debug)]
246pub struct LazyValidator {
247 config: ValidatorConfig,
249
250 cached_result: Option<ValidationResult>,
252
253 content_hash: u64,
255
256 #[cfg(feature = "std")]
258 last_validation: Option<Instant>,
259
260 #[cfg(feature = "analysis")]
262 analysis_config: AnalysisConfig,
263}
264
265impl LazyValidator {
266 pub fn new() -> Self {
268 Self::with_config(ValidatorConfig::default())
269 }
270
271 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 pub fn validate(&mut self, document: &EditorDocument) -> Result<&ValidationResult> {
304 let content = document.text();
305 let content_hash = self.calculate_hash(&content);
306
307 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 let issues = self.validate_with_core(&content, document)?;
321
322 #[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 pub fn force_validate(&mut self, document: &EditorDocument) -> Result<&ValidationResult> {
348 self.cached_result = None; self.validate(document)
350 }
351
352 pub fn is_valid(&mut self, document: &EditorDocument) -> Result<bool> {
354 Ok(self.validate(document)?.is_valid)
355 }
356
357 pub fn cached_result(&self) -> Option<&ValidationResult> {
359 self.cached_result.as_ref()
360 }
361
362 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 pub fn set_config(&mut self, config: ValidatorConfig) {
374 self.config = config;
375 self.clear_cache(); #[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 #[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 document.parse_script_with(|script| {
412 match ScriptAnalysis::analyze_with_config(script, self.analysis_config.clone()) {
414 Ok(analysis) => {
415 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 issues.push(ValidationIssue::new(
446 ValidationSeverity::Error,
447 "Failed to analyze script".to_string(),
448 "analyzer".to_string(),
449 ));
450 }
451 }
452 })?;
453
454 self.add_basic_checks(content, &mut issues);
456
457 issues.retain(|issue| issue.severity >= self.config.severity_threshold);
459
460 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 #[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 self.add_basic_checks(content, &mut issues);
481
482 issues.retain(|issue| issue.severity >= self.config.severity_threshold);
484
485 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 fn add_basic_checks(&self, content: &str, issues: &mut Vec<ValidationIssue>) {
495 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 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 fn calculate_hash(&self, content: &str) -> u64 {
539 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 assert!(result.is_valid);
621 let issues_count = result.issues.len();
622
623 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 let new_config = ValidatorConfig {
641 max_issues: 10,
642 ..Default::default()
643 };
644 validator.set_config(new_config);
645
646 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 assert!(result.warning_count > 0);
659 let warnings = result.issues_with_severity(ValidationSeverity::Warning);
660 assert!(!warnings.is_empty());
661 }
662}