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