Skip to main content

sphinx_ultra/directives/validation/
builtin.rs

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