import Parser from 'tree-sitter' import TS from 'tree-sitter-typescript' let parser = new Parser() parser.setLanguage(TS.typescript) const treesitter = String.raw // Extract `plugins` property of the object export for both ESM and CJS files const PLUGINS_QUERY = new Parser.Query( TS.typescript, treesitter` ; export default {} (export_statement value: [ (satisfies_expression (object (pair key: (property_identifier) @_name (#eq? @_name "plugins") value: (array) @imports ) )) value: (as_expression (object (pair key: (property_identifier) @_name (#eq? @_name "plugins") value: (array) @imports ) )) value: (object (pair key: (property_identifier) @_name (#eq? @_name "plugins") value: (array) @imports ) ) ] ) ; module.exports = {} (expression_statement (assignment_expression left: (member_expression) @left (#eq? @left "module.exports") right: [ (satisfies_expression (object (pair key: (property_identifier) @_name (#eq? @_name "plugins") value: (array) @imports ) )) (as_expression (object (pair key: (property_identifier) @_name (#eq? @_name "plugins") value: (array) @imports ) )) (object (pair key: (property_identifier) @_name (#eq? @_name "plugins") value: (array) @imports ) ) ] ) ) `, ) // Extract require() calls, as well as identifiers with options or require() // with options const PLUGIN_CALL_OPTIONS_QUERY = new Parser.Query( TS.typescript, treesitter` (call_expression function: [ (call_expression function: (identifier) @_name (#eq? @_name "require") arguments: (arguments (string (string_fragment) @module_string) ) ) (identifier) @module_identifier ] arguments: [ (arguments (object (pair key: [ (property_identifier) @property (string (string_fragment) @property) ] value: [ (string (string_fragment) @str_value) (template_string . (string_fragment) @str_value ; If the template string has more than exactly one string ; fragment at the top, the migration should bail. _ @error ) (number) @num_value (true) @true_value (false) @false_value (null) @null_value (array [ (string (string_fragment) @str_value) (template_string (string_fragment) @str_value) (number) @num_value (true) @true_value (false) @false_value (null) @null_value ]) @array_value ] ) ) ) (arguments) @_empty_args (#eq? @_empty_args "()") ] ) (call_expression function: (identifier) @_name (#eq? @_name "require") arguments: (arguments (string (string_fragment) @module_string) ) ) `, ) export type StaticPluginOptions = Record< string, | string | number | boolean | null | string | number | boolean | null | Array > export function findStaticPlugins(source: string): [string, null | StaticPluginOptions][] | null { try { let tree = parser.parse(source) let root = tree.rootNode let imports = extractStaticImportMap(source) let captures = PLUGINS_QUERY.matches(root) let plugins: [string, null | StaticPluginOptions][] = [] for (let match of captures) { for (let capture of match.captures) { if (capture.name !== 'imports') continue for (let pluginDefinition of capture.node.children) { if ( pluginDefinition.type === '[' || pluginDefinition.type === ']' || pluginDefinition.type === ',' ) continue switch (pluginDefinition.type) { case 'identifier': let source = imports[pluginDefinition.text] if (!source || source.export !== null) { return null } plugins.push([source.module, null]) break case 'string': plugins.push([pluginDefinition.children[1].text, null]) break case 'call_expression': let matches = PLUGIN_CALL_OPTIONS_QUERY.matches(pluginDefinition) if (matches.length === 0) return null let moduleName: string | null = null let moduleIdentifier: string | null = null let options: StaticPluginOptions | null = null let lastProperty: string | null = null let captures = matches.flatMap((m) => m.captures) for (let i = 0; i < captures.length; i++) { let capture = captures[i] switch (capture.name) { case 'module_identifier': { moduleIdentifier = capture.node.text break } case 'module_string': { moduleName = capture.node.text break } case 'property': { if (lastProperty !== null) return null lastProperty = capture.node.text break } case 'str_value': case 'num_value': case 'null_value': case 'true_value': case 'false_value': { if (lastProperty === null) return null options ??= {} options[lastProperty] = extractValue(capture) lastProperty = null break } case 'array_value': { if (lastProperty === null) return null options ??= {} // Loop over all captures after this one that are on the // same property (it will be one match for any array // element) let array: Array = [] let lastConsumedIndex = i arrayLoop: for (let j = i + 1; j < captures.length; j++) { let innerCapture = captures[j] switch (innerCapture.name) { case 'property': { if (innerCapture.node.text !== lastProperty) { break arrayLoop } break } case 'str_value': case 'num_value': case 'null_value': case 'true_value': case 'false_value': { array.push(extractValue(innerCapture)) lastConsumedIndex = j } } } i = lastConsumedIndex options[lastProperty] = array lastProperty = null break } case '_name': case '_empty_args': break default: return null } } if (lastProperty !== null) return null if (moduleIdentifier !== null) { let source = imports[moduleIdentifier] if (!source || (source.export !== null && source.export !== '*')) { return null } moduleName = source.module } if (moduleName === null) { return null } plugins.push([moduleName, options]) break default: return null } } } } return plugins } catch (error: any) { error(`${error?.message ?? error}`, { prefix: '↳ ' }) return null } } // Extract all top-level imports for both ESM and CJS files const IMPORT_QUERY = new Parser.Query( TS.typescript, treesitter` ; ESM import (import_statement (import_clause (identifier)? @default (named_imports (import_specifier name: (identifier) @imported-name alias: (identifier)? @imported-alias ) )? (namespace_import (identifier) @imported-namespace)? ) (string (string_fragment) @imported-from) ) ; CJS require (variable_declarator name: (identifier)? @default name: (object_pattern (shorthand_property_identifier_pattern)? @imported-name (pair_pattern key: (property_identifier) @imported-name value: (identifier) @imported-alias )? (rest_pattern (identifier) @imported-namespace )? )? value: (call_expression function: (identifier) @_fn (#eq? @_fn "require") arguments: (arguments (string (string_fragment) @imported-from ) ) ) ) `, ) export function extractStaticImportMap(source: string) { let tree = parser.parse(source) let root = tree.rootNode let captures = IMPORT_QUERY.matches(root) let imports: Record = {} for (let match of captures) { let toImport: { name: string; export: null | string }[] = [] let from = '' for (let i = 0; i < match.captures.length; i++) { let capture = match.captures[i] switch (capture.name) { case 'default': toImport.push({ name: capture.node.text, export: null }) break case 'imported-name': toImport.push({ name: capture.node.text, export: capture.node.text }) break case 'imported-from': from = capture.node.text break case 'imported-namespace': toImport.push({ name: capture.node.text, export: '*' }) break case 'imported-alias': if (toImport.length < 1) { throw new Error('Unexpected alias: ' + JSON.stringify(captures, null, 2)) } let prevImport = toImport[toImport.length - 1] let name = prevImport.name prevImport.export = name prevImport.name = capture.node.text break } } for (let { name, export: exportSource } of toImport) { imports[name] = { module: from, export: exportSource } } } return imports } function extractValue(capture: { name: string; node: { text: string } }) { return capture.name === 'num_value' ? parseFloat(capture.node.text) : capture.name === 'null_value' ? null : capture.name === 'true_value' ? true : capture.name === 'false_value' ? false : capture.node.text }