Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions jacodb-ets/ts-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion jacodb-ets/ts-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test": "vitest run --testTimeout=15000"
},
"dependencies": {
"typescript": "^5.4.5"
"typescript": "5.9.3"
},
"devDependencies": {
"@types/node": "^18.19.0",
Expand Down
63 changes: 35 additions & 28 deletions jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,29 +91,31 @@ export class StmtLowerer {
// snapshot captures BEFORE the captured variables are assigned. Hoist only the
// declarations whose values are evaluated earlier in the same statement list;
// a reference in a deferred closure body cannot force early capture.
const hoisted = new Set<ts.Statement>();
const declarations = new Map<ts.Symbol, { statement: ts.Statement; index: number }[]>();
statements.forEach((statement, index) => {
if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) {
return;
}
const functionSymbol = this.m.converter.symbolOf(statement.name);
if (functionSymbol === undefined) {
return;
}
if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) return;
const symbol = this.m.converter.symbolOf(statement.name);
if (symbol === undefined) return;

for (let i = 0; i < index; i++) {
const earlierStatement = statements[i];
const uses = this.functionUses(earlierStatement, functionSymbol);
if (uses.eager) {
hoisted.add(statement);
}
if (uses.deferred) {
// Capturing a function before its binding is assigned needs a
// live binding cell, which the lifted environment cannot express.
this.unsupportedForwardFunctionCaptures.add(earlierStatement);
}
}
const existing = declarations.get(symbol) ?? [];
existing.push({ statement, index });
declarations.set(symbol, existing);
});

const hoisted = new Set<ts.Statement>();
if (declarations.size > 0) {
statements.forEach((statement, index) => {
// Collect all forward references in one traversal instead of rescanning
// every earlier subtree separately for every later declaration.
for (const [symbol, uses] of this.functionUses(statement, declarations)) {
for (const declaration of declarations.get(symbol)!) {
if (declaration.index <= index) continue;
if (uses.eager) hoisted.add(declaration.statement);
if (uses.deferred) this.unsupportedForwardFunctionCaptures.add(statement);
}
}
});
}
for (const statement of statements) {
if (hoisted.has(statement)) {
this.lowerStatement(statement);
Expand All @@ -126,11 +128,13 @@ export class StmtLowerer {
}
}

private functionUses(node: ts.Node, functionSymbol: ts.Symbol): { eager: boolean; deferred: boolean } {
let eager = false;
let deferred = false;
private functionUses(
node: ts.Node,
functionSymbols: ReadonlyMap<ts.Symbol, unknown>,
): Map<ts.Symbol, { eager: boolean; deferred: boolean }> {
const uses = new Map<ts.Symbol, { eager: boolean; deferred: boolean }>();
const visit = (current: ts.Node, inDeferredBody: boolean): void => {
if ((eager && deferred) || ts.isTypeNode(current)) {
if (ts.isTypeNode(current)) {
return;
}

Expand Down Expand Up @@ -178,9 +182,12 @@ export class StmtLowerer {
const isValueReference = !isPropertyName && !isBindingName;
if (isValueReference) {
const referenceSymbol = this.m.converter.symbolOf(current);
if (referenceSymbol === functionSymbol) {
if (inDeferredBody) deferred = true;
else eager = true;
const isScopeFunction = referenceSymbol !== undefined && functionSymbols.has(referenceSymbol);
if (isScopeFunction) {
const referenceUses = uses.get(referenceSymbol) ?? { eager: false, deferred: false };
if (inDeferredBody) referenceUses.deferred = true;
else referenceUses.eager = true;
uses.set(referenceSymbol, referenceUses);
return;
}
}
Expand All @@ -190,7 +197,7 @@ export class StmtLowerer {
};

visit(node, false);
return { eager, deferred };
return uses;
}

private immediatelyCalledFunction(expression: ts.Expression): ts.ArrowFunction | ts.FunctionExpression | undefined {
Expand Down
30 changes: 30 additions & 0 deletions jacodb-ets/ts-frontend/test/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ describe("parseArgs", () => {
});

describe("project mode", () => {
it("supports Node20 projects with relative TypeScript extension imports", () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-node20-"));
tempDirs.push(projectDir);
fs.writeFileSync(path.join(projectDir, "package.json"), JSON.stringify({ type: "module" }));
fs.writeFileSync(path.join(projectDir, "tsconfig.json"), JSON.stringify({
compilerOptions: {
module: "node20", moduleResolution: "nodenext", rewriteRelativeImportExtensions: true,
noEmit: true, allowImportingTsExtensions: true,
},
files: ["index.ts", "dependency.ts"],
}));
fs.writeFileSync(path.join(projectDir, "dependency.ts"), "export class Dependency { value = 7; }");
fs.writeFileSync(path.join(projectDir, "index.ts"),
'import { Dependency } from "./dependency.ts"; export class Client extends Dependency {}');

const inputs = resolveProjectInputs(projectDir);
const outputDir = path.join(projectDir, "ir");
const result = main(["--project", projectDir, outputDir]);

expect(inputs.options.module).toBe(ts.ModuleKind.Node20);
expect(inputs.options.rewriteRelativeImportExtensions).toBe(true);
expect(result).toBe(0);
const file = JSON.parse(fs.readFileSync(path.join(outputDir, "index.ts.json"), "utf8"));
expect(file.importInfos).toContainEqual(expect.objectContaining({ importFrom: "./dependency.ts" }));
expect(file.classes).toContainEqual(expect.objectContaining({
signature: expect.objectContaining({ name: "Client" }),
superClassName: "Dependency",
}));
});

it("keeps an imported class alias while rejecting a mutable namespace property", () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-class-alias-"));
tempDirs.push(projectDir);
Expand Down
22 changes: 20 additions & 2 deletions jacodb-ets/ts-frontend/test/closures.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { ClassCategory } from "../src/dto/constants";
import { AssignStmtDto, StmtDto } from "../src/dto/stmts";
import { buildEtsFile } from "../src/lowering/fileBuilder";
import { serializeEtsFile } from "../src/serialize";
import { validateEtsFile } from "../src/validate";
import { consumers } from "./array-test-consumers";
import { defaultMethod, lower, methodByName, singleBlockStmts } from "./util";
import { compile, defaultMethod, lower, methodByName, singleBlockStmts } from "./util";

function allStmts(source: string): StmtDto[] {
const { file } = lower(source);
Expand All @@ -15,6 +16,23 @@ function allStmts(source: string): StmtDto[] {
}

describe("closures", () => {
it("does not repeatedly resolve sibling function subtrees for forward references", () => {
const declarations = Array.from({ length: 200 }, (_, index) =>
`function sibling${index}() { return input; }`).join("\n");
const { program, sourceFile, checker } = compile(`function outer(input: number) { ${declarations} }`);
const resolutions = vi.spyOn(checker, "getSymbolAtLocation");

try {
const file = buildEtsFile(program, sourceFile, { projectName: "proj", fileName: "test.ts" });

expect(validateEtsFile(file)).toEqual([]);
// One identifier per sibling must not cause a quadratic number of checker lookups.
expect(resolutions.mock.calls.length).toBeLessThan(5_000);
} finally {
resolutions.mockRestore();
}
});

it("lifts arrow functions into %AM methods with FunctionType use-site locals", () => {
const { file } = lower(`
let arr = [1, 2, 3];
Expand Down
Loading