From 0b468e40a1691f2f5ed68e866b1c9f8a373a3f31 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 07:59:19 -0500 Subject: [PATCH 1/3] Add fs.globSync, fs.glob and fs/promises glob The three glob entry points run a port of Node 24's lib/internal/fs/glob.js walk in scr_glob.c: the same LIFO queue of (path, patterns) items over the sorted directory listings, the same lstat, stat, readdir, realpath and seen-pattern caches, the same result set in insertion order, and the same exclusion checks, so matches come out in Node's order. Patterns compile with the existing minimatch port. glob_match is split into a compile step and a test step so the walk can reuse each compiled pattern and test it segment by segment. nocaseMagicOnly is now an option, because exclude patterns turn it off. Patterns (a string or string array) and options (cwd as a path or file: URL, exclude as patterns or a function, followSymlinks, withFileTypes) are validated in Node's order with Node's errors, including the internal assertion Node hits for a non-string cwd with exclude patterns. withFileTypes results are Dirent rows: name, parentPath and the type probes. parentPath follows Node's rule for each form: the joined path for globSync, the resolved path for fs/promises glob. fs.glob checks the callback and validates synchronously, then delivers string[] or the walk's error through the filesystem callback queue. fs/promises glob lowers as the source of a for await loop. The walk runs when the loop starts, and the matches found before an error bind before the error is thrown, as Node's async iterator yields them first. The older glob(3)-based fs.glob branch is removed. Corpus 5448-5452 and a node-types fixture check the behavior against Node. --- docs/content/docs/limitations.mdx | 2 + .../content/limitations/builtin-apis/glob.mdx | 1 + .../limitations/builtin-function-values.mdx | 2 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 22 +- .../generated/node-v24-backlog.json | 61 +- .../generated/node-v24-internal.json | 46 +- internal/compatibility/static-support.json | 1 + .../ambient/scriptc-node-fallback.d.ts | 39 + .../src/backend/llvm/lib-filesystem.ts | 63 + .../compiler/src/backend/llvm/lib-shared.ts | 5 + .../src/frontend/lowering/builtins/fs-glob.ts | 268 ++++ .../lowering/builtins/module-calls.ts | 7 + .../lowering/lower-async-iteration.ts | 2 +- .../src/frontend/lowering/lower-stmts.ts | 6 + .../compiler/src/frontend/lowering/lowerer.ts | 3 + .../src/frontend/lowering/surfaces.ts | 8 +- packages/compiler/src/ir/builtin-effects.ts | 5 + .../compiler/src/ir/builtin-signatures.ts | 14 + packages/compiler/src/ir/runtime-features.ts | 10 +- packages/compiler/src/ir/validate.ts | 10 +- packages/compiler/surface-manifest.json | 12 + .../compiler/test/fs-glob-boundaries.test.ts | 53 + .../corpus/5448-fs-glob-patterns.ts.json | 6 + .../tests/corpus/5449-fs-glob-options.ts.json | 6 + .../corpus/5450-fs-glob-validation.js.json | 6 + .../corpus/5451-fs-promises-glob.ts.json | 6 + .../corpus/5452-fs-glob-callback.ts.json | 6 + .../tests/fixtures/node-types/fs-glob.ts.json | 6 + packages/runtime/src/scr_bytes_io.c | 43 +- packages/runtime/src/scr_glob.c | 1109 ++++++++++++++++- packages/runtime/src/scr_inspect.h | 4 + packages/runtime/src/scr_path.h | 27 + packages/runtime/test/test_glob.c | 9 + tests/corpus/5448-fs-glob-patterns.ts | 82 ++ tests/corpus/5449-fs-glob-options.ts | 84 ++ tests/corpus/5450-fs-glob-validation.js | 80 ++ tests/corpus/5451-fs-promises-glob.ts | 74 ++ tests/corpus/5452-fs-glob-callback.ts | 44 + tests/fixtures/node-types/fs-glob.ts | 26 + tests/harness/project-config.test.ts | 14 + 41 files changed, 2168 insertions(+), 106 deletions(-) create mode 100644 docs/content/limitations/builtin-apis/glob.mdx create mode 100644 packages/compiler/src/frontend/lowering/builtins/fs-glob.ts create mode 100644 packages/compiler/test/fs-glob-boundaries.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json create mode 100644 tests/corpus/5448-fs-glob-patterns.ts create mode 100644 tests/corpus/5449-fs-glob-options.ts create mode 100644 tests/corpus/5450-fs-glob-validation.js create mode 100644 tests/corpus/5451-fs-promises-glob.ts create mode 100644 tests/corpus/5452-fs-glob-callback.ts create mode 100644 tests/fixtures/node-types/fs-glob.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index a2f05e50d..2d229bd84 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -76,6 +76,8 @@ title: "Limitations" ../limitations/builtin-apis/filesystem-flags.mdx +../limitations/builtin-apis/glob.mdx + ../limitations/builtin-apis/filehandle-and-descriptor-i-o.mdx ../limitations/builtin-apis/crypto.mdx diff --git a/docs/content/limitations/builtin-apis/glob.mdx b/docs/content/limitations/builtin-apis/glob.mdx new file mode 100644 index 000000000..5df4d6688 --- /dev/null +++ b/docs/content/limitations/builtin-apis/glob.mdx @@ -0,0 +1 @@ +- **Glob:** `fs.globSync`, `fs.glob` and `fs/promises` `glob` support every option. `withFileTypes` must be a literal `true` or `false`, and `exclude` functions cannot be combined with `withFileTypes`. The `fs/promises` iterator is supported only as the source of a `for await` loop, and its walk completes when the loop starts. `fs.glob` callbacks receive file names, not Dirents. diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index d005a509d..fa61ff1e6 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -15,4 +15,4 @@ Other immutable aliases of table-backed Node builtins support direct calls, but Synchronous and promise filesystem APIs with trailing arguments that affect behavior remain call-only. This includes writers, open, directory creation/removal, metadata readers, and directory readers. Passing them as values could discard modes, flags, encodings, or options during function-signature adaptation. -Native error-first filesystem callbacks can be stored and passed to platform adapters. Reads support Buffer and UTF-8 results; writes preserve string flags and modes; copy supports regular files and recursive directories. Reads and writes reject already-aborted signals, but native file operations complete synchronously before the deferred callback, so cancellation cannot interrupt I/O in progress. Symbolic-link copy, copy filters and clone modes, recursive glob patterns, glob exclusions, recursive directory reads, and Dirent results report a native limitation. +Native error-first filesystem callbacks can be stored and passed to platform adapters. Reads support Buffer and UTF-8 results; writes preserve string flags and modes; copy supports regular files and recursive directories. Reads and writes reject already-aborted signals, but native file operations complete synchronously before the deferred callback, so cancellation cannot interrupt I/O in progress. Symbolic-link copy, copy filters and clone modes, recursive directory reads, and Dirent results report a native limitation. `fs.glob` compiles only as a direct call; a stored `fs.glob` value throws `SC2020`. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index c70afba5a..392af99c2 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "28e11bdb8e9a2f58a9c6", + "artifactVersion": "e85ddcb75a2a2988f5a0", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index b46800a39..dcf70d5df 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -314,9 +314,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 128, + "partial": 131, "refused": 0, - "not-implemented": 145, + "not-implemented": 142, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -29690,9 +29690,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -30702,9 +30702,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -31921,9 +31921,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 606e9dd7c..c1e162be9 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 37, - "verify-gap": 2442, - "audit-partial": 906, + "verify-gap": 2439, + "audit-partial": 909, "classify": 2, "implement": 85 } @@ -26014,12 +26014,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fsPromises.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] } } }, @@ -27194,12 +27201,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fs.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] } } }, @@ -28615,12 +28629,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fs.globSync", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index e9aeb1f8f..dcb6687d2 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -326,9 +326,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 128, + "partial": 131, "refused": 0, - "not-implemented": 145, + "not-implemented": 142, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -43746,6 +43746,11 @@ "tests/corpus/3135-fs-file-handle-controls.ts", "tests/corpus/3136-fs-file-handle-options.ts", "tests/corpus/3137-fs-vector-callbacks.cjs", + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", "tests/corpus/5540-fs-callback-forms.cjs", "tests/corpus/5541-fs-stats-fields.cjs", "tests/corpus/fs-native-realpath.ts", @@ -43765,6 +43770,7 @@ "tests/corpus/node24-fs-timestamps-promises.ts", "tests/corpus/node24-fs-timestamps-symlinks.ts", "tests/corpus/node24-fs-timestamps.ts", + "tests/fixtures/node-types/fs-glob.ts", "tests/fixtures/node-types/fs-links.ts", "tests/fixtures/node-types/fs-statfs.ts", "tests/fixtures/node-types/fs-timestamps.ts" @@ -44792,8 +44798,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fsPromises.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] }, "anchorSource": "exact" }, @@ -45951,8 +45965,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] }, "anchorSource": "exact" }, @@ -47453,8 +47475,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.globSync", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1d87bcbc5..bf3c582b5 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -280,6 +280,7 @@ { "chapter": "child_process", "signatures": ["`stdout` Type: {stream.Readable|null|undefined}", "`stderr` Type: {stream.Readable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/1565-spawn-pipe-streams.ts", "tests/corpus/2939-child-shell-text.ts"] }, { "chapter": "child_process", "signatures": ["`stdin` Type: {stream.Writable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2894-child-stdin-backpressure.ts", "tests/corpus/2895-child-stdin-write-after-end.ts", "tests/corpus/2896-child-stdin-write-after-finish.ts", "tests/corpus/2897-child-stdin-destroy.ts", "tests/corpus/2898-child-stdin-spawn-failure.ts"] }, { "chapter": "fs", "signatures": ["`fsPromises.lstat(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2941-fsp-lstat-identity.ts"] }, + { "chapter": "fs", "signatures": ["`fs.globSync(pattern[, options])`", "`fs.glob(pattern[, options], callback)`", "`fsPromises.glob(pattern[, options])`"], "status": "partial", "evidence": ["tests/corpus/5448-fs-glob-patterns.ts", "tests/corpus/5449-fs-glob-options.ts", "tests/corpus/5450-fs-glob-validation.js", "tests/corpus/5451-fs-promises-glob.ts", "tests/corpus/5452-fs-glob-callback.ts", "tests/fixtures/node-types/fs-glob.ts"] }, { "chapter": "process", "signatures": ["`process.send(message[, sendHandle[, options]][, callback])`", "`process.disconnect()`", "`connected` Type: {boolean}", "Event: `'message'`", "Event: `'disconnect'`"], "status": "partial", "evidence": ["tests/corpus/2946-child-fork-ipc/main.ts"] }, { "chapter": "fs", "symbols": ["fs.Stats", "stats.isBlockDevice", "stats.isCharacterDevice", "stats.isDirectory", "stats.isFIFO", "stats.isFile", "stats.isSocket", "stats.isSymbolicLink"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, { "chapter": "fs", "parent": "Class: `fs.Stats`", "signatures": ["`mode` Type: {number|bigint}", "`nlink` Type: {number|bigint}", "`uid` Type: {number|bigint}", "`gid` Type: {number|bigint}", "`rdev` Type: {number|bigint}", "`size` Type: {number|bigint}", "`blksize` Type: {number|bigint}", "`blocks` Type: {number|bigint}", "`atimeMs` Type: {number|bigint}", "`mtimeMs` Type: {number|bigint}", "`birthtimeMs` Type: {number|bigint}", "`atimeNs` Type: {bigint}", "`mtimeNs` Type: {bigint}", "`ctimeNs` Type: {bigint}", "`birthtimeNs` Type: {bigint}", "`atime` Type: {Date}", "`mtime` Type: {Date}", "`ctime` Type: {Date}", "`birthtime` Type: {Date}"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index a7395d9f8..40a3ec87c 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1324,6 +1324,36 @@ declare module "node:fs" { * fences by name at the call site; undocumented keys drop like Node. */ options: { withFileTypes: true; recursive?: boolean; [option: string]: unknown }, ): Dirent[]; + /* fs.globSync / fs.glob: Node's glob walk over the file system. Patterns + * are minimatch globs (case-insensitive on macOS and Windows); results + * are relative to cwd unless the pattern is absolute. withFileTypes must + * be a literal so the result type is known. */ + export interface GlobOptions { + cwd?: string | URL; + exclude?: readonly string[] | ((fileName: string) => boolean); + followSymlinks?: boolean; + withFileTypes?: false; + } + export interface GlobOptionsWithFileTypes { + cwd?: string | URL; + exclude?: readonly string[]; + followSymlinks?: boolean; + withFileTypes: true; + } + export function globSync(pattern: string | readonly string[], options?: GlobOptions): string[]; + export function globSync( + pattern: string | readonly string[], + options: GlobOptionsWithFileTypes, + ): Dirent[]; + export function glob( + pattern: string | readonly string[], + callback: (err: NodeJS.ErrnoException | null, matches?: string[]) => void, + ): void; + export function glob( + pattern: string | readonly string[], + options: GlobOptions, + callback: (err: NodeJS.ErrnoException | null, matches?: string[]) => void, + ): void; export function accessSync(path: string, mode?: number): void; export function mkdtempSync(prefix: string): string; export const constants: { @@ -1686,6 +1716,15 @@ declare module "fs/promises" { [option: string]: unknown; }, ): Promise; + /* The async iterator form of fs.globSync: iterate it with for await. */ + export function glob( + pattern: string | readonly string[], + options?: import("node:fs").GlobOptions, + ): AsyncIterableIterator; + export function glob( + pattern: string | readonly string[], + options: import("node:fs").GlobOptionsWithFileTypes, + ): AsyncIterableIterator; export function rm(path: string): Promise; export function stat(path: string): Promise; export function statfs( diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 3683a3124..ce5c0cfc0 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -381,6 +381,69 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): B.line(`call void @scr_watcher_close(ptr ${w.name})`); return { name: "", type: e.type }; } + if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") { + // Dirent rows from one glob snapshot (scr_glob.c): mode 1 is the + // globSync form, 3 the fs/promises form with resolved parent paths. + if (e.type.kind !== "array" || e.type.elem.kind !== "record") { + throw new InternalCompilerError( + `llvm emitter bug: ${e.fn} result is not a Dirent record array`, + ); + } + const arrayT = e.type; + const recT = e.type.elem; + const pattern = host.emitExpr(e.args[0]!); + const options = host.emitExpr(e.args[1]!); + const mode = e.fn === "fsp.globTypes" ? "3.0" : "1.0"; + host.declare(`declare ptr @scr_fs_glob(ptr, ptr, double)`); + host.declare(`declare ${host.sizeType} @scr_fs_glob_count(ptr)`); + host.declare(`declare ptr @scr_fs_glob_name(ptr, ${host.sizeType})`); + host.declare(`declare ptr @scr_fs_glob_parent(ptr, ${host.sizeType})`); + host.declare(`declare double @scr_fs_glob_type(ptr, ${host.sizeType})`); + host.declare(`declare void @scr_fs_glob_free(ptr)`); + const snap = B.tmp(); + B.line( + `${snap} = call ptr @scr_fs_glob(ptr ${pattern.name}, ptr ${options.name}, double ${mode})`, + ); + host.emitPendingCheck(); + const cnt = B.tmp(); + B.line(`${cnt} = call ${host.sizeType} @scr_fs_glob_count(ptr ${snap})`); + const arr = B.tmp(); + B.line(`${arr} = ${arrNewCall(host.shapeHost, recT, cnt)}`); + const arrayOut = host.own({ name: arr, type: arrayT }); + const iSlot = B.slot(); + B.entryAllocas.push(`${iSlot} = alloca ${host.sizeType}`); + B.line(`store ${host.sizeType} 0, ptr ${iSlot}`); + const lc = B.newLabel("gl.c"); + const lb = B.newLabel("gl.b"); + const le = B.newLabel("gl.e"); + B.br(lc); + B.startBlock(lc); + const i = B.tmp(); + const cont = B.tmp(); + B.line(`${i} = load ${host.sizeType}, ptr ${iSlot}`); + B.line(`${cont} = icmp ult ${host.sizeType} ${i}, ${cnt}`); + B.condBr(cont, lb, le); + B.startBlock(lb); + const row = B.tmp(); + B.line(`${row} = call ptr @${mangleRecordNew(recT.shapeId)}()`); + const dt = B.tmp(); + B.line(`${dt} = call double @scr_fs_glob_type(ptr ${snap}, ${host.sizeType} ${i})`); + host.storeField(host.recordFieldPtr(row, recT.shapeId, "%dtype").ptr, F64, dt, false); + const nm = B.tmp(); + B.line(`${nm} = call ptr @scr_fs_glob_name(ptr ${snap}, ${host.sizeType} ${i}) ; +1`); + B.line(`store ptr ${nm}, ptr ${host.recordFieldPtr(row, recT.shapeId, "name").ptr}`); + const parent = B.tmp(); + B.line(`${parent} = call ptr @scr_fs_glob_parent(ptr ${snap}, ${host.sizeType} ${i}) ; +1`); + B.line(`store ptr ${parent}, ptr ${host.recordFieldPtr(row, recT.shapeId, "parentPath").ptr}`); + host.arrPush(arr, "ref", row); // push takes ownership of the row + const i2 = B.tmp(); + B.line(`${i2} = add ${host.sizeType} ${i}, 1`); + B.line(`store ${host.sizeType} ${i2}, ptr ${iSlot}`); + B.br(lc); + B.startBlock(le); + B.line(`call void @scr_fs_glob_free(ptr ${snap})`); + return arrayOut; + } if (e.fn === "fs.readdirTypesSync" || e.fn === "fsp.readdirTypes") { // Dirent rows assembled inline from one scandir snapshot — the C // emitter's flat loop. The sync form checks a scandir error before diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 33b9fd4ff..0c3a2963c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -325,6 +325,10 @@ export const LIB_FN_SYMS: Record = { "fs.rmRetrySync": "scr_fs_rm_opts_retry", "fs.rmdirSync": "scr_fs_rmdir", "fs.readdirSync": "scr_fs_readdir", + "fs.globSync": "scr_fs_glob_strings", + "fs.callbackGlob": "scr_fs_glob_callback", + "fsp.globStrings": "scr_fsp_glob_strings", + "fsp.globError": "scr_fsp_glob_error", "fs.realpathSync": "scr_fs_realpath", "fs.realpathNativeSync": "scr_fs_realpath_promise", "fs.unlinkSync": "scr_fs_unlink", @@ -1334,6 +1338,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "fs.existsChk", "fs.callbackValue", "fs.callbackCall", + "fs.callbackGlob", "http.createServer", "http.createServerEmpty", "http.request", diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts new file mode 100644 index 000000000..389da8dc0 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts @@ -0,0 +1,268 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { + BOOL, + DYN, + F64, + STRING, + VOID, + arrayOf, + type IrExpr, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../../ir/ir.js"; +import { dynUndefinedExpr, numLit } from "../../../ir/build.js"; +import { InternalCompilerError } from "../../../errors.js"; +import { locOf } from "../../program.js"; +import { checkForAwaitBinding } from "../lower-async-iteration.js"; +import { builtinImportOf } from "./module-bindings.js"; + +/* fs.globSync, fs.glob and fs/promises glob (scr_glob.c, a port of Node's + * lib/internal/fs/glob.js). The pattern and options cross as dyn values so + * the runtime validates them in Node's order. The result form (strings or + * Dirent rows) comes from the call's mapped result type, so withFileTypes + * must be statically known; the runtime checks that the option agrees. */ + +/** The interned Dirent record array (type-mapper.ts), or null. */ +function direntArray(lowerer: Lowerer, type: IrType | null | undefined): boolean { + if (type?.kind !== "array" || type.elem.kind !== "record") return false; + const shape = lowerer.shapes.get(type.elem.shapeId); + return ( + !!shape && + !shape.tuple && + !shape.indexValue && + shape.fields.length === 3 && + shape.fields.some((f) => f.name === "%dtype") + ); +} + +/** An exclude function receives Node's Dirent objects when withFileTypes is + * set, which have no native representation here. */ +function fenceDirentExclude(lowerer: Lowerer, call: ts.CallExpression, what: string): void { + const options = call.arguments[1]; + if (!options) return; + const exclude = lowerer.checker.getPropertyOfType(lowerer.typeOf(options), "exclude"); + if (!exclude) return; + const type = lowerer.checker.getTypeOfSymbolAtLocation(exclude, options); + if (lowerer.checker.getCallSignatures(type).length === 0) return; + lowerer.noLowering( + `${what} with an exclude function and withFileTypes`, + options, + "pass exclude as a string[] of glob patterns, or match on names without withFileTypes", + ); +} + +function globArgs(lowerer: Lowerer, call: ts.CallExpression, what: string): IrExpr[] { + if (call.arguments.some(ts.isSpreadElement)) { + lowerer.noLowering(`${what} with spread arguments`, call, "write the arguments positionally"); + } + return call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)); +} + +/** `globSync(pattern, options?)`: string[] or Dirent[]. */ +export function lowerFsGlobSyncCall( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const args = globArgs(lowerer, call, "fs.globSync"); + const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const types = direntArray(lowerer, callType); + if (types) fenceDirentExclude(lowerer, call, "fs.globSync"); + if (!types && !(callType?.kind === "array" && callType.elem.kind === "string")) { + lowerer.noLowering( + "fs.globSync where the result is not string[] or Dirent[]", + call, + "pass withFileTypes as a literal true or false so the result type is known", + ); + } + return { + kind: "libCall", + fn: types ? "fs.globTypesSync" : "fs.globSync", + args: [args[0] ?? dynUndefinedExpr(loc), args[1] ?? dynUndefinedExpr(loc)], + type: types ? callType! : arrayOf(STRING), + loc, + }; +} + +/** `fs.glob(pattern[, options], callback)`: the argument vector goes to the + * runtime, which validates it and calls back with string[]. */ +export function lowerFsGlobCallbackCall( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const args = globArgs(lowerer, call, "fs.glob"); + return { + kind: "libCall", + fn: "fs.callbackGlob", + args: [{ kind: "dynArrLit", elems: args, type: DYN, loc }], + type: VOID, + loc, + }; +} + +/** True when `node` calls fs/promises glob (any import spelling). */ +export function isFspGlobCall(lowerer: Lowerer, node: ts.Expression): node is ts.CallExpression { + let expr = node; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (!ts.isCallExpression(expr) || expr.questionDotToken) return false; + const callee = expr.expression; + const bi = ts.isIdentifier(callee) + ? builtinImportOf(lowerer, callee) + : ts.isPropertyAccessExpression(callee) && !callee.questionDotToken + ? lowerer.builtinMemberOf(callee) + : null; + return bi?.module === "fs/promises" && bi.member === "glob"; +} + +/** fs/promises `glob(pattern, options?)` as the head of a `for await` loop + * (lowerForOf marks the call): the matches as an array the loop awaits in + * order. The walk runs when the loop starts, like the other fs/promises + * operations, which complete before their continuations. Other uses of + * the async iterator have no lowering. */ +export function lowerFspGlobCall(lowerer: Lowerer, call: ts.CallExpression, loc: SrcLoc): IrExpr { + if (lowerer.fspGlobHead !== call) { + lowerer.noLowering( + "fs/promises glob outside a for await loop", + call, + "iterate the matches directly with for await (const path of glob(pattern)), or use fs.globSync", + ); + } + const args = globArgs(lowerer, call, "fs/promises glob"); + // The async iterator's yielded type (its first type argument). + const yielded = lowerer.checker.getTypeArguments(lowerer.typeOf(call) as ts.TypeReference)[0]; + const element = yielded ? lowerer.mapTypeOf(yielded) : null; + const rows: IrType | null = element ? { kind: "array", elem: element } : null; + const types = direntArray(lowerer, rows); + if (types) fenceDirentExclude(lowerer, call, "fs/promises glob"); + if (!types && element?.kind !== "string") { + lowerer.noLowering( + "fs/promises glob where the yielded value is not a string or a Dirent", + call, + "pass withFileTypes as a literal true or false so the yielded type is known", + ); + } + return { + kind: "libCall", + fn: types ? "fsp.globTypes" : "fsp.globStrings", + args: [args[0] ?? dynUndefinedExpr(loc), args[1] ?? dynUndefinedExpr(loc)], + type: types ? rows! : arrayOf(STRING), + loc, + }; +} + +/** `for await (const path of glob(pattern, options))`: the walk runs when + * the loop starts and its matches bind in order, one microtask hop per + * step. When the walk ended with an error (an exclude callback threw), the + * matches found before it bind first and the error is thrown when the loop + * reaches the end, as Node's async iterator yields them before rejecting; + * leaving the loop early with break or return skips it. */ +export function lowerFspGlobForAwait( + lowerer: Lowerer, + stmt: ts.ForOfStatement, + call: ts.CallExpression, +): IrStmt { + const binding = checkForAwaitBinding(lowerer, stmt); + const loc = locOf(stmt); + lowerer.fspGlobHead = call; + let rowsExpr: IrExpr; + try { + rowsExpr = lowerer.lowerExpr(call); + } finally { + lowerer.fspGlobHead = null; + } + if (rowsExpr.type.kind !== "array") { + throw new InternalCompilerError("fs/promises glob head did not lower to an array"); + } + const rowsT = rowsExpr.type; + const elemT = rowsT.elem; + lowerer.scopes.push(new Map()); + try { + const rows = lowerer.declareHiddenLocal("%globRows", rowsT); + const error = lowerer.declareHiddenLocal("%globError", DYN); + const index = lowerer.declareHiddenLocal("%globIndex", F64); + index.mutable = true; + const item = lowerer.declareLocal( + binding.declaration.name, + binding.declaration.name.text, + elemT, + binding.mutable, + ); + const rowsRef = (): IrExpr => ({ kind: "varRef", localId: rows.id, type: rowsT, loc }); + const indexRef = (): IrExpr => ({ kind: "varRef", localId: index.id, type: F64, loc }); + const errorRef = (): IrExpr => ({ kind: "varRef", localId: error.id, type: DYN, loc }); + const body = lowerer.inCtl("loop", () => lowerer.lowerScopedBlock(stmt.statement)); + const loop: IrStmt = { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [ + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "async.hop", args: [], type: VOID, loc }, + loc, + }, + { + kind: "if", + cond: { + kind: "unary", + op: "!", + operand: { kind: "arrayHas", arr: rowsRef(), index: indexRef(), type: BOOL, loc }, + type: BOOL, + loc, + }, + then: [ + { + kind: "if", + cond: { + kind: "unary", + op: "!", + operand: { kind: "dynTest", test: "undefined", value: errorRef(), type: BOOL, loc }, + type: BOOL, + loc, + }, + then: [{ kind: "throw", value: errorRef(), loc }], + else_: null, + loc, + }, + { kind: "break", loc }, + ], + else_: null, + loc, + }, + { + kind: "varDecl", + localId: item.id, + init: { kind: "arrayGet", arr: rowsRef(), index: indexRef(), type: elemT, loc }, + loc, + }, + { + kind: "assign", + localId: index.id, + value: { kind: "bin", op: "+", left: indexRef(), right: numLit(1, loc), type: F64, loc }, + loc, + }, + ...body, + ], + loc, + }; + return { + kind: "block", + body: [ + { kind: "varDecl", localId: rows.id, init: rowsExpr, loc }, + { + kind: "varDecl", + localId: error.id, + init: { kind: "libCall", fn: "fsp.globError", args: [], type: DYN, loc }, + loc, + }, + { kind: "varDecl", localId: index.id, init: numLit(0, loc), loc }, + loop, + ], + loc, + }; + } finally { + lowerer.scopes.pop(); + } +} diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index 5c5fbc31f..2512de466 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -42,6 +42,7 @@ import { lowerPathParseCall, } from "./operating-system.js"; import { lowerQuerystringParseCall, lowerQuerystringStringifyCall } from "./querystring.js"; +import { lowerFsGlobCallbackCall, lowerFsGlobSyncCall, lowerFspGlobCall } from "./fs-glob.js"; export function lowerBuiltinModuleCall( lowerer: Lowerer, @@ -59,6 +60,12 @@ export function lowerBuiltinModuleCall( if (bi.module === "buffer" && ["isAscii", "isUtf8", "transcode"].includes(bi.member)) { return lowerBufferEncodingCall(lowerer, expr, bi, fn, loc); } + if (bi.module === "fs" && bi.member === "glob") + return lowerFsGlobCallbackCall(lowerer, expr, loc); + if (bi.module === "fs" && bi.member === "globSync") + return lowerFsGlobSyncCall(lowerer, expr, loc); + if (bi.module === "fs/promises" && bi.member === "glob") + return lowerFspGlobCall(lowerer, expr, loc); if (fn.fn === "fs.callbackCall" && bi.member !== "rename") { return lowerFsCallbackCall(lowerer, expr, bi, loc); } diff --git a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts index 62b7a89a5..5957afcaa 100644 --- a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts +++ b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts @@ -279,7 +279,7 @@ function classAsyncIteratorPlan( }; } -function checkForAwaitBinding( +export function checkForAwaitBinding( lowerer: Lowerer, stmt: ts.ForOfStatement, ): { declaration: ts.VariableDeclaration & { name: ts.Identifier }; mutable: boolean } { diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 742fdfc3a..5d6305aa8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -129,6 +129,7 @@ import { lowerServerCloseOverrideAssignment, } from "./lower-server.js"; import { lowerUrlAssignment } from "./builtins/url.js"; +import { isFspGlobCall, lowerFspGlobForAwait } from "./builtins/fs-glob.js"; import { isNativeFfiRequire, builtinMemberRequireDecl, @@ -7828,6 +7829,11 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { // A synchronous array iterator is also valid in `for await`. Its values // take the async-from-sync path below; other async sources retain their // dedicated request protocols. + if (stmt.awaitModifier) { + let head: ts.Expression = stmt.expression; + while (ts.isParenthesizedExpression(head)) head = head.expression; + if (isFspGlobCall(lowerer, head)) return lowerFspGlobForAwait(lowerer, stmt, head); + } const awaitArray = !!stmt.awaitModifier && lowerer.mapTypeOf(lowerer.typeOf(stmt.expression))?.kind === "array"; if (stmt.awaitModifier && !awaitArray) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b490d42ff..99c6b4d46 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1819,6 +1819,9 @@ export class Lowerer { /** for-of loops over arrays that can hold neither holes nor undefined: * each pass reads the element payload directly. */ readonly presentElementLoops = new Set(); + /** The fs/promises glob() call lowerForOf is lowering as a for await + * head; other glob() calls have no lowering. */ + fspGlobHead: ts.Node | null = null; /** Capture entries and their origin share one mutable box. Normalize each * entry to the origin so writes and flow proofs stay synchronized. */ readonly runtimeOptionalRoots = new Map(); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index aa7e95e21..5042e2efd 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1037,6 +1037,8 @@ export const BUILTIN_MODULE_FNS: Record< rmSync: { fn: "fs.rmSync", params: [STRING], result: VOID }, rmdirSync: { fn: "fs.rmdirSync", params: [STRING], result: VOID }, readdirSync: { fn: "fs.readdirSync", params: [STRING], result: arrayOf(STRING) }, + // Pattern and options are checked by the runtime (lowerFsGlobSyncCall). + globSync: { fn: "fs.globSync", params: [DYN, DYN], result: arrayOf(STRING) }, statSync: { fn: "fs.statSync", params: [STRING], result: STATS_T }, mkdtempSync: { fn: "fs.mkdtempSync", params: [STRING], result: STRING }, // accessSync's omitted mode completes to 0 (F_OK) in the special case @@ -1190,6 +1192,8 @@ export const BUILTIN_MODULE_FNS: Record< result: { kind: "promise", inner: arrayOf(STRING) }, }, rm: { fn: "fsp.rm", params: [STRING], result: { kind: "promise", inner: VOID } }, + // The async iterator lowers only as a for await head (lowerFspGlobCall). + glob: { fn: "fsp.globStrings", params: [DYN, DYN], result: arrayOf(STRING) }, stat: { fn: "fsp.stat", params: [STRING], result: { kind: "promise", inner: STATS_T } }, realpath: { fn: "fsp.realpath", params: [STRING], result: { kind: "promise", inner: STRING } }, lstat: { fn: "fsp.lstat", params: [STRING], result: { kind: "promise", inner: STATS_T } }, @@ -1772,6 +1776,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< rmSync: ["fs.rmOptsSync", "fs.rmRetrySync"], // The { withFileTypes: true } Dirent form. readdirSync: ["fs.readdirTypesSync"], + globSync: ["fs.globTypesSync"], // The JS-source validation-ladder variant (lowerFsLadderCall): the // real mkdtemp runs when the options leave utf8 semantics. mkdtempSync: ["fs.mkdtempSyncChk"], @@ -1791,6 +1796,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< writeFile: ["fsp.writeFileMode"], // The { withFileTypes: true } Dirent form. readdir: ["fsp.readdirTypes"], + glob: ["fsp.globTypes"], }, crypto: { // The composed randomBytes(n).toString(enc) chain keeps its one-libCall @@ -1885,7 +1891,7 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ id: "node-builtin.fs.callbacks", kind: "node-builtin", name: "fs error-first callbacks", - fns: ["fs.callbackValue", "fs.callbackCall"], + fns: ["fs.callbackValue", "fs.callbackCall", "fs.callbackGlob"], note: "native filesystem callbacks, including stored platform adapter functions", }, // ── the Date slice (lowerDateCall/lowerNew): statics, construction, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 5b0fb6cf3..da7b4072c 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -521,6 +521,11 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.rmdirSync", "fs.readdirSync", "fs.readdirTypesSync", + "fs.globSync", + "fs.globTypesSync", + "fs.callbackGlob", + "fsp.globStrings", + "fsp.globTypes", "url.new", "url.newBase", "url.set", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 3da473720..f838a8ad0 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -444,6 +444,20 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * sorts), the directory's order on Windows; no "."/"..". Throws * Node's scandir errno error (may-throw seed set); fresh +1 array. */ "fs.readdirTypesSync": { argTypes: [STRING], result: VOID }, + /** fs.globSync / fs.glob / fs/promises glob (scr_glob.c, Node's + * lib/internal/fs/glob.js walk): pattern and options are dyn values the + * runtime validates in Node's order. The string forms answer string[]; + * the Dirent forms answer the call site's Dirent record array (validated + * in validate.ts) assembled from the snapshot; fs.callbackGlob takes the + * argument vector and calls back with string[]. */ + "fs.globSync": { argTypes: [DYN, DYN], result: arrayOf(STRING) }, + "fs.globTypesSync": { argTypes: [DYN, DYN], result: VOID }, + "fs.callbackGlob": { argTypes: [DYN], result: VOID }, + "fsp.globStrings": { argTypes: [DYN, DYN], result: arrayOf(STRING) }, + "fsp.globTypes": { argTypes: [DYN, DYN], result: VOID }, + /** The error an fs/promises glob walk ended with (undefined when none), + * read once right after fsp.globStrings / fsp.globTypes. */ + "fsp.globError": { argTypes: [], result: DYN }, /** node:path (scr_path.c ports BOTH of Node's implementations * function-by-function): the `path.*` family is posix, the * `path.win32*` family is Node v24's path.win32 ported exactly — the diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index ba09143ba..3c50b5bb5 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -43,6 +43,7 @@ export interface RuntimeFeatures { const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "fs.callbackValue", "fs.callbackCall", + "fs.callbackGlob", "async.awaitDyn", "timers.immediatePromise", "crypto.native", @@ -171,7 +172,14 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run fn === "dyn.nativeRegexIs" || fn === "util.stripVTControlCharacters" || fn === "path.matchesGlob" || - fn === "path.win32MatchesGlob" + fn === "path.win32MatchesGlob" || + // fs.glob's walk compiles its patterns with the glob matcher. + fn === "fs.globSync" || + fn === "fs.globTypesSync" || + fn === "fs.callbackGlob" || + fn === "fsp.globStrings" || + fn === "fsp.globTypes" || + fn === "fsp.globError" ) features.regex = true; if ( diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index c639cca05..408ea46cd 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4682,10 +4682,16 @@ function validateFunction( } return true; } - if (e.fn === "fs.readdirTypesSync" || e.fn === "fsp.readdirTypes") { + if ( + e.fn === "fs.readdirTypesSync" || + e.fn === "fsp.readdirTypes" || + e.fn === "fs.globTypesSync" || + e.fn === "fsp.globTypes" + ) { // Result: the interned Dirent record array — {%dtype: f64, // name: string, parentPath: string} rows (canonical field - // order; the structure lowerFsReaddirTypesCall pinned). + // order; the structure lowerFsReaddirTypesCall pinned). The glob + // forms answer the array itself. const result = e.fn === "fsp.readdirTypes" ? e.type.kind === "promise" diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index eaff1b176..4e3863094 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -945,6 +945,12 @@ "name": "fs.futimesSync", "status": "static" }, + { + "id": "node-builtin.fs.globSync", + "kind": "node-builtin", + "name": "fs.globSync", + "status": "static" + }, { "id": "node-builtin.fs.linkSync", "kind": "node-builtin", @@ -994,6 +1000,12 @@ "name": "fs/promises.chmod", "status": "static" }, + { + "id": "node-builtin.fs.promises.glob", + "kind": "node-builtin", + "name": "fs/promises.glob", + "status": "static" + }, { "id": "node-builtin.fs.promises.link", "kind": "node-builtin", diff --git a/packages/compiler/test/fs-glob-boundaries.test.ts b/packages/compiler/test/fs-glob-boundaries.test.ts new file mode 100644 index 000000000..169f40052 --- /dev/null +++ b/packages/compiler/test/fs-glob-boundaries.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function compileSource(source: string) { + const directory = mkdtempSync(join(tmpdir(), "scriptc-fs-glob-")); + try { + const entry = join(directory, "main.ts"); + writeFileSync(entry, source); + return await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +// Forms whose result type or element values have no native representation +// fail during compilation. +test.each([ + [ + "fs/promises glob outside a for await loop", + 'import { glob } from "node:fs/promises";\nconst matches = glob("*");\nvoid matches;\n', + ], +])("%s fails during compilation", async (what, source) => { + const result = await compileSource(source); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an fs.glob diagnostic"); + expect(result.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining(what) })]), + ); + expect(result.diagnostics.some((diagnostic) => diagnostic.code === "SC9001")).toBe(false); +}); + +test("the fs.glob forms compile", async () => { + const result = await compileSource(`import { globSync, glob as globCallback } from "node:fs"; +import { glob } from "node:fs/promises"; +console.log(globSync("**/*.ts", { cwd: ".", exclude: ["node_modules/**"] }).length); +console.log(globSync(["*.md"], { exclude: (path) => path.startsWith(".") }).length); +console.log(globSync("*", { withFileTypes: true }).map((d) => d.isFile()).length); +globCallback("*", (err, matches) => console.log(err, matches?.length)); +async function main(): Promise { + for await (const path of glob("*")) console.log(path); + for await (const entry of glob("*", { withFileTypes: true })) console.log(entry.name, entry.isDirectory()); +} +void main(); +`); + expect(result.ok, JSON.stringify(result.ok ? [] : result.diagnostics)).toBe(true); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json new file mode 100644 index 000000000..a373df5a1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5448-fs-glob-patterns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json new file mode 100644 index 000000000..802257b14 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5449-fs-glob-options.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json new file mode 100644 index 000000000..d88ba07ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5450-fs-glob-validation.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json new file mode 100644 index 000000000..0594615d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5451-fs-promises-glob.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json new file mode 100644 index 000000000..863acaba2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5452-fs-glob-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json new file mode 100644 index 000000000..7a222498b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fs-glob.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 547ba1852..152d0b632 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -23,13 +23,6 @@ static const ScrArrOps scr_bytes_io_bytes_ops = { scr_bytes_retain_v, scr_bytes_ #ifndef O_SYNC #define O_SYNC 0 /* Windows CRT opens have no separate synchronous flag. */ #endif -/* Bionic added glob/globfree in API 28; Android packs target API 26. */ -#if !defined(_WIN32) && !defined(__wasi__) && (!defined(__ANDROID__) || __ANDROID_API__ >= 28) -#include -#define SCR_FS_HAS_GLOB 1 -#else -#define SCR_FS_HAS_GLOB 0 -#endif #ifdef __APPLE__ #include /* lchmod(2) — the fs.lchmodSync ladder's real tail */ #endif @@ -554,7 +547,7 @@ static void scr_fs_cb_fire(ScrClosure *closure) { } #endif -static void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer) { +void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer) { ScrDyn *arguments = scr_dyn_new_arr(); if (scr_exc_pending()) { ScrCaught *caught = scr_exc_take(); @@ -815,36 +808,10 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc scr_dyn_release(force); scr_str_release(destination); } } else if (!strcmp(op, "glob")) { -#if SCR_FS_HAS_GLOB - if (strstr(path->data, "**") || scr_fs_cb_option_bool(ARG(1), "withFileTypes")) { - const char *message = "Recursive glob patterns and Dirent results have no native lowering"; - scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); - goto done; - } - ScrDyn *cwd = scr_fs_cb_option(ARG(1), "cwd"); - ScrDyn *exclude = scr_fs_cb_option(ARG(1), "exclude"); - if (exclude && exclude->kind != SCR_DYN_UNDEF) { - const char *message = "fs.glob exclude options have no native lowering"; - scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); - } else { - ScrStr *pattern = cwd && cwd->kind == SCR_DYN_STR ? scr_fs_cb_join(cwd->v.str, path->data) : scr_str_retain(path); - glob_t matches = {0}; - int rc = glob(pattern->data, 0, NULL, &matches); - if (rc && rc != GLOB_NOMATCH) scr_fs_throw(rc == GLOB_NOSPACE ? ENOMEM : EIO, "glob", path); - else { - result = scr_dyn_new_arr(); - size_t prefix = cwd && cwd->kind == SCR_DYN_STR ? cwd->v.str->len + 1 : 0; - for (size_t i = 0; i < matches.gl_pathc; i++) { - const char *name = matches.gl_pathv[i] + prefix; - scr_dyn_arr_push(result, scr_fs_cb_string(scr_str_new(name, strlen(name)))); - } - } - globfree(&matches); scr_str_release(pattern); - } - scr_dyn_release(cwd); scr_dyn_release(exclude); -#else - scr_fs_throw(ENOSYS, "glob", path); -#endif + /* Direct fs.glob calls lower to scr_fs_glob_callback (scr_glob.c); a + * stored fs.glob value is not supported. */ + const char *message = "fs.glob stored as a value has no native lowering; call fs.glob directly"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); } else if (!strcmp(op, "write") && ARG(1)->kind == SCR_DYN_STR) { /* write(fd, string[, position[, encoding]], callback): a non-number * position writes at the current offset; the encoding defaults to diff --git a/packages/runtime/src/scr_glob.c b/packages/runtime/src/scr_glob.c index b2c966fc7..89949c427 100644 --- a/packages/runtime/src/scr_glob.c +++ b/packages/runtime/src/scr_glob.c @@ -19,9 +19,11 @@ #include "scr_runtime.h" #include +#include #include #include #include +#include /* ── arena ───────────────────────────────────────────────────────────── */ @@ -943,6 +945,7 @@ struct Ast { typedef struct { Arena *a; bool nocase; + bool magic_only; /* nocaseMagicOnly */ } Ctx; static void partv_push(Arena *a, PartV *pv, Part p) { @@ -1601,6 +1604,19 @@ static bool throw_too_long(void) { return false; } +/* glob.toUpperCase() !== glob.toLowerCase(): the segment holds a cased + * letter (ASCII, Latin-1, Greek and Cyrillic, the scripts with case that + * file names commonly use). */ +static bool s_has_case(S s) { + for (size_t i = 0; i < s.n; i++) { + uint16_t c = s.p[i]; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) return true; + if (c >= 0xc0 && c <= 0x24f && c != 0xd7 && c != 0xf7) return true; + if (c >= 0x370 && c <= 0x52f) return true; + } + return false; +} + /* Minimatch.parse for one segment. False after arming an exception. */ static bool parse_segment(Ctx *x, S pattern, Pat *out) { memset(out, 0, sizeof *out); @@ -1621,7 +1637,10 @@ static bool parse_segment(Ctx *x, S pattern, Pat *out) { ast_parse(x, pattern, ast, 0, 0); ast_to_string(x, ast); Source src = ast_to_regexp_source(x, ast, -1); - bool any_magic = src.has_magic || ast->has_magic == 1; + /* AST#toMMPattern: under nocase without nocaseMagicOnly, a segment whose + * upper and lower case differ is matched by a case-insensitive regex. */ + bool any_magic = src.has_magic || ast->has_magic == 1 || + (x->nocase && !x->magic_only && s_has_case(pattern)); if (!any_magic) { out->kind = PAT_STRING; out->str = src.body; @@ -2022,27 +2041,39 @@ static bool match_pattern(Ctx *x, SV *ff, PatSlice pattern, bool windows) { return match_one_plain(x, &file, pattern, fi, pi); } -/* The whole minimatch(path, pattern, options) call. False with an armed - * exception on failure (*threw set). */ -static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, bool *threw) { - *threw = false; - if (pattern.n > 65536) { - *threw = true; - return throw_too_long(); - } - Ctx x = { a, nocase }; +/* A compiled Minimatch: the pattern set (one segment list per brace + * expansion after the optimization phases) and the matching globParts + * strings, which fs.glob's walker uses for its cache keys. */ +typedef struct { + Ctx x; + bool windows; + bool empty; /* the empty pattern: matches only the empty path */ + size_t n; + PatV *set; + SV *parts; +} Matcher; + +/* new Minimatch(pattern, options): false with an armed exception on + * failure. magic_only is nocaseMagicOnly (literal segments stay exact + * strings under nocase). Everything lives in the arena. */ +static bool glob_compile(Arena *a, S pattern, bool windows, bool nocase, bool magic_only, Matcher *m) { + memset(m, 0, sizeof *m); + m->x = (Ctx){ a, nocase, magic_only }; + m->windows = windows; + if (pattern.n > 65536) return throw_too_long(); + Ctx x = m->x; /* windowsPathsNoEscape: backslashes are separators, never escapes. */ B nb; b_init(&nb, a); for (size_t i = 0; i < pattern.n; i++) b_unit(&nb, pattern.p[i] == '\\' ? '/' : pattern.p[i]); S pat = b_done(&nb); - if (!pat.n) return path.n == 0; + if (!pat.n) { + m->empty = true; + return true; + } bool failed = false; SV expanded = brace_expand(a, pat, &failed); - if (failed) { - *threw = true; - return false; - } + if (failed) return false; /* [...new Set(...)] */ SV glob_set = { NULL, 0, 0 }; for (size_t i = 0; i < expanded.n; i++) { @@ -2099,7 +2130,6 @@ static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, b p.kind = PAT_STRING; p.str = s->v[k]; } else if (!parse_segment(&x, s->v[k], &p)) { - *threw = true; return false; } patv_push(a, &pv, p); @@ -2118,14 +2148,25 @@ static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, b } } } - /* match(f) */ + m->n = glob_parts.n; + m->set = set; + m->parts = glob_parts.v; + return true; +} + +/* Minimatch#match(path): false with an armed exception on failure + * (*threw set). Scratch allocations go to the matcher's arena. */ +static bool glob_test(Matcher *m, S path, bool *threw) { + *threw = false; + if (m->empty) return path.n == 0; + Arena *a = m->x.a; B fb; b_init(&fb, a); - for (size_t i = 0; i < path.n; i++) b_unit(&fb, windows && path.p[i] == '\\' ? '/' : path.p[i]); - SV ff = slash_split(a, b_done(&fb), windows); - for (size_t g = 0; g < glob_parts.n; g++) { - PatSlice pattern = { set[g].v, set[g].n }; - if (match_pattern(&x, &ff, pattern, windows)) return true; + for (size_t i = 0; i < path.n; i++) b_unit(&fb, m->windows && path.p[i] == '\\' ? '/' : path.p[i]); + SV ff = slash_split(a, b_done(&fb), m->windows); + for (size_t g = 0; g < m->n; g++) { + PatSlice pattern = { m->set[g].v, m->set[g].n }; + if (match_pattern(&m->x, &ff, pattern, m->windows)) return true; if (scr_exc_pending()) { *threw = true; return false; @@ -2134,6 +2175,17 @@ static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, b return false; } +/* The whole minimatch(path, pattern, options) call. False with an armed + * exception on failure (*threw set). */ +static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, bool *threw) { + Matcher m; + if (!glob_compile(a, pattern, windows, nocase, true, &m)) { + *threw = true; + return false; + } + return glob_test(&m, path, threw); +} + /* ── entry points ────────────────────────────────────────────────────── */ static bool scr_glob_host_nocase(void) { @@ -2161,3 +2213,1016 @@ bool scr_path_matches_glob(ScrStr *path, ScrStr *pattern) { bool scr_path_win32_matches_glob(ScrStr *path, ScrStr *pattern) { return scr_glob_match(path, pattern, true, scr_glob_host_nocase()); } +/* ── fs.glob / fs.globSync / fs/promises glob ──────────────────────────── + * A port of Node 24.22's lib/internal/fs/glob.js Glob class: the same + * walk over a LIFO queue of (path, patterns) items, the same caches + * (lstat, stat, readdir, realpath, seen pattern suffixes), the same result + * set in insertion order, and the same exclusion checks. readdir listings + * come from scr_fs_scandir, which sorts them like libuv, so results come + * out in Node's order. All strings the walk creates are kept in one pool + * and released together. */ + +#ifdef _WIN32 +#define GW_WINDOWS true +#else +#define GW_WINDOWS false +#endif + +typedef struct { + ScrStr **v; + size_t n, cap; +} GwPool; + +/* One lstat (or readdir entry) result: Node's Dirent kinds (1 file, 2 dir, + * 3 link, 0 other or unknown). */ +typedef struct { + ScrStr *name; + unsigned char kind; +} GwStat; + +typedef struct { + ScrStr *key; + void *val; +} GwSlot; + +/* A string-keyed map that keeps insertion order (JS Map semantics for + * iteration). */ +typedef struct { + GwSlot *slots; + size_t cap, n; + ScrStr **order; + size_t on, ocap; +} GwMap; + +typedef struct { + size_t *v; + size_t n, cap; +} GwIdx; /* a Set of indexes in insertion order */ + +typedef struct { + ScrStr **v; + size_t n; +} GwReal; /* realpaths seen on the way down (followSymlinks) */ + +typedef struct { + const PatV *pat; + const SV *parts; + GwIdx indexes; + GwIdx symlinks; + const GwReal *realpaths; + long last; +} GwPat; + +typedef struct { + GwPat **v; + size_t n, cap; +} GwPats; + +typedef struct { + ScrStr *path; + GwPats pats; +} GwItem; + +struct ScrFsGlob { + size_t n, cap; + ScrStr **paths; + ScrStr **names; + ScrStr **parents; + unsigned char *kinds; +}; + +typedef struct { + Arena a; + GwPool pool; + Ctx ctx; /* the include patterns' case mode, for segment tests */ + ScrStr *root; + bool follow; + bool with_types; + bool async_types; /* fs/promises Dirents carry resolved parent paths */ + ScrDyn *exclude_fn; + Matcher *exclude; + size_t nexclude; + GwPat **patterns; + size_t npatterns; + GwMap stats, follow_stats, readdirs, realpaths, seen, subpatterns; + GwMap result_set; + ScrStr **results; + size_t nresults, capresults; + GwItem *queue; + size_t nqueue, capqueue; +} Gw; + +static void gw_oom(void) { scr_trap("scriptc: out of memory\n"); } + +/* The error an fs/promises glob walk ended with, held until the loop has + * consumed the matches found before it (scr_fsp_glob_error). */ +static SCR_TL ScrCaught *scr_fs_glob_deferred; + +static void *gw_grow(void *v, size_t *cap, size_t need, size_t size) { + if (need <= *cap) return v; + size_t c = *cap ? *cap * 2 : 8; + while (c < need) c *= 2; + void *g = realloc(v, c * size); + if (!g) gw_oom(); + *cap = c; + return g; +} + +static ScrStr *gw_keep(Gw *w, ScrStr *s) { + w->pool.v = gw_grow(w->pool.v, &w->pool.cap, w->pool.n + 1, sizeof *w->pool.v); + w->pool.v[w->pool.n++] = s; + return s; +} + +static ScrStr *gw_cstr(Gw *w, const char *s) { return gw_keep(w, scr_str_new(s, strlen(s))); } + +static bool gw_eq_c(const ScrStr *s, const char *c) { + size_t n = strlen(c); + return s->len == n && memcmp(s->data, c, n) == 0; +} + +/* ── path.join / resolve / dirname / basename / isAbsolute ────────────── */ + +static ScrStr *gw_path2(Gw *w, ScrStr *x, ScrStr *y, bool resolve) { + ScrArr *parts = scr_arr_new(SCR_ELEM_STR, 2); + scr_arr_push_ref(parts, scr_str_retain(x)); + scr_arr_push_ref(parts, scr_str_retain(y)); +#ifdef _WIN32 + ScrStr *out = resolve ? scr_path_win32_resolve(parts) : scr_path_win32_join(parts); +#else + ScrStr *out = resolve ? scr_path_resolve(parts) : scr_path_join(parts); +#endif + scr_arr_release(parts); + return gw_keep(w, out); +} + +static ScrStr *gw_join(Gw *w, ScrStr *x, ScrStr *y) { return gw_path2(w, x, y, false); } + +static ScrStr *gw_resolve(Gw *w, ScrStr *x, ScrStr *y) { return gw_path2(w, x, y, true); } + +static ScrStr *gw_dirname(Gw *w, ScrStr *p) { +#ifdef _WIN32 + return gw_keep(w, scr_path_win32_dirname(p)); +#else + return gw_keep(w, scr_path_dirname(p)); +#endif +} + +static ScrStr *gw_basename(Gw *w, ScrStr *p) { + ScrStr *none = scr_str_new("", 0); +#ifdef _WIN32 + ScrStr *out = scr_path_win32_basename(p, none); +#else + ScrStr *out = scr_path_basename(p, none); +#endif + scr_str_release(none); + return gw_keep(w, out); +} + +static bool gw_is_absolute(ScrStr *p) { +#ifdef _WIN32 + return scr_path_win32_is_absolute(p); +#else + return scr_path_is_absolute(p); +#endif +} + +/* ── maps ─────────────────────────────────────────────────────────────── */ + +static size_t gw_hash(const ScrStr *k) { + size_t h = 1469598103934665603u; + for (size_t i = 0; i < k->len; i++) h = (h ^ (unsigned char)k->data[i]) * 1099511628211u; + return h; +} + +static GwSlot *gw_slot(GwMap *m, const ScrStr *key) { + if (!m->cap) return NULL; + size_t i = gw_hash(key) & (m->cap - 1); + for (;;) { + GwSlot *s = &m->slots[i]; + if (!s->key) return s; + if (s->key->len == key->len && memcmp(s->key->data, key->data, key->len) == 0) return s; + i = (i + 1) & (m->cap - 1); + } +} + +static void **gw_get(GwMap *m, const ScrStr *key) { + GwSlot *s = gw_slot(m, key); + return s && s->key ? &s->val : NULL; +} + +/* Map#set: replaces the value of an existing key, keeping its position. */ +static void gw_put(GwMap *m, ScrStr *key, void *val) { + if ((m->n + 1) * 2 > m->cap) { + size_t cap = m->cap ? m->cap * 2 : 16; + GwSlot *old = m->slots; + size_t oldcap = m->cap; + m->slots = calloc(cap, sizeof *m->slots); + if (!m->slots) gw_oom(); + m->cap = cap; + for (size_t i = 0; i < oldcap; i++) + if (old[i].key) *gw_slot(m, old[i].key) = old[i]; + free(old); + } + GwSlot *s = gw_slot(m, key); + if (!s->key) { + s->key = key; + m->n++; + m->order = gw_grow(m->order, &m->ocap, m->on + 1, sizeof *m->order); + m->order[m->on++] = key; + } + s->val = val; +} + +static void gw_map_clear(GwMap *m) { + if (m->cap) memset(m->slots, 0, m->cap * sizeof *m->slots); + m->n = 0; + m->on = 0; +} + +static void gw_map_free(GwMap *m) { + free(m->slots); + free(m->order); +} + +/* ── index sets and patterns ──────────────────────────────────────────── */ + +static bool gw_idx_has(const GwIdx *s, size_t i) { + for (size_t k = 0; k < s->n; k++) + if (s->v[k] == i) return true; + return false; +} + +static void gw_idx_add(Gw *w, GwIdx *s, size_t i) { + if (gw_idx_has(s, i)) return; + if (s->n == s->cap) { + size_t cap = s->cap ? s->cap * 2 : 4; + size_t *v = ar_alloc(&w->a, cap * sizeof *v); + if (s->n) memcpy(v, s->v, s->n * sizeof *v); + s->v = v; + s->cap = cap; + } + s->v[s->n++] = i; +} + +static GwIdx gw_idx_one(Gw *w, size_t i) { + GwIdx s = { NULL, 0, 0 }; + gw_idx_add(w, &s, i); + return s; +} + +static GwPat *gw_pat_child(Gw *w, const GwPat *p, GwIdx indexes, GwIdx symlinks, const GwReal *realpaths) { + GwPat *c = ar_alloc(&w->a, sizeof *c); + *c = *p; + c->indexes = indexes; + c->symlinks = symlinks; + c->realpaths = realpaths; + return c; +} + +/* pattern.at(i), negative indexes from the end; NULL past either end. */ +static const Pat *gw_at(const GwPat *p, long i) { + long n = (long)p->pat->n; + if (i < 0) i += n; + return i >= 0 && i < n ? &p->pat->v[i] : NULL; +} + +static bool gw_is_globstar(const Pat *p) { return p && p->kind == PAT_GLOBSTAR; } +static bool gw_is_string(const Pat *p) { return p && p->kind == PAT_STRING; } +static bool gw_is_str(const Pat *p, const char *c) { return gw_is_string(p) && s_eq_c(p->str, c); } + +static bool gw_has_seen_symlinks(const GwPat *p) { + for (size_t k = 0; k < p->indexes.n; k++) + if (!gw_idx_has(&p->symlinks, p->indexes.v[k])) return true; + return false; +} + +static bool gw_is_last(const GwPat *p, bool is_directory) { + return gw_idx_has(&p->indexes, (size_t)p->last) || + (gw_is_str(gw_at(p, -1), "") && is_directory && p->last >= 1 && + gw_idx_has(&p->indexes, (size_t)(p->last - 1)) && gw_is_globstar(gw_at(p, -2))); +} + +/* Pattern#test(index, name). */ +static bool gw_test(Gw *w, const GwPat *p, size_t index, ScrStr *name) { + const Pat *seg = gw_at(p, (long)index); + if (!seg) return false; + if (seg->kind == PAT_GLOBSTAR) return true; + S f = s_from_str(&w->a, name); + if (seg->kind == PAT_STRING) return s_eq(seg->str, f); + return pat_test(&w->ctx, seg, f); +} + +/* Pattern#cacheKey(index): the glob strings from index on, '/'-joined. */ +static ScrStr *gw_cache_key(Gw *w, const GwPat *p, size_t index) { + B b; + b_init(&b, &w->a); + for (size_t i = index; i < p->parts->n; i++) { + b_s(&b, p->parts->v[i]); + if (i != p->parts->n - 1) b_unit(&b, '/'); + } + return gw_keep(w, s_to_str(b_done(&b))); +} + +/* ── the caches ───────────────────────────────────────────────────────── */ + +static unsigned char gw_kind_of_mode(unsigned mode) { + if (S_ISREG(mode)) return 1; + if (S_ISDIR(mode)) return 2; +#ifdef S_ISLNK + if (S_ISLNK(mode)) return 3; +#endif + return 0; +} + +/* Cache#statSync: the cached entry, else lstat (NULL when it fails). */ +static GwStat *gw_stat(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->stats, path); + if (hit && *hit) return *hit; + struct stat st; +#ifdef _WIN32 + int rc = stat(path->data, &st); +#else + int rc = lstat(path->data, &st); +#endif + GwStat *s = NULL; + if (rc == 0) { + s = ar_alloc(&w->a, sizeof *s); + s->name = gw_basename(w, path); + s->kind = gw_kind_of_mode((unsigned)st.st_mode); + } + gw_put(&w->stats, path, s); + return s; +} + +/* Cache#followStatSync: stat(2) through links; NULL when it fails. */ +static bool gw_follow_is_dir(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->follow_stats, path); + if (hit) return *hit != NULL; + struct stat st; + bool dir = stat(path->data, &st) == 0 && S_ISDIR(st.st_mode); + gw_put(&w->follow_stats, path, dir ? (void *)1 : NULL); + return dir; +} + +static ScrStr *gw_realpath(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->realpaths, path); + if (hit) return *hit; + ScrStr *real = scr_fs_realpath(path); + if (!real) scr_exc_clear(); + else gw_keep(w, real); + gw_put(&w->realpaths, path, real); + return real; +} + +typedef struct { + GwStat *v; + size_t n; +} GwDir; + +/* Cache#readdirSync: the sorted listing, or [] when it cannot be read. */ +static GwDir *gw_readdir(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->readdirs, path); + if (hit) return *hit; + GwDir *d = ar_alloc(&w->a, sizeof *d); + d->v = NULL; + d->n = 0; + ScrScandir *scan = scr_fs_scandir(path); + if (!scan) { + scr_exc_clear(); + } else { + d->n = scr_fs_scandir_count(scan); + d->v = ar_alloc(&w->a, (d->n ? d->n : 1) * sizeof *d->v); + for (size_t i = 0; i < d->n; i++) { + d->v[i].name = gw_keep(w, scr_fs_scandir_name(scan, i)); + d->v[i].kind = (unsigned char)scr_fs_scandir_type(scan, i); + } + scr_fs_scandir_free(scan); + } + gw_put(&w->readdirs, path, d); + return d; +} + +/* Cache#add: records the pattern's suffixes at path; true when some were + * already recorded. */ +static bool gw_seen_add(Gw *w, ScrStr *path, const GwPat *p) { + void **hit = gw_get(&w->seen, path); + GwMap *set; + if (hit) { + set = *hit; + } else { + set = ar_alloc(&w->a, sizeof *set); + memset(set, 0, sizeof *set); + gw_put(&w->seen, path, set); + } + size_t before = set->n; + for (size_t k = 0; k < p->indexes.n; k++) gw_put(set, gw_cache_key(w, p, p->indexes.v[k]), (void *)1); + return set->n != before + p->indexes.n; +} + +static bool gw_seen(Gw *w, ScrStr *path, const GwPat *p, size_t index) { + void **hit = gw_get(&w->seen, path); + return hit && gw_get(*hit, gw_cache_key(w, p, index)) != NULL; +} + +/* ── exclusion and results ────────────────────────────────────────────── */ + +static bool gw_is_excluded(Gw *w, ScrStr *value) { + if (!w->nexclude) return false; + S f = s_from_str(&w->a, value); + for (size_t i = 0; i < w->nexclude; i++) { + bool threw; + if (glob_test(&w->exclude[i], f, &threw)) return true; + if (threw) return false; + } + return false; +} + +/* The exclude callback on a path or entry name; false when it threw (the + * exception stays pending and ends the walk). */ +static bool gw_exclude_call(Gw *w, ScrStr *value) { + ScrDyn *arg = scr_dyn_new_str(value); + ScrDyn *out = scr_dyn_call(w->exclude_fn, &arg, 1, "options.exclude"); + scr_dyn_release(arg); + if (!out) return false; + bool yes = scr_dyn_truthy(out); + scr_dyn_release(out); + return yes; +} + +/* ResultSet#add. */ +static void gw_result(Gw *w, ScrStr *value) { + if (gw_is_excluded(w, gw_resolve(w, w->root, value))) return; + if (gw_get(&w->result_set, value)) return; + gw_put(&w->result_set, value, (void *)1); + w->results = gw_grow(w->results, &w->capresults, w->nresults + 1, sizeof *w->results); + w->results[w->nresults++] = value; +} + +/* ── the walk ─────────────────────────────────────────────────────────── */ + +static void gw_pats_push(GwPats *l, GwPat *p) { + l->v = gw_grow(l->v, &l->cap, l->n + 1, sizeof *l->v); + l->v[l->n++] = p; +} + +/* #subpatterns.set(path, [pattern]) when replace, else push. */ +static void gw_sub(Gw *w, ScrStr *path, GwPat *p, bool replace) { + void **hit = gw_get(&w->subpatterns, path); + if (hit) { + GwPats *l = *hit; + if (replace) l->n = 0; + gw_pats_push(l, p); + return; + } + GwPats *l = ar_alloc(&w->a, sizeof *l); + memset(l, 0, sizeof *l); + gw_pats_push(l, p); + gw_put(&w->subpatterns, path, l); +} + +/* #addSubpattern. */ +static void gw_add_subpattern(Gw *w, ScrStr *path, GwPat *p) { + if (gw_is_excluded(w, path)) return; + ScrStr *fullpath = gw_resolve(w, w->root, path); + if (w->nexclude) { + ScrStr *slash = gw_keep(w, scr_str_concat(fullpath, gw_cstr(w, "/"))); + GwStat *st = gw_is_excluded(w, slash) ? gw_stat(w, fullpath) : NULL; + if (st && st->kind == 2) return; + } + if (w->exclude_fn) { + bool skip = gw_exclude_call(w, path); + if (scr_exc_pending() || skip) return; + } + gw_sub(w, path, p, false); +} + +static bool gw_is_directory(Gw *w, ScrStr *path, const GwStat *st, const GwPat *p) { + if (st && st->kind == 2) return true; + if (!st || st->kind != 3) return false; + if (w->follow) return gw_follow_is_dir(w, path); + return gw_has_seen_symlinks(p); +} + +static const GwReal *gw_next_realpaths(Gw *w, ScrStr *path, bool is_directory, const GwPat *p) { + if (!w->follow || !is_directory) return p->realpaths; + ScrStr *real = gw_realpath(w, path); + if (!real) return p->realpaths; + GwReal *next = ar_alloc(&w->a, sizeof *next); + next->n = p->realpaths->n + 1; + next->v = ar_alloc(&w->a, next->n * sizeof *next->v); + if (p->realpaths->n) memcpy(next->v, p->realpaths->v, p->realpaths->n * sizeof *next->v); + next->v[next->n - 1] = real; + return next; +} + +static bool gw_is_cyclic(Gw *w, ScrStr *path, bool is_directory, const GwPat *p) { + if (!w->follow || !is_directory) return false; + ScrStr *real = gw_realpath(w, path); + if (!real) return false; + for (size_t i = 0; i < p->realpaths->n; i++) + if (scr_str_eq(p->realpaths->v[i], real)) return true; + return false; +} + +static ScrStr *gw_str_of(Gw *w, S s) { return gw_keep(w, s_to_str(s)); } + +/* #addSubpatterns. */ +static void gw_add_subpatterns(Gw *w, ScrStr *path, GwPat *p) { + if (gw_seen_add(w, path, p)) return; + ScrStr *fullpath = gw_resolve(w, w->root, path); + GwStat *st = gw_stat(w, fullpath); + long last = p->last; + bool is_directory = gw_is_directory(w, fullpath, st, p); + bool is_last = gw_is_last(p, is_directory); + bool is_first = gw_idx_has(&p->indexes, 0); + if (gw_is_excluded(w, fullpath)) return; +#ifdef _WIN32 + if (is_first && gw_is_string(gw_at(p, 0)) && gw_at(p, 0)->str.n && + gw_at(p, 0)->str.p[gw_at(p, 0)->str.n - 1] == ':') { + ScrStr *drive = gw_str_of(w, gw_at(p, 0)->str); + gw_add_subpattern(w, gw_keep(w, scr_str_concat(drive, gw_cstr(w, "\\"))), + gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } +#endif + if (is_first && gw_is_str(gw_at(p, 0), "")) { + gw_add_subpattern(w, gw_cstr(w, "/"), gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } + if (is_first && gw_is_str(gw_at(p, 0), "..")) { + gw_add_subpattern(w, gw_cstr(w, "../"), gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } + if (is_first && gw_is_str(gw_at(p, 0), ".")) { + gw_add_subpattern(w, gw_cstr(w, "."), gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } + + if (is_last && gw_is_string(gw_at(p, -1))) { + ScrStr *tail = gw_str_of(w, gw_at(p, -1)->str); + GwStat *found = gw_stat(w, gw_join(w, fullpath, tail)); + if (found && (tail->len || is_directory)) gw_result(w, gw_join(w, path, tail)); + if (p->indexes.n == 1 && gw_idx_has(&p->indexes, (size_t)last)) return; + } else if (is_last && gw_is_globstar(gw_at(p, -1)) && + (!gw_eq_c(path, ".") || gw_is_str(gw_at(p, 0), ".") || (last == 0 && st))) { + gw_result(w, path); + } + if (scr_exc_pending()) return; + + if (!is_directory || gw_is_cyclic(w, fullpath, is_directory, p)) return; + const GwReal *next_realpaths = gw_next_realpaths(w, fullpath, is_directory, p); + + GwStat one; + GwDir single = { &one, 1 }; + GwDir *children; + const Pat *first = p->indexes.n == 1 ? gw_at(p, (long)p->indexes.v[0]) : NULL; + if (gw_is_string(first)) { + ScrStr *name = gw_str_of(w, first->str); + GwStat *found = gw_stat(w, gw_join(w, fullpath, name)); + if (!found) return; + one.name = name; + one.kind = found->kind; + children = &single; + } else { + children = gw_readdir(w, fullpath); + } + + for (size_t i = 0; i < children->n; i++) { + GwStat *entry = &children->v[i]; + ScrStr *entry_path = gw_join(w, path, entry->name); + ScrStr *entry_full = gw_join(w, fullpath, entry->name); + GwStat *cached = ar_alloc(&w->a, sizeof *cached); + *cached = *entry; + gw_put(&w->stats, entry_full, cached); + bool entry_dir = entry->kind == 2 || (w->follow && entry->kind == 3 && gw_follow_is_dir(w, entry_full)); + + GwIdx sub = { NULL, 0, 0 }; + GwIdx nsym = { NULL, 0, 0 }; + for (size_t k = 0; k < p->indexes.n; k++) { + size_t index = p->indexes.v[k]; + const Pat *current = gw_at(p, (long)index); + size_t next_index = index + 1; + const Pat *next = gw_at(p, (long)next_index); + bool from_symlink = !w->follow && gw_idx_has(&p->symlinks, index); + + if (gw_is_globstar(current)) { + bool is_dot = entry->name->len && entry->name->data[0] == '.'; + bool next_matches = gw_test(w, p, next_index, entry->name); + size_t next_non_glob = next_index; + while (gw_is_globstar(gw_at(p, (long)next_non_glob))) next_non_glob++; + bool matches_dot = is_dot && gw_test(w, p, next_non_glob, entry->name); + if (is_dot && !matches_dot) continue; + if (w->exclude_fn) { + bool skip = gw_exclude_call(w, entry->name); + if (scr_exc_pending()) return; + if (skip) continue; + } + if (!from_symlink && entry_dir) gw_idx_add(w, &sub, index); + else if (!from_symlink && (long)index == last) gw_result(w, entry_path); + if (next_matches && (long)next_index == last && !is_last) gw_result(w, entry_path); + else if (next_matches && entry_dir) gw_idx_add(w, &sub, index + 2); + if ((next_matches || gw_is_str(gw_at(p, 0), ".")) && entry_dir && !from_symlink) gw_idx_add(w, &sub, next_index); + if (!w->follow && entry->kind == 3) gw_idx_add(w, &nsym, index); + if (gw_is_str(next, "..") && entry_dir) { + ScrStr *parent = gw_join(w, path, gw_cstr(w, "..")); + if ((long)next_index < last) { + if (!gw_get(&w->subpatterns, path) && !gw_seen(w, path, p, next_index + 1)) + gw_sub(w, path, gw_pat_child(w, p, gw_idx_one(w, next_index + 1), (GwIdx){ 0 }, p->realpaths), true); + if (!gw_get(&w->subpatterns, parent) && !gw_seen(w, parent, p, next_index + 1)) + gw_sub(w, parent, gw_pat_child(w, p, gw_idx_one(w, next_index + 1), (GwIdx){ 0 }, p->realpaths), true); + } else { + if (!gw_seen(w, path, p, next_index)) { + gw_seen_add(w, path, gw_pat_child(w, p, gw_idx_one(w, next_index), (GwIdx){ 0 }, p->realpaths)); + gw_result(w, path); + } + if (!gw_seen(w, path, p, next_index) || !gw_seen(w, parent, p, next_index)) { + gw_seen_add(w, parent, gw_pat_child(w, p, gw_idx_one(w, next_index), (GwIdx){ 0 }, p->realpaths)); + gw_result(w, parent); + } + } + } + } + if (gw_is_string(current)) { + if (gw_test(w, p, index, entry->name) && (long)index != last) { + gw_idx_add(w, &sub, next_index); + } else if (gw_is_str(current, ".") && gw_test(w, p, next_index, entry->name)) { + if ((long)next_index == last) gw_result(w, entry_path); + else gw_idx_add(w, &sub, next_index + 1); + } + } + if (current && current->kind == PAT_REGEX && gw_test(w, p, index, entry->name)) { + if ((long)index == last) gw_result(w, entry_path); + else if (entry_dir) gw_idx_add(w, &sub, next_index); + } + if (scr_exc_pending()) return; + } + if (sub.n > 0) gw_add_subpattern(w, entry_path, gw_pat_child(w, p, sub, nsym, next_realpaths)); + if (scr_exc_pending()) return; + } +} + +static void gw_queue_push(Gw *w, ScrStr *path, GwPats pats) { + w->queue = gw_grow(w->queue, &w->capqueue, w->nqueue + 1, sizeof *w->queue); + w->queue[w->nqueue++] = (GwItem){ path, pats }; +} + +/* Glob#globSync's loop. */ +static void gw_run(Gw *w) { + GwPats all = { NULL, 0, 0 }; + for (size_t i = 0; i < w->npatterns; i++) gw_pats_push(&all, w->patterns[i]); + gw_queue_push(w, gw_cstr(w, "."), all); + while (w->nqueue && !scr_exc_pending()) { + GwItem item = w->queue[--w->nqueue]; + for (size_t i = 0; i < item.pats.n && !scr_exc_pending(); i++) gw_add_subpatterns(w, item.path, item.pats.v[i]); + free(item.pats.v); + for (size_t i = 0; i < w->subpatterns.on; i++) { + ScrStr *key = w->subpatterns.order[i]; + GwPats *l = *gw_get(&w->subpatterns, key); + gw_queue_push(w, key, *l); /* the queue item owns the list now */ + } + gw_map_clear(&w->subpatterns); + } + for (size_t i = 0; i < w->nqueue; i++) free(w->queue[i].pats.v); + w->nqueue = 0; +} + +/* ── options, patterns and the entry points ───────────────────────────── */ + +static bool gw_dyn_absent(const ScrDyn *d) { return !d || d->kind == SCR_DYN_UNDEF; } +static bool gw_dyn_nullish(const ScrDyn *d) { return gw_dyn_absent(d) || d->kind == SCR_DYN_NULL; } + +static bool gw_dyn_is_array(const ScrDyn *d) { + return d->kind == SCR_DYN_ARR || (d->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(d)); +} + +/* The value as a plain dyn array (+1), materializing typed arrays. */ +static ScrDyn *gw_dyn_array(const ScrDyn *d) { + if (d->kind == SCR_DYN_ARR) return scr_dyn_retain((ScrDyn *)d); + return scr_dyn_typed_ref_materialize(d); +} + +static ScrDyn *gw_option(const ScrDyn *options, const char *key) { + if (gw_dyn_nullish(options)) return scr_dyn_retain(scr_dyn_undefined()); + if (options->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(options); + ScrDyn *value = view ? scr_dyn_obj_read(view, key, strlen(key)) : NULL; + scr_dyn_release(view); + return value; + } + return scr_dyn_obj_read(options, key, strlen(key)); +} + +/* validateObject(options, 'options') with Node's default flags. */ +static bool gw_options_ok(const ScrDyn *options) { + if (gw_dyn_absent(options)) return true; + bool object = options->kind == SCR_DYN_OBJ || options->kind == SCR_DYN_HANDLE || + options->kind == SCR_DYN_BYTES || options->kind == SCR_DYN_PROXY || + (options->kind == SCR_DYN_TYPED_REF && !scr_dyn_ref_is_array(options)); + if (object) return true; + scr_dyn_arg_type_fail("options", "of type object", options); + return false; +} + +/* The maps are keyed by pool strings, so they are freed before the pool. */ +static void gw_free(Gw *w) { + GwMap *maps[] = { &w->stats, &w->follow_stats, &w->readdirs, &w->realpaths, &w->seen, &w->subpatterns, &w->result_set }; + for (size_t i = 0; i < w->seen.on; i++) { + GwMap *set = *gw_get(&w->seen, w->seen.order[i]); + gw_map_free(set); + } + for (size_t i = 0; i < sizeof maps / sizeof *maps; i++) gw_map_free(maps[i]); + for (size_t i = 0; i < w->pool.n; i++) scr_str_release(w->pool.v[i]); + free(w->pool.v); + free(w->results); + free(w->queue); + scr_dyn_release(w->exclude_fn); + ar_free(&w->a); +} + +/* new Glob(pattern, options): Node's option reads (exclude, cwd, + * followSymlinks, withFileTypes), validations and matcher compilation, in + * Node's order. False with the exception pending. */ +static bool gw_begin(Gw *w, const ScrDyn *pattern, const ScrDyn *options, ScrDyn **cwd_out) { + if (!gw_options_ok(options)) return false; + ScrDyn *exclude = gw_option(options, "exclude"); + if (!exclude) return false; + ScrDyn *cwd = gw_option(options, "cwd"); + ScrDyn *follow = cwd ? gw_option(options, "followSymlinks") : NULL; + ScrDyn *types = follow ? gw_option(options, "withFileTypes") : NULL; + bool ok = types != NULL; + /* toPathIfFileURL(cwd) ?? '.' */ + if (ok) { + if (gw_dyn_nullish(cwd)) { + w->root = gw_cstr(w, "."); + } else if (scr_dyn_native_url_is(cwd)) { + ScrStr *path = scr_url_to_path(cwd->v.handle.ptr); + ok = path != NULL; + if (ok) w->root = gw_keep(w, path); + } else if (cwd->kind == SCR_DYN_STR) { + w->root = gw_keep(w, scr_str_retain(cwd->v.str)); + } else { + *cwd_out = scr_dyn_retain(cwd); /* resolve() rejects it when the walk starts */ + } + } + if (ok && !gw_dyn_nullish(follow)) { + if (follow->kind != SCR_DYN_BOOL) { + scr_dyn_prop_type_fail("options.followSymlinks", "of type boolean", follow); + ok = false; + } else { + w->follow = follow->v.b; + } + } + if (ok) w->with_types = scr_dyn_truthy(types); + if (ok && !gw_dyn_nullish(exclude)) { + if (gw_dyn_is_array(exclude)) { + ScrDyn *list = gw_dyn_array(exclude); + ok = list != NULL; + for (size_t i = 0; ok && i < list->v.arr.len; i++) { + const ScrDyn *item = scr_dyn_arr_has_index(list, i) ? list->v.arr.items[i] : scr_dyn_undefined(); + if (item->kind != SCR_DYN_STR) { + char name[48]; + snprintf(name, sizeof name, "options.exclude[%zu]", i); + scr_dyn_prop_type_fail(name, "of type string", item); + ok = false; + } + } + if (ok && !w->root) { + /* The constructor's assert(typeof this.#root === 'string'). */ + static const char message[] = + "This is caused by either a bug in Node.js or incorrect usage of Node.js internals.\n" + "Please open an issue with this stack trace at https://github.com/nodejs/node/issues\n"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "ERR_INTERNAL_ASSERTION"); + ok = false; + } + if (ok) { + w->nexclude = list->v.arr.len; + w->exclude = ar_alloc(&w->a, (w->nexclude ? w->nexclude : 1) * sizeof *w->exclude); + for (size_t i = 0; ok && i < w->nexclude; i++) { + ScrStr *abs = gw_resolve(w, w->root, list->v.arr.items[i]->v.str); + ok = glob_compile(&w->a, s_from_str(&w->a, abs), GW_WINDOWS, scr_glob_host_nocase(), false, &w->exclude[i]); + } + } + scr_dyn_release(list); + } else if (exclude->kind == SCR_DYN_FUNC) { + w->exclude_fn = scr_dyn_retain(exclude); + } else { + scr_dyn_prop_type_fail("options.exclude", "of type function or string[]", exclude); + ok = false; + } + } + /* patterns: a string, or (typeof 'object') a string array. */ + ScrDyn *list = NULL; + if (ok) { + if (pattern->kind == SCR_DYN_STR) { + list = scr_dyn_new_arr(); + scr_dyn_arr_push(list, scr_dyn_retain((ScrDyn *)pattern)); + } else if (pattern->kind == SCR_DYN_NULL || pattern->kind == SCR_DYN_OBJ || pattern->kind == SCR_DYN_HANDLE || + pattern->kind == SCR_DYN_BYTES || pattern->kind == SCR_DYN_TYPED_REF || pattern->kind == SCR_DYN_ARR) { + if (!gw_dyn_is_array(pattern)) { + scr_dyn_arg_type_fail("patterns", "an instance of Array", pattern); + ok = false; + } else { + list = gw_dyn_array(pattern); + ok = list != NULL; + for (size_t i = 0; ok && i < list->v.arr.len; i++) { + const ScrDyn *item = scr_dyn_arr_has_index(list, i) ? list->v.arr.items[i] : scr_dyn_undefined(); + if (item->kind != SCR_DYN_STR) { + char name[32]; + snprintf(name, sizeof name, "patterns[%zu]", i); + scr_dyn_arg_type_fail(name, "of type string", item); + ok = false; + } + } + } + } else { + scr_dyn_arg_type_fail("patterns", "of type string", pattern); + ok = false; + } + } + w->ctx = (Ctx){ &w->a, scr_glob_host_nocase(), true }; + for (size_t i = 0; ok && i < list->v.arr.len; i++) { + Matcher *m = ar_alloc(&w->a, sizeof *m); + ok = glob_compile(&w->a, s_from_str(&w->a, list->v.arr.items[i]->v.str), GW_WINDOWS, scr_glob_host_nocase(), true, m); + if (!ok || m->empty) continue; + GwPat **grown = ar_alloc(&w->a, (w->npatterns + m->n) * sizeof *grown); + if (w->npatterns) memcpy(grown, w->patterns, w->npatterns * sizeof *grown); + w->patterns = grown; + for (size_t g = 0; g < m->n; g++) { + GwPat *p = ar_alloc(&w->a, sizeof *p); + memset(p, 0, sizeof *p); + p->pat = &m->set[g]; + p->parts = &m->parts[g]; + p->indexes = gw_idx_one(w, 0); + GwReal *none = ar_alloc(&w->a, sizeof *none); + none->v = NULL; + none->n = 0; + p->realpaths = none; + p->last = (long)m->set[g].n - 1; + w->patterns[w->npatterns++] = p; + } + } + scr_dyn_release(list); + scr_dyn_release(exclude); + scr_dyn_release(cwd); + scr_dyn_release(follow); + scr_dyn_release(types); + return ok && !scr_exc_pending(); +} + +/* The walk and its result snapshot. mode bit 0: Dirent rows; bit 1: the + * fs/promises form (Dirents carry resolved parent paths). */ +static ScrFsGlob *gw_snapshot(Gw *w, bool types, bool resolved_parents) { + ScrFsGlob *g = calloc(1, sizeof *g); + if (!g) gw_oom(); + g->n = w->nresults; + g->paths = malloc((g->n ? g->n : 1) * sizeof *g->paths); + if (!g->paths) gw_oom(); + if (types) { + g->names = malloc((g->n ? g->n : 1) * sizeof *g->names); + g->parents = malloc((g->n ? g->n : 1) * sizeof *g->parents); + g->kinds = malloc(g->n ? g->n : 1); + if (!g->names || !g->parents || !g->kinds) gw_oom(); + } + for (size_t i = 0; i < g->n; i++) { + ScrStr *path = w->results[i]; + g->paths[i] = scr_str_retain(path); + if (!types) continue; + ScrStr *full = resolved_parents ? gw_resolve(w, w->root, path) + : gw_is_absolute(path) ? path + : gw_join(w, w->root, path); + GwStat *st = gw_stat(w, full); + g->names[i] = scr_str_retain(gw_basename(w, full)); + g->parents[i] = scr_str_retain(gw_dirname(w, full)); + g->kinds[i] = st ? st->kind : 0; + } + return g; +} + +ScrFsGlob *scr_fs_glob(const ScrDyn *pattern, const ScrDyn *options, double mode) { + Gw w; + memset(&w, 0, sizeof w); + ScrDyn *bad_cwd = NULL; + bool types = ((unsigned)mode & 1) != 0; + ScrFsGlob *out = NULL; + if (gw_begin(&w, pattern, options, &bad_cwd)) { + if (w.with_types != types) { + static const char message[] = "fs.glob withFileTypes must be a literal true or false that matches the result type"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + } else if (!w.root) { + scr_dyn_arg_type_fail("paths[0]", "of type string", bad_cwd); + } else if (types && w.exclude_fn) { + static const char message[] = "fs.glob exclude functions with withFileTypes have no native lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + } else { + gw_run(&w); + bool async_form = ((unsigned)mode & 2) != 0; + if (scr_exc_pending() && async_form) { + /* The async iterator yields the matches found before the error, + * then throws it (scr_fsp_glob_error hands it to the loop). */ + scr_caught_release(scr_fs_glob_deferred); + scr_fs_glob_deferred = scr_exc_take(); + } + if (!scr_exc_pending()) out = gw_snapshot(&w, types, async_form); + } + } + scr_dyn_release(bad_cwd); + gw_free(&w); + return out; +} + +ScrArr *scr_fsp_glob_strings(const ScrDyn *pattern, const ScrDyn *options) { + ScrFsGlob *g = scr_fs_glob(pattern, options, 2); + if (!g) return NULL; + ScrArr *arr = scr_arr_new(SCR_ELEM_STR, g->n); + for (size_t i = 0; i < g->n; i++) scr_arr_push_ref(arr, scr_str_retain(g->paths[i])); + scr_fs_glob_free(g); + return arr; +} + +ScrDyn *scr_fsp_glob_error(void) { + if (!scr_fs_glob_deferred) return scr_dyn_retain(scr_dyn_undefined()); + ScrDyn *error = scr_caught_to_dyn(scr_fs_glob_deferred); + scr_caught_release(scr_fs_glob_deferred); + scr_fs_glob_deferred = NULL; + return error; +} + +size_t scr_fs_glob_count(const ScrFsGlob *g) { return g ? g->n : 0; } +ScrStr *scr_fs_glob_path(const ScrFsGlob *g, size_t i) { return scr_str_retain(g->paths[i]); } +ScrStr *scr_fs_glob_name(const ScrFsGlob *g, size_t i) { return scr_str_retain(g->names[i]); } +ScrStr *scr_fs_glob_parent(const ScrFsGlob *g, size_t i) { return scr_str_retain(g->parents[i]); } +double scr_fs_glob_type(const ScrFsGlob *g, size_t i) { return (double)g->kinds[i]; } + +void scr_fs_glob_free(ScrFsGlob *g) { + if (!g) return; + for (size_t i = 0; i < g->n; i++) { + scr_str_release(g->paths[i]); + if (g->names) { + scr_str_release(g->names[i]); + scr_str_release(g->parents[i]); + } + } + free(g->paths); + free(g->names); + free(g->parents); + free(g->kinds); + free(g); +} + +/* globSync(pattern, options) and the fs/promises string form: string[]. */ +ScrArr *scr_fs_glob_strings(const ScrDyn *pattern, const ScrDyn *options) { + ScrFsGlob *g = scr_fs_glob(pattern, options, 0); + if (!g) return NULL; + ScrArr *arr = scr_arr_new(SCR_ELEM_STR, g->n); + for (size_t i = 0; i < g->n; i++) scr_arr_push_ref(arr, scr_str_retain(g->paths[i])); + scr_fs_glob_free(g); + return arr; +} + +/* fs.glob(pattern[, options], callback): Node checks the callback, then + * runs the constructor's validation synchronously; the walk's result or + * error reaches the callback later. `args` is the argument vector. */ +void scr_fs_glob_callback(ScrDyn *args) { + size_t argc = args->kind == SCR_DYN_ARR ? args->v.arr.len : 0; + ScrDyn *const *items = argc ? args->v.arr.items : NULL; + const ScrDyn *pattern = argc > 0 ? items[0] : scr_dyn_undefined(); + const ScrDyn *options = argc > 1 ? items[1] : scr_dyn_undefined(); + const ScrDyn *callback = argc > 2 ? items[2] : scr_dyn_undefined(); + if (options->kind == SCR_DYN_FUNC) { + callback = options; + options = scr_dyn_undefined(); + } + if (callback->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("cb", "of type function", callback); + return; + } + Gw w; + memset(&w, 0, sizeof w); + ScrDyn *bad_cwd = NULL; + if (!gw_begin(&w, pattern, options, &bad_cwd)) { + scr_dyn_release(bad_cwd); + gw_free(&w); + return; + } + if (w.with_types) { + static const char message[] = "fs.glob callback Dirent results have no native lowering; use fs.globSync or fs/promises glob"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + scr_dyn_release(bad_cwd); + gw_free(&w); + return; + } + ScrDyn *result = NULL; + if (!w.root) { + scr_dyn_arg_type_fail("paths[0]", "of type string", bad_cwd); + } else { + gw_run(&w); + if (!scr_exc_pending()) { + result = scr_dyn_new_arr(); + for (size_t i = 0; i < w.nresults; i++) scr_dyn_arr_push(result, scr_dyn_new_str(w.results[i])); + } + } + scr_dyn_release(bad_cwd); + gw_free(&w); + scr_fs_cb_schedule(callback, result, NULL); +} diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 16c295b65..e153c18c8 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -101,6 +101,10 @@ ScrStr *scr_fs_read_file_encoded(ScrStr *path, ScrStr *encoding); ScrStr *scr_fs_read_fd_encoded(double fd, ScrStr *encoding); ScrDyn *scr_fs_callback_value(ScrStr *member); ScrDyn *scr_fs_callback_call(ScrStr *member, const ScrDyn *args); +/* The error-first callback queue (scr_bytes_io.c): a pending exception + * becomes [err], otherwise [null, result(, buffer)]; the callback runs on + * a later loop turn. result moves in. */ +void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer); ScrBytes *scr_fs_read_fd_bytes(double fd); /* +1; the fd form (scr_lib.c) */ void scr_fs_write_file_bytes(ScrStr *path, const ScrBytes *data); void scr_fs_append_file_bytes(ScrStr *path, const ScrBytes *data); diff --git a/packages/runtime/src/scr_path.h b/packages/runtime/src/scr_path.h index 7bf45e55c..9600b5e95 100644 --- a/packages/runtime/src/scr_path.h +++ b/packages/runtime/src/scr_path.h @@ -61,5 +61,32 @@ ScrStr *scr_path_win32_format(ScrStr *root, ScrStr *dir, ScrStr *base, ScrStr *n bool scr_path_matches_glob(ScrStr *path, ScrStr *pattern); bool scr_path_win32_matches_glob(ScrStr *path, ScrStr *pattern); bool scr_glob_match(ScrStr *path, ScrStr *pattern, bool windows, bool nocase); +/* fs.globSync / fs.glob / fs/promises glob (scr_glob.c): Node 24's + * lib/internal/fs/glob.js walk. The pattern (a string or string array) and + * options (exclude, cwd, followSymlinks, withFileTypes) arrive as dyn + * values and are validated in Node's order with Node's errors (borrowed). + * scr_fs_glob answers the result snapshot (+1, or NULL with the exception + * pending); mode bit 0 asks for Dirent rows (name, parentPath, UV_DIRENT + * kind) and must agree with withFileTypes, bit 1 is the fs/promises form, + * whose Dirents carry resolved parent paths. strings is the string[] form. + * callback takes fs.glob's argument vector, throws validation errors + * synchronously and delivers the result or the walk's error through the + * filesystem callback queue. */ +typedef struct ScrDyn ScrDyn; /* full definition in scr_dyn.h (C11 repeat) */ +typedef struct ScrFsGlob ScrFsGlob; +ScrFsGlob *scr_fs_glob(const ScrDyn *pattern, const ScrDyn *options, double mode); +size_t scr_fs_glob_count(const ScrFsGlob *g); /* NULL -> 0 */ +ScrStr *scr_fs_glob_path(const ScrFsGlob *g, size_t i); /* +1 */ +ScrStr *scr_fs_glob_name(const ScrFsGlob *g, size_t i); /* +1 */ +ScrStr *scr_fs_glob_parent(const ScrFsGlob *g, size_t i); /* +1 */ +double scr_fs_glob_type(const ScrFsGlob *g, size_t i); +void scr_fs_glob_free(ScrFsGlob *g); /* NULL-tolerant */ +ScrArr *scr_fs_glob_strings(const ScrDyn *pattern, const ScrDyn *options); +void scr_fs_glob_callback(ScrDyn *args); +/* The fs/promises form: a walk error does not throw; the matches found + * before it are returned and scr_fsp_glob_error answers the error (+1 dyn, + * undefined when there is none) once, for the loop to throw after them. */ +ScrArr *scr_fsp_glob_strings(const ScrDyn *pattern, const ScrDyn *options); +ScrDyn *scr_fsp_glob_error(void); #endif /* SCR_PATH_H */ diff --git a/packages/runtime/test/test_glob.c b/packages/runtime/test/test_glob.c index 8c9fa62b0..720cb374f 100644 --- a/packages/runtime/test/test_glob.c +++ b/packages/runtime/test/test_glob.c @@ -15,6 +15,15 @@ long scr_str_live_count(void); /* provided by scr_string.c */ +/* fs.glob's callback form (scr_glob.c) schedules through the filesystem + * callback queue in scr_bytes_io.c, which this binary does not link; the + * oracle never calls it. */ +void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer) { + (void)callback; + (void)buffer; + scr_dyn_release(result); +} + static int hex_val(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; diff --git a/tests/corpus/5448-fs-glob-patterns.ts b/tests/corpus/5448-fs-glob-patterns.ts new file mode 100644 index 000000000..2a3179341 --- /dev/null +++ b/tests/corpus/5448-fs-glob-patterns.ts @@ -0,0 +1,82 @@ +// fs.globSync over a fixed tree: globstars, braces, extglobs, classes, dot +// files, "." and ".." segments, trailing slashes, literal paths, missing +// directories, absolute patterns and pattern arrays. Results come out in +// Node's walk order over sorted directory listings. +import { globSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-")); +for (const dir of ["a/b/c", "src/lib", ".hidden", "node_modules/x", "docs"]) { + mkdirSync(join(root, dir), { recursive: true }); +} +const files = [ + "a/one.ts", + "a/b/two.ts", + "a/b/c/three.js", + "src/index.ts", + "src/lib/util.ts", + "src/lib/util.test.ts", + "README.md", + ".hidden/secret.ts", + ".dot.ts", + "node_modules/x/index.ts", + "Upper.TS", + "docs/guide.md", + "docs/[draft].md", +]; +for (const file of files) writeFileSync(join(root, file), ""); + +const show = (pattern: string | string[]): void => { + console.log(JSON.stringify(pattern), globSync(pattern, { cwd: root }).join(" ")); +}; + +for (const pattern of [ + "*.ts", + "*", + "**", + "**/*.ts", + "a/**", + "a/**/*.js", + "**/b", + "**/b/**", + "src/*", + "src/**/*.test.ts", + "{a,src}/*.ts", + "{a,src}/**/*.{ts,js}", + "a/{one,missing}.ts", + "?.ts", + "a/???.ts", + "[ab]/*", + "[!a]*", + "!(src|node_modules)/*.ts", + "@(a|src)/*.ts", + "+(a|b)/**", + "*.md", + "docs/\\[draft\\].md", + ".*", + "**/.*", + ".hidden/*", + "a/b/../one.ts", + "./a/*.ts", + "a/./b/*.ts", + "a/", + "a/b/", + "**/", + "a/b/c/three.js", + "missing/**", + "missing", + "**/node_modules/**", + "**/*.TS", + "", +]) { + show(pattern); +} +show(["*.md", "a/*.ts", "*.md"]); +show(["src/*", "src/**"]); +show([]); + +// Absolute patterns answer absolute paths; print them relative to the root. +const absolute = globSync(join(root, "a", "**", "*.ts")).map((p) => p.slice(root.length)); +console.log("absolute", absolute.join(" ")); +rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5449-fs-glob-options.ts b/tests/corpus/5449-fs-glob-options.ts new file mode 100644 index 000000000..e5e51262c --- /dev/null +++ b/tests/corpus/5449-fs-glob-options.ts @@ -0,0 +1,84 @@ +// fs.globSync options: cwd as an absolute path, a relative path and a file: +// URL; exclude as glob patterns (resolved against cwd, matched on whole +// paths and on directories with a trailing slash) or a function of the +// path; withFileTypes Dirents with name, parentPath (cwd-relative when cwd +// is relative) and the type probes; and followSymlinks. +import { type Dirent, globSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-options-")); +for (const dir of ["pkg/src/lib", "pkg/test", "pkg/node_modules/dep", "pkg/dist"]) { + mkdirSync(join(root, dir), { recursive: true }); +} +for (const file of [ + "pkg/package.json", + "pkg/src/index.ts", + "pkg/src/lib/a.ts", + "pkg/src/lib/a.test.ts", + "pkg/test/main.test.ts", + "pkg/node_modules/dep/index.ts", + "pkg/dist/index.js", +]) { + writeFileSync(join(root, file), "{}"); +} +let links = true; +try { + symlinkSync("src", join(root, "pkg", "linked"), "dir"); +} catch { + links = false; +} + +const pkg = join(root, "pkg"); +console.log("absolute cwd", globSync("**/*.ts", { cwd: pkg }).join(" ")); +process.chdir(root); +console.log("relative cwd", globSync("src/*.ts", { cwd: "pkg" }).join(" ")); +console.log("url cwd", globSync("*.json", { cwd: pathToFileURL(pkg) }).join(" ")); +console.log("default cwd", globSync("pkg/*.json").join(" ")); + +console.log( + "exclude patterns", + globSync("**/*.ts", { cwd: pkg, exclude: ["node_modules/**", "**/*.test.ts"] }).join(" "), +); +console.log("exclude directory", globSync("**", { cwd: pkg, exclude: ["dist/"] }).filter((p) => !p.includes("linked")).join(" ")); +console.log( + "exclude function", + globSync("**/*.ts", { cwd: pkg, exclude: (path) => path.startsWith("node_modules") || path.endsWith(".test.ts") }).join( + " ", + ), +); +const visited: string[] = []; +globSync("src/**", { + cwd: pkg, + exclude: (path) => { + visited.push(path); + return false; + }, +}); +console.log("exclude sees", visited.join(" ")); + +const describe = (list: Dirent[]): string => + list + .map((d) => { + const kind = d.isSymbolicLink() ? "link" : d.isDirectory() ? "dir" : d.isFile() ? "file" : "other"; + const parent = d.parentPath.startsWith(root) ? `${d.parentPath.slice(root.length)}` : d.parentPath; + return `${d.name}:${parent}:${kind}`; + }) + .join(" "); +console.log("dirents absolute", describe(globSync("src/**", { cwd: pkg, withFileTypes: true }))); +console.log("dirents relative", describe(globSync("pkg/src/*", { withFileTypes: true }))); +console.log("dirents exclude", describe(globSync("**/*.ts", { cwd: pkg, withFileTypes: true, exclude: ["node_modules/**"] }))); +console.log("withFileTypes false", globSync("*", { cwd: pkg, withFileTypes: false }).join(" ")); + +if (links) { + console.log("links", globSync("**/*.ts", { cwd: pkg, exclude: ["node_modules/**"] }).join(" ")); + console.log("links followed", globSync("**/*.ts", { cwd: pkg, followSymlinks: true, exclude: ["node_modules/**"] }).join(" ")); + console.log("link entry", describe(globSync("linked", { cwd: pkg, withFileTypes: true }))); + console.log("through link", globSync("linked/lib/*", { cwd: pkg }).join(" ")); +} else { + console.log("links unavailable"); +} +console.log("relative check", relative(root, pkg)); +process.chdir(tmpdir()); +rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5450-fs-glob-validation.js b/tests/corpus/5450-fs-glob-validation.js new file mode 100644 index 000000000..9914b0c1b --- /dev/null +++ b/tests/corpus/5450-fs-glob-validation.js @@ -0,0 +1,80 @@ +// fs.glob argument handling from untyped code: Node's ERR_INVALID_ARG_TYPE +// errors for patterns, options, exclude, followSymlinks, cwd and the +// callback, the ERR_INTERNAL_ASSERTION a non-string cwd with exclude +// patterns hits in Node, empty patterns, missing directories, exclude +// callbacks that throw or return non-booleans, and the errors fs.glob's +// callback and fs/promises glob deliver. +import { globSync, glob as globCallback, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { glob } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const any = (value) => /** @type {any} */ (value); +const attempt = (label, run) => { + try { + console.log(label, run()); + } catch (error) { + const e = /** @type {any} */ (error); + console.log(label, "threw", e.name, e.code, JSON.stringify(e.message)); + } +}; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-validation-")); +mkdirSync(join(root, "a")); +for (const file of ["a/one.ts", "a/two.ts", "top.md"]) writeFileSync(join(root, file), ""); +process.chdir(root); + +attempt("number pattern", () => globSync(any(5))); +attempt("null pattern", () => globSync(any(null))); +attempt("object pattern", () => globSync(any({}))); +attempt("function pattern", () => globSync(any(() => "*"))); +attempt("array with a number", () => globSync(any(["*", 5]))); +attempt("string options", () => globSync("*", any("x"))); +attempt("null options", () => globSync("*", any(null))); +attempt("array options", () => globSync("*", any([]))); +attempt("number exclude", () => globSync("*", any({ exclude: 5 }))); +attempt("exclude with a number", () => globSync("*", any({ exclude: ["a", 1] }))); +attempt("number followSymlinks", () => globSync("*", any({ followSymlinks: 1 }))); +attempt("null followSymlinks", () => globSync("*", any({ followSymlinks: null })).join(" ")); +attempt("number cwd", () => globSync("*", any({ cwd: 5 }))); +attempt("number cwd with exclude", () => globSync("*", any({ cwd: 5, exclude: ["x"] }))); +attempt("Buffer cwd", () => globSync("*", any({ cwd: Buffer.from("a") }))); +attempt("http URL cwd", () => globSync("*", any({ cwd: new URL("http://example.com/") }))); +attempt("missing cwd", () => globSync("*", { cwd: join(root, "missing") }).length); +attempt("empty pattern", () => globSync("").length); +attempt("no patterns", () => globSync([]).length); +attempt("exclude throws", () => + globSync("*", { + exclude: () => { + throw new RangeError("stop"); + }, + }), +); +attempt("exclude truthy values", () => globSync("**", { exclude: any((p) => (p.endsWith(".ts") ? 1 : "")) }).join(" ")); +attempt("missing callback", () => globCallback("*", any(undefined))); +attempt("bad pattern with callback", () => globCallback(any(5), () => console.log("not reached"))); + +globCallback("a/*.ts", (err, files) => { + console.log("callback", err, files.join(" ")); + globCallback("*", { cwd: "a" }, (err2, files2) => { + console.log("callback with options", err2, files2.join(" ")); + globCallback("*", any({ cwd: 5 }), (error) => { + console.log("callback cwd error", error.name, error.code, error.message); + void loop(); + }); + }); +}); + +async function loop() { + const seen = []; + for await (const path of glob("**/*.ts")) seen.push(path); + console.log("for await", seen.join(" ")); + try { + for await (const path of glob(any(5))) console.log("not reached", path); + } catch (error) { + const e = /** @type {any} */ (error); + console.log("for await bad pattern", e.name, e.code); + } + process.chdir(tmpdir()); + rmSync(root, { recursive: true, force: true }); +} diff --git a/tests/corpus/5451-fs-promises-glob.ts b/tests/corpus/5451-fs-promises-glob.ts new file mode 100644 index 000000000..5eb2c435e --- /dev/null +++ b/tests/corpus/5451-fs-promises-glob.ts @@ -0,0 +1,74 @@ +// fs/promises glob as a for await source: string matches, Dirent matches +// (fs/promises Dirents carry the resolved parent directory), the named, +// namespace and fs.promises spellings, early exits with break and continue, +// nested loops, and an error from an exclude callback surfacing at the +// loop. +import * as fs from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as fsp from "node:fs/promises"; +import { glob } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-fsp-glob-")); +for (const dir of ["one/deep", "two"]) mkdirSync(join(root, dir), { recursive: true }); +for (const file of ["one/a.txt", "one/deep/b.txt", "two/c.txt", "two/d.md", "e.txt"]) { + writeFileSync(join(root, file), ""); +} + +async function main(): Promise { + const all: string[] = []; + for await (const path of glob("**/*.txt", { cwd: root })) all.push(path); + console.log("named", all.join(" ")); + + const viaNamespace: string[] = []; + for await (const path of fsp.glob("*/*", { cwd: root, exclude: ["**/*.md"] })) viaNamespace.push(path); + console.log("namespace", viaNamespace.join(" ")); + + const viaFs: string[] = []; + for await (const path of fs.promises.glob("two/*", { cwd: root })) viaFs.push(path); + console.log("fs.promises", viaFs.join(" ")); + + const rows: string[] = []; + for await (const entry of glob("one/**", { cwd: root, withFileTypes: true })) { + rows.push(`${entry.name}|${entry.parentPath.slice(root.length)}|${entry.isDirectory()}`); + } + console.log("dirents", rows.join(" ")); + + let first = ""; + for await (const path of glob("**/*.txt", { cwd: root })) { + first = path; + break; + } + console.log("break", first); + + const skipped: string[] = []; + for await (const path of glob("**/*.txt", { cwd: root })) { + if (path.startsWith("one")) continue; + skipped.push(path); + } + console.log("continue", skipped.join(" ")); + + const pairs: string[] = []; + for await (const dir of glob("*/", { cwd: root })) { + for await (const file of glob(`${dir}/*.txt`, { cwd: root })) pairs.push(`${dir}>${file}`); + } + console.log("nested", pairs.join(" ")); + + try { + for await (const path of glob("**", { + cwd: root, + exclude: (p) => { + if (p === "two") throw new Error(`refused ${p}`); + return false; + }, + })) { + console.log("visited", path); + } + } catch (error) { + console.log("caught", (error as Error).message); + } + rmSync(root, { recursive: true, force: true }); +} + +void main(); diff --git a/tests/corpus/5452-fs-glob-callback.ts b/tests/corpus/5452-fs-glob-callback.ts new file mode 100644 index 000000000..438505783 --- /dev/null +++ b/tests/corpus/5452-fs-glob-callback.ts @@ -0,0 +1,44 @@ +// fs.glob's error-first callback form: with and without options, pattern +// arrays, exclude patterns and functions, the error an exclude callback +// throws arriving at the callback. Each call starts after the previous +// callback ran, so the output order does not depend on I/O timing. +import { glob, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-callback-")); +mkdirSync(join(root, "lib/inner"), { recursive: true }); +for (const file of ["lib/a.js", "lib/b.ts", "lib/inner/c.ts", "top.json"]) writeFileSync(join(root, file), ""); +process.chdir(root); + +const steps: (() => void)[] = [ + () => glob("lib/**/*.ts", (err, files) => next("no options", err, files)), + () => glob("**/*.ts", { cwd: "lib" }, (err, files) => next("cwd", err, files)), + () => glob(["*.json", "lib/*.js"], {}, (err, files) => next("array", err, files)), + () => glob("**/*", { exclude: ["lib/inner/**"] }, (err, files) => next("exclude patterns", err, files)), + () => glob("**/*", { exclude: (path) => path.endsWith(".js") }, (err, files) => next("exclude function", err, files)), + () => + glob( + "**", + { + exclude: () => { + throw new Error("exclude failed"); + }, + }, + (err, files) => next("exclude throws", err, files), + ), + () => finish(), +]; + +function next(label: string, err: NodeJS.ErrnoException | null, files: string[] | undefined): void { + console.log(label, err ? `error ${err.message}` : null, files === undefined ? "no files" : files.join(" ")); + steps.shift()?.(); +} + +function finish(): void { + process.chdir(tmpdir()); + rmSync(root, { recursive: true, force: true }); + console.log("done"); +} + +steps.shift()?.(); diff --git a/tests/fixtures/node-types/fs-glob.ts b/tests/fixtures/node-types/fs-glob.ts new file mode 100644 index 000000000..2467d73f1 --- /dev/null +++ b/tests/fixtures/node-types/fs-glob.ts @@ -0,0 +1,26 @@ +/* fs.globSync, fs/promises glob and fs.glob under @types/node: the same + * native walk as under the bundled declarations. */ +import { type Dirent, glob as globCallback, globSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { glob } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-node-types-glob-")); +mkdirSync(join(root, "src/util"), { recursive: true }); +for (const file of ["src/a.ts", "src/util/b.ts", "src/util/b.test.ts", "notes.md"]) writeFileSync(join(root, file), ""); + +console.log(globSync("**/*.ts", { cwd: root, exclude: ["**/*.test.ts"] }).join(" ")); +const entries: Dirent[] = globSync("src/*", { cwd: root, withFileTypes: true }); +console.log(entries.map((d) => `${d.name}:${d.isDirectory()}`).join(" ")); + +async function main(): Promise { + const found: string[] = []; + for await (const path of glob("src/**", { cwd: root })) found.push(path); + console.log(found.join(" ")); + globCallback("*.md", { cwd: root }, (err, matches) => { + console.log(err, matches.join(" ")); + rmSync(root, { recursive: true, force: true }); + }); +} + +void main(); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 53883e2dc..52b6104a3 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -548,6 +548,20 @@ test("node-types: Blob and File match Node under @types/node", async () => { expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: fs.glob forms match Node under @types/node", async () => { + const outDir = outDirFor("node-fs-glob"); + const entry = join(nodeTypesDir, "fs-glob.ts"); + const result = await compile(entry, { outPath: join(outDir, "fs-glob"), outDir, sanitize }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: util function helpers match Node under @types/node", async () => { const outDir = outDirFor("node-util-callables"); const entry = join(nodeTypesDir, "util-callables.ts"); From 6b7e9539fbb717ab5d1df9d1924ee197594b0921 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 08:02:39 -0500 Subject: [PATCH 2/3] Inline record result-slot entries and keep array headers out of loops A function that returns a record its caller only projects fills the caller's frame storage through a %into. entry. These entries are small and called once per use, but LLVM kept them out of line in loops: record-boundaries' pathLength called %into.pointAt twice per iteration. They now carry inlinehint, like the indexed-read and interface-dispatch helpers. Once inlined, the scalar array reads in them still blocked hoisting the arrays' header loads: the slow path's scr_arr_state and getter calls had no memory attributes. They are now read-only cold calls, as the dense read fallbacks already are (the getter runs only for a present element, so it does not trap). --- .../src/backend/llvm/dense-array-access.ts | 2 +- packages/compiler/src/backend/llvm/emitter.ts | 3 +- .../backend/llvm/local-array-reads.test.ts | 13 +++- .../src/backend/llvm/local-array-reads.ts | 14 +++- .../src/backend/llvm/stack-objects.test.ts | 3 + .../5585-record-results-inlined.ts.json | 6 ++ tests/corpus/5585-record-results-inlined.ts | 70 +++++++++++++++++++ 7 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json create mode 100644 tests/corpus/5585-record-results-inlined.ts diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 83163d99f..8f2535561 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -27,7 +27,7 @@ const SCR_ARR_VALUE = 1; * module-visible memory, or trap without returning to the caller (the trap * writes runtime-private stderr/TLS state). Declaring this lets LLVM keep * array header loads out of loops whose only calls are these cold paths. */ -const READ_FALLBACK = " cold memory(read, inaccessiblemem: readwrite)"; +export const READ_FALLBACK = " cold memory(read, inaccessiblemem: readwrite)"; interface DenseGuard { /** The checked dense offset, in the target's size type. */ diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4c8889c9c..aca34657d 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -78,7 +78,7 @@ import { type StringSplit, } from "./split-loops.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; -import { addRecordResultEntries } from "./record-results.js"; +import { RESULT_ENTRY_PREFIX, addRecordResultEntries } from "./record-results.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js"; @@ -6013,6 +6013,7 @@ export class LlEmitter { // tests small enough to inline at each call site. if (fn.name.startsWith("%dyn.class.protocol.fallback.")) attrs = `noinline ${attrs}`; else if (fn.name.startsWith("%dyn.class.protocol.")) attrs = `inlinehint ${attrs}`; + if (fn.name.startsWith(RESULT_ENTRY_PREFIX)) attrs = `inlinehint ${attrs}`; const debug = this.debugScope === null ? "" : ` !dbg ${this.debugScope}`; const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name); let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`; diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index e177594e6..b16fe2608 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -912,9 +912,16 @@ test.each([ expect(ir).not.toContain("@scr_union_new"); expect(ir).not.toContain("@scr_union_release"); expect(ir).not.toContain("@scr_union_get"); - // Holes, sparse and noncanonical indices keep the runtime state check. - expect(ir).toContain("@scr_arr_state"); - expect(ir).toContain(elem.kind === "f64" ? "@scr_arr_get_f64" : "@scr_arr_get_bool"); + // Holes, sparse and noncanonical indices keep the runtime state check, + // a read-only cold call, so loop-invariant header loads can be hoisted. + expect(ir).toMatch( + /call double @scr_arr_state\(.*\) cold memory\(read, inaccessiblemem: readwrite\)/, + ); + expect(ir).toMatch( + elem.kind === "f64" + ? /call double @scr_arr_get_f64\(.*\) cold memory\(read, inaccessiblemem: readwrite\)/ + : /call zeroext i1 @scr_arr_get_bool\(.*\) cold memory\(read, inaccessiblemem: readwrite\)/, + ); if (consumer !== "local") expect(ir).toContain("local.array.dense"); } }); diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index db56b273d..5646de371 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -14,6 +14,7 @@ import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { ReferenceEffects } from "./reference-effects.js"; import { SCR_ARR_FIELD, exactIndexLines } from "./common.js"; import { exactInteger, widenInteger } from "./integer-values.js"; +import { READ_FALLBACK } from "./dense-array-access.js"; export interface LocalArrayRead { type: IrType; @@ -696,10 +697,15 @@ export function emitLocalArrayRead( if (scalar) { // Holes, present undefined, sparse and noncanonical indices: the // runtime state decides presence, then the strict getter reads it. + // Both only read (the getter runs for a present element, so it does + // not trap), which lets LLVM keep the array's header loads out of a + // loop around an inlined read. const state = B.tmp(), present = B.tmp(); host.declare(`declare double @scr_arr_state(ptr, double)`); - B.line(`${state} = call double @scr_arr_state(ptr ${array.name}, double ${index.name})`); + B.line( + `${state} = call double @scr_arr_state(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, + ); B.line(`${present} = fcmp oeq double ${state}, 1.0`); B.condBr(present, slowValue, no); B.startBlock(slowValue); @@ -707,12 +713,14 @@ export function emitLocalArrayRead( bits = B.tmp(); if (read.element.kind === "f64") { host.declare(`declare double @scr_arr_get_f64(ptr, double)`); - B.line(`${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})`); + B.line( + `${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, + ); B.line(`${bits} = bitcast double ${value} to i64`); } else { host.declare(`declare zeroext i1 @scr_arr_get_bool(ptr, double)`); B.line( - `${value} = call zeroext i1 @scr_arr_get_bool(ptr ${array.name}, double ${index.name})`, + `${value} = call zeroext i1 @scr_arr_get_bool(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, ); B.line(`${bits} = zext i1 ${value} to i64`); } diff --git a/packages/compiler/src/backend/llvm/stack-objects.test.ts b/packages/compiler/src/backend/llvm/stack-objects.test.ts index 5869ac3de..845d6ae5e 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.test.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.test.ts @@ -73,6 +73,9 @@ test("projection-only records live in frame storage and producers fill it", asyn expect(projected).not.toMatch(/@sc_rnew_/); expect(projected).toMatch(/call void @sc_\w*into_add\(/); expect(projected).toMatch(/call void @sc_\w*into_shortest\(/); + // The result-slot entries are small and called per use: hinted inline. + expect(ir).toMatch(/^define internal void @sc_\w*into_add\([^)]*\) inlinehint /m); + expect(ir).toMatch(/^define internal void @sc_\w*into_shortest\([^)]*\) inlinehint /m); // A literal stores every field: its storage is not zeroed first. expect(projected).not.toContain("zeroinitializer"); // Stored values keep the heap path and the ordinary producer. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json new file mode 100644 index 000000000..f2956d94b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5585-record-results-inlined.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5585-record-results-inlined.ts b/tests/corpus/5585-record-results-inlined.ts new file mode 100644 index 000000000..47b81ce14 --- /dev/null +++ b/tests/corpus/5585-record-results-inlined.ts @@ -0,0 +1,70 @@ +// Records returned from functions that read arrays, called in loops so the +// result-slot entries inline: in-range reads, holes, reads past the end and +// at fractional indices (undefined fields, which make the arithmetic NaN), +// sparse arrays, boolean fields, and a recursive helper, against Node. +interface Point { + x: number; + y: number; +} +function pointAt(xs: number[], ys: number[], i: number): Point { + return { x: xs[i], y: ys[i] }; +} +function distance(p: Point, q: Point): number { + const dx = p.x - q.x; + const dy = p.y - q.y; + return Math.sqrt(dx * dx + dy * dy); +} +function pathLength(xs: number[], ys: number[], extra: number): number { + let total = 0; + for (let i = 0; i + 1 + extra < xs.length + extra; i++) total += distance(pointAt(xs, ys, i), pointAt(xs, ys, i + 1)); + return total; +} +const xs: number[] = []; +const ys: number[] = []; +for (let i = 0; i < 50; i++) { + xs.push((i * 37) % 11); + ys.push((i * 13) % 7); +} +console.log(pathLength(xs, ys, 0).toFixed(6), pathLength(xs, ys, 1), pathLength(xs, ys, 2)); +const p = pointAt(xs, ys, 60); +const q = pointAt(xs, ys, 1.5); +console.log(p.x, p.y, q.x === undefined, distance(p, pointAt(xs, ys, 0)), distance(pointAt(xs, ys, 3), pointAt(xs, ys, 3))); +const holey: number[] = [1, 2, 3]; +holey.length = 6; +holey[5] = 9; +let viaHoles = ""; +for (let i = 0; i < 7; i++) viaHoles += String(pointAt(holey, holey, i).x) + ","; +console.log(viaHoles); +const sparse: number[] = [4, 5]; +sparse[100] = 6; +console.log(pointAt(sparse, sparse, 100).y, pointAt(sparse, sparse, 50).y, distance(pointAt(sparse, xs, 0), pointAt(sparse, xs, 1))); +interface Flags { + on: boolean; + next: boolean; +} +function flagsAt(bits: boolean[], i: number): Flags { + return { on: bits[i], next: bits[i + 1] }; +} +const bits = [true, false, true, true]; +let flagText = ""; +for (let i = 0; i < 5; i++) { + const f = flagsAt(bits, i); + flagText += `${f.on}/${f.next} `; +} +console.log(flagText); +interface Span { + lo: number; + hi: number; +} +function widen(span: Span, values: number[], from: number, to: number): Span { + if (from >= to) return span; + const v = values[from]; + return widen({ lo: Math.min(span.lo, v), hi: Math.max(span.hi, v) }, values, from + 1, to); +} +let spans = 0; +for (let i = 0; i + 8 <= xs.length; i += 8) { + const s = widen({ lo: xs[i], hi: xs[i] }, xs, i + 1, i + 8); + spans += s.hi - s.lo; +} +const past = widen({ lo: 0, hi: 0 }, xs, 48, 52); +console.log(spans, past.lo, past.hi); From b7dddbbb39bd7e917fb0a50f599c74ac7db4180d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 08:36:53 -0500 Subject: [PATCH 3/3] Compute remainders by powers of two without fmod `x % c` for a constant c whose magnitude is a power of two of at least 1 now computes `x - trunc(x / c) * c`, which is exact for every double, and gives a zero result the dividend's sign. Divisors written as `2 ** k` count as constants. Other divisors keep frem, and a fractional constant divisor skips the integer check it can never pass. The inline integer path for unproven operands now accepts dividends up to 2^62 instead of 2^53. The remainder of two doubles is a double, so the conversion back is exact, and products such as `seed * 1103515245 + 12345` no longer call fmod. --- .../src/backend/llvm/expr-primitives.ts | 16 ++- .../src/backend/llvm/integer-remainder.ts | 101 ++++++++++++++++-- packages/compiler/test/llvm-remainder.test.ts | 35 ++++-- .../5455-power-of-two-remainder.ts.json | 6 ++ ...-remainder-large-integer-dividends.ts.json | 6 ++ .../tests/corpus/5457-lcg-modulus.ts.json | 6 ++ .../corpus/5458-fractional-remainder.ts.json | 6 ++ tests/corpus/5455-power-of-two-remainder.ts | 66 ++++++++++++ .../5456-remainder-large-integer-dividends.ts | 50 +++++++++ tests/corpus/5457-lcg-modulus.ts | 50 +++++++++ tests/corpus/5458-fractional-remainder.ts | 34 ++++++ 11 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json create mode 100644 tests/corpus/5455-power-of-two-remainder.ts create mode 100644 tests/corpus/5456-remainder-large-integer-dividends.ts create mode 100644 tests/corpus/5457-lcg-modulus.ts create mode 100644 tests/corpus/5458-fractional-remainder.ts diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 4b0e35874..07d969f6e 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -26,7 +26,12 @@ import { withinUint32, } from "../../ir/integer-ranges.js"; import { emitArrayValues } from "./expr-containers.js"; -import { emitCheckedRemainder, emitSignedIntegerRemainder } from "./integer-remainder.js"; +import { + constantDivisor, + emitCheckedRemainder, + emitPowerOfTwoRemainder, + emitSignedIntegerRemainder, +} from "./integer-remainder.js"; import { emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js"; import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; @@ -300,7 +305,14 @@ export function emitOperatorExpr( } } if (e.op === "%") { - const checked = emitCheckedRemainder(host, l, r, li, ri); + const constant = constantDivisor(e.right, ri); + const power = constant === null ? null : emitPowerOfTwoRemainder(host, l, constant); + if (power) return power; + // A fractional constant divisor never passes the integer check. + const checked = + constant !== null && !Number.isInteger(constant) + ? null + : emitCheckedRemainder(host, l, r, li, ri); if (checked) return checked; } if (arith[e.op] !== undefined) { diff --git a/packages/compiler/src/backend/llvm/integer-remainder.ts b/packages/compiler/src/backend/llvm/integer-remainder.ts index 79939f757..144695a45 100644 --- a/packages/compiler/src/backend/llvm/integer-remainder.ts +++ b/packages/compiler/src/backend/llvm/integer-remainder.ts @@ -1,7 +1,8 @@ -import { F64 } from "../../ir/ir.js"; +import { F64, type IrExpr } from "../../ir/ir.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { integerNumber, widenInteger, type LlInteger } from "./integer-values.js"; +import { emitTruncF64 } from "./trunc.js"; /** Integer proofs exclude negative zero and stay inside the exact-number * range, so signed i64 division cannot overflow. A possibly zero divisor @@ -43,14 +44,16 @@ export function emitSignedIntegerRemainder( return { name: result, type: F64 }; } -const EXACT = 2 ** 53; +/** 2^62, the bound on dividends the inline integer path accepts, as an + * i64 literal (`String(2 ** 62)` rounds its digits). */ +const DIVIDEND_LIMIT = "4611686018427387904"; /** Convert an unproven double to a candidate i64. `freeze` keeps NaN and * out-of-range conversions from producing poison; the returned condition * is the exact round trip, so it rejects every non-integer. Integral * doubles inside the i64 range convert exactly; a passing value outside * it (|x| >= 2^63) can only pair with a frozen integer of magnitude above - * 2^53. `-0` converts to 0 and passes. */ + * 2^62. `-0` converts to 0 and passes. */ function candidateInteger(host: LlvmEmitterContext, value: string): [string, string] { const B = host.B; const raw = B.tmp(), @@ -66,13 +69,17 @@ function candidateInteger(host: LlvmEmitterContext, value: string): [string, str /** JS `%` with an inline integer path for operands that are not both * proven integers with a nonzero divisor. At run time the dividend must be - * an integer of magnitude <= 2^53 and the divisor a nonzero integer; then + * an integer from -2^62 up to but not including 2^62 and the divisor a + * nonzero integer; then * i64 `srem` is exact (no INT64_MIN / -1 overflow is possible), takes the * dividend's sign like JS, and an exact zero takes the dividend's sign - * (`-4 % 2` and `-0 % 3` are -0). A divisor that passes the round trip - * with magnitude above 2^53 exceeds the dividend's, so `srem` returns the - * dividend exactly as JS does. Everything else (fractions, NaN, ±Infinity, - * zero divisors, huge dividends) takes the floating remainder, unchanged. + * (`-4 % 2` and `-0 % 3` are -0). The remainder of two doubles is itself a + * double, so its conversion back is exact, including for dividends above + * 2^53 (every double there is an integer). A divisor that passes the round + * trip with magnitude above 2^62 exceeds the dividend's, so `srem` returns + * the dividend exactly as JS does. Everything else (fractions, NaN, + * ±Infinity, zero divisors, huge dividends) takes the floating remainder, + * unchanged. * Returns null when the divisor is the constant zero (always NaN). */ export function emitCheckedRemainder( host: LlvmEmitterContext, @@ -90,8 +97,9 @@ export function emitCheckedRemainder( const [integer, exact] = candidateInteger(host, left.name); const biased = B.tmp(), inRange = B.tmp(); - B.line(`${biased} = add i64 ${integer}, ${EXACT}`); - B.line(`${inRange} = icmp ule i64 ${biased}, ${2 * EXACT}`); + // -2^62 <= integer < 2^62: the biased value is nonnegative. + B.line(`${biased} = add i64 ${integer}, ${DIVIDEND_LIMIT}`); + B.line(`${inRange} = icmp sge i64 ${biased}, 0`); conditions.push(exact, inRange); x = integer; } @@ -147,3 +155,76 @@ export function emitCheckedRemainder( B.line(`${result} = phi double [ ${fastValue}, %${fast} ], [ ${floating}, %${slow} ]`); return { name: result, type: F64 }; } + +/** The value of a divisor known at compile time: a number literal, its + * negation, `2 ** k` for an integer literal k from 0 to 1023 (exactly 2^k), + * or an integer whose proven range is a single value. */ +export function constantDivisor(expr: IrExpr, integer: LlInteger | null): number | null { + if (integer && integer.range.min === integer.range.max) return integer.range.min; + if (expr.kind === "numLit") return expr.value; + if (expr.kind === "unary" && expr.op === "-") { + const value = constantDivisor(expr.operand, null); + return value === null ? null : -value; + } + if ( + expr.kind === "bin" && + expr.op === "**" && + expr.left.kind === "numLit" && + expr.left.value === 2 && + expr.right.kind === "numLit" && + Number.isInteger(expr.right.value) && + expr.right.value >= 0 && + expr.right.value <= 1023 + ) + return 2 ** expr.right.value; + return null; +} + +/** Whether `value` is a power of two of at least 1. */ +function isPowerOfTwo(value: number): boolean { + if (!Number.isFinite(value) || value < 1) return false; + let power = 1; + while (power < value) power *= 2; + return power === value; +} + +/** JS `%` by a constant divisor whose magnitude c is a power of two of at + * least 1, as `x - trunc(x / c) * c`, which is exact for every double x. + * When |x| < c the quotient's magnitude is below 1 and truncates to zero, + * so the result is x. Otherwise x / c is a normal double and exact, and + * the truncated quotient times c is a multiple of c of magnitude at most + * |x|, also exact. When c is below x's ulp, x is itself a multiple of c and + * the difference is zero; otherwise both terms are multiples of x's ulp and + * the difference, smaller than c, is exact. ±Infinity gives the default + * NaN of `Infinity - Infinity`, the NaN fmod also returns for it, and a NaN + * dividend propagates. A zero difference takes the dividend's sign + * (`-4 % 2` and `-0 % 1` are -0). The divisor's sign does not change the + * result. Returns null for any other divisor; a power of two below 1 keeps + * frem, because x / c can overflow. */ +export function emitPowerOfTwoRemainder( + host: LlvmEmitterContext, + left: LlValue, + divisor: number, +): LlValue | null { + const magnitude = Math.abs(divisor); + if (!isPowerOfTwo(magnitude)) return null; + const B = host.B; + const c = f64Lit(magnitude); + const quotient = B.tmp(); + B.line(`${quotient} = fdiv double ${left.name}, ${c}`); + const whole = emitTruncF64(host, quotient); + const multiple = B.tmp(), + difference = B.tmp(), + zero = B.tmp(), + signedZero = B.tmp(), + result = B.tmp(); + host.declare("declare double @llvm.copysign.f64(double, double)"); + B.line(`${multiple} = fmul double ${whole}, ${c}`); + B.line(`${difference} = fsub double ${left.name}, ${multiple}`); + B.line(`${zero} = fcmp oeq double ${difference}, ${f64Lit(0)}`); + B.line( + `${signedZero} = call double @llvm.copysign.f64(double ${f64Lit(0)}, double ${left.name})`, + ); + B.line(`${result} = select i1 ${zero}, double ${signedZero}, double ${difference}`); + return { name: result, type: F64 }; +} diff --git a/packages/compiler/test/llvm-remainder.test.ts b/packages/compiler/test/llvm-remainder.test.ts index 7814cfbdb..e86ed4097 100644 --- a/packages/compiler/test/llvm-remainder.test.ts +++ b/packages/compiler/test/llvm-remainder.test.ts @@ -15,14 +15,18 @@ test("remainder uses integer proofs and preserves floating fallback and signed z ` function remainder(x: number, y: number): number { return x % y; } function constants(x: number): number { - let value = x % 8; + let value = x % 10; value %= 3; return value; } +function powers(x: number): number { + return (x % 8) + (x % -(2 ** 32)) + (x % 1) + (x % 0.5); +} function power(x: number, y: number): number { return x ** y; } function signed(x: number): number { return (x | 0) % -7; } function maybeZero(x: number, y: number): number { return (x | 0) % (y | 0); } console.log(remainder(-9.5, 8), constants(17.25), power(2, 3), signed(-7), maybeZero(7, 0)); +console.log(powers(-9.75)); // Fractional arguments keep the parameters arbitrary (no call-site proof). console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); `, @@ -36,25 +40,40 @@ console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec( ll, )![0]; - // Four source remainders keep a floating fallback behind a runtime - // integer check; ToUint32 coercion fallbacks use integer arithmetic. - expect(ll.match(/ = frem double /g)).toHaveLength(4); + // Five source remainders keep a floating fallback: four behind a + // runtime integer check and the one by 0.5. ToUint32 coercion + // fallbacks use integer arithmetic. + expect(ll.match(/ = frem double /g)).toHaveLength(5); expect(ll).toContain("uint32.coerce.slow"); expect(ll.match(/ = srem i64 /g)).toHaveLength(5); expect(ll).toMatch(/ = select i1 [^\n]+double [^\n]+double /); - // Unproven operands: both round-trip checks, the 2^53 dividend bound, + // Unproven operands: both round-trip checks, the 2^62 dividend bound, // and a nonzero divisor guard the integer path. const unproven = fn("remainder"); expect(unproven.match(/ = freeze i64 /g)).toHaveLength(2); - expect(unproven).toMatch(/ = add i64 [^\n]+, 9007199254740992$/m); - expect(unproven).toMatch(/ = icmp ule i64 [^\n]+, 18014398509481984$/m); + expect(unproven).toMatch(/ = add i64 [^\n]+, 4611686018427387904$/m); + expect(unproven).toMatch(/ = icmp sge i64 [^\n]+, 0$/m); expect(unproven).toMatch(/ = icmp ne i64 [^\n]+, 0$/m); expect(unproven).toContain("@llvm.expect.i1("); expect(unproven).toContain("rem.float"); // A constant divisor needs only the dividend check. const constant = fn("constants"); - expect(constant).toMatch(/ = srem i64 [^\n]+, 8$/m); + expect(constant).toMatch(/ = srem i64 [^\n]+, 10$/m); expect(constant).not.toMatch(/ = icmp ne i64 [^\n]+, 0$/m); + // Power-of-two divisors of at least 1 subtract the truncated multiple, + // with no integer check; the divisor's sign is dropped and 0.5 keeps + // frem. + const powers = fn("powers"); + expect(powers).not.toContain("freeze"); + expect(powers).not.toContain("srem"); + expect(powers.match(/ = fdiv double [^\n]+, 0x/g)).toHaveLength(3); + expect(powers).toMatch(/ = fdiv double [^\n]+, 0x4020000000000000$/m); + expect(powers).toMatch(/ = fdiv double [^\n]+, 0x41F0000000000000$/m); + expect(powers).toMatch(/ = fdiv double [^\n]+, 0x3FF0000000000000$/m); + expect( + powers.match(/ = call double @llvm.copysign.f64\(double 0x0000000000000000, /g), + ).toHaveLength(3); + expect(powers).toMatch(/ = frem double [^\n]+, 0x3FE0000000000000$/m); // Proven integers with a nonzero divisor need no check or fallback. const signed = fn("signed"); expect(signed).not.toContain("freeze"); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json new file mode 100644 index 000000000..c960ba1cd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5455-power-of-two-remainder.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json new file mode 100644 index 000000000..6b48639fb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5456-remainder-large-integer-dividends.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json new file mode 100644 index 000000000..0e2b42dad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5457-lcg-modulus.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json new file mode 100644 index 000000000..edb5a8a41 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5458-fractional-remainder.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5455-power-of-two-remainder.ts b/tests/corpus/5455-power-of-two-remainder.ts new file mode 100644 index 000000000..3d8969f37 --- /dev/null +++ b/tests/corpus/5455-power-of-two-remainder.ts @@ -0,0 +1,66 @@ +// `%` by a constant power of two of at least 1 matches Node for every kind +// of dividend: integers, fractions, subnormals, values beyond 2^53, -0, +// NaN and the infinities. The divisor's sign does not change the result, +// and a zero result takes the dividend's sign. + +function show(label: string, v: number): void { + console.log(label, Object.is(v, -0) ? "-0" : String(v)); +} + +const dividends: number[] = [ + 0, -0, 1, -1, 3, -4, 7.25, -7.25, 0.75, -0.75, 1.5, -1.5, 5e-324, -5e-324, + 2.2250738585072014e-308, -2.2250738585072014e-308, 1e-300, 2 ** 31 - 0.5, -(2 ** 31) + 0.5, + 2 ** 32 + 0.25, 2 ** 52 + 1, -(2 ** 52 + 3), 2 ** 52 + 0.5, 2 ** 53, -(2 ** 53), 2 ** 53 + 2, + 2 ** 60 + 1024, -(2 ** 60), 2 ** 63, 2 ** 64 + 4096, 1e21, -1e21, 1e300, -1e300, + Number.MAX_VALUE, -Number.MAX_VALUE, 123456789.123, -98765.4321, NaN, Infinity, -Infinity, +]; + +for (const x of dividends) { + show(`${x} % 1`, x % 1); + show(`${x} % -1`, x % -1); + show(`${x} % 2`, x % 2); + show(`${x} % 8`, x % 8); + show(`${x} % -16`, x % -16); + show(`${x} % 1024`, x % 1024); + show(`${x} % 2147483648`, x % 2147483648); + show(`${x} % -2147483648`, x % -2147483648); + show(`${x} % 4294967296`, x % 4294967296); + show(`${x} % 2 ** 32`, x % 2 ** 32); + show(`${x} % -(2 ** 40)`, x % -(2 ** 40)); + show(`${x} % 2 ** 52`, x % 2 ** 52); + show(`${x} % 2 ** 53`, x % 2 ** 53); + show(`${x} % 2 ** 64`, x % 2 ** 64); + show(`${x} % 2 ** 1023`, x % 2 ** 1023); + show(`${x} % -(2 ** 1023)`, x % -(2 ** 1023)); +} + +// Divisors that are not powers of two of at least 1 keep the general path. +for (const x of dividends) { + show(`${x} % 0.5`, x % 0.5); + show(`${x} % 0.125`, x % 0.125); + show(`${x} % 3`, x % 3); + show(`${x} % 6`, x % 6); + show(`${x} % 2 ** 0.5`, x % 2 ** 0.5); + show(`${x} % 2 ** -1`, x % 2 ** -1); +} + +// Module constants, compound assignment, record fields and closures. +const WORD = 4294967296; +const HALF_WORD = 65536; +interface Cell { + value: number; +} +const cell: Cell = { value: -12345.678 }; +let total = 0; +for (let i = 0; i < 64; i++) { + cell.value = cell.value * -3.7 + i; + let v = cell.value; + v %= HALF_WORD; + total += (v % WORD) + (cell.value % 256); +} +show("total", total); +const wrap = (x: number): number => x % 2 ** 16; +show("wrap", wrap(-1e15 - 0.5) + wrap(2 ** 70) + wrap(65536.5)); +let sum = 0; +for (let i = -20; i <= 20; i++) sum += (i * 0.37) % 1; +show("sum", sum); diff --git a/tests/corpus/5456-remainder-large-integer-dividends.ts b/tests/corpus/5456-remainder-large-integer-dividends.ts new file mode 100644 index 000000000..c2588b73c --- /dev/null +++ b/tests/corpus/5456-remainder-large-integer-dividends.ts @@ -0,0 +1,50 @@ +// `%` with integer dividends beyond 2^53 (every double there is an integer) +// and integer divisors: constant, int32 and unproven. Results match Node, +// including the sign of zero and the bounds of the inline integer path at +// 2^62 and 2^63. + +function show(label: string, v: number): void { + console.log(label, Object.is(v, -0) ? "-0" : String(v)); +} + +const dividends: number[] = [ + 2 ** 53, -(2 ** 53), 2 ** 53 + 2, -(2 ** 53 + 2), 2 ** 54 + 4, 2 ** 60 + 1024, -(2 ** 60 + 2048), + 2 ** 61 + 3 * 2 ** 9, 2 ** 62 - 1024, -(2 ** 62 - 1024), 2 ** 62, -(2 ** 62), 2 ** 62 + 1024, + -(2 ** 62 + 1024), 2 ** 63 - 1024, -(2 ** 63 - 1024), 2 ** 63, -(2 ** 63), 2 ** 64, -(2 ** 64), + 1e19, -1e19, 1e21, 9.999999999999999e22, +]; +const divisors: number[] = [ + 1, -1, 2, 3, -3, 7, 10, 1000, 65535, 1000000007, 2147483647, -2147483647, 2 ** 31, 2 ** 32 - 1, + 2 ** 53 - 1, 2 ** 53 + 2, 2 ** 62, -(2 ** 62), 2 ** 62 + 1024, 2 ** 63, -(2 ** 63), 2 ** 64, 0, + -0, +]; + +// Unproven dividends and divisors read from arrays. +let checksum = 0; +for (let i = 0; i < dividends.length; i++) { + for (let j = 0; j < divisors.length; j++) { + const r = dividends[i]! % divisors[j]!; + show(`${dividends[i]} % ${divisors[j]}`, r); + if (r === r) checksum = (checksum * 31 + (r % 1000003)) % 1000000007; + } +} +show("checksum", checksum); + +// Constant divisors. +for (const x of dividends) { + show(`${x} % 3`, x % 3); + show(`${x} % -10`, x % -10); + show(`${x} % 1000000007`, x % 1000000007); + show(`${x} % 2147483647`, x % 2147483647); + show(`${x} % -1`, x % -1); +} + +// Int32 divisors: loop counters and `| 0` values. +for (let k = 1; k <= 40; k += 13) { + for (const x of dividends) { + show(`${x} % ${k}`, x % k); + const m = (k * 7919) | 0; + show(`${x} % ${m}`, x % m); + show(`${x} % ${-m}`, x % -m); + } +} diff --git a/tests/corpus/5457-lcg-modulus.ts b/tests/corpus/5457-lcg-modulus.ts new file mode 100644 index 000000000..a475642dd --- /dev/null +++ b/tests/corpus/5457-lcg-modulus.ts @@ -0,0 +1,50 @@ +// Linear congruential generators whose products exceed 2^53, reduced with +// `%` by powers of two and by other constants. The products round, so every +// step must reproduce Node's double arithmetic exactly. + +let seed = 42; +function lcg31(): number { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed; +} +let prime = 42; +function parkMillerWide(): number { + prime = (prime * 1103515245 + 12345) % 2147483647; + return prime; +} +let word = 7; +function lcg32(): number { + word = (word * 6364136223846793 + 1442695040888963) % 2 ** 32; + return word; +} +let negative = -99; +function signedStep(): number { + negative = (negative * -1103515245 - 12345) % 2147483648; + return negative; +} + +const seen = new Map(); +let a = 0; +let b = 0; +let c = 0; +let d = 0; +for (let i = 0; i < 20000; i++) { + const x = lcg31(); + a = (a + x) % 1000000007; + b = (b * 31 + parkMillerWide()) % 1000000007; + c = (c + lcg32()) % 2 ** 40; + d = (d + signedStep()) % 1000000007; + if (i < 8) console.log(i, x, prime, word, negative); + seen.set(x, (seen.get(x) ?? 0) + 1); +} +console.log("sums", a, b, c, d, Object.is(d, -0)); +console.log("distinct", seen.size); + +// Bucket a stream into a small table with the low and high bits. +const buckets = new Int32Array(16); +for (let i = 0; i < 5000; i++) { + const x = lcg31(); + buckets[x % 16]++; + buckets[Math.floor(x / 134217728) % 16]++; +} +console.log(Array.from(buckets).join(",")); diff --git a/tests/corpus/5458-fractional-remainder.ts b/tests/corpus/5458-fractional-remainder.ts new file mode 100644 index 000000000..f83b2697c --- /dev/null +++ b/tests/corpus/5458-fractional-remainder.ts @@ -0,0 +1,34 @@ +// Fractional dividends with `%`: Weyl sequences and hashes reduced `% 1`, +// phases reduced by powers of two and by other divisors, and negative values +// whose remainders keep the dividend's sign. + +function show(label: string, v: number): void { + console.log(label, Object.is(v, -0) ? "-0" : String(v)); +} + +let weyl = 0.5; +let hashSum = 0; +for (let i = 0; i < 2000; i++) { + weyl = (weyl + 0.6180339887498949) % 1; + const h = (weyl * 2654435761.5) % 1; + hashSum += h; + if (i < 6) show(`weyl ${i}`, weyl); + if (i < 6) show(`hash ${i}`, h); +} +show("hashSum", hashSum); + +let phase = -3.75; +for (let i = 0; i < 12; i++) { + phase = phase * -2.5 + 0.125; + show(`phase ${i} % 4`, phase % 4); + show(`phase ${i} % 0.25`, phase % 0.25); + show(`phase ${i} % 360`, phase % 360); + show(`phase ${i} % (2 * Math.PI)`, phase % (2 * Math.PI)); +} + +// Fractional parts of negative values and of values that are integers. +for (const x of [-2.5, -2, -0.5, -0, 0, 0.5, 2, 2.5, -1e-310, 1e-310, 4503599627370495.5]) { + show(`${x} % 1`, x % 1); + show(`${x} % -1`, x % -1); + show(`${x} % 2`, x % 2); +}