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, PartialEq)] /// Resolved module specifier pub struct ModuleSpecifier(Url); impl ModuleSpecifier { pub fn as_url(&self) -> &Url { &self.0 } /// 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 { 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("../")) => { 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 = 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) => Err(InvalidUrl(err))?, }; Ok(ModuleSpecifier(url)) } /// Converts a string representing an absulute URL into a ModuleSpecifier. pub fn resolve_url( url_str: &str, ) -> Result { 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 { 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 { 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 for ModuleSpecifier { fn from(url: Url) -> Self { ModuleSpecifier(url) } } impl PartialEq 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); } } }