use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; use avril_runtime::caps::{Capabilities, Initial}; use avril_runtime::config_edit::{self, Addition, Change}; use avril_runtime::import_map::ImportMap; use avril_runtime::package_config; use avril_runtime::registry::{ Options, PackageStore, ProgressEvent, ProgressSink, npm::resolution::NpmResolver, parse_jsr_request, }; use futures::executor::block_on; use crate::{cli, cli::ParseError}; use avril_runtime::modules::ModuleGraph; pub struct Args { config: Option, pre: bool, offline: bool, automatic_types: bool, jsr_registry: url::Url, npm_registry: url::Url, cache_dir: Option, values: Vec, } /// `avril add` argument parsing failed. #[derive(Debug, thiserror::Error)] pub enum AddParseError { #[error(transparent)] Generic(#[from] ParseError), #[error("Expected one config path")] DuplicateConfig, #[error("{0}")] Url(#[from] url::ParseError), } pub fn parse(args: &[String]) -> std::result::Result { let command = cli::command("add").ok_or_else(|| ParseError::Usage(cli::usage()))?; let mut parsed = Args { config: None, pre: false, offline: false, automatic_types: true, jsr_registry: url::Url::parse("https://jsr.io/").unwrap(), npm_registry: url::Url::parse("https://registry.npmjs.org/").unwrap(), cache_dir: None, values: Vec::new(), }; for arg in args { if arg.starts_with('-') { command.check(arg).map_err(ParseError::Check)?; } match arg.as_str() { "--pre" => parsed.pre = true, "--offline" => parsed.offline = true, "--no-types-packages" => parsed.automatic_types = false, _ if arg.starts_with('-') => { let (flag, value) = match arg.split_once('=') { Some((flag, value)) if !value.is_empty() => { (flag.strip_prefix("--").unwrap_or(flag).to_owned(), value) } Some((flag, _)) => { return Err(ParseError::MissingValue { flag: flag.strip_prefix("--").unwrap_or(flag).to_owned(), equals: "=", } .into()); } None => { return Err(ParseError::MissingValue { flag: arg.strip_prefix("--").unwrap_or(arg).to_owned(), equals: "", } .into()); } }; match flag.as_str() { "config" => { if parsed.config.replace(value.to_owned()).is_some() { return Err(AddParseError::DuplicateConfig); } } "jsr-registry" => parsed.jsr_registry = url::Url::parse(value)?, "npm-registry" => parsed.npm_registry = url::Url::parse(value)?, "cache-dir" => parsed.cache_dir = Some(value.to_owned()), _ => return Err(ParseError::UnexpectedValue { flag }.into()), } } _ => parsed.values.push(arg.clone()), } } Ok(parsed) } /// `avril add`: pins unversioned targets to the newest release, edits the config, then /// caches what the new mapping can import, as `avril cache` would. pub fn run(args: Args) -> Result<(), Box> { let cwd = std::env::current_dir()?.canonicalize()?; let Args { config, pre, offline, automatic_types, jsr_registry, npm_registry, cache_dir, values, } = args; let cache = cache_dir .map(|dir| cwd.join(dir)) .unwrap_or_else(|| cli::default_cache(&cwd)); let config = config.map(|config| cwd.join(config)); let command = cli::command("add").ok_or_else(cli::usage)?; let (alias, target) = match values.as_slice() { [target] => (None, target.as_str()), [alias, target] => (Some(alias.as_str()), target.as_str()), _ => return Err(command.usage().into()), }; let caps = Capabilities::new(Initial::new(false), cwd.clone()); let authority = avril_runtime::authority::SourceAuthority::execute(caps.clone()); let config = match config { Some(config) => config, None => ModuleGraph::project_root_for_add( &authority, cwd.join("avril.conf") .to_str() .ok_or("Paths must be UTF-8")?, )? .join("avril.conf"), }; let directory = config.parent().ok_or("Config needs a parent directory")?; let host = avril_host::Host::new()?; let store = Arc::new( PackageStore::new_with_authority( host.handle(), Options { jsr_registry, npm_registry, cache, lock: directory.join("avril.lock"), project_root: directory.to_owned(), offline, frozen: false, update: false, ephemeral_lock: false, vendor: None, }, authority.clone(), )? .with_progress(progress_sink()), ); let npm_target = target.starts_with("npm:"); let target = match config_edit::unversioned(target)? { Some(name) => { if npm_target { if pre { return Err( "--pre does not apply to npm: targets; select a prerelease tag explicitly" .into(), ); } phase(format!("Selecting version for {target:?}...")); let version = block_on(store.npm_select_version(target))?; config_edit::with_caret(target, &name, &version) } else { phase(format!("Selecting version for {target:?}...")); let version = block_on(store.latest(&name, pre))?.ok_or_else(|| { if pre { format!("{name} has no non-yanked versions") } else { format!( "{name} has no non-yanked stable versions; --pre allows prereleases" ) } })?; config_edit::with_caret(target, &name, &version) } } None if pre => { if npm_target { return Err( "--pre does not apply to npm: targets; select a prerelease tag explicitly" .into(), ); } return Err("--pre only applies to targets without a version".into()); } None => target.to_owned(), }; let package = config_edit::whole_package(&target)?; // Packages belong in deps; only an alias for a subpath or prefix needs imports. let addition = match alias { Some(alias) if !config_edit::selects_whole_package(&target)? || !package_config::valid_name(alias) => { Addition::Import { alias, target: &target } } name => Addition::Dependency { name, package: &package }, }; let binding = addition.binding()?; let change = config_edit::add_file(&config, &addition)?; report(&change, &binding, addition.target(), &config, &cwd); let prefix = match addition { Addition::Import { alias, target } if alias.ends_with('/') => { Some((alias.to_owned(), target.to_owned())) } Addition::Import { .. } => None, Addition::Dependency { package, .. } => { Some((format!("{binding}/"), format!("{package}/"))) } }; let type_target = target.clone(); let requests = if npm_target { if prefix.is_some() { phase(format!("Preparing exports for {target:?}...")); } npm_add_requests(&store, &target, prefix.as_ref())? } else if let Some((alias, target)) = prefix { let base = url::Url::from_file_path(&config).map_err(|_| "Paths must be absolute")?; let map = ImportMap::parse( &format!( "imports: {{{}: {}}}", serde_json::to_string(&alias)?, serde_json::to_string(&target)? ), &base, )?; let referrer = base.join(".avril-add.js")?; let export = |request: &str| parse_jsr_request(request).map(|request| request.export_name()); phase(format!("Preparing exports for {target:?}...")); // The prefix's own normalized subpath, found by probing beneath it. let probe = export(&format!("{target}x"))?; let prefix = &probe["./".len()..probe.len() - 1]; let mut requests = Vec::new(); for key in block_on(store.exports(&target))? { // Each export the prefix itself or a suffix beneath it reaches under the // runtime's import map rules, which reject escapes and encoded separators. // Normalization drops one trailing slash, so a key ending in '/' needs another. let suffix = key .strip_prefix("./") .and_then(|key| key.strip_prefix(prefix)); let mut suffixes = [String::new()].into_iter().chain( suffix .into_iter() .flat_map(|suffix| [suffix.to_owned(), format!("{suffix}/")]), ); if let Some(request) = suffixes.find_map(|suffix| { let request = map.resolve(&format!("{alias}{suffix}"), &referrer).ok()??; (export(&request).ok()? == key).then_some(request) }) { requests.push(request); } } requests } else { vec![target] }; let graph = ModuleGraph::open_shared(authority, store.clone()); graph.set_automatic_types(automatic_types); graph.set_typescript_version(avril_lsp::BACKEND_VERSION)?; // Warm through the updated config so joint root contexts match what // the runtime prepares: registry-referrer contexts would pin different // roots than a frozen run looks up once other deps or peers exist. let referrer = if npm_target { config.to_str().ok_or("Paths must be UTF-8")?.to_owned() } else { store.options().jsr_registry.to_string() }; let warn_unsupported = Rc::new({ let warned = RefCell::new(HashSet::::new()); move |specifier: &str, referrer: &str| { if warned.borrow_mut().insert(specifier.to_owned()) { use std::io::Write; let _ = writeln!( std::io::stderr(), "Warning: skipping unsupported Node.js builtin import {specifier:?} from {referrer:?}; builtin modules remain unsupported at runtime", ); } } }); let type_requests = if requests.is_empty() { vec![type_target] } else { requests.clone() }; for request in requests { phase(format!("Caching {request:?}...")); block_on(graph.prepare_for_add(request, referrer.clone(), warn_unsupported.clone()))?; } for request in type_requests { phase(format!("Caching types for {request:?}...")); block_on(graph.prepare_types_for_request(&request, &referrer))?; } Ok(()) } /// Cacheable requests for a whole npm package or the export prefix a /// prefix target names: exact subpaths resolve as a single request, /// anything else enumerates ESM exports beneath that scope only. fn npm_add_requests( store: &Arc, target: &str, prefix: Option<&(String, String)>, ) -> Result, Box> { let Some((_, scope)) = prefix else { return Ok(vec![target.to_owned()]); }; let resolver = NpmResolver::new(store.clone()); Ok(block_on(resolver.export_requests(scope))?) } fn phase(message: String) { use std::io::Write; let message = crate::progress::color_action(message, avril_ext_console::colors(true)); let _ = writeln!(std::io::stderr(), "{message}"); } fn progress_sink() -> ProgressSink { Arc::new(|event: &ProgressEvent| match event { ProgressEvent::NpmMetadata { name } => { phase(format!("Fetching npm metadata for {name:?}...")); } ProgressEvent::NpmArchive { name, version } => { phase(format!( "Downloading npm archive for {:?}...", format!("{name}@{version}"), )); } ProgressEvent::NpmMaterialized { name, version } => { phase(format!( "Materialized npm package {:?}...", format!("{name}@{version}"), )); } ProgressEvent::SourceDownload { .. } | ProgressEvent::NpmMaterializing { .. } | ProgressEvent::NpmResolving => {} }) } fn report( change: &Change, alias: &str, target: &str, config: &std::path::Path, cwd: &std::path::Path, ) { let paint = |stderr: bool, style: &str, text: &str| { if avril_ext_console::colors(stderr) { format!("\x1b[{style}m{text}\x1b[0m") } else { text.to_owned() } }; let string = |text: &str| paint(false, "32", &serde_json::to_string(text).unwrap()); let (status, style, previous) = match change { Change::Added => ("Added", "1;32", String::new()), Change::Updated { previous } => ( "Updated", "1;33", format!("{} → ", previous.as_deref().map_or("null".into(), string)), ), Change::Unchanged => ("Unchanged", "1", String::new()), }; let mut path = String::new(); // Paths may contain terminal controls, like the strings escaped as JSON above. for ch in config .strip_prefix(cwd) .unwrap_or(config) .to_string_lossy() .chars() { if ch.is_control() { path.extend(ch.escape_default()); } else { path.push(ch); } } // The status is decoration on stderr; stdout keeps just the resulting mapping. eprint!("{} ", paint(true, style, status)); println!( "{}: {previous}{} in {path}", paint(false, "1", &serde_json::to_string(alias).unwrap()), string(target), ); }