rw_deno_core/
module_specifier.rs

1// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
2
3use crate::normalize_path;
4use std::error::Error;
5use std::fmt;
6use std::path::Path;
7use std::path::PathBuf;
8use url::ParseError;
9use url::Url;
10
11/// Error indicating the reason resolving a module specifier failed.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum ModuleResolutionError {
14  InvalidUrl(ParseError),
15  InvalidBaseUrl(ParseError),
16  InvalidPath(PathBuf),
17  ImportPrefixMissing(String, Option<String>),
18}
19use ModuleResolutionError::*;
20
21impl Error for ModuleResolutionError {
22  fn source(&self) -> Option<&(dyn Error + 'static)> {
23    match self {
24      InvalidUrl(ref err) | InvalidBaseUrl(ref err) => Some(err),
25      _ => None,
26    }
27  }
28}
29
30impl fmt::Display for ModuleResolutionError {
31  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
32    match self {
33      InvalidUrl(ref err) => write!(f, "invalid URL: {err}"),
34      InvalidBaseUrl(ref err) => {
35        write!(f, "invalid base URL for relative import: {err}")
36      }
37      InvalidPath(ref path) => write!(f, "invalid module path: {path:?}"),
38      ImportPrefixMissing(ref specifier, ref maybe_referrer) => write!(
39        f,
40        "Relative import path \"{}\" not prefixed with / or ./ or ../{}",
41        specifier,
42        match maybe_referrer {
43          Some(referrer) => format!(" from \"{referrer}\""),
44          None => String::new(),
45        }
46      ),
47    }
48  }
49}
50
51/// Resolved module specifier
52pub type ModuleSpecifier = Url;
53
54/// Resolves module using this algorithm:
55/// <https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier>
56pub fn resolve_import(
57  specifier: &str,
58  base: &str,
59) -> Result<ModuleSpecifier, ModuleResolutionError> {
60  let url = match Url::parse(specifier) {
61    // 1. Apply the URL parser to specifier.
62    //    If the result is not failure, return he result.
63    Ok(url) => url,
64
65    // 2. If specifier does not start with the character U+002F SOLIDUS (/),
66    //    the two-character sequence U+002E FULL STOP, U+002F SOLIDUS (./),
67    //    or the three-character sequence U+002E FULL STOP, U+002E FULL STOP,
68    //    U+002F SOLIDUS (../), return failure.
69    Err(ParseError::RelativeUrlWithoutBase)
70      if !(specifier.starts_with('/')
71        || specifier.starts_with("./")
72        || specifier.starts_with("../")) =>
73    {
74      let maybe_referrer = if base.is_empty() {
75        None
76      } else {
77        Some(base.to_string())
78      };
79      return Err(ImportPrefixMissing(specifier.to_string(), maybe_referrer));
80    }
81
82    // 3. Return the result of applying the URL parser to specifier with base
83    //    URL as the base URL.
84    Err(ParseError::RelativeUrlWithoutBase) => {
85      let base = Url::parse(base).map_err(InvalidBaseUrl)?;
86      base.join(specifier).map_err(InvalidUrl)?
87    }
88
89    // If parsing the specifier as a URL failed for a different reason than
90    // it being relative, always return the original error. We don't want to
91    // return `ImportPrefixMissing` or `InvalidBaseUrl` if the real
92    // problem lies somewhere else.
93    Err(err) => return Err(InvalidUrl(err)),
94  };
95
96  Ok(url)
97}
98
99/// Converts a string representing an absolute URL into a ModuleSpecifier.
100pub fn resolve_url(
101  url_str: &str,
102) -> Result<ModuleSpecifier, ModuleResolutionError> {
103  Url::parse(url_str).map_err(ModuleResolutionError::InvalidUrl)
104}
105
106/// Takes a string representing either an absolute URL or a file path,
107/// as it may be passed to deno as a command line argument.
108/// The string is interpreted as a URL if it starts with a valid URI scheme,
109/// e.g. 'http:' or 'file:' or 'git+ssh:'. If not, it's interpreted as a
110/// file path; if it is a relative path it's resolved relative to passed
111/// `current_dir`.
112pub fn resolve_url_or_path(
113  specifier: &str,
114  current_dir: &Path,
115) -> Result<ModuleSpecifier, ModuleResolutionError> {
116  if specifier_has_uri_scheme(specifier) {
117    resolve_url(specifier)
118  } else {
119    resolve_path(specifier, current_dir)
120  }
121}
122
123/// Converts a string representing a relative or absolute path into a
124/// ModuleSpecifier. A relative path is considered relative to the passed
125/// `current_dir`.
126pub fn resolve_path(
127  path_str: &str,
128  current_dir: &Path,
129) -> Result<ModuleSpecifier, ModuleResolutionError> {
130  let path = current_dir.join(path_str);
131  let path = normalize_path(path);
132  Url::from_file_path(&path)
133    .map_err(|()| ModuleResolutionError::InvalidPath(path))
134}
135
136/// Returns true if the input string starts with a sequence of characters
137/// that could be a valid URI scheme, like 'https:', 'git+ssh:' or 'data:'.
138///
139/// According to RFC 3986 (https://tools.ietf.org/html/rfc3986#section-3.1),
140/// a valid scheme has the following format:
141///   scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
142///
143/// We additionally require the scheme to be at least 2 characters long,
144/// because otherwise a windows path like c:/foo would be treated as a URL,
145/// while no schemes with a one-letter name actually exist.
146fn specifier_has_uri_scheme(specifier: &str) -> bool {
147  let mut chars = specifier.chars();
148  let mut len = 0usize;
149  // THe first character must be a letter.
150  match chars.next() {
151    Some(c) if c.is_ascii_alphabetic() => len += 1,
152    _ => return false,
153  }
154  // Second and following characters must be either a letter, number,
155  // plus sign, minus sign, or dot.
156  loop {
157    match chars.next() {
158      Some(c) if c.is_ascii_alphanumeric() || "+-.".contains(c) => len += 1,
159      Some(':') if len >= 2 => return true,
160      _ => return false,
161    }
162  }
163}
164
165#[cfg(all(test, not(miri)))]
166mod tests {
167  use super::*;
168  use crate::serde_json::from_value;
169  use crate::serde_json::json;
170  use std::env::current_dir;
171  use std::path::Path;
172
173  #[test]
174  fn test_resolve_import() {
175    let tests = vec![
176      (
177        "./005_more_imports.ts",
178        "http://deno.land/core/tests/006_url_imports.ts",
179        "http://deno.land/core/tests/005_more_imports.ts",
180      ),
181      (
182        "../005_more_imports.ts",
183        "http://deno.land/core/tests/006_url_imports.ts",
184        "http://deno.land/core/005_more_imports.ts",
185      ),
186      (
187        "http://deno.land/core/tests/005_more_imports.ts",
188        "http://deno.land/core/tests/006_url_imports.ts",
189        "http://deno.land/core/tests/005_more_imports.ts",
190      ),
191      (
192        "data:text/javascript,export default 'grapes';",
193        "http://deno.land/core/tests/006_url_imports.ts",
194        "data:text/javascript,export default 'grapes';",
195      ),
196      (
197        "blob:https://whatwg.org/d0360e2f-caee-469f-9a2f-87d5b0456f6f",
198        "http://deno.land/core/tests/006_url_imports.ts",
199        "blob:https://whatwg.org/d0360e2f-caee-469f-9a2f-87d5b0456f6f",
200      ),
201      (
202        "javascript:export default 'artichokes';",
203        "http://deno.land/core/tests/006_url_imports.ts",
204        "javascript:export default 'artichokes';",
205      ),
206      (
207        "data:text/plain,export default 'kale';",
208        "http://deno.land/core/tests/006_url_imports.ts",
209        "data:text/plain,export default 'kale';",
210      ),
211      (
212        "/dev/core/tests/005_more_imports.ts",
213        "file:///home/yeti",
214        "file:///dev/core/tests/005_more_imports.ts",
215      ),
216      (
217        "//zombo.com/1999.ts",
218        "https://cherry.dev/its/a/thing",
219        "https://zombo.com/1999.ts",
220      ),
221      (
222        "http://deno.land/this/url/is/valid",
223        "base is clearly not a valid url",
224        "http://deno.land/this/url/is/valid",
225      ),
226      (
227        "//server/some/dir/file",
228        "file:///home/yeti/deno",
229        "file://server/some/dir/file",
230      ),
231      // This test is disabled because the url crate does not follow the spec,
232      // dropping the server part from the final result.
233      // (
234      //   "/another/path/at/the/same/server",
235      //   "file://server/some/dir/file",
236      //   "file://server/another/path/at/the/same/server",
237      // ),
238    ];
239
240    for (specifier, base, expected_url) in tests {
241      let url = resolve_import(specifier, base).unwrap().to_string();
242      assert_eq!(url, expected_url);
243    }
244  }
245
246  #[test]
247  fn test_resolve_import_error() {
248    use url::ParseError::*;
249    use ModuleResolutionError::*;
250
251    let tests = vec![
252      (
253        "awesome.ts",
254        "<unknown>",
255        ImportPrefixMissing(
256          "awesome.ts".to_string(),
257          Some("<unknown>".to_string()),
258        ),
259      ),
260      (
261        "005_more_imports.ts",
262        "http://deno.land/core/tests/006_url_imports.ts",
263        ImportPrefixMissing(
264          "005_more_imports.ts".to_string(),
265          Some("http://deno.land/core/tests/006_url_imports.ts".to_string()),
266        ),
267      ),
268      (
269        ".tomato",
270        "http://deno.land/core/tests/006_url_imports.ts",
271        ImportPrefixMissing(
272          ".tomato".to_string(),
273          Some("http://deno.land/core/tests/006_url_imports.ts".to_string()),
274        ),
275      ),
276      (
277        "..zucchini.mjs",
278        "http://deno.land/core/tests/006_url_imports.ts",
279        ImportPrefixMissing(
280          "..zucchini.mjs".to_string(),
281          Some("http://deno.land/core/tests/006_url_imports.ts".to_string()),
282        ),
283      ),
284      (
285        r".\yam.es",
286        "http://deno.land/core/tests/006_url_imports.ts",
287        ImportPrefixMissing(
288          r".\yam.es".to_string(),
289          Some("http://deno.land/core/tests/006_url_imports.ts".to_string()),
290        ),
291      ),
292      (
293        r"..\yam.es",
294        "http://deno.land/core/tests/006_url_imports.ts",
295        ImportPrefixMissing(
296          r"..\yam.es".to_string(),
297          Some("http://deno.land/core/tests/006_url_imports.ts".to_string()),
298        ),
299      ),
300      (
301        "https://eggplant:b/c",
302        "http://deno.land/core/tests/006_url_imports.ts",
303        InvalidUrl(InvalidPort),
304      ),
305      (
306        "https://eggplant@/c",
307        "http://deno.land/core/tests/006_url_imports.ts",
308        InvalidUrl(EmptyHost),
309      ),
310      (
311        "./foo.ts",
312        "/relative/base/url",
313        InvalidBaseUrl(RelativeUrlWithoutBase),
314      ),
315    ];
316
317    for (specifier, base, expected_err) in tests {
318      let err = resolve_import(specifier, base).unwrap_err();
319      assert_eq!(err, expected_err);
320    }
321  }
322
323  #[test]
324  fn test_resolve_url_or_path() {
325    // Absolute URL.
326    let mut tests: Vec<(&str, String)> = vec![
327      (
328        "http://deno.land/core/tests/006_url_imports.ts",
329        "http://deno.land/core/tests/006_url_imports.ts".to_string(),
330      ),
331      (
332        "https://deno.land/core/tests/006_url_imports.ts",
333        "https://deno.land/core/tests/006_url_imports.ts".to_string(),
334      ),
335    ];
336
337    // The local path tests assume that the cwd is the deno repo root.
338    let cwd = current_dir().unwrap();
339    let cwd_str = cwd.to_str().unwrap();
340
341    if cfg!(target_os = "windows") {
342      // Absolute local path.
343      let expected_url = "file:///C:/deno/tests/006_url_imports.ts";
344      tests.extend(vec![
345        (
346          r"C:/deno/tests/006_url_imports.ts",
347          expected_url.to_string(),
348        ),
349        (
350          r"C:\deno\tests\006_url_imports.ts",
351          expected_url.to_string(),
352        ),
353        (
354          r"\\?\C:\deno\tests\006_url_imports.ts",
355          expected_url.to_string(),
356        ),
357        // Not supported: `Url::from_file_path()` fails.
358        // (r"\\.\C:\deno\tests\006_url_imports.ts", expected_url.to_string()),
359        // Not supported: `Url::from_file_path()` performs the wrong conversion.
360        // (r"//./C:/deno/tests/006_url_imports.ts", expected_url.to_string()),
361      ]);
362
363      // Rooted local path without drive letter.
364      let expected_url = format!(
365        "file:///{}:/deno/tests/006_url_imports.ts",
366        cwd_str.get(..1).unwrap(),
367      );
368      tests.extend(vec![
369        (r"/deno/tests/006_url_imports.ts", expected_url.to_string()),
370        (r"\deno\tests\006_url_imports.ts", expected_url.to_string()),
371        (
372          r"\deno\..\deno\tests\006_url_imports.ts",
373          expected_url.to_string(),
374        ),
375        (r"\deno\.\tests\006_url_imports.ts", expected_url),
376      ]);
377
378      // Relative local path.
379      let expected_url = format!(
380        "file:///{}/tests/006_url_imports.ts",
381        cwd_str.replace('\\', "/")
382      );
383      tests.extend(vec![
384        (r"tests/006_url_imports.ts", expected_url.to_string()),
385        (r"tests\006_url_imports.ts", expected_url.to_string()),
386        (r"./tests/006_url_imports.ts", (*expected_url).to_string()),
387        (r".\tests\006_url_imports.ts", (*expected_url).to_string()),
388      ]);
389
390      // UNC network path.
391      let expected_url = "file://server/share/deno/cool";
392      tests.extend(vec![
393        (r"\\server\share\deno\cool", expected_url.to_string()),
394        (r"\\server/share/deno/cool", expected_url.to_string()),
395        // Not supported: `Url::from_file_path()` performs the wrong conversion.
396        // (r"//server/share/deno/cool", expected_url.to_string()),
397      ]);
398    } else {
399      // Absolute local path.
400      let expected_url = "file:///deno/tests/006_url_imports.ts";
401      tests.extend(vec![
402        ("/deno/tests/006_url_imports.ts", expected_url.to_string()),
403        ("//deno/tests/006_url_imports.ts", expected_url.to_string()),
404      ]);
405
406      // Relative local path.
407      let expected_url = format!("file://{cwd_str}/tests/006_url_imports.ts");
408      tests.extend(vec![
409        ("tests/006_url_imports.ts", expected_url.to_string()),
410        ("./tests/006_url_imports.ts", expected_url.to_string()),
411        (
412          "tests/../tests/006_url_imports.ts",
413          expected_url.to_string(),
414        ),
415        ("tests/./006_url_imports.ts", expected_url),
416      ]);
417    }
418
419    for (specifier, expected_url) in tests {
420      let url = resolve_url_or_path(specifier, &cwd).unwrap().to_string();
421      assert_eq!(url, expected_url);
422    }
423  }
424
425  #[test]
426  fn test_resolve_url_or_path_deprecated_error() {
427    use url::ParseError::*;
428    use ModuleResolutionError::*;
429
430    let mut tests = vec![
431      ("https://eggplant:b/c", InvalidUrl(InvalidPort)),
432      ("https://:8080/a/b/c", InvalidUrl(EmptyHost)),
433    ];
434    if cfg!(target_os = "windows") {
435      let p = r"\\.\c:/stuff/deno/script.ts";
436      tests.push((p, InvalidPath(PathBuf::from(p))));
437    }
438
439    for (specifier, expected_err) in tests {
440      let err =
441        resolve_url_or_path(specifier, &PathBuf::from("/")).unwrap_err();
442      assert_eq!(err, expected_err);
443    }
444  }
445
446  #[test]
447  fn test_specifier_has_uri_scheme() {
448    let tests = vec![
449      ("http://foo.bar/etc", true),
450      ("HTTP://foo.bar/etc", true),
451      ("http:ftp:", true),
452      ("http:", true),
453      ("hTtP:", true),
454      ("ftp:", true),
455      ("mailto:spam@please.me", true),
456      ("git+ssh://git@github.com/denoland/deno", true),
457      ("blob:https://whatwg.org/mumbojumbo", true),
458      ("abc.123+DEF-ghi:", true),
459      ("abc.123+def-ghi:@", true),
460      ("", false),
461      (":not", false),
462      ("http", false),
463      ("c:dir", false),
464      ("X:", false),
465      ("./http://not", false),
466      ("1abc://kinda/but/no", false),
467      ("schluẞ://no/more", false),
468    ];
469
470    for (specifier, expected) in tests {
471      let result = specifier_has_uri_scheme(specifier);
472      assert_eq!(result, expected);
473    }
474  }
475
476  #[test]
477  fn test_normalize_path() {
478    assert_eq!(normalize_path(Path::new("a/../b")), PathBuf::from("b"));
479    assert_eq!(normalize_path(Path::new("a/./b/")), PathBuf::from("a/b/"));
480    assert_eq!(
481      normalize_path(Path::new("a/./b/../c")),
482      PathBuf::from("a/c")
483    );
484
485    if cfg!(windows) {
486      assert_eq!(
487        normalize_path(Path::new("C:\\a\\.\\b\\..\\c")),
488        PathBuf::from("C:\\a\\c")
489      );
490    }
491  }
492
493  #[test]
494  fn test_deserialize_module_specifier() {
495    let actual: ModuleSpecifier =
496      from_value(json!("http://deno.land/x/mod.ts")).unwrap();
497    let expected = resolve_url("http://deno.land/x/mod.ts").unwrap();
498    assert_eq!(actual, expected);
499  }
500}