1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2024-11-25 15:29:32 -05:00
denoland-deno/cli/import_map.rs

808 lines
23 KiB
Rust

// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
use deno_core::error::AnyError;
use deno_core::serde::Serialize;
use deno_core::serde_json;
use deno_core::serde_json::Map;
use deno_core::serde_json::Value;
use deno_core::url::Url;
use indexmap::IndexMap;
use log::debug;
use log::info;
use std::cmp::Ordering;
use std::collections::HashSet;
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub enum ImportMapError {
UnmappedBareSpecifier(String, Option<String>),
Other(String),
}
impl fmt::Display for ImportMapError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ImportMapError::UnmappedBareSpecifier(specifier, maybe_referrer) => write!(
f,
"Relative import path \"{}\" not prefixed with / or ./ or ../ and not in import map{}",
specifier,
match maybe_referrer {
Some(referrer) => format!(" from \"{}\"", referrer),
None => format!(""),
}
),
ImportMapError::Other(message) => f.pad(message),
}
}
}
impl Error for ImportMapError {}
// https://url.spec.whatwg.org/#special-scheme
const SPECIAL_PROTOCOLS: &[&str] =
&["ftp", "file", "http", "https", "ws", "wss"];
fn is_special(url: &Url) -> bool {
SPECIAL_PROTOCOLS.contains(&url.scheme())
}
type SpecifierMap = IndexMap<String, Option<Url>>;
type ScopesMap = IndexMap<String, SpecifierMap>;
#[derive(Debug, Clone, Serialize)]
pub struct ImportMap {
#[serde(skip)]
base_url: String,
imports: SpecifierMap,
scopes: ScopesMap,
}
impl ImportMap {
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(err) => {
return Err(ImportMapError::Other(format!(
"Unable to parse import map JSON: {}",
err.to_string()
)));
}
};
match v {
Value::Object(_) => {}
_ => {
return Err(ImportMapError::Other(
"Import map JSON must be an object".to_string(),
));
}
}
let mut diagnostics = vec![];
let normalized_imports = match &v.get("imports") {
Some(imports_map) => {
if !imports_map.is_object() {
return Err(ImportMapError::Other(
"Import map's 'imports' must be an object".to_string(),
));
}
let imports_map = imports_map.as_object().unwrap();
ImportMap::parse_specifier_map(imports_map, base_url, &mut diagnostics)
}
None => IndexMap::new(),
};
let normalized_scopes = match &v.get("scopes") {
Some(scope_map) => {
if !scope_map.is_object() {
return Err(ImportMapError::Other(
"Import map's 'scopes' must be an object".to_string(),
));
}
let scope_map = scope_map.as_object().unwrap();
ImportMap::parse_scope_map(scope_map, base_url, &mut diagnostics)?
}
None => IndexMap::new(),
};
let mut keys: HashSet<String> = v
.as_object()
.unwrap()
.keys()
.map(|k| k.to_string())
.collect();
keys.remove("imports");
keys.remove("scopes");
for key in keys {
diagnostics.push(format!("Invalid top-level key \"{}\". Only \"imports\" and \"scopes\" can be present.", key));
}
let import_map = ImportMap {
base_url: base_url.to_string(),
imports: normalized_imports,
scopes: normalized_scopes,
};
if !diagnostics.is_empty() {
info!("Import map diagnostics:");
for diagnotic in diagnostics {
info!(" - {}", diagnotic);
}
}
Ok(import_map)
}
fn try_url_like_specifier(specifier: &str, base: &str) -> Option<Url> {
if specifier.starts_with('/')
|| specifier.starts_with("./")
|| specifier.starts_with("../")
{
if let Ok(base_url) = Url::parse(base) {
if let Ok(url) = base_url.join(specifier) {
return Some(url);
}
}
}
if let Ok(url) = Url::parse(specifier) {
return Some(url);
}
None
}
/// Parse provided key as import map specifier.
///
/// Specifiers must be valid URLs (eg. "`https://deno.land/x/std/testing/asserts.ts`")
/// or "bare" specifiers (eg. "moment").
fn normalize_specifier_key(
specifier_key: &str,
base_url: &str,
diagnostics: &mut Vec<String>,
) -> Option<String> {
// ignore empty keys
if specifier_key.is_empty() {
diagnostics.push("Invalid empty string specifier.".to_string());
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())
}
fn append_specifier_to_base(
base: &Url,
specifier: &str,
) -> Result<Url, AnyError> {
let mut base = base.clone();
let is_relative_or_absolute_specifier = specifier.starts_with("../")
|| specifier.starts_with("./")
|| specifier.starts_with('/');
// The specifier could be a windows path such as "C:/a/test.ts" in which
// case we don't want to use `join` because it will make the specifier
// the url since it contains what looks to be a uri scheme. To work around
// this, we append the specifier to the path segments of the base url when
// the specifier is not relative or absolute.
if !is_relative_or_absolute_specifier && base.path_segments_mut().is_ok() {
{
let mut segments = base.path_segments_mut().unwrap();
segments.pop_if_empty();
segments.extend(specifier.split('/'));
}
Ok(base)
} else {
Ok(base.join(specifier)?)
}
}
/// 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,
diagnostics: &mut Vec<String>,
) -> 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,
diagnostics,
) {
Some(s) => s,
None => continue,
};
let potential_address = match value {
Value::String(address) => address.to_string(),
_ => {
diagnostics.push(format!("Invalid address {:#?} for the specifier key \"{}\". Addresses must be strings.", value, specifier_key));
normalized_map.insert(normalized_specifier_key, None);
continue;
}
};
let address_url =
match ImportMap::try_url_like_specifier(&potential_address, base_url) {
Some(url) => url,
None => {
diagnostics.push(format!(
"Invalid address \"{}\" for the specifier key \"{}\".",
potential_address, specifier_key
));
normalized_map.insert(normalized_specifier_key, None);
continue;
}
};
let address_url_string = address_url.to_string();
if specifier_key.ends_with('/') && !address_url_string.ends_with('/') {
diagnostics.push(format!(
"Invalid target address {:?} for package specifier {:?}. \
Package address targets must end with \"/\".",
address_url_string, specifier_key
));
normalized_map.insert(normalized_specifier_key, None);
continue;
}
normalized_map.insert(normalized_specifier_key, Some(address_url));
}
// Sort in longest and alphabetical order.
normalized_map.sort_by(|k1, _v1, k2, _v2| match k1.cmp(k2) {
Ordering::Greater => Ordering::Less,
Ordering::Less => Ordering::Greater,
// JSON guarantees that there can't be duplicate keys
Ordering::Equal => unreachable!(),
});
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,
diagnostics: &mut Vec<String>,
) -> 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::Other(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) => url.to_string(),
_ => {
diagnostics.push(format!(
"Invalid scope \"{}\" (parsed against base URL \"{}\").",
scope_prefix, base_url
));
continue;
}
};
let norm_map = ImportMap::parse_specifier_map(
potential_specifier_map,
base_url,
diagnostics,
);
normalized_map.insert(scope_prefix_url, norm_map);
}
// Sort in longest and alphabetical order.
normalized_map.sort_by(|k1, _v1, k2, _v2| match k1.cmp(k2) {
Ordering::Greater => Ordering::Less,
Ordering::Less => Ordering::Greater,
// JSON guarantees that there can't be duplicate keys
Ordering::Equal => unreachable!(),
});
Ok(normalized_map)
}
fn resolve_scopes_match(
scopes: &ScopesMap,
normalized_specifier: &str,
as_url: Option<&Url>,
referrer: &str,
) -> Result<Option<Url>, ImportMapError> {
// exact-match
if let Some(scope_imports) = scopes.get(referrer) {
let scope_match = ImportMap::resolve_imports_match(
scope_imports,
normalized_specifier,
as_url,
)?;
// 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)
{
let scope_match = ImportMap::resolve_imports_match(
scope_imports,
normalized_specifier,
as_url,
)?;
// Return only if there was actual match (not None).
if scope_match.is_some() {
return Ok(scope_match);
}
}
}
Ok(None)
}
fn resolve_imports_match(
specifier_map: &SpecifierMap,
normalized_specifier: &str,
as_url: Option<&Url>,
) -> Result<Option<Url>, ImportMapError> {
// exact-match
if let Some(maybe_address) = specifier_map.get(normalized_specifier) {
if let Some(address) = maybe_address {
return Ok(Some(address.clone()));
} else {
return Err(ImportMapError::Other(format!(
"Blocked by null entry for \"{:?}\"",
normalized_specifier
)));
}
}
// Package-prefix match
// "most-specific wins", i.e. when there are multiple matching keys,
// choose the longest.
for (specifier_key, maybe_address) in specifier_map.iter() {
if !specifier_key.ends_with('/') {
continue;
}
if !normalized_specifier.starts_with(specifier_key) {
continue;
}
if let Some(url) = as_url {
if !is_special(url) {
continue;
}
}
let resolution_result = maybe_address.as_ref().ok_or_else(|| {
ImportMapError::Other(format!(
"Blocked by null entry for \"{:?}\"",
specifier_key
))
})?;
// Enforced by parsing.
assert!(resolution_result.to_string().ends_with('/'));
let after_prefix = &normalized_specifier[specifier_key.len()..];
let url = match ImportMap::append_specifier_to_base(
resolution_result,
after_prefix,
) {
Ok(url) => url,
Err(_) => {
return Err(ImportMapError::Other(format!(
"Failed to resolve the specifier \"{:?}\" as its after-prefix
portion \"{:?}\" could not be URL-parsed relative to the URL prefix
\"{:?}\" mapped to by the prefix \"{:?}\"",
normalized_specifier,
after_prefix,
resolution_result,
specifier_key
)));
}
};
if !url.as_str().starts_with(resolution_result.as_str()) {
return Err(ImportMapError::Other(format!(
"The specifier \"{:?}\" backtracks above its prefix \"{:?}\"",
normalized_specifier, specifier_key
)));
}
return Ok(Some(url));
}
debug!(
"Specifier {:?} was not mapped in import map.",
normalized_specifier
);
Ok(None)
}
pub fn resolve(
&self,
specifier: &str,
referrer: &str,
) -> Result<Url, ImportMapError> {
let as_url: Option<Url> =
ImportMap::try_url_like_specifier(specifier, referrer);
let normalized_specifier = if let Some(url) = as_url.as_ref() {
url.to_string()
} else {
specifier.to_string()
};
let scopes_match = ImportMap::resolve_scopes_match(
&self.scopes,
&normalized_specifier,
as_url.as_ref(),
&referrer.to_string(),
)?;
// match found in scopes map
if let Some(scopes_match) = scopes_match {
return Ok(scopes_match);
}
let imports_match = ImportMap::resolve_imports_match(
&self.imports,
&normalized_specifier,
as_url.as_ref(),
)?;
// match found in import map
if let Some(imports_match) = imports_match {
return Ok(imports_match);
}
// The specifier was able to be turned into a URL, but wasn't remapped into anything.
if let Some(as_url) = as_url {
return Ok(as_url);
}
Err(ImportMapError::UnmappedBareSpecifier(
specifier.to_string(),
Some(referrer.to_string()),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::path::PathBuf;
use walkdir::WalkDir;
#[derive(Debug)]
enum TestKind {
Resolution {
given_specifier: String,
expected_specifier: Option<String>,
base_url: String,
},
Parse {
expected_import_map: Value,
},
}
#[derive(Debug)]
struct ImportMapTestCase {
name: String,
import_map: String,
import_map_base_url: String,
kind: TestKind,
}
fn load_import_map_wpt_tests() -> Vec<String> {
let mut found_test_files = vec![];
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let import_map_wpt_path =
repo_root.join("test_util/wpt/import-maps/data-driven/resources");
eprintln!("import map wpt path {:#?}", import_map_wpt_path);
for entry in WalkDir::new(import_map_wpt_path)
.contents_first(true)
.into_iter()
.filter_entry(|e| {
eprintln!("entry {:#?}", e);
if let Some(ext) = e.path().extension() {
return ext.to_string_lossy() == "json";
}
false
})
.filter_map(|e| match e {
Ok(e) => Some(e),
_ => None,
})
.map(|e| PathBuf::from(e.path()))
{
found_test_files.push(entry);
}
let mut file_contents = vec![];
for file in found_test_files {
let content = std::fs::read_to_string(file).unwrap();
file_contents.push(content);
}
file_contents
}
fn parse_import_map_tests(test_str: &str) -> Vec<ImportMapTestCase> {
let json_file: serde_json::Value = serde_json::from_str(test_str).unwrap();
let maybe_name = json_file
.get("name")
.map(|s| s.as_str().unwrap().to_string());
return parse_test_object(&json_file, maybe_name, None, None, None, None);
fn parse_test_object(
test_obj: &Value,
maybe_name_prefix: Option<String>,
maybe_import_map: Option<String>,
maybe_base_url: Option<String>,
maybe_import_map_base_url: Option<String>,
maybe_expected_import_map: Option<Value>,
) -> Vec<ImportMapTestCase> {
let maybe_import_map_base_url =
if let Some(base_url) = test_obj.get("importMapBaseURL") {
Some(base_url.as_str().unwrap().to_string())
} else {
maybe_import_map_base_url
};
let maybe_base_url = if let Some(base_url) = test_obj.get("baseURL") {
Some(base_url.as_str().unwrap().to_string())
} else {
maybe_base_url
};
let maybe_expected_import_map =
if let Some(im) = test_obj.get("expectedParsedImportMap") {
Some(im.to_owned())
} else {
maybe_expected_import_map
};
let maybe_import_map = if let Some(import_map) = test_obj.get("importMap")
{
Some(if import_map.is_string() {
import_map.as_str().unwrap().to_string()
} else {
serde_json::to_string(import_map).unwrap()
})
} else {
maybe_import_map
};
if let Some(nested_tests) = test_obj.get("tests") {
let nested_tests_obj = nested_tests.as_object().unwrap();
let mut collected = vec![];
for (name, test_obj) in nested_tests_obj {
let nested_name = if let Some(ref name_prefix) = maybe_name_prefix {
format!("{}: {}", name_prefix, name)
} else {
name.to_string()
};
let parsed_nested_tests = parse_test_object(
test_obj,
Some(nested_name),
maybe_import_map.clone(),
maybe_base_url.clone(),
maybe_import_map_base_url.clone(),
maybe_expected_import_map.clone(),
);
collected.extend(parsed_nested_tests)
}
return collected;
}
let mut collected_cases = vec![];
if let Some(results) = test_obj.get("expectedResults") {
let expected_results = results.as_object().unwrap();
for (given, expected) in expected_results {
let name = if let Some(ref name_prefix) = maybe_name_prefix {
format!("{}: {}", name_prefix, given)
} else {
given.to_string()
};
let given_specifier = given.to_string();
let expected_specifier = expected.as_str().map(|str| str.to_string());
let test_case = ImportMapTestCase {
name,
import_map_base_url: maybe_import_map_base_url.clone().unwrap(),
import_map: maybe_import_map.clone().unwrap(),
kind: TestKind::Resolution {
given_specifier,
expected_specifier,
base_url: maybe_base_url.clone().unwrap(),
},
};
collected_cases.push(test_case);
}
} else if let Some(expected_import_map) = maybe_expected_import_map {
let test_case = ImportMapTestCase {
name: maybe_name_prefix.unwrap(),
import_map_base_url: maybe_import_map_base_url.unwrap(),
import_map: maybe_import_map.unwrap(),
kind: TestKind::Parse {
expected_import_map,
},
};
collected_cases.push(test_case);
} else {
eprintln!("unreachable {:#?}", test_obj);
unreachable!();
}
collected_cases
}
}
fn run_import_map_test_cases(tests: Vec<ImportMapTestCase>) {
for test in tests {
match &test.kind {
TestKind::Resolution {
given_specifier,
expected_specifier,
base_url,
} => {
let import_map =
ImportMap::from_json(&test.import_map_base_url, &test.import_map)
.unwrap();
let maybe_resolved = import_map
.resolve(given_specifier, base_url)
.ok()
.map(|url| url.to_string());
assert_eq!(expected_specifier, &maybe_resolved, "{}", test.name);
}
TestKind::Parse {
expected_import_map,
} => {
if matches!(expected_import_map, Value::Null) {
assert!(ImportMap::from_json(
&test.import_map_base_url,
&test.import_map
)
.is_err());
} else {
let import_map =
ImportMap::from_json(&test.import_map_base_url, &test.import_map)
.unwrap();
let import_map_value = serde_json::to_value(import_map).unwrap();
assert_eq!(expected_import_map, &import_map_value, "{}", test.name);
}
}
}
}
}
#[test]
fn wpt() {
let test_file_contents = load_import_map_wpt_tests();
eprintln!("Found test files {}", test_file_contents.len());
for test_file in test_file_contents {
let tests = parse_import_map_tests(&test_file);
run_import_map_test_cases(tests);
}
}
#[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 JSON message test
assert_eq!(
ImportMap::from_json(base_url, "{\"a\":1,}")
.unwrap_err()
.to_string(),
"Unable to parse import map JSON: trailing comma at line 1 column 8",
);
// 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 mapped_windows_file_specifier() {
// from issue #11530
let mut specifiers = SpecifierMap::new();
specifiers.insert(
"file:///".to_string(),
Some(Url::parse("http://localhost/").unwrap()),
);
let resolved_specifier = ImportMap::resolve_imports_match(
&specifiers,
"file:///C:/folder/file.ts",
None,
)
.unwrap()
.unwrap();
assert_eq!(
resolved_specifier.as_str(),
"http://localhost/C:/folder/file.ts"
);
}
}