Skip to content

Commit 62d0d91

Browse files
feat(runtime): add runtime:path standard module
Modern, platform-aware path utilities — the second runtime: module (SPEC §11, DECISIONS D26). A pure-computation ES module that imports platform + cwd() from runtime:process, so separators and resolve() follow the real OS (and importing it needs Env). One platform-correct surface — no posix/win32 dual namespaces, no overloaded signatures — plus file: URL interop, making dirname(fromFileURL(import.meta.url)) the modern __dirname. - runtime_modules/path.js + registry entry; exports sep, delimiter, isAbsolute, normalize, join, resolve, dirname, basename, extname, parse, relative, fromFileURL, toFileURL - examples/modules/path.mjs; comprehensive #[cfg(unix)] e2e test - docs: API.md section + table; site /api/path page, sidebar, overview table - CHANGELOG [Unreleased]
1 parent e5c7cfa commit 62d0d91

9 files changed

Lines changed: 394 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ pre-`0.1.0` and the public API is unstable.
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **`runtime:path`** — modern, platform-aware path utilities, the second
12+
`runtime:` standard module (DECISIONS D26, SPEC §11). A pure-computation ES
13+
module that takes the host platform and `cwd()` from `runtime:process` (so it
14+
carries `Env`); separators and `resolve()` follow the real OS. Exports `sep`,
15+
`delimiter`, `isAbsolute`, `normalize`, `join`, `resolve`, `dirname`,
16+
`basename`, `extname`, `parse`, `relative`, and `file:` URL interop
17+
(`fromFileURL`/`toFileURL``dirname(fromFileURL(import.meta.url))` is the
18+
modern `__dirname`). One platform-correct surface: no `posix`/`win32` dual
19+
namespaces and no overloaded signatures. New `examples/modules/path.mjs`.
20+
921
## [0.1.0] - 2026-06-14
1022

1123
### Project

crates/runtime-cli/tests/modules.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,51 @@ fn runtime_process_exit_sets_exit_code() {
212212
);
213213
}
214214

