1use 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
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 column: u32,
50 pub exported: bool,
52 pub signature: Option<String>,
54 pub params: Vec<ParamDoc>,
56 pub return_type: Option<String>,
58 pub children: Vec<DocItem>,
60 pub tags: Vec<DocTag>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ParamDoc {
67 pub name: String,
69 pub type_annotation: Option<String>,
71 pub optional: bool,
73 pub default_value: Option<String>,
75 pub description: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct DocTag {
82 pub tag: String,
84 pub value: String,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum DocItemKind {
92 Module,
94 Function,
96 Class,
98 Interface,
100 Type,
102 Enum,
104 Variable,
106 Method,
108 Property,
110 Constructor,
112 Getter,
114 Setter,
116}
117
118pub struct DocExtractor {
120 include_private: bool,
122}
123
124impl DocExtractor {
125 #[must_use]
127 pub fn new() -> Self {
128 Self { include_private: false }
129 }
130
131 #[must_use]
133 pub fn with_private(include_private: bool) -> Self {
134 Self { include_private }
135 }
136
137 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 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 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
189struct DocVisitor<'a> {
191 source: &'a str,
192 file_path: &'a str,
193 include_private: bool,
194 items: Vec<DocItem>,
195 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 fn extract_jsdoc(&self, start: u32) -> Option<(String, Vec<DocTag>)> {
208 let source_before = &self.source[..start as usize];
209
210 if let Some(end) = source_before.rfind("*/") {
212 let remaining = &source_before[..end];
213 if let Some(start_idx) = remaining.rfind("/**") {
214 let between = &source_before[end + 2..];
217 let between_trimmed = between.trim();
218
219 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 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 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 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 if let Some((tag, value)) = current_tag.take() {
271 tags.push(DocTag { tag, value });
272 }
273
274 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 if !value.is_empty() {
282 value.push(' ');
283 }
284 value.push_str(line);
285 } else {
286 if !description.is_empty() {
288 description.push(' ');
289 }
290 description.push_str(line);
291 }
292 }
293
294 if let Some((tag, value)) = current_tag {
296 tags.push(DocTag { tag, value });
297 }
298
299 (description, tags)
300 }
301
302 fn format_function_signature(&self, func: &Function) -> String {
304 let mut sig = String::new();
305
306 if let Some(id) = &func.id {
308 sig.push_str(id.name.as_str());
309 }
310
311 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(¶ms.join(", "));
317 sig.push('>');
318 }
319
320 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(¶ms.join(", "));
325 sig.push(')');
326
327 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 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 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 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 fn extract_params(&self, func: &Function, tags: &[DocTag]) -> Vec<ParamDoc> {
428 func.params
429 .items
430 .iter()
431 .map(|param| {
432 let name = match ¶m.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 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 fn create_function_item(&self, func: &Function, exported: bool) -> Option<DocItem> {
472 let name = func.id.as_ref()?.name.to_string();
473
474 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 fn create_class_item(&self, class: &Class, name: &str, exported: bool) -> Option<DocItem> {
500 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 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 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 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}