acp/annotate/converters/
javadoc.rs

1//! @acp:module "Javadoc Parser"
2//! @acp:summary "Parses Java documentation comments and converts to ACP format"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Javadoc Parser
8//!
9//! Parses Java documentation comments in the standard Javadoc format:
10//!
11//! ## Comment Style
12//! - `/** ... */` block comments with leading asterisks
13//!
14//! ## Standard Tags
15//! - `@param name description` - Parameter documentation
16//! - `@return description` - Return value documentation
17//! - `@throws/@exception type description` - Exception documentation
18//! - `@author name` - Author information
19//! - `@since version` - Version when added
20//! - `@see reference` - Cross-reference
21//! - `@deprecated description` - Deprecation notice
22//! - `@version version` - Version information
23//!
24//! ## Inline Tags
25//! - `{@link reference}` - Inline cross-reference
26//! - `{@code text}` - Code formatting
27//! - `{@inheritDoc}` - Inherit documentation
28//!
29//! ## Features Detected
30//! - HTML stripping from descriptions
31//! - First sentence extraction for summary
32//! - Package/class/method level detection
33
34use std::sync::LazyLock;
35
36use regex::Regex;
37
38use super::{DocStandardParser, ParsedDocumentation};
39use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
40
41/// @acp:summary "Matches @param tag"
42static PARAM_TAG: LazyLock<Regex> =
43    LazyLock::new(|| Regex::new(r"^@param\s+(\w+)\s+(.*)$").expect("Invalid param tag regex"));
44
45/// @acp:summary "Matches @return/@returns tag"
46static RETURN_TAG: LazyLock<Regex> =
47    LazyLock::new(|| Regex::new(r"^@returns?\s+(.*)$").expect("Invalid return tag regex"));
48
49/// @acp:summary "Matches @throws/@exception tag"
50static THROWS_TAG: LazyLock<Regex> = LazyLock::new(|| {
51    Regex::new(r"^@(?:throws|exception)\s+(\S+)\s*(.*)$").expect("Invalid throws tag regex")
52});
53
54/// @acp:summary "Matches @author tag"
55static AUTHOR_TAG: LazyLock<Regex> =
56    LazyLock::new(|| Regex::new(r"^@author\s+(.+)$").expect("Invalid author tag regex"));
57
58/// @acp:summary "Matches @since tag"
59static SINCE_TAG: LazyLock<Regex> =
60    LazyLock::new(|| Regex::new(r"^@since\s+(.+)$").expect("Invalid since tag regex"));
61
62/// @acp:summary "Matches @version tag"
63static VERSION_TAG: LazyLock<Regex> =
64    LazyLock::new(|| Regex::new(r"^@version\s+(.+)$").expect("Invalid version tag regex"));
65
66/// @acp:summary "Matches @see tag"
67static SEE_TAG: LazyLock<Regex> =
68    LazyLock::new(|| Regex::new(r"^@see\s+(.+)$").expect("Invalid see tag regex"));
69
70/// @acp:summary "Matches {@link reference} inline tag"
71static LINK_INLINE: LazyLock<Regex> =
72    LazyLock::new(|| Regex::new(r"\{@link\s+([^}]+)\}").expect("Invalid link inline regex"));
73
74/// @acp:summary "Matches {@code text} inline tag"
75static CODE_INLINE: LazyLock<Regex> =
76    LazyLock::new(|| Regex::new(r"\{@code\s+([^}]+)\}").expect("Invalid code inline regex"));
77
78/// @acp:summary "Matches {@inheritDoc} inline tag"
79static INHERIT_DOC: LazyLock<Regex> =
80    LazyLock::new(|| Regex::new(r"\{@inheritDoc\}").expect("Invalid inheritDoc regex"));
81
82/// @acp:summary "Matches HTML tags for stripping"
83static HTML_TAG: LazyLock<Regex> =
84    LazyLock::new(|| Regex::new(r"<[^>]+>").expect("Invalid HTML tag regex"));
85
86/// @acp:summary "Matches @code block (pre tags with code)"
87static CODE_BLOCK: LazyLock<Regex> = LazyLock::new(|| {
88    Regex::new(r"<pre>\s*(?:<code>)?([\s\S]*?)(?:</code>)?\s*</pre>")
89        .expect("Invalid code block regex")
90});
91
92/// @acp:summary "Java-specific extensions for Javadoc comments"
93#[derive(Debug, Clone, Default)]
94pub struct JavadocExtensions {
95    /// Whether this is package-level documentation (package-info.java)
96    pub is_package_doc: bool,
97
98    /// Whether this is class/interface level documentation
99    pub is_type_doc: bool,
100
101    /// Version information from @version
102    pub version: Option<String>,
103
104    /// Authors from @author tags
105    pub authors: Vec<String>,
106
107    /// Inherited documentation marker
108    pub inherits_doc: bool,
109
110    /// Code examples found in <pre> blocks
111    pub code_examples: Vec<String>,
112
113    /// Inline {@link} references extracted
114    pub inline_links: Vec<String>,
115}
116
117/// @acp:summary "Parses Javadoc comments"
118/// @acp:lock normal
119pub struct JavadocParser {
120    /// Java-specific extensions parsed from doc comments
121    extensions: JavadocExtensions,
122}
123
124impl JavadocParser {
125    /// @acp:summary "Creates a new Javadoc parser"
126    pub fn new() -> Self {
127        Self {
128            extensions: JavadocExtensions::default(),
129        }
130    }
131
132    /// @acp:summary "Creates a parser marked as package documentation"
133    pub fn for_package() -> Self {
134        Self {
135            extensions: JavadocExtensions {
136                is_package_doc: true,
137                ..Default::default()
138            },
139        }
140    }
141
142    /// @acp:summary "Creates a parser marked as type documentation"
143    pub fn for_type() -> Self {
144        Self {
145            extensions: JavadocExtensions {
146                is_type_doc: true,
147                ..Default::default()
148            },
149        }
150    }
151
152    /// @acp:summary "Gets the parsed Java extensions"
153    pub fn extensions(&self) -> &JavadocExtensions {
154        &self.extensions
155    }
156
157    /// @acp:summary "Strips Javadoc comment markers from lines"
158    fn strip_comment_markers(line: &str) -> &str {
159        let trimmed = line.trim();
160
161        // Handle opening /**
162        if let Some(rest) = trimmed.strip_prefix("/**") {
163            return rest.trim();
164        }
165
166        // Handle closing */
167        if let Some(rest) = trimmed.strip_suffix("*/") {
168            let rest = rest.trim();
169            // Also strip leading * if present
170            if let Some(rest) = rest.strip_prefix('*') {
171                return rest.trim_start();
172            }
173            return rest;
174        }
175
176        // Handle middle lines with leading *
177        if let Some(rest) = trimmed.strip_prefix('*') {
178            // Don't strip if it's part of closing */
179            if !rest.starts_with('/') {
180                return rest.trim_start();
181            }
182        }
183
184        trimmed
185    }
186
187    /// @acp:summary "Strips HTML tags from text"
188    fn strip_html(text: &str) -> String {
189        HTML_TAG.replace_all(text, "").to_string()
190    }
191
192    /// @acp:summary "Processes inline tags like {@link} and {@code}"
193    fn process_inline_tags(text: &str) -> (String, Vec<String>) {
194        let mut links = Vec::new();
195
196        // Extract links
197        for caps in LINK_INLINE.captures_iter(text) {
198            if let Some(link) = caps.get(1) {
199                links.push(link.as_str().trim().to_string());
200            }
201        }
202
203        // Replace inline tags with their content
204        let processed = LINK_INLINE.replace_all(text, "$1");
205        let processed = CODE_INLINE.replace_all(&processed, "`$1`");
206        let processed = INHERIT_DOC.replace_all(&processed, "[inherited]");
207
208        (processed.to_string(), links)
209    }
210
211    /// @acp:summary "Extracts first sentence as summary"
212    fn extract_summary(text: &str) -> String {
213        let mut summary = String::new();
214
215        for line in text.lines() {
216            let trimmed = line.trim();
217
218            // Skip leading empty lines
219            if trimmed.is_empty() {
220                if summary.is_empty() {
221                    continue;
222                } else {
223                    break;
224                }
225            }
226
227            // Add space between lines
228            if !summary.is_empty() {
229                summary.push(' ');
230            }
231
232            // Look for sentence-ending punctuation
233            for (i, c) in trimmed.char_indices() {
234                if c == '.' || c == '!' || c == '?' {
235                    let next_byte = i + c.len_utf8();
236                    let rest = &trimmed[next_byte..];
237                    if rest.is_empty() || rest.starts_with(char::is_whitespace) {
238                        summary.push_str(&trimmed[..next_byte]);
239                        return Self::strip_html(&summary);
240                    }
241                }
242            }
243
244            summary.push_str(trimmed);
245        }
246
247        Self::strip_html(&summary)
248    }
249
250    /// @acp:summary "Extracts code examples from <pre> blocks"
251    fn extract_code_examples(text: &str) -> Vec<String> {
252        CODE_BLOCK
253            .captures_iter(text)
254            .filter_map(|caps| caps.get(1).map(|m| m.as_str().trim().to_string()))
255            .filter(|s| !s.is_empty())
256            .collect()
257    }
258}
259
260impl Default for JavadocParser {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266impl DocStandardParser for JavadocParser {
267    fn parse(&self, raw_comment: &str) -> ParsedDocumentation {
268        let mut doc = ParsedDocumentation::new();
269        let mut extensions = self.extensions.clone();
270
271        let lines: Vec<&str> = raw_comment.lines().collect();
272        let mut content_lines = Vec::new();
273        let mut current_tag: Option<String> = None;
274        let mut tag_content = String::new();
275
276        // Helper to process accumulated tag content
277        let process_tag = |tag: &str,
278                           content: &str,
279                           doc: &mut ParsedDocumentation,
280                           ext: &mut JavadocExtensions| {
281            let content = content.trim();
282            if content.is_empty() && tag != "@deprecated" {
283                return;
284            }
285
286            if let Some(caps) = PARAM_TAG.captures(&format!("{} {}", tag, content)) {
287                let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
288                let desc = caps.get(2).map(|m| m.as_str()).unwrap_or("");
289                doc.params
290                    .push((name.to_string(), None, Some(desc.to_string())));
291            } else if let Some(caps) = RETURN_TAG.captures(&format!("{} {}", tag, content)) {
292                let desc = caps.get(1).map(|m| m.as_str()).unwrap_or("");
293                doc.returns = Some((None, Some(desc.to_string())));
294            } else if let Some(caps) = THROWS_TAG.captures(&format!("{} {}", tag, content)) {
295                let exc_type = caps.get(1).map(|m| m.as_str()).unwrap_or("");
296                let desc = caps.get(2).map(|m| m.as_str());
297                doc.throws
298                    .push((exc_type.to_string(), desc.map(|s| s.to_string())));
299            } else if let Some(caps) = AUTHOR_TAG.captures(&format!("{} {}", tag, content)) {
300                let author = caps.get(1).map(|m| m.as_str()).unwrap_or("");
301                doc.author = Some(author.to_string());
302                ext.authors.push(author.to_string());
303            } else if let Some(caps) = SINCE_TAG.captures(&format!("{} {}", tag, content)) {
304                let since = caps.get(1).map(|m| m.as_str()).unwrap_or("");
305                doc.since = Some(since.to_string());
306            } else if let Some(caps) = VERSION_TAG.captures(&format!("{} {}", tag, content)) {
307                let version = caps.get(1).map(|m| m.as_str()).unwrap_or("");
308                ext.version = Some(version.to_string());
309            } else if let Some(caps) = SEE_TAG.captures(&format!("{} {}", tag, content)) {
310                let reference = caps.get(1).map(|m| m.as_str()).unwrap_or("");
311                doc.see_refs.push(reference.to_string());
312            } else if tag == "@deprecated" {
313                let msg = if content.is_empty() {
314                    "Deprecated".to_string()
315                } else {
316                    content.to_string()
317                };
318                doc.deprecated = Some(msg);
319            }
320        };
321
322        for line in &lines {
323            let stripped = Self::strip_comment_markers(line);
324
325            // Check for new tag
326            if stripped.starts_with('@') {
327                // Process previous tag if any
328                if let Some(ref tag) = current_tag {
329                    process_tag(tag, &tag_content, &mut doc, &mut extensions);
330                }
331
332                // Start new tag
333                let parts: Vec<&str> = stripped.splitn(2, char::is_whitespace).collect();
334                current_tag = Some(parts[0].to_string());
335                tag_content = parts.get(1).map(|s| s.to_string()).unwrap_or_default();
336            } else if current_tag.is_some() {
337                // Continue current tag
338                if !tag_content.is_empty() {
339                    tag_content.push(' ');
340                }
341                tag_content.push_str(stripped);
342            } else {
343                // Regular content line
344                content_lines.push(stripped.to_string());
345            }
346        }
347
348        // Process final tag if any
349        if let Some(ref tag) = current_tag {
350            process_tag(tag, &tag_content, &mut doc, &mut extensions);
351        }
352
353        // Process content for summary and description
354        let full_text = content_lines.join("\n");
355
356        // Check for {@inheritDoc}
357        if INHERIT_DOC.is_match(&full_text) {
358            extensions.inherits_doc = true;
359        }
360
361        // Process inline tags and extract links
362        let (processed_text, inline_links) = Self::process_inline_tags(&full_text);
363        extensions.inline_links = inline_links.clone();
364
365        // Add inline links to see_refs
366        for link in inline_links {
367            if !doc.see_refs.contains(&link) {
368                doc.see_refs.push(link);
369            }
370        }
371
372        // Extract code examples
373        extensions.code_examples = Self::extract_code_examples(&full_text);
374        for example in &extensions.code_examples {
375            doc.examples.push(example.clone());
376        }
377
378        // Extract summary
379        let summary = Self::extract_summary(&processed_text);
380        if !summary.is_empty() {
381            doc.summary = Some(summary.clone());
382        }
383
384        // Set description if there's more content
385        let stripped_html = Self::strip_html(&processed_text);
386        let trimmed = stripped_html.trim();
387        if !trimmed.is_empty() && trimmed.len() > summary.len() {
388            doc.description = Some(trimmed.to_string());
389        }
390
391        // Store extensions in custom tags
392        if extensions.is_package_doc {
393            doc.custom_tags
394                .push(("package_doc".to_string(), "true".to_string()));
395        }
396        if extensions.is_type_doc {
397            doc.custom_tags
398                .push(("type_doc".to_string(), "true".to_string()));
399        }
400        if let Some(ref version) = extensions.version {
401            doc.custom_tags
402                .push(("version".to_string(), version.clone()));
403        }
404        if extensions.inherits_doc {
405            doc.custom_tags
406                .push(("inherits_doc".to_string(), "true".to_string()));
407        }
408        if extensions.authors.len() > 1 {
409            doc.custom_tags
410                .push(("multiple_authors".to_string(), "true".to_string()));
411        }
412
413        doc
414    }
415
416    fn standard_name(&self) -> &'static str {
417        "javadoc"
418    }
419
420    /// @acp:summary "Converts parsed Javadoc to ACP suggestions with Java-specific handling"
421    fn to_suggestions(
422        &self,
423        parsed: &ParsedDocumentation,
424        target: &str,
425        line: usize,
426    ) -> Vec<Suggestion> {
427        let mut suggestions = Vec::new();
428
429        // Convert summary (truncated)
430        if let Some(summary) = &parsed.summary {
431            let truncated = truncate_for_summary(summary, 100);
432            suggestions.push(Suggestion::summary(
433                target,
434                line,
435                truncated,
436                SuggestionSource::Converted,
437            ));
438        }
439
440        // Convert deprecated
441        if let Some(msg) = &parsed.deprecated {
442            suggestions.push(Suggestion::deprecated(
443                target,
444                line,
445                msg,
446                SuggestionSource::Converted,
447            ));
448        }
449
450        // Convert @see references to @acp:ref
451        for see_ref in &parsed.see_refs {
452            suggestions.push(Suggestion::new(
453                target,
454                line,
455                AnnotationType::Ref,
456                see_ref,
457                SuggestionSource::Converted,
458            ));
459        }
460
461        // Convert @throws to AI hint
462        if !parsed.throws.is_empty() {
463            let throws_list: Vec<String> = parsed.throws.iter().map(|(t, _)| t.clone()).collect();
464            suggestions.push(Suggestion::ai_hint(
465                target,
466                line,
467                format!("throws {}", throws_list.join(", ")),
468                SuggestionSource::Converted,
469            ));
470        }
471
472        // Java-specific: package documentation
473        if parsed
474            .custom_tags
475            .iter()
476            .any(|(k, v)| k == "package_doc" && v == "true")
477        {
478            suggestions.push(Suggestion::new(
479                target,
480                line,
481                AnnotationType::Module,
482                parsed.summary.as_deref().unwrap_or(target),
483                SuggestionSource::Converted,
484            ));
485        }
486
487        // Java-specific: type documentation (class/interface)
488        if parsed
489            .custom_tags
490            .iter()
491            .any(|(k, v)| k == "type_doc" && v == "true")
492        {
493            suggestions.push(Suggestion::ai_hint(
494                target,
495                line,
496                "type-level documentation",
497                SuggestionSource::Converted,
498            ));
499        }
500
501        // Java-specific: inherits documentation
502        if parsed
503            .custom_tags
504            .iter()
505            .any(|(k, v)| k == "inherits_doc" && v == "true")
506        {
507            suggestions.push(Suggestion::ai_hint(
508                target,
509                line,
510                "inherits documentation from parent",
511                SuggestionSource::Converted,
512            ));
513        }
514
515        // Convert @since to AI hint
516        if let Some(since) = &parsed.since {
517            suggestions.push(Suggestion::ai_hint(
518                target,
519                line,
520                format!("since {}", since),
521                SuggestionSource::Converted,
522            ));
523        }
524
525        // Convert version to AI hint
526        if let Some((_, version)) = parsed.custom_tags.iter().find(|(k, _)| k == "version") {
527            suggestions.push(Suggestion::ai_hint(
528                target,
529                line,
530                format!("version {}", version),
531                SuggestionSource::Converted,
532            ));
533        }
534
535        // Convert examples existence to AI hint
536        if !parsed.examples.is_empty() {
537            suggestions.push(Suggestion::ai_hint(
538                target,
539                line,
540                "has code examples",
541                SuggestionSource::Converted,
542            ));
543        }
544
545        // Convert author to AI hint if present
546        if let Some(author) = &parsed.author {
547            suggestions.push(Suggestion::ai_hint(
548                target,
549                line,
550                format!("author: {}", author),
551                SuggestionSource::Converted,
552            ));
553        }
554
555        suggestions
556    }
557}
558
559/// @acp:summary "Truncates a string to the specified length for summary use"
560fn truncate_for_summary(s: &str, max_len: usize) -> String {
561    let trimmed = s.trim();
562    if trimmed.len() <= max_len {
563        trimmed.to_string()
564    } else {
565        let truncate_at = trimmed[..max_len].rfind(' ').unwrap_or(max_len);
566        format!("{}...", &trimmed[..truncate_at])
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn test_strip_comment_markers() {
576        assert_eq!(JavadocParser::strip_comment_markers("/** Hello"), "Hello");
577        assert_eq!(JavadocParser::strip_comment_markers(" * Hello"), "Hello");
578        assert_eq!(JavadocParser::strip_comment_markers(" */"), "");
579        assert_eq!(
580            JavadocParser::strip_comment_markers("   *   Indented"),
581            "Indented"
582        );
583    }
584
585    #[test]
586    fn test_strip_html() {
587        assert_eq!(
588            JavadocParser::strip_html("Hello <b>world</b>!"),
589            "Hello world!"
590        );
591        assert_eq!(JavadocParser::strip_html("<p>Paragraph</p>"), "Paragraph");
592    }
593
594    #[test]
595    fn test_process_inline_tags() {
596        let (processed, links) = JavadocParser::process_inline_tags(
597            "See {@link String} for details about {@code format}",
598        );
599        assert!(processed.contains("String"));
600        assert!(processed.contains("`format`"));
601        assert!(links.contains(&"String".to_string()));
602    }
603
604    #[test]
605    fn test_extract_summary() {
606        let summary = JavadocParser::extract_summary(
607            "Returns the length of this string. The length is equal to the number of characters.",
608        );
609        assert_eq!(summary, "Returns the length of this string.");
610    }
611
612    #[test]
613    fn test_parse_basic_javadoc() {
614        let parser = JavadocParser::new();
615        let doc = parser.parse(
616            r#"
617/**
618 * Returns the character at the specified index.
619 * An index ranges from 0 to length() - 1.
620 */
621"#,
622        );
623
624        assert_eq!(
625            doc.summary,
626            Some("Returns the character at the specified index.".to_string())
627        );
628    }
629
630    #[test]
631    fn test_parse_with_params() {
632        let parser = JavadocParser::new();
633        let doc = parser.parse(
634            r#"
635/**
636 * Copies characters from this string into the destination array.
637 *
638 * @param srcBegin index of the first character to copy
639 * @param srcEnd index after the last character to copy
640 * @param dst the destination array
641 */
642"#,
643        );
644
645        assert_eq!(doc.params.len(), 3);
646        assert_eq!(doc.params[0].0, "srcBegin");
647        assert_eq!(doc.params[1].0, "srcEnd");
648        assert_eq!(doc.params[2].0, "dst");
649    }
650
651    #[test]
652    fn test_parse_with_return() {
653        let parser = JavadocParser::new();
654        let doc = parser.parse(
655            r#"
656/**
657 * Returns the length of this string.
658 *
659 * @return the length of the sequence of characters
660 */
661"#,
662        );
663
664        assert!(doc.returns.is_some());
665        let (_, desc) = doc.returns.as_ref().unwrap();
666        assert!(desc.as_ref().unwrap().contains("length"));
667    }
668
669    #[test]
670    fn test_parse_with_throws() {
671        let parser = JavadocParser::new();
672        let doc = parser.parse(
673            r#"
674/**
675 * Returns the character at the specified index.
676 *
677 * @throws IndexOutOfBoundsException if the index is out of range
678 * @throws NullPointerException if the string is null
679 */
680"#,
681        );
682
683        assert_eq!(doc.throws.len(), 2);
684        assert_eq!(doc.throws[0].0, "IndexOutOfBoundsException");
685        assert_eq!(doc.throws[1].0, "NullPointerException");
686    }
687
688    #[test]
689    fn test_parse_with_exception() {
690        let parser = JavadocParser::new();
691        let doc = parser.parse(
692            r#"
693/**
694 * Parses the string.
695 *
696 * @exception ParseException if parsing fails
697 */
698"#,
699        );
700
701        assert_eq!(doc.throws.len(), 1);
702        assert_eq!(doc.throws[0].0, "ParseException");
703    }
704
705    #[test]
706    fn test_parse_with_see() {
707        let parser = JavadocParser::new();
708        let doc = parser.parse(
709            r#"
710/**
711 * Creates a new string builder.
712 *
713 * @see StringBuilder
714 * @see StringBuffer#append(String)
715 */
716"#,
717        );
718
719        assert!(doc.see_refs.contains(&"StringBuilder".to_string()));
720        assert!(doc
721            .see_refs
722            .contains(&"StringBuffer#append(String)".to_string()));
723    }
724
725    #[test]
726    fn test_parse_with_deprecated() {
727        let parser = JavadocParser::new();
728        let doc = parser.parse(
729            r#"
730/**
731 * Gets the date.
732 *
733 * @deprecated Use {@link LocalDate} instead
734 */
735"#,
736        );
737
738        assert!(doc.deprecated.is_some());
739        assert!(doc.deprecated.as_ref().unwrap().contains("LocalDate"));
740    }
741
742    #[test]
743    fn test_parse_with_author_and_since() {
744        let parser = JavadocParser::new();
745        let doc = parser.parse(
746            r#"
747/**
748 * A utility class for string operations.
749 *
750 * @author John Doe
751 * @since 1.0
752 * @version 2.1
753 */
754"#,
755        );
756
757        assert_eq!(doc.author, Some("John Doe".to_string()));
758        assert_eq!(doc.since, Some("1.0".to_string()));
759        assert!(doc
760            .custom_tags
761            .iter()
762            .any(|(k, v)| k == "version" && v == "2.1"));
763    }
764
765    #[test]
766    fn test_parse_with_inline_link() {
767        let parser = JavadocParser::new();
768        let doc = parser.parse(
769            r#"
770/**
771 * Returns a string similar to {@link String#valueOf(Object)}.
772 */
773"#,
774        );
775
776        assert!(doc.see_refs.contains(&"String#valueOf(Object)".to_string()));
777    }
778
779    #[test]
780    fn test_parse_with_code_block() {
781        let parser = JavadocParser::new();
782        let doc = parser.parse(
783            r#"
784/**
785 * Formats a string.
786 *
787 * <pre>
788 * String result = format("Hello %s", "World");
789 * </pre>
790 */
791"#,
792        );
793
794        assert!(!doc.examples.is_empty());
795        assert!(doc.examples[0].contains("format"));
796    }
797
798    #[test]
799    fn test_parse_with_inherit_doc() {
800        let parser = JavadocParser::new();
801        let doc = parser.parse(
802            r#"
803/**
804 * {@inheritDoc}
805 */
806"#,
807        );
808
809        assert!(doc
810            .custom_tags
811            .iter()
812            .any(|(k, v)| k == "inherits_doc" && v == "true"));
813    }
814
815    #[test]
816    fn test_parse_multiline_param() {
817        let parser = JavadocParser::new();
818        let doc = parser.parse(
819            r#"
820/**
821 * Processes input.
822 *
823 * @param data the input data to process,
824 *             which can span multiple lines
825 */
826"#,
827        );
828
829        assert_eq!(doc.params.len(), 1);
830        let (_, _, desc) = &doc.params[0];
831        assert!(desc.as_ref().unwrap().contains("multiple lines"));
832    }
833
834    #[test]
835    fn test_parse_html_in_description() {
836        let parser = JavadocParser::new();
837        let doc = parser.parse(
838            r#"
839/**
840 * <p>Returns the <b>formatted</b> string.</p>
841 *
842 * <ul>
843 *   <li>Item 1</li>
844 *   <li>Item 2</li>
845 * </ul>
846 */
847"#,
848        );
849
850        // Summary should have HTML stripped
851        assert!(doc.summary.is_some());
852        let summary = doc.summary.as_ref().unwrap();
853        assert!(!summary.contains("<p>"));
854        assert!(!summary.contains("<b>"));
855    }
856
857    #[test]
858    fn test_package_doc_parser() {
859        let parser = JavadocParser::for_package();
860        let doc = parser.parse(
861            r#"
862/**
863 * Provides utility classes for string manipulation.
864 */
865"#,
866        );
867
868        assert!(doc
869            .custom_tags
870            .iter()
871            .any(|(k, v)| k == "package_doc" && v == "true"));
872    }
873
874    #[test]
875    fn test_type_doc_parser() {
876        let parser = JavadocParser::for_type();
877        let doc = parser.parse(
878            r#"
879/**
880 * A class representing a person.
881 */
882"#,
883        );
884
885        assert!(doc
886            .custom_tags
887            .iter()
888            .any(|(k, v)| k == "type_doc" && v == "true"));
889    }
890
891    #[test]
892    fn test_to_suggestions_basic() {
893        let parser = JavadocParser::new();
894        let doc = parser.parse(
895            r#"
896/**
897 * Creates a new instance of the class.
898 */
899"#,
900        );
901
902        let suggestions = parser.to_suggestions(&doc, "MyClass", 10);
903
904        assert!(suggestions
905            .iter()
906            .any(|s| s.annotation_type == AnnotationType::Summary
907                && s.value.contains("Creates a new instance")));
908    }
909
910    #[test]
911    fn test_to_suggestions_deprecated() {
912        let parser = JavadocParser::new();
913        let doc = parser.parse(
914            r#"
915/**
916 * Old method.
917 * @deprecated Use newMethod instead
918 */
919"#,
920        );
921
922        let suggestions = parser.to_suggestions(&doc, "oldMethod", 1);
923
924        assert!(suggestions
925            .iter()
926            .any(|s| s.annotation_type == AnnotationType::Deprecated));
927    }
928
929    #[test]
930    fn test_to_suggestions_throws() {
931        let parser = JavadocParser::new();
932        let doc = parser.parse(
933            r#"
934/**
935 * Parses input.
936 * @throws IOException if reading fails
937 * @throws ParseException if parsing fails
938 */
939"#,
940        );
941
942        let suggestions = parser.to_suggestions(&doc, "parse", 1);
943
944        assert!(suggestions
945            .iter()
946            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("throws")));
947    }
948
949    #[test]
950    fn test_to_suggestions_refs() {
951        let parser = JavadocParser::new();
952        let doc = parser.parse(
953            r#"
954/**
955 * Gets the value.
956 * @see OtherClass
957 */
958"#,
959        );
960
961        let suggestions = parser.to_suggestions(&doc, "getValue", 1);
962
963        assert!(suggestions
964            .iter()
965            .any(|s| s.annotation_type == AnnotationType::Ref && s.value == "OtherClass"));
966    }
967
968    #[test]
969    fn test_to_suggestions_since() {
970        let parser = JavadocParser::new();
971        let doc = parser.parse(
972            r#"
973/**
974 * New feature.
975 * @since 2.0
976 */
977"#,
978        );
979
980        let suggestions = parser.to_suggestions(&doc, "feature", 1);
981
982        assert!(suggestions
983            .iter()
984            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("since 2.0")));
985    }
986
987    #[test]
988    fn test_to_suggestions_examples() {
989        let parser = JavadocParser::new();
990        let doc = parser.parse(
991            r#"
992/**
993 * Formats output.
994 * <pre>
995 * format("test");
996 * </pre>
997 */
998"#,
999        );
1000
1001        let suggestions = parser.to_suggestions(&doc, "format", 1);
1002
1003        assert!(suggestions
1004            .iter()
1005            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("examples")));
1006    }
1007
1008    #[test]
1009    fn test_truncate_for_summary() {
1010        assert_eq!(truncate_for_summary("Short", 100), "Short");
1011        assert_eq!(
1012            truncate_for_summary("This is a very long summary that needs truncation", 20),
1013            "This is a very long..."
1014        );
1015    }
1016
1017    #[test]
1018    fn test_extract_summary_multi_sentence() {
1019        let summary = JavadocParser::extract_summary("First sentence. Second sentence.");
1020        assert_eq!(summary, "First sentence.");
1021    }
1022
1023    #[test]
1024    fn test_deprecated_empty() {
1025        let parser = JavadocParser::new();
1026        let doc = parser.parse(
1027            r#"
1028/**
1029 * Old method.
1030 * @deprecated
1031 */
1032"#,
1033        );
1034
1035        assert!(doc.deprecated.is_some());
1036        assert_eq!(doc.deprecated.as_ref().unwrap(), "Deprecated");
1037    }
1038}