diff --git a/.eslintignore b/.eslintignore index e269a9ac..e413f06b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ /tests/fixtures /dist tools/create-test-example.js +tmp diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 10c67363..610913e1 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,7 +12,7 @@ module.exports = { } }, parserOptions: { - ecmaVersion: 2020, + ecmaVersion: 2022, sourceType: "module" }, overrides: [ @@ -34,5 +34,10 @@ module.exports = { "no-console": "off" } } - ] + ], + rules: { + "jsdoc/check-tag-names": ["error", { + definedTags: ["local", "export"] + }] + } }; diff --git a/.gitignore b/.gitignore index c9e42c6d..3c0be30d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ _test.js .eslint-release-info.json yarn.lock package-lock.json +tmp diff --git a/espree.js b/espree.js index 71e3d47d..35b49250 100644 --- a/espree.js +++ b/espree.js @@ -56,6 +56,68 @@ */ /* eslint no-undefined:0, no-use-before-define: 0 */ +// ---------------------------------------------------------------------------- +// Types exported from file +// ---------------------------------------------------------------------------- +/** + * @typedef {3|5|6|7|8|9|10|11|12|13|2015|2016|2017|2018|2019|2020|2021|2022|'latest'} ecmaVersion + */ + +/** + * @typedef {import('./lib/token-translator').EsprimaToken} EspreeToken + */ + +/** + * @typedef {import('./lib/espree').EsprimaComment} EspreeComment + */ + +/** + * @typedef {{ + * comments?: EspreeComment[] + * } & EspreeToken[]} EspreeTokens + */ + +/** + * `jsx.Options` gives us 2 optional properties, so extend it + * + * `allowReserved`, `ranges`, `locations`, `allowReturnOutsideFunction`, + * `onToken`, and `onComment` are as in `acorn.Options` + * + * `ecmaVersion` currently 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, + * ecmaVersion?: ecmaVersion, + * sourceType?: "script"|"module"|"commonjs", + * ecmaFeatures?: { + * jsx?: boolean, + * globalReturn?: boolean, + * impliedStrict?: boolean + * }, + * range?: boolean, + * loc?: boolean, + * tokens?: boolean, + * comment?: boolean, + * }} ParserOptions + */ + +// ---------------------------------------------------------------------------- +// Local type imports +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {import('acorn')} acorn + * @typedef {import('./lib/espree').EnhancedSyntaxError} EnhancedSyntaxError + * @typedef {typeof import('./lib/espree').EspreeParser} IEspreeParser + */ + import * as acorn from "acorn"; import jsx from "acorn-jsx"; import espree from "./lib/espree.js"; @@ -66,23 +128,54 @@ 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 = /** @type {unknown} */ (espree()); + + this._regular = /** @type {IEspreeParser} */ ( + acorn.Parser.extend( + + /** @type {(BaseParser: typeof acorn.Parser) => typeof acorn.Parser} */ + (espreeParserFactory) + ) + ); } 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 = /** @type {unknown} */ (espree()); + const jsxFactory = jsx(); + + this._jsx = /** @type {IEspreeParser} */ ( + acorn.Parser.extend( + jsxFactory, + + /** @type {(BaseParser: typeof acorn.Parser) => typeof acorn.Parser} */ + (espreeParserFactory) + ) + ); } 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 +194,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 {EspreeTokens} An array of tokens. + * @throws {EnhancedSyntaxError} If the input code is invalid. * @private */ export function tokenize(code, options) { @@ -114,7 +207,7 @@ export function tokenize(code, options) { options = Object.assign({}, options, { tokens: true }); // eslint-disable-line no-param-reassign } - return new Parser(options, code).tokenize(); + return /** @type {EspreeTokens} */ (new Parser(options, code).tokenize()); } //------------------------------------------------------------------------------ @@ -124,9 +217,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 +241,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..425e8025 100644 --- a/lib/espree.js +++ b/lib/espree.js @@ -2,27 +2,140 @@ import TokenTranslator from "./token-translator.js"; import { normalizeOptions } from "./options.js"; - const STATE = Symbol("espree's internal state"); const ESPRIMA_FINISH_NODE = Symbol("espree's esprimaFinishNode"); +// ---------------------------------------------------------------------------- +// Types exported from file +// ---------------------------------------------------------------------------- +/** + * @typedef {{ + * index?: number; + * lineNumber?: number; + * column?: number; + * } & SyntaxError} EnhancedSyntaxError + */ + +// We add `jsxAttrValueToken` ourselves. +/** + * @typedef {{ + * jsxAttrValueToken?: acorn.TokenType; + * } & tokTypesType} EnhancedTokTypes + */ + +// ---------------------------------------------------------------------------- +// Local type imports +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {import('acorn')} acorn + * @typedef {import('acorn-jsx').TokTypes} tokTypesType + * @typedef {import('acorn-jsx').AcornJsxParserCtor} AcornJsxParserCtor + * @typedef {import('../espree').ParserOptions} ParserOptions + * @typedef {import('../espree').ecmaVersion} ecmaVersion + */ + +// ---------------------------------------------------------------------------- +// Local types +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {{ + * generator?: boolean + * } & acorn.Node} EsprimaNode + */ +/** + * Suggests an integer + * @local + * @typedef {number} int + */ + +/** + * @typedef {{ + * type: string, + * value: string, + * range?: [number, number], + * start?: number, + * end?: number, + * loc?: { + * start: acorn.Position | undefined, + * end: acorn.Position | undefined + * } + * }} EsprimaComment + */ /** - * Converts an Acorn comment to a Esprima comment. - * @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. + * First two properties as in `acorn.Comment`; next two as in `acorn.Comment` + * but optional. Last is different as has to allow `undefined` + */ +/** + * @local + * + * @typedef {import('../espree').EspreeTokens} 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?: import('./token-translator').EsprimaToken[]; + * body: acorn.Node[]; + * } & acorn.Node} EsprimaProgramNode + */ +/** + * Converts an Acorn comment to an Esprima comment. + * + * - block True if it's a block comment, false if not. + * - text The text of the comment. + * - start The index at which the comment starts. + * - end The index at which the comment ends. + * - startLoc The location at which the comment starts. + * - endLoc The location at which the comment ends. + * @local + * @typedef {( + * block: boolean, + * text: string, + * start: int, + * end: int, + * startLoc: acorn.Position | undefined, + * endLoc: acorn.Position | undefined + * ) => EsprimaComment | void} AcornToEsprimaCommentConverter + */ + +// ---------------------------------------------------------------------------- +// Utilities +// ---------------------------------------------------------------------------- +/** + * 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 +153,376 @@ function convertAcornCommentToEsprimaComment(block, text, start, end, startLoc, return comment; } -export default () => Parser => { - const tokTypes = Object.assign({}, Parser.acorn.tokTypes); - - if (Parser.acornJsx) { - Object.assign(tokTypes, Parser.acornJsx.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); - } +// ---------------------------------------------------------------------------- +// Exports +// ---------------------------------------------------------------------------- +/* eslint-disable arrow-body-style -- Need to supply formatted JSDoc for type info */ +export default () => { + + /** + * Returns the Espree parser. + * @param {AcornJsxParserCtor} Parser The Acorn parser + * @returns {typeof EspreeParser} The Espree parser + */ + return Parser => { + const tokTypes = /** @type {EnhancedTokTypes} */ (Object.assign({}, Parser.acorn.tokTypes)); + + 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 no-shadow -- Using first class as type */ + /** + * @export + */ + return class EspreeParser extends Parser { + /* eslint-enable no-shadow -- Using first class as type */ + /* 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 */ + + const newOpts = (typeof opts !== "object" || opts === null) + ? {} + : opts; + + const codeString = typeof code === "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 + /** + * Handler for receiving a token + * @param {acorn.Token} token The token + * @returns {void} + */ + onToken: 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); + + const comments = /** @type {EsprimaComment[]} */ (this[STATE].comments); + + 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()); - if (program.range) { - program.range[0] = firstNode.range[0]; + program.sourceType = extra.originalSourceType; + + 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; + } + + if (templateElement.loc) { + templateElement.loc.start.column += startOffset; + templateElement.loc.end.column += endOffset; + } + }); - templateElement.start += startOffset; - templateElement.end += endOffset; + return program; + } - if (templateElement.range) { - templateElement.range[0] += startOffset; - templateElement.range[1] += 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); + } - if (templateElement.loc) { - templateElement.loc.start.column += startOffset; - templateElement.loc.end.column += endOffset; - } - }); + /** + * 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); - return program; - } + /** @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 (pos !== null && pos !== void 0) { - this.pos = pos; + if (this.options.locations) { + while (this.pos < /** @type {int} */ (this.lineStart)) { - if (this.options.locations) { - while (this.pos < this.lineStart) { - this.lineStart = this.input.lastIndexOf("\n", this.lineStart - 2) + 1; - --this.curLine; + /** @type {int} */ + this.lineStart = this.input.lastIndexOf("\n", /** @type {int} */ (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"); + } + 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; - } - /** - * 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..679c0167 100644 --- a/lib/options.js +++ b/lib/options.js @@ -3,6 +3,39 @@ * @author Kai Cataldo */ +// ---------------------------------------------------------------------------- +// Local type imports +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {import('../espree').ParserOptions} ParserOptions + * @typedef {import('../espree').ecmaVersion} ecmaVersion + */ + +// ---------------------------------------------------------------------------- +// Local types +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {{ + * ecmaVersion: ecmaVersion, + * 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, + * comment?: boolean + * }} NormalizedParserOptions + */ + //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ @@ -38,7 +71,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 +98,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") { @@ -83,12 +116,13 @@ function normalizeSourceType(sourceType = "script") { /** * 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); + const sourceType = normalizeSourceType(options.sourceType); const ranges = options.range === true; const locations = options.loc === true; @@ -98,6 +132,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..b40c85c0 100644 --- a/lib/token-translator.js +++ b/lib/token-translator.js @@ -4,6 +4,61 @@ */ /* eslint no-underscore-dangle: 0 */ +// ---------------------------------------------------------------------------- +// Local type imports +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {import('acorn')} acorn + * @typedef {import('./espree').EnhancedTokTypes} EnhancedTokTypes + * @typedef {import('../espree').ecmaVersion} ecmaVersion + */ + +// ---------------------------------------------------------------------------- +// Local types +// ---------------------------------------------------------------------------- +/** + * 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`. + */ +/** + * @local + * + * @typedef {{ + * value: any; + * start?: number; + * end?: number; + * loc?: acorn.SourceLocation; + * range?: [number, number]; + * regex?: {flags: string, pattern: string}; + * }} BaseEsprimaToken + * + * @typedef {{ + * jsxAttrValueToken: boolean; + * ecmaVersion: ecmaVersion; + * }} ExtraNoTokens + * + * @typedef {{ + * tokens: EsprimaToken[] + * } & ExtraNoTokens} Extra + */ + +/** + * @typedef {{ + * type: string; + * } & BaseEsprimaToken} EsprimaToken + */ + //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ @@ -34,7 +89,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 +98,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]; @@ -64,59 +120,65 @@ function convertTemplatePart(tokens, code) { return token; } -/** - * Contains logic to translate Acorn tokens into Esprima tokens. - * @param {Object} 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 - */ -function TokenTranslator(acornTokTypes, code) { +class TokenTranslator { - // token types - this._acornTokTypes = acornTokTypes; + /** + * Contains logic to translate Acorn tokens into Esprima tokens. + * @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(acornTokTypes, code) { - // token buffer for templates - this._tokens = []; + // token types + this._acornTokTypes = acornTokTypes; - // track the last curly brace - this._curlyBrace = null; + // token buffer for templates + /** @type {acorn.Token[]} */ + this._tokens = []; - // the source code - this._code = code; + // track the last curly brace + this._curlyBrace = null; -} + // the source code + this._code = code; -TokenTranslator.prototype = { - constructor: TokenTranslator, + } /** - * Translates a single Esprima token to a single Acorn token. This may be + * Translates a single Acorn token to a single Esprima 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 +193,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) { @@ -256,7 +318,7 @@ TokenTranslator.prototype = { tokens.push(this.translate(token, extra)); } -}; +} //------------------------------------------------------------------------------ // Public diff --git a/package.json b/package.json index c4257e32..bc401429 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": { ".": [ { @@ -19,7 +20,7 @@ "version": "9.3.1", "files": [ "lib", - "dist/espree.cjs", + "dist", "espree.js" ], "engines": { @@ -36,21 +37,29 @@ "eslint-visitor-keys": "^3.3.0" }, "devDependencies": { + "@es-joy/escodegen": "^3.5.1", + "@es-joy/js2ts-assistant": "^0.2.0", + "@es-joy/jsdoc-eslint-parser": "^0.17.0", + "@es-joy/jsdoccomment": "^0.30.0", "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.2.0", - "c8": "^7.11.0", + "ast-types": "^0.14.2", + "c8": "^7.11.3", "chai": "^4.3.6", - "eslint": "^8.13.0", + "eslint": "^8.15.0", "eslint-config-eslint": "^7.0.0", - "eslint-plugin-jsdoc": "^39.2.4", + "eslint-plugin-jsdoc": "^39.3.0", "eslint-plugin-node": "^11.1.0", "eslint-release": "^3.2.0", "esprima-fb": "^8001.2001.0-dev-harmony-fb", + "esquery": "^1.4.0", + "globby": "^13.1.1", "mocha": "^9.2.2", "npm-run-all": "^4.1.5", "rollup": "^2.41.2", - "shelljs": "^0.3.0" + "shelljs": "^0.3.0", + "typescript": "^4.6.4" }, "keywords": [ "ast", @@ -65,6 +74,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 .", "fixlint": "npm run lint -- --fix", "build": "rollup -c rollup.config.js", @@ -72,6 +82,7 @@ "pretest": "npm run build", "prepublishOnly": "npm run update-version && npm run build", "sync-docs": "node sync-docs.js", + "js-for-ts": "node tools/js-for-ts.js", "generate-release": "eslint-generate-release", "generate-alpharelease": "eslint-generate-prerelease alpha", "generate-betarelease": "eslint-generate-prerelease beta", diff --git a/tests/lib/espree.js b/tests/lib/espree.js new file mode 100644 index 00000000..487e8761 --- /dev/null +++ b/tests/lib/espree.js @@ -0,0 +1,29 @@ +/** + * @fileoverview Tests for EspreeParser. + * @author Brett Zamir + */ + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +import assert from "assert"; +import * as acorn from "acorn"; +import espree from "../../lib/espree.js"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe("espree", () => { + it("Throws upon `jsx_readString` call 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/tools/js-for-ts.js b/tools/js-for-ts.js new file mode 100644 index 00000000..88a0292e --- /dev/null +++ b/tools/js-for-ts.js @@ -0,0 +1,81 @@ +/** + * @fileoverview Tool to prepare JavaScript (+JSDoc) for TypeScript, inlining + * `@local`-marked `@typedef`'s, and building a faux class for `@export`-marked + * classes so the type can be exported out of a given file. + * @author Brett Zamir + */ + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +import js2tsAssistant from "@es-joy/js2ts-assistant"; + +// ---------------------------------------------------------------------------- +// Modify output +// ---------------------------------------------------------------------------- + +await js2tsAssistant({ + customClassHandling({ + ast, builders, superClassName + }) { + + // Since we're not tracking types as in using a proper TS transformer + // (like `ttypescript`?), we hack this one for now; for generating + // our dummy version of the private + // `class EspreeParser extends Parser`, we ensure `acorn` exists to be + // imported and that we extend from a reference accessible at the root + // level of the module + if (superClassName === "Parser") { + + // Make import available + ast.body.unshift( + builders.importDeclaration( + [ + builders.importNamespaceSpecifier( + builders.identifier("acorn") + ) + ], + builders.literal("acorn"), + "value" + ) + ); + return "acorn.Parser"; + } + return null; + }, + customParamHandling({ + tag, identifier, typeCast + }) { + + // Since the super class is more restrictive, we hack in some type + // casts which would be difficult for `js2tsAssistant` to auto-detect + if (tag.name === "opts") { + identifier.jsdoc = typeCast({ + typeLines: [ + { + type: "JsdocTypeLine", + initial: "", + delimiter: "", + postDelimiter: "", + rawType: "acorn.Options" + } + ], + rawType: "acorn.Options" + }); + } else if (tag.name === "code") { + identifier.jsdoc = typeCast({ + typeLines: [ + { + type: "JsdocTypeLine", + initial: "", + delimiter: "", + postDelimiter: "", + rawType: "string" + } + ], + rawType: "string" + }); + } + } +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..df1b84f4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "moduleResolution": "node", + "module": "esnext", + "allowJs": true, + "checkJs": true, + "noEmit": false, + "declaration": true, + "declarationMap": true, + "allowSyntheticDefaultImports": true, + "emitDeclarationOnly": true, + "strict": true, + "target": "es5", + "outDir": "dist" + }, + "_preprocess_include": ["espree.js", "lib/**/*.js"], + "_preprocess_exclude": ["node_modules"], + "include": ["tmp/espree.js", "tmp/lib/**/*.js"], + "exclude": ["node_modules"] +}