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..c888a456 100644 --- a/espree.js +++ b/espree.js @@ -56,6 +56,57 @@ */ /* eslint no-undefined:0, no-use-before-define: 0 */ +// ---------------------------------------------------------------------------- +// Types exported from file +// ---------------------------------------------------------------------------- +/** + * `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?: acorn.ecmaVersion, + * sourceType?: "script"|"module"|"commonjs", + * ecmaFeatures?: { + * jsx?: boolean, + * globalReturn?: boolean, + * impliedStrict?: boolean + * }, + * range?: boolean, + * loc?: boolean, + * tokens?: boolean | null, + * comment?: boolean, + * } & jsx.Options} ParserOptions + */ + +// ---------------------------------------------------------------------------- +// Local type imports +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {import('acorn')} acorn + * @typedef {typeof import('acorn-jsx').AcornJsxParser} AcornJsxParser + * @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 +117,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(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 +172,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 +195,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 +219,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..7e92d139 100644 --- a/lib/espree.js +++ b/lib/espree.js @@ -2,27 +2,138 @@ 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 {typeof import('acorn-jsx').tokTypes} tokTypesType + * @typedef {typeof import('acorn-jsx').AcornJsxParser} AcornJsxParser + * @typedef {import('../espree').ParserOptions} ParserOptions + */ + +// ---------------------------------------------------------------------------- +// Local types +// ---------------------------------------------------------------------------- +/** + * @local + * + * @typedef {acorn.ecmaVersion} ecmaVersion + * + * @typedef {{ + * generator?: boolean + * } & acorn.Node} EsprimaNode + */ +/** + * Suggests an integer + * @local + * @typedef {number} int + */ +/** + * First three properties as in `acorn.Comment`; next two as in `acorn.Comment` + * but optional. Last is different as has to allow `undefined` + * @local + * + * @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 + */ +/** + * 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 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. + * 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 +151,378 @@ 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 {AcornJsxParser} 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 */ + + /** @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 + /** + * 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]; - if (program.range) { - program.range[0] = firstNode.range[0]; + const program = /** @type {EsprimaProgramNode} */ (super.parse()); + + 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; + } - 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); - parseTopLevel(node) { - if (this[STATE].impliedStrict) { - this.strict = true; + /** @type {EnhancedSyntaxError} */ + const err = new SyntaxError(message); + + 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 {number} */ (this.lineStart)) { - if (this.options.locations) { - while (this.pos < this.lineStart) { - this.lineStart = this.input.lastIndexOf("\n", this.lineStart - 2) + 1; - --this.curLine; + /** @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..20ab23b8 100644 --- a/lib/options.js +++ b/lib/options.js @@ -3,6 +3,38 @@ * @author Kai Cataldo */ +// ---------------------------------------------------------------------------- +// Local type imports +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {import('../espree').ParserOptions} ParserOptions + */ + +// ---------------------------------------------------------------------------- +// Local types +// ---------------------------------------------------------------------------- +/** + * @local + * @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 + */ + //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ @@ -38,7 +70,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 +97,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 +115,14 @@ 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); + + /** @type {"script"|"module"} */ 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..a88f5e4f 100644 --- a/lib/token-translator.js +++ b/lib/token-translator.js @@ -4,6 +4,60 @@ */ /* eslint no-underscore-dangle: 0 */ +// ---------------------------------------------------------------------------- +// Local type imports +// ---------------------------------------------------------------------------- +/** + * @local + * @typedef {import('acorn')} acorn + * @typedef {import('../lib/espree').EnhancedTokTypes} EnhancedTokTypes + */ + +// ---------------------------------------------------------------------------- +// 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 {{ + * type: string; + * } & BaseEsprimaToken} EsprimaToken + * + * @typedef {{ + * type: string | acorn.TokenType; + * } & BaseEsprimaToken} EsprimaTokenFlexible + * + * @typedef {{ + * jsxAttrValueToken: boolean; + * ecmaVersion: acorn.ecmaVersion; + * }} ExtraNoTokens + * + * @typedef {{ + * tokens: EsprimaTokenFlexible[] + * } & ExtraNoTokens} Extra + */ + //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ @@ -34,7 +88,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 +97,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 +121,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 +132,7 @@ function TokenTranslator(acornTokTypes, code) { this._acornTokTypes = acornTokTypes; // token buffer for templates + /** @type {(acorn.Token)[]} */ this._tokens = []; // track the last curly brace @@ -94,29 +150,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 +194,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 78005f5c..72b6e17e 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": { @@ -31,26 +33,33 @@ }, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.7.0", - "acorn-jsx": "^5.3.1", + "acorn": "^8.7.1", + "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.3.0" }, "devDependencies": { + "@es-joy/escodegen": "^3.5.1", + "@es-joy/jsdoc-eslint-parser": "^0.16.0", + "@es-joy/jsdoccomment": "^0.29.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.2", "chai": "^4.3.6", - "eslint": "^8.13.0", + "eslint": "^8.14.0", "eslint-config-eslint": "^7.0.0", - "eslint-plugin-jsdoc": "^39.2.4", + "eslint-plugin-jsdoc": "^39.2.9", "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.5.5" }, "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/intermediate-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..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/tools/intermediate-js-for-ts.js b/tools/intermediate-js-for-ts.js new file mode 100644 index 00000000..595e90a3 --- /dev/null +++ b/tools/intermediate-js-for-ts.js @@ -0,0 +1,295 @@ +import { readFile, writeFile, mkdir } from "fs/promises"; +import { join, dirname } from "path"; + +import esquery from "esquery"; +import { globby } from "globby"; + +import * as jsdocEslintParser from "@es-joy/jsdoc-eslint-parser/typescript.js"; +import { + estreeToString, jsdocVisitorKeys, jsdocTypeVisitorKeys +} from "@es-joy/jsdoccomment"; + +import * as escodegen from "@es-joy/escodegen"; + +import { builders } from "ast-types"; + +const { + _preprocess_include: include, + _preprocess_exclude: ignoreFiles +} = JSON.parse(await readFile("tsconfig.json")); + +const files = await globby(include, { + ignoreFiles +}); + +await Promise.all(files.map(async file => { + const contents = await readFile(file, "utf8"); + + const tree = jsdocEslintParser.parseForESLint(contents, { + mode: "typescript", + throwOnTypeParsingErrors: true + }); + + const { visitorKeys, ast } = tree; + + const typedefSiblingsOfLocal = "JsdocTag[tag=local] ~ JsdocTag[tag=typedef]"; + const typedefs = esquery.query(ast, typedefSiblingsOfLocal, { + visitorKeys + }); + + // Replace type shorthands with our typedef long form + typedefs.forEach(({ name, parsedType }) => { + const nameNodes = esquery.query(ast, `JsdocTypeName[value=${name}]`, { + visitorKeys + }); + + // Rather than go to the trouble of splicing from a child whose index + // we have to work to find, just copy the keys to the existing object + nameNodes.forEach(nameNode => { + Object.keys(nameNode).forEach(prop => { + if (prop === "parent") { + return; + } + delete nameNode[prop]; + }); + Object.entries(parsedType).forEach(([prop, val]) => { + if (prop === "parent") { + return; + } + nameNode[prop] = val; + }); + }); + }); + + // Remove local typedefs from AST + for (const typedef of typedefs) { + const { tags } = typedef.parent; + const idx = tags.indexOf(typedef); + + tags.splice(idx, 1); + } + + // Now remove the empty locals + const emptyLocals = esquery.query(ast, "JsdocBlock:has(JsdocTag:not([tag!=local]))", { + visitorKeys + }); + + for (const emptyLocal of emptyLocals) { + const idx = ast.jsdocBlocks.indexOf(emptyLocal); + + ast.jsdocBlocks.splice(idx, 1); + } + + const exportBlocks = esquery.query(ast, "JsdocBlock:has(JsdocTag[tag=export])", { + visitorKeys + }); + + /** + * Build a JSDoc type cast. + * @param {Object} extraInfo Extra type info + * @returns {JsdocBlock} The JsdocBlock object + */ + function typeCast(extraInfo) { + return { + type: "JsdocBlock", + initial: "", + delimiter: "/**", + postDelimiter: "", + terminal: "*/", + descriptionLines: [], + tags: [ + { + type: "JsdocTag", + tag: "type", + postTag: " ", + descriptionLines: [], + ...extraInfo, + postType: "", + initial: "", + delimiter: "", + postDelimiter: " " + } + ] + }; + } + + for (const exportBlock of exportBlocks) { + switch (exportBlock.parent.type) { + case "ReturnStatement": { + const parent = exportBlock.parent.argument; + + switch (parent.type) { + case "ClassExpression": { + const classBody = parent.body.body.map(({ + type, kind, key, value, computed, + static: statik + }) => { + if (computed) { + return null; + } + const { jsdoc } = value.parent; + + switch (type) { + case "MethodDefinition": { + const returns = jsdoc.tags.find( + tag => tag.tag === "returns" + ); + const objectExpressionOrOther = !returns || returns.rawType === "void" || + kind === "constructor" + ? builders.identifier("undefined") + : builders.objectExpression([]); + + if (kind !== "constructor") { + objectExpressionOrOther.jsdoc = typeCast({ + parsedType: returns.parsedType + }); + } + + const paramNames = jsdoc.tags.filter( + tag => tag.tag === "param" + ).map( + // eslint-disable-next-line arrow-body-style + tag => { + const identifier = builders.identifier(tag.name); + + // Hack in some needed type casts + 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" + }); + } + return identifier; + } + ); + const functionExpression = builders.functionExpression( + null, + + paramNames, + + builders.blockStatement([ + kind === "constructor" + ? builders.expressionStatement( + builders.callExpression( + builders.super(), + paramNames + ) + ) + : builders.returnStatement( + objectExpressionOrOther + ) + ]) + ); + + const methodDefinition = builders.methodDefinition( + kind, + key, + functionExpression, + statik + ); + + methodDefinition.jsdoc = jsdoc; + + return methodDefinition; + } default: + throw new Error(`Unknown ${type}`); + } + }).filter(Boolean); + + let superClass = parent.superClass.name; + + // Since we're not tracking types as in using a + // proper TS transformer (like `ttypescript`?), + // we hack this one for now + if (parent.superClass.name === "Parser") { + + // Make import available + ast.body.unshift( + builders.importDeclaration( + [ + builders.importNamespaceSpecifier( + builders.identifier("acorn") + ) + ], + builders.literal("acorn"), + "value" + ) + ); + superClass = "acorn.Parser"; + } + + const classDeclaration = builders.classDeclaration( + builders.identifier(parent.id.name), + builders.classBody(classBody), + + builders.identifier(superClass) + ); + + ast.body.push(builders.exportNamedDeclaration( + classDeclaration + )); + + break; + } default: + throw new Error(`Unsupported type ${parent.type}`); + } + + break; + } default: + throw new Error("Currently unsupported AST export structure"); + } + } + + const generated = escodegen.generate(ast, { + sourceContent: contents, + codegenFactory() { + const { CodeGenerator } = escodegen; + + Object.keys(jsdocVisitorKeys).forEach(method => { + CodeGenerator.Statement[method] = + CodeGenerator.prototype[method] = node => + + // We have to add our own line break, as `jsdoccomment` (nor + // `comment-parser`) keep track of trailing content + (( + node.endLine ? "\n" : "" + ) + estreeToString(node) + + (node.endLine ? `\n${node.initial}` : " ")); + }); + + Object.keys(jsdocTypeVisitorKeys).forEach(method => { + CodeGenerator.Statement[method] = + CodeGenerator.prototype[method] = node => + estreeToString(node); + }); + + return new CodeGenerator(); + } + }); + + const targetFile = join("tmp", file); + + await mkdir(dirname(targetFile), { recursive: true }); + await writeFile(targetFile, generated); +})); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..fd102ce3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "moduleResolution": "node", + "module": "esnext", + "allowJs": true, + "checkJs": true, + "noEmit": false, + "declaration": true, + "declarationMap": 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"] +}