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 da577c2ca..768d0de24 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": "bef290787c18ee9ed334", + "artifactVersion": "0ae6cfd9272ef79cd01d", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index c5f0cf461..caa0e0d7b 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 732431fd9..85b09ead2 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": 45, - "verify-gap": 2412, - "audit-partial": 928, + "verify-gap": 2409, + "audit-partial": 931, "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 a8d02f687..853a739cc 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 baa569b78..ed6b485a4 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 7de145b7a..2619d65a4 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/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/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/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index b8e17fc1c..f60405d7a 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -434,6 +434,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/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/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 cea9a4086..3373e5316 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1041,6 +1041,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 @@ -1194,6 +1196,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 } }, @@ -1790,6 +1794,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"], @@ -1809,6 +1814,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 @@ -1903,7 +1909,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 1e9cf378d..764152126 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 5f09888a2..0b8416fcd 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 27326228c..85975adc2 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 55f40c7c4..c9ac64504 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 567d0a106..e40d682d2 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/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/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/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/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/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/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); +} 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); 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");