1use crate::domains::{CrossReference, ReferenceLocation, ReferenceType};
2use lazy_static::lazy_static;
3use regex::Regex;
8use std::collections::HashMap;
9
10lazy_static! {
11 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 static ref TARGET_REGEX: Regex = Regex::new(
23 r"`([^<>]+?)(?:\s*<([^<>]+?)>)?`"
24 ).unwrap();
25}
26
27pub struct ReferenceParser {
29 role_mapping: HashMap<String, ReferenceType>,
31}
32
33impl Default for ReferenceParser {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl ReferenceParser {
40 pub fn new() -> Self {
42 let mut role_mapping = HashMap::new();
43
44 role_mapping.insert("doc".to_string(), ReferenceType::Document);
46 role_mapping.insert("ref".to_string(), ReferenceType::Section);
47
48 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 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 pub fn register_role(&mut self, role: String, ref_type: ReferenceType) {
76 self.role_mapping.insert(role, ref_type);
77 }
78
79 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 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 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 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 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 fn extract_target_and_display(&self, target_text: &str) -> (String, Option<String>) {
168 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 (target_text.trim().to_string(), None)
183 }
184
185 fn is_external_reference(&self, target: &str, ref_type: &ReferenceType) -> bool {
187 match ref_type {
188 ReferenceType::Document => {
189 target.starts_with("http://")
191 || target.starts_with("https://")
192 || target.starts_with("file://")
193 }
194 ReferenceType::Function | ReferenceType::Class | ReferenceType::Module => {
195 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 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 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 assert!(parser
284 .parse_content("a timestamp 12:30:45 and :this:that", "t", None)
285 .is_empty());
286 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 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 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 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}