import Parser from 'tree-sitter'
import TS from 'tree-sitter-typescript'
let parser = new Parser()
parser.setLanguage(TS.typescript)
const treesitter = String.raw
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
)
)
]
)
)
`,
)
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<string | number | boolean | null>
>
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 ??= {}
let array: Array<string | number | boolean | null> = []
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
}
}
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<string, { module: string; export: string | null }> = {}
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
}