UNPKG

ecmarkup

Version:

Custom element definitions and core utilities for markup that specifies ECMAScript and related technologies.

549 lines (548 loc) 25.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.typecheck = typecheck; const expr_parser_1 = require("./expr-parser"); const utils_1 = require("./utils"); const type_logic_1 = require("./type-logic"); const getExpressionVisitor = (spec, warn, onlyPerformed, alwaysAssertedToBeNormal) => (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 biblioEntry = spec.biblio.byAoid(calleeName); if (biblioEntry == null) { if (!['toUppercase', 'toLowercase'].includes(calleeName)) { // TODO make the spec not do this warn(callee[0].location.start.offset, `could not find definition for ${calleeName}`); } return; } if (biblioEntry.kind === 'syntax-directed operation' && expr.name === 'call') { warn(callee[0].location.start.offset, `${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(callee[0].location.start.offset, `${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(callee[0].location.start.offset, 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 = (0, type_logic_1.typeFromExpr)(arg, spec.biblio, warn); const paramType = (0, type_logic_1.typeFromExprType)(param.type); const error = getErrorForUsingTypeXAsTypeY(argType, paramType); if (error != null) { let hint; switch (error) { case 'number-to-real': { hint = '\nhint: you passed an ES language Number, but this position takes a mathematical value'; break; } case 'bigint-to-real': { hint = '\nhint: you passed an ES language BigInt, but this position takes a mathematical value'; break; } case 'real-to-number': { hint = '\nhint: you passed a mathematical value, but this position takes an ES language Number'; break; } case 'real-to-bigint': { hint = '\nhint: you passed a mathematical value, but this position takes an ES language BigInt'; break; } case 'other': { hint = ''; break; } } const argDescriptor = argType.kind.startsWith('concrete') || argType.kind === 'enum value' || argType.kind === 'null' || argType.kind === 'undefined' ? `(${(0, type_logic_1.serialize)(argType)})` : `type (${(0, type_logic_1.serialize)(argType)})`; const items = (0, type_logic_1.stripWhitespace)(arg.items); warn(items[0].location.start.offset, `argument ${argDescriptor} does not look plausibly assignable to parameter type (${(0, type_logic_1.serialize)(paramType)})${hint}`); } } } 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', 'ReturnCompletion'].includes(calleeName)) { if (consumedAsCompletion) { warn(callee[0].location.start.offset, `${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(callee[0].location.start.offset, `${calleeName} returns a Completion Record, but is not consumed as if it does`); } else if (!isCompletion && consumedAsCompletion) { warn(callee[0].location.start.offset, `${calleeName} does not return a Completion Record, but is consumed as if it does`); } if (returnType.kind === 'unused' && !isCalledAsPerform(expr, path, false)) { warn(callee[0].location.start.offset, `${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'); } } }; function getErrorForUsingTypeXAsTypeY(argType, paramType) { // 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 = (0, type_logic_1.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')) { if (argType.kind === 'concrete number' && (0, type_logic_1.dominates)({ kind: 'real' }, paramType)) { return 'number-to-real'; } else if (argType.kind === 'concrete bigint' && (0, type_logic_1.dominates)({ kind: 'real' }, paramType)) { return 'bigint-to-real'; } else if (argType.kind === 'concrete real' && (0, type_logic_1.dominates)({ kind: 'number' }, paramType)) { return 'real-to-number'; } else if (argType.kind === 'concrete real' && (0, type_logic_1.dominates)({ kind: 'bigint' }, paramType)) { return 'real-to-bigint'; } return 'other'; } return null; } 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 warn = (offset, message) => { const { line, column } = (0, utils_1.offsetToLineAndColumn)(originalHtml, offset); spec.warn({ type: 'contents', ruleId: 'typecheck', message, node, nodeRelativeLine: line, nodeRelativeColumn: column, }); }; let containingClause = node; while ((containingClause === null || containingClause === void 0 ? void 0 : containingClause.nodeName) != null && containingClause.nodeName !== 'EMU-CLAUSE') { containingClause = containingClause.parentElement; } const aoid = containingClause === null || containingClause === void 0 ? void 0 : containingClause.getAttribute('aoid'); const biblioEntry = aoid == null ? null : spec.biblio.byAoid(aoid); const signature = biblioEntry === null || biblioEntry === void 0 ? void 0 : biblioEntry.signature; // hasPossibleCompletionReturn is a three-state: null to indicate we're not looking, or a boolean let hasPossibleCompletionReturn = (signature === null || signature === void 0 ? void 0 : signature.return) == null || ['sdo', 'internal method', 'concrete method'].includes(containingClause.getAttribute('type')) ? null : false; const returnType = (signature === null || signature === void 0 ? void 0 : signature.return) == null ? null : (0, type_logic_1.typeFromExprType)(signature.return); let numberOfAbstractClosuresWeAreWithin = 0; let hadReturnIssue = false; const walkLines = (list) => { var _a; for (const line of list.contents) { let thisLineIsAbstractClosure = false; // we already parsed in collect-algorithm-diagnostics, but that was before generating the biblio // the biblio affects the parse of calls, so we need to re-do it // TODO do collect-algorithm-diagnostics after generating the biblio, somehow? 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)(getExpressionVisitor(spec, warn, onlyPerformed, alwaysAssertedToBeNormal), item); if (returnType != null) { let idx = item.items.length - 1; let last; while (idx >= 0 && ((last = item.items[idx]).name !== 'text' || last.contents.trim() === '')) { --idx; } if (last != null && last.contents.endsWith('performs the following steps when called:')) { thisLineIsAbstractClosure = true; ++numberOfAbstractClosuresWeAreWithin; } else if (numberOfAbstractClosuresWeAreWithin === 0) { const returnWarn = (biblioEntry === null || biblioEntry === void 0 ? void 0 : biblioEntry._skipReturnChecks) ? () => { hadReturnIssue = true; } : warn; const lineHadCompletionReturn = inspectReturns(returnWarn, item, returnType, spec.biblio); if (hasPossibleCompletionReturn != null) { if (lineHadCompletionReturn == null) { hasPossibleCompletionReturn = null; } else { hasPossibleCompletionReturn || (hasPossibleCompletionReturn = lineHadCompletionReturn); } } } } } if (((_a = line.sublist) === null || _a === void 0 ? void 0 : _a.name) === 'ol') { walkLines(line.sublist); } if (thisLineIsAbstractClosure) { --numberOfAbstractClosuresWeAreWithin; } } }; walkLines(tree.contents); if (returnType != null && (0, type_logic_1.isPossiblyAbruptCompletion)(returnType) && hasPossibleCompletionReturn === false) { if (biblioEntry._skipReturnChecks) { hadReturnIssue = true; } else { spec.warn({ type: 'node', ruleId: 'completion-algorithm-lacks-completiony-thing', message: 'this algorithm is declared as returning an abrupt completion, but there is no step which might plausibly return an abrupt completion', node, }); } } if ((biblioEntry === null || biblioEntry === void 0 ? void 0 : biblioEntry._skipReturnChecks) && !hadReturnIssue) { spec.warn({ type: 'node', ruleId: 'unnecessary-attribute', message: 'this algorithm has the "skip return check" attribute, but there is nothing which would cause an issue if it were removed', node, }); } } 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, }); } } } 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; } // returns a boolean to indicate whether this line can return an abrupt completion, or null to indicate we should stop caring // also checks return types in general function inspectReturns(warn, line, returnType, biblio) { let hadAbrupt = false; // check for `throw` etc const throwRegexp = /\b(ReturnIfAbrupt\b|IfAbruptCloseIterator\b|(^|(?<=, ))[tT]hrow (a\b|the\b|$)|[rR]eturn( a| a new| the)? Completion Record\b|the result of evaluating\b)|(?<=[\s(]|\b)\?\s/; let thrower = line.items.find(e => e.name === 'text' && throwRegexp.test(e.contents)); let offsetOfThrow; if (thrower == null) { // `Throw` etc are at the top level, but the `?` macro can be nested, so we need to walk the whole line looking for it (0, expr_parser_1.walk)(e => { if (thrower != null || e.name !== 'text') return; const qm = /((?<=[\s(])|\b|^)\?(\s|$)/.exec(e.contents); if (qm != null) { thrower = e; offsetOfThrow = qm.index; } }, line); } else { offsetOfThrow = throwRegexp.exec(thrower.contents).index; } if (thrower != null) { if ((0, type_logic_1.isPossiblyAbruptCompletion)(returnType)) { hadAbrupt = true; } else { warn(thrower.location.start.offset + offsetOfThrow, 'this would return an abrupt completion, but the containing AO is declared not to return an abrupt completion'); return null; } } // check return types const returnIndex = line.items.findIndex(e => e.name === 'text' && /\b[Rr]eturn /.test(e.contents)); if (returnIndex === -1) return hadAbrupt; const last = line.items[line.items.length - 1]; if (last.name !== 'text' || !/\.\s*$/.test(last.contents)) return hadAbrupt; const ret = line.items[returnIndex]; const afterRet = /\b[Rr]eturn\b/.exec(ret.contents).index + 6; /* 'return'.length */ const beforePeriod = /\.\s*$/.exec(last.contents).index; let returnedExpr; if (ret === last) { returnedExpr = { name: 'seq', items: [ { name: 'text', contents: ret.contents.slice(afterRet, beforePeriod), location: { start: { offset: ret.location.start.offset + afterRet }, end: { offset: ret.location.end.offset - (ret.contents.length - beforePeriod) }, }, }, ], }; } else { const tweakedFirst = { name: 'text', contents: ret.contents.slice(afterRet), location: { start: { offset: ret.location.start.offset + afterRet }, end: ret.location.end }, }; const tweakedLast = { name: 'text', contents: last.contents.slice(0, beforePeriod), location: { start: last.location.start, end: { offset: last.location.end.offset - (last.contents.length - beforePeriod) }, }, }; returnedExpr = { name: 'seq', items: [tweakedFirst, ...line.items.slice(returnIndex + 1, -1), tweakedLast], }; } const typeOfReturnedExpr = (0, type_logic_1.typeFromExpr)(returnedExpr, biblio, warn); if (hadAbrupt != null && (0, type_logic_1.isPossiblyAbruptCompletion)(typeOfReturnedExpr)) { hadAbrupt = true; } let error = getErrorForUsingTypeXAsTypeY(typeOfReturnedExpr, returnType); if (error !== null) { if ((0, type_logic_1.isCompletion)(returnType) && !(0, type_logic_1.isCompletion)(typeOfReturnedExpr)) { // special case: you can return values for normal completions without wrapping error = getErrorForUsingTypeXAsTypeY({ kind: 'normal completion', of: typeOfReturnedExpr }, returnType); if (error == null) return hadAbrupt; } const returnDescriptor = typeOfReturnedExpr.kind.startsWith('concrete') || typeOfReturnedExpr.kind === 'enum value' || typeOfReturnedExpr.kind === 'null' || typeOfReturnedExpr.kind === 'undefined' ? `returned value (${(0, type_logic_1.serialize)(typeOfReturnedExpr)})` : `type of returned value (${(0, type_logic_1.serialize)(typeOfReturnedExpr)})`; let hint; switch (error) { case 'number-to-real': { hint = '\nhint: you returned an ES language Number, but this algorithm returns a mathematical value'; break; } case 'bigint-to-real': { hint = '\nhint: you returned an ES language BigInt, but this algorithm returns a mathematical value'; break; } case 'real-to-number': { hint = '\nhint: you returned a mathematical value, but this algorithm returns an ES language Number'; break; } case 'real-to-bigint': { hint = '\nhint: you returned a mathematical value, but this algorithm returns an ES language BigInt'; break; } case 'other': { hint = ''; break; } } warn(returnedExpr.items[0].location.start.offset + /^\s*/.exec(returnedExpr.items[0].contents)[0].length, `${returnDescriptor} does not look plausibly assignable to algorithm's return type (${(0, type_logic_1.serialize)(returnType)})${hint}`); return null; } return hadAbrupt; } 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 text = null; let prev; while ((0, expr_parser_1.isProsePart)((prev = seq.items[prevIndex]))) { if (prev.name === 'text') { text = prev.contents + (text !== null && text !== void 0 ? text : ''); --prevIndex; } else if (prev.name === 'tag') { --prevIndex; } else { break; } } return text; }