diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 82a1884bd..aefcbc0af 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -3225,7 +3225,14 @@ export function lowerFileInit( }); ctx.hoistedFnDecls.add(stmt); } + const islandActions = lowerer.islandImportActions.get(sf); + const islandPlaced = new Set(); for (const { stmt, dep } of orderedImportsOf(lowerer.program, sf)) { + const island = islandActions?.get(stmt); + if (island !== undefined) { + header.push(...island); + islandPlaced.add(stmt); + } const npm = lowerer.npmInitActions.get(sf)?.get(stmt); if (npm !== undefined && npm.length > 0) { header.push(...npm); @@ -3294,6 +3301,11 @@ export function lowerFileInit( } } } + // An island load whose statement the import order leaves out keeps the + // prelude's place. + const islandUnplaced: IrStmt[] = []; + for (const [stmt, actions] of islandActions ?? []) + if (!islandPlaced.has(stmt)) islandUnplaced.push(...actions); if (asyncDeps.length === 1) { const dep = asyncDeps[0]!; const promiseT: IrType = { kind: "promise", inner: VOID }; @@ -3432,7 +3444,7 @@ export function lowerFileInit( if (ms && ms.sf === sf) statics.push({ pos: ms.pos, info: c }); } statics.sort((a, b) => a.pos - b.pos); - const body = [...header, ...prelude]; + const body = [...header, ...islandUnplaced, ...prelude]; let at = 0; for (const stmt of stmts) { // `<=`: a mixin instantiation's position IS its statement's start diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index c36dc345f..df515a3d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -31,6 +31,7 @@ import { VOID, type IrExpr, type IrGlobal, + type IrLocal, type IrStmt, type IrType, type SrcLoc, @@ -390,9 +391,26 @@ function runsAtModuleEvaluation(node: ts.Node): boolean { return false; } +/** The %init statements that run in `stmt`'s place in its file's %init + * header (Lowerer.islandImportActions). */ +function islandStatementActions(lowerer: Lowerer, sf: ts.SourceFile, stmt: ts.Statement): IrStmt[] { + let byStatement = lowerer.islandImportActions.get(sf); + if (byStatement === undefined) { + byStatement = new Map(); + lowerer.islandImportActions.set(sf, byStatement); + } + let actions = byStatement.get(stmt); + if (actions === undefined) { + actions = []; + byStatement.set(stmt, actions); + } + return actions; +} + /** A static import (or re-export) of an island package: loads the module - * in the importer's %init prelude. Returns false when `spec` is not an - * island module. */ + * in the importer's %init header, in the statement's place among the + * dependencies' %init calls, as Node evaluates it in source order. Returns + * false when `spec` is not an island module. */ export function collectIslandImport( lowerer: Lowerer, sf: ts.SourceFile, @@ -418,8 +436,7 @@ export function collectIslandImport( lowerer.usesNodeIsland = true; const exportLoc = locOf(stmt); const exported = islandModuleGlobal(lowerer, path); - const initActions = lowerer.jsonInitActions.get(sf) ?? []; - lowerer.jsonInitActions.set(sf, initActions); + const initActions = islandStatementActions(lowerer, sf, stmt); initActions.push({ kind: "assign", localId: exported.global.id, @@ -446,8 +463,7 @@ export function collectIslandImport( lowerer.usesNodeIsland = true; const loc = locOf(stmt); const binding = islandModuleGlobal(lowerer, path); - const actions = lowerer.jsonInitActions.get(sf) ?? []; - lowerer.jsonInitActions.set(sf, actions); + const actions = islandStatementActions(lowerer, sf, stmt); actions.push({ kind: "assign", localId: binding.global.id, @@ -1499,6 +1515,16 @@ function lowerIslandChain( return stmts.length > 0 ? { kind: "seqExpr", stmts, result, type: result.type, loc } : result; } +/** The local an island handle binding reads here: its own binding, retyped + * checked-dynamic, or, inside a closure, the capture of an enclosing + * function's binding, which resolveLocal registers. */ +function islandHandleLocal(lowerer: Lowerer, ident: ts.Identifier): IrLocal | null { + const origin = lowerer.peekLocal(ident); + if (origin === null) return null; + origin.type = DYN; + return lowerer.resolveLocal(ident); +} + /** Expressions on island values (the lowerExpr hook; null: not an island * expression, the ordinary lowering applies). */ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | null { @@ -1527,7 +1553,7 @@ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | expr, ); lowerer.usesNodeIsland = true; - const slot = lowerer.globalOf(expr) ?? lowerer.peekLocal(expr); + const slot = lowerer.globalOf(expr) ?? islandHandleLocal(lowerer, expr); if (slot === null) return null; // the local is declared dyn slot.type = DYN; const ref = varRef(slot.id, DYN, loc); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 43822d695..ac74ee69f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3662,6 +3662,12 @@ export class Lowerer { readonly globalsList: IrGlobal[] = []; readonly npmInitActions = new Map>(); + /** Per-file %init statements of static island imports and re-exports, + * by the import or export statement that loads the island module: the + * importer's %init header runs them in that statement's place among its + * dependencies' %init calls, so the island module evaluates in source + * order with the native dependencies around it. */ + readonly islandImportActions = new Map>(); /** Per-file %init PRELUDE statements for JSON imports: bakeable DATA * assignments with no observable evaluation order of their own — * prepended by lowerFileInit so the bindings are live before any @@ -4380,6 +4386,7 @@ export class Lowerer { checkedTable("globalsByDeclNode", () => this.globalsByDeclNode.size), checkedTable("globalsList", () => this.globalsList.length), checkedTable("npmInitActions", () => this.npmInitActions.size), + checkedTable("islandImportActions", () => this.islandImportActions.size), checkedTable("jsonInitActions", () => this.jsonInitActions.size), checkedTable("jsonRequireModules", () => this.jsonRequireModules.size), checkedTable("jsonRequireCalls", () => this.jsonRequireCalls.size), diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 1b4eb3579..b0fbb0189 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5532,6 +5532,23 @@ export function checkPreflightTs7( } } +/** Whether a declaration file has the runtime file TypeScript pairs it + * with (`x.d.ts` with `x.js`, `x.d.mts` with `x.mjs`, `x.d.cts` with + * `x.cjs`): a specifier that resolves to the declaration then still names a + * module Node can load. */ +function declarationHasRuntimeFile(declaration: ts.SourceFile): boolean { + const name = declaration.fileName; + const pairs: [string, string][] = [ + [".d.ts", ".js"], + [".d.mts", ".mjs"], + [".d.cts", ".cjs"], + ]; + for (const [declared, runtime] of pairs) + if (name.endsWith(declared)) + return trackedFileExists(name.slice(0, -declared.length) + runtime); + return false; +} + function preflight7(load: LoadResult): { diags: ScrDiagnostic[]; moduleOrder: ts.SourceFile[]; @@ -6065,7 +6082,7 @@ function preflight7(load: LoadResult): { // ERR_MODULE_NOT_FOUND), and a declaration file is not a program // module: an edge to it would lower the declarations' own imports // as code. - if (reDep !== null && reDep.isDeclarationFile) { + if (reDep !== null && reDep.isDeclarationFile && !declarationHasRuntimeFile(reDep)) { diags.push( unsupportedDiag( "SC1010", @@ -6271,7 +6288,7 @@ function preflight7(load: LoadResult): { // edge would lower its imports as if it were code (SC2013 for every // specifier inside a package's `.d.ts`). Type-only imports were // already erased above; this is the value form. - if (dep !== null && dep.isDeclarationFile && !isJson) { + if (dep !== null && dep.isDeclarationFile && !isJson && !declarationHasRuntimeFile(dep)) { diags.push( unsupportedDiag( "SC1010", diff --git a/packages/compiler/test/lib-diagnostics-options.test.ts b/packages/compiler/test/lib-diagnostics-options.test.ts index 1ef69d867..c561f1c2f 100644 --- a/packages/compiler/test/lib-diagnostics-options.test.ts +++ b/packages/compiler/test/lib-diagnostics-options.test.ts @@ -88,7 +88,7 @@ test("noUnusedLocals and noUnusedParameters report nothing in declaration files" const codes = (extension: string, text: string): string[] => checkPreflightTs7( project(options, { - "main.ts": 'import "./extra.js";\nexport {};\n', + "main.ts": 'export type {} from "./extra.js";\n', [`extra.${extension}`]: text, }), ).diags.map((d) => d.message); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/node-island-import-order/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/node-island-import-order/main.ts.json new file mode 100644 index 000000000..a6aa6d7e8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/node-island-import-order/main.ts.json @@ -0,0 +1,10 @@ +{ + "order": [ + "/tests/corpus/node-island-import-order/native-a.ts", + "/tests/corpus/node-island-import-order/barrel.ts", + "/tests/corpus/node-island-import-order/native-b.ts", + "/tests/corpus/node-island-import-order/user.ts", + "/tests/corpus/node-island-import-order/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6897-node-island-declared-arrays/main.ts b/tests/corpus/6897-node-island-declared-arrays/main.ts index a5c02a23d..9093d97de 100644 --- a/tests/corpus/6897-node-island-declared-arrays/main.ts +++ b/tests/corpus/6897-node-island-declared-arrays/main.ts @@ -38,3 +38,13 @@ const second = list(); const sortedNative: string[] = [...first]; sortedNative.sort(); console.log(sortedNative.join(","), second.join(","), describe(first)); + +// A function-local island array captured by an arrow crosses the same way. +function captured(): string { + const local = list(); + const show = (): string => describe(local); + const size = (): number => local.length; + const spread = (): string => [...local].join(","); + return `${show()} ${size()} ${spread()}`; +} +console.log(captured()); diff --git a/tests/corpus/node-island-import-order/barrel.ts b/tests/corpus/node-island-import-order/barrel.ts new file mode 100644 index 000000000..525d358c0 --- /dev/null +++ b/tests/corpus/node-island-import-order/barrel.ts @@ -0,0 +1,5 @@ +// The island re-export comes first, so the island module evaluates before +// the native module this barrel imports next. +export { tag } from "sidekit"; +import { a } from "./native-a.ts"; +console.log("barrel evaluates", a); diff --git a/tests/corpus/node-island-import-order/main.ts b/tests/corpus/node-island-import-order/main.ts new file mode 100644 index 000000000..92f18fad9 --- /dev/null +++ b/tests/corpus/node-island-import-order/main.ts @@ -0,0 +1,8 @@ +// @requires-libnode +// Static imports and re-exports of a Node island package evaluate the +// island module in source order among the native modules a file imports, +// as Node evaluates dependencies: an island package's top-level code runs +// before a native module imported after it, and after one imported before. +import { tag } from "./barrel.ts"; +import "./user.ts"; +console.log("main", tag("main")); diff --git a/tests/corpus/node-island-import-order/native-a.ts b/tests/corpus/node-island-import-order/native-a.ts new file mode 100644 index 000000000..41180e891 --- /dev/null +++ b/tests/corpus/node-island-import-order/native-a.ts @@ -0,0 +1,2 @@ +console.log("native module a evaluates"); +export const a = "a"; diff --git a/tests/corpus/node-island-import-order/native-b.ts b/tests/corpus/node-island-import-order/native-b.ts new file mode 100644 index 000000000..63f51a2e8 --- /dev/null +++ b/tests/corpus/node-island-import-order/native-b.ts @@ -0,0 +1,2 @@ +console.log("native module b evaluates"); +export const b = "b"; diff --git a/tests/corpus/node-island-import-order/node_modules/laterkit/index.d.ts b/tests/corpus/node-island-import-order/node_modules/laterkit/index.d.ts new file mode 100644 index 000000000..ee4242fe4 --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/laterkit/index.d.ts @@ -0,0 +1 @@ +export declare function tag(text: string): string; diff --git a/tests/corpus/node-island-import-order/node_modules/laterkit/index.js b/tests/corpus/node-island-import-order/node_modules/laterkit/index.js new file mode 100644 index 000000000..9498a667c --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/laterkit/index.js @@ -0,0 +1,4 @@ +"use strict"; +// Top-level code with an observable effect: when it runs is the test. +console.log("second island module evaluates"); +exports.tag = (text) => "<" + text + ">"; diff --git a/tests/corpus/node-island-import-order/node_modules/laterkit/package.json b/tests/corpus/node-island-import-order/node_modules/laterkit/package.json new file mode 100644 index 000000000..17c4d36a7 --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/laterkit/package.json @@ -0,0 +1 @@ +{ "name": "laterkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/node-island-import-order/node_modules/sidekit/index.d.ts b/tests/corpus/node-island-import-order/node_modules/sidekit/index.d.ts new file mode 100644 index 000000000..ee4242fe4 --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/sidekit/index.d.ts @@ -0,0 +1 @@ +export declare function tag(text: string): string; diff --git a/tests/corpus/node-island-import-order/node_modules/sidekit/index.js b/tests/corpus/node-island-import-order/node_modules/sidekit/index.js new file mode 100644 index 000000000..c0de7152d --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/sidekit/index.js @@ -0,0 +1,4 @@ +"use strict"; +// Top-level code with an observable effect: when it runs is the test. +console.log("island module evaluates"); +exports.tag = (text) => "[" + text + "]"; diff --git a/tests/corpus/node-island-import-order/node_modules/sidekit/package.json b/tests/corpus/node-island-import-order/node_modules/sidekit/package.json new file mode 100644 index 000000000..1d6608b2f --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/sidekit/package.json @@ -0,0 +1 @@ +{ "name": "sidekit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/node-island-import-order/package.json b/tests/corpus/node-island-import-order/package.json new file mode 100644 index 000000000..c4ccd8ecc --- /dev/null +++ b/tests/corpus/node-island-import-order/package.json @@ -0,0 +1 @@ +{ "name": "node-island-import-order", "private": true, "type": "module", "scriptc": { "packages": { "sidekit": "node", "laterkit": "node" } } } diff --git a/tests/corpus/node-island-import-order/user.ts b/tests/corpus/node-island-import-order/user.ts new file mode 100644 index 000000000..afb0b0edd --- /dev/null +++ b/tests/corpus/node-island-import-order/user.ts @@ -0,0 +1,5 @@ +// A native import, then an island import, then another native import. +import { b } from "./native-b.ts"; +import { tag } from "laterkit"; +import { a } from "./native-a.ts"; +console.log("user evaluates", tag(a + b));