215+
// POSIX-only: separators/roots are platform-specific and the CI test job runs
216+
// on Linux (macOS is also POSIX). Windows path semantics are exercised by hand.
217+
#[cfg(unix)]
218+
#[test]
219+
fn runtime_path_exposes_modern_surface() {
220+
let out = esrun()
221+
.arg("-e")
222+
.arg(
223+
"import * as p from 'runtime:path'; const o=(k,v)=>console.log(k+'='+v);\
224+
o('sep',p.sep); o('delimiter',p.delimiter);\
225+
o('join',p.join('a','b','..','c/d/'));\
226+
o('normalize',p.normalize('/a/./b/../c'));\
227+
o('isAbs',p.isAbsolute('/a')+','+p.isAbsolute('a'));\
228+
o('dirname',p.dirname('/a/b/c.txt'));\
229+
o('basename',p.basename('/a/b/c.txt'));\
230+
o('extname',p.extname('archive.tar.gz'));\
231+
o('relative',p.relative('/a/b/c','/a/x/y'));\
232+
o('parse',JSON.stringify(p.parse('/a/b/c.txt')));\
233+
o('resolveAbs',p.resolve('/x','y','z'));\
234+
o('fromFileURL',p.fromFileURL('file:///a/b%20c.txt'));\
235+
o('toFileURL',p.toFileURL('/a/b c.txt').href);",
236+
)
237+
.output()
238+
.expect("spawn esrun");
239+
assert!(out.status.success(), "stderr: {}", stderr(&out));
240+
let s = stdout(&out);
241+
for expected in [
242+
"sep=/",
243+
"delimiter=:",
244+
"join=a/c/d",
245+
"normalize=/a/c",
246+
"isAbs=true,false",
247+
"dirname=/a/b",
248+
"basename=c.txt",
249+
"extname=.gz",
250+
"relative=../../x/y",
251+
"parse={\"root\":\"/\",\"dir\":\"/a/b\",\"base\":\"c.txt\",\"name\":\"c\",\"ext\":\".txt\"}",
252+
"resolveAbs=/x/y/z",
253+
"fromFileURL=/a/b c.txt",
254+
"toFileURL=file:///a/b%20c.txt",
255+
] {
256+
assert!(s.contains(expected), "missing {expected:?} in:\n{s}");
257+
}
258+
}
259+
215260
#[test]
216261
fn unknown_runtime_builtin_module_errors() {
217262
let out = esrun()

crates/runtime/src/runtime_modules.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
pub(crate) fn source(specifier: &str) -> Option<&'static str> {
1414
match specifier {
1515
"runtime:process" => Some(include_str!("runtime_modules/process.js")),
16+
"runtime:path" => Some(include_str!("runtime_modules/path.js")),
1617
_ => None,
1718
}
1819
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// runtime:path — modern, platform-aware path utilities (DECISIONS D26, SPEC §11).
2+
//
3+
// Pure computation: it performs no I/O. The host platform and working directory
4+
// come from runtime:process, so `sep`, `resolve()`, and friends follow the real
5+
// OS (importing this module therefore needs the `Env` capability, as evaluating
6+
// runtime:process does). Deliberately free of legacy Node baggage: one
7+
// platform-correct surface — no `posix`/`win32` dual namespaces, no overloaded
8+
// signatures — plus first-class file: URL interop (the modern `__dirname`).
9+
10+
import { platform, cwd } from "runtime:process";
11+
12+
const WINDOWS = platform === "windows";
13+
const sep = WINDOWS ? "\\" : "/";
14+
const delimiter = WINDOWS ? ";" : ":";
15+
16+
// On Windows either slash separates; on POSIX only "/".
17+
const SPLIT = WINDOWS ? /[\\/]+/ : /\/+/;
18+
const isSepChar = (c) => c === "/" || (WINDOWS && c === "\\");
19+
const isDrive = (p) => WINDOWS && p.length >= 2 && p[1] === ":" && /[A-Za-z]/.test(p[0]);
20+
21+
function str(p, name = "path") {
22+
if (typeof p !== "string") throw new TypeError(`${name} must be a string, got ${typeof p}`);
23+
return p;
24+
}
25+
26+
function isAbsolute(p) {
27+
str(p);
28+
if (WINDOWS) {
29+
if (isDrive(p)) return p.length >= 3 && isSepChar(p[2]); // "C:\..."
30+
return p.length >= 1 && isSepChar(p[0]); // "\..." or "/..."
31+
}
32+
return p.length >= 1 && p[0] === "/";
33+
}
34+
35+
// Splits a path into its absolute root ("", "/", or "C:\\") and the remainder.
36+
function split(p) {
37+
if (WINDOWS) {
38+
if (isDrive(p)) {
39+
const abs = p.length >= 3 && isSepChar(p[2]);
40+
return { root: p.slice(0, 2) + (abs ? sep : ""), rest: p.slice(abs ? 3 : 2), abs };
41+
}
42+
if (p.length >= 1 && isSepChar(p[0])) return { root: sep, rest: p.slice(1), abs: true };
43+
return { root: "", rest: p, abs: false };
44+
}
45+
if (p.length >= 1 && p[0] === "/") return { root: "/", rest: p.slice(1), abs: true };
46+
return { root: "", rest: p, abs: false };
47+
}
48+
49+
// Collapses "." and ".." across a list of segments. Leading ".." are kept only
50+
// for relative paths (an absolute path cannot climb above its root).
51+
function collapse(rest, abs) {
52+
const out = [];
53+
for (const s of rest.split(SPLIT)) {
54+
if (s === "" || s === ".") continue;
55+
if (s === "..") {
56+
if (out.length && out[out.length - 1] !== "..") out.pop();
57+
else if (!abs) out.push("..");
58+
} else {
59+
out.push(s);
60+
}
61+
}
62+
return out;
63+
}
64+
65+
function normalize(p) {
66+
str(p);
67+
if (p.length === 0) return ".";
68+
const { root, rest, abs } = split(p);
69+
const body = collapse(rest, abs).join(sep);
70+
const result = root + body;
71+
return result.length === 0 ? "." : result;
72+
}
73+
74+
function join(...segments) {
75+
const parts = segments.filter((s) => str(s, "segment").length > 0);
76+
if (parts.length === 0) return ".";
77+
return normalize(parts.join(sep));
78+
}
79+
80+
// Resolves to an absolute path, walking segments right-to-left until one is
81+
// absolute; anything still relative is anchored at the current directory.
82+
function resolve(...segments) {
83+
let resolved = "";
84+
let abs = false;
85+
for (let i = segments.length - 1; i >= 0 && !abs; i--) {
86+
const s = str(segments[i], "segment");
87+
if (s.length === 0) continue;
88+
resolved = resolved.length ? s + sep + resolved : s;
89+
abs = isAbsolute(s);
90+
}
91+
if (!abs) resolved = resolved.length ? cwd() + sep + resolved : cwd();
92+
return normalize(resolved);
93+
}
94+
95+
function dirname(p) {
96+
str(p);
97+
const { root, rest } = split(p);
98+
const parts = rest.split(SPLIT).filter((s) => s.length > 0);
99+
if (parts.length <= 1) return root.length ? root.replace(/[\\/]+$/, "") || sep : ".";
100+
return root + parts.slice(0, -1).join(sep);
101+
}
102+
103+
function basename(p) {
104+
str(p);
105+
const parts = split(p).rest.split(SPLIT).filter((s) => s.length > 0);
106+
return parts.length ? parts[parts.length - 1] : "";
107+
}
108+
109+
function extname(p) {
110+
const base = basename(p);
111+
const dot = base.lastIndexOf(".");
112+
return dot <= 0 ? "" : base.slice(dot);
113+
}
114+
115+
function parse(p) {
116+
str(p);
117+
const { root } = split(p);
118+
const base = basename(p);
119+
const ext = extname(p);
120+
return {
121+
root,
122+
dir: dirname(p),
123+
base,
124+
name: ext ? base.slice(0, -ext.length) : base,
125+
ext,
126+
};
127+
}
128+
129+
function relative(from, to) {
130+
const a = resolve(str(from, "from"));
131+
const b = resolve(str(to, "to"));
132+
if (a === b) return "";
133+
const ap = split(a).rest.split(SPLIT).filter(Boolean);
134+
const bp = split(b).rest.split(SPLIT).filter(Boolean);
135+
let i = 0;
136+
while (i < ap.length && i < bp.length && ap[i] === bp[i]) i++;
137+
const up = new Array(ap.length - i).fill("..");
138+
return [...up, ...bp.slice(i)].join(sep) || ".";
139+
}
140+
141+
// file: URL interop — the modern replacement for __dirname:
142+
// dirname(fromFileURL(import.meta.url))
143+
function fromFileURL(url) {
144+
const u = url instanceof URL ? url : new URL(str(url, "url"));
145+
if (u.protocol !== "file:") throw new TypeError(`expected a file: URL, got ${u.protocol}`);
146+
let p = decodeURIComponent(u.pathname);
147+
if (WINDOWS) p = p.replace(/^\//, "").replace(/\//g, "\\");
148+
return p;
149+
}
150+
151+
function toFileURL(p) {
152+
const abs = resolve(str(p));
153+
const segs = abs.split(SPLIT);
154+
// Keep a Windows drive ("C:") verbatim; percent-encode every real segment.
155+
const encoded = segs
156+
.map((s, i) => (i === 0 && isDrive(abs) ? s : encodeURIComponent(s)))
157+
.join("/");
158+
return new URL("file://" + (WINDOWS ? "/" + encoded : encoded));
159+
}
160+
161+
export {
162+
sep,
163+
delimiter,
164+
isAbsolute,
165+
normalize,
166+
join,
167+
resolve,
168+
dirname,
169+
basename,
170+
extname,
171+
parse,
172+
relative,
173+
fromFileURL,
174+
toFileURL,
175+
};
176+
export default {
177+
sep,
178+
delimiter,
179+
isAbsolute,
180+
normalize,
181+
join,
182+
resolve,
183+
dirname,
184+
basename,
185+
extname,
186+
parse,
187+
relative,
188+
fromFileURL,
189+
toFileURL,
190+
};

docs/API.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module's operations are gated on an explicit [`Capability`](#capabilities).
1515
- [The `runtime:` scheme](#the-runtime-scheme)
1616
- [Capabilities](#capabilities)
1717
- [`runtime:process`](#runtimeprocess)
18+
- [`runtime:path`](#runtimepath)
1819

1920
---
2021

@@ -82,7 +83,7 @@ the required capability has been granted.
8283
| Module | Status | Capability | Reference |
8384
| ----------------- | ----------- | ---------- | ----------------------------- |
8485
| `runtime:process` | Available | `Env` | [](#runtimeprocess) |
85-
| `runtime:path` | Planned | | |
86+
| `runtime:path` | Available | `Env`* | [](#runtimepath) |
8687
| `runtime:fs` | Planned | `FileRead` / `FileWrite` ||
8788
| `runtime:net` | Planned | `Net` ||
8889
| `runtime:http` | Planned | `Net` ||
@@ -165,5 +166,45 @@ if (failed) exit(1);
165166
exit(); // defaults to 0
166167
```
167168

169+
---
170+
171+
## `runtime:path`
172+
173+
Modern, platform-aware path utilities. Pure computation — it performs no I/O.
174+
The host platform and working directory come from
175+
[`runtime:process`](#runtimeprocess), so separators and `resolve()` follow the
176+
real OS; that is why it carries `Env` (\*importing it evaluates `runtime:process`).
177+
178+
This is intentionally free of legacy baggage: one platform-correct surface (no
179+
`posix`/`win32` dual namespaces, no overloaded signatures), plus first-class
180+
`file:` URL interop — `dirname(fromFileURL(import.meta.url))` is the modern
181+
`__dirname`.
182+
183+
```js
184+
import { join, resolve, dirname, fromFileURL } from "runtime:path";
185+
186+
const here = dirname(fromFileURL(import.meta.url));
187+
const cfg = resolve(here, "config", "app.json");
188+
```
189+
190+
### Exports
191+
192+
| Export | Type | Description |
193+
| ----------------------- | ----------------------------- | --------------------------------------------------------------------------- |
194+
| `sep` | `string` | Path segment separator for the host OS (`"/"` or `"\\"`). |
195+
| `delimiter` | `string` | Path list delimiter for the host OS (`":"` or `";"`). |
196+
| `isAbsolute(p)` | `(string) => boolean` | Whether `p` is an absolute path. |
197+
| `normalize(p)` | `(string) => string` | Collapses `.`/`..` and redundant separators. |
198+
| `join(...segments)` | `(...string) => string` | Joins segments with the separator, then normalizes. |
199+
| `resolve(...segments)` | `(...string) => string` | Resolves to an absolute path, anchoring at `cwd()` if no segment is absolute.|
200+
| `dirname(p)` | `(string) => string` | The directory portion of `p`. |
201+
| `basename(p)` | `(string) => string` | The final segment of `p` (no suffix-stripping overload). |
202+
| `extname(p)` | `(string) => string` | The extension of the final segment, including the dot (or `""`). |
203+
| `parse(p)` | `(string) => object` | `{ root, dir, base, name, ext }`. |
204+
| `relative(from, to)` | `(string, string) => string` | Relative path from `from` to `to` (both resolved first). |
205+
| `fromFileURL(url)` | `(string \| URL) => string` | Converts a `file:` URL to a path. |
206+
| `toFileURL(p)` | `(string) => URL` | Converts a path (resolved to absolute) to a `file:` URL. |
207+
| `default` | `object` | An aggregate of all named exports. |
208+
168209
<!-- Reference links -->
169210
[D27]: ./DECISIONS.md

examples/modules/path.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// runtime:path — modern, platform-aware path utilities. Run with:
2+
// esrun examples/modules/path.mjs
3+
import { join, normalize, resolve, dirname, basename, extname, parse, relative, isAbsolute, sep, fromFileURL } from "runtime:path";
4+
5+
console.log("sep:", JSON.stringify(sep));
6+
console.log("join:", join("a", "b", "..", "c/d/")); // a/c/d
7+
console.log("normalize:", normalize("/a/./b/../c")); // /a/c
8+
console.log("dirname:", dirname("/a/b/c.txt")); // /a/b
9+
console.log("basename:", basename("/a/b/c.txt")); // c.txt
10+
console.log("extname:", extname("archive.tar.gz")); // .gz
11+
console.log("isAbsolute:", isAbsolute("/a"), isAbsolute("a")); // true false
12+
console.log("relative:", relative("/a/b/c", "/a/x/y")); // ../../x/y
13+
console.log("parse:", JSON.stringify(parse("/a/b/c.txt")));
14+
15+
// The modern __dirname: the directory of the current module.
16+
const here = dirname(fromFileURL(import.meta.url));
17+
console.log("here:", here);
18+
console.log("resolve:", resolve("data", "x.json")); // <cwd>/data/x.json

site/app/api/page.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const IMPORT = `import { env, args } from "runtime:process";`;
55

66
const modules = [
77
{ name: "runtime:process", status: "Available", cap: "Env", href: "/api/process" },
8-
{ name: "runtime:path", status: "Planned", cap: "", href: null },
8+
{ name: "runtime:path", status: "Available", cap: "Env", href: "/api/path" },
99
{ name: "runtime:fs", status: "Planned", cap: "FileRead / FileWrite", href: null },
1010
{ name: "runtime:net", status: "Planned", cap: "Net", href: null },
1111
{ name: "runtime:http", status: "Planned", cap: "Net", href: null },

0 commit comments

Comments
 (0)