ecmarkup
Version:
Custom element definitions and core utilities for markup that specifies ECMAScript and related technologies.
817 lines (816 loc) • 31.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.typeFromExpr = exports.meet = exports.join = exports.typecheck = void 0;
const expr_parser_1 = require("./expr-parser");
const utils_1 = require("./utils");
function typecheck(spec) {
const isUnused = (t) => {
var _a;
return t.kind === 'unused' ||
(t.kind === 'completion' &&
(t.completionType === 'abrupt' || ((_a = t.typeOfValueIfNormal) === null || _a === void 0 ? void 0 : _a.kind) === 'unused'));
};
const AOs = spec.biblio
.localEntries()
.filter(e => { var _a; return e.type === 'op' && ((_a = e.signature) === null || _a === void 0 ? void 0 : _a.return) != null; });
const onlyPerformed = new Map(AOs.filter(e => !isUnused(e.signature.return)).map(a => [a.aoid, null]));
const alwaysAssertedToBeNormal = new Map(
// prettier-ignore
AOs
.filter(e => e.signature.return.kind === 'completion' && !e.skipGlobalChecks)
.map(a => [a.aoid, null]));
// TODO strictly speaking this needs to be done in the namespace of the current algorithm
const opNames = spec.biblio.getOpNames(spec.namespace);
// TODO move declarations out of loop
for (const node of spec.doc.querySelectorAll('emu-alg')) {
if (node.hasAttribute('example') || !('ecmarkdownTree' in node)) {
continue;
}
const tree = node.ecmarkdownTree;
if (tree == null) {
continue;
}
const originalHtml = node.originalHtml;
const expressionVisitor = (expr, path) => {
if (expr.name !== 'call' && expr.name !== 'sdo-call') {
return;
}
const { callee, arguments: args } = expr;
if (!(callee.length === 1 && callee[0].name === 'text')) {
return;
}
const calleeName = callee[0].contents;
const warn = (message) => {
const { line, column } = (0, utils_1.offsetToLineAndColumn)(originalHtml, callee[0].location.start.offset);
spec.warn({
type: 'contents',
ruleId: 'typecheck',
message,
node,
nodeRelativeLine: line,
nodeRelativeColumn: column,
});
};
const biblioEntry = spec.biblio.byAoid(calleeName);
if (biblioEntry == null) {
if (!['toUppercase', 'toLowercase'].includes(calleeName)) {
// TODO make the spec not do this
warn(`could not find definition for ${calleeName}`);
}
return;
}
if (biblioEntry.kind === 'syntax-directed operation' && expr.name === 'call') {
warn(`${calleeName} is a syntax-directed operation and should not be invoked like a regular call`);
}
else if (biblioEntry.kind != null &&
biblioEntry.kind !== 'syntax-directed operation' &&
expr.name === 'sdo-call') {
warn(`${calleeName} is not a syntax-directed operation but here is being invoked as one`);
}
if (biblioEntry.signature == null) {
return;
}
const { signature } = biblioEntry;
const min = signature.parameters.length;
const max = min + signature.optionalParameters.length;
if (args.length < min || args.length > max) {
const count = `${min}${min === max ? '' : `-${max}`}`;
const message = `${calleeName} takes ${count} argument${count === '1' ? '' : 's'}, but this invocation passes ${args.length}`;
warn(message);
}
else {
const params = signature.parameters.concat(signature.optionalParameters);
for (const [arg, param] of (0, utils_1.zip)(args, params, true)) {
if (param.type == null)
continue;
const argType = typeFromExpr(arg, spec.biblio);
const paramType = typeFromExprType(param.type);
// often we can't infer the argument precisely, so we check only that the intersection is nonempty rather than that the argument type is a subtype of the parameter type
const intersection = meet(argType, paramType);
if (intersection.kind === 'never' ||
(intersection.kind === 'list' &&
intersection.of.kind === 'never' &&
// if the meet is list<never>, and we're passing a concrete list, it had better be empty
argType.kind === 'list' &&
argType.of.kind !== 'never')) {
const items = stripWhitespace(arg.items);
const { line, column } = (0, utils_1.offsetToLineAndColumn)(originalHtml, items[0].location.start.offset);
const argDescriptor = argType.kind.startsWith('concrete') ||
argType.kind === 'enum value' ||
argType.kind === 'null' ||
argType.kind === 'undefined'
? `(${serialize(argType)})`
: `type (${serialize(argType)})`;
let hint = '';
if (argType.kind === 'concrete number' && dominates({ kind: 'real' }, paramType)) {
hint =
'\nhint: you passed an ES language Number, but this position takes a mathematical value';
}
else if (argType.kind === 'concrete real' &&
dominates({ kind: 'number' }, paramType)) {
hint =
'\nhint: you passed a mathematical value, but this position takes an ES language Number';
}
else if (argType.kind === 'concrete real' &&
dominates({ kind: 'bigint' }, paramType)) {
hint =
'\nhint: you passed a mathematical value, but this position takes an ES language BigInt';
}
spec.warn({
type: 'contents',
ruleId: 'typecheck',
message: `argument ${argDescriptor} does not look plausibly assignable to parameter type (${serialize(paramType)})${hint}`,
node,
nodeRelativeLine: line,
nodeRelativeColumn: column,
});
}
}
}
const { return: returnType } = signature;
if (returnType == null) {
return;
}
const consumedAsCompletion = isConsumedAsCompletion(expr, path);
// checks elsewhere ensure that well-formed documents never have a union of completion and non-completion, so checking the first child suffices
// TODO: this is for 'a break completion or a throw completion', which is kind of a silly union; maybe address that in some other way?
const isCompletion = returnType.kind === 'completion' ||
(returnType.kind === 'union' && returnType.types[0].kind === 'completion');
if (['Completion', 'ThrowCompletion', 'NormalCompletion'].includes(calleeName)) {
if (consumedAsCompletion) {
warn(`${calleeName} clearly creates a Completion Record; it does not need to be marked as such, and it would not be useful to immediately unwrap its result`);
}
}
else if (isCompletion && !consumedAsCompletion) {
warn(`${calleeName} returns a Completion Record, but is not consumed as if it does`);
}
else if (!isCompletion && consumedAsCompletion) {
warn(`${calleeName} does not return a Completion Record, but is consumed as if it does`);
}
if (returnType.kind === 'unused' && !isCalledAsPerform(expr, path, false)) {
warn(`${calleeName} does not return a meaningful value and should only be invoked as \`Perform ${calleeName}(...).\``);
}
if (onlyPerformed.has(calleeName) && onlyPerformed.get(calleeName) !== 'top') {
const old = onlyPerformed.get(calleeName);
const performed = isCalledAsPerform(expr, path, true);
if (!performed) {
onlyPerformed.set(calleeName, 'top');
}
else if (old === null) {
onlyPerformed.set(calleeName, 'only performed');
}
}
if (alwaysAssertedToBeNormal.has(calleeName) &&
alwaysAssertedToBeNormal.get(calleeName) !== 'top') {
const old = alwaysAssertedToBeNormal.get(calleeName);
const asserted = isAssertedToBeNormal(expr, path);
if (!asserted) {
alwaysAssertedToBeNormal.set(calleeName, 'top');
}
else if (old === null) {
alwaysAssertedToBeNormal.set(calleeName, 'always asserted normal');
}
}
};
const walkLines = (list) => {
var _a;
for (const line of list.contents) {
const item = (0, expr_parser_1.parse)(line.contents, opNames);
if (item.name === 'failure') {
const { line, column } = (0, utils_1.offsetToLineAndColumn)(originalHtml, item.offset);
spec.warn({
type: 'contents',
ruleId: 'expression-parsing',
message: item.message,
node,
nodeRelativeLine: line,
nodeRelativeColumn: column,
});
}
else {
(0, expr_parser_1.walk)(expressionVisitor, item);
}
if (((_a = line.sublist) === null || _a === void 0 ? void 0 : _a.name) === 'ol') {
walkLines(line.sublist);
}
}
};
walkLines(tree.contents);
}
for (const [aoid, state] of onlyPerformed) {
if (state !== 'only performed') {
continue;
}
const message = `${aoid} is only ever invoked with Perform, so it should return ~unused~ or a Completion Record which, if normal, contains ~unused~`;
const ruleId = 'perform-not-unused';
const biblioEntry = spec.biblio.byAoid(aoid);
if (biblioEntry._node) {
spec.spec.warn({
type: 'node',
ruleId,
message,
node: biblioEntry._node,
});
}
else {
spec.spec.warn({
type: 'global',
ruleId,
message,
});
}
}
for (const [aoid, state] of alwaysAssertedToBeNormal) {
if (state !== 'always asserted normal') {
continue;
}
if (aoid === 'AsyncGeneratorAwaitReturn') {
// TODO remove this when https://github.com/tc39/ecma262/issues/2412 is fixed
continue;
}
const message = `every call site of ${aoid} asserts the return value is a normal completion; it should be refactored to not return a completion record at all. if this AO is called in ways ecmarkup cannot analyze, add the "skip global checks" attribute to the header.`;
const ruleId = 'always-asserted-normal';
const biblioEntry = spec.biblio.byAoid(aoid);
if (biblioEntry._node) {
spec.spec.warn({
type: 'node',
ruleId,
message,
node: biblioEntry._node,
});
}
else {
spec.spec.warn({
type: 'global',
ruleId,
message,
});
}
}
}
exports.typecheck = typecheck;
function isCalledAsPerform(expr, path, allowQuestion) {
const prev = previousText(expr, path);
return prev != null && (allowQuestion ? /\bperform ([?!]\s)?$/i : /\bperform $/i).test(prev);
}
function isAssertedToBeNormal(expr, path) {
const prev = previousText(expr, path);
return prev != null && /\s!\s$/.test(prev);
}
function isConsumedAsCompletion(expr, path) {
const part = parentSkippingBlankSpace(expr, path);
if (part == null) {
return false;
}
const { parent, index } = part;
if (parent.name === 'seq') {
// if the previous text ends in `! ` or `? `, this is a completion
const text = textFromPreviousPart(parent, index);
if (text != null && /[!?]\s$/.test(text)) {
return true;
}
}
else if (parent.name === 'call' && index === 0 && parent.arguments.length === 1) {
// if this is `Completion(Expr())`, this is a completion
const parts = parent.callee;
if (parts.length === 1 && parts[0].name === 'text' && parts[0].contents === 'Completion') {
return true;
}
}
return false;
}
function parentSkippingBlankSpace(expr, path) {
for (let pointer = expr, i = path.length - 1; i >= 0; pointer = path[i].parent, --i) {
const { parent } = path[i];
if (parent.name === 'seq' &&
parent.items.every(i => i === pointer || i.name === 'tag' || (i.name === 'text' && /^\s*$/.test(i.contents)))) {
// if parent is just whitespace/tags around the call, walk up the tree further
continue;
}
return path[i];
}
return null;
}
function previousText(expr, path) {
const part = parentSkippingBlankSpace(expr, path);
if (part == null) {
return null;
}
const { parent, index } = part;
if (parent.name === 'seq') {
return textFromPreviousPart(parent, index);
}
return null;
}
function textFromPreviousPart(seq, index) {
let prevIndex = index - 1;
let prev;
while ((0, expr_parser_1.isProsePart)((prev = seq.items[prevIndex]))) {
if (prev.name === 'text') {
return prev.contents;
}
else if (prev.name === 'tag') {
--prevIndex;
}
else {
break;
}
}
return null;
}
function stripWhitespace(items) {
var _a, _b;
items = [...items];
while (((_a = items[0]) === null || _a === void 0 ? void 0 : _a.name) === 'text' && /^\s+$/.test(items[0].contents)) {
items.shift();
}
while (((_b = items[items.length - 1]) === null || _b === void 0 ? void 0 : _b.name) === 'text' &&
// @ts-expect-error
/^\s+$/.test(items[items.length - 1].contents)) {
items.pop();
}
return items;
}
const simpleKinds = new Set([
'unknown',
'never',
'record',
'real',
'integer',
'non-negative integer',
'negative integer',
'positive integer',
'ES value',
'string',
'number',
'integral number',
'bigint',
'boolean',
'null',
'undefined',
]);
const dominateGraph = {
// @ts-expect-error TS does not know about __proto__
__proto__: null,
record: ['completion'],
real: [
'integer',
'non-negative integer',
'negative integer',
'positive integer',
'concrete real',
],
integer: ['non-negative integer', 'negative integer', 'positive integer'],
'non-negative integer': ['positive integer'],
'ES value': [
'string',
'number',
'integral number',
'bigint',
'boolean',
'null',
'undefined',
'concrete string',
'concrete number',
'concrete bigint',
'concrete boolean',
],
string: ['concrete string'],
number: ['integral number', 'concrete number'],
bigint: ['concrete bigint'],
boolean: ['concrete boolean'],
};
/*
The type lattice used here is very simple (aside from explicit unions).
As such we mostly only need to define the `dominates` relationship and apply trivial rules:
if `x` dominates `y`, then `join(x,y) = x` and `meet(x,y) = y`; if neither dominates the other than the join is top and the meet is bottom.
Unions/lists/completions take a little more work.
*/
function dominates(a, b) {
var _a, _b;
if (a.kind === 'unknown' || b.kind === 'never') {
return true;
}
if (b.kind === 'union') {
return b.of.every(t => dominates(a, t));
}
if (a.kind === 'union') {
// not necessarily true for arbitrary lattices, but true for ours
return a.of.some(t => dominates(t, b));
}
if ((a.kind === 'list' && b.kind === 'list') ||
(a.kind === 'completion' && b.kind === 'completion')) {
return dominates(a.of, b.of);
}
if (simpleKinds.has(a.kind) && simpleKinds.has(b.kind) && a.kind === b.kind) {
return true;
}
if ((_b = (_a = dominateGraph[a.kind]) === null || _a === void 0 ? void 0 : _a.includes(b.kind)) !== null && _b !== void 0 ? _b : false) {
return true;
}
if (a.kind === 'integer' && b.kind === 'concrete real') {
return !b.value.includes('.');
}
if (a.kind === 'integral number' && b.kind === 'concrete number') {
return Number.isFinite(b.value) && b.value === Math.round(b.value);
}
if (a.kind === 'non-negative integer' && b.kind === 'concrete real') {
return !b.value.includes('.') && b.value[0] !== '-';
}
if (a.kind === 'negative integer' && b.kind === 'concrete real') {
return !b.value.includes('.') && b.value[0] === '-';
}
if (a.kind === 'positive integer' && b.kind === 'concrete real') {
return !b.value.includes('.') && b.value[0] !== '-' && b.value !== '0';
}
if (a.kind === b.kind &&
[
'concrete string',
'concrete number',
'concrete real',
'concrete bigint',
'concrete boolean',
'enum value',
].includes(a.kind)) {
// @ts-expect-error TS is not quite smart enough for this
return Object.is(a.value, b.value);
}
return false;
}
function addToUnion(types, type) {
if (types.some(t => dominates(t, type))) {
return { kind: 'union', of: types };
}
const live = types.filter(t => !dominates(type, t));
if (live.length === 0) {
return type;
}
return { kind: 'union', of: [...live, type] };
}
function join(a, b) {
if (dominates(a, b)) {
return a;
}
if (dominates(b, a)) {
return b;
}
if (b.kind === 'union') {
[a, b] = [b, a];
}
if (a.kind === 'union') {
if (b.kind === 'union') {
return b.of.reduce((acc, t) => (acc.kind === 'union' ? addToUnion(acc.of, t) : join(acc, t)), a);
}
return addToUnion(a.of, b);
}
if ((a.kind === 'list' && b.kind === 'list') ||
(a.kind === 'completion' && b.kind === 'completion')) {
return { kind: a.kind, of: join(a.of, b.of) };
}
return { kind: 'union', of: [a, b] };
}
exports.join = join;
function meet(a, b) {
if (dominates(a, b)) {
return b;
}
if (dominates(b, a)) {
return a;
}
if (a.kind !== 'union' && b.kind === 'union') {
[a, b] = [b, a];
}
if (a.kind === 'union') {
// union is join. meet distributes over join.
return a.of.map(t => meet(t, b)).reduce(join);
}
if ((a.kind === 'list' && b.kind === 'list') ||
(a.kind === 'completion' && b.kind === 'completion')) {
return { kind: a.kind, of: meet(a.of, b.of) };
}
return { kind: 'never' };
}
exports.meet = meet;
function serialize(type) {
switch (type.kind) {
case 'unknown': {
return 'unknown';
}
case 'never': {
return 'never';
}
case 'union': {
const parts = type.of.map(serialize);
if (parts.length > 2) {
return parts.slice(0, -1).join(', ') + ', or ' + parts[parts.length - 1];
}
return parts[0] + ' or ' + parts[1];
}
case 'list': {
if (type.of.kind === 'never') {
return 'empty List';
}
return 'List of ' + serialize(type.of);
}
case 'record': {
return 'Record';
}
case 'completion': {
if (type.of.kind === 'never') {
return 'an abrupt Completion Record';
}
else if (type.of.kind === 'unknown') {
return 'a Completion Record';
}
return 'a Completion Record normally holding ' + serialize(type.of);
}
case 'real': {
return 'mathematical value';
}
case 'integer':
case 'non-negative integer':
case 'negative integer':
case 'positive integer':
case 'null':
case 'undefined': {
return type.kind;
}
case 'concrete string': {
return `"${type.value}"`;
}
case 'concrete real': {
return type.value;
}
case 'concrete boolean': {
return `${type.value}`;
}
case 'enum value': {
return `~${type.value}~`;
}
case 'concrete number': {
if (Object.is(type.value, 0 / 0)) {
return '*NaN*';
}
let repr;
if (Object.is(type.value, -0)) {
repr = '-0';
}
else if (type.value === 0) {
repr = '+0';
}
else if (type.value === 2e308) {
repr = '+∞';
}
else if (type.value === -2e308) {
repr = '-∞';
}
else if (type.value > 4503599627370495.5) {
repr = String(BigInt(type.value));
}
else {
repr = String(type.value);
}
return `*${repr}*<sub>𝔽</sub>`;
}
case 'concrete bigint': {
return `*${type.value}*<sub>ℤ</sub>`;
}
case 'ES value': {
return 'ECMAScript language value';
}
case 'boolean': {
return 'Boolean';
}
case 'string': {
return 'String';
}
case 'number': {
return 'Number';
}
case 'integral number': {
return 'integral Number';
}
case 'bigint': {
return 'BigInt';
}
}
}
function typeFromExpr(expr, biblio) {
var _a, _b;
seq: if (expr.name === 'seq') {
const items = stripWhitespace(expr.items);
if (items.length === 1) {
expr = items[0];
break seq;
}
if (items.length === 2 &&
items[0].name === 'star' &&
items[0].contents[0].name === 'text' &&
items[1].name === 'text') {
switch (items[1].contents) {
case '𝔽': {
const text = items[0].contents[0].contents;
let value;
if (text === '-0') {
value = -0;
}
else if (text === '+∞') {
value = 2e308;
}
else if (text === '-∞') {
value = -2e308;
}
else {
value = parseFloat(text);
}
return { kind: 'concrete number', value };
}
case 'ℤ': {
return { kind: 'concrete bigint', value: BigInt(items[0].contents[0].contents) };
}
}
}
if (((_a = items[0]) === null || _a === void 0 ? void 0 : _a.name) === 'text' && ['!', '?'].includes(items[0].contents.trim())) {
const remaining = stripWhitespace(items.slice(1));
if (remaining.length === 1 && ['call', 'sdo-call'].includes(remaining[0].name)) {
const callType = typeFromExpr(remaining[0], biblio);
if (callType.kind === 'completion') {
return callType.of;
}
}
}
}
switch (expr.name) {
case 'text': {
const text = expr.contents.trim();
if (/^-?[0-9]+(\.[0-9]+)?$/.test(text)) {
return { kind: 'concrete real', value: text };
}
break;
}
case 'list': {
return {
kind: 'list',
of: expr.elements.map(t => typeFromExpr(t, biblio)).reduce(join, { kind: 'never' }),
};
}
case 'record': {
return { kind: 'record' };
}
case 'call':
case 'sdo-call': {
const { callee } = expr;
if (!(callee.length === 1 && callee[0].name === 'text')) {
break;
}
const calleeName = callee[0].contents;
const biblioEntry = biblio.byAoid(calleeName);
if (((_b = biblioEntry === null || biblioEntry === void 0 ? void 0 : biblioEntry.signature) === null || _b === void 0 ? void 0 : _b.return) == null) {
break;
}
return typeFromExprType(biblioEntry.signature.return);
}
case 'tilde': {
if (expr.contents.length === 1 && expr.contents[0].name === 'text') {
return { kind: 'enum value', value: expr.contents[0].contents };
}
break;
}
case 'star': {
if (expr.contents.length === 1 && expr.contents[0].name === 'text') {
const text = expr.contents[0].contents;
if (text === 'null') {
return { kind: 'null' };
}
else if (text === 'undefined') {
return { kind: 'undefined' };
}
else if (text === 'NaN') {
return { kind: 'concrete number', value: 0 / 0 };
}
else if (text === 'true') {
return { kind: 'concrete boolean', value: true };
}
else if (text === 'false') {
return { kind: 'concrete boolean', value: false };
}
else if (text.startsWith('"') && text.endsWith('"')) {
return { kind: 'concrete string', value: text.slice(1, -1) };
}
}
break;
}
}
return { kind: 'unknown' };
}
exports.typeFromExpr = typeFromExpr;
function typeFromExprType(type) {
switch (type.kind) {
case 'union': {
return type.types.map(typeFromExprType).reduce(join);
}
case 'list': {
return {
kind: 'list',
of: type.elements == null ? { kind: 'unknown' } : typeFromExprType(type.elements),
};
}
case 'completion': {
if (type.completionType === 'abrupt') {
return { kind: 'completion', of: { kind: 'never' } };
}
return {
kind: 'completion',
of: type.typeOfValueIfNormal == null
? { kind: 'unknown' }
: typeFromExprType(type.typeOfValueIfNormal),
};
}
case 'opaque': {
const text = type.type;
if (text.startsWith('"') && text.endsWith('"')) {
return { kind: 'concrete string', value: text.slice(1, -1) };
}
if (text.startsWith('~') && text.endsWith('~')) {
return { kind: 'enum value', value: text.slice(1, -1) };
}
if (/^-?[0-9]+(\.[0-9]+)?$/.test(text)) {
return { kind: 'concrete real', value: text };
}
if (text.startsWith('*') && text.endsWith('*<sub>𝔽</sub>')) {
const innerText = text.slice(1, -14);
let value;
if (innerText === '-0') {
value = -0;
}
else if (innerText === '+∞') {
value = 2e308;
}
else if (innerText === '-∞') {
value = -2e308;
}
else {
value = parseFloat(innerText);
}
return { kind: 'concrete number', value };
}
if (text === '*NaN*') {
return { kind: 'concrete number', value: 0 / 0 };
}
if (text.startsWith('*') && text.endsWith('*<sub>ℤ</sub>')) {
return { kind: 'concrete bigint', value: BigInt(text.slice(1, -14)) };
}
if (text === 'an ECMAScript language value' || text === 'ECMAScript language values') {
return { kind: 'ES value' };
}
if (text === 'a String' || text === 'Strings') {
return { kind: 'string' };
}
if (text === 'a Number' || text === 'Numbers') {
return { kind: 'number' };
}
if (text === 'a Boolean' || text === 'Booleans') {
return { kind: 'boolean' };
}
if (text === 'a BigInt' || text === 'BigInts') {
return { kind: 'bigint' };
}
if (text === 'an integral Number' || text === 'integral Numbers') {
return { kind: 'integral number' };
}
if (text === 'a mathematical value' || text === 'mathematical values') {
return { kind: 'real' };
}
if (text === 'an integer' || text === 'integers') {
return { kind: 'integer' };
}
if (text === 'a non-negative integer' || text === 'non-negative integers') {
return { kind: 'non-negative integer' };
}
if (text === 'a negative integer' || text === 'negative integers') {
return { kind: 'negative integer' };
}
if (text === 'a positive integer' || text === 'positive integers') {
return { kind: 'positive integer' };
}
if (text === 'a time value' || text === 'time values') {
return {
kind: 'union',
of: [{ kind: 'integral number' }, { kind: 'concrete number', value: 0 / 0 }],
};
}
if (text === '*null*') {
return { kind: 'null' };
}
if (text === '*undefined*') {
return { kind: 'undefined' };
}
break;
}
case 'unused': {
// this is really only a return type, but might as well handle it
return { kind: 'enum value', value: '~unused~' };
}
}
return { kind: 'unknown' };
}