1use std::sync::LazyLock;
29
30use regex::Regex;
31
32use super::{DocStandardParser, ParsedDocumentation};
33use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
34
35static DEPRECATED_PREFIX: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"^Deprecated:\s*(.*)$").expect("Invalid deprecated prefix regex"));
38
39static BUG_PREFIX: LazyLock<Regex> =
41 LazyLock::new(|| Regex::new(r"^BUG\(([^)]+)\):\s*(.*)$").expect("Invalid bug prefix regex"));
42
43static TODO_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
45 Regex::new(r"^(TODO|FIXME|XXX)(?:\(([^)]+)\))?:\s*(.*)$").expect("Invalid todo prefix regex")
46});
47
48static SEE_ALSO: LazyLock<Regex> =
50 LazyLock::new(|| Regex::new(r"^See\s*(?:also)?:\s*(.+)$").expect("Invalid see also regex"));
51
52static CROSS_REF: LazyLock<Regex> = LazyLock::new(|| {
54 Regex::new(r"\[([a-zA-Z_][a-zA-Z0-9_.]*)\]").expect("Invalid cross-ref regex")
55});
56
57#[derive(Debug, Clone, Default)]
59pub struct GoDocExtensions {
60 pub is_package_doc: bool,
62
63 pub bugs: Vec<(String, String)>,
65
66 pub is_exported: bool,
68
69 pub cross_refs: Vec<String>,
71
72 pub follows_convention: bool,
74
75 pub code_examples: Vec<String>,
77}
78
79pub struct GodocParser {
82 extensions: GoDocExtensions,
84
85 element_name: Option<String>,
87}
88
89impl GodocParser {
90 pub fn new() -> Self {
92 Self {
93 extensions: GoDocExtensions::default(),
94 element_name: None,
95 }
96 }
97
98 pub fn with_element_name(mut self, name: impl Into<String>) -> Self {
100 self.element_name = Some(name.into());
101 self
102 }
103
104 pub fn extensions(&self) -> &GoDocExtensions {
106 &self.extensions
107 }
108
109 fn strip_comment_prefix(line: &str) -> &str {
111 let trimmed = line.trim();
112 if let Some(rest) = trimmed.strip_prefix("//") {
113 rest.strip_prefix(' ').unwrap_or(rest)
115 } else if let Some(rest) = trimmed.strip_prefix("/*") {
116 rest.trim_start()
117 } else if let Some(rest) = trimmed.strip_prefix("*/") {
118 rest.trim()
119 } else if let Some(rest) = trimmed.strip_prefix('*') {
120 rest.trim_start()
122 } else {
123 trimmed
124 }
125 }
126
127 fn check_convention(&self, first_line: &str) -> bool {
129 if let Some(name) = &self.element_name {
130 first_line.starts_with(name)
131 } else {
132 true
134 }
135 }
136
137 fn extract_summary(text: &str) -> String {
139 let mut summary = String::new();
141
142 for line in text.lines() {
143 let trimmed = line.trim();
144
145 if trimmed.is_empty() {
147 if summary.is_empty() {
148 continue; } else {
150 break; }
152 }
153
154 if !summary.is_empty() {
156 summary.push(' ');
157 }
158
159 for (i, c) in trimmed.char_indices() {
161 if c == '.' || c == '!' || c == '?' {
162 let next_byte = i + c.len_utf8();
163 let rest = &trimmed[next_byte..];
164 if rest.is_empty() || rest.starts_with(char::is_whitespace) {
166 summary.push_str(&trimmed[..next_byte]);
167 return summary;
168 }
169 }
170 }
171
172 summary.push_str(trimmed);
174 }
175
176 summary
177 }
178
179 fn extract_cross_refs(&self, text: &str) -> Vec<String> {
181 CROSS_REF
182 .captures_iter(text)
183 .filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
184 .collect()
185 }
186
187 fn extract_code_examples(&self, lines: &[&str]) -> Vec<String> {
189 let mut examples = Vec::new();
190 let mut current_example = Vec::new();
191 let mut in_example = false;
192
193 for line in lines {
194 let stripped = Self::strip_comment_prefix(line);
195
196 if stripped.starts_with('\t') || stripped.starts_with(" ") {
197 in_example = true;
199 let code = stripped
201 .strip_prefix('\t')
202 .unwrap_or_else(|| stripped.trim_start());
203 current_example.push(code.to_string());
204 } else if in_example {
205 if !current_example.is_empty() {
207 examples.push(current_example.join("\n"));
208 current_example.clear();
209 }
210 in_example = false;
211 }
212 }
213
214 if !current_example.is_empty() {
216 examples.push(current_example.join("\n"));
217 }
218
219 examples
220 }
221}
222
223impl Default for GodocParser {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229impl DocStandardParser for GodocParser {
230 fn parse(&self, raw_comment: &str) -> ParsedDocumentation {
231 let mut doc = ParsedDocumentation::new();
232 let mut extensions = GoDocExtensions::default();
233
234 let lines: Vec<&str> = raw_comment.lines().collect();
235 let mut content_lines = Vec::new();
236 let mut in_deprecated = false;
237 let mut deprecated_text = Vec::new();
238
239 for line in &lines {
240 let stripped = Self::strip_comment_prefix(line);
241
242 if let Some(caps) = DEPRECATED_PREFIX.captures(stripped) {
244 in_deprecated = true;
245 let rest = caps.get(1).map(|m| m.as_str()).unwrap_or("");
246 if !rest.is_empty() {
247 deprecated_text.push(rest.to_string());
248 }
249 continue;
250 }
251
252 if in_deprecated {
254 if stripped.is_empty() {
255 in_deprecated = false;
257 } else {
258 deprecated_text.push(stripped.to_string());
259 continue;
260 }
261 }
262
263 if let Some(caps) = BUG_PREFIX.captures(stripped) {
265 let who = caps.get(1).map(|m| m.as_str()).unwrap_or("unknown");
266 let desc = caps.get(2).map(|m| m.as_str()).unwrap_or("");
267 extensions.bugs.push((who.to_string(), desc.to_string()));
268 continue;
269 }
270
271 if let Some(caps) = TODO_PREFIX.captures(stripped) {
273 let desc = caps.get(3).map(|m| m.as_str()).unwrap_or("");
274 doc.todos.push(desc.to_string());
275 continue;
276 }
277
278 if let Some(caps) = SEE_ALSO.captures(stripped) {
280 let refs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
281 for r in refs.split(',') {
282 let r = r.trim();
283 if !r.is_empty() {
284 doc.see_refs.push(r.to_string());
285 }
286 }
287 continue;
288 }
289
290 content_lines.push(*line);
291 }
292
293 if !deprecated_text.is_empty() {
295 doc.deprecated = Some(deprecated_text.join(" "));
296 }
297
298 let content: Vec<String> = content_lines
300 .iter()
301 .map(|l| Self::strip_comment_prefix(l).to_string())
302 .collect();
303
304 let full_text = content.join("\n");
305
306 let summary = Self::extract_summary(&full_text);
308 let has_summary = !summary.is_empty();
309 if has_summary {
310 doc.summary = Some(summary.clone());
311
312 extensions.follows_convention = self.check_convention(&summary);
314 }
315
316 let trimmed_text = full_text.trim();
318 if trimmed_text.len() > summary.len() {
319 doc.description = Some(trimmed_text.to_string());
320 }
321
322 extensions.cross_refs = self.extract_cross_refs(&full_text);
324 for ref_name in &extensions.cross_refs {
325 doc.see_refs.push(ref_name.clone());
326 }
327
328 extensions.code_examples = self.extract_code_examples(&content_lines);
330 for example in &extensions.code_examples {
331 doc.examples.push(example.clone());
332 }
333
334 if extensions.is_package_doc {
336 doc.custom_tags
337 .push(("package_doc".to_string(), "true".to_string()));
338 }
339 if !extensions.bugs.is_empty() {
340 doc.custom_tags
341 .push(("has_bugs".to_string(), "true".to_string()));
342 for (who, desc) in &extensions.bugs {
343 doc.notes.push(format!("BUG({}): {}", who, desc));
344 }
345 }
346 if has_summary && !extensions.follows_convention {
348 doc.custom_tags
349 .push(("unconventional_doc".to_string(), "true".to_string()));
350 }
351
352 doc
353 }
354
355 fn standard_name(&self) -> &'static str {
356 "godoc"
357 }
358
359 fn to_suggestions(
361 &self,
362 parsed: &ParsedDocumentation,
363 target: &str,
364 line: usize,
365 ) -> Vec<Suggestion> {
366 let mut suggestions = Vec::new();
367
368 if let Some(summary) = &parsed.summary {
370 let truncated = truncate_for_summary(summary, 100);
371 suggestions.push(Suggestion::summary(
372 target,
373 line,
374 truncated,
375 SuggestionSource::Converted,
376 ));
377 }
378
379 if let Some(msg) = &parsed.deprecated {
381 suggestions.push(Suggestion::deprecated(
382 target,
383 line,
384 msg,
385 SuggestionSource::Converted,
386 ));
387 }
388
389 for see_ref in &parsed.see_refs {
391 suggestions.push(Suggestion::new(
392 target,
393 line,
394 AnnotationType::Ref,
395 see_ref,
396 SuggestionSource::Converted,
397 ));
398 }
399
400 for todo in &parsed.todos {
402 suggestions.push(Suggestion::new(
403 target,
404 line,
405 AnnotationType::Hack,
406 format!("reason=\"{}\"", todo),
407 SuggestionSource::Converted,
408 ));
409 }
410
411 if parsed
413 .custom_tags
414 .iter()
415 .any(|(k, v)| k == "has_bugs" && v == "true")
416 {
417 suggestions.push(Suggestion::ai_hint(
418 target,
419 line,
420 "has documented bugs - review before use",
421 SuggestionSource::Converted,
422 ));
423 }
424
425 if parsed
427 .custom_tags
428 .iter()
429 .any(|(k, v)| k == "unconventional_doc" && v == "true")
430 {
431 suggestions.push(Suggestion::ai_hint(
432 target,
433 line,
434 "doc doesn't follow Go convention (should start with element name)",
435 SuggestionSource::Converted,
436 ));
437 }
438
439 if parsed
441 .custom_tags
442 .iter()
443 .any(|(k, v)| k == "package_doc" && v == "true")
444 {
445 suggestions.push(Suggestion::new(
446 target,
447 line,
448 AnnotationType::Module,
449 parsed.summary.as_deref().unwrap_or(target),
450 SuggestionSource::Converted,
451 ));
452 }
453
454 if !parsed.examples.is_empty() {
456 suggestions.push(Suggestion::ai_hint(
457 target,
458 line,
459 "has documented examples",
460 SuggestionSource::Converted,
461 ));
462 }
463
464 for note in &parsed.notes {
466 if note.starts_with("BUG(") {
467 suggestions.push(Suggestion::ai_hint(
468 target,
469 line,
470 note,
471 SuggestionSource::Converted,
472 ));
473 }
474 }
475
476 suggestions
477 }
478}
479
480fn truncate_for_summary(s: &str, max_len: usize) -> String {
482 let trimmed = s.trim();
483 if trimmed.len() <= max_len {
484 trimmed.to_string()
485 } else {
486 let truncate_at = trimmed[..max_len].rfind(' ').unwrap_or(max_len);
487 format!("{}...", &trimmed[..truncate_at])
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn test_strip_comment_prefix() {
497 assert_eq!(GodocParser::strip_comment_prefix("// Hello"), "Hello");
498 assert_eq!(GodocParser::strip_comment_prefix("//Hello"), "Hello");
499 assert_eq!(GodocParser::strip_comment_prefix(" // Hello"), "Hello");
500 }
501
502 #[test]
503 fn test_parse_basic_godoc() {
504 let parser = GodocParser::new();
505 let doc = parser.parse(
506 r#"
507// NewParser creates a new parser instance.
508// It initializes the parser with default settings.
509"#,
510 );
511
512 assert_eq!(
513 doc.summary,
514 Some("NewParser creates a new parser instance.".to_string())
515 );
516 }
517
518 #[test]
519 fn test_parse_with_deprecated() {
520 let parser = GodocParser::new();
521 let doc = parser.parse(
522 r#"
523// OldFunction does something.
524//
525// Deprecated: Use NewFunction instead.
526"#,
527 );
528
529 assert!(doc.deprecated.is_some());
530 assert!(doc.deprecated.unwrap().contains("NewFunction"));
531 }
532
533 #[test]
534 fn test_parse_with_bug() {
535 let parser = GodocParser::new();
536 let doc = parser.parse(
537 r#"
538// Calculate computes a value.
539//
540// BUG(alice): Does not handle negative numbers correctly.
541"#,
542 );
543
544 assert!(doc
545 .custom_tags
546 .iter()
547 .any(|(k, v)| k == "has_bugs" && v == "true"));
548 assert!(doc.notes.iter().any(|n| n.contains("BUG(alice)")));
549 }
550
551 #[test]
552 fn test_parse_with_todo() {
553 let parser = GodocParser::new();
554 let doc = parser.parse(
555 r#"
556// Process handles the input.
557// TODO(bob): Add error handling
558"#,
559 );
560
561 assert!(!doc.todos.is_empty());
562 assert!(doc.todos[0].contains("Add error handling"));
563 }
564
565 #[test]
566 fn test_parse_with_see_also() {
567 let parser = GodocParser::new();
568 let doc = parser.parse(
569 r#"
570// Read reads data from the source.
571// See also: Write, Close
572"#,
573 );
574
575 assert!(doc.see_refs.contains(&"Write".to_string()));
576 assert!(doc.see_refs.contains(&"Close".to_string()));
577 }
578
579 #[test]
580 fn test_parse_with_code_example() {
581 let parser = GodocParser::new();
582 let doc = parser.parse(
583 r#"
584// Add adds two numbers.
585// Example:
586// result := Add(2, 3)
587// fmt.Println(result) // Output: 5
588"#,
589 );
590
591 assert!(!doc.examples.is_empty());
592 assert!(doc.examples[0].contains("Add(2, 3)"));
593 }
594
595 #[test]
596 fn test_parse_with_cross_refs() {
597 let parser = GodocParser::new();
598 let doc = parser.parse(
599 r#"
600// Parse parses input using [Config] and returns a [Result].
601"#,
602 );
603
604 assert!(doc.see_refs.contains(&"Config".to_string()));
605 assert!(doc.see_refs.contains(&"Result".to_string()));
606 }
607
608 #[test]
609 fn test_parse_multi_paragraph() {
610 let parser = GodocParser::new();
611 let doc = parser.parse(
612 r#"
613// Handler processes HTTP requests.
614//
615// It validates the input, performs the operation,
616// and returns an appropriate response.
617"#,
618 );
619
620 assert_eq!(
621 doc.summary,
622 Some("Handler processes HTTP requests.".to_string())
623 );
624 assert!(doc.description.is_some());
625 }
626
627 #[test]
628 fn test_convention_check_pass() {
629 let parser = GodocParser::new().with_element_name("NewParser");
630 let doc = parser.parse(
631 r#"
632// NewParser creates a new parser.
633"#,
634 );
635
636 assert!(!doc
638 .custom_tags
639 .iter()
640 .any(|(k, _)| k == "unconventional_doc"));
641 }
642
643 #[test]
644 fn test_convention_check_fail() {
645 let parser = GodocParser::new().with_element_name("NewParser");
646 let doc = parser.parse(
647 r#"
648// Creates a new parser instance.
649"#,
650 );
651
652 assert!(doc
653 .custom_tags
654 .iter()
655 .any(|(k, v)| k == "unconventional_doc" && v == "true"));
656 }
657
658 #[test]
659 fn test_block_comment() {
660 let parser = GodocParser::new();
661 let doc = parser.parse(
662 r#"
663/*
664Package utils provides utility functions.
665
666It includes helpers for common operations.
667*/
668"#,
669 );
670
671 assert!(doc.summary.is_some());
672 assert!(doc.summary.unwrap().contains("Package utils"));
673 }
674
675 #[test]
676 fn test_to_suggestions_basic() {
677 let parser = GodocParser::new();
678 let doc = parser.parse(
679 r#"
680// NewClient creates a new API client.
681"#,
682 );
683
684 let suggestions = parser.to_suggestions(&doc, "NewClient", 10);
685
686 assert!(suggestions
687 .iter()
688 .any(|s| s.annotation_type == AnnotationType::Summary
689 && s.value.contains("creates a new API client")));
690 }
691
692 #[test]
693 fn test_to_suggestions_deprecated() {
694 let parser = GodocParser::new();
695 let doc = parser.parse(
696 r#"
697// Old does something.
698// Deprecated: Use New instead.
699"#,
700 );
701
702 let suggestions = parser.to_suggestions(&doc, "Old", 1);
703
704 assert!(suggestions
705 .iter()
706 .any(|s| s.annotation_type == AnnotationType::Deprecated));
707 }
708
709 #[test]
710 fn test_to_suggestions_bugs() {
711 let parser = GodocParser::new();
712 let doc = parser.parse(
713 r#"
714// Calculate computes values.
715// BUG(dev): Off by one error.
716"#,
717 );
718
719 let suggestions = parser.to_suggestions(&doc, "Calculate", 1);
720
721 assert!(suggestions
722 .iter()
723 .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("bugs")));
724 }
725
726 #[test]
727 fn test_to_suggestions_refs() {
728 let parser = GodocParser::new();
729 let doc = parser.parse(
730 r#"
731// Process uses [Config] to process data.
732"#,
733 );
734
735 let suggestions = parser.to_suggestions(&doc, "Process", 1);
736
737 assert!(suggestions
738 .iter()
739 .any(|s| s.annotation_type == AnnotationType::Ref && s.value == "Config"));
740 }
741
742 #[test]
743 fn test_to_suggestions_todos() {
744 let parser = GodocParser::new();
745 let doc = parser.parse(
746 r#"
747// Incomplete function.
748// TODO: Finish implementation
749"#,
750 );
751
752 let suggestions = parser.to_suggestions(&doc, "Incomplete", 1);
753
754 assert!(suggestions
755 .iter()
756 .any(|s| s.annotation_type == AnnotationType::Hack));
757 }
758
759 #[test]
760 fn test_to_suggestions_examples() {
761 let parser = GodocParser::new();
762 let doc = parser.parse(
763 r#"
764// Add adds numbers.
765// sum := Add(1, 2)
766"#,
767 );
768
769 let suggestions = parser.to_suggestions(&doc, "Add", 1);
770
771 assert!(suggestions
772 .iter()
773 .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("examples")));
774 }
775
776 #[test]
777 fn test_truncate_for_summary() {
778 assert_eq!(truncate_for_summary("Short", 100), "Short");
779 assert_eq!(
780 truncate_for_summary("This is a very long summary that needs truncation", 20),
781 "This is a very long..."
782 );
783 }
784
785 #[test]
786 fn test_extract_summary_single_sentence() {
787 let summary = GodocParser::extract_summary("This is a simple summary.");
788 assert_eq!(summary, "This is a simple summary.");
789 }
790
791 #[test]
792 fn test_extract_summary_multi_sentence() {
793 let summary = GodocParser::extract_summary("First sentence. Second sentence.");
794 assert_eq!(summary, "First sentence.");
795 }
796
797 #[test]
798 fn test_deprecated_multiline() {
799 let parser = GodocParser::new();
800 let doc = parser.parse(
801 r#"
802// OldAPI is deprecated.
803//
804// Deprecated: This API is deprecated and will be removed in v2.0.
805// Use NewAPI instead for better performance.
806"#,
807 );
808
809 assert!(doc.deprecated.is_some());
810 let deprecated = doc.deprecated.unwrap();
811 assert!(deprecated.contains("v2.0"));
812 assert!(deprecated.contains("NewAPI"));
813 }
814}