tailwindcss/packages/@tailwindcss-upgrade/src/utils/extract-static-plugins.ts
Robin Malfait 93f9c99027
Improve robustness when upgrading (#15038)
This PR improves the robustness when running the upgrade script.

Right now when you run it and if you run into issues, it could be that
an error with stack trace is printed in the terminal. This PR improves
most of the cases where this happens to ensure the output is easier to
parse as a human.

# Test plan:

Used SourceGraph to find some popular open source repositories that use
Tailwind and tried to run the upgrade tool on those repositories. If a
repository fails to upgrade, then that's a good candidate for this PR to
showcase the improved error messages.

github.com/docker/docs

| Before | After |
| --- | --- |
| <img width="1455" alt="image"
src="https://github.com/user-attachments/assets/ae28c1c1-8472-45a2-89f7-ed74a703e216">
| <img width="1455" alt="image"
src="https://github.com/user-attachments/assets/6bf4ec79-ddfc-47c4-8ba0-051566cb0116">
|

github.com/parcel-bundler/parcel

| Before | After |
| --- | --- |
| <img width="1455" alt="image"
src="https://github.com/user-attachments/assets/826e510f-df7a-4672-9895-8e13da1d03a8">
| <img width="1455" alt="image"
src="https://github.com/user-attachments/assets/a75146f5-bfac-4c96-a02b-be00ef671f73">
|

github.com/vercel/next.js

| Before | After |
| --- | --- |
| <img width="1455" alt="image"
src="https://github.com/user-attachments/assets/8d6c3744-f210-4164-b1ee-51950d44b349">
| <img width="1455" alt="image"
src="https://github.com/user-attachments/assets/b2739a9a-9629-411d-a506-3993a5867caf">
|
2024-11-19 14:29:51 +01:00

383 lines
11 KiB
TypeScript

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<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 ??= {}
// 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<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
}
}
// 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<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
}