ferrijs_std/node/
require_resolve.rs1use simd_json::prelude::*;
23use std::path::{Path, PathBuf};
24
25const EXTENSIONS: &[&str] = &["js", "mjs", "cjs", "ts", "mts", "cts", "tsx", "jsx", "json"];
31
32const INDEX_STEMS: &[&str] = &["index"];
34
35pub 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
58fn is_path_specifier(specifier: &str) -> bool {
60 specifier.starts_with("./")
61 || specifier.starts_with("../")
62 || specifier.starts_with('/')
63 || specifier == "."
64 || specifier == ".."
65}
66
67fn as_file_or_directory(path: &Path) -> Option<PathBuf> {
69 as_file(path).or_else(|| as_directory(path))
70}
71
72fn 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
93fn 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
111fn 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
134fn 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 if let Some(nested) = export_target(value) {
151 return Some(nested);
152 }
153 }
154 }
155 None
156}
157
158fn 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 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
179fn 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 #[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}