sphinx_ultra/directives/validation/
parser.rs1use super::{ParsedDirective, ParsedRole, SourceLocation};
4use lazy_static::lazy_static;
5use regex::Regex;
6use std::collections::HashMap;
7
8lazy_static! {
9 static ref DIRECTIVE_REGEX: Regex = Regex::new(
11 r"(?m)^\.\. ([a-zA-Z][a-zA-Z0-9_-]*)::(.*?)$"
12 ).unwrap();
13
14 static ref OPTION_REGEX: Regex = Regex::new(
16 r"(?m)^\s+:([a-zA-Z][a-zA-Z0-9_-]*): ?(.*?)$"
17 ).unwrap();
18
19 static ref ROLE_REGEX: Regex = Regex::new(
23 r":([a-zA-Z][a-zA-Z0-9_-]*):(`[^`]+`)"
24 ).unwrap();
25
26 static ref ROLE_WITH_TEXT_REGEX: Regex = Regex::new(
28 r"`([^<]+)<([^>]+)>`"
29 ).unwrap();
30}
31
32const INLINE_CONTENT_DIRECTIVES: &[&str] = &[
35 "note",
36 "warning",
37 "tip",
38 "hint",
39 "important",
40 "caution",
41 "danger",
42 "error",
43 "attention",
44 "seealso",
45];
46
47pub struct DirectiveRoleParser {
49 source_file: String,
51}
52
53impl DirectiveRoleParser {
54 pub fn new(source_file: String) -> Self {
56 Self { source_file }
57 }
58
59 pub fn extract_directives(&self, content: &str) -> Vec<ParsedDirective> {
61 let mut directives = Vec::new();
62 let lines: Vec<&str> = content.lines().collect();
63
64 for (line_num, line) in lines.iter().enumerate() {
65 if let Some(captures) = DIRECTIVE_REGEX.captures(line) {
66 let directive_name = captures.get(1).unwrap().as_str().to_string();
67 let args_str = captures.get(2).unwrap().as_str().trim();
68
69 let is_inline_content =
70 INLINE_CONTENT_DIRECTIVES.contains(&directive_name.as_str());
71
72 let arguments: Vec<String> = if args_str.is_empty() || is_inline_content {
75 Vec::new()
76 } else {
77 args_str.split_whitespace().map(|s| s.to_string()).collect()
78 };
79
80 let (options, body, _content_end_line) =
82 self.parse_directive_body(&lines, line_num + 1);
83
84 let content = if is_inline_content && !args_str.is_empty() {
85 if body.is_empty() {
86 args_str.to_string()
87 } else {
88 format!("{}\n{}", args_str, body)
89 }
90 } else {
91 body
92 };
93
94 let directive = ParsedDirective {
95 name: directive_name,
96 arguments,
97 options,
98 content,
99 location: SourceLocation {
100 file: self.source_file.clone(),
101 line: line_num + 1,
102 column: line.find("..").unwrap_or(0) + 1,
103 },
104 };
105
106 directives.push(directive);
107 }
108 }
109
110 directives
111 }
112
113 pub fn extract_roles(&self, content: &str) -> Vec<ParsedRole> {
115 let mut roles = Vec::new();
116 let lines: Vec<&str> = content.lines().collect();
117
118 for (line_num, line) in lines.iter().enumerate() {
119 for captures in ROLE_REGEX.captures_iter(line) {
120 let role_name = captures.get(1).unwrap().as_str().to_string();
121 let role_content = captures.get(2).unwrap().as_str();
122
123 let role_content = if role_content.starts_with('`') && role_content.ends_with('`') {
125 &role_content[1..role_content.len() - 1]
126 } else {
127 role_content
128 };
129
130 let (target, display_text) = if role_content.contains('<')
132 && role_content.contains('>')
133 {
134 if let Some(angle_start) = role_content.rfind('<') {
136 if let Some(angle_end) = role_content.rfind('>') {
137 if angle_start < angle_end {
138 let display = role_content[..angle_start].trim().to_string();
139 let target = role_content[angle_start + 1..angle_end].to_string();
140 (
141 target,
142 if display.is_empty() {
143 None
144 } else {
145 Some(display)
146 },
147 )
148 } else {
149 (role_content.to_string(), None)
150 }
151 } else {
152 (role_content.to_string(), None)
153 }
154 } else {
155 (role_content.to_string(), None)
156 }
157 } else {
158 (role_content.to_string(), None)
159 };
160
161 let role = ParsedRole {
162 name: role_name,
163 target,
164 display_text,
165 location: SourceLocation {
166 file: self.source_file.clone(),
167 line: line_num + 1,
168 column: line.find(':').unwrap_or(0) + 1,
169 },
170 };
171
172 roles.push(role);
173 }
174 }
175
176 roles
177 }
178
179 fn parse_directive_body(
181 &self,
182 lines: &[&str],
183 start_line: usize,
184 ) -> (HashMap<String, String>, String, usize) {
185 let mut options = HashMap::new();
186 let mut content_lines = Vec::new();
187 let mut current_line = start_line;
188 let mut in_content = false;
189 let mut body_indent: Option<String> = None;
192
193 while current_line < lines.len() {
194 let line = lines[current_line];
195
196 if line.trim().is_empty() {
198 if in_content {
199 content_lines.push(String::new());
200 }
201 current_line += 1;
202 continue;
203 }
204
205 if let Some(option_captures) = OPTION_REGEX.captures(line) {
207 if !in_content {
208 let option_name = option_captures.get(1).unwrap().as_str().to_string();
209 let option_value = option_captures.get(2).unwrap().as_str().to_string();
210 options.insert(option_name, option_value);
211 current_line += 1;
212 continue;
213 }
214 }
215
216 let leading_len = line.len() - line.trim_start_matches([' ', '\t']).len();
218 if leading_len > 0 {
219 let prefix = body_indent.get_or_insert_with(|| line[..leading_len].to_string());
220 let content_line = match line.strip_prefix(prefix.as_str()) {
221 Some(stripped) => stripped,
222 None => break,
224 };
225 in_content = true;
226 content_lines.push(content_line.to_string());
227 current_line += 1;
228 continue;
229 }
230
231 if in_content {
233 break;
234 }
235
236 if !line.starts_with(':') {
238 break;
239 }
240
241 current_line += 1;
242 }
243
244 let content = content_lines.join("\n");
245 (options, content, current_line)
246 }
247
248 pub fn parse_content(&self, content: &str) -> (Vec<ParsedDirective>, Vec<ParsedRole>) {
250 let directives = self.extract_directives(content);
251 let roles = self.extract_roles(content);
252 (directives, roles)
253 }
254
255 pub fn is_directive_line(line: &str) -> bool {
257 DIRECTIVE_REGEX.is_match(line)
258 }
259
260 pub fn contains_role(text: &str) -> bool {
262 ROLE_REGEX.is_match(text)
263 }
264
265 pub fn count_directives(content: &str) -> usize {
267 DIRECTIVE_REGEX.find_iter(content).count()
268 }
269
270 pub fn count_roles(content: &str) -> usize {
272 ROLE_REGEX.find_iter(content).count()
273 }
274}
275
276#[derive(Debug, Default, Clone)]
278pub struct ParseStatistics {
279 pub directive_count: usize,
281 pub role_count: usize,
283 pub directives_by_type: HashMap<String, usize>,
285 pub roles_by_type: HashMap<String, usize>,
287 pub lines_processed: usize,
289}
290
291impl ParseStatistics {
292 pub fn new() -> Self {
294 Self::default()
295 }
296
297 pub fn record_directive(&mut self, directive: &ParsedDirective) {
299 self.directive_count += 1;
300 *self
301 .directives_by_type
302 .entry(directive.name.clone())
303 .or_insert(0) += 1;
304 }
305
306 pub fn record_role(&mut self, role: &ParsedRole) {
308 self.role_count += 1;
309 *self.roles_by_type.entry(role.name.clone()).or_insert(0) += 1;
310 }
311
312 pub fn set_lines_processed(&mut self, lines: usize) {
314 self.lines_processed = lines;
315 }
316
317 pub fn total_items(&self) -> usize {
319 self.directive_count + self.role_count
320 }
321}
322
323pub struct StatisticalDirectiveRoleParser {
325 parser: DirectiveRoleParser,
326 statistics: ParseStatistics,
327}
328
329impl StatisticalDirectiveRoleParser {
330 pub fn new(source_file: String) -> Self {
332 Self {
333 parser: DirectiveRoleParser::new(source_file),
334 statistics: ParseStatistics::new(),
335 }
336 }
337
338 pub fn parse_with_statistics(
340 &mut self,
341 content: &str,
342 ) -> (Vec<ParsedDirective>, Vec<ParsedRole>) {
343 let (directives, roles) = self.parser.parse_content(content);
344
345 self.statistics.set_lines_processed(content.lines().count());
347
348 for directive in &directives {
349 self.statistics.record_directive(directive);
350 }
351
352 for role in &roles {
353 self.statistics.record_role(role);
354 }
355
356 (directives, roles)
357 }
358
359 pub fn statistics(&self) -> &ParseStatistics {
361 &self.statistics
362 }
363
364 pub fn reset_statistics(&mut self) {
366 self.statistics = ParseStatistics::new();
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn test_directive_parsing() {
376 let parser = DirectiveRoleParser::new("test.rst".to_string());
377
378 let content = r#"
379.. note:: This is a note
380
381 This is the content of the note.
382 It can span multiple lines.
383
384.. code-block:: python
385 :linenos:
386 :caption: Example code
387
388 def hello():
389 print("Hello, world!")
390"#;
391
392 let directives = parser.extract_directives(content);
393 assert_eq!(directives.len(), 2);
394
395 assert_eq!(directives[0].name, "note");
398 assert!(directives[0].arguments.is_empty());
399 assert!(directives[0].content.starts_with("This is a note"));
400 assert!(directives[0].content.contains("content of the note"));
401
402 assert_eq!(directives[1].name, "code-block");
404 assert_eq!(directives[1].arguments.len(), 1);
405 assert_eq!(directives[1].arguments[0], "python");
406 assert_eq!(directives[1].options.len(), 2);
407 assert!(directives[1].options.contains_key("linenos"));
408 assert_eq!(
409 directives[1].options.get("caption"),
410 Some(&"Example code".to_string())
411 );
412 assert!(directives[1].content.contains("def hello()"));
413 }
414
415 #[test]
416 fn test_role_parsing() {
417 let parser = DirectiveRoleParser::new("test.rst".to_string());
418
419 let content = r#"
420See :doc:`installation` for setup instructions.
421Use :ref:`advanced-config` for configuration.
422Download the :download:`example.pdf` file.
423For math, use :math:`x = \frac{a}{b}`.
424See :doc:`Custom Title <installation>` for details.
425"#;
426
427 let roles = parser.extract_roles(content);
428 assert_eq!(roles.len(), 5);
429
430 assert_eq!(roles[0].name, "doc");
432 assert_eq!(roles[0].target, "installation");
433 assert_eq!(roles[0].display_text, None);
434
435 assert_eq!(roles[1].name, "ref");
437 assert_eq!(roles[1].target, "advanced-config");
438
439 assert_eq!(roles[2].name, "download");
441 assert_eq!(roles[2].target, "example.pdf");
442
443 assert_eq!(roles[3].name, "math");
445 assert_eq!(roles[3].target, r"x = \frac{a}{b}");
446
447 assert_eq!(roles[4].name, "doc");
449 assert_eq!(roles[4].target, "installation");
450 assert_eq!(roles[4].display_text, Some("Custom Title".to_string()));
451 }
452
453 #[test]
454 fn test_statistical_parser() {
455 let mut parser = StatisticalDirectiveRoleParser::new("test.rst".to_string());
456
457 let content = r#"
458.. note:: Test note
459
460 Content here.
461
462See :doc:`test` and :ref:`section`.
463"#;
464
465 let (directives, roles) = parser.parse_with_statistics(content);
466
467 assert_eq!(directives.len(), 1);
468 assert_eq!(roles.len(), 2);
469
470 let stats = parser.statistics();
471 assert_eq!(stats.directive_count, 1);
472 assert_eq!(stats.role_count, 2);
473 assert_eq!(stats.total_items(), 3);
474 assert_eq!(stats.directives_by_type.get("note"), Some(&1));
475 assert_eq!(stats.roles_by_type.get("doc"), Some(&1));
476 assert_eq!(stats.roles_by_type.get("ref"), Some(&1));
477 }
478
479 #[test]
480 fn test_utility_functions() {
481 assert!(DirectiveRoleParser::is_directive_line(".. note:: Test"));
482 assert!(!DirectiveRoleParser::is_directive_line(
483 "This is not a directive"
484 ));
485
486 assert!(DirectiveRoleParser::contains_role("See :doc:`test` here"));
487 assert!(!DirectiveRoleParser::contains_role("No roles here"));
488
489 let content = ".. note:: Test\n.. warning:: Another\nSee :doc:`test` and :ref:`section`.";
490 assert_eq!(DirectiveRoleParser::count_directives(content), 2);
491 assert_eq!(DirectiveRoleParser::count_roles(content), 2);
492 }
493
494 #[test]
495 fn test_inline_admonition_content() {
496 let parser = DirectiveRoleParser::new("test.rst".to_string());
497
498 let directives = parser.extract_directives(".. note:: Everything on one line.");
500 assert_eq!(directives.len(), 1);
501 assert!(directives[0].arguments.is_empty());
502 assert_eq!(directives[0].content, "Everything on one line.");
503
504 let directives = parser.extract_directives(".. warning:: Watch out.");
506 assert!(directives[0].arguments.is_empty());
507 assert_eq!(directives[0].content, "Watch out.");
508
509 let directives = parser.extract_directives(".. note::");
511 assert!(directives[0].arguments.is_empty());
512 assert!(directives[0].content.is_empty());
513
514 let directives = parser.extract_directives(".. code-block:: python");
516 assert_eq!(directives[0].arguments, vec!["python".to_string()]);
517 }
518
519 #[test]
520 fn test_any_body_indent_is_content() {
521 let parser = DirectiveRoleParser::new("test.rst".to_string());
522
523 let directives = parser.extract_directives(".. note::\n\n Two-space indented body.\n");
525 assert_eq!(directives.len(), 1);
526 assert_eq!(directives[0].content, "Two-space indented body.");
527
528 let directives = parser.extract_directives(".. note::\n\n Four spaces.\n");
530 assert_eq!(directives[0].content, "Four spaces.");
531 }
532
533 #[test]
534 fn test_roles_require_backticks() {
535 let parser = DirectiveRoleParser::new("test.rst".to_string());
536
537 let roles = parser.extract_roles("compare a:b:c and :this:that in prose");
539 assert!(roles.is_empty());
540
541 let roles = parser.extract_roles("but :ref:`real-target` is a role");
542 assert_eq!(roles.len(), 1);
543 assert_eq!(roles[0].target, "real-target");
544 }
545
546 #[test]
547 fn test_directive_options_parsing() {
548 let parser = DirectiveRoleParser::new("test.rst".to_string());
549
550 let content = r#"
551.. figure:: image.png
552 :width: 100px
553 :alt: Test image
554 :align: center
555
556 This is the caption.
557"#;
558
559 let directives = parser.extract_directives(content);
560 assert_eq!(directives.len(), 1);
561
562 let directive = &directives[0];
563 assert_eq!(directive.name, "figure");
564 assert_eq!(directive.arguments.len(), 1);
565 assert_eq!(directive.arguments[0], "image.png");
566 assert_eq!(directive.options.len(), 3);
567 assert_eq!(directive.options.get("width"), Some(&"100px".to_string()));
568 assert_eq!(
569 directive.options.get("alt"),
570 Some(&"Test image".to_string())
571 );
572 assert_eq!(directive.options.get("align"), Some(&"center".to_string()));
573 assert_eq!(directive.content.trim(), "This is the caption.");
574 }
575}