diff options
Diffstat (limited to 'cli/util/extract.rs')
-rw-r--r-- | cli/util/extract.rs | 1410 |
1 files changed, 1410 insertions, 0 deletions
diff --git a/cli/util/extract.rs b/cli/util/extract.rs new file mode 100644 index 000000000..e27a79347 --- /dev/null +++ b/cli/util/extract.rs @@ -0,0 +1,1410 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use deno_ast::swc::ast; +use deno_ast::swc::atoms::Atom; +use deno_ast::swc::common::collections::AHashSet; +use deno_ast::swc::common::comments::CommentKind; +use deno_ast::swc::common::DUMMY_SP; +use deno_ast::swc::utils as swc_utils; +use deno_ast::swc::visit::as_folder; +use deno_ast::swc::visit::FoldWith as _; +use deno_ast::swc::visit::Visit; +use deno_ast::swc::visit::VisitMut; +use deno_ast::swc::visit::VisitWith as _; +use deno_ast::MediaType; +use deno_ast::SourceRangedForSpanned as _; +use deno_core::error::AnyError; +use deno_core::ModuleSpecifier; +use regex::Regex; +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::sync::Arc; + +use crate::file_fetcher::File; +use crate::util::path::mapped_specifier_for_tsc; + +/// Extracts doc tests from a given file, transforms them into pseudo test +/// files by wrapping the content of the doc tests in a `Deno.test` call, and +/// returns a list of the pseudo test files. +/// +/// The difference from [`extract_snippet_files`] is that this function wraps +/// extracted code snippets in a `Deno.test` call. +pub fn extract_doc_tests(file: File) -> Result<Vec<File>, AnyError> { + extract_inner(file, WrapKind::DenoTest) +} + +/// Extracts code snippets from a given file and returns a list of the extracted +/// files. +/// +/// The difference from [`extract_doc_tests`] is that this function does *not* +/// wrap extracted code snippets in a `Deno.test` call. +pub fn extract_snippet_files(file: File) -> Result<Vec<File>, AnyError> { + extract_inner(file, WrapKind::NoWrap) +} + +#[derive(Clone, Copy)] +enum WrapKind { + DenoTest, + NoWrap, +} + +fn extract_inner( + file: File, + wrap_kind: WrapKind, +) -> Result<Vec<File>, AnyError> { + let file = file.into_text_decoded()?; + + let exports = match deno_ast::parse_program(deno_ast::ParseParams { + specifier: file.specifier.clone(), + text: file.source.clone(), + media_type: file.media_type, + capture_tokens: false, + scope_analysis: false, + maybe_syntax: None, + }) { + Ok(parsed) => { + let mut c = ExportCollector::default(); + c.visit_program(parsed.program_ref()); + c + } + Err(_) => ExportCollector::default(), + }; + + let extracted_files = if file.media_type == MediaType::Unknown { + extract_files_from_fenced_blocks( + &file.specifier, + &file.source, + file.media_type, + )? + } else { + extract_files_from_source_comments( + &file.specifier, + file.source.clone(), + file.media_type, + )? + }; + + extracted_files + .into_iter() + .map(|extracted_file| { + generate_pseudo_file(extracted_file, &file.specifier, &exports, wrap_kind) + }) + .collect::<Result<_, _>>() +} + +fn extract_files_from_fenced_blocks( + specifier: &ModuleSpecifier, + source: &str, + media_type: MediaType, +) -> Result<Vec<File>, AnyError> { + // The pattern matches code blocks as well as anything in HTML comment syntax, + // but it stores the latter without any capturing groups. This way, a simple + // check can be done to see if a block is inside a comment (and skip typechecking) + // or not by checking for the presence of capturing groups in the matches. + let blocks_regex = + lazy_regex::regex!(r"(?s)<!--.*?-->|```([^\r\n]*)\r?\n([\S\s]*?)```"); + let lines_regex = lazy_regex::regex!(r"(?:\# ?)?(.*)"); + + extract_files_from_regex_blocks( + specifier, + source, + media_type, + /* file line index */ 0, + blocks_regex, + lines_regex, + ) +} + +fn extract_files_from_source_comments( + specifier: &ModuleSpecifier, + source: Arc<str>, + media_type: MediaType, +) -> Result<Vec<File>, AnyError> { + let parsed_source = deno_ast::parse_module(deno_ast::ParseParams { + specifier: specifier.clone(), + text: source, + media_type, + capture_tokens: false, + maybe_syntax: None, + scope_analysis: false, + })?; + let comments = parsed_source.comments().get_vec(); + let blocks_regex = lazy_regex::regex!(r"```([^\r\n]*)\r?\n([\S\s]*?)```"); + let lines_regex = lazy_regex::regex!(r"(?:\* ?)(?:\# ?)?(.*)"); + + let files = comments + .iter() + .filter(|comment| { + if comment.kind != CommentKind::Block || !comment.text.starts_with('*') { + return false; + } + + true + }) + .flat_map(|comment| { + extract_files_from_regex_blocks( + specifier, + &comment.text, + media_type, + parsed_source.text_info_lazy().line_index(comment.start()), + blocks_regex, + lines_regex, + ) + }) + .flatten() + .collect(); + + Ok(files) +} + +fn extract_files_from_regex_blocks( + specifier: &ModuleSpecifier, + source: &str, + media_type: MediaType, + file_line_index: usize, + blocks_regex: &Regex, + lines_regex: &Regex, +) -> Result<Vec<File>, AnyError> { + let files = blocks_regex + .captures_iter(source) + .filter_map(|block| { + block.get(1)?; + + let maybe_attributes: Option<Vec<_>> = block + .get(1) + .map(|attributes| attributes.as_str().split(' ').collect()); + + let file_media_type = if let Some(attributes) = maybe_attributes { + if attributes.contains(&"ignore") { + return None; + } + + match attributes.first() { + Some(&"js") => MediaType::JavaScript, + Some(&"javascript") => MediaType::JavaScript, + Some(&"mjs") => MediaType::Mjs, + Some(&"cjs") => MediaType::Cjs, + Some(&"jsx") => MediaType::Jsx, + Some(&"ts") => MediaType::TypeScript, + Some(&"typescript") => MediaType::TypeScript, + Some(&"mts") => MediaType::Mts, + Some(&"cts") => MediaType::Cts, + Some(&"tsx") => MediaType::Tsx, + _ => MediaType::Unknown, + } + } else { + media_type + }; + + if file_media_type == MediaType::Unknown { + return None; + } + + let line_offset = source[0..block.get(0).unwrap().start()] + .chars() + .filter(|c| *c == '\n') + .count(); + + let line_count = block.get(0).unwrap().as_str().split('\n').count(); + + let body = block.get(2).unwrap(); + let text = body.as_str(); + + // TODO(caspervonb) generate an inline source map + let mut file_source = String::new(); + for line in lines_regex.captures_iter(text) { + let text = line.get(1).unwrap(); + writeln!(file_source, "{}", text.as_str()).unwrap(); + } + + let file_specifier = ModuleSpecifier::parse(&format!( + "{}${}-{}", + specifier, + file_line_index + line_offset + 1, + file_line_index + line_offset + line_count + 1, + )) + .unwrap(); + let file_specifier = + mapped_specifier_for_tsc(&file_specifier, file_media_type) + .map(|s| ModuleSpecifier::parse(&s).unwrap()) + .unwrap_or(file_specifier); + + Some(File { + specifier: file_specifier, + maybe_headers: None, + source: file_source.into_bytes().into(), + }) + }) + .collect(); + + Ok(files) +} + +#[derive(Default)] +struct ExportCollector { + named_exports: BTreeSet<Atom>, + default_export: Option<Atom>, +} + +impl ExportCollector { + fn to_import_specifiers( + &self, + symbols_to_exclude: &AHashSet<Atom>, + ) -> Vec<ast::ImportSpecifier> { + let mut import_specifiers = vec![]; + + if let Some(default_export) = &self.default_export { + if !symbols_to_exclude.contains(default_export) { + import_specifiers.push(ast::ImportSpecifier::Default( + ast::ImportDefaultSpecifier { + span: DUMMY_SP, + local: ast::Ident { + span: DUMMY_SP, + ctxt: Default::default(), + sym: default_export.clone(), + optional: false, + }, + }, + )); + } + } + + for named_export in &self.named_exports { + if symbols_to_exclude.contains(named_export) { + continue; + } + + import_specifiers.push(ast::ImportSpecifier::Named( + ast::ImportNamedSpecifier { + span: DUMMY_SP, + local: ast::Ident { + span: DUMMY_SP, + ctxt: Default::default(), + sym: named_export.clone(), + optional: false, + }, + imported: None, + is_type_only: false, + }, + )); + } + + import_specifiers + } +} + +impl Visit for ExportCollector { + fn visit_ts_module_decl(&mut self, ts_module_decl: &ast::TsModuleDecl) { + if ts_module_decl.declare { + return; + } + + ts_module_decl.visit_children_with(self); + } + + fn visit_export_decl(&mut self, export_decl: &ast::ExportDecl) { + match &export_decl.decl { + ast::Decl::Class(class) => { + self.named_exports.insert(class.ident.sym.clone()); + } + ast::Decl::Fn(func) => { + self.named_exports.insert(func.ident.sym.clone()); + } + ast::Decl::Var(var) => { + for var_decl in &var.decls { + let atoms = extract_sym_from_pat(&var_decl.name); + self.named_exports.extend(atoms); + } + } + ast::Decl::TsEnum(ts_enum) => { + self.named_exports.insert(ts_enum.id.sym.clone()); + } + ast::Decl::TsModule(ts_module) => { + if ts_module.declare { + return; + } + + match &ts_module.id { + ast::TsModuleName::Ident(ident) => { + self.named_exports.insert(ident.sym.clone()); + } + ast::TsModuleName::Str(s) => { + self.named_exports.insert(s.value.clone()); + } + } + } + ast::Decl::TsTypeAlias(ts_type_alias) => { + self.named_exports.insert(ts_type_alias.id.sym.clone()); + } + ast::Decl::TsInterface(ts_interface) => { + self.named_exports.insert(ts_interface.id.sym.clone()); + } + ast::Decl::Using(_) => {} + } + } + + fn visit_export_default_decl( + &mut self, + export_default_decl: &ast::ExportDefaultDecl, + ) { + match &export_default_decl.decl { + ast::DefaultDecl::Class(class) => { + if let Some(ident) = &class.ident { + self.default_export = Some(ident.sym.clone()); + } + } + ast::DefaultDecl::Fn(func) => { + if let Some(ident) = &func.ident { + self.default_export = Some(ident.sym.clone()); + } + } + ast::DefaultDecl::TsInterfaceDecl(_) => {} + } + } + + fn visit_export_named_specifier( + &mut self, + export_named_specifier: &ast::ExportNamedSpecifier, + ) { + fn get_atom(export_name: &ast::ModuleExportName) -> Atom { + match export_name { + ast::ModuleExportName::Ident(ident) => ident.sym.clone(), + ast::ModuleExportName::Str(s) => s.value.clone(), + } + } + + match &export_named_specifier.exported { + Some(exported) => { + self.named_exports.insert(get_atom(exported)); + } + None => { + self + .named_exports + .insert(get_atom(&export_named_specifier.orig)); + } + } + } + + fn visit_named_export(&mut self, named_export: &ast::NamedExport) { + // ExportCollector does not handle re-exports + if named_export.src.is_some() { + return; + } + + named_export.visit_children_with(self); + } +} + +fn extract_sym_from_pat(pat: &ast::Pat) -> Vec<Atom> { + fn rec(pat: &ast::Pat, atoms: &mut Vec<Atom>) { + match pat { + ast::Pat::Ident(binding_ident) => { + atoms.push(binding_ident.sym.clone()); + } + ast::Pat::Array(array_pat) => { + for elem in array_pat.elems.iter().flatten() { + rec(elem, atoms); + } + } + ast::Pat::Rest(rest_pat) => { + rec(&rest_pat.arg, atoms); + } + ast::Pat::Object(object_pat) => { + for prop in &object_pat.props { + match prop { + ast::ObjectPatProp::Assign(assign_pat_prop) => { + atoms.push(assign_pat_prop.key.sym.clone()); + } + ast::ObjectPatProp::KeyValue(key_value_pat_prop) => { + rec(&key_value_pat_prop.value, atoms); + } + ast::ObjectPatProp::Rest(rest_pat) => { + rec(&rest_pat.arg, atoms); + } + } + } + } + ast::Pat::Assign(assign_pat) => { + rec(&assign_pat.left, atoms); + } + ast::Pat::Invalid(_) | ast::Pat::Expr(_) => {} + } + } + + let mut atoms = vec![]; + rec(pat, &mut atoms); + atoms +} + +/// Generates a "pseudo" file from a given file by applying the following +/// transformations: +/// +/// 1. Injects `import` statements for expoted items from the base file +/// 2. If `wrap_kind` is [`WrapKind::DenoTest`], wraps the content of the file +/// in a `Deno.test` call. +/// +/// For example, given a file that looks like: +/// +/// ```ts +/// import { assertEquals } from "@std/assert/equals"; +/// +/// assertEquals(increment(1), 2); +/// ``` +/// +/// and the base file (from which the above snippet was extracted): +/// +/// ```ts +/// export function increment(n: number): number { +/// return n + 1; +/// } +/// +/// export const SOME_CONST = "HELLO"; +/// ``` +/// +/// The generated pseudo test file would look like (if `wrap_in_deno_test` is enabled): +/// +/// ```ts +/// import { assertEquals } from "@std/assert/equals"; +/// import { increment, SOME_CONST } from "./base.ts"; +/// +/// Deno.test("./base.ts$1-3.ts", async () => { +/// assertEquals(increment(1), 2); +/// }); +/// ``` +/// +/// # Edge case - duplicate identifier +/// +/// If a given file imports, say, `doSomething` from an external module while +/// the base file exports `doSomething` as well, the generated pseudo test file +/// would end up having two duplciate imports for `doSomething`, causing the +/// duplicate identifier error. +/// +/// To avoid this issue, when a given file imports `doSomething`, this takes +/// precedence over the automatic import injection for the base file's +/// `doSomething`. So the generated pseudo test file would look like: +/// +/// ```ts +/// import { assertEquals } from "@std/assert/equals"; +/// import { doSomething } from "./some_external_module.ts"; +/// +/// Deno.test("./base.ts$1-3.ts", async () => { +/// assertEquals(doSomething(1), 2); +/// }); +/// ``` +fn generate_pseudo_file( + file: File, + base_file_specifier: &ModuleSpecifier, + exports: &ExportCollector, + wrap_kind: WrapKind, +) -> Result<File, AnyError> { + let file = file.into_text_decoded()?; + + let parsed = deno_ast::parse_program(deno_ast::ParseParams { + specifier: file.specifier.clone(), + text: file.source, + media_type: file.media_type, + capture_tokens: false, + scope_analysis: true, + maybe_syntax: None, + })?; + + let top_level_atoms = swc_utils::collect_decls_with_ctxt::<Atom, _>( + parsed.program_ref(), + parsed.top_level_context(), + ); + + let transformed = + parsed + .program_ref() + .clone() + .fold_with(&mut as_folder(Transform { + specifier: &file.specifier, + base_file_specifier, + exports_from_base: exports, + atoms_to_be_excluded_from_import: top_level_atoms, + wrap_kind, + })); + + let source = deno_ast::swc::codegen::to_code(&transformed); + + log::debug!("{}:\n{}", file.specifier, source); + + Ok(File { + specifier: file.specifier, + maybe_headers: None, + source: source.into_bytes().into(), + }) +} + +struct Transform<'a> { + specifier: &'a ModuleSpecifier, + base_file_specifier: &'a ModuleSpecifier, + exports_from_base: &'a ExportCollector, + atoms_to_be_excluded_from_import: AHashSet<Atom>, + wrap_kind: WrapKind, +} + +impl<'a> VisitMut for Transform<'a> { + fn visit_mut_program(&mut self, node: &mut ast::Program) { + let new_module_items = match node { + ast::Program::Module(module) => { + let mut module_decls = vec![]; + let mut stmts = vec![]; + + for item in &module.body { + match item { + ast::ModuleItem::ModuleDecl(decl) => { + module_decls.push(decl.clone()); + } + ast::ModuleItem::Stmt(stmt) => { + stmts.push(stmt.clone()); + } + } + } + + let mut transformed_items = vec![]; + transformed_items + .extend(module_decls.into_iter().map(ast::ModuleItem::ModuleDecl)); + let import_specifiers = self + .exports_from_base + .to_import_specifiers(&self.atoms_to_be_excluded_from_import); + if !import_specifiers.is_empty() { + transformed_items.push(ast::ModuleItem::ModuleDecl( + ast::ModuleDecl::Import(ast::ImportDecl { + span: DUMMY_SP, + specifiers: import_specifiers, + src: Box::new(ast::Str { + span: DUMMY_SP, + value: self.base_file_specifier.to_string().into(), + raw: None, + }), + type_only: false, + with: None, + phase: ast::ImportPhase::Evaluation, + }), + )); + } + match self.wrap_kind { + WrapKind::DenoTest => { + transformed_items.push(ast::ModuleItem::Stmt(wrap_in_deno_test( + stmts, + self.specifier.to_string().into(), + ))); + } + WrapKind::NoWrap => { + transformed_items + .extend(stmts.into_iter().map(ast::ModuleItem::Stmt)); + } + } + + transformed_items + } + ast::Program::Script(script) => { + let mut transformed_items = vec![]; + + let import_specifiers = self + .exports_from_base + .to_import_specifiers(&self.atoms_to_be_excluded_from_import); + if !import_specifiers.is_empty() { + transformed_items.push(ast::ModuleItem::ModuleDecl( + ast::ModuleDecl::Import(ast::ImportDecl { + span: DUMMY_SP, + specifiers: import_specifiers, + src: Box::new(ast::Str { + span: DUMMY_SP, + value: self.base_file_specifier.to_string().into(), + raw: None, + }), + type_only: false, + with: None, + phase: ast::ImportPhase::Evaluation, + }), + )); + } + + match self.wrap_kind { + WrapKind::DenoTest => { + transformed_items.push(ast::ModuleItem::Stmt(wrap_in_deno_test( + script.body.clone(), + self.specifier.to_string().into(), + ))); + } + WrapKind::NoWrap => { + transformed_items.extend( + script.body.clone().into_iter().map(ast::ModuleItem::Stmt), + ); + } + } + + transformed_items + } + }; + + *node = ast::Program::Module(ast::Module { + span: DUMMY_SP, + body: new_module_items, + shebang: None, + }); + } +} + +fn wrap_in_deno_test(stmts: Vec<ast::Stmt>, test_name: Atom) -> ast::Stmt { + ast::Stmt::Expr(ast::ExprStmt { + span: DUMMY_SP, + expr: Box::new(ast::Expr::Call(ast::CallExpr { + span: DUMMY_SP, + callee: ast::Callee::Expr(Box::new(ast::Expr::Member(ast::MemberExpr { + span: DUMMY_SP, + obj: Box::new(ast::Expr::Ident(ast::Ident { + span: DUMMY_SP, + sym: "Deno".into(), + optional: false, + ..Default::default() + })), + prop: ast::MemberProp::Ident(ast::IdentName { + span: DUMMY_SP, + sym: "test".into(), + }), + }))), + args: vec![ + ast::ExprOrSpread { + spread: None, + expr: Box::new(ast::Expr::Lit(ast::Lit::Str(ast::Str { + span: DUMMY_SP, + value: test_name, + raw: None, + }))), + }, + ast::ExprOrSpread { + spread: None, + expr: Box::new(ast::Expr::Arrow(ast::ArrowExpr { + span: DUMMY_SP, + params: vec![], + body: Box::new(ast::BlockStmtOrExpr::BlockStmt(ast::BlockStmt { + span: DUMMY_SP, + stmts, + ..Default::default() + })), + is_async: true, + is_generator: false, + type_params: None, + return_type: None, + ..Default::default() + })), + }, + ], + type_args: None, + ..Default::default() + })), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::file_fetcher::TextDecodedFile; + use deno_ast::swc::atoms::Atom; + use pretty_assertions::assert_eq; + + #[test] + fn test_extract_doc_tests() { + struct Input { + source: &'static str, + specifier: &'static str, + } + struct Expected { + source: &'static str, + specifier: &'static str, + media_type: MediaType, + } + struct Test { + input: Input, + expected: Vec<Expected>, + } + + let tests = [ + Test { + input: Input { + source: r#""#, + specifier: "file:///main.ts", + }, + expected: vec![], + }, + Test { + input: Input { + source: r#" +/** + * ```ts + * import { assertEquals } from "@std/assert/equal"; + * + * assertEquals(add(1, 2), 3); + * ``` + */ +export function add(a: number, b: number): number { + return a + b; +} +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import { assertEquals } from "@std/assert/equal"; +import { add } from "file:///main.ts"; +Deno.test("file:///main.ts$3-8.ts", async ()=>{ + assertEquals(add(1, 2), 3); +}); +"#, + specifier: "file:///main.ts$3-8.ts", + media_type: MediaType::TypeScript, + }], + }, + Test { + input: Input { + source: r#" +/** + * ```ts + * foo(); + * ``` + */ +export function foo() {} + +export default class Bar {} +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import Bar, { foo } from "file:///main.ts"; +Deno.test("file:///main.ts$3-6.ts", async ()=>{ + foo(); +}); +"#, + specifier: "file:///main.ts$3-6.ts", + media_type: MediaType::TypeScript, + }], + }, + Test { + input: Input { + source: r#" +/** + * ```ts + * const input = { a: 42 } satisfies Args; + * foo(input); + * ``` + */ +export function foo(args: Args) {} + +export type Args = { a: number }; +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import { Args, foo } from "file:///main.ts"; +Deno.test("file:///main.ts$3-7.ts", async ()=>{ + const input = { + a: 42 + } satisfies Args; + foo(input); +}); +"#, + specifier: "file:///main.ts$3-7.ts", + media_type: MediaType::TypeScript, + }], + }, + Test { + input: Input { + source: r#" +/** + * This is a module-level doc. + * + * ```ts + * foo(); + * ``` + * + * @module doc + */ +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"Deno.test("file:///main.ts$5-8.ts", async ()=>{ + foo(); +}); +"#, + specifier: "file:///main.ts$5-8.ts", + media_type: MediaType::TypeScript, + }], + }, + Test { + input: Input { + source: r#" +/** + * This is a module-level doc. + * + * ```js + * const cls = new MyClass(); + * ``` + * + * @module doc + */ + +/** + * ```ts + * foo(); + * ``` + */ +export function foo() {} + +export default class MyClass {} + +export * from "./other.ts"; +"#, + specifier: "file:///main.ts", + }, + expected: vec![ + Expected { + source: r#"import MyClass, { foo } from "file:///main.ts"; +Deno.test("file:///main.ts$5-8.js", async ()=>{ + const cls = new MyClass(); +}); +"#, + specifier: "file:///main.ts$5-8.js", + media_type: MediaType::JavaScript, + }, + Expected { + source: r#"import MyClass, { foo } from "file:///main.ts"; +Deno.test("file:///main.ts$13-16.ts", async ()=>{ + foo(); +}); +"#, + specifier: "file:///main.ts$13-16.ts", + media_type: MediaType::TypeScript, + }, + ], + }, + // Avoid duplicate imports + Test { + input: Input { + source: r#" +/** + * ```ts + * import { DUPLICATE1 } from "./other1.ts"; + * import * as DUPLICATE2 from "./other2.js"; + * import { foo as DUPLICATE3 } from "./other3.tsx"; + * + * foo(); + * ``` + */ +export function foo() {} + +export const DUPLICATE1 = "dup1"; +const DUPLICATE2 = "dup2"; +export default DUPLICATE2; +const DUPLICATE3 = "dup3"; +export { DUPLICATE3 }; +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import { DUPLICATE1 } from "./other1.ts"; +import * as DUPLICATE2 from "./other2.js"; +import { foo as DUPLICATE3 } from "./other3.tsx"; +import { foo } from "file:///main.ts"; +Deno.test("file:///main.ts$3-10.ts", async ()=>{ + foo(); +}); +"#, + specifier: "file:///main.ts$3-10.ts", + media_type: MediaType::TypeScript, + }], + }, + // duplication of imported identifier and local identifier is fine + Test { + input: Input { + source: r#" +/** + * ```ts + * const foo = createFoo(); + * foo(); + * ``` + */ +export function createFoo() { + return () => "created foo"; +} + +export const foo = () => "foo"; +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import { createFoo } from "file:///main.ts"; +Deno.test("file:///main.ts$3-7.ts", async ()=>{ + const foo = createFoo(); + foo(); +}); +"#, + specifier: "file:///main.ts$3-7.ts", + media_type: MediaType::TypeScript, + }], + }, + // example code has an exported item `foo` - because `export` must be at + // the top level, `foo` is "hoisted" to the top level instead of being + // wrapped in `Deno.test`. + Test { + input: Input { + source: r#" +/** + * ```ts + * doSomething(); + * export const foo = 42; + * ``` + */ +export function doSomething() {} +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"export const foo = 42; +import { doSomething } from "file:///main.ts"; +Deno.test("file:///main.ts$3-7.ts", async ()=>{ + doSomething(); +}); +"#, + specifier: "file:///main.ts$3-7.ts", + media_type: MediaType::TypeScript, + }], + }, + Test { + input: Input { + source: r#" +# Header + +This is a *markdown*. + +```js +import { assertEquals } from "@std/assert/equal"; +import { add } from "jsr:@deno/non-existent"; + +assertEquals(add(1, 2), 3); +``` +"#, + specifier: "file:///README.md", + }, + expected: vec![Expected { + source: r#"import { assertEquals } from "@std/assert/equal"; +import { add } from "jsr:@deno/non-existent"; +Deno.test("file:///README.md$6-12.js", async ()=>{ + assertEquals(add(1, 2), 3); +}); +"#, + specifier: "file:///README.md$6-12.js", + media_type: MediaType::JavaScript, + }], + }, + ]; + + for test in tests { + let file = File { + specifier: ModuleSpecifier::parse(test.input.specifier).unwrap(), + maybe_headers: None, + source: test.input.source.as_bytes().into(), + }; + let got_decoded = extract_doc_tests(file) + .unwrap() + .into_iter() + .map(|f| f.into_text_decoded().unwrap()) + .collect::<Vec<_>>(); + let expected = test + .expected + .iter() + .map(|e| TextDecodedFile { + specifier: ModuleSpecifier::parse(e.specifier).unwrap(), + media_type: e.media_type, + source: e.source.into(), + }) + .collect::<Vec<_>>(); + assert_eq!(got_decoded, expected); + } + } + + #[test] + fn test_extract_snippet_files() { + struct Input { + source: &'static str, + specifier: &'static str, + } + struct Expected { + source: &'static str, + specifier: &'static str, + media_type: MediaType, + } + struct Test { + input: Input, + expected: Vec<Expected>, + } + + let tests = [ + Test { + input: Input { + source: r#""#, + specifier: "file:///main.ts", + }, + expected: vec![], + }, + Test { + input: Input { + source: r#" +/** + * ```ts + * import { assertEquals } from "@std/assert/equals"; + * + * assertEquals(add(1, 2), 3); + * ``` + */ +export function add(a: number, b: number): number { + return a + b; +} +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import { assertEquals } from "@std/assert/equals"; +import { add } from "file:///main.ts"; +assertEquals(add(1, 2), 3); +"#, + specifier: "file:///main.ts$3-8.ts", + media_type: MediaType::TypeScript, + }], + }, + Test { + input: Input { + source: r#" +/** + * ```ts + * import { assertEquals } from "@std/assert/equals"; + * import { DUPLICATE } from "./other.ts"; + * + * assertEquals(add(1, 2), 3); + * ``` + */ +export function add(a: number, b: number): number { + return a + b; +} + +export const DUPLICATE = "dup"; +"#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import { assertEquals } from "@std/assert/equals"; +import { DUPLICATE } from "./other.ts"; +import { add } from "file:///main.ts"; +assertEquals(add(1, 2), 3); +"#, + specifier: "file:///main.ts$3-9.ts", + media_type: MediaType::TypeScript, + }], + }, + // duplication of imported identifier and local identifier is fine, since + // we wrap the snippet in a block. + // This would be a problem if the local one is declared with `var`, as + // `var` is not block scoped but function scoped. For now we don't handle + // this case assuming that `var` is not used in modern code. + Test { + input: Input { + source: r#" + /** + * ```ts + * const foo = createFoo(); + * foo(); + * ``` + */ + export function createFoo() { + return () => "created foo"; + } + + export const foo = () => "foo"; + "#, + specifier: "file:///main.ts", + }, + expected: vec![Expected { + source: r#"import { createFoo } from "file:///main.ts"; +const foo = createFoo(); +foo(); +"#, + specifier: "file:///main.ts$3-7.ts", + media_type: MediaType::TypeScript, + }], + }, + Test { + input: Input { + source: r#" +# Header + +This is a *markdown*. + +```js +import { assertEquals } from "@std/assert/equal"; +import { add } from "jsr:@deno/non-existent"; + +assertEquals(add(1, 2), 3); +``` +"#, + specifier: "file:///README.md", + }, + expected: vec![Expected { + source: r#"import { assertEquals } from "@std/assert/equal"; +import { add } from "jsr:@deno/non-existent"; +assertEquals(add(1, 2), 3); +"#, + specifier: "file:///README.md$6-12.js", + media_type: MediaType::JavaScript, + }], + }, + ]; + + for test in tests { + let file = File { + specifier: ModuleSpecifier::parse(test.input.specifier).unwrap(), + maybe_headers: None, + source: test.input.source.as_bytes().into(), + }; + let got_decoded = extract_snippet_files(file) + .unwrap() + .into_iter() + .map(|f| f.into_text_decoded().unwrap()) + .collect::<Vec<_>>(); + let expected = test + .expected + .iter() + .map(|e| TextDecodedFile { + specifier: ModuleSpecifier::parse(e.specifier).unwrap(), + media_type: e.media_type, + source: e.source.into(), + }) + .collect::<Vec<_>>(); + assert_eq!(got_decoded, expected); + } + } + + #[test] + fn test_export_collector() { + fn helper(input: &'static str) -> ExportCollector { + let mut collector = ExportCollector::default(); + let parsed = deno_ast::parse_module(deno_ast::ParseParams { + specifier: deno_ast::ModuleSpecifier::parse("file:///main.ts").unwrap(), + text: input.into(), + media_type: deno_ast::MediaType::TypeScript, + capture_tokens: false, + scope_analysis: false, + maybe_syntax: None, + }) + .unwrap(); + + collector.visit_program(parsed.program_ref()); + collector + } + + struct Test { + input: &'static str, + named_expected: BTreeSet<Atom>, + default_expected: Option<Atom>, + } + + macro_rules! atom_set { + ($( $x:expr ),*) => { + [$( Atom::from($x) ),*].into_iter().collect::<BTreeSet<_>>() + }; + } + + let tests = [ + Test { + input: r#"export const foo = 42;"#, + named_expected: atom_set!("foo"), + default_expected: None, + }, + Test { + input: r#"export let foo = 42;"#, + named_expected: atom_set!("foo"), + default_expected: None, + }, + Test { + input: r#"export var foo = 42;"#, + named_expected: atom_set!("foo"), + default_expected: None, + }, + Test { + input: r#"export const foo = () => {};"#, + named_expected: atom_set!("foo"), + default_expected: None, + }, + Test { + input: r#"export function foo() {}"#, + named_expected: atom_set!("foo"), + default_expected: None, + }, + Test { + input: r#"export class Foo {}"#, + named_expected: atom_set!("Foo"), + default_expected: None, + }, + Test { + input: r#"export enum Foo {}"#, + named_expected: atom_set!("Foo"), + default_expected: None, + }, + Test { + input: r#"export module Foo {}"#, + named_expected: atom_set!("Foo"), + default_expected: None, + }, + Test { + input: r#"export module "foo" {}"#, + named_expected: atom_set!("foo"), + default_expected: None, + }, + Test { + input: r#"export namespace Foo {}"#, + named_expected: atom_set!("Foo"), + default_expected: None, + }, + Test { + input: r#"export type Foo = string;"#, + named_expected: atom_set!("Foo"), + default_expected: None, + }, + Test { + input: r#"export interface Foo {};"#, + named_expected: atom_set!("Foo"), + default_expected: None, + }, + Test { + input: r#"export let name1, name2;"#, + named_expected: atom_set!("name1", "name2"), + default_expected: None, + }, + Test { + input: r#"export const name1 = 1, name2 = 2;"#, + named_expected: atom_set!("name1", "name2"), + default_expected: None, + }, + Test { + input: r#"export function* generatorFunc() {}"#, + named_expected: atom_set!("generatorFunc"), + default_expected: None, + }, + Test { + input: r#"export const { name1, name2: bar } = obj;"#, + named_expected: atom_set!("name1", "bar"), + default_expected: None, + }, + Test { + input: r#"export const [name1, name2] = arr;"#, + named_expected: atom_set!("name1", "name2"), + default_expected: None, + }, + Test { + input: r#"export const { name1 = 42 } = arr;"#, + named_expected: atom_set!("name1"), + default_expected: None, + }, + Test { + input: r#"export default function foo() {}"#, + named_expected: atom_set!(), + default_expected: Some("foo".into()), + }, + Test { + input: r#"export { foo, bar as barAlias };"#, + named_expected: atom_set!("foo", "barAlias"), + default_expected: None, + }, + Test { + input: r#" +export default class Foo {} +export let value1 = 42; +const value2 = "Hello"; +const value3 = "World"; +export { value2 }; +"#, + named_expected: atom_set!("value1", "value2"), + default_expected: Some("Foo".into()), + }, + // overloaded function + Test { + input: r#" +export function foo(a: number): boolean; +export function foo(a: boolean): string; +export function foo(a: number | boolean): boolean | string { + return typeof a === "number" ? true : "hello"; +} +"#, + named_expected: atom_set!("foo"), + default_expected: None, + }, + // The collector deliberately does not handle re-exports, because from + // doc reader's perspective, an example code would become hard to follow + // if it uses re-exported items (as opposed to normal, non-re-exported + // items that would look verbose if an example code explicitly imports + // them). + Test { + input: r#" +export * from "./module1.ts"; +export * as name1 from "./module2.ts"; +export { name2, name3 as N3 } from "./module3.js"; +export { default } from "./module4.ts"; +export { default as myDefault } from "./module5.ts"; +"#, + named_expected: atom_set!(), + default_expected: None, + }, + Test { + input: r#" +export namespace Foo { + export type MyType = string; + export const myValue = 42; + export function myFunc(): boolean; +} +"#, + named_expected: atom_set!("Foo"), + default_expected: None, + }, + Test { + input: r#" +declare namespace Foo { + export type MyType = string; + export const myValue = 42; + export function myFunc(): boolean; +} +"#, + named_expected: atom_set!(), + default_expected: None, + }, + Test { + input: r#" +declare module Foo { + export type MyType = string; + export const myValue = 42; + export function myFunc(): boolean; +} +"#, + named_expected: atom_set!(), + default_expected: None, + }, + Test { + input: r#" +declare global { + export type MyType = string; + export const myValue = 42; + export function myFunc(): boolean; +} +"#, + named_expected: atom_set!(), + default_expected: None, + }, + ]; + + for test in tests { + let got = helper(test.input); + assert_eq!(got.named_exports, test.named_expected); + assert_eq!(got.default_export, test.default_expected); + } + } +} |