diff options
Diffstat (limited to 'cli/import_map.rs')
-rw-r--r-- | cli/import_map.rs | 2076 |
1 files changed, 2076 insertions, 0 deletions
diff --git a/cli/import_map.rs b/cli/import_map.rs new file mode 100644 index 000000000..d2916c198 --- /dev/null +++ b/cli/import_map.rs @@ -0,0 +1,2076 @@ +use deno::ErrBox; +use deno::ModuleSpecifier; +use indexmap::IndexMap; +use serde_json::Map; +use serde_json::Value; +use std::cmp::Ordering; +use std::error::Error; +use std::fmt; +use std::fs; +use std::io; +use url::Url; + +#[derive(Debug)] +pub struct ImportMapError { + pub msg: String, +} + +impl ImportMapError { + pub fn new(msg: &str) -> Self { + ImportMapError { + msg: msg.to_string(), + } + } +} + +impl fmt::Display for ImportMapError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.pad(&self.msg) + } +} + +impl Error for ImportMapError {} + +// NOTE: here is difference between deno and reference implementation - deno currently +// can't resolve URL with other schemes (eg. data:, about:, blob:) +const SUPPORTED_FETCH_SCHEMES: [&str; 3] = ["http", "https", "file"]; + +type SpecifierMap = IndexMap<String, Vec<ModuleSpecifier>>; +type ScopesMap = IndexMap<String, SpecifierMap>; + +#[derive(Debug)] +pub struct ImportMap { + base_url: String, + imports: SpecifierMap, + scopes: ScopesMap, +} + +impl ImportMap { + pub fn load(file_path: &str) -> Result<Self, ErrBox> { + let file_url = ModuleSpecifier::resolve_url_or_path(file_path)?.to_string(); + let resolved_path = std::env::current_dir().unwrap().join(file_path); + debug!( + "Attempt to load import map: {}", + resolved_path.to_str().unwrap() + ); + + // Load the contents of import map + let json_string = fs::read_to_string(&resolved_path).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Error retrieving import map file at \"{}\": {}", + resolved_path.to_str().unwrap(), + err.to_string() + ) + .as_str(), + ) + })?; + // The URL of the import map is the base URL for its values. + ImportMap::from_json(&file_url, &json_string).map_err(ErrBox::from) + } + + pub fn from_json( + base_url: &str, + json_string: &str, + ) -> Result<Self, ImportMapError> { + let v: Value = match serde_json::from_str(json_string) { + Ok(v) => v, + Err(_) => { + return Err(ImportMapError::new("Unable to parse import map JSON")); + } + }; + + match v { + Value::Object(_) => {} + _ => { + return Err(ImportMapError::new("Import map JSON must be an object")); + } + } + + let normalized_imports = match &v.get("imports") { + Some(imports_map) => { + if !imports_map.is_object() { + return Err(ImportMapError::new( + "Import map's 'imports' must be an object", + )); + } + + let imports_map = imports_map.as_object().unwrap(); + ImportMap::parse_specifier_map(imports_map, base_url) + } + None => IndexMap::new(), + }; + + let normalized_scopes = match &v.get("scopes") { + Some(scope_map) => { + if !scope_map.is_object() { + return Err(ImportMapError::new( + "Import map's 'scopes' must be an object", + )); + } + + let scope_map = scope_map.as_object().unwrap(); + ImportMap::parse_scope_map(scope_map, base_url)? + } + None => IndexMap::new(), + }; + + let import_map = ImportMap { + base_url: base_url.to_string(), + imports: normalized_imports, + scopes: normalized_scopes, + }; + + Ok(import_map) + } + + fn try_url_like_specifier(specifier: &str, base: &str) -> Option<Url> { + // this should never fail + if specifier.starts_with('/') + || specifier.starts_with("./") + || specifier.starts_with("../") + { + let base_url = Url::parse(base).unwrap(); + let url = base_url.join(specifier).unwrap(); + return Some(url); + } + + if let Ok(url) = Url::parse(specifier) { + if SUPPORTED_FETCH_SCHEMES.contains(&url.scheme()) { + return Some(url); + } + } + + None + } + + /// Parse provided key as import map specifier. + /// + /// Specifiers must be valid URLs (eg. "https://deno.land/x/std/testing/mod.ts") + /// or "bare" specifiers (eg. "moment"). + // TODO: add proper error handling: https://github.com/WICG/import-maps/issues/100 + fn normalize_specifier_key( + specifier_key: &str, + base_url: &str, + ) -> Option<String> { + // ignore empty keys + if specifier_key.is_empty() { + return None; + } + + if let Some(url) = + ImportMap::try_url_like_specifier(specifier_key, base_url) + { + return Some(url.to_string()); + } + + // "bare" specifier + Some(specifier_key.to_string()) + } + + /// Parse provided addresses as valid URLs. + /// + /// Non-valid addresses are skipped. + fn normalize_addresses( + specifier_key: &str, + base_url: &str, + potential_addresses: Vec<String>, + ) -> Vec<ModuleSpecifier> { + let mut normalized_addresses: Vec<ModuleSpecifier> = vec![]; + + for potential_address in potential_addresses { + let url = + match ImportMap::try_url_like_specifier(&potential_address, base_url) { + Some(url) => url, + None => continue, + }; + + let url_string = url.to_string(); + if specifier_key.ends_with('/') && !url_string.ends_with('/') { + eprintln!( + "Invalid target address {:?} for package specifier {:?}.\ + Package address targets must end with \"/\".", + url_string, specifier_key + ); + continue; + } + + normalized_addresses.push(url.into()); + } + + normalized_addresses + } + + /// Convert provided JSON map to valid SpecifierMap. + /// + /// From specification: + /// - order of iteration must be retained + /// - SpecifierMap's keys are sorted in longest and alphabetic order + fn parse_specifier_map( + json_map: &Map<String, Value>, + base_url: &str, + ) -> SpecifierMap { + let mut normalized_map: SpecifierMap = SpecifierMap::new(); + + // Order is preserved because of "preserve_order" feature of "serde_json". + for (specifier_key, value) in json_map.iter() { + let normalized_specifier_key = + match ImportMap::normalize_specifier_key(specifier_key, base_url) { + Some(s) => s, + None => continue, + }; + + let potential_addresses: Vec<String> = match value { + Value::String(address) => vec![address.to_string()], + Value::Array(address_array) => { + let mut string_addresses: Vec<String> = vec![]; + + for address in address_array { + match address { + Value::String(address) => { + string_addresses.push(address.to_string()) + } + _ => continue, + } + } + + string_addresses + } + Value::Null => vec![], + _ => vec![], + }; + + let normalized_address_array = ImportMap::normalize_addresses( + &normalized_specifier_key, + base_url, + potential_addresses, + ); + + debug!( + "normalized specifier {:?}; {:?}", + normalized_specifier_key, normalized_address_array + ); + normalized_map.insert(normalized_specifier_key, normalized_address_array); + } + + // Sort in longest and alphabetical order. + normalized_map.sort_by(|k1, _v1, k2, _v2| { + if k1.len() > k2.len() { + return Ordering::Less; + } else if k2.len() > k1.len() { + return Ordering::Greater; + } + + k2.cmp(k1) + }); + + normalized_map + } + + /// Convert provided JSON map to valid ScopeMap. + /// + /// From specification: + /// - order of iteration must be retained + /// - ScopeMap's keys are sorted in longest and alphabetic order + fn parse_scope_map( + scope_map: &Map<String, Value>, + base_url: &str, + ) -> Result<ScopesMap, ImportMapError> { + let mut normalized_map: ScopesMap = ScopesMap::new(); + + // Order is preserved because of "preserve_order" feature of "serde_json". + for (scope_prefix, potential_specifier_map) in scope_map.iter() { + if !potential_specifier_map.is_object() { + return Err(ImportMapError::new(&format!( + "The value for the {:?} scope prefix must be an object", + scope_prefix + ))); + } + + let potential_specifier_map = + potential_specifier_map.as_object().unwrap(); + + let scope_prefix_url = + match Url::parse(base_url).unwrap().join(scope_prefix) { + Ok(url) => { + if !SUPPORTED_FETCH_SCHEMES.contains(&url.scheme()) { + eprintln!( + "Invalid scope {:?}. Scope URLs must have a valid fetch scheme.", + url.to_string() + ); + continue; + } + url.to_string() + } + _ => continue, + }; + + let norm_map = + ImportMap::parse_specifier_map(potential_specifier_map, base_url); + + normalized_map.insert(scope_prefix_url, norm_map); + } + + // Sort in longest and alphabetical order. + normalized_map.sort_by(|k1, _v1, k2, _v2| { + if k1.len() > k2.len() { + return Ordering::Less; + } else if k2.len() > k1.len() { + return Ordering::Greater; + } + + k2.cmp(k1) + }); + + Ok(normalized_map) + } + + pub fn resolve_scopes_match( + scopes: &ScopesMap, + normalized_specifier: &str, + referrer: &str, + ) -> Result<Option<ModuleSpecifier>, ImportMapError> { + // exact-match + if let Some(scope_imports) = scopes.get(referrer) { + if let Ok(scope_match) = + ImportMap::resolve_imports_match(scope_imports, normalized_specifier) + { + // Return only if there was actual match (not None). + if scope_match.is_some() { + return Ok(scope_match); + } + } + } + + for (normalized_scope_key, scope_imports) in scopes.iter() { + if normalized_scope_key.ends_with('/') + && referrer.starts_with(normalized_scope_key) + { + if let Ok(scope_match) = + ImportMap::resolve_imports_match(scope_imports, normalized_specifier) + { + // Return only if there was actual match (not None). + if scope_match.is_some() { + return Ok(scope_match); + } + } + } + } + + Ok(None) + } + + // TODO: https://github.com/WICG/import-maps/issues/73#issuecomment-439327758 + // for some more optimized candidate implementations. + pub fn resolve_imports_match( + imports: &SpecifierMap, + normalized_specifier: &str, + ) -> Result<Option<ModuleSpecifier>, ImportMapError> { + // exact-match + if let Some(address_vec) = imports.get(normalized_specifier) { + if address_vec.is_empty() { + return Err(ImportMapError::new(&format!( + "Specifier {:?} was mapped to no addresses.", + normalized_specifier + ))); + } else if address_vec.len() == 1 { + let address = address_vec.first().unwrap(); + debug!( + "Specifier {:?} was mapped to {:?}.", + normalized_specifier, address + ); + return Ok(Some(address.clone())); + } else { + return Err(ImportMapError::new( + "Multi-address mappings are not yet supported", + )); + } + } + + // package-prefix match + // "most-specific wins", i.e. when there are multiple matching keys, + // choose the longest. + // https://github.com/WICG/import-maps/issues/102 + for (specifier_key, address_vec) in imports.iter() { + if specifier_key.ends_with('/') + && normalized_specifier.starts_with(specifier_key) + { + if address_vec.is_empty() { + return Err(ImportMapError::new(&format!("Specifier {:?} was mapped to no addresses (via prefix specifier key {:?}).", normalized_specifier, specifier_key))); + } else if address_vec.len() == 1 { + let address = address_vec.first().unwrap(); + let after_prefix = &normalized_specifier[specifier_key.len()..]; + + let base_url = address.as_url(); + if let Ok(url) = base_url.join(after_prefix) { + debug!("Specifier {:?} was mapped to {:?} (via prefix specifier key {:?}).", normalized_specifier, url, address); + return Ok(Some(ModuleSpecifier::from(url))); + } + + unreachable!(); + } else { + return Err(ImportMapError::new( + "Multi-address mappings are not yet supported", + )); + } + } + } + + debug!( + "Specifier {:?} was not mapped in import map.", + normalized_specifier + ); + + Ok(None) + } + + // TODO: add support for built-in modules + /// Currently we support two types of specifiers: URL (http://, https://, file://) + /// and "bare" (moment, jquery, lodash) + /// + /// Scenarios: + /// 1. import resolved using import map -> String + /// 2. import restricted by import map -> ImportMapError + /// 3. import not mapped -> None + pub fn resolve( + &self, + specifier: &str, + referrer: &str, + ) -> Result<Option<ModuleSpecifier>, ImportMapError> { + let resolved_url: Option<Url> = + ImportMap::try_url_like_specifier(specifier, referrer); + let normalized_specifier = match &resolved_url { + Some(url) => url.to_string(), + None => specifier.to_string(), + }; + + let scopes_match = ImportMap::resolve_scopes_match( + &self.scopes, + &normalized_specifier, + &referrer.to_string(), + )?; + + // match found in scopes map + if scopes_match.is_some() { + return Ok(scopes_match); + } + + let imports_match = + ImportMap::resolve_imports_match(&self.imports, &normalized_specifier)?; + + // match found in import map + if imports_match.is_some() { + return Ok(imports_match); + } + + // no match in import map but we got resolvable URL + if let Some(resolved_url) = resolved_url { + return Ok(Some(ModuleSpecifier::from(resolved_url))); + } + + Err(ImportMapError::new(&format!( + "Unmapped bare specifier {:?}", + normalized_specifier + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_nonexistent() { + let file_path = "nonexistent_import_map.json"; + assert!(ImportMap::load(file_path).is_err()); + } + + #[test] + fn from_json_1() { + let base_url = "https://deno.land"; + + // empty JSON + assert!(ImportMap::from_json(base_url, "{}").is_ok()); + + let non_object_strings = vec!["null", "true", "1", "\"foo\"", "[]"]; + + // invalid JSON + for non_object in non_object_strings.to_vec() { + assert!(ImportMap::from_json(base_url, non_object).is_err()); + } + + // invalid schema: 'imports' is non-object + for non_object in non_object_strings.to_vec() { + assert!(ImportMap::from_json( + base_url, + &format!("{{\"imports\": {}}}", non_object), + ) + .is_err()); + } + + // invalid schema: 'scopes' is non-object + for non_object in non_object_strings.to_vec() { + assert!(ImportMap::from_json( + base_url, + &format!("{{\"scopes\": {}}}", non_object), + ) + .is_err()); + } + } + + #[test] + fn from_json_2() { + let json_map = r#"{ + "imports": { + "foo": "https://example.com/1", + "bar": ["https://example.com/2"], + "fizz": null + } + }"#; + let result = ImportMap::from_json("https://deno.land", json_map); + assert!(result.is_ok()); + } + + #[test] + fn parse_specifier_keys_relative() { + // Should absolutize strings prefixed with ./, ../, or / into the corresponding URLs.. + let json_map = r#"{ + "imports": { + "./foo": "/dotslash", + "../foo": "/dotdotslash", + "/foo": "/slash" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert_eq!( + import_map + .imports + .get("https://base.example/path1/path2/foo") + .unwrap()[0], + "https://base.example/dotslash".to_string() + ); + assert_eq!( + import_map + .imports + .get("https://base.example/path1/foo") + .unwrap()[0], + "https://base.example/dotdotslash".to_string() + ); + assert_eq!( + import_map.imports.get("https://base.example/foo").unwrap()[0], + "https://base.example/slash".to_string() + ); + + // Should absolutize the literal strings ./, ../, or / with no suffix.. + let json_map = r#"{ + "imports": { + "./": "/dotslash/", + "../": "/dotdotslash/", + "/": "/slash/" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert_eq!( + import_map + .imports + .get("https://base.example/path1/path2/") + .unwrap()[0], + "https://base.example/dotslash/".to_string() + ); + assert_eq!( + import_map + .imports + .get("https://base.example/path1/") + .unwrap()[0], + "https://base.example/dotdotslash/".to_string() + ); + assert_eq!( + import_map.imports.get("https://base.example/").unwrap()[0], + "https://base.example/slash/".to_string() + ); + + // Should treat percent-encoded variants of ./, ../, or / as bare specifiers.. + let json_map = r#"{ + "imports": { + "%2E/": "/dotSlash1/", + "%2E%2E/": "/dotDotSlash1/", + ".%2F": "/dotSlash2", + "..%2F": "/dotDotSlash2", + "%2F": "/slash2", + "%2E%2F": "/dotSlash3", + "%2E%2E%2F": "/dotDotSlash3" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert_eq!( + import_map.imports.get("%2E/").unwrap()[0], + "https://base.example/dotSlash1/".to_string() + ); + assert_eq!( + import_map.imports.get("%2E%2E/").unwrap()[0], + "https://base.example/dotDotSlash1/".to_string() + ); + assert_eq!( + import_map.imports.get(".%2F").unwrap()[0], + "https://base.example/dotSlash2".to_string() + ); + assert_eq!( + import_map.imports.get("..%2F").unwrap()[0], + "https://base.example/dotDotSlash2".to_string() + ); + assert_eq!( + import_map.imports.get("%2F").unwrap()[0], + "https://base.example/slash2".to_string() + ); + assert_eq!( + import_map.imports.get("%2E%2F").unwrap()[0], + "https://base.example/dotSlash3".to_string() + ); + assert_eq!( + import_map.imports.get("%2E%2E%2F").unwrap()[0], + "https://base.example/dotDotSlash3".to_string() + ); + } + + #[test] + fn parse_specifier_keys_absolute() { + // Should only accept absolute URL specifier keys with fetch schemes,. + // treating others as bare specifiers. + let json_map = r#"{ + "imports": { + "file:///good": "/file", + "http://good/": "/http/", + "https://good/": "/https/", + "about:bad": "/about", + "blob:bad": "/blob", + "data:bad": "/data", + "filesystem:bad": "/filesystem", + "ftp://bad/": "/ftp/", + "import:bad": "/import", + "mailto:bad": "/mailto", + "javascript:bad": "/javascript", + "wss:bad": "/wss" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert_eq!( + import_map.imports.get("http://good/").unwrap()[0], + "https://base.example/http/".to_string() + ); + assert_eq!( + import_map.imports.get("https://good/").unwrap()[0], + "https://base.example/https/".to_string() + ); + assert_eq!( + import_map.imports.get("file:///good").unwrap()[0], + "https://base.example/file".to_string() + ); + assert_eq!( + import_map.imports.get("http://good/").unwrap()[0], + "https://base.example/http/".to_string() + ); + assert_eq!( + import_map.imports.get("import:bad").unwrap()[0], + "https://base.example/import".to_string() + ); + assert_eq!( + import_map.imports.get("mailto:bad").unwrap()[0], + "https://base.example/mailto".to_string() + ); + assert_eq!( + import_map.imports.get("javascript:bad").unwrap()[0], + "https://base.example/javascript".to_string() + ); + assert_eq!( + import_map.imports.get("wss:bad").unwrap()[0], + "https://base.example/wss".to_string() + ); + assert_eq!( + import_map.imports.get("about:bad").unwrap()[0], + "https://base.example/about".to_string() + ); + assert_eq!( + import_map.imports.get("blob:bad").unwrap()[0], + "https://base.example/blob".to_string() + ); + assert_eq!( + import_map.imports.get("data:bad").unwrap()[0], + "https://base.example/data".to_string() + ); + + // Should parse absolute URLs, treating unparseable ones as bare specifiers.. + let json_map = r#"{ + "imports": { + "https://ex ample.org/": "/unparseable1/", + "https://example.com:demo": "/unparseable2", + "http://[www.example.com]/": "/unparseable3/", + "https:example.org": "/invalidButParseable1/", + "https://///example.com///": "/invalidButParseable2/", + "https://example.net": "/prettyNormal/", + "https://ex%41mple.com/": "/percentDecoding/", + "https://example.com/%41": "/noPercentDecoding" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert_eq!( + import_map.imports.get("https://ex ample.org/").unwrap()[0], + "https://base.example/unparseable1/".to_string() + ); + assert_eq!( + import_map.imports.get("https://example.com:demo").unwrap()[0], + "https://base.example/unparseable2".to_string() + ); + assert_eq!( + import_map.imports.get("http://[www.example.com]/").unwrap()[0], + "https://base.example/unparseable3/".to_string() + ); + assert_eq!( + import_map.imports.get("https://example.org/").unwrap()[0], + "https://base.example/invalidButParseable1/".to_string() + ); + assert_eq!( + import_map.imports.get("https://example.com///").unwrap()[0], + "https://base.example/invalidButParseable2/".to_string() + ); + assert_eq!( + import_map.imports.get("https://example.net/").unwrap()[0], + "https://base.example/prettyNormal/".to_string() + ); + assert_eq!( + import_map.imports.get("https://example.com/").unwrap()[0], + "https://base.example/percentDecoding/".to_string() + ); + assert_eq!( + import_map.imports.get("https://example.com/%41").unwrap()[0], + "https://base.example/noPercentDecoding".to_string() + ); + } + + #[test] + fn parse_scope_keys_relative() { + // Should work with no prefix.. + let json_map = r#"{ + "scopes": { + "foo": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/foo")); + + // Should work with ./, ../, and / prefixes.. + let json_map = r#"{ + "scopes": { + "./foo": {}, + "../foo": {}, + "/foo": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/foo")); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/foo")); + assert!(import_map.scopes.contains_key("https://base.example/foo")); + + // Should work with /s, ?s, and #s.. + let json_map = r#"{ + "scopes": { + "foo/bar?baz#qux": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/foo/bar?baz#qux")); + + // Should work with an empty string scope key.. + let json_map = r#"{ + "scopes": { + "": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/path3")); + + // Should work with / suffixes.. + let json_map = r#"{ + "scopes": { + "foo/": {}, + "./foo/": {}, + "../foo/": {}, + "/foo/": {}, + "/foo//": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/foo/")); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/foo/")); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/foo/")); + assert!(import_map.scopes.contains_key("https://base.example/foo/")); + assert!(import_map.scopes.contains_key("https://base.example/foo//")); + + // Should deduplicate based on URL parsing rules.. + let json_map = r#"{ + "scopes": { + "foo/\\": {}, + "foo//": {}, + "foo\\\\": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/foo//")); + assert_eq!(import_map.scopes.len(), 1); + } + + #[test] + fn parse_scope_keys_absolute() { + // Should only accept absolute URL scope keys with fetch schemes.. + let json_map = r#"{ + "scopes": { + "http://good/": {}, + "https://good/": {}, + "file:///good": {}, + "about:bad": {}, + "blob:bad": {}, + "data:bad": {}, + "filesystem:bad": {}, + "ftp://bad/": {}, + "import:bad": {}, + "mailto:bad": {}, + "javascript:bad": {}, + "wss:bad": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + assert!(import_map.scopes.contains_key("http://good/")); + assert!(import_map.scopes.contains_key("https://good/")); + assert!(import_map.scopes.contains_key("file:///good")); + assert_eq!(import_map.scopes.len(), 3); + + // Should parse absolute URL scope keys, ignoring unparseable ones.. + let json_map = r#"{ + "scopes": { + "https://ex ample.org/": {}, + "https://example.com:demo": {}, + "http://[www.example.com]/": {}, + "https:example.org": {}, + "https://///example.com///": {}, + "https://example.net": {}, + "https://ex%41mple.com/foo/": {}, + "https://example.com/%41": {} + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + // tricky case! remember we have a base URL + assert!(import_map + .scopes + .contains_key("https://base.example/path1/path2/example.org")); + assert!(import_map.scopes.contains_key("https://example.com///")); + assert!(import_map.scopes.contains_key("https://example.net/")); + assert!(import_map.scopes.contains_key("https://example.com/foo/")); + assert!(import_map.scopes.contains_key("https://example.com/%41")); + assert_eq!(import_map.scopes.len(), 5); + } + + #[test] + fn parse_addresses_relative_url_like() { + // Should accept strings prefixed with ./, ../, or /.. + let json_map = r#"{ + "imports": { + "dotSlash": "./foo", + "dotDotSlash": "../foo", + "slash": "/foo" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert_eq!( + import_map.imports.get("dotSlash").unwrap(), + &vec!["https://base.example/path1/path2/foo".to_string()] + ); + assert_eq!( + import_map.imports.get("dotDotSlash").unwrap(), + &vec!["https://base.example/path1/foo".to_string()] + ); + assert_eq!( + import_map.imports.get("slash").unwrap(), + &vec!["https://base.example/foo".to_string()] + ); + + // Should accept the literal strings ./, ../, or / with no suffix.. + let json_map = r#"{ + "imports": { + "dotSlash": "./", + "dotDotSlash": "../", + "slash": "/" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert_eq!( + import_map.imports.get("dotSlash").unwrap(), + &vec!["https://base.example/path1/path2/".to_string()] + ); + assert_eq!( + import_map.imports.get("dotDotSlash").unwrap(), + &vec!["https://base.example/path1/".to_string()] + ); + assert_eq!( + import_map.imports.get("slash").unwrap(), + &vec!["https://base.example/".to_string()] + ); + + // Should ignore percent-encoded variants of ./, ../, or /.. + let json_map = r#"{ + "imports": { + "dotSlash1": "%2E/", + "dotDotSlash1": "%2E%2E/", + "dotSlash2": ".%2F", + "dotDotSlash2": "..%2F", + "slash2": "%2F", + "dotSlash3": "%2E%2F", + "dotDotSlash3": "%2E%2E%2F" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert!(import_map.imports.get("dotSlash1").unwrap().is_empty()); + assert!(import_map.imports.get("dotDotSlash1").unwrap().is_empty()); + assert!(import_map.imports.get("dotSlash2").unwrap().is_empty()); + assert!(import_map.imports.get("dotDotSlash2").unwrap().is_empty()); + assert!(import_map.imports.get("slash2").unwrap().is_empty()); + assert!(import_map.imports.get("dotSlash3").unwrap().is_empty()); + assert!(import_map.imports.get("dotDotSlash3").unwrap().is_empty()); + } + + #[test] + fn parse_addresses_absolute_with_fetch_schemes() { + // Should only accept absolute URL addresses with fetch schemes.. + let json_map = r#"{ + "imports": { + "http": "http://good/", + "https": "https://good/", + "file": "file:///good", + "about": "about:bad", + "blob": "blob:bad", + "data": "data:bad", + "filesystem": "filesystem:bad", + "ftp": "ftp://good/", + "import": "import:bad", + "mailto": "mailto:bad", + "javascript": "javascript:bad", + "wss": "wss:bad" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert_eq!( + import_map.imports.get("file").unwrap(), + &vec!["file:///good".to_string()] + ); + assert_eq!( + import_map.imports.get("http").unwrap(), + &vec!["http://good/".to_string()] + ); + assert_eq!( + import_map.imports.get("https").unwrap(), + &vec!["https://good/".to_string()] + ); + + assert!(import_map.imports.get("about").unwrap().is_empty()); + assert!(import_map.imports.get("blob").unwrap().is_empty()); + assert!(import_map.imports.get("data").unwrap().is_empty()); + assert!(import_map.imports.get("filesystem").unwrap().is_empty()); + assert!(import_map.imports.get("ftp").unwrap().is_empty()); + assert!(import_map.imports.get("import").unwrap().is_empty()); + assert!(import_map.imports.get("mailto").unwrap().is_empty()); + assert!(import_map.imports.get("javascript").unwrap().is_empty()); + assert!(import_map.imports.get("wss").unwrap().is_empty()); + } + + #[test] + fn parse_addresses_absolute_with_fetch_schemes_arrays() { + // Should only accept absolute URL addresses with fetch schemes inside arrays.. + let json_map = r#"{ + "imports": { + "http": ["http://good/"], + "https": ["https://good/"], + "file": ["file:///good"], + "about": ["about:bad"], + "blob": ["blob:bad"], + "data": ["data:bad"], + "filesystem": ["filesystem:bad"], + "ftp": ["ftp://good/"], + "import": ["import:bad"], + "mailto": ["mailto:bad"], + "javascript": ["javascript:bad"], + "wss": ["wss:bad"] + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert_eq!( + import_map.imports.get("file").unwrap(), + &vec!["file:///good".to_string()] + ); + assert_eq!( + import_map.imports.get("http").unwrap(), + &vec!["http://good/".to_string()] + ); + assert_eq!( + import_map.imports.get("https").unwrap(), + &vec!["https://good/".to_string()] + ); + + assert!(import_map.imports.get("about").unwrap().is_empty()); + assert!(import_map.imports.get("blob").unwrap().is_empty()); + assert!(import_map.imports.get("data").unwrap().is_empty()); + assert!(import_map.imports.get("filesystem").unwrap().is_empty()); + assert!(import_map.imports.get("ftp").unwrap().is_empty()); + assert!(import_map.imports.get("import").unwrap().is_empty()); + assert!(import_map.imports.get("mailto").unwrap().is_empty()); + assert!(import_map.imports.get("javascript").unwrap().is_empty()); + assert!(import_map.imports.get("wss").unwrap().is_empty()); + } + + #[test] + fn parse_addresses_unparseable() { + // Should parse absolute URLs, ignoring unparseable ones.. + let json_map = r#"{ + "imports": { + "unparseable1": "https://ex ample.org/", + "unparseable2": "https://example.com:demo", + "unparseable3": "http://[www.example.com]/", + "invalidButParseable1": "https:example.org", + "invalidButParseable2": "https://///example.com///", + "prettyNormal": "https://example.net", + "percentDecoding": "https://ex%41mple.com/", + "noPercentDecoding": "https://example.com/%41" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert_eq!( + import_map.imports.get("invalidButParseable1").unwrap(), + &vec!["https://example.org/".to_string()] + ); + assert_eq!( + import_map.imports.get("invalidButParseable2").unwrap(), + &vec!["https://example.com///".to_string()] + ); + assert_eq!( + import_map.imports.get("prettyNormal").unwrap(), + &vec!["https://example.net/".to_string()] + ); + assert_eq!( + import_map.imports.get("percentDecoding").unwrap(), + &vec!["https://example.com/".to_string()] + ); + assert_eq!( + import_map.imports.get("noPercentDecoding").unwrap(), + &vec!["https://example.com/%41".to_string()] + ); + + assert!(import_map.imports.get("unparseable1").unwrap().is_empty()); + assert!(import_map.imports.get("unparseable2").unwrap().is_empty()); + assert!(import_map.imports.get("unparseable3").unwrap().is_empty()); + } + + #[test] + fn parse_addresses_unparseable_arrays() { + // Should parse absolute URLs, ignoring unparseable ones inside arrays.. + let json_map = r#"{ + "imports": { + "unparseable1": ["https://ex ample.org/"], + "unparseable2": ["https://example.com:demo"], + "unparseable3": ["http://[www.example.com]/"], + "invalidButParseable1": ["https:example.org"], + "invalidButParseable2": ["https://///example.com///"], + "prettyNormal": ["https://example.net"], + "percentDecoding": ["https://ex%41mple.com/"], + "noPercentDecoding": ["https://example.com/%41"] + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert_eq!( + import_map.imports.get("invalidButParseable1").unwrap(), + &vec!["https://example.org/".to_string()] + ); + assert_eq!( + import_map.imports.get("invalidButParseable2").unwrap(), + &vec!["https://example.com///".to_string()] + ); + assert_eq!( + import_map.imports.get("prettyNormal").unwrap(), + &vec!["https://example.net/".to_string()] + ); + assert_eq!( + import_map.imports.get("percentDecoding").unwrap(), + &vec!["https://example.com/".to_string()] + ); + assert_eq!( + import_map.imports.get("noPercentDecoding").unwrap(), + &vec!["https://example.com/%41".to_string()] + ); + + assert!(import_map.imports.get("unparseable1").unwrap().is_empty()); + assert!(import_map.imports.get("unparseable2").unwrap().is_empty()); + assert!(import_map.imports.get("unparseable3").unwrap().is_empty()); + } + + #[test] + fn parse_addresses_mismatched_trailing_slashes() { + // Should parse absolute URLs, ignoring unparseable ones inside arrays.. + let json_map = r#"{ + "imports": { + "trailer/": "/notrailer" + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert!(import_map.imports.get("trailer/").unwrap().is_empty()); + // TODO: I'd be good to assert that warning was shown + } + + #[test] + fn parse_addresses_mismatched_trailing_slashes_array() { + // Should warn for a mismatch alone in an array.. + let json_map = r#"{ + "imports": { + "trailer/": ["/notrailer"] + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert!(import_map.imports.get("trailer/").unwrap().is_empty()); + // TODO: I'd be good to assert that warning was shown + } + + #[test] + fn parse_addresses_mismatched_trailing_slashes_with_nonmismatched_array() { + // Should warn for a mismatch alone in an array.. + let json_map = r#"{ + "imports": { + "trailer/": ["/atrailer/", "/notrailer"] + } + }"#; + let import_map = + ImportMap::from_json("https://base.example/path1/path2/path3", json_map) + .unwrap(); + + assert_eq!( + import_map.imports.get("trailer/").unwrap(), + &vec!["https://base.example/atrailer/".to_string()] + ); + // TODO: I'd be good to assert that warning was shown + } + + #[test] + fn parse_addresses_other_invalid() { + // Should ignore unprefixed strings that are not absolute URLs. + for bad in &["bar", "\\bar", "~bar", "#bar", "?bar"] { + let json_map = json!({ + "imports": { + "foo": bad + } + }); + let import_map = ImportMap::from_json( + "https://base.example/path1/path2/path3", + &json_map.to_string(), + ) + .unwrap(); + + assert!(import_map.imports.get("foo").unwrap().is_empty()); + } + } + + fn get_empty_import_map() -> ImportMap { + ImportMap { + base_url: "https://example.com/app/main.ts".to_string(), + imports: IndexMap::new(), + scopes: IndexMap::new(), + } + } + + fn assert_resolve( + result: Result<Option<ModuleSpecifier>, ImportMapError>, + expected_url: &str, + ) { + let maybe_url = result + .unwrap_or_else(|err| panic!("ImportMap::resolve failed: {:?}", err)); + let resolved_url = + maybe_url.unwrap_or_else(|| panic!("Unexpected None resolved URL")); + assert_eq!(resolved_url, expected_url.to_string()); + } + + #[test] + fn resolve_unmapped_relative_specifiers() { + let referrer_url = "https://example.com/js/script.ts"; + let import_map = get_empty_import_map(); + + // Should resolve ./ specifiers as URLs. + assert_resolve( + import_map.resolve("./foo", referrer_url), + "https://example.com/js/foo", + ); + assert_resolve( + import_map.resolve("./foo/bar", referrer_url), + "https://example.com/js/foo/bar", + ); + assert_resolve( + import_map.resolve("./foo/../bar", referrer_url), + "https://example.com/js/bar", + ); + assert_resolve( + import_map.resolve("./foo/../../bar", referrer_url), + "https://example.com/bar", + ); + + // Should resolve ../ specifiers as URLs. + assert_resolve( + import_map.resolve("../foo", referrer_url), + "https://example.com/foo", + ); + assert_resolve( + import_map.resolve("../foo/bar", referrer_url), + "https://example.com/foo/bar", + ); + assert_resolve( + import_map.resolve("../../../foo/bar", referrer_url), + "https://example.com/foo/bar", + ); + } + + #[test] + fn resolve_unmapped_absolute_specifiers() { + let referrer_url = "https://example.com/js/script.ts"; + let import_map = get_empty_import_map(); + + // Should resolve / specifiers as URLs. + assert_resolve( + import_map.resolve("/foo", referrer_url), + "https://example.com/foo", + ); + assert_resolve( + import_map.resolve("/foo/bar", referrer_url), + "https://example.com/foo/bar", + ); + assert_resolve( + import_map.resolve("../../foo/bar", referrer_url), + "https://example.com/foo/bar", + ); + assert_resolve( + import_map.resolve("/../foo/../bar", referrer_url), + "https://example.com/bar", + ); + + // Should parse absolute fetch-scheme URLs. + assert_resolve( + import_map.resolve("https://example.net", referrer_url), + "https://example.net/", + ); + assert_resolve( + import_map.resolve("https://ex%41mple.com/", referrer_url), + "https://example.com/", + ); + assert_resolve( + import_map.resolve("https:example.org", referrer_url), + "https://example.org/", + ); + assert_resolve( + import_map.resolve("https://///example.com///", referrer_url), + "https://example.com///", + ); + } + + #[test] + fn resolve_unmapped_bad_specifiers() { + let referrer_url = "https://example.com/js/script.ts"; + let import_map = get_empty_import_map(); + + // Should fail for absolute non-fetch-scheme URLs. + assert!(import_map.resolve("about:good", referrer_url).is_err()); + assert!(import_map.resolve("mailto:bad", referrer_url).is_err()); + assert!(import_map.resolve("import:bad", referrer_url).is_err()); + assert!(import_map.resolve("javascript:bad", referrer_url).is_err()); + assert!(import_map.resolve("wss:bad", referrer_url).is_err()); + + // Should fail for string not parseable as absolute URLs and not starting with ./, ../ or /. + assert!(import_map.resolve("foo", referrer_url).is_err()); + assert!(import_map.resolve("\\foo", referrer_url).is_err()); + assert!(import_map.resolve(":foo", referrer_url).is_err()); + assert!(import_map.resolve("@foo", referrer_url).is_err()); + assert!(import_map.resolve("%2E/foo", referrer_url).is_err()); + assert!(import_map.resolve("%2E%2Efoo", referrer_url).is_err()); + assert!(import_map.resolve(".%2Efoo", referrer_url).is_err()); + assert!(import_map + .resolve("https://ex ample.org", referrer_url) + .is_err()); + assert!(import_map + .resolve("https://example.org:deno", referrer_url) + .is_err()); + assert!(import_map + .resolve("https://[example.org]", referrer_url) + .is_err()); + } + + #[test] + fn resolve_imports_mapped() { + let base_url = "https://example.com/app/main.ts"; + let referrer_url = "https://example.com/js/script.ts"; + + // Should fail when mapping is to an empty array. + let json_map = r#"{ + "imports": { + "moment": null, + "lodash": [] + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + assert!(import_map.resolve("moment", referrer_url).is_err()); + assert!(import_map.resolve("lodash", referrer_url).is_err()); + } + + #[test] + fn resolve_imports_package_like_modules() { + let base_url = "https://example.com/app/main.ts"; + let referrer_url = "https://example.com/js/script.ts"; + + let json_map = r#"{ + "imports": { + "moment": "/deps/moment/src/moment.js", + "moment/": "/deps/moment/src/", + "lodash-dot": "./deps/lodash-es/lodash.js", + "lodash-dot/": "./deps/lodash-es/", + "lodash-dotdot": "../deps/lodash-es/lodash.js", + "lodash-dotdot/": "../deps/lodash-es/", + "nowhere/": [] + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + // Should work for package main modules. + assert_resolve( + import_map.resolve("moment", referrer_url), + "https://example.com/deps/moment/src/moment.js", + ); + assert_resolve( + import_map.resolve("lodash-dot", referrer_url), + "https://example.com/app/deps/lodash-es/lodash.js", + ); + assert_resolve( + import_map.resolve("lodash-dotdot", referrer_url), + "https://example.com/deps/lodash-es/lodash.js", + ); + + // Should work for package submodules. + assert_resolve( + import_map.resolve("moment/foo", referrer_url), + "https://example.com/deps/moment/src/foo", + ); + assert_resolve( + import_map.resolve("lodash-dot/foo", referrer_url), + "https://example.com/app/deps/lodash-es/foo", + ); + assert_resolve( + import_map.resolve("lodash-dotdot/foo", referrer_url), + "https://example.com/deps/lodash-es/foo", + ); + + // Should work for package names that end in a slash. + assert_resolve( + import_map.resolve("moment/", referrer_url), + "https://example.com/deps/moment/src/", + ); + + // Should fail for package modules that are not declared. + assert!(import_map.resolve("underscore/", referrer_url).is_err()); + assert!(import_map.resolve("underscore/foo", referrer_url).is_err()); + + // Should fail for package submodules that map to nowhere. + assert!(import_map.resolve("nowhere/foo", referrer_url).is_err()); + } + + #[test] + fn resolve_imports_tricky_specifiers() { + let base_url = "https://example.com/app/main.ts"; + let referrer_url = "https://example.com/js/script.ts"; + + let json_map = r#"{ + "imports": { + "package/withslash": "/deps/package-with-slash/index.mjs", + "not-a-package": "/lib/not-a-package.mjs", + ".": "/lib/dot.mjs", + "..": "/lib/dotdot.mjs", + "..\\\\": "/lib/dotdotbackslash.mjs", + "%2E": "/lib/percent2e.mjs", + "%2F": "/lib/percent2f.mjs" + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + // Should work for explicitly-mapped specifiers that happen to have a slash. + assert_resolve( + import_map.resolve("package/withslash", referrer_url), + "https://example.com/deps/package-with-slash/index.mjs", + ); + + // Should work when the specifier has punctuation. + assert_resolve( + import_map.resolve(".", referrer_url), + "https://example.com/lib/dot.mjs", + ); + assert_resolve( + import_map.resolve("..", referrer_url), + "https://example.com/lib/dotdot.mjs", + ); + assert_resolve( + import_map.resolve("..\\\\", referrer_url), + "https://example.com/lib/dotdotbackslash.mjs", + ); + assert_resolve( + import_map.resolve("%2E", referrer_url), + "https://example.com/lib/percent2e.mjs", + ); + assert_resolve( + import_map.resolve("%2F", referrer_url), + "https://example.com/lib/percent2f.mjs", + ); + + // Should fail for attempting to get a submodule of something not declared with a trailing slash. + assert!(import_map + .resolve("not-a-package/foo", referrer_url) + .is_err()); + } + + #[test] + fn resolve_imports_url_like_specifier() { + let base_url = "https://example.com/app/main.ts"; + let referrer_url = "https://example.com/js/script.ts"; + + let json_map = r#"{ + "imports": { + "/node_modules/als-polyfill/index.mjs": "std:kv-storage", + "/lib/foo.mjs": "./more/bar.mjs", + "./dotrelative/foo.mjs": "/lib/dot.mjs", + "../dotdotrelative/foo.mjs": "/lib/dotdot.mjs", + "/lib/no.mjs": null, + "./dotrelative/no.mjs": [], + "/": "/lib/slash-only/", + "./": "/lib/dotslash-only/", + "/test/": "/lib/url-trailing-slash/", + "./test/": "/lib/url-trailing-slash-dot/", + "/test": "/lib/test1.mjs", + "../test": "/lib/test2.mjs" + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + // Should remap to other URLs. + assert_resolve( + import_map.resolve("https://example.com/lib/foo.mjs", referrer_url), + "https://example.com/app/more/bar.mjs", + ); + assert_resolve( + import_map.resolve("https://///example.com/lib/foo.mjs", referrer_url), + "https://example.com/app/more/bar.mjs", + ); + assert_resolve( + import_map.resolve("/lib/foo.mjs", referrer_url), + "https://example.com/app/more/bar.mjs", + ); + assert_resolve( + import_map + .resolve("https://example.com/app/dotrelative/foo.mjs", referrer_url), + "https://example.com/lib/dot.mjs", + ); + assert_resolve( + import_map.resolve("../app/dotrelative/foo.mjs", referrer_url), + "https://example.com/lib/dot.mjs", + ); + assert_resolve( + import_map + .resolve("https://example.com/dotdotrelative/foo.mjs", referrer_url), + "https://example.com/lib/dotdot.mjs", + ); + assert_resolve( + import_map.resolve("../dotdotrelative/foo.mjs", referrer_url), + "https://example.com/lib/dotdot.mjs", + ); + + // Should fail for URLs that remap to empty arrays. + assert!(import_map + .resolve("https://example.com/lib/no.mjs", referrer_url) + .is_err()); + assert!(import_map.resolve("/lib/no.mjs", referrer_url).is_err()); + assert!(import_map.resolve("../lib/no.mjs", referrer_url).is_err()); + assert!(import_map + .resolve("https://example.com/app/dotrelative/no.mjs", referrer_url) + .is_err()); + assert!(import_map + .resolve("/app/dotrelative/no.mjs", referrer_url) + .is_err()); + assert!(import_map + .resolve("../app/dotrelative/no.mjs", referrer_url) + .is_err()); + + // Should remap URLs that are just composed from / and .. + assert_resolve( + import_map.resolve("https://example.com/", referrer_url), + "https://example.com/lib/slash-only/", + ); + assert_resolve( + import_map.resolve("/", referrer_url), + "https://example.com/lib/slash-only/", + ); + assert_resolve( + import_map.resolve("../", referrer_url), + "https://example.com/lib/slash-only/", + ); + assert_resolve( + import_map.resolve("https://example.com/app/", referrer_url), + "https://example.com/lib/dotslash-only/", + ); + assert_resolve( + import_map.resolve("/app/", referrer_url), + "https://example.com/lib/dotslash-only/", + ); + assert_resolve( + import_map.resolve("../app/", referrer_url), + "https://example.com/lib/dotslash-only/", + ); + + // Should remap URLs that are prefix-matched by keys with trailing slashes. + assert_resolve( + import_map.resolve("/test/foo.mjs", referrer_url), + "https://example.com/lib/url-trailing-slash/foo.mjs", + ); + assert_resolve( + import_map.resolve("https://example.com/app/test/foo.mjs", referrer_url), + "https://example.com/lib/url-trailing-slash-dot/foo.mjs", + ); + + // Should use the last entry's address when URL-like specifiers parse to the same absolute URL. + // + // NOTE: this works properly because of "preserve_order" feature flag to "serde_json" crate + assert_resolve( + import_map.resolve("/test", referrer_url), + "https://example.com/lib/test2.mjs", + ); + } + + #[test] + fn resolve_imports_overlapping_entities_with_trailing_slashes() { + let base_url = "https://example.com/app/main.ts"; + let referrer_url = "https://example.com/js/script.ts"; + + // Should favor the most-specific key (no empty arrays). + { + let json_map = r#"{ + "imports": { + "a": "/1", + "a/": "/2/", + "a/b": "/3", + "a/b/": "/4/" + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + assert_resolve( + import_map.resolve("a", referrer_url), + "https://example.com/1", + ); + assert_resolve( + import_map.resolve("a/", referrer_url), + "https://example.com/2/", + ); + assert_resolve( + import_map.resolve("a/b", referrer_url), + "https://example.com/3", + ); + assert_resolve( + import_map.resolve("a/b/", referrer_url), + "https://example.com/4/", + ); + assert_resolve( + import_map.resolve("a/b/c", referrer_url), + "https://example.com/4/c", + ); + } + + // Should favor the most-specific key when empty arrays are involved for less-specific keys. + { + let json_map = r#"{ + "imports": { + "a": [], + "a/": [], + "a/b": "/3", + "a/b/": "/4/" + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + assert!(import_map.resolve("a", referrer_url).is_err()); + assert!(import_map.resolve("a/", referrer_url).is_err()); + assert!(import_map.resolve("a/x", referrer_url).is_err()); + assert_resolve( + import_map.resolve("a/b", referrer_url), + "https://example.com/3", + ); + assert_resolve( + import_map.resolve("a/b/", referrer_url), + "https://example.com/4/", + ); + assert_resolve( + import_map.resolve("a/b/c", referrer_url), + "https://example.com/4/c", + ); + assert!(import_map.resolve("a/x/c", referrer_url).is_err()); + } + } + + #[test] + fn resolve_scopes_map_to_empty_array() { + let base_url = "https://example.com/app/main.ts"; + let referrer_url = "https://example.com/js"; + + let json_map = r#"{ + "scopes": { + "/js/": { + "moment": "null", + "lodash": [] + } + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + assert!(import_map.resolve("moment", referrer_url).is_err()); + assert!(import_map.resolve("lodash", referrer_url).is_err()); + } + + #[test] + fn resolve_scopes_exact_vs_prefix_matching() { + let base_url = "https://example.com/app/main.ts"; + + let json_map = r#"{ + "scopes": { + "/js": { + "moment": "/only-triggered-by-exact/moment", + "moment/": "/only-triggered-by-exact/moment/" + }, + "/js/": { + "moment": "/triggered-by-any-subpath/moment", + "moment/": "/triggered-by-any-subpath/moment/" + } + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + let js_non_dir = "https://example.com/js"; + let js_in_dir = "https://example.com/js/app.mjs"; + let with_js_prefix = "https://example.com/jsiscool"; + + assert_resolve( + import_map.resolve("moment", js_non_dir), + "https://example.com/only-triggered-by-exact/moment", + ); + assert_resolve( + import_map.resolve("moment/foo", js_non_dir), + "https://example.com/only-triggered-by-exact/moment/foo", + ); + assert_resolve( + import_map.resolve("moment", js_in_dir), + "https://example.com/triggered-by-any-subpath/moment", + ); + assert_resolve( + import_map.resolve("moment/foo", js_in_dir), + "https://example.com/triggered-by-any-subpath/moment/foo", + ); + assert!(import_map.resolve("moment", with_js_prefix).is_err()); + assert!(import_map.resolve("moment/foo", with_js_prefix).is_err()); + } + + #[test] + fn resolve_scopes_only_exact_in_map() { + let base_url = "https://example.com/app/main.ts"; + + let json_map = r#"{ + "scopes": { + "/js": { + "moment": "/only-triggered-by-exact/moment", + "moment/": "/only-triggered-by-exact/moment/" + } + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + // Should match correctly when only an exact match is in the map. + let js_non_dir = "https://example.com/js"; + let js_in_dir = "https://example.com/js/app.mjs"; + let with_js_prefix = "https://example.com/jsiscool"; + + assert_resolve( + import_map.resolve("moment", js_non_dir), + "https://example.com/only-triggered-by-exact/moment", + ); + assert_resolve( + import_map.resolve("moment/foo", js_non_dir), + "https://example.com/only-triggered-by-exact/moment/foo", + ); + assert!(import_map.resolve("moment", js_in_dir).is_err()); + assert!(import_map.resolve("moment/foo", js_in_dir).is_err()); + assert!(import_map.resolve("moment", with_js_prefix).is_err()); + assert!(import_map.resolve("moment/foo", with_js_prefix).is_err()); + } + + #[test] + fn resolve_scopes_only_prefix_in_map() { + let base_url = "https://example.com/app/main.ts"; + + let json_map = r#"{ + "scopes": { + "/js/": { + "moment": "/triggered-by-any-subpath/moment", + "moment/": "/triggered-by-any-subpath/moment/" + } + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + // Should match correctly when only a prefix match is in the map. + let js_non_dir = "https://example.com/js"; + let js_in_dir = "https://example.com/js/app.mjs"; + let with_js_prefix = "https://example.com/jsiscool"; + + assert!(import_map.resolve("moment", js_non_dir).is_err()); + assert!(import_map.resolve("moment/foo", js_non_dir).is_err()); + assert_resolve( + import_map.resolve("moment", js_in_dir), + "https://example.com/triggered-by-any-subpath/moment", + ); + assert_resolve( + import_map.resolve("moment/foo", js_in_dir), + "https://example.com/triggered-by-any-subpath/moment/foo", + ); + assert!(import_map.resolve("moment", with_js_prefix).is_err()); + assert!(import_map.resolve("moment/foo", with_js_prefix).is_err()); + } + + #[test] + fn resolve_scopes_package_like() { + let base_url = "https://example.com/app/main.ts"; + + let json_map = r#"{ + "imports": { + "moment": "/node_modules/moment/src/moment.js", + "moment/": "/node_modules/moment/src/", + "lodash-dot": "./node_modules/lodash-es/lodash.js", + "lodash-dot/": "./node_modules/lodash-es/", + "lodash-dotdot": "../node_modules/lodash-es/lodash.js", + "lodash-dotdot/": "../node_modules/lodash-es/" + }, + "scopes": { + "/": { + "moment": "/node_modules_3/moment/src/moment.js", + "vue": "/node_modules_3/vue/dist/vue.runtime.esm.js" + }, + "/js/": { + "lodash-dot": "./node_modules_2/lodash-es/lodash.js", + "lodash-dot/": "./node_modules_2/lodash-es/", + "lodash-dotdot": "../node_modules_2/lodash-es/lodash.js", + "lodash-dotdot/": "../node_modules_2/lodash-es/" + } + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + // Should match correctly when only a prefix match is in the map. + let js_in_dir = "https://example.com/js/app.mjs"; + let top_level = "https://example.com/app.mjs"; + + // Should resolve scoped. + assert_resolve( + import_map.resolve("lodash-dot", js_in_dir), + "https://example.com/app/node_modules_2/lodash-es/lodash.js", + ); + assert_resolve( + import_map.resolve("lodash-dotdot", js_in_dir), + "https://example.com/node_modules_2/lodash-es/lodash.js", + ); + assert_resolve( + import_map.resolve("lodash-dot/foo", js_in_dir), + "https://example.com/app/node_modules_2/lodash-es/foo", + ); + assert_resolve( + import_map.resolve("lodash-dotdot/foo", js_in_dir), + "https://example.com/node_modules_2/lodash-es/foo", + ); + + // Should apply best scope match. + assert_resolve( + import_map.resolve("moment", top_level), + "https://example.com/node_modules_3/moment/src/moment.js", + ); + assert_resolve( + import_map.resolve("moment", js_in_dir), + "https://example.com/node_modules_3/moment/src/moment.js", + ); + assert_resolve( + import_map.resolve("vue", js_in_dir), + "https://example.com/node_modules_3/vue/dist/vue.runtime.esm.js", + ); + + // Should fallback to "imports". + assert_resolve( + import_map.resolve("moment/foo", top_level), + "https://example.com/node_modules/moment/src/foo", + ); + assert_resolve( + import_map.resolve("moment/foo", js_in_dir), + "https://example.com/node_modules/moment/src/foo", + ); + assert_resolve( + import_map.resolve("lodash-dot", top_level), + "https://example.com/app/node_modules/lodash-es/lodash.js", + ); + assert_resolve( + import_map.resolve("lodash-dotdot", top_level), + "https://example.com/node_modules/lodash-es/lodash.js", + ); + assert_resolve( + import_map.resolve("lodash-dot/foo", top_level), + "https://example.com/app/node_modules/lodash-es/foo", + ); + assert_resolve( + import_map.resolve("lodash-dotdot/foo", top_level), + "https://example.com/node_modules/lodash-es/foo", + ); + + // Should still fail for package-like specifiers that are not declared. + assert!(import_map.resolve("underscore/", js_in_dir).is_err()); + assert!(import_map.resolve("underscore/foo", js_in_dir).is_err()); + } + + #[test] + fn resolve_scopes_inheritance() { + // https://github.com/WICG/import-maps#scope-inheritance + let base_url = "https://example.com/app/main.ts"; + + let json_map = r#"{ + "imports": { + "a": "/a-1.mjs", + "b": "/b-1.mjs", + "c": "/c-1.mjs" + }, + "scopes": { + "/scope2/": { + "a": "/a-2.mjs" + }, + "/scope2/scope3/": { + "b": "/b-3.mjs" + } + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + let scope_1_url = "https://example.com/scope1/foo.mjs"; + let scope_2_url = "https://example.com/scope2/foo.mjs"; + let scope_3_url = "https://example.com/scope2/scope3/foo.mjs"; + + // Should fall back to "imports" when none match. + assert_resolve( + import_map.resolve("a", scope_1_url), + "https://example.com/a-1.mjs", + ); + assert_resolve( + import_map.resolve("b", scope_1_url), + "https://example.com/b-1.mjs", + ); + assert_resolve( + import_map.resolve("c", scope_1_url), + "https://example.com/c-1.mjs", + ); + + // Should use a direct scope override. + assert_resolve( + import_map.resolve("a", scope_2_url), + "https://example.com/a-2.mjs", + ); + assert_resolve( + import_map.resolve("b", scope_2_url), + "https://example.com/b-1.mjs", + ); + assert_resolve( + import_map.resolve("c", scope_2_url), + "https://example.com/c-1.mjs", + ); + + // Should use an indirect scope override. + assert_resolve( + import_map.resolve("a", scope_3_url), + "https://example.com/a-2.mjs", + ); + assert_resolve( + import_map.resolve("b", scope_3_url), + "https://example.com/b-3.mjs", + ); + assert_resolve( + import_map.resolve("c", scope_3_url), + "https://example.com/c-1.mjs", + ); + } + + #[test] + fn resolve_scopes_relative_url_keys() { + // https://github.com/WICG/import-maps#scope-inheritance + let base_url = "https://example.com/app/main.ts"; + + let json_map = r#"{ + "imports": { + "a": "/a-1.mjs", + "b": "/b-1.mjs", + "c": "/c-1.mjs" + }, + "scopes": { + "": { + "a": "/a-empty-string.mjs" + }, + "./": { + "b": "/b-dot-slash.mjs" + }, + "../": { + "c": "/c-dot-dot-slash.mjs" + } + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + let in_same_dir_as_map = "https://example.com/app/foo.mjs"; + let in_dir_above_map = "https://example.com/foo.mjs"; + + // Should resolve an empty string scope using the import map URL. + assert_resolve( + import_map.resolve("a", base_url), + "https://example.com/a-empty-string.mjs", + ); + assert_resolve( + import_map.resolve("a", in_same_dir_as_map), + "https://example.com/a-1.mjs", + ); + + // Should resolve a ./ scope using the import map URL's directory. + assert_resolve( + import_map.resolve("b", base_url), + "https://example.com/b-dot-slash.mjs", + ); + assert_resolve( + import_map.resolve("b", in_same_dir_as_map), + "https://example.com/b-dot-slash.mjs", + ); + + // Should resolve a ../ scope using the import map URL's directory. + assert_resolve( + import_map.resolve("c", base_url), + "https://example.com/c-dot-dot-slash.mjs", + ); + assert_resolve( + import_map.resolve("c", in_same_dir_as_map), + "https://example.com/c-dot-dot-slash.mjs", + ); + assert_resolve( + import_map.resolve("c", in_dir_above_map), + "https://example.com/c-dot-dot-slash.mjs", + ); + } + + #[test] + fn cant_resolve_to_built_in() { + let base_url = "https://example.com/app/main.ts"; + + let import_map = ImportMap::from_json(base_url, "{}").unwrap(); + + assert!(import_map.resolve("std:blank", base_url).is_err()); + } + + #[test] + fn resolve_builtins_remap() { + let base_url = "https://example.com/app/main.ts"; + + let json_map = r#"{ + "imports": { + "std:blank": "./blank.mjs", + "std:none": "./none.mjs" + } + }"#; + let import_map = ImportMap::from_json(base_url, json_map).unwrap(); + + assert_resolve( + import_map.resolve("std:blank", base_url), + "https://example.com/app/blank.mjs", + ); + assert_resolve( + import_map.resolve("std:none", base_url), + "https://example.com/app/none.mjs", + ); + } +} |