mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-29 07:04:09 +08:00
Hold any later value in JavaScript var bindings (#1916)
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
import { isJsSourceFile } from "../program.js";
|
||||
import { PoisonError, dynFallbackType } from "./lowerer.js";
|
||||
import { functionCanReturnUndefined } from "../function-completion.js";
|
||||
import { jsBindingAppendsOnly, jsBindingHasOpenWrites } from "./lower-stmts.js";
|
||||
import type { FnCtx } from "./function-context.js";
|
||||
|
||||
/** Keep the conservative completion rule shared by return construction
|
||||
@@ -124,6 +125,32 @@ export function producesConstructor(lowerer: Lowerer, type: ts.Type): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a return of `decl` itself (not of a nested function) is a bare
|
||||
* read of a JavaScript variable whose writes the checker does not
|
||||
* constrain. A string that is only appended to stays a string. */
|
||||
function returnsOpenJsBinding(lowerer: Lowerer, decl: ts.SignatureDeclaration): boolean {
|
||||
let open = false;
|
||||
const opens = (value: ts.Expression | undefined): boolean => {
|
||||
while (value && ts.isParenthesizedExpression(value)) value = value.expression;
|
||||
if (!value || !ts.isIdentifier(value)) return false;
|
||||
const binding = lowerer.resolveValueSymbol(value)?.valueDeclaration;
|
||||
return (
|
||||
binding !== undefined &&
|
||||
ts.isVariableDeclaration(binding) &&
|
||||
jsBindingHasOpenWrites(lowerer, binding) &&
|
||||
!jsBindingAppendsOnly(lowerer, binding)
|
||||
);
|
||||
};
|
||||
const scan = (node: ts.Node): void => {
|
||||
if (open || (node !== decl && ts.isFunctionLike(node))) return;
|
||||
if (ts.isReturnStatement(node) && opens(node.expression)) open = true;
|
||||
ts.forEachChild(node, scan);
|
||||
};
|
||||
if (ts.isArrowFunction(decl) && decl.body && !ts.isBlock(decl.body)) open = opens(decl.body);
|
||||
else scan(decl);
|
||||
return open;
|
||||
}
|
||||
|
||||
/** Map a declaration's return contract while retaining JavaScript
|
||||
* inference fallbacks. Diagnostics preserve the caller's blame node. */
|
||||
export function declaredReturnType(
|
||||
@@ -212,6 +239,23 @@ export function declaredReturnType(
|
||||
lowerer.unions.get(mappedReturn.unionId)?.arms.some((arm) => arm.kind === "func")
|
||||
)
|
||||
return DYN;
|
||||
// A JS function returning a local that holds whatever its writes store
|
||||
// (jsBindingHasOpenWrites): the inferred result is the initializer's
|
||||
// type, which the returned value need not have.
|
||||
// An async function's promise fulfills with that value.
|
||||
if (
|
||||
isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
|
||||
!decl.type &&
|
||||
!decl.asteriskToken &&
|
||||
!hasExplicitJsDocReturn(decl) &&
|
||||
mappedReturn !== null &&
|
||||
mappedReturn.kind !== "dyn" &&
|
||||
!(mappedReturn.kind === "promise" && mappedReturn.inner.kind === "dyn") &&
|
||||
returnsOpenJsBinding(lowerer, decl)
|
||||
)
|
||||
return decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)
|
||||
? { kind: "promise", inner: DYN }
|
||||
: DYN;
|
||||
// JS inference assigns Number to arithmetic over any, although the
|
||||
// runtime operands can both be BigInts. Preserve that native result.
|
||||
if (
|
||||
|
||||
@@ -1220,6 +1220,43 @@ function lowerJsVarFromExisting(
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a JavaScript declaration's object value changes shape after its
|
||||
* initializer (jsOpenObjectType, jsObjectBindingExpands) or carries
|
||||
* methods, so only the checked-dynamic representation holds it.
|
||||
* `inferred` is the declaration's mapped checker type. */
|
||||
function jsDeclarationExpandsObject(
|
||||
lowerer: Lowerer,
|
||||
decl: ts.VariableDeclaration,
|
||||
inferred: IrType | null,
|
||||
): boolean {
|
||||
const arms =
|
||||
inferred?.kind === "union"
|
||||
? (lowerer.unions.get(inferred.unionId)?.arms ?? [])
|
||||
: inferred
|
||||
? [inferred]
|
||||
: [];
|
||||
return (
|
||||
(!decl.type &&
|
||||
!hasJsTypeAnnotation(decl) &&
|
||||
inferred !== null &&
|
||||
jsOpenObjectType(decl, inferred, lowerer.shapes, lowerer.unions).kind === "dyn") ||
|
||||
arms.some((arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm)) ||
|
||||
(isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
|
||||
!decl.type &&
|
||||
!hasJsTypeAnnotation(decl) &&
|
||||
decl.initializer !== undefined &&
|
||||
ts.isObjectLiteralExpression(decl.initializer) &&
|
||||
decl.initializer.properties.nodes.some(
|
||||
(property) =>
|
||||
ts.isMethodDeclaration(property) ||
|
||||
ts.isAccessor(property) ||
|
||||
(ts.isPropertyAssignment(property) &&
|
||||
(ts.isFunctionExpression(property.initializer) ||
|
||||
ts.isArrowFunction(property.initializer))),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/** The `var` symbol's function-scoped binding TYPE — the declared type at
|
||||
* the binding name, with the JS-source fallbacks every mutable binding
|
||||
* takes (an empty-object-literal type is checked-dynamic because tsc
|
||||
@@ -1254,6 +1291,19 @@ function varBindingType(lowerer: Lowerer, nameNode: ts.Identifier): IrType | nul
|
||||
type = staticImportNamespaceType(lowerer, nameNode.parent.initializer) ?? type;
|
||||
}
|
||||
if (!type || type.kind === "void") return null;
|
||||
// A JavaScript `var` holds whatever its writes store, as a `let` does:
|
||||
// the checker types it by its initializer and checks no later write
|
||||
// (`var fraction = 0;`, later `fraction = match[7].slice(0, 3)`), and an
|
||||
// object it holds may gain members (`mark.snippet = snippet(mark)`).
|
||||
if (
|
||||
type.kind !== "dyn" &&
|
||||
ts.isVariableDeclaration(nameNode.parent) &&
|
||||
nameNode.parent.name === nameNode &&
|
||||
isJsSourceFile(ts.getSourceFileOfNode(nameNode)) &&
|
||||
(jsBindingHasOpenWrites(lowerer, nameNode.parent) ||
|
||||
jsDeclarationExpandsObject(lowerer, nameNode.parent, type))
|
||||
)
|
||||
return DYN;
|
||||
// A binding readable before its first store holds undefined until then.
|
||||
return lowerer.runtimeOptionalBindingType(nameNode, type);
|
||||
}
|
||||
@@ -5597,26 +5647,7 @@ export function lowerVarDecl(
|
||||
: inferredObjectType
|
||||
? [inferredObjectType]
|
||||
: [];
|
||||
const expandsObject =
|
||||
(!decl.type &&
|
||||
!hasJsTypeAnnotation(decl) &&
|
||||
inferredObjectType !== null &&
|
||||
jsOpenObjectType(decl, inferredObjectType, lowerer.shapes, lowerer.unions).kind === "dyn") ||
|
||||
inferredObjectArms.some(
|
||||
(arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm),
|
||||
) ||
|
||||
(isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
|
||||
!decl.type &&
|
||||
!hasJsTypeAnnotation(decl) &&
|
||||
ts.isObjectLiteralExpression(decl.initializer) &&
|
||||
decl.initializer.properties.nodes.some(
|
||||
(property) =>
|
||||
ts.isMethodDeclaration(property) ||
|
||||
ts.isAccessor(property) ||
|
||||
(ts.isPropertyAssignment(property) &&
|
||||
(ts.isFunctionExpression(property.initializer) ||
|
||||
ts.isArrowFunction(property.initializer))),
|
||||
));
|
||||
const expandsObject = jsDeclarationExpandsObject(lowerer, decl, inferredObjectType);
|
||||
let init: IrExpr;
|
||||
try {
|
||||
init =
|
||||
@@ -6327,18 +6358,15 @@ export function hasJsTypeAnnotation(decl: ts.VariableDeclaration): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Inferred JS scalar initializers do not constrain later writes through
|
||||
* an any-valued callback. Keep those bindings open, including captured writes. */
|
||||
export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
|
||||
if (
|
||||
!isJsSourceFile(ts.getSourceFileOfNode(decl)) ||
|
||||
decl.type ||
|
||||
hasJsTypeAnnotation(decl) ||
|
||||
!ts.isIdentifier(decl.name)
|
||||
)
|
||||
return false;
|
||||
const symbol = lowerer.resolveValueSymbol(decl.name);
|
||||
if (!symbol) return false;
|
||||
/** The assignments to a declaration's binding in its function (or file),
|
||||
* `=` and compound alike. Null when the name has no symbol. */
|
||||
function jsBindingWrites(
|
||||
lowerer: Lowerer,
|
||||
decl: ts.VariableDeclaration,
|
||||
name: ts.Identifier,
|
||||
): ts.BinaryExpression[] | null {
|
||||
const symbol = lowerer.resolveValueSymbol(name);
|
||||
if (!symbol) return null;
|
||||
let owner: ts.Node = decl;
|
||||
while (owner.parent && !ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) owner = owner.parent;
|
||||
let writes = lowerer.jsBindingWritesByOwner.get(owner);
|
||||
@@ -6359,9 +6387,36 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
|
||||
writes = indexed;
|
||||
lowerer.jsBindingWritesByOwner.set(owner, writes);
|
||||
}
|
||||
const candidates = (writes.get(decl.name.text) ?? []).filter(
|
||||
return (writes.get(name.text) ?? []).filter(
|
||||
(node) => lowerer.resolveValueSymbol(node.left as ts.Identifier) === symbol,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a JavaScript binding always holds a string: the checker types it
|
||||
* as one, and every write appends to it (`result += part`), which yields a
|
||||
* string whatever the part is. */
|
||||
export function jsBindingAppendsOnly(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
|
||||
if (!ts.isIdentifier(decl.name)) return false;
|
||||
if (lowerer.mapTypeOf(lowerer.typeOf(decl.name))?.kind !== "string") return false;
|
||||
const writes = jsBindingWrites(lowerer, decl, decl.name);
|
||||
return (
|
||||
writes !== null &&
|
||||
writes.every((node) => node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken)
|
||||
);
|
||||
}
|
||||
|
||||
/** Inferred JS scalar initializers do not constrain later writes through
|
||||
* an any-valued callback. Keep those bindings open, including captured writes. */
|
||||
export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
|
||||
if (
|
||||
!isJsSourceFile(ts.getSourceFileOfNode(decl)) ||
|
||||
decl.type ||
|
||||
hasJsTypeAnnotation(decl) ||
|
||||
!ts.isIdentifier(decl.name)
|
||||
)
|
||||
return false;
|
||||
const candidates = jsBindingWrites(lowerer, decl, decl.name);
|
||||
if (candidates === null) return false;
|
||||
if (
|
||||
candidates.some(
|
||||
(node) =>
|
||||
@@ -6369,6 +6424,18 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
|
||||
)
|
||||
)
|
||||
return true;
|
||||
// A `+=` of a string turns a number or boolean binding into a string
|
||||
// (`n += "px"`); the checker keeps the declared type.
|
||||
if (
|
||||
candidates.some(
|
||||
(node) =>
|
||||
node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken &&
|
||||
(lowerer.typeOf(node.right).flags & ts.TypeFlags.StringLike) !== 0,
|
||||
)
|
||||
) {
|
||||
const declared = lowerer.mapTypeOf(lowerer.typeOf(decl.name));
|
||||
if (declared?.kind === "f64" || declared?.kind === "bool") return true;
|
||||
}
|
||||
// A write that can store null or undefined into a binding the checker
|
||||
// types as a primitive (`offset = match[3] || null`): JavaScript files
|
||||
// are checked without strict null checks, so the declared type does not
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/7037-js-var-open-writes/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/7038-js-var-later-writes.mjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// A JavaScript package whose `var` bindings start as one type and later
|
||||
// hold another, as js-yaml's timestamp and error code does: a number that
|
||||
// becomes a string and back (`fraction = match[7].slice(0, 3)`), an object
|
||||
// literal that gains a member (`mark.snippet = snippet(mark)`), and `+=` of
|
||||
// a string onto a number. Node runs every write; each binding holds it.
|
||||
import { errorAt, millis, label, total } from "scalar-marks";
|
||||
|
||||
console.log(millis("2001-12-14t21:59:43.10"), millis("2001-12-14t21:59:43"), millis("1970-01-01t00:00:00.12345"));
|
||||
for (const [input, at] of [["a: [1", 5], ["key: 'open", 10], ["a:\n [1", 7], ["ok", 1]] as const) {
|
||||
try {
|
||||
console.log(errorAt(input, at, "unexpected end"));
|
||||
} catch (e) {
|
||||
const err = e as { name: string; message: string; mark: { line: number; column: number; snippet: string } };
|
||||
console.log(err.name, "|", err.message, "|", err.mark.line, err.mark.column, JSON.stringify(err.mark.snippet));
|
||||
}
|
||||
}
|
||||
console.log(label(3), label(0));
|
||||
console.log(total([1, 2, 3]), total(["a", "b"]), total([1, "b", 2]));
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export declare function millis(data: string): number;
|
||||
export declare function errorAt(input: string, position: number, message: string): string;
|
||||
export declare function label(n: number): string;
|
||||
export declare function total(values: (number | string)[]): number | string;
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
"use strict";
|
||||
var TIMESTAMP = new RegExp(
|
||||
'^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)' +
|
||||
'[Tt]([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?$');
|
||||
|
||||
// The milliseconds of a timestamp: the fraction starts as the number 0,
|
||||
// holds its digits as a string while it is padded, and ends as a number.
|
||||
function millis(data) {
|
||||
var match, fraction = 0, delta = null;
|
||||
match = TIMESTAMP.exec(data);
|
||||
if (match === null) throw new Error('Date resolve error');
|
||||
if (match[7]) {
|
||||
fraction = match[7].slice(0, 3);
|
||||
while (fraction.length < 3) {
|
||||
fraction += '0';
|
||||
}
|
||||
fraction = +fraction;
|
||||
}
|
||||
if (delta) fraction -= delta;
|
||||
return Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6], fraction);
|
||||
}
|
||||
|
||||
function State(input) {
|
||||
this.input = input;
|
||||
this.position = 0;
|
||||
this.line = 0;
|
||||
this.lineStart = 0;
|
||||
}
|
||||
|
||||
function snippet(mark) {
|
||||
var start = mark.position, text = mark.buffer;
|
||||
return text.slice(0, start) + '<' + text.slice(start) + '>';
|
||||
}
|
||||
|
||||
function MarkError(reason, mark) {
|
||||
Error.call(this);
|
||||
this.name = 'MarkError';
|
||||
this.reason = reason;
|
||||
this.mark = mark;
|
||||
this.message = reason + ' (' + (mark.line + 1) + ':' + (mark.column + 1) + ')';
|
||||
}
|
||||
MarkError.prototype = Object.create(Error.prototype);
|
||||
MarkError.prototype.constructor = MarkError;
|
||||
|
||||
// The error's mark is an object literal that gains its snippet afterwards.
|
||||
function generateError(state, message) {
|
||||
var mark = {
|
||||
buffer: state.input,
|
||||
position: state.position,
|
||||
line: state.line,
|
||||
column: state.position - state.lineStart
|
||||
};
|
||||
mark.snippet = snippet(mark);
|
||||
return new MarkError(message, mark);
|
||||
}
|
||||
|
||||
function errorAt(input, position, message) {
|
||||
var state = new State(input);
|
||||
for (var i = 0; i < position && i < input.length; i++) {
|
||||
state.position++;
|
||||
if (input[i] === '\n') { state.line++; state.lineStart = state.position; }
|
||||
}
|
||||
if (state.position >= input.length) throw generateError(state, message);
|
||||
return 'no error at ' + state.position;
|
||||
}
|
||||
|
||||
// A number binding that `+=` turns into a string.
|
||||
function label(n) {
|
||||
var width = 10;
|
||||
width += 'px';
|
||||
return n + ' ' + width;
|
||||
}
|
||||
|
||||
// A sum that becomes a string concatenation once a string arrives.
|
||||
function total(values) {
|
||||
var sum = 0;
|
||||
for (var i = 0; i < values.length; i++) sum += values[i];
|
||||
return sum;
|
||||
}
|
||||
|
||||
module.exports = { millis: millis, errorAt: errorAt, label: label, total: total };
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "scalar-marks", "version": "1.0.0", "main": "./index.js", "types": "./index.d.ts" }
|
||||
@@ -0,0 +1,59 @@
|
||||
// JavaScript `var` and `let` bindings that later hold another type than
|
||||
// their initializer's, which nothing checks in a JavaScript file: Node
|
||||
// runs each write, and a function returning the binding returns what it
|
||||
// holds.
|
||||
function retyped(input) {
|
||||
var value = 0;
|
||||
value = input.slice(0, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
function padded(digits) {
|
||||
var fraction = 0;
|
||||
if (digits) {
|
||||
fraction = digits.slice(0, 3);
|
||||
while (fraction.length < 3) fraction += "0";
|
||||
fraction = +fraction;
|
||||
}
|
||||
return fraction + 1;
|
||||
}
|
||||
|
||||
function suffixed(n) {
|
||||
var width = n * 2;
|
||||
width += "px";
|
||||
return width;
|
||||
}
|
||||
|
||||
function letRetyped(record) {
|
||||
let current = 1;
|
||||
current = record.name;
|
||||
return current;
|
||||
}
|
||||
|
||||
function grown(line) {
|
||||
var mark = { line: line, column: line - 1 };
|
||||
mark.text = "at " + (mark.line + 1);
|
||||
return mark;
|
||||
}
|
||||
|
||||
async function awaitedTotal(values) {
|
||||
var total = 0;
|
||||
for (var i = 0; i < values.length; i++) total += await values[i];
|
||||
console.log("awaited", total, typeof total);
|
||||
return total;
|
||||
}
|
||||
|
||||
function counted(values) {
|
||||
var count = 0;
|
||||
for (var i = 0; i < values.length; i++) count += values[i];
|
||||
return count;
|
||||
}
|
||||
|
||||
console.log(retyped("abc"), typeof retyped("abc"));
|
||||
console.log(padded("5"), padded("12345"), padded(""));
|
||||
console.log(suffixed(4), typeof suffixed(4));
|
||||
console.log(letRetyped({ name: "x" }), letRetyped({ name: 2 }));
|
||||
console.log(JSON.stringify(grown(3)), Object.keys(grown(0)).join(","));
|
||||
console.log(counted([1, 2]), counted([1, "a", 2]));
|
||||
await awaitedTotal([1, Promise.resolve(2)]);
|
||||
await awaitedTotal([1, Promise.resolve("b")]);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1970, "san": 2150 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1570, "san": 2390 }
|
||||
Reference in New Issue
Block a user