mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Type untyped JavaScript Maps, Sets and arrays from their uses
In a JavaScript file, `const m = new Map()`, `new Set()` and `[]` type as Map<any, any>, Set<any> and any[], so every use lowered to the checked-dynamic collection operations: boxed keys and values, generic method dispatch, and dynamic arithmetic on what they returned. js-collection-types.ts proves types for such bindings from their uses. A `const` qualifies when every reference is a direct use of the collection: - for a Map: get/has/delete/clear, size, and set as a statement; - for a Set: has/delete/clear, size, and add as a statement; - for an array: push/pop/shift/at/includes/indexOf/join and length. The value then never leaves the binding. Its key, value or element type is the one primitive (string, number or boolean) of every expression the program writes. That is found by a fixed point that types `m.get(k) ?? d`, arithmetic, comparisons and constants defined from them, and it requires lookups of the same type. Any other reference (passing, returning, storing, exporting, iterating, inspecting, an optional call), a direct `eval` in the file, or any doubt keeps the binding untyped. A proven binding answers the lowerer's typeOf with a carrier type declared in scriptc-overrides.d.ts, which only the lowering program includes. `new Map()` and `new Set()` adopt it, so the binding lowers as the typed Map<K, V>, Set<K> or T[], as a JSDoc @type would make it. 7985 covers the typed cases and every refusal.
This commit is contained in:
@@ -70,3 +70,24 @@ interface PromiseConstructor {
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/* Carriers of the types the lowerer gives untyped JavaScript collections
|
||||
* whose uses prove them (js-collection-types.ts). Nothing reads them at run
|
||||
* time. */
|
||||
declare namespace __scriptcInferredCollections {
|
||||
const map_string_string: Map<string, string>;
|
||||
const map_string_number: Map<string, number>;
|
||||
const map_string_boolean: Map<string, boolean>;
|
||||
const map_number_string: Map<number, string>;
|
||||
const map_number_number: Map<number, number>;
|
||||
const map_number_boolean: Map<number, boolean>;
|
||||
const map_boolean_string: Map<boolean, string>;
|
||||
const map_boolean_number: Map<boolean, number>;
|
||||
const map_boolean_boolean: Map<boolean, boolean>;
|
||||
const set_string: Set<string>;
|
||||
const set_number: Set<number>;
|
||||
const set_boolean: Set<boolean>;
|
||||
const array_string: string[];
|
||||
const array_number: number[];
|
||||
const array_boolean: boolean[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/* Types of untyped JavaScript collections that their uses prove.
|
||||
*
|
||||
* In a JavaScript file, `const m = new Map()`, `new Set()` and `[]` type as
|
||||
* Map<any, any>, Set<any> and any[] to the checker, so every use lowers to
|
||||
* the checked-dynamic collection operations. When every reference of such a
|
||||
* `const` is a direct use of the collection, the value never leaves the
|
||||
* binding. If its keys, values or elements are then each one primitive type
|
||||
* wherever the program writes and looks them up, the binding lowers as the
|
||||
* typed Map<K, V>, Set<K> or T[], as a JSDoc @type would make it:
|
||||
*
|
||||
* - Map: `m.get(k)`, `m.has(k)`, `m.delete(k)`, `m.clear()`, `m.size`, and
|
||||
* `m.set(k, v)` as a statement (its result is the map).
|
||||
* - Set: `s.add(k)` as a statement, `s.has(k)`, `s.delete(k)`, `s.clear()`,
|
||||
* `s.size`.
|
||||
* - Array: `a.push(...)`, `a.pop()`, `a.shift()`, `a.at(i)`, `a.includes(v)`,
|
||||
* `a.indexOf(v)`, `a.join(separator)`, `a.length`.
|
||||
*
|
||||
* Any other reference (passing, returning, storing, exporting, iterating,
|
||||
* spreading, inspecting the collection, an optional call), a direct `eval`
|
||||
* in the file, or any doubt about a type keeps the binding untyped.
|
||||
*
|
||||
* The types come from carrier declarations in scriptc-overrides.d.ts, which
|
||||
* only the lowering program includes. */
|
||||
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import { isJsSourceFile } from "../program.js";
|
||||
import { overridesDtsPath } from "../dts-paths.js";
|
||||
import type { Lowerer } from "./lowerer.js";
|
||||
|
||||
type Primitive = "string" | "number" | "boolean";
|
||||
const PRIMITIVES: readonly Primitive[] = ["string", "number", "boolean"];
|
||||
|
||||
type Collection = "map" | "set" | "array";
|
||||
|
||||
interface Candidate {
|
||||
readonly kind: Collection;
|
||||
readonly declaration: ts.VariableDeclaration;
|
||||
/** Every identifier that names the binding, its declaration's included. */
|
||||
readonly names: ts.Identifier[];
|
||||
/** Map keys or Set members written by set/add. */
|
||||
readonly keyWrites: ts.Expression[];
|
||||
/** Map values written by set. Array elements written by push. */
|
||||
readonly valueWrites: ts.Expression[];
|
||||
/** Arguments compared with keys or elements: get/has/delete, includes/indexOf. */
|
||||
readonly lookups: ts.Expression[];
|
||||
/** Array indexes passed to at(). */
|
||||
readonly indexes: ts.Expression[];
|
||||
/** Separators passed to join(). */
|
||||
readonly separators: ts.Expression[];
|
||||
key: Primitive | undefined;
|
||||
value: Primitive | undefined;
|
||||
}
|
||||
|
||||
const MAP_LOOKUPS = new Set(["get", "has", "delete"]);
|
||||
const SET_LOOKUPS = new Set(["has", "delete"]);
|
||||
const ARRAY_READS = new Set(["pop", "shift"]);
|
||||
const ARRAY_SEARCHES = new Set(["includes", "indexOf"]);
|
||||
|
||||
/** The inferred type of the binding a `new Map()` or `new Set()` initializes,
|
||||
* which the construction adopts. */
|
||||
export function initializedCollectionType(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.NewExpression,
|
||||
): ts.Type | null {
|
||||
const declaration = expr.parent;
|
||||
return declaration !== undefined &&
|
||||
ts.isVariableDeclaration(declaration) &&
|
||||
declaration.initializer === expr &&
|
||||
ts.isIdentifier(declaration.name)
|
||||
? inferredCollectionType(lowerer, declaration.name)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The inferred collection type of an identifier that names a proven
|
||||
* binding, or null. Computed for every JavaScript file of the program on
|
||||
* first use. */
|
||||
export function inferredCollectionType(lowerer: Lowerer, node: ts.Identifier): ts.Type | null {
|
||||
const types = (lowerer.inferredCollectionTypes ??= inferProgram(lowerer));
|
||||
return types.size === 0 ? null : (types.get(node) ?? null);
|
||||
}
|
||||
|
||||
function inferProgram(lowerer: Lowerer): Map<ts.Node, ts.Type> {
|
||||
const out = new Map<ts.Node, ts.Type>();
|
||||
const carriers = carrierTypes(lowerer);
|
||||
if (carriers === null) return out;
|
||||
for (const file of lowerer.program.getSourceFiles()) {
|
||||
if (file.isDeclarationFile || !isJsSourceFile(file)) continue;
|
||||
for (const candidate of inferFile(lowerer, file)) {
|
||||
const name =
|
||||
candidate.kind === "map"
|
||||
? `map_${candidate.key}_${candidate.value}`
|
||||
: candidate.kind === "set"
|
||||
? `set_${candidate.key}`
|
||||
: `array_${candidate.value}`;
|
||||
const type = carriers.get(name);
|
||||
if (type === undefined) continue;
|
||||
for (const id of candidate.names) out.set(id, type);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The carrier declarations' types, by name (`map_string_number`,
|
||||
* `set_number`, `array_string`, ...), or null when the lowering program does
|
||||
* not include them. */
|
||||
function carrierTypes(lowerer: Lowerer): Map<string, ts.Type> | null {
|
||||
const file = lowerer.program.getSourceFile(overridesDtsPath());
|
||||
if (file === undefined) return null;
|
||||
const out = new Map<string, ts.Type>();
|
||||
for (const statement of file.statements.nodes) {
|
||||
if (
|
||||
!ts.isModuleDeclaration(statement) ||
|
||||
!ts.isIdentifier(statement.name) ||
|
||||
statement.name.text !== "__scriptcInferredCollections" ||
|
||||
statement.body === undefined ||
|
||||
!ts.isModuleBlock(statement.body)
|
||||
)
|
||||
continue;
|
||||
for (const member of statement.body.statements.nodes) {
|
||||
if (!ts.isVariableStatement(member)) continue;
|
||||
for (const declaration of member.declarationList.declarations.nodes)
|
||||
if (ts.isIdentifier(declaration.name))
|
||||
out.set(declaration.name.text, lowerer.checker.getTypeAtLocation(declaration.name));
|
||||
}
|
||||
}
|
||||
return out.size > 0 ? out : null;
|
||||
}
|
||||
|
||||
function inferFile(lowerer: Lowerer, file: ts.SourceFile): Candidate[] {
|
||||
const candidates = new Map<ts.Symbol, Candidate>();
|
||||
let directEval = false;
|
||||
const declarations = (node: ts.Node): void => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === "eval"
|
||||
)
|
||||
directEval = true;
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
const kind = candidateKind(lowerer, node);
|
||||
const symbol = kind !== null ? lowerer.checker.getSymbolAtLocation(node.name) : undefined;
|
||||
if (kind !== null && symbol !== undefined)
|
||||
candidates.set(symbol, {
|
||||
kind,
|
||||
declaration: node,
|
||||
names: [node.name as ts.Identifier],
|
||||
keyWrites: [],
|
||||
valueWrites: [],
|
||||
lookups: [],
|
||||
indexes: [],
|
||||
separators: [],
|
||||
key: undefined,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, declarations);
|
||||
};
|
||||
declarations(file);
|
||||
if (directEval || candidates.size === 0) return [];
|
||||
|
||||
const names = new Set(
|
||||
[...candidates.values()].map((c) => (c.declaration.name as ts.Identifier).text),
|
||||
);
|
||||
const refused = new Set<ts.Symbol>();
|
||||
const references = (node: ts.Node): void => {
|
||||
if (ts.isIdentifier(node) && names.has(node.text)) {
|
||||
const symbol = referencedSymbol(lowerer, node);
|
||||
const candidate = symbol !== undefined ? candidates.get(symbol) : undefined;
|
||||
if (symbol !== undefined && candidate !== undefined && node !== candidate.declaration.name) {
|
||||
if (recordUse(candidate, node)) candidate.names.push(node);
|
||||
else refused.add(symbol);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, references);
|
||||
};
|
||||
references(file);
|
||||
for (const symbol of refused) candidates.delete(symbol);
|
||||
const proven = [...candidates.values()].filter(
|
||||
(c) => c.valueWrites.length > 0 || c.keyWrites.length > 0,
|
||||
);
|
||||
solve(lowerer, proven);
|
||||
return proven.filter((c) =>
|
||||
c.kind === "map" ? c.key && c.value : c.kind === "set" ? c.key : c.value,
|
||||
);
|
||||
}
|
||||
|
||||
/** The collection a `const` declaration starts empty, when the checker
|
||||
* types it as an untyped Map, Set or array. */
|
||||
function candidateKind(lowerer: Lowerer, declaration: ts.VariableDeclaration): Collection | null {
|
||||
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) return null;
|
||||
const list = declaration.parent;
|
||||
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null;
|
||||
const statement = list.parent;
|
||||
if (
|
||||
ts.isVariableStatement(statement) &&
|
||||
ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
||||
)
|
||||
return null;
|
||||
const init = declaration.initializer;
|
||||
let kind: Collection | null = null;
|
||||
if (ts.isArrayLiteralExpression(init) && init.elements.nodes.length === 0) kind = "array";
|
||||
else if (
|
||||
ts.isNewExpression(init) &&
|
||||
init.typeArguments === undefined &&
|
||||
(init.arguments?.nodes.length ?? 0) === 0
|
||||
) {
|
||||
if (lowerer.isStdlibGlobal(init.expression, "Map")) kind = "map";
|
||||
else if (lowerer.isStdlibGlobal(init.expression, "Set")) kind = "set";
|
||||
}
|
||||
if (kind === null) return null;
|
||||
// A JSDoc type, or anything else the checker already knows, stands.
|
||||
const type = lowerer.checker.getTypeAtLocation(declaration.name);
|
||||
const args =
|
||||
kind === "array"
|
||||
? lowerer.checker.isArrayType(type)
|
||||
? lowerer.checker.getTypeArguments(type as ts.TypeReference)
|
||||
: []
|
||||
: lowerer.checker.getTypeArguments(type as ts.TypeReference);
|
||||
const arity = kind === "map" ? 2 : 1;
|
||||
if (args.length !== arity || !args.every((a) => (a.flags & ts.TypeFlags.Any) !== 0)) return null;
|
||||
return kind;
|
||||
}
|
||||
|
||||
function referencedSymbol(lowerer: Lowerer, id: ts.Identifier): ts.Symbol | undefined {
|
||||
const parent = id.parent;
|
||||
if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === id)
|
||||
return lowerer.checker.getShorthandAssignmentValueSymbol(parent);
|
||||
return lowerer.checker.getSymbolAtLocation(id);
|
||||
}
|
||||
|
||||
/** Records a reference's use, or answers false when the reference is not
|
||||
* one of the collection uses this inference proves. */
|
||||
function recordUse(candidate: Candidate, id: ts.Identifier): boolean {
|
||||
const access = id.parent;
|
||||
if (
|
||||
access === undefined ||
|
||||
!ts.isPropertyAccessExpression(access) ||
|
||||
access.expression !== id ||
|
||||
access.questionDotToken !== undefined
|
||||
)
|
||||
return false;
|
||||
const name = access.name.text;
|
||||
const call = access.parent;
|
||||
if (call !== undefined && ts.isCallExpression(call) && call.expression === access) {
|
||||
if (call.questionDotToken !== undefined) return false;
|
||||
const args = call.arguments.nodes;
|
||||
if (args.some(ts.isSpreadElement)) return false;
|
||||
switch (candidate.kind) {
|
||||
case "map":
|
||||
if (MAP_LOOKUPS.has(name) && args.length === 1)
|
||||
return (candidate.lookups.push(args[0]!), true);
|
||||
if (name === "clear") return args.length === 0;
|
||||
if (name === "set" && args.length === 2 && isStatement(call)) {
|
||||
candidate.keyWrites.push(args[0]!);
|
||||
candidate.valueWrites.push(args[1]!);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case "set":
|
||||
if (SET_LOOKUPS.has(name) && args.length === 1)
|
||||
return (candidate.lookups.push(args[0]!), true);
|
||||
if (name === "clear") return args.length === 0;
|
||||
if (name === "add" && args.length === 1 && isStatement(call))
|
||||
return (candidate.keyWrites.push(args[0]!), true);
|
||||
return false;
|
||||
case "array":
|
||||
if (name === "push") return (candidate.valueWrites.push(...args), true);
|
||||
if (ARRAY_READS.has(name)) return args.length === 0;
|
||||
if (name === "at" && args.length === 1) return (candidate.indexes.push(args[0]!), true);
|
||||
if (ARRAY_SEARCHES.has(name) && args.length === 1)
|
||||
return (candidate.lookups.push(args[0]!), true);
|
||||
if (name === "join" && args.length <= 1) return (candidate.separators.push(...args), true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (candidate.kind === "array" ? name === "length" : name === "size") && isPlainRead(access);
|
||||
}
|
||||
|
||||
function isStatement(call: ts.CallExpression): boolean {
|
||||
let node: ts.Node = call;
|
||||
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
|
||||
return node.parent !== undefined && ts.isExpressionStatement(node.parent);
|
||||
}
|
||||
|
||||
/** A read that neither writes nor deletes the property. */
|
||||
function isPlainRead(access: ts.PropertyAccessExpression): boolean {
|
||||
let node: ts.Node = access;
|
||||
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
|
||||
const parent = node.parent;
|
||||
if (parent === undefined) return false;
|
||||
if (
|
||||
ts.isBinaryExpression(parent) &&
|
||||
parent.left === node &&
|
||||
isAssignment(parent.operatorToken.kind)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
(ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
|
||||
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
|
||||
parent.operator === ts.SyntaxKind.MinusMinusToken)
|
||||
)
|
||||
return false;
|
||||
return !ts.isDeleteExpression(parent);
|
||||
}
|
||||
|
||||
function isAssignment(kind: ts.SyntaxKind): boolean {
|
||||
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
|
||||
}
|
||||
|
||||
/** Decides each candidate's key and value types: a fixed point in which a
|
||||
* decided candidate's reads type the expressions built from them. */
|
||||
function solve(lowerer: Lowerer, candidates: Candidate[]): void {
|
||||
const bySymbol = new Map<ts.Symbol, Candidate>();
|
||||
for (const c of candidates) {
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(c.declaration.name);
|
||||
if (symbol) bySymbol.set(symbol, c);
|
||||
}
|
||||
for (let changed = true; changed;) {
|
||||
changed = false;
|
||||
for (const c of candidates) {
|
||||
if (c.kind !== "array" && c.key === undefined) {
|
||||
const key = uniform(lowerer, bySymbol, c.keyWrites);
|
||||
if (key !== null) {
|
||||
c.key = key;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (c.kind !== "set" && c.value === undefined) {
|
||||
// Each candidate type is assumed for this collection's own reads.
|
||||
const accepted = PRIMITIVES.filter((assumed) => {
|
||||
c.value = assumed;
|
||||
const found = uniform(lowerer, bySymbol, c.valueWrites);
|
||||
c.value = undefined;
|
||||
return found === assumed;
|
||||
});
|
||||
if (accepted.length === 1) {
|
||||
c.value = accepted[0];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of candidates) {
|
||||
const element = c.kind === "array" ? c.value : c.key;
|
||||
// Lookups compare with the stored type; an index is a number.
|
||||
const lookupsAgree =
|
||||
element !== undefined &&
|
||||
c.lookups.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === element) &&
|
||||
c.indexes.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "number") &&
|
||||
c.separators.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "string");
|
||||
if (!lookupsAgree) {
|
||||
c.key = undefined;
|
||||
c.value = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The one primitive type of every expression, or null. */
|
||||
function uniform(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
exprs: readonly ts.Expression[],
|
||||
): Primitive | null {
|
||||
let found: Primitive | null = null;
|
||||
for (const e of exprs) {
|
||||
const p = primitiveOf(lowerer, bySymbol, e, new Set());
|
||||
if (p === null || (found !== null && p !== found)) return null;
|
||||
found = p;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function checkerPrimitive(type: ts.Type): Primitive | null {
|
||||
const flags = type.flags;
|
||||
if (flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) return "string";
|
||||
if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) return "number";
|
||||
if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return "boolean";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The primitive type `e` always evaluates to, or null. The checker answers
|
||||
* where it knows; where it types `e` as `any`, the value's structure does:
|
||||
* a read of a decided candidate, arithmetic and comparisons over such
|
||||
* values, and constants defined from them. */
|
||||
function primitiveOf(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
visiting: Set<ts.Node>,
|
||||
): Primitive | null {
|
||||
const type = lowerer.checker.getTypeAtLocation(e);
|
||||
if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) return checkerPrimitive(type);
|
||||
const of = (x: ts.Expression): Primitive | null => primitiveOf(lowerer, bySymbol, x, visiting);
|
||||
if (ts.isParenthesizedExpression(e)) return of(e.expression);
|
||||
if (ts.isPrefixUnaryExpression(e)) {
|
||||
if (e.operator === ts.SyntaxKind.ExclamationToken) return "boolean";
|
||||
return e.operator !== ts.SyntaxKind.PlusPlusToken &&
|
||||
e.operator !== ts.SyntaxKind.MinusMinusToken &&
|
||||
of(e.operand) === "number"
|
||||
? "number"
|
||||
: null;
|
||||
}
|
||||
if (ts.isConditionalExpression(e)) {
|
||||
const yes = of(e.whenTrue);
|
||||
return yes !== null && yes === of(e.whenFalse) ? yes : null;
|
||||
}
|
||||
if (ts.isBinaryExpression(e)) {
|
||||
const op = e.operatorToken.kind;
|
||||
switch (op) {
|
||||
case ts.SyntaxKind.LessThanToken:
|
||||
case ts.SyntaxKind.GreaterThanToken:
|
||||
case ts.SyntaxKind.LessThanEqualsToken:
|
||||
case ts.SyntaxKind.GreaterThanEqualsToken:
|
||||
case ts.SyntaxKind.EqualsEqualsEqualsToken:
|
||||
case ts.SyntaxKind.ExclamationEqualsEqualsToken:
|
||||
case ts.SyntaxKind.EqualsEqualsToken:
|
||||
case ts.SyntaxKind.ExclamationEqualsToken:
|
||||
return "boolean";
|
||||
case ts.SyntaxKind.QuestionQuestionToken:
|
||||
case ts.SyntaxKind.BarBarToken: {
|
||||
// `m.get(k) ?? d` and `a.pop() || d`: the stored type or a default of it.
|
||||
const stored = storedRead(lowerer, bySymbol, e.left);
|
||||
const fallback = of(e.right);
|
||||
return stored !== null && stored === fallback ? stored : null;
|
||||
}
|
||||
case ts.SyntaxKind.PlusToken: {
|
||||
const left = of(e.left);
|
||||
const right = of(e.right);
|
||||
if (left === "number" && right === "number") return "number";
|
||||
return left !== null && right !== null && (left === "string" || right === "string")
|
||||
? "string"
|
||||
: null;
|
||||
}
|
||||
case ts.SyntaxKind.MinusToken:
|
||||
case ts.SyntaxKind.AsteriskToken:
|
||||
case ts.SyntaxKind.SlashToken:
|
||||
case ts.SyntaxKind.PercentToken:
|
||||
case ts.SyntaxKind.AsteriskAsteriskToken:
|
||||
case ts.SyntaxKind.BarToken:
|
||||
case ts.SyntaxKind.AmpersandToken:
|
||||
case ts.SyntaxKind.CaretToken:
|
||||
case ts.SyntaxKind.LessThanLessThanToken:
|
||||
case ts.SyntaxKind.GreaterThanGreaterThanToken:
|
||||
case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken:
|
||||
return of(e.left) === "number" && of(e.right) === "number" ? "number" : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (ts.isCallExpression(e) || ts.isPropertyAccessExpression(e))
|
||||
return candidateRead(lowerer, bySymbol, e);
|
||||
// A constant defined from such an expression.
|
||||
if (ts.isIdentifier(e) && !visiting.has(e)) {
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(e);
|
||||
const declaration = symbol?.valueDeclaration;
|
||||
if (
|
||||
declaration !== undefined &&
|
||||
ts.isVariableDeclaration(declaration) &&
|
||||
declaration.initializer !== undefined &&
|
||||
ts.isVariableDeclarationList(declaration.parent) &&
|
||||
(declaration.parent.flags & ts.NodeFlags.Const) !== 0
|
||||
) {
|
||||
visiting.add(e);
|
||||
const p = primitiveOf(lowerer, bySymbol, declaration.initializer, visiting);
|
||||
visiting.delete(e);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The candidate a use reads, when `e` is `c.member` or `c.member(...)` of a
|
||||
* candidate `c`. */
|
||||
function candidateOf(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
): { candidate: Candidate; member: string } | null {
|
||||
const access = ts.isCallExpression(e) ? e.expression : e;
|
||||
if (!ts.isPropertyAccessExpression(access) || !ts.isIdentifier(access.expression)) return null;
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(access.expression);
|
||||
const candidate = symbol !== undefined ? bySymbol.get(symbol) : undefined;
|
||||
return candidate !== undefined ? { candidate, member: access.name.text } : null;
|
||||
}
|
||||
|
||||
/** The type of a candidate's read that may answer undefined: `m.get(k)`,
|
||||
* `a.pop()`, `a.shift()`, `a.at(i)`. */
|
||||
function storedRead(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
): Primitive | null {
|
||||
while (ts.isParenthesizedExpression(e)) e = e.expression;
|
||||
if (!ts.isCallExpression(e)) return null;
|
||||
const read = candidateOf(lowerer, bySymbol, e);
|
||||
if (read === null) return null;
|
||||
const { candidate, member } = read;
|
||||
if (candidate.kind === "map" && member === "get") return candidate.value ?? null;
|
||||
if (candidate.kind === "array" && (ARRAY_READS.has(member) || member === "at"))
|
||||
return candidate.value ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The type of a candidate's read that always answers a value: size,
|
||||
* length, has, includes, indexOf, push. */
|
||||
function candidateRead(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
): Primitive | null {
|
||||
const read = candidateOf(lowerer, bySymbol, e);
|
||||
if (read === null) return null;
|
||||
const { candidate, member } = read;
|
||||
if (!ts.isCallExpression(e)) return member === "size" || member === "length" ? "number" : null;
|
||||
if (member === "has" || member === "includes" || member === "delete") return "boolean";
|
||||
if (member === "indexOf" || (candidate.kind === "array" && member === "push")) return "number";
|
||||
if (candidate.kind === "array" && member === "join") return "string";
|
||||
return null;
|
||||
}
|
||||
@@ -113,6 +113,7 @@ import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./low
|
||||
import { isAssertClassNew } from "./assert-instances.js";
|
||||
import { isCjsRequireGlobal } from "./lower-node-module.js";
|
||||
import { lowerArrayConstructor } from "./containers/array-construction.js";
|
||||
import { initializedCollectionType } from "./js-collection-types.js";
|
||||
import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js";
|
||||
import { lowerObjectConstructor } from "./containers/indexed-objects.js";
|
||||
import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js";
|
||||
@@ -10568,7 +10569,11 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
// expression). The CONTEXTUAL type carries the annotation — adopt
|
||||
// it when it is a supported map. TS type arguments keep winning:
|
||||
// their expression type already maps.
|
||||
if (mapped?.kind !== "map" || contextualSeed) {
|
||||
const inferred = initializedCollectionType(lowerer, expr);
|
||||
if (inferred) {
|
||||
tsType = inferred;
|
||||
mapped = lowerer.mapTypeOf(inferred);
|
||||
} else if (mapped?.kind !== "map" || contextualSeed) {
|
||||
const ctx = lowerer.checker.getContextualType(expr);
|
||||
const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null;
|
||||
if (ctx && ctxMapped?.kind === "map") {
|
||||
@@ -10708,7 +10713,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
// `new Set<T>()`: construct + bulk add from supported values,
|
||||
// including live collection cursors. Duplicates collapse in insertion order.
|
||||
if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Set")) {
|
||||
const tsType = lowerer.typeOf(expr);
|
||||
const inferred = initializedCollectionType(lowerer, expr);
|
||||
const tsType = inferred ?? lowerer.typeOf(expr);
|
||||
let mapped = lowerer.mapTypeOf(tsType);
|
||||
const fieldType = assignedThisFieldType(lowerer, expr);
|
||||
if (mapped?.kind !== "set" && fieldType?.kind === "set") mapped = fieldType;
|
||||
|
||||
@@ -156,6 +156,7 @@ import {
|
||||
trackClassFieldCreation,
|
||||
} from "./class-dynamic-dispatch.js";
|
||||
import { guardClassAttributeWrites } from "./class-attributes.js";
|
||||
import { inferredCollectionType } from "./js-collection-types.js";
|
||||
import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js";
|
||||
import { applyHandleProperties } from "./handle-properties.js";
|
||||
import { installBufferValue } from "./containers/bytes.js";
|
||||
@@ -2846,6 +2847,9 @@ export class Lowerer {
|
||||
/** Parameters read as `any` in the error-path copy of an error-first
|
||||
* callback. */
|
||||
readonly anySymbols = new Set<ts.Symbol>();
|
||||
/** The collection types that uses prove for untyped JavaScript bindings,
|
||||
* by every identifier naming one (js-collection-types.ts). */
|
||||
inferredCollectionTypes: Map<ts.Node, ts.Type> | undefined = undefined;
|
||||
/** overload-choice's answers per call, binding and function. */
|
||||
readonly dynamicOverloadAnswers = new Map<ts.Node, boolean>();
|
||||
/** Error parameters of an error-first callback with a known value in
|
||||
@@ -9055,6 +9059,10 @@ export class Lowerer {
|
||||
}
|
||||
|
||||
typeOf(node: ts.Node): ts.Type {
|
||||
if (ts.isIdentifier(node)) {
|
||||
const inferred = inferredCollectionType(this, node);
|
||||
if (inferred) return inferred;
|
||||
}
|
||||
if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType();
|
||||
// A checked-dynamic argument that another overload could take at run
|
||||
// time leaves the resolved overload's return a guess, and JS bindings
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile, deserializeModule, validateModule } from "../src/index.js";
|
||||
|
||||
// Corpus 7985 exercises every refusal: only the collections whose uses
|
||||
// prove their types lower as typed collections; the rest stay untyped.
|
||||
test("untyped JavaScript collections take the types their uses prove, and only those", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "scriptc-js-collection-types-"));
|
||||
try {
|
||||
const entry = join(
|
||||
import.meta.dirname,
|
||||
"../../../tests/corpus/7985-untyped-collection-types.mjs",
|
||||
);
|
||||
const output = join(dir, "main.ir.json");
|
||||
const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output });
|
||||
if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics));
|
||||
const module = deserializeModule(readFileSync(output, "utf8"));
|
||||
expect(validateModule(module)).toEqual([]);
|
||||
const types = new Map(module.globals.map((g) => [g.name, JSON.stringify(g.type)]));
|
||||
const map = (key: string, value: string) =>
|
||||
JSON.stringify({ kind: "map", key: { kind: key }, value: { kind: value } });
|
||||
const set = (elem: string) => JSON.stringify({ kind: "set", elem: { kind: elem } });
|
||||
const array = (elem: string) => JSON.stringify({ kind: "array", elem: { kind: elem } });
|
||||
// Untyped collections hold checked-dynamic keys, values and elements.
|
||||
const typed = (t: string) => !t.includes('"dyn"');
|
||||
expect(Object.fromEntries([...types].filter(([, t]) => typed(t)))).toEqual({
|
||||
counts: map("string", "f64"),
|
||||
seen: set("f64"),
|
||||
stack: array("f64"),
|
||||
words: array("string"),
|
||||
byNumber: map("f64", "string"),
|
||||
nums: array("f64"),
|
||||
flags: set("bool"),
|
||||
cache: map("string", "f64"),
|
||||
"%loaded": '{"kind":"bool"}',
|
||||
});
|
||||
for (const untyped of [
|
||||
"strings",
|
||||
"numbers",
|
||||
"mixedKeys",
|
||||
"mixedValues",
|
||||
"mixedElements",
|
||||
"passed",
|
||||
"stored",
|
||||
"iterated",
|
||||
"eachSet",
|
||||
"shown",
|
||||
"chained",
|
||||
"optional",
|
||||
"indexed",
|
||||
"truncated",
|
||||
"spread",
|
||||
"unread",
|
||||
"exported",
|
||||
])
|
||||
expect(typed(types.get(untyped)!), untyped).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/7985-untyped-collection-types.mjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Untyped Maps, Sets and arrays whose uses prove their key, value and
|
||||
// element types compile as typed collections; any other use keeps them
|
||||
// untyped. Both must answer as Node does, including SameValueZero keys,
|
||||
// lookups of another type, and every use that refuses the typing.
|
||||
|
||||
// Typed: counters, membership sets and stacks used only through their
|
||||
// methods.
|
||||
const counts = new Map();
|
||||
const seen = new Set();
|
||||
const stack = [];
|
||||
const words = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const key = "k" + (i % 7);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
seen.add(i % 5);
|
||||
stack.push(i * 2);
|
||||
if (stack.length > 6) stack.shift();
|
||||
words.push(key);
|
||||
}
|
||||
console.log(counts.size, counts.get("k3"), counts.get("missing"), counts.has("k6"), counts.delete("k0"), counts.size);
|
||||
console.log(seen.size, seen.has(4), seen.has(5), seen.delete(4), seen.has(4));
|
||||
console.log(stack.length, stack.at(-1), stack.at(0), stack.pop(), stack.includes(70), stack.indexOf(72), words.join("|").length);
|
||||
|
||||
// SameValueZero keys and NaN elements on typed collections.
|
||||
const byNumber = new Map();
|
||||
byNumber.set(NaN, "nan");
|
||||
byNumber.set(-0, "zero");
|
||||
byNumber.set(1.5, "x");
|
||||
console.log(byNumber.get(NaN), byNumber.get(0), byNumber.get(-0), byNumber.size, byNumber.has(1.5));
|
||||
const nums = [];
|
||||
nums.push(NaN);
|
||||
nums.push(-0);
|
||||
console.log(nums.includes(NaN), nums.indexOf(NaN), nums.includes(0), nums.indexOf(0));
|
||||
const flags = new Set();
|
||||
flags.add(true);
|
||||
console.log(flags.has(true), flags.has(false), flags.size);
|
||||
|
||||
// Captured by a closure that only uses it.
|
||||
const cache = new Map();
|
||||
const latest = () => cache.get("c") ?? -1;
|
||||
console.log(latest());
|
||||
for (let i = 0; i < 3; i++) cache.set("c", i * 10);
|
||||
console.log(latest(), cache.size);
|
||||
|
||||
// Lookups of another type: the typing refuses, and the answers stay Node's.
|
||||
const strings = new Map();
|
||||
strings.set("1", "one");
|
||||
console.log(strings.get(1), strings.get("1"), strings.has(1));
|
||||
const numbers = [];
|
||||
numbers.push(1);
|
||||
console.log(numbers.includes("1"), numbers.indexOf("1"), numbers.at("0"));
|
||||
|
||||
// Mixed key, value and element types.
|
||||
const mixedKeys = new Map();
|
||||
mixedKeys.set(1, "number");
|
||||
mixedKeys.set("1", "string");
|
||||
console.log(mixedKeys.size, mixedKeys.get(1), mixedKeys.get("1"));
|
||||
const mixedValues = new Map();
|
||||
mixedValues.set("a", 1);
|
||||
mixedValues.set("b", "two");
|
||||
console.log(mixedValues.get("a"), mixedValues.get("b"));
|
||||
const mixedElements = [];
|
||||
mixedElements.push(1);
|
||||
mixedElements.push("two");
|
||||
console.log(mixedElements.join(","), mixedElements.length);
|
||||
|
||||
// Escapes and other uses that keep the collection untyped.
|
||||
const passed = new Map();
|
||||
passed.set("x", 1);
|
||||
function sizeOf(m) {
|
||||
return m.size;
|
||||
}
|
||||
console.log(sizeOf(passed));
|
||||
function make() {
|
||||
const made = new Set();
|
||||
made.add("y");
|
||||
return made;
|
||||
}
|
||||
console.log(make().has("y"));
|
||||
const stored = [];
|
||||
stored.push(3);
|
||||
const holder = { list: stored };
|
||||
holder.list.push(4);
|
||||
console.log(stored.length, holder.list.join("+"));
|
||||
const iterated = new Map();
|
||||
iterated.set("a", 1);
|
||||
iterated.set("b", 2);
|
||||
for (const [k, v] of iterated) console.log("entry", k, v);
|
||||
const eachSet = new Set();
|
||||
eachSet.add(3);
|
||||
eachSet.forEach((v) => console.log("member", v));
|
||||
const shown = new Map();
|
||||
shown.set("s", 1);
|
||||
console.log(shown);
|
||||
const chained = new Map();
|
||||
chained.set("a", 1).set("b", 2);
|
||||
console.log(chained.size);
|
||||
const optional = new Map();
|
||||
optional.set("o", 1);
|
||||
console.log(optional?.get("o"), optional.get?.("o"));
|
||||
const indexed = [];
|
||||
indexed.push(1);
|
||||
indexed[3] = 2;
|
||||
console.log(indexed.length, indexed.join(","));
|
||||
const truncated = [];
|
||||
truncated.push(1, 2, 3);
|
||||
truncated.length = 1;
|
||||
console.log(truncated.length, truncated.at(0));
|
||||
const spread = [];
|
||||
spread.push(5);
|
||||
console.log(Math.max(...spread));
|
||||
const unread = new Map();
|
||||
console.log(unread.size, unread.get("none"));
|
||||
export const exported = new Map();
|
||||
exported.set("e", 1);
|
||||
console.log(exported.get("e"));
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2400, "san": 2600 }
|
||||
Reference in New Issue
Block a user