Skip to main content

ferrijs_std/node/
require_resolve.rs

1//! Node's `require.resolve`: a specifier plus the directory it was
2//! written in, answered as an absolute path.
3//!
4//! Host-neutral on purpose. The algorithm is Node's and belongs here; WHO
5//! is asking — which file a bundled frame came from, which specifiers the
6//! runtime serves natively — is the host's to decide, so nothing in this
7//! module looks at a `Ctx` or knows a builtin from a package.
8//!
9//! Node's algorithm, minus the parts that cannot exist here. It reports a
10//! path and nothing else — unlike a loader, which resolves a specifier in
11//! order to LOAD it and can therefore insist the target is ESM. `require.resolve('./legacy.cjs')` is a legitimate
12//! question with a legitimate answer, so nothing here inspects module
13//! format.
14//!
15//! Not implemented, and documented rather than faked: `require.resolve`'s
16//! `{ paths }` option and `require.resolve.paths()`. Both describe a
17//! module search path this runtime does not have.
18//!
19//! Hand-written rather than vendored: upstream llrt is ESM-only and has no
20//! `require`, let alone `require.resolve`.
21
22use simd_json::prelude::*;
23use std::path::{Path, PathBuf};
24
25/// Extensions tried when a specifier names no file that exists, in order.
26///
27/// Node's own list is `.js`, `.json`, `.node`; the TypeScript ones are
28/// here because this runtime serves them as source and a suite written in
29/// TypeScript resolves its own siblings.
30const EXTENSIONS: &[&str] = &["js", "mjs", "cjs", "ts", "mts", "cts", "tsx", "jsx", "json"];
31
32/// Index files tried when a specifier names a directory.
33const INDEX_STEMS: &[&str] = &["index"];
34
35/// Resolve `specifier` as written in a file inside `base_dir`.
36///
37/// # Errors
38///
39/// Returns an error naming the specifier when nothing resolves, the way
40/// Node's `MODULE_NOT_FOUND` does.
41pub fn resolve(base_dir: &Path, specifier: &str) -> Result<PathBuf, String> {
42  if specifier.is_empty() {
43    return Err(not_found(specifier, base_dir));
44  }
45
46  if is_path_specifier(specifier) {
47    let joined = if Path::new(specifier).is_absolute() {
48      PathBuf::from(specifier)
49    } else {
50      base_dir.join(specifier)
51    };
52    return as_file_or_directory(&joined).ok_or_else(|| not_found(specifier, base_dir));
53  }
54
55  from_node_modules(base_dir, specifier).ok_or_else(|| not_found(specifier, base_dir))
56}
57
58/// `./x`, `../x`, `/x`, `.` and `..` — everything else is a package name.
59fn is_path_specifier(specifier: &str) -> bool {
60  specifier.starts_with("./")
61    || specifier.starts_with("../")
62    || specifier.starts_with('/')
63    || specifier == "."
64    || specifier == ".."
65}
66
67/// Node's LOAD_AS_FILE then LOAD_AS_DIRECTORY.
68fn as_file_or_directory(path: &Path) -> Option<PathBuf> {
69  as_file(path).or_else(|| as_directory(path))
70}
71
72/// The path itself, then the path with each extension appended.
73///
74/// Appended, never substituted: `./chart.min` resolves to
75/// `./chart.min.js`, and `Path::with_extension` would have looked for
76/// `./chart.js`.
77fn as_file(path: &Path) -> Option<PathBuf> {
78  if path.is_file() {
79    return canonical(path);
80  }
81  for ext in EXTENSIONS {
82    let mut candidate = path.as_os_str().to_os_string();
83    candidate.push(".");
84    candidate.push(ext);
85    let candidate = PathBuf::from(candidate);
86    if candidate.is_file() {
87      return canonical(&candidate);
88    }
89  }
90  None
91}
92
93/// A directory resolves through its `package.json` entry, then `index.*`.
94fn as_directory(path: &Path) -> Option<PathBuf> {
95  if !path.is_dir() {
96    return None;
97  }
98  if let Some(entry) = manifest_entry(path) {
99    if let Some(found) = as_file_or_directory(&path.join(entry)) {
100      return Some(found);
101    }
102  }
103  for stem in INDEX_STEMS {
104    if let Some(found) = as_file(&path.join(stem)) {
105      return Some(found);
106    }
107  }
108  None
109}
110
111/// The file a package's `package.json` points at.
112///
113/// `exports` first (its `require` / `import` / `default` condition, or a
114/// bare string), then `module`, then `main` — the order a bundler reads
115/// them in, and the same precedence [`crate::discover`] uses for an
116/// package.
117fn manifest_entry(pkg_dir: &Path) -> Option<String> {
118  let text = std::fs::read_to_string(pkg_dir.join("package.json")).ok()?;
119  let mut bytes = text.into_bytes();
120  let json = simd_json::to_owned_value(&mut bytes).ok()?;
121  if let Some(exports) = json.get("exports") {
122    if let Some(entry) = export_target(exports) {
123      return Some(entry);
124    }
125  }
126  for field in ["module", "main"] {
127    if let Some(value) = json.get(field).and_then(|v| v.as_str()) {
128      return Some(value.to_string());
129    }
130  }
131  None
132}
133
134/// The root target of an `exports` field: a bare string, or the `"."`
135/// entry, resolved through the conditions this runtime presents.
136fn export_target(exports: &simd_json::OwnedValue) -> Option<String> {
137  if let Some(direct) = exports.as_str() {
138    return Some(direct.to_string());
139  }
140  let root = exports.get(".").unwrap_or(exports);
141  if let Some(direct) = root.as_str() {
142    return Some(direct.to_string());
143  }
144  for condition in ["import", "require", "default"] {
145    if let Some(value) = root.get(condition) {
146      if let Some(direct) = value.as_str() {
147        return Some(direct.to_string());
148      }
149      // A nested condition map (`{ import: { default: "./x.js" } }`).
150      if let Some(nested) = export_target(value) {
151        return Some(nested);
152      }
153    }
154  }
155  None
156}
157
158/// Walk `node_modules` upward from `base_dir`, as Node does.
159fn from_node_modules(base_dir: &Path, specifier: &str) -> Option<PathBuf> {
160  let (package, subpath) = split_package(specifier);
161  for dir in base_dir.ancestors() {
162    // `node_modules/node_modules` is not a thing; skip a directory that
163    // is itself inside one only when it names no package of its own.
164    let candidate = dir.join("node_modules").join(&package);
165    if !candidate.is_dir() {
166      continue;
167    }
168    let found = match subpath {
169      Some(sub) => as_file_or_directory(&candidate.join(sub)),
170      None => as_directory(&candidate),
171    };
172    if found.is_some() {
173      return found;
174    }
175  }
176  None
177}
178
179/// `@scope/name/sub/path` -> (`@scope/name`, `sub/path`).
180fn split_package(specifier: &str) -> (String, Option<&str>) {
181  let mut parts = specifier.splitn(if specifier.starts_with('@') { 3 } else { 2 }, '/');
182  let mut name = parts.next().unwrap_or(specifier).to_string();
183  if specifier.starts_with('@') {
184    if let Some(second) = parts.next() {
185      name.push('/');
186      name.push_str(second);
187    }
188  }
189  let rest = parts.next().filter(|s| !s.is_empty());
190  (name, rest)
191}
192
193fn canonical(path: &Path) -> Option<PathBuf> {
194  Some(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
195}
196
197fn not_found(specifier: &str, base_dir: &Path) -> String {
198  format!("Cannot find module '{specifier}' from {}", base_dir.display())
199}
200
201#[cfg(test)]
202mod tests {
203  use super::*;
204
205  fn tree() -> tempfile::TempDir {
206    let tmp = tempfile::tempdir().expect("tempdir");
207    let root = tmp.path();
208    std::fs::write(root.join("sibling.ts"), b"").expect("write");
209    std::fs::write(root.join("chart.min.js"), b"").expect("write");
210    std::fs::create_dir_all(root.join("folder")).expect("mkdir");
211    std::fs::write(root.join("folder/index.js"), b"").expect("write");
212    std::fs::create_dir_all(root.join("nested/deep")).expect("mkdir");
213    std::fs::write(root.join("nested/deep/leaf.ts"), b"").expect("write");
214    tmp
215  }
216
217  #[test]
218  fn a_relative_specifier_gets_its_extension_appended() {
219    let tmp = tree();
220    let found = resolve(tmp.path(), "./sibling").expect("resolve");
221    assert_eq!(found.file_name().expect("name"), "sibling.ts");
222  }
223
224  /// An extension is APPENDED, not substituted: `./chart.min` is not a
225  /// request for `./chart.js`.
226  #[test]
227  fn a_dotted_stem_keeps_its_own_suffix() {
228    let tmp = tree();
229    let found = resolve(tmp.path(), "./chart.min").expect("resolve");
230    assert_eq!(found.file_name().expect("name"), "chart.min.js");
231  }
232
233  #[test]
234  fn a_directory_resolves_through_its_index() {
235    let tmp = tree();
236    let found = resolve(tmp.path(), "./folder").expect("resolve");
237    assert_eq!(found.file_name().expect("name"), "index.js");
238  }
239
240  #[test]
241  fn a_parent_specifier_resolves_from_the_asking_directory() {
242    let tmp = tree();
243    let found = resolve(&tmp.path().join("nested/deep"), "../../sibling.ts").expect("resolve");
244    assert_eq!(found.file_name().expect("name"), "sibling.ts");
245  }
246
247  #[test]
248  fn a_missing_module_names_itself_and_where_it_was_asked_from() {
249    let tmp = tree();
250    let err = resolve(tmp.path(), "./nope").expect_err("missing");
251    assert!(err.contains("Cannot find module './nope'"), "{err}");
252    assert!(err.contains(&tmp.path().display().to_string()), "{err}");
253  }
254
255  #[test]
256  fn a_bare_specifier_walks_node_modules_upward() {
257    let tmp = tree();
258    let pkg = tmp.path().join("node_modules/acme");
259    std::fs::create_dir_all(&pkg).expect("mkdir");
260    std::fs::write(pkg.join("package.json"), br#"{"main":"./lib/entry.js"}"#).expect("write");
261    std::fs::create_dir_all(pkg.join("lib")).expect("mkdir");
262    std::fs::write(pkg.join("lib/entry.js"), b"").expect("write");
263
264    let found = resolve(&tmp.path().join("nested/deep"), "acme").expect("resolve");
265    assert_eq!(found, canonical(&pkg.join("lib/entry.js")).expect("canonical"));
266  }
267
268  #[test]
269  fn a_scoped_package_subpath_resolves() {
270    let tmp = tree();
271    let pkg = tmp.path().join("node_modules/@acme/kit");
272    std::fs::create_dir_all(pkg.join("src")).expect("mkdir");
273    std::fs::write(pkg.join("package.json"), br#"{"main":"./index.js"}"#).expect("write");
274    std::fs::write(pkg.join("src/helper.ts"), b"").expect("write");
275
276    let found = resolve(tmp.path(), "@acme/kit/src/helper").expect("resolve");
277    assert_eq!(found.file_name().expect("name"), "helper.ts");
278  }
279
280  #[test]
281  fn an_exports_condition_decides_the_entry() {
282    let tmp = tree();
283    let pkg = tmp.path().join("node_modules/conditional");
284    std::fs::create_dir_all(&pkg).expect("mkdir");
285    std::fs::write(
286      pkg.join("package.json"),
287      br#"{"exports":{".":{"import":"./esm.js","require":"./cjs.js"}},"main":"./ignored.js"}"#,
288    )
289    .expect("write");
290    std::fs::write(pkg.join("esm.js"), b"").expect("write");
291    std::fs::write(pkg.join("cjs.js"), b"").expect("write");
292    std::fs::write(pkg.join("ignored.js"), b"").expect("write");
293
294    let found = resolve(tmp.path(), "conditional").expect("resolve");
295    assert_eq!(found.file_name().expect("name"), "esm.js");
296  }
297}