sphinx_ultra/directives/validation/
roles.rs1use super::{ParsedRole, RoleValidationResult, RoleValidator};
4
5#[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 if role.target.contains("..") {
27 return RoleValidationResult::Warning(
28 "Document path contains parent directory references".to_string(),
29 );
30 }
31
32 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#[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 if role.target.contains(' ') {
73 return RoleValidationResult::Error(
74 "Reference targets cannot contain spaces".to_string(),
75 );
76 }
77
78 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#[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 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 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#[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 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 if role.target.contains('\\')
194 && !role.target.contains("\\frac")
195 && !role.target.contains("\\sqrt")
196 {
197 }
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#[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 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#[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 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#[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 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#[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 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 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#[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 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#[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 if role.target.contains('&') && !role.target.contains("&") {
465 return RoleValidationResult::Warning(
466 "Use & 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 let role = create_test_role("doc", "installation", None);
506 assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
507
508 let role = create_test_role("doc", "", None);
510 assert!(matches!(
511 validator.validate(&role),
512 RoleValidationResult::Error(_)
513 ));
514
515 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 let role = create_test_role("ref", "advanced-usage", None);
529 assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
530
531 let role = create_test_role("ref", "advanced usage", None);
533 assert!(matches!(
534 validator.validate(&role),
535 RoleValidationResult::Error(_)
536 ));
537
538 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 let role = create_test_role("download", "example.pdf", None);
552 assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
553
554 let role = create_test_role("download", "example", None);
556 assert!(matches!(
557 validator.validate(&role),
558 RoleValidationResult::Warning(_)
559 ));
560
561 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 let role = create_test_role("math", "x = y + z", None);
575 assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
576
577 let role = create_test_role("math", "", None);
579 assert!(matches!(
580 validator.validate(&role),
581 RoleValidationResult::Error(_)
582 ));
583
584 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 let role = create_test_role("kbd", "Ctrl+C", None);
598 assert_eq!(validator.validate(&role), RoleValidationResult::Valid);
599
600 let role = create_test_role("kbd", "", None);
602 assert!(matches!(
603 validator.validate(&role),
604 RoleValidationResult::Error(_)
605 ));
606 }
607}