1use std::sync::LazyLock;
31
32use regex::Regex;
33
34use super::{DocStandardParser, ParsedDocumentation};
35use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
36
37static SECTION_HEADER: LazyLock<Regex> = LazyLock::new(|| {
39 Regex::new(r"^#\s+(Examples?|Arguments?|Parameters?|Returns?|Panics?|Errors?|Safety|Type\s+Parameters?|See\s+Also|Notes?|Warnings?)\s*$")
40 .expect("Invalid section header regex")
41});
42
43static INTRA_DOC_LINK: LazyLock<Regex> = LazyLock::new(|| {
45 Regex::new(r"\[`?([a-zA-Z_][a-zA-Z0-9_:]*)`?\](?:\([^)]+\))?")
46 .expect("Invalid intra-doc link regex")
47});
48
49static CODE_BLOCK_START: LazyLock<Regex> =
51 LazyLock::new(|| Regex::new(r"^```(\w*)?\s*$").expect("Invalid code block regex"));
52
53static ARG_LINE: LazyLock<Regex> = LazyLock::new(|| {
55 Regex::new(r"^\*?\s*`?([a-zA-Z_][a-zA-Z0-9_]*)`?\s*[-:]?\s*(.*)$")
56 .expect("Invalid argument line regex")
57});
58
59#[derive(Debug, Clone, Default)]
61pub struct RustDocExtensions {
62 pub is_module_doc: bool,
64
65 pub is_unsafe: bool,
67
68 pub returns_result: bool,
70
71 pub is_async: bool,
73
74 pub panics: Vec<String>,
76
77 pub errors: Vec<String>,
79
80 pub safety: Vec<String>,
82
83 pub type_params: Vec<(String, Option<String>)>,
85
86 pub doc_links: Vec<String>,
88
89 pub must_use: Option<String>,
91
92 pub deprecated_since: Option<String>,
94
95 pub feature_gate: Option<String>,
97}
98
99pub struct RustdocParser {
102 extensions: RustDocExtensions,
104}
105
106impl RustdocParser {
107 pub fn new() -> Self {
109 Self {
110 extensions: RustDocExtensions::default(),
111 }
112 }
113
114 pub fn extensions(&self) -> &RustDocExtensions {
116 &self.extensions
117 }
118
119 fn is_module_doc(raw: &str) -> bool {
121 raw.lines().any(|line| {
123 let trimmed = line.trim();
124 trimmed.starts_with("//!")
125 })
126 }
127
128 fn strip_doc_prefix(line: &str) -> &str {
130 let trimmed = line.trim();
131 if let Some(rest) = trimmed.strip_prefix("///") {
132 rest.trim_start()
133 } else if let Some(rest) = trimmed.strip_prefix("//!") {
134 rest.trim_start()
135 } else if let Some(rest) = trimmed.strip_prefix("*") {
136 rest.trim_start()
138 } else {
139 trimmed
140 }
141 }
142
143 fn parse_section_content(
145 &self,
146 section: &str,
147 content: &[String],
148 doc: &mut ParsedDocumentation,
149 extensions: &mut RustDocExtensions,
150 ) {
151 let text = content.join("\n").trim().to_string();
152 if text.is_empty() {
153 return;
154 }
155
156 match section.to_lowercase().as_str() {
157 "arguments" | "argument" | "parameters" | "parameter" => {
158 for param in self.parse_arguments(&text) {
160 doc.params.push(param);
161 }
162 }
163 "returns" | "return" => {
164 doc.returns = Some((None, Some(text)));
165 }
166 "panics" | "panic" => {
167 extensions.panics.push(text.clone());
168 doc.notes.push(format!("Panics: {}", text));
169 }
170 "errors" | "error" => {
171 extensions.errors.push(text.clone());
172 extensions.returns_result = true;
173 doc.notes.push(format!("Errors: {}", text));
174 }
175 "safety" => {
176 extensions.is_unsafe = true;
177 extensions.safety.push(text.clone());
178 doc.notes.push(format!("Safety: {}", text));
179 }
180 "type parameters" | "type parameter" => {
181 for (name, desc) in self.parse_type_params(&text) {
183 extensions.type_params.push((name, desc));
184 }
185 }
186 "examples" | "example" => {
187 doc.examples.push(text);
188 }
189 "see also" => {
190 for ref_line in text.lines() {
191 let ref_line = ref_line.trim();
192 if !ref_line.is_empty() {
193 doc.see_refs.push(ref_line.to_string());
194 }
195 }
196 }
197 "notes" | "note" => {
198 doc.notes.push(text);
199 }
200 "warnings" | "warning" => {
201 doc.notes.push(format!("Warning: {}", text));
202 }
203 _ => {}
204 }
205 }
206
207 fn parse_arguments(&self, text: &str) -> Vec<(String, Option<String>, Option<String>)> {
209 let mut args = Vec::new();
210 let mut current_name: Option<String> = None;
211 let mut current_desc = Vec::new();
212
213 for line in text.lines() {
214 let trimmed = line.trim();
215 if trimmed.is_empty() {
216 continue;
217 }
218
219 let content = if let Some(rest) = trimmed.strip_prefix('-') {
221 rest.trim()
222 } else if let Some(rest) = trimmed.strip_prefix('*') {
223 rest.trim()
224 } else {
225 trimmed
226 };
227
228 if let Some(caps) = ARG_LINE.captures(content) {
230 if let Some(name) = current_name.take() {
232 let desc = if current_desc.is_empty() {
233 None
234 } else {
235 Some(current_desc.join(" "))
236 };
237 args.push((name, None, desc));
238 current_desc.clear();
239 }
240
241 let name = caps.get(1).unwrap().as_str().to_string();
242 let desc = caps.get(2).map(|m| m.as_str().trim().to_string());
243 current_name = Some(name);
244 if let Some(d) = desc {
245 if !d.is_empty() {
246 current_desc.push(d);
247 }
248 }
249 } else if current_name.is_some() && (line.starts_with(" ") || line.starts_with("\t")) {
250 current_desc.push(trimmed.to_string());
252 }
253 }
254
255 if let Some(name) = current_name {
257 let desc = if current_desc.is_empty() {
258 None
259 } else {
260 Some(current_desc.join(" "))
261 };
262 args.push((name, None, desc));
263 }
264
265 args
266 }
267
268 fn parse_type_params(&self, text: &str) -> Vec<(String, Option<String>)> {
270 let mut params = Vec::new();
271
272 for line in text.lines() {
273 let trimmed = line.trim();
274 if trimmed.is_empty() {
275 continue;
276 }
277
278 let content = if let Some(rest) = trimmed.strip_prefix('-') {
280 rest.trim()
281 } else if let Some(rest) = trimmed.strip_prefix('*') {
282 rest.trim()
283 } else {
284 trimmed
285 };
286
287 if let Some(caps) = ARG_LINE.captures(content) {
289 let name = caps.get(1).unwrap().as_str().to_string();
290 let desc = caps.get(2).map(|m| m.as_str().trim().to_string());
291 params.push((name, desc));
292 }
293 }
294
295 params
296 }
297
298 fn extract_doc_links(&self, text: &str) -> Vec<String> {
300 INTRA_DOC_LINK
301 .captures_iter(text)
302 .filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
303 .collect()
304 }
305
306 fn has_code_examples(&self, raw: &str) -> bool {
308 let mut in_code_block = false;
309 for line in raw.lines() {
310 let stripped = Self::strip_doc_prefix(line);
311 if stripped.starts_with("```") {
312 in_code_block = !in_code_block;
313 }
314 }
315 raw.contains("```")
316 }
317}
318
319impl Default for RustdocParser {
320 fn default() -> Self {
321 Self::new()
322 }
323}
324
325impl DocStandardParser for RustdocParser {
326 fn parse(&self, raw_comment: &str) -> ParsedDocumentation {
327 let mut doc = ParsedDocumentation::new();
328 let mut extensions = RustDocExtensions {
329 is_module_doc: Self::is_module_doc(raw_comment),
330 ..Default::default()
331 };
332
333 let mut summary_lines = Vec::new();
334 let mut current_section: Option<String> = None;
335 let mut section_content = Vec::new();
336 let mut in_code_block = false;
337 let mut first_paragraph_done = false;
338
339 for line in raw_comment.lines() {
340 let stripped = Self::strip_doc_prefix(line);
341
342 if CODE_BLOCK_START.is_match(stripped) {
344 in_code_block = !in_code_block;
345 if current_section.is_some() {
346 section_content.push(stripped.to_string());
347 }
348 continue;
349 }
350
351 if in_code_block {
352 if current_section.is_some() {
353 section_content.push(stripped.to_string());
354 }
355 continue;
356 }
357
358 if let Some(caps) = SECTION_HEADER.captures(stripped) {
360 if let Some(ref section) = current_section {
362 self.parse_section_content(
363 section,
364 §ion_content,
365 &mut doc,
366 &mut extensions,
367 );
368 }
369 section_content.clear();
370
371 current_section = Some(caps.get(1).unwrap().as_str().to_string());
372 first_paragraph_done = true;
373 } else if current_section.is_some() {
374 section_content.push(stripped.to_string());
375 } else if stripped.is_empty() {
376 if !summary_lines.is_empty() {
378 first_paragraph_done = true;
379 }
380 } else if !first_paragraph_done {
381 summary_lines.push(stripped.to_string());
382 }
383 }
384
385 if let Some(ref section) = current_section {
387 self.parse_section_content(section, §ion_content, &mut doc, &mut extensions);
388 }
389
390 if !summary_lines.is_empty() {
392 doc.summary = Some(summary_lines[0].clone());
393 if summary_lines.len() > 1 {
394 doc.description = Some(summary_lines.join(" "));
395 }
396 }
397
398 extensions.doc_links = self.extract_doc_links(raw_comment);
400
401 if self.has_code_examples(raw_comment) && doc.examples.is_empty() {
403 doc.examples.push("Has code examples".to_string());
404 }
405
406 if extensions.is_module_doc {
408 doc.custom_tags
409 .push(("module_doc".to_string(), "true".to_string()));
410 }
411 if extensions.is_unsafe {
412 doc.custom_tags
413 .push(("unsafe".to_string(), "true".to_string()));
414 }
415 if extensions.returns_result {
416 doc.custom_tags
417 .push(("returns_result".to_string(), "true".to_string()));
418 }
419 if !extensions.panics.is_empty() {
420 doc.custom_tags
421 .push(("has_panics".to_string(), "true".to_string()));
422 }
423 if !extensions.safety.is_empty() {
424 doc.custom_tags
425 .push(("has_safety".to_string(), "true".to_string()));
426 }
427 for link in &extensions.doc_links {
428 doc.see_refs.push(link.clone());
429 }
430
431 doc
432 }
433
434 fn standard_name(&self) -> &'static str {
435 "rustdoc"
436 }
437
438 fn to_suggestions(
440 &self,
441 parsed: &ParsedDocumentation,
442 target: &str,
443 line: usize,
444 ) -> Vec<Suggestion> {
445 let mut suggestions = Vec::new();
446
447 if let Some(summary) = &parsed.summary {
449 let truncated = truncate_for_summary(summary, 100);
450 suggestions.push(Suggestion::summary(
451 target,
452 line,
453 truncated,
454 SuggestionSource::Converted,
455 ));
456 }
457
458 if let Some(msg) = &parsed.deprecated {
460 suggestions.push(Suggestion::deprecated(
461 target,
462 line,
463 msg,
464 SuggestionSource::Converted,
465 ));
466 }
467
468 for see_ref in &parsed.see_refs {
470 if !["self", "Self", "crate", "super"].contains(&see_ref.as_str()) {
472 suggestions.push(Suggestion::new(
473 target,
474 line,
475 AnnotationType::Ref,
476 see_ref,
477 SuggestionSource::Converted,
478 ));
479 }
480 }
481
482 for todo in &parsed.todos {
484 suggestions.push(Suggestion::new(
485 target,
486 line,
487 AnnotationType::Hack,
488 format!("reason=\"{}\"", todo),
489 SuggestionSource::Converted,
490 ));
491 }
492
493 if parsed
495 .custom_tags
496 .iter()
497 .any(|(k, v)| k == "unsafe" && v == "true")
498 {
499 suggestions.push(Suggestion::ai_hint(
500 target,
501 line,
502 "unsafe code - review safety requirements",
503 SuggestionSource::Converted,
504 ));
505 }
506
507 if parsed
509 .custom_tags
510 .iter()
511 .any(|(k, v)| k == "has_safety" && v == "true")
512 {
513 suggestions.push(Suggestion::ai_hint(
514 target,
515 line,
516 "has safety requirements documented",
517 SuggestionSource::Converted,
518 ));
519 }
520
521 if parsed
523 .custom_tags
524 .iter()
525 .any(|(k, v)| k == "has_panics" && v == "true")
526 {
527 suggestions.push(Suggestion::ai_hint(
528 target,
529 line,
530 "may panic - see Panics section",
531 SuggestionSource::Converted,
532 ));
533 }
534
535 if parsed
537 .custom_tags
538 .iter()
539 .any(|(k, v)| k == "returns_result" && v == "true")
540 {
541 suggestions.push(Suggestion::ai_hint(
542 target,
543 line,
544 "returns Result type with documented errors",
545 SuggestionSource::Converted,
546 ));
547 }
548
549 if parsed
551 .custom_tags
552 .iter()
553 .any(|(k, v)| k == "module_doc" && v == "true")
554 {
555 suggestions.push(Suggestion::new(
556 target,
557 line,
558 AnnotationType::Module,
559 parsed.summary.as_deref().unwrap_or(target),
560 SuggestionSource::Converted,
561 ));
562 }
563
564 if !parsed.examples.is_empty() {
566 suggestions.push(Suggestion::ai_hint(
567 target,
568 line,
569 "has documented examples",
570 SuggestionSource::Converted,
571 ));
572 }
573
574 for note in &parsed.notes {
576 let truncated = if note.len() > 80 {
578 format!("{}...", ¬e[..77])
579 } else {
580 note.clone()
581 };
582 suggestions.push(Suggestion::ai_hint(
583 target,
584 line,
585 truncated,
586 SuggestionSource::Converted,
587 ));
588 }
589
590 suggestions
591 }
592}
593
594fn truncate_for_summary(s: &str, max_len: usize) -> String {
596 let trimmed = s.trim();
597 if trimmed.len() <= max_len {
598 trimmed.to_string()
599 } else {
600 let truncate_at = trimmed[..max_len].rfind(' ').unwrap_or(max_len);
601 format!("{}...", &trimmed[..truncate_at])
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn test_is_module_doc() {
611 assert!(RustdocParser::is_module_doc("//! Module docs"));
612 assert!(!RustdocParser::is_module_doc("/// Item docs"));
613 }
614
615 #[test]
616 fn test_strip_doc_prefix() {
617 assert_eq!(RustdocParser::strip_doc_prefix("/// Hello"), "Hello");
618 assert_eq!(RustdocParser::strip_doc_prefix("//! World"), "World");
619 assert_eq!(RustdocParser::strip_doc_prefix("/// Spaced"), "Spaced");
620 }
621
622 #[test]
623 fn test_parse_basic_rustdoc() {
624 let parser = RustdocParser::new();
625 let doc = parser.parse(
626 r#"
627/// Creates a new instance of the parser.
628///
629/// This function initializes the parser with default settings.
630"#,
631 );
632
633 assert_eq!(
634 doc.summary,
635 Some("Creates a new instance of the parser.".to_string())
636 );
637 }
638
639 #[test]
640 fn test_parse_with_arguments_section() {
641 let parser = RustdocParser::new();
642 let doc = parser.parse(
643 r#"
644/// Calculates the sum of two numbers.
645///
646/// # Arguments
647///
648/// * `a` - The first number
649/// * `b` - The second number
650///
651/// # Returns
652///
653/// The sum of a and b
654"#,
655 );
656
657 assert_eq!(
658 doc.summary,
659 Some("Calculates the sum of two numbers.".to_string())
660 );
661 assert_eq!(doc.params.len(), 2);
662 assert_eq!(doc.params[0].0, "a");
663 assert_eq!(doc.params[1].0, "b");
664 assert!(doc.returns.is_some());
665 }
666
667 #[test]
668 fn test_parse_with_panics_section() {
669 let parser = RustdocParser::new();
670 let doc = parser.parse(
671 r#"
672/// Divides two numbers.
673///
674/// # Panics
675///
676/// Panics if the divisor is zero.
677"#,
678 );
679
680 assert!(doc
681 .custom_tags
682 .iter()
683 .any(|(k, v)| k == "has_panics" && v == "true"));
684 assert!(doc.notes.iter().any(|n| n.contains("Panics")));
685 }
686
687 #[test]
688 fn test_parse_with_errors_section() {
689 let parser = RustdocParser::new();
690 let doc = parser.parse(
691 r#"
692/// Opens a file.
693///
694/// # Errors
695///
696/// Returns an error if the file does not exist.
697"#,
698 );
699
700 assert!(doc
701 .custom_tags
702 .iter()
703 .any(|(k, v)| k == "returns_result" && v == "true"));
704 assert!(doc.notes.iter().any(|n| n.contains("Errors")));
705 }
706
707 #[test]
708 fn test_parse_with_safety_section() {
709 let parser = RustdocParser::new();
710 let doc = parser.parse(
711 r#"
712/// Dereferences a raw pointer.
713///
714/// # Safety
715///
716/// The pointer must be valid and properly aligned.
717"#,
718 );
719
720 assert!(doc
721 .custom_tags
722 .iter()
723 .any(|(k, v)| k == "unsafe" && v == "true"));
724 assert!(doc
725 .custom_tags
726 .iter()
727 .any(|(k, v)| k == "has_safety" && v == "true"));
728 }
729
730 #[test]
731 fn test_parse_with_examples() {
732 let parser = RustdocParser::new();
733 let doc = parser.parse(
734 r#"
735/// Adds two numbers.
736///
737/// # Examples
738///
739/// ```rust
740/// let result = add(2, 3);
741/// assert_eq!(result, 5);
742/// ```
743"#,
744 );
745
746 assert!(!doc.examples.is_empty());
747 }
748
749 #[test]
750 fn test_parse_module_level_docs() {
751 let parser = RustdocParser::new();
752 let doc = parser.parse(
753 r#"
754//! This module provides utility functions.
755//!
756//! It includes helpers for parsing and formatting.
757"#,
758 );
759
760 assert!(doc
761 .custom_tags
762 .iter()
763 .any(|(k, v)| k == "module_doc" && v == "true"));
764 assert_eq!(
765 doc.summary,
766 Some("This module provides utility functions.".to_string())
767 );
768 }
769
770 #[test]
771 fn test_parse_intra_doc_links() {
772 let parser = RustdocParser::new();
773 let doc = parser.parse(
774 r#"
775/// See [`Parser`] and [`Config::new`] for more details.
776"#,
777 );
778
779 assert!(doc.see_refs.contains(&"Parser".to_string()));
780 assert!(doc.see_refs.contains(&"Config::new".to_string()));
781 }
782
783 #[test]
784 fn test_parse_type_parameters() {
785 let parser = RustdocParser::new();
786 let doc = parser.parse(
787 r#"
788/// A generic container.
789///
790/// # Type Parameters
791///
792/// * `T` - The type of elements stored
793/// * `E` - The error type
794"#,
795 );
796
797 assert_eq!(doc.summary, Some("A generic container.".to_string()));
798 }
799
800 #[test]
801 fn test_to_suggestions_basic() {
802 let parser = RustdocParser::new();
803 let doc = parser.parse(
804 r#"
805/// Creates a new parser instance.
806"#,
807 );
808
809 let suggestions = parser.to_suggestions(&doc, "new", 10);
810
811 assert!(suggestions
812 .iter()
813 .any(|s| s.annotation_type == AnnotationType::Summary
814 && s.value.contains("Creates a new parser")));
815 }
816
817 #[test]
818 fn test_to_suggestions_unsafe() {
819 let parser = RustdocParser::new();
820 let doc = parser.parse(
821 r#"
822/// Dereferences a raw pointer.
823///
824/// # Safety
825///
826/// The pointer must be valid.
827"#,
828 );
829
830 let suggestions = parser.to_suggestions(&doc, "deref", 1);
831
832 assert!(suggestions
833 .iter()
834 .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("unsafe")));
835 }
836
837 #[test]
838 fn test_to_suggestions_panics() {
839 let parser = RustdocParser::new();
840 let doc = parser.parse(
841 r#"
842/// Divides two numbers.
843///
844/// # Panics
845///
846/// Panics if divisor is zero.
847"#,
848 );
849
850 let suggestions = parser.to_suggestions(&doc, "divide", 1);
851
852 assert!(suggestions
853 .iter()
854 .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("panic")));
855 }
856
857 #[test]
858 fn test_to_suggestions_result() {
859 let parser = RustdocParser::new();
860 let doc = parser.parse(
861 r#"
862/// Opens a file.
863///
864/// # Errors
865///
866/// Returns error if file not found.
867"#,
868 );
869
870 let suggestions = parser.to_suggestions(&doc, "open_file", 1);
871
872 assert!(suggestions
873 .iter()
874 .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("Result")));
875 }
876
877 #[test]
878 fn test_to_suggestions_module_doc() {
879 let parser = RustdocParser::new();
880 let doc = parser.parse(
881 r#"
882//! Parser utilities module.
883"#,
884 );
885
886 let suggestions = parser.to_suggestions(&doc, "parser", 1);
887
888 assert!(suggestions
889 .iter()
890 .any(|s| s.annotation_type == AnnotationType::Module));
891 }
892
893 #[test]
894 fn test_to_suggestions_refs() {
895 let parser = RustdocParser::new();
896 let doc = parser.parse(
897 r#"
898/// See [`Config`] for configuration options.
899"#,
900 );
901
902 let suggestions = parser.to_suggestions(&doc, "func", 1);
903
904 assert!(suggestions
905 .iter()
906 .any(|s| s.annotation_type == AnnotationType::Ref && s.value == "Config"));
907 }
908
909 #[test]
910 fn test_code_block_not_parsed_as_section() {
911 let parser = RustdocParser::new();
912 let doc = parser.parse(
913 r#"
914/// Example function.
915///
916/// # Examples
917///
918/// ```rust
919/// // # This is a comment, not a section
920/// let x = 5;
921/// ```
922"#,
923 );
924
925 assert!(!doc.examples.is_empty());
927 }
928
929 #[test]
930 fn test_truncate_for_summary() {
931 assert_eq!(truncate_for_summary("Short", 100), "Short");
932 assert_eq!(
933 truncate_for_summary("This is a very long summary that needs truncation", 20),
934 "This is a very long..."
935 );
936 }
937
938 #[test]
939 fn test_see_also_section() {
940 let parser = RustdocParser::new();
941 let doc = parser.parse(
942 r#"
943/// Main function.
944///
945/// # See Also
946///
947/// - `other_function`
948/// - `related_module::helper`
949"#,
950 );
951
952 assert!(doc.see_refs.len() >= 2);
953 }
954
955 #[test]
956 fn test_multiple_sections() {
957 let parser = RustdocParser::new();
958 let doc = parser.parse(
959 r#"
960/// Processes input data.
961///
962/// # Arguments
963///
964/// * `input` - The input data
965///
966/// # Returns
967///
968/// The processed result
969///
970/// # Panics
971///
972/// Panics on invalid input
973///
974/// # Examples
975///
976/// ```
977/// let result = process("test");
978/// ```
979"#,
980 );
981
982 assert_eq!(doc.params.len(), 1);
983 assert!(doc.returns.is_some());
984 assert!(doc.custom_tags.iter().any(|(k, _)| k == "has_panics"));
985 assert!(!doc.examples.is_empty());
986 }
987}