Skip to main content

ferrijs_std/node/
path.rs

1//! `node:path` — POSIX-style pure string operations.
2//!
3//! `join`, `resolve`, `dirname`, `basename`, `extname`, `normalize`,
4//! `relative`, `isAbsolute`, `parse`, `format`, `toNamespacedPath`,
5//! `sep`, `delimiter`, and `posix` pointing back at the module.
6//! `resolve` roots at `process.cwd()` — the sandbox root this runtime
7//! reports, not the real process directory. No win32 flavour: this
8//! runtime's path model is POSIX, and a `path.win32` that answered
9//! POSIX would be worse than an absent one.
10//!
11//! Every entry point normalises in a single pass into one `String`: a
12//! path helper is called in a loop by the code above it (a bundler
13//! resolving a graph, a test runner naming fixtures), so an
14//! intermediate `Vec<&str>` and a `join` per call are not free.
15
16use std::sync::Arc;
17
18use rquickjs::function::{Func, Opt, Rest};
19use rquickjs::{Ctx, JsLifetime, Object};
20
21/// What `process.cwd()` answers, kept where `resolve` can read it
22/// without calling into JS.
23///
24/// `resolve` and `relative` need the working directory on every call,
25/// and reaching it through `globalThis.process.cwd()` is a global
26/// lookup, a property lookup and a JS call each time. The realm's cwd
27/// is fixed at build time (there is no `chdir` in this sandbox), so the
28/// process shim stores it here and the path module reads it directly.
29#[derive(Clone)]
30pub struct Cwd(pub Arc<str>);
31
32// SAFETY: owns only an `Arc<str>`; no borrowed JS values, so restating
33// the unused `'js` lifetime is sound.
34#[allow(unsafe_code)]
35unsafe impl JsLifetime<'_> for Cwd {
36  type Changed<'to> = Cwd;
37}
38
39/// Record what `process.cwd()` answers for this realm.
40pub fn set_cwd(ctx: &Ctx<'_>, cwd: &str) {
41  let _ = ctx.store_userdata(Cwd(Arc::from(cwd)));
42}
43
44/// The realm's working directory, falling back to the JS `process.cwd()`
45/// for a realm whose host installed a `process` of its own, and to `/`
46/// for one with no `process` at all.
47fn cwd(ctx: &Ctx<'_>) -> Arc<str> {
48  if let Some(c) = ctx.userdata::<Cwd>() {
49    return Arc::clone(&c.0);
50  }
51  let from_js: rquickjs::Result<String> = (|| {
52    let process: Object<'_> = ctx.globals().get("process")?;
53    let cwd_fn: rquickjs::Function<'_> = process.get("cwd")?;
54    cwd_fn.call(())
55  })();
56  Arc::from(from_js.unwrap_or_else(|_| "/".to_string()))
57}
58
59/// Normalise `parts` (each a path fragment, split on `/` by the caller)
60/// into `out`, resolving `.` and `..` as POSIX does.
61///
62/// `absolute` and `trailing` come from the whole path the parts were
63/// taken from, because a fragment cannot know whether it is first or
64/// last.
65fn normalize_into<'a>(parts: impl Iterator<Item = &'a str>, absolute: bool, trailing: bool, out: &mut String) {
66  let root = out.len();
67  if absolute {
68    out.push('/');
69  }
70  let body = out.len();
71  // Where each kept segment begins, separator included, so a `..` drops
72  // the one before it by truncating rather than by a second pass.
73  let mut starts: Vec<usize> = Vec::new();
74  for seg in parts {
75    match seg {
76      "" | "." => {},
77      ".." => {
78        if let Some(start) = starts.pop() {
79          out.truncate(start);
80        } else if !absolute {
81          // A relative path keeps a `..` it cannot resolve, and nothing
82          // later may pop it: `../..` is two levels, not zero.
83          if out.len() > body {
84            out.push('/');
85          }
86          out.push_str("..");
87        }
88      },
89      s => {
90        let start = out.len();
91        if out.len() > body {
92          out.push('/');
93        }
94        starts.push(start);
95        out.push_str(s);
96      },
97    }
98  }
99  if out.len() == body {
100    if !absolute {
101      out.truncate(root);
102      out.push('.');
103    }
104    return;
105  }
106  if trailing {
107    out.push('/');
108  }
109}
110
111fn normalize_str(path: &str) -> String {
112  let mut out = String::with_capacity(path.len() + 1);
113  normalize_into(
114    path.split('/'),
115    path.starts_with('/'),
116    path.len() > 1 && path.ends_with('/'),
117    &mut out,
118  );
119  out
120}
121
122fn join_segments(segments: &[String]) -> String {
123  let Some(first) = segments.iter().find(|s| !s.is_empty()) else {
124    return ".".to_string();
125  };
126  let last = segments.iter().rev().find(|s| !s.is_empty()).unwrap_or(first);
127  let capacity = segments.iter().map(|s| s.len() + 1).sum::<usize>() + 1;
128  let mut out = String::with_capacity(capacity);
129  normalize_into(
130    segments.iter().filter(|s| !s.is_empty()).flat_map(|s| s.split('/')),
131    first.starts_with('/'),
132    last.len() > 1 && last.ends_with('/'),
133    &mut out,
134  );
135  out
136}
137
138fn dirname_str(path: &str) -> String {
139  let trimmed = path.trim_end_matches('/');
140  match trimmed.rfind('/') {
141    Some(0) => "/".to_string(),
142    Some(i) => trimmed[..i].to_string(),
143    None => {
144      if path.starts_with('/') {
145        "/".to_string()
146      } else {
147        ".".to_string()
148      }
149    },
150  }
151}
152
153fn basename_of(path: &str) -> &str {
154  let trimmed = path.trim_end_matches('/');
155  trimmed.rsplit('/').next().unwrap_or(trimmed)
156}
157
158fn basename_str(path: &str, ext: Option<&str>) -> String {
159  let base = basename_of(path);
160  match ext {
161    Some(e) if base.len() > e.len() && base.ends_with(e) => base[..base.len() - e.len()].to_string(),
162    _ => base.to_string(),
163  }
164}
165
166/// The extension of `base`, dot included, or `""`. A leading dot
167/// (`.gitignore`) is the name, not an extension.
168fn extname_of(base: &str) -> &str {
169  match base.rfind('.') {
170    Some(i) if i > 0 => &base[i..],
171    _ => "",
172  }
173}
174
175fn extname_str(path: &str) -> String {
176  extname_of(basename_of(path)).to_string()
177}
178
179/// Node's `resolve`: walk the arguments right to left, prepending, until
180/// one is absolute; fall back to the working directory. Building from
181/// the right means a segment that a later absolute path would have
182/// discarded is never copied at all.
183fn resolve_segments(cwd: &str, segments: &[String]) -> String {
184  let mut parts: Vec<&str> = Vec::new();
185  let mut absolute = false;
186  for seg in segments.iter().rev() {
187    if seg.is_empty() {
188      continue;
189    }
190    parts.push(seg.as_str());
191    if seg.starts_with('/') {
192      absolute = true;
193      break;
194    }
195  }
196  if !absolute {
197    parts.push(cwd);
198    absolute = cwd.starts_with('/');
199  }
200  parts.reverse();
201  let capacity = parts.iter().map(|s| s.len() + 1).sum::<usize>() + 1;
202  let mut out = String::with_capacity(capacity);
203  // `resolve` never answers a trailing slash, except for the root.
204  normalize_into(parts.iter().flat_map(|s| s.split('/')), absolute, false, &mut out);
205  out
206}
207
208fn relative_str(from: &str, to: &str) -> String {
209  let f = normalize_str(from);
210  let t = normalize_str(to);
211  let fp: Vec<&str> = f.split('/').filter(|s| !s.is_empty()).collect();
212  let tp: Vec<&str> = t.split('/').filter(|s| !s.is_empty()).collect();
213  let common = fp.iter().zip(tp.iter()).take_while(|(a, b)| a == b).count();
214  let mut out: Vec<&str> = vec![".."; fp.len() - common];
215  out.extend(&tp[common..]);
216  out.join("/")
217}
218
219/// `path.parse`, as a named function so `Ctx` and the object it builds
220/// share one `'js` (an inline closure would give each its own).
221fn parse_fn<'js>(ctx: Ctx<'js>, path: String) -> rquickjs::Result<Object<'js>> {
222  parse_object(&ctx, &path)
223}
224
225/// `path.parse`: the root, directory, base name, extension and stem, the
226/// five fields `path.format` reads back.
227fn parse_object<'js>(ctx: &Ctx<'js>, path: &str) -> rquickjs::Result<Object<'js>> {
228  let o = Object::new(ctx.clone())?;
229  let root = if path.starts_with('/') { "/" } else { "" };
230  let base = basename_of(path);
231  let ext = extname_of(base);
232  let name = &base[..base.len() - ext.len()];
233  let dir = {
234    let trimmed = path.trim_end_matches('/');
235    match trimmed.rfind('/') {
236      Some(0) => "/",
237      Some(i) => &trimmed[..i],
238      None => "",
239    }
240  };
241  o.set("root", root)?;
242  o.set("dir", dir)?;
243  o.set("base", base)?;
244  o.set("ext", ext)?;
245  o.set("name", name)?;
246  Ok(o)
247}
248
249/// `path.format`: `dir` wins over `root`, and `base` over `name`+`ext`,
250/// which is Node's precedence.
251fn format_str(root: &str, dir: &str, base: &str, name: &str, ext: &str) -> String {
252  let base = if base.is_empty() {
253    let mut b = String::with_capacity(name.len() + ext.len() + 1);
254    b.push_str(name);
255    if !ext.is_empty() && !ext.starts_with('.') {
256      b.push('.');
257    }
258    b.push_str(ext);
259    b
260  } else {
261    base.to_string()
262  };
263  if dir.is_empty() {
264    return format!("{root}{base}");
265  }
266  if dir == "/" {
267    return format!("/{base}");
268  }
269  format!("{dir}/{base}")
270}
271
272/// Build the `path` module object (fresh per call; only built once per
273/// session by the module loader).
274pub fn path_object<'js>(ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
275  let o = Object::new(ctx.clone())?;
276  o.set("sep", "/")?;
277  o.set("delimiter", ":")?;
278  o.set("join", Func::from(|segs: Rest<String>| join_segments(&segs.0)))?;
279  o.set(
280    "resolve",
281    Func::from(|ctx: Ctx<'_>, segs: Rest<String>| -> String { resolve_segments(&cwd(&ctx), &segs.0) }),
282  )?;
283  o.set("normalize", Func::from(|p: String| normalize_str(&p)))?;
284  o.set("dirname", Func::from(|p: String| dirname_str(&p)))?;
285  o.set(
286    "basename",
287    Func::from(|p: String, ext: Opt<String>| basename_str(&p, ext.0.as_deref())),
288  )?;
289  o.set("extname", Func::from(|p: String| extname_str(&p)))?;
290  o.set(
291    "relative",
292    Func::from(|ctx: Ctx<'_>, from: String, to: String| -> String {
293      let cwd = cwd(&ctx);
294      relative_str(&resolve_segments(&cwd, &[from]), &resolve_segments(&cwd, &[to]))
295    }),
296  )?;
297  o.set("isAbsolute", Func::from(|p: String| p.starts_with('/')))?;
298  o.set("parse", Func::from(parse_fn))?;
299  o.set(
300    "format",
301    Func::from(|bag: Object<'_>| -> rquickjs::Result<String> {
302      let field = |k: &str| -> String { bag.get::<_, Option<String>>(k).ok().flatten().unwrap_or_default() };
303      Ok(format_str(
304        &field("root"),
305        &field("dir"),
306        &field("base"),
307        &field("name"),
308        &field("ext"),
309      ))
310    }),
311  )?;
312  // POSIX has no namespaced paths; Node's own posix flavour is the
313  // identity here too.
314  o.set("toNamespacedPath", Func::from(|p: String| p))?;
315  // `path.posix` is this module: code that picks a flavour explicitly
316  // gets the one flavour this runtime has.
317  o.set("posix", o.clone())?;
318  Ok(o)
319}
320
321#[cfg(test)]
322mod tests {
323  use super::*;
324
325  #[test]
326  fn normalize_matches_node() {
327    for (input, want) in [
328      ("/a/b/../c", "/a/c"),
329      ("/foo/bar//baz/asdf/quux/..", "/foo/bar/baz/asdf"),
330      ("a/b/..", "a"),
331      ("a/..", "."),
332      ("", "."),
333      ("/", "/"),
334      ("/..", "/"),
335      ("../..", "../.."),
336      ("./a/", "a/"),
337      ("/a/b/", "/a/b/"),
338      ("//a", "/a"),
339      ("../a/../b", "../b"),
340    ] {
341      assert_eq!(normalize_str(input), want, "normalize({input:?})");
342    }
343  }
344
345  #[test]
346  fn join_matches_node() {
347    let j = |parts: &[&str]| join_segments(&parts.iter().map(|s| (*s).to_string()).collect::<Vec<_>>());
348    assert_eq!(j(&["/foo", "bar", "baz/asdf", "quux", ".."]), "/foo/bar/baz/asdf");
349    assert_eq!(j(&["a", "", "b"]), "a/b");
350    assert_eq!(j(&[]), ".");
351    assert_eq!(j(&["", ""]), ".");
352    assert_eq!(j(&["a/", "b"]), "a/b");
353    assert_eq!(j(&["/"]), "/");
354    assert_eq!(j(&["a", "b/"]), "a/b/");
355  }
356
357  #[test]
358  fn resolve_matches_node() {
359    let r = |parts: &[&str]| resolve_segments("/base", &parts.iter().map(|s| (*s).to_string()).collect::<Vec<_>>());
360    assert_eq!(r(&["/foo/bar", "./baz"]), "/foo/bar/baz");
361    assert_eq!(r(&["/foo/bar", "/tmp/file/"]), "/tmp/file");
362    assert_eq!(r(&["a", "b"]), "/base/a/b");
363    assert_eq!(r(&[]), "/base");
364    assert_eq!(r(&["/"]), "/");
365    assert_eq!(r(&["..", ".."]), "/");
366  }
367
368  #[test]
369  fn parse_and_format_round_trip() {
370    assert_eq!(extname_of(basename_of("/home/user/file.txt")), ".txt");
371    assert_eq!(extname_of(basename_of("/home/.gitignore")), "");
372    assert_eq!(basename_of("/home/user/file.txt"), "file.txt");
373    assert_eq!(format_str("/", "/home/user", "file.txt", "", ""), "/home/user/file.txt");
374    assert_eq!(format_str("/", "", "", "file", ".txt"), "/file.txt");
375    assert_eq!(format_str("", "/", "index.js", "", ""), "/index.js");
376  }
377
378  #[test]
379  fn relative_matches_node() {
380    assert_eq!(relative_str("/a/b/c", "/a/b/c/d"), "d");
381    assert_eq!(relative_str("/a/b/c", "/a/x"), "../../x");
382    assert_eq!(relative_str("/a", "/a"), "");
383  }
384}