Skip to main content

sphinx_ultra/directives/validation/
roles.rs

1//! Built-in role validators for common Sphinx roles
2
3use super::{ParsedRole, RoleValidationResult, RoleValidator};
4
5/// Validator for doc role
6#[derive(Default)]
7pub struct DocRoleValidator;
8
9impl DocRoleValidator {
10    pub fn new() -> Self {
11        Self
12    }
13}
14
15impl RoleValidator for DocRoleValidator {
16    fn name(&self) -> &str {
17        "doc"
18    }
19
20    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
21        if role.target.is_empty() {
22            return RoleValidationResult::Error("Doc role requires a document target".to_string());
23        }
24
25        // Relative paths (../index) are valid :doc: targets — no warning.
26
27        // Check for common document extensions
28        if role.target.ends_with(".rst") || role.target.ends_with(".md") {
29            return RoleValidationResult::Warning(
30                "Document reference should not include file extension".to_string(),
31            );
32        }
33
34        RoleValidationResult::Valid
35    }
36
37    fn requires_target(&self) -> bool {
38        true
39    }
40
41    fn allows_display_text(&self) -> bool {
42        true
43    }
44}
45
46/// Validator for ref role
47#[derive(Default)]
48pub struct RefRoleValidator;
49
50impl RefRoleValidator {
51    pub fn new() -> Self {
52        Self
53    }
54}
55
56impl RoleValidator for RefRoleValidator {
57    fn name(&self) -> &str {
58        "ref"
59    }
60
61    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
62        if role.target.is_empty() {
63            return RoleValidationResult::Error("Ref role requires a reference target".to_string());
64        }
65
66        // docutils labels may contain spaces and uppercase (`.. _My Label:`
67        // is valid, matching is case-insensitive) — no format policing here.
68        RoleValidationResult::Valid
69    }
70
71    fn requires_target(&self) -> bool {
72        true
73    }
74
75    fn allows_display_text(&self) -> bool {
76        true
77    }
78}
79
80/// Validator for download role
81#[derive(Default)]
82pub struct DownloadRoleValidator;
83
84impl DownloadRoleValidator {
85    pub fn new() -> Self {
86        Self
87    }
88}
89
90impl RoleValidator for DownloadRoleValidator {
91    fn name(&self) -> &str {
92        "download"
93    }
94
95    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
96        if role.target.is_empty() {
97            return RoleValidationResult::Error("Download role requires a file path".to_string());
98        }
99
100        // Check for potentially downloadable file types
101        let downloadable_extensions = [
102            "pdf", "zip", "tar", "gz", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv",
103            "json", "xml", "sql", "py", "rs", "js", "cpp", "c", "h", "java", "go", "rb", "php",
104        ];
105
106        if let Some(extension) = role.target.split('.').next_back() {
107            if !downloadable_extensions.contains(&extension.to_lowercase().as_str()) {
108                return RoleValidationResult::Warning(format!(
109                    "Unusual file type for download: {}",
110                    extension
111                ));
112            }
113        } else {
114            return RoleValidationResult::Warning(
115                "Download target has no file extension".to_string(),
116            );
117        }
118
119        // Check for absolute paths or URLs
120        if role.target.starts_with("http://") || role.target.starts_with("https://") {
121            return RoleValidationResult::Warning(
122                "Download role should reference local files, not URLs".to_string(),
123            );
124        }
125
126        RoleValidationResult::Valid
127    }
128
129    fn requires_target(&self) -> bool {
130        true
131    }
132
133    fn allows_display_text(&self) -> bool {
134        true
135    }
136}
137
138/// Validator for math role
139#[derive(Default)]
140pub struct MathRoleValidator;
141
142impl MathRoleValidator {
143    pub fn new() -> Self {
144        Self
145    }
146}
147
148impl RoleValidator for MathRoleValidator {
149    fn name(&self) -> &str {
150        "math"
151    }
152
153    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
154        if role.target.is_empty() {
155            return RoleValidationResult::Error(
156                "Math role requires LaTeX math expression".to_string(),
157            );
158        }
159
160        // Basic LaTeX syntax check
161        let open_braces = role.target.matches('{').count();
162        let close_braces = role.target.matches('}').count();
163
164        if open_braces != close_braces {
165            return RoleValidationResult::Warning(
166                "Unmatched braces in math expression".to_string(),
167            );
168        }
169
170        // Check for common LaTeX commands
171        if role.target.contains('\\')
172            && !role.target.contains("\\frac")
173            && !role.target.contains("\\sqrt")
174        {
175            // This is a very basic check; real validation would be much more comprehensive
176        }
177
178        RoleValidationResult::Valid
179    }
180
181    fn requires_target(&self) -> bool {
182        true
183    }
184
185    fn allows_display_text(&self) -> bool {
186        false
187    }
188}
189
190/// Validator for abbreviation role
191#[derive(Default)]
192pub struct AbbreviationRoleValidator;
193
194impl AbbreviationRoleValidator {
195    pub fn new() -> Self {
196        Self
197    }
198}
199
200impl RoleValidator for AbbreviationRoleValidator {
201    fn name(&self) -> &str {
202        "abbr"
203    }
204
205    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
206        if role.target.is_empty() {
207            return RoleValidationResult::Error("Abbreviation role requires text".to_string());
208        }
209
210        // Check for typical abbreviation format
211        if !role.target.chars().any(|c| c.is_uppercase()) {
212            return RoleValidationResult::Warning(
213                "Abbreviations typically contain uppercase letters".to_string(),
214            );
215        }
216
217        RoleValidationResult::Valid
218    }
219
220    fn requires_target(&self) -> bool {
221        true
222    }
223
224    fn allows_display_text(&self) -> bool {
225        true
226    }
227}
228
229/// Validator for command role
230#[derive(Default)]
231pub struct CommandRoleValidator;
232
233impl CommandRoleValidator {
234    pub fn new() -> Self {
235        Self
236    }
237}
238
239impl RoleValidator for CommandRoleValidator {
240    fn name(&self) -> &str {
241        "command"
242    }
243
244    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
245        if role.target.is_empty() {
246            return RoleValidationResult::Error("Command role requires a command name".to_string());
247        }
248
249        // Check for shell injection characters
250        let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>'];
251        if role.target.chars().any(|c| dangerous_chars.contains(&c)) {
252            return RoleValidationResult::Warning(
253                "Command contains potentially dangerous characters".to_string(),
254            );
255        }
256
257        RoleValidationResult::Valid
258    }
259
260    fn requires_target(&self) -> bool {
261        true
262    }
263
264    fn allows_display_text(&self) -> bool {
265        false
266    }
267}
268
269/// Validator for file role
270#[derive(Default)]
271pub struct FileRoleValidator;
272
273impl FileRoleValidator {
274    pub fn new() -> Self {
275        Self
276    }
277}
278
279impl RoleValidator for FileRoleValidator {
280    fn name(&self) -> &str {
281        "file"
282    }
283
284    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
285        if role.target.is_empty() {
286            return RoleValidationResult::Error("File role requires a file path".to_string());
287        }
288
289        // Check for valid file path characters
290        let invalid_chars = ['<', '>', ':', '"', '|', '?', '*'];
291        if role.target.chars().any(|c| invalid_chars.contains(&c)) {
292            return RoleValidationResult::Error(
293                "File path contains invalid characters".to_string(),
294            );
295        }
296
297        RoleValidationResult::Valid
298    }
299
300    fn requires_target(&self) -> bool {
301        true
302    }
303
304    fn allows_display_text(&self) -> bool {
305        false
306    }
307}
308
309/// Validator for kbd role
310#[derive(Default)]
311pub struct KbdRoleValidator;
312
313impl KbdRoleValidator {
314    pub fn new() -> Self {
315        Self
316    }
317}
318
319impl RoleValidator for KbdRoleValidator {
320    fn name(&self) -> &str {
321        "kbd"
322    }
323
324    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
325        if role.target.is_empty() {
326            return RoleValidationResult::Error("Kbd role requires key combination".to_string());
327        }
328
329        // Any key name is legal (:kbd:`Cmd`, :kbd:`PgUp`, …) — no whitelist.
330        RoleValidationResult::Valid
331    }
332
333    fn requires_target(&self) -> bool {
334        true
335    }
336
337    fn allows_display_text(&self) -> bool {
338        false
339    }
340}
341
342/// Validator for menuselection role
343#[derive(Default)]
344pub struct MenuSelectionRoleValidator;
345
346impl MenuSelectionRoleValidator {
347    pub fn new() -> Self {
348        Self
349    }
350}
351
352impl RoleValidator for MenuSelectionRoleValidator {
353    fn name(&self) -> &str {
354        "menuselection"
355    }
356
357    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
358        if role.target.is_empty() {
359            return RoleValidationResult::Error(
360                "Menu selection role requires menu path".to_string(),
361            );
362        }
363
364        // A single menu item without separators is legal — no separator check.
365        RoleValidationResult::Valid
366    }
367
368    fn requires_target(&self) -> bool {
369        true
370    }
371
372    fn allows_display_text(&self) -> bool {
373        false
374    }
375}
376
377/// Validator for guilabel role
378#[derive(Default)]
379pub struct GuiLabelRoleValidator;
380
381impl GuiLabelRoleValidator {
382    pub fn new() -> Self {
383        Self
384    }
385}
386
387impl RoleValidator for GuiLabelRoleValidator {
388    fn name(&self) -> &str {
389        "guilabel"
390    }
391
392    fn validate(&self, role: &ParsedRole) -> RoleValidationResult {
393        if role.target.is_empty() {
394            return RoleValidationResult::Error("GUI label role requires label text".to_string());
395        }
396
397        // Check for ampersand (access key indicator)
398        if role.target.contains('&') && !role.target.contains("&amp;") {
399            return RoleValidationResult::Warning(
400                "Use &amp; for literal ampersand in GUI labels".to_string(),
401            );
402        }
403
404        RoleValidationResult::Valid
405    }
406
407    fn requires_target(&self) -> bool {
408        true
409    }
410
411    fn allows_display_text(&self) -> bool {
412        false
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::directives::validation::SourceLocation;
420
421    fn create_test_role(name: &str, target: &str, display_text: Option<String>) -> ParsedRole {
422        ParsedRole {
423            name: name.to_string(),
424            target: target.to_string(),
425            display_text,
426            location: SourceLocation {
427                file: "test.rst".to_string(),
428                line: 1,
429                column: 1,
430            },
431        }
432    }
433
434    #[test]
435    fn test_doc_role_validator() {
436        let validator = DocRoleValidator::new();
437
438        // Valid doc role
439        let role = create_test_role("doc", "installation", None);
440        assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
441
442        // Empty target
443        let role = create_test_role("doc", "", None);
444        assert!(matches!(
445            validator.validate(&role),
446            RoleValidationResult::Error(_)
447        ));
448
449        // With extension
450        let role = create_test_role("doc", "installation.rst", None);
451        assert!(matches!(
452            validator.validate(&role),
453            RoleValidationResult::Warning(_)
454        ));
455    }
456
457    #[test]
458    fn test_ref_role_validator() {
459        let validator = RefRoleValidator::new();
460
461        // Valid ref role
462        let role = create_test_role("ref", "advanced-usage", None);
463        assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
464
465        // Labels with spaces are valid docutils (`.. _advanced usage:`)
466        let role = create_test_role("ref", "advanced usage", None);
467        assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
468
469        // Uppercase is fine too (label matching is case-insensitive)
470        let role = create_test_role("ref", "Advanced-Usage", None);
471        assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
472    }
473
474    #[test]
475    fn test_download_role_validator() {
476        let validator = DownloadRoleValidator::new();
477
478        // Valid download role
479        let role = create_test_role("download", "example.pdf", None);
480        assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
481
482        // No extension
483        let role = create_test_role("download", "example", None);
484        assert!(matches!(
485            validator.validate(&role),
486            RoleValidationResult::Warning(_)
487        ));
488
489        // URL
490        let role = create_test_role("download", "https://example.com/file.pdf", None);
491        assert!(matches!(
492            validator.validate(&role),
493            RoleValidationResult::Warning(_)
494        ));
495    }
496
497    #[test]
498    fn test_math_role_validator() {
499        let validator = MathRoleValidator::new();
500
501        // Valid math role
502        let role = create_test_role("math", "x = y + z", None);
503        assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
504
505        // Empty target
506        let role = create_test_role("math", "", None);
507        assert!(matches!(
508            validator.validate(&role),
509            RoleValidationResult::Error(_)
510        ));
511
512        // Unmatched braces
513        let role = create_test_role("math", "x = \\frac{a}{b", None);
514        assert!(matches!(
515            validator.validate(&role),
516            RoleValidationResult::Warning(_)
517        ));
518    }
519
520    #[test]
521    fn test_kbd_role_validator() {
522        let validator = KbdRoleValidator::new();
523
524        // Valid kbd role
525        let role = create_test_role("kbd", "Ctrl+C", None);
526        assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
527
528        // Empty target
529        let role = create_test_role("kbd", "", None);
530        assert!(matches!(
531            validator.validate(&role),
532            RoleValidationResult::Error(_)
533        ));
534    }
535}