use std::io::Read; use std::path::Path; use crate::config::ValueKind; use serde_fjson::{Document, PathSegment}; /// What `add` writes: an alias in `imports`, or a whole `jsr:` package in `deps`, bound /// to its package name unless `name` renames it. #[derive(Debug, Clone, Copy)] pub enum Addition<'a> { Import { alias: &'a str, target: &'a str, }, Dependency { name: Option<&'a str>, package: &'a str, }, } impl Addition<'_> { /// The specifier, or namespace of specifiers, that the addition binds. pub fn binding(&self) -> Result { match *self { Self::Import { alias, .. } => Ok(alias.to_owned()), Self::Dependency { name: Some(name), .. } => Ok(name.to_owned()), Self::Dependency { name: None, package } => package_name(package), } } pub fn target(&self) -> &str { match *self { Self::Import { target, .. } => target, Self::Dependency { package, .. } => package, } } } fn package_name(target: &str) -> Result { if target.starts_with("npm:") { return crate::registry::npm::parse_npm_request(target) .map(|request| request.name) .map_err(|error| error.to_string()); } crate::registry::parse_jsr_request(target) .map(|request| request.name) .map_err(|error| error.to_string()) } /// Whether a registry target names a whole package, without any subpath or prefix. pub fn selects_whole_package(target: &str) -> Result { if target.starts_with("npm:") { let request = crate::registry::npm::parse_npm_request(target).map_err(|error| error.to_string())?; return Ok(request.subpath.is_none() && !target.ends_with('/')); } let request = crate::registry::parse_jsr_request(target).map_err(|error| error.to_string())?; Ok(request.subpath.is_none() && !target.ends_with('/')) } /// The whole package a registry target selects from, keeping its version requirement. pub fn whole_package(target: &str) -> Result { if target.starts_with("npm:") { let request = crate::registry::npm::parse_npm_request(target).map_err(|error| error.to_string())?; return Ok(request.request_key()); } let request = crate::registry::parse_jsr_request(target).map_err(|error| error.to_string())?; let name = request.name.clone(); let version = after_name(target, &name) .strip_prefix('@') .and_then(|rest| rest.split('/').next()) .map_or(String::new(), |version| format!("@{version}")); Ok(format!("jsr:{name}{version}")) } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Change { Added, /// The alias previously mapped elsewhere, or was blocked when `previous` is `None`. Updated { previous: Option, }, Unchanged, } /// The package a registry target names without any version, which `add` fills in. pub fn unversioned(target: &str) -> Result, String> { if target.starts_with("npm:") { let request = crate::registry::npm::parse_npm_request(target).map_err(|error| error.to_string())?; return Ok(request.range.is_none().then(|| request.name.clone())); } let request = crate::registry::parse_jsr_request(target).map_err(|error| error.to_string())?; let name = request.name.clone(); Ok((!after_name(target, &name).starts_with('@')).then_some(name)) } /// Gives a target `unversioned` named `name` a caret range from `version`, keeping any /// subpath, as `deno add` does. pub fn with_caret(target: &str, name: &str, version: &str) -> String { if target.starts_with("npm:") { let request = crate::registry::npm::parse_npm_request(target) .map(|request| request.subpath) .unwrap_or_default(); let subpath = request.map_or(String::new(), |path| format!("/{path}")); return format!("npm:{name}@^{version}{subpath}"); } format!("jsr:{name}@^{version}{}", after_name(target, name)) } fn after_name<'a>(target: &'a str, name: &str) -> &'a str { let rest = &target["jsr:".len()..]; &rest.strip_prefix('/').unwrap_or(rest)[name.len()..] } pub fn add(source: &str, addition: &Addition) -> Result<(String, Change), String> { match *addition { Addition::Import { alias, target } => { if alias.is_empty() || alias.starts_with(['.', '/']) || alias.contains([':', '\\']) { return Err("Expected a bare import alias".into()); } package_name(target)?; if alias.ends_with('/') != target.ends_with('/') { return Err("Slash-prefix aliases and targets must both end with '/'".into()); } } Addition::Dependency { package, .. } => { if !selects_whole_package(package)? { return Err("Dependencies are whole packages, without subpaths".into()); } } } let mut document = Document::parse_object(source).map_err(|error| error.render("avril.conf", source))?; let root = document.root(); let base = url::Url::parse(if cfg!(windows) { "file:///C:/package/avril.conf" } else { "file:///package/avril.conf" }) .unwrap(); crate::package_config::PackageConfig::from_value(root, &base) .map_err(|error| error.render("avril.conf", source))?; let ValueKind::Object(members) = &root.kind else { unreachable!() }; let field = |key: &str| members.iter().find(|member| member.key == key); let target = addition.target(); let (existing, path, value) = match *addition { Addition::Import { alias, .. } => { let imports = field("imports"); let existing = imports.and_then(|imports| { let ValueKind::Object(entries) = &imports.value.kind else { unreachable!() }; entries .iter() .find(|entry| entry.key == alias) .map(|entry| (&entry.value, false)) }); if imports.is_some() { ( existing, vec!["imports".into(), alias.into()], serde_json::json!(target), ) } else { ( None, vec!["imports".into()], serde_json::json!({(alias): target}), ) } } Addition::Dependency { name, package } => { let implicit = package_name(package)?; // Naming a package after itself is the implicit form. let name = name.filter(|name| *name != implicit); let binding = name.unwrap_or(&implicit).to_owned(); let deps = field("deps"); let entries = match deps.map(|deps| &deps.value.kind) { Some(ValueKind::Array(entries)) => entries.as_slice(), None => &[], _ => unreachable!(), }; let existing = entries .iter() .enumerate() .find_map(|(index, entry)| match &entry.kind { // A renamed replacement keeps this binding only as {from, name}. ValueKind::String(from) => ((from.starts_with("jsr:") || from.starts_with("npm:")) && package_name(from).as_deref() == Ok(binding.as_str())) .then_some((entry, name.is_some(), vec!["deps".into(), index.into()])), ValueKind::Object(fields) => { let field = |key: &str| fields.iter().find(|field| field.key == key); field("name") .is_some_and(|name| { name.value.kind == ValueKind::String(binding.clone()) }) .then(|| { field("from").map(|from| { ( &from.value, false, vec!["deps".into(), index.into(), "from".into()], ) }) }) .flatten() } _ => None, }); let entry = match name { Some(name) => serde_json::json!({"from": package, "name": name}), None => serde_json::json!(package), }; if let Some((existing, whole, path)) = existing { let value = if whole { entry } else { serde_json::json!(target) }; (Some((existing, whole)), path, value) } else if deps.is_some() { ( None, vec!["deps".into(), PathSegment::Index(entries.len())], entry, ) } else { (None, vec!["deps".into()], serde_json::json!([entry])) } } }; let change = if let Some((existing, whole)) = existing { let previous = match &existing.kind { ValueKind::String(previous) => Some(previous.clone()), _ => None, }; if !whole && previous.as_deref() == Some(target) { return Ok((document.into_source(), Change::Unchanged)); } Change::Updated { previous } } else { Change::Added }; document .set(&path, &value) .map_err(|error| error.render("avril.conf", source))?; let output = document.into_source(); crate::package_config::PackageConfig::parse(&output, &base) .map_err(|error| error.render("avril.conf", &output))?; Ok((output, change)) } pub fn add_file(path: &Path, addition: &Addition) -> Result { let parent = path.parent().ok_or("Config needs a parent directory")?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; let mut change = Change::Unchanged; crate::registry::update_file( path, || read(path), |source| { let source = source.as_deref().unwrap_or_default(); let edited; (edited, change) = add(source, addition)?; Ok((edited != source).then(|| edited.into_bytes())) }, )?; Ok(change) } fn read(path: &Path) -> Result, String> { let mut options = std::fs::OpenOptions::new(); options.read(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); } match options.open(path) { Ok(file) => { if !file .metadata() .map_err(|error| error.to_string())? .is_file() { return Err("Config must be a regular file".into()); } let mut source = String::new(); file.take(32 * 1024 * 1024 + 1) .read_to_string(&mut source) .map_err(|error| error.to_string())?; if source.len() > 32 * 1024 * 1024 { return Err("Config is too large".into()); } Ok(Some(source)) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error.to_string()), } }