Skip to main content

ox_content_docs/
extractor.rs

1//! Documentation extraction from source code using OXC parser.
2
3use oxc_allocator::Allocator;
4use oxc_ast::ast::{
5    BindingPatternKind, Class, Declaration, ExportDefaultDeclarationKind, Function, Statement,
6    TSSignature, TSType, TSTypeName,
7};
8use oxc_ast::visit::walk;
9use oxc_ast::Visit;
10use oxc_parser::Parser;
11use oxc_span::SourceType;
12use serde::{Deserialize, Serialize};
13use std::path::Path;
14use thiserror::Error;
15
16/// Result type for extraction operations.
17pub type ExtractResult<T> = Result<T, ExtractError>;
18
19/// Errors during documentation extraction.
20#[derive(Debug, Error)]
21pub enum ExtractError {
22    /// IO error.
23    #[error("IO error: {0}")]
24    Io(#[from] std::io::Error),
25
26    /// Parse error.
27    #[error("Parse error: {0}")]
28    Parse(String),
29
30    /// Unsupported file type.
31    #[error("Unsupported file type: {0}")]
32    UnsupportedFile(String),
33}
34
35/// Documentation item extracted from source code.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DocItem {
38    /// Item name.
39    pub name: String,
40    /// Item kind (function, class, interface, etc.).
41    pub kind: DocItemKind,
42    /// Documentation comment (JSDoc).
43    pub doc: Option<String>,
44    /// Source file path.
45    pub source_path: String,
46    /// Line number in source.
47    pub line: u32,
48    /// Column number in source.
49    pub column: u32,
50    /// Whether the item is exported.
51    pub exported: bool,
52    /// Type signature (if applicable).
53    pub signature: Option<String>,
54    /// Parameters (for functions/methods).
55    pub params: Vec<ParamDoc>,
56    /// Return type (for functions/methods).
57    pub return_type: Option<String>,
58    /// Child items (for classes, modules, etc.).
59    pub children: Vec<DocItem>,
60    /// JSDoc tags.
61    pub tags: Vec<DocTag>,
62}
63
64/// Parameter documentation.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ParamDoc {
67    /// Parameter name.
68    pub name: String,
69    /// Parameter type.
70    pub type_annotation: Option<String>,
71    /// Whether the parameter is optional.
72    pub optional: bool,
73    /// Default value (if any).
74    pub default_value: Option<String>,
75    /// Description from JSDoc @param tag.
76    pub description: Option<String>,
77}
78
79/// JSDoc tag.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct DocTag {
82    /// Tag name (e.g., "param", "returns", "example").
83    pub tag: String,
84    /// Tag value.
85    pub value: String,
86}
87
88/// Kind of documentation item.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum DocItemKind {
92    /// Module or namespace.
93    Module,
94    /// Function.
95    Function,
96    /// Class.
97    Class,
98    /// Interface (TypeScript).
99    Interface,
100    /// Type alias.
101    Type,
102    /// Enum.
103    Enum,
104    /// Variable or constant.
105    Variable,
106    /// Class method.
107    Method,
108    /// Class property.
109    Property,
110    /// Constructor.
111    Constructor,
112    /// Getter.
113    Getter,
114    /// Setter.
115    Setter,
116}
117
118/// Documentation extractor.
119pub struct DocExtractor {
120    /// Include private items.
121    include_private: bool,
122}
123
124impl DocExtractor {
125    /// Creates a new documentation extractor.
126    #[must_use]
127    pub fn new() -> Self {
128        Self { include_private: false }
129    }
130
131    /// Creates a new extractor that includes private items.
132    #[must_use]
133    pub fn with_private(include_private: bool) -> Self {
134        Self { include_private }
135    }
136
137    /// Extracts documentation from a source file.
138    pub fn extract_file(&self, path: &Path) -> ExtractResult<Vec<DocItem>> {
139        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
140
141        match extension {
142            "ts" | "tsx" | "js" | "jsx" | "mts" | "mjs" | "cts" | "cjs" => self.extract_js_ts(path),
143            _ => Err(ExtractError::UnsupportedFile(extension.to_string())),
144        }
145    }
146
147    /// Extracts documentation from source code string.
148    pub fn extract_source(
149        &self,
150        source: &str,
151        file_path: &str,
152        source_type: SourceType,
153    ) -> ExtractResult<Vec<DocItem>> {
154        let allocator = Allocator::default();
155        let ret = Parser::new(&allocator, source, source_type).parse();
156
157        if !ret.errors.is_empty() {
158            let error_msg = ret
159                .errors
160                .iter()
161                .map(std::string::ToString::to_string)
162                .collect::<Vec<_>>()
163                .join(", ");
164            return Err(ExtractError::Parse(error_msg));
165        }
166
167        let mut visitor = DocVisitor::new(source, file_path, self.include_private);
168        visitor.visit_program(&ret.program);
169
170        Ok(visitor.items)
171    }
172
173    /// Extracts documentation from a JavaScript/TypeScript file.
174    fn extract_js_ts(&self, path: &Path) -> ExtractResult<Vec<DocItem>> {
175        let content = std::fs::read_to_string(path)?;
176        let file_path = path.to_string_lossy().to_string();
177        let source_type = SourceType::from_path(path).unwrap_or_default();
178
179        self.extract_source(&content, &file_path, source_type)
180    }
181}
182
183impl Default for DocExtractor {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189/// AST visitor for extracting documentation.
190struct DocVisitor<'a> {
191    source: &'a str,
192    file_path: &'a str,
193    include_private: bool,
194    items: Vec<DocItem>,
195    /// Track default export
196    has_default_export: bool,
197}
198
199impl<'a> DocVisitor<'a> {
200    fn new(source: &'a str, file_path: &'a str, include_private: bool) -> Self {
201        Self { source, file_path, include_private, items: Vec::new(), has_default_export: false }
202    }
203
204    /// Extract JSDoc comment before a given position.
205    /// Only extracts if the JSDoc is immediately adjacent to the declaration
206    /// (with only whitespace and keywords like 'export', 'async', etc. in between).
207    fn extract_jsdoc(&self, start: u32) -> Option<(String, Vec<DocTag>)> {
208        let source_before = &self.source[..start as usize];
209
210        // Find the last JSDoc comment (/** ... */)
211        if let Some(end) = source_before.rfind("*/") {
212            let remaining = &source_before[..end];
213            if let Some(start_idx) = remaining.rfind("/**") {
214                // Check that between the comment end and declaration start,
215                // there's only whitespace and allowed keywords
216                let between = &source_before[end + 2..];
217                let between_trimmed = between.trim();
218
219                // Skip if there's actual code between the comment and declaration
220                // Allow: whitespace, 'export', 'default', 'async', 'function', 'class', 'interface', 'type', 'const', 'let', 'var', 'enum'
221                let allowed_keywords = [
222                    "export",
223                    "default",
224                    "async",
225                    "function",
226                    "class",
227                    "interface",
228                    "type",
229                    "const",
230                    "let",
231                    "var",
232                    "enum",
233                    "abstract",
234                    "declare",
235                ];
236
237                // Check if the content between is just allowed keywords
238                let words: Vec<&str> = between_trimmed.split_whitespace().collect();
239                let is_adjacent =
240                    words.iter().all(|word| allowed_keywords.contains(word) || word.is_empty());
241
242                if is_adjacent {
243                    let comment = &source_before[start_idx..end + 2];
244                    let (doc, tags) = Self::parse_jsdoc(comment);
245                    return Some((doc, tags));
246                }
247            }
248        }
249        None
250    }
251
252    /// Parse JSDoc comment into description and tags.
253    fn parse_jsdoc(comment: &str) -> (String, Vec<DocTag>) {
254        let mut description = String::new();
255        let mut tags = Vec::new();
256        let mut current_tag: Option<(String, String)> = None;
257
258        // Remove /** and */ and leading asterisks
259        let lines: Vec<&str> = comment
260            .trim_start_matches("/**")
261            .trim_end_matches("*/")
262            .lines()
263            .map(|line| line.trim().trim_start_matches('*').trim())
264            .filter(|line| !line.is_empty())
265            .collect();
266
267        for line in lines {
268            if line.starts_with('@') {
269                // Save previous tag if any
270                if let Some((tag, value)) = current_tag.take() {
271                    tags.push(DocTag { tag, value });
272                }
273
274                // Parse new tag
275                let parts: Vec<&str> = line.splitn(2, ' ').collect();
276                let tag_name = parts[0].trim_start_matches('@').to_string();
277                let tag_value = parts.get(1).unwrap_or(&"").to_string();
278                current_tag = Some((tag_name, tag_value));
279            } else if let Some((_, ref mut value)) = current_tag {
280                // Continue previous tag value
281                if !value.is_empty() {
282                    value.push(' ');
283                }
284                value.push_str(line);
285            } else {
286                // Add to description
287                if !description.is_empty() {
288                    description.push(' ');
289                }
290                description.push_str(line);
291            }
292        }
293
294        // Save last tag if any
295        if let Some((tag, value)) = current_tag {
296            tags.push(DocTag { tag, value });
297        }
298
299        (description, tags)
300    }
301
302    /// Format a function signature.
303    fn format_function_signature(&self, func: &Function) -> String {
304        let mut sig = String::new();
305
306        // Function name
307        if let Some(id) = &func.id {
308            sig.push_str(id.name.as_str());
309        }
310
311        // Type parameters
312        if let Some(type_params) = &func.type_parameters {
313            sig.push('<');
314            let params: Vec<String> =
315                type_params.params.iter().map(|p| p.name.name.to_string()).collect();
316            sig.push_str(&params.join(", "));
317            sig.push('>');
318        }
319
320        // Parameters
321        sig.push('(');
322        let params: Vec<String> =
323            func.params.items.iter().map(|p| self.format_binding_pattern(&p.pattern)).collect();
324        sig.push_str(&params.join(", "));
325        sig.push(')');
326
327        // Return type
328        if let Some(return_type) = &func.return_type {
329            sig.push_str(": ");
330            sig.push_str(&self.format_ts_type(&return_type.type_annotation));
331        }
332
333        sig
334    }
335
336    /// Format a binding pattern.
337    fn format_binding_pattern(&self, pattern: &oxc_ast::ast::BindingPattern) -> String {
338        match &pattern.kind {
339            BindingPatternKind::BindingIdentifier(id) => {
340                let mut s = id.name.to_string();
341                if pattern.optional {
342                    s.push('?');
343                }
344                if let Some(type_ann) = &pattern.type_annotation {
345                    s.push_str(": ");
346                    s.push_str(&self.format_ts_type(&type_ann.type_annotation));
347                }
348                s
349            }
350            BindingPatternKind::ObjectPattern(_) => "{...}".to_string(),
351            BindingPatternKind::ArrayPattern(_) => "[...]".to_string(),
352            BindingPatternKind::AssignmentPattern(assign) => {
353                self.format_binding_pattern(&assign.left)
354            }
355        }
356    }
357
358    /// Format a TypeScript type.
359    fn format_ts_type(&self, ts_type: &TSType) -> String {
360        match ts_type {
361            TSType::TSAnyKeyword(_) => "any".to_string(),
362            TSType::TSBooleanKeyword(_) => "boolean".to_string(),
363            TSType::TSNumberKeyword(_) => "number".to_string(),
364            TSType::TSStringKeyword(_) => "string".to_string(),
365            TSType::TSVoidKeyword(_) => "void".to_string(),
366            TSType::TSNullKeyword(_) => "null".to_string(),
367            TSType::TSUndefinedKeyword(_) => "undefined".to_string(),
368            TSType::TSNeverKeyword(_) => "never".to_string(),
369            TSType::TSBigIntKeyword(_) => "bigint".to_string(),
370            TSType::TSSymbolKeyword(_) => "symbol".to_string(),
371            TSType::TSObjectKeyword(_) => "object".to_string(),
372            TSType::TSTypeReference(ref_type) => Self::format_ts_type_name(&ref_type.type_name),
373            TSType::TSArrayType(arr) => format!("{}[]", self.format_ts_type(&arr.element_type)),
374            TSType::TSUnionType(union) => {
375                let types: Vec<String> =
376                    union.types.iter().map(|t| self.format_ts_type(t)).collect();
377                types.join(" | ")
378            }
379            TSType::TSIntersectionType(inter) => {
380                let types: Vec<String> =
381                    inter.types.iter().map(|t| self.format_ts_type(t)).collect();
382                types.join(" & ")
383            }
384            TSType::TSFunctionType(func) => {
385                let params: Vec<String> = func
386                    .params
387                    .items
388                    .iter()
389                    .map(|p| self.format_binding_pattern(&p.pattern))
390                    .collect();
391                let ret = self.format_ts_type(&func.return_type.type_annotation);
392                format!("({}) => {}", params.join(", "), ret)
393            }
394            TSType::TSTypeLiteral(_) => "{ ... }".to_string(),
395            TSType::TSTupleType(tuple) => {
396                let types: Vec<String> = tuple
397                    .element_types
398                    .iter()
399                    .map(|t| self.format_ts_type(t.to_ts_type()))
400                    .collect();
401                format!("[{}]", types.join(", "))
402            }
403            TSType::TSLiteralType(lit) => match &lit.literal {
404                oxc_ast::ast::TSLiteral::StringLiteral(s) => format!("\"{}\"", s.value),
405                oxc_ast::ast::TSLiteral::NumericLiteral(n) => n
406                    .raw
407                    .as_ref()
408                    .map_or_else(|| n.value.to_string(), std::string::ToString::to_string),
409                oxc_ast::ast::TSLiteral::BooleanLiteral(b) => b.value.to_string(),
410                _ => "literal".to_string(),
411            },
412            _ => "unknown".to_string(),
413        }
414    }
415
416    /// Format a TypeScript type name.
417    fn format_ts_type_name(name: &TSTypeName) -> String {
418        match name {
419            TSTypeName::IdentifierReference(id) => id.name.to_string(),
420            TSTypeName::QualifiedName(qn) => {
421                format!("{}.{}", Self::format_ts_type_name(&qn.left), qn.right.name)
422            }
423        }
424    }
425
426    /// Extract parameters from a function.
427    fn extract_params(&self, func: &Function, tags: &[DocTag]) -> Vec<ParamDoc> {
428        func.params
429            .items
430            .iter()
431            .map(|param| {
432                let name = match &param.pattern.kind {
433                    BindingPatternKind::BindingIdentifier(id) => id.name.to_string(),
434                    _ => "param".to_string(),
435                };
436
437                let type_annotation = param
438                    .pattern
439                    .type_annotation
440                    .as_ref()
441                    .map(|t| self.format_ts_type(&t.type_annotation));
442
443                let description =
444                    tags.iter().find(|t| t.tag == "param" && t.value.starts_with(&name)).map(|t| {
445                        t.value
446                            .trim_start_matches(&name)
447                            .trim_start_matches(" - ")
448                            .trim()
449                            .to_string()
450                    });
451
452                ParamDoc {
453                    name,
454                    type_annotation,
455                    optional: param.pattern.optional,
456                    default_value: None,
457                    description,
458                }
459            })
460            .collect()
461    }
462
463    /// Extract return type from tags.
464    fn extract_return_type(&self, func: &Function, tags: &[DocTag]) -> Option<String> {
465        func.return_type.as_ref().map(|r| self.format_ts_type(&r.type_annotation)).or_else(|| {
466            tags.iter().find(|t| t.tag == "returns" || t.tag == "return").map(|t| t.value.clone())
467        })
468    }
469
470    /// Create a DocItem from a function.
471    fn create_function_item(&self, func: &Function, exported: bool) -> Option<DocItem> {
472        let name = func.id.as_ref()?.name.to_string();
473
474        // Skip private items if not included
475        if !self.include_private && name.starts_with('_') {
476            return None;
477        }
478
479        let (doc, tags) =
480            self.extract_jsdoc(func.span.start).unwrap_or((String::new(), Vec::new()));
481
482        Some(DocItem {
483            name,
484            kind: DocItemKind::Function,
485            doc: if doc.is_empty() { None } else { Some(doc) },
486            source_path: self.file_path.to_string(),
487            line: func.span.start,
488            column: 0,
489            exported,
490            signature: Some(self.format_function_signature(func)),
491            params: self.extract_params(func, &tags),
492            return_type: self.extract_return_type(func, &tags),
493            children: Vec::new(),
494            tags,
495        })
496    }
497
498    /// Create a DocItem from a class.
499    fn create_class_item(&self, class: &Class, name: &str, exported: bool) -> Option<DocItem> {
500        // Skip private items if not included
501        if !self.include_private && name.starts_with('_') {
502            return None;
503        }
504
505        let (doc, tags) =
506            self.extract_jsdoc(class.span.start).unwrap_or((String::new(), Vec::new()));
507
508        let mut children = Vec::new();
509
510        // Extract class members
511        for element in &class.body.body {
512            match element {
513                oxc_ast::ast::ClassElement::MethodDefinition(method) => {
514                    let method_name = match &method.key {
515                        oxc_ast::ast::PropertyKey::StaticIdentifier(id) => id.name.to_string(),
516                        _ => continue,
517                    };
518
519                    if !self.include_private && method_name.starts_with('_') {
520                        continue;
521                    }
522
523                    let kind = match method.kind {
524                        oxc_ast::ast::MethodDefinitionKind::Constructor => DocItemKind::Constructor,
525                        oxc_ast::ast::MethodDefinitionKind::Get => DocItemKind::Getter,
526                        oxc_ast::ast::MethodDefinitionKind::Set => DocItemKind::Setter,
527                        oxc_ast::ast::MethodDefinitionKind::Method => DocItemKind::Method,
528                    };
529
530                    let (method_doc, method_tags) = self
531                        .extract_jsdoc(method.span.start)
532                        .unwrap_or((String::new(), Vec::new()));
533
534                    children.push(DocItem {
535                        name: method_name,
536                        kind,
537                        doc: if method_doc.is_empty() { None } else { Some(method_doc) },
538                        source_path: self.file_path.to_string(),
539                        line: method.span.start,
540                        column: 0,
541                        exported: false,
542                        signature: Some(self.format_function_signature(&method.value)),
543                        params: self.extract_params(&method.value, &method_tags),
544                        return_type: self.extract_return_type(&method.value, &method_tags),
545                        children: Vec::new(),
546                        tags: method_tags,
547                    });
548                }
549                oxc_ast::ast::ClassElement::PropertyDefinition(prop) => {
550                    let prop_name = match &prop.key {
551                        oxc_ast::ast::PropertyKey::StaticIdentifier(id) => id.name.to_string(),
552                        _ => continue,
553                    };
554
555                    if !self.include_private && prop_name.starts_with('_') {
556                        continue;
557                    }
558
559                    let (prop_doc, prop_tags) =
560                        self.extract_jsdoc(prop.span.start).unwrap_or((String::new(), Vec::new()));
561
562                    let type_annotation = prop
563                        .type_annotation
564                        .as_ref()
565                        .map(|t| self.format_ts_type(&t.type_annotation));
566
567                    children.push(DocItem {
568                        name: prop_name,
569                        kind: DocItemKind::Property,
570                        doc: if prop_doc.is_empty() { None } else { Some(prop_doc) },
571                        source_path: self.file_path.to_string(),
572                        line: prop.span.start,
573                        column: 0,
574                        exported: false,
575                        signature: type_annotation,
576                        params: Vec::new(),
577                        return_type: None,
578                        children: Vec::new(),
579                        tags: prop_tags,
580                    });
581                }
582                _ => {}
583            }
584        }
585
586        Some(DocItem {
587            name: name.to_string(),
588            kind: DocItemKind::Class,
589            doc: if doc.is_empty() { None } else { Some(doc) },
590            source_path: self.file_path.to_string(),
591            line: class.span.start,
592            column: 0,
593            exported,
594            signature: None,
595            params: Vec::new(),
596            return_type: None,
597            children,
598            tags,
599        })
600    }
601}
602
603impl<'a> Visit<'a> for DocVisitor<'a> {
604    fn visit_statement(&mut self, stmt: &Statement<'a>) {
605        match stmt {
606            Statement::ExportNamedDeclaration(export) => {
607                if let Some(ref decl) = export.declaration {
608                    self.visit_declaration_as_exported(decl);
609                }
610            }
611            Statement::ExportDefaultDeclaration(export) => {
612                self.has_default_export = true;
613                match &export.declaration {
614                    ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
615                        if let Some(item) = self.create_function_item(func, true) {
616                            self.items.push(item);
617                        }
618                    }
619                    ExportDefaultDeclarationKind::ClassDeclaration(class) => {
620                        let name = class
621                            .id
622                            .as_ref()
623                            .map_or_else(|| "default".to_string(), |id| id.name.to_string());
624                        if let Some(item) = self.create_class_item(class, &name, true) {
625                            self.items.push(item);
626                        }
627                    }
628                    _ => {}
629                }
630            }
631            _ => {
632                walk::walk_statement(self, stmt);
633            }
634        }
635    }
636
637    fn visit_declaration(&mut self, decl: &Declaration<'a>) {
638        // Only visit non-exported declarations
639        self.visit_declaration_internal(decl, false);
640    }
641}
642
643impl<'a> DocVisitor<'a> {
644    fn visit_declaration_as_exported(&mut self, decl: &Declaration<'a>) {
645        self.visit_declaration_internal(decl, true);
646    }
647
648    fn visit_declaration_internal(&mut self, decl: &Declaration<'a>, exported: bool) {
649        match decl {
650            Declaration::FunctionDeclaration(func) => {
651                if let Some(item) = self.create_function_item(func, exported) {
652                    self.items.push(item);
653                }
654            }
655            Declaration::ClassDeclaration(class) => {
656                if let Some(id) = &class.id {
657                    let name = id.name.to_string();
658                    if let Some(item) = self.create_class_item(class, &name, exported) {
659                        self.items.push(item);
660                    }
661                }
662            }
663            Declaration::VariableDeclaration(var_decl) => {
664                for declarator in &var_decl.declarations {
665                    if let BindingPatternKind::BindingIdentifier(id) = &declarator.id.kind {
666                        let name = id.name.to_string();
667
668                        if !self.include_private && name.starts_with('_') {
669                            continue;
670                        }
671
672                        let (doc, tags) = self
673                            .extract_jsdoc(var_decl.span.start)
674                            .unwrap_or((String::new(), Vec::new()));
675
676                        let type_annotation = declarator
677                            .id
678                            .type_annotation
679                            .as_ref()
680                            .map(|t| self.format_ts_type(&t.type_annotation));
681
682                        self.items.push(DocItem {
683                            name,
684                            kind: DocItemKind::Variable,
685                            doc: if doc.is_empty() { None } else { Some(doc) },
686                            source_path: self.file_path.to_string(),
687                            line: var_decl.span.start,
688                            column: 0,
689                            exported,
690                            signature: type_annotation,
691                            params: Vec::new(),
692                            return_type: None,
693                            children: Vec::new(),
694                            tags,
695                        });
696                    }
697                }
698            }
699            Declaration::TSTypeAliasDeclaration(type_alias) => {
700                let name = type_alias.id.name.to_string();
701
702                if !self.include_private && name.starts_with('_') {
703                    return;
704                }
705
706                let (doc, tags) = self
707                    .extract_jsdoc(type_alias.span.start)
708                    .unwrap_or((String::new(), Vec::new()));
709
710                self.items.push(DocItem {
711                    name,
712                    kind: DocItemKind::Type,
713                    doc: if doc.is_empty() { None } else { Some(doc) },
714                    source_path: self.file_path.to_string(),
715                    line: type_alias.span.start,
716                    column: 0,
717                    exported,
718                    signature: Some(self.format_ts_type(&type_alias.type_annotation)),
719                    params: Vec::new(),
720                    return_type: None,
721                    children: Vec::new(),
722                    tags,
723                });
724            }
725            Declaration::TSInterfaceDeclaration(interface) => {
726                let name = interface.id.name.to_string();
727
728                if !self.include_private && name.starts_with('_') {
729                    return;
730                }
731
732                let (doc, tags) =
733                    self.extract_jsdoc(interface.span.start).unwrap_or((String::new(), Vec::new()));
734
735                let mut children = Vec::new();
736
737                // Extract interface members
738                for sig in &interface.body.body {
739                    match sig {
740                        TSSignature::TSPropertySignature(prop) => {
741                            let prop_name = match &prop.key {
742                                oxc_ast::ast::PropertyKey::StaticIdentifier(id) => {
743                                    id.name.to_string()
744                                }
745                                _ => continue,
746                            };
747
748                            let (prop_doc, prop_tags) = self
749                                .extract_jsdoc(prop.span.start)
750                                .unwrap_or((String::new(), Vec::new()));
751
752                            let type_annotation = prop
753                                .type_annotation
754                                .as_ref()
755                                .map(|t| self.format_ts_type(&t.type_annotation));
756
757                            children.push(DocItem {
758                                name: prop_name,
759                                kind: DocItemKind::Property,
760                                doc: if prop_doc.is_empty() { None } else { Some(prop_doc) },
761                                source_path: self.file_path.to_string(),
762                                line: prop.span.start,
763                                column: 0,
764                                exported: false,
765                                signature: type_annotation,
766                                params: Vec::new(),
767                                return_type: None,
768                                children: Vec::new(),
769                                tags: prop_tags,
770                            });
771                        }
772                        TSSignature::TSMethodSignature(method) => {
773                            let method_name = match &method.key {
774                                oxc_ast::ast::PropertyKey::StaticIdentifier(id) => {
775                                    id.name.to_string()
776                                }
777                                _ => continue,
778                            };
779
780                            let (method_doc, method_tags) = self
781                                .extract_jsdoc(method.span.start)
782                                .unwrap_or((String::new(), Vec::new()));
783
784                            let params: Vec<ParamDoc> = method
785                                .params
786                                .items
787                                .iter()
788                                .map(|p| {
789                                    let param_name = match &p.pattern.kind {
790                                        BindingPatternKind::BindingIdentifier(id) => {
791                                            id.name.to_string()
792                                        }
793                                        _ => "param".to_string(),
794                                    };
795                                    ParamDoc {
796                                        name: param_name,
797                                        type_annotation: p
798                                            .pattern
799                                            .type_annotation
800                                            .as_ref()
801                                            .map(|t| self.format_ts_type(&t.type_annotation)),
802                                        optional: p.pattern.optional,
803                                        default_value: None,
804                                        description: None,
805                                    }
806                                })
807                                .collect();
808
809                            let return_type = method
810                                .return_type
811                                .as_ref()
812                                .map(|r| self.format_ts_type(&r.type_annotation));
813
814                            children.push(DocItem {
815                                name: method_name,
816                                kind: DocItemKind::Method,
817                                doc: if method_doc.is_empty() { None } else { Some(method_doc) },
818                                source_path: self.file_path.to_string(),
819                                line: method.span.start,
820                                column: 0,
821                                exported: false,
822                                signature: None,
823                                params,
824                                return_type,
825                                children: Vec::new(),
826                                tags: method_tags,
827                            });
828                        }
829                        _ => {}
830                    }
831                }
832
833                self.items.push(DocItem {
834                    name,
835                    kind: DocItemKind::Interface,
836                    doc: if doc.is_empty() { None } else { Some(doc) },
837                    source_path: self.file_path.to_string(),
838                    line: interface.span.start,
839                    column: 0,
840                    exported,
841                    signature: None,
842                    params: Vec::new(),
843                    return_type: None,
844                    children,
845                    tags,
846                });
847            }
848            Declaration::TSEnumDeclaration(enum_decl) => {
849                let name = enum_decl.id.name.to_string();
850
851                if !self.include_private && name.starts_with('_') {
852                    return;
853                }
854
855                let (doc, tags) =
856                    self.extract_jsdoc(enum_decl.span.start).unwrap_or((String::new(), Vec::new()));
857
858                let children: Vec<DocItem> = enum_decl
859                    .members
860                    .iter()
861                    .map(|member| {
862                        let member_name = match &member.id {
863                            oxc_ast::ast::TSEnumMemberName::Identifier(id) => id.name.to_string(),
864                            oxc_ast::ast::TSEnumMemberName::String(s) => s.value.to_string(),
865                        };
866                        DocItem {
867                            name: member_name,
868                            kind: DocItemKind::Property,
869                            doc: None,
870                            source_path: self.file_path.to_string(),
871                            line: member.span.start,
872                            column: 0,
873                            exported: false,
874                            signature: None,
875                            params: Vec::new(),
876                            return_type: None,
877                            children: Vec::new(),
878                            tags: Vec::new(),
879                        }
880                    })
881                    .collect();
882
883                self.items.push(DocItem {
884                    name,
885                    kind: DocItemKind::Enum,
886                    doc: if doc.is_empty() { None } else { Some(doc) },
887                    source_path: self.file_path.to_string(),
888                    line: enum_decl.span.start,
889                    column: 0,
890                    exported,
891                    signature: None,
892                    params: Vec::new(),
893                    return_type: None,
894                    children,
895                    tags,
896                });
897            }
898            _ => {}
899        }
900    }
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    #[test]
908    fn test_extract_function() {
909        let source = r"
910/**
911 * Adds two numbers together.
912 * @param a - The first number
913 * @param b - The second number
914 * @returns The sum of a and b
915 */
916export function add(a: number, b: number): number {
917    return a + b;
918}
919";
920
921        let extractor = DocExtractor::new();
922        let items = extractor.extract_source(source, "test.ts", SourceType::ts()).unwrap();
923
924        assert_eq!(items.len(), 1);
925        assert_eq!(items[0].name, "add");
926        assert_eq!(items[0].kind, DocItemKind::Function);
927        assert!(items[0].exported);
928        assert!(items[0].doc.as_ref().unwrap().contains("Adds two numbers"));
929        assert_eq!(items[0].params.len(), 2);
930    }
931
932    #[test]
933    fn test_extract_interface() {
934        let source = r"
935/**
936 * User interface.
937 */
938export interface User {
939    /** User's name */
940    name: string;
941    /** User's age */
942    age: number;
943}
944";
945
946        let extractor = DocExtractor::new();
947        let items = extractor.extract_source(source, "test.ts", SourceType::ts()).unwrap();
948
949        assert_eq!(items.len(), 1);
950        assert_eq!(items[0].name, "User");
951        assert_eq!(items[0].kind, DocItemKind::Interface);
952        assert_eq!(items[0].children.len(), 2);
953    }
954}