Skip to main content

sphinx_ultra/domains/
parser.rs

1use crate::domains::{CrossReference, ReferenceLocation, ReferenceType};
2use lazy_static::lazy_static;
3/// Reference Parser for extracting cross-references from RST content
4///
5/// This module provides functionality to parse RST content and extract
6/// cross-references like :doc:, :ref:, :func:, :class:, etc.
7use regex::Regex;
8use std::collections::HashMap;
9
10lazy_static! {
11    /// Regex for matching Sphinx cross-references
12    /// Matches :ref:`target`, :doc:`target`, and domain-qualified forms like
13    /// :py:func:`module.function`. Backtick form only: bare ':name:word' is
14    /// not role syntax, and matching it made prose, code samples, and
15    /// line-wrapped roles parse as (then "broken") references.
16    static ref CROSS_REF_REGEX: Regex = Regex::new(
17        r":([a-zA-Z][a-zA-Z0-9_.-]*(?::[a-zA-Z][a-zA-Z0-9_.-]*)*):(`[^`]+`)"
18    ).unwrap();
19
20    /// Regex for extracting target and display text from backtick format
21    /// Matches `target <display>` or just `target`
22    static ref TARGET_REGEX: Regex = Regex::new(
23        r"`([^<>]+?)(?:\s*<([^<>]+?)>)?`"
24    ).unwrap();
25}
26
27/// Parser for extracting cross-references from RST content
28pub struct ReferenceParser {
29    /// Map of role names to reference types
30    role_mapping: HashMap<String, ReferenceType>,
31}
32
33impl Default for ReferenceParser {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl ReferenceParser {
40    /// Create a new reference parser
41    pub fn new() -> Self {
42        let mut role_mapping = HashMap::new();
43
44        // Standard RST roles
45        role_mapping.insert("doc".to_string(), ReferenceType::Document);
46        role_mapping.insert("ref".to_string(), ReferenceType::Section);
47
48        // Python domain roles
49        role_mapping.insert("func".to_string(), ReferenceType::Function);
50        role_mapping.insert("class".to_string(), ReferenceType::Class);
51        role_mapping.insert("mod".to_string(), ReferenceType::Module);
52        role_mapping.insert("meth".to_string(), ReferenceType::Method);
53        role_mapping.insert("attr".to_string(), ReferenceType::Attribute);
54        role_mapping.insert("data".to_string(), ReferenceType::Data);
55        role_mapping.insert("exc".to_string(), ReferenceType::Exception);
56
57        // Other common roles
58        role_mapping.insert(
59            "numref".to_string(),
60            ReferenceType::Custom("numref".to_string()),
61        );
62        role_mapping.insert(
63            "envvar".to_string(),
64            ReferenceType::Custom("envvar".to_string()),
65        );
66        role_mapping.insert(
67            "option".to_string(),
68            ReferenceType::Custom("option".to_string()),
69        );
70
71        Self { role_mapping }
72    }
73
74    /// Register a custom role mapping
75    pub fn register_role(&mut self, role: String, ref_type: ReferenceType) {
76        self.role_mapping.insert(role, ref_type);
77    }
78
79    /// Parse content and extract all cross-references
80    pub fn parse_content(
81        &self,
82        content: &str,
83        docname: &str,
84        source_path: Option<String>,
85    ) -> Vec<CrossReference> {
86        let mut references = Vec::new();
87
88        for (line_num, line) in content.lines().enumerate() {
89            let line_refs = self.parse_line(line, docname, line_num + 1, source_path.clone());
90            references.extend(line_refs);
91        }
92
93        references
94    }
95
96    /// Parse a single line and extract cross-references
97    pub fn parse_line(
98        &self,
99        line: &str,
100        docname: &str,
101        line_num: usize,
102        source_path: Option<String>,
103    ) -> Vec<CrossReference> {
104        let mut references = Vec::new();
105
106        for cap in CROSS_REF_REGEX.captures_iter(line) {
107            let role = cap.get(1).unwrap().as_str();
108            let target_text = cap.get(2).unwrap().as_str();
109
110            if let Some(cross_ref) = self.parse_reference(
111                role,
112                target_text,
113                docname,
114                line_num,
115                cap.get(0).unwrap().start(),
116                source_path.clone(),
117            ) {
118                references.push(cross_ref);
119            }
120        }
121
122        references
123    }
124
125    /// Parse a single reference
126    fn parse_reference(
127        &self,
128        role: &str,
129        target_text: &str,
130        docname: &str,
131        line_num: usize,
132        column: usize,
133        source_path: Option<String>,
134    ) -> Option<CrossReference> {
135        // Domain-qualified roles (:py:func:) map through their base role.
136        let base_role = role.rsplit(':').next().unwrap_or(role);
137        let ref_type = self
138            .role_mapping
139            .get(base_role)
140            .cloned()
141            .unwrap_or_else(|| ReferenceType::Custom(base_role.to_string()));
142
143        let (target, display_text) = self.extract_target_and_display(target_text);
144
145        // Check if this might be an external reference
146        let is_external = self.is_external_reference(&target, &ref_type);
147
148        Some(CrossReference {
149            ref_type,
150            target,
151            display_text,
152            source_location: ReferenceLocation {
153                docname: docname.to_string(),
154                lineno: Some(line_num),
155                column: Some(column),
156                source_path,
157            },
158            is_external,
159        })
160    }
161
162    /// Extract target and display text from target string
163    ///
164    /// Sphinx semantics for `` `Display Text <target>` ``: the angle brackets
165    /// carry the target and the leading text is the display text. Without
166    /// angle brackets the whole text is the target.
167    fn extract_target_and_display(&self, target_text: &str) -> (String, Option<String>) {
168        // Handle backtick format
169        if target_text.starts_with('`') && target_text.ends_with('`') {
170            if let Some(cap) = TARGET_REGEX.captures(target_text) {
171                let leading_text = cap.get(1).unwrap().as_str().trim().to_string();
172                return match cap.get(2) {
173                    Some(angle_target) => {
174                        (angle_target.as_str().trim().to_string(), Some(leading_text))
175                    }
176                    None => (leading_text, None),
177                };
178            }
179        }
180
181        // Simple format without backticks
182        (target_text.trim().to_string(), None)
183    }
184
185    /// Determine if a reference is external
186    fn is_external_reference(&self, target: &str, ref_type: &ReferenceType) -> bool {
187        match ref_type {
188            ReferenceType::Document => {
189                // External if it contains a protocol or starts with http
190                target.starts_with("http://")
191                    || target.starts_with("https://")
192                    || target.starts_with("file://")
193            }
194            ReferenceType::Function | ReferenceType::Class | ReferenceType::Module => {
195                // External if it starts with a known external library
196                target.starts_with("builtins.")
197                    || target.starts_with("typing.")
198                    || target.starts_with("collections.")
199                    || target.starts_with("pathlib.")
200                    || target.starts_with("os.")
201                    || target.starts_with("sys.")
202                    || target.starts_with("json.")
203                    || target.starts_with("re.")
204                    || target.starts_with("datetime.")
205                    || target.starts_with("urllib.")
206                    || target.starts_with("http.")
207            }
208            _ => false,
209        }
210    }
211
212    /// Get statistics about parsed references
213    pub fn get_reference_stats(&self, references: &[CrossReference]) -> HashMap<String, usize> {
214        let mut stats = HashMap::new();
215
216        for reference in references {
217            let key = match &reference.ref_type {
218                ReferenceType::Custom(name) => name.clone(),
219                _ => format!("{:?}", reference.ref_type),
220            };
221            *stats.entry(key).or_insert(0) += 1;
222        }
223
224        stats
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_reference_parser_creation() {
234        let parser = ReferenceParser::new();
235        assert!(parser.role_mapping.contains_key("doc"));
236        assert!(parser.role_mapping.contains_key("func"));
237        assert!(parser.role_mapping.contains_key("class"));
238    }
239
240    #[test]
241    fn test_simple_reference_parsing() {
242        let parser = ReferenceParser::new();
243        let content = "See :doc:`installation` for details.";
244
245        let refs = parser.parse_content(content, "index", None);
246        assert_eq!(refs.len(), 1);
247
248        let ref_obj = &refs[0];
249        assert_eq!(ref_obj.ref_type, ReferenceType::Document);
250        assert_eq!(ref_obj.target, "installation");
251        assert_eq!(ref_obj.display_text, None);
252        assert!(!ref_obj.is_external);
253    }
254
255    #[test]
256    fn test_reference_with_display_text() {
257        // Sphinx semantics: `Display Text <target>` — the angle brackets
258        // carry the target, the leading text is what gets displayed.
259        let parser = ReferenceParser::new();
260        let content = "See :doc:`Installation Guide <installation>` for details.";
261
262        let refs = parser.parse_content(content, "index", None);
263        assert_eq!(refs.len(), 1);
264
265        let ref_obj = &refs[0];
266        assert_eq!(ref_obj.target, "installation");
267        assert_eq!(ref_obj.display_text, Some("Installation Guide".to_string()));
268    }
269
270    #[test]
271    fn test_domain_qualified_role_maps_through_base() {
272        let parser = ReferenceParser::new();
273        let refs = parser.parse_content("Call :py:func:`missing.fn` here.", "api", None);
274        assert_eq!(refs.len(), 1);
275        assert_eq!(refs[0].ref_type, ReferenceType::Function);
276        assert_eq!(refs[0].target, "missing.fn");
277    }
278
279    #[test]
280    fn test_bare_and_wrapped_roles_do_not_parse() {
281        let parser = ReferenceParser::new();
282        // Bare ':word:text' is not role syntax
283        assert!(parser
284            .parse_content("a timestamp 12:30:45 and :this:that", "t", None)
285            .is_empty());
286        // A role wrapped across lines must not half-match as a broken target
287        assert!(parser
288            .parse_content("See :ref:`my long\nlabel` here.", "t", None)
289            .is_empty());
290    }
291
292    #[test]
293    fn test_python_function_reference() {
294        let parser = ReferenceParser::new();
295        let content = "Use :func:`mymodule.my_function` to process data.";
296
297        let refs = parser.parse_content(content, "api", None);
298        assert_eq!(refs.len(), 1);
299
300        let ref_obj = &refs[0];
301        assert_eq!(ref_obj.ref_type, ReferenceType::Function);
302        assert_eq!(ref_obj.target, "mymodule.my_function");
303        assert!(!ref_obj.is_external);
304    }
305
306    #[test]
307    fn test_external_reference_detection() {
308        let parser = ReferenceParser::new();
309
310        // External Python reference
311        let content1 = "Use :func:`os.path.join` for paths.";
312        let refs1 = parser.parse_content(content1, "test", None);
313        assert_eq!(refs1.len(), 1);
314        assert!(refs1[0].is_external);
315
316        // External document reference
317        let content2 = "See :doc:`https://docs.python.org/3/` for more.";
318        let refs2 = parser.parse_content(content2, "test", None);
319        assert_eq!(refs2.len(), 1);
320        assert!(refs2[0].is_external);
321    }
322
323    #[test]
324    fn test_multiple_references_in_line() {
325        let parser = ReferenceParser::new();
326        let content = "Use :func:`func1` and :class:`MyClass` together.";
327
328        let refs = parser.parse_content(content, "test", None);
329        assert_eq!(refs.len(), 2);
330
331        assert_eq!(refs[0].ref_type, ReferenceType::Function);
332        assert_eq!(refs[0].target, "func1");
333
334        assert_eq!(refs[1].ref_type, ReferenceType::Class);
335        assert_eq!(refs[1].target, "MyClass");
336    }
337
338    #[test]
339    fn test_section_reference() {
340        let parser = ReferenceParser::new();
341        let content = "See :ref:`installation-section` for setup instructions.";
342
343        let refs = parser.parse_content(content, "guide", None);
344        assert_eq!(refs.len(), 1);
345
346        let ref_obj = &refs[0];
347        assert_eq!(ref_obj.ref_type, ReferenceType::Section);
348        assert_eq!(ref_obj.target, "installation-section");
349    }
350
351    #[test]
352    fn test_custom_role() {
353        let mut parser = ReferenceParser::new();
354        parser.register_role(
355            "myref".to_string(),
356            ReferenceType::Custom("myref".to_string()),
357        );
358
359        let content = "See :myref:`custom-target` for details.";
360        let refs = parser.parse_content(content, "test", None);
361        assert_eq!(refs.len(), 1);
362
363        let ref_obj = &refs[0];
364        assert_eq!(ref_obj.ref_type, ReferenceType::Custom("myref".to_string()));
365        assert_eq!(ref_obj.target, "custom-target");
366    }
367
368    #[test]
369    fn test_multiline_content() {
370        let parser = ReferenceParser::new();
371        let content = r#"This is line 1 with :doc:`doc1`.
372This is line 2 with :func:`function1`.
373This is line 3 with :ref:`section1`."#;
374
375        let refs = parser.parse_content(content, "test", None);
376        assert_eq!(refs.len(), 3);
377
378        // Check line numbers
379        assert_eq!(refs[0].source_location.lineno, Some(1));
380        assert_eq!(refs[1].source_location.lineno, Some(2));
381        assert_eq!(refs[2].source_location.lineno, Some(3));
382    }
383
384    #[test]
385    fn test_reference_stats() {
386        let parser = ReferenceParser::new();
387        let content = r#"Use :doc:`doc1` and :doc:`doc2`.
388Also :func:`func1` and :class:`class1`."#;
389
390        let refs = parser.parse_content(content, "test", None);
391        let stats = parser.get_reference_stats(&refs);
392
393        assert_eq!(stats.get("Document"), Some(&2));
394        assert_eq!(stats.get("Function"), Some(&1));
395        assert_eq!(stats.get("Class"), Some(&1));
396    }
397}