Skip to content
Open
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
47 changes: 47 additions & 0 deletions gradle/jitpack/setup-node.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/sh
set -eu

script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH='' cd -- "$script_dir/../.." && pwd)
node_version=$(tr -d '[:space:]' < "$repo_root/jacodb-ets/ts-frontend/.nvmrc")

case "$node_version" in
20.20.2)
node_sha256=bb8a5273607ebe712a27bb4f8870fb1257d09993b8d0812a4178a3feaa2effa4
;;
*)
echo "No checksum configured for Node.js $node_version" >&2
exit 1
;;
esac

if [ "$(uname -s)" != Linux ] || [ "$(uname -m)" != x86_64 ]; then
echo "The JitPack Node.js bootstrap supports Linux x86_64 only" >&2
exit 1
fi

install_root=${JITPACK_NODE_INSTALL_ROOT:-"$repo_root/.jitpack"}
archive="node-v${node_version}-linux-x64-glibc-217.tar.xz"
archive_path="$install_root/$archive"
extracted_dir="$install_root/${archive%.tar.xz}"
node_dir="$install_root/node"
download_url="https://unofficial-builds.nodejs.org/download/release/v${node_version}/$archive"

mkdir -p "$install_root"
curl --fail --location --show-error --silent \
--retry 3 --retry-delay 1 \
--output "$archive_path" \
"$download_url"
printf '%s %s\n' "$node_sha256" "$archive_path" | sha256sum --check
tar -xJf "$archive_path" -C "$install_root"
ln -s "$extracted_dir" "$node_dir"

if [ ! -x "$node_dir/bin/node" ] || [ ! -x "$node_dir/bin/npm" ]; then
echo "The downloaded Node.js toolchain is incomplete" >&2
exit 1
fi

