From a115340288d974f141cceb16faac71914402c445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 9 Jun 2019 15:08:20 +0200 Subject: [PATCH] feat: Import maps (#2360) --- Cargo.lock | 2 + build_extra/rust/BUILD.gn | 6 +- cli/BUILD.gn | 1 + cli/Cargo.toml | 3 +- cli/errors.rs | 14 + cli/flags.rs | 57 +- cli/import_map.rs | 2133 +++++++++++++++++++++++++ cli/main.rs | 2 + cli/msg.fbs | 1 + cli/ops.rs | 25 +- cli/state.rs | 86 +- core/modules.rs | 30 +- tests/033_import_map.out | 7 + tests/033_import_map.test | 2 + tests/importmaps/import_map.json | 14 + tests/importmaps/lodash/lodash.ts | 1 + tests/importmaps/lodash/other_file.ts | 1 + tests/importmaps/moment/moment.ts | 1 + tests/importmaps/moment/other_file.ts | 1 + tests/importmaps/scope/scoped.ts | 2 + tests/importmaps/scoped_moment.ts | 1 + tests/importmaps/test.ts | 6 + tests/importmaps/vue.ts | 1 + website/manual.md | 45 + 24 files changed, 2406 insertions(+), 36 deletions(-) create mode 100644 cli/import_map.rs create mode 100644 tests/033_import_map.out create mode 100644 tests/033_import_map.test create mode 100644 tests/importmaps/import_map.json create mode 100644 tests/importmaps/lodash/lodash.ts create mode 100644 tests/importmaps/lodash/other_file.ts create mode 100644 tests/importmaps/moment/moment.ts create mode 100644 tests/importmaps/moment/other_file.ts create mode 100644 tests/importmaps/scope/scoped.ts create mode 100644 tests/importmaps/scoped_moment.ts create mode 100644 tests/importmaps/test.ts create mode 100644 tests/importmaps/vue.ts diff --git a/Cargo.lock b/Cargo.lock index 4b51253da7..8977d27569 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,6 +221,7 @@ dependencies = [ "http 0.1.17 (registry+https://github.com/rust-lang/crates.io-index)", "hyper 0.12.29 (registry+https://github.com/rust-lang/crates.io-index)", "hyper-rustls 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)", + "indexmap 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "integer-atomics 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.55 (registry+https://github.com/rust-lang/crates.io-index)", @@ -950,6 +951,7 @@ name = "serde_json" version = "1.0.39" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ + "indexmap 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "itoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)", "ryu 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/build_extra/rust/BUILD.gn b/build_extra/rust/BUILD.gn index 1dcc5dfb7c..ab92107881 100644 --- a/build_extra/rust/BUILD.gn +++ b/build_extra/rust/BUILD.gn @@ -1301,11 +1301,15 @@ rust_proc_macro("serde_derive") { rust_rlib("serde_json") { edition = "2015" source_root = "$cargo_home/registry/src/github.com-1ecc6299db9ec823/serde_json-1.0.39/src/lib.rs" - features = [ "default" ] + features = [ + "default", + "preserve_order", + ] extern_rlib = [ "itoa", "ryu", "serde", + "indexmap", ] args = [ "--cap-lints", diff --git a/cli/BUILD.gn b/cli/BUILD.gn index 45386f3208..484a3f74dc 100644 --- a/cli/BUILD.gn +++ b/cli/BUILD.gn @@ -29,6 +29,7 @@ main_extern_rlib = [ "http", "hyper", "hyper_rustls", + "indexmap", "lazy_static", "libc", "log", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d2481bb6e3..9221cd8c42 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -27,6 +27,7 @@ futures = "0.1.27" http = "0.1.17" hyper = "0.12.29" hyper-rustls = "0.16.1" +indexmap = "1.0.2" integer-atomics = "1.0.2" lazy_static = "1.3.0" libc = "0.2.55" @@ -38,7 +39,7 @@ ring = "0.14.6" rustyline = "4.1.0" serde = "1.0.91" serde_derive = "1.0.91" -serde_json = "1.0.39" +serde_json = { version = "1.0.39", features = [ "preserve_order" ] } source-map-mappings = "0.5.0" tempfile = "3.0.8" tokio = "0.1.20" diff --git a/cli/errors.rs b/cli/errors.rs index 8e57fe5f49..eb0fc7d276 100644 --- a/cli/errors.rs +++ b/cli/errors.rs @@ -1,4 +1,5 @@ // Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +use crate::import_map::ImportMapError; use crate::js_errors::JSErrorColor; pub use crate::msg::ErrorKind; use crate::resolve_addr::ResolveAddrError; @@ -24,6 +25,7 @@ enum Repr { IoErr(io::Error), UrlErr(url::ParseError), HyperErr(hyper::Error), + ImportMapErr(ImportMapError), } pub fn new(kind: ErrorKind, msg: String) -> DenoError { @@ -92,6 +94,7 @@ impl DenoError { ErrorKind::HttpOther } } + Repr::ImportMapErr(ref _err) => ErrorKind::ImportMapError, } } } @@ -103,6 +106,7 @@ impl fmt::Display for DenoError { Repr::IoErr(ref err) => err.fmt(f), Repr::UrlErr(ref err) => err.fmt(f), Repr::HyperErr(ref err) => err.fmt(f), + Repr::ImportMapErr(ref err) => f.pad(&err.msg), } } } @@ -114,6 +118,7 @@ impl std::error::Error for DenoError { Repr::IoErr(ref err) => err.description(), Repr::UrlErr(ref err) => err.description(), Repr::HyperErr(ref err) => err.description(), + Repr::ImportMapErr(ref err) => &err.msg, } } @@ -123,6 +128,7 @@ impl std::error::Error for DenoError { Repr::IoErr(ref err) => Some(err), Repr::UrlErr(ref err) => Some(err), Repr::HyperErr(ref err) => Some(err), + Repr::ImportMapErr(ref _err) => None, } } } @@ -202,6 +208,14 @@ impl From for DenoError { } } +impl From for DenoError { + fn from(err: ImportMapError) -> Self { + Self { + repr: Repr::ImportMapErr(err), + } + } +} + pub fn bad_resource() -> DenoError { new(ErrorKind::BadResource, String::from("bad resource id")) } diff --git a/cli/flags.rs b/cli/flags.rs index b9a298d28d..b5e759f250 100644 --- a/cli/flags.rs +++ b/cli/flags.rs @@ -15,6 +15,9 @@ pub struct DenoFlags { /// When the `--config`/`-c` flag is used to pass the name, this will be set /// the path passed on the command line, otherwise `None`. pub config_path: Option, + /// When the `--importmap` flag is used to pass the name, this will be set + /// the path passed on the command line, otherwise `None`. + pub import_map_path: Option, pub allow_read: bool, pub read_whitelist: Vec, pub allow_write: bool, @@ -82,6 +85,16 @@ fn add_run_args<'a, 'b>(app: App<'a, 'b>) -> App<'a, 'b> { Arg::with_name("no-prompt") .long("no-prompt") .help("Do not use prompts"), + ).arg( + Arg::with_name("importmap") + .long("importmap") + .value_name("FILE") + .help("Load import map file") + .long_help( + "Load import map file +Specification: https://wicg.github.io/import-maps/ +Examples: https://github.com/WICG/import-maps#the-import-map", + ).takes_value(true), ) } @@ -367,10 +380,10 @@ pub fn parse_flags(matches: &ArgMatches) -> DenoFlags { flags.v8_flags = Some(v8_flags); } - flags = parse_permission_args(flags, matches); + flags = parse_run_args(flags, matches); // flags specific to "run" subcommand if let Some(run_matches) = matches.subcommand_matches("run") { - flags = parse_permission_args(flags.clone(), run_matches); + flags = parse_run_args(flags.clone(), run_matches); } flags @@ -378,10 +391,7 @@ pub fn parse_flags(matches: &ArgMatches) -> DenoFlags { /// Parse permission specific matches Args and assign to DenoFlags. /// This method is required because multiple subcommands use permission args. -fn parse_permission_args( - mut flags: DenoFlags, - matches: &ArgMatches, -) -> DenoFlags { +fn parse_run_args(mut flags: DenoFlags, matches: &ArgMatches) -> DenoFlags { if matches.is_present("allow-read") { if matches.value_of("allow-read").is_some() { let read_wl = matches.values_of("allow-read").unwrap(); @@ -435,6 +445,7 @@ fn parse_permission_args( if matches.is_present("no-prompt") { flags.no_prompts = true; } + flags.import_map_path = matches.value_of("importmap").map(ToOwned::to_owned); flags } @@ -912,6 +923,7 @@ mod tests { assert_eq!(subcommand, DenoSubcommand::Xeval); assert_eq!(argv, svec!["deno", "console.log(val)"]); } + #[test] fn test_flags_from_vec_19() { use tempfile::TempDir; @@ -936,6 +948,7 @@ mod tests { assert_eq!(subcommand, DenoSubcommand::Run); assert_eq!(argv, svec!["deno", "script.ts"]); } + #[test] fn test_flags_from_vec_20() { use tempfile::TempDir; @@ -960,6 +973,7 @@ mod tests { assert_eq!(subcommand, DenoSubcommand::Run); assert_eq!(argv, svec!["deno", "script.ts"]); } + #[test] fn test_flags_from_vec_21() { let (flags, subcommand, argv) = flags_from_vec(svec![ @@ -1067,4 +1081,35 @@ mod tests { assert_eq!(subcommand, DenoSubcommand::Bundle); assert_eq!(argv, svec!["deno", "source.ts", "bundle.js"]) } + + #[test] + fn test_flags_from_vec_27() { + let (flags, subcommand, argv) = flags_from_vec(svec![ + "deno", + "run", + "--importmap=importmap.json", + "script.ts" + ]); + assert_eq!( + flags, + DenoFlags { + import_map_path: Some("importmap.json".to_owned()), + ..DenoFlags::default() + } + ); + assert_eq!(subcommand, DenoSubcommand::Run); + assert_eq!(argv, svec!["deno", "script.ts"]); + + let (flags, subcommand, argv) = + flags_from_vec(svec!["deno", "--importmap=importmap.json", "script.ts"]); + assert_eq!( + flags, + DenoFlags { + import_map_path: Some("importmap.json".to_owned()), + ..DenoFlags::default() + } + ); + assert_eq!(subcommand, DenoSubcommand::Run); + assert_eq!(argv, svec!["deno", "script.ts"]); + } } diff --git a/cli/import_map.rs b/cli/import_map.rs new file mode 100644 index 0000000000..5fe4235953 --- /dev/null +++ b/cli/import_map.rs @@ -0,0 +1,2133 @@ +use indexmap::IndexMap; +use serde_json::Map; +use serde_json::Value; +use std::cmp::Ordering; +use std::fs; +use url::Url; + +#[derive(Debug)] +pub struct ImportMapError { + pub msg: String, +} + +impl ImportMapError { + pub fn new(msg: &str) -> Self { + ImportMapError { + msg: msg.to_string(), + } + } +} + +// 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>; +type ScopesMap = IndexMap; + +#[derive(Debug)] +pub struct ImportMap { + base_url: String, + imports: SpecifierMap, + scopes: ScopesMap, +} + +impl ImportMap { + pub fn load(base_url: &str, file_name: &str) -> Result { + let cwd = std::env::current_dir().unwrap(); + let resolved_path = cwd.join(file_name); + debug!( + "Attempt to load import map: {}", + resolved_path.to_str().unwrap() + ); + + // Load the contents of import map + match fs::read_to_string(&resolved_path) { + Ok(json_string) => ImportMap::from_json(base_url, &json_string), + _ => panic!( + "Error retrieving import map file at \"{}\"", + resolved_path.to_str().unwrap() + ), + } + } + + pub fn from_json( + base_url: &str, + json_string: &str, + ) -> Result { + 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 { + // 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 { + // 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, + ) -> Vec { + let mut normalized_addresses: Vec = 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_string); + } + + 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, + 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 = match value { + Value::String(address) => vec![address.to_string()], + Value::Array(address_array) => { + let mut string_addresses: Vec = 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, + base_url: &str, + ) -> Result { + 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, 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, 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.to_string())); + } 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()..]; + + if let Ok(base_url) = Url::parse(address) { + if let Ok(url) = base_url.join(after_prefix) { + let resolved_url = url.to_string(); + debug!("Specifier {:?} was mapped to {:?} (via prefix specifier key {:?}).", normalized_specifier, resolved_url, address); + return Ok(Some(resolved_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, ImportMapError> { + let resolved_url: Option = + 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(resolved_url.to_string())); + } + + Err(ImportMapError::new(&format!( + "Unmapped bare specifier {:?}", + normalized_specifier + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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(), + } + } + + #[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_eq!( + import_map.resolve("./foo", referrer_url).unwrap(), + Some("https://example.com/js/foo".to_string()) + ); + assert_eq!( + import_map.resolve("./foo/bar", referrer_url).unwrap(), + Some("https://example.com/js/foo/bar".to_string()) + ); + assert_eq!( + import_map.resolve("./foo/../bar", referrer_url).unwrap(), + Some("https://example.com/js/bar".to_string()) + ); + assert_eq!( + import_map.resolve("./foo/../../bar", referrer_url).unwrap(), + Some("https://example.com/bar".to_string()) + ); + + // Should resolve ../ specifiers as URLs. + assert_eq!( + import_map.resolve("../foo", referrer_url).unwrap(), + Some("https://example.com/foo".to_string()) + ); + assert_eq!( + import_map.resolve("../foo/bar", referrer_url).unwrap(), + Some("https://example.com/foo/bar".to_string()) + ); + assert_eq!( + import_map + .resolve("../../../foo/bar", referrer_url) + .unwrap(), + Some("https://example.com/foo/bar".to_string()) + ); + } + + #[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_eq!( + import_map.resolve("/foo", referrer_url).unwrap(), + Some("https://example.com/foo".to_string()) + ); + assert_eq!( + import_map.resolve("/foo/bar", referrer_url).unwrap(), + Some("https://example.com/foo/bar".to_string()) + ); + assert_eq!( + import_map.resolve("../../foo/bar", referrer_url).unwrap(), + Some("https://example.com/foo/bar".to_string()) + ); + assert_eq!( + import_map.resolve("/../foo/../bar", referrer_url).unwrap(), + Some("https://example.com/bar".to_string()) + ); + + // Should parse absolute fetch-scheme URLs. + assert_eq!( + import_map + .resolve("https://example.net", referrer_url) + .unwrap(), + Some("https://example.net/".to_string()) + ); + assert_eq!( + import_map + .resolve("https://ex%41mple.com/", referrer_url) + .unwrap(), + Some("https://example.com/".to_string()) + ); + assert_eq!( + import_map + .resolve("https:example.org", referrer_url) + .unwrap(), + Some("https://example.org/".to_string()) + ); + assert_eq!( + import_map + .resolve("https://///example.com///", referrer_url) + .unwrap(), + Some("https://example.com///".to_string()) + ); + } + + #[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_eq!( + import_map.resolve("moment", referrer_url).unwrap(), + Some("https://example.com/deps/moment/src/moment.js".to_string()) + ); + assert_eq!( + import_map.resolve("lodash-dot", referrer_url).unwrap(), + Some("https://example.com/app/deps/lodash-es/lodash.js".to_string()) + ); + assert_eq!( + import_map.resolve("lodash-dotdot", referrer_url).unwrap(), + Some("https://example.com/deps/lodash-es/lodash.js".to_string()) + ); + + // Should work for package submodules. + assert_eq!( + import_map.resolve("moment/foo", referrer_url).unwrap(), + Some("https://example.com/deps/moment/src/foo".to_string()) + ); + assert_eq!( + import_map.resolve("lodash-dot/foo", referrer_url).unwrap(), + Some("https://example.com/app/deps/lodash-es/foo".to_string()) + ); + assert_eq!( + import_map + .resolve("lodash-dotdot/foo", referrer_url) + .unwrap(), + Some("https://example.com/deps/lodash-es/foo".to_string()) + ); + + // Should work for package names that end in a slash. + assert_eq!( + import_map.resolve("moment/", referrer_url).unwrap(), + Some("https://example.com/deps/moment/src/".to_string()) + ); + + // 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_eq!( + import_map + .resolve("package/withslash", referrer_url) + .unwrap(), + Some("https://example.com/deps/package-with-slash/index.mjs".to_string()) + ); + + // Should work when the specifier has punctuation. + assert_eq!( + import_map.resolve(".", referrer_url).unwrap(), + Some("https://example.com/lib/dot.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("..", referrer_url).unwrap(), + Some("https://example.com/lib/dotdot.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("..\\\\", referrer_url).unwrap(), + Some("https://example.com/lib/dotdotbackslash.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("%2E", referrer_url).unwrap(), + Some("https://example.com/lib/percent2e.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("%2F", referrer_url).unwrap(), + Some("https://example.com/lib/percent2f.mjs".to_string()) + ); + + // 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_eq!( + import_map + .resolve("https://example.com/lib/foo.mjs", referrer_url) + .unwrap(), + Some("https://example.com/app/more/bar.mjs".to_string()) + ); + assert_eq!( + import_map + .resolve("https://///example.com/lib/foo.mjs", referrer_url) + .unwrap(), + Some("https://example.com/app/more/bar.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("/lib/foo.mjs", referrer_url).unwrap(), + Some("https://example.com/app/more/bar.mjs".to_string()) + ); + assert_eq!( + import_map + .resolve("https://example.com/app/dotrelative/foo.mjs", referrer_url) + .unwrap(), + Some("https://example.com/lib/dot.mjs".to_string()) + ); + assert_eq!( + import_map + .resolve("../app/dotrelative/foo.mjs", referrer_url) + .unwrap(), + Some("https://example.com/lib/dot.mjs".to_string()) + ); + assert_eq!( + import_map + .resolve("https://example.com/dotdotrelative/foo.mjs", referrer_url) + .unwrap(), + Some("https://example.com/lib/dotdot.mjs".to_string()) + ); + assert_eq!( + import_map + .resolve("../dotdotrelative/foo.mjs", referrer_url) + .unwrap(), + Some("https://example.com/lib/dotdot.mjs".to_string()) + ); + + // 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_eq!( + import_map + .resolve("https://example.com/", referrer_url) + .unwrap(), + Some("https://example.com/lib/slash-only/".to_string()) + ); + assert_eq!( + import_map.resolve("/", referrer_url).unwrap(), + Some("https://example.com/lib/slash-only/".to_string()) + ); + assert_eq!( + import_map.resolve("../", referrer_url).unwrap(), + Some("https://example.com/lib/slash-only/".to_string()) + ); + assert_eq!( + import_map + .resolve("https://example.com/app/", referrer_url) + .unwrap(), + Some("https://example.com/lib/dotslash-only/".to_string()) + ); + assert_eq!( + import_map.resolve("/app/", referrer_url).unwrap(), + Some("https://example.com/lib/dotslash-only/".to_string()) + ); + assert_eq!( + import_map.resolve("../app/", referrer_url).unwrap(), + Some("https://example.com/lib/dotslash-only/".to_string()) + ); + + // Should remap URLs that are prefix-matched by keys with trailing slashes. + assert_eq!( + import_map.resolve("/test/foo.mjs", referrer_url).unwrap(), + Some("https://example.com/lib/url-trailing-slash/foo.mjs".to_string()) + ); + assert_eq!( + import_map + .resolve("https://example.com/app/test/foo.mjs", referrer_url) + .unwrap(), + Some( + "https://example.com/lib/url-trailing-slash-dot/foo.mjs".to_string() + ) + ); + + // 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_eq!( + import_map.resolve("/test", referrer_url).unwrap(), + Some("https://example.com/lib/test2.mjs".to_string()) + ); + } + + #[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_eq!( + import_map.resolve("a", referrer_url).unwrap(), + Some("https://example.com/1".to_string()) + ); + assert_eq!( + import_map.resolve("a/", referrer_url).unwrap(), + Some("https://example.com/2/".to_string()) + ); + assert_eq!( + import_map.resolve("a/b", referrer_url).unwrap(), + Some("https://example.com/3".to_string()) + ); + assert_eq!( + import_map.resolve("a/b/", referrer_url).unwrap(), + Some("https://example.com/4/".to_string()) + ); + assert_eq!( + import_map.resolve("a/b/c", referrer_url).unwrap(), + Some("https://example.com/4/c".to_string()) + ); + } + + // 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_eq!( + import_map.resolve("a/b", referrer_url).unwrap(), + Some("https://example.com/3".to_string()) + ); + assert_eq!( + import_map.resolve("a/b/", referrer_url).unwrap(), + Some("https://example.com/4/".to_string()) + ); + assert_eq!( + import_map.resolve("a/b/c", referrer_url).unwrap(), + Some("https://example.com/4/c".to_string()) + ); + 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_eq!( + import_map.resolve("moment", js_non_dir).unwrap(), + Some("https://example.com/only-triggered-by-exact/moment".to_string()) + ); + assert_eq!( + import_map.resolve("moment/foo", js_non_dir).unwrap(), + Some( + "https://example.com/only-triggered-by-exact/moment/foo".to_string() + ) + ); + assert_eq!( + import_map.resolve("moment", js_in_dir).unwrap(), + Some("https://example.com/triggered-by-any-subpath/moment".to_string()) + ); + assert_eq!( + import_map.resolve("moment/foo", js_in_dir).unwrap(), + Some( + "https://example.com/triggered-by-any-subpath/moment/foo".to_string() + ) + ); + 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_eq!( + import_map.resolve("moment", js_non_dir).unwrap(), + Some("https://example.com/only-triggered-by-exact/moment".to_string()) + ); + assert_eq!( + import_map.resolve("moment/foo", js_non_dir).unwrap(), + Some( + "https://example.com/only-triggered-by-exact/moment/foo".to_string() + ) + ); + 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_eq!( + import_map.resolve("moment", js_in_dir).unwrap(), + Some("https://example.com/triggered-by-any-subpath/moment".to_string()) + ); + assert_eq!( + import_map.resolve("moment/foo", js_in_dir).unwrap(), + Some( + "https://example.com/triggered-by-any-subpath/moment/foo".to_string() + ) + ); + 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_eq!( + import_map.resolve("lodash-dot", js_in_dir).unwrap(), + Some( + "https://example.com/app/node_modules_2/lodash-es/lodash.js" + .to_string() + ) + ); + assert_eq!( + import_map.resolve("lodash-dotdot", js_in_dir).unwrap(), + Some( + "https://example.com/node_modules_2/lodash-es/lodash.js".to_string() + ) + ); + assert_eq!( + import_map.resolve("lodash-dot/foo", js_in_dir).unwrap(), + Some("https://example.com/app/node_modules_2/lodash-es/foo".to_string()) + ); + assert_eq!( + import_map.resolve("lodash-dotdot/foo", js_in_dir).unwrap(), + Some("https://example.com/node_modules_2/lodash-es/foo".to_string()) + ); + + // Should apply best scope match. + assert_eq!( + import_map.resolve("moment", top_level).unwrap(), + Some( + "https://example.com/node_modules_3/moment/src/moment.js".to_string() + ) + ); + assert_eq!( + import_map.resolve("moment", js_in_dir).unwrap(), + Some( + "https://example.com/node_modules_3/moment/src/moment.js".to_string() + ) + ); + assert_eq!( + import_map.resolve("vue", js_in_dir).unwrap(), + Some( + "https://example.com/node_modules_3/vue/dist/vue.runtime.esm.js" + .to_string() + ) + ); + + // Should fallback to "imports". + assert_eq!( + import_map.resolve("moment/foo", top_level).unwrap(), + Some("https://example.com/node_modules/moment/src/foo".to_string()) + ); + assert_eq!( + import_map.resolve("moment/foo", js_in_dir).unwrap(), + Some("https://example.com/node_modules/moment/src/foo".to_string()) + ); + assert_eq!( + import_map.resolve("lodash-dot", top_level).unwrap(), + Some( + "https://example.com/app/node_modules/lodash-es/lodash.js".to_string() + ) + ); + assert_eq!( + import_map.resolve("lodash-dotdot", top_level).unwrap(), + Some("https://example.com/node_modules/lodash-es/lodash.js".to_string()) + ); + assert_eq!( + import_map.resolve("lodash-dot/foo", top_level).unwrap(), + Some("https://example.com/app/node_modules/lodash-es/foo".to_string()) + ); + assert_eq!( + import_map.resolve("lodash-dotdot/foo", top_level).unwrap(), + Some("https://example.com/node_modules/lodash-es/foo".to_string()) + ); + + // 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_eq!( + import_map.resolve("a", scope_1_url).unwrap(), + Some("https://example.com/a-1.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("b", scope_1_url).unwrap(), + Some("https://example.com/b-1.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("c", scope_1_url).unwrap(), + Some("https://example.com/c-1.mjs".to_string()) + ); + + // Should use a direct scope override. + assert_eq!( + import_map.resolve("a", scope_2_url).unwrap(), + Some("https://example.com/a-2.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("b", scope_2_url).unwrap(), + Some("https://example.com/b-1.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("c", scope_2_url).unwrap(), + Some("https://example.com/c-1.mjs".to_string()) + ); + + // Should use an indirect scope override. + assert_eq!( + import_map.resolve("a", scope_3_url).unwrap(), + Some("https://example.com/a-2.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("b", scope_3_url).unwrap(), + Some("https://example.com/b-3.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("c", scope_3_url).unwrap(), + Some("https://example.com/c-1.mjs".to_string()) + ); + } + + #[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_eq!( + import_map.resolve("a", base_url).unwrap(), + Some("https://example.com/a-empty-string.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("a", in_same_dir_as_map).unwrap(), + Some("https://example.com/a-1.mjs".to_string()) + ); + + // Should resolve a ./ scope using the import map URL's directory. + assert_eq!( + import_map.resolve("b", base_url).unwrap(), + Some("https://example.com/b-dot-slash.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("b", in_same_dir_as_map).unwrap(), + Some("https://example.com/b-dot-slash.mjs".to_string()) + ); + + // Should resolve a ../ scope using the import map URL's directory. + assert_eq!( + import_map.resolve("c", base_url).unwrap(), + Some("https://example.com/c-dot-dot-slash.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("c", in_same_dir_as_map).unwrap(), + Some("https://example.com/c-dot-dot-slash.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("c", in_dir_above_map).unwrap(), + Some("https://example.com/c-dot-dot-slash.mjs".to_string()) + ); + } + + #[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_eq!( + import_map.resolve("std:blank", base_url).unwrap(), + Some("https://example.com/app/blank.mjs".to_string()) + ); + assert_eq!( + import_map.resolve("std:none", base_url).unwrap(), + Some("https://example.com/app/none.mjs".to_string()) + ); + } +} diff --git a/cli/main.rs b/cli/main.rs index ad0374af22..132bd7bc01 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -9,6 +9,7 @@ extern crate futures; extern crate serde_json; extern crate clap; extern crate deno; +extern crate indexmap; #[cfg(unix)] extern crate nix; extern crate rand; @@ -24,6 +25,7 @@ mod fs; mod global_timer; mod http_body; mod http_util; +mod import_map; pub mod js_errors; pub mod msg; pub mod msg_util; diff --git a/cli/msg.fbs b/cli/msg.fbs index 7f2db381fb..56410097c3 100644 --- a/cli/msg.fbs +++ b/cli/msg.fbs @@ -136,6 +136,7 @@ enum ErrorKind: byte { OpNotAvaiable, WorkerInitFailed, UnixError, + ImportMapError, } table Cwd {} diff --git a/cli/ops.rs b/cli/ops.rs index c76983c47c..f39daaab61 100644 --- a/cli/ops.rs +++ b/cli/ops.rs @@ -29,6 +29,7 @@ use crate::worker::Worker; use deno::js_check; use deno::Buf; use deno::JSError; +//use deno::Loader; use deno::Op; use deno::PinnedBuf; use flatbuffers::FlatBufferBuilder; @@ -499,10 +500,30 @@ fn op_fetch_module_meta_data( let use_cache = !state.flags.reload; let no_fetch = state.flags.no_fetch; + // TODO(bartlomieju): I feel this is wrong - specifier is only resolved if there's an + // import map - why it is not always resolved? Eg. "bad-module.ts" will return NotFound + // error whilst it should return RelativeUrlWithCannotBeABaseBase error + let resolved_specifier = match &state.import_map { + Some(import_map) => { + match import_map.resolve(specifier, referrer) { + Ok(result) => match result { + Some(url) => url.clone(), + None => specifier.to_string(), + }, + Err(err) => panic!("error resolving using import map: {:?}", err), // TODO: this should be coerced to DenoError + } + } + None => specifier.to_string(), + }; + let fut = state .dir - .fetch_module_meta_data_async(specifier, referrer, use_cache, no_fetch) - .and_then(move |out| { + .fetch_module_meta_data_async( + &resolved_specifier, + referrer, + use_cache, + no_fetch, + ).and_then(move |out| { let builder = &mut FlatBufferBuilder::new(); let data_off = builder.create_vector(out.source_code.as_slice()); let msg_args = msg::FetchModuleMetaDataResArgs { diff --git a/cli/state.rs b/cli/state.rs index 9a8b1cab22..4a2db65c22 100644 --- a/cli/state.rs +++ b/cli/state.rs @@ -6,6 +6,7 @@ use crate::errors::DenoError; use crate::errors::DenoResult; use crate::flags; use crate::global_timer::GlobalTimer; +use crate::import_map::ImportMap; use crate::msg; use crate::ops; use crate::permissions::DenoPermissions; @@ -57,6 +58,7 @@ pub struct ThreadSafeState(Arc); #[cfg_attr(feature = "cargo-clippy", allow(stutter))] pub struct State { + pub main_module: Option, pub dir: deno_dir::DenoDir, pub argv: Vec, pub permissions: DenoPermissions, @@ -67,6 +69,9 @@ pub struct State { /// When flags contains a `.config_path` option, the fully qualified path /// name of the passed path will be resolved and set. pub config_path: Option, + /// When flags contains a `.import_map_path` option, the content of the + /// import map file will be resolved and set. + pub import_map: Option, pub metrics: Metrics, pub worker_channels: Mutex, pub global_timer: Mutex, @@ -111,9 +116,10 @@ pub fn fetch_module_meta_data_and_maybe_compile_async( let state_ = state.clone(); let specifier = specifier.to_string(); let referrer = referrer.to_string(); + let is_root = referrer == "."; let f = - futures::future::result(ThreadSafeState::resolve(&specifier, &referrer)); + futures::future::result(state.resolve(&specifier, &referrer, is_root)); f.and_then(move |module_id| { let use_cache = !state_.flags.reload || state_.has_compiled(&module_id); let no_fetch = state_.flags.no_fetch; @@ -157,7 +163,28 @@ pub fn fetch_module_meta_data_and_maybe_compile( impl Loader for ThreadSafeState { type Error = DenoError; - fn resolve(specifier: &str, referrer: &str) -> Result { + fn resolve( + &self, + specifier: &str, + referrer: &str, + is_root: bool, + ) -> Result { + if !is_root { + if let Some(import_map) = &self.import_map { + match import_map.resolve(specifier, referrer) { + Ok(result) => { + if result.is_some() { + return Ok(result.unwrap()); + } + } + Err(err) => { + // TODO(bartlomieju): this should be coerced to DenoError + panic!("error resolving using import map: {:?}", err); + } + } + } + } + resolve_module_spec(specifier, referrer).map_err(DenoError::from) } @@ -233,14 +260,50 @@ impl ThreadSafeState { _ => None, }; + let dir = + deno_dir::DenoDir::new(custom_root, &config, progress.clone()).unwrap(); + + let main_module: Option = if argv_rest.len() <= 1 { + None + } else { + let specifier = argv_rest[1].clone(); + let referrer = "."; + // TODO: does this really have to be resolved by DenoDir? + // Maybe we can call `resolve_module_spec` + match dir.resolve_module_url(&specifier, referrer) { + Ok(url) => Some(url.to_string()), + Err(e) => { + debug!("Potentially swallowed error {}", e); + None + } + } + }; + + let mut import_map = None; + if let Some(file_name) = &flags.import_map_path { + let base_url = match &main_module { + Some(url) => url, + None => unreachable!(), + }; + + match ImportMap::load(base_url, file_name) { + Ok(map) => import_map = Some(map), + Err(err) => { + println!("{:?}", err); + panic!("Error parsing import map"); + } + } + } + ThreadSafeState(Arc::new(State { - dir: deno_dir::DenoDir::new(custom_root, &config, progress.clone()) - .unwrap(), + main_module, + dir, argv: argv_rest, permissions: DenoPermissions::from_flags(&flags), flags, config, config_path, + import_map, metrics: Metrics::default(), worker_channels: Mutex::new(internal_channels), global_timer: Mutex::new(GlobalTimer::new()), @@ -255,18 +318,9 @@ impl ThreadSafeState { /// Read main module from argv pub fn main_module(&self) -> Option { - if self.argv.len() <= 1 { - None - } else { - let specifier = self.argv[1].clone(); - let referrer = "."; - match self.dir.resolve_module_url(&specifier, referrer) { - Ok(url) => Some(url.to_string()), - Err(e) => { - debug!("Potentially swallowed error {}", e); - None - } - } + match &self.main_module { + Some(url) => Some(url.to_string()), + None => None, } } diff --git a/core/modules.rs b/core/modules.rs index 8a600fd7e0..fbfdb0b072 100644 --- a/core/modules.rs +++ b/core/modules.rs @@ -43,7 +43,12 @@ pub trait Loader: Send + Sync { /// When implementing an spec-complaint VM, this should be exactly the /// algorithm described here: /// https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier - fn resolve(specifier: &str, referrer: &str) -> Result; + fn resolve( + &self, + specifier: &str, + referrer: &str, + is_root: bool, + ) -> Result; /// Given an absolute url, load its source code. fn load(&self, url: &str) -> Box>; @@ -98,17 +103,15 @@ impl RecursiveLoad { referrer: &str, parent_id: Option, ) -> Result { - let url = L::resolve(specifier, referrer)?; + let is_root = parent_id.is_none(); + let url = self.loader.resolve(specifier, referrer, is_root)?; - let is_root = if let Some(parent_id) = parent_id { + if !is_root { { let mut m = self.modules.lock().unwrap(); - m.add_child(parent_id, &url); + m.add_child(parent_id.unwrap(), &url); } - false - } else { - true - }; + } { // #B We only add modules that have not yet been resolved for RecursiveLoad. @@ -251,7 +254,9 @@ impl Future for RecursiveLoad { |specifier: &str, referrer_id: deno_mod| -> deno_mod { let modules = self.modules.lock().unwrap(); let referrer = modules.get_name(referrer_id).unwrap(); - match L::resolve(specifier, &referrer) { + // TODO(bartlomieju): there must be a better way + let is_root = referrer == "."; + match self.loader.resolve(specifier, &referrer, is_root) { Ok(url) => match modules.get_id(&url) { Some(id) => id, None => 0, @@ -619,7 +624,12 @@ mod tests { impl Loader for MockLoader { type Error = MockError; - fn resolve(specifier: &str, referrer: &str) -> Result { + fn resolve( + &self, + specifier: &str, + referrer: &str, + _is_root: bool, + ) -> Result { eprintln!(">> RESOLVING, S: {}, R: {}", specifier, referrer); let output_specifier = if specifier.starts_with("./") && referrer.starts_with("./") { diff --git a/tests/033_import_map.out b/tests/033_import_map.out new file mode 100644 index 0000000000..e9b9160e9c --- /dev/null +++ b/tests/033_import_map.out @@ -0,0 +1,7 @@ +Hello from remapped moment! +Hello from remapped moment dir! +Hello from remapped lodash! +Hello from remapped lodash dir! +Hello from remapped Vue! +Hello from scoped moment! +Hello from scoped! diff --git a/tests/033_import_map.test b/tests/033_import_map.test new file mode 100644 index 0000000000..1633c1807f --- /dev/null +++ b/tests/033_import_map.test @@ -0,0 +1,2 @@ +args: run --reload --importmap=tests/importmaps/import_map.json tests/importmaps/test.ts +output: tests/033_import_map.out diff --git a/tests/importmaps/import_map.json b/tests/importmaps/import_map.json new file mode 100644 index 0000000000..601874aab9 --- /dev/null +++ b/tests/importmaps/import_map.json @@ -0,0 +1,14 @@ +{ + "imports": { + "moment": "./moment/moment.ts", + "moment/": "./moment/", + "lodash": "./lodash/lodash.ts", + "lodash/": "./lodash/", + "https://www.unpkg.com/vue/dist/vue.runtime.esm.js": "./vue.ts" + }, + "scopes": { + "scope/": { + "moment": "./scoped_moment.ts" + } + } +} diff --git a/tests/importmaps/lodash/lodash.ts b/tests/importmaps/lodash/lodash.ts new file mode 100644 index 0000000000..2ec04ed3cf --- /dev/null +++ b/tests/importmaps/lodash/lodash.ts @@ -0,0 +1 @@ +console.log("Hello from remapped lodash!"); diff --git a/tests/importmaps/lodash/other_file.ts b/tests/importmaps/lodash/other_file.ts new file mode 100644 index 0000000000..714adae3fb --- /dev/null +++ b/tests/importmaps/lodash/other_file.ts @@ -0,0 +1 @@ +console.log("Hello from remapped lodash dir!"); diff --git a/tests/importmaps/moment/moment.ts b/tests/importmaps/moment/moment.ts new file mode 100644 index 0000000000..2b54a431e7 --- /dev/null +++ b/tests/importmaps/moment/moment.ts @@ -0,0 +1 @@ +console.log("Hello from remapped moment!"); diff --git a/tests/importmaps/moment/other_file.ts b/tests/importmaps/moment/other_file.ts new file mode 100644 index 0000000000..24f3a0226d --- /dev/null +++ b/tests/importmaps/moment/other_file.ts @@ -0,0 +1 @@ +console.log("Hello from remapped moment dir!"); diff --git a/tests/importmaps/scope/scoped.ts b/tests/importmaps/scope/scoped.ts new file mode 100644 index 0000000000..9a0b5d8e36 --- /dev/null +++ b/tests/importmaps/scope/scoped.ts @@ -0,0 +1,2 @@ +import "moment"; +console.log("Hello from scoped!"); diff --git a/tests/importmaps/scoped_moment.ts b/tests/importmaps/scoped_moment.ts new file mode 100644 index 0000000000..9f67f88d4a --- /dev/null +++ b/tests/importmaps/scoped_moment.ts @@ -0,0 +1 @@ +console.log("Hello from scoped moment!"); diff --git a/tests/importmaps/test.ts b/tests/importmaps/test.ts new file mode 100644 index 0000000000..9b09e9953d --- /dev/null +++ b/tests/importmaps/test.ts @@ -0,0 +1,6 @@ +import "moment"; +import "moment/other_file.ts"; +import "lodash"; +import "lodash/other_file.ts"; +import "https://www.unpkg.com/vue/dist/vue.runtime.esm.js"; +import "./scope/scoped.ts"; diff --git a/tests/importmaps/vue.ts b/tests/importmaps/vue.ts new file mode 100644 index 0000000000..76dbe19179 --- /dev/null +++ b/tests/importmaps/vue.ts @@ -0,0 +1 @@ +console.log("Hello from remapped Vue!"); diff --git a/website/manual.md b/website/manual.md index 08ac60ab71..684d44c2e1 100644 --- a/website/manual.md +++ b/website/manual.md @@ -634,6 +634,7 @@ OPTIONS: --allow-read= Allow file system read access --allow-write= Allow file system write access -c, --config Load compiler configuration file + --importmap Load import map file --v8-flags= Set V8 command line options SUBCOMMANDS: @@ -676,6 +677,50 @@ Particularly useful ones: --async-stack-trace ``` +## Import maps + +Deno supports [import maps](https://github.com/WICG/import-maps). + +One can use import map with `--importmap=` CLI flag. + +Current limitations: + +- single import map +- no fallback URLs +- Deno does not support `std:` namespace +- Does supports only `file:`, `http:` and `https:` schemes + +Example: + +```js +// import_map.json + +{ + "imports": { + "http/": "https://deno.land/std/http/" + } +} +``` + +```ts +// hello_server.ts + +import { serve } from "http/server.ts"; + +async function main() { + const body = new TextEncoder().encode("Hello World\n"); + for await (const req of serve(":8000")) { + req.respond({ body }); + } +} + +main(); +``` + +```bash +$ deno run --importmap=import_map.json hello_server.ts +``` + ## Internal details ### Deno and Linux analogy