1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum IssueSeverity {
38 Info,
40 Hint,
42 Warning,
44 Error,
46 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum IssueCategory {
65 Timing,
67 Styling,
69 Content,
71 Performance,
73 Compliance,
75 Accessibility,
77 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#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct IssueLocation {
98 pub line: usize,
100 pub column: usize,
102 pub offset: usize,
104 pub length: usize,
106 pub span: String,
108}
109
110#[derive(Debug, Clone)]
112pub struct LintIssue {
113 severity: IssueSeverity,
115 category: IssueCategory,
117 message: String,
119 description: Option<String>,
121 location: Option<IssueLocation>,
123 rule_id: &'static str,
125 suggested_fix: Option<String>,
127}
128
129impl LintIssue {
130 #[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 #[must_use]
151 pub fn with_description(mut self, description: String) -> Self {
152 self.description = Some(description);
153 self
154 }
155
156 #[must_use]
158 pub fn with_location(mut self, location: IssueLocation) -> Self {
159 self.location = Some(location);
160 self
161 }
162
163 #[must_use]
165 pub fn with_suggested_fix(mut self, fix: String) -> Self {
166 self.suggested_fix = Some(fix);
167 self
168 }
169
170 #[must_use]
172 pub const fn severity(&self) -> IssueSeverity {
173 self.severity
174 }
175
176 #[must_use]
178 pub const fn category(&self) -> IssueCategory {
179 self.category
180 }
181
182 #[must_use]
184 pub fn message(&self) -> &str {
185 &self.message
186 }
187
188 #[must_use]
190 pub fn description(&self) -> Option<&str> {
191 self.description.as_deref()
192 }
193
194 #[must_use]
196 pub const fn location(&self) -> Option<&IssueLocation> {
197 self.location.as_ref()
198 }
199
200 #[must_use]
202 pub const fn rule_id(&self) -> &'static str {
203 self.rule_id
204 }
205
206 #[must_use]
208 pub fn suggested_fix(&self) -> Option<&str> {
209 self.suggested_fix.as_deref()
210 }
211}
212
213#[derive(Debug, Clone)]
215pub struct LintConfig {
216 pub min_severity: IssueSeverity,
218 pub max_issues: usize,
220 pub strict_mode: bool,
222 pub enabled_rules: Vec<&'static str>,
224 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, strict_mode: false,
234 enabled_rules: Vec::new(),
235 disabled_rules: Vec::new(),
236 }
237 }
238}
239
240impl LintConfig {
241 #[must_use]
243 pub const fn with_min_severity(mut self, severity: IssueSeverity) -> Self {
244 self.min_severity = severity;
245 self
246 }
247
248 #[must_use]
250 pub const fn with_max_issues(mut self, max: usize) -> Self {
251 self.max_issues = max;
252 self
253 }
254
255 #[must_use]
257 pub const fn with_strict_compliance(mut self, enabled: bool) -> Self {
258 self.strict_mode = enabled;
259 self
260 }
261
262 #[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 #[must_use]
273 pub fn should_report_severity(&self, severity: IssueSeverity) -> bool {
274 severity >= self.min_severity
275 }
276}
277
278pub trait LintRule: Send + Sync {
280 fn id(&self) -> &'static str;
282
283 fn name(&self) -> &'static str;
285
286 fn description(&self) -> &'static str;
288
289 fn default_severity(&self) -> IssueSeverity;
291
292 fn category(&self) -> IssueCategory;
294
295 fn check_script(&self, analysis: &ScriptAnalysis) -> Vec<LintIssue>;
297}
298
299pub 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
335pub fn lint_script(script: &Script, config: &LintConfig) -> Result<Vec<LintIssue>> {
345 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 analysis.resolve_all_styles();
358 analysis.analyze_events();
359
360 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 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 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}