// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use deno_ast::ModuleSpecifier; use deno_core::anyhow::bail; use deno_core::anyhow::Context; use deno_core::error::AnyError; use deno_core::resolve_url_or_path; use log::warn; use crate::args::CliOptions; use crate::args::Flags; use crate::args::FmtOptionsConfig; use crate::args::VendorFlags; use crate::proc_state::ProcState; use crate::tools::fmt::format_json; use crate::util::fs::canonicalize_path; use crate::util::fs::resolve_from_cwd; use crate::util::path::relative_specifier; use crate::util::path::specifier_to_file_path; mod analyze; mod build; mod import_map; mod mappings; mod specifiers; #[cfg(test)] mod test; pub async fn vendor( flags: Flags, vendor_flags: VendorFlags, ) -> Result<(), AnyError> { let mut cli_options = CliOptions::from_flags(flags)?; let raw_output_dir = match &vendor_flags.output_path { Some(output_path) => output_path.to_owned(), None => PathBuf::from("vendor/"), }; let output_dir = resolve_from_cwd(&raw_output_dir)?; validate_output_dir(&output_dir, &vendor_flags)?; validate_options(&mut cli_options, &output_dir)?; let ps = ProcState::from_cli_options(Arc::new(cli_options)).await?; let graph = create_graph(&ps, &vendor_flags).await?; let vendored_count = build::build( graph, &ps.parsed_source_cache, &output_dir, ps.maybe_import_map.as_deref(), ps.lockfile.clone(), &build::RealVendorEnvironment, )?; log::info!( concat!("Vendored {} {} into {} directory.",), vendored_count, if vendored_count == 1 { "module" } else { "modules" }, raw_output_dir.display(), ); if vendored_count > 0 { let import_map_path = raw_output_dir.join("import_map.json"); if maybe_update_config_file(&output_dir, &ps) { log::info!( concat!( "\nUpdated your local Deno configuration file with a reference to the ", "new vendored import map at {}. Invoking Deno subcommands will now ", "automatically resolve using the vendored modules. You may override ", "this by providing the `--import-map ` flag or by ", "manually editing your Deno configuration file.", ), import_map_path.display(), ); } else { log::info!( concat!( "\nTo use vendored modules, specify the `--import-map {}` flag when ", r#"invoking Deno subcommands or add an `"importMap": ""` "#, "entry to a deno.json file.", ), import_map_path.display(), ); } } Ok(()) } fn validate_output_dir( output_dir: &Path, flags: &VendorFlags, ) -> Result<(), AnyError> { if !flags.force && !is_dir_empty(output_dir)? { bail!(concat!( "Output directory was not empty. Please specify an empty directory or use ", "--force to ignore this error and potentially overwrite its contents.", )); } Ok(()) } fn validate_options( options: &mut CliOptions, output_dir: &Path, ) -> Result<(), AnyError> { // check the import map if let Some(import_map_path) = options .resolve_import_map_specifier()? .and_then(|p| specifier_to_file_path(&p).ok()) .and_then(|p| canonicalize_path(&p).ok()) { // make the output directory in order to canonicalize it for the check below std::fs::create_dir_all(output_dir)?; let output_dir = canonicalize_path(output_dir).with_context(|| { format!("Failed to canonicalize: {}", output_dir.display()) })?; if import_map_path.starts_with(output_dir) { // canonicalize to make the test for this pass on the CI let cwd = canonicalize_path(&std::env::current_dir()?)?; // We don't allow using the output directory to help generate the // new state because this may lead to cryptic error messages. log::warn!( concat!( "Ignoring import map. Specifying an import map file ({}) in the ", "deno vendor output directory is not supported. If you wish to use ", "an import map while vendoring, please specify one located outside ", "this directory." ), import_map_path .strip_prefix(&cwd) .unwrap_or(&import_map_path) .display() .to_string(), ); // don't use an import map in the config options.set_import_map_specifier(None); } } Ok(()) } fn maybe_update_config_file(output_dir: &Path, ps: &ProcState) -> bool { assert!(output_dir.is_absolute()); let config_file_specifier = match ps.options.maybe_config_file_specifier() { Some(f) => f, None => return false, }; let fmt_config = ps .options .maybe_config_file() .as_ref() .and_then(|config| config.to_fmt_config().ok()) .unwrap_or_default() .unwrap_or_default(); let result = update_config_file( &config_file_specifier, &ModuleSpecifier::from_file_path(output_dir.join("import_map.json")) .unwrap(), &fmt_config.options, ); match result { Ok(()) => true, Err(err) => { warn!("Error updating config file. {:#}", err); false } } } fn update_config_file( config_specifier: &ModuleSpecifier, import_map_specifier: &ModuleSpecifier, fmt_options: &FmtOptionsConfig, ) -> Result<(), AnyError> { if config_specifier.scheme() != "file" { return Ok(()); } let config_path = specifier_to_file_path(config_specifier)?; let config_text = std::fs::read_to_string(&config_path)?; let relative_text = match relative_specifier(config_specifier, import_map_specifier) { Some(text) => text, None => return Ok(()), // ignore }; if let Some(new_text) = update_config_text(&config_text, &relative_text, fmt_options) { std::fs::write(config_path, new_text)?; } Ok(()) } fn update_config_text( text: &str, import_map_specifier: &str, fmt_options: &FmtOptionsConfig, ) -> Option { use jsonc_parser::ast::ObjectProp; use jsonc_parser::ast::Value; let ast = jsonc_parser::parse_to_ast(text, &Default::default(), &Default::default()) .ok()?; let obj = match ast.value { Some(Value::Object(obj)) => obj, _ => return None, // shouldn't happen, so ignore }; let import_map_specifier = import_map_specifier.replace('\"', "\\\""); match obj.get("importMap") { Some(ObjectProp { value: Value::StringLit(lit), .. }) => Some(format!( "{}{}{}", &text[..lit.range.start + 1], import_map_specifier, &text[lit.range.end - 1..], )), None => { // insert it crudely at a position that won't cause any issues // with comments and format after to make it look nice let insert_position = obj.range.end - 1; let insert_text = format!( r#"{}"importMap": "{}""#, if obj.properties.is_empty() { "" } else { "," }, import_map_specifier ); let new_text = format!( "{}{}{}", &text[..insert_position], insert_text, &text[insert_position..], ); format_json(&new_text, fmt_options) .ok() .map(|formatted_text| formatted_text.unwrap_or(new_text)) } // shouldn't happen, so ignore Some(_) => None, } } fn is_dir_empty(dir_path: &Path) -> Result { match std::fs::read_dir(dir_path) { Ok(mut dir) => Ok(dir.next().is_none()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(true), Err(err) => { bail!("Error reading directory {}: {}", dir_path.display(), err) } } } async fn create_graph( ps: &ProcState, flags: &VendorFlags, ) -> Result { let entry_points = flags .specifiers .iter() .map(|p| resolve_url_or_path(p, ps.options.initial_cwd())) .collect::, _>>()?; ps.create_graph(entry_points).await } #[cfg(test)] mod internal_test { use super::*; use pretty_assertions::assert_eq; #[test] fn update_config_text_no_existing_props_add_prop() { let text = update_config_text( "{\n}", "./vendor/import_map.json", &Default::default(), ) .unwrap(); assert_eq!( text, r#"{ "importMap": "./vendor/import_map.json" } "# ); } #[test] fn update_config_text_existing_props_add_prop() { let text = update_config_text( r#"{ "tasks": { "task1": "other" } } "#, "./vendor/import_map.json", &Default::default(), ) .unwrap(); assert_eq!( text, r#"{ "tasks": { "task1": "other" }, "importMap": "./vendor/import_map.json" } "# ); } #[test] fn update_config_text_update_prop() { let text = update_config_text( r#"{ "importMap": "./local.json" } "#, "./vendor/import_map.json", &Default::default(), ) .unwrap(); assert_eq!( text, r#"{ "importMap": "./vendor/import_map.json" } "# ); } }