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, Comment, Declaration, ExportDefaultDeclarationKind, Expression,
6    Function, Statement, TSSignature, TSType, TSTypeName,
7};
8use oxc_ast::visit::walk;
9use oxc_ast::Visit;
10use oxc_parser::Parser;
11use oxc_span::{GetSpan, 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    /// End line number in source.
49    pub end_line: u32,
50    /// Column number in source.
51    pub column: u32,
52    /// Raw JSDoc comment content without the outer delimiters.
53    pub jsdoc: Option<String>,
54    /// Whether the item is exported.
55    pub exported: bool,
56    /// Type signature (if applicable).
57    pub signature: Option<String>,
58    /// Parameters (for functions/methods).
59    pub params: Vec<ParamDoc>,
60    /// Return type (for functions/methods).
61    pub return_type: Option<String>,
62    /// Child items (for classes, modules, etc.).
63    pub children: Vec<DocItem>,
64    /// JSDoc tags.
65    pub tags: Vec<DocTag>,
66}
67
68/// Parameter documentation.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ParamDoc {
71    /// Parameter name.
72    pub name: String,
73    /// Parameter type.
74    pub type_annotation: Option<String>,
75    /// Whether the parameter is optional.
76    pub optional: bool,
77    /// Default value (if any).
78    pub default_value: Option<String>,
79    /// Description from JSDoc @param tag.
80    pub description: Option<String>,
81}
82
83/// JSDoc tag.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DocTag {
86    /// Tag name (e.g., "param", "returns", "example").
87    pub tag: String,
88    /// Tag value.
89    pub value: String,
90}
91
92/// Kind of documentation item.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum DocItemKind {
96    /// Module or namespace.
97    Module,
98    /// Function.
99    Function,
100    /// Class.
101    Class,
102    /// Interface (TypeScript).
103    Interface,
104    /// Type alias.
105    Type,
106    /// Enum.
107    Enum,
108    /// Variable or constant.
109    Variable,
110    /// Class method.
111    Method,
112    /// Class property.
113    Property,
114    /// Constructor.
115    Constructor,
116    /// Getter.
117    Getter,
118    /// Setter.
119    Setter,
120}
121
122/// Documentation extractor.
123pub struct DocExtractor {
124    /// Include private items.
125    include_private: bool,
126}
127
128impl DocExtractor {
129    /// Creates a new documentation extractor.
130    #[must_use]
131    pub fn new() -> Self {
132        Self { include_private: false }
133    }
134
135    /// Creates a new extractor that includes private items.
136    #[must_use]
137    pub fn with_private(include_private: bool) -> Self {
138        Self { include_private }
139    }
140
141    /// Extracts documentation from a source file.
142    pub fn extract_file(&self, path: &Path) -> ExtractResult<Vec<DocItem>> {
143        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
144
145        match extension {
146            "ts" | "tsx" | "js" | "jsx" | "mts" | "mjs" | "cts" | "cjs" => self.extract_js_ts(path),
147            _ => Err(ExtractError::UnsupportedFile(extension.to_string())),
148        }
149    }
150
151    /// Extracts documentation from source code string.
152    pub fn extract_source(
153        &self,
154        source: &str,
155        file_path: &str,
156        source_type: SourceType,
157    ) -> ExtractResult<Vec<DocItem>> {
158        let allocator = Allocator::default();
159        let ret = Parser::new(&allocator, source, source_type).parse();
160
161        if !ret.errors.is_empty() {
162            let error_msg = ret
163                .errors
164                .iter()
165                .map(std::string::ToString::to_string)
166                .collect::<Vec<_>>()
167                .join(", ");
168            return Err(ExtractError::Parse(error_msg));
169        }
170
171        let mut visitor = DocVisitor::new(
172            source,
173            file_path,
174            self.include_private,
175            ret.program.comments.iter().copied().collect(),
176        );
177        visitor.visit_program(&ret.program);
178
179        Ok(visitor.items)
180    }
181
182    /// Extracts documentation from a JavaScript/TypeScript file.
183    fn extract_js_ts(&self, path: &Path) -> ExtractResult<Vec<DocItem>> {
184        let content = std::fs::read_to_string(path)?;
185        let file_path = path.to_string_lossy().to_string();
186        let source_type = SourceType::from_path(path).unwrap_or_default();
187
188        self.extract_source(&content, &file_path, source_type)
189    }
190}
191
192impl Default for DocExtractor {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198/// AST visitor for extracting documentation.
199struct DocVisitor<'a> {
200    source: &'a str,
201    file_path: &'a str,
202    include_private: bool,
203    comments: Vec<Comment>,
204    line_starts: Vec<usize>,
205    items: Vec<DocItem>,
206    /// Track default export
207    has_default_export: bool,
208}
209
210impl<'a> DocVisitor<'a> {
211    fn new(
212        source: &'a str,
213        file_path: &'a str,
214        include_private: bool,
215        comments: Vec<Comment>,
216    ) -> Self {
217        let mut line_starts = vec![0];
218        line_starts.extend(
219            source
220                .bytes()
221                .enumerate()
222                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
223        );
224
225        Self {
226            source,
227            file_path,
228            include_private,
229            comments,
230            line_starts,
231            items: Vec::new(),
232            has_default_export: false,
233        }
234    }
235
236    fn slice(&self, start: u32, end: u32) -> String {
237        self.source[start as usize..end as usize].to_string()
238    }
239
240    fn line_number(&self, position: u32) -> u32 {
241        let position = position as usize;
242        self.line_starts.partition_point(|&start| start <= position) as u32
243    }
244
245    fn column_number(&self, position: u32) -> u32 {
246        let position = position as usize;
247        let line_index = self.line_starts.partition_point(|&start| start <= position);
248        let line_start = self.line_starts[line_index.saturating_sub(1)];
249        (position.saturating_sub(line_start)) as u32
250    }
251
252    fn span_lines(&self, start: u32, end: u32) -> (u32, u32) {
253        let start_line = self.line_number(start);
254        let end_position = end.saturating_sub(1).max(start);
255        let end_line = self.line_number(end_position);
256        (start_line, end_line)
257    }
258
259    fn extract_jsdoc(&self, attached_to: u32) -> Option<(String, String, Vec<DocTag>)> {
260        let comment =
261            self.comments.iter().rev().find(|comment| {
262                comment.attached_to == attached_to && comment.is_jsdoc(self.source)
263            })?;
264
265        let mut raw = comment.content_span().source_text(self.source).to_string();
266        if raw.starts_with('*') {
267            raw.remove(0);
268        }
269        let raw = raw.trim_matches('\n').to_string();
270        let (doc, tags) = Self::parse_jsdoc(&raw);
271        Some((raw, doc, tags))
272    }
273
274    /// Parse JSDoc comment into description and tags.
275    fn parse_jsdoc(comment: &str) -> (String, Vec<DocTag>) {
276        let mut description_lines = Vec::new();
277        let mut tags = Vec::new();
278        let mut current_tag: Option<(String, Vec<String>)> = None;
279
280        let lines: Vec<String> = comment
281            .lines()
282            .map(|line| {
283                let trimmed = line.trim_start();
284                let trimmed = trimmed.strip_prefix('*').unwrap_or(trimmed);
285                trimmed.strip_prefix(' ').unwrap_or(trimmed).trim_end().to_string()
286            })
287            .collect();
288
289        for line in lines {
290            let trimmed = line.trim_start();
291            if let Some(without_at) = trimmed.strip_prefix('@') {
292                // Save previous tag if any
293                if let Some((tag, value_lines)) = current_tag.take() {
294                    tags.push(DocTag { tag, value: value_lines.join("\n").trim().to_string() });
295                }
296
297                let split_at = without_at
298                    .char_indices()
299                    .find_map(|(index, ch)| ch.is_whitespace().then_some(index))
300                    .unwrap_or(without_at.len());
301                let tag_name = without_at[..split_at].to_string();
302                let tag_value = without_at[split_at..].trim_start().to_string();
303                current_tag = Some((tag_name, vec![tag_value]));
304            } else if let Some((_, ref mut value_lines)) = current_tag {
305                value_lines.push(line);
306            } else {
307                description_lines.push(line);
308            }
309        }
310
311        // Save last tag if any
312        if let Some((tag, value_lines)) = current_tag {
313            tags.push(DocTag { tag, value: value_lines.join("\n").trim().to_string() });
314        }
315
316        (description_lines.join("\n").trim().to_string(), tags)
317    }
318
319    fn format_type_parameter_declaration<T>(
320        &self,
321        type_params: Option<&oxc_allocator::Box<'a, T>>,
322    ) -> String
323    where
324        T: oxc_span::GetSpan,
325    {
326        type_params
327            .map(|type_params| self.slice(type_params.span().start, type_params.span().end))
328            .unwrap_or_default()
329    }
330
331    fn format_formal_parameters(&self, params: &oxc_ast::ast::FormalParameters<'a>) -> String {
332        let mut items = params
333            .items
334            .iter()
335            .map(|param| self.slice(param.span.start, param.span.end))
336            .collect::<Vec<_>>();
337
338        if let Some(rest) = &params.rest {
339            items.push(format!(
340                "...{}",
341                self.slice(rest.argument.span().start, rest.argument.span().end)
342            ));
343        }
344
345        items.join(", ")
346    }
347
348    fn format_function_signature(&self, func: &Function<'a>, name: &str, exported: bool) -> String {
349        let mut sig = String::new();
350
351        if exported {
352            sig.push_str("export ");
353        }
354        if func.declare {
355            sig.push_str("declare ");
356        }
357        if func.r#async {
358            sig.push_str("async ");
359        }
360        sig.push_str("function ");
361        if func.generator {
362            sig.push('*');
363        }
364        sig.push_str(name);
365        sig.push_str(&self.format_type_parameter_declaration(func.type_parameters.as_ref()));
366        sig.push('(');
367        sig.push_str(&self.format_formal_parameters(&func.params));
368        sig.push(')');
369
370        if let Some(return_type) = func.return_type.as_ref() {
371            sig.push_str(": ");
372            sig.push_str(&self.slice(
373                return_type.type_annotation.span().start,
374                return_type.type_annotation.span().end,
375            ));
376        }
377
378        sig
379    }
380
381    fn format_assigned_function_signature(
382        &self,
383        name: &str,
384        r#async: bool,
385        type_parameters: Option<
386            &oxc_allocator::Box<'a, oxc_ast::ast::TSTypeParameterDeclaration<'a>>,
387        >,
388        params: &oxc_ast::ast::FormalParameters<'a>,
389        return_type: Option<&oxc_allocator::Box<'a, oxc_ast::ast::TSTypeAnnotation<'a>>>,
390    ) -> String {
391        let mut sig = String::new();
392        if r#async {
393            sig.push_str("async ");
394        }
395        sig.push_str(name);
396        sig.push_str(&self.format_type_parameter_declaration(type_parameters));
397        sig.push('(');
398        sig.push_str(&self.format_formal_parameters(params));
399        sig.push(')');
400
401        if let Some(return_type) = return_type {
402            sig.push_str(": ");
403            sig.push_str(&self.slice(
404                return_type.type_annotation.span().start,
405                return_type.type_annotation.span().end,
406            ));
407        }
408
409        sig
410    }
411
412    fn format_class_signature(&self, class: &Class, name: &str, exported: bool) -> String {
413        let mut sig = String::new();
414        if exported {
415            sig.push_str("export ");
416        }
417        if class.r#abstract {
418            sig.push_str("abstract ");
419        }
420        if class.declare {
421            sig.push_str("declare ");
422        }
423        sig.push_str("class ");
424        sig.push_str(name);
425        sig.push_str(&self.format_type_parameter_declaration(class.type_parameters.as_ref()));
426
427        if let Some(super_class) = &class.super_class {
428            sig.push_str(" extends ");
429            sig.push_str(&self.slice(super_class.span().start, super_class.span().end));
430            if let Some(type_params) = &class.super_type_parameters {
431                sig.push_str(&self.format_type_parameter_declaration(Some(type_params)));
432            }
433        }
434
435        if let Some(implements) = &class.implements {
436            let implements = implements
437                .iter()
438                .map(|item| {
439                    let mut value =
440                        self.slice(item.expression.span().start, item.expression.span().end);
441                    if let Some(type_params) = &item.type_parameters {
442                        value.push_str(&self.format_type_parameter_declaration(Some(type_params)));
443                    }
444                    value
445                })
446                .collect::<Vec<_>>()
447                .join(", ");
448
449            if !implements.is_empty() {
450                sig.push_str(" implements ");
451                sig.push_str(&implements);
452            }
453        }
454
455        sig
456    }
457
458    fn format_interface_signature(
459        &self,
460        interface: &oxc_ast::ast::TSInterfaceDeclaration<'a>,
461        exported: bool,
462    ) -> String {
463        let mut sig = String::new();
464        if exported {
465            sig.push_str("export ");
466        }
467        if interface.declare {
468            sig.push_str("declare ");
469        }
470        sig.push_str("interface ");
471        sig.push_str(interface.id.name.as_str());
472        sig.push_str(&self.format_type_parameter_declaration(interface.type_parameters.as_ref()));
473
474        if let Some(extends) = &interface.extends {
475            let extends = extends
476                .iter()
477                .map(|item| {
478                    let mut value =
479                        self.slice(item.expression.span().start, item.expression.span().end);
480                    if let Some(type_params) = &item.type_parameters {
481                        value.push_str(&self.format_type_parameter_declaration(Some(type_params)));
482                    }
483                    value
484                })
485                .collect::<Vec<_>>()
486                .join(", ");
487
488            if !extends.is_empty() {
489                sig.push_str(" extends ");
490                sig.push_str(&extends);
491            }
492        }
493
494        sig
495    }
496
497    fn format_type_alias_signature(
498        &self,
499        type_alias: &oxc_ast::ast::TSTypeAliasDeclaration<'a>,
500        exported: bool,
501    ) -> String {
502        let mut sig = String::new();
503        if exported {
504            sig.push_str("export ");
505        }
506        if type_alias.declare {
507            sig.push_str("declare ");
508        }
509        sig.push_str("type ");
510        sig.push_str(type_alias.id.name.as_str());
511        sig.push_str(&self.format_type_parameter_declaration(type_alias.type_parameters.as_ref()));
512        sig.push_str(" = ");
513        sig.push_str(
514            &self.slice(
515                type_alias.type_annotation.span().start,
516                type_alias.type_annotation.span().end,
517            ),
518        );
519        sig
520    }
521
522    fn has_private_tag(tags: &[DocTag]) -> bool {
523        tags.iter().any(|tag| tag.tag == "private")
524    }
525
526    /// Format a binding pattern.
527    fn format_binding_pattern(&self, pattern: &oxc_ast::ast::BindingPattern) -> String {
528        match &pattern.kind {
529            BindingPatternKind::BindingIdentifier(id) => {
530                let mut s = id.name.to_string();
531                if pattern.optional {
532                    s.push('?');
533                }
534                if let Some(type_ann) = &pattern.type_annotation {
535                    s.push_str(": ");
536                    s.push_str(&self.format_ts_type(&type_ann.type_annotation));
537                }
538                s
539            }
540            BindingPatternKind::ObjectPattern(_) => "{...}".to_string(),
541            BindingPatternKind::ArrayPattern(_) => "[...]".to_string(),
542            BindingPatternKind::AssignmentPattern(assign) => {
543                self.format_binding_pattern(&assign.left)
544            }
545        }
546    }
547
548    /// Format a TypeScript type.
549    fn format_ts_type(&self, ts_type: &TSType) -> String {
550        match ts_type {
551            TSType::TSAnyKeyword(_) => "any".to_string(),
552            TSType::TSBooleanKeyword(_) => "boolean".to_string(),
553            TSType::TSNumberKeyword(_) => "number".to_string(),
554            TSType::TSStringKeyword(_) => "string".to_string(),
555            TSType::TSVoidKeyword(_) => "void".to_string(),
556            TSType::TSNullKeyword(_) => "null".to_string(),
557            TSType::TSUndefinedKeyword(_) => "undefined".to_string(),
558            TSType::TSNeverKeyword(_) => "never".to_string(),
559            TSType::TSBigIntKeyword(_) => "bigint".to_string(),
560            TSType::TSSymbolKeyword(_) => "symbol".to_string(),
561            TSType::TSObjectKeyword(_) => "object".to_string(),
562            TSType::TSTypeReference(ref_type) => Self::format_ts_type_name(&ref_type.type_name),
563            TSType::TSArrayType(arr) => format!("{}[]", self.format_ts_type(&arr.element_type)),
564            TSType::TSUnionType(union) => {
565                let types: Vec<String> =
566                    union.types.iter().map(|t| self.format_ts_type(t)).collect();
567                types.join(" | ")
568            }
569            TSType::TSIntersectionType(inter) => {
570                let types: Vec<String> =
571                    inter.types.iter().map(|t| self.format_ts_type(t)).collect();
572                types.join(" & ")
573            }
574            TSType::TSFunctionType(func) => {
575                let params: Vec<String> = func
576                    .params
577                    .items
578                    .iter()
579                    .map(|p| self.format_binding_pattern(&p.pattern))
580                    .collect();
581                let ret = self.format_ts_type(&func.return_type.type_annotation);
582                format!("({}) => {}", params.join(", "), ret)
583            }
584            TSType::TSTypeLiteral(_) => "{ ... }".to_string(),
585            TSType::TSTupleType(tuple) => {
586                let types: Vec<String> = tuple
587                    .element_types
588                    .iter()
589                    .map(|t| self.format_ts_type(t.to_ts_type()))
590                    .collect();
591                format!("[{}]", types.join(", "))
592            }
593            TSType::TSLiteralType(lit) => match &lit.literal {
594                oxc_ast::ast::TSLiteral::StringLiteral(s) => format!("\"{}\"", s.value),
595                oxc_ast::ast::TSLiteral::NumericLiteral(n) => n
596                    .raw
597                    .as_ref()
598                    .map_or_else(|| n.value.to_string(), std::string::ToString::to_string),
599                oxc_ast::ast::TSLiteral::BooleanLiteral(b) => b.value.to_string(),
600                _ => "literal".to_string(),
601            },
602            _ => "unknown".to_string(),
603        }
604    }
605
606    /// Format a TypeScript type name.
607    fn format_ts_type_name(name: &TSTypeName) -> String {
608        match name {
609            TSTypeName::IdentifierReference(id) => id.name.to_string(),
610            TSTypeName::QualifiedName(qn) => {
611                format!("{}.{}", Self::format_ts_type_name(&qn.left), qn.right.name)
612            }
613        }
614    }
615
616    fn extract_params_from_formals(
617        &self,
618        params: &oxc_ast::ast::FormalParameters<'a>,
619        tags: &[DocTag],
620    ) -> Vec<ParamDoc> {
621        params
622            .items
623            .iter()
624            .map(|param| {
625                let name = match &param.pattern.kind {
626                    BindingPatternKind::BindingIdentifier(id) => id.name.to_string(),
627                    _ => "param".to_string(),
628                };
629
630                let type_annotation = param
631                    .pattern
632                    .type_annotation
633                    .as_ref()
634                    .map(|t| self.format_ts_type(&t.type_annotation));
635
636                let description =
637                    tags.iter().find(|t| t.tag == "param" && t.value.starts_with(&name)).map(|t| {
638                        t.value
639                            .trim_start_matches(&name)
640                            .trim_start_matches(" - ")
641                            .trim()
642                            .to_string()
643                    });
644
645                ParamDoc {
646                    name,
647                    type_annotation,
648                    optional: param.pattern.optional,
649                    default_value: None,
650                    description,
651                }
652            })
653            .collect()
654    }
655
656    /// Extract parameters from a function.
657    fn extract_params(&self, func: &Function, tags: &[DocTag]) -> Vec<ParamDoc> {
658        self.extract_params_from_formals(&func.params, tags)
659    }
660
661    fn extract_return_type_from_annotation(
662        &self,
663        return_type: Option<&oxc_allocator::Box<'a, oxc_ast::ast::TSTypeAnnotation<'a>>>,
664        tags: &[DocTag],
665    ) -> Option<String> {
666        return_type.map(|r| self.format_ts_type(&r.type_annotation)).or_else(|| {
667            tags.iter().find(|t| t.tag == "returns" || t.tag == "return").map(|t| t.value.clone())
668        })
669    }
670
671    /// Extract return type from tags.
672    fn extract_return_type(&self, func: &Function, tags: &[DocTag]) -> Option<String> {
673        self.extract_return_type_from_annotation(func.return_type.as_ref(), tags)
674    }
675
676    /// Create a DocItem from a function.
677    fn create_function_item(
678        &self,
679        func: &Function,
680        exported: bool,
681        attached_to: u32,
682    ) -> Option<DocItem> {
683        let name = func.id.as_ref()?.name.to_string();
684        let (jsdoc, doc, tags) = self.extract_jsdoc(attached_to)?;
685        if !self.include_private && Self::has_private_tag(&tags) {
686            return None;
687        }
688        let (line, end_line) = self.span_lines(attached_to, func.span.end);
689
690        Some(DocItem {
691            name,
692            kind: DocItemKind::Function,
693            doc: if doc.is_empty() { None } else { Some(doc) },
694            source_path: self.file_path.to_string(),
695            line,
696            end_line,
697            column: self.column_number(attached_to),
698            jsdoc: Some(jsdoc),
699            exported,
700            signature: Some(self.format_function_signature(
701                func,
702                func.id.as_ref()?.name.as_str(),
703                exported,
704            )),
705            params: self.extract_params(func, &tags),
706            return_type: self.extract_return_type(func, &tags),
707            children: Vec::new(),
708            tags,
709        })
710    }
711
712    /// Create a DocItem from a class.
713    fn create_class_item(
714        &self,
715        class: &Class,
716        name: &str,
717        exported: bool,
718        attached_to: u32,
719    ) -> Option<DocItem> {
720        let (jsdoc, doc, tags) = self.extract_jsdoc(attached_to)?;
721        if !self.include_private && Self::has_private_tag(&tags) {
722            return None;
723        }
724        let (line, end_line) = self.span_lines(attached_to, class.span.end);
725
726        let mut children = Vec::new();
727
728        // Extract class members
729        for element in &class.body.body {
730            match element {
731                oxc_ast::ast::ClassElement::MethodDefinition(method) => {
732                    let method_name = match &method.key {
733                        oxc_ast::ast::PropertyKey::StaticIdentifier(id) => id.name.to_string(),
734                        _ => continue,
735                    };
736
737                    let kind = match method.kind {
738                        oxc_ast::ast::MethodDefinitionKind::Constructor => DocItemKind::Constructor,
739                        oxc_ast::ast::MethodDefinitionKind::Get => DocItemKind::Getter,
740                        oxc_ast::ast::MethodDefinitionKind::Set => DocItemKind::Setter,
741                        oxc_ast::ast::MethodDefinitionKind::Method => DocItemKind::Method,
742                    };
743
744                    let Some((method_jsdoc, method_doc, method_tags)) =
745                        self.extract_jsdoc(method.span.start)
746                    else {
747                        continue;
748                    };
749                    if !self.include_private && Self::has_private_tag(&method_tags) {
750                        continue;
751                    }
752                    let (method_line, method_end_line) =
753                        self.span_lines(method.span.start, method.span.end);
754
755                    children.push(DocItem {
756                        name: method_name,
757                        kind,
758                        doc: if method_doc.is_empty() { None } else { Some(method_doc) },
759                        source_path: self.file_path.to_string(),
760                        line: method_line,
761                        end_line: method_end_line,
762                        column: self.column_number(method.span.start),
763                        jsdoc: Some(method_jsdoc),
764                        exported: false,
765                        signature: Some(self.format_assigned_function_signature(
766                            "",
767                            method.value.r#async,
768                            method.value.type_parameters.as_ref(),
769                            &method.value.params,
770                            method.value.return_type.as_ref(),
771                        )),
772                        params: self.extract_params(&method.value, &method_tags),
773                        return_type: self.extract_return_type(&method.value, &method_tags),
774                        children: Vec::new(),
775                        tags: method_tags,
776                    });
777                }
778                oxc_ast::ast::ClassElement::PropertyDefinition(prop) => {
779                    let prop_name = match &prop.key {
780                        oxc_ast::ast::PropertyKey::StaticIdentifier(id) => id.name.to_string(),
781                        _ => continue,
782                    };
783
784                    let Some((prop_jsdoc, prop_doc, prop_tags)) =
785                        self.extract_jsdoc(prop.span.start)
786                    else {
787                        continue;
788                    };
789                    if !self.include_private && Self::has_private_tag(&prop_tags) {
790                        continue;
791                    }
792                    let (prop_line, prop_end_line) =
793                        self.span_lines(prop.span.start, prop.span.end);
794
795                    let type_annotation = prop
796                        .type_annotation
797                        .as_ref()
798                        .map(|t| self.format_ts_type(&t.type_annotation));
799
800                    children.push(DocItem {
801                        name: prop_name,
802                        kind: DocItemKind::Property,
803                        doc: if prop_doc.is_empty() { None } else { Some(prop_doc) },
804                        source_path: self.file_path.to_string(),
805                        line: prop_line,
806                        end_line: prop_end_line,
807                        column: self.column_number(prop.span.start),
808                        jsdoc: Some(prop_jsdoc),
809                        exported: false,
810                        signature: type_annotation,
811                        params: Vec::new(),
812                        return_type: None,
813                        children: Vec::new(),
814                        tags: prop_tags,
815                    });
816                }
817                _ => {}
818            }
819        }
820
821        Some(DocItem {
822            name: name.to_string(),
823            kind: DocItemKind::Class,
824            doc: if doc.is_empty() { None } else { Some(doc) },
825            source_path: self.file_path.to_string(),
826            line,
827            end_line,
828            column: self.column_number(attached_to),
829            jsdoc: Some(jsdoc),
830            exported,
831            signature: Some(self.format_class_signature(class, name, exported)),
832            params: Vec::new(),
833            return_type: None,
834            children,
835            tags,
836        })
837    }
838}
839
840impl<'a> Visit<'a> for DocVisitor<'a> {
841    fn visit_statement(&mut self, stmt: &Statement<'a>) {
842        match stmt {
843            Statement::ExportNamedDeclaration(export) => {
844                if let Some(ref decl) = export.declaration {
845                    self.visit_declaration_as_exported(decl, export.span.start);
846                }
847            }
848            Statement::ExportDefaultDeclaration(export) => {
849                self.has_default_export = true;
850                match &export.declaration {
851                    ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
852                        if let Some(item) = self.create_function_item(func, true, export.span.start)
853                        {
854                            self.items.push(item);
855                        }
856                    }
857                    ExportDefaultDeclarationKind::ClassDeclaration(class) => {
858                        let name = class
859                            .id
860                            .as_ref()
861                            .map_or_else(|| "default".to_string(), |id| id.name.to_string());
862                        if let Some(item) =
863                            self.create_class_item(class, &name, true, export.span.start)
864                        {
865                            self.items.push(item);
866                        }
867                    }
868                    _ => {}
869                }
870            }
871            _ => {
872                walk::walk_statement(self, stmt);
873            }
874        }
875    }
876
877    fn visit_declaration(&mut self, decl: &Declaration<'a>) {
878        // Only visit non-exported declarations
879        self.visit_declaration_internal(decl, false, decl.span().start);
880    }
881}
882
883impl<'a> DocVisitor<'a> {
884    fn visit_declaration_as_exported(&mut self, decl: &Declaration<'a>, attached_to: u32) {
885        self.visit_declaration_internal(decl, true, attached_to);
886    }
887
888    fn visit_declaration_internal(
889        &mut self,
890        decl: &Declaration<'a>,
891        exported: bool,
892        attached_to: u32,
893    ) {
894        match decl {
895            Declaration::FunctionDeclaration(func) => {
896                if let Some(item) = self.create_function_item(func, exported, attached_to) {
897                    self.items.push(item);
898                }
899            }
900            Declaration::ClassDeclaration(class) => {
901                if let Some(id) = &class.id {
902                    let name = id.name.to_string();
903                    if let Some(item) = self.create_class_item(class, &name, exported, attached_to)
904                    {
905                        self.items.push(item);
906                    }
907                }
908            }
909            Declaration::VariableDeclaration(var_decl) => {
910                let Some((jsdoc, doc, tags)) = self.extract_jsdoc(attached_to) else {
911                    return;
912                };
913                if !self.include_private && Self::has_private_tag(&tags) {
914                    return;
915                }
916                let (line, end_line) = self.span_lines(attached_to, var_decl.span.end);
917
918                for declarator in &var_decl.declarations {
919                    if let BindingPatternKind::BindingIdentifier(id) = &declarator.id.kind {
920                        let name = id.name.to_string();
921
922                        let Some(initializer) = &declarator.init else {
923                            continue;
924                        };
925
926                        match initializer {
927                            Expression::ArrowFunctionExpression(arrow) => {
928                                self.items.push(DocItem {
929                                    name: name.clone(),
930                                    kind: DocItemKind::Function,
931                                    doc: if doc.is_empty() { None } else { Some(doc.clone()) },
932                                    source_path: self.file_path.to_string(),
933                                    line,
934                                    end_line,
935                                    column: self.column_number(attached_to),
936                                    jsdoc: Some(jsdoc.clone()),
937                                    exported,
938                                    signature: Some(self.format_assigned_function_signature(
939                                        &name,
940                                        arrow.r#async,
941                                        arrow.type_parameters.as_ref(),
942                                        &arrow.params,
943                                        arrow.return_type.as_ref(),
944                                    )),
945                                    params: self.extract_params_from_formals(&arrow.params, &tags),
946                                    return_type: self.extract_return_type_from_annotation(
947                                        arrow.return_type.as_ref(),
948                                        &tags,
949                                    ),
950                                    children: Vec::new(),
951                                    tags: tags.clone(),
952                                });
953                            }
954                            Expression::FunctionExpression(func_expr) => {
955                                self.items.push(DocItem {
956                                    name: name.clone(),
957                                    kind: DocItemKind::Function,
958                                    doc: if doc.is_empty() { None } else { Some(doc.clone()) },
959                                    source_path: self.file_path.to_string(),
960                                    line,
961                                    end_line,
962                                    column: self.column_number(attached_to),
963                                    jsdoc: Some(jsdoc.clone()),
964                                    exported,
965                                    signature: Some(self.format_assigned_function_signature(
966                                        &name,
967                                        func_expr.r#async,
968                                        func_expr.type_parameters.as_ref(),
969                                        &func_expr.params,
970                                        func_expr.return_type.as_ref(),
971                                    )),
972                                    params: self.extract_params(func_expr, &tags),
973                                    return_type: self.extract_return_type(func_expr, &tags),
974                                    children: Vec::new(),
975                                    tags: tags.clone(),
976                                });
977                            }
978                            _ => {}
979                        }
980                    }
981                }
982            }
983            Declaration::TSTypeAliasDeclaration(type_alias) => {
984                let Some((jsdoc, doc, tags)) = self.extract_jsdoc(attached_to) else {
985                    return;
986                };
987                if !self.include_private && Self::has_private_tag(&tags) {
988                    return;
989                }
990                let (line, end_line) = self.span_lines(attached_to, type_alias.span.end);
991
992                self.items.push(DocItem {
993                    name: type_alias.id.name.to_string(),
994                    kind: DocItemKind::Type,
995                    doc: if doc.is_empty() { None } else { Some(doc) },
996                    source_path: self.file_path.to_string(),
997                    line,
998                    end_line,
999                    column: self.column_number(attached_to),
1000                    jsdoc: Some(jsdoc),
1001                    exported,
1002                    signature: Some(self.format_type_alias_signature(type_alias, exported)),
1003                    params: Vec::new(),
1004                    return_type: None,
1005                    children: Vec::new(),
1006                    tags,
1007                });
1008            }
1009            Declaration::TSInterfaceDeclaration(interface) => {
1010                let Some((jsdoc, doc, tags)) = self.extract_jsdoc(attached_to) else {
1011                    return;
1012                };
1013                if !self.include_private && Self::has_private_tag(&tags) {
1014                    return;
1015                }
1016                let (line, end_line) = self.span_lines(attached_to, interface.span.end);
1017
1018                let mut children = Vec::new();
1019
1020                // Extract interface members
1021                for sig in &interface.body.body {
1022                    match sig {
1023                        TSSignature::TSPropertySignature(prop) => {
1024                            let prop_name = match &prop.key {
1025                                oxc_ast::ast::PropertyKey::StaticIdentifier(id) => {
1026                                    id.name.to_string()
1027                                }
1028                                _ => continue,
1029                            };
1030
1031                            let Some((prop_jsdoc, prop_doc, prop_tags)) =
1032                                self.extract_jsdoc(prop.span.start)
1033                            else {
1034                                continue;
1035                            };
1036                            if !self.include_private && Self::has_private_tag(&prop_tags) {
1037                                continue;
1038                            }
1039                            let (prop_line, prop_end_line) =
1040                                self.span_lines(prop.span.start, prop.span.end);
1041
1042                            let type_annotation = prop
1043                                .type_annotation
1044                                .as_ref()
1045                                .map(|t| self.format_ts_type(&t.type_annotation));
1046
1047                            children.push(DocItem {
1048                                name: prop_name,
1049                                kind: DocItemKind::Property,
1050                                doc: if prop_doc.is_empty() { None } else { Some(prop_doc) },
1051                                source_path: self.file_path.to_string(),
1052                                line: prop_line,
1053                                end_line: prop_end_line,
1054                                column: self.column_number(prop.span.start),
1055                                jsdoc: Some(prop_jsdoc),
1056                                exported: false,
1057                                signature: type_annotation,
1058                                params: Vec::new(),
1059                                return_type: None,
1060                                children: Vec::new(),
1061                                tags: prop_tags,
1062                            });
1063                        }
1064                        TSSignature::TSMethodSignature(method) => {
1065                            let method_name = match &method.key {
1066                                oxc_ast::ast::PropertyKey::StaticIdentifier(id) => {
1067                                    id.name.to_string()
1068                                }
1069                                _ => continue,
1070                            };
1071
1072                            let Some((method_jsdoc, method_doc, method_tags)) =
1073                                self.extract_jsdoc(method.span.start)
1074                            else {
1075                                continue;
1076                            };
1077                            if !self.include_private && Self::has_private_tag(&method_tags) {
1078                                continue;
1079                            }
1080                            let (method_line, method_end_line) =
1081                                self.span_lines(method.span.start, method.span.end);
1082
1083                            children.push(DocItem {
1084                                name: method_name.clone(),
1085                                kind: DocItemKind::Method,
1086                                doc: if method_doc.is_empty() { None } else { Some(method_doc) },
1087                                source_path: self.file_path.to_string(),
1088                                line: method_line,
1089                                end_line: method_end_line,
1090                                column: self.column_number(method.span.start),
1091                                jsdoc: Some(method_jsdoc),
1092                                exported: false,
1093                                signature: Some(self.format_assigned_function_signature(
1094                                    &method_name,
1095                                    false,
1096                                    method.type_parameters.as_ref(),
1097                                    &method.params,
1098                                    method.return_type.as_ref(),
1099                                )),
1100                                params: self
1101                                    .extract_params_from_formals(&method.params, &method_tags),
1102                                return_type: self.extract_return_type_from_annotation(
1103                                    method.return_type.as_ref(),
1104                                    &method_tags,
1105                                ),
1106                                children: Vec::new(),
1107                                tags: method_tags,
1108                            });
1109                        }
1110                        _ => {}
1111                    }
1112                }
1113
1114                self.items.push(DocItem {
1115                    name: interface.id.name.to_string(),
1116                    kind: DocItemKind::Interface,
1117                    doc: if doc.is_empty() { None } else { Some(doc) },
1118                    source_path: self.file_path.to_string(),
1119                    line,
1120                    end_line,
1121                    column: self.column_number(attached_to),
1122                    jsdoc: Some(jsdoc),
1123                    exported,
1124                    signature: Some(self.format_interface_signature(interface, exported)),
1125                    params: Vec::new(),
1126                    return_type: None,
1127                    children,
1128                    tags,
1129                });
1130            }
1131            Declaration::TSEnumDeclaration(enum_decl) => {
1132                let Some((jsdoc, doc, tags)) = self.extract_jsdoc(attached_to) else {
1133                    return;
1134                };
1135                if !self.include_private && Self::has_private_tag(&tags) {
1136                    return;
1137                }
1138                let (line, end_line) = self.span_lines(attached_to, enum_decl.span.end);
1139
1140                let children: Vec<DocItem> = enum_decl
1141                    .members
1142                    .iter()
1143                    .map(|member| {
1144                        let member_name = match &member.id {
1145                            oxc_ast::ast::TSEnumMemberName::Identifier(id) => id.name.to_string(),
1146                            oxc_ast::ast::TSEnumMemberName::String(s) => s.value.to_string(),
1147                        };
1148                        let (member_line, member_end_line) =
1149                            self.span_lines(member.span.start, member.span.end);
1150                        DocItem {
1151                            name: member_name,
1152                            kind: DocItemKind::Property,
1153                            doc: None,
1154                            source_path: self.file_path.to_string(),
1155                            line: member_line,
1156                            end_line: member_end_line,
1157                            column: self.column_number(member.span.start),
1158                            jsdoc: None,
1159                            exported: false,
1160                            signature: None,
1161                            params: Vec::new(),
1162                            return_type: None,
1163                            children: Vec::new(),
1164                            tags: Vec::new(),
1165                        }
1166                    })
1167                    .collect();
1168
1169                self.items.push(DocItem {
1170                    name: enum_decl.id.name.to_string(),
1171                    kind: DocItemKind::Enum,
1172                    doc: if doc.is_empty() { None } else { Some(doc) },
1173                    source_path: self.file_path.to_string(),
1174                    line,
1175                    end_line,
1176                    column: self.column_number(attached_to),
1177                    jsdoc: Some(jsdoc),
1178                    exported,
1179                    signature: None,
1180                    params: Vec::new(),
1181                    return_type: None,
1182                    children,
1183                    tags,
1184                });
1185            }
1186            _ => {}
1187        }
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194
1195    #[test]
1196    fn test_extract_function() {
1197        let source = r"
1198/**
1199 * Adds two numbers together.
1200 * @param a - The first number
1201 * @param b - The second number
1202 * @returns The sum of a and b
1203 */
1204export function add(a: number, b: number): number {
1205    return a + b;
1206}
1207";
1208
1209        let extractor = DocExtractor::new();
1210        let items = extractor.extract_source(source, "test.ts", SourceType::ts()).unwrap();
1211
1212        assert_eq!(items.len(), 1);
1213        assert_eq!(items[0].name, "add");
1214        assert_eq!(items[0].kind, DocItemKind::Function);
1215        assert!(items[0].exported);
1216        assert!(items[0].doc.as_ref().unwrap().contains("Adds two numbers"));
1217        assert_eq!(items[0].params.len(), 2);
1218    }
1219
1220    #[test]
1221    fn test_extract_interface() {
1222        let source = r"
1223/**
1224 * User interface.
1225 */
1226export interface User {
1227    /** User's name */
1228    name: string;
1229    /** User's age */
1230    age: number;
1231}
1232";
1233
1234        let extractor = DocExtractor::new();
1235        let items = extractor.extract_source(source, "test.ts", SourceType::ts()).unwrap();
1236
1237        assert_eq!(items.len(), 1);
1238        assert_eq!(items[0].name, "User");
1239        assert_eq!(items[0].kind, DocItemKind::Interface);
1240        assert_eq!(items[0].children.len(), 2);
1241    }
1242}