mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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"> |
383 lines
11 KiB
TypeScript
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
|
|
}
|