From 2c14620ba63642a4399d30fec65247cbcb407de3 Mon Sep 17 00:00:00 2001 From: fisker Cheung Date: Tue, 15 Dec 2020 14:09:44 +0800 Subject: [PATCH] Refactor: Improve JavaScript parser readability (#9783) --- cspell.json | 1 + src/language-js/parser-babel.js | 199 ++++++++++----------- src/language-js/parser-espree.js | 40 +++-- src/language-js/parser-flow.js | 48 ++--- src/language-js/parser-meriyah.js | 37 ++-- src/language-js/parser-typescript.js | 73 ++++---- src/language-js/parser/try-combinations.js | 17 ++ 7 files changed, 214 insertions(+), 201 deletions(-) create mode 100644 src/language-js/parser/try-combinations.js diff --git a/cspell.json b/cspell.json index 21333bf09..84359a44a 100644 --- a/cspell.json +++ b/cspell.json @@ -368,6 +368,7 @@ "TSAs", "tsep", "TSESTree", + "TSESTreeOptions", "TSJS", "tslib", "typeahead", diff --git a/src/language-js/parser-babel.js b/src/language-js/parser-babel.js index b26aa83b8..8a30b1bdd 100644 --- a/src/language-js/parser-babel.js +++ b/src/language-js/parser-babel.js @@ -1,5 +1,6 @@ "use strict"; +const flatten = require("lodash/flatten"); const createError = require("../common/parser-create-error"); const { getNextNonSpaceNonCommentCharacterIndexWithStartIndex, @@ -8,61 +9,46 @@ const { const { hasPragma } = require("./pragma"); const { locStart, locEnd } = require("./loc"); const postprocess = require("./parse-postprocess"); +const tryCombinations = require("./parser/try-combinations"); -function babelOptions({ sourceType, extraPlugins = [] }) { - return { - sourceType, - allowAwaitOutsideFunction: true, - allowImportExportEverywhere: true, - allowReturnOutsideFunction: true, - allowSuperOutsideMethod: true, - allowUndeclaredExports: true, - errorRecovery: true, - createParenthesizedExpressions: true, - plugins: [ - // When adding a plugin, please add a test in `tests/js/babel-plugins`, - // To remove plugins, remove it here and run `yarn test tests/js/babel-plugins` to verify - - "doExpressions", - "classProperties", - "exportDefaultFrom", - "functionBind", - "functionSent", - "classPrivateProperties", - "throwExpressions", - "classPrivateMethods", - "v8intrinsic", - "partialApplication", - ["decorators", { decoratorsBeforeExport: false }], - "privateIn", - "importAssertions", - ["recordAndTuple", { syntaxType: "hash" }], - "decimal", - "moduleStringNames", - "classStaticBlock", - ...extraPlugins, - ], - tokens: true, - ranges: true, - }; -} - -function resolvePluginsConflict( - condition, - pluginCombinations, - conflictPlugins -) { - if (!condition) { - return pluginCombinations; - } - const combinations = []; - for (const combination of pluginCombinations) { - for (const plugin of conflictPlugins) { - combinations.push([...combination, plugin]); - } - } - return combinations; -} +const parseOptions = { + sourceType: "module", + allowAwaitOutsideFunction: true, + allowImportExportEverywhere: true, + allowReturnOutsideFunction: true, + allowSuperOutsideMethod: true, + allowUndeclaredExports: true, + errorRecovery: true, + createParenthesizedExpressions: true, + plugins: [ + // When adding a plugin, please add a test in `tests/js/babel-plugins`, + // To remove plugins, remove it here and run `yarn test tests/js/babel-plugins` to verify + "doExpressions", + "classProperties", + "exportDefaultFrom", + "functionBind", + "functionSent", + "classPrivateProperties", + "throwExpressions", + "classPrivateMethods", + "v8intrinsic", + "partialApplication", + ["decorators", { decoratorsBeforeExport: false }], + "privateIn", + "importAssertions", + ["recordAndTuple", { syntaxType: "hash" }], + "decimal", + "moduleStringNames", + "classStaticBlock", + ], + tokens: true, + ranges: true, +}; +const pipelineOperatorPlugins = [ + ["pipelineOperator", { proposal: "smart" }], + ["pipelineOperator", { proposal: "minimal" }], + ["pipelineOperator", { proposal: "fsharp" }], +]; // Similar to babel // https://github.com/babel/babel/pull/7934/files#diff-a739835084910b0ee3ea649df5a4d223R67 @@ -89,47 +75,67 @@ function isFlowFile(text, options) { return FLOW_PRAGMA_REGEX.test(text); } +function parseWithOptions(parseMethod, text, options) { + // Inline the require to avoid loading all the JS if we don't use it + /** @type {import("@babel/parser").parse | import("@babel/parser").parseExpression} */ + const parse = require("@babel/parser")[parseMethod]; + const ast = parse(text, options); + // @ts-ignore + const error = ast.errors.find((error) => shouldRethrowRecoveredError(error)); + if (error) { + throw error; + } + return ast; +} + +function createParseError(error) { + // babel error prints (l:c) with cols that are zero indexed + // so we need our custom error + const { message, loc } = error; + + throw createError(message.replace(/ \(.*\)/, ""), { + start: { + line: loc ? loc.line : 0, + column: loc ? loc.column + 1 : 0, + }, + }); +} + function createParse(parseMethod, ...pluginCombinations) { + const commonPlugins = parseOptions.plugins; + pluginCombinations = pluginCombinations.length + ? pluginCombinations.map((plugins) => [...commonPlugins, ...plugins]) + : [commonPlugins]; + return (text, parsers, opts = {}) => { if (opts.parser === "babel" && isFlowFile(text, opts)) { opts.parser = "babel-flow"; return parseFlow(text, parsers, opts); } - // Inline the require to avoid loading all the JS if we don't use it - const babel = require("@babel/parser"); + let combinations = pluginCombinations; + if (text.includes("|>")) { + combinations = flatten( + pipelineOperatorPlugins.map((pipelineOperatorPlugin) => + combinations.map((plugins) => [...plugins, pipelineOperatorPlugin]) + ) + ); + } const sourceType = opts.__babelSourceType === "script" ? "script" : "module"; + const { result: ast, error } = tryCombinations( + ...combinations.map((plugins) => () => + parseWithOptions(parseMethod, text, { + ...parseOptions, + sourceType, + plugins, + }) + ) + ); - let ast; - try { - const combinations = resolvePluginsConflict( - text.includes("|>"), - pluginCombinations, - [ - ["pipelineOperator", { proposal: "smart" }], - ["pipelineOperator", { proposal: "minimal" }], - ["pipelineOperator", { proposal: "fsharp" }], - ] - ); - ast = tryCombinations( - (options) => babel[parseMethod](text, options), - combinations.map((extraPlugins) => - babelOptions({ sourceType, extraPlugins }) - ) - ); - } catch (error) { - // babel error prints (l:c) with cols that are zero indexed - // so we need our custom error - const { message, loc } = error; - - throw createError(message.replace(/ \(.*\)/, ""), { - start: { - line: loc ? loc.line : 0, - column: loc ? loc.column + 1 : 0, - }, - }); + if (!ast) { + throw createParseError(error); } return postprocess(ast, { ...opts, originalText: text }); @@ -148,20 +154,6 @@ const parseTypeScript = createParse( ); const parseExpression = createParse("parseExpression", ["jsx"]); -function tryCombinations(fn, combinations) { - let error; - for (let i = 0; i < combinations.length; i++) { - try { - return rethrowSomeRecoveredErrors(fn(combinations[i])); - } catch (_error) { - if (!error) { - error = _error; - } - } - } - throw error; -} - const messagesShouldThrow = new Set([ // TSErrors.UnexpectedTypeAnnotation // https://github.com/babel/babel/blob/008fe25ae22e78288fbc637d41069bb4a1040987/packages/babel-parser/src/plugins/typescript/index.js#L95 @@ -174,21 +166,12 @@ const messagesShouldThrow = new Set([ "Type parameters must come after the async keyword, e.g. instead of ` async () => {}`, use `async () => {}`", ]); -function shouldRethrow(error) { +function shouldRethrowRecoveredError(error) { const [, message] = error.message.match(/(.*?)\s*\(\d+:\d+\)/); // Only works for literal message return messagesShouldThrow.has(message); } -function rethrowSomeRecoveredErrors(ast) { - const error = ast.errors.find((error) => shouldRethrow(error)); - if (error) { - throw error; - } - - return ast; -} - function parseJson(text, parsers, opts) { const ast = parseExpression(text, parsers, opts); diff --git a/src/language-js/parser-espree.js b/src/language-js/parser-espree.js index ede9745cf..59d719f19 100644 --- a/src/language-js/parser-espree.js +++ b/src/language-js/parser-espree.js @@ -4,8 +4,9 @@ const createError = require("../common/parser-create-error"); const { hasPragma } = require("./pragma"); const { locStart, locEnd } = require("./loc"); const postprocess = require("./parse-postprocess"); +const tryCombinations = require("./parser/try-combinations"); -const espreeOptions = { +const parseOptions = { range: true, loc: true, comment: true, @@ -18,9 +19,20 @@ const espreeOptions = { }, }; +function createParseError(error) { + const { message, lineNumber, column } = error; + + /* istanbul ignore next */ + if (typeof lineNumber !== "number") { + throw error; + } + + throw createError(message, { start: { line: lineNumber, column } }); +} + function parse(originalText, parsers, options) { const { parse, latestEcmaVersion } = require("espree"); - espreeOptions.ecmaVersion = latestEcmaVersion; + parseOptions.ecmaVersion = latestEcmaVersion; // Replace shebang with space const shebang = getShebang(originalText); @@ -28,24 +40,14 @@ function parse(originalText, parsers, options) { ? " ".repeat(shebang.length) + originalText.slice(shebang.length) : originalText; - let ast; + const { result: ast, error: moduleParseError } = tryCombinations( + () => parse(text, { ...parseOptions, sourceType: "module" }), + () => parse(text, { ...parseOptions, sourceType: "script" }) + ); - try { - ast = parse(text, espreeOptions); - } catch (moduleError) { - try { - ast = parse(text, { ...espreeOptions, sourceType: "script" }); - } catch (_) { - // throw the error for `module` parsing - const { message, lineNumber, column } = moduleError; - - /* istanbul ignore next */ - if (typeof lineNumber !== "number") { - throw moduleError; - } - - throw createError(message, { start: { line: lineNumber, column } }); - } + if (!ast) { + // throw the error for `module` parsing + throw createParseError(moduleParseError); } return postprocess(ast, { ...options, originalText }); diff --git a/src/language-js/parser-flow.js b/src/language-js/parser-flow.js index f38494d72..e66f2e108 100644 --- a/src/language-js/parser-flow.js +++ b/src/language-js/parser-flow.js @@ -5,32 +5,36 @@ const { hasPragma } = require("./pragma"); const { locStart, locEnd } = require("./loc"); const postprocess = require("./parse-postprocess"); +const parseOptions = { + enums: true, + esproposal_decorators: true, + esproposal_class_instance_fields: true, + esproposal_class_static_fields: true, + esproposal_export_star_as: true, + esproposal_optional_chaining: true, + esproposal_nullish_coalescing: true, + tokens: true, +}; + +function createParseError(error) { + const { + message, + loc: { start, end }, + } = error; + + return createError(message, { + start: { line: start.line, column: start.column + 1 }, + end: { line: end.line, column: end.column + 1 }, + }); +} + function parse(text, parsers, opts) { // Inline the require to avoid loading all the JS if we don't use it - const flowParser = require("flow-parser"); - - const ast = flowParser.parse(text, { - enums: true, - esproposal_decorators: true, - esproposal_class_instance_fields: true, - esproposal_class_static_fields: true, - esproposal_export_star_as: true, - esproposal_optional_chaining: true, - esproposal_nullish_coalescing: true, - tokens: true, - }); - + const { parse } = require("flow-parser"); + const ast = parse(text, parseOptions); const [error] = ast.errors; if (error) { - const { - message, - loc: { start, end }, - } = error; - - throw createError(message, { - start: { line: start.line, column: start.column + 1 }, - end: { line: end.line, column: end.column + 1 }, - }); + throw createParseError(error); } return postprocess(ast, { ...opts, originalText: text }); diff --git a/src/language-js/parser-meriyah.js b/src/language-js/parser-meriyah.js index 4086ca389..960090f1e 100644 --- a/src/language-js/parser-meriyah.js +++ b/src/language-js/parser-meriyah.js @@ -4,6 +4,7 @@ const createError = require("../common/parser-create-error"); const { hasPragma } = require("./pragma"); const { locStart, locEnd } = require("./loc"); const postprocess = require("./parse-postprocess"); +const tryCombinations = require("./parser/try-combinations"); // https://github.com/meriyah/meriyah/blob/4676f60b6c149d7082bde2c9147f9ae2359c8075/src/parser.ts#L185 const parseOptions = { @@ -59,25 +60,27 @@ function parseWithOptions(text, module) { return ast; } +function createParseError(error) { + // throw the error for `module` parsing + const { message, line, column } = error; + + /* istanbul ignore next */ + if (typeof line !== "number") { + return error; + } + + return createError(message, { start: { line, column } }); +} + function parse(text, parsers, options) { - let ast; + const { result: ast, error: moduleParseError } = tryCombinations( + () => parseWithOptions(text, /* module */ true), + () => parseWithOptions(text, /* module */ false) + ); - try { - ast = parseWithOptions(text /* module */, true); - } catch (moduleError) { - try { - ast = parseWithOptions(text, /* module */ false); - } catch (_) { - // throw the error for `module` parsing - const { message, line, column } = moduleError; - - /* istanbul ignore next */ - if (typeof line !== "number") { - throw moduleError; - } - - throw createError(message, { start: { line, column } }); - } + if (!ast) { + // Throw the error for `module` parsing + throw createParseError(moduleParseError); } return postprocess(ast, { ...options, originalText: text }); diff --git a/src/language-js/parser-typescript.js b/src/language-js/parser-typescript.js index bf60d724a..c2499f940 100644 --- a/src/language-js/parser-typescript.js +++ b/src/language-js/parser-typescript.js @@ -4,30 +4,49 @@ const createError = require("../common/parser-create-error"); const { hasPragma } = require("./pragma"); const { locStart, locEnd } = require("./loc"); const postprocess = require("./parse-postprocess"); +const tryCombinations = require("./parser/try-combinations"); + +/** @type {import("@typescript-eslint/typescript-estree").TSESTreeOptions} */ +const parseOptions = { + // `jest@<=26.4.2` rely on `loc` + // https://github.com/facebook/jest/issues/10444 + loc: true, + range: true, + comment: true, + useJSXTextNode: true, + jsx: true, + tokens: true, + loggerFn: false, + project: [], +}; + +function createParseError(error) { + const { message, lineNumber, column } = error; + + /* istanbul ignore next */ + if (typeof lineNumber !== "number") { + return error; + } + + return createError(message, { + start: { line: lineNumber, column: column + 1 }, + }); +} function parse(text, parsers, opts) { const jsx = isProbablyJsx(text); - let result; - try { + + const { parseWithNodeMaps } = require("@typescript-eslint/typescript-estree"); + const { result, error: firstError } = tryCombinations( // Try passing with our best guess first. - result = tryParseTypeScript(text, jsx); - } catch (firstError) { - try { - // But if we get it wrong, try the opposite. - result = tryParseTypeScript(text, !jsx); - } catch (secondError) { - // Suppose our guess is correct, throw the first error - const { message, lineNumber, column } = firstError; + () => parseWithNodeMaps(text, { ...parseOptions, jsx }), + // But if we get it wrong, try the opposite. + () => parseWithNodeMaps(text, { ...parseOptions, jsx: !jsx }) + ); - /* istanbul ignore next */ - if (typeof lineNumber !== "number") { - throw firstError; - } - - throw createError(message, { - start: { line: lineNumber, column: column + 1 }, - }); - } + if (!result) { + // Suppose our guess is correct, throw the first error + throw createParseError(firstError); } return postprocess(result.ast, { @@ -37,22 +56,6 @@ function parse(text, parsers, opts) { }); } -function tryParseTypeScript(text, jsx) { - const { parseWithNodeMaps } = require("@typescript-eslint/typescript-estree"); - return parseWithNodeMaps(text, { - // `jest@<=26.4.2` rely on `loc` - // https://github.com/facebook/jest/issues/10444 - loc: true, - range: true, - comment: true, - useJSXTextNode: true, - jsx, - tokens: true, - loggerFn: false, - project: [], - }); -} - /** * Use a naive regular expression to detect JSX */ diff --git a/src/language-js/parser/try-combinations.js b/src/language-js/parser/try-combinations.js new file mode 100644 index 000000000..f7edd9e48 --- /dev/null +++ b/src/language-js/parser/try-combinations.js @@ -0,0 +1,17 @@ +"use strict"; + +function tryCombinations(...combinations) { + let firstError; + for (const [index, fn] of combinations.entries()) { + try { + return { result: fn() }; + } catch (error) { + if (index === 0) { + firstError = error; + } + } + } + return { error: firstError }; +} + +module.exports = tryCombinations;