Skip to main content

sphinx_ultra/directives/validation/
parser.rs

1//! Parser for extracting directives and roles from RST content
2
3use super::{ParsedDirective, ParsedRole, SourceLocation};
4use lazy_static::lazy_static;
5use regex::Regex;
6use std::collections::HashMap;
7
8lazy_static! {
9    /// Regex for matching directive patterns
10    static ref DIRECTIVE_REGEX: Regex = Regex::new(
11        r"(?m)^\.\. ([a-zA-Z][a-zA-Z0-9_-]*)::(.*?)$"
12    ).unwrap();
13
14    /// Regex for matching directive options
15    static ref OPTION_REGEX: Regex = Regex::new(
16        r"(?m)^\s+:([a-zA-Z][a-zA-Z0-9_-]*): ?(.*?)$"
17    ).unwrap();
18
19    /// Regex for matching role patterns. Backtick form only: the bare
20    /// ':name:word' form is not role syntax, and matching it made ordinary
21    /// prose and code samples parse as roles.
22    static ref ROLE_REGEX: Regex = Regex::new(
23        r":([a-zA-Z][a-zA-Z0-9_-]*):(`[^`]+`)"
24    ).unwrap();
25
26    /// Regex for parsing role with display text
27    static ref ROLE_WITH_TEXT_REGEX: Regex = Regex::new(
28        r"`([^<]+)<([^>]+)>`"
29    ).unwrap();
30}
31
32/// Directives whose directive-line text is body content, not arguments
33/// (docutils admonition semantics: `.. note:: inline text` is a one-line note).
34const 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
47/// Parser for extracting directives and roles from RST content
48pub struct DirectiveRoleParser {
49    /// Source file being parsed
50    source_file: String,
51}
52
53impl DirectiveRoleParser {
54    /// Creates a new parser for the given source file
55    pub fn new(source_file: String) -> Self {
56        Self { source_file }
57    }
58
59    /// Extracts all directives from the given content
60    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                // Parse arguments (admonitions take none: directive-line text
73                // is their content)
74                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                // Look for options and content in following lines
81                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    /// Extracts all roles from the given content
114    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                // Remove backticks if present
124                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                // Check for display text format: `Display Text <target>`
131                let (target, display_text) = if role_content.contains('<')
132                    && role_content.contains('>')
133                {
134                    // Try to parse "Display Text <target>" format (without expecting backticks)
135                    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    /// Parses directive body (options and content)
180    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        // docutils accepts any consistent indent >= 1; the first body line
190        // fixes the block's indent prefix.
191        let mut body_indent: Option<String> = None;
192
193        while current_line < lines.len() {
194            let line = lines[current_line];
195
196            // Empty line
197            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            // Check for option
206            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            // Check if line is indented (content)
217            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                    // A line indented differently than the block ends it.
223                    None => break,
224                };
225                in_content = true;
226                content_lines.push(content_line.to_string());
227                current_line += 1;
228                continue;
229            }
230
231            // Non-indented line after we've seen content means end of directive
232            if in_content {
233                break;
234            }
235
236            // If we haven't seen options or content, this might be the start of content
237            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    /// Extracts both directives and roles from content
249    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    /// Validates that a line contains a properly formatted directive
256    pub fn is_directive_line(line: &str) -> bool {
257        DIRECTIVE_REGEX.is_match(line)
258    }
259
260    /// Validates that text contains a role
261    pub fn contains_role(text: &str) -> bool {
262        ROLE_REGEX.is_match(text)
263    }
264
265    /// Counts the number of directives in content
266    pub fn count_directives(content: &str) -> usize {
267        DIRECTIVE_REGEX.find_iter(content).count()
268    }
269
270    /// Counts the number of roles in content
271    pub fn count_roles(content: &str) -> usize {
272        ROLE_REGEX.find_iter(content).count()
273    }
274}
275
276/// Statistics about parsed content
277#[derive(Debug, Default, Clone)]
278pub struct ParseStatistics {
279    /// Number of directives found
280    pub directive_count: usize,
281    /// Number of roles found
282    pub role_count: usize,
283    /// Breakdown by directive type
284    pub directives_by_type: HashMap<String, usize>,
285    /// Breakdown by role type
286    pub roles_by_type: HashMap<String, usize>,
287    /// Lines processed
288    pub lines_processed: usize,
289}
290
291impl ParseStatistics {
292    /// Creates new parse statistics
293    pub fn new() -> Self {
294        Self::default()
295    }
296
297    /// Records a directive
298    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    /// Records a role
307    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    /// Records lines processed
313    pub fn set_lines_processed(&mut self, lines: usize) {
314        self.lines_processed = lines;
315    }
316
317    /// Returns total items parsed
318    pub fn total_items(&self) -> usize {
319        self.directive_count + self.role_count
320    }
321}
322
323/// Enhanced parser with statistics tracking
324pub struct StatisticalDirectiveRoleParser {
325    parser: DirectiveRoleParser,
326    statistics: ParseStatistics,
327}
328
329impl StatisticalDirectiveRoleParser {
330    /// Creates a new statistical parser
331    pub fn new(source_file: String) -> Self {
332        Self {
333            parser: DirectiveRoleParser::new(source_file),
334            statistics: ParseStatistics::new(),
335        }
336    }
337
338    /// Parses content and updates statistics
339    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        // Update statistics
346        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    /// Returns current statistics
360    pub fn statistics(&self) -> &ParseStatistics {
361        &self.statistics
362    }
363
364    /// Resets statistics
365    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        // Check note directive — docutils semantics: directive-line text is
396        // the first line of the admonition's content, never arguments
397        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        // Check code-block directive
403        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        // Check doc role
431        assert_eq!(roles[0].name, "doc");
432        assert_eq!(roles[0].target, "installation");
433        assert_eq!(roles[0].display_text, None);
434
435        // Check ref role
436        assert_eq!(roles[1].name, "ref");
437        assert_eq!(roles[1].target, "advanced-config");
438
439        // Check download role
440        assert_eq!(roles[2].name, "download");
441        assert_eq!(roles[2].target, "example.pdf");
442
443        // Check math role
444        assert_eq!(roles[3].name, "math");
445        assert_eq!(roles[3].target, r"x = \frac{a}{b}");
446
447        // Check doc role with display text
448        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        // One-line admonition: valid Sphinx, the text is the whole content
499        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        // warning behaves the same way
505        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        // Truly empty admonition has no content (a real docutils error)
510        let directives = parser.extract_directives(".. note::");
511        assert!(directives[0].arguments.is_empty());
512        assert!(directives[0].content.is_empty());
513
514        // Argument-taking directives keep argument semantics
515        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        // 2-space indent is valid docutils; must not read as "no content"
524        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        // 4-space indent works the same way
529        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        // Bare ':word:text' is not role syntax and must not parse as one
538        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}