summaryrefslogtreecommitdiff
path: root/core/module_specifier.rs
diff options
context:
space:
mode:
Diffstat (limited to 'core/module_specifier.rs')
-rw-r--r--core/module_specifier.rs473
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);
+ }
+ }
+}