From ac7b14a9eecefb1ae3435282304fbcd0256ff79a Mon Sep 17 00:00:00 2001 From: Brett Zamir Date: Sun, 6 Feb 2022 15:04:13 +0800 Subject: [PATCH] INCOMPLETE: 0. depends on acorn and acorn-jsx being updated per PRs a. https://github.com/acornjs/acorn/pull/1104 (merged but not yet released) b. https://github.com/acornjs/acorn/pull/1105 (merged but not yet released) c. https://github.com/acornjs/acorn-jsx/pull/130 1. Refactor to avoid `typedef` shortcuts when not desired for export 2. Avoid dummy class, so parse JavaScript with https://github.com/es-joy/jsdoc-eslint-parser feat: Adds JSDoc-based TypeScript declaration file Also: 1. chore: adds `editorconfig` 2. refactor: Removes unused esprima 3. refactor: changes to force EspreeParser constructor to convert `new String` to plain string (for typing) 4. refactor: switches to `Object.keys` to avoid `hasOwnProperty` and easier for typing 5. refactor: drops a use of `acorn.Parser.extend` for typing purposes 6. refactor: checks for existence of `tokens` in `tokenize` (as may be absent) 7. refactor: checks for existence of `firstNode.range` and `firstNode.loc` in `parse` (as may be absent) 8. refactor: checks for existence of `extra.lastToken.range` and `extra.lastToken.loc` in `parse` (as may be absent) 7. feat: throws specific error if `jsx_readString` superclass undefined 8. refactor: checks for existence of `lastTemplateToken.loc` and `lastTemplateToken.range` in `token-translator.js` (as may be absent) --- .editorconfig | 15 + espree.js | 92 +++++- lib/espree.js | 687 ++++++++++++++++++++++++++-------------- lib/options.js | 38 ++- lib/token-translator.js | 112 +++++-- package.json | 5 +- tests/lib/espree.js | 16 + tsconfig.json | 20 ++ 8 files changed, 695 insertions(+), 290 deletions(-) create mode 100644 .editorconfig create mode 100644 tests/lib/espree.js create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..a4d65231 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +; EditorConfig file: https://EditorConfig.org +; Install the "EditorConfig" plugin into your editor to use + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +indent_size = 2 diff --git a/espree.js b/espree.js index 71e3d47d..757e7e49 100644 --- a/espree.js +++ b/espree.js @@ -56,6 +56,50 @@ */ /* eslint no-undefined:0, no-use-before-define: 0 */ +/** + * @typedef {import('acorn')} acorn + * @typedef {typeof import('acorn-jsx').AcornJsxParser} AcornJsxParser + * @typedef {import('./lib/espree').EnhancedSyntaxError} EnhancedSyntaxError + * @typedef {typeof import('./lib/espree').EspreeParser} IEspreeParser + * @typedef {acorn.ecmaVersion} ecmaVersion + */ +/** + * `jsx.Options` gives us 2 optional properties, so extend it + * + * `allowReserved`, `ranges`, `locations`, `allowReturnOutsideFunction`, + * `onToken`, and `onComment` are as in `acorn.Options` + * + * `ecmaVersion` as in `acorn.Options` though optional + * + * `sourceType` as in `acorn.Options` but also allows `commonjs` + * + * `ecmaFeatures`, `range`, `loc`, `tokens` are not in `acorn.Options` + * + * `comment` is not in `acorn.Options` and doesn't err without it, but is used + * @typedef {{ + * allowReserved?: boolean | "never", + * ranges?: boolean, + * locations?: boolean, + * allowReturnOutsideFunction?: boolean, + * onToken?: ((token: acorn.Token) => any) | acorn.Token[] + * onComment?: (( + * isBlock: boolean, text: string, start: number, end: number, startLoc?: acorn.Position, + * endLoc?: acorn.Position + * ) => void) | acorn.Comment[] + * ecmaVersion?: ecmaVersion, + * sourceType?: "script"|"module"|"commonjs", + * ecmaFeatures?: { + * jsx?: boolean, + * globalReturn?: boolean, + * impliedStrict?: boolean + * }, + * range?: boolean, + * loc?: boolean, + * tokens?: boolean | null + * comment?: boolean, + * } & jsx.Options} ParserOptions + */ + import * as acorn from "acorn"; import jsx from "acorn-jsx"; import espree from "./lib/espree.js"; @@ -66,23 +110,43 @@ import { getLatestEcmaVersion, getSupportedEcmaVersions } from "./lib/options.js // To initialize lazily. const parsers = { - _regular: null, - _jsx: null, + _regular: /** @type {IEspreeParser|null} */ (null), + _jsx: /** @type {IEspreeParser|null} */ (null), + /** + * Returns regular Parser + * @returns {IEspreeParser} Regular Acorn parser + */ get regular() { if (this._regular === null) { - this._regular = acorn.Parser.extend(espree()); + const espreeParserFactory = espree(); + + // Cast the `acorn.Parser` to our own for required properties not specified in *.d.ts + this._regular = espreeParserFactory(/** @type {AcornJsxParser} */ (acorn.Parser)); } return this._regular; }, + /** + * Returns JSX Parser + * @returns {IEspreeParser} JSX Acorn parser + */ get jsx() { if (this._jsx === null) { - this._jsx = acorn.Parser.extend(jsx(), espree()); + const espreeParserFactory = espree(); + const jsxFactory = jsx(); + + // Cast the `acorn.Parser` to our own for required properties not specified in *.d.ts + this._jsx = espreeParserFactory(/** @type {AcornJsxParser} */ (jsxFactory(acorn.Parser))); } return this._jsx; }, + /** + * Returns Regular or JSX Parser + * @param {ParserOptions} options Parser options + * @returns {IEspreeParser} Regular or JSX Acorn parser + */ get(options) { const useJsx = Boolean( options && @@ -101,9 +165,9 @@ const parsers = { /** * Tokenizes the given code. * @param {string} code The code to tokenize. - * @param {Object} options Options defining how to tokenize. - * @returns {Token[]} An array of tokens. - * @throws {SyntaxError} If the input code is invalid. + * @param {ParserOptions} options Options defining how to tokenize. + * @returns {acorn.Token[]|null} An array of tokens. + * @throws {EnhancedSyntaxError} If the input code is invalid. * @private */ export function tokenize(code, options) { @@ -124,9 +188,9 @@ export function tokenize(code, options) { /** * Parses the given code. * @param {string} code The code to tokenize. - * @param {Object} options Options defining how to tokenize. - * @returns {ASTNode} The "Program" AST node. - * @throws {SyntaxError} If the input code is invalid. + * @param {ParserOptions} options Options defining how to tokenize. + * @returns {acorn.Node} The "Program" AST node. + * @throws {EnhancedSyntaxError} If the input code is invalid. */ export function parse(code, options) { const Parser = parsers.get(options); @@ -148,17 +212,15 @@ export const VisitorKeys = (function() { // Derive node types from VisitorKeys /* istanbul ignore next */ export const Syntax = (function() { - let name, + let /** @type {Object} */ types = {}; if (typeof Object.create === "function") { types = Object.create(null); } - for (name in VisitorKeys) { - if (Object.hasOwnProperty.call(VisitorKeys, name)) { - types[name] = name; - } + for (const name of Object.keys(VisitorKeys)) { + types[name] = name; } if (typeof Object.freeze === "function") { diff --git a/lib/espree.js b/lib/espree.js index 786d89fa..594bcb81 100644 --- a/lib/espree.js +++ b/lib/espree.js @@ -1,28 +1,160 @@ /* eslint-disable no-param-reassign*/ import TokenTranslator from "./token-translator.js"; import { normalizeOptions } from "./options.js"; - +import * as acorn from "acorn"; const STATE = Symbol("espree's internal state"); const ESPRIMA_FINISH_NODE = Symbol("espree's esprimaFinishNode"); +/** + * Suggests an integer + * @typedef {number} int + */ +/** + * @typedef {import('acorn')} acorn + * @typedef {acorn.ecmaVersion} ecmaVersion + * @typedef {typeof import('acorn-jsx').tokTypes} tokTypesType + * @typedef {typeof import('acorn-jsx').AcornJsxParser} AcornJsxParser + * @typedef {import('../espree').ParserOptions} ParserOptions + * + * @typedef {{ + * generator?: boolean + * } & acorn.Node} EsprimaNode + * + */ +/** + * We add `jsxAttrValueToken` ourselves. + * + * `jsxName`, `jsxTagEnd`, and `jsxTagStart` are used in Acorn JSX but not + * in its *d.ts + * + * `invalidTemplate` and `questionDot` are not in *.d.ts + * @todo Move three to acorn JSX? + * @typedef {{ + * jsxAttrValueToken?: acorn.TokenType; + * } & tokTypesType} EnhancedTokTypes + */ +/** + * First three properties as in `acorn.Comment`; next two as in `acorn.Comment` + * but optional. Last is different as has to allow `undefined` + * @typedef {{ + * type: string, + * value: string, + * range?: [number, number], + * start?: number, + * end?: number, + * loc?: { + * start: acorn.Position | undefined, + * end: acorn.Position | undefined + * } + * }} EsprimaComment + * + * @typedef {{ + * comments?: EsprimaComment[] + * } & acorn.Token[]} EspreeTokens + * + * @typedef {{ + * tail?: boolean + * } & acorn.Node} AcornTemplateNode + * + * @typedef {{ + * originalSourceType: "script"|"module"|"commonjs"; + * ecmaVersion: ecmaVersion; + * comments: EsprimaComment[]|null; + * impliedStrict: boolean; + * lastToken: acorn.Token|null; + * templateElements: (AcornTemplateNode)[]; + * jsxAttrValueToken: boolean; + * }} BaseStateObject + * + * @typedef {{ + * tokens: null; + * } & BaseStateObject} StateObject + * + * @typedef {{ + * tokens: EspreeTokens; + * } & BaseStateObject} StateObjectWithTokens + * + * @typedef {{ + * sourceType?: "script"|"module"|"commonjs"; + * comments?: EsprimaComment[]; + * tokens?: acorn.Token[]; + * body: acorn.Node[]; + * } & acorn.Node} EsprimaProgramNode + */ +/** + * @typedef {{ + * index?: number; + * lineNumber?: number; + * column?: number; + * } & SyntaxError} EnhancedSyntaxError + */ + +/* +NOT USING: +* @typedef {{ +* parse(): EsprimaProgramNode; +* tokenize(): EspreeTokens|null; +* }} IEspreeParser +* +* @typedef {{ +* new(options: ParserOptions, input: string, startPos?: number): IEspreeParser; +* }} EspreeParser + */ + +export class EspreeParser extends acorn.Parser { + + /* eslint-disable class-methods-use-this, no-useless-constructor */ + /** + * Constructor for EspreeParser + * @param {ParserOptions} options The parser options + * @param {string} input The input text + * @param {number} [startPos] The starting position + */ + constructor(options, input, startPos) { + super(/** @type {acorn.Options} */ (options), input, startPos); + } + + /** + * Parse + * @returns {EsprimaProgramNode} The Espree program Node + */ + parse() { + return /** @type {EsprimaProgramNode} */ ({}); + } + + /** + * Tokenize + * @returns {EspreeTokens|null} The Espree tokens + */ + tokenize() { + return null; + } + /* eslint-enable class-methods-use-this, no-useless-constructor */ +} /** - * Converts an Acorn comment to a Esprima comment. + * Converts an Acorn comment to an Esprima comment. + * @callback AcornToEsprimaCommentConverter * @param {boolean} block True if it's a block comment, false if not. * @param {string} text The text of the comment. * @param {int} start The index at which the comment starts. * @param {int} end The index at which the comment ends. - * @param {Location} startLoc The location at which the comment starts. - * @param {Location} endLoc The location at which the comment ends. - * @returns {Object} The comment object. + * @param {acorn.Position|undefined} startLoc The location at which the comment starts. + * @param {acorn.Position|undefined} endLoc The location at which the comment ends. + */ + +/** + * Converts an Acorn comment to an Esprima comment. + * @type {AcornToEsprimaCommentConverter} + * @returns {EsprimaComment} The comment object. * @private */ function convertAcornCommentToEsprimaComment(block, text, start, end, startLoc, endLoc) { - const comment = { + const comment = /** @type {EsprimaComment} */ ({ type: block ? "Block" : "Line", value: text - }; + }); if (typeof start === "number") { comment.start = start; @@ -40,289 +172,364 @@ function convertAcornCommentToEsprimaComment(block, text, start, end, startLoc, return comment; } -export default () => Parser => { - const tokTypes = Object.assign({}, Parser.acorn.tokTypes); +/* eslint-disable arrow-body-style -- Need to supply formatted JSDoc for type info */ +export default () => { - if (Parser.acornJsx) { - Object.assign(tokTypes, Parser.acornJsx.tokTypes); - } + /** + * Returns the Espree parser. + * @param {AcornJsxParser} Parser The Acorn parser + * @returns {typeof EspreeParser} The Espree parser + */ + return Parser => { + const tokTypes = /** @type {EnhancedTokTypes} */ (Object.assign({}, Parser.acorn.tokTypes)); - return class Espree extends Parser { - constructor(opts, code) { - if (typeof opts !== "object" || opts === null) { - opts = {}; - } - if (typeof code !== "string" && !(code instanceof String)) { - code = String(code); - } + if (Parser.acornJsx) { + Object.assign(tokTypes, Parser.acornJsx.tokTypes); + } - // save original source type in case of commonjs - const originalSourceType = opts.sourceType; - const options = normalizeOptions(opts); - const ecmaFeatures = options.ecmaFeatures || {}; - const tokenTranslator = - options.tokens === true - ? new TokenTranslator(tokTypes, code) - : null; - - // Initialize acorn parser. - super({ - - // do not use spread, because we don't want to pass any unknown options to acorn - ecmaVersion: options.ecmaVersion, - sourceType: options.sourceType, - ranges: options.ranges, - locations: options.locations, - allowReserved: options.allowReserved, - - // Truthy value is true for backward compatibility. - allowReturnOutsideFunction: options.allowReturnOutsideFunction, - - // Collect tokens - onToken: token => { - if (tokenTranslator) { - - // Use `tokens`, `ecmaVersion`, and `jsxAttrValueToken` in the state. - tokenTranslator.onToken(token, this[STATE]); - } - if (token.type !== tokTypes.eof) { - this[STATE].lastToken = token; + // eslint-disable-next-line no-shadow -- Using first class as type + return class EspreeParser extends Parser { + /* eslint-disable jsdoc/check-types -- Allows generic object */ + /** + * Adapted parser for Espree. + * @param {ParserOptions|null} opts Espree options + * @param {string|object} code The source code + */ + constructor(opts, code) { + /* eslint-enable jsdoc/check-types -- Allows generic object */ + + /** @type {ParserOptions} */ + const newOpts = (typeof opts !== "object" || opts === null) + ? {} + : opts; + + const codeString = typeof code === "string" + ? /** @type {string} */ (code) + : String(code); + + // save original source type in case of commonjs + const originalSourceType = newOpts.sourceType; + const options = normalizeOptions(newOpts); + const ecmaFeatures = options.ecmaFeatures || {}; + const tokenTranslator = + options.tokens === true + ? new TokenTranslator(tokTypes, codeString) + : null; + + // Initialize acorn parser. + super({ + + // do not use spread, because we don't want to pass any unknown options to acorn + ecmaVersion: options.ecmaVersion, + sourceType: options.sourceType, + ranges: options.ranges, + locations: options.locations, + allowReserved: options.allowReserved, + + // Truthy value is true for backward compatibility. + allowReturnOutsideFunction: options.allowReturnOutsideFunction, + + // Collect tokens + onToken: /** @param {acorn.Token} token */ token => { + if (tokenTranslator) { + + // Use `tokens`, `ecmaVersion`, and `jsxAttrValueToken` in the state. + tokenTranslator.onToken(token, /** @type {StateObjectWithTokens} */ (this[STATE])); + } + if (token.type !== tokTypes.eof) { + this[STATE].lastToken = token; + } + }, + + // Collect comments + /** + * Converts an Acorn comment to an Esprima comment. + * @type {AcornToEsprimaCommentConverter} + */ + onComment: (block, text, start, end, startLoc, endLoc) => { + if (this[STATE].comments) { + const comment = convertAcornCommentToEsprimaComment(block, text, start, end, startLoc, endLoc); + + this[STATE].comments?.push(comment); + } } - }, - - // Collect comments - onComment: (block, text, start, end, startLoc, endLoc) => { - if (this[STATE].comments) { - const comment = convertAcornCommentToEsprimaComment(block, text, start, end, startLoc, endLoc); + }, codeString); - this[STATE].comments.push(comment); - } + // Force for TypeScript (indicating that `lineStart` is not undefined) + if (!this.lineStart) { + this.lineStart = 0; } - }, code); - /* - * Data that is unique to Espree and is not represented internally in - * Acorn. We put all of this data into a symbol property as a way to - * avoid potential naming conflicts with future versions of Acorn. + /** + * Data that is unique to Espree and is not represented internally in + * Acorn. We put all of this data into a symbol property as a way to + * avoid potential naming conflicts with future versions of Acorn. + * @type {StateObjectWithTokens|StateObject} + */ + this[STATE] = { + originalSourceType: originalSourceType || options.sourceType, + tokens: tokenTranslator ? /** @type {EspreeTokens} */ ([]) : null, + comments: options.comment === true + ? /** @type {EsprimaComment[]} */ ([]) + : null, + impliedStrict: ecmaFeatures.impliedStrict === true && this.options.ecmaVersion >= 5, + ecmaVersion: this.options.ecmaVersion, + jsxAttrValueToken: false, + + /** @type {acorn.Token|null} */ + lastToken: null, + + /** @type {(AcornTemplateNode)[]} */ + templateElements: [] + }; + } + + /** + * Returns Espree tokens. + * @returns {EspreeTokens|null} Espree tokens */ - this[STATE] = { - originalSourceType: originalSourceType || options.sourceType, - tokens: tokenTranslator ? [] : null, - comments: options.comment === true ? [] : null, - impliedStrict: ecmaFeatures.impliedStrict === true && this.options.ecmaVersion >= 5, - ecmaVersion: this.options.ecmaVersion, - jsxAttrValueToken: false, - lastToken: null, - templateElements: [] - }; - } + tokenize() { + do { + this.next(); + } while (this.type !== tokTypes.eof); - tokenize() { - do { + // Consume the final eof token this.next(); - } while (this.type !== tokTypes.eof); - // Consume the final eof token - this.next(); + const extra = this[STATE]; + const tokens = extra.tokens; - const extra = this[STATE]; - const tokens = extra.tokens; + if (extra.comments && tokens) { + tokens.comments = extra.comments; + } - if (extra.comments) { - tokens.comments = extra.comments; + return tokens; } - return tokens; - } - - finishNode(...args) { - const result = super.finishNode(...args); - - return this[ESPRIMA_FINISH_NODE](result); - } - - finishNodeAt(...args) { - const result = super.finishNodeAt(...args); - - return this[ESPRIMA_FINISH_NODE](result); - } + /** + * Calls parent. + * @param {acorn.Node} node The node + * @param {string} type The type + * @returns {acorn.Node} The altered Node + */ + finishNode(node, type) { + const result = super.finishNode(node, type); - parse() { - const extra = this[STATE]; - const program = super.parse(); + return this[ESPRIMA_FINISH_NODE](result); + } - program.sourceType = extra.originalSourceType; + /** + * Calls parent. + * @param {acorn.Node} node The node + * @param {string} type The type + * @param {number} pos The position + * @param {acorn.Position} loc The location + * @returns {acorn.Node} The altered Node + */ + finishNodeAt(node, type, pos, loc) { + const result = super.finishNodeAt(node, type, pos, loc); - if (extra.comments) { - program.comments = extra.comments; - } - if (extra.tokens) { - program.tokens = extra.tokens; + return this[ESPRIMA_FINISH_NODE](result); } - /* - * Adjust opening and closing position of program to match Esprima. - * Acorn always starts programs at range 0 whereas Esprima starts at the - * first AST node's start (the only real difference is when there's leading - * whitespace or leading comments). Acorn also counts trailing whitespace - * as part of the program whereas Esprima only counts up to the last token. + /** + * Parses. + * @returns {EsprimaProgramNode} The program Node */ - if (program.body.length) { - const [firstNode] = program.body; + parse() { + const extra = this[STATE]; + + const program = /** @type {EsprimaProgramNode} */ (super.parse()); + + program.sourceType = extra.originalSourceType; - if (program.range) { - program.range[0] = firstNode.range[0]; + if (extra.comments) { + program.comments = extra.comments; } - if (program.loc) { - program.loc.start = firstNode.loc.start; + if (extra.tokens) { + program.tokens = extra.tokens; } - program.start = firstNode.start; - } - if (extra.lastToken) { - if (program.range) { - program.range[1] = extra.lastToken.range[1]; + + /* + * Adjust opening and closing position of program to match Esprima. + * Acorn always starts programs at range 0 whereas Esprima starts at the + * first AST node's start (the only real difference is when there's leading + * whitespace or leading comments). Acorn also counts trailing whitespace + * as part of the program whereas Esprima only counts up to the last token. + */ + if (program.body.length) { + const [firstNode] = program.body; + + if (program.range && firstNode.range) { + program.range[0] = firstNode.range[0]; + } + if (program.loc && firstNode.loc) { + program.loc.start = firstNode.loc.start; + } + program.start = firstNode.start; } - if (program.loc) { - program.loc.end = extra.lastToken.loc.end; + if (extra.lastToken) { + if (program.range && extra.lastToken.range) { + program.range[1] = extra.lastToken.range[1]; + } + if (program.loc && extra.lastToken.loc) { + program.loc.end = extra.lastToken.loc.end; + } + program.end = extra.lastToken.end; } - program.end = extra.lastToken.end; - } - /* - * https://github.com/eslint/espree/issues/349 - * Ensure that template elements have correct range information. - * This is one location where Acorn produces a different value - * for its start and end properties vs. the values present in the - * range property. In order to avoid confusion, we set the start - * and end properties to the values that are present in range. - * This is done here, instead of in finishNode(), because Acorn - * uses the values of start and end internally while parsing, making - * it dangerous to change those values while parsing is ongoing. - * By waiting until the end of parsing, we can safely change these - * values without affect any other part of the process. - */ - this[STATE].templateElements.forEach(templateElement => { - const startOffset = -1; - const endOffset = templateElement.tail ? 1 : 2; + /* + * https://github.com/eslint/espree/issues/349 + * Ensure that template elements have correct range information. + * This is one location where Acorn produces a different value + * for its start and end properties vs. the values present in the + * range property. In order to avoid confusion, we set the start + * and end properties to the values that are present in range. + * This is done here, instead of in finishNode(), because Acorn + * uses the values of start and end internally while parsing, making + * it dangerous to change those values while parsing is ongoing. + * By waiting until the end of parsing, we can safely change these + * values without affect any other part of the process. + */ + this[STATE].templateElements.forEach(templateElement => { + const startOffset = -1; + const endOffset = templateElement.tail ? 1 : 2; + + templateElement.start += startOffset; + templateElement.end += endOffset; + + if (templateElement.range) { + templateElement.range[0] += startOffset; + templateElement.range[1] += endOffset; + } - templateElement.start += startOffset; - templateElement.end += endOffset; + if (templateElement.loc) { + templateElement.loc.start.column += startOffset; + templateElement.loc.end.column += endOffset; + } + }); - if (templateElement.range) { - templateElement.range[0] += startOffset; - templateElement.range[1] += endOffset; - } + return program; + } - if (templateElement.loc) { - templateElement.loc.start.column += startOffset; - templateElement.loc.end.column += endOffset; + /** + * Parses top level. + * @param {acorn.Node} node AST Node + * @returns {acorn.Node} The changed node + */ + parseTopLevel(node) { + if (this[STATE].impliedStrict) { + this.strict = true; } - }); + return super.parseTopLevel(node); + } - return program; - } + /** + * Overwrites the default raise method to throw Esprima-style errors. + * @param {int} pos The position of the error. + * @param {string} message The error message. + * @throws {EnhancedSyntaxError} A syntax error. + * @returns {void} + */ + raise(pos, message) { + const loc = Parser.acorn.getLineInfo(this.input, pos); + + /** @type {EnhancedSyntaxError} */ + const err = new SyntaxError(message); - parseTopLevel(node) { - if (this[STATE].impliedStrict) { - this.strict = true; + err.index = pos; + err.lineNumber = loc.line; + err.column = loc.column + 1; // acorn uses 0-based columns + throw err; } - return super.parseTopLevel(node); - } - /** - * Overwrites the default raise method to throw Esprima-style errors. - * @param {int} pos The position of the error. - * @param {string} message The error message. - * @throws {SyntaxError} A syntax error. - * @returns {void} - */ - raise(pos, message) { - const loc = Parser.acorn.getLineInfo(this.input, pos); - const err = new SyntaxError(message); - - err.index = pos; - err.lineNumber = loc.line; - err.column = loc.column + 1; // acorn uses 0-based columns - throw err; - } + /** + * Overwrites the default raise method to throw Esprima-style errors. + * @param {int} pos The position of the error. + * @param {string} message The error message. + * @throws {EnhancedSyntaxError} A syntax error. + * @returns {void} + */ + raiseRecoverable(pos, message) { + this.raise(pos, message); + } - /** - * Overwrites the default raise method to throw Esprima-style errors. - * @param {int} pos The position of the error. - * @param {string} message The error message. - * @throws {SyntaxError} A syntax error. - * @returns {void} - */ - raiseRecoverable(pos, message) { - this.raise(pos, message); - } + /** + * Overwrites the default unexpected method to throw Esprima-style errors. + * @param {int} pos The position of the error. + * @throws {EnhancedSyntaxError} A syntax error. + * @returns {void} + */ + unexpected(pos) { + let message = "Unexpected token"; - /** - * Overwrites the default unexpected method to throw Esprima-style errors. - * @param {int} pos The position of the error. - * @throws {SyntaxError} A syntax error. - * @returns {void} - */ - unexpected(pos) { - let message = "Unexpected token"; - - if (pos !== null && pos !== void 0) { - this.pos = pos; - - if (this.options.locations) { - while (this.pos < this.lineStart) { - this.lineStart = this.input.lastIndexOf("\n", this.lineStart - 2) + 1; - --this.curLine; + if (pos !== null && pos !== void 0) { + this.pos = pos; + + if (this.options.locations) { + while (this.pos < /** @type {number} */ (this.lineStart)) { + + /** @type {number} */ + this.lineStart = this.input.lastIndexOf("\n", /** @type {number} */ (this.lineStart) - 2) + 1; + --this.curLine; + } } + + this.nextToken(); } - this.nextToken(); - } + if (this.end > this.start) { + message += ` ${this.input.slice(this.start, this.end)}`; + } - if (this.end > this.start) { - message += ` ${this.input.slice(this.start, this.end)}`; + this.raise(this.start, message); } - this.raise(this.start, message); - } + /** + * Esprima-FB represents JSX strings as tokens called "JSXText", but Acorn-JSX + * uses regular tt.string without any distinction between this and regular JS + * strings. As such, we intercept an attempt to read a JSX string and set a flag + * on extra so that when tokens are converted, the next token will be switched + * to JSXText via onToken. + * @param {number} quote A character code + * @returns {void} + */ + jsx_readString(quote) { // eslint-disable-line camelcase + if (typeof super.jsx_readString === "undefined") { + throw new Error("Not a JSX parser"); + } + const result = super.jsx_readString(quote); - /* - * Esprima-FB represents JSX strings as tokens called "JSXText", but Acorn-JSX - * uses regular tt.string without any distinction between this and regular JS - * strings. As such, we intercept an attempt to read a JSX string and set a flag - * on extra so that when tokens are converted, the next token will be switched - * to JSXText via onToken. - */ - jsx_readString(quote) { // eslint-disable-line camelcase - const result = super.jsx_readString(quote); - - if (this.type === tokTypes.string) { - this[STATE].jsxAttrValueToken = true; + if (this.type === tokTypes.string) { + this[STATE].jsxAttrValueToken = true; + } + return result; } - return result; - } - /** - * Performs last-minute Esprima-specific compatibility checks and fixes. - * @param {ASTNode} result The node to check. - * @returns {ASTNode} The finished node. - */ - [ESPRIMA_FINISH_NODE](result) { + /** + * Performs last-minute Esprima-specific compatibility checks and fixes. + * @param {acorn.Node} result The node to check. + * @returns {EsprimaNode} The finished node. + */ + [ESPRIMA_FINISH_NODE](result) { - // Acorn doesn't count the opening and closing backticks as part of templates - // so we have to adjust ranges/locations appropriately. - if (result.type === "TemplateElement") { + const esprimaResult = /** @type {EsprimaNode} */ (result); - // save template element references to fix start/end later - this[STATE].templateElements.push(result); - } + // Acorn doesn't count the opening and closing backticks as part of templates + // so we have to adjust ranges/locations appropriately. + if (result.type === "TemplateElement") { - if (result.type.includes("Function") && !result.generator) { - result.generator = false; - } + // save template element references to fix start/end later + this[STATE].templateElements.push(result); + } - return result; - } + if (result.type.includes("Function") && !esprimaResult.generator) { + esprimaResult.generator = false; + } + + return esprimaResult; + } + }; }; }; diff --git a/lib/options.js b/lib/options.js index 87739699..23747f5b 100644 --- a/lib/options.js +++ b/lib/options.js @@ -7,6 +7,10 @@ // Helpers //------------------------------------------------------------------------------ +/** + * @typedef {import('../espree').ParserOptions} ParserOptions + */ + const SUPPORTED_VERSIONS = [ 3, 5, @@ -38,7 +42,7 @@ export function getSupportedEcmaVersions() { /** * Normalize ECMAScript version from the initial config - * @param {(number|"latest")} ecmaVersion ECMAScript version from the initial config + * @param {number|"latest"} ecmaVersion ECMAScript version from the initial config * @throws {Error} throws an error if the ecmaVersion is invalid. * @returns {number} normalized ECMAScript version */ @@ -65,9 +69,9 @@ function normalizeEcmaVersion(ecmaVersion = 5) { /** * Normalize sourceType from the initial config - * @param {string} sourceType to normalize + * @param {"script"|"module"|"commonjs"} sourceType to normalize * @throws {Error} throw an error if sourceType is invalid - * @returns {string} normalized sourceType + * @returns {"script"|"module"} normalized sourceType */ function normalizeSourceType(sourceType = "script") { if (sourceType === "script" || sourceType === "module") { @@ -81,14 +85,36 @@ function normalizeSourceType(sourceType = "script") { throw new Error("Invalid sourceType."); } +/** + * @typedef {{ + * ecmaVersion: 10 | 9 | 8 | 7 | 6 | 5 | 3 | 11 | 12 | 13 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | "latest", + * sourceType: "script"|"module", + * range?: boolean, + * loc?: boolean, + * allowReserved: boolean | "never", + * ecmaFeatures?: { + * jsx?: boolean, + * globalReturn?: boolean, + * impliedStrict?: boolean + * }, + * ranges: boolean, + * locations: boolean, + * allowReturnOutsideFunction: boolean, + * tokens?: boolean | null, + * comment?: boolean + * }} NormalizedParserOptions + */ + /** * Normalize parserOptions - * @param {Object} options the parser options to normalize + * @param {ParserOptions} options the parser options to normalize * @throws {Error} throw an error if found invalid option. - * @returns {Object} normalized options + * @returns {NormalizedParserOptions} normalized options */ export function normalizeOptions(options) { const ecmaVersion = normalizeEcmaVersion(options.ecmaVersion); + + /** @type {"script"|"module"} */ const sourceType = normalizeSourceType(options.sourceType); const ranges = options.range === true; const locations = options.loc === true; @@ -98,6 +124,8 @@ export function normalizeOptions(options) { // a value of `false` is intentionally allowed here, so a shared config can overwrite it when needed throw new Error("`allowReserved` is only supported when ecmaVersion is 3"); } + + // Note: value in Acorn can also be "never" but we throw in such a case if (typeof options.allowReserved !== "undefined" && typeof options.allowReserved !== "boolean") { throw new Error("`allowReserved`, when present, must be `true` or `false`"); } diff --git a/lib/token-translator.js b/lib/token-translator.js index 9aa5e22e..b84c36cb 100644 --- a/lib/token-translator.js +++ b/lib/token-translator.js @@ -4,6 +4,51 @@ */ /* eslint no-underscore-dangle: 0 */ +/** + * @typedef {import('acorn')} acorn + * @typedef {acorn.ecmaVersion} ecmaVersion + * @typedef {import('../lib/espree').EnhancedTokTypes} EnhancedTokTypes + */ +/** + * Based on the `acorn.Token` class, but without a fixed `type` (since we need + * it to be a string). Avoiding `type` lets us make one extending interface + * more strict and another more lax. + * + * We could make `value` more strict to `string` even though the original is + * `any`. + * + * `start` and `end` are required in `acorn.Token` + * + * `loc` and `range` are from `acorn.Token` + * + * Adds `regex`. + * @typedef {{ + * value: any; + * start?: number; + * end?: number; + * loc?: acorn.SourceLocation; + * range?: [number, number]; + * regex?: {flags: string, pattern: string}; + * }} BaseEsprimaToken + * + * @typedef {{ + * type: string; + * } & BaseEsprimaToken} EsprimaToken + * + * @typedef {{ + * type: string | acorn.TokenType; + * } & BaseEsprimaToken} EsprimaTokenFlexible + * + * @typedef {{ + * jsxAttrValueToken: boolean; + * ecmaVersion: ecmaVersion; + * }} ExtraNoTokens + * + * @typedef {{ + * tokens: EsprimaTokenFlexible[] + * } & ExtraNoTokens} Extra + */ + //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ @@ -34,7 +79,7 @@ const Token = { /** * Converts part of a template into an Esprima token. - * @param {AcornToken[]} tokens The Acorn tokens representing the template. + * @param {(acorn.Token)[]} tokens The Acorn tokens representing the template. * @param {string} code The source code. * @returns {EsprimaToken} The Esprima equivalent of the template token. * @private @@ -43,19 +88,20 @@ function convertTemplatePart(tokens, code) { const firstToken = tokens[0], lastTemplateToken = tokens[tokens.length - 1]; + /** @type {EsprimaToken} */ const token = { type: Token.Template, value: code.slice(firstToken.start, lastTemplateToken.end) }; - if (firstToken.loc) { + if (firstToken.loc && lastTemplateToken.loc) { token.loc = { start: firstToken.loc.start, end: lastTemplateToken.loc.end }; } - if (firstToken.range) { + if (firstToken.range && lastTemplateToken.range) { token.start = firstToken.range[0]; token.end = lastTemplateToken.range[1]; token.range = [token.start, token.end]; @@ -66,7 +112,7 @@ function convertTemplatePart(tokens, code) { /** * Contains logic to translate Acorn tokens into Esprima tokens. - * @param {Object} acornTokTypes The Acorn token types. + * @param {EnhancedTokTypes} acornTokTypes The Acorn token types. * @param {string} code The source code Acorn is parsing. This is necessary * to correct the "value" property of some tokens. * @constructor @@ -77,6 +123,7 @@ function TokenTranslator(acornTokTypes, code) { this._acornTokTypes = acornTokTypes; // token buffer for templates + /** @type {(acorn.Token)[]} */ this._tokens = []; // track the last curly brace @@ -94,29 +141,36 @@ TokenTranslator.prototype = { * Translates a single Esprima token to a single Acorn token. This may be * inaccurate due to how templates are handled differently in Esprima and * Acorn, but should be accurate for all other tokens. - * @param {AcornToken} token The Acorn token to translate. - * @param {Object} extra Espree extra object. + * @param {acorn.Token} token The Acorn token to translate. + * @param {ExtraNoTokens} extra Espree extra object. * @returns {EsprimaToken} The Esprima version of the token. */ translate(token, extra) { const type = token.type, - tt = this._acornTokTypes; + tt = this._acornTokTypes, + + // We use an unknown type because `acorn.Token` is a class whose + // `type` property we cannot override to our desired `string`; + // this also allows us to define a stricter `EsprimaToken` with + // a string-only `type` property + unknownType = /** @type {unknown} */ (token), + newToken = /** @type {EsprimaToken} */ (unknownType); if (type === tt.name) { - token.type = Token.Identifier; + newToken.type = Token.Identifier; // TODO: See if this is an Acorn bug if (token.value === "static") { - token.type = Token.Keyword; + newToken.type = Token.Keyword; } if (extra.ecmaVersion > 5 && (token.value === "yield" || token.value === "let")) { - token.type = Token.Keyword; + newToken.type = Token.Keyword; } } else if (type === tt.privateId) { - token.type = Token.PrivateIdentifier; + newToken.type = Token.PrivateIdentifier; } else if (type === tt.semi || type === tt.comma || type === tt.parenL || type === tt.parenR || @@ -131,51 +185,51 @@ TokenTranslator.prototype = { (type.binop && !type.keyword) || type.isAssign) { - token.type = Token.Punctuator; - token.value = this._code.slice(token.start, token.end); + newToken.type = Token.Punctuator; + newToken.value = this._code.slice(token.start, token.end); } else if (type === tt.jsxName) { - token.type = Token.JSXIdentifier; + newToken.type = Token.JSXIdentifier; } else if (type.label === "jsxText" || type === tt.jsxAttrValueToken) { - token.type = Token.JSXText; + newToken.type = Token.JSXText; } else if (type.keyword) { if (type.keyword === "true" || type.keyword === "false") { - token.type = Token.Boolean; + newToken.type = Token.Boolean; } else if (type.keyword === "null") { - token.type = Token.Null; + newToken.type = Token.Null; } else { - token.type = Token.Keyword; + newToken.type = Token.Keyword; } } else if (type === tt.num) { - token.type = Token.Numeric; - token.value = this._code.slice(token.start, token.end); + newToken.type = Token.Numeric; + newToken.value = this._code.slice(token.start, token.end); } else if (type === tt.string) { if (extra.jsxAttrValueToken) { extra.jsxAttrValueToken = false; - token.type = Token.JSXText; + newToken.type = Token.JSXText; } else { - token.type = Token.String; + newToken.type = Token.String; } - token.value = this._code.slice(token.start, token.end); + newToken.value = this._code.slice(token.start, token.end); } else if (type === tt.regexp) { - token.type = Token.RegularExpression; + newToken.type = Token.RegularExpression; const value = token.value; - token.regex = { + newToken.regex = { flags: value.flags, pattern: value.pattern }; - token.value = `/${value.pattern}/${value.flags}`; + newToken.value = `/${value.pattern}/${value.flags}`; } - return token; + return newToken; }, /** * Function to call during Acorn's onToken handler. - * @param {AcornToken} token The Acorn token. - * @param {Object} extra The Espree extra object. + * @param {acorn.Token} token The Acorn token. + * @param {Extra} extra The Espree extra object. * @returns {void} */ onToken(token, extra) { diff --git a/package.json b/package.json index 419d2f63..06cffedb 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "homepage": "https://github.com/eslint/espree", "main": "dist/espree.cjs", "type": "module", + "types": "dist/espree.d.ts", "exports": { ".": [ { @@ -20,6 +21,7 @@ "files": [ "lib", "dist/espree.cjs", + "dist/espree.d.ts", "espree.js" ], "engines": { @@ -46,7 +48,6 @@ "eslint-plugin-jsdoc": "^32.2.0", "eslint-plugin-node": "^11.1.0", "eslint-release": "^3.2.0", - "esprima": "latest", "esprima-fb": "^8001.2001.0-dev-harmony-fb", "json-diff": "^0.5.4", "mocha": "^8.3.1", @@ -55,6 +56,7 @@ "rollup": "^2.41.2", "shelljs": "^0.3.0", "shelljs-nodecli": "^0.1.1", + "typescript": "^4.5.5", "unicode-6.3.0": "^0.7.5" }, "keywords": [ @@ -70,6 +72,7 @@ "unit:esm": "c8 mocha --color --reporter progress --timeout 30000 'tests/lib/**/*.js'", "unit:cjs": "mocha --color --reporter progress --timeout 30000 tests/lib/commonjs.cjs", "test": "npm-run-all -p unit lint", + "tsc": "tsc", "lint": "eslint \"*.?(c)js\" lib/ tests/lib/", "fixlint": "npm run lint -- --fix", "build": "rollup -c rollup.config.js", diff --git a/tests/lib/espree.js b/tests/lib/espree.js new file mode 100644 index 00000000..15f12ce8 --- /dev/null +++ b/tests/lib/espree.js @@ -0,0 +1,16 @@ +import assert from "assert"; +import * as acorn from "acorn"; +import espree from "../../lib/espree.js"; + +describe("espree", () => { + it("Throws upon `jsx_readString` when not using JSX", () => { + const espreeParserFactory = espree(); + const AcornParser = acorn.Parser; + const EspreeParser = espreeParserFactory(/** @type {EspreeParser} */ (AcornParser)); + const parser = new EspreeParser({}, ""); + + assert.throws(() => { + parser.jsx_readString(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..3b58f06a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "moduleResolution": "node", + "module": "esnext", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "strict": true, + "target": "es5", + "allowSyntheticDefaultImports": true, + "outFile": "dist/espree.d.ts" + }, + "include": ["espree.js", "lib/**/*.js"], + "exclude": ["node_modules"] +}