diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 6399558bd..534fad940 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -42,6 +42,11 @@ declare module "node:fs" { /* The bare-encoding spelling — the options record's encoding key alone. */ export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void; export function writeFileSync(path: string | URL, data: string | Uint8Array): void; + /* The file-descriptor form: Node writes the whole data to an already + * open descriptor and leaves it open (closeSync owns its lifetime, and + * the write lands at the descriptor's current offset). */ + export function writeFileSync(fd: number, data: string): void; + export function writeFileSync(fd: number, data: string, encoding: "utf8" | "utf-8"): void; export function appendFileSync(path: string | URL, data: string | Uint8Array): void; export function existsSync(path: string | URL): boolean; export function mkdirSync(path: string | URL): void; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 14476833f..e3e7e3797 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -557,6 +557,7 @@ export const LIB_FN_SYMS: Record = { "fs.readFileSyncBuf": "scr_fs_read_file_bytes", "fs.readFileSyncBytes": "scr_fs_read_file_bytes", "fs.writeFileSyncBytes": "scr_fs_write_file_bytes", + "fs.writeFileSyncFd": "scr_fs_write_file_fd", "fs.appendFileSyncBytes": "scr_fs_append_file_bytes", "fs.readFdSyncBytes": "scr_fs_read_fd_bytes", // Stats snapshots (scr_lib.c): statSync/lstatSync throw like the other diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 6b5e2d8ae..a308f2216 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -997,6 +997,42 @@ export function lowerFsWriteBytesCall( return null; } +// The writeFileSync(fd, data[, "utf8"]) form — Node accepts a file +// descriptor where it accepts a path (write to a descriptor openSync +// returned, the descriptor staying open: closeSync owns its lifetime). +// Routed by the ARGUMENT's static type, like readFileSync(fd). +export function lowerFsWriteDescriptorCall( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const supported = 'only string data is supported: writeFileSync(fd, data, "utf8")'; + const dataNode = expr.arguments.nodes[1]!; + const dataIr = lowerer.mapTypeOf(lowerer.typeOf(dataNode)); + if (dataIr?.kind !== "string") { + lowerer.noLowering( + `writeFileSync(fd) of '${dataIr ? lowerer.fmt(dataIr) : lowerer.checker.typeToString(lowerer.typeOf(dataNode))}' data`, + dataNode, + supported, + ); + } + if (expr.arguments.nodes.length > 3) { + lowerer.noLowering( + `writeFileSync(fd) with ${expr.arguments.nodes.length} arguments`, + expr, + supported, + ); + } + const fd = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, F64); + return { + kind: "libCall", + fn: "fs.writeFileSyncFd", + args: [fd, lowerer.lowerExprExpecting(dataNode, STRING)], + type: VOID, + loc, + }; +} + export function lowerFsWriteOptionsCall( lowerer: Lowerer, expr: ts.CallExpression, diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index b21c964f9..bf5223d2f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -14,6 +14,7 @@ import { lowerFsReadSyncCall, lowerFsWriteSyncCall, lowerFsReadDescriptorCall, + lowerFsWriteDescriptorCall, lowerFsEncodedRead, lowerFsPromisesEncodedRead, lowerFsMkdirSyncCall, @@ -331,6 +332,17 @@ export function lowerBuiltinModuleCall( const lowered = lowerFsWriteBytesCall(lowerer, expr, bi, loc); if (lowered) return lowered; } + // writeFileSync(fd, data): Node accepts an open descriptor where it + // accepts a path. Routed by the FIRST ARGUMENT's static type, like + // readFileSync(fd) (before the path forms complete the data type). + if ( + bi.module === "fs" && + bi.member === "writeFileSync" && + expr.arguments.nodes.length >= 2 && + lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind === "f64" + ) { + return lowerFsWriteDescriptorCall(lowerer, expr, loc); + } if ( bi.module === "fs" && (bi.member === "writeFileSync" || bi.member === "appendFileSync") && diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 8f41aa93e..4a35b3aee 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3592,11 +3592,12 @@ export function lowerUnionProperty( * message. The caller (the property/element dispatch) maybeNarrows. */ export function lowerUnionKeyedRead( lowerer: Lowerer, - expr: ts.Expression, + expr: ts.Node, unionId: string, value: IrExpr, key: IrExpr, literalField: string | null, + missingFieldAsUndefined = false, ): IrExpr | null { const def = lowerer.unions.get(unionId); if (!def) return null; @@ -3646,6 +3647,14 @@ export function lowerUnionKeyedRead( } if (!shape.indexValue) { if (literalField === null && shape.fields.length > 0) continue; + // A record arm that does not declare the key at all answers + // UNDEFINED in JS. Destructuring asks for that answer (its own + // desugar reads a missing property as undefined, Node-exact); a + // dotted read keeps refusing until narrowing proves the arm. + if (literalField !== null && missingFieldAsUndefined) { + push(UNDEFINED_T); + continue; + } return null; } if (!pushAnswer(shape.indexValue)) return null; @@ -3690,6 +3699,12 @@ export function lowerUnionKeyedRead( if (!surfaces(declared)) return null; continue; } + if (literalField !== null && !shape.indexValue && missingFieldAsUndefined) { + // The missing-field answer above must surface as the join's + // undefined arm (the backend's keyed-read miss path stores it). + if (!(type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)) return null; + continue; + } const ovfShape: IrRecordShape = literalField !== null && shape.indexValue ? { ...shape, fields: [] } : shape; if (!recordKeyResultOk(lowerer, ovfShape, type)) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index b2e452faf..690f8a5ef 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -27,7 +27,11 @@ import { checkedPrivateReceiver, localPrivateBrandClass } from "./private-brands import { staticCondition } from "./static-typeof.js"; import { constituentTypes } from "../ts7/checker.js"; import type { Lowerer } from "./lowerer.js"; -import { lowerMaybePromiseAwait, maybePromiseUnionOf } from "./maybe-promise.js"; +import { + lowerMaybePromiseAwait, + maybePromiseUnionOf, + promiseArmedUnionOf, +} from "./maybe-promise.js"; import { checkedClassUnionAssertion } from "./class-assertions.js"; import { BIGINT_T, @@ -676,9 +680,23 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { loc, }; } - if (inner.kind === "union" || inner.kind === "dyn") { - // A union inner would need an arm-wise re-tag into the result - + if (inner.kind === "union") { + // A UNION inner (`Promise | undefined`): the awaited + // result is the checker's own `Awaited` of the operand, which + // the maybe-promise lowering already produces — it awaits the + // narrowed promise arm and coerces its settled union, and the + // unit arms keep JS's one-microtask hop. Only the promise-or- + // unit shape reaches here, so the maybe-promise union is the + // same union with its only data arms being units. + const armed = promiseArmedUnionOf(lowerer, value.type); + if (armed) return lowerMaybePromiseAwait(lowerer, expr, value, armed, loc); + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (the promise's inner type has no combined result union — await the promise arm after narrowing instead)`, + ); + } + if (inner.kind === "dyn") { lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 2afb40c60..afc14196e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -10,8 +10,12 @@ import { lowerIndexedUnionNumberRead, lowerIndexedUnionRead, } from "./indexed-unions.js"; -import { declaredMemberOf, isPromisePrototypeRead } from "./expressions/property-access.js"; -import { hostsAll } from "./expressions/hosted-narrowing.js"; +import { + declaredMemberOf, + isPromisePrototypeRead, + lowerUnionKeyedRead, +} from "./expressions/property-access.js"; +import { hostedStorageView, hostsAll } from "./expressions/hosted-narrowing.js"; import { requestGeneralStreamSurface } from "./general-streams.js"; import { lowerForAwaitDynStream, nodeStreamInterfaceSide } from "./dyn-streams.js"; import { lowerForAwaitValueStream } from "./builtins/hash-stream.js"; @@ -3538,6 +3542,34 @@ export function lowerBindingPattern( .get(srcType.unionId) ?.arms.every((arm) => arm.kind === "record" || arm.kind === "object") ) { + const arms = lowerer.unions.get(srcType.unionId)!.arms; + if (!unionObjectPatternFits(lowerer, pattern, arms)) { + // NARROW FIRST: the checker may have refined the union at this + // very site (`if (u.kind === "a") { const { x } = u }`, an `in` + // guard) while the IR value keeps the stored, wider union. Bind + // the narrowed view once and destructure that. + const view = narrowedUnionPatternSource( + lowerer, + pattern, + present, + srcType.unionId, + locOf(pattern), + ); + if (view !== null) { + const tmp = lowerer.declareHiddenLocal("%destrN", view.type); + out.push({ kind: "varDecl", localId: tmp.id, init: view, loc: locOf(pattern) }); + lowerer.lowerBindingPattern( + pattern, + () => ({ kind: "varRef", localId: tmp.id, type: view.type, loc: locOf(pattern) }), + view.type, + isLet, + out, + dynSpell, + allowDynObject, + ); + return; + } + } lowerUnionObjectPattern(lowerer, pattern, srcRef, srcType, isLet, out); return; } @@ -3950,11 +3982,235 @@ function lowerUrlObjectPattern( } } +/** The fresh record for `{ a, ...rest }` over a UNION source: per arm, + * the unconsumed fields copied out at destructure time into a fresh + * record of the checker's arm-wise rest type, selected by the runtime + * tag. Null-safe answer: an arm whose remaining fields do not spell an + * arm of the checker's rest type keeps the named fence. */ +function unionObjectRestValue( + lowerer: Lowerer, + el: ts.BindingElement, + srcRef: () => IrExpr, + srcType: IrType & { kind: "union" }, + arms: readonly IrType[], +): IrExpr { + const loc = locOf(el); + const restT = patternBindingType(lowerer, el.name!); + const fence: (why: string) => never = (why) => lowerer.unsupported("SC1031", el, why); + if (restT === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + // The consumed set is the pattern's literal keys: a computed sibling + // would make it a runtime fact. + const consumed = new Set(); + const pattern = el.parent; + if (ts.isObjectBindingPattern(pattern)) { + for (const sib of pattern.elements.nodes) { + if (sib === el || sib.dotDotDotToken) continue; + const key = sib.propertyName ?? sib.name; + if (key && ts.isComputedPropertyName(key)) { + fence("rest bindings beside computed keys (the consumed set is a runtime fact)"); + } + if (key && (ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNumericLiteral(key))) { + consumed.add(key.text); + } + } + } + const pieces: { tag: number; value: IrExpr }[] = []; + for (let tag = 0; tag < arms.length; tag++) { + const arm = arms[tag]!; + if (arm.kind !== "record") { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.indexValue) { + fence( + "rest bindings over index-signature shapes (the undeclared entries would need overflow packing)", + ); + } + const rem = shape.fields.filter((f) => !consumed.has(f.name)); + if (rem.some((f) => f.name.startsWith("%"))) { + fence("rest bindings over accessor shapes (the entries are closures, not data)"); + } + const armRef: IrExpr = { + kind: "unionNarrow", + unionId: srcType.unionId, + tag, + value: srcRef(), + type: arm, + loc, + }; + const packed: IrExpr = lowerer.keyOrder.copy( + { + kind: "recordLit", + fields: rem.map((f) => ({ + name: f.name, + value: lowerer.presenceKeepingCopy( + armRef, + arm.shapeId, + f.name, + { + kind: "recordGet", + obj: armRef, + shapeId: arm.shapeId, + field: f.name, + type: f.type, + loc, + }, + loc, + ), + })), + type: { + kind: "record", + shapeId: lowerer.shapes.intern(rem.map((f) => ({ name: f.name, type: f.type }))), + }, + loc, + }, + arm.shapeId, + ); + const coerced = lowerer.coerceInto(el, packed, restT); + if (!typeEquals(coerced.type, restT)) { + fence( + `rest bindings over union sources (the remaining fields of an arm do not spell an arm of '${lowerer.fmt(restT)}')`, + ); + } + pieces.push({ tag, value: coerced }); + } + let result: IrExpr | null = null; + for (let i = pieces.length - 1; i >= 0; i--) { + const piece = pieces[i]!; + result = + result === null + ? piece.value + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: srcType.unionId, + tag: piece.tag, + negated: false, + value: srcRef(), + type: BOOL, + loc, + }, + then: piece.value, + else_: result, + type: restT, + loc, + }; + } + if (result === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + return result; +} + +/** The one type every arm declares for `propName`, or null when an arm + * does not declare it (or types it differently, or not at all). */ +function unionPatternCommonType( + lowerer: Lowerer, + arms: readonly IrType[], + propName: string, +): IrType | null { + let common: IrType | null = null; + for (const arm of arms) { + let ft: IrType | undefined; + if (arm.kind === "record") { + ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; + } else if (arm.kind === "object") { + ft = lowerer.classes.get(arm.className)?.fields.get(propName); + } + if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { + return null; + } + common = ft; + } + return common; +} + +/** Whether the unionDisc fast path lowers this pattern over these arms + * unchanged: no rest element, and every element's key folds to one name + * every arm declares with one type. */ +function unionObjectPatternFits( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + arms: readonly IrType[], +): boolean { + for (const el of pattern.elements.nodes) { + if (el.name === undefined) continue; + if (el.dotDotDotToken) return false; + const prop = el.propertyName ?? el.name; + const propName = + ts.isIdentifier(prop) || + ts.isComputedPropertyName(prop) || + ts.isStringLiteralLike(prop) || + ts.isNumericLiteral(prop) + ? patternKeyNameOf(lowerer, prop as ts.PropertyName) + : null; + if (propName === null) return false; + if (unionPatternCommonType(lowerer, arms, propName) === null) return false; + } + return true; +} + +/** The CHECKER-NARROWED view of a destructuring source: `if (u.kind === + * "a") { const { x } = u }` or an `in` guard refines the type at the + * pattern's own site, while the IR value keeps the stored (wider) union + * and the un-narrowed lowering. Bind the value to the narrowed sub-union + * — a trusted arm extraction for one arm, a re-tag with the excluded + * arms as traps for a sub-union — so the pattern reads the arms the + * checker proved. Null when the checker did not narrow, when the + * refinement is an intersection of stored records (hostedStorageView + * keeps that layout), or when nothing was excluded. */ +function narrowedUnionPatternSource( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + value: IrExpr, + fromId: string, + loc: SrcLoc, +): IrExpr | null { + const from = lowerer.unions.get(fromId); + if (!from) return null; + const narrowed = lowerer.irTypeOf(pattern); + if (narrowed.kind === "void" || isUnitType(narrowed)) return null; + const siteArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!siteArms || siteArms.length === 0) return null; + const allowed = new Set(); + for (const arm of siteArms) { + const tag = lowerer.armTag(fromId, arm); + // Not a stored arm: a checker intersection that refines the stored + // records, which keeps the value's layout. + if (tag < 0) return hostedStorageView(lowerer, value, narrowed, loc); + allowed.add(tag); + } + if (allowed.size === 0 || allowed.size === from.arms.length) return null; + if (allowed.size === 1) { + const tag = [...allowed][0]!; + // The checker proved the tag; the extraction traps on a mismatch + // instead of reading another arm. + return { kind: "unionNarrow", unionId: fromId, tag, value, type: from.arms[tag]!, loc }; + } + const arms = from.arms.filter((_, tag) => allowed.has(tag)); + const subId = lowerer.unions.transform(from, arms); + const helper = lowerer.narrowedRetagHelper(pattern, fromId, subId, loc); + if (!helper) return null; + return { + kind: "call", + callee: helper, + args: [value], + type: { kind: "union", unionId: subId }, + loc, + }; +} + /** An object pattern over a union of records and class instances * (`function f({ kind, path }: Add | Remove)`): each element reads a data - * field every arm declares with one type, through the runtime tag, as - * `value.field` does. Rest elements and fields the arms type differently - * stay fenced. */ + * field through the runtime tag, as `value.field` does — the shared + * `unionDisc` when every arm declares it with one type, else the + * `unionKeyGet` join (an arm that does not declare the key answers + * undefined, as JS reads it). A rest element packs each arm's unconsumed + * fields into a fresh record. Class arms still need same-typed fields. */ function lowerUnionObjectPattern( lowerer: Lowerer, pattern: ts.ObjectBindingPattern, @@ -3967,7 +4223,17 @@ function lowerUnionObjectPattern( for (const el of pattern.elements.nodes) { if (el.name === undefined) continue; if (el.dotDotDotToken) { - lowerer.unsupported("SC1031", el, `rest bindings over union sources (${NARROW_FIRST})`); + // `{ a, ...rest }` over a union: each arm packs its own unconsumed + // fields into a FRESH record (JS's CopyDataProperties makes a fresh + // object too), and the checker's rest type is the union of those + // per-arm packs. The tag decides which pack runs. + lowerer.bindPatternTarget( + el.name, + unionObjectRestValue(lowerer, el, srcRef, srcType, arms), + isLet, + out, + ); + continue; } const prop = el.propertyName ?? el.name; const propName = @@ -3984,35 +4250,43 @@ function lowerUnionObjectPattern( "destructuring with computed keys that do not fold to one property name", ); } - let common: IrType | null = null; - for (const arm of arms) { - let ft: IrType | undefined; - if (arm.kind === "record") { - ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; - } else if (arm.kind === "object") { - ft = lowerer.classes.get(arm.className)?.fields.get(propName); - } - if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { - common = null; - break; - } - common = ft; - } - if (common === null) { - lowerer.unsupported( - "SC1031", + const common = unionPatternCommonType(lowerer, arms, propName); + let value: IrExpr; + if (common !== null) { + value = { + kind: "unionDisc", + unionId: srcType.unionId, + field: propName, + value: srcRef(), + type: common, + loc: locOf(el), + }; + } else { + // The unionDisc generalization (`unionKeyGet`): each arm answers + // its own declared type and an arm that does not declare the + // key answers undefined — exactly JS's desugar, which reads a + // missing property as undefined. + const loc = locOf(el); + const joined = lowerUnionKeyedRead( + lowerer, el, - `destructuring '${propName}' from a union whose arms type it differently (every arm must be a record or class with a same-typed field '${propName}' — ${NARROW_FIRST})`, + srcType.unionId, + srcRef(), + { kind: "strLit", value: propName, type: STRING, loc }, + propName, + true, ); + if (joined === null) { + lowerer.unsupported( + "SC1031", + el, + `destructuring '${propName}' from a union whose arms answer it with types that have no combined representation (every arm's '${ + propName + }' must join into one union — ${NARROW_FIRST})`, + ); + } + value = joined; } - let value: IrExpr = { - kind: "unionDisc", - unionId: srcType.unionId, - field: propName, - value: srcRef(), - type: common, - loc: locOf(el), - }; if (el.initializer) value = applyBindingDefault(lowerer, el, value); lowerer.bindPatternTarget(el.name, value, isLet, out); } diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 97fd3ccda..03b65bd54 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2197,7 +2197,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< rename: ["fs.checkPath"], readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"], // The bytes-data form and the { mode } options form. - writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync"], + // The bytes-data form, the { mode } options form, and the + // file-descriptor data form. + writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync", "fs.writeFileSyncFd"], appendFileSync: ["fs.appendFileSyncBytes"], // The fs stream members (bytesRead/bytesWritten, pending, path, close). createReadStream: [ diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index a87a448c5..050699bd4 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -920,6 +920,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "emitter.setDefaultMaxChk", "fs.readFileSyncBytes", "fs.writeFileSyncBytes", + "fs.writeFileSyncFd", "fs.appendFileSyncBytes", "zlib.inflateSync", "zlib.inflateRawSync", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ba35a92e3..bfb39cf16 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -5289,6 +5289,11 @@ function buildLibFnSignatures() { "fs.mkdtempSync": { argTypes: [STRING], result: STRING }, "fs.accessSync": { argTypes: [STRING, F64], result: VOID }, "fs.readFdSync": { argTypes: [F64, STRING], result: STRING }, + /** The writeFileSync(fd, data) form: a write(2) loop over the whole + * string at the descriptor's current offset, leaving the descriptor + * open (closeSync owns it). Errors carry the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ + "fs.writeFileSyncFd": { argTypes: [F64, STRING], result: VOID }, "fs.readFdSyncBytes": { argTypes: [F64], result: BYTES_U8 }, /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..25021cc12 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-write-file-sync-fd.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json new file mode 100644 index 000000000..f62e66a8a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-await-promise-arm.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..99385b8d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-destructuring-join-narrow.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index e8009d25a..67f18c0d8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4242,6 +4242,25 @@ double scr_fs_write_str_sync(double fd, ScrStr *data, double position, return wrote; } +/* writeFileSync(fd, data): Node writes the WHOLE data to the descriptor + * and leaves it open (closeSync owns its lifetime), at the descriptor's + * current offset — libuv's uv_fs_write loop, so a pipe's short writes + * resume until the data is gone. Errors keep the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ +void scr_fs_write_file_fd(double fd, ScrStr *data) { + char *owned; + const char *bytes = scr_str_utf8(data, &owned); + size_t at = 0; + while (at < data->len) { + double wrote = scr_fs_write_bytes(fd, bytes + at, data->len - at, -1); + /* A throw leaves 0 (or the short count a partial write reported); + * a zero-length write on a nonempty slice would spin, so stop. */ + if (wrote <= 0) break; + at += (size_t)wrote; + } + scr_str_utf8_end(owned); +} + /* fs.readSync(fd, buffer, offset, length[, position]) — the buffer form. * Node validates offset/length against the buffer before reading and * throws ERR_OUT_OF_RANGE; here the checks clamp to the same contract and diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 1d8d91d97..fa88873af 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -289,6 +289,10 @@ ScrStr *scr_fs_read_file(ScrStr *path); ScrStr *scr_fs_realpath(ScrStr *path); ScrStr *scr_fs_realpath_promise(ScrStr *path); void scr_fs_write_file(ScrStr *path, ScrStr *data); +/* writeFileSync(fd, data): a write(2) loop over the whole string at the + * descriptor's current offset; the descriptor stays open (closeSync owns + * it). Errors carry the fd operation's errno shape. */ +void scr_fs_write_file_fd(double fd, ScrStr *data); void scr_fs_append_file(ScrStr *path, ScrStr *data); bool scr_fs_exists(ScrStr *path); void scr_fs_mkdir(ScrStr *path); diff --git a/tests/corpus/fs-write-file-sync-fd.ts b/tests/corpus/fs-write-file-sync-fd.ts new file mode 100644 index 000000000..cfe992598 --- /dev/null +++ b/tests/corpus/fs-write-file-sync-fd.ts @@ -0,0 +1,14 @@ +// writeFileSync(fd, data): Node accepts an open file descriptor where it +// accepts a path, writes the whole data from the descriptor's current +// offset, and leaves the descriptor open. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const scratch = path.join(os.tmpdir(), `scriptc-write-fd-${process.pid}.txt`); +const fd = fs.openSync(scratch, "w"); +fs.writeFileSync(fd, "hello "); +fs.writeFileSync(fd, "world"); +fs.closeSync(fd); +console.log(fs.readFileSync(scratch, "utf8")); +fs.rmSync(scratch); diff --git a/tests/corpus/union-await-promise-arm.ts b/tests/corpus/union-await-promise-arm.ts new file mode 100644 index 000000000..91d9f4485 --- /dev/null +++ b/tests/corpus/union-await-promise-arm.ts @@ -0,0 +1,24 @@ +// Awaiting a union whose promise arm settles a union +// (`Promise | undefined`): the promise arm is awaited, and a +// non-promise arm answers its own value after one microtask turn. +function maybe(flag: boolean): Promise | undefined { + if (!flag) return undefined; + return Promise.resolve(flag ? "value" : null); +} + +async function main(): Promise { + const absent = await maybe(false); + console.log(String(absent)); + const settled = await maybe(true); + console.log(String(settled)); + const nil = await maybe(true); + console.log(nil === null); + + // Awaiting a non-promise arm takes exactly one microtask turn. + const order: string[] = []; + Promise.resolve().then(() => order.push("microtask")); + const hopped = await maybe(false); + order.push(`after:${String(hopped)}`); + console.log(order.join(",")); +} +main(); diff --git a/tests/corpus/union-destructuring-join-narrow.ts b/tests/corpus/union-destructuring-join-narrow.ts new file mode 100644 index 000000000..85de1faff --- /dev/null +++ b/tests/corpus/union-destructuring-join-narrow.ts @@ -0,0 +1,73 @@ +// Object patterns over a union whose arms type a field differently (or do +// not declare it), narrowed sources, and rest elements over a union. +interface Wide { + kind: "wide"; + value: number; + extra?: string; +} +interface Narrow { + kind: "narrow"; + value: string; + extra?: undefined; +} +type Item = Wide | Narrow; + +// The joined read: `number | string`, `string | undefined`. +function read(item: Item): void { + const { value, extra } = item; + console.log(item.kind, String(value), String(extra)); +} +read({ kind: "wide", value: 7, extra: "e" }); +read({ kind: "narrow", value: "v" }); + +interface Delta { + type: "delta"; + partial: { text: string }; + other: number; +} +interface Final { + type: "final"; + partial: { text: number }; + tail: boolean; +} +interface Bare { + type: "bare"; + other: number; +} +type Wire = Delta | Final | Bare; + +// A rest element over a union packs each arm's own remaining fields. +function rest(event: Delta | Final): string { + const { partial: _partial, ...kept } = event; + return JSON.stringify(kept); +} +console.log(rest({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(rest({ type: "final", partial: { text: 3 }, tail: true })); + +// A discriminant test and an `in` guard narrow the source; the pattern +// reads the arms the checker proved. +function narrowed(event: Wire): string { + if (event.type === "delta") { + const { partial, other } = event; + return `tag:${partial.text}:${other}`; + } + if ("partial" in event) { + const { partial } = event; + return `in:${String(partial.text)}`; + } + return "none"; +} +console.log(narrowed({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(narrowed({ type: "final", partial: { text: 3 }, tail: true })); +console.log(narrowed({ type: "bare", other: 4 })); + +// An `in` guard alone narrows to a SUB-union: both remaining arms keep +// their own field types. +function inOnly(w: Wire): string { + if (!("partial" in w)) return "none"; + const { partial } = w; + return String(partial.text); +} +console.log(inOnly({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(inOnly({ type: "final", partial: { text: 3 }, tail: true })); +console.log(inOnly({ type: "bare", other: 4 })); diff --git a/tests/diagnostics/destructuring-fences.ts b/tests/diagnostics/destructuring-fences.ts index 0ee60579d..25ee180c2 100644 --- a/tests/diagnostics/destructuring-fences.ts +++ b/tests/diagnostics/destructuring-fences.ts @@ -42,7 +42,7 @@ interface E2 { label: string; } function fromUnion(u: E1 | E2): string { - const { b } = u; // union source whose arms type the field differently: narrow first + const { b } = u; // arms type the field differently: the joined read compiles return String(b); } console.log(fromUnion({ kind: "one", b: "s", count: 1 })); diff --git a/tests/harness/__snapshots__/destructuring-fences.ts.txt b/tests/harness/__snapshots__/destructuring-fences.ts.txt index 41026a7c8..02484d872 100644 --- a/tests/harness/__snapshots__/destructuring-fences.ts.txt +++ b/tests/harness/__snapshots__/destructuring-fences.ts.txt @@ -19,22 +19,6 @@ destructuring-fences.ts:31:9 - error SC1090: reading a property that has only a | ^~~~~~ 32 | console.log(onlyIn); -destructuring-fences.ts:45:11 - error SC1031: destructuring 'b' from a union whose arms type it differently (every arm must be a record or class with a same-typed field 'b' — narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet - - 44 | function fromUnion(u: E1 | E2): string { - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - | ^ - 46 | return String(b); - -destructuring-fences.ts:46:17 - error SC2004: uses of 'b' inherit the blocker on its declaration - - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - 46 | return String(b); - | ^ - 47 | } - - hint: the declaration of 'b' did not compile — fix the diagnostic reported there and these sites clear with it - destructuring-fences.ts:51:7 - error SC1031: object destructuring of non-record values (the source is array-typed) are not supported yet 50 | const xs = [1, 2, 3]; diff --git a/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..5bfc37eb6 --- /dev/null +++ b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json @@ -0,0 +1 @@ +{ "plain": 3750, "san": 5900 } diff --git a/tests/harness/corpus-costs/union-await-promise-arm.ts.json b/tests/harness/corpus-costs/union-await-promise-arm.ts.json new file mode 100644 index 000000000..6eebf7e03 --- /dev/null +++ b/tests/harness/corpus-costs/union-await-promise-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 3150, "san": 4400 } diff --git a/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..2117fc7cd --- /dev/null +++ b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 2900 }