// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. use deno_ast::ModuleSpecifier; use deno_core::anyhow::bail; use deno_core::anyhow::Context; use deno_core::error::generic_error; use deno_core::error::AnyError; use serde::Deserialize; use serde::Serialize; use crate::semver::VersionReq; /// A reference to an npm package's name, version constraint, and potential sub path. /// /// This contains all the information found in an npm specifier. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct NpmPackageReference { pub req: NpmPackageReq, pub sub_path: Option, } impl NpmPackageReference { pub fn from_specifier( specifier: &ModuleSpecifier, ) -> Result { Self::from_str(specifier.as_str()) } pub fn from_str(specifier: &str) -> Result { let original_text = specifier; let specifier = match specifier.strip_prefix("npm:") { Some(s) => { // Strip leading slash, which might come from import map s.strip_prefix('/').unwrap_or(s) } None => { // don't allocate a string here and instead use a static string // because this is hit a lot when a url is not an npm specifier return Err(generic_error("Not an npm specifier")); } }; let parts = specifier.split('/').collect::>(); let name_part_len = if specifier.starts_with('@') { 2 } else { 1 }; if parts.len() < name_part_len { return Err(generic_error(format!("Not a valid package: {specifier}"))); } let name_parts = &parts[0..name_part_len]; let req = match NpmPackageReq::parse_from_parts(name_parts) { Ok(pkg_req) => pkg_req, Err(err) => { return Err(generic_error(format!( "Invalid npm specifier '{original_text}'. {err:#}" ))) } }; let sub_path = if parts.len() == name_parts.len() { None } else { let sub_path = parts[name_part_len..].join("/"); if sub_path.is_empty() { None } else { Some(sub_path) } }; if let Some(sub_path) = &sub_path { if let Some(at_index) = sub_path.rfind('@') { let (new_sub_path, version) = sub_path.split_at(at_index); let msg = format!( "Invalid package specifier 'npm:{req}/{sub_path}'. Did you mean to write 'npm:{req}{version}/{new_sub_path}'?" ); return Err(generic_error(msg)); } } Ok(NpmPackageReference { req, sub_path }) } } impl std::fmt::Display for NpmPackageReference { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(sub_path) = &self.sub_path { write!(f, "npm:{}/{}", self.req, sub_path) } else { write!(f, "npm:{}", self.req) } } } /// The name and version constraint component of an `NpmPackageReference`. #[derive( Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, )] pub struct NpmPackageReq { pub name: String, pub version_req: Option, } impl std::fmt::Display for NpmPackageReq { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.version_req { Some(req) => write!(f, "{}@{}", self.name, req), None => write!(f, "{}", self.name), } } } impl NpmPackageReq { pub fn from_str(text: &str) -> Result { let parts = text.split('/').collect::>(); match NpmPackageReq::parse_from_parts(&parts) { Ok(req) => Ok(req), Err(err) => { let msg = format!("Invalid npm package requirement '{text}'. {err:#}"); Err(generic_error(msg)) } } } fn parse_from_parts(name_parts: &[&str]) -> Result { assert!(!name_parts.is_empty()); // this should be provided the result of a string split let last_name_part = &name_parts[name_parts.len() - 1]; let (name, version_req) = if let Some(at_index) = last_name_part.rfind('@') { let version = &last_name_part[at_index + 1..]; let last_name_part = &last_name_part[..at_index]; let version_req = VersionReq::parse_from_specifier(version) .with_context(|| "Invalid version requirement.")?; let name = if name_parts.len() == 1 { last_name_part.to_string() } else { format!("{}/{}", name_parts[0], last_name_part) }; (name, Some(version_req)) } else { (name_parts.join("/"), None) }; if name.is_empty() { bail!("Did not contain a package name.") } Ok(Self { name, version_req }) } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; #[test] fn parse_npm_package_ref() { assert_eq!( NpmPackageReference::from_str("npm:@package/test").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "@package/test".to_string(), version_req: None, }, sub_path: None, } ); assert_eq!( NpmPackageReference::from_str("npm:@package/test@1").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "@package/test".to_string(), version_req: Some(VersionReq::parse_from_specifier("1").unwrap()), }, sub_path: None, } ); assert_eq!( NpmPackageReference::from_str("npm:@package/test@~1.1/sub_path").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "@package/test".to_string(), version_req: Some(VersionReq::parse_from_specifier("~1.1").unwrap()), }, sub_path: Some("sub_path".to_string()), } ); assert_eq!( NpmPackageReference::from_str("npm:@package/test/sub_path").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "@package/test".to_string(), version_req: None, }, sub_path: Some("sub_path".to_string()), } ); assert_eq!( NpmPackageReference::from_str("npm:test").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "test".to_string(), version_req: None, }, sub_path: None, } ); assert_eq!( NpmPackageReference::from_str("npm:test@^1.2").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "test".to_string(), version_req: Some(VersionReq::parse_from_specifier("^1.2").unwrap()), }, sub_path: None, } ); assert_eq!( NpmPackageReference::from_str("npm:test@~1.1/sub_path").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "test".to_string(), version_req: Some(VersionReq::parse_from_specifier("~1.1").unwrap()), }, sub_path: Some("sub_path".to_string()), } ); assert_eq!( NpmPackageReference::from_str("npm:@package/test/sub_path").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "@package/test".to_string(), version_req: None, }, sub_path: Some("sub_path".to_string()), } ); assert_eq!( NpmPackageReference::from_str("npm:@package") .err() .unwrap() .to_string(), "Not a valid package: @package" ); // should parse leading slash assert_eq!( NpmPackageReference::from_str("npm:/@package/test/sub_path").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "@package/test".to_string(), version_req: None, }, sub_path: Some("sub_path".to_string()), } ); assert_eq!( NpmPackageReference::from_str("npm:/test").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "test".to_string(), version_req: None, }, sub_path: None, } ); assert_eq!( NpmPackageReference::from_str("npm:/test/").unwrap(), NpmPackageReference { req: NpmPackageReq { name: "test".to_string(), version_req: None, }, sub_path: None, } ); // should error for no name assert_eq!( NpmPackageReference::from_str("npm:/") .err() .unwrap() .to_string(), "Invalid npm specifier 'npm:/'. Did not contain a package name." ); assert_eq!( NpmPackageReference::from_str("npm://test") .err() .unwrap() .to_string(), "Invalid npm specifier 'npm://test'. Did not contain a package name." ); } }