acp/annotate/converters/
jsdoc.rs

1//! @acp:module "JSDoc Parser"
2//! @acp:summary "Parses JSDoc/TSDoc comments and converts to ACP format"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # JSDoc Parser
8//!
9//! Parses JSDoc and TSDoc documentation comments and converts them to
10//! ACP annotations. Supports:
11//!
12//! ## JSDoc Tags
13//! - @description, @summary
14//! - @deprecated
15//! - @see, @link
16//! - @todo, @fixme
17//! - @param, @returns, @throws
18//! - @private, @internal, @public
19//! - @module, @category
20//! - @readonly
21//! - @example
22//!
23//! ## TSDoc Extensions
24//! - @alpha, @beta - Stability modifiers
25//! - @packageDocumentation - Module-level docs
26//! - @remarks - Extended description
27//! - @defaultValue - Default value documentation
28//! - @typeParam - Generic type parameters
29//! - @override, @virtual, @sealed - Inheritance modifiers
30//! - {@inheritDoc} - Documentation inheritance
31
32use std::sync::LazyLock;
33
34use regex::Regex;
35
36use super::{DocStandardParser, ParsedDocumentation};
37use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
38
39/// @acp:summary "Matches JSDoc tags (anchored to line start)"
40static JSDOC_TAG: LazyLock<Regex> = LazyLock::new(|| {
41    Regex::new(r"^@(\w+)(?:\s+\{([^}]+)\})?\s*(.*)").expect("Invalid JSDoc tag regex")
42});
43
44/// @acp:summary "Matches inline {@link ...} references"
45static JSDOC_LINK: LazyLock<Regex> =
46    LazyLock::new(|| Regex::new(r"\{@link\s+([^}]+)\}").expect("Invalid JSDoc link regex"));
47
48/// @acp:summary "Matches inline {@inheritDoc ...} references"
49static INHERIT_DOC: LazyLock<Regex> = LazyLock::new(|| {
50    Regex::new(r"\{@inheritDoc(?:\s+([^}]+))?\}").expect("Invalid inheritDoc regex")
51});
52
53/// @acp:summary "TSDoc-specific fields extending ParsedDocumentation"
54#[derive(Debug, Clone, Default)]
55pub struct TsDocExtensions {
56    /// @alpha modifier - unstable API
57    pub is_alpha: bool,
58
59    /// @beta modifier - preview API
60    pub is_beta: bool,
61
62    /// @packageDocumentation - file/module level doc
63    pub is_package_doc: bool,
64
65    /// @remarks - extended description
66    pub remarks: Option<String>,
67
68    /// @privateRemarks - internal notes (not exported)
69    pub private_remarks: Option<String>,
70
71    /// @defaultValue entries
72    pub default_values: Vec<(String, String)>,
73
74    /// @typeParam entries: (name, description)
75    pub type_params: Vec<(String, Option<String>)>,
76
77    /// @override modifier
78    pub is_override: bool,
79
80    /// @virtual modifier
81    pub is_virtual: bool,
82
83    /// @sealed modifier
84    pub is_sealed: bool,
85
86    /// {@inheritDoc Target} references
87    pub inherit_doc: Option<String>,
88
89    /// @eventProperty modifier
90    pub is_event_property: bool,
91}
92
93/// @acp:summary "Parses JSDoc/TSDoc documentation comments"
94/// @acp:lock normal
95pub struct JsDocParser {
96    /// Whether to parse TSDoc-specific tags
97    parse_tsdoc: bool,
98}
99
100impl JsDocParser {
101    /// @acp:summary "Creates a new JSDoc parser"
102    pub fn new() -> Self {
103        Self { parse_tsdoc: false }
104    }
105
106    /// @acp:summary "Creates a parser with TSDoc support enabled"
107    pub fn with_tsdoc() -> Self {
108        Self { parse_tsdoc: true }
109    }
110
111    /// @acp:summary "Extracts inline links from text"
112    fn extract_inline_links(&self, text: &str) -> Vec<String> {
113        JSDOC_LINK
114            .captures_iter(text)
115            .filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
116            .collect()
117    }
118
119    /// @acp:summary "Extracts {@inheritDoc Target} references"
120    fn extract_inherit_doc(&self, text: &str) -> Option<String> {
121        INHERIT_DOC
122            .captures(text)
123            .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
124    }
125
126    /// @acp:summary "Checks if a tag continues multiline content"
127    fn is_continuation_line(line: &str) -> bool {
128        !line.is_empty() && !line.starts_with('@')
129    }
130}
131
132impl Default for JsDocParser {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138/// @acp:summary "TSDoc parser with full TSDoc support"
139/// @acp:lock normal
140pub struct TsDocParser {
141    /// TSDoc extensions parsed from comments
142    pub extensions: TsDocExtensions,
143}
144
145impl TsDocParser {
146    /// @acp:summary "Creates a new TSDoc parser"
147    pub fn new() -> Self {
148        Self {
149            extensions: TsDocExtensions::default(),
150        }
151    }
152
153    /// @acp:summary "Gets the parsed TSDoc extensions"
154    pub fn extensions(&self) -> &TsDocExtensions {
155        &self.extensions
156    }
157}
158
159impl Default for TsDocParser {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl DocStandardParser for JsDocParser {
166    fn parse(&self, raw_comment: &str) -> ParsedDocumentation {
167        let mut doc = ParsedDocumentation::new();
168        let mut description_lines = Vec::new();
169        let mut in_description = true;
170        let mut current_example = String::new();
171        let mut in_example = false;
172
173        // For multiline tag content
174        let mut current_tag: Option<String> = None;
175        let mut current_tag_content = String::new();
176
177        // TSDoc extension tracking
178        let mut remarks_content = String::new();
179        let mut in_remarks = false;
180
181        for line in raw_comment.lines() {
182            // Clean the line (remove leading whitespace first)
183            let trimmed = line.trim();
184
185            // Skip opening/closing JSDoc markers (standalone lines)
186            if trimmed == "/**" || trimmed == "*/" || trimmed == "/*" {
187                continue;
188            }
189
190            // Handle single-line JSDoc: /** content */ or /** @tag */
191            let line = if trimmed.starts_with("/**") && trimmed.ends_with("*/") {
192                trimmed
193                    .trim_start_matches("/**")
194                    .trim_end_matches("*/")
195                    .trim()
196            } else {
197                // Remove leading * and whitespace for content lines
198                trimmed.trim_start_matches('*').trim()
199            };
200
201            // Skip empty lines at the start
202            if line.is_empty() && description_lines.is_empty() && !in_example && !in_remarks {
203                continue;
204            }
205
206            // Check for JSDoc tag
207            if let Some(caps) = JSDOC_TAG.captures(line) {
208                in_description = false;
209
210                // Save current example if we were in one
211                if in_example && !current_example.is_empty() {
212                    doc.examples.push(current_example.trim().to_string());
213                    current_example = String::new();
214                    in_example = false;
215                }
216
217                // Save remarks if we were collecting them
218                if in_remarks && !remarks_content.is_empty() {
219                    doc.notes.push(remarks_content.trim().to_string());
220                    remarks_content = String::new();
221                    in_remarks = false;
222                }
223
224                // Save previous multiline tag content
225                if let Some(tag) = current_tag.take() {
226                    self.save_multiline_tag(&mut doc, &tag, &current_tag_content);
227                    current_tag_content.clear();
228                }
229
230                let tag = caps.get(1).map(|m| m.as_str()).unwrap_or("");
231                let type_info = caps.get(2).map(|m| m.as_str().to_string());
232                let content = caps.get(3).map(|m| m.as_str().trim().to_string());
233
234                match tag {
235                    "description" | "desc" => {
236                        if let Some(desc) = content {
237                            if !desc.is_empty() {
238                                doc.description = Some(desc);
239                            }
240                        }
241                    }
242                    "summary" => {
243                        doc.summary = content;
244                    }
245                    "deprecated" => {
246                        doc.deprecated = content.or(Some("Deprecated".to_string()));
247                    }
248                    "see" | "link" => {
249                        if let Some(ref_target) = content {
250                            doc.see_refs.push(ref_target);
251                        }
252                    }
253                    "todo" | "fixme" => {
254                        if let Some(msg) = content {
255                            doc.todos.push(msg);
256                        }
257                    }
258                    "param" | "arg" | "argument" => {
259                        if let Some(rest) = content {
260                            // Parse "name description" or just "name"
261                            let parts: Vec<&str> =
262                                rest.splitn(2, |c: char| c.is_whitespace()).collect();
263                            let name = parts.first().unwrap_or(&"").to_string();
264                            let desc = parts.get(1).map(|s| s.trim().to_string());
265                            if !name.is_empty() {
266                                doc.params.push((name, type_info, desc));
267                            }
268                        }
269                    }
270                    "returns" | "return" => {
271                        doc.returns = Some((type_info, content));
272                    }
273                    "throws" | "exception" | "raise" => {
274                        let exc_type =
275                            type_info.unwrap_or_else(|| content.clone().unwrap_or_default());
276                        if !exc_type.is_empty() {
277                            doc.throws.push((exc_type, content));
278                        }
279                    }
280                    "example" => {
281                        in_example = true;
282                        if let Some(ex) = content {
283                            if !ex.is_empty() {
284                                current_example.push_str(&ex);
285                                current_example.push('\n');
286                            }
287                        }
288                    }
289                    "module" | "fileoverview" | "packageDocumentation" => {
290                        if let Some(name) = content.clone() {
291                            if !name.is_empty() {
292                                doc.custom_tags.push(("module".to_string(), name));
293                            }
294                        }
295                        // Mark as package doc for TSDoc
296                        if self.parse_tsdoc && tag == "packageDocumentation" {
297                            doc.custom_tags
298                                .push(("packageDocumentation".to_string(), "true".to_string()));
299                        }
300                    }
301                    "category" | "group" => {
302                        if let Some(cat) = content {
303                            doc.custom_tags.push(("category".to_string(), cat));
304                        }
305                    }
306                    "private" => {
307                        doc.custom_tags
308                            .push(("visibility".to_string(), "private".to_string()));
309                    }
310                    "internal" => {
311                        doc.custom_tags
312                            .push(("visibility".to_string(), "internal".to_string()));
313                    }
314                    "protected" => {
315                        doc.custom_tags
316                            .push(("visibility".to_string(), "protected".to_string()));
317                    }
318                    "public" => {
319                        doc.custom_tags
320                            .push(("visibility".to_string(), "public".to_string()));
321                    }
322                    "readonly" => {
323                        doc.custom_tags
324                            .push(("readonly".to_string(), "true".to_string()));
325                    }
326                    "since" => {
327                        doc.since = content;
328                    }
329                    "author" => {
330                        doc.author = content;
331                    }
332                    "note" | "remark" => {
333                        if let Some(note) = content {
334                            doc.notes.push(note);
335                        }
336                    }
337                    "warning" | "warn" => {
338                        if let Some(warning) = content {
339                            doc.notes.push(format!("Warning: {}", warning));
340                        }
341                    }
342                    // TSDoc-specific tags
343                    "alpha" => {
344                        doc.custom_tags
345                            .push(("stability".to_string(), "alpha".to_string()));
346                    }
347                    "beta" => {
348                        doc.custom_tags
349                            .push(("stability".to_string(), "beta".to_string()));
350                    }
351                    "remarks" => {
352                        in_remarks = true;
353                        if let Some(r) = content {
354                            if !r.is_empty() {
355                                remarks_content.push_str(&r);
356                                remarks_content.push('\n');
357                            }
358                        }
359                    }
360                    "privateRemarks" => {
361                        // Store but don't export (internal notes)
362                        if let Some(r) = content {
363                            doc.custom_tags.push(("privateRemarks".to_string(), r));
364                        }
365                    }
366                    "defaultValue" => {
367                        if let Some(val) = content {
368                            doc.custom_tags.push(("defaultValue".to_string(), val));
369                        }
370                    }
371                    "typeParam" | "typeparam" => {
372                        if let Some(rest) = content {
373                            // Parse "T description" or just "T"
374                            let parts: Vec<&str> =
375                                rest.splitn(2, |c: char| c.is_whitespace()).collect();
376                            let name = parts.first().unwrap_or(&"").to_string();
377                            let desc = parts.get(1).map(|s| s.trim().to_string());
378                            if !name.is_empty() {
379                                doc.custom_tags.push((
380                                    "typeParam".to_string(),
381                                    format!("{}: {}", name, desc.unwrap_or_default()),
382                                ));
383                            }
384                        }
385                    }
386                    "override" => {
387                        doc.custom_tags
388                            .push(("override".to_string(), "true".to_string()));
389                    }
390                    "virtual" => {
391                        doc.custom_tags
392                            .push(("virtual".to_string(), "true".to_string()));
393                    }
394                    "sealed" => {
395                        doc.custom_tags
396                            .push(("sealed".to_string(), "true".to_string()));
397                    }
398                    "eventProperty" => {
399                        doc.custom_tags
400                            .push(("eventProperty".to_string(), "true".to_string()));
401                    }
402                    _ => {
403                        // Store unknown tags (may include multiline content)
404                        if let Some(val) = content.clone() {
405                            if !val.is_empty() {
406                                doc.custom_tags.push((tag.to_string(), val));
407                            } else {
408                                // Tag with no inline content - might be multiline
409                                current_tag = Some(tag.to_string());
410                            }
411                        } else {
412                            // Modifier tag with no value
413                            doc.custom_tags.push((tag.to_string(), String::new()));
414                        }
415                    }
416                }
417            } else if in_example {
418                // Continue collecting example content
419                current_example.push_str(line);
420                current_example.push('\n');
421            } else if in_remarks {
422                // Continue collecting remarks content
423                remarks_content.push_str(line);
424                remarks_content.push('\n');
425            } else if current_tag.is_some() && Self::is_continuation_line(line) {
426                // Continue multiline tag content
427                current_tag_content.push_str(line);
428                current_tag_content.push('\n');
429            } else if in_description && !line.is_empty() {
430                description_lines.push(line.to_string());
431            }
432        }
433
434        // Save final example
435        if in_example && !current_example.is_empty() {
436            doc.examples.push(current_example.trim().to_string());
437        }
438
439        // Save final remarks
440        if in_remarks && !remarks_content.is_empty() {
441            doc.notes.push(remarks_content.trim().to_string());
442        }
443
444        // Save final multiline tag
445        if let Some(tag) = current_tag.take() {
446            self.save_multiline_tag(&mut doc, &tag, &current_tag_content);
447        }
448
449        // First line of description becomes summary (if not already set)
450        if doc.summary.is_none() && !description_lines.is_empty() {
451            doc.summary = Some(description_lines[0].clone());
452        }
453
454        // Join description lines
455        if !description_lines.is_empty() && doc.description.is_none() {
456            doc.description = Some(description_lines.join(" "));
457        }
458
459        // Extract inline links from description
460        if let Some(desc) = &doc.description {
461            let links = self.extract_inline_links(desc);
462            doc.see_refs.extend(links);
463
464            // Extract {@inheritDoc} references
465            if let Some(inherit) = self.extract_inherit_doc(desc) {
466                doc.custom_tags.push(("inheritDoc".to_string(), inherit));
467            }
468        }
469
470        doc
471    }
472
473    fn standard_name(&self) -> &'static str {
474        if self.parse_tsdoc {
475            "tsdoc"
476        } else {
477            "jsdoc"
478        }
479    }
480
481    fn to_suggestions(
482        &self,
483        parsed: &ParsedDocumentation,
484        target: &str,
485        line: usize,
486    ) -> Vec<Suggestion> {
487        let mut suggestions = Vec::new();
488
489        // Convert summary
490        if let Some(summary) = &parsed.summary {
491            let truncated = super::truncate_summary(summary, 100);
492            suggestions.push(Suggestion::summary(
493                target,
494                line,
495                truncated,
496                SuggestionSource::Converted,
497            ));
498        }
499
500        // Convert deprecated
501        if let Some(msg) = &parsed.deprecated {
502            suggestions.push(Suggestion::deprecated(
503                target,
504                line,
505                msg,
506                SuggestionSource::Converted,
507            ));
508        }
509
510        // Convert @see references to @acp:ref
511        for see_ref in &parsed.see_refs {
512            suggestions.push(Suggestion::new(
513                target,
514                line,
515                AnnotationType::Ref,
516                see_ref,
517                SuggestionSource::Converted,
518            ));
519        }
520
521        // Convert @todo to @acp:hack
522        for todo in &parsed.todos {
523            suggestions.push(Suggestion::new(
524                target,
525                line,
526                AnnotationType::Hack,
527                format!("reason=\"{}\"", todo),
528                SuggestionSource::Converted,
529            ));
530        }
531
532        // Convert visibility to lock level
533        if let Some(visibility) = parsed.get_visibility() {
534            let lock_level = match visibility {
535                "private" | "internal" => "restricted",
536                "protected" => "normal",
537                _ => "normal",
538            };
539            suggestions.push(Suggestion::lock(
540                target,
541                line,
542                lock_level,
543                SuggestionSource::Converted,
544            ));
545        }
546
547        // Convert @module
548        if let Some(module_name) = parsed.get_module() {
549            suggestions.push(Suggestion::new(
550                target,
551                line,
552                AnnotationType::Module,
553                module_name,
554                SuggestionSource::Converted,
555            ));
556        }
557
558        // Convert @category to domain
559        if let Some(category) = parsed.get_category() {
560            suggestions.push(Suggestion::domain(
561                target,
562                line,
563                category.to_lowercase(),
564                SuggestionSource::Converted,
565            ));
566        }
567
568        // Convert throws to AI hint
569        if !parsed.throws.is_empty() {
570            let throws_list: Vec<String> = parsed.throws.iter().map(|(t, _)| t.clone()).collect();
571            suggestions.push(Suggestion::ai_hint(
572                target,
573                line,
574                format!("throws {}", throws_list.join(", ")),
575                SuggestionSource::Converted,
576            ));
577        }
578
579        // TSDoc-specific conversions
580        if self.parse_tsdoc {
581            // Convert @alpha/@beta to @acp:stability
582            if let Some((_, stability)) = parsed.custom_tags.iter().find(|(k, _)| k == "stability")
583            {
584                suggestions.push(Suggestion::new(
585                    target,
586                    line,
587                    AnnotationType::Stability,
588                    stability,
589                    SuggestionSource::Converted,
590                ));
591            }
592
593            // Convert @defaultValue to AI hint
594            for (key, value) in &parsed.custom_tags {
595                if key == "defaultValue" {
596                    suggestions.push(Suggestion::ai_hint(
597                        target,
598                        line,
599                        format!("default: {}", value),
600                        SuggestionSource::Converted,
601                    ));
602                }
603                if key == "typeParam" {
604                    suggestions.push(Suggestion::ai_hint(
605                        target,
606                        line,
607                        format!("type param {}", value),
608                        SuggestionSource::Converted,
609                    ));
610                }
611                if key == "override" && value == "true" {
612                    suggestions.push(Suggestion::ai_hint(
613                        target,
614                        line,
615                        "overrides parent",
616                        SuggestionSource::Converted,
617                    ));
618                }
619                if key == "sealed" && value == "true" {
620                    suggestions.push(Suggestion::lock(
621                        target,
622                        line,
623                        "strict",
624                        SuggestionSource::Converted,
625                    ));
626                }
627            }
628        }
629
630        // Convert readonly to AI hint
631        if parsed.is_readonly() {
632            suggestions.push(Suggestion::ai_hint(
633                target,
634                line,
635                "readonly",
636                SuggestionSource::Converted,
637            ));
638        }
639
640        // Convert examples existence to AI hint
641        if !parsed.examples.is_empty() {
642            suggestions.push(Suggestion::ai_hint(
643                target,
644                line,
645                "has examples",
646                SuggestionSource::Converted,
647            ));
648        }
649
650        // Convert notes/warnings/remarks to AI hints
651        for note in &parsed.notes {
652            suggestions.push(Suggestion::ai_hint(
653                target,
654                line,
655                note,
656                SuggestionSource::Converted,
657            ));
658        }
659
660        suggestions
661    }
662}
663
664impl JsDocParser {
665    /// @acp:summary "Saves multiline tag content to the appropriate field"
666    fn save_multiline_tag(&self, doc: &mut ParsedDocumentation, tag: &str, content: &str) {
667        let content = content.trim().to_string();
668        if content.is_empty() {
669            return;
670        }
671
672        match tag {
673            "description" | "desc" => {
674                doc.description = Some(content);
675            }
676            "example" => {
677                doc.examples.push(content);
678            }
679            _ => {
680                doc.custom_tags.push((tag.to_string(), content));
681            }
682        }
683    }
684}
685
686impl DocStandardParser for TsDocParser {
687    fn parse(&self, raw_comment: &str) -> ParsedDocumentation {
688        // Delegate to JsDocParser with TSDoc mode enabled
689        let parser = JsDocParser::with_tsdoc();
690        parser.parse(raw_comment)
691    }
692
693    fn standard_name(&self) -> &'static str {
694        "tsdoc"
695    }
696
697    fn to_suggestions(
698        &self,
699        parsed: &ParsedDocumentation,
700        target: &str,
701        line: usize,
702    ) -> Vec<Suggestion> {
703        // Delegate to JsDocParser with TSDoc mode enabled
704        let parser = JsDocParser::with_tsdoc();
705        parser.to_suggestions(parsed, target, line)
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    #[test]
714    fn test_parse_basic_jsdoc() {
715        let parser = JsDocParser::new();
716        let doc = parser.parse(
717            r#"
718            /**
719             * Validates a user session.
720             * @param {string} token - The JWT token
721             * @returns {Promise<User>} The user object
722             * @deprecated Use validateSessionV2 instead
723             */
724        "#,
725        );
726
727        assert_eq!(doc.summary, Some("Validates a user session.".to_string()));
728        assert_eq!(
729            doc.deprecated,
730            Some("Use validateSessionV2 instead".to_string())
731        );
732        assert_eq!(doc.params.len(), 1);
733        assert_eq!(doc.params[0].0, "token");
734        assert!(doc.returns.is_some());
735    }
736
737    #[test]
738    fn test_parse_module_jsdoc() {
739        let parser = JsDocParser::new();
740        let doc = parser.parse(
741            r#"
742            /**
743             * @module Authentication
744             * @category Security
745             */
746        "#,
747        );
748
749        assert_eq!(doc.get_module(), Some("Authentication"));
750        assert_eq!(doc.get_category(), Some("Security"));
751    }
752
753    #[test]
754    fn test_parse_visibility_tags() {
755        let parser = JsDocParser::new();
756
757        let private_doc = parser.parse("/** @private */");
758        assert_eq!(private_doc.get_visibility(), Some("private"));
759
760        let internal_doc = parser.parse("/** @internal */");
761        assert_eq!(internal_doc.get_visibility(), Some("internal"));
762    }
763
764    #[test]
765    fn test_parse_see_and_link() {
766        let parser = JsDocParser::new();
767        let doc = parser.parse(
768            r#"
769            /**
770             * See {@link OtherClass} for more info.
771             * @see AnotherClass
772             */
773        "#,
774        );
775
776        assert!(doc.see_refs.contains(&"OtherClass".to_string()));
777        assert!(doc.see_refs.contains(&"AnotherClass".to_string()));
778    }
779
780    #[test]
781    fn test_parse_throws() {
782        let parser = JsDocParser::new();
783        let doc = parser.parse(
784            r#"
785            /**
786             * @throws {Error} When something goes wrong
787             * @throws {ValidationError} When validation fails
788             */
789        "#,
790        );
791
792        assert_eq!(doc.throws.len(), 2);
793        assert_eq!(doc.throws[0].0, "Error");
794        assert_eq!(doc.throws[1].0, "ValidationError");
795    }
796
797    #[test]
798    fn test_parse_todo() {
799        let parser = JsDocParser::new();
800        let doc = parser.parse(
801            r#"
802            /**
803             * @todo Add proper error handling
804             * @fixme This is broken
805             */
806        "#,
807        );
808
809        assert_eq!(doc.todos.len(), 2);
810        assert!(doc.todos[0].contains("error handling"));
811    }
812
813    #[test]
814    fn test_parse_example() {
815        let parser = JsDocParser::new();
816        let doc = parser.parse(
817            r#"
818            /**
819             * Example function
820             * @example
821             * const result = myFunc();
822             * console.log(result);
823             */
824        "#,
825        );
826
827        assert!(!doc.examples.is_empty());
828        assert!(doc.examples[0].contains("myFunc"));
829    }
830
831    #[test]
832    fn test_parse_readonly() {
833        let parser = JsDocParser::new();
834        let doc = parser.parse("/** @readonly */");
835        assert!(doc.is_readonly());
836    }
837
838    // ===== TSDoc-specific tests =====
839
840    #[test]
841    fn test_parse_tsdoc_alpha() {
842        let parser = JsDocParser::with_tsdoc();
843        let doc = parser.parse(
844            r#"
845            /**
846             * Experimental API
847             * @alpha
848             */
849        "#,
850        );
851
852        let has_alpha = doc
853            .custom_tags
854            .iter()
855            .any(|(k, v)| k == "stability" && v == "alpha");
856        assert!(has_alpha);
857    }
858
859    #[test]
860    fn test_parse_tsdoc_beta() {
861        let parser = JsDocParser::with_tsdoc();
862        let doc = parser.parse(
863            r#"
864            /**
865             * Preview API
866             * @beta
867             */
868        "#,
869        );
870
871        let has_beta = doc
872            .custom_tags
873            .iter()
874            .any(|(k, v)| k == "stability" && v == "beta");
875        assert!(has_beta);
876    }
877
878    #[test]
879    fn test_parse_tsdoc_package_documentation() {
880        let parser = JsDocParser::with_tsdoc();
881        let doc = parser.parse(
882            r#"
883            /**
884             * @packageDocumentation
885             * This is the main module.
886             */
887        "#,
888        );
889
890        let has_pkg_doc = doc
891            .custom_tags
892            .iter()
893            .any(|(k, v)| k == "packageDocumentation" && v == "true");
894        assert!(has_pkg_doc);
895    }
896
897    #[test]
898    fn test_parse_tsdoc_remarks() {
899        let parser = JsDocParser::with_tsdoc();
900        let doc = parser.parse(
901            r#"
902            /**
903             * Brief summary.
904             * @remarks
905             * This is a longer explanation
906             * that spans multiple lines.
907             */
908        "#,
909        );
910
911        assert!(!doc.notes.is_empty());
912        assert!(doc.notes[0].contains("longer explanation"));
913    }
914
915    #[test]
916    fn test_parse_tsdoc_default_value() {
917        let parser = JsDocParser::with_tsdoc();
918        let doc = parser.parse(
919            r#"
920            /**
921             * The timeout in milliseconds.
922             * @defaultValue 5000
923             */
924        "#,
925        );
926
927        let has_default = doc
928            .custom_tags
929            .iter()
930            .any(|(k, v)| k == "defaultValue" && v == "5000");
931        assert!(has_default);
932    }
933
934    #[test]
935    fn test_parse_tsdoc_type_param() {
936        let parser = JsDocParser::with_tsdoc();
937        let doc = parser.parse(
938            r#"
939            /**
940             * A generic container.
941             * @typeParam T The type of contained value
942             */
943        "#,
944        );
945
946        let has_type_param = doc
947            .custom_tags
948            .iter()
949            .any(|(k, v)| k == "typeParam" && v.contains("T:"));
950        assert!(has_type_param);
951    }
952
953    #[test]
954    fn test_parse_tsdoc_override() {
955        let parser = JsDocParser::with_tsdoc();
956        let doc = parser.parse(
957            r#"
958            /**
959             * Overrides parent implementation.
960             * @override
961             */
962        "#,
963        );
964
965        let has_override = doc
966            .custom_tags
967            .iter()
968            .any(|(k, v)| k == "override" && v == "true");
969        assert!(has_override);
970    }
971
972    #[test]
973    fn test_parse_tsdoc_sealed() {
974        let parser = JsDocParser::with_tsdoc();
975        let doc = parser.parse(
976            r#"
977            /**
978             * This class cannot be extended.
979             * @sealed
980             */
981        "#,
982        );
983
984        let has_sealed = doc
985            .custom_tags
986            .iter()
987            .any(|(k, v)| k == "sealed" && v == "true");
988        assert!(has_sealed);
989    }
990
991    #[test]
992    fn test_parse_tsdoc_virtual() {
993        let parser = JsDocParser::with_tsdoc();
994        let doc = parser.parse(
995            r#"
996            /**
997             * Can be overridden by subclasses.
998             * @virtual
999             */
1000        "#,
1001        );
1002
1003        let has_virtual = doc
1004            .custom_tags
1005            .iter()
1006            .any(|(k, v)| k == "virtual" && v == "true");
1007        assert!(has_virtual);
1008    }
1009
1010    #[test]
1011    fn test_parse_inherit_doc() {
1012        let parser = JsDocParser::with_tsdoc();
1013        let doc = parser.parse(
1014            r#"
1015            /**
1016             * {@inheritDoc ParentClass.method}
1017             */
1018        "#,
1019        );
1020
1021        let has_inherit = doc.custom_tags.iter().any(|(k, _)| k == "inheritDoc");
1022        assert!(has_inherit);
1023    }
1024
1025    #[test]
1026    fn test_tsdoc_to_suggestions_alpha() {
1027        let parser = JsDocParser::with_tsdoc();
1028        let doc = parser.parse(
1029            r#"
1030            /**
1031             * Experimental feature.
1032             * @alpha
1033             */
1034        "#,
1035        );
1036
1037        let suggestions = parser.to_suggestions(&doc, "myFunction", 10);
1038        let has_stability = suggestions
1039            .iter()
1040            .any(|s| s.annotation_type == AnnotationType::Stability && s.value == "alpha");
1041        assert!(has_stability);
1042    }
1043
1044    #[test]
1045    fn test_tsdoc_to_suggestions_sealed() {
1046        let parser = JsDocParser::with_tsdoc();
1047        let doc = parser.parse(
1048            r#"
1049            /**
1050             * Locked class.
1051             * @sealed
1052             */
1053        "#,
1054        );
1055
1056        let suggestions = parser.to_suggestions(&doc, "MyClass", 10);
1057        let has_strict_lock = suggestions
1058            .iter()
1059            .any(|s| s.annotation_type == AnnotationType::Lock && s.value == "strict");
1060        assert!(has_strict_lock);
1061    }
1062
1063    #[test]
1064    fn test_tsdoc_to_suggestions_default_value() {
1065        let parser = JsDocParser::with_tsdoc();
1066        let doc = parser.parse(
1067            r#"
1068            /**
1069             * Timeout setting.
1070             * @defaultValue 3000
1071             */
1072        "#,
1073        );
1074
1075        let suggestions = parser.to_suggestions(&doc, "timeout", 10);
1076        let has_default_hint = suggestions
1077            .iter()
1078            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("default:"));
1079        assert!(has_default_hint);
1080    }
1081
1082    #[test]
1083    fn test_tsdoc_parser_delegation() {
1084        let parser = TsDocParser::new();
1085        let doc = parser.parse(
1086            r#"
1087            /**
1088             * API function.
1089             * @beta
1090             * @param {string} name The name
1091             */
1092        "#,
1093        );
1094
1095        assert_eq!(doc.summary, Some("API function.".to_string()));
1096        assert_eq!(doc.params.len(), 1);
1097        let has_beta = doc
1098            .custom_tags
1099            .iter()
1100            .any(|(k, v)| k == "stability" && v == "beta");
1101        assert!(has_beta);
1102    }
1103
1104    #[test]
1105    fn test_multiline_remarks() {
1106        let parser = JsDocParser::with_tsdoc();
1107        let doc = parser.parse(
1108            r#"
1109            /**
1110             * Summary line.
1111             *
1112             * @remarks
1113             * First line of remarks.
1114             * Second line of remarks.
1115             * Third line with more detail.
1116             *
1117             * @param x A parameter
1118             */
1119        "#,
1120        );
1121
1122        assert!(!doc.notes.is_empty());
1123        let remarks = &doc.notes[0];
1124        assert!(remarks.contains("First line"));
1125        assert!(remarks.contains("Third line"));
1126    }
1127
1128    #[test]
1129    fn test_complex_tsdoc() {
1130        let parser = JsDocParser::with_tsdoc();
1131        let doc = parser.parse(
1132            r#"
1133            /**
1134             * Processes user authentication.
1135             *
1136             * @remarks
1137             * This function handles OAuth2 and JWT tokens.
1138             * It's designed for high-throughput scenarios.
1139             *
1140             * @typeParam T The credential type
1141             * @param credentials User credentials
1142             * @returns The authenticated user
1143             * @throws {AuthError} When authentication fails
1144             * @beta
1145             * @see OAuthProvider
1146             *
1147             * @example
1148             * const user = await authenticate(creds);
1149             * console.log(user.name);
1150             */
1151        "#,
1152        );
1153
1154        assert_eq!(
1155            doc.summary,
1156            Some("Processes user authentication.".to_string())
1157        );
1158        assert!(!doc.notes.is_empty());
1159        assert!(doc.notes[0].contains("OAuth2"));
1160        assert_eq!(doc.params.len(), 1);
1161        assert!(doc.returns.is_some());
1162        assert_eq!(doc.throws.len(), 1);
1163        assert!(doc.see_refs.contains(&"OAuthProvider".to_string()));
1164        assert!(!doc.examples.is_empty());
1165
1166        let has_beta = doc
1167            .custom_tags
1168            .iter()
1169            .any(|(k, v)| k == "stability" && v == "beta");
1170        assert!(has_beta);
1171
1172        let has_type_param = doc.custom_tags.iter().any(|(k, _)| k == "typeParam");
1173        assert!(has_type_param);
1174    }
1175}