acp/annotate/converters/
godoc.rs

1//! @acp:module "Go Doc Parser"
2//! @acp:summary "Parses Go documentation comments and converts to ACP format"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Go Doc Parser
8//!
9//! Parses Go documentation comments in the standard godoc format:
10//!
11//! ## Comment Styles
12//! - `//` line comments (most common)
13//! - `/* */` block comments
14//!
15//! ## Conventions
16//! - First sentence should start with the element name
17//! - "Deprecated:" prefix marks deprecated items
18//! - "BUG(who):" for known issues
19//! - Code examples are indented by a tab
20//! - Paragraphs separated by blank lines
21//!
22//! ## Features Detected
23//! - Deprecation notices
24//! - Bug annotations
25//! - Code examples (indented blocks)
26//! - Cross-references to other symbols
27
28use std::sync::LazyLock;
29
30use regex::Regex;
31
32use super::{DocStandardParser, ParsedDocumentation};
33use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
34
35/// @acp:summary "Matches Deprecated: prefix"
36static DEPRECATED_PREFIX: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r"^Deprecated:\s*(.*)$").expect("Invalid deprecated prefix regex"));
38
39/// @acp:summary "Matches BUG(who): prefix"
40static BUG_PREFIX: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"^BUG\(([^)]+)\):\s*(.*)$").expect("Invalid bug prefix regex"));
42
43/// @acp:summary "Matches TODO(who): or FIXME(who): prefix"
44static TODO_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
45    Regex::new(r"^(TODO|FIXME|XXX)(?:\(([^)]+)\))?:\s*(.*)$").expect("Invalid todo prefix regex")
46});
47
48/// @acp:summary "Matches See also: or See: references"
49static SEE_ALSO: LazyLock<Regex> =
50    LazyLock::new(|| Regex::new(r"^See\s*(?:also)?:\s*(.+)$").expect("Invalid see also regex"));
51
52/// @acp:summary "Matches cross-reference to another symbol [Name] or [pkg.Name]"
53static 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/// @acp:summary "Go-specific extensions for doc comments"
58#[derive(Debug, Clone, Default)]
59pub struct GoDocExtensions {
60    /// Whether this is package-level documentation
61    pub is_package_doc: bool,
62
63    /// Known bugs (who, description)
64    pub bugs: Vec<(String, String)>,
65
66    /// Whether the element is exported (starts with uppercase)
67    pub is_exported: bool,
68
69    /// Cross-references found in the documentation
70    pub cross_refs: Vec<String>,
71
72    /// Whether doc follows convention (starts with element name)
73    pub follows_convention: bool,
74
75    /// Code examples found (indented blocks)
76    pub code_examples: Vec<String>,
77}
78
79/// @acp:summary "Parses Go doc comments"
80/// @acp:lock normal
81pub struct GodocParser {
82    /// Go-specific extensions parsed from doc comments
83    extensions: GoDocExtensions,
84
85    /// The name of the element being documented (for convention check)
86    element_name: Option<String>,
87}
88
89impl GodocParser {
90    /// @acp:summary "Creates a new Godoc parser"
91    pub fn new() -> Self {
92        Self {
93            extensions: GoDocExtensions::default(),
94            element_name: None,
95        }
96    }
97
98    /// @acp:summary "Creates a parser with known element name for convention checking"
99    pub fn with_element_name(mut self, name: impl Into<String>) -> Self {
100        self.element_name = Some(name.into());
101        self
102    }
103
104    /// @acp:summary "Gets the parsed Go extensions"
105    pub fn extensions(&self) -> &GoDocExtensions {
106        &self.extensions
107    }
108
109    /// @acp:summary "Strips Go comment prefixes from lines"
110    fn strip_comment_prefix(line: &str) -> &str {
111        let trimmed = line.trim();
112        if let Some(rest) = trimmed.strip_prefix("//") {
113            // Handle "// " with space or just "//"
114            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            // Handle "* " in block comments
121            rest.trim_start()
122        } else {
123            trimmed
124        }
125    }
126
127    /// @acp:summary "Checks if first sentence starts with element name (Go convention)"
128    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            // Can't check without element name
133            true
134        }
135    }
136
137    /// @acp:summary "Extracts the first sentence as summary"
138    fn extract_summary(text: &str) -> String {
139        // Find first sentence ending with period, question mark, or exclamation
140        let mut summary = String::new();
141
142        for line in text.lines() {
143            let trimmed = line.trim();
144
145            // Skip leading empty lines, but break on empty line after content
146            if trimmed.is_empty() {
147                if summary.is_empty() {
148                    continue; // Skip leading empty lines
149                } else {
150                    break; // Empty line ends the summary paragraph
151                }
152            }
153
154            // Add space between lines
155            if !summary.is_empty() {
156                summary.push(' ');
157            }
158
159            // Look for sentence-ending punctuation followed by whitespace or end
160            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                    // End of line or followed by whitespace = end of sentence
165                    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            // No sentence end found, add whole line and continue
173            summary.push_str(trimmed);
174        }
175
176        summary
177    }
178
179    /// @acp:summary "Extracts cross-references from text"
180    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    /// @acp:summary "Extracts code examples from text"
188    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                // Indented line = code
198                in_example = true;
199                // Remove one level of indentation
200                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                // End of code block
206                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        // Don't forget last example
215        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            // Check for Deprecated: prefix
243            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            // Continue collecting deprecated text if in deprecated block
253            if in_deprecated {
254                if stripped.is_empty() {
255                    // End of deprecated block
256                    in_deprecated = false;
257                } else {
258                    deprecated_text.push(stripped.to_string());
259                    continue;
260                }
261            }
262
263            // Check for BUG(who): prefix
264            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            // Check for TODO/FIXME prefix
272            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            // Check for See also: references
279            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        // Set deprecated if found
294        if !deprecated_text.is_empty() {
295            doc.deprecated = Some(deprecated_text.join(" "));
296        }
297
298        // Process content lines for summary and description
299        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        // Extract summary (first sentence)
307        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            // Check convention (starts with element name)
313            extensions.follows_convention = self.check_convention(&summary);
314        }
315
316        // Set description if there's more than summary
317        let trimmed_text = full_text.trim();
318        if trimmed_text.len() > summary.len() {
319            doc.description = Some(trimmed_text.to_string());
320        }
321
322        // Extract cross-references
323        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        // Extract code examples
329        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        // Store extensions in custom tags
335        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        // Only flag unconventional if we have a summary to check against
347        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    /// @acp:summary "Converts parsed Godoc to ACP suggestions with Go-specific handling"
360    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        // Convert summary (truncated)
369        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        // Convert deprecated
380        if let Some(msg) = &parsed.deprecated {
381            suggestions.push(Suggestion::deprecated(
382                target,
383                line,
384                msg,
385                SuggestionSource::Converted,
386            ));
387        }
388
389        // Convert cross-references to @acp:ref
390        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        // Convert TODOs to @acp:hack
401        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        // Go-specific: has bugs documented
412        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        // Go-specific: unconventional documentation
426        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        // Go-specific: package documentation
440        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        // Convert examples existence to AI hint
455        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        // Convert bug notes to AI hints
465        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
480/// @acp:summary "Truncates a string to the specified length for summary use"
481fn 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        // Should follow convention (starts with element name)
637        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}