sphinx_ultra/directives/validation/
builtin.rs

1//! Built-in directive validators for common Sphinx directives
2
3use super::{DirectiveValidationResult, DirectiveValidator, ParsedDirective};
4
5/// Validator for code-block directive
6#[derive(Default)]
7pub struct CodeBlockValidator;
8
9impl CodeBlockValidator {
10    pub fn new() -> Self {
11        Self
12    }
13}
14
15impl DirectiveValidator for CodeBlockValidator {
16    fn name(&self) -> &str {
17        "code-block"
18    }
19
20    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
21        // Check if language is specified
22        if directive.arguments.is_empty() {
23            return DirectiveValidationResult::Warning(
24                "No language specified for code-block directive".to_string(),
25            );
26        }
27
28        // Check for valid language
29        let language = &directive.arguments[0];
30        if language.is_empty() {
31            return DirectiveValidationResult::Error(
32                "Empty language specification in code-block directive".to_string(),
33            );
34        }
35
36        // Check if content is provided
37        if directive.content.trim().is_empty() {
38            return DirectiveValidationResult::Warning(
39                "Code-block directive has no content".to_string(),
40            );
41        }
42
43        // Validate common options
44        for (option, value) in &directive.options {
45            match option.as_str() {
46                "linenos" => {
47                    if !value.is_empty() {
48                        return DirectiveValidationResult::Error(
49                            "linenos option should not have a value".to_string(),
50                        );
51                    }
52                }
53                "lineno-start" => {
54                    if value.parse::<u32>().is_err() {
55                        return DirectiveValidationResult::Error(
56                            "lineno-start must be a positive integer".to_string(),
57                        );
58                    }
59                }
60                "emphasize-lines" => {
61                    // Could validate line numbers format here
62                }
63                "caption" | "name" | "dedent" => {
64                    // These are valid options
65                }
66                _ => {
67                    return DirectiveValidationResult::Warning(format!(
68                        "Unknown option '{}' for code-block directive",
69                        option
70                    ));
71                }
72            }
73        }
74
75        DirectiveValidationResult::Valid
76    }
77
78    fn expected_arguments(&self) -> Vec<String> {
79        vec!["language".to_string()]
80    }
81
82    fn valid_options(&self) -> Vec<String> {
83        vec![
84            "linenos".to_string(),
85            "lineno-start".to_string(),
86            "emphasize-lines".to_string(),
87            "caption".to_string(),
88            "name".to_string(),
89            "dedent".to_string(),
90            "force".to_string(),
91        ]
92    }
93
94    fn requires_content(&self) -> bool {
95        false // Can be empty for demonstration purposes
96    }
97
98    fn allows_content(&self) -> bool {
99        true
100    }
101}
102
103/// Validator for note directive
104#[derive(Default)]
105pub struct NoteValidator;
106
107impl NoteValidator {
108    pub fn new() -> Self {
109        Self
110    }
111}
112
113impl DirectiveValidator for NoteValidator {
114    fn name(&self) -> &str {
115        "note"
116    }
117
118    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
119        // Note directive should have content
120        if directive.content.trim().is_empty() {
121            return DirectiveValidationResult::Error("Note directive requires content".to_string());
122        }
123
124        // Note directive typically doesn't take arguments
125        if !directive.arguments.is_empty() {
126            return DirectiveValidationResult::Warning(
127                "Note directive does not expect arguments".to_string(),
128            );
129        }
130
131        // Validate options
132        for option in directive.options.keys() {
133            match option.as_str() {
134                "class" | "name" => {
135                    // Valid options
136                }
137                _ => {
138                    return DirectiveValidationResult::Warning(format!(
139                        "Unknown option '{}' for note directive",
140                        option
141                    ));
142                }
143            }
144        }
145
146        DirectiveValidationResult::Valid
147    }
148
149    fn expected_arguments(&self) -> Vec<String> {
150        vec![]
151    }
152
153    fn valid_options(&self) -> Vec<String> {
154        vec!["class".to_string(), "name".to_string()]
155    }
156
157    fn requires_content(&self) -> bool {
158        true
159    }
160
161    fn allows_content(&self) -> bool {
162        true
163    }
164}
165
166/// Validator for warning directive
167#[derive(Default)]
168pub struct WarningValidator;
169
170impl WarningValidator {
171    pub fn new() -> Self {
172        Self
173    }
174}
175
176impl DirectiveValidator for WarningValidator {
177    fn name(&self) -> &str {
178        "warning"
179    }
180
181    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
182        // Warning directive should have content
183        if directive.content.trim().is_empty() {
184            return DirectiveValidationResult::Error(
185                "Warning directive requires content".to_string(),
186            );
187        }
188
189        // Warning directive typically doesn't take arguments
190        if !directive.arguments.is_empty() {
191            return DirectiveValidationResult::Warning(
192                "Warning directive does not expect arguments".to_string(),
193            );
194        }
195
196        // Validate options
197        for option in directive.options.keys() {
198            match option.as_str() {
199                "class" | "name" => {
200                    // Valid options
201                }
202                _ => {
203                    return DirectiveValidationResult::Warning(format!(
204                        "Unknown option '{}' for warning directive",
205                        option
206                    ));
207                }
208            }
209        }
210
211        DirectiveValidationResult::Valid
212    }
213
214    fn expected_arguments(&self) -> Vec<String> {
215        vec![]
216    }
217
218    fn valid_options(&self) -> Vec<String> {
219        vec!["class".to_string(), "name".to_string()]
220    }
221
222    fn requires_content(&self) -> bool {
223        true
224    }
225
226    fn allows_content(&self) -> bool {
227        true
228    }
229}
230
231/// Validator for image directive
232#[derive(Default)]
233pub struct ImageValidator;
234
235impl ImageValidator {
236    pub fn new() -> Self {
237        Self
238    }
239}
240
241impl DirectiveValidator for ImageValidator {
242    fn name(&self) -> &str {
243        "image"
244    }
245
246    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
247        // Image directive requires a path argument
248        if directive.arguments.is_empty() {
249            return DirectiveValidationResult::Error(
250                "Image directive requires a path argument".to_string(),
251            );
252        }
253
254        let image_path = &directive.arguments[0];
255        if image_path.is_empty() {
256            return DirectiveValidationResult::Error("Image path cannot be empty".to_string());
257        }
258
259        // Check for valid image extensions
260        let valid_extensions = ["png", "jpg", "jpeg", "gif", "svg", "bmp", "webp"];
261        if let Some(extension) = image_path.split('.').next_back() {
262            if !valid_extensions.contains(&extension.to_lowercase().as_str()) {
263                return DirectiveValidationResult::Warning(format!(
264                    "Unusual image extension: {}",
265                    extension
266                ));
267            }
268        }
269
270        // Validate options
271        for (option, value) in &directive.options {
272            match option.as_str() {
273                "alt" | "target" | "class" | "name" => {
274                    // Valid text options
275                }
276                "width" | "height" => {
277                    // Should be length units
278                    if !value.ends_with("px") && !value.ends_with("%") && !value.ends_with("em") {
279                        return DirectiveValidationResult::Warning(format!(
280                            "{} should include units (px, %, em)",
281                            option
282                        ));
283                    }
284                }
285                "scale" => {
286                    if value.parse::<f32>().is_err() {
287                        return DirectiveValidationResult::Error(
288                            "Scale must be a number".to_string(),
289                        );
290                    }
291                }
292                "align" => {
293                    let valid_alignments = ["left", "center", "right", "top", "middle", "bottom"];
294                    if !valid_alignments.contains(&value.as_str()) {
295                        return DirectiveValidationResult::Error(format!(
296                            "Invalid alignment: {}. Valid options: {}",
297                            value,
298                            valid_alignments.join(", ")
299                        ));
300                    }
301                }
302                _ => {
303                    return DirectiveValidationResult::Warning(format!(
304                        "Unknown option '{}' for image directive",
305                        option
306                    ));
307                }
308            }
309        }
310
311        DirectiveValidationResult::Valid
312    }
313
314    fn expected_arguments(&self) -> Vec<String> {
315        vec!["image_uri".to_string()]
316    }
317
318    fn valid_options(&self) -> Vec<String> {
319        vec![
320            "alt".to_string(),
321            "height".to_string(),
322            "width".to_string(),
323            "scale".to_string(),
324            "align".to_string(),
325            "target".to_string(),
326            "class".to_string(),
327            "name".to_string(),
328        ]
329    }
330
331    fn requires_content(&self) -> bool {
332        false
333    }
334
335    fn allows_content(&self) -> bool {
336        false
337    }
338}
339
340/// Validator for figure directive
341#[derive(Default)]
342pub struct FigureValidator;
343
344impl FigureValidator {
345    pub fn new() -> Self {
346        Self
347    }
348}
349
350impl DirectiveValidator for FigureValidator {
351    fn name(&self) -> &str {
352        "figure"
353    }
354
355    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
356        // Figure directive requires a path argument
357        if directive.arguments.is_empty() {
358            return DirectiveValidationResult::Error(
359                "Figure directive requires a path argument".to_string(),
360            );
361        }
362
363        // Reuse image validation logic
364        let image_validator = ImageValidator::new();
365        let mut temp_directive = directive.clone();
366        temp_directive.name = "image".to_string();
367        let image_result = image_validator.validate(&temp_directive);
368
369        // Figure can have content (caption)
370        match image_result {
371            DirectiveValidationResult::Valid => DirectiveValidationResult::Valid,
372            other => other,
373        }
374    }
375
376    fn expected_arguments(&self) -> Vec<String> {
377        vec!["image_uri".to_string()]
378    }
379
380    fn valid_options(&self) -> Vec<String> {
381        vec![
382            "alt".to_string(),
383            "height".to_string(),
384            "width".to_string(),
385            "scale".to_string(),
386            "align".to_string(),
387            "target".to_string(),
388            "class".to_string(),
389            "name".to_string(),
390            "figwidth".to_string(),
391            "figclass".to_string(),
392        ]
393    }
394
395    fn requires_content(&self) -> bool {
396        false
397    }
398
399    fn allows_content(&self) -> bool {
400        true
401    }
402}
403
404/// Validator for toctree directive
405#[derive(Default)]
406pub struct TocTreeValidator;
407
408impl TocTreeValidator {
409    pub fn new() -> Self {
410        Self
411    }
412}
413
414impl DirectiveValidator for TocTreeValidator {
415    fn name(&self) -> &str {
416        "toctree"
417    }
418
419    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
420        // Toctree typically has content (list of documents)
421        if directive.content.trim().is_empty() {
422            return DirectiveValidationResult::Warning("Toctree directive is empty".to_string());
423        }
424
425        // Validate options
426        for (option, value) in &directive.options {
427            match option.as_str() {
428                "maxdepth" => {
429                    if let Ok(depth) = value.parse::<u32>() {
430                        if depth > 10 {
431                            return DirectiveValidationResult::Warning(
432                                "Very deep toctree depth may cause performance issues".to_string(),
433                            );
434                        }
435                    } else {
436                        return DirectiveValidationResult::Error(
437                            "maxdepth must be a positive integer".to_string(),
438                        );
439                    }
440                }
441                "numbered" | "titlesonly" | "glob" | "reversed" | "hidden" | "includehidden" => {
442                    // Flag options
443                    if !value.is_empty() {
444                        return DirectiveValidationResult::Warning(format!(
445                            "{} option should not have a value",
446                            option
447                        ));
448                    }
449                }
450                "caption" | "name" | "class" => {
451                    // Valid text options
452                }
453                _ => {
454                    return DirectiveValidationResult::Warning(format!(
455                        "Unknown option '{}' for toctree directive",
456                        option
457                    ));
458                }
459            }
460        }
461
462        DirectiveValidationResult::Valid
463    }
464
465    fn expected_arguments(&self) -> Vec<String> {
466        vec![]
467    }
468
469    fn valid_options(&self) -> Vec<String> {
470        vec![
471            "maxdepth".to_string(),
472            "numbered".to_string(),
473            "titlesonly".to_string(),
474            "glob".to_string(),
475            "reversed".to_string(),
476            "hidden".to_string(),
477            "includehidden".to_string(),
478            "caption".to_string(),
479            "name".to_string(),
480            "class".to_string(),
481        ]
482    }
483
484    fn requires_content(&self) -> bool {
485        false
486    }
487
488    fn allows_content(&self) -> bool {
489        true
490    }
491}
492
493/// Validator for include directive
494#[derive(Default)]
495pub struct IncludeValidator;
496
497impl IncludeValidator {
498    pub fn new() -> Self {
499        Self
500    }
501}
502
503impl DirectiveValidator for IncludeValidator {
504    fn name(&self) -> &str {
505        "include"
506    }
507
508    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
509        // Include directive requires a file path
510        if directive.arguments.is_empty() {
511            return DirectiveValidationResult::Error(
512                "Include directive requires a file path".to_string(),
513            );
514        }
515
516        let file_path = &directive.arguments[0];
517        if file_path.is_empty() {
518            return DirectiveValidationResult::Error(
519                "Include file path cannot be empty".to_string(),
520            );
521        }
522
523        // Check for common file extensions
524        if let Some(extension) = file_path.split('.').next_back() {
525            let valid_extensions = ["rst", "txt", "md", "inc"];
526            if !valid_extensions.contains(&extension.to_lowercase().as_str()) {
527                return DirectiveValidationResult::Warning(format!(
528                    "Unusual file extension for include: {}",
529                    extension
530                ));
531            }
532        }
533
534        DirectiveValidationResult::Valid
535    }
536
537    fn expected_arguments(&self) -> Vec<String> {
538        vec!["filename".to_string()]
539    }
540
541    fn valid_options(&self) -> Vec<String> {
542        vec![
543            "start-line".to_string(),
544            "end-line".to_string(),
545            "start-after".to_string(),
546            "end-before".to_string(),
547            "literal".to_string(),
548            "code".to_string(),
549            "number-lines".to_string(),
550            "encoding".to_string(),
551            "tab-width".to_string(),
552        ]
553    }
554
555    fn requires_content(&self) -> bool {
556        false
557    }
558
559    fn allows_content(&self) -> bool {
560        false
561    }
562}
563
564/// Validator for literalinclude directive
565#[derive(Default)]
566pub struct LiteralIncludeValidator;
567
568impl LiteralIncludeValidator {
569    pub fn new() -> Self {
570        Self
571    }
572}
573
574impl DirectiveValidator for LiteralIncludeValidator {
575    fn name(&self) -> &str {
576        "literalinclude"
577    }
578
579    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
580        // Similar to include but for code files
581        if directive.arguments.is_empty() {
582            return DirectiveValidationResult::Error(
583                "Literalinclude directive requires a file path".to_string(),
584            );
585        }
586
587        let file_path = &directive.arguments[0];
588        if file_path.is_empty() {
589            return DirectiveValidationResult::Error(
590                "Literalinclude file path cannot be empty".to_string(),
591            );
592        }
593
594        // Validate line number options
595        for (option, value) in &directive.options {
596            match option.as_str() {
597                "start-line" | "end-line" | "lineno-start" | "tab-width" => {
598                    if value.parse::<u32>().is_err() {
599                        return DirectiveValidationResult::Error(format!(
600                            "{} must be a positive integer",
601                            option
602                        ));
603                    }
604                }
605                "dedent" => {
606                    if !value.is_empty() && value.parse::<u32>().is_err() {
607                        return DirectiveValidationResult::Error(
608                            "dedent must be a positive integer".to_string(),
609                        );
610                    }
611                }
612                "language" | "start-after" | "end-before" | "prepend" | "append" | "caption"
613                | "name" | "class" | "encoding" | "pyobject" | "diff" => {
614                    // Valid text options
615                }
616                "linenos" | "force" => {
617                    // Flag options
618                    if !value.is_empty() {
619                        return DirectiveValidationResult::Warning(format!(
620                            "{} option should not have a value",
621                            option
622                        ));
623                    }
624                }
625                _ => {
626                    return DirectiveValidationResult::Warning(format!(
627                        "Unknown option '{}' for literalinclude directive",
628                        option
629                    ));
630                }
631            }
632        }
633
634        DirectiveValidationResult::Valid
635    }
636
637    fn expected_arguments(&self) -> Vec<String> {
638        vec!["filename".to_string()]
639    }
640
641    fn valid_options(&self) -> Vec<String> {
642        vec![
643            "language".to_string(),
644            "linenos".to_string(),
645            "lineno-start".to_string(),
646            "emphasize-lines".to_string(),
647            "lines".to_string(),
648            "start-line".to_string(),
649            "end-line".to_string(),
650            "start-after".to_string(),
651            "end-before".to_string(),
652            "prepend".to_string(),
653            "append".to_string(),
654            "dedent".to_string(),
655            "tab-width".to_string(),
656            "encoding".to_string(),
657            "pyobject".to_string(),
658            "caption".to_string(),
659            "name".to_string(),
660            "class".to_string(),
661            "diff".to_string(),
662            "force".to_string(),
663        ]
664    }
665
666    fn requires_content(&self) -> bool {
667        false
668    }
669
670    fn allows_content(&self) -> bool {
671        false
672    }
673}
674
675/// Validator for admonition directive
676#[derive(Default)]
677pub struct AdmonitionValidator;
678
679impl AdmonitionValidator {
680    pub fn new() -> Self {
681        Self
682    }
683}
684
685impl DirectiveValidator for AdmonitionValidator {
686    fn name(&self) -> &str {
687        "admonition"
688    }
689
690    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
691        // Admonition directive requires a title argument
692        if directive.arguments.is_empty() {
693            return DirectiveValidationResult::Error(
694                "Admonition directive requires a title argument".to_string(),
695            );
696        }
697
698        // Should have content
699        if directive.content.trim().is_empty() {
700            return DirectiveValidationResult::Warning(
701                "Admonition directive has no content".to_string(),
702            );
703        }
704
705        DirectiveValidationResult::Valid
706    }
707
708    fn expected_arguments(&self) -> Vec<String> {
709        vec!["title".to_string()]
710    }
711
712    fn valid_options(&self) -> Vec<String> {
713        vec!["class".to_string(), "name".to_string()]
714    }
715
716    fn requires_content(&self) -> bool {
717        false
718    }
719
720    fn allows_content(&self) -> bool {
721        true
722    }
723}
724
725/// Validator for math directive
726#[derive(Default)]
727pub struct MathValidator;
728
729impl MathValidator {
730    pub fn new() -> Self {
731        Self
732    }
733}
734
735impl DirectiveValidator for MathValidator {
736    fn name(&self) -> &str {
737        "math"
738    }
739
740    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
741        // Math directive should have content
742        if directive.content.trim().is_empty() {
743            return DirectiveValidationResult::Error(
744                "Math directive requires LaTeX math content".to_string(),
745            );
746        }
747
748        // Basic LaTeX syntax check
749        let content = directive.content.trim();
750        let open_braces = content.matches('{').count();
751        let close_braces = content.matches('}').count();
752
753        if open_braces != close_braces {
754            return DirectiveValidationResult::Warning(
755                "Unmatched braces in math content".to_string(),
756            );
757        }
758
759        DirectiveValidationResult::Valid
760    }
761
762    fn expected_arguments(&self) -> Vec<String> {
763        vec![]
764    }
765
766    fn valid_options(&self) -> Vec<String> {
767        vec!["label".to_string(), "name".to_string(), "class".to_string()]
768    }
769
770    fn requires_content(&self) -> bool {
771        true
772    }
773
774    fn allows_content(&self) -> bool {
775        true
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::directives::validation::SourceLocation;
783    use std::collections::HashMap;
784
785    fn create_test_directive(
786        name: &str,
787        args: Vec<String>,
788        options: HashMap<String, String>,
789        content: &str,
790    ) -> ParsedDirective {
791        ParsedDirective {
792            name: name.to_string(),
793            arguments: args,
794            options,
795            content: content.to_string(),
796            location: SourceLocation {
797                file: "test.rst".to_string(),
798                line: 1,
799                column: 1,
800            },
801        }
802    }
803
804    #[test]
805    fn test_code_block_validator() {
806        let validator = CodeBlockValidator::new();
807
808        // Valid code block
809        let directive = create_test_directive(
810            "code-block",
811            vec!["python".to_string()],
812            HashMap::new(),
813            "print('Hello, world!')",
814        );
815        assert_eq!(
816            validator.validate(&directive),
817            DirectiveValidationResult::Valid
818        );
819
820        // Missing language
821        let directive = create_test_directive(
822            "code-block",
823            vec![],
824            HashMap::new(),
825            "print('Hello, world!')",
826        );
827        assert!(matches!(
828            validator.validate(&directive),
829            DirectiveValidationResult::Warning(_)
830        ));
831    }
832
833    #[test]
834    fn test_note_validator() {
835        let validator = NoteValidator::new();
836
837        // Valid note
838        let directive = create_test_directive("note", vec![], HashMap::new(), "This is a note");
839        assert_eq!(
840            validator.validate(&directive),
841            DirectiveValidationResult::Valid
842        );
843
844        // Missing content
845        let directive = create_test_directive("note", vec![], HashMap::new(), "");
846        assert!(matches!(
847            validator.validate(&directive),
848            DirectiveValidationResult::Error(_)
849        ));
850    }
851
852    #[test]
853    fn test_image_validator() {
854        let validator = ImageValidator::new();
855
856        // Valid image
857        let directive =
858            create_test_directive("image", vec!["test.png".to_string()], HashMap::new(), "");
859        assert_eq!(
860            validator.validate(&directive),
861            DirectiveValidationResult::Valid
862        );
863
864        // Missing path
865        let directive = create_test_directive("image", vec![], HashMap::new(), "");
866        assert!(matches!(
867            validator.validate(&directive),
868            DirectiveValidationResult::Error(_)
869        ));
870    }
871
872    #[test]
873    fn test_math_validator() {
874        let validator = MathValidator::new();
875
876        // Valid math
877        let directive = create_test_directive("math", vec![], HashMap::new(), "x = \\frac{a}{b}");
878        assert_eq!(
879            validator.validate(&directive),
880            DirectiveValidationResult::Valid
881        );
882
883        // Missing content
884        let directive = create_test_directive("math", vec![], HashMap::new(), "");
885        assert!(matches!(
886            validator.validate(&directive),
887            DirectiveValidationResult::Error(_)
888        ));
889    }
890}