Skip to main content

sphinx_ultra/directives/validation/
builtin.rs

1//! Built-in directive validators for common Sphinx directives
2//!
3//! ## Option lists
4//!
5//! Each validator's option list is spelled ONCE, as a shared `&[&str]`
6//! const, for two reasons found the hard way in wave 4.5:
7//!
8//! * `valid_options()` returns a freshly allocated `Vec<String>` on every
9//!   call, and `LiteralIncludeValidator::validate` calls it from inside its
10//!   per-option loop;
11//! * more importantly, a validator that spells its options twice drifts.
12//!   Task 14's env oracle caught the build warning `Unknown option 'lines'`
13//!   on a `literalinclude` — against an option the very same validator
14//!   advertised as valid — and the task-16 audit found the same shape in
15//!   `code-block` (`force`), `figure` (`figwidth`/`figclass`, warned about
16//!   under the *image* directive's name) and `image` (`loading`).
17//!
18//! Every list mirrors the directive's parse-time `option_spec` in
19//! `src/rst/block.rs`, which is this crate's probe-verified transcription
20//! of the real docutils/sphinx spec. The test
21//! `validator_option_lists_match_the_parser_spec` holds the two together in
22//! BOTH directions, and `every_validator_accepts_every_option_it_advertises`
23//! holds each list against its own `validate`. A fabricated "Unknown
24//! option" warning is not cosmetic: it fails `-W` on projects Sphinx builds
25//! clean.
26
27use super::{DirectiveValidationResult, DirectiveValidator, ParsedDirective};
28
29/// `CODE_BLOCK_OPTS` (`SP/directives/code.py` CodeBlock.option_spec).
30const CODE_BLOCK_OPTIONS: &[&str] = &[
31    "force",
32    "linenos",
33    "dedent",
34    "lineno-start",
35    "emphasize-lines",
36    "caption",
37    "class",
38    "name",
39];
40
41/// `ADMONITION_OPTS` — shared by `note`, `warning` and `admonition`.
42const ADMONITION_OPTIONS: &[&str] = &["class", "name"];
43
44/// `IMAGE_OPTS` (`DU/parsers/rst/directives/images.py` Image.option_spec).
45const IMAGE_OPTIONS: &[&str] = &[
46    "alt", "height", "width", "scale", "align", "target", "loading", "class", "name",
47];
48
49/// `FIGURE_OPTS`: the image set plus the three figure-only options.
50const FIGURE_OPTIONS: &[&str] = &[
51    "alt", "height", "width", "scale", "align", "target", "loading", "class", "name", "figwidth",
52    "figclass", "figname",
53];
54
55/// The options `FigureValidator` handles itself instead of delegating to
56/// [`ImageValidator`], which does not know them.
57const FIGURE_ONLY_OPTIONS: &[&str] = &["figwidth", "figclass", "figname"];
58
59/// `TOCTREE_OPTS` (`SP/directives/other.py` TocTree.option_spec).
60const TOCTREE_OPTIONS: &[&str] = &[
61    "maxdepth",
62    "name",
63    "class",
64    "caption",
65    "glob",
66    "hidden",
67    "includehidden",
68    "numbered",
69    "titlesonly",
70    "reversed",
71];
72
73/// `INCLUDE_OPTS` (`DU/parsers/rst/directives/misc.py` Include.option_spec).
74const INCLUDE_OPTIONS: &[&str] = &[
75    "literal",
76    "code",
77    "encoding",
78    "parser",
79    "tab-width",
80    "start-line",
81    "end-line",
82    "start-after",
83    "end-before",
84    "number-lines",
85    "class",
86    "name",
87];
88
89/// `LITERALINCLUDE_OPTS` (`SP/directives/code.py` LiteralInclude).
90///
91/// NOTE the two names that are NOT here: `start-line` and `end-line`
92/// belong to docutils' `include`, and Sphinx's `literalinclude` has
93/// neither (probe: `sorted(LiteralInclude.option_spec)` on 9.1.0 lists 21
94/// names, none of them those). They were advertised anyway, so the
95/// validator accepted an option the parser rejects.
96const LITERALINCLUDE_OPTIONS: &[&str] = &[
97    "dedent",
98    "linenos",
99    "lineno-start",
100    "lineno-match",
101    "tab-width",
102    "language",
103    "force",
104    "encoding",
105    "pyobject",
106    "lines",
107    "start-after",
108    "end-before",
109    "start-at",
110    "end-at",
111    "prepend",
112    "append",
113    "emphasize-lines",
114    "caption",
115    "class",
116    "name",
117    "diff",
118];
119
120/// `SPHINX_MATH_OPTS` (`SP/directives/patches.py` MathDirective).
121const MATH_OPTIONS: &[&str] = &["label", "name", "class", "no-wrap", "nowrap"];
122
123/// The owned form the [`DirectiveValidator::valid_options`] signature asks
124/// for. Allocating here keeps the const the single spelling.
125fn names(options: &[&'static str]) -> Vec<String> {
126    options.iter().map(|name| (*name).to_string()).collect()
127}
128
129/// A docutils length: a number with an optional unit (bare numbers default
130/// to pixels).
131fn is_valid_length(value: &str) -> bool {
132    const UNITS: &[&str] = &["em", "ex", "px", "in", "cm", "mm", "pt", "pc", "%"];
133    let number = UNITS
134        .iter()
135        .find_map(|u| value.strip_suffix(u))
136        .unwrap_or(value);
137    !number.trim().is_empty() && number.trim().parse::<f64>().is_ok()
138}
139
140/// Validator for code-block directive
141#[derive(Default)]
142pub struct CodeBlockValidator;
143
144impl CodeBlockValidator {
145    pub fn new() -> Self {
146        Self
147    }
148}
149
150impl DirectiveValidator for CodeBlockValidator {
151    fn name(&self) -> &str {
152        "code-block"
153    }
154
155    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
156        // A bare `.. code-block::` is valid Sphinx: the language falls back to
157        // highlight_language. An EMPTY code-block is valid too — Sphinx's
158        // `CodeBlock.run` renders an empty literal_block without a word
159        // (`directives/code.py`), so "has no content" was a fabricated
160        // warning that failed `-W` on markup sphinx-build accepts.
161
162        // Validate common options
163        for (option, value) in &directive.options {
164            match option.as_str() {
165                // Flags: `force` was advertised by `valid_options` but had
166                // no arm, so `.. code-block:: python` + `:force:` warned
167                // "Unknown option" against an option Sphinx accepts.
168                "linenos" | "force" => {
169                    if !value.is_empty() {
170                        return DirectiveValidationResult::Error(format!(
171                            "{option} option should not have a value"
172                        ));
173                    }
174                }
175                "emphasize-lines" => {
176                    // Could validate line numbers format here
177                }
178                // Value-carrying options. `lineno-start` is typed `int` in
179                // sphinx (`code.py:112`), so `-3` and `0` are accepted there;
180                // `dedent` is `optional_int`. The parse-time converter owns
181                // every value diagnostic — a second opinion here can only
182                // fabricate ("must be a positive integer" for a value
183                // sphinx-build takes).
184                "caption" | "name" | "dedent" | "class" | "lineno-start" => {}
185                _ => {
186                    return DirectiveValidationResult::Warning(format!(
187                        "Unknown option '{}' for code-block directive",
188                        option
189                    ));
190                }
191            }
192        }
193
194        DirectiveValidationResult::Valid
195    }
196
197    fn expected_arguments(&self) -> Vec<String> {
198        vec!["language".to_string()]
199    }
200
201    fn valid_options(&self) -> Vec<String> {
202        names(CODE_BLOCK_OPTIONS)
203    }
204
205    fn requires_content(&self) -> bool {
206        false // Can be empty for demonstration purposes
207    }
208
209    fn allows_content(&self) -> bool {
210        true
211    }
212}
213
214/// Validator for note directive
215#[derive(Default)]
216pub struct NoteValidator;
217
218impl NoteValidator {
219    pub fn new() -> Self {
220        Self
221    }
222}
223
224impl DirectiveValidator for NoteValidator {
225    fn name(&self) -> &str {
226        "note"
227    }
228
229    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
230        // Note directive should have content (the parser routes directive-line
231        // text into content, so a one-line `.. note:: text` passes here)
232        if directive.content.trim().is_empty() {
233            return DirectiveValidationResult::Error("Note directive requires content".to_string());
234        }
235
236        // Validate options
237        for option in directive.options.keys() {
238            match option.as_str() {
239                "class" | "name" => {
240                    // Valid options
241                }
242                _ => {
243                    return DirectiveValidationResult::Warning(format!(
244                        "Unknown option '{}' for note directive",
245                        option
246                    ));
247                }
248            }
249        }
250
251        DirectiveValidationResult::Valid
252    }
253
254    fn expected_arguments(&self) -> Vec<String> {
255        vec![]
256    }
257
258    fn valid_options(&self) -> Vec<String> {
259        names(ADMONITION_OPTIONS)
260    }
261
262    fn requires_content(&self) -> bool {
263        true
264    }
265
266    fn allows_content(&self) -> bool {
267        true
268    }
269}
270
271/// Validator for warning directive
272#[derive(Default)]
273pub struct WarningValidator;
274
275impl WarningValidator {
276    pub fn new() -> Self {
277        Self
278    }
279}
280
281impl DirectiveValidator for WarningValidator {
282    fn name(&self) -> &str {
283        "warning"
284    }
285
286    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
287        // Warning directive should have content (directive-line text counts,
288        // same as note)
289        if directive.content.trim().is_empty() {
290            return DirectiveValidationResult::Error(
291                "Warning directive requires content".to_string(),
292            );
293        }
294
295        // Validate options
296        for option in directive.options.keys() {
297            match option.as_str() {
298                "class" | "name" => {
299                    // Valid options
300                }
301                _ => {
302                    return DirectiveValidationResult::Warning(format!(
303                        "Unknown option '{}' for warning directive",
304                        option
305                    ));
306                }
307            }
308        }
309
310        DirectiveValidationResult::Valid
311    }
312
313    fn expected_arguments(&self) -> Vec<String> {
314        vec![]
315    }
316
317    fn valid_options(&self) -> Vec<String> {
318        names(ADMONITION_OPTIONS)
319    }
320
321    fn requires_content(&self) -> bool {
322        true
323    }
324
325    fn allows_content(&self) -> bool {
326        true
327    }
328}
329
330/// Validator for image directive
331#[derive(Default)]
332pub struct ImageValidator;
333
334impl ImageValidator {
335    pub fn new() -> Self {
336        Self
337    }
338}
339
340impl DirectiveValidator for ImageValidator {
341    fn name(&self) -> &str {
342        "image"
343    }
344
345    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
346        // Image directive requires a path argument
347        if directive.arguments.is_empty() {
348            return DirectiveValidationResult::Error(
349                "Image directive requires a path argument".to_string(),
350            );
351        }
352
353        let image_path = &directive.arguments[0];
354        if image_path.is_empty() {
355            return DirectiveValidationResult::Error("Image path cannot be empty".to_string());
356        }
357
358        // Check for valid image extensions
359        let valid_extensions = ["png", "jpg", "jpeg", "gif", "svg", "bmp", "webp"];
360        if let Some(extension) = image_path.split('.').next_back() {
361            if !valid_extensions.contains(&extension.to_lowercase().as_str()) {
362                return DirectiveValidationResult::Warning(format!(
363                    "Unusual image extension: {}",
364                    extension
365                ));
366            }
367        }
368
369        // Validate options
370        for (option, value) in &directive.options {
371            match option.as_str() {
372                // `loading` (embed/link/lazy) is part of the docutils
373                // image spec and was missing here, so `:loading: lazy`
374                // warned "Unknown option" against valid markup.
375                "alt" | "target" | "class" | "name" | "loading" => {
376                    // Valid text options
377                }
378                "width" | "height" => {
379                    if !is_valid_length(value) {
380                        return DirectiveValidationResult::Warning(format!(
381                            "{} is not a valid length: '{}'",
382                            option, value
383                        ));
384                    }
385                }
386                "scale" => {
387                    if value.parse::<f32>().is_err() {
388                        return DirectiveValidationResult::Error(
389                            "Scale must be a number".to_string(),
390                        );
391                    }
392                }
393                "align" => {
394                    let valid_alignments = ["left", "center", "right", "top", "middle", "bottom"];
395                    if !valid_alignments.contains(&value.as_str()) {
396                        return DirectiveValidationResult::Error(format!(
397                            "Invalid alignment: {}. Valid options: {}",
398                            value,
399                            valid_alignments.join(", ")
400                        ));
401                    }
402                }
403                _ => {
404                    return DirectiveValidationResult::Warning(format!(
405                        "Unknown option '{}' for image directive",
406                        option
407                    ));
408                }
409            }
410        }
411
412        DirectiveValidationResult::Valid
413    }
414
415    fn expected_arguments(&self) -> Vec<String> {
416        vec!["image_uri".to_string()]
417    }
418
419    fn valid_options(&self) -> Vec<String> {
420        names(IMAGE_OPTIONS)
421    }
422
423    fn requires_content(&self) -> bool {
424        false
425    }
426
427    fn allows_content(&self) -> bool {
428        false
429    }
430}
431
432/// Validator for figure directive
433#[derive(Default)]
434pub struct FigureValidator;
435
436impl FigureValidator {
437    pub fn new() -> Self {
438        Self
439    }
440}
441
442impl DirectiveValidator for FigureValidator {
443    fn name(&self) -> &str {
444        "figure"
445    }
446
447    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
448        // Figure directive requires a path argument
449        if directive.arguments.is_empty() {
450            return DirectiveValidationResult::Error(
451                "Figure directive requires a path argument".to_string(),
452            );
453        }
454
455        // Reuse image validation logic for the shared options. The
456        // figure-only ones must be removed first: `ImageValidator` does
457        // not know them, so they fell through to its catch-all and a plain
458        // `.. figure:: x.png` + `:figwidth: image` warned "Unknown option
459        // 'figwidth' for image directive" -- naming the wrong directive,
460        // about an option this validator itself advertises.
461        let image_validator = ImageValidator::new();
462        let mut temp_directive = directive.clone();
463        temp_directive.name = "image".to_string();
464        for option in FIGURE_ONLY_OPTIONS {
465            temp_directive.options.remove(*option);
466        }
467        let image_result = image_validator.validate(&temp_directive);
468
469        // Figure can have content (caption)
470        match image_result {
471            DirectiveValidationResult::Valid => DirectiveValidationResult::Valid,
472            other => other,
473        }
474    }
475
476    fn expected_arguments(&self) -> Vec<String> {
477        vec!["image_uri".to_string()]
478    }
479
480    fn valid_options(&self) -> Vec<String> {
481        names(FIGURE_OPTIONS)
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 toctree directive
494#[derive(Default)]
495pub struct TocTreeValidator;
496
497impl TocTreeValidator {
498    pub fn new() -> Self {
499        Self
500    }
501}
502
503impl DirectiveValidator for TocTreeValidator {
504    fn name(&self) -> &str {
505        "toctree"
506    }
507
508    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
509        // Toctree typically has content (list of documents)
510        if directive.content.trim().is_empty() {
511            return DirectiveValidationResult::Warning("Toctree directive is empty".to_string());
512        }
513
514        // Validate options
515        for (option, value) in &directive.options {
516            match option.as_str() {
517                // `maxdepth` is typed `int` (`directives/other.py`,
518                // `TocTree.option_spec`): `-1` is the documented "no limit"
519                // spelling, and no depth is "too deep" to Sphinx. The
520                // parse-time converter owns the value diagnostics; the
521                // positive-integer and depth>10 checks that lived here were
522                // fabricated warnings (same class as literalinclude's
523                // `tab-width`, panel fix round B).
524                "maxdepth" => {}
525                // `numbered` is NOT a flag: Sphinx types it `int_or_nothing`
526                // (`directives/other.py`, `TocTree.option_spec`), so
527                // `:numbered: 2` -- the documented spelling for a numbering
528                // depth -- is valid input. Warning on it fabricated a
529                // diagnostic Sphinx never emits and failed `-W` on projects
530                // sphinx 9.1.0 builds clean. Option handling for toctree
531                // belongs to the parser's own table (`TOCTREE_OPTS`, which
532                // has always had this right); nothing is re-checked here.
533                "numbered" => {}
534                "titlesonly" | "glob" | "reversed" | "hidden" | "includehidden" => {
535                    // Flag options
536                    if !value.is_empty() {
537                        return DirectiveValidationResult::Warning(format!(
538                            "{} option should not have a value",
539                            option
540                        ));
541                    }
542                }
543                "caption" | "name" | "class" => {
544                    // Valid text options
545                }
546                _ => {
547                    return DirectiveValidationResult::Warning(format!(
548                        "Unknown option '{}' for toctree directive",
549                        option
550                    ));
551                }
552            }
553        }
554
555        DirectiveValidationResult::Valid
556    }
557
558    fn expected_arguments(&self) -> Vec<String> {
559        vec![]
560    }
561
562    fn valid_options(&self) -> Vec<String> {
563        names(TOCTREE_OPTIONS)
564    }
565
566    fn requires_content(&self) -> bool {
567        false
568    }
569
570    fn allows_content(&self) -> bool {
571        true
572    }
573}
574
575/// Validator for include directive
576#[derive(Default)]
577pub struct IncludeValidator;
578
579impl IncludeValidator {
580    pub fn new() -> Self {
581        Self
582    }
583}
584
585impl DirectiveValidator for IncludeValidator {
586    fn name(&self) -> &str {
587        "include"
588    }
589
590    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
591        // Include directive requires a file path
592        if directive.arguments.is_empty() {
593            return DirectiveValidationResult::Error(
594                "Include directive requires a file path".to_string(),
595            );
596        }
597
598        let file_path = &directive.arguments[0];
599        if file_path.is_empty() {
600            return DirectiveValidationResult::Error(
601                "Include file path cannot be empty".to_string(),
602            );
603        }
604
605        // No opinion on the target's spelling: docutils' `Include` opens
606        // whatever path it is given (`<isonum.txt>` is a standard include,
607        // `snippet.py` with `:literal:` is ordinary), and sphinx has no
608        // extension check to mirror. The "Unusual file extension" warning
609        // that lived here was fabricated (panel fix round B, [30]).
610
611        DirectiveValidationResult::Valid
612    }
613
614    fn expected_arguments(&self) -> Vec<String> {
615        vec!["filename".to_string()]
616    }
617
618    fn valid_options(&self) -> Vec<String> {
619        names(INCLUDE_OPTIONS)
620    }
621
622    fn requires_content(&self) -> bool {
623        false
624    }
625
626    fn allows_content(&self) -> bool {
627        false
628    }
629}
630
631/// Validator for literalinclude directive
632#[derive(Default)]
633pub struct LiteralIncludeValidator;
634
635impl LiteralIncludeValidator {
636    pub fn new() -> Self {
637        Self
638    }
639}
640
641impl DirectiveValidator for LiteralIncludeValidator {
642    fn name(&self) -> &str {
643        "literalinclude"
644    }
645
646    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
647        // Similar to include but for code files
648        if directive.arguments.is_empty() {
649            return DirectiveValidationResult::Error(
650                "Literalinclude directive requires a file path".to_string(),
651            );
652        }
653
654        let file_path = &directive.arguments[0];
655        if file_path.is_empty() {
656            return DirectiveValidationResult::Error(
657                "Literalinclude file path cannot be empty".to_string(),
658            );
659        }
660
661        // Option loop. NO value-range arms: `lineno-start` and `tab-width`
662        // are typed plain `int` in sphinx (`code.py:112`, `:425-427`) — a
663        // negative or zero value is accepted there — and `dedent` is
664        // `optional_int`, whose own converter rejects a negative one at
665        // parse time with sphinx's text. Every value diagnostic belongs to
666        // the parse-time converter; the "must be a positive integer" arms
667        // that lived here fabricated warnings sphinx-build never emits.
668        for (option, value) in &directive.options {
669            match option.as_str() {
670                "language" | "start-after" | "end-before" | "prepend" | "append" | "caption"
671                | "name" | "class" | "encoding" | "pyobject" | "diff" | "lineno-start"
672                | "tab-width" | "dedent" => {
673                    // Valid value-carrying options
674                }
675                "linenos" | "force" | "lineno-match" => {
676                    // Flag options
677                    if !value.is_empty() {
678                        return DirectiveValidationResult::Warning(format!(
679                            "{} option should not have a value",
680                            option
681                        ));
682                    }
683                }
684                // Every other name the spec admits (`lines`,
685                // `emphasize-lines`, `start-at`, `end-at`, …) carries a free
686                // string this validator has no extra constraint for.
687                // Consulting the shared const rather than a second literal
688                // list is what keeps the two from drifting: they did, and a
689                // plain `.. literalinclude:: f.py` + `:lines:` warned
690                // "Unknown option 'lines'" against an option the very same
691                // validator advertises as valid.
692                _ if LITERALINCLUDE_OPTIONS.contains(&option.as_str()) => {}
693                _ => {
694                    return DirectiveValidationResult::Warning(format!(
695                        "Unknown option '{}' for literalinclude directive",
696                        option
697                    ));
698                }
699            }
700        }
701
702        DirectiveValidationResult::Valid
703    }
704
705    fn expected_arguments(&self) -> Vec<String> {
706        vec!["filename".to_string()]
707    }
708
709    fn valid_options(&self) -> Vec<String> {
710        names(LITERALINCLUDE_OPTIONS)
711    }
712
713    fn requires_content(&self) -> bool {
714        false
715    }
716
717    fn allows_content(&self) -> bool {
718        false
719    }
720}
721
722/// Validator for admonition directive
723#[derive(Default)]
724pub struct AdmonitionValidator;
725
726impl AdmonitionValidator {
727    pub fn new() -> Self {
728        Self
729    }
730}
731
732impl DirectiveValidator for AdmonitionValidator {
733    fn name(&self) -> &str {
734        "admonition"
735    }
736
737    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
738        // Admonition directive requires a title argument
739        if directive.arguments.is_empty() {
740            return DirectiveValidationResult::Error(
741                "Admonition directive requires a title argument".to_string(),
742            );
743        }
744
745        // Should have content
746        if directive.content.trim().is_empty() {
747            return DirectiveValidationResult::Warning(
748                "Admonition directive has no content".to_string(),
749            );
750        }
751
752        DirectiveValidationResult::Valid
753    }
754
755    fn expected_arguments(&self) -> Vec<String> {
756        vec!["title".to_string()]
757    }
758
759    fn valid_options(&self) -> Vec<String> {
760        names(ADMONITION_OPTIONS)
761    }
762
763    fn requires_content(&self) -> bool {
764        false
765    }
766
767    fn allows_content(&self) -> bool {
768        true
769    }
770}
771
772/// Validator for math directive
773#[derive(Default)]
774pub struct MathValidator;
775
776impl MathValidator {
777    pub fn new() -> Self {
778        Self
779    }
780}
781
782impl DirectiveValidator for MathValidator {
783    fn name(&self) -> &str {
784        "math"
785    }
786
787    fn validate(&self, directive: &ParsedDirective) -> DirectiveValidationResult {
788        // Math directive should have content
789        if directive.content.trim().is_empty() {
790            return DirectiveValidationResult::Error(
791                "Math directive requires LaTeX math content".to_string(),
792            );
793        }
794
795        // Basic LaTeX syntax check
796        let content = directive.content.trim();
797        let open_braces = content.matches('{').count();
798        let close_braces = content.matches('}').count();
799
800        if open_braces != close_braces {
801            return DirectiveValidationResult::Warning(
802                "Unmatched braces in math content".to_string(),
803            );
804        }
805
806        DirectiveValidationResult::Valid
807    }
808
809    fn expected_arguments(&self) -> Vec<String> {
810        vec![]
811    }
812
813    fn valid_options(&self) -> Vec<String> {
814        names(MATH_OPTIONS)
815    }
816
817    fn requires_content(&self) -> bool {
818        true
819    }
820
821    fn allows_content(&self) -> bool {
822        true
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use crate::directives::validation::SourceLocation;
830    use std::collections::HashMap;
831
832    fn create_test_directive(
833        name: &str,
834        args: Vec<String>,
835        options: HashMap<String, String>,
836        content: &str,
837    ) -> ParsedDirective {
838        ParsedDirective {
839            name: name.to_string(),
840            arguments: args,
841            options,
842            content: content.to_string(),
843            location: SourceLocation {
844                file: "test.rst".to_string(),
845                line: 1,
846                column: 1,
847            },
848        }
849    }
850
851    #[test]
852    fn test_code_block_validator() {
853        let validator = CodeBlockValidator::new();
854
855        // Valid code block
856        let directive = create_test_directive(
857            "code-block",
858            vec!["python".to_string()],
859            HashMap::new(),
860            "print('Hello, world!')",
861        );
862        assert_eq!(
863            validator.validate(&directive),
864            DirectiveValidationResult::Valid
865        );
866
867        // No language is valid Sphinx (falls back to highlight_language)
868        let directive = create_test_directive(
869            "code-block",
870            vec![],
871            HashMap::new(),
872            "print('Hello, world!')",
873        );
874        assert_eq!(
875            validator.validate(&directive),
876            DirectiveValidationResult::Valid
877        );
878
879        // Bare numbers and all docutils units are valid lengths
880        for width in ["100", "2cm", "50%", "1.5em", "12pt"] {
881            let mut options = HashMap::new();
882            options.insert("width".to_string(), width.to_string());
883            let directive = create_test_directive("image", vec!["x.png".to_string()], options, "");
884            assert_eq!(
885                ImageValidator::new().validate(&directive),
886                DirectiveValidationResult::Valid,
887                "width '{width}' must be accepted"
888            );
889        }
890    }
891
892    #[test]
893    fn test_note_validator() {
894        let validator = NoteValidator::new();
895
896        // Valid note
897        let directive = create_test_directive("note", vec![], HashMap::new(), "This is a note");
898        assert_eq!(
899            validator.validate(&directive),
900            DirectiveValidationResult::Valid
901        );
902
903        // Missing content
904        let directive = create_test_directive("note", vec![], HashMap::new(), "");
905        assert!(matches!(
906            validator.validate(&directive),
907            DirectiveValidationResult::Error(_)
908        ));
909    }
910
911    #[test]
912    fn test_image_validator() {
913        let validator = ImageValidator::new();
914
915        // Valid image
916        let directive =
917            create_test_directive("image", vec!["test.png".to_string()], HashMap::new(), "");
918        assert_eq!(
919            validator.validate(&directive),
920            DirectiveValidationResult::Valid
921        );
922
923        // Missing path
924        let directive = create_test_directive("image", vec![], HashMap::new(), "");
925        assert!(matches!(
926            validator.validate(&directive),
927            DirectiveValidationResult::Error(_)
928        ));
929    }
930
931    #[test]
932    fn test_math_validator() {
933        let validator = MathValidator::new();
934
935        // Valid math
936        let directive = create_test_directive("math", vec![], HashMap::new(), "x = \\frac{a}{b}");
937        assert_eq!(
938            validator.validate(&directive),
939            DirectiveValidationResult::Valid
940        );
941
942        // Missing content
943        let directive = create_test_directive("math", vec![], HashMap::new(), "");
944        assert!(matches!(
945            validator.validate(&directive),
946            DirectiveValidationResult::Error(_)
947        ));
948    }
949
950    /// Every name `LiteralIncludeValidator::valid_options` advertises must
951    /// actually validate. The two lists had drifted: `:lines:`,
952    /// `:emphasize-lines:` and `:lineno-match:` — three of the directive's
953    /// most common options, all present in the real option spec
954    /// (`LITERALINCLUDE_OPTS`, src/rst/block.rs) — fell through to the
955    /// catch-all and warned "Unknown option", a warning stream Sphinx has
956    /// no counterpart for (found by the env-fixture inc_* projects).
957    #[test]
958    fn literalinclude_accepts_every_option_it_advertises() {
959        let validator = LiteralIncludeValidator::new();
960
961        for option in validator.valid_options() {
962            // A value every constrained option accepts: the integer ones
963            // parse it, the flags reject a non-empty value, the rest are
964            // free strings.
965            let value = if matches!(
966                option.as_str(),
967                "linenos" | "force" | "lineno-match" | "dedent"
968            ) {
969                String::new()
970            } else {
971                "1".to_string()
972            };
973            let mut options = HashMap::new();
974            options.insert(option.clone(), value);
975            let directive =
976                create_test_directive("literalinclude", vec!["f.py".to_string()], options, "");
977            assert_eq!(
978                validator.validate(&directive),
979                DirectiveValidationResult::Valid,
980                "option {option:?} is advertised by valid_options but does not validate"
981            );
982        }
983
984        // The catch-all still catches a name that really is not in the spec.
985        let mut options = HashMap::new();
986        options.insert("no-such-option".to_string(), String::new());
987        let directive =
988            create_test_directive("literalinclude", vec!["f.py".to_string()], options, "");
989        assert_eq!(
990            validator.validate(&directive),
991            DirectiveValidationResult::Warning(
992                "Unknown option 'no-such-option' for literalinclude directive".to_string()
993            )
994        );
995    }
996
997    /// Every registered validator, with a directive shaped so that
998    /// validation actually reaches the option loop (arguments where the
999    /// directive needs one, content where it requires one).
1000    fn every_validator() -> Vec<(Box<dyn DirectiveValidator>, Vec<String>, &'static str)> {
1001        let arg = |s: &str| vec![s.to_string()];
1002        vec![
1003            (
1004                Box::new(CodeBlockValidator::new()),
1005                arg("python"),
1006                "print(1)",
1007            ),
1008            (Box::new(NoteValidator::new()), vec![], "body"),
1009            (Box::new(WarningValidator::new()), vec![], "body"),
1010            (Box::new(ImageValidator::new()), arg("x.png"), ""),
1011            (Box::new(FigureValidator::new()), arg("x.png"), "caption"),
1012            (Box::new(TocTreeValidator::new()), vec![], "a\nb"),
1013            (Box::new(IncludeValidator::new()), arg("inc.rst"), ""),
1014            (Box::new(LiteralIncludeValidator::new()), arg("f.py"), ""),
1015            (Box::new(AdmonitionValidator::new()), arg("Title"), "body"),
1016            (Box::new(MathValidator::new()), vec![], "x = 1"),
1017        ]
1018    }
1019
1020    /// THE DRIFT AUDIT (wave-4.5 task 16, generalizing task 14's finding).
1021    ///
1022    /// For every registered validator: each name it advertises must be
1023    /// ACCEPTED by its own `validate`. A validator whose `validate` match
1024    /// and `valid_options` disagree emits `Unknown option 'x'` for an
1025    /// option it simultaneously calls valid — a warning Sphinx has no
1026    /// counterpart for, which fails `-W` on a clean project.
1027    ///
1028    /// The assertion is about RECOGNITION, not about per-value
1029    /// constraints: an advertised option must never produce the
1030    /// `Unknown option '…'` catch-all, whatever value it carries. (A
1031    /// value-checking arm may still reject a specific value — `:align: 1`
1032    /// is an "Invalid alignment" error, and that is correct.) The value
1033    /// set includes a negative and a zero so an integer-typed option is
1034    /// exercised on the values sphinx's plain `int` converter accepts —
1035    /// the range where the fabricated "must be a positive integer" arms
1036    /// used to hide.
1037    #[test]
1038    fn every_validator_accepts_every_option_it_advertises() {
1039        for (validator, arguments, content) in every_validator() {
1040            for option in validator.valid_options() {
1041                for value in ["", "1", "left", "-1", "0"] {
1042                    let mut options = HashMap::new();
1043                    options.insert(option.clone(), value.to_string());
1044                    let directive = create_test_directive(
1045                        validator.name(),
1046                        arguments.clone(),
1047                        options,
1048                        content,
1049                    );
1050                    if let DirectiveValidationResult::Warning(message)
1051                    | DirectiveValidationResult::Error(message) = validator.validate(&directive)
1052                    {
1053                        assert!(
1054                            !message.starts_with(&format!("Unknown option '{option}'")),
1055                            "{}: option {option:?} is advertised by valid_options \
1056                             but its validate() calls it unknown (value {value:?})",
1057                            validator.name()
1058                        );
1059                    }
1060                }
1061            }
1062        }
1063    }
1064
1065    /// The other half of the audit: each validator's advertised list must
1066    /// equal the directive's parse-time `option_spec`
1067    /// (`directive_option_names`, src/rst/block.rs), which is this crate's
1068    /// probe-verified transcription of the real docutils/sphinx spec.
1069    ///
1070    /// Both directions matter. An option in the spec but not the list is a
1071    /// fabricated `Unknown option` warning waiting to happen (this caught
1072    /// `code-block`'s `class`, `image`/`figure`'s `loading`, and
1073    /// `include`'s `parser`/`class`/`name`). An option in the list but not
1074    /// the spec is a name the validator blesses and the parser then
1075    /// rejects — which is what `literalinclude`'s `start-line`/`end-line`
1076    /// were, borrowed from docutils' `include`, where they do exist.
1077    #[test]
1078    fn validator_option_lists_match_the_parser_spec() {
1079        use std::collections::BTreeSet;
1080        for (validator, _, _) in every_validator() {
1081            let name = validator.name();
1082            let spec: BTreeSet<String> = crate::rst::block::directive_option_names(name)
1083                .unwrap_or_else(|| panic!("{name}: no parse-time directive spec"))
1084                .into_iter()
1085                .map(str::to_string)
1086                .collect();
1087            let advertised: BTreeSet<String> = validator.valid_options().into_iter().collect();
1088            assert_eq!(
1089                advertised,
1090                spec,
1091                "{name}: valid_options and the parser's option_spec disagree.\n  \
1092                 advertised but not in the spec: {:?}\n  \
1093                 in the spec but not advertised: {:?}",
1094                advertised.difference(&spec).collect::<Vec<_>>(),
1095                spec.difference(&advertised).collect::<Vec<_>>(),
1096            );
1097        }
1098    }
1099
1100    /// `literalinclude` has no `:start-line:`/`:end-line:` — those belong
1101    /// to docutils' `include`. Pinned in both directions so the removal
1102    /// cannot be undone by copy-paste from the sibling validator.
1103    #[test]
1104    fn start_line_and_end_line_are_include_only() {
1105        for option in ["start-line", "end-line"] {
1106            assert!(
1107                IncludeValidator::new()
1108                    .valid_options()
1109                    .contains(&option.to_string()),
1110                "include must still advertise {option:?}"
1111            );
1112            assert!(
1113                !LiteralIncludeValidator::new()
1114                    .valid_options()
1115                    .contains(&option.to_string()),
1116                "literalinclude must not advertise {option:?}: sphinx 9.1.0's \
1117                 LiteralInclude.option_spec has no such key"
1118            );
1119            let mut options = HashMap::new();
1120            options.insert(option.to_string(), "2".to_string());
1121            let directive =
1122                create_test_directive("literalinclude", vec!["f.py".to_string()], options, "");
1123            assert_eq!(
1124                LiteralIncludeValidator::new().validate(&directive),
1125                DirectiveValidationResult::Warning(format!(
1126                    "Unknown option '{option}' for literalinclude directive"
1127                ))
1128            );
1129        }
1130    }
1131    /// Panel fix round B, [17]/[31]: the integer-typed options accept
1132    /// whatever sphinx's converters accept. `lineno-start`/`tab-width` are
1133    /// plain `int` (negative and zero included), `maxdepth` is `int` with
1134    /// `-1` as the documented "unlimited", `dedent` is `optional_int`
1135    /// whose diagnostics are the parse-time converter's business. A
1136    /// clean sphinx project must never earn a validation warning here.
1137    #[test]
1138    fn integer_options_accept_the_values_sphinxs_converters_accept() {
1139        let cases: &[(&str, Vec<String>, &str, &str, &str)] = &[
1140            (
1141                "literalinclude",
1142                vec!["f.py".to_string()],
1143                "",
1144                "tab-width",
1145                "-1",
1146            ),
1147            (
1148                "literalinclude",
1149                vec!["f.py".to_string()],
1150                "",
1151                "lineno-start",
1152                "-3",
1153            ),
1154            (
1155                "literalinclude",
1156                vec!["f.py".to_string()],
1157                "",
1158                "lineno-start",
1159                "0",
1160            ),
1161            (
1162                "literalinclude",
1163                vec!["f.py".to_string()],
1164                "",
1165                "dedent",
1166                "-2",
1167            ),
1168            ("literalinclude", vec!["f.py".to_string()], "", "dedent", ""),
1169            (
1170                "code-block",
1171                vec!["python".to_string()],
1172                "x = 1",
1173                "lineno-start",
1174                "-3",
1175            ),
1176            (
1177                "code-block",
1178                vec!["python".to_string()],
1179                "x = 1",
1180                "lineno-start",
1181                "0",
1182            ),
1183            ("code-block", vec![], "x = 1", "dedent", "-2"),
1184            ("toctree", vec![], "a\nb", "maxdepth", "-1"),
1185            ("toctree", vec![], "a\nb", "maxdepth", "99"),
1186        ];
1187        let registry = crate::directives::validation::DirectiveRegistry::with_builtin_validators();
1188        for (name, arguments, content, option, value) in cases {
1189            let mut options = HashMap::new();
1190            options.insert((*option).to_string(), (*value).to_string());
1191            let directive = create_test_directive(name, arguments.clone(), options, content);
1192            assert_eq!(
1193                registry.validate_directive(&directive),
1194                DirectiveValidationResult::Valid,
1195                "{name} :{option}: {value:?} is accepted by sphinx-build"
1196            );
1197        }
1198    }
1199
1200    /// Panel fix round B, [30]: docutils' `include` opens any path — a
1201    /// standard include (`<isonum.txt>`), a `.py` shown with `:literal:`,
1202    /// an extension-less file — and sphinx has no extension check, so the
1203    /// old "Unusual file extension" warning fabricated a diagnostic.
1204    #[test]
1205    fn include_has_no_opinion_on_the_targets_extension() {
1206        let registry = crate::directives::validation::DirectiveRegistry::with_builtin_validators();
1207        for (target, option) in [
1208            ("<isonum.txt>", None),
1209            ("snippet.py", Some("literal")),
1210            ("snippet.py", None),
1211            ("NOTES", None),
1212            ("data.csv", Some("code")),
1213        ] {
1214            let mut options = HashMap::new();
1215            if let Some(option) = option {
1216                options.insert(option.to_string(), String::new());
1217            }
1218            let directive = create_test_directive("include", vec![target.to_string()], options, "");
1219            assert_eq!(
1220                registry.validate_directive(&directive),
1221                DirectiveValidationResult::Valid,
1222                ".. include:: {target}"
1223            );
1224        }
1225    }
1226
1227    /// Panel fix round B, [17]: an empty `code-block` is legal sphinx
1228    /// (`CodeBlock.run` builds an empty `literal_block` and says nothing),
1229    /// so it is not a validation finding either.
1230    #[test]
1231    fn an_empty_code_block_is_not_a_finding() {
1232        let registry = crate::directives::validation::DirectiveRegistry::with_builtin_validators();
1233        for arguments in [vec![], vec!["python".to_string()]] {
1234            let directive = create_test_directive("code-block", arguments, HashMap::new(), "");
1235            assert_eq!(
1236                registry.validate_directive(&directive),
1237                DirectiveValidationResult::Valid
1238            );
1239        }
1240    }
1241}