diff options
Diffstat (limited to 'core/module_specifier.rs')
-rw-r--r-- | core/module_specifier.rs | 473 |
1 files changed, 473 insertions, 0 deletions
diff --git a/core/module_specifier.rs b/core/module_specifier.rs new file mode 100644 index 000000000..dbab0ce9b --- /dev/null +++ b/core/module_specifier.rs @@ -0,0 +1,473 @@ +use std::env::current_dir; +use std::error::Error; +use std::fmt; +use std::path::PathBuf; +use url::ParseError; +use url::Url; + +/// Error indicating the reason resolving a module specifier failed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ModuleResolutionError { + InvalidUrl(ParseError), + InvalidBaseUrl(ParseError), + InvalidPath(PathBuf), + ImportPrefixMissing(String), +} +use ModuleResolutionError::*; + +impl Error for ModuleResolutionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + InvalidUrl(ref err) | InvalidBaseUrl(ref err) => Some(err), + _ => None, + } + } +} + +impl fmt::Display for ModuleResolutionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + InvalidUrl(ref err) => write!(f, "invalid URL: {}", err), + InvalidBaseUrl(ref err) => { + write!(f, "invalid base URL for relative import: {}", err) + } + InvalidPath(ref path) => write!(f, "invalid module path: {:?}", path), + ImportPrefixMissing(ref specifier) => write!( + f, + "relative import path \"{}\" not prefixed with / or ./ or ../", + specifier + ), + } + } +} + +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +/// Resolved module specifier +pub struct ModuleSpecifier(Url); + +impl ModuleSpecifier { + fn is_dummy_specifier(specifier: &str) -> bool { + specifier == "<unknown>" + } + + pub fn as_url(&self) -> &Url { + &self.0 + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Resolves module using this algorithm: + /// https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier + pub fn resolve_import( + specifier: &str, + base: &str, + ) -> Result<ModuleSpecifier, ModuleResolutionError> { + let url = match Url::parse(specifier) { + // 1. Apply the URL parser to specifier. + // If the result is not failure, return he result. + Ok(url) => url, + + // 2. If specifier does not start with the character U+002F SOLIDUS (/), + // the two-character sequence U+002E FULL STOP, U+002F SOLIDUS (./), + // or the three-character sequence U+002E FULL STOP, U+002E FULL STOP, + // U+002F SOLIDUS (../), return failure. + Err(ParseError::RelativeUrlWithoutBase) + if !(specifier.starts_with('/') + || specifier.starts_with("./") + || specifier.starts_with("../")) => + { + return Err(ImportPrefixMissing(specifier.to_string())) + } + + // 3. Return the result of applying the URL parser to specifier with base + // URL as the base URL. + Err(ParseError::RelativeUrlWithoutBase) => { + let base = if ModuleSpecifier::is_dummy_specifier(base) { + // Handle <unknown> case, happening under e.g. repl. + // Use CWD for such case. + + // Forcefully join base to current dir. + // Otherwise, later joining in Url would be interpreted in + // the parent directory (appending trailing slash does not work) + let path = current_dir().unwrap().join(base); + Url::from_file_path(path).unwrap() + } else { + Url::parse(base).map_err(InvalidBaseUrl)? + }; + base.join(&specifier).map_err(InvalidUrl)? + } + + // If parsing the specifier as a URL failed for a different reason than + // it being relative, always return the original error. We don't want to + // return `ImportPrefixMissing` or `InvalidBaseUrl` if the real + // problem lies somewhere else. + Err(err) => return Err(InvalidUrl(err)), + }; + + Ok(ModuleSpecifier(url)) + } + + /// Converts a string representing an absulute URL into a ModuleSpecifier. + pub fn resolve_url( + url_str: &str, + ) -> Result<ModuleSpecifier, ModuleResolutionError> { + Url::parse(url_str) + .map(ModuleSpecifier) + .map_err(ModuleResolutionError::InvalidUrl) + } + + /// Takes a string representing either an absolute URL or a file path, + /// as it may be passed to deno as a command line argument. + /// The string is interpreted as a URL if it starts with a valid URI scheme, + /// e.g. 'http:' or 'file:' or 'git+ssh:'. If not, it's interpreted as a + /// file path; if it is a relative path it's resolved relative to the current + /// working directory. + pub fn resolve_url_or_path( + specifier: &str, + ) -> Result<ModuleSpecifier, ModuleResolutionError> { + if Self::specifier_has_uri_scheme(specifier) { + Self::resolve_url(specifier) + } else { + Self::resolve_path(specifier) + } + } + + /// Converts a string representing a relative or absolute path into a + /// ModuleSpecifier. A relative path is considered relative to the current + /// working directory. + fn resolve_path( + path_str: &str, + ) -> Result<ModuleSpecifier, ModuleResolutionError> { + let path = current_dir().unwrap().join(path_str); + Url::from_file_path(path.clone()) + .map(ModuleSpecifier) + .map_err(|()| ModuleResolutionError::InvalidPath(path)) + } + + /// Returns true if the input string starts with a sequence of characters + /// that could be a valid URI scheme, like 'https:', 'git+ssh:' or 'data:'. + /// + /// According to RFC 3986 (https://tools.ietf.org/html/rfc3986#section-3.1), + /// a valid scheme has the following format: + /// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + /// + /// We additionally require the scheme to be at least 2 characters long, + /// because otherwise a windows path like c:/foo would be treated as a URL, + /// while no schemes with a one-letter name actually exist. + fn specifier_has_uri_scheme(specifier: &str) -> bool { + let mut chars = specifier.chars(); + let mut len = 0usize; + // THe first character must be a letter. + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => len += 1, + _ => return false, + } + // Second and following characters must be either a letter, number, + // plus sign, minus sign, or dot. + loop { + match chars.next() { + Some(c) if c.is_ascii_alphanumeric() || "+-.".contains(c) => len += 1, + Some(':') if len >= 2 => return true, + _ => return false, + } + } + } +} + +impl fmt::Display for ModuleSpecifier { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.0.fmt(f) + } +} + +impl From<Url> for ModuleSpecifier { + fn from(url: Url) -> Self { + ModuleSpecifier(url) + } +} + +impl PartialEq<String> for ModuleSpecifier { + fn eq(&self, other: &String) -> bool { + &self.to_string() == other + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_import() { + let tests = vec![ + ( + "./005_more_imports.ts", + "http://deno.land/core/tests/006_url_imports.ts", + "http://deno.land/core/tests/005_more_imports.ts", + ), + ( + "../005_more_imports.ts", + "http://deno.land/core/tests/006_url_imports.ts", + "http://deno.land/core/005_more_imports.ts", + ), + ( + "http://deno.land/core/tests/005_more_imports.ts", + "http://deno.land/core/tests/006_url_imports.ts", + "http://deno.land/core/tests/005_more_imports.ts", + ), + ( + "data:text/javascript,export default 'grapes';", + "http://deno.land/core/tests/006_url_imports.ts", + "data:text/javascript,export default 'grapes';", + ), + ( + "blob:https://whatwg.org/d0360e2f-caee-469f-9a2f-87d5b0456f6f", + "http://deno.land/core/tests/006_url_imports.ts", + "blob:https://whatwg.org/d0360e2f-caee-469f-9a2f-87d5b0456f6f", + ), + ( + "javascript:export default 'artichokes';", + "http://deno.land/core/tests/006_url_imports.ts", + "javascript:export default 'artichokes';", + ), + ( + "data:text/plain,export default 'kale';", + "http://deno.land/core/tests/006_url_imports.ts", + "data:text/plain,export default 'kale';", + ), + ( + "/dev/core/tests/005_more_imports.ts", + "file:///home/yeti", + "file:///dev/core/tests/005_more_imports.ts", + ), + ( + "//zombo.com/1999.ts", + "https://cherry.dev/its/a/thing", + "https://zombo.com/1999.ts", + ), + ( + "http://deno.land/this/url/is/valid", + "base is clearly not a valid url", + "http://deno.land/this/url/is/valid", + ), + ( + "//server/some/dir/file", + "file:///home/yeti/deno", + "file://server/some/dir/file", + ), + // This test is disabled because the url crate does not follow the spec, + // dropping the server part from the final result. + // ( + // "/another/path/at/the/same/server", + // "file://server/some/dir/file", + // "file://server/another/path/at/the/same/server", + // ), + ]; + + for (specifier, base, expected_url) in tests { + let url = ModuleSpecifier::resolve_import(specifier, base) + .unwrap() + .to_string(); + assert_eq!(url, expected_url); + } + } + + #[test] + fn test_resolve_import_error() { + use url::ParseError::*; + use ModuleResolutionError::*; + + let tests = vec![ + ( + "005_more_imports.ts", + "http://deno.land/core/tests/006_url_imports.ts", + ImportPrefixMissing("005_more_imports.ts".to_string()), + ), + ( + ".tomato", + "http://deno.land/core/tests/006_url_imports.ts", + ImportPrefixMissing(".tomato".to_string()), + ), + ( + "..zucchini.mjs", + "http://deno.land/core/tests/006_url_imports.ts", + ImportPrefixMissing("..zucchini.mjs".to_string()), + ), + ( + r".\yam.es", + "http://deno.land/core/tests/006_url_imports.ts", + ImportPrefixMissing(r".\yam.es".to_string()), + ), + ( + r"..\yam.es", + "http://deno.land/core/tests/006_url_imports.ts", + ImportPrefixMissing(r"..\yam.es".to_string()), + ), + ( + "https://eggplant:b/c", + "http://deno.land/core/tests/006_url_imports.ts", + InvalidUrl(InvalidPort), + ), + ( + "https://eggplant@/c", + "http://deno.land/core/tests/006_url_imports.ts", + InvalidUrl(EmptyHost), + ), + ( + "./foo.ts", + "/relative/base/url", + InvalidBaseUrl(RelativeUrlWithoutBase), + ), + ]; + + for (specifier, base, expected_err) in tests { + let err = ModuleSpecifier::resolve_import(specifier, base).unwrap_err(); + assert_eq!(err, expected_err); + } + } + + #[test] + fn test_resolve_url_or_path() { + // Absolute URL. + let mut tests: Vec<(&str, String)> = vec![ + ( + "http://deno.land/core/tests/006_url_imports.ts", + "http://deno.land/core/tests/006_url_imports.ts".to_string(), + ), + ( + "https://deno.land/core/tests/006_url_imports.ts", + "https://deno.land/core/tests/006_url_imports.ts".to_string(), + ), + ]; + + // The local path tests assume that the cwd is the deno repo root. + let cwd = current_dir().unwrap(); + let cwd_str = cwd.to_str().unwrap(); + + if cfg!(target_os = "windows") { + // Absolute local path. + let expected_url = "file:///C:/deno/tests/006_url_imports.ts"; + tests.extend(vec![ + ( + r"C:/deno/tests/006_url_imports.ts", + expected_url.to_string(), + ), + ( + r"C:\deno\tests\006_url_imports.ts", + expected_url.to_string(), + ), + ( + r"\\?\C:\deno\tests\006_url_imports.ts", + expected_url.to_string(), + ), + // Not supported: `Url::from_file_path()` fails. + // (r"\\.\C:\deno\tests\006_url_imports.ts", expected_url.to_string()), + // Not supported: `Url::from_file_path()` performs the wrong conversion. + // (r"//./C:/deno/tests/006_url_imports.ts", expected_url.to_string()), + ]); + + // Rooted local path without drive letter. + let expected_url = format!( + "file:///{}:/deno/tests/006_url_imports.ts", + cwd_str.get(..1).unwrap(), + ); + tests.extend(vec![ + (r"/deno/tests/006_url_imports.ts", expected_url.to_string()), + (r"\deno\tests\006_url_imports.ts", expected_url.to_string()), + ]); + + // Relative local path. + let expected_url = format!( + "file:///{}/tests/006_url_imports.ts", + cwd_str.replace("\\", "/") + ); + tests.extend(vec![ + (r"tests/006_url_imports.ts", expected_url.to_string()), + (r"tests\006_url_imports.ts", expected_url.to_string()), + (r"./tests/006_url_imports.ts", expected_url.to_string()), + (r".\tests\006_url_imports.ts", expected_url.to_string()), + ]); + + // UNC network path. + let expected_url = "file://server/share/deno/cool"; + tests.extend(vec![ + (r"\\server\share\deno\cool", expected_url.to_string()), + (r"\\server/share/deno/cool", expected_url.to_string()), + // Not supported: `Url::from_file_path()` performs the wrong conversion. + // (r"//server/share/deno/cool", expected_url.to_string()), + ]); + } else { + // Absolute local path. + let expected_url = "file:///deno/tests/006_url_imports.ts"; + tests.extend(vec![ + ("/deno/tests/006_url_imports.ts", expected_url.to_string()), + ("//deno/tests/006_url_imports.ts", expected_url.to_string()), + ]); + + // Relative local path. + let expected_url = format!("file://{}/tests/006_url_imports.ts", cwd_str); + tests.extend(vec![ + ("tests/006_url_imports.ts", expected_url.to_string()), + ("./tests/006_url_imports.ts", expected_url.to_string()), + ]); + } + + for (specifier, expected_url) in tests { + let url = ModuleSpecifier::resolve_url_or_path(specifier) + .unwrap() + .to_string(); + assert_eq!(url, expected_url); + } + } + + #[test] + fn test_resolve_url_or_path_error() { + use url::ParseError::*; + use ModuleResolutionError::*; + + let mut tests = vec![ + ("https://eggplant:b/c", InvalidUrl(InvalidPort)), + ("https://:8080/a/b/c", InvalidUrl(EmptyHost)), + ]; + if cfg!(target_os = "windows") { + let p = r"\\.\c:/stuff/deno/script.ts"; + tests.push((p, InvalidPath(PathBuf::from(p)))); + } + + for (specifier, expected_err) in tests { + let err = ModuleSpecifier::resolve_url_or_path(specifier).unwrap_err(); + assert_eq!(err, expected_err); + } + } + + #[test] + fn test_specifier_has_uri_scheme() { + let tests = vec![ + ("http://foo.bar/etc", true), + ("HTTP://foo.bar/etc", true), + ("http:ftp:", true), + ("http:", true), + ("hTtP:", true), + ("ftp:", true), + ("mailto:spam@please.me", true), + ("git+ssh://git@github.com/denoland/deno", true), + ("blob:https://whatwg.org/mumbojumbo", true), + ("abc.123+DEF-ghi:", true), + ("abc.123+def-ghi:@", true), + ("", false), + (":not", false), + ("http", false), + ("c:dir", false), + ("X:", false), + ("./http://not", false), + ("1abc://kinda/but/no", false), + ("schluẞ://no/more", false), + ]; + + for (specifier, expected) in tests { + let result = ModuleSpecifier::specifier_has_uri_scheme(specifier); + assert_eq!(result, expected); + } + } +} |