From 9a1effd421c1fbccf11541ebf18ad4621c1c0e0f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 03:03:32 -0500 Subject: [PATCH 1/7] Complete fs.Stats in the typed and callback lanes --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 166 ++--- .../generated/node-v24-backlog.json | 382 ++++++---- .../generated/node-v24-internal.json | 194 +++-- internal/compatibility/static-support.json | 2 + .../ambient/scriptc-node-fallback.d.ts | 16 +- .../compiler/src/backend/llvm/lib-shared.ts | 14 + .../frontend/lowering/builtins/filesystem.ts | 35 +- .../frontend/lowering/builtins/properties.ts | 48 +- .../src/frontend/lowering/lower-exprs.ts | 5 +- .../compiler/src/ir/builtin-signatures.ts | 15 + .../corpus/5541-fs-stats-fields.cjs.json | 6 + packages/runtime/src/scr_bytes_io.c | 88 +-- packages/runtime/src/scr_file_handle.c | 102 +-- packages/runtime/src/scr_lib.c | 699 +++++++++++++++--- packages/runtime/src/scr_runtime.h | 24 + tests/corpus/5541-fs-stats-fields.cjs | 89 +++ 17 files changed, 1269 insertions(+), 618 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5541-fs-stats-fields.cjs.json create mode 100644 tests/corpus/5541-fs-stats-fields.cjs diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 6e92ff856..a11598111 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": "88ef6fabee3e41b50d74", + "artifactVersion": "e9cb9536313152d2f936", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 83e26f998..bc2cfe5b3 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": 98, + "partial": 125, "refused": 0, - "not-implemented": 175, + "not-implemented": 148, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -33554,9 +33554,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" } }, { @@ -33577,9 +33577,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" } }, { @@ -33600,9 +33600,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" } }, { @@ -33623,9 +33623,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" } }, { @@ -33646,9 +33646,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" } }, { @@ -33669,9 +33669,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" } }, { @@ -33692,9 +33692,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" } }, { @@ -33715,9 +33715,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" } }, { @@ -33784,9 +33784,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" } }, { @@ -33807,9 +33807,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" } }, { @@ -33830,9 +33830,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" } }, { @@ -33853,9 +33853,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" } }, { @@ -33876,9 +33876,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" } }, { @@ -33899,9 +33899,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" } }, { @@ -33922,9 +33922,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" } }, { @@ -33945,9 +33945,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Filesystem-capacity fields preserve unsigned platform values as numbers or BigInts.", + "verification": "test-backed" } }, { @@ -33968,9 +33968,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" } }, { @@ -33991,9 +33991,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" } }, { @@ -34037,9 +34037,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" } }, { @@ -34060,9 +34060,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" } }, { @@ -34083,9 +34083,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" } }, { @@ -34106,9 +34106,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" } }, { @@ -34129,9 +34129,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" } }, { @@ -34152,9 +34152,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" } }, { @@ -34175,9 +34175,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" } }, { @@ -34198,9 +34198,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" } }, { @@ -34221,9 +34221,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 a427b558d..79f2b96d9 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 37, - "verify-gap": 2472, - "audit-partial": 876, + "verify-gap": 2445, + "audit-partial": 903, "classify": 2, "implement": 85 } @@ -30509,12 +30509,14 @@ "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.Stats", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30534,12 +30536,14 @@ "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.stats.isBlockDevice", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30559,12 +30563,14 @@ "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.stats.isCharacterDevice", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30584,12 +30590,14 @@ "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.stats.isDirectory", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30609,12 +30617,14 @@ "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.stats.isFIFO", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30634,12 +30644,14 @@ "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.stats.isFile", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30659,12 +30671,14 @@ "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.stats.isSocket", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30684,12 +30698,14 @@ "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.stats.isSymbolicLink", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30763,12 +30779,14 @@ "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.mode", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30788,12 +30806,14 @@ "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.nlink", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30813,12 +30833,14 @@ "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.uid", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30838,12 +30860,14 @@ "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.gid", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30863,12 +30887,14 @@ "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.rdev", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30888,12 +30914,14 @@ "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.size", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30913,12 +30941,14 @@ "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.blksize", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30938,12 +30968,14 @@ "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.blocks", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30963,12 +30995,14 @@ "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.atimeMs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -30988,12 +31022,14 @@ "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.mtimeMs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31040,12 +31076,14 @@ "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.birthtimeMs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31065,12 +31103,14 @@ "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.atimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31090,12 +31130,14 @@ "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.mtimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31115,12 +31157,14 @@ "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.ctimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31140,12 +31184,14 @@ "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.birthtimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31165,12 +31211,14 @@ "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.atime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31190,12 +31238,14 @@ "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.mtime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31215,12 +31265,14 @@ "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.ctime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, @@ -31240,12 +31292,14 @@ "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.birthtime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index ecd001311..783480734 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": 98, + "partial": 125, "refused": 0, - "not-implemented": 175, + "not-implemented": 148, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -43746,6 +43746,7 @@ "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/5541-fs-stats-fields.cjs", "tests/corpus/fs-native-realpath.ts", "tests/corpus/fs-read-encodings.ts", "tests/corpus/node24-fs-links-callbacks.cjs", @@ -49250,8 +49251,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.Stats", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49296,8 +49300,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.stats.isBlockDevice", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49319,8 +49326,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.stats.isCharacterDevice", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49342,8 +49352,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.stats.isDirectory", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49365,8 +49378,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.stats.isFIFO", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49388,8 +49404,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.stats.isFile", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49411,8 +49430,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.stats.isSocket", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49434,8 +49456,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.stats.isSymbolicLink", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "exact" }, @@ -49509,8 +49534,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.mode", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49532,8 +49560,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.nlink", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49555,8 +49586,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.uid", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49578,8 +49612,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.gid", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49601,8 +49638,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.rdev", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49624,8 +49664,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.size", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49647,8 +49690,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.blksize", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49670,8 +49716,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.blocks", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49693,8 +49742,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.atimeMs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49716,8 +49768,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.mtimeMs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49765,8 +49820,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.birthtimeMs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49788,8 +49846,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.atimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49811,8 +49872,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.mtimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49834,8 +49898,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.ctimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49857,8 +49924,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.birthtimeNs", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49880,8 +49950,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.atime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49903,8 +49976,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.mtime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49926,8 +50002,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.ctime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, @@ -49949,8 +50028,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.birthtime", + "tests": [ + "tests/corpus/5541-fs-stats-fields.cjs" + ] }, "anchorSource": "ancestor" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 48eb01ba2..e67deacb5 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -280,6 +280,8 @@ { "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": "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"] }, { "chapter": "fs", "signatures": ["`dev` Type: {number|bigint}", "`ino` Type: {number|bigint}", "`ctimeMs` Type: {number|bigint}"], "status": "partial", "evidence": ["tests/corpus/2941-fsp-lstat-identity.ts"] }, { "chapter": "fs", "signatures": ["`filehandle[Symbol.asyncDispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "fs", "signatures": ["`fsPromises.realpath(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2940-fsp-realpath.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index ae28cde0a..33013a032 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1350,14 +1350,28 @@ declare module "node:fs" { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; + isBlockDevice(): boolean; + isCharacterDevice(): boolean; + isFIFO(): boolean; + isSocket(): boolean; readonly dev: number; + readonly mode: number; + readonly nlink: number; + readonly uid: number; + readonly gid: number; + readonly rdev: number; + readonly blksize: number; readonly ino: number; readonly size: number; readonly blocks: number; - readonly nlink: number; readonly atimeMs: number; readonly mtimeMs: number; readonly ctimeMs: number; + readonly birthtimeMs: number; + readonly atime: Date; + readonly mtime: Date; + readonly ctime: Date; + readonly birthtime: Date; } export function statSync(path: string): Stats; export function lstatSync(path: string): Stats; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 362ff0fec..adfb456df 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -451,6 +451,20 @@ export const LIB_FN_SYMS: Record = { "stats.atimeMs": "scr_stats_atime_ms", "stats.mtimeMs": "scr_stats_mtime_ms", "stats.ctimeMs": "scr_stats_ctime_ms", + "stats.birthtimeMs": "scr_stats_birthtime_ms", + "stats.mode": "scr_stats_mode", + "stats.uid": "scr_stats_uid", + "stats.gid": "scr_stats_gid", + "stats.rdev": "scr_stats_rdev", + "stats.blksize": "scr_stats_blksize", + "stats.isBlockDevice": "scr_stats_is_block_device", + "stats.isCharacterDevice": "scr_stats_is_character_device", + "stats.isFIFO": "scr_stats_is_fifo", + "stats.isSocket": "scr_stats_is_socket", + "stats.atime": "scr_stats_atime", + "stats.mtime": "scr_stats_mtime", + "stats.ctime": "scr_stats_ctime", + "stats.birthtime": "scr_stats_birthtime", "text.decode": "scr_text_decode", "bytes.bufferSource": "scr_bytes_buffer_source", "text.decodeBufferSource": "scr_text_decode_buffer_source", diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index 92e46caeb..06848e481 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -904,12 +904,21 @@ export function lowerFileHandleMethodCall( ); } -/** Method calls on Stats-typed receivers: isFile()/isDirectory()/ - * isSymbolicLink() are pure reads on the stat snapshot (a followed - * statSync snapshot never answers true to isSymbolicLink — take - * lstatSync's, Node's own split). Everything else @types/node declares - * (mtime, mode, ...) fences member-qualified. Null for non-Stats - * receivers. */ +/** The Stats type tests, each a read of the snapshot's mode. */ +export const STATS_TYPE_TESTS: Record = { + isFile: "stats.isFile", + isDirectory: "stats.isDirectory", + isSymbolicLink: "stats.isSymbolicLink", + isBlockDevice: "stats.isBlockDevice", + isCharacterDevice: "stats.isCharacterDevice", + isFIFO: "stats.isFIFO", + isSocket: "stats.isSocket", +}; + +/** Method calls on Stats-typed receivers: the type tests are pure reads on + * the stat snapshot (a followed statSync snapshot never answers true to + * isSymbolicLink — take lstatSync's, Node's own split). Anything else + * fences member-qualified. Null for non-Stats receivers. */ export function lowerStatsMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -919,23 +928,15 @@ export function lowerStatsMethodCall( if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "stats") return null; if (!lowerer.isStdlibMember(access)) return null; const name = access.name.text; - if ( - (name === "isFile" || name === "isDirectory" || name === "isSymbolicLink") && - call.arguments.length === 0 - ) { + const fn = Object.hasOwn(STATS_TYPE_TESTS, name) ? STATS_TYPE_TESTS[name] : undefined; + if (fn && call.arguments.length === 0) { const receiver = lowerer.lowerExpr(access.expression); - const fn = - name === "isFile" - ? "stats.isFile" - : name === "isDirectory" - ? "stats.isDirectory" - : "stats.isSymbolicLink"; return { kind: "libCall", fn, args: [receiver], type: BOOL, loc: locOf(call) }; } lowerer.noLowering( `Stats.${name}`, call, - "isFile(), isDirectory(), isSymbolicLink(), dev, ino, size, blocks, nlink, atimeMs, mtimeMs, and ctimeMs are the supported Stats members", + "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", lowerer.checker.getSymbolAtLocation(access.name), ); } diff --git a/packages/compiler/src/frontend/lowering/builtins/properties.ts b/packages/compiler/src/frontend/lowering/builtins/properties.ts index b7f6ccf8f..93e7e1e02 100644 --- a/packages/compiler/src/frontend/lowering/builtins/properties.ts +++ b/packages/compiler/src/frontend/lowering/builtins/properties.ts @@ -8,9 +8,11 @@ import { BOOL, CHILDSTREAM_T, CHILDWRITER_T, + DATE_T, F64, FILEHANDLE_T, type IrExpr, + type IrLibFn, type IrType, STRING, } from "../../../ir/ir.js"; @@ -23,6 +25,28 @@ import { storedTextCodecClassOf } from "./text-codecs.js"; * — null for a normal exit or spawn failure; a timeout kill reports its * killSignal, Node's shape). Null for everything else, so the ordinary * chain (and its fences) keeps going. */ + +/** Stats data members: each reads the snapshot (scr_stats_*). */ +const STATS_FIELDS: Record = { + dev: "stats.dev", + mode: "stats.mode", + nlink: "stats.nlink", + uid: "stats.uid", + gid: "stats.gid", + rdev: "stats.rdev", + blksize: "stats.blksize", + ino: "stats.ino", + blocks: "stats.blocks", + atimeMs: "stats.atimeMs", + mtimeMs: "stats.mtimeMs", + ctimeMs: "stats.ctimeMs", + birthtimeMs: "stats.birthtimeMs", + atime: "stats.atime", + mtime: "stats.mtime", + ctime: "stats.ctime", + birthtime: "stats.birthtime", +}; +const DATE_FIELDS = new Set(["atime", "mtime", "ctime", "birthtime"]); export function lowerBuiltinExtraProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -199,26 +223,12 @@ export function lowerBuiltinExtraProperty( lowerer.checker.getSymbolAtLocation(expr.name), ); } - if ( - kind === "stats" && - (name === "dev" || - name === "ino" || - name === "blocks" || - name === "nlink" || - name === "atimeMs" || - name === "mtimeMs" || - name === "ctimeMs") - ) { + if (kind === "stats" && Object.hasOwn(STATS_FIELDS, name)) { const receiver = lowerer.lowerExpr(expr.expression); - const fn = `stats.${name}` as - | "stats.dev" - | "stats.ino" - | "stats.blocks" - | "stats.nlink" - | "stats.atimeMs" - | "stats.mtimeMs" - | "stats.ctimeMs"; - return { kind: "libCall", fn, args: [receiver], type: F64, loc }; + const fn = STATS_FIELDS[name]!; + // The Date fields are the *Ms values rounded, as Node's dateFromMs builds them. + const type = DATE_FIELDS.has(name) ? DATE_T : F64; + return { kind: "libCall", fn, args: [receiver], type, loc }; } if (kind === "spawnRes" && name === "signal") { const receiver = lowerer.lowerExpr(expr.expression); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8864f980d..a2e98a6ec 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -7,6 +7,7 @@ import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { lowerBlobInstanceof } from "./builtins/blob.js"; import { lowerKeyObjectInstanceof } from "./builtins/key-object.js"; +import { STATS_TYPE_TESTS } from "./builtins/filesystem.js"; import { threadsImportOf } from "../threads-import.js"; import { experimentalImportOf, lifetimeRegionMemberOf } from "../experimental-import.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js"; @@ -5081,13 +5082,13 @@ export function lowerIntrinsicProperty( const receiver = lowerer.lowerExpr(expr.expression); return { kind: "libCall", fn: "stats.size", args: [receiver], type: F64, loc: locOf(expr) }; } - if (name === "isFile" || name === "isDirectory") { + if (Object.hasOwn(STATS_TYPE_TESTS, name)) { lowerer.unsupported("SC1090", expr, `Stats methods as values (call '${name}' directly)`); } lowerer.noLowering( `Stats.${name}`, expr, - "isFile(), isDirectory(), isSymbolicLink(), dev, ino, size, blocks, nlink, atimeMs, mtimeMs, and ctimeMs are the supported Stats members", + "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", lowerer.checker.getSymbolAtLocation(expr.name), ); } diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 14375d729..4f41a1eb4 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1006,6 +1006,21 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "stats.atimeMs": { argTypes: [STATS_T], result: F64 }, "stats.mtimeMs": { argTypes: [STATS_T], result: F64 }, "stats.ctimeMs": { argTypes: [STATS_T], result: F64 }, + "stats.birthtimeMs": { argTypes: [STATS_T], result: F64 }, + "stats.mode": { argTypes: [STATS_T], result: F64 }, + "stats.uid": { argTypes: [STATS_T], result: F64 }, + "stats.gid": { argTypes: [STATS_T], result: F64 }, + "stats.rdev": { argTypes: [STATS_T], result: F64 }, + "stats.blksize": { argTypes: [STATS_T], result: F64 }, + "stats.isBlockDevice": { argTypes: [STATS_T], result: BOOL }, + "stats.isCharacterDevice": { argTypes: [STATS_T], result: BOOL }, + "stats.isFIFO": { argTypes: [STATS_T], result: BOOL }, + "stats.isSocket": { argTypes: [STATS_T], result: BOOL }, + /** The Date fields: the *Ms value rounded (Node's dateFromMs). */ + "stats.atime": { argTypes: [STATS_T], result: DATE_T }, + "stats.mtime": { argTypes: [STATS_T], result: DATE_T }, + "stats.ctime": { argTypes: [STATS_T], result: DATE_T }, + "stats.birthtime": { argTypes: [STATS_T], result: DATE_T }, // The wider sync fs slice (unlink/chmod/chown/copyfile and the // mode-carrying write/mkdir forms). /** The wider sync fs slice (scr_lib.c), all throwing catchably with diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5541-fs-stats-fields.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5541-fs-stats-fields.cjs.json new file mode 100644 index 000000000..b37688cb6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5541-fs-stats-fields.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5541-fs-stats-fields.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index cef6d1181..bc52d8531 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -537,91 +537,11 @@ static void scr_fs_cb_copy(const ScrStr *source, const ScrStr *destination, bool } } -static ScrDyn *scr_fs_cb_stat_test(ScrClosure *closure, ScrDyn *const *args, size_t argc) { - (void)args; (void)argc; - return scr_dyn_new_bool(scr_box_get_bool(closure->caps[0])); -} - -static void scr_fs_cb_stat_method(ScrDyn *object, const char *name, bool answer) { - ScrClosure *closure = scr_closure_new(NULL, 1); - closure->caps[0] = scr_box_new(SCR_BOX_BOOL); - scr_box_set_bool(closure->caps[0], answer); - scr_dyn_obj_set(object, name, strlen(name), scr_dyn_new_func(closure, scr_fs_cb_stat_test, 0, "native:fs.Stats", name)); -} - -static void scr_fs_cb_stat_number(ScrDyn *object, const char *name, int64_t number, bool bigint) { - ScrDyn *value; - if (bigint) { - ScrBigInt *integer = scr_bigint_from_i64(number); - value = scr_dyn_new_bigint(integer); - scr_bigint_release(integer); - } else value = scr_dyn_new_num((double)number); - scr_dyn_obj_set(object, name, strlen(name), value); -} - -static void scr_fs_cb_stat_date(ScrDyn *object, const char *name, double milliseconds) { - ScrDyn *arguments = scr_dyn_new_arr(); - scr_dyn_arr_push(arguments, scr_dyn_new_num(milliseconds)); - ScrDyn *date = scr_dyn_native_date_new(arguments); - scr_dyn_release(arguments); - if (date) scr_dyn_obj_set(object, name, strlen(name), date); -} - static ScrDyn *scr_fs_cb_stats(const ScrStr *path, double fd, const ScrDyn *options, bool nofollow) { - struct stat status; -#ifdef _WIN32 - (void)nofollow; - int rc = path ? stat(path->data, &status) : fstat((int)fd, &status); -#else - int rc = path ? (nofollow ? lstat(path->data, &status) : stat(path->data, &status)) : fstat((int)fd, &status); -#endif - if (rc < 0) { scr_fs_throw(errno, path ? (nofollow ? "lstat" : "stat") : "fstat", path); return NULL; } - bool bigint = scr_fs_cb_option_bool(options, "bigint"); - ScrDyn *object = scr_dyn_new_obj(); -#define SCR_FS_STAT_FIELD(field) scr_fs_cb_stat_number(object, #field, (int64_t)status.st_##field, bigint) - SCR_FS_STAT_FIELD(dev); SCR_FS_STAT_FIELD(ino); SCR_FS_STAT_FIELD(mode); SCR_FS_STAT_FIELD(nlink); - SCR_FS_STAT_FIELD(uid); SCR_FS_STAT_FIELD(gid); SCR_FS_STAT_FIELD(rdev); SCR_FS_STAT_FIELD(size); -#ifndef _WIN32 - SCR_FS_STAT_FIELD(blksize); SCR_FS_STAT_FIELD(blocks); -#endif -#undef SCR_FS_STAT_FIELD - double atime = (double)status.st_atime * 1000, mtime = (double)status.st_mtime * 1000, ctime = (double)status.st_ctime * 1000; - double birthtime = 0; -#ifdef __APPLE__ - atime += status.st_atimespec.tv_nsec / 1000000.0; mtime += status.st_mtimespec.tv_nsec / 1000000.0; - ctime += status.st_ctimespec.tv_nsec / 1000000.0; - birthtime = (double)status.st_birthtimespec.tv_sec * 1000 + status.st_birthtimespec.tv_nsec / 1000000.0; -#elif !defined(_WIN32) && !defined(__wasi__) - atime += status.st_atim.tv_nsec / 1000000.0; mtime += status.st_mtim.tv_nsec / 1000000.0; - ctime += status.st_ctim.tv_nsec / 1000000.0; -#endif - scr_fs_cb_stat_date(object, "atime", atime); scr_fs_cb_stat_date(object, "mtime", mtime); - scr_fs_cb_stat_date(object, "ctime", ctime); scr_fs_cb_stat_date(object, "birthtime", birthtime); - if (bigint) { - scr_fs_cb_stat_number(object, "atimeMs", (int64_t)atime, true); - scr_fs_cb_stat_number(object, "mtimeMs", (int64_t)mtime, true); - scr_fs_cb_stat_number(object, "ctimeMs", (int64_t)ctime, true); - scr_fs_cb_stat_number(object, "birthtimeMs", (int64_t)birthtime, true); - } else { - scr_dyn_obj_set(object, "atimeMs", 7, scr_dyn_new_num(atime)); - scr_dyn_obj_set(object, "mtimeMs", 7, scr_dyn_new_num(mtime)); - scr_dyn_obj_set(object, "ctimeMs", 7, scr_dyn_new_num(ctime)); - scr_dyn_obj_set(object, "birthtimeMs", 11, scr_dyn_new_num(birthtime)); - } - scr_fs_cb_stat_method(object, "isFile", S_ISREG(status.st_mode)); - scr_fs_cb_stat_method(object, "isDirectory", S_ISDIR(status.st_mode)); -#ifndef _WIN32 - scr_fs_cb_stat_method(object, "isSymbolicLink", S_ISLNK(status.st_mode)); - scr_fs_cb_stat_method(object, "isBlockDevice", S_ISBLK(status.st_mode)); - scr_fs_cb_stat_method(object, "isCharacterDevice", S_ISCHR(status.st_mode)); - scr_fs_cb_stat_method(object, "isFIFO", S_ISFIFO(status.st_mode)); - scr_fs_cb_stat_method(object, "isSocket", S_ISSOCK(status.st_mode)); -#else - scr_fs_cb_stat_method(object, "isSymbolicLink", false); - scr_fs_cb_stat_method(object, "isBlockDevice", false); - scr_fs_cb_stat_method(object, "isCharacterDevice", S_ISCHR(status.st_mode)); - scr_fs_cb_stat_method(object, "isFIFO", false); scr_fs_cb_stat_method(object, "isSocket", false); -#endif + ScrStats *snapshot = !path ? scr_fs_fstat(fd) : nofollow ? scr_fs_lstat((ScrStr *)path) : scr_fs_stat((ScrStr *)path); + if (!snapshot) return NULL; + ScrDyn *object = scr_stats_dyn(snapshot, scr_fs_cb_option_bool(options, "bigint")); + scr_stats_release(snapshot); return object; } diff --git a/packages/runtime/src/scr_file_handle.c b/packages/runtime/src/scr_file_handle.c index 8d0ad072d..32438d524 100644 --- a/packages/runtime/src/scr_file_handle.c +++ b/packages/runtime/src/scr_file_handle.c @@ -34,42 +34,6 @@ struct ScrFileHandle { int fd; }; -/* ScrStats is opaque at the public runtime boundary. FileHandle's fstat - * snapshot completes the same layout privately in this translation unit. */ -struct ScrStats { - size_t rc; - bool is_file; - bool is_dir; - bool is_symlink; - double dev; - double ino; - double size; - double blocks; - double nlink; - double atime_ms; - double mtime_ms; - double ctime_ms; -}; - -#ifdef _WIN32 -/* Keep the FILETIME conversion identical to scr_lib.c's path-stat arm: - * libuv splits the 100ns count into Unix seconds and nanoseconds before Node - * combines it into the public millisecond value. */ -static double scr_file_handle_filetime_ms(FILETIME ft) { - ULARGE_INTEGER raw; - raw.LowPart = ft.dwLowDateTime; - raw.HighPart = ft.dwHighDateTime; - int64_t ticks = (int64_t)raw.QuadPart - INT64_C(116444736000000000); - int64_t sec = ticks / INT64_C(10000000); - int64_t rem = ticks % INT64_C(10000000); - if (rem < 0) { - sec--; - rem += INT64_C(10000000); - } - return (double)sec * 1000.0 + (double)rem / 10000.0; -} -#endif - typedef struct { char *data; size_t len; @@ -395,9 +359,9 @@ void scr_file_handle_write_file_bytes(ScrFileHandle *h, ScrBytes *data) { ScrStats *scr_file_handle_stat(ScrFileHandle *h) { if (!scr_file_handle_require_open(h)) return NULL; - struct stat st; - if (fstat(h->fd, &st) != 0) { - int err = errno; + int err = 0; + ScrStats *out = scr_stats_of_fd(h->fd, &err); + if (!out) { const char *name = err == EBADF ? "EBADF" : err == EIO ? "EIO" : "EUNKNOWN"; const char *text = err == EBADF ? "bad file descriptor" : err == EIO ? "i/o error" : strerror(err); @@ -406,66 +370,6 @@ ScrStats *scr_file_handle_stat(ScrFileHandle *h) { scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); return NULL; } - ScrStats *out = malloc(sizeof(ScrStats)); - if (!out) scr_trap("scriptc: out of memory\n"); - out->rc = 1; - out->is_file = S_ISREG(st.st_mode); - out->is_dir = S_ISDIR(st.st_mode); - out->is_symlink = false; /* fstat follows the open descriptor. */ - out->dev = (double)st.st_dev; - out->ino = (double)st.st_ino; - out->size = (double)st.st_size; -#if defined(_WIN32) - out->blocks = st.st_size <= 0 ? 0.0 : (double)(((uint64_t)st.st_size + 511) >> 9); - out->nlink = (double)st.st_nlink; - out->atime_ms = (double)st.st_atime * 1000.0; - out->mtime_ms = (double)st.st_mtime * 1000.0; - out->ctime_ms = (double)st.st_ctime * 1000.0; - HANDLE os_handle = (HANDLE)_get_osfhandle(h->fd); - if (os_handle != INVALID_HANDLE_VALUE) { - BY_HANDLE_FILE_INFORMATION basic; - if (GetFileInformationByHandle(os_handle, &basic)) { - out->dev = (double)basic.dwVolumeSerialNumber; - out->ino = (double)basic.nFileIndexHigh * 4294967296.0 + - (double)basic.nFileIndexLow; - out->nlink = (double)basic.nNumberOfLinks; - out->atime_ms = scr_file_handle_filetime_ms(basic.ftLastAccessTime); - out->mtime_ms = scr_file_handle_filetime_ms(basic.ftLastWriteTime); - } - FILE_BASIC_INFO times; - if (GetFileInformationByHandleEx( - os_handle, FileBasicInfo, ×, sizeof times)) { - FILETIME changed; - changed.dwLowDateTime = times.ChangeTime.LowPart; - changed.dwHighDateTime = times.ChangeTime.HighPart; - out->ctime_ms = scr_file_handle_filetime_ms(changed); - } - FILE_STANDARD_INFO standard; - if (GetFileInformationByHandleEx( - os_handle, FileStandardInfo, &standard, sizeof standard)) { - out->blocks = (double)((uint64_t)standard.AllocationSize.QuadPart >> 9); - out->nlink = (double)standard.NumberOfLinks; - } - } -#elif defined(__APPLE__) - out->blocks = (double)st.st_blocks; - out->nlink = (double)st.st_nlink; - out->atime_ms = (double)st.st_atimespec.tv_sec * 1000.0 + - (double)st.st_atimespec.tv_nsec / 1e6; - out->mtime_ms = (double)st.st_mtimespec.tv_sec * 1000.0 + - (double)st.st_mtimespec.tv_nsec / 1e6; - out->ctime_ms = (double)st.st_ctimespec.tv_sec * 1000.0 + - (double)st.st_ctimespec.tv_nsec / 1e6; -#else - out->blocks = (double)st.st_blocks; - out->nlink = (double)st.st_nlink; - out->atime_ms = (double)st.st_atim.tv_sec * 1000.0 + - (double)st.st_atim.tv_nsec / 1e6; - out->mtime_ms = (double)st.st_mtim.tv_sec * 1000.0 + - (double)st.st_mtim.tv_nsec / 1e6; - out->ctime_ms = (double)st.st_ctim.tv_sec * 1000.0 + - (double)st.st_ctim.tv_nsec / 1e6; -#endif return out; } diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index fc5e7b7da..6f31527c4 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -130,6 +130,17 @@ static ScrStr *scr_fs_win_utf8(const WCHAR *text, size_t length) { #if defined(__linux__) #include /* sched_getaffinity, CPU_COUNT (os.availableParallelism) */ #include /* PR_SET_NAME (process.title) */ +#include /* SYS_statx (fs stats) */ +#ifndef SYS_statx +#if defined(__x86_64__) +#define SYS_statx 332 +#elif defined(__aarch64__) +#define SYS_statx 291 /* the generic syscall table */ +#endif +#endif +#ifndef AT_EMPTY_PATH +#define AT_EMPTY_PATH 0x1000 +#endif #elif defined(__APPLE__) #include /* sysctlbyname (os.availableParallelism) */ #endif @@ -2140,7 +2151,7 @@ static double scr_filetime_us(FILETIME ft) { /* A filesystem FILETIME is 100ns ticks since 1601. Match libuv's split into * Unix seconds + nanoseconds before doing Node's millisecond arithmetic; the * split matters for the last-bit rounding of dates far from the epoch. */ -static double scr_filetime_unix_ms(FILETIME ft) { +static void scr_filetime_timespec(FILETIME ft, int64_t *sec_out, int64_t *nsec_out) { ULARGE_INTEGER raw; raw.LowPart = ft.dwLowDateTime; raw.HighPart = ft.dwHighDateTime; @@ -2151,7 +2162,8 @@ static double scr_filetime_unix_ms(FILETIME ft) { sec--; rem += INT64_C(10000000); } - return (double)sec * 1000.0 + (double)rem / 10000.0; + *sec_out = sec; + *nsec_out = rem * 100; } double scr_cpu_user(void) { FILETIME c, e, k, u; @@ -4770,24 +4782,37 @@ void scr_process_stdin_destroy(void) { } /* ── Stats values ──────────────────────────────────────────────────── - * An immutable snapshot of stat(2) results — the lowered type/mode, device, - * inode, size, allocation/link, and access/write/change-time slice. statSync - * THROWS like the other sync fs calls; the promise form rejects (see the fsp - * section). */ + * An immutable snapshot of one stat(2) result: libuv's integers and its four + * timestamps. statSync THROWS like the other sync fs calls; the promise form + * rejects (see the fsp section). scr_stats_dyn builds Node's Stats and + * BigIntStats objects from the same snapshot. */ + +/* The integers in the order Node's binding reports them. */ +enum { + SCR_STATS_DEV, + SCR_STATS_MODE, + SCR_STATS_NLINK, + SCR_STATS_UID, + SCR_STATS_GID, + SCR_STATS_RDEV, + SCR_STATS_BLKSIZE, + SCR_STATS_INO, + SCR_STATS_SIZE, + SCR_STATS_BLOCKS, + SCR_STATS_FIELDS +}; + +/* The timestamps, each kept as seconds + nanoseconds. */ +enum { SCR_STATS_ATIME, SCR_STATS_MTIME, SCR_STATS_CTIME, SCR_STATS_BIRTHTIME }; struct ScrStats { size_t rc; bool is_file; bool is_dir; bool is_symlink; /* lstat only — a followed stat never sees one */ - double dev; - double ino; - double size; - double blocks; /* allocated size in 512-byte units (Node/libuv) */ - double nlink; - double atime_ms; - double mtime_ms; /* milliseconds with the nanosecond fraction (Node) */ - double ctime_ms; + uint64_t fields[SCR_STATS_FIELDS]; /* blocks counts 512-byte units */ + int64_t sec[4]; + int64_t nsec[4]; }; ScrStats *scr_stats_retain(ScrStats *s) { @@ -4803,20 +4828,66 @@ void scr_stats_release(ScrStats *s) { void *scr_stats_retain_v(void *p) { return scr_stats_retain(p); } void scr_stats_release_v(void *p) { scr_stats_release(p); } +/* The S_IFMT type bits, spelled out because Windows lacks some macros. */ +#define SCR_STATS_IFMT 0xF000u +#define SCR_STATS_IFIFO 0x1000u +#define SCR_STATS_IFCHR 0x2000u +#define SCR_STATS_IFDIR 0x4000u +#define SCR_STATS_IFBLK 0x6000u +#define SCR_STATS_IFREG 0x8000u +#define SCR_STATS_IFLNK 0xA000u +#define SCR_STATS_IFSOCK 0xC000u + +static bool scr_stats_type_is(const ScrStats *s, unsigned type) { +#ifdef _WIN32 + /* Node answers false for the types Windows does not have. */ + if (type == SCR_STATS_IFIFO || type == SCR_STATS_IFBLK || type == SCR_STATS_IFSOCK) return false; +#endif + return (s->fields[SCR_STATS_MODE] & SCR_STATS_IFMT) == type; +} + bool scr_stats_is_file(ScrStats *s) { return s->is_file; } bool scr_stats_is_dir(ScrStats *s) { return s->is_dir; } bool scr_stats_is_symlink(ScrStats *s) { return s->is_symlink; } -double scr_stats_dev(ScrStats *s) { return s->dev; } -double scr_stats_ino(ScrStats *s) { return s->ino; } -double scr_stats_size(ScrStats *s) { return s->size; } -double scr_stats_blocks(ScrStats *s) { return s->blocks; } -double scr_stats_nlink(ScrStats *s) { return s->nlink; } -double scr_stats_atime_ms(ScrStats *s) { return s->atime_ms; } -double scr_stats_mtime_ms(ScrStats *s) { return s->mtime_ms; } -double scr_stats_ctime_ms(ScrStats *s) { return s->ctime_ms; } +bool scr_stats_is_block_device(ScrStats *s) { return scr_stats_type_is(s, SCR_STATS_IFBLK); } +bool scr_stats_is_character_device(ScrStats *s) { return scr_stats_type_is(s, SCR_STATS_IFCHR); } +bool scr_stats_is_fifo(ScrStats *s) { return scr_stats_type_is(s, SCR_STATS_IFIFO); } +bool scr_stats_is_socket(ScrStats *s) { return scr_stats_type_is(s, SCR_STATS_IFSOCK); } +double scr_stats_dev(ScrStats *s) { return (double)s->fields[SCR_STATS_DEV]; } +double scr_stats_mode(ScrStats *s) { return (double)s->fields[SCR_STATS_MODE]; } +double scr_stats_nlink(ScrStats *s) { return (double)s->fields[SCR_STATS_NLINK]; } +double scr_stats_uid(ScrStats *s) { return (double)s->fields[SCR_STATS_UID]; } +double scr_stats_gid(ScrStats *s) { return (double)s->fields[SCR_STATS_GID]; } +double scr_stats_rdev(ScrStats *s) { return (double)s->fields[SCR_STATS_RDEV]; } +double scr_stats_blksize(ScrStats *s) { return (double)s->fields[SCR_STATS_BLKSIZE]; } +double scr_stats_ino(ScrStats *s) { return (double)s->fields[SCR_STATS_INO]; } +double scr_stats_size(ScrStats *s) { return (double)s->fields[SCR_STATS_SIZE]; } +double scr_stats_blocks(ScrStats *s) { return (double)s->fields[SCR_STATS_BLOCKS]; } + +/* Node's msFromTimeSpec: seconds * 1000 + nanoseconds / 1e6. */ +static double scr_stats_ms(const ScrStats *s, int which) { + return (double)s->sec[which] * 1000.0 + (double)s->nsec[which] / 1e6; +} + +double scr_stats_atime_ms(ScrStats *s) { return scr_stats_ms(s, SCR_STATS_ATIME); } +double scr_stats_mtime_ms(ScrStats *s) { return scr_stats_ms(s, SCR_STATS_MTIME); } +double scr_stats_ctime_ms(ScrStats *s) { return scr_stats_ms(s, SCR_STATS_CTIME); } +double scr_stats_birthtime_ms(ScrStats *s) { return scr_stats_ms(s, SCR_STATS_BIRTHTIME); } + +/* Math.round: the nearest integer, halves toward +Infinity. */ +static double scr_stats_round(double ms) { + double low = floor(ms); + return ms - low >= 0.5 ? low + 1 : low; +} + +/* The Date fields: Node rounds the millisecond value before new Date(). */ +double scr_stats_atime(ScrStats *s) { return scr_date_new_ms(scr_stats_round(scr_stats_ms(s, SCR_STATS_ATIME))); } +double scr_stats_mtime(ScrStats *s) { return scr_date_new_ms(scr_stats_round(scr_stats_ms(s, SCR_STATS_MTIME))); } +double scr_stats_ctime(ScrStats *s) { return scr_date_new_ms(scr_stats_round(scr_stats_ms(s, SCR_STATS_CTIME))); } +double scr_stats_birthtime(ScrStats *s) { return scr_date_new_ms(scr_stats_round(scr_stats_ms(s, SCR_STATS_BIRTHTIME))); } static ScrStats *scr_stats_new(void) { - ScrStats *s = malloc(sizeof(ScrStats)); + ScrStats *s = calloc(1, sizeof(ScrStats)); if (!s) { scr_trap("scriptc: out of memory\n"); } @@ -4824,7 +4895,22 @@ static ScrStats *scr_stats_new(void) { return s; } +static void scr_stats_set_time(ScrStats *s, int which, int64_t sec, int64_t nsec) { + s->sec[which] = sec; + s->nsec[which] = nsec; +} + #ifdef _WIN32 +static void scr_stats_set_filetime(ScrStats *s, int which, FILETIME ft) { + scr_filetime_timespec(ft, &s->sec[which], &s->nsec[which]); +} + +/* libuv's Windows mode: the entry type plus read for everyone, and write + * unless the entry is read-only. */ +static uint64_t scr_stats_win_mode(unsigned type, bool read_only) { + return type | (read_only ? 0444u : 0666u); +} + /* The CRT fallback is deliberately complete: callers either get every field * from this one stat() result or every field from one Win32 handle below, * never a snapshot spliced across two path resolutions. */ @@ -4833,14 +4919,22 @@ static ScrStats *scr_stats_of_crt(const struct stat *st) { s->is_file = S_ISREG(st->st_mode); s->is_dir = S_ISDIR(st->st_mode); s->is_symlink = false; - s->dev = (double)st->st_dev; - s->ino = (double)st->st_ino; - s->size = (double)st->st_size; - s->blocks = st->st_size <= 0 ? 0.0 : (double)(((uint64_t)st->st_size + 511) >> 9); - s->nlink = (double)st->st_nlink; - s->atime_ms = (double)st->st_atime * 1000.0; - s->mtime_ms = (double)st->st_mtime * 1000.0; - s->ctime_ms = (double)st->st_ctime * 1000.0; + s->fields[SCR_STATS_DEV] = (uint64_t)st->st_dev; + /* Character devices and pipes carry only their type bits in libuv. */ + unsigned type = (unsigned)st->st_mode & SCR_STATS_IFMT; + s->fields[SCR_STATS_MODE] = type == SCR_STATS_IFREG || type == SCR_STATS_IFDIR + ? scr_stats_win_mode(type, !(st->st_mode & 0200)) + : type; + s->fields[SCR_STATS_NLINK] = (uint64_t)st->st_nlink; + s->fields[SCR_STATS_BLKSIZE] = 4096; + s->fields[SCR_STATS_INO] = (uint64_t)st->st_ino; + s->fields[SCR_STATS_SIZE] = st->st_size <= 0 ? 0 : (uint64_t)st->st_size; + s->fields[SCR_STATS_BLOCKS] = st->st_size <= 0 ? 0 : ((uint64_t)st->st_size + 511) >> 9; + scr_stats_set_time(s, SCR_STATS_ATIME, (int64_t)st->st_atime, 0); + scr_stats_set_time(s, SCR_STATS_MTIME, (int64_t)st->st_mtime, 0); + /* The CRT's st_ctime is the creation time. */ + scr_stats_set_time(s, SCR_STATS_CTIME, (int64_t)st->st_ctime, 0); + scr_stats_set_time(s, SCR_STATS_BIRTHTIME, (int64_t)st->st_ctime, 0); return s; } @@ -5082,57 +5176,188 @@ static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op, s->is_file = !is_link && (have_standard ? !standard.Directory : !is_dir); s->is_dir = !is_link && (have_standard ? standard.Directory : is_dir); s->is_symlink = is_link; - s->dev = (double)basic.dwVolumeSerialNumber; - s->ino = (double)basic.nFileIndexHigh * 4294967296.0 + - (double)basic.nFileIndexLow; - s->size = is_link && link_size >= 0 ? link_size : s->is_dir ? 0.0 - : have_standard ? (double)standard.EndOfFile.QuadPart : (double)size; - s->blocks = have_standard - ? (double)((uint64_t)standard.AllocationSize.QuadPart >> 9) - : size == 0 ? 0.0 : (double)((size + 511) >> 9); - s->nlink = have_standard - ? (double)standard.NumberOfLinks - : (double)basic.nNumberOfLinks; - s->atime_ms = scr_filetime_unix_ms(basic.ftLastAccessTime); - s->mtime_ms = scr_filetime_unix_ms(basic.ftLastWriteTime); + s->fields[SCR_STATS_DEV] = basic.dwVolumeSerialNumber; + s->fields[SCR_STATS_MODE] = scr_stats_win_mode( + is_link ? SCR_STATS_IFLNK : s->is_dir ? SCR_STATS_IFDIR : SCR_STATS_IFREG, + (basic.dwFileAttributes & FILE_ATTRIBUTE_READONLY) != 0); + s->fields[SCR_STATS_NLINK] = have_standard ? standard.NumberOfLinks : basic.nNumberOfLinks; + s->fields[SCR_STATS_BLKSIZE] = 4096; + s->fields[SCR_STATS_INO] = ((uint64_t)basic.nFileIndexHigh << 32) | basic.nFileIndexLow; + s->fields[SCR_STATS_SIZE] = is_link && link_size >= 0 ? (uint64_t)link_size : s->is_dir ? 0 + : have_standard ? (uint64_t)standard.EndOfFile.QuadPart : size; + s->fields[SCR_STATS_BLOCKS] = have_standard + ? (uint64_t)standard.AllocationSize.QuadPart >> 9 + : size == 0 ? 0 : (size + 511) >> 9; + scr_stats_set_filetime(s, SCR_STATS_ATIME, basic.ftLastAccessTime); + scr_stats_set_filetime(s, SCR_STATS_MTIME, basic.ftLastWriteTime); + scr_stats_set_filetime(s, SCR_STATS_BIRTHTIME, basic.ftCreationTime); if (have_times) { FILETIME changed; changed.dwLowDateTime = times.ChangeTime.LowPart; changed.dwHighDateTime = times.ChangeTime.HighPart; - s->ctime_ms = scr_filetime_unix_ms(changed); + scr_stats_set_filetime(s, SCR_STATS_CTIME, changed); } else { - s->ctime_ms = s->mtime_ms; + scr_stats_set_time(s, SCR_STATS_CTIME, s->sec[SCR_STATS_MTIME], s->nsec[SCR_STATS_MTIME]); } return s; } + +/* An open descriptor: the CRT snapshot, completed from the Win32 handle the + * way libuv's fstat reads it. */ +static ScrStats *scr_stats_of_fd_win(int fd, const struct stat *st) { + ScrStats *out = scr_stats_of_crt(st); + HANDLE os_handle = (HANDLE)_get_osfhandle(fd); + if (os_handle == INVALID_HANDLE_VALUE) return out; + BY_HANDLE_FILE_INFORMATION basic; + if (GetFileInformationByHandle(os_handle, &basic)) { + out->fields[SCR_STATS_DEV] = basic.dwVolumeSerialNumber; + out->fields[SCR_STATS_INO] = ((uint64_t)basic.nFileIndexHigh << 32) | basic.nFileIndexLow; + out->fields[SCR_STATS_NLINK] = basic.nNumberOfLinks; + out->fields[SCR_STATS_MODE] = scr_stats_win_mode( + out->is_dir ? SCR_STATS_IFDIR : SCR_STATS_IFREG, + (basic.dwFileAttributes & FILE_ATTRIBUTE_READONLY) != 0); + scr_stats_set_filetime(out, SCR_STATS_ATIME, basic.ftLastAccessTime); + scr_stats_set_filetime(out, SCR_STATS_MTIME, basic.ftLastWriteTime); + scr_stats_set_filetime(out, SCR_STATS_BIRTHTIME, basic.ftCreationTime); + } + FILE_BASIC_INFO times; + if (GetFileInformationByHandleEx(os_handle, FileBasicInfo, ×, sizeof times)) { + FILETIME changed; + changed.dwLowDateTime = times.ChangeTime.LowPart; + changed.dwHighDateTime = times.ChangeTime.HighPart; + scr_stats_set_filetime(out, SCR_STATS_CTIME, changed); + } + FILE_STANDARD_INFO standard; + if (GetFileInformationByHandleEx(os_handle, FileStandardInfo, &standard, sizeof standard)) { + out->fields[SCR_STATS_BLOCKS] = (uint64_t)standard.AllocationSize.QuadPart >> 9; + out->fields[SCR_STATS_NLINK] = standard.NumberOfLinks; + } + return out; +} #else +/* libuv's uv__to_stat: macOS, FreeBSD and NetBSD report a creation time; + * elsewhere birthtime repeats the change time. */ static ScrStats *scr_stats_of(const struct stat *st) { ScrStats *s = scr_stats_new(); s->is_file = S_ISREG(st->st_mode); s->is_dir = S_ISDIR(st->st_mode); s->is_symlink = S_ISLNK(st->st_mode); - s->dev = (double)st->st_dev; - s->ino = (double)st->st_ino; + s->fields[SCR_STATS_DEV] = (uint64_t)st->st_dev; + s->fields[SCR_STATS_MODE] = (uint64_t)st->st_mode; + s->fields[SCR_STATS_NLINK] = (uint64_t)st->st_nlink; + s->fields[SCR_STATS_UID] = (uint64_t)st->st_uid; + s->fields[SCR_STATS_GID] = (uint64_t)st->st_gid; + s->fields[SCR_STATS_RDEV] = (uint64_t)st->st_rdev; + s->fields[SCR_STATS_BLKSIZE] = (uint64_t)st->st_blksize; + s->fields[SCR_STATS_INO] = (uint64_t)st->st_ino; + s->fields[SCR_STATS_SIZE] = (uint64_t)st->st_size; + s->fields[SCR_STATS_BLOCKS] = (uint64_t)st->st_blocks; #if defined(__APPLE__) - s->atime_ms = (double)st->st_atimespec.tv_sec * 1000.0 + - (double)st->st_atimespec.tv_nsec / 1e6; - s->mtime_ms = (double)st->st_mtimespec.tv_sec * 1000.0 + - (double)st->st_mtimespec.tv_nsec / 1e6; - s->ctime_ms = (double)st->st_ctimespec.tv_sec * 1000.0 + - (double)st->st_ctimespec.tv_nsec / 1e6; + scr_stats_set_time(s, SCR_STATS_ATIME, st->st_atimespec.tv_sec, st->st_atimespec.tv_nsec); + scr_stats_set_time(s, SCR_STATS_MTIME, st->st_mtimespec.tv_sec, st->st_mtimespec.tv_nsec); + scr_stats_set_time(s, SCR_STATS_CTIME, st->st_ctimespec.tv_sec, st->st_ctimespec.tv_nsec); + scr_stats_set_time(s, SCR_STATS_BIRTHTIME, st->st_birthtimespec.tv_sec, st->st_birthtimespec.tv_nsec); #else - s->atime_ms = (double)st->st_atim.tv_sec * 1000.0 + - (double)st->st_atim.tv_nsec / 1e6; - s->mtime_ms = (double)st->st_mtim.tv_sec * 1000.0 + - (double)st->st_mtim.tv_nsec / 1e6; - s->ctime_ms = (double)st->st_ctim.tv_sec * 1000.0 + - (double)st->st_ctim.tv_nsec / 1e6; + scr_stats_set_time(s, SCR_STATS_ATIME, st->st_atim.tv_sec, st->st_atim.tv_nsec); + scr_stats_set_time(s, SCR_STATS_MTIME, st->st_mtim.tv_sec, st->st_mtim.tv_nsec); + scr_stats_set_time(s, SCR_STATS_CTIME, st->st_ctim.tv_sec, st->st_ctim.tv_nsec); +#if defined(__FreeBSD__) || defined(__NetBSD__) + scr_stats_set_time(s, SCR_STATS_BIRTHTIME, st->st_birthtim.tv_sec, st->st_birthtim.tv_nsec); +#else + scr_stats_set_time(s, SCR_STATS_BIRTHTIME, st->st_ctim.tv_sec, st->st_ctim.tv_nsec); +#endif #endif - s->blocks = (double)st->st_blocks; - s->nlink = (double)st->st_nlink; - s->size = (double)st->st_size; return s; } + +#if defined(__linux__) && defined(SYS_statx) +/* The kernel's struct statx (include/uapi/linux/stat.h). libuv reads stat + * results through statx, which also reports the creation time. */ +typedef struct { + int64_t tv_sec; + uint32_t tv_nsec; + int32_t reserved; +} ScrStatxTime; + +typedef struct { + uint32_t mask; + uint32_t blksize; + uint64_t attributes; + uint32_t nlink; + uint32_t uid; + uint32_t gid; + uint16_t mode; + uint16_t spare0; + uint64_t ino; + uint64_t size; + uint64_t blocks; + uint64_t attributes_mask; + ScrStatxTime atime, btime, ctime, mtime; + uint32_t rdev_major, rdev_minor; + uint32_t dev_major, dev_minor; + uint64_t spare[14]; +} ScrStatx; + +_Static_assert(sizeof(ScrStatx) == 256, "struct statx is 256 bytes"); + +/* glibc's and musl's makedev encoding, which libuv applies to statx. */ +static uint64_t scr_stats_makedev(uint64_t major, uint64_t minor) { + return ((major & 0xfffff000u) << 32) | ((major & 0xfffu) << 8) | + ((minor & 0xffffff00u) << 12) | (minor & 0xffu); +} + +static SCR_TL bool scr_stats_no_statx = false; + +static ScrStats *scr_stats_of_statx(const ScrStatx *sx) { + ScrStats *s = scr_stats_new(); + unsigned type = sx->mode & SCR_STATS_IFMT; + s->is_file = type == SCR_STATS_IFREG; + s->is_dir = type == SCR_STATS_IFDIR; + s->is_symlink = type == SCR_STATS_IFLNK; + s->fields[SCR_STATS_DEV] = scr_stats_makedev(sx->dev_major, sx->dev_minor); + s->fields[SCR_STATS_MODE] = sx->mode; + s->fields[SCR_STATS_NLINK] = sx->nlink; + s->fields[SCR_STATS_UID] = sx->uid; + s->fields[SCR_STATS_GID] = sx->gid; + s->fields[SCR_STATS_RDEV] = scr_stats_makedev(sx->rdev_major, sx->rdev_minor); + s->fields[SCR_STATS_BLKSIZE] = sx->blksize; + s->fields[SCR_STATS_INO] = sx->ino; + s->fields[SCR_STATS_SIZE] = sx->size; + s->fields[SCR_STATS_BLOCKS] = sx->blocks; + scr_stats_set_time(s, SCR_STATS_ATIME, sx->atime.tv_sec, sx->atime.tv_nsec); + scr_stats_set_time(s, SCR_STATS_MTIME, sx->mtime.tv_sec, sx->mtime.tv_nsec); + scr_stats_set_time(s, SCR_STATS_CTIME, sx->ctime.tv_sec, sx->ctime.tv_nsec); + scr_stats_set_time(s, SCR_STATS_BIRTHTIME, sx->btime.tv_sec, sx->btime.tv_nsec); + return s; +} +#endif + +/* stat, lstat, or fstat when path is NULL, as libuv runs them: statx on + * Linux, else the classic call. NULL with *error set on failure. */ +static ScrStats *scr_stats_posix(const char *path, int fd, bool no_follow, int *error) { +#if defined(__linux__) && defined(SYS_statx) + if (!scr_stats_no_statx) { + ScrStatx sx; + /* STATX_BASIC_STATS | STATX_BTIME */ + long rc = path ? syscall(SYS_statx, AT_FDCWD, path, no_follow ? AT_SYMLINK_NOFOLLOW : 0, 0xfffu, &sx) + : syscall(SYS_statx, fd, "", AT_EMPTY_PATH, 0xfffu, &sx); + if (rc == 0) return scr_stats_of_statx(&sx); + if (rc == -1 && errno != EINVAL && errno != EPERM && errno != ENOSYS && errno != EOPNOTSUPP) { + *error = errno; + return NULL; + } + /* A seccomp filter or an old kernel: libuv uses stat from then on. */ + scr_stats_no_statx = true; + } +#endif + struct stat st; + int rc = path ? (no_follow ? lstat(path, &st) : stat(path, &st)) : fstat(fd, &st); + if (rc != 0) { + *error = errno; + return NULL; + } + return scr_stats_of(&st); +} #endif /* Symbolic link targets are stored verbatim on POSIX. Windows junctions @@ -5373,12 +5598,10 @@ ScrStats *scr_fs_stat(ScrStr *path) { #ifdef _WIN32 return scr_stats_of_path(path, "stat", false); #else - struct stat st; - if (stat(path->data, &st) != 0) { /* follows symlinks, like Node's statSync */ - scr_fs_throw(errno, "stat", path); - return NULL; - } - return scr_stats_of(&st); + int error = 0; + ScrStats *s = scr_stats_posix(path->data, -1, false, &error); /* follows symlinks */ + if (!s) scr_fs_throw(error, "stat", path); + return s; #endif } @@ -5386,38 +5609,330 @@ ScrStats *scr_fs_lstat(ScrStr *path) { #ifdef _WIN32 return scr_stats_of_path(path, "lstat", true); #else + int error = 0; + ScrStats *s = scr_stats_posix(path->data, -1, true, &error); /* no follow */ + if (!s) scr_fs_throw(error, "lstat", path); + return s; +#endif +} + +ScrStats *scr_stats_of_fd(int fd, int *error) { +#ifdef _WIN32 struct stat st; - if (lstat(path->data, &st) != 0) { /* no follow; Node reports lstat */ - scr_fs_throw(errno, "lstat", path); + if (fstat(fd, &st) != 0) { + *error = errno; return NULL; } - return scr_stats_of(&st); + return scr_stats_of_fd_win(fd, &st); +#else + return scr_stats_posix(NULL, fd, false, error); #endif } ScrStats *scr_fs_fstat(double fd) { - struct stat st; - if (fstat((int)fd, &st) != 0) { - scr_fs_throw_nopath(errno, "fstat"); - return NULL; - } -#ifdef _WIN32 - ScrStats *s = scr_stats_new(); - s->is_file = S_ISREG(st.st_mode); - s->is_dir = S_ISDIR(st.st_mode); - s->is_symlink = false; - s->dev = (double)st.st_dev; - s->ino = (double)st.st_ino; - s->size = (double)st.st_size; - s->blocks = s->size <= 0 ? 0 : ceil(s->size / 512.0); - s->nlink = (double)st.st_nlink; - s->atime_ms = (double)st.st_atime * 1000.0; - s->mtime_ms = (double)st.st_mtime * 1000.0; - s->ctime_ms = (double)st.st_ctime * 1000.0; + int error = 0; + ScrStats *s = scr_stats_of_fd((int)fd, &error); + if (!s) scr_fs_throw_nopath(error, "fstat"); return s; -#else - return scr_stats_of(&st); +} + +/* ── Stats objects ─────────────────────────────────────────────────── + * Node's Stats and BigIntStats over a snapshot. The integers and the *Ms + * fields (and BigIntStats' *Ns fields) are own properties in the binding's + * order. Stats.prototype holds _checkModeProperty and lazy atime, mtime, + * ctime and birthtime accessors, which define an own Date the first time + * they run. The type tests (isFile, ...) sit on StatsBase.prototype and go + * through this._checkModeProperty(S_IF...), which reads this.mode. */ + +static SCR_TL ScrDyn *scr_stats_base_proto = NULL; +static SCR_TL ScrDyn *scr_stats_protos[2] = { NULL, NULL }; /* Stats, BigIntStats */ + +static const char *const scr_stats_date_names[4] = { "atime", "mtime", "ctime", "birthtime" }; + +/* Object.defineProperty(target, name, { value, writable } or { get, set }, + * enumerable, configurable: true). value, get and set move in. */ +static bool scr_stats_define(ScrDyn *target, const char *name, ScrDyn *value, ScrDyn *get, ScrDyn *set, bool enumerable) { + ScrDyn *descriptor = scr_dyn_new_obj(); + if (value) { + scr_dyn_obj_set(descriptor, "value", 5, value); + scr_dyn_obj_set(descriptor, "writable", 8, scr_dyn_new_bool(true)); + } + if (get) scr_dyn_obj_set(descriptor, "get", 3, get); + if (set) scr_dyn_obj_set(descriptor, "set", 3, set); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(enumerable)); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(true)); + ScrStr *text = scr_str_new(name, strlen(name)); + ScrDyn *key = scr_dyn_new_str(text); + scr_str_release(text); + ScrDyn *defined = scr_dyn_define_property(target, key, descriptor); + scr_dyn_release(key); + scr_dyn_release(descriptor); + if (!defined) return false; + scr_dyn_release(defined); + return true; +} + +/* `this` for a prototype member, or NULL with Node's TypeError when the + * receiver is undefined or null and a property read would throw. */ +static ScrDyn *scr_stats_receiver(const char *reading) { + ScrDyn *self = scr_dyn_this_get(); + if (self->kind != SCR_DYN_UNDEF && self->kind != SCR_DYN_NULL) return self; + char message[96]; + int n = snprintf(message, sizeof message, "Cannot read properties of %s (reading '%s')", + self->kind == SCR_DYN_UNDEF ? "undefined" : "null", reading); + scr_dyn_release(self); + scr_throw_error_msg(SCR_ERR_TYPE, message, (size_t)n); + return NULL; +} + +/* The lazy Date getter: setOwnProperty(this, name, new Date(Math.round( + * Number(this.Ms)))). */ +static ScrDyn *scr_stats_date_get(int which) { + char ms_name[16]; + int len = snprintf(ms_name, sizeof ms_name, "%sMs", scr_stats_date_names[which]); + ScrDyn *self = scr_stats_receiver(ms_name); + if (!self) return NULL; + ScrDyn *ms = scr_dyn_obj_read(self, ms_name, (size_t)len); + double number = ms ? scr_dyn_number_constructor(ms) : NAN; + scr_dyn_release(ms); + ScrDyn *date = NULL; + if (!scr_exc_pending()) { + ScrDyn *arguments = scr_dyn_new_arr(); + scr_dyn_arr_push(arguments, scr_dyn_new_num(scr_stats_round(number))); + date = scr_dyn_native_date_new(arguments); + scr_dyn_release(arguments); + } + if (date && !scr_stats_define(self, scr_stats_date_names[which], scr_dyn_retain(date), NULL, NULL, true)) { + scr_dyn_release(date); + date = NULL; + } + scr_dyn_release(self); + return date; +} + +/* The setter: setOwnProperty(this, name, value). */ +static ScrDyn *scr_stats_date_set(int which, ScrDyn *const *args, size_t argc) { + ScrDyn *self = scr_dyn_this_get(); + bool ok = scr_stats_define(self, scr_stats_date_names[which], + scr_dyn_retain(argc > 0 ? args[0] : scr_dyn_undefined()), NULL, NULL, true); + scr_dyn_release(self); + return ok ? scr_dyn_retain(scr_dyn_undefined()) : NULL; +} + +#define SCR_STATS_DATE_ACCESSORS(index) \ + static ScrDyn *scr_stats_date_get_##index(ScrClosure *closure, ScrDyn *const *args, size_t argc) { \ + (void)closure; \ + (void)args; \ + (void)argc; \ + return scr_stats_date_get(index); \ + } \ + static ScrDyn *scr_stats_date_set_##index(ScrClosure *closure, ScrDyn *const *args, size_t argc) { \ + (void)closure; \ + return scr_stats_date_set(index, args, argc); \ + } +SCR_STATS_DATE_ACCESSORS(0) +SCR_STATS_DATE_ACCESSORS(1) +SCR_STATS_DATE_ACCESSORS(2) +SCR_STATS_DATE_ACCESSORS(3) +#undef SCR_STATS_DATE_ACCESSORS + +static const ScrDynThunk scr_stats_date_getters[4] = { + scr_stats_date_get_0, scr_stats_date_get_1, scr_stats_date_get_2, scr_stats_date_get_3, +}; +static const ScrDynThunk scr_stats_date_setters[4] = { + scr_stats_date_set_0, scr_stats_date_set_1, scr_stats_date_set_2, scr_stats_date_set_3, +}; + +/* _checkModeProperty(property): (this.mode & S_IFMT) === property, with + * BigInt operands on BigIntStats. */ +static ScrDyn *scr_stats_check_mode(ScrDyn *const *args, size_t argc, bool bigint) { + const ScrDyn *property = argc > 0 ? args[0] : scr_dyn_undefined(); +#ifdef _WIN32 + if (property->kind == SCR_DYN_NUM && + (property->v.num == SCR_STATS_IFIFO || property->v.num == SCR_STATS_IFBLK || property->v.num == SCR_STATS_IFSOCK)) + return scr_dyn_new_bool(false); #endif + ScrDyn *self = scr_stats_receiver("mode"); + if (!self) return NULL; + ScrDyn *mode = scr_dyn_obj_read(self, "mode", 4); + scr_dyn_release(self); + if (!mode) return NULL; + ScrDyn *mask; + if (bigint) { + ScrBigInt *value = scr_bigint_from_u64(SCR_STATS_IFMT); + mask = scr_dyn_new_bigint(value); + scr_bigint_release(value); + } else mask = scr_dyn_new_num(SCR_STATS_IFMT); + ScrStr *operation = scr_str_new("&", 1); + ScrDyn *masked = scr_dyn_bitwise(mode, mask, operation); + scr_str_release(operation); + scr_dyn_release(mask); + scr_dyn_release(mode); + if (!masked) return NULL; + ScrDyn *expected = NULL; + if (bigint) { + ScrBigInt *value = scr_dyn_bigint_constructor(property); + if (value) { + expected = scr_dyn_new_bigint(value); + scr_bigint_release(value); + } + } else expected = scr_dyn_retain((ScrDyn *)property); + ScrDyn *result = expected ? scr_dyn_new_bool(scr_dyn_strict_eq(masked, expected)) : NULL; + scr_dyn_release(expected); + scr_dyn_release(masked); + return result; +} + +static ScrDyn *scr_stats_check_mode_number(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + return scr_stats_check_mode(args, argc, false); +} + +static ScrDyn *scr_stats_check_mode_bigint(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + return scr_stats_check_mode(args, argc, true); +} + +/* isFile() and the other type tests: this._checkModeProperty(S_IF...). */ +static ScrDyn *scr_stats_type_test(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *self = scr_stats_receiver("_checkModeProperty"); + if (!self) return NULL; + ScrDyn *check = scr_dyn_obj_read(self, "_checkModeProperty", 18); + ScrDyn *result = NULL; + if (check) { + ScrDyn *type = scr_dyn_new_num(scr_box_get_f64(closure->caps[0])); + scr_dyn_this_push_dyn(self); + result = scr_dyn_call(check, &type, 1, "this._checkModeProperty"); + scr_dyn_this_pop(); + scr_dyn_release(type); + scr_dyn_release(check); + } + scr_dyn_release(self); + return result; +} + +/* The prototypes' constructor members are only named; calling one has no + * lowering. The type tests and _checkModeProperty are anonymous functions in + * Node (assigned function expressions), so they carry no name. */ +static ScrDyn *scr_stats_constructor(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + (void)args; + (void)argc; + static const char message[] = "Calling the fs.Stats constructors has no lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; +} + +static ScrDyn *scr_stats_method(ScrDynThunk thunk, uint32_t arity, const char *name, double type) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(closure->caps[0], type); + return scr_dyn_new_func(closure, thunk, arity, "native:fs.Stats", name); +} + +static void scr_stats_protos_teardown(void) { + for (size_t i = 0; i < 2; i++) { + if (!scr_stats_protos[i]) continue; + scr_stats_protos[i]->v.obj.class_name = NULL; + scr_dyn_release(scr_stats_protos[i]); + scr_stats_protos[i] = NULL; + } + if (scr_stats_base_proto) { + scr_stats_base_proto->v.obj.class_name = NULL; + scr_dyn_release(scr_stats_base_proto); + scr_stats_base_proto = NULL; + } +} + +static ScrDyn *scr_stats_prototype(bool bigint) { + if (scr_stats_protos[bigint]) return scr_stats_protos[bigint]; + if (!scr_stats_base_proto) { + static const struct { + const char *name; + unsigned type; + } tests[] = { + { "isDirectory", SCR_STATS_IFDIR }, + { "isFile", SCR_STATS_IFREG }, + { "isBlockDevice", SCR_STATS_IFBLK }, + { "isCharacterDevice", SCR_STATS_IFCHR }, + { "isSymbolicLink", SCR_STATS_IFLNK }, + { "isFIFO", SCR_STATS_IFIFO }, + { "isSocket", SCR_STATS_IFSOCK }, + }; + ScrDyn *base = scr_dyn_new_obj(); + base->v.obj.class_name = "StatsBase"; + scr_stats_define(base, "constructor", scr_stats_method(scr_stats_constructor, 10, "StatsBase", 0), NULL, NULL, false); + for (size_t i = 0; i < sizeof tests / sizeof tests[0]; i++) + scr_dyn_obj_set(base, tests[i].name, strlen(tests[i].name), + scr_stats_method(scr_stats_type_test, 0, NULL, tests[i].type)); + scr_stats_base_proto = base; + scr_atexit(scr_stats_protos_teardown); + } + const char *name = bigint ? "BigIntStats" : "Stats"; + ScrDyn *proto = scr_dyn_new_obj(); + proto->v.obj.class_name = name; + proto->prototype = scr_dyn_retain(scr_stats_base_proto); + scr_stats_define(proto, "constructor", scr_stats_method(scr_stats_constructor, 14, name, 0), NULL, NULL, false); + for (int i = 0; i < 4; i++) + scr_stats_define(proto, scr_stats_date_names[i], NULL, + scr_stats_method(scr_stats_date_getters[i], 0, "get", 0), + scr_stats_method(scr_stats_date_setters[i], 1, "set", 0), true); + scr_dyn_obj_set(proto, "_checkModeProperty", 18, + scr_stats_method(bigint ? scr_stats_check_mode_bigint : scr_stats_check_mode_number, 1, NULL, 0)); + scr_stats_protos[bigint] = proto; + return proto; +} + +static ScrDyn *scr_stats_bigint_dyn(ScrBigInt *value /* moves */) { + ScrDyn *boxed = scr_dyn_new_bigint(value); + scr_bigint_release(value); + return boxed; +} + +ScrDyn *scr_stats_dyn(ScrStats *s, bool bigint) { + static const char *const names[SCR_STATS_FIELDS] = { + "dev", "mode", "nlink", "uid", "gid", "rdev", "blksize", "ino", "size", "blocks", + }; + ScrDyn *object = scr_dyn_new_obj(); + object->prototype = scr_dyn_retain(scr_stats_prototype(bigint)); + for (size_t i = 0; i < SCR_STATS_FIELDS; i++) { + /* The binding's BigInt64Array holds the integers as signed values. */ + ScrDyn *value = bigint ? scr_stats_bigint_dyn(scr_bigint_from_i64((int64_t)s->fields[i])) + : scr_dyn_new_num((double)s->fields[i]); + scr_dyn_obj_set(object, names[i], strlen(names[i]), value); + } + ScrDyn *ns[4] = { NULL, NULL, NULL, NULL }; + for (int i = 0; i < 4; i++) { + char name[16]; + int len = snprintf(name, sizeof name, "%sMs", scr_stats_date_names[i]); + if (!bigint) { + scr_dyn_obj_set(object, name, (size_t)len, scr_dyn_new_num(scr_stats_ms(s, i))); + continue; + } + /* nsFromTimeSpecBigInt, then the millisecond quotient. */ + ScrBigInt *sec = scr_bigint_from_i64(s->sec[i]); + ScrBigInt *billion = scr_bigint_from_i64(1000000000); + ScrBigInt *scaled = scr_bigint_mul(sec, billion); + ScrBigInt *nsec = scr_bigint_from_i64(s->nsec[i]); + ScrBigInt *total = scr_bigint_add(scaled, nsec); + ScrBigInt *million = scr_bigint_from_i64(1000000); + ScrBigInt *ms = scr_bigint_div(total, million); + scr_bigint_release(sec); + scr_bigint_release(billion); + scr_bigint_release(scaled); + scr_bigint_release(nsec); + scr_bigint_release(million); + scr_dyn_obj_set(object, name, (size_t)len, scr_stats_bigint_dyn(ms)); + ns[i] = scr_stats_bigint_dyn(total); + } + for (int i = 0; bigint && i < 4; i++) { + char name[16]; + int len = snprintf(name, sizeof name, "%sNs", scr_stats_date_names[i]); + scr_dyn_obj_set(object, name, (size_t)len, ns[i]); + } + return object; } ScrArr *scr_fs_readdir(ScrStr *path) { diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 22dc53d9d..0e4d1855b 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -3901,6 +3901,30 @@ double scr_stats_nlink(ScrStats *s); double scr_stats_atime_ms(ScrStats *s); /* ms with the sub-second fraction */ double scr_stats_mtime_ms(ScrStats *s); /* ms with the ns fraction */ double scr_stats_ctime_ms(ScrStats *s); /* ms with the ns fraction */ +double scr_stats_birthtime_ms(ScrStats *s); +double scr_stats_mode(ScrStats *s); +double scr_stats_uid(ScrStats *s); +double scr_stats_gid(ScrStats *s); +double scr_stats_rdev(ScrStats *s); +double scr_stats_blksize(ScrStats *s); +/* The type tests Node derives from mode (false on Windows for FIFOs, block + * devices and sockets). */ +bool scr_stats_is_block_device(ScrStats *s); +bool scr_stats_is_character_device(ScrStats *s); +bool scr_stats_is_fifo(ScrStats *s); +bool scr_stats_is_socket(ScrStats *s); +/* The Date fields' time values: the *Ms value rounded, as Node's + * dateFromMs does. */ +double scr_stats_atime(ScrStats *s); +double scr_stats_mtime(ScrStats *s); +double scr_stats_ctime(ScrStats *s); +double scr_stats_birthtime(ScrStats *s); +/* fstat without throwing: +1, or NULL with *error set to the errno. */ +ScrStats *scr_stats_of_fd(int fd, int *error); +/* Node's Stats (or BigIntStats) object for the snapshot: own fields in the + * binding's order, lazy Date accessors and the type tests on the + * prototypes. Borrows s; +1. */ +ScrDyn *scr_stats_dyn(ScrStats *s, bool bigint); /* fs/promises: the SAME sync operations, minting an already-settled * promise — success fulfills, failure REJECTS with the would-be thrown diff --git a/tests/corpus/5541-fs-stats-fields.cjs b/tests/corpus/5541-fs-stats-fields.cjs new file mode 100644 index 000000000..4db83847e --- /dev/null +++ b/tests/corpus/5541-fs-stats-fields.cjs @@ -0,0 +1,89 @@ +// fs.Stats fields: statSync, lstatSync and fstatSync expose mode, uid, +// gid, rdev, blksize, birthtimeMs, the Date fields (the *Ms values +// rounded) and every type test. The callback forms build Node's Stats and +// BigIntStats objects: own fields in the binding's order, lazy Date +// accessors that become own properties when read, and type tests that go +// through this._checkModeProperty and read this.mode. +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const util = require("node:util"); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-stats-")); +const file = path.join(dir, "f.txt"); +fs.writeFileSync(file, "stats text"); +const posix = process.platform !== "win32"; + +// The typed snapshot. +const s = fs.statSync(file); +console.log("type bits", (s.mode & 0o170000) === 0o100000, s.mode === fs.lstatSync(file).mode); +console.log("ids", s.uid >= 0, s.gid >= 0, s.rdev, s.blksize > 0, s.nlink, s.size, s.blocks >= 0); +console.log("tests", s.isFile(), s.isDirectory(), s.isSymbolicLink(), s.isBlockDevice(), s.isCharacterDevice(), s.isFIFO(), s.isSocket()); +console.log("dates", s.atime.getTime() === Math.round(s.atimeMs), s.mtime.getTime() === Math.round(s.mtimeMs), s.ctime.getTime() === Math.round(s.ctimeMs), s.birthtime.getTime() === Math.round(s.birthtimeMs)); +console.log("iso", s.mtime.toISOString() === new Date(Math.round(s.mtimeMs)).toISOString()); +const d = fs.statSync(dir); +console.log("directory", d.isDirectory(), d.isFile(), (d.mode & 0o170000) === 0o040000); +const fd = fs.openSync(file, "r"); +const f = fs.fstatSync(fd); +console.log("fstat", f.ino === s.ino, f.mode === s.mode, f.birthtimeMs === s.birthtimeMs, f.blksize === s.blksize); +fs.closeSync(fd); +if (posix) { + const n = fs.statSync("/dev/null"); + console.log("char device", n.isCharacterDevice(), n.isFile(), n.rdev > 0, (n.mode & 0o170000) === 0o020000); + fs.symlinkSync("f.txt", path.join(dir, "link")); + const l = fs.lstatSync(path.join(dir, "link")); + console.log("link", l.isSymbolicLink(), (l.mode & 0o170000) === 0o120000, l.size); +} else { + console.log("device and link checks need POSIX"); +} + +const mask = (text) => text.replace(/\n */g, " ").replace(/\d+(\.\d+)?n?/g, "N"); + +// The callback objects. +fs.stat(file, (err, t) => { + console.log("keys", err, Object.keys(t).join()); + const keys = []; + for (const key in t) keys.push(key); + console.log("for-in", keys.join()); + console.log("inspect", mask(util.inspect(t))); + console.log("same snapshot", t.mode === s.mode, t.ino === s.ino, t.birthtimeMs === s.birthtimeMs, t.blksize === s.blksize); + console.log("lazy", Object.keys(t).includes("mtime"), t.mtime === t.mtime, Object.keys(t).includes("mtime")); + console.log("rounded", t.mtime.getTime() === Math.round(t.mtimeMs), t.birthtime.getTime() === s.birthtime.getTime()); + const own = Object.getOwnPropertyDescriptor(t, "mtime"); + console.log("own date", own.writable, own.enumerable, own.configurable); + t.atime = 5; + console.log("assigned", t.atime, Object.keys(t).slice(-2).join()); + const proto = Object.getPrototypeOf(t); + console.log("prototypes", Object.getOwnPropertyNames(proto).join(), Object.getOwnPropertyNames(Object.getPrototypeOf(proto)).join()); + console.log("names", t.constructor.name, t.constructor.length, JSON.stringify(t.isFile.name), t.isFile.length); + console.log("json", JSON.stringify(t).startsWith('{"dev":'), JSON.stringify(t).includes('"atime":5')); + t.mode = 0o010644; + console.log("mode read", t.isFIFO() === posix, t.isFile()); + t.mode = "x"; + console.log("odd mode", t.isFile()); + fs.lstat(file, { bigint: true }, (err2, b) => { + console.log("bigint keys", err2, Object.keys(b).join()); + console.log("bigint inspect", mask(util.inspect(b)), b.constructor.name); + console.log("bigint values", typeof b.mode, b.mode === BigInt(s.mode), b.ino === BigInt(s.ino), b.mtimeMs === b.mtimeNs / 1000000n); + console.log("bigint dates", b.mtime.getTime() === Number(b.mtimeMs), b.isFile(), b.isDirectory()); + b.mode = 5; + try { + b.isFile(); + } catch (error) { + console.log("mixed", error.name, error.message); + } + const { isFile } = b; + try { + isFile(); + } catch (error) { + console.log("detached", error.name, error.message); + } + const handle = fs.openSync(file, "r"); + fs.fstat(handle, (err3, h) => { + console.log("fstat callback", err3, h.isDirectory(), h.isFile(), h.ino === s.ino, h.mode === s.mode, h.birthtimeMs === s.birthtimeMs); + fs.closeSync(handle); + fs.rmSync(dir, { recursive: true }); + console.log("removed", fs.existsSync(dir)); + }); + }); +}); From 416311f2c5726ab2d20e40e0c7d8a860cc1a75c6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 06:17:38 -0500 Subject: [PATCH 2/7] Append to strings in place for fields, concat chains and character codes The runtime appends to a string in place when the left operand is uniquely owned, but three common forms never got there: - `obj.field += s` on a plain class or record field kept a second reference to the old value, so every append copied the whole string. The compound assignment now reads a stored string field into a mutable local, stores "" into the field (no code runs in between: both operands are already strings), runs the self-append `old = old + rhs` and stores the result. - `s = s + a + b` copied per append: the self-append recognized only `s = s + x`. A concat chain whose leftmost operand is the target now takes the remaining operands as the suffix, in the same order. - `s += String.fromCharCode(c)` built a one-character string first. scr_str_concat_move_char_code appends the character, in place for ASCII. Self-appends also borrow the suffix instead of retaining it, and a discarded compound assignment no longer retains and releases its result. --- packages/compiler/src/backend/llvm/emitter.ts | 52 ++++++++-- .../src/backend/llvm/expr-primitives.ts | 7 +- .../src/frontend/lowering/lower-exprs.ts | 60 ++++++++++-- packages/compiler/src/ir/analysis.test.ts | 22 ++++- packages/compiler/src/ir/analysis.ts | 39 +++++--- .../tests/corpus/5552-string-appends.ts.json | 6 ++ .../5553-string-append-char-code.ts.json | 6 ++ packages/runtime/src/scr_string.c | 22 +++++ packages/runtime/src/scr_string.h | 4 + tests/corpus/5552-string-appends.ts | 94 +++++++++++++++++++ tests/corpus/5553-string-append-char-code.ts | 37 ++++++++ tests/fixtures/self-hosting/ir-build.ts | 2 +- 12 files changed, 313 insertions(+), 38 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5552-string-appends.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5553-string-append-char-code.ts.json create mode 100644 tests/corpus/5552-string-appends.ts create mode 100644 tests/corpus/5553-string-append-char-code.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index b5b8b5596..4c8889c9c 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1,4 +1,5 @@ import { type ArraySearchKind, emitArraySearchHelpers } from "./array-search.js"; +import { emitStringInputs } from "./string-lifetimes.js"; import { emitLlvmLayouts } from "./layouts.js"; import { compileRegexMatcher, type RegexMatcherCode } from "./regex-matchers.js"; import { CYC_BUF, CYC_HDR, FIRST_EMITTED_CYC_KIND, inlineAllocSupported } from "./alloc.js"; @@ -6836,10 +6837,9 @@ export class LlEmitter { storeLocalUnion(this, localStorage, s.value); break; } - const concat = s.value; - const suffix = matchStringSelfConcat(s.localId, concat); - if (suffix && concat.kind === "strConcat") { - this.emitStringSelfConcatAssign(s.localId, concat.left, suffix, false); + const self = matchStringSelfConcat(s.localId, s.value); + if (self) { + this.emitStringSelfConcatAssign(s.localId, self.left, self.suffix, false); break; } const b = this.binding(s.localId); @@ -8226,7 +8226,11 @@ export class LlEmitter { e.kind === "seqExpr" && isRefCounted(e.result.type) && e.result.kind !== "strLit" && - this.discardableRead(e.result) + (this.discardableRead(e.result) || + // A function-local binding's read has no effect of its own (a + // compound assignment's result, say); a module binding's read may + // throw before initialization, so it stays. + (e.result.kind === "varRef" && this.binding(e.result.localId).kind === "local")) ) { this.emitSequence(() => { for (const s of e.stmts) this.emitStmt(s); @@ -8287,8 +8291,11 @@ export class LlEmitter { // of first building the suffix as a separate string. const parts = stringParts(suffix); const mixed = parts.length > 1 || numberPart(parts[0]!) !== null; + const charCode = mixed ? null : this.emitSingleCharCode(suffix); const inputs = mixed ? emitConcatInputs(this, parts) : []; - const right = mixed ? null : this.emitExpr(suffix); + // The concat only reads the suffix; relinquishing the binding below + // runs no code that could rebind a borrowed local. + const right = mixed || charCode !== null ? null : emitStringInputs(this, [suffix])[0]!; const b = this.binding(localId); const B = this.B; if (b.kind === "boxed") { @@ -8301,7 +8308,15 @@ export class LlEmitter { this.releaseValue(old, b.type); } let raw: string; - if (right === null) { + if (charCode !== null) { + // The snapshot moves into the consuming character append. + this.moveTemp(snapshot); + this.declare(`declare ptr @scr_str_concat_move_char_code(ptr, double)`); + raw = B.tmp(); + B.line( + `${raw} = call ptr @scr_str_concat_move_char_code(ptr ${snapshot.name}, double ${charCode.name})`, + ); + } else if (right === null) { raw = emitMixedConcat(this, snapshot.name, inputs); } else { this.declare(`declare ptr @scr_str_concat(ptr, ptr)`); @@ -8321,6 +8336,17 @@ export class LlEmitter { return result; } + /** The code of a one-argument `String.fromCharCode(c)` suffix, emitted, so + * `s += String.fromCharCode(c)` appends the character without building + * the one-character string; null for any other suffix. */ + private emitSingleCharCode(suffix: IrExpr): LlValue | null { + if (suffix.kind !== "libCall" || suffix.fn !== "string.fromCharCode") return null; + const packed = suffix.args[0]; + if (packed?.kind !== "arrayLit" || packed.elems.length !== 1 || packed.spreads?.length) + return null; + return this.emitExpr(packed.elems[0]!); + } + /** `local = local + suffix` when the suffix cannot read or write the * local (an unboxed binding is only reachable through this function's own * expressions): reading the binding after the suffix gives the value the @@ -8347,14 +8373,22 @@ export class LlEmitter { return null; const parts = stringParts(suffix); const mixed = parts.length > 1 || numberPart(parts[0]!) !== null; + const charCode = mixed ? null : this.emitSingleCharCode(suffix); const inputs = mixed ? emitConcatInputs(this, parts) : []; - const right = mixed ? null : this.emitExpr(suffix); + // The concat only reads the suffix, which may stay borrowed. + const right = mixed || charCode !== null ? null : emitStringInputs(this, [suffix])[0]!; this.materializeSplitLocal(localId); const B = this.B; const head = B.tmp(); B.line(`${head} = load ptr, ptr ${b.slot}`); let raw: string; - if (right === null) { + if (charCode !== null) { + this.declare(`declare ptr @scr_str_concat_move_char_code(ptr, double)`); + raw = B.tmp(); + B.line( + `${raw} = call ptr @scr_str_concat_move_char_code(ptr ${head}, double ${charCode.name})`, + ); + } else if (right === null) { raw = emitMixedConcat(this, head, inputs, true); } else { this.declare(`declare ptr @scr_str_concat_move(ptr, ptr)`); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 57a5e1bcf..4b0e35874 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -443,11 +443,8 @@ export function emitOperatorExpr( // `x = e` in expression position: the binding takes its OWN // reference (retain for ref kinds), the temp stays the yielded // value — release the old value before storing the retained value. - const concat = e.value; - const suffix = matchStringSelfConcat(e.localId, concat); - if (suffix && concat.kind === "strConcat") { - return host.emitStringSelfConcatAssign(e.localId, concat.left, suffix, true); - } + const self = matchStringSelfConcat(e.localId, e.value); + if (self) return host.emitStringSelfConcatAssign(e.localId, self.left, self.suffix, true); const b = host.binding(e.localId); const v = host.emitExpr(e.value); if (host.storeInt32Local(e.localId, v, e.value)) return v; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8864f980d..f4d355cb9 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -29,7 +29,7 @@ import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-asser import { checkedArmExtraction } from "./coercions/unions.js"; import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js"; import { lowerUnionFieldRead, lowerUnionFieldWrite } from "./expressions/union-fields.js"; -import { captureContextArguments } from "./function-context.js"; +import { captureContextArguments, declareContextLocal } from "./function-context.js"; import { OBJECT_CALLABLE_VALUES } from "./surfaces.js"; import { wasiGuestPath } from "../../wasi-paths.js"; import { @@ -15045,7 +15045,29 @@ function lowerFieldCompoundValue( target.obj = save(target.obj, "%compoundReceiver"); // Accessors observe getter, RHS side effects, then setter, all through // the saved receiver. - const read = save(lowerer.fieldGetExpr(target, locOf(access), access), "%compoundOld"); + const fieldRead = lowerer.fieldGetExpr(target, locOf(access), access); + // `+=` on a stored string field reads it into a mutable local, so the + // concat below can append to that local in place. + const storedString = + op === "+" && + fieldRead.type.kind === "string" && + target.fieldType.kind === "string" && + (target.container === "class" || target.container === "record"); + let read: IrExpr; + if (storedString) { + const old = declareContextLocal( + lowerer.ctx, + "%compoundOld", + STRING, + true, + undefined, + undefined, + ); + body.push({ kind: "varDecl", localId: old.id, init: fieldRead, loc: fieldRead.loc }); + read = varRef(old.id, STRING, fieldRead.loc); + } else { + read = save(fieldRead, "%compoundOld"); + } const rhs = save( rhsNode ? lowerer.lowerExpr(rhsNode) : { kind: "numLit", value: 1, type: F64, loc }, "%compoundRhs", @@ -15055,16 +15077,12 @@ function lowerFieldCompoundValue( ? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc } : lowerOptionalNumber(lowerer, rhs, loc); let value: IrExpr; + let appended: IrExpr | null = null; const numericRead = lowerOptionalNumber(lowerer, read, loc, access); const bigintValue = rhsNode ? bigintCompoundValue(op, read, rhs, loc) : null; if (op === "+" && read.type.kind === "string") { - value = { - kind: "strConcat", - left: read, - right: lowerer.ensureString(rhs, rhsNode ?? access), - type: STRING, - loc, - }; + appended = lowerer.ensureString(rhs, rhsNode ?? access); + value = { kind: "strConcat", left: read, right: appended, type: STRING, loc }; } else if (numericRead.type.kind === "f64" && numericRhs.type.kind === "f64") { value = { kind: "bin", op, left: numericRead, right: numericRhs, type: F64, loc }; } else if (bigintValue) { @@ -15086,6 +15104,30 @@ function lowerFieldCompoundValue( } else { lowerer.unsupported("SC1043", access); } + if (storedString && rhs.type.kind === "string" && read.kind === "varRef" && appended !== null) { + // The field gives up its value before the concat (no code runs in + // between: both operands are already strings), and the concat is the + // self-append `old = old + rhs`, which hands the local's reference to + // the runtime: the old string is then uniquely owned unless something + // else holds it, and is appended to in place. Repeated `this.text += s` + // is linear rather than a copy per append. + const oldId = read.localId; + const oldLoc = read.loc; + const oldRef = (): IrExpr => varRef(oldId, STRING, oldLoc); + const concat: IrExpr = { + kind: "strConcat", + left: oldRef(), + right: appended, + type: STRING, + loc, + }; + body.push( + lowerer.fieldSetStmt(target, { kind: "strLit", value: "", type: STRING, loc }, loc, access), + { kind: "assign", localId: oldId, value: concat, loc }, + lowerer.fieldSetStmt(target, oldRef(), loc, access), + ); + return { kind: "seqExpr", stmts: body, result: oldRef(), type: STRING, loc }; + } const result = save(value, "%compoundResult"); body.push( lowerer.fieldSetStmt(target, lowerer.coerceInto(access, result, target.fieldType), loc, access), diff --git a/packages/compiler/src/ir/analysis.test.ts b/packages/compiler/src/ir/analysis.test.ts index 153f8b5ea..9906bcaee 100644 --- a/packages/compiler/src/ir/analysis.test.ts +++ b/packages/compiler/src/ir/analysis.test.ts @@ -42,14 +42,30 @@ test("union widening preserves record identity and recursive element layouts", ( expect(unionWideningTags([node], [other])).toBeNull(); }); -test("matchStringSelfConcat recognizes only the immediate string self-concat", () => { +test("matchStringSelfConcat recognizes the string self-concat", () => { const suffix = str("x"); - expect(matchStringSelfConcat("acc", concat(ref("acc"), suffix))).toBe(suffix); + const target = ref("acc"); + expect(matchStringSelfConcat("acc", concat(target, suffix))).toEqual({ left: target, suffix }); +}); + +test("matchStringSelfConcat takes the operands after a leftmost target as the suffix", () => { + const target = ref("acc"); + const self = matchStringSelfConcat( + "acc", + concat(concat(concat(target, str("x")), str("y")), str("z")), + ); + expect(self?.left).toBe(target); + expect(self?.suffix).toMatchObject({ + kind: "strConcat", + left: { kind: "strConcat", left: { value: "x" }, right: { value: "y" } }, + right: { value: "z" }, + }); }); test("matchStringSelfConcat rejects non-canonical and non-string shapes", () => { expect(matchStringSelfConcat("acc", concat(ref("other"), str("x")))).toBeNull(); - expect(matchStringSelfConcat("acc", concat(concat(ref("acc"), str("x")), str("y")))).toBeNull(); + expect(matchStringSelfConcat("acc", concat(concat(ref("other"), str("x")), str("y")))).toBeNull(); + expect(matchStringSelfConcat("acc", concat(str("x"), ref("acc")))).toBeNull(); expect(matchStringSelfConcat("acc", str("x"))).toBeNull(); expect(matchStringSelfConcat("acc", concat(ref("acc", F64), str("x")))).toBeNull(); expect(matchStringSelfConcat("acc", concat(ref("acc"), str("x"), F64))).toBeNull(); diff --git a/packages/compiler/src/ir/analysis.ts b/packages/compiler/src/ir/analysis.ts index 87a2d280b..cd75ffbe4 100644 --- a/packages/compiler/src/ir/analysis.ts +++ b/packages/compiler/src/ir/analysis.ts @@ -27,28 +27,45 @@ export function unionWideningTags(from: readonly IrType[], to: readonly IrType[] } /** - * Recognize the one concat shape whose destination binding can temporarily + * Recognize the concat shape whose destination binding can temporarily * hand off its ownership to the left operand: * * target = target + suffix + * target = target + a + b (the suffix is a + b) * - * Both native emitters use this rather than attempting a wider purity or - * alias analysis. In particular, fields, nested concat trees, dynamic - * operations, and a different destination all retain the ordinary borrowed - * concat lowering. + * The target is the leftmost operand, so it is read first either way, and + * the remaining operands keep their order in the suffix; every operand is + * already a string, so regrouping them does not change the result. Both + * native emitters use this rather than attempting a wider purity or alias + * analysis. In particular, fields, dynamic operations, and a different + * destination all retain the ordinary borrowed concat lowering. */ -export function matchStringSelfConcat(targetLocalId: string, value: IrExpr): IrExpr | null { +export function matchStringSelfConcat( + targetLocalId: string, + value: IrExpr, +): { left: IrExpr; suffix: IrExpr } | null { if ( value.kind !== "strConcat" || value.type.kind !== "string" || - value.left.kind !== "varRef" || - value.left.type.kind !== "string" || - value.left.localId !== targetLocalId || value.right.type.kind !== "string" - ) { + ) return null; + if (value.left.kind === "varRef") { + if (value.left.type.kind !== "string" || value.left.localId !== targetLocalId) return null; + return { left: value.left, suffix: value.right }; } - return value.right; + const inner = matchStringSelfConcat(targetLocalId, value.left); + if (inner === null) return null; + return { + left: inner.left, + suffix: { + kind: "strConcat", + left: inner.suffix, + right: value.right, + type: value.type, + loc: value.loc, + }, + }; } /** Descendant tree needed by constructor queries. Parent links deliberately diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5552-string-appends.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5552-string-appends.ts.json new file mode 100644 index 000000000..bcdc24046 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5552-string-appends.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5552-string-appends.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5553-string-append-char-code.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5553-string-append-char-code.ts.json new file mode 100644 index 000000000..b786ca386 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5553-string-append-char-code.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5553-string-append-char-code.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 88273c6d0..e9e31f7d6 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -700,6 +700,28 @@ ScrStr *scr_str_concat_move(ScrStr *a, ScrStr *b) { return r; } +ScrStr *scr_str_concat_move_char_code(ScrStr *a, double code) { + uint32_t cp = scr_to_uint32(code) & 0xFFFFu; + if (cp < 0x80 && a->rc == 1 && a->cap > a->len) { + /* scr_str_concat's in-place append of one ASCII byte: the string stays + * ASCII if it was, and a non-ASCII string's index facts below the old + * end stay valid (scr_sidx_concat_append). */ + size_t oldlen = a->len; + scr_short_forget(a); + scr_str_hash_forget(a); + a->data[oldlen] = (char)cp; + a->len = oldlen + 1; + a->data[oldlen + 1] = '\0'; + if (!a->ascii) scr_sidx_concat_append(a, oldlen); + return a; + } + ScrStr *c = scr_str_from_char_code_one(code); + ScrStr *r = scr_str_concat(a, c); + scr_str_release(a); + scr_str_release(c); + return r; +} + ScrStr *scr_str_concat_mixed_move(ScrStr *head, ScrStr *const *parts, const double *nums, size_t count) { /* A head selects the general body directly (see scr_str_concat_mixed). */ diff --git a/packages/runtime/src/scr_string.h b/packages/runtime/src/scr_string.h index 9212effaf..dc942e45d 100644 --- a/packages/runtime/src/scr_string.h +++ b/packages/runtime/src/scr_string.h @@ -242,6 +242,10 @@ ScrStr *scr_str_concat_mixed(ScrStr *head, ScrStr *const *parts, const double *n * else can observe `s`): one call instead of a snapshot retain, the * binding's release and the snapshot's release around a borrowing concat. */ ScrStr *scr_str_concat_move(ScrStr *a, ScrStr *b); +/* scr_str_concat_move(a, String.fromCharCode(code)) without building the + * one-character string: an ASCII character appends to a uniquely owned a + * in place. */ +ScrStr *scr_str_concat_move_char_code(ScrStr *a, double code); ScrStr *scr_str_concat_mixed_move(ScrStr *head, ScrStr *const *parts, const double *nums, size_t count); /* a + String(x): scr_str_concat's contract with a number right operand. */ diff --git a/tests/corpus/5552-string-appends.ts b/tests/corpus/5552-string-appends.ts new file mode 100644 index 000000000..b4fe12fa4 --- /dev/null +++ b/tests/corpus/5552-string-appends.ts @@ -0,0 +1,94 @@ +// String appends to fields, records, captured variables and through +// concat chains: results, aliases of the old string (which must not +// change), right sides that reassign the field, accessors, non-string right +// sides, the value of the compound assignment, and surrogate halves joined +// by appends, against Node. +class Writer { + text = ""; + count = 0; + write(s: string): void { + this.text += s; + } + reset(): string { + this.text = "reset"; + return "!"; + } +} +class Sub extends Writer { + extra = "e"; +} +const w = new Writer(); +for (let i = 0; i < 300; i++) w.write("ab" + (i % 10)); +console.log(w.text.length, w.text.slice(0, 12), w.text.slice(-6)); +const before = w.text; +w.text += "tail"; +console.log(before.length, before.endsWith("tail"), w.text.endsWith("tail"), w.text.length); +w.text += w.reset(); +console.log(w.text.length, w.text.slice(-6)); +const v = (w.text += "X"); +console.log(v.slice(-2), w.text === v); +w.count += 2; +const n = new Writer(); +n.text += 5 as unknown as string; +n.text += String(1.5); +console.log(n.text, w.count); +const sub = new Sub(); +sub.text += "s1"; +sub.extra += "x"; +sub.text += sub.extra; +console.log(sub.text, sub.extra); + +class Box { + private _v = "a"; + get v(): string { + return this._v; + } + set v(x: string) { + this._v = x + "|"; + } +} +const bx = new Box(); +bx.v += "b"; +bx.v += "c"; +console.log(bx.v); + +type Rec = { name: string; size: number }; +const rec: Rec = { name: "r", size: 1 }; +const alias = rec.name; +for (let i = 0; i < 50; i++) rec.name += String.fromCharCode(97 + (i % 26)); +console.log(rec.name.length, alias, rec.name.slice(0, 5)); + +// Concat chains whose leftmost operand is the target. +let s = "x"; +const t = s; +for (let i = 0; i < 100; i++) s = s + "-" + i; +console.log(s.length, t, s.slice(-8)); +let u = "u"; +u = u + 1 + 2; +u = u + "a" + 3.5 + "b"; +console.log(u); +let pair = "p"; +pair = pair + "\ud83d" + "\ude00"; +console.log(pair, pair.length); +let joined = ""; +joined += "\ud83d"; +const half = joined; +joined += "\ude00"; +console.log(joined, joined.length, half.length); + +// Captured variables. +function counter(): [(s: string) => void, () => string] { + let out = ""; + return [ + (s: string) => { + out += s; + out = out + "," + s.length; + }, + () => out, + ]; +} +const [add, get] = counter(); +add("aa"); +const snapshot = get(); +add("b"); +console.log(get(), snapshot); diff --git a/tests/corpus/5553-string-append-char-code.ts b/tests/corpus/5553-string-append-char-code.ts new file mode 100644 index 000000000..0216830e7 --- /dev/null +++ b/tests/corpus/5553-string-append-char-code.ts @@ -0,0 +1,37 @@ +// `s += String.fromCharCode(c)` appends one character: ASCII, two- and +// three-byte characters, surrogate halves that join into one character, +// codes that wrap modulo 2^16, aliases of the old string (which must not +// change), and growth from an empty string, against Node. +let s = ""; +const seen: string[] = []; +for (let i = 0; i < 1000; i++) { + s += String.fromCharCode(32 + (i % 95)); + if (i % 250 === 0) seen.push(s); +} +console.log(s.length, s.slice(0, 10), s.slice(-10), seen.map((x) => x.length).join(",")); +let t = "abc"; +const alias = t; +t += String.fromCharCode(100); +t += String.fromCharCode(0xe9); +t += String.fromCharCode(0x65e5); +t += String.fromCharCode(0x10041); +t += String.fromCharCode(-1); +console.log(t, t.length, alias, alias.length); +let pair = "x"; +pair += String.fromCharCode(0xd83d); +const half = pair; +pair += String.fromCharCode(0xde00); +console.log(pair, pair.length, half.length, half.charCodeAt(1).toString(16)); +let lone = ""; +lone += String.fromCharCode(0xde00); +lone += String.fromCharCode(0xd83d); +console.log(lone.length, lone.charCodeAt(0).toString(16), lone.charCodeAt(1).toString(16)); +let codes = ""; +for (const c of [72, 101, 108, 108, 111, 1.9, 65.5, 0x7f, 0x80]) codes += String.fromCharCode(c); +console.log(JSON.stringify(codes), codes.length); +function build(n: number): string { + let out = ""; + for (let i = 0; i < n; i++) out += String.fromCharCode(97 + (i % 26)); + return out; +} +console.log(build(0), build(1), build(27), build(5000).length); diff --git a/tests/fixtures/self-hosting/ir-build.ts b/tests/fixtures/self-hosting/ir-build.ts index 042914571..26b75326d 100644 --- a/tests/fixtures/self-hosting/ir-build.ts +++ b/tests/fixtures/self-hosting/ir-build.ts @@ -14,7 +14,7 @@ const concat: IrExpr = { kind: "strConcat", left: text, right: suffix, type: STR // Execute backend-independent analyses against the same real, recursive // IR types that the builders consume. Fail before generating any module // if an analysis loses a variant or reads the wrong payload. -if (matchStringSelfConcat("text.0", concat) !== suffix) throw new Error("concat recognition"); +if (matchStringSelfConcat("text.0", concat)?.suffix !== suffix) throw new Error("concat recognition"); if (matchStringSelfConcat("other.0", concat) !== null) throw new Error("concat target"); if (matchStringSelfConcat("text.0", numLit(0, loc)) !== null) throw new Error("concat variant"); if (!isStableReceiverOperand(numLit(3, loc), "sum.0")) throw new Error("stable literal"); From 740bd38e0fe16c425eae1a57b0cc9a2c13a460d8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 06:24:48 -0500 Subject: [PATCH 3/7] Support socket and server ref() and unref() socket.ref()/unref() and server.ref()/unref() decide whether the handle keeps the event loop alive, on typed handles and through checked-dynamic handles. The loop's net liveness now counts only ref'd sockets and servers; unref'd ones still deliver their events while other work keeps the loop running, because the sleep polls the net unit's fd whenever any handle exists. Corpus 5595 covers the server, client and accepted sockets. --- .../ambient/scriptc-node-fallback.d.ts | 6 ++++ .../compiler/src/backend/llvm/lib-shared.ts | 2 ++ .../src/frontend/lowering/lower-server.ts | 24 +++++++++++++++ .../compiler/src/ir/builtin-signatures.ts | 4 +++ .../tests/corpus/5595-net-ref-unref.ts.json | 6 ++++ packages/runtime/src/scr_async.c | 17 ++++++++--- packages/runtime/src/scr_http2.h | 1 + packages/runtime/src/scr_net.c | 30 +++++++++++++++++-- packages/runtime/src/scr_net.h | 3 ++ tests/corpus/5595-net-ref-unref.ts | 30 +++++++++++++++++++ 10 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5595-net-ref-unref.ts.json create mode 100644 tests/corpus/5595-net-ref-unref.ts diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index ae28cde0a..c3e9c06c0 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3324,6 +3324,9 @@ declare module "node:zlib" { * 'error'. */ declare module "net" { export interface Socket { + /* Whether the socket keeps the event loop alive (ref'd by default). */ + ref(): void; + unref(): void; readonly remoteAddress: string | undefined; /* true once the fd is gone (destroy() or full close) — Node's flag. */ readonly destroyed: boolean; @@ -3388,6 +3391,9 @@ declare module "net" { once(event: "session", listener: (session: Buffer) => void): void; } export interface Server { + /* Whether the server keeps the event loop alive (ref'd by default). */ + ref(): void; + unref(): void; readonly listening: boolean; /* Node answers the server itself (`return this` chaining). */ listen(port: number, callback?: () => void): Server; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 362ff0fec..de5312e88 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -923,6 +923,8 @@ export const LIB_FN_SYMS: Record = { // the client request forms have non-generic shapes — special-cased. "net.serverPort": "scr_net_server_port", "net.serverListening": "scr_net_server_listening", + "net.sockSetRef": "scr_net_sock_set_ref", + "net.serverSetRef": "scr_net_server_set_ref", "net.sockWrite": "scr_net_sock_write_str", "net.sockWriteBytes": "scr_net_sock_write_bytes", "net.sockEnd": "scr_net_sock_end", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 19918d6e4..050fe9be3 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -1920,6 +1920,18 @@ function lowerNetServerMethodCall( loc, }; } + if ((name === "ref" || name === "unref") && args.length === 0) { + // Whether the server keeps the event loop alive. + requireStatementPosition(lowerer, call, `server.${name}()`); + const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); + return { + kind: "libCall", + fn: "net.serverSetRef", + args: [receiver, boolLit(name === "ref", loc)], + type: VOID, + loc, + }; + } if (name === "address") { // The full AddressInfo record (the dgram.address materialization). // The composed `server.address().port` read already lowered through @@ -1964,6 +1976,18 @@ function lowerNetSocketMethodCall( const name = access.name.text; const loc = locOf(call); const args = call.arguments; + if ((name === "ref" || name === "unref") && args.length === 0) { + // Whether the socket keeps the event loop alive. + requireStatementPosition(lowerer, call, `socket.${name}()`); + const receiver = lowerer.lowerExpr(access.expression); + return { + kind: "libCall", + fn: "net.sockSetRef", + args: [receiver, boolLit(name === "ref", loc)], + type: VOID, + loc, + }; + } if (name === "write" || name === "end") { requireStatementPosition(lowerer, call, `socket.${name}(...)`); // write(chunk, encoding) — the two-argument encoding form: 'buffer' diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 21ae07caa..3370b157a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1137,6 +1137,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ }, "net.serverPort": { argTypes: [NETSERVER_T], result: F64 }, "net.serverListening": { argTypes: [NETSERVER_T], result: BOOL }, + /** socket.ref()/unref() and server.ref()/unref(): whether the handle + * keeps the event loop alive. */ + "net.sockSetRef": { argTypes: [NETSOCKET_T, BOOL], result: VOID }, + "net.serverSetRef": { argTypes: [NETSERVER_T, BOOL], result: VOID }, // net.serverAddress's record result is shape-checked in the libCall case // (the dgram.address sentinel pattern). /** server.address() as the full AddressInfo record (the dgram.address diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5595-net-ref-unref.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5595-net-ref-unref.ts.json new file mode 100644 index 000000000..40fc6e135 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5595-net-ref-unref.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5595-net-ref-unref.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 66ebfc892..54288ac4c 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3340,6 +3340,12 @@ static SCR_TL bool (*scr_net_pending_fn)(void) = NULL; static SCR_TL void (*scr_net_dispatch_fn)(void) = NULL; static SCR_TL int (*scr_net_pollfd_fn)(void) = NULL; +/* Net io without liveness: unref'd sockets and servers still deliver + * events while the loop runs for other work, but do not keep it alive. */ +static SCR_TL bool (*scr_net_io_fn)(void) = NULL; + +void scr_loop_set_net_io(bool (*io)(void)) { scr_net_io_fn = io; } + void scr_loop_set_net(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void)) { scr_net_pending_fn = pending; scr_net_dispatch_fn = dispatch; @@ -3621,6 +3627,9 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { bool kids = scr_children_pending(); bool events = scr_events_pending_fn != NULL && scr_events_pending_fn(); bool net = scr_net_pending_fn != NULL && scr_net_pending_fn(); + /* `net` keeps the process alive; `net_io` (unref'd handles too) decides + * whether the sleep polls the net unit's fd */ + bool net_io = net || (scr_net_io_fn != NULL && scr_net_io_fn()); bool dgram = scr_dgram_pending_fn != NULL && scr_dgram_pending_fn(); bool watch = scr_watch_pending_fn != NULL && scr_watch_pending_fn(); bool ffi = scr_ffi_pending_fn != NULL && scr_ffi_pending_fn(); @@ -3674,12 +3683,12 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { #endif bool evw = scr_events_watching_fn != NULL && scr_events_watching_fn(); #ifdef SCR_WORKERS - if (workers && scr_workers_wait_fn && !kids && !evw && !net && !dgram && !watch && !ffi && !renames && !crypto) { + if (workers && scr_workers_wait_fn && !kids && !evw && !net_io && !dgram && !watch && !ffi && !renames && !crypto) { scr_workers_wait_fn(due > now ? due - now : 0); now = scr_now_ms(); } else #endif - if (evw || net || dgram || watch || ffi || workers) { + if (evw || net_io || dgram || watch || ffi || workers) { #if defined(_WIN32) || defined(__wasi__) /* The win32 arm, and WASI hosts whose poll_oneoff adapters do not * reliably wake for a closed inherited stdin pipe: the sleep is a capped nanosleep and @@ -3693,7 +3702,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * show up in a profile, the upgrade is a real waitable arm — * WaitForMultipleObjects over WSAEVENTs, or IOCP. */ if (evw && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS; - if ((net || dgram || watch) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; + if ((net_io || dgram || watch) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; if ((ffi || workers) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; if (kids && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; if (due > now) { @@ -3720,7 +3729,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { fds[nfds].events = POLLIN; fds[nfds++].revents = 0; } - if (net) { + if (net_io) { /* The net unit's poller fd: readable while socket events are * pending, so arrived data/accepts end the sleep immediately. * Dispatch (which also DRAINS the poller) happens at the next diff --git a/packages/runtime/src/scr_http2.h b/packages/runtime/src/scr_http2.h index 7e8d944de..fdcd741e3 100644 --- a/packages/runtime/src/scr_http2.h +++ b/packages/runtime/src/scr_http2.h @@ -199,6 +199,7 @@ void scr_tls_fetch_client_wrap(ScrNetSocket *sock, void *ctx); * events hook, and `pollfd` exposes the unit's poller fd so the idle * poll(2) sleep wakes on socket readiness (-1 = no fd yet). */ void scr_loop_set_net(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void)); +void scr_loop_set_net_io(bool (*io)(void)); /* True when fibers are queued on the microtask ready queue — dispatch * hooks use it to yield between event batches so promise jobs interleave * (net's sweep/drain alternation). */ diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 225391c35..461acbedc 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -365,6 +365,7 @@ struct ScrNetServer { int fd; /* -1 while not listening */ int port; bool listening; + bool unrefd; /* server.unref(): does not keep the loop alive */ bool closing; /* close() called; 'close' fires when conns drain */ bool close_emitted; /* settled: off the registry, listeners dropped */ bool emit_listening; @@ -420,6 +421,7 @@ struct ScrNetSocket { int kind; /* FIRST: poller udata routes on it */ size_t rc; int fd; /* -1 once closed */ + bool unrefd; /* socket.unref(): does not keep the loop alive */ bool connecting; bool rd_eof; /* peer FIN seen (or read half abandoned) */ bool enc_utf8; /* setEncoding('utf8'): 'data' delivers strings */ @@ -3039,11 +3041,24 @@ static void scr_net_sweep(void) { /* ── the loop hooks (scr_async.c) ────────────────────────────────────── */ +/* Loop liveness: a ref'd server or socket, or pending protocol work. */ static bool scr_net_pending(void) { - if (scr_net_servers != NULL || scr_net_socks != NULL) return true; + for (ScrNetServer *srv = scr_net_servers; srv; srv = srv->next) + if (!srv->unrefd) return true; + for (ScrNetSocket *sock = scr_net_socks; sock; sock = sock->next) + if (!sock->unrefd) return true; return scr_net_proto_pending(); } +/* Any handle, ref'd or not: the loop polls the net fd while it runs. */ +static bool scr_net_io(void) { return scr_net_servers != NULL || scr_net_socks != NULL; } + +/* socket.ref() / socket.unref() and the server pair: whether the handle + * keeps the event loop alive (Node's default is ref'd). */ +void scr_net_sock_set_ref(ScrNetSocket *s, bool ref) { s->unrefd = !ref; } +void scr_net_server_set_ref(ScrNetServer *s, bool ref) { s->unrefd = !ref; } +bool scr_net_sock_has_ref(ScrNetSocket *s) { return !s->unrefd; } + static int scr_net_pollfd(void) { return scr_net_poller != NULL ? scrp_poller_fd(scr_net_poller) : -1; } @@ -3203,6 +3218,7 @@ void scr_net_install(void) { installed = true; scr_atexit(scr_net_cleanup_atexit); scr_loop_set_net(&scr_net_pending, &scr_net_dispatch, &scr_net_pollfd); + scr_loop_set_net_io(&scr_net_io); } /* ── checked-dynamic handle dispatch (SCR_DYNH_NET_SOCKET ops) ───────── @@ -3385,13 +3401,17 @@ static ScrDyn *scr_net_dynh_sock_invoke(void *h, ScrDyn *self, const char *metho scr_str_release(str); return d; } + if (strcmp(method, "ref") == 0 || strcmp(method, "unref") == 0) { + scr_net_sock_set_ref(s, method[0] == 'r'); + return scr_dyn_retain(self); + } if (scr_net_dynh_tls != NULL) { ScrDyn *out = NULL; if (scr_net_dynh_tls->invoke(s, method, args, argc, &out)) return out; } { static const char *const known[] = { "connect", "setKeepAlive", - "address", "ref", "unref", "cork", "uncork", "read", "unpipe", + "address", "cork", "uncork", "read", "unpipe", "off", "removeListener", "removeAllListeners", "emit", "prependListener", "prependOnceListener", "listenerCount", "listeners", "resetAndDestroy", NULL }; for (size_t i = 0; known[i]; i++) { @@ -3619,6 +3639,10 @@ static ScrDyn *scr_net_dynh_srv_invoke(void *h, ScrDyn *self, const char *method scr_dyn_arg_type_fail("socket", "an instance of Socket", sockd); return NULL; } + if (strcmp(method, "ref") == 0 || strcmp(method, "unref") == 0) { + scr_net_server_set_ref((ScrNetServer *)h, method[0] == 'r'); + return scr_dyn_retain(self); + } if (strcmp(method, "toString") == 0) { ScrStr *str = scr_str_new("[object Object]", 15); ScrDyn *d = scr_dyn_new_str(str); @@ -3626,7 +3650,7 @@ static ScrDyn *scr_net_dynh_srv_invoke(void *h, ScrDyn *self, const char *method return d; } { - static const char *const known[] = { "ref", "unref", "setTimeout", "getConnections", + static const char *const known[] = { "setTimeout", "getConnections", "off", "removeListener", "removeAllListeners", "emit", "prependListener", "prependOnceListener", "listenerCount", "listeners", "closeAllConnections", "closeIdleConnections", NULL }; diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index 668d3014c..117a242c7 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -137,6 +137,9 @@ void scr_net_listen_opts_reuse_port(ScrNetServer *s, double port, ScrStr *host / ScrClosure *cb /*moves, nullable*/); double scr_net_server_port(ScrNetServer *s); /* address().port */ bool scr_net_server_listening(ScrNetServer *s); +void scr_net_sock_set_ref(ScrNetSocket *s, bool ref); +void scr_net_server_set_ref(ScrNetServer *s, bool ref); +bool scr_net_sock_has_ref(ScrNetSocket *s); /* Writable http.Server timeout property storage. `field` is the compiler * ABI selector: timeout, keepAliveTimeout, headersTimeout, requestTimeout, * keepAliveTimeoutBuffer. Typed reads validate that no dynamic write left diff --git a/tests/corpus/5595-net-ref-unref.ts b/tests/corpus/5595-net-ref-unref.ts new file mode 100644 index 000000000..331941314 --- /dev/null +++ b/tests/corpus/5595-net-ref-unref.ts @@ -0,0 +1,30 @@ +// socket.unref() and server.unref() let the process exit while the handle +// is open, and ref() undoes it. An unref'd socket still delivers its events +// while other work (here a timer) keeps the loop running. +import * as net from "node:net"; +import * as http from "node:http"; + +const server = net.createServer((socket) => { + socket.unref(); + socket.on("data", (d: Buffer) => socket.write(`echo ${d.toString()}`)); +}); +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as { port: number }).port; + server.unref(); + const client = net.connect({ port, host: "127.0.0.1" }, () => { + client.write("hi"); + }); + client.on("data", (d: Buffer) => { + console.log("client got", d.toString()); + client.unref(); + setTimeout(() => console.log("timer still ran"), 50); + }); + const web = http.createServer((req, res) => res.end("ok")); + web.listen(0, "127.0.0.1", () => { + web.unref(); + web.ref(); + web.unref(); + console.log("web unref'd"); + }); +}); +process.on("exit", () => console.log("exit")); From 3b379bf62390d237409c2e1456066193f0351f79 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 06:50:27 -0500 Subject: [PATCH 4/7] Convert nullable records into widened record slots A record parameter of a function the record-flow analysis does not track widens to the fields its own arguments can leave undefined, and since the flow-class change those arguments include object literals and call results. Another call can pass that parameter a value stored as `R | undefined`: a for-of element whose loop the element analysis cannot prove free of holes binds `R | undefined` while the checker types it `R`. Coercing that union into the widened layout of R found no mapping and failed with SC2003, where the declared parameter used to take the checked extraction of the record arm. A union of one record arm and unit arms flowing into a record that arm lifts to now takes the same checked extraction, then the record's width lift into the destination layout. --- .../compiler/src/frontend/lowering/lowerer.ts | 16 +++++++++ ...-record-boundary-nullable-argument.ts.json | 6 ++++ .../5333-record-boundary-nullable-argument.ts | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5333-record-boundary-nullable-argument.ts.json create mode 100644 tests/corpus/5333-record-boundary-nullable-argument.ts diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 27726a818..b490d42ff 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -7906,6 +7906,22 @@ export class Lowerer { return this.checkedNarrowCall(helper, expr, expected, expr.loc); } } + // A record arm beside unit arms flowing into another layout of that + // record (`R | undefined` into the runtime-optional variant of R): the + // same checked extraction of the record arm, then the record's width + // lift into the destination layout. + if (expr.type.kind === "union" && expected.kind === "record") { + const arms = this.unions.get(expr.type.unionId)?.arms ?? []; + const records = arms.filter((arm) => !isUnitType(arm)); + const arm = records.length === 1 && records[0]!.kind === "record" ? records[0]! : null; + const lift = arm ? this.widthLiftPlan(arm, expected) : null; + const helper = + arm && lift ? this.narrowedArmHelper(expr.type.unionId, arm, expr.loc) : null; + if (arm && lift && helper) { + const narrowed = this.checkedNarrowCall(helper, expr, arm, expr.loc); + return this.applyWidthLift(lift, narrowed, expected, expr.loc); + } + } if (!typeEquals(expr.type, expected)) { const w = this.widthCoerce(expr, expected); if (w) return w; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5333-record-boundary-nullable-argument.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5333-record-boundary-nullable-argument.ts.json new file mode 100644 index 000000000..0cf49e98a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5333-record-boundary-nullable-argument.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5333-record-boundary-nullable-argument.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5333-record-boundary-nullable-argument.ts b/tests/corpus/5333-record-boundary-nullable-argument.ts new file mode 100644 index 000000000..d3d007b75 --- /dev/null +++ b/tests/corpus/5333-record-boundary-nullable-argument.ts @@ -0,0 +1,33 @@ +// A record parameter widened from a literal argument also receives a loop +// element whose storage admits undefined. The element converts to the +// widened layout after the usual checked extraction of its record arm. +interface Item { + name: string; + kind: number; +} +class Bag { + readonly names = new Map(); + add(entry: Item): void { + const existing = this.names.get(entry.name); + if (existing === undefined || existing.kind < entry.kind) this.names.set(entry.name, entry); + } +} +const seen = new Set(); +seen.add("x"); +const parts: string[] = ["a", "d"]; +const bag = new Bag(); +const at = Number(process.argv.length > 99); +bag.add({ name: parts[at], kind: 1 }); +function grow(xs: T[], index: number, value: T): T[] { + xs[index] = value; + return xs; +} +const sparse = grow([], 4, 1); +const groups = [ + { results: [{ name: "b", kind: 2 }, { name: "c", kind: 3 }] }, + { results: [{ name: "b", kind: 5 }] }, +]; +for (const g of groups) for (const res of g.results) bag.add(res); +const order: string[] = []; +for (const [name, entry] of bag.names) order.push(name + ":" + entry.kind); +console.log(order.join(","), sparse.length, seen.size); From 0b468e40a1691f2f5ed68e866b1c9f8a373a3f31 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 07:59:19 -0500 Subject: [PATCH 5/7] Add fs.globSync, fs.glob and fs/promises glob The three glob entry points run a port of Node 24's lib/internal/fs/glob.js walk in scr_glob.c: the same LIFO queue of (path, patterns) items over the sorted directory listings, the same lstat, stat, readdir, realpath and seen-pattern caches, the same result set in insertion order, and the same exclusion checks, so matches come out in Node's order. Patterns compile with the existing minimatch port. glob_match is split into a compile step and a test step so the walk can reuse each compiled pattern and test it segment by segment. nocaseMagicOnly is now an option, because exclude patterns turn it off. Patterns (a string or string array) and options (cwd as a path or file: URL, exclude as patterns or a function, followSymlinks, withFileTypes) are validated in Node's order with Node's errors, including the internal assertion Node hits for a non-string cwd with exclude patterns. withFileTypes results are Dirent rows: name, parentPath and the type probes. parentPath follows Node's rule for each form: the joined path for globSync, the resolved path for fs/promises glob. fs.glob checks the callback and validates synchronously, then delivers string[] or the walk's error through the filesystem callback queue. fs/promises glob lowers as the source of a for await loop. The walk runs when the loop starts, and the matches found before an error bind before the error is thrown, as Node's async iterator yields them first. The older glob(3)-based fs.glob branch is removed. Corpus 5448-5452 and a node-types fixture check the behavior against Node. --- docs/content/docs/limitations.mdx | 2 + .../content/limitations/builtin-apis/glob.mdx | 1 + .../limitations/builtin-function-values.mdx | 2 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 22 +- .../generated/node-v24-backlog.json | 61 +- .../generated/node-v24-internal.json | 46 +- internal/compatibility/static-support.json | 1 + .../ambient/scriptc-node-fallback.d.ts | 39 + .../src/backend/llvm/lib-filesystem.ts | 63 + .../compiler/src/backend/llvm/lib-shared.ts | 5 + .../src/frontend/lowering/builtins/fs-glob.ts | 268 ++++ .../lowering/builtins/module-calls.ts | 7 + .../lowering/lower-async-iteration.ts | 2 +- .../src/frontend/lowering/lower-stmts.ts | 6 + .../compiler/src/frontend/lowering/lowerer.ts | 3 + .../src/frontend/lowering/surfaces.ts | 8 +- packages/compiler/src/ir/builtin-effects.ts | 5 + .../compiler/src/ir/builtin-signatures.ts | 14 + packages/compiler/src/ir/runtime-features.ts | 10 +- packages/compiler/src/ir/validate.ts | 10 +- packages/compiler/surface-manifest.json | 12 + .../compiler/test/fs-glob-boundaries.test.ts | 53 + .../corpus/5448-fs-glob-patterns.ts.json | 6 + .../tests/corpus/5449-fs-glob-options.ts.json | 6 + .../corpus/5450-fs-glob-validation.js.json | 6 + .../corpus/5451-fs-promises-glob.ts.json | 6 + .../corpus/5452-fs-glob-callback.ts.json | 6 + .../tests/fixtures/node-types/fs-glob.ts.json | 6 + packages/runtime/src/scr_bytes_io.c | 43 +- packages/runtime/src/scr_glob.c | 1109 ++++++++++++++++- packages/runtime/src/scr_inspect.h | 4 + packages/runtime/src/scr_path.h | 27 + packages/runtime/test/test_glob.c | 9 + tests/corpus/5448-fs-glob-patterns.ts | 82 ++ tests/corpus/5449-fs-glob-options.ts | 84 ++ tests/corpus/5450-fs-glob-validation.js | 80 ++ tests/corpus/5451-fs-promises-glob.ts | 74 ++ tests/corpus/5452-fs-glob-callback.ts | 44 + tests/fixtures/node-types/fs-glob.ts | 26 + tests/harness/project-config.test.ts | 14 + 41 files changed, 2168 insertions(+), 106 deletions(-) create mode 100644 docs/content/limitations/builtin-apis/glob.mdx create mode 100644 packages/compiler/src/frontend/lowering/builtins/fs-glob.ts create mode 100644 packages/compiler/test/fs-glob-boundaries.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json create mode 100644 tests/corpus/5448-fs-glob-patterns.ts create mode 100644 tests/corpus/5449-fs-glob-options.ts create mode 100644 tests/corpus/5450-fs-glob-validation.js create mode 100644 tests/corpus/5451-fs-promises-glob.ts create mode 100644 tests/corpus/5452-fs-glob-callback.ts create mode 100644 tests/fixtures/node-types/fs-glob.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index a2f05e50d..2d229bd84 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -76,6 +76,8 @@ title: "Limitations" ../limitations/builtin-apis/filesystem-flags.mdx +../limitations/builtin-apis/glob.mdx + ../limitations/builtin-apis/filehandle-and-descriptor-i-o.mdx ../limitations/builtin-apis/crypto.mdx diff --git a/docs/content/limitations/builtin-apis/glob.mdx b/docs/content/limitations/builtin-apis/glob.mdx new file mode 100644 index 000000000..5df4d6688 --- /dev/null +++ b/docs/content/limitations/builtin-apis/glob.mdx @@ -0,0 +1 @@ +- **Glob:** `fs.globSync`, `fs.glob` and `fs/promises` `glob` support every option. `withFileTypes` must be a literal `true` or `false`, and `exclude` functions cannot be combined with `withFileTypes`. The `fs/promises` iterator is supported only as the source of a `for await` loop, and its walk completes when the loop starts. `fs.glob` callbacks receive file names, not Dirents. diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index d005a509d..fa61ff1e6 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -15,4 +15,4 @@ Other immutable aliases of table-backed Node builtins support direct calls, but Synchronous and promise filesystem APIs with trailing arguments that affect behavior remain call-only. This includes writers, open, directory creation/removal, metadata readers, and directory readers. Passing them as values could discard modes, flags, encodings, or options during function-signature adaptation. -Native error-first filesystem callbacks can be stored and passed to platform adapters. Reads support Buffer and UTF-8 results; writes preserve string flags and modes; copy supports regular files and recursive directories. Reads and writes reject already-aborted signals, but native file operations complete synchronously before the deferred callback, so cancellation cannot interrupt I/O in progress. Symbolic-link copy, copy filters and clone modes, recursive glob patterns, glob exclusions, recursive directory reads, and Dirent results report a native limitation. +Native error-first filesystem callbacks can be stored and passed to platform adapters. Reads support Buffer and UTF-8 results; writes preserve string flags and modes; copy supports regular files and recursive directories. Reads and writes reject already-aborted signals, but native file operations complete synchronously before the deferred callback, so cancellation cannot interrupt I/O in progress. Symbolic-link copy, copy filters and clone modes, recursive directory reads, and Dirent results report a native limitation. `fs.glob` compiles only as a direct call; a stored `fs.glob` value throws `SC2020`. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index c70afba5a..392af99c2 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "28e11bdb8e9a2f58a9c6", + "artifactVersion": "e85ddcb75a2a2988f5a0", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index b46800a39..dcf70d5df 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -314,9 +314,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 128, + "partial": 131, "refused": 0, - "not-implemented": 145, + "not-implemented": 142, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -29690,9 +29690,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -30702,9 +30702,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -31921,9 +31921,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 606e9dd7c..c1e162be9 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 37, - "verify-gap": 2442, - "audit-partial": 906, + "verify-gap": 2439, + "audit-partial": 909, "classify": 2, "implement": 85 } @@ -26014,12 +26014,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fsPromises.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] } } }, @@ -27194,12 +27201,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fs.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] } } }, @@ -28615,12 +28629,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:fs.fs.globSync", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index e9aeb1f8f..dcb6687d2 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -326,9 +326,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 128, + "partial": 131, "refused": 0, - "not-implemented": 145, + "not-implemented": 142, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -43746,6 +43746,11 @@ "tests/corpus/3135-fs-file-handle-controls.ts", "tests/corpus/3136-fs-file-handle-options.ts", "tests/corpus/3137-fs-vector-callbacks.cjs", + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", "tests/corpus/5540-fs-callback-forms.cjs", "tests/corpus/5541-fs-stats-fields.cjs", "tests/corpus/fs-native-realpath.ts", @@ -43765,6 +43770,7 @@ "tests/corpus/node24-fs-timestamps-promises.ts", "tests/corpus/node24-fs-timestamps-symlinks.ts", "tests/corpus/node24-fs-timestamps.ts", + "tests/fixtures/node-types/fs-glob.ts", "tests/fixtures/node-types/fs-links.ts", "tests/fixtures/node-types/fs-statfs.ts", "tests/fixtures/node-types/fs-timestamps.ts" @@ -44792,8 +44798,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fsPromises.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] }, "anchorSource": "exact" }, @@ -45951,8 +45965,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.glob", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] }, "anchorSource": "exact" }, @@ -47453,8 +47475,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "compiler-feature:fs.fs.globSync", + "tests": [ + "tests/corpus/5448-fs-glob-patterns.ts", + "tests/corpus/5449-fs-glob-options.ts", + "tests/corpus/5450-fs-glob-validation.js", + "tests/corpus/5451-fs-promises-glob.ts", + "tests/corpus/5452-fs-glob-callback.ts", + "tests/fixtures/node-types/fs-glob.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1d87bcbc5..bf3c582b5 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -280,6 +280,7 @@ { "chapter": "child_process", "signatures": ["`stdout` Type: {stream.Readable|null|undefined}", "`stderr` Type: {stream.Readable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/1565-spawn-pipe-streams.ts", "tests/corpus/2939-child-shell-text.ts"] }, { "chapter": "child_process", "signatures": ["`stdin` Type: {stream.Writable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2894-child-stdin-backpressure.ts", "tests/corpus/2895-child-stdin-write-after-end.ts", "tests/corpus/2896-child-stdin-write-after-finish.ts", "tests/corpus/2897-child-stdin-destroy.ts", "tests/corpus/2898-child-stdin-spawn-failure.ts"] }, { "chapter": "fs", "signatures": ["`fsPromises.lstat(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2941-fsp-lstat-identity.ts"] }, + { "chapter": "fs", "signatures": ["`fs.globSync(pattern[, options])`", "`fs.glob(pattern[, options], callback)`", "`fsPromises.glob(pattern[, options])`"], "status": "partial", "evidence": ["tests/corpus/5448-fs-glob-patterns.ts", "tests/corpus/5449-fs-glob-options.ts", "tests/corpus/5450-fs-glob-validation.js", "tests/corpus/5451-fs-promises-glob.ts", "tests/corpus/5452-fs-glob-callback.ts", "tests/fixtures/node-types/fs-glob.ts"] }, { "chapter": "process", "signatures": ["`process.send(message[, sendHandle[, options]][, callback])`", "`process.disconnect()`", "`connected` Type: {boolean}", "Event: `'message'`", "Event: `'disconnect'`"], "status": "partial", "evidence": ["tests/corpus/2946-child-fork-ipc/main.ts"] }, { "chapter": "fs", "symbols": ["fs.Stats", "stats.isBlockDevice", "stats.isCharacterDevice", "stats.isDirectory", "stats.isFIFO", "stats.isFile", "stats.isSocket", "stats.isSymbolicLink"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, { "chapter": "fs", "parent": "Class: `fs.Stats`", "signatures": ["`mode` Type: {number|bigint}", "`nlink` Type: {number|bigint}", "`uid` Type: {number|bigint}", "`gid` Type: {number|bigint}", "`rdev` Type: {number|bigint}", "`size` Type: {number|bigint}", "`blksize` Type: {number|bigint}", "`blocks` Type: {number|bigint}", "`atimeMs` Type: {number|bigint}", "`mtimeMs` Type: {number|bigint}", "`birthtimeMs` Type: {number|bigint}", "`atimeNs` Type: {bigint}", "`mtimeNs` Type: {bigint}", "`ctimeNs` Type: {bigint}", "`birthtimeNs` Type: {bigint}", "`atime` Type: {Date}", "`mtime` Type: {Date}", "`ctime` Type: {Date}", "`birthtime` Type: {Date}"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index a7395d9f8..40a3ec87c 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1324,6 +1324,36 @@ declare module "node:fs" { * fences by name at the call site; undocumented keys drop like Node. */ options: { withFileTypes: true; recursive?: boolean; [option: string]: unknown }, ): Dirent[]; + /* fs.globSync / fs.glob: Node's glob walk over the file system. Patterns + * are minimatch globs (case-insensitive on macOS and Windows); results + * are relative to cwd unless the pattern is absolute. withFileTypes must + * be a literal so the result type is known. */ + export interface GlobOptions { + cwd?: string | URL; + exclude?: readonly string[] | ((fileName: string) => boolean); + followSymlinks?: boolean; + withFileTypes?: false; + } + export interface GlobOptionsWithFileTypes { + cwd?: string | URL; + exclude?: readonly string[]; + followSymlinks?: boolean; + withFileTypes: true; + } + export function globSync(pattern: string | readonly string[], options?: GlobOptions): string[]; + export function globSync( + pattern: string | readonly string[], + options: GlobOptionsWithFileTypes, + ): Dirent[]; + export function glob( + pattern: string | readonly string[], + callback: (err: NodeJS.ErrnoException | null, matches?: string[]) => void, + ): void; + export function glob( + pattern: string | readonly string[], + options: GlobOptions, + callback: (err: NodeJS.ErrnoException | null, matches?: string[]) => void, + ): void; export function accessSync(path: string, mode?: number): void; export function mkdtempSync(prefix: string): string; export const constants: { @@ -1686,6 +1716,15 @@ declare module "fs/promises" { [option: string]: unknown; }, ): Promise; + /* The async iterator form of fs.globSync: iterate it with for await. */ + export function glob( + pattern: string | readonly string[], + options?: import("node:fs").GlobOptions, + ): AsyncIterableIterator; + export function glob( + pattern: string | readonly string[], + options: import("node:fs").GlobOptionsWithFileTypes, + ): AsyncIterableIterator; export function rm(path: string): Promise; export function stat(path: string): Promise; export function statfs( diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 3683a3124..ce5c0cfc0 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -381,6 +381,69 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): B.line(`call void @scr_watcher_close(ptr ${w.name})`); return { name: "", type: e.type }; } + if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") { + // Dirent rows from one glob snapshot (scr_glob.c): mode 1 is the + // globSync form, 3 the fs/promises form with resolved parent paths. + if (e.type.kind !== "array" || e.type.elem.kind !== "record") { + throw new InternalCompilerError( + `llvm emitter bug: ${e.fn} result is not a Dirent record array`, + ); + } + const arrayT = e.type; + const recT = e.type.elem; + const pattern = host.emitExpr(e.args[0]!); + const options = host.emitExpr(e.args[1]!); + const mode = e.fn === "fsp.globTypes" ? "3.0" : "1.0"; + host.declare(`declare ptr @scr_fs_glob(ptr, ptr, double)`); + host.declare(`declare ${host.sizeType} @scr_fs_glob_count(ptr)`); + host.declare(`declare ptr @scr_fs_glob_name(ptr, ${host.sizeType})`); + host.declare(`declare ptr @scr_fs_glob_parent(ptr, ${host.sizeType})`); + host.declare(`declare double @scr_fs_glob_type(ptr, ${host.sizeType})`); + host.declare(`declare void @scr_fs_glob_free(ptr)`); + const snap = B.tmp(); + B.line( + `${snap} = call ptr @scr_fs_glob(ptr ${pattern.name}, ptr ${options.name}, double ${mode})`, + ); + host.emitPendingCheck(); + const cnt = B.tmp(); + B.line(`${cnt} = call ${host.sizeType} @scr_fs_glob_count(ptr ${snap})`); + const arr = B.tmp(); + B.line(`${arr} = ${arrNewCall(host.shapeHost, recT, cnt)}`); + const arrayOut = host.own({ name: arr, type: arrayT }); + const iSlot = B.slot(); + B.entryAllocas.push(`${iSlot} = alloca ${host.sizeType}`); + B.line(`store ${host.sizeType} 0, ptr ${iSlot}`); + const lc = B.newLabel("gl.c"); + const lb = B.newLabel("gl.b"); + const le = B.newLabel("gl.e"); + B.br(lc); + B.startBlock(lc); + const i = B.tmp(); + const cont = B.tmp(); + B.line(`${i} = load ${host.sizeType}, ptr ${iSlot}`); + B.line(`${cont} = icmp ult ${host.sizeType} ${i}, ${cnt}`); + B.condBr(cont, lb, le); + B.startBlock(lb); + const row = B.tmp(); + B.line(`${row} = call ptr @${mangleRecordNew(recT.shapeId)}()`); + const dt = B.tmp(); + B.line(`${dt} = call double @scr_fs_glob_type(ptr ${snap}, ${host.sizeType} ${i})`); + host.storeField(host.recordFieldPtr(row, recT.shapeId, "%dtype").ptr, F64, dt, false); + const nm = B.tmp(); + B.line(`${nm} = call ptr @scr_fs_glob_name(ptr ${snap}, ${host.sizeType} ${i}) ; +1`); + B.line(`store ptr ${nm}, ptr ${host.recordFieldPtr(row, recT.shapeId, "name").ptr}`); + const parent = B.tmp(); + B.line(`${parent} = call ptr @scr_fs_glob_parent(ptr ${snap}, ${host.sizeType} ${i}) ; +1`); + B.line(`store ptr ${parent}, ptr ${host.recordFieldPtr(row, recT.shapeId, "parentPath").ptr}`); + host.arrPush(arr, "ref", row); // push takes ownership of the row + const i2 = B.tmp(); + B.line(`${i2} = add ${host.sizeType} ${i}, 1`); + B.line(`store ${host.sizeType} ${i2}, ptr ${iSlot}`); + B.br(lc); + B.startBlock(le); + B.line(`call void @scr_fs_glob_free(ptr ${snap})`); + return arrayOut; + } if (e.fn === "fs.readdirTypesSync" || e.fn === "fsp.readdirTypes") { // Dirent rows assembled inline from one scandir snapshot — the C // emitter's flat loop. The sync form checks a scandir error before diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 33b9fd4ff..0c3a2963c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -325,6 +325,10 @@ export const LIB_FN_SYMS: Record = { "fs.rmRetrySync": "scr_fs_rm_opts_retry", "fs.rmdirSync": "scr_fs_rmdir", "fs.readdirSync": "scr_fs_readdir", + "fs.globSync": "scr_fs_glob_strings", + "fs.callbackGlob": "scr_fs_glob_callback", + "fsp.globStrings": "scr_fsp_glob_strings", + "fsp.globError": "scr_fsp_glob_error", "fs.realpathSync": "scr_fs_realpath", "fs.realpathNativeSync": "scr_fs_realpath_promise", "fs.unlinkSync": "scr_fs_unlink", @@ -1334,6 +1338,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "fs.existsChk", "fs.callbackValue", "fs.callbackCall", + "fs.callbackGlob", "http.createServer", "http.createServerEmpty", "http.request", diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts new file mode 100644 index 000000000..389da8dc0 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts @@ -0,0 +1,268 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { + BOOL, + DYN, + F64, + STRING, + VOID, + arrayOf, + type IrExpr, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../../ir/ir.js"; +import { dynUndefinedExpr, numLit } from "../../../ir/build.js"; +import { InternalCompilerError } from "../../../errors.js"; +import { locOf } from "../../program.js"; +import { checkForAwaitBinding } from "../lower-async-iteration.js"; +import { builtinImportOf } from "./module-bindings.js"; + +/* fs.globSync, fs.glob and fs/promises glob (scr_glob.c, a port of Node's + * lib/internal/fs/glob.js). The pattern and options cross as dyn values so + * the runtime validates them in Node's order. The result form (strings or + * Dirent rows) comes from the call's mapped result type, so withFileTypes + * must be statically known; the runtime checks that the option agrees. */ + +/** The interned Dirent record array (type-mapper.ts), or null. */ +function direntArray(lowerer: Lowerer, type: IrType | null | undefined): boolean { + if (type?.kind !== "array" || type.elem.kind !== "record") return false; + const shape = lowerer.shapes.get(type.elem.shapeId); + return ( + !!shape && + !shape.tuple && + !shape.indexValue && + shape.fields.length === 3 && + shape.fields.some((f) => f.name === "%dtype") + ); +} + +/** An exclude function receives Node's Dirent objects when withFileTypes is + * set, which have no native representation here. */ +function fenceDirentExclude(lowerer: Lowerer, call: ts.CallExpression, what: string): void { + const options = call.arguments[1]; + if (!options) return; + const exclude = lowerer.checker.getPropertyOfType(lowerer.typeOf(options), "exclude"); + if (!exclude) return; + const type = lowerer.checker.getTypeOfSymbolAtLocation(exclude, options); + if (lowerer.checker.getCallSignatures(type).length === 0) return; + lowerer.noLowering( + `${what} with an exclude function and withFileTypes`, + options, + "pass exclude as a string[] of glob patterns, or match on names without withFileTypes", + ); +} + +function globArgs(lowerer: Lowerer, call: ts.CallExpression, what: string): IrExpr[] { + if (call.arguments.some(ts.isSpreadElement)) { + lowerer.noLowering(`${what} with spread arguments`, call, "write the arguments positionally"); + } + return call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)); +} + +/** `globSync(pattern, options?)`: string[] or Dirent[]. */ +export function lowerFsGlobSyncCall( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const args = globArgs(lowerer, call, "fs.globSync"); + const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const types = direntArray(lowerer, callType); + if (types) fenceDirentExclude(lowerer, call, "fs.globSync"); + if (!types && !(callType?.kind === "array" && callType.elem.kind === "string")) { + lowerer.noLowering( + "fs.globSync where the result is not string[] or Dirent[]", + call, + "pass withFileTypes as a literal true or false so the result type is known", + ); + } + return { + kind: "libCall", + fn: types ? "fs.globTypesSync" : "fs.globSync", + args: [args[0] ?? dynUndefinedExpr(loc), args[1] ?? dynUndefinedExpr(loc)], + type: types ? callType! : arrayOf(STRING), + loc, + }; +} + +/** `fs.glob(pattern[, options], callback)`: the argument vector goes to the + * runtime, which validates it and calls back with string[]. */ +export function lowerFsGlobCallbackCall( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const args = globArgs(lowerer, call, "fs.glob"); + return { + kind: "libCall", + fn: "fs.callbackGlob", + args: [{ kind: "dynArrLit", elems: args, type: DYN, loc }], + type: VOID, + loc, + }; +} + +/** True when `node` calls fs/promises glob (any import spelling). */ +export function isFspGlobCall(lowerer: Lowerer, node: ts.Expression): node is ts.CallExpression { + let expr = node; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (!ts.isCallExpression(expr) || expr.questionDotToken) return false; + const callee = expr.expression; + const bi = ts.isIdentifier(callee) + ? builtinImportOf(lowerer, callee) + : ts.isPropertyAccessExpression(callee) && !callee.questionDotToken + ? lowerer.builtinMemberOf(callee) + : null; + return bi?.module === "fs/promises" && bi.member === "glob"; +} + +/** fs/promises `glob(pattern, options?)` as the head of a `for await` loop + * (lowerForOf marks the call): the matches as an array the loop awaits in + * order. The walk runs when the loop starts, like the other fs/promises + * operations, which complete before their continuations. Other uses of + * the async iterator have no lowering. */ +export function lowerFspGlobCall(lowerer: Lowerer, call: ts.CallExpression, loc: SrcLoc): IrExpr { + if (lowerer.fspGlobHead !== call) { + lowerer.noLowering( + "fs/promises glob outside a for await loop", + call, + "iterate the matches directly with for await (const path of glob(pattern)), or use fs.globSync", + ); + } + const args = globArgs(lowerer, call, "fs/promises glob"); + // The async iterator's yielded type (its first type argument). + const yielded = lowerer.checker.getTypeArguments(lowerer.typeOf(call) as ts.TypeReference)[0]; + const element = yielded ? lowerer.mapTypeOf(yielded) : null; + const rows: IrType | null = element ? { kind: "array", elem: element } : null; + const types = direntArray(lowerer, rows); + if (types) fenceDirentExclude(lowerer, call, "fs/promises glob"); + if (!types && element?.kind !== "string") { + lowerer.noLowering( + "fs/promises glob where the yielded value is not a string or a Dirent", + call, + "pass withFileTypes as a literal true or false so the yielded type is known", + ); + } + return { + kind: "libCall", + fn: types ? "fsp.globTypes" : "fsp.globStrings", + args: [args[0] ?? dynUndefinedExpr(loc), args[1] ?? dynUndefinedExpr(loc)], + type: types ? rows! : arrayOf(STRING), + loc, + }; +} + +/** `for await (const path of glob(pattern, options))`: the walk runs when + * the loop starts and its matches bind in order, one microtask hop per + * step. When the walk ended with an error (an exclude callback threw), the + * matches found before it bind first and the error is thrown when the loop + * reaches the end, as Node's async iterator yields them before rejecting; + * leaving the loop early with break or return skips it. */ +export function lowerFspGlobForAwait( + lowerer: Lowerer, + stmt: ts.ForOfStatement, + call: ts.CallExpression, +): IrStmt { + const binding = checkForAwaitBinding(lowerer, stmt); + const loc = locOf(stmt); + lowerer.fspGlobHead = call; + let rowsExpr: IrExpr; + try { + rowsExpr = lowerer.lowerExpr(call); + } finally { + lowerer.fspGlobHead = null; + } + if (rowsExpr.type.kind !== "array") { + throw new InternalCompilerError("fs/promises glob head did not lower to an array"); + } + const rowsT = rowsExpr.type; + const elemT = rowsT.elem; + lowerer.scopes.push(new Map()); + try { + const rows = lowerer.declareHiddenLocal("%globRows", rowsT); + const error = lowerer.declareHiddenLocal("%globError", DYN); + const index = lowerer.declareHiddenLocal("%globIndex", F64); + index.mutable = true; + const item = lowerer.declareLocal( + binding.declaration.name, + binding.declaration.name.text, + elemT, + binding.mutable, + ); + const rowsRef = (): IrExpr => ({ kind: "varRef", localId: rows.id, type: rowsT, loc }); + const indexRef = (): IrExpr => ({ kind: "varRef", localId: index.id, type: F64, loc }); + const errorRef = (): IrExpr => ({ kind: "varRef", localId: error.id, type: DYN, loc }); + const body = lowerer.inCtl("loop", () => lowerer.lowerScopedBlock(stmt.statement)); + const loop: IrStmt = { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [ + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "async.hop", args: [], type: VOID, loc }, + loc, + }, + { + kind: "if", + cond: { + kind: "unary", + op: "!", + operand: { kind: "arrayHas", arr: rowsRef(), index: indexRef(), type: BOOL, loc }, + type: BOOL, + loc, + }, + then: [ + { + kind: "if", + cond: { + kind: "unary", + op: "!", + operand: { kind: "dynTest", test: "undefined", value: errorRef(), type: BOOL, loc }, + type: BOOL, + loc, + }, + then: [{ kind: "throw", value: errorRef(), loc }], + else_: null, + loc, + }, + { kind: "break", loc }, + ], + else_: null, + loc, + }, + { + kind: "varDecl", + localId: item.id, + init: { kind: "arrayGet", arr: rowsRef(), index: indexRef(), type: elemT, loc }, + loc, + }, + { + kind: "assign", + localId: index.id, + value: { kind: "bin", op: "+", left: indexRef(), right: numLit(1, loc), type: F64, loc }, + loc, + }, + ...body, + ], + loc, + }; + return { + kind: "block", + body: [ + { kind: "varDecl", localId: rows.id, init: rowsExpr, loc }, + { + kind: "varDecl", + localId: error.id, + init: { kind: "libCall", fn: "fsp.globError", args: [], type: DYN, loc }, + loc, + }, + { kind: "varDecl", localId: index.id, init: numLit(0, loc), loc }, + loop, + ], + loc, + }; + } finally { + lowerer.scopes.pop(); + } +} diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index 5c5fbc31f..2512de466 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -42,6 +42,7 @@ import { lowerPathParseCall, } from "./operating-system.js"; import { lowerQuerystringParseCall, lowerQuerystringStringifyCall } from "./querystring.js"; +import { lowerFsGlobCallbackCall, lowerFsGlobSyncCall, lowerFspGlobCall } from "./fs-glob.js"; export function lowerBuiltinModuleCall( lowerer: Lowerer, @@ -59,6 +60,12 @@ export function lowerBuiltinModuleCall( if (bi.module === "buffer" && ["isAscii", "isUtf8", "transcode"].includes(bi.member)) { return lowerBufferEncodingCall(lowerer, expr, bi, fn, loc); } + if (bi.module === "fs" && bi.member === "glob") + return lowerFsGlobCallbackCall(lowerer, expr, loc); + if (bi.module === "fs" && bi.member === "globSync") + return lowerFsGlobSyncCall(lowerer, expr, loc); + if (bi.module === "fs/promises" && bi.member === "glob") + return lowerFspGlobCall(lowerer, expr, loc); if (fn.fn === "fs.callbackCall" && bi.member !== "rename") { return lowerFsCallbackCall(lowerer, expr, bi, loc); } diff --git a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts index 62b7a89a5..5957afcaa 100644 --- a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts +++ b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts @@ -279,7 +279,7 @@ function classAsyncIteratorPlan( }; } -function checkForAwaitBinding( +export function checkForAwaitBinding( lowerer: Lowerer, stmt: ts.ForOfStatement, ): { declaration: ts.VariableDeclaration & { name: ts.Identifier }; mutable: boolean } { diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 742fdfc3a..5d6305aa8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -129,6 +129,7 @@ import { lowerServerCloseOverrideAssignment, } from "./lower-server.js"; import { lowerUrlAssignment } from "./builtins/url.js"; +import { isFspGlobCall, lowerFspGlobForAwait } from "./builtins/fs-glob.js"; import { isNativeFfiRequire, builtinMemberRequireDecl, @@ -7828,6 +7829,11 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { // A synchronous array iterator is also valid in `for await`. Its values // take the async-from-sync path below; other async sources retain their // dedicated request protocols. + if (stmt.awaitModifier) { + let head: ts.Expression = stmt.expression; + while (ts.isParenthesizedExpression(head)) head = head.expression; + if (isFspGlobCall(lowerer, head)) return lowerFspGlobForAwait(lowerer, stmt, head); + } const awaitArray = !!stmt.awaitModifier && lowerer.mapTypeOf(lowerer.typeOf(stmt.expression))?.kind === "array"; if (stmt.awaitModifier && !awaitArray) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b490d42ff..99c6b4d46 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1819,6 +1819,9 @@ export class Lowerer { /** for-of loops over arrays that can hold neither holes nor undefined: * each pass reads the element payload directly. */ readonly presentElementLoops = new Set(); + /** The fs/promises glob() call lowerForOf is lowering as a for await + * head; other glob() calls have no lowering. */ + fspGlobHead: ts.Node | null = null; /** Capture entries and their origin share one mutable box. Normalize each * entry to the origin so writes and flow proofs stay synchronized. */ readonly runtimeOptionalRoots = new Map(); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index aa7e95e21..5042e2efd 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1037,6 +1037,8 @@ export const BUILTIN_MODULE_FNS: Record< rmSync: { fn: "fs.rmSync", params: [STRING], result: VOID }, rmdirSync: { fn: "fs.rmdirSync", params: [STRING], result: VOID }, readdirSync: { fn: "fs.readdirSync", params: [STRING], result: arrayOf(STRING) }, + // Pattern and options are checked by the runtime (lowerFsGlobSyncCall). + globSync: { fn: "fs.globSync", params: [DYN, DYN], result: arrayOf(STRING) }, statSync: { fn: "fs.statSync", params: [STRING], result: STATS_T }, mkdtempSync: { fn: "fs.mkdtempSync", params: [STRING], result: STRING }, // accessSync's omitted mode completes to 0 (F_OK) in the special case @@ -1190,6 +1192,8 @@ export const BUILTIN_MODULE_FNS: Record< result: { kind: "promise", inner: arrayOf(STRING) }, }, rm: { fn: "fsp.rm", params: [STRING], result: { kind: "promise", inner: VOID } }, + // The async iterator lowers only as a for await head (lowerFspGlobCall). + glob: { fn: "fsp.globStrings", params: [DYN, DYN], result: arrayOf(STRING) }, stat: { fn: "fsp.stat", params: [STRING], result: { kind: "promise", inner: STATS_T } }, realpath: { fn: "fsp.realpath", params: [STRING], result: { kind: "promise", inner: STRING } }, lstat: { fn: "fsp.lstat", params: [STRING], result: { kind: "promise", inner: STATS_T } }, @@ -1772,6 +1776,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< rmSync: ["fs.rmOptsSync", "fs.rmRetrySync"], // The { withFileTypes: true } Dirent form. readdirSync: ["fs.readdirTypesSync"], + globSync: ["fs.globTypesSync"], // The JS-source validation-ladder variant (lowerFsLadderCall): the // real mkdtemp runs when the options leave utf8 semantics. mkdtempSync: ["fs.mkdtempSyncChk"], @@ -1791,6 +1796,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< writeFile: ["fsp.writeFileMode"], // The { withFileTypes: true } Dirent form. readdir: ["fsp.readdirTypes"], + glob: ["fsp.globTypes"], }, crypto: { // The composed randomBytes(n).toString(enc) chain keeps its one-libCall @@ -1885,7 +1891,7 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ id: "node-builtin.fs.callbacks", kind: "node-builtin", name: "fs error-first callbacks", - fns: ["fs.callbackValue", "fs.callbackCall"], + fns: ["fs.callbackValue", "fs.callbackCall", "fs.callbackGlob"], note: "native filesystem callbacks, including stored platform adapter functions", }, // ── the Date slice (lowerDateCall/lowerNew): statics, construction, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 5b0fb6cf3..da7b4072c 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -521,6 +521,11 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.rmdirSync", "fs.readdirSync", "fs.readdirTypesSync", + "fs.globSync", + "fs.globTypesSync", + "fs.callbackGlob", + "fsp.globStrings", + "fsp.globTypes", "url.new", "url.newBase", "url.set", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 3da473720..f838a8ad0 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -444,6 +444,20 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * sorts), the directory's order on Windows; no "."/"..". Throws * Node's scandir errno error (may-throw seed set); fresh +1 array. */ "fs.readdirTypesSync": { argTypes: [STRING], result: VOID }, + /** fs.globSync / fs.glob / fs/promises glob (scr_glob.c, Node's + * lib/internal/fs/glob.js walk): pattern and options are dyn values the + * runtime validates in Node's order. The string forms answer string[]; + * the Dirent forms answer the call site's Dirent record array (validated + * in validate.ts) assembled from the snapshot; fs.callbackGlob takes the + * argument vector and calls back with string[]. */ + "fs.globSync": { argTypes: [DYN, DYN], result: arrayOf(STRING) }, + "fs.globTypesSync": { argTypes: [DYN, DYN], result: VOID }, + "fs.callbackGlob": { argTypes: [DYN], result: VOID }, + "fsp.globStrings": { argTypes: [DYN, DYN], result: arrayOf(STRING) }, + "fsp.globTypes": { argTypes: [DYN, DYN], result: VOID }, + /** The error an fs/promises glob walk ended with (undefined when none), + * read once right after fsp.globStrings / fsp.globTypes. */ + "fsp.globError": { argTypes: [], result: DYN }, /** node:path (scr_path.c ports BOTH of Node's implementations * function-by-function): the `path.*` family is posix, the * `path.win32*` family is Node v24's path.win32 ported exactly — the diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index ba09143ba..3c50b5bb5 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -43,6 +43,7 @@ export interface RuntimeFeatures { const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "fs.callbackValue", "fs.callbackCall", + "fs.callbackGlob", "async.awaitDyn", "timers.immediatePromise", "crypto.native", @@ -171,7 +172,14 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run fn === "dyn.nativeRegexIs" || fn === "util.stripVTControlCharacters" || fn === "path.matchesGlob" || - fn === "path.win32MatchesGlob" + fn === "path.win32MatchesGlob" || + // fs.glob's walk compiles its patterns with the glob matcher. + fn === "fs.globSync" || + fn === "fs.globTypesSync" || + fn === "fs.callbackGlob" || + fn === "fsp.globStrings" || + fn === "fsp.globTypes" || + fn === "fsp.globError" ) features.regex = true; if ( diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index c639cca05..408ea46cd 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4682,10 +4682,16 @@ function validateFunction( } return true; } - if (e.fn === "fs.readdirTypesSync" || e.fn === "fsp.readdirTypes") { + if ( + e.fn === "fs.readdirTypesSync" || + e.fn === "fsp.readdirTypes" || + e.fn === "fs.globTypesSync" || + e.fn === "fsp.globTypes" + ) { // Result: the interned Dirent record array — {%dtype: f64, // name: string, parentPath: string} rows (canonical field - // order; the structure lowerFsReaddirTypesCall pinned). + // order; the structure lowerFsReaddirTypesCall pinned). The glob + // forms answer the array itself. const result = e.fn === "fsp.readdirTypes" ? e.type.kind === "promise" diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index eaff1b176..4e3863094 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -945,6 +945,12 @@ "name": "fs.futimesSync", "status": "static" }, + { + "id": "node-builtin.fs.globSync", + "kind": "node-builtin", + "name": "fs.globSync", + "status": "static" + }, { "id": "node-builtin.fs.linkSync", "kind": "node-builtin", @@ -994,6 +1000,12 @@ "name": "fs/promises.chmod", "status": "static" }, + { + "id": "node-builtin.fs.promises.glob", + "kind": "node-builtin", + "name": "fs/promises.glob", + "status": "static" + }, { "id": "node-builtin.fs.promises.link", "kind": "node-builtin", diff --git a/packages/compiler/test/fs-glob-boundaries.test.ts b/packages/compiler/test/fs-glob-boundaries.test.ts new file mode 100644 index 000000000..169f40052 --- /dev/null +++ b/packages/compiler/test/fs-glob-boundaries.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function compileSource(source: string) { + const directory = mkdtempSync(join(tmpdir(), "scriptc-fs-glob-")); + try { + const entry = join(directory, "main.ts"); + writeFileSync(entry, source); + return await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +// Forms whose result type or element values have no native representation +// fail during compilation. +test.each([ + [ + "fs/promises glob outside a for await loop", + 'import { glob } from "node:fs/promises";\nconst matches = glob("*");\nvoid matches;\n', + ], +])("%s fails during compilation", async (what, source) => { + const result = await compileSource(source); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an fs.glob diagnostic"); + expect(result.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining(what) })]), + ); + expect(result.diagnostics.some((diagnostic) => diagnostic.code === "SC9001")).toBe(false); +}); + +test("the fs.glob forms compile", async () => { + const result = await compileSource(`import { globSync, glob as globCallback } from "node:fs"; +import { glob } from "node:fs/promises"; +console.log(globSync("**/*.ts", { cwd: ".", exclude: ["node_modules/**"] }).length); +console.log(globSync(["*.md"], { exclude: (path) => path.startsWith(".") }).length); +console.log(globSync("*", { withFileTypes: true }).map((d) => d.isFile()).length); +globCallback("*", (err, matches) => console.log(err, matches?.length)); +async function main(): Promise { + for await (const path of glob("*")) console.log(path); + for await (const entry of glob("*", { withFileTypes: true })) console.log(entry.name, entry.isDirectory()); +} +void main(); +`); + expect(result.ok, JSON.stringify(result.ok ? [] : result.diagnostics)).toBe(true); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json new file mode 100644 index 000000000..a373df5a1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5448-fs-glob-patterns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5448-fs-glob-patterns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json new file mode 100644 index 000000000..802257b14 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5449-fs-glob-options.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5449-fs-glob-options.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json new file mode 100644 index 000000000..d88ba07ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5450-fs-glob-validation.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5450-fs-glob-validation.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json new file mode 100644 index 000000000..0594615d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5451-fs-promises-glob.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5451-fs-promises-glob.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json new file mode 100644 index 000000000..863acaba2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5452-fs-glob-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5452-fs-glob-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json new file mode 100644 index 000000000..7a222498b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-glob.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fs-glob.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 547ba1852..152d0b632 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -23,13 +23,6 @@ static const ScrArrOps scr_bytes_io_bytes_ops = { scr_bytes_retain_v, scr_bytes_ #ifndef O_SYNC #define O_SYNC 0 /* Windows CRT opens have no separate synchronous flag. */ #endif -/* Bionic added glob/globfree in API 28; Android packs target API 26. */ -#if !defined(_WIN32) && !defined(__wasi__) && (!defined(__ANDROID__) || __ANDROID_API__ >= 28) -#include -#define SCR_FS_HAS_GLOB 1 -#else -#define SCR_FS_HAS_GLOB 0 -#endif #ifdef __APPLE__ #include /* lchmod(2) — the fs.lchmodSync ladder's real tail */ #endif @@ -554,7 +547,7 @@ static void scr_fs_cb_fire(ScrClosure *closure) { } #endif -static void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer) { +void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer) { ScrDyn *arguments = scr_dyn_new_arr(); if (scr_exc_pending()) { ScrCaught *caught = scr_exc_take(); @@ -815,36 +808,10 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc scr_dyn_release(force); scr_str_release(destination); } } else if (!strcmp(op, "glob")) { -#if SCR_FS_HAS_GLOB - if (strstr(path->data, "**") || scr_fs_cb_option_bool(ARG(1), "withFileTypes")) { - const char *message = "Recursive glob patterns and Dirent results have no native lowering"; - scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); - goto done; - } - ScrDyn *cwd = scr_fs_cb_option(ARG(1), "cwd"); - ScrDyn *exclude = scr_fs_cb_option(ARG(1), "exclude"); - if (exclude && exclude->kind != SCR_DYN_UNDEF) { - const char *message = "fs.glob exclude options have no native lowering"; - scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); - } else { - ScrStr *pattern = cwd && cwd->kind == SCR_DYN_STR ? scr_fs_cb_join(cwd->v.str, path->data) : scr_str_retain(path); - glob_t matches = {0}; - int rc = glob(pattern->data, 0, NULL, &matches); - if (rc && rc != GLOB_NOMATCH) scr_fs_throw(rc == GLOB_NOSPACE ? ENOMEM : EIO, "glob", path); - else { - result = scr_dyn_new_arr(); - size_t prefix = cwd && cwd->kind == SCR_DYN_STR ? cwd->v.str->len + 1 : 0; - for (size_t i = 0; i < matches.gl_pathc; i++) { - const char *name = matches.gl_pathv[i] + prefix; - scr_dyn_arr_push(result, scr_fs_cb_string(scr_str_new(name, strlen(name)))); - } - } - globfree(&matches); scr_str_release(pattern); - } - scr_dyn_release(cwd); scr_dyn_release(exclude); -#else - scr_fs_throw(ENOSYS, "glob", path); -#endif + /* Direct fs.glob calls lower to scr_fs_glob_callback (scr_glob.c); a + * stored fs.glob value is not supported. */ + const char *message = "fs.glob stored as a value has no native lowering; call fs.glob directly"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); } else if (!strcmp(op, "write") && ARG(1)->kind == SCR_DYN_STR) { /* write(fd, string[, position[, encoding]], callback): a non-number * position writes at the current offset; the encoding defaults to diff --git a/packages/runtime/src/scr_glob.c b/packages/runtime/src/scr_glob.c index b2c966fc7..89949c427 100644 --- a/packages/runtime/src/scr_glob.c +++ b/packages/runtime/src/scr_glob.c @@ -19,9 +19,11 @@ #include "scr_runtime.h" #include +#include #include #include #include +#include /* ── arena ───────────────────────────────────────────────────────────── */ @@ -943,6 +945,7 @@ struct Ast { typedef struct { Arena *a; bool nocase; + bool magic_only; /* nocaseMagicOnly */ } Ctx; static void partv_push(Arena *a, PartV *pv, Part p) { @@ -1601,6 +1604,19 @@ static bool throw_too_long(void) { return false; } +/* glob.toUpperCase() !== glob.toLowerCase(): the segment holds a cased + * letter (ASCII, Latin-1, Greek and Cyrillic, the scripts with case that + * file names commonly use). */ +static bool s_has_case(S s) { + for (size_t i = 0; i < s.n; i++) { + uint16_t c = s.p[i]; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) return true; + if (c >= 0xc0 && c <= 0x24f && c != 0xd7 && c != 0xf7) return true; + if (c >= 0x370 && c <= 0x52f) return true; + } + return false; +} + /* Minimatch.parse for one segment. False after arming an exception. */ static bool parse_segment(Ctx *x, S pattern, Pat *out) { memset(out, 0, sizeof *out); @@ -1621,7 +1637,10 @@ static bool parse_segment(Ctx *x, S pattern, Pat *out) { ast_parse(x, pattern, ast, 0, 0); ast_to_string(x, ast); Source src = ast_to_regexp_source(x, ast, -1); - bool any_magic = src.has_magic || ast->has_magic == 1; + /* AST#toMMPattern: under nocase without nocaseMagicOnly, a segment whose + * upper and lower case differ is matched by a case-insensitive regex. */ + bool any_magic = src.has_magic || ast->has_magic == 1 || + (x->nocase && !x->magic_only && s_has_case(pattern)); if (!any_magic) { out->kind = PAT_STRING; out->str = src.body; @@ -2022,27 +2041,39 @@ static bool match_pattern(Ctx *x, SV *ff, PatSlice pattern, bool windows) { return match_one_plain(x, &file, pattern, fi, pi); } -/* The whole minimatch(path, pattern, options) call. False with an armed - * exception on failure (*threw set). */ -static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, bool *threw) { - *threw = false; - if (pattern.n > 65536) { - *threw = true; - return throw_too_long(); - } - Ctx x = { a, nocase }; +/* A compiled Minimatch: the pattern set (one segment list per brace + * expansion after the optimization phases) and the matching globParts + * strings, which fs.glob's walker uses for its cache keys. */ +typedef struct { + Ctx x; + bool windows; + bool empty; /* the empty pattern: matches only the empty path */ + size_t n; + PatV *set; + SV *parts; +} Matcher; + +/* new Minimatch(pattern, options): false with an armed exception on + * failure. magic_only is nocaseMagicOnly (literal segments stay exact + * strings under nocase). Everything lives in the arena. */ +static bool glob_compile(Arena *a, S pattern, bool windows, bool nocase, bool magic_only, Matcher *m) { + memset(m, 0, sizeof *m); + m->x = (Ctx){ a, nocase, magic_only }; + m->windows = windows; + if (pattern.n > 65536) return throw_too_long(); + Ctx x = m->x; /* windowsPathsNoEscape: backslashes are separators, never escapes. */ B nb; b_init(&nb, a); for (size_t i = 0; i < pattern.n; i++) b_unit(&nb, pattern.p[i] == '\\' ? '/' : pattern.p[i]); S pat = b_done(&nb); - if (!pat.n) return path.n == 0; + if (!pat.n) { + m->empty = true; + return true; + } bool failed = false; SV expanded = brace_expand(a, pat, &failed); - if (failed) { - *threw = true; - return false; - } + if (failed) return false; /* [...new Set(...)] */ SV glob_set = { NULL, 0, 0 }; for (size_t i = 0; i < expanded.n; i++) { @@ -2099,7 +2130,6 @@ static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, b p.kind = PAT_STRING; p.str = s->v[k]; } else if (!parse_segment(&x, s->v[k], &p)) { - *threw = true; return false; } patv_push(a, &pv, p); @@ -2118,14 +2148,25 @@ static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, b } } } - /* match(f) */ + m->n = glob_parts.n; + m->set = set; + m->parts = glob_parts.v; + return true; +} + +/* Minimatch#match(path): false with an armed exception on failure + * (*threw set). Scratch allocations go to the matcher's arena. */ +static bool glob_test(Matcher *m, S path, bool *threw) { + *threw = false; + if (m->empty) return path.n == 0; + Arena *a = m->x.a; B fb; b_init(&fb, a); - for (size_t i = 0; i < path.n; i++) b_unit(&fb, windows && path.p[i] == '\\' ? '/' : path.p[i]); - SV ff = slash_split(a, b_done(&fb), windows); - for (size_t g = 0; g < glob_parts.n; g++) { - PatSlice pattern = { set[g].v, set[g].n }; - if (match_pattern(&x, &ff, pattern, windows)) return true; + for (size_t i = 0; i < path.n; i++) b_unit(&fb, m->windows && path.p[i] == '\\' ? '/' : path.p[i]); + SV ff = slash_split(a, b_done(&fb), m->windows); + for (size_t g = 0; g < m->n; g++) { + PatSlice pattern = { m->set[g].v, m->set[g].n }; + if (match_pattern(&m->x, &ff, pattern, m->windows)) return true; if (scr_exc_pending()) { *threw = true; return false; @@ -2134,6 +2175,17 @@ static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, b return false; } +/* The whole minimatch(path, pattern, options) call. False with an armed + * exception on failure (*threw set). */ +static bool glob_match(Arena *a, S path, S pattern, bool windows, bool nocase, bool *threw) { + Matcher m; + if (!glob_compile(a, pattern, windows, nocase, true, &m)) { + *threw = true; + return false; + } + return glob_test(&m, path, threw); +} + /* ── entry points ────────────────────────────────────────────────────── */ static bool scr_glob_host_nocase(void) { @@ -2161,3 +2213,1016 @@ bool scr_path_matches_glob(ScrStr *path, ScrStr *pattern) { bool scr_path_win32_matches_glob(ScrStr *path, ScrStr *pattern) { return scr_glob_match(path, pattern, true, scr_glob_host_nocase()); } +/* ── fs.glob / fs.globSync / fs/promises glob ──────────────────────────── + * A port of Node 24.22's lib/internal/fs/glob.js Glob class: the same + * walk over a LIFO queue of (path, patterns) items, the same caches + * (lstat, stat, readdir, realpath, seen pattern suffixes), the same result + * set in insertion order, and the same exclusion checks. readdir listings + * come from scr_fs_scandir, which sorts them like libuv, so results come + * out in Node's order. All strings the walk creates are kept in one pool + * and released together. */ + +#ifdef _WIN32 +#define GW_WINDOWS true +#else +#define GW_WINDOWS false +#endif + +typedef struct { + ScrStr **v; + size_t n, cap; +} GwPool; + +/* One lstat (or readdir entry) result: Node's Dirent kinds (1 file, 2 dir, + * 3 link, 0 other or unknown). */ +typedef struct { + ScrStr *name; + unsigned char kind; +} GwStat; + +typedef struct { + ScrStr *key; + void *val; +} GwSlot; + +/* A string-keyed map that keeps insertion order (JS Map semantics for + * iteration). */ +typedef struct { + GwSlot *slots; + size_t cap, n; + ScrStr **order; + size_t on, ocap; +} GwMap; + +typedef struct { + size_t *v; + size_t n, cap; +} GwIdx; /* a Set of indexes in insertion order */ + +typedef struct { + ScrStr **v; + size_t n; +} GwReal; /* realpaths seen on the way down (followSymlinks) */ + +typedef struct { + const PatV *pat; + const SV *parts; + GwIdx indexes; + GwIdx symlinks; + const GwReal *realpaths; + long last; +} GwPat; + +typedef struct { + GwPat **v; + size_t n, cap; +} GwPats; + +typedef struct { + ScrStr *path; + GwPats pats; +} GwItem; + +struct ScrFsGlob { + size_t n, cap; + ScrStr **paths; + ScrStr **names; + ScrStr **parents; + unsigned char *kinds; +}; + +typedef struct { + Arena a; + GwPool pool; + Ctx ctx; /* the include patterns' case mode, for segment tests */ + ScrStr *root; + bool follow; + bool with_types; + bool async_types; /* fs/promises Dirents carry resolved parent paths */ + ScrDyn *exclude_fn; + Matcher *exclude; + size_t nexclude; + GwPat **patterns; + size_t npatterns; + GwMap stats, follow_stats, readdirs, realpaths, seen, subpatterns; + GwMap result_set; + ScrStr **results; + size_t nresults, capresults; + GwItem *queue; + size_t nqueue, capqueue; +} Gw; + +static void gw_oom(void) { scr_trap("scriptc: out of memory\n"); } + +/* The error an fs/promises glob walk ended with, held until the loop has + * consumed the matches found before it (scr_fsp_glob_error). */ +static SCR_TL ScrCaught *scr_fs_glob_deferred; + +static void *gw_grow(void *v, size_t *cap, size_t need, size_t size) { + if (need <= *cap) return v; + size_t c = *cap ? *cap * 2 : 8; + while (c < need) c *= 2; + void *g = realloc(v, c * size); + if (!g) gw_oom(); + *cap = c; + return g; +} + +static ScrStr *gw_keep(Gw *w, ScrStr *s) { + w->pool.v = gw_grow(w->pool.v, &w->pool.cap, w->pool.n + 1, sizeof *w->pool.v); + w->pool.v[w->pool.n++] = s; + return s; +} + +static ScrStr *gw_cstr(Gw *w, const char *s) { return gw_keep(w, scr_str_new(s, strlen(s))); } + +static bool gw_eq_c(const ScrStr *s, const char *c) { + size_t n = strlen(c); + return s->len == n && memcmp(s->data, c, n) == 0; +} + +/* ── path.join / resolve / dirname / basename / isAbsolute ────────────── */ + +static ScrStr *gw_path2(Gw *w, ScrStr *x, ScrStr *y, bool resolve) { + ScrArr *parts = scr_arr_new(SCR_ELEM_STR, 2); + scr_arr_push_ref(parts, scr_str_retain(x)); + scr_arr_push_ref(parts, scr_str_retain(y)); +#ifdef _WIN32 + ScrStr *out = resolve ? scr_path_win32_resolve(parts) : scr_path_win32_join(parts); +#else + ScrStr *out = resolve ? scr_path_resolve(parts) : scr_path_join(parts); +#endif + scr_arr_release(parts); + return gw_keep(w, out); +} + +static ScrStr *gw_join(Gw *w, ScrStr *x, ScrStr *y) { return gw_path2(w, x, y, false); } + +static ScrStr *gw_resolve(Gw *w, ScrStr *x, ScrStr *y) { return gw_path2(w, x, y, true); } + +static ScrStr *gw_dirname(Gw *w, ScrStr *p) { +#ifdef _WIN32 + return gw_keep(w, scr_path_win32_dirname(p)); +#else + return gw_keep(w, scr_path_dirname(p)); +#endif +} + +static ScrStr *gw_basename(Gw *w, ScrStr *p) { + ScrStr *none = scr_str_new("", 0); +#ifdef _WIN32 + ScrStr *out = scr_path_win32_basename(p, none); +#else + ScrStr *out = scr_path_basename(p, none); +#endif + scr_str_release(none); + return gw_keep(w, out); +} + +static bool gw_is_absolute(ScrStr *p) { +#ifdef _WIN32 + return scr_path_win32_is_absolute(p); +#else + return scr_path_is_absolute(p); +#endif +} + +/* ── maps ─────────────────────────────────────────────────────────────── */ + +static size_t gw_hash(const ScrStr *k) { + size_t h = 1469598103934665603u; + for (size_t i = 0; i < k->len; i++) h = (h ^ (unsigned char)k->data[i]) * 1099511628211u; + return h; +} + +static GwSlot *gw_slot(GwMap *m, const ScrStr *key) { + if (!m->cap) return NULL; + size_t i = gw_hash(key) & (m->cap - 1); + for (;;) { + GwSlot *s = &m->slots[i]; + if (!s->key) return s; + if (s->key->len == key->len && memcmp(s->key->data, key->data, key->len) == 0) return s; + i = (i + 1) & (m->cap - 1); + } +} + +static void **gw_get(GwMap *m, const ScrStr *key) { + GwSlot *s = gw_slot(m, key); + return s && s->key ? &s->val : NULL; +} + +/* Map#set: replaces the value of an existing key, keeping its position. */ +static void gw_put(GwMap *m, ScrStr *key, void *val) { + if ((m->n + 1) * 2 > m->cap) { + size_t cap = m->cap ? m->cap * 2 : 16; + GwSlot *old = m->slots; + size_t oldcap = m->cap; + m->slots = calloc(cap, sizeof *m->slots); + if (!m->slots) gw_oom(); + m->cap = cap; + for (size_t i = 0; i < oldcap; i++) + if (old[i].key) *gw_slot(m, old[i].key) = old[i]; + free(old); + } + GwSlot *s = gw_slot(m, key); + if (!s->key) { + s->key = key; + m->n++; + m->order = gw_grow(m->order, &m->ocap, m->on + 1, sizeof *m->order); + m->order[m->on++] = key; + } + s->val = val; +} + +static void gw_map_clear(GwMap *m) { + if (m->cap) memset(m->slots, 0, m->cap * sizeof *m->slots); + m->n = 0; + m->on = 0; +} + +static void gw_map_free(GwMap *m) { + free(m->slots); + free(m->order); +} + +/* ── index sets and patterns ──────────────────────────────────────────── */ + +static bool gw_idx_has(const GwIdx *s, size_t i) { + for (size_t k = 0; k < s->n; k++) + if (s->v[k] == i) return true; + return false; +} + +static void gw_idx_add(Gw *w, GwIdx *s, size_t i) { + if (gw_idx_has(s, i)) return; + if (s->n == s->cap) { + size_t cap = s->cap ? s->cap * 2 : 4; + size_t *v = ar_alloc(&w->a, cap * sizeof *v); + if (s->n) memcpy(v, s->v, s->n * sizeof *v); + s->v = v; + s->cap = cap; + } + s->v[s->n++] = i; +} + +static GwIdx gw_idx_one(Gw *w, size_t i) { + GwIdx s = { NULL, 0, 0 }; + gw_idx_add(w, &s, i); + return s; +} + +static GwPat *gw_pat_child(Gw *w, const GwPat *p, GwIdx indexes, GwIdx symlinks, const GwReal *realpaths) { + GwPat *c = ar_alloc(&w->a, sizeof *c); + *c = *p; + c->indexes = indexes; + c->symlinks = symlinks; + c->realpaths = realpaths; + return c; +} + +/* pattern.at(i), negative indexes from the end; NULL past either end. */ +static const Pat *gw_at(const GwPat *p, long i) { + long n = (long)p->pat->n; + if (i < 0) i += n; + return i >= 0 && i < n ? &p->pat->v[i] : NULL; +} + +static bool gw_is_globstar(const Pat *p) { return p && p->kind == PAT_GLOBSTAR; } +static bool gw_is_string(const Pat *p) { return p && p->kind == PAT_STRING; } +static bool gw_is_str(const Pat *p, const char *c) { return gw_is_string(p) && s_eq_c(p->str, c); } + +static bool gw_has_seen_symlinks(const GwPat *p) { + for (size_t k = 0; k < p->indexes.n; k++) + if (!gw_idx_has(&p->symlinks, p->indexes.v[k])) return true; + return false; +} + +static bool gw_is_last(const GwPat *p, bool is_directory) { + return gw_idx_has(&p->indexes, (size_t)p->last) || + (gw_is_str(gw_at(p, -1), "") && is_directory && p->last >= 1 && + gw_idx_has(&p->indexes, (size_t)(p->last - 1)) && gw_is_globstar(gw_at(p, -2))); +} + +/* Pattern#test(index, name). */ +static bool gw_test(Gw *w, const GwPat *p, size_t index, ScrStr *name) { + const Pat *seg = gw_at(p, (long)index); + if (!seg) return false; + if (seg->kind == PAT_GLOBSTAR) return true; + S f = s_from_str(&w->a, name); + if (seg->kind == PAT_STRING) return s_eq(seg->str, f); + return pat_test(&w->ctx, seg, f); +} + +/* Pattern#cacheKey(index): the glob strings from index on, '/'-joined. */ +static ScrStr *gw_cache_key(Gw *w, const GwPat *p, size_t index) { + B b; + b_init(&b, &w->a); + for (size_t i = index; i < p->parts->n; i++) { + b_s(&b, p->parts->v[i]); + if (i != p->parts->n - 1) b_unit(&b, '/'); + } + return gw_keep(w, s_to_str(b_done(&b))); +} + +/* ── the caches ───────────────────────────────────────────────────────── */ + +static unsigned char gw_kind_of_mode(unsigned mode) { + if (S_ISREG(mode)) return 1; + if (S_ISDIR(mode)) return 2; +#ifdef S_ISLNK + if (S_ISLNK(mode)) return 3; +#endif + return 0; +} + +/* Cache#statSync: the cached entry, else lstat (NULL when it fails). */ +static GwStat *gw_stat(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->stats, path); + if (hit && *hit) return *hit; + struct stat st; +#ifdef _WIN32 + int rc = stat(path->data, &st); +#else + int rc = lstat(path->data, &st); +#endif + GwStat *s = NULL; + if (rc == 0) { + s = ar_alloc(&w->a, sizeof *s); + s->name = gw_basename(w, path); + s->kind = gw_kind_of_mode((unsigned)st.st_mode); + } + gw_put(&w->stats, path, s); + return s; +} + +/* Cache#followStatSync: stat(2) through links; NULL when it fails. */ +static bool gw_follow_is_dir(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->follow_stats, path); + if (hit) return *hit != NULL; + struct stat st; + bool dir = stat(path->data, &st) == 0 && S_ISDIR(st.st_mode); + gw_put(&w->follow_stats, path, dir ? (void *)1 : NULL); + return dir; +} + +static ScrStr *gw_realpath(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->realpaths, path); + if (hit) return *hit; + ScrStr *real = scr_fs_realpath(path); + if (!real) scr_exc_clear(); + else gw_keep(w, real); + gw_put(&w->realpaths, path, real); + return real; +} + +typedef struct { + GwStat *v; + size_t n; +} GwDir; + +/* Cache#readdirSync: the sorted listing, or [] when it cannot be read. */ +static GwDir *gw_readdir(Gw *w, ScrStr *path) { + void **hit = gw_get(&w->readdirs, path); + if (hit) return *hit; + GwDir *d = ar_alloc(&w->a, sizeof *d); + d->v = NULL; + d->n = 0; + ScrScandir *scan = scr_fs_scandir(path); + if (!scan) { + scr_exc_clear(); + } else { + d->n = scr_fs_scandir_count(scan); + d->v = ar_alloc(&w->a, (d->n ? d->n : 1) * sizeof *d->v); + for (size_t i = 0; i < d->n; i++) { + d->v[i].name = gw_keep(w, scr_fs_scandir_name(scan, i)); + d->v[i].kind = (unsigned char)scr_fs_scandir_type(scan, i); + } + scr_fs_scandir_free(scan); + } + gw_put(&w->readdirs, path, d); + return d; +} + +/* Cache#add: records the pattern's suffixes at path; true when some were + * already recorded. */ +static bool gw_seen_add(Gw *w, ScrStr *path, const GwPat *p) { + void **hit = gw_get(&w->seen, path); + GwMap *set; + if (hit) { + set = *hit; + } else { + set = ar_alloc(&w->a, sizeof *set); + memset(set, 0, sizeof *set); + gw_put(&w->seen, path, set); + } + size_t before = set->n; + for (size_t k = 0; k < p->indexes.n; k++) gw_put(set, gw_cache_key(w, p, p->indexes.v[k]), (void *)1); + return set->n != before + p->indexes.n; +} + +static bool gw_seen(Gw *w, ScrStr *path, const GwPat *p, size_t index) { + void **hit = gw_get(&w->seen, path); + return hit && gw_get(*hit, gw_cache_key(w, p, index)) != NULL; +} + +/* ── exclusion and results ────────────────────────────────────────────── */ + +static bool gw_is_excluded(Gw *w, ScrStr *value) { + if (!w->nexclude) return false; + S f = s_from_str(&w->a, value); + for (size_t i = 0; i < w->nexclude; i++) { + bool threw; + if (glob_test(&w->exclude[i], f, &threw)) return true; + if (threw) return false; + } + return false; +} + +/* The exclude callback on a path or entry name; false when it threw (the + * exception stays pending and ends the walk). */ +static bool gw_exclude_call(Gw *w, ScrStr *value) { + ScrDyn *arg = scr_dyn_new_str(value); + ScrDyn *out = scr_dyn_call(w->exclude_fn, &arg, 1, "options.exclude"); + scr_dyn_release(arg); + if (!out) return false; + bool yes = scr_dyn_truthy(out); + scr_dyn_release(out); + return yes; +} + +/* ResultSet#add. */ +static void gw_result(Gw *w, ScrStr *value) { + if (gw_is_excluded(w, gw_resolve(w, w->root, value))) return; + if (gw_get(&w->result_set, value)) return; + gw_put(&w->result_set, value, (void *)1); + w->results = gw_grow(w->results, &w->capresults, w->nresults + 1, sizeof *w->results); + w->results[w->nresults++] = value; +} + +/* ── the walk ─────────────────────────────────────────────────────────── */ + +static void gw_pats_push(GwPats *l, GwPat *p) { + l->v = gw_grow(l->v, &l->cap, l->n + 1, sizeof *l->v); + l->v[l->n++] = p; +} + +/* #subpatterns.set(path, [pattern]) when replace, else push. */ +static void gw_sub(Gw *w, ScrStr *path, GwPat *p, bool replace) { + void **hit = gw_get(&w->subpatterns, path); + if (hit) { + GwPats *l = *hit; + if (replace) l->n = 0; + gw_pats_push(l, p); + return; + } + GwPats *l = ar_alloc(&w->a, sizeof *l); + memset(l, 0, sizeof *l); + gw_pats_push(l, p); + gw_put(&w->subpatterns, path, l); +} + +/* #addSubpattern. */ +static void gw_add_subpattern(Gw *w, ScrStr *path, GwPat *p) { + if (gw_is_excluded(w, path)) return; + ScrStr *fullpath = gw_resolve(w, w->root, path); + if (w->nexclude) { + ScrStr *slash = gw_keep(w, scr_str_concat(fullpath, gw_cstr(w, "/"))); + GwStat *st = gw_is_excluded(w, slash) ? gw_stat(w, fullpath) : NULL; + if (st && st->kind == 2) return; + } + if (w->exclude_fn) { + bool skip = gw_exclude_call(w, path); + if (scr_exc_pending() || skip) return; + } + gw_sub(w, path, p, false); +} + +static bool gw_is_directory(Gw *w, ScrStr *path, const GwStat *st, const GwPat *p) { + if (st && st->kind == 2) return true; + if (!st || st->kind != 3) return false; + if (w->follow) return gw_follow_is_dir(w, path); + return gw_has_seen_symlinks(p); +} + +static const GwReal *gw_next_realpaths(Gw *w, ScrStr *path, bool is_directory, const GwPat *p) { + if (!w->follow || !is_directory) return p->realpaths; + ScrStr *real = gw_realpath(w, path); + if (!real) return p->realpaths; + GwReal *next = ar_alloc(&w->a, sizeof *next); + next->n = p->realpaths->n + 1; + next->v = ar_alloc(&w->a, next->n * sizeof *next->v); + if (p->realpaths->n) memcpy(next->v, p->realpaths->v, p->realpaths->n * sizeof *next->v); + next->v[next->n - 1] = real; + return next; +} + +static bool gw_is_cyclic(Gw *w, ScrStr *path, bool is_directory, const GwPat *p) { + if (!w->follow || !is_directory) return false; + ScrStr *real = gw_realpath(w, path); + if (!real) return false; + for (size_t i = 0; i < p->realpaths->n; i++) + if (scr_str_eq(p->realpaths->v[i], real)) return true; + return false; +} + +static ScrStr *gw_str_of(Gw *w, S s) { return gw_keep(w, s_to_str(s)); } + +/* #addSubpatterns. */ +static void gw_add_subpatterns(Gw *w, ScrStr *path, GwPat *p) { + if (gw_seen_add(w, path, p)) return; + ScrStr *fullpath = gw_resolve(w, w->root, path); + GwStat *st = gw_stat(w, fullpath); + long last = p->last; + bool is_directory = gw_is_directory(w, fullpath, st, p); + bool is_last = gw_is_last(p, is_directory); + bool is_first = gw_idx_has(&p->indexes, 0); + if (gw_is_excluded(w, fullpath)) return; +#ifdef _WIN32 + if (is_first && gw_is_string(gw_at(p, 0)) && gw_at(p, 0)->str.n && + gw_at(p, 0)->str.p[gw_at(p, 0)->str.n - 1] == ':') { + ScrStr *drive = gw_str_of(w, gw_at(p, 0)->str); + gw_add_subpattern(w, gw_keep(w, scr_str_concat(drive, gw_cstr(w, "\\"))), + gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } +#endif + if (is_first && gw_is_str(gw_at(p, 0), "")) { + gw_add_subpattern(w, gw_cstr(w, "/"), gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } + if (is_first && gw_is_str(gw_at(p, 0), "..")) { + gw_add_subpattern(w, gw_cstr(w, "../"), gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } + if (is_first && gw_is_str(gw_at(p, 0), ".")) { + gw_add_subpattern(w, gw_cstr(w, "."), gw_pat_child(w, p, gw_idx_one(w, 1), (GwIdx){ 0 }, p->realpaths)); + return; + } + + if (is_last && gw_is_string(gw_at(p, -1))) { + ScrStr *tail = gw_str_of(w, gw_at(p, -1)->str); + GwStat *found = gw_stat(w, gw_join(w, fullpath, tail)); + if (found && (tail->len || is_directory)) gw_result(w, gw_join(w, path, tail)); + if (p->indexes.n == 1 && gw_idx_has(&p->indexes, (size_t)last)) return; + } else if (is_last && gw_is_globstar(gw_at(p, -1)) && + (!gw_eq_c(path, ".") || gw_is_str(gw_at(p, 0), ".") || (last == 0 && st))) { + gw_result(w, path); + } + if (scr_exc_pending()) return; + + if (!is_directory || gw_is_cyclic(w, fullpath, is_directory, p)) return; + const GwReal *next_realpaths = gw_next_realpaths(w, fullpath, is_directory, p); + + GwStat one; + GwDir single = { &one, 1 }; + GwDir *children; + const Pat *first = p->indexes.n == 1 ? gw_at(p, (long)p->indexes.v[0]) : NULL; + if (gw_is_string(first)) { + ScrStr *name = gw_str_of(w, first->str); + GwStat *found = gw_stat(w, gw_join(w, fullpath, name)); + if (!found) return; + one.name = name; + one.kind = found->kind; + children = &single; + } else { + children = gw_readdir(w, fullpath); + } + + for (size_t i = 0; i < children->n; i++) { + GwStat *entry = &children->v[i]; + ScrStr *entry_path = gw_join(w, path, entry->name); + ScrStr *entry_full = gw_join(w, fullpath, entry->name); + GwStat *cached = ar_alloc(&w->a, sizeof *cached); + *cached = *entry; + gw_put(&w->stats, entry_full, cached); + bool entry_dir = entry->kind == 2 || (w->follow && entry->kind == 3 && gw_follow_is_dir(w, entry_full)); + + GwIdx sub = { NULL, 0, 0 }; + GwIdx nsym = { NULL, 0, 0 }; + for (size_t k = 0; k < p->indexes.n; k++) { + size_t index = p->indexes.v[k]; + const Pat *current = gw_at(p, (long)index); + size_t next_index = index + 1; + const Pat *next = gw_at(p, (long)next_index); + bool from_symlink = !w->follow && gw_idx_has(&p->symlinks, index); + + if (gw_is_globstar(current)) { + bool is_dot = entry->name->len && entry->name->data[0] == '.'; + bool next_matches = gw_test(w, p, next_index, entry->name); + size_t next_non_glob = next_index; + while (gw_is_globstar(gw_at(p, (long)next_non_glob))) next_non_glob++; + bool matches_dot = is_dot && gw_test(w, p, next_non_glob, entry->name); + if (is_dot && !matches_dot) continue; + if (w->exclude_fn) { + bool skip = gw_exclude_call(w, entry->name); + if (scr_exc_pending()) return; + if (skip) continue; + } + if (!from_symlink && entry_dir) gw_idx_add(w, &sub, index); + else if (!from_symlink && (long)index == last) gw_result(w, entry_path); + if (next_matches && (long)next_index == last && !is_last) gw_result(w, entry_path); + else if (next_matches && entry_dir) gw_idx_add(w, &sub, index + 2); + if ((next_matches || gw_is_str(gw_at(p, 0), ".")) && entry_dir && !from_symlink) gw_idx_add(w, &sub, next_index); + if (!w->follow && entry->kind == 3) gw_idx_add(w, &nsym, index); + if (gw_is_str(next, "..") && entry_dir) { + ScrStr *parent = gw_join(w, path, gw_cstr(w, "..")); + if ((long)next_index < last) { + if (!gw_get(&w->subpatterns, path) && !gw_seen(w, path, p, next_index + 1)) + gw_sub(w, path, gw_pat_child(w, p, gw_idx_one(w, next_index + 1), (GwIdx){ 0 }, p->realpaths), true); + if (!gw_get(&w->subpatterns, parent) && !gw_seen(w, parent, p, next_index + 1)) + gw_sub(w, parent, gw_pat_child(w, p, gw_idx_one(w, next_index + 1), (GwIdx){ 0 }, p->realpaths), true); + } else { + if (!gw_seen(w, path, p, next_index)) { + gw_seen_add(w, path, gw_pat_child(w, p, gw_idx_one(w, next_index), (GwIdx){ 0 }, p->realpaths)); + gw_result(w, path); + } + if (!gw_seen(w, path, p, next_index) || !gw_seen(w, parent, p, next_index)) { + gw_seen_add(w, parent, gw_pat_child(w, p, gw_idx_one(w, next_index), (GwIdx){ 0 }, p->realpaths)); + gw_result(w, parent); + } + } + } + } + if (gw_is_string(current)) { + if (gw_test(w, p, index, entry->name) && (long)index != last) { + gw_idx_add(w, &sub, next_index); + } else if (gw_is_str(current, ".") && gw_test(w, p, next_index, entry->name)) { + if ((long)next_index == last) gw_result(w, entry_path); + else gw_idx_add(w, &sub, next_index + 1); + } + } + if (current && current->kind == PAT_REGEX && gw_test(w, p, index, entry->name)) { + if ((long)index == last) gw_result(w, entry_path); + else if (entry_dir) gw_idx_add(w, &sub, next_index); + } + if (scr_exc_pending()) return; + } + if (sub.n > 0) gw_add_subpattern(w, entry_path, gw_pat_child(w, p, sub, nsym, next_realpaths)); + if (scr_exc_pending()) return; + } +} + +static void gw_queue_push(Gw *w, ScrStr *path, GwPats pats) { + w->queue = gw_grow(w->queue, &w->capqueue, w->nqueue + 1, sizeof *w->queue); + w->queue[w->nqueue++] = (GwItem){ path, pats }; +} + +/* Glob#globSync's loop. */ +static void gw_run(Gw *w) { + GwPats all = { NULL, 0, 0 }; + for (size_t i = 0; i < w->npatterns; i++) gw_pats_push(&all, w->patterns[i]); + gw_queue_push(w, gw_cstr(w, "."), all); + while (w->nqueue && !scr_exc_pending()) { + GwItem item = w->queue[--w->nqueue]; + for (size_t i = 0; i < item.pats.n && !scr_exc_pending(); i++) gw_add_subpatterns(w, item.path, item.pats.v[i]); + free(item.pats.v); + for (size_t i = 0; i < w->subpatterns.on; i++) { + ScrStr *key = w->subpatterns.order[i]; + GwPats *l = *gw_get(&w->subpatterns, key); + gw_queue_push(w, key, *l); /* the queue item owns the list now */ + } + gw_map_clear(&w->subpatterns); + } + for (size_t i = 0; i < w->nqueue; i++) free(w->queue[i].pats.v); + w->nqueue = 0; +} + +/* ── options, patterns and the entry points ───────────────────────────── */ + +static bool gw_dyn_absent(const ScrDyn *d) { return !d || d->kind == SCR_DYN_UNDEF; } +static bool gw_dyn_nullish(const ScrDyn *d) { return gw_dyn_absent(d) || d->kind == SCR_DYN_NULL; } + +static bool gw_dyn_is_array(const ScrDyn *d) { + return d->kind == SCR_DYN_ARR || (d->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(d)); +} + +/* The value as a plain dyn array (+1), materializing typed arrays. */ +static ScrDyn *gw_dyn_array(const ScrDyn *d) { + if (d->kind == SCR_DYN_ARR) return scr_dyn_retain((ScrDyn *)d); + return scr_dyn_typed_ref_materialize(d); +} + +static ScrDyn *gw_option(const ScrDyn *options, const char *key) { + if (gw_dyn_nullish(options)) return scr_dyn_retain(scr_dyn_undefined()); + if (options->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(options); + ScrDyn *value = view ? scr_dyn_obj_read(view, key, strlen(key)) : NULL; + scr_dyn_release(view); + return value; + } + return scr_dyn_obj_read(options, key, strlen(key)); +} + +/* validateObject(options, 'options') with Node's default flags. */ +static bool gw_options_ok(const ScrDyn *options) { + if (gw_dyn_absent(options)) return true; + bool object = options->kind == SCR_DYN_OBJ || options->kind == SCR_DYN_HANDLE || + options->kind == SCR_DYN_BYTES || options->kind == SCR_DYN_PROXY || + (options->kind == SCR_DYN_TYPED_REF && !scr_dyn_ref_is_array(options)); + if (object) return true; + scr_dyn_arg_type_fail("options", "of type object", options); + return false; +} + +/* The maps are keyed by pool strings, so they are freed before the pool. */ +static void gw_free(Gw *w) { + GwMap *maps[] = { &w->stats, &w->follow_stats, &w->readdirs, &w->realpaths, &w->seen, &w->subpatterns, &w->result_set }; + for (size_t i = 0; i < w->seen.on; i++) { + GwMap *set = *gw_get(&w->seen, w->seen.order[i]); + gw_map_free(set); + } + for (size_t i = 0; i < sizeof maps / sizeof *maps; i++) gw_map_free(maps[i]); + for (size_t i = 0; i < w->pool.n; i++) scr_str_release(w->pool.v[i]); + free(w->pool.v); + free(w->results); + free(w->queue); + scr_dyn_release(w->exclude_fn); + ar_free(&w->a); +} + +/* new Glob(pattern, options): Node's option reads (exclude, cwd, + * followSymlinks, withFileTypes), validations and matcher compilation, in + * Node's order. False with the exception pending. */ +static bool gw_begin(Gw *w, const ScrDyn *pattern, const ScrDyn *options, ScrDyn **cwd_out) { + if (!gw_options_ok(options)) return false; + ScrDyn *exclude = gw_option(options, "exclude"); + if (!exclude) return false; + ScrDyn *cwd = gw_option(options, "cwd"); + ScrDyn *follow = cwd ? gw_option(options, "followSymlinks") : NULL; + ScrDyn *types = follow ? gw_option(options, "withFileTypes") : NULL; + bool ok = types != NULL; + /* toPathIfFileURL(cwd) ?? '.' */ + if (ok) { + if (gw_dyn_nullish(cwd)) { + w->root = gw_cstr(w, "."); + } else if (scr_dyn_native_url_is(cwd)) { + ScrStr *path = scr_url_to_path(cwd->v.handle.ptr); + ok = path != NULL; + if (ok) w->root = gw_keep(w, path); + } else if (cwd->kind == SCR_DYN_STR) { + w->root = gw_keep(w, scr_str_retain(cwd->v.str)); + } else { + *cwd_out = scr_dyn_retain(cwd); /* resolve() rejects it when the walk starts */ + } + } + if (ok && !gw_dyn_nullish(follow)) { + if (follow->kind != SCR_DYN_BOOL) { + scr_dyn_prop_type_fail("options.followSymlinks", "of type boolean", follow); + ok = false; + } else { + w->follow = follow->v.b; + } + } + if (ok) w->with_types = scr_dyn_truthy(types); + if (ok && !gw_dyn_nullish(exclude)) { + if (gw_dyn_is_array(exclude)) { + ScrDyn *list = gw_dyn_array(exclude); + ok = list != NULL; + for (size_t i = 0; ok && i < list->v.arr.len; i++) { + const ScrDyn *item = scr_dyn_arr_has_index(list, i) ? list->v.arr.items[i] : scr_dyn_undefined(); + if (item->kind != SCR_DYN_STR) { + char name[48]; + snprintf(name, sizeof name, "options.exclude[%zu]", i); + scr_dyn_prop_type_fail(name, "of type string", item); + ok = false; + } + } + if (ok && !w->root) { + /* The constructor's assert(typeof this.#root === 'string'). */ + static const char message[] = + "This is caused by either a bug in Node.js or incorrect usage of Node.js internals.\n" + "Please open an issue with this stack trace at https://github.com/nodejs/node/issues\n"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "ERR_INTERNAL_ASSERTION"); + ok = false; + } + if (ok) { + w->nexclude = list->v.arr.len; + w->exclude = ar_alloc(&w->a, (w->nexclude ? w->nexclude : 1) * sizeof *w->exclude); + for (size_t i = 0; ok && i < w->nexclude; i++) { + ScrStr *abs = gw_resolve(w, w->root, list->v.arr.items[i]->v.str); + ok = glob_compile(&w->a, s_from_str(&w->a, abs), GW_WINDOWS, scr_glob_host_nocase(), false, &w->exclude[i]); + } + } + scr_dyn_release(list); + } else if (exclude->kind == SCR_DYN_FUNC) { + w->exclude_fn = scr_dyn_retain(exclude); + } else { + scr_dyn_prop_type_fail("options.exclude", "of type function or string[]", exclude); + ok = false; + } + } + /* patterns: a string, or (typeof 'object') a string array. */ + ScrDyn *list = NULL; + if (ok) { + if (pattern->kind == SCR_DYN_STR) { + list = scr_dyn_new_arr(); + scr_dyn_arr_push(list, scr_dyn_retain((ScrDyn *)pattern)); + } else if (pattern->kind == SCR_DYN_NULL || pattern->kind == SCR_DYN_OBJ || pattern->kind == SCR_DYN_HANDLE || + pattern->kind == SCR_DYN_BYTES || pattern->kind == SCR_DYN_TYPED_REF || pattern->kind == SCR_DYN_ARR) { + if (!gw_dyn_is_array(pattern)) { + scr_dyn_arg_type_fail("patterns", "an instance of Array", pattern); + ok = false; + } else { + list = gw_dyn_array(pattern); + ok = list != NULL; + for (size_t i = 0; ok && i < list->v.arr.len; i++) { + const ScrDyn *item = scr_dyn_arr_has_index(list, i) ? list->v.arr.items[i] : scr_dyn_undefined(); + if (item->kind != SCR_DYN_STR) { + char name[32]; + snprintf(name, sizeof name, "patterns[%zu]", i); + scr_dyn_arg_type_fail(name, "of type string", item); + ok = false; + } + } + } + } else { + scr_dyn_arg_type_fail("patterns", "of type string", pattern); + ok = false; + } + } + w->ctx = (Ctx){ &w->a, scr_glob_host_nocase(), true }; + for (size_t i = 0; ok && i < list->v.arr.len; i++) { + Matcher *m = ar_alloc(&w->a, sizeof *m); + ok = glob_compile(&w->a, s_from_str(&w->a, list->v.arr.items[i]->v.str), GW_WINDOWS, scr_glob_host_nocase(), true, m); + if (!ok || m->empty) continue; + GwPat **grown = ar_alloc(&w->a, (w->npatterns + m->n) * sizeof *grown); + if (w->npatterns) memcpy(grown, w->patterns, w->npatterns * sizeof *grown); + w->patterns = grown; + for (size_t g = 0; g < m->n; g++) { + GwPat *p = ar_alloc(&w->a, sizeof *p); + memset(p, 0, sizeof *p); + p->pat = &m->set[g]; + p->parts = &m->parts[g]; + p->indexes = gw_idx_one(w, 0); + GwReal *none = ar_alloc(&w->a, sizeof *none); + none->v = NULL; + none->n = 0; + p->realpaths = none; + p->last = (long)m->set[g].n - 1; + w->patterns[w->npatterns++] = p; + } + } + scr_dyn_release(list); + scr_dyn_release(exclude); + scr_dyn_release(cwd); + scr_dyn_release(follow); + scr_dyn_release(types); + return ok && !scr_exc_pending(); +} + +/* The walk and its result snapshot. mode bit 0: Dirent rows; bit 1: the + * fs/promises form (Dirents carry resolved parent paths). */ +static ScrFsGlob *gw_snapshot(Gw *w, bool types, bool resolved_parents) { + ScrFsGlob *g = calloc(1, sizeof *g); + if (!g) gw_oom(); + g->n = w->nresults; + g->paths = malloc((g->n ? g->n : 1) * sizeof *g->paths); + if (!g->paths) gw_oom(); + if (types) { + g->names = malloc((g->n ? g->n : 1) * sizeof *g->names); + g->parents = malloc((g->n ? g->n : 1) * sizeof *g->parents); + g->kinds = malloc(g->n ? g->n : 1); + if (!g->names || !g->parents || !g->kinds) gw_oom(); + } + for (size_t i = 0; i < g->n; i++) { + ScrStr *path = w->results[i]; + g->paths[i] = scr_str_retain(path); + if (!types) continue; + ScrStr *full = resolved_parents ? gw_resolve(w, w->root, path) + : gw_is_absolute(path) ? path + : gw_join(w, w->root, path); + GwStat *st = gw_stat(w, full); + g->names[i] = scr_str_retain(gw_basename(w, full)); + g->parents[i] = scr_str_retain(gw_dirname(w, full)); + g->kinds[i] = st ? st->kind : 0; + } + return g; +} + +ScrFsGlob *scr_fs_glob(const ScrDyn *pattern, const ScrDyn *options, double mode) { + Gw w; + memset(&w, 0, sizeof w); + ScrDyn *bad_cwd = NULL; + bool types = ((unsigned)mode & 1) != 0; + ScrFsGlob *out = NULL; + if (gw_begin(&w, pattern, options, &bad_cwd)) { + if (w.with_types != types) { + static const char message[] = "fs.glob withFileTypes must be a literal true or false that matches the result type"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + } else if (!w.root) { + scr_dyn_arg_type_fail("paths[0]", "of type string", bad_cwd); + } else if (types && w.exclude_fn) { + static const char message[] = "fs.glob exclude functions with withFileTypes have no native lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + } else { + gw_run(&w); + bool async_form = ((unsigned)mode & 2) != 0; + if (scr_exc_pending() && async_form) { + /* The async iterator yields the matches found before the error, + * then throws it (scr_fsp_glob_error hands it to the loop). */ + scr_caught_release(scr_fs_glob_deferred); + scr_fs_glob_deferred = scr_exc_take(); + } + if (!scr_exc_pending()) out = gw_snapshot(&w, types, async_form); + } + } + scr_dyn_release(bad_cwd); + gw_free(&w); + return out; +} + +ScrArr *scr_fsp_glob_strings(const ScrDyn *pattern, const ScrDyn *options) { + ScrFsGlob *g = scr_fs_glob(pattern, options, 2); + if (!g) return NULL; + ScrArr *arr = scr_arr_new(SCR_ELEM_STR, g->n); + for (size_t i = 0; i < g->n; i++) scr_arr_push_ref(arr, scr_str_retain(g->paths[i])); + scr_fs_glob_free(g); + return arr; +} + +ScrDyn *scr_fsp_glob_error(void) { + if (!scr_fs_glob_deferred) return scr_dyn_retain(scr_dyn_undefined()); + ScrDyn *error = scr_caught_to_dyn(scr_fs_glob_deferred); + scr_caught_release(scr_fs_glob_deferred); + scr_fs_glob_deferred = NULL; + return error; +} + +size_t scr_fs_glob_count(const ScrFsGlob *g) { return g ? g->n : 0; } +ScrStr *scr_fs_glob_path(const ScrFsGlob *g, size_t i) { return scr_str_retain(g->paths[i]); } +ScrStr *scr_fs_glob_name(const ScrFsGlob *g, size_t i) { return scr_str_retain(g->names[i]); } +ScrStr *scr_fs_glob_parent(const ScrFsGlob *g, size_t i) { return scr_str_retain(g->parents[i]); } +double scr_fs_glob_type(const ScrFsGlob *g, size_t i) { return (double)g->kinds[i]; } + +void scr_fs_glob_free(ScrFsGlob *g) { + if (!g) return; + for (size_t i = 0; i < g->n; i++) { + scr_str_release(g->paths[i]); + if (g->names) { + scr_str_release(g->names[i]); + scr_str_release(g->parents[i]); + } + } + free(g->paths); + free(g->names); + free(g->parents); + free(g->kinds); + free(g); +} + +/* globSync(pattern, options) and the fs/promises string form: string[]. */ +ScrArr *scr_fs_glob_strings(const ScrDyn *pattern, const ScrDyn *options) { + ScrFsGlob *g = scr_fs_glob(pattern, options, 0); + if (!g) return NULL; + ScrArr *arr = scr_arr_new(SCR_ELEM_STR, g->n); + for (size_t i = 0; i < g->n; i++) scr_arr_push_ref(arr, scr_str_retain(g->paths[i])); + scr_fs_glob_free(g); + return arr; +} + +/* fs.glob(pattern[, options], callback): Node checks the callback, then + * runs the constructor's validation synchronously; the walk's result or + * error reaches the callback later. `args` is the argument vector. */ +void scr_fs_glob_callback(ScrDyn *args) { + size_t argc = args->kind == SCR_DYN_ARR ? args->v.arr.len : 0; + ScrDyn *const *items = argc ? args->v.arr.items : NULL; + const ScrDyn *pattern = argc > 0 ? items[0] : scr_dyn_undefined(); + const ScrDyn *options = argc > 1 ? items[1] : scr_dyn_undefined(); + const ScrDyn *callback = argc > 2 ? items[2] : scr_dyn_undefined(); + if (options->kind == SCR_DYN_FUNC) { + callback = options; + options = scr_dyn_undefined(); + } + if (callback->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("cb", "of type function", callback); + return; + } + Gw w; + memset(&w, 0, sizeof w); + ScrDyn *bad_cwd = NULL; + if (!gw_begin(&w, pattern, options, &bad_cwd)) { + scr_dyn_release(bad_cwd); + gw_free(&w); + return; + } + if (w.with_types) { + static const char message[] = "fs.glob callback Dirent results have no native lowering; use fs.globSync or fs/promises glob"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + scr_dyn_release(bad_cwd); + gw_free(&w); + return; + } + ScrDyn *result = NULL; + if (!w.root) { + scr_dyn_arg_type_fail("paths[0]", "of type string", bad_cwd); + } else { + gw_run(&w); + if (!scr_exc_pending()) { + result = scr_dyn_new_arr(); + for (size_t i = 0; i < w.nresults; i++) scr_dyn_arr_push(result, scr_dyn_new_str(w.results[i])); + } + } + scr_dyn_release(bad_cwd); + gw_free(&w); + scr_fs_cb_schedule(callback, result, NULL); +} diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 16c295b65..e153c18c8 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -101,6 +101,10 @@ ScrStr *scr_fs_read_file_encoded(ScrStr *path, ScrStr *encoding); ScrStr *scr_fs_read_fd_encoded(double fd, ScrStr *encoding); ScrDyn *scr_fs_callback_value(ScrStr *member); ScrDyn *scr_fs_callback_call(ScrStr *member, const ScrDyn *args); +/* The error-first callback queue (scr_bytes_io.c): a pending exception + * becomes [err], otherwise [null, result(, buffer)]; the callback runs on + * a later loop turn. result moves in. */ +void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer); ScrBytes *scr_fs_read_fd_bytes(double fd); /* +1; the fd form (scr_lib.c) */ void scr_fs_write_file_bytes(ScrStr *path, const ScrBytes *data); void scr_fs_append_file_bytes(ScrStr *path, const ScrBytes *data); diff --git a/packages/runtime/src/scr_path.h b/packages/runtime/src/scr_path.h index 7bf45e55c..9600b5e95 100644 --- a/packages/runtime/src/scr_path.h +++ b/packages/runtime/src/scr_path.h @@ -61,5 +61,32 @@ ScrStr *scr_path_win32_format(ScrStr *root, ScrStr *dir, ScrStr *base, ScrStr *n bool scr_path_matches_glob(ScrStr *path, ScrStr *pattern); bool scr_path_win32_matches_glob(ScrStr *path, ScrStr *pattern); bool scr_glob_match(ScrStr *path, ScrStr *pattern, bool windows, bool nocase); +/* fs.globSync / fs.glob / fs/promises glob (scr_glob.c): Node 24's + * lib/internal/fs/glob.js walk. The pattern (a string or string array) and + * options (exclude, cwd, followSymlinks, withFileTypes) arrive as dyn + * values and are validated in Node's order with Node's errors (borrowed). + * scr_fs_glob answers the result snapshot (+1, or NULL with the exception + * pending); mode bit 0 asks for Dirent rows (name, parentPath, UV_DIRENT + * kind) and must agree with withFileTypes, bit 1 is the fs/promises form, + * whose Dirents carry resolved parent paths. strings is the string[] form. + * callback takes fs.glob's argument vector, throws validation errors + * synchronously and delivers the result or the walk's error through the + * filesystem callback queue. */ +typedef struct ScrDyn ScrDyn; /* full definition in scr_dyn.h (C11 repeat) */ +typedef struct ScrFsGlob ScrFsGlob; +ScrFsGlob *scr_fs_glob(const ScrDyn *pattern, const ScrDyn *options, double mode); +size_t scr_fs_glob_count(const ScrFsGlob *g); /* NULL -> 0 */ +ScrStr *scr_fs_glob_path(const ScrFsGlob *g, size_t i); /* +1 */ +ScrStr *scr_fs_glob_name(const ScrFsGlob *g, size_t i); /* +1 */ +ScrStr *scr_fs_glob_parent(const ScrFsGlob *g, size_t i); /* +1 */ +double scr_fs_glob_type(const ScrFsGlob *g, size_t i); +void scr_fs_glob_free(ScrFsGlob *g); /* NULL-tolerant */ +ScrArr *scr_fs_glob_strings(const ScrDyn *pattern, const ScrDyn *options); +void scr_fs_glob_callback(ScrDyn *args); +/* The fs/promises form: a walk error does not throw; the matches found + * before it are returned and scr_fsp_glob_error answers the error (+1 dyn, + * undefined when there is none) once, for the loop to throw after them. */ +ScrArr *scr_fsp_glob_strings(const ScrDyn *pattern, const ScrDyn *options); +ScrDyn *scr_fsp_glob_error(void); #endif /* SCR_PATH_H */ diff --git a/packages/runtime/test/test_glob.c b/packages/runtime/test/test_glob.c index 8c9fa62b0..720cb374f 100644 --- a/packages/runtime/test/test_glob.c +++ b/packages/runtime/test/test_glob.c @@ -15,6 +15,15 @@ long scr_str_live_count(void); /* provided by scr_string.c */ +/* fs.glob's callback form (scr_glob.c) schedules through the filesystem + * callback queue in scr_bytes_io.c, which this binary does not link; the + * oracle never calls it. */ +void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *buffer) { + (void)callback; + (void)buffer; + scr_dyn_release(result); +} + static int hex_val(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; diff --git a/tests/corpus/5448-fs-glob-patterns.ts b/tests/corpus/5448-fs-glob-patterns.ts new file mode 100644 index 000000000..2a3179341 --- /dev/null +++ b/tests/corpus/5448-fs-glob-patterns.ts @@ -0,0 +1,82 @@ +// fs.globSync over a fixed tree: globstars, braces, extglobs, classes, dot +// files, "." and ".." segments, trailing slashes, literal paths, missing +// directories, absolute patterns and pattern arrays. Results come out in +// Node's walk order over sorted directory listings. +import { globSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-")); +for (const dir of ["a/b/c", "src/lib", ".hidden", "node_modules/x", "docs"]) { + mkdirSync(join(root, dir), { recursive: true }); +} +const files = [ + "a/one.ts", + "a/b/two.ts", + "a/b/c/three.js", + "src/index.ts", + "src/lib/util.ts", + "src/lib/util.test.ts", + "README.md", + ".hidden/secret.ts", + ".dot.ts", + "node_modules/x/index.ts", + "Upper.TS", + "docs/guide.md", + "docs/[draft].md", +]; +for (const file of files) writeFileSync(join(root, file), ""); + +const show = (pattern: string | string[]): void => { + console.log(JSON.stringify(pattern), globSync(pattern, { cwd: root }).join(" ")); +}; + +for (const pattern of [ + "*.ts", + "*", + "**", + "**/*.ts", + "a/**", + "a/**/*.js", + "**/b", + "**/b/**", + "src/*", + "src/**/*.test.ts", + "{a,src}/*.ts", + "{a,src}/**/*.{ts,js}", + "a/{one,missing}.ts", + "?.ts", + "a/???.ts", + "[ab]/*", + "[!a]*", + "!(src|node_modules)/*.ts", + "@(a|src)/*.ts", + "+(a|b)/**", + "*.md", + "docs/\\[draft\\].md", + ".*", + "**/.*", + ".hidden/*", + "a/b/../one.ts", + "./a/*.ts", + "a/./b/*.ts", + "a/", + "a/b/", + "**/", + "a/b/c/three.js", + "missing/**", + "missing", + "**/node_modules/**", + "**/*.TS", + "", +]) { + show(pattern); +} +show(["*.md", "a/*.ts", "*.md"]); +show(["src/*", "src/**"]); +show([]); + +// Absolute patterns answer absolute paths; print them relative to the root. +const absolute = globSync(join(root, "a", "**", "*.ts")).map((p) => p.slice(root.length)); +console.log("absolute", absolute.join(" ")); +rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5449-fs-glob-options.ts b/tests/corpus/5449-fs-glob-options.ts new file mode 100644 index 000000000..e5e51262c --- /dev/null +++ b/tests/corpus/5449-fs-glob-options.ts @@ -0,0 +1,84 @@ +// fs.globSync options: cwd as an absolute path, a relative path and a file: +// URL; exclude as glob patterns (resolved against cwd, matched on whole +// paths and on directories with a trailing slash) or a function of the +// path; withFileTypes Dirents with name, parentPath (cwd-relative when cwd +// is relative) and the type probes; and followSymlinks. +import { type Dirent, globSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-options-")); +for (const dir of ["pkg/src/lib", "pkg/test", "pkg/node_modules/dep", "pkg/dist"]) { + mkdirSync(join(root, dir), { recursive: true }); +} +for (const file of [ + "pkg/package.json", + "pkg/src/index.ts", + "pkg/src/lib/a.ts", + "pkg/src/lib/a.test.ts", + "pkg/test/main.test.ts", + "pkg/node_modules/dep/index.ts", + "pkg/dist/index.js", +]) { + writeFileSync(join(root, file), "{}"); +} +let links = true; +try { + symlinkSync("src", join(root, "pkg", "linked"), "dir"); +} catch { + links = false; +} + +const pkg = join(root, "pkg"); +console.log("absolute cwd", globSync("**/*.ts", { cwd: pkg }).join(" ")); +process.chdir(root); +console.log("relative cwd", globSync("src/*.ts", { cwd: "pkg" }).join(" ")); +console.log("url cwd", globSync("*.json", { cwd: pathToFileURL(pkg) }).join(" ")); +console.log("default cwd", globSync("pkg/*.json").join(" ")); + +console.log( + "exclude patterns", + globSync("**/*.ts", { cwd: pkg, exclude: ["node_modules/**", "**/*.test.ts"] }).join(" "), +); +console.log("exclude directory", globSync("**", { cwd: pkg, exclude: ["dist/"] }).filter((p) => !p.includes("linked")).join(" ")); +console.log( + "exclude function", + globSync("**/*.ts", { cwd: pkg, exclude: (path) => path.startsWith("node_modules") || path.endsWith(".test.ts") }).join( + " ", + ), +); +const visited: string[] = []; +globSync("src/**", { + cwd: pkg, + exclude: (path) => { + visited.push(path); + return false; + }, +}); +console.log("exclude sees", visited.join(" ")); + +const describe = (list: Dirent[]): string => + list + .map((d) => { + const kind = d.isSymbolicLink() ? "link" : d.isDirectory() ? "dir" : d.isFile() ? "file" : "other"; + const parent = d.parentPath.startsWith(root) ? `${d.parentPath.slice(root.length)}` : d.parentPath; + return `${d.name}:${parent}:${kind}`; + }) + .join(" "); +console.log("dirents absolute", describe(globSync("src/**", { cwd: pkg, withFileTypes: true }))); +console.log("dirents relative", describe(globSync("pkg/src/*", { withFileTypes: true }))); +console.log("dirents exclude", describe(globSync("**/*.ts", { cwd: pkg, withFileTypes: true, exclude: ["node_modules/**"] }))); +console.log("withFileTypes false", globSync("*", { cwd: pkg, withFileTypes: false }).join(" ")); + +if (links) { + console.log("links", globSync("**/*.ts", { cwd: pkg, exclude: ["node_modules/**"] }).join(" ")); + console.log("links followed", globSync("**/*.ts", { cwd: pkg, followSymlinks: true, exclude: ["node_modules/**"] }).join(" ")); + console.log("link entry", describe(globSync("linked", { cwd: pkg, withFileTypes: true }))); + console.log("through link", globSync("linked/lib/*", { cwd: pkg }).join(" ")); +} else { + console.log("links unavailable"); +} +console.log("relative check", relative(root, pkg)); +process.chdir(tmpdir()); +rmSync(root, { recursive: true, force: true }); diff --git a/tests/corpus/5450-fs-glob-validation.js b/tests/corpus/5450-fs-glob-validation.js new file mode 100644 index 000000000..9914b0c1b --- /dev/null +++ b/tests/corpus/5450-fs-glob-validation.js @@ -0,0 +1,80 @@ +// fs.glob argument handling from untyped code: Node's ERR_INVALID_ARG_TYPE +// errors for patterns, options, exclude, followSymlinks, cwd and the +// callback, the ERR_INTERNAL_ASSERTION a non-string cwd with exclude +// patterns hits in Node, empty patterns, missing directories, exclude +// callbacks that throw or return non-booleans, and the errors fs.glob's +// callback and fs/promises glob deliver. +import { globSync, glob as globCallback, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { glob } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const any = (value) => /** @type {any} */ (value); +const attempt = (label, run) => { + try { + console.log(label, run()); + } catch (error) { + const e = /** @type {any} */ (error); + console.log(label, "threw", e.name, e.code, JSON.stringify(e.message)); + } +}; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-validation-")); +mkdirSync(join(root, "a")); +for (const file of ["a/one.ts", "a/two.ts", "top.md"]) writeFileSync(join(root, file), ""); +process.chdir(root); + +attempt("number pattern", () => globSync(any(5))); +attempt("null pattern", () => globSync(any(null))); +attempt("object pattern", () => globSync(any({}))); +attempt("function pattern", () => globSync(any(() => "*"))); +attempt("array with a number", () => globSync(any(["*", 5]))); +attempt("string options", () => globSync("*", any("x"))); +attempt("null options", () => globSync("*", any(null))); +attempt("array options", () => globSync("*", any([]))); +attempt("number exclude", () => globSync("*", any({ exclude: 5 }))); +attempt("exclude with a number", () => globSync("*", any({ exclude: ["a", 1] }))); +attempt("number followSymlinks", () => globSync("*", any({ followSymlinks: 1 }))); +attempt("null followSymlinks", () => globSync("*", any({ followSymlinks: null })).join(" ")); +attempt("number cwd", () => globSync("*", any({ cwd: 5 }))); +attempt("number cwd with exclude", () => globSync("*", any({ cwd: 5, exclude: ["x"] }))); +attempt("Buffer cwd", () => globSync("*", any({ cwd: Buffer.from("a") }))); +attempt("http URL cwd", () => globSync("*", any({ cwd: new URL("http://example.com/") }))); +attempt("missing cwd", () => globSync("*", { cwd: join(root, "missing") }).length); +attempt("empty pattern", () => globSync("").length); +attempt("no patterns", () => globSync([]).length); +attempt("exclude throws", () => + globSync("*", { + exclude: () => { + throw new RangeError("stop"); + }, + }), +); +attempt("exclude truthy values", () => globSync("**", { exclude: any((p) => (p.endsWith(".ts") ? 1 : "")) }).join(" ")); +attempt("missing callback", () => globCallback("*", any(undefined))); +attempt("bad pattern with callback", () => globCallback(any(5), () => console.log("not reached"))); + +globCallback("a/*.ts", (err, files) => { + console.log("callback", err, files.join(" ")); + globCallback("*", { cwd: "a" }, (err2, files2) => { + console.log("callback with options", err2, files2.join(" ")); + globCallback("*", any({ cwd: 5 }), (error) => { + console.log("callback cwd error", error.name, error.code, error.message); + void loop(); + }); + }); +}); + +async function loop() { + const seen = []; + for await (const path of glob("**/*.ts")) seen.push(path); + console.log("for await", seen.join(" ")); + try { + for await (const path of glob(any(5))) console.log("not reached", path); + } catch (error) { + const e = /** @type {any} */ (error); + console.log("for await bad pattern", e.name, e.code); + } + process.chdir(tmpdir()); + rmSync(root, { recursive: true, force: true }); +} diff --git a/tests/corpus/5451-fs-promises-glob.ts b/tests/corpus/5451-fs-promises-glob.ts new file mode 100644 index 000000000..5eb2c435e --- /dev/null +++ b/tests/corpus/5451-fs-promises-glob.ts @@ -0,0 +1,74 @@ +// fs/promises glob as a for await source: string matches, Dirent matches +// (fs/promises Dirents carry the resolved parent directory), the named, +// namespace and fs.promises spellings, early exits with break and continue, +// nested loops, and an error from an exclude callback surfacing at the +// loop. +import * as fs from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as fsp from "node:fs/promises"; +import { glob } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-fsp-glob-")); +for (const dir of ["one/deep", "two"]) mkdirSync(join(root, dir), { recursive: true }); +for (const file of ["one/a.txt", "one/deep/b.txt", "two/c.txt", "two/d.md", "e.txt"]) { + writeFileSync(join(root, file), ""); +} + +async function main(): Promise { + const all: string[] = []; + for await (const path of glob("**/*.txt", { cwd: root })) all.push(path); + console.log("named", all.join(" ")); + + const viaNamespace: string[] = []; + for await (const path of fsp.glob("*/*", { cwd: root, exclude: ["**/*.md"] })) viaNamespace.push(path); + console.log("namespace", viaNamespace.join(" ")); + + const viaFs: string[] = []; + for await (const path of fs.promises.glob("two/*", { cwd: root })) viaFs.push(path); + console.log("fs.promises", viaFs.join(" ")); + + const rows: string[] = []; + for await (const entry of glob("one/**", { cwd: root, withFileTypes: true })) { + rows.push(`${entry.name}|${entry.parentPath.slice(root.length)}|${entry.isDirectory()}`); + } + console.log("dirents", rows.join(" ")); + + let first = ""; + for await (const path of glob("**/*.txt", { cwd: root })) { + first = path; + break; + } + console.log("break", first); + + const skipped: string[] = []; + for await (const path of glob("**/*.txt", { cwd: root })) { + if (path.startsWith("one")) continue; + skipped.push(path); + } + console.log("continue", skipped.join(" ")); + + const pairs: string[] = []; + for await (const dir of glob("*/", { cwd: root })) { + for await (const file of glob(`${dir}/*.txt`, { cwd: root })) pairs.push(`${dir}>${file}`); + } + console.log("nested", pairs.join(" ")); + + try { + for await (const path of glob("**", { + cwd: root, + exclude: (p) => { + if (p === "two") throw new Error(`refused ${p}`); + return false; + }, + })) { + console.log("visited", path); + } + } catch (error) { + console.log("caught", (error as Error).message); + } + rmSync(root, { recursive: true, force: true }); +} + +void main(); diff --git a/tests/corpus/5452-fs-glob-callback.ts b/tests/corpus/5452-fs-glob-callback.ts new file mode 100644 index 000000000..438505783 --- /dev/null +++ b/tests/corpus/5452-fs-glob-callback.ts @@ -0,0 +1,44 @@ +// fs.glob's error-first callback form: with and without options, pattern +// arrays, exclude patterns and functions, the error an exclude callback +// throws arriving at the callback. Each call starts after the previous +// callback ran, so the output order does not depend on I/O timing. +import { glob, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-glob-callback-")); +mkdirSync(join(root, "lib/inner"), { recursive: true }); +for (const file of ["lib/a.js", "lib/b.ts", "lib/inner/c.ts", "top.json"]) writeFileSync(join(root, file), ""); +process.chdir(root); + +const steps: (() => void)[] = [ + () => glob("lib/**/*.ts", (err, files) => next("no options", err, files)), + () => glob("**/*.ts", { cwd: "lib" }, (err, files) => next("cwd", err, files)), + () => glob(["*.json", "lib/*.js"], {}, (err, files) => next("array", err, files)), + () => glob("**/*", { exclude: ["lib/inner/**"] }, (err, files) => next("exclude patterns", err, files)), + () => glob("**/*", { exclude: (path) => path.endsWith(".js") }, (err, files) => next("exclude function", err, files)), + () => + glob( + "**", + { + exclude: () => { + throw new Error("exclude failed"); + }, + }, + (err, files) => next("exclude throws", err, files), + ), + () => finish(), +]; + +function next(label: string, err: NodeJS.ErrnoException | null, files: string[] | undefined): void { + console.log(label, err ? `error ${err.message}` : null, files === undefined ? "no files" : files.join(" ")); + steps.shift()?.(); +} + +function finish(): void { + process.chdir(tmpdir()); + rmSync(root, { recursive: true, force: true }); + console.log("done"); +} + +steps.shift()?.(); diff --git a/tests/fixtures/node-types/fs-glob.ts b/tests/fixtures/node-types/fs-glob.ts new file mode 100644 index 000000000..2467d73f1 --- /dev/null +++ b/tests/fixtures/node-types/fs-glob.ts @@ -0,0 +1,26 @@ +/* fs.globSync, fs/promises glob and fs.glob under @types/node: the same + * native walk as under the bundled declarations. */ +import { type Dirent, glob as globCallback, globSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { glob } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "scriptc-node-types-glob-")); +mkdirSync(join(root, "src/util"), { recursive: true }); +for (const file of ["src/a.ts", "src/util/b.ts", "src/util/b.test.ts", "notes.md"]) writeFileSync(join(root, file), ""); + +console.log(globSync("**/*.ts", { cwd: root, exclude: ["**/*.test.ts"] }).join(" ")); +const entries: Dirent[] = globSync("src/*", { cwd: root, withFileTypes: true }); +console.log(entries.map((d) => `${d.name}:${d.isDirectory()}`).join(" ")); + +async function main(): Promise { + const found: string[] = []; + for await (const path of glob("src/**", { cwd: root })) found.push(path); + console.log(found.join(" ")); + globCallback("*.md", { cwd: root }, (err, matches) => { + console.log(err, matches.join(" ")); + rmSync(root, { recursive: true, force: true }); + }); +} + +void main(); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 53883e2dc..52b6104a3 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -548,6 +548,20 @@ test("node-types: Blob and File match Node under @types/node", async () => { expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: fs.glob forms match Node under @types/node", async () => { + const outDir = outDirFor("node-fs-glob"); + const entry = join(nodeTypesDir, "fs-glob.ts"); + const result = await compile(entry, { outPath: join(outDir, "fs-glob"), outDir, sanitize }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: util function helpers match Node under @types/node", async () => { const outDir = outDirFor("node-util-callables"); const entry = join(nodeTypesDir, "util-callables.ts"); From 6b7e9539fbb717ab5d1df9d1924ee197594b0921 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 08:02:39 -0500 Subject: [PATCH 6/7] Inline record result-slot entries and keep array headers out of loops A function that returns a record its caller only projects fills the caller's frame storage through a %into. entry. These entries are small and called once per use, but LLVM kept them out of line in loops: record-boundaries' pathLength called %into.pointAt twice per iteration. They now carry inlinehint, like the indexed-read and interface-dispatch helpers. Once inlined, the scalar array reads in them still blocked hoisting the arrays' header loads: the slow path's scr_arr_state and getter calls had no memory attributes. They are now read-only cold calls, as the dense read fallbacks already are (the getter runs only for a present element, so it does not trap). --- .../src/backend/llvm/dense-array-access.ts | 2 +- packages/compiler/src/backend/llvm/emitter.ts | 3 +- .../backend/llvm/local-array-reads.test.ts | 13 +++- .../src/backend/llvm/local-array-reads.ts | 14 +++- .../src/backend/llvm/stack-objects.test.ts | 3 + .../5585-record-results-inlined.ts.json | 6 ++ tests/corpus/5585-record-results-inlined.ts | 70 +++++++++++++++++++ 7 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json create mode 100644 tests/corpus/5585-record-results-inlined.ts diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 83163d99f..8f2535561 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -27,7 +27,7 @@ const SCR_ARR_VALUE = 1; * module-visible memory, or trap without returning to the caller (the trap * writes runtime-private stderr/TLS state). Declaring this lets LLVM keep * array header loads out of loops whose only calls are these cold paths. */ -const READ_FALLBACK = " cold memory(read, inaccessiblemem: readwrite)"; +export const READ_FALLBACK = " cold memory(read, inaccessiblemem: readwrite)"; interface DenseGuard { /** The checked dense offset, in the target's size type. */ diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4c8889c9c..aca34657d 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -78,7 +78,7 @@ import { type StringSplit, } from "./split-loops.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; -import { addRecordResultEntries } from "./record-results.js"; +import { RESULT_ENTRY_PREFIX, addRecordResultEntries } from "./record-results.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js"; @@ -6013,6 +6013,7 @@ export class LlEmitter { // tests small enough to inline at each call site. if (fn.name.startsWith("%dyn.class.protocol.fallback.")) attrs = `noinline ${attrs}`; else if (fn.name.startsWith("%dyn.class.protocol.")) attrs = `inlinehint ${attrs}`; + if (fn.name.startsWith(RESULT_ENTRY_PREFIX)) attrs = `inlinehint ${attrs}`; const debug = this.debugScope === null ? "" : ` !dbg ${this.debugScope}`; const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name); let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`; diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index e177594e6..b16fe2608 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -912,9 +912,16 @@ test.each([ expect(ir).not.toContain("@scr_union_new"); expect(ir).not.toContain("@scr_union_release"); expect(ir).not.toContain("@scr_union_get"); - // Holes, sparse and noncanonical indices keep the runtime state check. - expect(ir).toContain("@scr_arr_state"); - expect(ir).toContain(elem.kind === "f64" ? "@scr_arr_get_f64" : "@scr_arr_get_bool"); + // Holes, sparse and noncanonical indices keep the runtime state check, + // a read-only cold call, so loop-invariant header loads can be hoisted. + expect(ir).toMatch( + /call double @scr_arr_state\(.*\) cold memory\(read, inaccessiblemem: readwrite\)/, + ); + expect(ir).toMatch( + elem.kind === "f64" + ? /call double @scr_arr_get_f64\(.*\) cold memory\(read, inaccessiblemem: readwrite\)/ + : /call zeroext i1 @scr_arr_get_bool\(.*\) cold memory\(read, inaccessiblemem: readwrite\)/, + ); if (consumer !== "local") expect(ir).toContain("local.array.dense"); } }); diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index db56b273d..5646de371 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -14,6 +14,7 @@ import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { ReferenceEffects } from "./reference-effects.js"; import { SCR_ARR_FIELD, exactIndexLines } from "./common.js"; import { exactInteger, widenInteger } from "./integer-values.js"; +import { READ_FALLBACK } from "./dense-array-access.js"; export interface LocalArrayRead { type: IrType; @@ -696,10 +697,15 @@ export function emitLocalArrayRead( if (scalar) { // Holes, present undefined, sparse and noncanonical indices: the // runtime state decides presence, then the strict getter reads it. + // Both only read (the getter runs for a present element, so it does + // not trap), which lets LLVM keep the array's header loads out of a + // loop around an inlined read. const state = B.tmp(), present = B.tmp(); host.declare(`declare double @scr_arr_state(ptr, double)`); - B.line(`${state} = call double @scr_arr_state(ptr ${array.name}, double ${index.name})`); + B.line( + `${state} = call double @scr_arr_state(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, + ); B.line(`${present} = fcmp oeq double ${state}, 1.0`); B.condBr(present, slowValue, no); B.startBlock(slowValue); @@ -707,12 +713,14 @@ export function emitLocalArrayRead( bits = B.tmp(); if (read.element.kind === "f64") { host.declare(`declare double @scr_arr_get_f64(ptr, double)`); - B.line(`${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})`); + B.line( + `${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, + ); B.line(`${bits} = bitcast double ${value} to i64`); } else { host.declare(`declare zeroext i1 @scr_arr_get_bool(ptr, double)`); B.line( - `${value} = call zeroext i1 @scr_arr_get_bool(ptr ${array.name}, double ${index.name})`, + `${value} = call zeroext i1 @scr_arr_get_bool(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, ); B.line(`${bits} = zext i1 ${value} to i64`); } diff --git a/packages/compiler/src/backend/llvm/stack-objects.test.ts b/packages/compiler/src/backend/llvm/stack-objects.test.ts index 5869ac3de..845d6ae5e 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.test.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.test.ts @@ -73,6 +73,9 @@ test("projection-only records live in frame storage and producers fill it", asyn expect(projected).not.toMatch(/@sc_rnew_/); expect(projected).toMatch(/call void @sc_\w*into_add\(/); expect(projected).toMatch(/call void @sc_\w*into_shortest\(/); + // The result-slot entries are small and called per use: hinted inline. + expect(ir).toMatch(/^define internal void @sc_\w*into_add\([^)]*\) inlinehint /m); + expect(ir).toMatch(/^define internal void @sc_\w*into_shortest\([^)]*\) inlinehint /m); // A literal stores every field: its storage is not zeroed first. expect(projected).not.toContain("zeroinitializer"); // Stored values keep the heap path and the ordinary producer. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json new file mode 100644 index 000000000..f2956d94b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5585-record-results-inlined.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5585-record-results-inlined.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5585-record-results-inlined.ts b/tests/corpus/5585-record-results-inlined.ts new file mode 100644 index 000000000..47b81ce14 --- /dev/null +++ b/tests/corpus/5585-record-results-inlined.ts @@ -0,0 +1,70 @@ +// Records returned from functions that read arrays, called in loops so the +// result-slot entries inline: in-range reads, holes, reads past the end and +// at fractional indices (undefined fields, which make the arithmetic NaN), +// sparse arrays, boolean fields, and a recursive helper, against Node. +interface Point { + x: number; + y: number; +} +function pointAt(xs: number[], ys: number[], i: number): Point { + return { x: xs[i], y: ys[i] }; +} +function distance(p: Point, q: Point): number { + const dx = p.x - q.x; + const dy = p.y - q.y; + return Math.sqrt(dx * dx + dy * dy); +} +function pathLength(xs: number[], ys: number[], extra: number): number { + let total = 0; + for (let i = 0; i + 1 + extra < xs.length + extra; i++) total += distance(pointAt(xs, ys, i), pointAt(xs, ys, i + 1)); + return total; +} +const xs: number[] = []; +const ys: number[] = []; +for (let i = 0; i < 50; i++) { + xs.push((i * 37) % 11); + ys.push((i * 13) % 7); +} +console.log(pathLength(xs, ys, 0).toFixed(6), pathLength(xs, ys, 1), pathLength(xs, ys, 2)); +const p = pointAt(xs, ys, 60); +const q = pointAt(xs, ys, 1.5); +console.log(p.x, p.y, q.x === undefined, distance(p, pointAt(xs, ys, 0)), distance(pointAt(xs, ys, 3), pointAt(xs, ys, 3))); +const holey: number[] = [1, 2, 3]; +holey.length = 6; +holey[5] = 9; +let viaHoles = ""; +for (let i = 0; i < 7; i++) viaHoles += String(pointAt(holey, holey, i).x) + ","; +console.log(viaHoles); +const sparse: number[] = [4, 5]; +sparse[100] = 6; +console.log(pointAt(sparse, sparse, 100).y, pointAt(sparse, sparse, 50).y, distance(pointAt(sparse, xs, 0), pointAt(sparse, xs, 1))); +interface Flags { + on: boolean; + next: boolean; +} +function flagsAt(bits: boolean[], i: number): Flags { + return { on: bits[i], next: bits[i + 1] }; +} +const bits = [true, false, true, true]; +let flagText = ""; +for (let i = 0; i < 5; i++) { + const f = flagsAt(bits, i); + flagText += `${f.on}/${f.next} `; +} +console.log(flagText); +interface Span { + lo: number; + hi: number; +} +function widen(span: Span, values: number[], from: number, to: number): Span { + if (from >= to) return span; + const v = values[from]; + return widen({ lo: Math.min(span.lo, v), hi: Math.max(span.hi, v) }, values, from + 1, to); +} +let spans = 0; +for (let i = 0; i + 8 <= xs.length; i += 8) { + const s = widen({ lo: xs[i], hi: xs[i] }, xs, i + 1, i + 8); + spans += s.hi - s.lo; +} +const past = widen({ lo: 0, hi: 0 }, xs, 48, 52); +console.log(spans, past.lo, past.hi); From b7dddbbb39bd7e917fb0a50f599c74ac7db4180d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 08:36:53 -0500 Subject: [PATCH 7/7] Compute remainders by powers of two without fmod `x % c` for a constant c whose magnitude is a power of two of at least 1 now computes `x - trunc(x / c) * c`, which is exact for every double, and gives a zero result the dividend's sign. Divisors written as `2 ** k` count as constants. Other divisors keep frem, and a fractional constant divisor skips the integer check it can never pass. The inline integer path for unproven operands now accepts dividends up to 2^62 instead of 2^53. The remainder of two doubles is a double, so the conversion back is exact, and products such as `seed * 1103515245 + 12345` no longer call fmod. --- .../src/backend/llvm/expr-primitives.ts | 16 ++- .../src/backend/llvm/integer-remainder.ts | 101 ++++++++++++++++-- packages/compiler/test/llvm-remainder.test.ts | 35 ++++-- .../5455-power-of-two-remainder.ts.json | 6 ++ ...-remainder-large-integer-dividends.ts.json | 6 ++ .../tests/corpus/5457-lcg-modulus.ts.json | 6 ++ .../corpus/5458-fractional-remainder.ts.json | 6 ++ tests/corpus/5455-power-of-two-remainder.ts | 66 ++++++++++++ .../5456-remainder-large-integer-dividends.ts | 50 +++++++++ tests/corpus/5457-lcg-modulus.ts | 50 +++++++++ tests/corpus/5458-fractional-remainder.ts | 34 ++++++ 11 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json create mode 100644 tests/corpus/5455-power-of-two-remainder.ts create mode 100644 tests/corpus/5456-remainder-large-integer-dividends.ts create mode 100644 tests/corpus/5457-lcg-modulus.ts create mode 100644 tests/corpus/5458-fractional-remainder.ts diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 4b0e35874..07d969f6e 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -26,7 +26,12 @@ import { withinUint32, } from "../../ir/integer-ranges.js"; import { emitArrayValues } from "./expr-containers.js"; -import { emitCheckedRemainder, emitSignedIntegerRemainder } from "./integer-remainder.js"; +import { + constantDivisor, + emitCheckedRemainder, + emitPowerOfTwoRemainder, + emitSignedIntegerRemainder, +} from "./integer-remainder.js"; import { emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js"; import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; @@ -300,7 +305,14 @@ export function emitOperatorExpr( } } if (e.op === "%") { - const checked = emitCheckedRemainder(host, l, r, li, ri); + const constant = constantDivisor(e.right, ri); + const power = constant === null ? null : emitPowerOfTwoRemainder(host, l, constant); + if (power) return power; + // A fractional constant divisor never passes the integer check. + const checked = + constant !== null && !Number.isInteger(constant) + ? null + : emitCheckedRemainder(host, l, r, li, ri); if (checked) return checked; } if (arith[e.op] !== undefined) { diff --git a/packages/compiler/src/backend/llvm/integer-remainder.ts b/packages/compiler/src/backend/llvm/integer-remainder.ts index 79939f757..144695a45 100644 --- a/packages/compiler/src/backend/llvm/integer-remainder.ts +++ b/packages/compiler/src/backend/llvm/integer-remainder.ts @@ -1,7 +1,8 @@ -import { F64 } from "../../ir/ir.js"; +import { F64, type IrExpr } from "../../ir/ir.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { integerNumber, widenInteger, type LlInteger } from "./integer-values.js"; +import { emitTruncF64 } from "./trunc.js"; /** Integer proofs exclude negative zero and stay inside the exact-number * range, so signed i64 division cannot overflow. A possibly zero divisor @@ -43,14 +44,16 @@ export function emitSignedIntegerRemainder( return { name: result, type: F64 }; } -const EXACT = 2 ** 53; +/** 2^62, the bound on dividends the inline integer path accepts, as an + * i64 literal (`String(2 ** 62)` rounds its digits). */ +const DIVIDEND_LIMIT = "4611686018427387904"; /** Convert an unproven double to a candidate i64. `freeze` keeps NaN and * out-of-range conversions from producing poison; the returned condition * is the exact round trip, so it rejects every non-integer. Integral * doubles inside the i64 range convert exactly; a passing value outside * it (|x| >= 2^63) can only pair with a frozen integer of magnitude above - * 2^53. `-0` converts to 0 and passes. */ + * 2^62. `-0` converts to 0 and passes. */ function candidateInteger(host: LlvmEmitterContext, value: string): [string, string] { const B = host.B; const raw = B.tmp(), @@ -66,13 +69,17 @@ function candidateInteger(host: LlvmEmitterContext, value: string): [string, str /** JS `%` with an inline integer path for operands that are not both * proven integers with a nonzero divisor. At run time the dividend must be - * an integer of magnitude <= 2^53 and the divisor a nonzero integer; then + * an integer from -2^62 up to but not including 2^62 and the divisor a + * nonzero integer; then * i64 `srem` is exact (no INT64_MIN / -1 overflow is possible), takes the * dividend's sign like JS, and an exact zero takes the dividend's sign - * (`-4 % 2` and `-0 % 3` are -0). A divisor that passes the round trip - * with magnitude above 2^53 exceeds the dividend's, so `srem` returns the - * dividend exactly as JS does. Everything else (fractions, NaN, ±Infinity, - * zero divisors, huge dividends) takes the floating remainder, unchanged. + * (`-4 % 2` and `-0 % 3` are -0). The remainder of two doubles is itself a + * double, so its conversion back is exact, including for dividends above + * 2^53 (every double there is an integer). A divisor that passes the round + * trip with magnitude above 2^62 exceeds the dividend's, so `srem` returns + * the dividend exactly as JS does. Everything else (fractions, NaN, + * ±Infinity, zero divisors, huge dividends) takes the floating remainder, + * unchanged. * Returns null when the divisor is the constant zero (always NaN). */ export function emitCheckedRemainder( host: LlvmEmitterContext, @@ -90,8 +97,9 @@ export function emitCheckedRemainder( const [integer, exact] = candidateInteger(host, left.name); const biased = B.tmp(), inRange = B.tmp(); - B.line(`${biased} = add i64 ${integer}, ${EXACT}`); - B.line(`${inRange} = icmp ule i64 ${biased}, ${2 * EXACT}`); + // -2^62 <= integer < 2^62: the biased value is nonnegative. + B.line(`${biased} = add i64 ${integer}, ${DIVIDEND_LIMIT}`); + B.line(`${inRange} = icmp sge i64 ${biased}, 0`); conditions.push(exact, inRange); x = integer; } @@ -147,3 +155,76 @@ export function emitCheckedRemainder( B.line(`${result} = phi double [ ${fastValue}, %${fast} ], [ ${floating}, %${slow} ]`); return { name: result, type: F64 }; } + +/** The value of a divisor known at compile time: a number literal, its + * negation, `2 ** k` for an integer literal k from 0 to 1023 (exactly 2^k), + * or an integer whose proven range is a single value. */ +export function constantDivisor(expr: IrExpr, integer: LlInteger | null): number | null { + if (integer && integer.range.min === integer.range.max) return integer.range.min; + if (expr.kind === "numLit") return expr.value; + if (expr.kind === "unary" && expr.op === "-") { + const value = constantDivisor(expr.operand, null); + return value === null ? null : -value; + } + if ( + expr.kind === "bin" && + expr.op === "**" && + expr.left.kind === "numLit" && + expr.left.value === 2 && + expr.right.kind === "numLit" && + Number.isInteger(expr.right.value) && + expr.right.value >= 0 && + expr.right.value <= 1023 + ) + return 2 ** expr.right.value; + return null; +} + +/** Whether `value` is a power of two of at least 1. */ +function isPowerOfTwo(value: number): boolean { + if (!Number.isFinite(value) || value < 1) return false; + let power = 1; + while (power < value) power *= 2; + return power === value; +} + +/** JS `%` by a constant divisor whose magnitude c is a power of two of at + * least 1, as `x - trunc(x / c) * c`, which is exact for every double x. + * When |x| < c the quotient's magnitude is below 1 and truncates to zero, + * so the result is x. Otherwise x / c is a normal double and exact, and + * the truncated quotient times c is a multiple of c of magnitude at most + * |x|, also exact. When c is below x's ulp, x is itself a multiple of c and + * the difference is zero; otherwise both terms are multiples of x's ulp and + * the difference, smaller than c, is exact. ±Infinity gives the default + * NaN of `Infinity - Infinity`, the NaN fmod also returns for it, and a NaN + * dividend propagates. A zero difference takes the dividend's sign + * (`-4 % 2` and `-0 % 1` are -0). The divisor's sign does not change the + * result. Returns null for any other divisor; a power of two below 1 keeps + * frem, because x / c can overflow. */ +export function emitPowerOfTwoRemainder( + host: LlvmEmitterContext, + left: LlValue, + divisor: number, +): LlValue | null { + const magnitude = Math.abs(divisor); + if (!isPowerOfTwo(magnitude)) return null; + const B = host.B; + const c = f64Lit(magnitude); + const quotient = B.tmp(); + B.line(`${quotient} = fdiv double ${left.name}, ${c}`); + const whole = emitTruncF64(host, quotient); + const multiple = B.tmp(), + difference = B.tmp(), + zero = B.tmp(), + signedZero = B.tmp(), + result = B.tmp(); + host.declare("declare double @llvm.copysign.f64(double, double)"); + B.line(`${multiple} = fmul double ${whole}, ${c}`); + B.line(`${difference} = fsub double ${left.name}, ${multiple}`); + B.line(`${zero} = fcmp oeq double ${difference}, ${f64Lit(0)}`); + B.line( + `${signedZero} = call double @llvm.copysign.f64(double ${f64Lit(0)}, double ${left.name})`, + ); + B.line(`${result} = select i1 ${zero}, double ${signedZero}, double ${difference}`); + return { name: result, type: F64 }; +} diff --git a/packages/compiler/test/llvm-remainder.test.ts b/packages/compiler/test/llvm-remainder.test.ts index 7814cfbdb..e86ed4097 100644 --- a/packages/compiler/test/llvm-remainder.test.ts +++ b/packages/compiler/test/llvm-remainder.test.ts @@ -15,14 +15,18 @@ test("remainder uses integer proofs and preserves floating fallback and signed z ` function remainder(x: number, y: number): number { return x % y; } function constants(x: number): number { - let value = x % 8; + let value = x % 10; value %= 3; return value; } +function powers(x: number): number { + return (x % 8) + (x % -(2 ** 32)) + (x % 1) + (x % 0.5); +} function power(x: number, y: number): number { return x ** y; } function signed(x: number): number { return (x | 0) % -7; } function maybeZero(x: number, y: number): number { return (x | 0) % (y | 0); } console.log(remainder(-9.5, 8), constants(17.25), power(2, 3), signed(-7), maybeZero(7, 0)); +console.log(powers(-9.75)); // Fractional arguments keep the parameters arbitrary (no call-site proof). console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); `, @@ -36,25 +40,40 @@ console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec( ll, )![0]; - // Four source remainders keep a floating fallback behind a runtime - // integer check; ToUint32 coercion fallbacks use integer arithmetic. - expect(ll.match(/ = frem double /g)).toHaveLength(4); + // Five source remainders keep a floating fallback: four behind a + // runtime integer check and the one by 0.5. ToUint32 coercion + // fallbacks use integer arithmetic. + expect(ll.match(/ = frem double /g)).toHaveLength(5); expect(ll).toContain("uint32.coerce.slow"); expect(ll.match(/ = srem i64 /g)).toHaveLength(5); expect(ll).toMatch(/ = select i1 [^\n]+double [^\n]+double /); - // Unproven operands: both round-trip checks, the 2^53 dividend bound, + // Unproven operands: both round-trip checks, the 2^62 dividend bound, // and a nonzero divisor guard the integer path. const unproven = fn("remainder"); expect(unproven.match(/ = freeze i64 /g)).toHaveLength(2); - expect(unproven).toMatch(/ = add i64 [^\n]+, 9007199254740992$/m); - expect(unproven).toMatch(/ = icmp ule i64 [^\n]+, 18014398509481984$/m); + expect(unproven).toMatch(/ = add i64 [^\n]+, 4611686018427387904$/m); + expect(unproven).toMatch(/ = icmp sge i64 [^\n]+, 0$/m); expect(unproven).toMatch(/ = icmp ne i64 [^\n]+, 0$/m); expect(unproven).toContain("@llvm.expect.i1("); expect(unproven).toContain("rem.float"); // A constant divisor needs only the dividend check. const constant = fn("constants"); - expect(constant).toMatch(/ = srem i64 [^\n]+, 8$/m); + expect(constant).toMatch(/ = srem i64 [^\n]+, 10$/m); expect(constant).not.toMatch(/ = icmp ne i64 [^\n]+, 0$/m); + // Power-of-two divisors of at least 1 subtract the truncated multiple, + // with no integer check; the divisor's sign is dropped and 0.5 keeps + // frem. + const powers = fn("powers"); + expect(powers).not.toContain("freeze"); + expect(powers).not.toContain("srem"); + expect(powers.match(/ = fdiv double [^\n]+, 0x/g)).toHaveLength(3); + expect(powers).toMatch(/ = fdiv double [^\n]+, 0x4020000000000000$/m); + expect(powers).toMatch(/ = fdiv double [^\n]+, 0x41F0000000000000$/m); + expect(powers).toMatch(/ = fdiv double [^\n]+, 0x3FF0000000000000$/m); + expect( + powers.match(/ = call double @llvm.copysign.f64\(double 0x0000000000000000, /g), + ).toHaveLength(3); + expect(powers).toMatch(/ = frem double [^\n]+, 0x3FE0000000000000$/m); // Proven integers with a nonzero divisor need no check or fallback. const signed = fn("signed"); expect(signed).not.toContain("freeze"); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json new file mode 100644 index 000000000..c960ba1cd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5455-power-of-two-remainder.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5455-power-of-two-remainder.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json new file mode 100644 index 000000000..6b48639fb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5456-remainder-large-integer-dividends.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5456-remainder-large-integer-dividends.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json new file mode 100644 index 000000000..0e2b42dad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5457-lcg-modulus.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5457-lcg-modulus.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json new file mode 100644 index 000000000..edb5a8a41 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5458-fractional-remainder.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5458-fractional-remainder.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5455-power-of-two-remainder.ts b/tests/corpus/5455-power-of-two-remainder.ts new file mode 100644 index 000000000..3d8969f37 --- /dev/null +++ b/tests/corpus/5455-power-of-two-remainder.ts @@ -0,0 +1,66 @@ +// `%` by a constant power of two of at least 1 matches Node for every kind +// of dividend: integers, fractions, subnormals, values beyond 2^53, -0, +// NaN and the infinities. The divisor's sign does not change the result, +// and a zero result takes the dividend's sign. + +function show(label: string, v: number): void { + console.log(label, Object.is(v, -0) ? "-0" : String(v)); +} + +const dividends: number[] = [ + 0, -0, 1, -1, 3, -4, 7.25, -7.25, 0.75, -0.75, 1.5, -1.5, 5e-324, -5e-324, + 2.2250738585072014e-308, -2.2250738585072014e-308, 1e-300, 2 ** 31 - 0.5, -(2 ** 31) + 0.5, + 2 ** 32 + 0.25, 2 ** 52 + 1, -(2 ** 52 + 3), 2 ** 52 + 0.5, 2 ** 53, -(2 ** 53), 2 ** 53 + 2, + 2 ** 60 + 1024, -(2 ** 60), 2 ** 63, 2 ** 64 + 4096, 1e21, -1e21, 1e300, -1e300, + Number.MAX_VALUE, -Number.MAX_VALUE, 123456789.123, -98765.4321, NaN, Infinity, -Infinity, +]; + +for (const x of dividends) { + show(`${x} % 1`, x % 1); + show(`${x} % -1`, x % -1); + show(`${x} % 2`, x % 2); + show(`${x} % 8`, x % 8); + show(`${x} % -16`, x % -16); + show(`${x} % 1024`, x % 1024); + show(`${x} % 2147483648`, x % 2147483648); + show(`${x} % -2147483648`, x % -2147483648); + show(`${x} % 4294967296`, x % 4294967296); + show(`${x} % 2 ** 32`, x % 2 ** 32); + show(`${x} % -(2 ** 40)`, x % -(2 ** 40)); + show(`${x} % 2 ** 52`, x % 2 ** 52); + show(`${x} % 2 ** 53`, x % 2 ** 53); + show(`${x} % 2 ** 64`, x % 2 ** 64); + show(`${x} % 2 ** 1023`, x % 2 ** 1023); + show(`${x} % -(2 ** 1023)`, x % -(2 ** 1023)); +} + +// Divisors that are not powers of two of at least 1 keep the general path. +for (const x of dividends) { + show(`${x} % 0.5`, x % 0.5); + show(`${x} % 0.125`, x % 0.125); + show(`${x} % 3`, x % 3); + show(`${x} % 6`, x % 6); + show(`${x} % 2 ** 0.5`, x % 2 ** 0.5); + show(`${x} % 2 ** -1`, x % 2 ** -1); +} + +// Module constants, compound assignment, record fields and closures. +const WORD = 4294967296; +const HALF_WORD = 65536; +interface Cell { + value: number; +} +const cell: Cell = { value: -12345.678 }; +let total = 0; +for (let i = 0; i < 64; i++) { + cell.value = cell.value * -3.7 + i; + let v = cell.value; + v %= HALF_WORD; + total += (v % WORD) + (cell.value % 256); +} +show("total", total); +const wrap = (x: number): number => x % 2 ** 16; +show("wrap", wrap(-1e15 - 0.5) + wrap(2 ** 70) + wrap(65536.5)); +let sum = 0; +for (let i = -20; i <= 20; i++) sum += (i * 0.37) % 1; +show("sum", sum); diff --git a/tests/corpus/5456-remainder-large-integer-dividends.ts b/tests/corpus/5456-remainder-large-integer-dividends.ts new file mode 100644 index 000000000..c2588b73c --- /dev/null +++ b/tests/corpus/5456-remainder-large-integer-dividends.ts @@ -0,0 +1,50 @@ +// `%` with integer dividends beyond 2^53 (every double there is an integer) +// and integer divisors: constant, int32 and unproven. Results match Node, +// including the sign of zero and the bounds of the inline integer path at +// 2^62 and 2^63. + +function show(label: string, v: number): void { + console.log(label, Object.is(v, -0) ? "-0" : String(v)); +} + +const dividends: number[] = [ + 2 ** 53, -(2 ** 53), 2 ** 53 + 2, -(2 ** 53 + 2), 2 ** 54 + 4, 2 ** 60 + 1024, -(2 ** 60 + 2048), + 2 ** 61 + 3 * 2 ** 9, 2 ** 62 - 1024, -(2 ** 62 - 1024), 2 ** 62, -(2 ** 62), 2 ** 62 + 1024, + -(2 ** 62 + 1024), 2 ** 63 - 1024, -(2 ** 63 - 1024), 2 ** 63, -(2 ** 63), 2 ** 64, -(2 ** 64), + 1e19, -1e19, 1e21, 9.999999999999999e22, +]; +const divisors: number[] = [ + 1, -1, 2, 3, -3, 7, 10, 1000, 65535, 1000000007, 2147483647, -2147483647, 2 ** 31, 2 ** 32 - 1, + 2 ** 53 - 1, 2 ** 53 + 2, 2 ** 62, -(2 ** 62), 2 ** 62 + 1024, 2 ** 63, -(2 ** 63), 2 ** 64, 0, + -0, +]; + +// Unproven dividends and divisors read from arrays. +let checksum = 0; +for (let i = 0; i < dividends.length; i++) { + for (let j = 0; j < divisors.length; j++) { + const r = dividends[i]! % divisors[j]!; + show(`${dividends[i]} % ${divisors[j]}`, r); + if (r === r) checksum = (checksum * 31 + (r % 1000003)) % 1000000007; + } +} +show("checksum", checksum); + +// Constant divisors. +for (const x of dividends) { + show(`${x} % 3`, x % 3); + show(`${x} % -10`, x % -10); + show(`${x} % 1000000007`, x % 1000000007); + show(`${x} % 2147483647`, x % 2147483647); + show(`${x} % -1`, x % -1); +} + +// Int32 divisors: loop counters and `| 0` values. +for (let k = 1; k <= 40; k += 13) { + for (const x of dividends) { + show(`${x} % ${k}`, x % k); + const m = (k * 7919) | 0; + show(`${x} % ${m}`, x % m); + show(`${x} % ${-m}`, x % -m); + } +} diff --git a/tests/corpus/5457-lcg-modulus.ts b/tests/corpus/5457-lcg-modulus.ts new file mode 100644 index 000000000..a475642dd --- /dev/null +++ b/tests/corpus/5457-lcg-modulus.ts @@ -0,0 +1,50 @@ +// Linear congruential generators whose products exceed 2^53, reduced with +// `%` by powers of two and by other constants. The products round, so every +// step must reproduce Node's double arithmetic exactly. + +let seed = 42; +function lcg31(): number { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed; +} +let prime = 42; +function parkMillerWide(): number { + prime = (prime * 1103515245 + 12345) % 2147483647; + return prime; +} +let word = 7; +function lcg32(): number { + word = (word * 6364136223846793 + 1442695040888963) % 2 ** 32; + return word; +} +let negative = -99; +function signedStep(): number { + negative = (negative * -1103515245 - 12345) % 2147483648; + return negative; +} + +const seen = new Map(); +let a = 0; +let b = 0; +let c = 0; +let d = 0; +for (let i = 0; i < 20000; i++) { + const x = lcg31(); + a = (a + x) % 1000000007; + b = (b * 31 + parkMillerWide()) % 1000000007; + c = (c + lcg32()) % 2 ** 40; + d = (d + signedStep()) % 1000000007; + if (i < 8) console.log(i, x, prime, word, negative); + seen.set(x, (seen.get(x) ?? 0) + 1); +} +console.log("sums", a, b, c, d, Object.is(d, -0)); +console.log("distinct", seen.size); + +// Bucket a stream into a small table with the low and high bits. +const buckets = new Int32Array(16); +for (let i = 0; i < 5000; i++) { + const x = lcg31(); + buckets[x % 16]++; + buckets[Math.floor(x / 134217728) % 16]++; +} +console.log(Array.from(buckets).join(",")); diff --git a/tests/corpus/5458-fractional-remainder.ts b/tests/corpus/5458-fractional-remainder.ts new file mode 100644 index 000000000..f83b2697c --- /dev/null +++ b/tests/corpus/5458-fractional-remainder.ts @@ -0,0 +1,34 @@ +// Fractional dividends with `%`: Weyl sequences and hashes reduced `% 1`, +// phases reduced by powers of two and by other divisors, and negative values +// whose remainders keep the dividend's sign. + +function show(label: string, v: number): void { + console.log(label, Object.is(v, -0) ? "-0" : String(v)); +} + +let weyl = 0.5; +let hashSum = 0; +for (let i = 0; i < 2000; i++) { + weyl = (weyl + 0.6180339887498949) % 1; + const h = (weyl * 2654435761.5) % 1; + hashSum += h; + if (i < 6) show(`weyl ${i}`, weyl); + if (i < 6) show(`hash ${i}`, h); +} +show("hashSum", hashSum); + +let phase = -3.75; +for (let i = 0; i < 12; i++) { + phase = phase * -2.5 + 0.125; + show(`phase ${i} % 4`, phase % 4); + show(`phase ${i} % 0.25`, phase % 0.25); + show(`phase ${i} % 360`, phase % 360); + show(`phase ${i} % (2 * Math.PI)`, phase % (2 * Math.PI)); +} + +// Fractional parts of negative values and of values that are integers. +for (const x of [-2.5, -2, -0.5, -0, 0, 0.5, 2, 2.5, -1e-310, 1e-310, 4503599627370495.5]) { + show(`${x} % 1`, x % 1); + show(`${x} % -1`, x % -1); + show(`${x} % 2`, x % 2); +}