diff options
Diffstat (limited to 'cli')
-rw-r--r-- | cli/args/flags.rs | 68 | ||||
-rw-r--r-- | cli/lsp/testing/execution.rs | 13 | ||||
-rw-r--r-- | cli/main.rs | 7 | ||||
-rw-r--r-- | cli/tools/check.rs | 46 | ||||
-rw-r--r-- | cli/tools/test/mod.rs | 336 | ||||
-rw-r--r-- | cli/util/extract.rs | 1410 | ||||
-rw-r--r-- | cli/util/mod.rs | 1 |
7 files changed, 1604 insertions, 277 deletions
diff --git a/cli/args/flags.rs b/cli/args/flags.rs index f832c2a62..92336a0a1 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -109,6 +109,8 @@ pub struct CacheFlags { #[derive(Clone, Debug, Eq, PartialEq)] pub struct CheckFlags { pub files: Vec<String>, + pub doc: bool, + pub doc_only: bool, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1695,6 +1697,19 @@ Unless --reload is specified, this command will not re-download already cached d .hide(true) ) .arg( + Arg::new("doc") + .long("doc") + .help("Type-check code blocks in JSDoc as well as actual code") + .action(ArgAction::SetTrue) + ) + .arg( + Arg::new("doc-only") + .long("doc-only") + .help("Type-check code blocks in JSDoc and Markdown only") + .action(ArgAction::SetTrue) + .conflicts_with("doc") + ) + .arg( Arg::new("file") .num_args(1..) .required_unless_present("help") @@ -2789,7 +2804,7 @@ or <c>**/__tests__/**</>: .arg( Arg::new("doc") .long("doc") - .help("Type-check code blocks in JSDoc and Markdown") + .help("Evaluate code blocks in JSDoc and Markdown") .action(ArgAction::SetTrue) .help_heading(TEST_HEADING), ) @@ -4121,7 +4136,11 @@ fn check_parse( if matches.get_flag("all") || matches.get_flag("remote") { flags.type_check_mode = TypeCheckMode::All; } - flags.subcommand = DenoSubcommand::Check(CheckFlags { files }); + flags.subcommand = DenoSubcommand::Check(CheckFlags { + files, + doc: matches.get_flag("doc"), + doc_only: matches.get_flag("doc-only"), + }); Ok(()) } @@ -6862,12 +6881,55 @@ mod tests { Flags { subcommand: DenoSubcommand::Check(CheckFlags { files: svec!["script.ts"], + doc: false, + doc_only: false, + }), + type_check_mode: TypeCheckMode::Local, + ..Flags::default() + } + ); + + let r = flags_from_vec(svec!["deno", "check", "--doc", "script.ts"]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Check(CheckFlags { + files: svec!["script.ts"], + doc: true, + doc_only: false, }), type_check_mode: TypeCheckMode::Local, ..Flags::default() } ); + let r = flags_from_vec(svec!["deno", "check", "--doc-only", "markdown.md"]); + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Check(CheckFlags { + files: svec!["markdown.md"], + doc: false, + doc_only: true, + }), + type_check_mode: TypeCheckMode::Local, + ..Flags::default() + } + ); + + // `--doc` and `--doc-only` are mutually exclusive + let r = flags_from_vec(svec![ + "deno", + "check", + "--doc", + "--doc-only", + "script.ts" + ]); + assert_eq!( + r.unwrap_err().kind(), + clap::error::ErrorKind::ArgumentConflict + ); + for all_flag in ["--remote", "--all"] { let r = flags_from_vec(svec!["deno", "check", all_flag, "script.ts"]); assert_eq!( @@ -6875,6 +6937,8 @@ mod tests { Flags { subcommand: DenoSubcommand::Check(CheckFlags { files: svec!["script.ts"], + doc: false, + doc_only: false, }), type_check_mode: TypeCheckMode::All, ..Flags::default() diff --git a/cli/lsp/testing/execution.rs b/cli/lsp/testing/execution.rs index a8cea8dd4..55d81f342 100644 --- a/cli/lsp/testing/execution.rs +++ b/cli/lsp/testing/execution.rs @@ -234,16 +234,9 @@ impl TestRun { &cli_options.permissions_options(), )?; let main_graph_container = factory.main_module_graph_container().await?; - test::check_specifiers( - factory.file_fetcher()?, - main_graph_container, - self - .queue - .iter() - .map(|s| (s.clone(), test::TestMode::Executable)) - .collect(), - ) - .await?; + main_graph_container + .check_specifiers(&self.queue.iter().cloned().collect::<Vec<_>>()) + .await?; let (concurrent_jobs, fail_fast) = if let DenoSubcommand::Test(test_flags) = cli_options.sub_command() { diff --git a/cli/main.rs b/cli/main.rs index 10d9ead4e..a7e1c8342 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -121,12 +121,7 @@ async fn run_subcommand(flags: Arc<Flags>) -> Result<i32, AnyError> { tools::installer::install_from_entrypoints(flags, &cache_flags.files).await }), DenoSubcommand::Check(check_flags) => spawn_subcommand(async move { - let factory = CliFactory::from_flags(flags); - let main_graph_container = - factory.main_module_graph_container().await?; - main_graph_container - .load_and_type_check_files(&check_flags.files) - .await + tools::check::check(flags, check_flags).await }), DenoSubcommand::Clean => spawn_subcommand(async move { tools::clean::clean() diff --git a/cli/tools/check.rs b/cli/tools/check.rs index d50af5230..9c464fa16 100644 --- a/cli/tools/check.rs +++ b/cli/tools/check.rs @@ -15,7 +15,9 @@ use once_cell::sync::Lazy; use regex::Regex; use crate::args::check_warn_tsconfig; +use crate::args::CheckFlags; use crate::args::CliOptions; +use crate::args::Flags; use crate::args::TsConfig; use crate::args::TsConfigType; use crate::args::TsTypeLib; @@ -24,13 +26,57 @@ use crate::cache::CacheDBHash; use crate::cache::Caches; use crate::cache::FastInsecureHasher; use crate::cache::TypeCheckCache; +use crate::factory::CliFactory; use crate::graph_util::BuildFastCheckGraphOptions; use crate::graph_util::ModuleGraphBuilder; use crate::npm::CliNpmResolver; use crate::tsc; use crate::tsc::Diagnostics; +use crate::util::extract; use crate::util::path::to_percent_decoded_str; +pub async fn check( + flags: Arc<Flags>, + check_flags: CheckFlags, +) -> Result<(), AnyError> { + let factory = CliFactory::from_flags(flags); + + let main_graph_container = factory.main_module_graph_container().await?; + + let specifiers = + main_graph_container.collect_specifiers(&check_flags.files)?; + if specifiers.is_empty() { + log::warn!("{} No matching files found.", colors::yellow("Warning")); + } + + let specifiers_for_typecheck = if check_flags.doc || check_flags.doc_only { + let file_fetcher = factory.file_fetcher()?; + + let mut specifiers_for_typecheck = if check_flags.doc { + specifiers.clone() + } else { + vec![] + }; + + for s in specifiers { + let file = file_fetcher.fetch_bypass_permissions(&s).await?; + let snippet_files = extract::extract_snippet_files(file)?; + for snippet_file in snippet_files { + specifiers_for_typecheck.push(snippet_file.specifier.clone()); + file_fetcher.insert_memory_files(snippet_file); + } + } + + specifiers_for_typecheck + } else { + specifiers + }; + + main_graph_container + .check_specifiers(&specifiers_for_typecheck) + .await +} + /// Options for performing a check of a module graph. Note that the decision to /// emit or not is determined by the `ts_config` settings. pub struct CheckOptions { diff --git a/cli/tools/test/mod.rs b/cli/tools/test/mod.rs index 63382ffc6..d043ffcba 100644 --- a/cli/tools/test/mod.rs +++ b/cli/tools/test/mod.rs @@ -9,21 +9,18 @@ use crate::display; use crate::factory::CliFactory; use crate::file_fetcher::File; use crate::file_fetcher::FileFetcher; -use crate::graph_container::MainModuleGraphContainer; use crate::graph_util::has_graph_root_local_dependent_changed; use crate::ops; +use crate::util::extract::extract_doc_tests; use crate::util::file_watcher; use crate::util::fs::collect_specifiers; use crate::util::path::get_extension; use crate::util::path::is_script_ext; -use crate::util::path::mapped_specifier_for_tsc; use crate::util::path::matches_pattern_or_exact_path; use crate::worker::CliMainWorkerFactory; use crate::worker::CoverageCollector; -use deno_ast::swc::common::comments::CommentKind; use deno_ast::MediaType; -use deno_ast::SourceRangedForSpanned; use deno_config::glob::FilePatterns; use deno_config::glob::WalkEntry; use deno_core::anyhow; @@ -151,6 +148,20 @@ pub enum TestMode { Both, } +impl TestMode { + /// Returns `true` if the test mode indicates that code snippet extraction is + /// needed. + fn needs_test_extraction(&self) -> bool { + matches!(self, Self::Documentation | Self::Both) + } + + /// Returns `true` if the test mode indicates that the test should be + /// type-checked and run. + fn needs_test_run(&self) -> bool { + matches!(self, Self::Executable | Self::Both) + } +} + #[derive(Clone, Debug, Default)] pub struct TestFilter { pub substring: Option<String>, @@ -1174,233 +1185,6 @@ async fn wait_for_activity_to_stabilize( }) } -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) -} - -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_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, - ) -} - -async fn fetch_inline_files( - file_fetcher: &FileFetcher, - specifiers: Vec<ModuleSpecifier>, -) -> Result<Vec<File>, AnyError> { - let mut files = Vec::new(); - for specifier in specifiers { - let file = file_fetcher - .fetch_bypass_permissions(&specifier) - .await? - .into_text_decoded()?; - - let inline_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, - file.media_type, - ) - }; - - files.extend(inline_files?); - } - - Ok(files) -} - -/// Type check a collection of module and document specifiers. -pub async fn check_specifiers( - file_fetcher: &FileFetcher, - main_graph_container: &Arc<MainModuleGraphContainer>, - specifiers: Vec<(ModuleSpecifier, TestMode)>, -) -> Result<(), AnyError> { - let inline_files = fetch_inline_files( - file_fetcher, - specifiers - .iter() - .filter_map(|(specifier, mode)| { - if *mode != TestMode::Executable { - Some(specifier.clone()) - } else { - None - } - }) - .collect(), - ) - .await?; - - let mut module_specifiers = specifiers - .into_iter() - .filter_map(|(specifier, mode)| { - if mode != TestMode::Documentation { - Some(specifier) - } else { - None - } - }) - .collect::<Vec<_>>(); - - if !inline_files.is_empty() { - module_specifiers - .extend(inline_files.iter().map(|file| file.specifier.clone())); - - for file in inline_files { - file_fetcher.insert_memory_files(file); - } - } - - main_graph_container - .check_specifiers(&module_specifiers) - .await?; - - Ok(()) -} - static HAS_TEST_RUN_SIGINT_HANDLER: AtomicBool = AtomicBool::new(false); /// Test a collection of specifiers with test modes concurrently. @@ -1788,14 +1572,19 @@ pub async fn run_tests( return Err(generic_error("No test modules found")); } + let doc_tests = get_doc_tests(&specifiers_with_mode, file_fetcher).await?; + let specifiers_for_typecheck_and_test = + get_target_specifiers(specifiers_with_mode, &doc_tests); + for doc_test in doc_tests { + file_fetcher.insert_memory_files(doc_test); + } + let main_graph_container = factory.main_module_graph_container().await?; - check_specifiers( - file_fetcher, - main_graph_container, - specifiers_with_mode.clone(), - ) - .await?; + // Typecheck + main_graph_container + .check_specifiers(&specifiers_for_typecheck_and_test) + .await?; if workspace_test_options.no_run { return Ok(()); @@ -1804,17 +1593,12 @@ pub async fn run_tests( let worker_factory = Arc::new(factory.create_cli_main_worker_factory().await?); + // Run tests test_specifiers( worker_factory, &permissions, permission_desc_parser, - specifiers_with_mode - .into_iter() - .filter_map(|(s, m)| match m { - TestMode::Documentation => None, - _ => Some(s), - }) - .collect(), + specifiers_for_typecheck_and_test, TestSpecifiersOptions { cwd: Url::from_directory_path(cli_options.initial_cwd()).map_err( |_| { @@ -1949,8 +1733,6 @@ pub async fn run_tests_with_watch( test_modules.clone() }; - let worker_factory = - Arc::new(factory.create_cli_main_worker_factory().await?); let specifiers_with_mode = fetch_specifiers_with_test_mode( &cli_options, file_fetcher, @@ -1962,30 +1744,34 @@ pub async fn run_tests_with_watch( .filter(|(specifier, _)| test_modules_to_reload.contains(specifier)) .collect::<Vec<(ModuleSpecifier, TestMode)>>(); + let doc_tests = + get_doc_tests(&specifiers_with_mode, file_fetcher).await?; + let specifiers_for_typecheck_and_test = + get_target_specifiers(specifiers_with_mode, &doc_tests); + for doc_test in doc_tests { + file_fetcher.insert_memory_files(doc_test); + } + let main_graph_container = factory.main_module_graph_container().await?; - check_specifiers( - file_fetcher, - main_graph_container, - specifiers_with_mode.clone(), - ) - .await?; + + // Typecheck + main_graph_container + .check_specifiers(&specifiers_for_typecheck_and_test) + .await?; if workspace_test_options.no_run { return Ok(()); } + let worker_factory = + Arc::new(factory.create_cli_main_worker_factory().await?); + test_specifiers( worker_factory, &permissions, permission_desc_parser, - specifiers_with_mode - .into_iter() - .filter_map(|(s, m)| match m { - TestMode::Documentation => None, - _ => Some(s), - }) - .collect(), + specifiers_for_typecheck_and_test, TestSpecifiersOptions { cwd: Url::from_directory_path(cli_options.initial_cwd()).map_err( |_| { @@ -2020,6 +1806,38 @@ pub async fn run_tests_with_watch( Ok(()) } +/// Extracts doc tests from files specified by the given specifiers. +async fn get_doc_tests( + specifiers_with_mode: &[(Url, TestMode)], + file_fetcher: &FileFetcher, +) -> Result<Vec<File>, AnyError> { + let specifiers_needing_extraction = specifiers_with_mode + .iter() + .filter(|(_, mode)| mode.needs_test_extraction()) + .map(|(s, _)| s); + + let mut doc_tests = Vec::new(); + for s in specifiers_needing_extraction { + let file = file_fetcher.fetch_bypass_permissions(s).await?; + doc_tests.extend(extract_doc_tests(file)?); + } + + Ok(doc_tests) +} + +/// Get a list of specifiers that we need to perform typecheck and run tests on. +/// The result includes "pseudo specifiers" for doc tests. +fn get_target_specifiers( + specifiers_with_mode: Vec<(Url, TestMode)>, + doc_tests: &[File], +) -> Vec<Url> { + specifiers_with_mode + .into_iter() + .filter_map(|(s, mode)| mode.needs_test_run().then_some(s)) + .chain(doc_tests.iter().map(|d| d.specifier.clone())) + .collect() +} + /// Tracks failures for the `--fail-fast` argument in /// order to tell when to stop running tests. #[derive(Clone, Default)] 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); + } + } +} diff --git a/cli/util/mod.rs b/cli/util/mod.rs index b9071c496..e59b09d2c 100644 --- a/cli/util/mod.rs +++ b/cli/util/mod.rs @@ -7,6 +7,7 @@ pub mod console; pub mod diff; pub mod display; pub mod draw_thread; +pub mod extract; pub mod file_watcher; pub mod fs; pub mod logger; |