Skip to main content

nodejs/stdlib/
path.rs

1//! Node `path` module (POSIX semantics, matching macOS/Linux `path`).
2
3use super::arg_str;
4use crate::host::with_host;
5use fusevm::Value;
6use indexmap::IndexMap;
7
8pub const METHODS: &[&str] = &[
9    "join",
10    "resolve",
11    "normalize",
12    "basename",
13    "dirname",
14    "extname",
15    "isAbsolute",
16    "relative",
17    "parse",
18    "format",
19    "matchesGlob",
20    "toNamespacedPath",
21];
22
23/// `path.sep` / `path.delimiter` constants.
24pub fn constant(name: &str) -> Option<Value> {
25    match name {
26        "sep" => Some(with_host(|h| h.new_str("/"))),
27        "delimiter" => Some(with_host(|h| h.new_str(":"))),
28        _ => None,
29    }
30}
31
32pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
33    let parts: Vec<String> = (0..args.len()).map(|i| arg_str(args, i)).collect();
34    let s = |v: String| Ok(with_host(|h| h.new_str(v)));
35    Some(match method {
36        "join" => s(join(&parts)),
37        "resolve" => s(resolve(&parts)),
38        "normalize" => s(normalize(&first(&parts))),
39        "basename" => s(basename(&first(&parts), parts.get(1).map(|x| x.as_str()))),
40        "dirname" => s(dirname(&first(&parts))),
41        "extname" => s(extname(&first(&parts))),
42        "isAbsolute" => Ok(Value::Bool(first(&parts).starts_with('/'))),
43        "relative" => s(relative(
44            &first(&parts),
45            parts.get(1).cloned().unwrap_or_default().as_str(),
46        )),
47        "parse" => Ok(parse(&first(&parts))),
48        "format" => s(format(args.first())),
49        "matchesGlob" => Ok(Value::Bool(matches_glob(
50            &first(&parts),
51            parts.get(1).map(|x| x.as_str()).unwrap_or(""),
52        ))),
53        // POSIX `toNamespacedPath` is the identity (Windows-only namespacing).
54        "toNamespacedPath" => s(first(&parts)),
55        _ => return None,
56    })
57}
58
59fn first(parts: &[String]) -> String {
60    parts.first().cloned().unwrap_or_default()
61}
62
63fn join(parts: &[String]) -> String {
64    let joined: Vec<&str> = parts
65        .iter()
66        .map(|s| s.as_str())
67        .filter(|s| !s.is_empty())
68        .collect();
69    if joined.is_empty() {
70        return ".".into();
71    }
72    normalize(&joined.join("/"))
73}
74
75/// Collapse `.`/`..` segments and duplicate slashes, preserving a leading `/` and
76/// a trailing `/` — Node's `path.normalize` behaviour.
77fn normalize(p: &str) -> String {
78    if p.is_empty() {
79        return ".".into();
80    }
81    let is_abs = p.starts_with('/');
82    let trailing = p.ends_with('/') && p.len() > 1;
83    let mut out: Vec<&str> = Vec::new();
84    for seg in p.split('/') {
85        match seg {
86            "" | "." => {}
87            ".." => {
88                if let Some(&last) = out.last() {
89                    if last != ".." {
90                        out.pop();
91                        continue;
92                    }
93                }
94                if !is_abs {
95                    out.push("..");
96                }
97            }
98            s => out.push(s),
99        }
100    }
101    let mut body = out.join("/");
102    if body.is_empty() {
103        return if is_abs { "/".into() } else { ".".into() };
104    }
105    if is_abs {
106        body.insert(0, '/');
107    }
108    if trailing {
109        body.push('/');
110    }
111    body
112}
113
114fn basename(p: &str, ext: Option<&str>) -> String {
115    let base = p
116        .trim_end_matches('/')
117        .rsplit('/')
118        .next()
119        .unwrap_or("")
120        .to_string();
121    match ext {
122        Some(e) if base.ends_with(e) && base != *e => base[..base.len() - e.len()].to_string(),
123        _ => base,
124    }
125}
126
127fn dirname(p: &str) -> String {
128    let trimmed = p.trim_end_matches('/');
129    match trimmed.rfind('/') {
130        Some(0) => "/".into(),
131        Some(i) => trimmed[..i].to_string(),
132        None => ".".into(),
133    }
134}
135
136fn extname(p: &str) -> String {
137    let base = basename(p, None);
138    match base.rfind('.') {
139        Some(i) if i > 0 => base[i..].to_string(),
140        _ => String::new(),
141    }
142}
143
144fn resolve(parts: &[String]) -> String {
145    let mut resolved = String::new();
146    let mut is_abs = false;
147    for p in parts.iter().rev() {
148        if p.is_empty() {
149            continue;
150        }
151        resolved = if resolved.is_empty() {
152            p.clone()
153        } else {
154            format!("{p}/{resolved}")
155        };
156        if p.starts_with('/') {
157            is_abs = true;
158            break;
159        }
160    }
161    if !is_abs {
162        let cwd = std::env::current_dir()
163            .map(|d| d.to_string_lossy().into_owned())
164            .unwrap_or_else(|_| "/".into());
165        resolved = if resolved.is_empty() {
166            cwd
167        } else {
168            format!("{cwd}/{resolved}")
169        };
170    }
171    let n = normalize(&resolved);
172    // resolve never keeps a trailing slash (except root).
173    if n.len() > 1 {
174        n.trim_end_matches('/').to_string()
175    } else {
176        n
177    }
178}
179
180fn relative(from: &str, to: &str) -> String {
181    let from = resolve(&[from.to_string()]);
182    let to = resolve(&[to.to_string()]);
183    let fs: Vec<&str> = from.split('/').filter(|s| !s.is_empty()).collect();
184    let ts: Vec<&str> = to.split('/').filter(|s| !s.is_empty()).collect();
185    let common = fs.iter().zip(ts.iter()).take_while(|(a, b)| a == b).count();
186    let mut out: Vec<String> = vec!["..".into(); fs.len() - common];
187    out.extend(ts[common..].iter().map(|s| s.to_string()));
188    if out.is_empty() {
189        String::new()
190    } else {
191        out.join("/")
192    }
193}
194
195fn parse(p: &str) -> Value {
196    let root = if p.starts_with('/') { "/" } else { "" };
197    let dir = dirname(p);
198    let base = basename(p, None);
199    let ext = extname(p);
200    let name = base.strip_suffix(&ext).unwrap_or(&base).to_string();
201    with_host(|h| {
202        let mut m = IndexMap::new();
203        m.insert("root".into(), h.new_str(root));
204        m.insert("dir".into(), h.new_str(dir));
205        m.insert("base".into(), h.new_str(base));
206        m.insert("ext".into(), h.new_str(ext));
207        m.insert("name".into(), h.new_str(name));
208        h.new_object(m)
209    })
210}
211
212fn format(obj: Option<&Value>) -> String {
213    let Some(obj) = obj else { return String::new() };
214    let get = |k: &str| {
215        with_host(|h| match h.get(obj) {
216            Some(crate::host::JsObj::Object(p)) => {
217                p.get(k).map(|v| h.str_of(v)).unwrap_or_default()
218            }
219            _ => String::new(),
220        })
221    };
222    let dir = get("dir");
223    let root = get("root");
224    let base = if !get("base").is_empty() {
225        get("base")
226    } else {
227        format!("{}{}", get("name"), get("ext"))
228    };
229    let d = if !dir.is_empty() { dir } else { root };
230    if d.is_empty() {
231        base
232    } else if d.ends_with('/') {
233        format!("{d}{base}")
234    } else {
235        format!("{d}/{base}")
236    }
237}
238
239/// `path.matchesGlob(path, pattern)` — whether `path` matches the glob `pattern`.
240/// Supports `*` (within a segment), `**` (across `/`), `?`, `[...]` classes, and
241/// top-level `{a,b}` brace alternatives — the minimatch-style subset Node uses.
242fn matches_glob(path: &str, pattern: &str) -> bool {
243    let text: Vec<char> = path.chars().collect();
244    expand_braces(pattern)
245        .iter()
246        .any(|pat| glob_match(&text, &pat.chars().collect::<Vec<char>>()))
247}
248
249/// Expand top-level `{a,b,c}` alternatives into concrete pattern strings.
250fn expand_braces(pattern: &str) -> Vec<String> {
251    let chars: Vec<char> = pattern.chars().collect();
252    for (i, &c) in chars.iter().enumerate() {
253        if c != '{' {
254            continue;
255        }
256        let mut depth = 1;
257        let mut commas: Vec<usize> = Vec::new();
258        let mut close = None;
259        for (j, &cj) in chars.iter().enumerate().skip(i + 1) {
260            match cj {
261                '{' => depth += 1,
262                '}' => {
263                    depth -= 1;
264                    if depth == 0 {
265                        close = Some(j);
266                        break;
267                    }
268                }
269                ',' if depth == 1 => commas.push(j),
270                _ => {}
271            }
272        }
273        let (Some(close), false) = (close, commas.is_empty()) else {
274            continue;
275        };
276        let prefix: String = chars[..i].iter().collect();
277        let suffix: String = chars[close + 1..].iter().collect();
278        let mut bounds = vec![i];
279        bounds.extend(&commas);
280        bounds.push(close);
281        let mut out = Vec::new();
282        for w in bounds.windows(2) {
283            let alt: String = chars[w[0] + 1..w[1]].iter().collect();
284            out.extend(expand_braces(&format!("{prefix}{alt}{suffix}")));
285        }
286        return out;
287    }
288    vec![pattern.to_string()]
289}
290
291/// Recursive glob matcher over char slices. `*` never crosses `/`, `**` does.
292fn glob_match(t: &[char], p: &[char]) -> bool {
293    if p.is_empty() {
294        return t.is_empty();
295    }
296    match p[0] {
297        '*' => {
298            let double = p.len() >= 2 && p[1] == '*';
299            let rest = {
300                let mut k = 0;
301                while k < p.len() && p[k] == '*' {
302                    k += 1;
303                }
304                &p[k..]
305            };
306            if rest.is_empty() {
307                return double || !t.contains(&'/');
308            }
309            let mut ti = 0;
310            loop {
311                if glob_match(&t[ti..], rest) {
312                    return true;
313                }
314                if ti >= t.len() {
315                    return false;
316                }
317                if !double && t[ti] == '/' {
318                    return false;
319                }
320                ti += 1;
321            }
322        }
323        '?' => !t.is_empty() && t[0] != '/' && glob_match(&t[1..], &p[1..]),
324        '[' => match match_class(t.first().copied(), p) {
325            Some((matched, plen)) => matched && glob_match(&t[1..], &p[plen..]),
326            // Unterminated `[` is a literal bracket.
327            None => !t.is_empty() && t[0] == '[' && glob_match(&t[1..], &p[1..]),
328        },
329        c => !t.is_empty() && t[0] == c && glob_match(&t[1..], &p[1..]),
330    }
331}
332
333/// Match `ch` against a `[...]` class starting at `p[0] == '['`. Returns
334/// `(matched, chars_consumed)`, or `None` when the class is unterminated.
335fn match_class(ch: Option<char>, p: &[char]) -> Option<(bool, usize)> {
336    let mut i = 1;
337    let mut negate = false;
338    if matches!(p.get(i), Some('!') | Some('^')) {
339        negate = true;
340        i += 1;
341    }
342    let start = i;
343    let mut matched = false;
344    while i < p.len() && (p[i] != ']' || i == start) {
345        if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
346            if let Some(c) = ch {
347                if p[i] <= c && c <= p[i + 2] {
348                    matched = true;
349                }
350            }
351            i += 3;
352        } else {
353            if ch == Some(p[i]) {
354                matched = true;
355            }
356            i += 1;
357        }
358    }
359    if i >= p.len() {
360        return None;
361    }
362    // `ch` is None (empty text) or a `/` never matches a class.
363    let ok = matches!(ch, Some(c) if c != '/') && (matched ^ negate);
364    Some((ok, i + 1))
365}