PATH="$node_dir/bin:$PATH"
export PATH
node --version
npm --version
2 changes: 1 addition & 1 deletion jacodb-ets/ts-frontend/.nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20
20.20.2
12 changes: 11 additions & 1 deletion jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ class FileBuilder {
build(sourceFile: ts.SourceFile): EtsFileDto {
const contents = this.buildScope(sourceFile.statements, undefined);

contents.classes.push(...this.ctx.anonymous.classes);
const ownedStructuralClasses = this.ctx.converter.structuralClasses.filter(({ signature }) =>
signature.declaringFile.projectName === this.fileSignature.projectName
&& signature.declaringFile.fileName === this.fileSignature.fileName,
);
contents.classes.push(...this.ctx.anonymous.classes, ...ownedStructuralClasses);
// Anonymous closure methods retain the enclosing class so lexical
// `this` has the same type as in the source method. Add anonymous
// classes first because their methods may themselves contain closures.
Expand Down Expand Up @@ -296,6 +300,12 @@ class FileBuilder {
const classes: ClassDto[] = [];
const namespaces: NamespaceDto[] = [];

for (const statement of statements) {
if (ts.isTypeAliasDeclaration(statement)) {
this.ctx.converter.materializeStructuralAlias(statement);
}
}

const defaultClassSignature: ClassSignatureDto = {
name: DEFAULT_ARK_CLASS_NAME,
declaringFile: this.fileSignature,
Expand Down
152 changes: 151 additions & 1 deletion jacodb-ets/ts-frontend/src/types/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
*/

import * as ts from "typescript";
import { PATTERN_PARAMETER_PREFIX } from "../dto/constants";
import { ClassCategory, PATTERN_PARAMETER_PREFIX } from "../dto/constants";
import { ClassDto, FieldDto } from "../dto/model";
import {
ClassSignatureDto,
FileSignatureDto,
Expand All @@ -54,11 +55,19 @@ import {
UnclearReferenceTypeDto,
VOID_TYPE,
} from "../dto/types";
import { decoratorsOf, memberName, modifiersOf } from "../lowering/astUtils";

/** Guard against deeply nested / self-referential types. */
const MAX_DEPTH = 8;

export class TypeConverter {
readonly structuralClasses: ClassDto[] = [];
private readonly structuralClassByNode = new Map<ts.TypeNode, ClassDto>();
private readonly structuralTypeParametersByNode = new Map<
ts.TypeNode,
readonly ts.TypeParameterDeclaration[]
>();

constructor(
private readonly checker: ts.TypeChecker,
private readonly fileSignatureFor: (sf: ts.SourceFile) => FileSignatureDto,
Expand Down Expand Up @@ -141,6 +150,8 @@ export class TypeConverter {
return NUMBER_TYPE;
case ts.SyntaxKind.StringKeyword:
return STRING_TYPE;
case ts.SyntaxKind.ObjectKeyword:
return this.materializeStructuralClass(node, [], depth, substitutions);
case ts.SyntaxKind.VoidKeyword:
return VOID_TYPE;
case ts.SyntaxKind.NeverKeyword:
Expand Down Expand Up @@ -181,6 +192,9 @@ export class TypeConverter {
signature: this.functionSignatureFromTypeNode(node, depth, substitutions),
};
}
if (ts.isTypeLiteralNode(node)) {
return this.convertTypeLiteralNode(node, depth, substitutions);
}
if (ts.isTypeReferenceNode(node)) {
return this.convertTypeReference(node, depth, substitutions);
}
Expand All @@ -191,6 +205,131 @@ export class TypeConverter {
return UNKNOWN_TYPE;
}

private convertTypeLiteralNode(
node: ts.TypeLiteralNode,
depth: number,
substitutions?: ReadonlyMap<ts.TypeParameterDeclaration, TypeDto>,
): TypeDto {
const members: ts.PropertySignature[] = [];
for (const member of node.members) {
if (
!ts.isPropertySignature(member) ||
member.name === undefined ||
ts.isComputedPropertyName(member.name)
) {
return UNKNOWN_TYPE;
}
members.push(member);
}

return this.materializeStructuralClass(node, members, depth, substitutions);
}

/** Materialize structural nodes in an alias declaration before any use-site specialization. */
materializeStructuralAlias(decl: ts.TypeAliasDeclaration): void {
const visit = (node: ts.Node): void => {
if (ts.isTypeLiteralNode(node) || node.kind === ts.SyntaxKind.ObjectKeyword) {
this.convertTypeNode(node as ts.TypeNode);
return;
}
ts.forEachChild(node, visit);
};
visit(decl.type);
}

private materializeStructuralClass(
node: ts.TypeNode,
members: readonly ts.PropertySignature[],
depth: number,
substitutions?: ReadonlyMap<ts.TypeParameterDeclaration, TypeDto>,
): ClassTypeDto {
const existing = this.structuralClassByNode.get(node);
if (existing !== undefined) {
return this.structuralClassType(node, existing, substitutions);
}

const typeParameters = this.structuralTypeParameters(node);
const signature: ClassSignatureDto = {
name: `%ST${node.getStart(node.getSourceFile())}`,
declaringFile: this.fileSignatureFor(node.getSourceFile()),
};
const structuralClass: ClassDto = {
signature,
modifiers: 0,
decorators: [],
category: ClassCategory.TYPE_LITERAL,
superClassName: "",
implementedInterfaceNames: [],
fields: [],
methods: [],
};
const convertedTypeParameters = this.convertTypeParameters(typeParameters);
if (convertedTypeParameters !== undefined) {
structuralClass.typeParameters = convertedTypeParameters;
}

// Register the shell before converting fields so recursive aliases such as
// `type Node = { next?: Node }` resolve back to the same structural class.
this.structuralClassByNode.set(node, structuralClass);
this.structuralClasses.push(structuralClass);

const definitionSubstitutions = new Map(substitutions);
typeParameters.forEach((parameter) => definitionSubstitutions.delete(parameter));
structuralClass.fields = members.map((member): FieldDto => ({
signature: {
declaringClass: signature,
name: memberName(member.name),
type: this.convertTypeNode(member.type, depth + 1, definitionSubstitutions),
},
modifiers: modifiersOf(member),
decorators: decoratorsOf(member),
questionToken: member.questionToken !== undefined,
exclamationToken: false,
}));

return this.structuralClassType(node, structuralClass, substitutions);
}

private structuralClassType(
node: ts.TypeNode,
structuralClass: ClassDto,
substitutions?: ReadonlyMap<ts.TypeParameterDeclaration, TypeDto>,
): ClassTypeDto {
const result: ClassTypeDto = { _: "ClassType", signature: structuralClass.signature };
const typeParameters = this.structuralTypeParameters(node);
if (typeParameters.length > 0) {
result.typeParameters = typeParameters.map((parameter) =>
substitutions?.get(parameter) ?? { _: "GenericType", name: parameter.name.text },
);
}
return result;
}

private structuralTypeParameters(node: ts.TypeNode): readonly ts.TypeParameterDeclaration[] {
const cached = this.structuralTypeParametersByNode.get(node);
if (cached !== undefined) {
return cached;
}

const result: ts.TypeParameterDeclaration[] = [];
const seen = new Set<ts.TypeParameterDeclaration>();
const visit = (candidate: ts.Node): void => {
if (ts.isTypeReferenceNode(candidate)) {
const symbol = this.resolveSymbol(candidate.typeName);
const parameter = symbol?.declarations?.find(ts.isTypeParameterDeclaration);
if (parameter !== undefined && !isWithin(parameter, node) && !seen.has(parameter)) {
seen.add(parameter);
result.push(parameter);
}
}
ts.forEachChild(candidate, visit);
};
visit(node);
result.sort((left, right) => left.pos - right.pos);
this.structuralTypeParametersByNode.set(node, result);
return result;
}

private convertLiteralTypeNode(node: ts.LiteralTypeNode): TypeDto {
const literal = node.literal;
if (literal.kind === ts.SyntaxKind.NullKeyword) {
Expand Down Expand Up @@ -592,6 +731,17 @@ function entityNameToString(name: ts.EntityName): string {
return `${entityNameToString(name.left)}.${name.right.text}`;
}

function isWithin(node: ts.Node, ancestor: ts.Node): boolean {
let current: ts.Node | undefined = node;
while (current !== undefined) {
if (current === ancestor) {
return true;
}
current = current.parent;
}
return false;
}

/** Class-like declaration of a symbol (class / interface / enum). */
function findClassLikeDeclaration(
symbol: ts.Symbol,
Expand Down
64 changes: 64 additions & 0 deletions jacodb-ets/ts-frontend/test/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as os from "os";
import * as path from "path";
import * as ts from "typescript";
import { afterEach, describe, expect, it } from "vitest";
import { ClassCategory } from "../src/dto/constants";
import { main, parseArgs, resolveProjectInputs } from "../src/index";

const tempDirs: string[] = [];
Expand Down Expand Up @@ -126,6 +127,69 @@ describe("project mode", () => {
});
});

it("emits an imported structural type only in its declaring file", () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-structural-owner-"));
tempDirs.push(projectDir);
fs.writeFileSync(
path.join(projectDir, "a.ts"),
"export type Shared = { value: number }; export function fromA(value: Shared): number { return value.value; }",
);
fs.writeFileSync(
path.join(projectDir, "b.ts"),
'import { Shared } from "./a"; export function fromB(value: Shared): number { return value.value; }',
);

const outputDir = path.join(projectDir, "ir");
expect(main(["--project", projectDir, outputDir])).toBe(0);
const declaringFile = JSON.parse(fs.readFileSync(path.join(outputDir, "a.ts.json"), "utf8"));
const importingFile = JSON.parse(fs.readFileSync(path.join(outputDir, "b.ts.json"), "utf8"));
const structuralClasses = [...declaringFile.classes, ...importingFile.classes].filter(
(candidate: { category?: number }) => candidate.category === ClassCategory.TYPE_LITERAL,
);

expect(structuralClasses).toHaveLength(1);
expect(structuralClasses[0].signature.declaringFile.fileName).toBe("a.ts");
const fromB = importingFile.classes[0].methods.find(
(method: { signature: { name: string } }) => method.signature.name === "fromB",
);
expect(fromB.signature.parameters[0].type.signature).toEqual(structuralClasses[0].signature);
});

it("emits an imported generic structural type nested in an alias wrapper", () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-generic-owner-"));
tempDirs.push(projectDir);
fs.writeFileSync(
path.join(projectDir, "a.ts"),
"type Identity<T> = T; export type Box<T> = Identity<{ value: T }>;",
);
fs.writeFileSync(
path.join(projectDir, "b.ts"),
'import { Box } from "./a"; export function read(value: Box<number>): number { return value.value; }',
);

const outputDir = path.join(projectDir, "ir");
expect(main(["--project", projectDir, outputDir])).toBe(0);
const declaringFile = JSON.parse(fs.readFileSync(path.join(outputDir, "a.ts.json"), "utf8"));
const importingFile = JSON.parse(fs.readFileSync(path.join(outputDir, "b.ts.json"), "utf8"));
const structuralClasses = [...declaringFile.classes, ...importingFile.classes].filter(
(candidate: { category?: number }) => candidate.category === ClassCategory.TYPE_LITERAL,
);

expect(structuralClasses).toHaveLength(1);
expect(structuralClasses[0]).toMatchObject({
signature: { declaringFile: { fileName: "a.ts" } },
typeParameters: [{ _: "GenericType", name: "T" }],
fields: [{ signature: { name: "value", type: { _: "GenericType", name: "T" } } }],
});
const read = importingFile.classes[0].methods.find(
(method: { signature: { name: string } }) => method.signature.name === "read",
);
expect(read.signature.parameters[0].type).toMatchObject({
signature: structuralClasses[0].signature,
typeParameters: [{ _: "NumberType" }],
});
});

it("keeps an in-root filename beginning with two dots project-owned", () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-dotdot-file-"));
tempDirs.push(projectDir);
Expand Down
Loading
Loading