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`, `sep`, `delimiter`. `resolve` roots at
5//! `process.cwd()` — the sandbox root this runtime reports, not the real
6//! process directory. No win32 flavour, and no `parse` / `format` yet.
7
8use rquickjs::function::{Func, Opt, Rest};
9use rquickjs::{Ctx, Object};
10
11fn normalize_str(path: &str) -> String {
12  let absolute = path.starts_with('/');
13  let mut out: Vec<&str> = Vec::new();
14  for seg in path.split('/') {
15    match seg {
16      "" | "." => {},
17      ".." => {
18        if matches!(out.last(), Some(&"..")) || (out.is_empty() && !absolute) {
19          out.push("..");
20        } else {
21          out.pop();
22        }
23      },
24      s => out.push(s),
25    }
26  }
27  let joined = out.join("/");
28  let trailing = path.len() > 1 && path.ends_with('/') && !joined.is_empty();
29  match (absolute, joined.is_empty()) {
30    (true, true) => "/".to_string(),
31    (true, false) => format!("/{joined}{}", if trailing { "/" } else { "" }),
32    (false, true) => ".".to_string(),
33    (false, false) => format!("{joined}{}", if trailing { "/" } else { "" }),
34  }
35}
36
37fn join_segments(segments: &[String]) -> String {
38  let parts: Vec<&str> = segments.iter().map(String::as_str).filter(|s| !s.is_empty()).collect();
39  if parts.is_empty() {
40    return ".".to_string();
41  }
42  normalize_str(&parts.join("/"))
43}
44
45fn dirname_str(path: &str) -> String {
46  let trimmed = path.trim_end_matches('/');
47  match trimmed.rfind('/') {
48    Some(0) => "/".to_string(),
49    Some(i) => trimmed[..i].to_string(),
50    None => {
51      if path.starts_with('/') {
52        "/".to_string()
53      } else {
54        ".".to_string()
55      }
56    },
57  }
58}
59
60fn basename_str(path: &str, ext: Option<&str>) -> String {
61  let trimmed = path.trim_end_matches('/');
62  let base = trimmed.rsplit('/').next().unwrap_or(trimmed);
63  match ext {
64    Some(e) if base.len() > e.len() && base.ends_with(e) => base[..base.len() - e.len()].to_string(),
65    _ => base.to_string(),
66  }
67}
68
69fn extname_str(path: &str) -> String {
70  let base = basename_str(path, None);
71  match base.rfind('.') {
72    // A leading dot (`.gitignore`) is not an extension.
73    Some(i) if i > 0 => base[i..].to_string(),
74    _ => String::new(),
75  }
76}
77
78fn resolve_segments(cwd: &str, segments: &[String]) -> String {
79  let mut acc = cwd.to_string();
80  for seg in segments {
81    if seg.is_empty() {
82      continue;
83    }
84    if seg.starts_with('/') {
85      acc.clone_from(seg);
86    } else {
87      acc = format!("{acc}/{seg}");
88    }
89  }
90  let n = normalize_str(&acc);
91  // `resolve` never returns a trailing slash (except root).
92  if n.len() > 1 {
93    n.trim_end_matches('/').to_string()
94  } else {
95    n
96  }
97}
98
99fn relative_str(from: &str, to: &str) -> String {
100  let f = normalize_str(from);
101  let t = normalize_str(to);
102  let fp: Vec<&str> = f.split('/').filter(|s| !s.is_empty()).collect();
103  let tp: Vec<&str> = t.split('/').filter(|s| !s.is_empty()).collect();
104  let common = fp.iter().zip(tp.iter()).take_while(|(a, b)| a == b).count();
105  let mut out: Vec<&str> = vec![".."; fp.len() - common];
106  out.extend(&tp[common..]);
107  out.join("/")
108}
109
110/// The current working directory the JS surface reports: the sandbox
111/// root via the `process` shim, falling back to `/`.
112fn js_cwd(ctx: &Ctx<'_>) -> String {
113  let cwd: rquickjs::Result<String> = (|| {
114    let process: Object<'_> = ctx.globals().get("process")?;
115    let cwd_fn: rquickjs::Function<'_> = process.get("cwd")?;
116    cwd_fn.call(())
117  })();
118  cwd.unwrap_or_else(|_| "/".to_string())
119}
120
121/// Build the `path` module object (fresh per call; only built once per
122/// session by the module loader).
123pub fn path_object<'js>(ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
124  let o = Object::new(ctx.clone())?;
125  o.set("sep", "/")?;
126  o.set("delimiter", ":")?;
127  o.set("join", Func::from(|segs: Rest<String>| join_segments(&segs.0)))?;
128  o.set(
129    "resolve",
130    Func::from(|ctx: Ctx<'_>, segs: Rest<String>| -> String { resolve_segments(&js_cwd(&ctx), &segs.0) }),
131  )?;
132  o.set("normalize", Func::from(|p: String| normalize_str(&p)))?;
133  o.set("dirname", Func::from(|p: String| dirname_str(&p)))?;
134  o.set(
135    "basename",
136    Func::from(|p: String, ext: Opt<String>| basename_str(&p, ext.0.as_deref())),
137  )?;
138  o.set("extname", Func::from(|p: String| extname_str(&p)))?;
139  o.set(
140    "relative",
141    Func::from(|ctx: Ctx<'_>, from: String, to: String| -> String {
142      let cwd = js_cwd(&ctx);
143      relative_str(&resolve_segments(&cwd, &[from]), &resolve_segments(&cwd, &[to]))
144    }),
145  )?;
146  o.set("isAbsolute", Func::from(|p: String| p.starts_with('/')))?;
147  Ok(o)
148}