1use 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
16pub type ExtractResult<T> = Result<T, ExtractError>;
18
19#[derive(Debug, Error)]
21pub enum ExtractError {
22 #[error("IO error: {0}")]
24 Io(#[from] std::io::Error),
25
26 #[error("Parse error: {0}")]
28 Parse(String),
29
30 #[error("Unsupported file type: {0}")]
32 UnsupportedFile(String),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DocItem {
38 pub name: String,
40 pub kind: DocItemKind,
42 pub doc: Option<String>,
44 pub source_path: String,
46 pub line: u32,
48 pub end_line: u32,
50 pub column: u32,
52 pub jsdoc: Option<String>,
54 pub exported: bool,
56 pub signature: Option<String>,
58 pub params: Vec<ParamDoc>,
60 pub return_type: Option<String>,
62 pub children: Vec<DocItem>,
64 pub tags: Vec<DocTag>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ParamDoc {
71 pub name: String,
73 pub type_annotation: Option<String>,
75 pub optional: bool,
77 pub default_value: Option<String>,
79 pub description: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DocTag {
86 pub tag: String,
88 pub value: String,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum DocItemKind {
96 Module,
98 Function,
100 Class,
102 Interface,
104 Type,
106 Enum,
108 Variable,
110 Method,
112 Property,
114 Constructor,
116 Getter,
118 Setter,
120}
121
122pub struct DocExtractor {
124 include_private: bool,
126}
127
128impl DocExtractor {
129 #[must_use]
131 pub fn new() -> Self {
132 Self { include_private: false }
133 }
134
135 #[must_use]
137 pub fn with_private(include_private: bool) -> Self {
138 Self { include_private }
139 }
140
141 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 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 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
198struct 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 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 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 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 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) = ¶ms.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 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 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 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 ¶m.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 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 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 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 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 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 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 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}