From beeeef5460f28378f30d891f2922ef19f038eaa2 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Tue, 3 Nov 2026 12:13:11 -0600 Subject: [PATCH] refactor: use @jssg/utils for common codemod transformations Replace hand-rolled import manipulation (add/remove/rename/resolve, binding resolution, reference renaming, indent detection) in 41 codemod scripts across nine recipe packages with the shared helpers from @jssg/utils/javascript/{imports,bindings,style}, standardizing every recipe on ^0.0.11. Behavior-preserving: all fixture suites, metrics, and workflow goldens pass; the only fixture diffs are formatting-only. Logic with no util equivalent stays hand-rolled (in-place import moves with re-exports, collision-aware rename orchestration, single-specifier expansion, namespace member renames). CONTRIBUTING gains a 'Prefer shared codemod utils' section. Also fixes the pre-existing v1.54 typed-extension fixture type-check (the packages it imports are now declared devDependencies) and a latent node lookup bug in the mui4 styles codemod. Carries a type-only yarn patch for two TS7 assertion failures in @jssg/utils 0.0.11's raw .ts source, to be dropped once upstream ships compiled declarations. --- .changeset/jssg-utils-refactor.md | 27 ++ .../@jssg-utils-npm-0.0.11-19369c37c8.patch | 22 ++ CONTRIBUTING.md | 8 + .../mui4-to-bui-migration-recipe/package.json | 3 + .../migrate-mui-accordion-to-bui-accordion.ts | 250 ++++---------- .../scripts/migrate-mui-alert-to-bui-alert.ts | 285 ++++------------ .../migrate-mui-autocomplete-to-combobox.ts | 312 ++++-------------- .../migrate-mui-avatar-to-bui-avatar.ts | 228 +++---------- .../scripts/migrate-mui-bootstrap-to-bui.ts | 44 +-- .../migrate-mui-button-to-bui-button.ts | 213 +++--------- .../scripts/migrate-mui-card-to-bui-card.ts | 303 ++++------------- .../scripts/migrate-mui-chip-to-tag.ts | 243 +++----------- .../migrate-mui-dialog-to-bui-dialog.ts | 257 ++++----------- .../migrate-mui-icon-button-to-button-icon.ts | 191 +++-------- .../migrate-mui-icons-to-remix-icons.ts | 163 ++------- .../migrate-mui-layout-to-bui-layout.ts | 287 ++++++---------- .../scripts/migrate-mui-link-to-bui-link.ts | 264 +++------------ .../migrate-mui-list-family-to-bui-list.ts | 254 ++++---------- .../migrate-mui-menu-popover-to-bui-menu.ts | 284 ++++------------ .../migrate-mui-radio-checkbox-to-bui.ts | 252 ++++---------- ...migrate-mui-select-family-to-bui-select.ts | 250 ++++---------- .../migrate-mui-skeleton-to-bui-skeleton.ts | 240 +++----------- .../migrate-mui-slider-to-bui-slider.ts | 226 +++---------- .../migrate-mui-styles-to-bui-css-modules.ts | 125 +++---- .../migrate-mui-switch-to-bui-switch.ts | 252 ++++---------- .../scripts/migrate-mui-tabs-to-bui-tabs.ts | 299 +++++------------ .../migrate-mui-textfield-to-bui-textfield.ts | 271 ++++----------- .../migrate-mui-tooltip-to-bui-tooltip.ts | 212 +++--------- .../scripts/migrate-mui-typography-to-text.ts | 251 ++++---------- .../merge-existing-bui/expected.tsx | 5 +- .../simple-accordion/expected.tsx | 2 - .../merge-existing-bui/expected.tsx | 3 +- .../with-title/expected.tsx | 1 - .../combobox-success/expected.tsx | 1 - .../render-input-size-small/expected.tsx | 1 - .../search-autocomplete/expected.tsx | 1 - .../size-small/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 3 +- .../merge-existing-bui/expected.tsx | 1 - .../complex-header-todo/expected.tsx | 2 - .../header-content-actions/expected.tsx | 3 - .../media-todo/expected.tsx | 2 - .../simple-card/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../controlled-dialog/expected.tsx | 3 - .../footer-button-attr-order/expected.tsx | 3 - .../merge-existing-bui/expected.tsx | 3 - .../merge-existing-bui/expected.tsx | 1 - .../box-flex-container/expected.tsx | 1 - .../grid-simple/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../paper-card-like-bui-children/expected.tsx | 3 +- .../paper-card-like/expected.tsx | 1 - .../paper-simple/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 3 - .../simple-list-with-icon/expected.tsx | 3 - .../text-only-no-icon/expected.tsx | 2 - .../merge-existing-bui/expected.tsx | 2 - .../expected.tsx | 2 - .../popover-content-with-trigger/expected.tsx | 1 - .../popover-menu-with-trigger/expected.tsx | 3 - .../popover-select-menuitem-noop/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 3 - .../simple-checkbox-group/expected.tsx | 2 - .../simple-radio-group/expected.tsx | 2 - .../expected.tsx | 1 - .../basic-form-control/expected.tsx | 3 - .../form-control-size-small/expected.tsx | 3 - .../merge-existing-bui/expected.tsx | 4 - .../size-large/expected.tsx | 3 - .../size-small/expected.tsx | 3 - .../merge-existing-bui/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../direct-object-arg/expected.tsx | 1 - .../dynamic-rule-todo/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../multiple-classes/expected.tsx | 1 - .../static-theme-tokens/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 4 - .../standalone-tabs/expected.tsx | 1 - .../tab-context-pattern/expected.tsx | 3 - .../form-control-size-small/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../merge-existing-bui/expected.tsx | 1 - .../tests/workflow/workflow.test.ts | 5 +- codemods/v1.45.0/package.json | 3 + .../scripts/migrate-avatar-to-custom.ts | 76 +---- .../scripts/migrate-checkbox-to-react-aria.ts | 82 +---- .../migrate-collapsible-to-accordion.ts | 87 +---- codemods/v1.47.0/package.json | 3 + .../migrate-table-to-use-table-hook.ts | 85 +---- codemods/v1.48.0/package.json | 2 +- .../scripts/migrate-surface-to-bg-system.ts | 53 +-- .../scripts/remove-alert-surface-prop.ts | 61 +--- .../scripts/rename-header-to-plugin-header.ts | 150 +++------ codemods/v1.49.0/package.json | 2 +- codemods/v1.50.0/package.json | 2 +- .../scripts/header-tab-to-nav-tab-item.ts | 89 +++-- .../humanize-entity-ref-to-presentation.ts | 202 +++++------- .../scripts/migrate-permissioned-route.ts | 9 +- codemods/v1.51.0/package.json | 2 +- .../v1.51.0/scripts/loading-to-is-pending.ts | 53 +-- .../scripts/migrate-nav-item-to-page.ts | 48 +-- .../scripts/migrate-policy-query-user.ts | 20 +- .../scripts/portable-schema-method-call.ts | 53 +-- .../scripts/render-test-app-nav-migration.ts | 2 +- codemods/v1.52.0/package.json | 3 + .../migrate-bui-props-to-intersection.ts | 90 +---- .../scripts/migrate-select-combobox-props.ts | 80 +---- codemods/v1.54.0/package.json | 9 +- .../scripts/migrate-connections-query.ts | 79 ++--- .../migrate-extension-config-schema.ts | 42 +-- .../scripts/rename-root-connection-auth.ts | 15 +- codemods/v1.55.0/package.json | 7 +- .../move-connections-config-imports.ts | 14 +- .../scripts/remove-bui-anchor-render-props.ts | 145 ++------ .../remove-kubernetes-implicit-children.ts | 10 +- .../scripts/rename-connections-public-api.ts | 6 +- yarn.lock | 79 +++-- 121 files changed, 1933 insertions(+), 5858 deletions(-) create mode 100644 .changeset/jssg-utils-refactor.md create mode 100644 .yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch diff --git a/.changeset/jssg-utils-refactor.md b/.changeset/jssg-utils-refactor.md new file mode 100644 index 00000000..552ef10f --- /dev/null +++ b/.changeset/jssg-utils-refactor.md @@ -0,0 +1,27 @@ +--- +'@backstage/mui4-to-bui-migration-recipe': patch +'@backstage/v1-45-0-migration-recipe': patch +'@backstage/v1-47-0-migration-recipe': patch +'@backstage/v1-48-0-migration-recipe': patch +'@backstage/v1-49-0-migration-recipe': patch +'@backstage/v1-50-0-migration-recipe': patch +'@backstage/v1-51-0-migration-recipe': patch +'@backstage/v1-52-0-migration-recipe': patch +'@backstage/v1-54-0-migration-recipe': patch +'@backstage/v1-55-0-migration-recipe': patch +--- + +Refactor: use @jssg/utils for common codemod transformations + +Replace hand-rolled import manipulation (add/remove/rename/resolve, binding +resolution, reference renaming, indent detection) across the codemod scripts +with the shared helpers from `@jssg/utils/javascript/{imports,bindings,style}`, +and standardize every recipe on `@jssg/utils ^0.0.11`. Behavior-preserving: +all fixture suites, metrics, and workflow goldens pass unchanged apart from +formatting-only fixture updates (removed import statements no longer leave a +blank line; merged import specifiers keep source order instead of being +re-sorted). + +Also fixes the `migrate-extension-config-schema` typed-extension fixture +type-check by declaring the packages it imports (`@backstage/frontend-plugin-api`, +`zod`) as dev dependencies of the v1.54 recipe. diff --git a/.yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch b/.yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch new file mode 100644 index 00000000..1a7aa1bf --- /dev/null +++ b/.yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch @@ -0,0 +1,22 @@ +diff --git a/src/javascript/exports/imports.ts b/src/javascript/exports/imports.ts +index 8f7a78220d2ecc6ebcafb817a8a94877ccb97881..3691a79f19f4d90a3cee4573d58e0ebf3ff42136 100644 +--- a/src/javascript/exports/imports.ts ++++ b/src/javascript/exports/imports.ts +@@ -2247,7 +2247,7 @@ function requireObjectPatterns( + }, + }, + ], +- } satisfies Rule; ++ } as unknown as Rule; + const patterns = new Map>(); + const specifiers = tsProgram.findAll({ + rule: { +@@ -2258,7 +2258,7 @@ function requireObjectPatterns( + inside: { + stopBy: "end", + kind: "variable_declarator", +- has: value, ++ has: value as unknown as never, + }, + }, + }, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d789cf1c..6f5624e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,14 @@ Conventions to follow: Use `codemods/v1.54.0/` as a reference when adding a script and workflow step. +### Prefer shared codemod utils + +Before hand-rolling a common transformation — import add/remove/rename/resolve, import binding resolution, reference renaming, indent or quote-style detection — check `@jssg/utils` first: every recipe can import it (`@jssg/utils/javascript/imports`, `.../bindings`, `.../style`; read the source under `node_modules/@jssg/utils/src/javascript/exports/` for the exact API). The utils handle edge cases (aliases, CJS `require`, dynamic `import()`, multi-line clause layout) that bespoke scans miss, and they are maintained upstream. + +When a util you need seems to be missing, prefer bumping `@jssg/utils` to a newer release over recreating the logic locally — it ships new helpers frequently (0.0.11 added `listImports`, the `bindings` module, and the `style` module). Keep bespoke logic only where no util models the transformation (e.g. collision-aware rename orchestration), and say so in the PR description. + +`@jssg/utils` 0.0.11 ships raw `.ts` source that does not type-check under this repo's TypeScript 7 toolchain (two assertion sites in `imports.ts`). Until upstream ships compiled declarations or a fix, the repo carries a temporary patch at `.yarn/patches/` (registered in the root `package.json` `resolutions.patchedDependencies`); it is type-only and changes no runtime behavior. Drop the patch when a newer `@jssg/utils` release type-checks cleanly. + ## Adding a migration recipe Each Backstage release publishes a single `migration-recipe` package directly under `codemods//` — the recipe is the only published package for its version, and every workflow step runs a local script instead of a nested registry codemod: diff --git a/codemods/misc/mui4-to-bui-migration-recipe/package.json b/codemods/misc/mui4-to-bui-migration-recipe/package.json index 3b0ecbc8..520dbd4a 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/package.json +++ b/codemods/misc/mui4-to-bui-migration-recipe/package.json @@ -6,6 +6,9 @@ "scripts": { "test": "run-recipe-tests" }, + "dependencies": { + "@jssg/utils": "patch:@jssg/utils@npm%3A0.0.11#~/.yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch" + }, "devDependencies": { "@backstage/scripts": "workspace:*", "@codemod.com/jssg-types": "1.6.3", diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-accordion-to-bui-accordion.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-accordion-to-bui-accordion.ts index cf7f3099..32ded790 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-accordion-to-bui-accordion.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-accordion-to-bui-accordion.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -34,185 +35,32 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface AccordionImports { localNames: Map - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] + defaultSources: string[] + barrelNames: string[] } -function collectAccordionImports(rootNode: SgNode): AccordionImports { +function collectAccordionImports(rootNode: SgNode): AccordionImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + const defaultSources: string[] = [] + const barrelNames: string[] = [] for (const componentName of MUI_ACCORDION_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) - } - importNodesToRemove.push(imp) - } - } - - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const foundNames = new Set() - for (const componentName of MUI_ACCORDION_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - foundNames.add(componentName) - } + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + defaultSources.push(`@material-ui/core/${componentName}`) } - if (foundNames.size > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundNames.size >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) - } - } - } - - return { localNames, importNodesToRemove, barrelImportsToPrune } -} - -function addBuiImport( - rootNode: SgNode, - importNodesToRemove: SgNode[], - names: string[], - edits: Edit[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const namesToAdd = names.filter((name) => getNamedImportLocalName(existingImport, name) === null) - if (namesToAdd.length > 0) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const existing = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - for (const name of namesToAdd) { - existing.push(name) - } - existing.sort() - edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: MUI_BARREL_SOURCE })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } } - return false - } - - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false } - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - migrationMetric.increment({ action: 'import-added' }) - return false + return { localNames, defaultSources, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -399,7 +247,7 @@ function transformAccordionChildren(accordionElement: SgNode, localNames: M } function transformAccordionElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], ): { preserveImport: boolean; migrated: boolean } { @@ -489,7 +337,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, barrelImportsToPrune } = collectAccordionImports(rootNode) + const { localNames, defaultSources, barrelNames } = collectAccordionImports(rootNode) if (localNames.size === 0) { return Promise.resolve(null) @@ -510,34 +358,58 @@ const transform: Codemod = (root) => { } } - let replacedImport = false - const handledBarrelIds = new Set() if (buiNames.size > 0) { - replacedImport = addBuiImport( - rootNode, - importNodesToRemove, - [...buiNames], - edits, - barrelImportsToPrune, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue + if (barrelNames.length > 0) { + const barrelRemovedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) { + const matching = item.specifiers.filter((spec) => barrelRemovedNames.has(spec.importedName)) + if (matching.length === 0) { + continue + } + if (matching.length >= item.specifiers.length) { + migrationMetric.increment({ action: 'import-removed' }) + } else { + migrationMetric.increment({ action: 'import-pruned' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-alert-to-bui-alert.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-alert-to-bui-alert.ts index 08aea30c..fd6e9ae0 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-alert-to-bui-alert.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-alert-to-bui-alert.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -12,204 +13,16 @@ const SEVERITY_TO_STATUS: Record = { } const BUI_SOURCE = '@backstage/ui' +const MUI_LAB_ALERT_SOURCE = '@material-ui/lab/Alert' +const MUI_CORE_ALERT_SOURCE = '@material-ui/core/Alert' +const MUI_LAB_ALERT_TITLE_SOURCE = '@material-ui/lab/AlertTitle' +const MUI_CORE_BARREL = '@material-ui/core' +const MUI_LAB_BARREL = '@material-ui/lab' function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function collectAlertImports(rootNode: SgNode): { - alertLocalName: string | null - alertTitleLocalName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} { - let alertLocalName: string | null = null - let alertTitleLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab/Alert')) { - alertLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Alert')) { - alertLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab/AlertTitle')) { - alertTitleLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const alertName = getNamedImportLocalName(imp, 'Alert') - if (alertName) { - alertLocalName = alertName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: ['Alert'] }) - } - } - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab')) { - const alertName = getNamedImportLocalName(imp, 'Alert') - const alertTitleName = getNamedImportLocalName(imp, 'AlertTitle') - - if (alertName) { - alertLocalName = alertName - } - if (alertTitleName) { - alertTitleLocalName = alertTitleName - } - - const namesToRemove: string[] = [] - if (alertName) { - namesToRemove.push('Alert') - } - if (alertTitleName) { - namesToRemove.push('AlertTitle') - } - - if (namesToRemove.length > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (namesToRemove.length >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/lab', names: namesToRemove }) - } - } - } - - return { alertLocalName, alertTitleLocalName, importNodesToRemove, importSpecifiersToRemove } -} - -function pruneBarrelImportSpecifiers( - imp: SgNode, - source: string, - namesToRemove: string[], - edits: Edit[], - appendAlertImport = false, -): void { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - edits.push(imp.replace('')) - } else { - const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') - let replacement = `import { ${specTexts} } from '${source}';` - if (appendAlertImport) { - replacement += `\nimport { Alert } from '${BUI_SOURCE}';` - migrationMetric.increment({ action: 'import-added' }) - } - edits.push(imp.replace(replacement)) - } - migrationMetric.increment({ action: 'import-removed' }) -} - -function addBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const hasAlert = specifiers.some((spec) => getImportedName(spec) === 'Alert') - if (!hasAlert) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const names = specifiers.map((spec) => spec.text()) - names.push('Alert') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - } - } - return false - } - - const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) - } else if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(`import { Alert } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false -} - function withTodoComment(comment: string, elementText: string): string { return `<> ${comment} @@ -453,8 +266,18 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { alertLocalName, alertTitleLocalName, importNodesToRemove, importSpecifiersToRemove } = - collectAlertImports(rootNode) + // Local names use the same precedence as the original collection order: + // barrel named imports win over per-path default imports. + const labBarrelAlert = getImport(rootNode, { type: 'named', name: 'Alert', from: MUI_LAB_BARREL }) + const coreBarrelAlert = getImport(rootNode, { type: 'named', name: 'Alert', from: MUI_CORE_BARREL }) + const coreAlertDefault = getImport(rootNode, { type: 'default', from: MUI_CORE_ALERT_SOURCE }) + const labAlertDefault = getImport(rootNode, { type: 'default', from: MUI_LAB_ALERT_SOURCE }) + const labBarrelAlertTitle = getImport(rootNode, { type: 'named', name: 'AlertTitle', from: MUI_LAB_BARREL }) + const labAlertTitleDefault = getImport(rootNode, { type: 'default', from: MUI_LAB_ALERT_TITLE_SOURCE }) + + const alertLocalName = + labBarrelAlert?.alias ?? coreBarrelAlert?.alias ?? coreAlertDefault?.alias ?? labAlertDefault?.alias ?? null + const alertTitleLocalName = labBarrelAlertTitle?.alias ?? labAlertTitleDefault?.alias ?? null if (!alertLocalName) { return null @@ -462,37 +285,61 @@ const transform: Codemod = async (root) => { const { preserveImport, migrated } = transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, edits) - let replacedImport = false - let addedViaBarrelPrune = false - - if (migrated && !preserveImport && importNodesToRemove.length > 1) { - for (const imp of importNodesToRemove.slice(1)) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + if (migrated) { + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Alert' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) } } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { - const appendAlertImport = migrated && findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 - if (appendAlertImport) { - addedViaBarrelPrune = true + if (labBarrelAlert || labBarrelAlertTitle) { + const names = [labBarrelAlert ? 'Alert' : null, labBarrelAlertTitle ? 'AlertTitle' : null].filter( + (name): name is string => name !== null, + ) + let removedAny = false + for (const name of names) { + const edit = removeImport(rootNode, { type: 'named', specifiers: [name], from: MUI_LAB_BARREL }) + if (edit) { + edits.push(...edit) + removedAny = true + } + } + if (removedAny) { + migrationMetric.increment({ action: 'import-removed' }) } - pruneBarrelImportSpecifiers(imp, source, names, edits, appendAlertImport) } - } - - if (migrated && !addedViaBarrelPrune) { - replacedImport = addBuiImport(rootNode, importNodesToRemove, edits) - } - - if (!preserveImport) { - const [firstImport] = importNodesToRemove - if (firstImport) { - if (replacedImport) { + if (coreBarrelAlert) { + const edit = removeImport(rootNode, { type: 'named', specifiers: ['Alert'], from: MUI_CORE_BARREL }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - } else { - edits.push(firstImport.replace('')) + } + } + for (const source of [MUI_LAB_ALERT_SOURCE, MUI_CORE_ALERT_SOURCE, MUI_LAB_ALERT_TITLE_SOURCE]) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-autocomplete-to-combobox.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-autocomplete-to-combobox.ts index d1145457..b228c57e 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-autocomplete-to-combobox.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-autocomplete-to-combobox.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -7,6 +8,8 @@ const migrationMetric = useMetricAtom('migrate-mui-autocomplete-to-combobox') const BUI_SOURCE = '@backstage/ui' const MUI_LAB_BARREL = '@material-ui/lab' const MUI_LAB_AUTOCOMPLETE = '@material-ui/lab/Autocomplete' +const MUI_TEXT_FIELD = '@material-ui/core/TextField' +const MUI_CORE_BARREL = '@material-ui/core' function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -19,138 +22,6 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function collectAutocompleteImports(rootNode: SgNode): { - localName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} { - let localName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() - - for (const imp of findImportStatementsFrom(rootNode, MUI_LAB_AUTOCOMPLETE)) { - localName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, MUI_LAB_BARREL)) { - const name = getNamedImportLocalName(imp, 'Autocomplete') - if (name) { - localName = name - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: MUI_LAB_BARREL, names: ['Autocomplete'] }) - } - } - } - - return { localName, importNodesToRemove, importSpecifiersToRemove } -} - -const MUI_TEXT_FIELD = '@material-ui/core/TextField' -const MUI_CORE_BARREL = '@material-ui/core' - -function collectTextFieldImports(rootNode: SgNode): { - localName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} { - let localName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() - - for (const imp of findImportStatementsFrom(rootNode, MUI_TEXT_FIELD)) { - localName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, MUI_CORE_BARREL)) { - const name = getNamedImportLocalName(imp, 'TextField') - if (name) { - localName = name - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: MUI_CORE_BARREL, names: ['TextField'] }) - } - } - } - - return { localName, importNodesToRemove, importSpecifiersToRemove } -} - function isInsideElementIds(node: SgNode, elementIds: Set): boolean { let current: SgNode | null = node while (current) { @@ -183,78 +54,6 @@ function textFieldUsedOutsideMigrated( return false } -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, - replacedDeepImportIds: Set, -): void { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - return - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return - } - - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names: namesToRemove }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return - } - - const [deep] = importNodesToRemove - if (deep) { - edits.push(deep.replace(buiImport)) - replacedDeepImportIds.add(deep.id()) - migrationMetric.increment({ action: 'import-added' }) - } -} - function getElementName(opening: SgNode): string | null { for (const child of opening.children()) { if (child.is('identifier') || child.is('member_expression')) { @@ -806,7 +605,9 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localName, importNodesToRemove, importSpecifiersToRemove } = collectAutocompleteImports(rootNode) + const autocompleteDefault = getImport(rootNode, { type: 'default', from: MUI_LAB_AUTOCOMPLETE }) + const autocompleteBarrelNamed = getImport(rootNode, { type: 'named', name: 'Autocomplete', from: MUI_LAB_BARREL }) + const localName = autocompleteBarrelNamed?.alias ?? autocompleteDefault?.alias ?? null if (!localName) { return null } @@ -819,56 +620,81 @@ const transform: Codemod = async (root) => { buiNames, ) - // Fold unused TextField imports into the same removal pass so addBuiImport - // does not append onto a TextField import that we later delete. + // Fold unused TextField imports into the same removal pass so the BUI import + // does not merge onto a TextField import that we later delete. + let removeTextField = false if (migratedElementIds.size > 0) { - const textFieldImports = collectTextFieldImports(rootNode) - if ( - textFieldImports.localName && - !textFieldUsedOutsideMigrated(rootNode, textFieldImports.localName, migratedElementIds) - ) { - importNodesToRemove.push(...textFieldImports.importNodesToRemove) - for (const [imp, info] of textFieldImports.importSpecifiersToRemove) { - const existing = importSpecifiersToRemove.get(imp) - if (existing) { - existing.names.push(...info.names) - } else { - importSpecifiersToRemove.set(imp, info) - } - } + const textFieldDefault = getImport(rootNode, { type: 'default', from: MUI_TEXT_FIELD }) + const textFieldBarrelNamed = getImport(rootNode, { type: 'named', name: 'TextField', from: MUI_CORE_BARREL }) + const textFieldLocalName = textFieldBarrelNamed?.alias ?? textFieldDefault?.alias ?? null + if (textFieldLocalName && !textFieldUsedOutsideMigrated(rootNode, textFieldLocalName, migratedElementIds)) { + removeTextField = true } } - const handledBarrelIds = new Set() - const replacedDeepImportIds = new Set() - if (migrated && buiNames.size > 0) { - addBuiImport( - rootNode, - [...buiNames], - importNodesToRemove, - importSpecifiersToRemove, - edits, - handledBarrelIds, - replacedDeepImportIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { - continue + if (autocompleteBarrelNamed) { + for (const item of listImports(rootNode, { from: MUI_LAB_BARREL })) { + if (item.specifiers.some((spec) => spec.importedName === 'Autocomplete')) { + migrationMetric.increment({ action: 'import-removed' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: ['Autocomplete'], from: MUI_LAB_BARREL }) + if (edit) { + edits.push(...edit) + } + } + if (removeTextField) { + for (const item of listImports(rootNode, { from: MUI_CORE_BARREL })) { + if (item.specifiers.some((spec) => spec.importedName === 'TextField')) { + migrationMetric.increment({ action: 'import-removed' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: ['TextField'], from: MUI_CORE_BARREL }) + if (edit) { + edits.push(...edit) } - edits.push(imp.replace(getPrunedBarrelImportText(imp, names, source))) - migrationMetric.increment({ action: 'import-removed' }) } - for (const imp of importNodesToRemove) { - if (replacedDeepImportIds.has(imp.id())) { + if (autocompleteDefault) { + const edit = removeImport(rootNode, { type: 'default', from: MUI_LAB_AUTOCOMPLETE }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } + } + if (removeTextField) { + const edit = removeImport(rootNode, { type: 'default', from: MUI_TEXT_FIELD }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-avatar-to-bui-avatar.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-avatar-to-bui-avatar.ts index a96cd3f2..c6c2bbb5 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-avatar-to-bui-avatar.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-avatar-to-bui-avatar.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -5,6 +6,7 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-avatar-to-bui-avatar') const BUI_SOURCE = '@backstage/ui' +const MUI_AVATAR_SOURCE = '@material-ui/core/Avatar' const MUI_BARREL_SOURCE = '@material-ui/core' const PASSTHROUGH_PROPS = new Set(['className', 'id', 'onClick', 'onMouseEnter', 'onMouseLeave', 'title']) @@ -22,174 +24,6 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function collectAvatarImports(rootNode: SgNode): { - avatarLocalName: string | null - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] -} { - let avatarLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Avatar')) { - avatarLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const localName = getNamedImportLocalName(imp, 'Avatar') - if (localName) { - avatarLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: new Set(['Avatar']) }) - } - } - } - - return { avatarLocalName, importNodesToRemove, barrelImportsToPrune } -} - -function addBuiImport( - rootNode: SgNode, - importNodesToRemove: SgNode[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const alreadyImported = getNamedImportLocalName(existingImport, 'Avatar') !== null - if (!alreadyImported) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const names = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - names.push('Avatar') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { Avatar } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - } - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - const buiImport = `import { Avatar } from '${BUI_SOURCE}';` - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false - } - - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false -} - function getElementName(opening: SgNode): string | null { for (const child of opening.children()) { if (child.is('identifier') || child.is('member_expression')) { @@ -443,11 +277,21 @@ function transformAvatarElements( return { preserveImport, migrated } } +function hasSingleSpecifier(importNode: SgNode): boolean { + const stmt = importNode.ancestors().find((a) => a.kind() === 'import_statement') + if (!stmt) { + return false + } + return stmt.findAll({ rule: { kind: 'import_specifier' } }).length <= 1 +} + const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { avatarLocalName, importNodesToRemove, barrelImportsToPrune } = collectAvatarImports(rootNode) + const defaultImport = getImport(rootNode, { type: 'default', from: MUI_AVATAR_SOURCE }) + const namedImport = getImport(rootNode, { type: 'named', name: 'Avatar', from: MUI_BARREL_SOURCE }) + const avatarLocalName = defaultImport?.alias ?? namedImport?.alias ?? null if (!avatarLocalName) { return Promise.resolve(null) @@ -455,27 +299,47 @@ const transform: Codemod = (root) => { const { preserveImport, migrated } = transformAvatarElements(rootNode, avatarLocalName, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Avatar' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue + if (namedImport) { + const edit = removeImport(rootNode, { type: 'named', specifiers: ['Avatar'], from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: hasSingleSpecifier(namedImport.node) ? 'import-removed' : 'import-pruned' }) } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + if (defaultImport) { + const edit = removeImport(rootNode, { type: 'default', from: MUI_AVATAR_SOURCE }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-bootstrap-to-bui.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-bootstrap-to-bui.ts index 5882d492..ec8e8a0d 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-bootstrap-to-bui.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-bootstrap-to-bui.ts @@ -1,3 +1,4 @@ +import { getAllImports, listImports } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -6,21 +7,6 @@ const migrationMetric = useMetricAtom('migrate-mui-bootstrap-to-bui') const BUI_CSS_IMPORT = '@backstage/ui/css/styles.css' -function findImportStatementsMatching(rootNode: SgNode, pattern: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: pattern, - }, - }, - }, - }) -} - function normalizeFilePath(filename: string): string { return filename.replace(/^\\\\\?\\/, '').replaceAll('\\', '/') } @@ -28,7 +14,7 @@ function normalizeFilePath(filename: string): string { /** * Match Backstage app/plugin entry files where the global BUI stylesheet belongs. */ -function isAppEntryFile(filename: string, rootNode: SgNode): boolean { +function isAppEntryFile(filename: string, rootNode: SgNode): boolean { const normalized = normalizeFilePath(filename) if (/(?:^|\/)packages\/app\/src\/App\.tsx?$/.test(normalized)) { @@ -42,33 +28,19 @@ function isAppEntryFile(filename: string, rootNode: SgNode): boolean { } // Typical app bootstrap entry when the filename is index.tsx content (e.g. jssg fixtures). - const createRootImports = findImportStatementsMatching(rootNode, '^react-dom/client$') - for (const imp of createRootImports) { - const createRootSpecifier = imp.find({ - rule: { - kind: 'import_specifier', - has: { - kind: 'identifier', - regex: '^createRoot$', - }, - }, - }) - if (createRootSpecifier) { - return true - } + if (getAllImports(rootNode, { type: 'named', name: 'createRoot', from: 'react-dom/client' }).length > 0) { + return true } return false } -function hasMuiImports(rootNode: SgNode): boolean { - const muiImports = findImportStatementsMatching(rootNode, '^@material-ui/') - return muiImports.length > 0 +function hasMuiImports(rootNode: SgNode): boolean { + return listImports(rootNode).some((item) => item.source.startsWith('@material-ui/')) } -function hasBuiCssImport(rootNode: SgNode): boolean { - const cssImports = findImportStatementsMatching(rootNode, '^@backstage/ui/css/styles\\.css$') - return cssImports.length > 0 +function hasBuiCssImport(rootNode: SgNode): boolean { + return listImports(rootNode, { from: BUI_CSS_IMPORT }).length > 0 } const transform: Codemod = (root) => { diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-button-to-bui-button.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-button-to-bui-button.ts index 35357077..8807e3f7 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-button-to-bui-button.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-button-to-bui-button.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -11,6 +12,8 @@ const VARIANT_MAP: Record = { } const BUI_SOURCE = '@backstage/ui' +const MUI_BUTTON_SOURCE = '@material-ui/core/Button' +const MUI_BARREL_SOURCE = '@material-ui/core' /** Props that need TODO markers because their semantics don't map mechanically. */ const TODO_PROPS = new Set(['component', 'fullWidth', 'disableElevation', 'disableRipple', 'disableFocusRipple']) @@ -24,168 +27,6 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function collectButtonImports(rootNode: SgNode): { - buttonLocalName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, string[]> -} { - let buttonLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, string[]>() - - // Default import: import Button from '@material-ui/core/Button' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Button')) { - buttonLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - // Named import from barrel: import { Button } from '@material-ui/core' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const localName = getNamedImportLocalName(imp, 'Button') - if (localName) { - buttonLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, ['Button']) - } - } - } - - return { buttonLocalName, importNodesToRemove, importSpecifiersToRemove } -} - -function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - edits.push(imp.replace('')) - } else { - const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') - edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) - } - migrationMetric.increment({ action: 'import-removed' }) -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - edits: Edit[], -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push( - existingImport.replace(`${existingImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - migrationMetric.increment({ action: 'import-added' }) - } - return false - } - - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - } else if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\n${buiImport}`)) - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false -} - function withTodoComment(comment: string, elementText: string): string { return `<> ${comment} @@ -512,30 +353,56 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { buttonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectButtonImports(rootNode) - + const defaultImport = getImport(rootNode, { type: 'default', from: MUI_BUTTON_SOURCE }) + const namedImport = getImport(rootNode, { type: 'named', name: 'Button', from: MUI_BARREL_SOURCE }) + const buttonLocalName = defaultImport?.alias ?? namedImport?.alias ?? null if (!buttonLocalName) { return null } const { preserveImport, migrated, buiNames } = transformButtonElements(rootNode, buttonLocalName, edits) - let replacedImport = false if (migrated) { - replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + } + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) } if (!preserveImport) { - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + if (defaultImport) { + const edit = removeImport(rootNode, { type: 'default', from: MUI_BUTTON_SOURCE }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } - for (const [imp, namesToRemove] of importSpecifiersToRemove) { - pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + if (namedImport) { + const edit = removeImport(rootNode, { type: 'named', specifiers: ['Button'], from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-card-to-bui-card.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-card-to-bui-card.ts index 2911bf43..fc8a4d69 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-card-to-bui-card.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-card-to-bui-card.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -30,199 +31,32 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { - edits.push(imp.replace(getPrunedBarrelImportText(imp, namesToRemove, source))) - migrationMetric.increment({ action: 'import-removed' }) -} - interface CardImports { localNames: Map - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> - deepImportByComponent: Map> + deepComponents: MuiCardComponent[] + barrelNames: string[] } -function collectCardImports(rootNode: SgNode): CardImports { +function collectCardImports(rootNode: SgNode): CardImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() - const deepImportByComponent = new Map>() + const deepComponents: MuiCardComponent[] = [] + const barrelNames: string[] = [] for (const componentName of MUI_CARD_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + deepComponents.push(componentName) + } + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: MUI_BARREL })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } - importNodesToRemove.push(imp) - deepImportByComponent.set(componentName, imp) } } - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL)) { - const namesToRemove: string[] = [] - for (const componentName of MUI_CARD_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - namesToRemove.push(componentName) - } - } - if (namesToRemove.length > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (namesToRemove.length >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: MUI_BARREL, names: namesToRemove }) - } - } - } - - return { localNames, importNodesToRemove, importSpecifiersToRemove, deepImportByComponent } -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, - replacedDeepImportIds: Set, -): void { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - return - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return - } - - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names: namesToRemove }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return - } - - const removableDeep = importNodesToRemove.find((imp) => !replacedDeepImportIds.has(imp.id())) - if (removableDeep) { - edits.push(removableDeep.replace(buiImport)) - replacedDeepImportIds.add(removableDeep.id()) - migrationMetric.increment({ action: 'import-added' }) - return - } - - migrationMetric.increment({ action: 'import-added' }) + return { localNames, deepComponents, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -472,7 +306,7 @@ function isInsideTransformedCard(el: SgNode, cardLocalNames: Set): } function transformCardElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], buiNames: Set, @@ -530,8 +364,7 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, importSpecifiersToRemove, deepImportByComponent } = - collectCardImports(rootNode) + const { localNames, deepComponents } = collectCardImports(rootNode) if (localNames.size === 0) { return null @@ -548,66 +381,72 @@ const transform: Codemod = async (root) => { } } - const handledBarrelIds = new Set() - const replacedDeepImportIds = new Set() - if (migrated && buiNames.size > 0) { - const removableDeepImports = importNodesToRemove.filter((imp) => { - for (const [component, node] of deepImportByComponent) { - if (node.id() === imp.id() && preservedMui.has(component)) { - return false - } - } - return true + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, }) - - const foldableBarrels = new Map, { source: string; names: string[] }>() - for (const [imp, value] of importSpecifiersToRemove) { - const names = value.names.filter((n) => namesToStripFromBarrel.has(n)) - if (names.length > 0) { - foldableBarrels.set(imp, { source: value.source, names }) - } - } - - addBuiImport( - rootNode, - [...buiNames], - removableDeepImports, - foldableBarrels, - edits, - handledBarrelIds, - replacedDeepImportIds, + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } + } + + for (const item of listImports(rootNode, { from: MUI_BARREL })) { + const cardSpecs = item.specifiers.filter((spec) => + (MUI_CARD_COMPONENTS as readonly string[]).includes(spec.importedName), ) - } - - for (const [imp, { source, names }] of importSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { + if (cardSpecs.length === 0) { + continue + } + if (cardSpecs.length >= item.specifiers.length) { + const edit = removeImport(rootNode, { + type: 'named', + specifiers: cardSpecs.map((spec) => spec.importedName), + from: MUI_BARREL, + }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } continue } - const removable = names.filter((n) => namesToStripFromBarrel.has(n)) + const removable = cardSpecs.map((spec) => spec.importedName).filter((n) => namesToStripFromBarrel.has(n)) if (removable.length === 0) { continue } - pruneBarrelImportSpecifiers(imp, source, removable, edits) + const edit = removeImport(rootNode, { type: 'named', specifiers: removable, from: MUI_BARREL }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } } - for (const imp of importNodesToRemove) { - if (replacedDeepImportIds.has(imp.id())) { - migrationMetric.increment({ action: 'import-removed' }) + for (const component of deepComponents) { + if (preservedMui.has(component)) { continue } - let preserve = false - for (const [component, node] of deepImportByComponent) { - if (node.id() === imp.id() && preservedMui.has(component)) { - preserve = true - break - } - } - if (preserve) { - continue + const edit = removeImport(rootNode, { type: 'default', from: `@material-ui/core/${component}` }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-chip-to-tag.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-chip-to-tag.ts index ec0e23e7..0112deb5 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-chip-to-tag.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-chip-to-tag.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -5,6 +6,8 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-chip-to-tag') const BUI_SOURCE = '@backstage/ui' +const MUI_CHIP_SOURCE = '@material-ui/core/Chip' +const MUI_BARREL_SOURCE = '@material-ui/core' /** Props that indicate an interactive chip — not safe to auto-migrate. */ const INTERACTIVE_PROPS = new Set(['onDelete', 'clickable', 'avatar', 'deleteIcon', 'onClick']) @@ -13,80 +16,6 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { - edits.push(imp.replace(getPrunedBarrelImportText(imp, namesToRemove, source))) - migrationMetric.increment({ action: 'import-removed' }) -} - function withTodoComment(comment: string, elementText: string): string { return `<> ${comment} @@ -94,112 +23,6 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function collectChipImports(rootNode: SgNode): { - chipLocalName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} { - let chipLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() - - // Default import: import Chip from '@material-ui/core/Chip' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Chip')) { - chipLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - // Named import from barrel: import { Chip } from '@material-ui/core' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const localName = getNamedImportLocalName(imp, 'Chip') - if (localName) { - chipLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: ['Chip'] }) - } - } - } - - return { chipLocalName, importNodesToRemove, importSpecifiersToRemove } -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - } else { - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names: namesToRemove }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return false - } - - if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false -} - function hasProp(opening: SgNode, propName: string): boolean { return ( opening.find({ @@ -501,7 +324,9 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { chipLocalName, importNodesToRemove, importSpecifiersToRemove } = collectChipImports(rootNode) + const defaultImport = getImport(rootNode, { type: 'default', from: MUI_CHIP_SOURCE }) + const namedImport = getImport(rootNode, { type: 'named', name: 'Chip', from: MUI_BARREL_SOURCE }) + const chipLocalName = defaultImport?.alias ?? namedImport?.alias ?? null if (!chipLocalName) { return null @@ -509,37 +334,51 @@ const transform: Codemod = async (root) => { const { needsTagGroup, preserveImport, migrated } = transformChipElements(rootNode, chipLocalName, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - const importNames = ['Tag'] + const importNames = [{ name: 'Tag' }] if (needsTagGroup) { - importNames.push('TagGroup') + importNames.push({ name: 'TagGroup' }) + } + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: importNames, + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) } - replacedImport = addBuiImport( - rootNode, - importNames, - importNodesToRemove, - importSpecifiersToRemove, - edits, - handledBarrelIds, - ) } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { - continue + if (namedImport) { + const edit = removeImport(rootNode, { type: 'named', specifiers: ['Chip'], from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) } - pruneBarrelImportSpecifiers(imp, source, names, edits) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + if (defaultImport) { + const edit = removeImport(rootNode, { type: 'default', from: MUI_CHIP_SOURCE }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-dialog-to-bui-dialog.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-dialog-to-bui-dialog.ts index 61c34327..d373afce 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-dialog-to-bui-dialog.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-dialog-to-bui-dialog.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -34,86 +35,12 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - const MUI_DIALOG_COMPONENTS = ['Dialog', 'DialogTitle', 'DialogContent', 'DialogActions'] interface DialogImports { localNames: Map - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { - edits.push(imp.replace(getPrunedBarrelImportText(imp, namesToRemove, source))) - migrationMetric.increment({ action: 'import-removed' }) + defaultSources: string[] + barrelNames: string[] } function withTodoComment(comment: string, elementText: string): string { @@ -123,115 +50,26 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function collectDialogImports(rootNode: SgNode): DialogImports { +function collectDialogImports(rootNode: SgNode): DialogImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + const defaultSources: string[] = [] + const barrelNames: string[] = [] for (const componentName of MUI_DIALOG_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) - } - importNodesToRemove.push(imp) - } - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const namesToRemove: string[] = [] - for (const componentName of MUI_DIALOG_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - namesToRemove.push(componentName) - } - } - if (namesToRemove.length > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (namesToRemove.length >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: namesToRemove }) - } - } - } - - return { localNames, importNodesToRemove, importSpecifiersToRemove } -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - } else { - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names: namesToRemove }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return false + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + defaultSources.push(`@material-ui/core/${componentName}`) } - - if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: '@material-ui/core' })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } } } - migrationMetric.increment({ action: 'import-added' }) - return false + return { localNames, defaultSources, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -438,7 +276,7 @@ function transformDialogChildren( } function transformDialogElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], buiNames: Set, @@ -593,7 +431,7 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectDialogImports(rootNode) + const { localNames, defaultSources, barrelNames } = collectDialogImports(rootNode) if (localNames.size === 0) { return null @@ -610,33 +448,56 @@ const transform: Codemod = async (root) => { const { preserveImport, migrated } = transformDialogElements(rootNode, localNames, edits, buiNames) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport( - rootNode, - [...buiNames], - importNodesToRemove, - importSpecifiersToRemove, - edits, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { - continue + if (barrelNames.length > 0) { + const removedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: '@material-ui/core' })) { + const matching = item.specifiers.filter((spec) => removedNames.has(spec.importedName)) + if (matching.length === 0) { + continue + } + if (matching.length > 0) { + migrationMetric.increment({ action: 'import-removed' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: '@material-ui/core' }) + if (edit) { + edits.push(...edit) } - pruneBarrelImportSpecifiers(imp, source, names, edits) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icon-button-to-button-icon.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icon-button-to-button-icon.ts index 1da3a348..d1b9a54f 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icon-button-to-button-icon.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icon-button-to-button-icon.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -10,149 +11,6 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function collectIconButtonImports(rootNode: SgNode): { - iconButtonLocalName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, string[]> -} { - let iconButtonLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, string[]>() - - // Default import: import IconButton from '@material-ui/core/IconButton' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/IconButton')) { - iconButtonLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - // Named import from barrel: import { IconButton } from '@material-ui/core' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const localName = getNamedImportLocalName(imp, 'IconButton') - if (localName) { - iconButtonLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, ['IconButton']) - } - } - } - - return { iconButtonLocalName, importNodesToRemove, importSpecifiersToRemove } -} - -function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - edits.push(imp.replace('')) - } else { - const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') - edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) - } - migrationMetric.increment({ action: 'import-removed' }) -} - -function addButtonIconToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const hasButtonIcon = specifiers.some((spec) => getImportedName(spec) === 'ButtonIcon') - if (!hasButtonIcon) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const names = specifiers.map((spec) => spec.text()) - names.push('ButtonIcon') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - } - } - return false - } - - const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) - } else if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(`import { ButtonIcon } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false -} - function withTodoComment(comment: string, elementText: string): string { return `<> ${comment} @@ -416,7 +274,9 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { iconButtonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectIconButtonImports(rootNode) + const iconButtonDefault = getImport(rootNode, { type: 'default', from: '@material-ui/core/IconButton' }) + const iconButtonBarrelNamed = getImport(rootNode, { type: 'named', name: 'IconButton', from: '@material-ui/core' }) + const iconButtonLocalName = iconButtonBarrelNamed?.alias ?? iconButtonDefault?.alias ?? null if (!iconButtonLocalName) { return null @@ -424,22 +284,47 @@ const transform: Codemod = async (root) => { const { preserveImport, migrated } = transformIconButtonElements(rootNode, iconButtonLocalName, edits) - let replacedImport = false if (migrated) { - replacedImport = addButtonIconToBuiImport(rootNode, importNodesToRemove, edits) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'ButtonIcon' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + if (iconButtonBarrelNamed) { + const edit = removeImport(rootNode, { type: 'named', specifiers: ['IconButton'], from: '@material-ui/core' }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } - for (const [imp, namesToRemove] of importSpecifiersToRemove) { - pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + if (iconButtonDefault) { + const edit = removeImport(rootNode, { type: 'default', from: '@material-ui/core/IconButton' }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icons-to-remix-icons.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icons-to-remix-icons.ts index 3cfff2e5..2a52c348 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icons-to-remix-icons.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-icons-to-remix-icons.ts @@ -1,3 +1,4 @@ +import { addImport, listImports } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -103,34 +104,6 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - interface IconImportInfo { localName: string muiIconName: string @@ -138,75 +111,42 @@ interface IconImportInfo { importNode: SgNode } -function collectIconImports(rootNode: SgNode): { +function collectIconImports(rootNode: SgNode): { icons: IconImportInfo[] namespaceImports: SgNode[] } { const icons: IconImportInfo[] = [] const namespaceImports: SgNode[] = [] - const allImports = rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: '^@material-ui/icons', - }, - }, - }, - }) - - for (const imp of allImports) { - const nsImport = imp.find({ rule: { kind: 'namespace_import' } }) - if (nsImport) { - namespaceImports.push(imp) + for (const item of listImports(rootNode)) { + if (item.node.kind() !== 'import_statement' || !item.source.startsWith('@material-ui/icons')) { continue } - const stringFrag = imp.find({ - rule: { - kind: 'string_fragment', - regex: '^@material-ui/icons', - }, - }) - if (!stringFrag) { + if (item.namespaceName !== null) { + namespaceImports.push(item.node) continue } - const sourcePath = stringFrag.text() - - if (sourcePath !== '@material-ui/icons') { - const muiIconName = sourcePath.replace('@material-ui/icons/', '') - const localName = getDefaultImportName(imp) - if (localName) { + if (item.source !== '@material-ui/icons') { + const muiIconName = item.source.replace('@material-ui/icons/', '') + if (item.defaultName) { icons.push({ - localName, + localName: item.defaultName, muiIconName, remixName: ICON_MAP[muiIconName] ?? null, - importNode: imp, + importNode: item.node, }) } continue } - const specifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (!importedNameNode) { - continue - } - const muiIconName = importedNameNode.text() - const localNameNode = identifiers[1] ?? importedNameNode + for (const spec of item.specifiers) { icons.push({ - localName: localNameNode.text(), - muiIconName, - remixName: ICON_MAP[muiIconName] ?? null, - importNode: imp, + localName: spec.localName, + muiIconName: spec.importedName, + remixName: ICON_MAP[spec.importedName] ?? null, + importNode: item.node, }) } } @@ -214,59 +154,20 @@ function collectIconImports(rootNode: SgNode): { return { icons, namespaceImports } } -function addRemixImports( - rootNode: SgNode, - remixImports: Map, - excludedImportIds: Set, - edits: Edit[], -): void { +function addRemixImports(rootNode: SgNode, remixImports: Map, edits: Edit[]): void { if (remixImports.size === 0) { return } - const existingImports = findImportStatementsFrom(rootNode, REMIX_SOURCE) - const existingImport = existingImports[0] ?? null - - const specifiers: string[] = [] - for (const [remixName, localName] of remixImports) { - if (remixName === localName) { - specifiers.push(remixName) - } else { - specifiers.push(`${remixName} as ${localName}`) - } - } - specifiers.sort() - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const existing = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - for (const spec of specifiers) { - if (!existing.includes(spec)) { - existing.push(spec) - } - } - existing.sort() - edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) - } - } else { - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = - [...allImports].reverse().find((imp) => !excludedImportIds.has(imp.id())) ?? allImports.at(-1) ?? null - - if (anchorImport) { - const insertAt = anchorImport.range().end.index - edits.push({ - startPos: insertAt, - endPos: insertAt, - insertedText: `\nimport { ${specifiers.join(', ')} } from '${REMIX_SOURCE}';`, - }) - } + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...remixImports.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([remixName, localName]) => ({ name: remixName, alias: localName !== remixName ? localName : undefined })), + from: REMIX_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) } migrationMetric.increment({ action: 'remix-import-added' }) @@ -306,7 +207,7 @@ function getPropStringValue(opening: SgNode, propName: string): string | nu return null } -function transformIconJsx(rootNode: SgNode, iconLocalNames: Set, edits: Edit[]): void { +function transformIconJsx(rootNode: SgNode, iconLocalNames: Set, edits: Edit[]): void { const jsxElements = rootNode.findAll({ rule: { kind: 'jsx_self_closing_element', @@ -395,7 +296,11 @@ function isExtensionConfigIconPair(pair: SgNode): boolean { return false } -function transformExtensionIconSlots(rootNode: SgNode, iconLocalNames: Set, edits: Edit[]): void { +function transformExtensionIconSlots( + rootNode: SgNode, + iconLocalNames: Set, + edits: Edit[], +): void { const iconPairs = rootNode.findAll({ rule: { kind: 'pair', @@ -448,7 +353,6 @@ const transform: Codemod = (root) => { const remixImports = new Map() const iconLocalNames = new Set() const processedImportIds = new Set() - const excludedImportIds = new Set() for (const icon of icons) { if (icon.remixName) { @@ -456,7 +360,6 @@ const transform: Codemod = (root) => { iconLocalNames.add(icon.localName) if (!processedImportIds.has(icon.importNode.id())) { - excludedImportIds.add(icon.importNode.id()) edits.push(icon.importNode.replace('')) processedImportIds.add(icon.importNode.id()) } @@ -471,7 +374,7 @@ const transform: Codemod = (root) => { } } - addRemixImports(rootNode, remixImports, excludedImportIds, edits) + addRemixImports(rootNode, remixImports, edits) transformIconJsx(rootNode, iconLocalNames, edits) transformExtensionIconSlots(rootNode, iconLocalNames, edits) diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-layout-to-bui-layout.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-layout-to-bui-layout.ts index e53b61c5..29fcd508 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-layout-to-bui-layout.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-layout-to-bui-layout.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -47,86 +48,30 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface LayoutImports { localNames: Map - importNodes: SgNode[] } -function collectLayoutImports(rootNode: SgNode): LayoutImports { +function collectLayoutImports(rootNode: SgNode): LayoutImports { const localNames = new Map() - const importNodes: SgNode[] = [] for (const componentName of MUI_LAYOUT_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) - } - importNodes.push(imp) - } - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - for (const componentName of MUI_LAYOUT_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - } + for (const imp of getAllImports(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` })) { + localNames.set(imp.alias, componentName) } - const hasLayoutImport = MUI_LAYOUT_COMPONENTS.some( - (componentName) => getNamedImportLocalName(imp, componentName) !== null, - ) - if (hasLayoutImport) { - importNodes.push(imp) + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: '@material-ui/core' })) { + localNames.set(named.alias, componentName) } } - return { localNames, importNodes } + return { localNames } } -function isMuiComponentStillUsed(rootNode: SgNode, localName: string, migratedLocalNames: Set): boolean { +function isMuiComponentStillUsed( + rootNode: SgNode, + localName: string, + migratedLocalNames: Set, +): boolean { if (migratedLocalNames.has(localName)) { return false } @@ -155,82 +100,92 @@ function isMuiComponentStillUsed(rootNode: SgNode, localName: string, migra } function removeUnusedLayoutImports( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, - importNodes: SgNode[], migratedLocalNames: Set, edits: Edit[], -): Set { - const seenImportIds = new Set() - const removedImportIds = new Set() +): void { + // Deep default imports: import Box from '@material-ui/core/Box' + for (const componentName of MUI_LAYOUT_COMPONENTS) { + const source = `@material-ui/core/${componentName}` + for (const imp of getAllImports(rootNode, { type: 'default', from: source })) { + if (!localNames.has(imp.alias) || isMuiComponentStillUsed(rootNode, imp.alias, migratedLocalNames)) { + continue + } + const edit = removeImport(rootNode, { type: 'default', from: source, alias: imp.alias }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } + } + } - for (const imp of importNodes) { - if (seenImportIds.has(imp.id())) { + // Barrel imports: import { Box, Grid } from '@material-ui/core' + for (const item of listImports(rootNode, { from: '@material-ui/core' })) { + const specifiers = item.specifiers.map((spec) => ({ + importedName: spec.importedName, + localName: spec.localName, + isLayout: localNames.has(spec.localName), + stillUsed: + localNames.has(spec.localName) && isMuiComponentStillUsed(rootNode, spec.localName, migratedLocalNames), + })) + const touchesLayout = + specifiers.some((spec) => spec.isLayout) || (item.defaultName !== null && localNames.has(item.defaultName)) + if (!touchesLayout) { continue } - seenImportIds.add(imp.id()) - - const defaultName = getDefaultImportName(imp) - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (defaultName && allSpecifiers.length === 0) { - if (localNames.has(defaultName) && !isMuiComponentStillUsed(rootNode, defaultName, migratedLocalNames)) { - edits.push(imp.replace('')) - removedImportIds.add(imp.id()) + const defaultIsUnusedLayout = + item.defaultName !== null && + localNames.has(item.defaultName) && + !isMuiComponentStillUsed(rootNode, item.defaultName, migratedLocalNames) + const removable = specifiers.filter((spec) => spec.isLayout && !spec.stillUsed) + const keepsAnyNamed = specifiers.length > removable.length + + if (!keepsAnyNamed && !defaultIsUnusedLayout && item.defaultName === null) { + // Every specifier is an unused layout import — drop the whole statement. + const edit = removeImport(rootNode, { + type: 'named', + specifiers: removable.map((spec) => ({ name: spec.importedName, alias: spec.localName })), + from: '@material-ui/core', + }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) } continue } - const defaultIsUnusedLayout = - defaultName !== null && - localNames.has(defaultName) && - !isMuiComponentStillUsed(rootNode, defaultName, migratedLocalNames) + if (!keepsAnyNamed && (defaultIsUnusedLayout || item.defaultName === null)) { + // Mixed statement whose default and named bindings are all removable. + edits.push(item.node.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + continue + } - const remainingSpecifiers = allSpecifiers.filter((spec) => { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + let trimmed = false + if (removable.length > 0) { + const edit = removeImport(rootNode, { + type: 'named', + specifiers: removable.map((spec) => ({ name: spec.importedName, alias: spec.localName })), + from: '@material-ui/core', }) - const [importedNameNode] = identifiers - if (!importedNameNode) { - return true + if (edit) { + edits.push(...edit) + trimmed = true } - const localNameNode = identifiers[1] ?? importedNameNode - const localName = localNameNode.text() - if (!localNames.has(localName)) { - return true + } + if (defaultIsUnusedLayout && item.defaultName !== null) { + const edit = removeImport(rootNode, { type: 'default', from: '@material-ui/core', alias: item.defaultName }) + if (edit) { + edits.push(...edit) + trimmed = true } - return isMuiComponentStillUsed(rootNode, localName, migratedLocalNames) - }) - - const importSource = imp.find({ rule: { kind: 'string_fragment' } })?.text() - - if (remainingSpecifiers.length === 0 && (defaultIsUnusedLayout || !defaultName)) { - edits.push(imp.replace('')) - removedImportIds.add(imp.id()) - migrationMetric.increment({ action: 'import-removed' }) - } else if (defaultIsUnusedLayout && remainingSpecifiers.length > 0 && importSource) { - edits.push( - imp.replace(`import { ${remainingSpecifiers.map((s) => s.text()).join(', ')} } from '${importSource}';`), - ) + } + if (trimmed) { migrationMetric.increment({ action: 'import-trimmed' }) - } else if (remainingSpecifiers.length < allSpecifiers.length) { - const namedImports = imp.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - edits.push(namedImports.replace(`{ ${remainingSpecifiers.map((s) => s.text()).join(', ')} }`)) - migrationMetric.increment({ action: 'import-trimmed' }) - } } } - - return removedImportIds -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null } function withTodoComment(comment: string, elementText: string): string { @@ -240,59 +195,6 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function addBuiImport(rootNode: SgNode, names: string[], excludedImportIds: Set, edits: Edit[]): void { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const existingImportedNames = new Set( - existingImport - .findAll({ rule: { kind: 'import_specifier' } }) - .map((spec) => getImportedName(spec)) - .filter((name): name is string => name !== null), - ) - const namesToAdd = names.filter((name) => !existingImportedNames.has(name)) - if (namesToAdd.length === 0) { - return - } - - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const existing = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - existing.push(...namesToAdd) - existing.sort() - edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - const sortedNames = [...namesToAdd].sort() - edits.push( - existingImport.replace(`${existingImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - migrationMetric.increment({ action: 'import-added' }) - } - } else { - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const sortedNames = [...names].sort() - const anchorImport = - [...allImports].reverse().find((imp) => !excludedImportIds.has(imp.id())) ?? allImports.at(-1) ?? null - - if (anchorImport) { - const insertAt = anchorImport.range().end.index - edits.push({ - startPos: insertAt, - endPos: insertAt, - insertedText: `\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`, - }) - } - migrationMetric.increment({ action: 'import-added' }) - } -} - function getElementName(opening: SgNode): string | null { for (const child of opening.children()) { if (child.is('identifier') || child.is('member_expression')) { @@ -760,7 +662,7 @@ function transformGridElement(el: SgNode, opening: SgNode, edits: Edit } function transformLayoutElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], ): { usedBuiNames: Set; migratedLocalNames: Set } { @@ -825,7 +727,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodes } = collectLayoutImports(rootNode) + const { localNames } = collectLayoutImports(rootNode) if (localNames.size === 0) { return Promise.resolve(null) @@ -834,10 +736,33 @@ const transform: Codemod = (root) => { // Transform elements before removing imports so TODO paths keep MUI imports. const { usedBuiNames, migratedLocalNames } = transformLayoutElements(rootNode, localNames, edits) - const removedImportIds = removeUnusedLayoutImports(rootNode, localNames, importNodes, migratedLocalNames, edits) + removeUnusedLayoutImports(rootNode, localNames, migratedLocalNames, edits) if (usedBuiNames.size > 0) { - addBuiImport(rootNode, [...usedBuiNames], removedImportIds, edits) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...usedBuiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-link-to-bui-link.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-link-to-bui-link.ts index 3164ffe2..253b2223 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-link-to-bui-link.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-link-to-bui-link.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -27,202 +28,30 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getLocalImportNames(rootNode: SgNode): Set { - const names = new Set() - for (const imp of rootNode.findAll({ rule: { kind: 'import_statement' } })) { - const defaultName = getDefaultImportName(imp) - if (defaultName) { - names.add(defaultName) - } - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const localNameNode = identifiers[1] ?? identifiers[0] - if (localNameNode) { - names.add(localNameNode.text()) - } - } - } - return names -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function collectMuiLinkImports(rootNode: SgNode): { - localName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} { - let localName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() - - for (const imp of findImportStatementsFrom(rootNode, MUI_LINK)) { - localName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL)) { - const name = getNamedImportLocalName(imp, 'Link') - if (name) { - localName = name - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: MUI_BARREL, names: ['Link'] }) - } - } - } - - return { localName, importNodesToRemove, importSpecifiersToRemove } -} - -function resolveBuiLinkName(rootNode: SgNode, muiLocalName: string): string { - for (const imp of findImportStatementsFrom(rootNode, BUI_SOURCE)) { - const existing = getNamedImportLocalName(imp, 'Link') - if (existing) { - return existing - } +function resolveBuiLinkName(rootNode: SgNode, muiLocalName: string): string { + const existingBuiLink = getImport(rootNode, { type: 'named', name: 'Link', from: BUI_SOURCE }) + if (existingBuiLink) { + return existingBuiLink.alias } // MUI local name will be removed; ignore it when checking for `Link` collisions // (e.g. `@backstage/core-components` Link or `@backstage/core-components/Link`). - const bound = getLocalImportNames(rootNode) - for (const name of bound) { - if (name === 'Link' && name !== muiLocalName) { - return 'BuiLink' + const bound = new Set() + for (const item of listImports(rootNode)) { + if (item.defaultName) { + bound.add(item.defaultName) } - } - return 'Link' -} - -function addBuiImport( - rootNode: SgNode, - buiLocalName: string, - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, - replacedDeepImportIds: Set, -): void { - const importSpec = buiLocalName === 'Link' ? 'Link' : 'Link as BuiLink' - const buiImport = `import { ${importSpec} } from '${BUI_SOURCE}';` - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - if (getNamedImportLocalName(existingImport, 'Link')) { - return + if (item.namespaceName) { + bound.add(item.namespaceName) } - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingTexts = specifiers.map((spec) => spec.text()) - existingTexts.push(importSpec) - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) + for (const spec of item.specifiers) { + bound.add(spec.localName) } - return } - - // Prefer in-place replacement of a deep MUI Link import to avoid leftover blank lines. - const [deep] = importNodesToRemove - if (deep) { - edits.push(deep.replace(buiImport)) - replacedDeepImportIds.add(deep.id()) - migrationMetric.increment({ action: 'import-added' }) - return - } - - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, names, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return - } - - const skipIds = new Set([...importSpecifiersToRemove.keys()].map((imp) => imp.id())) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) + if (bound.has('Link') && muiLocalName !== 'Link') { + return 'BuiLink' } + return 'Link' } function getElementName(opening: SgNode): string | null { @@ -307,7 +136,7 @@ function buildBuiLink(element: SgNode, opening: SgNode, buiLocalName: } function transformLinkElements( - rootNode: SgNode, + rootNode: SgNode, localName: string, buiLocalName: string, edits: Edit[], @@ -369,7 +198,9 @@ const transform: Codemod = async (root) => { return null } - const { localName, importNodesToRemove, importSpecifiersToRemove } = collectMuiLinkImports(rootNode) + const linkDefault = getImport(rootNode, { type: 'default', from: MUI_LINK }) + const linkBarrelNamed = getImport(rootNode, { type: 'named', name: 'Link', from: MUI_BARREL }) + const localName = linkBarrelNamed?.alias ?? linkDefault?.alias ?? null if (!localName) { return null } @@ -381,36 +212,47 @@ const transform: Codemod = async (root) => { return null } - const handledBarrelIds = new Set() - const replacedDeepImportIds = new Set() - if (migrated) { - addBuiImport( - rootNode, - buiLocalName, - importNodesToRemove, - importSpecifiersToRemove, - edits, - handledBarrelIds, - replacedDeepImportIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [buiLocalName === 'Link' ? { name: 'Link' } : { name: 'Link', alias: buiLocalName }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { - continue + if (linkBarrelNamed) { + const edit = removeImport(rootNode, { type: 'named', specifiers: ['Link'], from: MUI_BARREL }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) } - edits.push(imp.replace(getPrunedBarrelImportText(imp, names, source))) - migrationMetric.increment({ action: 'import-removed' }) } - for (const imp of importNodesToRemove) { - if (replacedDeepImportIds.has(imp.id())) { + if (linkDefault) { + const edit = removeImport(rootNode, { type: 'default', from: MUI_LINK }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-list-family-to-bui-list.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-list-family-to-bui-list.ts index 86a4f44e..772609dc 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-list-family-to-bui-list.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-list-family-to-bui-list.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -34,84 +35,10 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface ListImports { localNames: Map - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { - edits.push(imp.replace(getPrunedBarrelImportText(imp, namesToRemove, source))) - migrationMetric.increment({ action: 'import-removed' }) + defaultSources: string[] + barrelNames: string[] } function withTodoComment(comment: string, elementText: string): string { @@ -121,115 +48,26 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function collectListImports(rootNode: SgNode): ListImports { +function collectListImports(rootNode: SgNode): ListImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + const defaultSources: string[] = [] + const barrelNames: string[] = [] for (const componentName of MUI_LIST_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) - } - importNodesToRemove.push(imp) - } - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const namesToRemove: string[] = [] - for (const componentName of MUI_LIST_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - namesToRemove.push(componentName) - } - } - if (namesToRemove.length > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (namesToRemove.length >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: namesToRemove }) - } - } - } - - return { localNames, importNodesToRemove, importSpecifiersToRemove } -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - } else { - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names: namesToRemove }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return false + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + defaultSources.push(`@material-ui/core/${componentName}`) } - - if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: '@material-ui/core' })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } } } - migrationMetric.increment({ action: 'import-added' }) - return false + return { localNames, defaultSources, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -445,7 +283,7 @@ function analyzeListItem(el: SgNode, localNames: Map): List } function transformListElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], ): { preserveImport: boolean; migrated: boolean; buiNames: Set } { @@ -558,7 +396,7 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectListImports(rootNode) + const { localNames, defaultSources, barrelNames } = collectListImports(rootNode) if (localNames.size === 0) { return null @@ -566,33 +404,53 @@ const transform: Codemod = async (root) => { const { preserveImport, migrated, buiNames } = transformListElements(rootNode, localNames, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport( - rootNode, - [...buiNames], - importNodesToRemove, - importSpecifiersToRemove, - edits, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { - continue + if (barrelNames.length > 0) { + const removedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: '@material-ui/core' })) { + const matching = item.specifiers.filter((spec) => removedNames.has(spec.importedName)) + if (matching.length > 0) { + migrationMetric.increment({ action: 'import-removed' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: '@material-ui/core' }) + if (edit) { + edits.push(...edit) } - pruneBarrelImportSpecifiers(imp, source, names, edits) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-menu-popover-to-bui-menu.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-menu-popover-to-bui-menu.ts index b52a9bfe..f87dcae8 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-menu-popover-to-bui-menu.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-menu-popover-to-bui-menu.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -32,84 +33,10 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface MenuImports { localNames: Map - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { - edits.push(imp.replace(getPrunedBarrelImportText(imp, namesToRemove, source))) - migrationMetric.increment({ action: 'import-removed' }) + deepComponents: string[] + barrelNames: string[] } function withTodoComment(comment: string, elementText: string): string { @@ -119,115 +46,26 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function collectMenuImports(rootNode: SgNode): MenuImports { +function collectMenuImports(rootNode: SgNode): MenuImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + const deepComponents: string[] = [] + const barrelNames: string[] = [] for (const componentName of MUI_MENU_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + deepComponents.push(componentName) + } + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: '@material-ui/core' })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } - importNodesToRemove.push(imp) } } - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const namesToRemove: string[] = [] - for (const componentName of MUI_MENU_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - namesToRemove.push(componentName) - } - } - if (namesToRemove.length > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (namesToRemove.length >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: namesToRemove }) - } - } - } - - return { localNames, importNodesToRemove, importSpecifiersToRemove } -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - } else { - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names: namesToRemove }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return false - } - - if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false + return { localNames, deepComponents, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -552,7 +390,7 @@ function buildTriggerWrapperProps(opening: SgNode): string[] | null { } function transformMenuElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], ): { @@ -722,7 +560,7 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectMenuImports(rootNode) + const { localNames, deepComponents } = collectMenuImports(rootNode) if (localNames.size === 0) { return null @@ -730,51 +568,65 @@ const transform: Codemod = async (root) => { const { preserveImport, migrated, buiNames, consumedMuiNames } = transformMenuElements(rootNode, localNames, edits) - // Only drop imports for MUI symbols we actually migrated. MenuItem under Select - // must stay when a Popover takes the plain Popover path. - const filteredImportNodesToRemove = importNodesToRemove.filter((imp) => { - const defaultName = getDefaultImportName(imp) - if (!defaultName) { - return true - } - const muiName = localNames.get(defaultName) - return muiName !== undefined && consumedMuiNames.has(muiName) - }) - const filteredImportSpecifiersToRemove = new Map, { source: string; names: string[] }>() - for (const [imp, { source, names }] of importSpecifiersToRemove) { - const kept = names.filter((name) => consumedMuiNames.has(name)) - if (kept.length > 0) { - filteredImportSpecifiersToRemove.set(imp, { source, names: kept }) - } - } - - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport( - rootNode, - [...buiNames], - filteredImportNodesToRemove, - filteredImportSpecifiersToRemove, - edits, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const [imp, { source, names }] of filteredImportSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { + // Only drop imports for MUI symbols we actually migrated. MenuItem under Select + // must stay when a Popover takes the plain Popover path. A barrel that imports + // only menu components is removed wholesale, matching the previous behavior. + const menuComponentSet = new Set(MUI_MENU_COMPONENTS) + for (const item of listImports(rootNode, { from: '@material-ui/core' })) { + const menuSpecs = item.specifiers.filter((spec) => menuComponentSet.has(spec.importedName)) + if (menuSpecs.length === 0) { continue } - pruneBarrelImportSpecifiers(imp, source, names, edits) - } - for (const imp of filteredImportNodesToRemove) { - if (replacedImport && imp.id() === filteredImportNodesToRemove[0]?.id()) { + const removable = + menuSpecs.length >= item.specifiers.length + ? menuSpecs.map((spec) => spec.importedName) + : menuSpecs.map((spec) => spec.importedName).filter((name) => consumedMuiNames.has(name)) + if (removable.length === 0) { + continue + } + const edit = removeImport(rootNode, { type: 'named', specifiers: removable, from: '@material-ui/core' }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) + } + } + for (const component of deepComponents) { + if (!consumedMuiNames.has(component)) { continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + const edit = removeImport(rootNode, { type: 'default', from: `@material-ui/core/${component}` }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-radio-checkbox-to-bui.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-radio-checkbox-to-bui.ts index 272c8679..aec99b59 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-radio-checkbox-to-bui.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-radio-checkbox-to-bui.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -20,185 +21,32 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface GroupImports { localNames: Map - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] + defaultSources: string[] + barrelNames: string[] } -function collectGroupImports(rootNode: SgNode): GroupImports { +function collectGroupImports(rootNode: SgNode): GroupImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + const defaultSources: string[] = [] + const barrelNames: string[] = [] for (const componentName of MUI_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + defaultSources.push(`@material-ui/core/${componentName}`) + } + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: MUI_BARREL_SOURCE })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } - importNodesToRemove.push(imp) } } - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const foundNames = new Set() - for (const componentName of MUI_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - foundNames.add(componentName) - } - } - if (foundNames.size > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundNames.size >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) - } - } - } - - return { localNames, importNodesToRemove, barrelImportsToPrune } -} - -function addBuiImport( - rootNode: SgNode, - importNodesToRemove: SgNode[], - names: string[], - edits: Edit[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const namesToAdd = names.filter((name) => getNamedImportLocalName(existingImport, name) === null) - if (namesToAdd.length > 0) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const existing = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - for (const name of namesToAdd) { - existing.push(name) - } - existing.sort() - edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - } - return false - } - - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false - } - - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - migrationMetric.increment({ action: 'import-added' }) - return false + return { localNames, defaultSources, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -558,7 +406,7 @@ function tryTransformStandaloneCheckbox(el: SgNode): string | null { } function transformGroupElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], ): { usedBuiNames: Set; preserveImport: boolean } { @@ -689,7 +537,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, barrelImportsToPrune } = collectGroupImports(rootNode) + const { localNames, defaultSources, barrelNames } = collectGroupImports(rootNode) const hasTarget = [...localNames.values()].some( (v) => v === 'RadioGroup' || v === 'FormGroup' || v === 'FormControlLabel' || v === 'Checkbox', @@ -700,34 +548,58 @@ const transform: Codemod = (root) => { const { usedBuiNames, preserveImport } = transformGroupElements(rootNode, localNames, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (usedBuiNames.size > 0) { - replacedImport = addBuiImport( - rootNode, - importNodesToRemove, - [...usedBuiNames], - edits, - barrelImportsToPrune, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...usedBuiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue + if (barrelNames.length > 0) { + const removedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) { + const matching = item.specifiers.filter((spec) => removedNames.has(spec.importedName)) + if (matching.length === 0) { + continue + } + if (matching.length >= item.specifiers.length) { + migrationMetric.increment({ action: 'import-removed' }) + } else { + migrationMetric.increment({ action: 'import-pruned' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-select-family-to-bui-select.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-select-family-to-bui-select.ts index de06ae87..ce94e8ee 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-select-family-to-bui-select.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-select-family-to-bui-select.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -35,185 +36,32 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface SelectImports { localNames: Map - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] + defaultSources: string[] + barrelNames: string[] } -function collectSelectImports(rootNode: SgNode): SelectImports { +function collectSelectImports(rootNode: SgNode): SelectImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + const defaultSources: string[] = [] + const barrelNames: string[] = [] for (const componentName of MUI_SELECT_COMPONENTS) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) - } - importNodesToRemove.push(imp) - } - } - - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const foundNames = new Set() - for (const componentName of MUI_SELECT_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - foundNames.add(componentName) - } + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + defaultSources.push(`@material-ui/core/${componentName}`) } - if (foundNames.size > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundNames.size >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: MUI_BARREL_SOURCE })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } } } - return { localNames, importNodesToRemove, barrelImportsToPrune } -} - -function addBuiImport( - rootNode: SgNode, - importNodesToRemove: SgNode[], - names: string[], - edits: Edit[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const namesToAdd = names.filter((name) => getNamedImportLocalName(existingImport, name) === null) - if (namesToAdd.length > 0) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const existing = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - for (const name of namesToAdd) { - existing.push(name) - } - existing.sort() - edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - } - return false - } - - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false - } - - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - migrationMetric.increment({ action: 'import-added' }) - return false + return { localNames, defaultSources, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -587,7 +435,7 @@ function findSelectInFormControl( } function transformSelectPatterns( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], ): { preserveImport: boolean; migrated: boolean } { @@ -812,7 +660,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, barrelImportsToPrune } = collectSelectImports(rootNode) + const { localNames, defaultSources, barrelNames } = collectSelectImports(rootNode) const hasSelect = [...localNames.values()].includes('Select') if (!hasSelect) { @@ -821,34 +669,58 @@ const transform: Codemod = (root) => { const { preserveImport, migrated } = transformSelectPatterns(rootNode, localNames, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport( - rootNode, - importNodesToRemove, - ['Select'], - edits, - barrelImportsToPrune, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Select' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue + if (barrelNames.length > 0) { + const removedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) { + const matching = item.specifiers.filter((spec) => removedNames.has(spec.importedName)) + if (matching.length === 0) { + continue + } + if (matching.length >= item.specifiers.length) { + migrationMetric.increment({ action: 'import-removed' }) + } else { + migrationMetric.increment({ action: 'import-pruned' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-skeleton-to-bui-skeleton.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-skeleton-to-bui-skeleton.ts index 726a2778..d843606c 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-skeleton-to-bui-skeleton.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-skeleton-to-bui-skeleton.ts @@ -1,3 +1,4 @@ +import { addImport, getImport, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -23,178 +24,6 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_LAB_BARREL}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function collectSkeletonImports(rootNode: SgNode): { - skeletonLocalName: string | null - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] -} { - let skeletonLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] - - for (const source of ['@material-ui/lab/Skeleton', '@material-ui/core/Skeleton']) { - for (const imp of findImportStatementsFrom(rootNode, source)) { - skeletonLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - } - - for (const barrel of [MUI_LAB_BARREL, MUI_CORE_BARREL]) { - for (const imp of findImportStatementsFrom(rootNode, barrel)) { - const localName = getNamedImportLocalName(imp, 'Skeleton') - if (localName) { - skeletonLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: new Set(['Skeleton']) }) - } - } - } - } - - return { skeletonLocalName, importNodesToRemove, barrelImportsToPrune } -} - -function addBuiImport( - rootNode: SgNode, - importNodesToRemove: SgNode[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const alreadyImported = getNamedImportLocalName(existingImport, 'Skeleton') !== null - if (!alreadyImported) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const names = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - names.push('Skeleton') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { Skeleton } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - } - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - const buiImport = `import { Skeleton } from '${BUI_SOURCE}';` - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false - } - - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false -} - function getElementName(opening: SgNode): string | null { for (const child of opening.children()) { if (child.is('identifier') || child.is('member_expression')) { @@ -325,11 +154,30 @@ function transformSkeletonElements( return { preserveImport, migrated } } +const MUI_SKELETON_SOURCES = ['@material-ui/lab/Skeleton', '@material-ui/core/Skeleton'] +const MUI_BARRELS = [MUI_LAB_BARREL, MUI_CORE_BARREL] + +function hasSingleSpecifier(importNode: SgNode): boolean { + const stmt = importNode.ancestors().find((a) => a.kind() === 'import_statement') + if (!stmt) { + return false + } + return stmt.findAll({ rule: { kind: 'import_specifier' } }).length <= 1 +} + const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { skeletonLocalName, importNodesToRemove, barrelImportsToPrune } = collectSkeletonImports(rootNode) + const defaultImports = MUI_SKELETON_SOURCES.map((source) => ({ + source, + imp: getImport(rootNode, { type: 'default', from: source }), + })).filter(({ imp }) => imp !== null) + const barrelImports = MUI_BARRELS.map((barrel) => ({ + barrel, + imp: getImport(rootNode, { type: 'named', name: 'Skeleton', from: barrel }), + })).filter(({ imp }) => imp !== null) + const skeletonLocalName = defaultImports[0]?.imp?.alias ?? barrelImports[0]?.imp?.alias ?? null if (!skeletonLocalName) { return Promise.resolve(null) @@ -337,27 +185,47 @@ const transform: Codemod = (root) => { const { preserveImport, migrated } = transformSkeletonElements(rootNode, skeletonLocalName, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Skeleton' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue + for (const { barrel, imp } of barrelImports) { + const edit = removeImport(rootNode, { type: 'named', specifiers: ['Skeleton'], from: barrel }) + if (edit && imp) { + edits.push(...edit) + migrationMetric.increment({ action: hasSingleSpecifier(imp.node) ? 'import-removed' : 'import-pruned' }) } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const { source } of defaultImports) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-slider-to-bui-slider.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-slider-to-bui-slider.ts index bad17263..8540049a 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-slider-to-bui-slider.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-slider-to-bui-slider.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -55,174 +56,21 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function collectSliderImports(rootNode: SgNode): { - sliderLocalName: string | null - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] -} { +function collectSliderImports(rootNode: SgNode): string | null { let sliderLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] // Default import: import Slider from '@material-ui/core/Slider' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Slider')) { - sliderLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) + const deepImport = getImport(rootNode, { type: 'default', from: '@material-ui/core/Slider' }) + if (deepImport) { + sliderLocalName = deepImport.alias } // Named import from barrel: import { Slider } from '@material-ui/core' - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const localName = getNamedImportLocalName(imp, 'Slider') - if (localName) { - sliderLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: new Set(['Slider']) }) - } - } + for (const named of getAllImports(rootNode, { type: 'named', name: 'Slider', from: MUI_BARREL_SOURCE })) { + sliderLocalName = named.alias } - return { sliderLocalName, importNodesToRemove, barrelImportsToPrune } -} - -function addBuiImport( - rootNode: SgNode, - importNodesToRemove: SgNode[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const alreadyImported = getNamedImportLocalName(existingImport, 'Slider') !== null - if (!alreadyImported) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const names = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - names.push('Slider') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { Slider } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - } - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - const buiImport = `import { Slider } from '${BUI_SOURCE}';` - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false - } - - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false + return sliderLocalName } function getElementName(opening: SgNode): string | null { @@ -469,7 +317,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { sliderLocalName, importNodesToRemove, barrelImportsToPrune } = collectSliderImports(rootNode) + const sliderLocalName = collectSliderImports(rootNode) if (!sliderLocalName) { return Promise.resolve(null) @@ -477,27 +325,55 @@ const transform: Codemod = (root) => { const { preserveImport, migrated } = transformSliderElements(rootNode, sliderLocalName, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Slider' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue - } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) + const deepEdit = removeImport(rootNode, { type: 'default', from: '@material-ui/core/Slider' }) + if (deepEdit) { + edits.push(...deepEdit) + migrationMetric.increment({ action: 'import-removed' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { - migrationMetric.increment({ action: 'import-removed' }) + for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) { + const hasSlider = item.specifiers.some((spec) => spec.importedName === 'Slider') + if (!hasSlider) { continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + const edit = removeImport(rootNode, { + type: 'named', + specifiers: ['Slider'], + from: MUI_BARREL_SOURCE, + }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ + action: item.specifiers.length > 1 ? 'import-pruned' : 'import-removed', + }) + } } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-styles-to-bui-css-modules.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-styles-to-bui-css-modules.ts index 322c16ef..005233ba 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-styles-to-bui-css-modules.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-styles-to-bui-css-modules.ts @@ -1,3 +1,4 @@ +import { listImports, removeImport } from '@jssg/utils/javascript/imports' import { parse, type Codemod, type Edit, type SgNode } from 'codemod:ast-grep' import type CSS from 'codemod:ast-grep/langs/css' import type TSX from 'codemod:ast-grep/langs/tsx' @@ -45,89 +46,47 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface StylesImportInfo { makeStylesLocal: string | null withStylesLocal: string | null - importNodesToRemove: SgNode[] + styleSources: string[] } -function collectStylesImports(rootNode: SgNode): StylesImportInfo { +const MUI_STYLE_SOURCES = ['@material-ui/core/styles', '@material-ui/core', '@material-ui/styles'] + +function collectStylesImports(rootNode: SgNode): StylesImportInfo { let makeStylesLocal: string | null = null let withStylesLocal: string | null = null - const importNodesToRemove: SgNode[] = [] - - const sources = ['@material-ui/core/styles', '@material-ui/core', '@material-ui/styles'] + const styleSources: string[] = [] - for (const source of sources) { - for (const imp of findImportStatementsFrom(rootNode, source)) { - const ms = getNamedImportLocalName(imp, 'makeStyles') - const cs = getNamedImportLocalName(imp, 'createStyles') - const ws = getNamedImportLocalName(imp, 'withStyles') - - if (ms) { - makeStylesLocal = ms - } - if (ws) { - withStylesLocal = ws + for (const source of MUI_STYLE_SOURCES) { + for (const item of listImports(rootNode, { from: source })) { + const names = new Set(['makeStyles', 'createStyles', 'withStyles']) + const targetSpecs = item.specifiers.filter((spec) => names.has(spec.importedName)) + if (targetSpecs.length === 0) { + continue } - if (ms || cs || ws) { - if (getDefaultImportName(imp)) { - continue + for (const spec of targetSpecs) { + if (spec.importedName === 'makeStyles') { + makeStylesLocal = spec.localName } - - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - const targetCount = (ms ? 1 : 0) + (cs ? 1 : 0) + (ws ? 1 : 0) - if (targetCount >= allSpecifiers.length) { - importNodesToRemove.push(imp) + if (spec.importedName === 'withStyles') { + withStylesLocal = spec.localName } } + + if (item.defaultName) { + continue + } + + if (targetSpecs.length >= item.specifiers.length && !styleSources.includes(source)) { + styleSources.push(source) + } } } - return { makeStylesLocal, withStylesLocal, importNodesToRemove } + return { makeStylesLocal, withStylesLocal, styleSources } } /** @@ -317,7 +276,7 @@ function deriveCssModuleFilePath(filename: string): string { return normalized.replace(/\.[^.]+$/, '.module.css') } -function hasCssModuleImport(rootNode: SgNode, cssModuleImportPath: string): boolean { +function hasCssModuleImport(rootNode: SgNode, cssModuleImportPath: string): boolean { const imports = rootNode.findAll({ rule: { kind: 'import_statement' } }) for (const imp of imports) { const stringFrag = imp.find({ rule: { kind: 'string_fragment' } }) @@ -493,7 +452,7 @@ const transform: Codemod = (root, options) => { const edits: Edit[] = [] const dryRun = Boolean(options.dryRun) - const { makeStylesLocal, withStylesLocal, importNodesToRemove } = collectStylesImports(rootNode) + const { makeStylesLocal, withStylesLocal, styleSources } = collectStylesImports(rootNode) if (!makeStylesLocal && !withStylesLocal) { return Promise.resolve(null) @@ -644,17 +603,16 @@ const transform: Codemod = (root, options) => { migrationMetric.increment({ action: 'makeStyles-removed' }) // Add CSS module import after existing imports (avoid mutating imports slated for removal) - const importsToRemoveIds = new Set(importNodesToRemove.map((imp) => imp.id())) - const survivingImports = rootNode - .findAll({ rule: { kind: 'import_statement' } }) - .filter((imp) => !importsToRemoveIds.has(imp.id())) + const survivingImports = listImports(rootNode).filter( + (item) => item.node.kind() === 'import_statement' && !styleSources.includes(item.source), + ) const cssImportLine = `import styles from '${cssModuleImportPath}';` if (!hasCssModuleImport(rootNode, cssModuleImportPath)) { if (survivingImports.length > 0) { const lastSurvivingImport = survivingImports.at(-1) if (lastSurvivingImport) { - const insertAt = lastSurvivingImport.range().end.index + const insertAt = lastSurvivingImport.node.range().end.index edits.push({ startPos: insertAt, endPos: insertAt, @@ -728,9 +686,22 @@ const transform: Codemod = (root, options) => { } // Remove the MUI styles imports - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + for (const source of styleSources) { + const names = new Set(['makeStyles', 'createStyles', 'withStyles']) + for (const item of listImports(rootNode, { from: source })) { + const targetSpecs = item.specifiers.filter((spec) => names.has(spec.importedName)) + if (targetSpecs.length > 0 && targetSpecs.length >= item.specifiers.length && !item.defaultName) { + migrationMetric.increment({ action: 'import-removed' }) + } + } + const edit = removeImport(rootNode, { + type: 'named', + specifiers: ['makeStyles', 'createStyles', 'withStyles'], + from: source, + }) + if (edit) { + edits.push(...edit) + } } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-switch-to-bui-switch.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-switch-to-bui-switch.ts index cace9af0..47654222 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-switch-to-bui-switch.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-switch-to-bui-switch.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -58,188 +59,42 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function collectSwitchImports(rootNode: SgNode): { +function collectSwitchImports(rootNode: SgNode): { switchLocalName: string | null fclLocalName: string | null - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] + defaultSources: string[] + barrelNames: string[] } { let switchLocalName: string | null = null let fclLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + const defaultSources: string[] = [] + const barrelNames: string[] = [] - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Switch')) { - switchLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) + const switchDefault = getImport(rootNode, { type: 'default', from: '@material-ui/core/Switch' }) + if (switchDefault) { + switchLocalName = switchDefault.alias + defaultSources.push('@material-ui/core/Switch') } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/FormControlLabel')) { - fclLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) + const fclDefault = getImport(rootNode, { type: 'default', from: '@material-ui/core/FormControlLabel' }) + if (fclDefault) { + fclLocalName = fclDefault.alias + defaultSources.push('@material-ui/core/FormControlLabel') } - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const foundNames = new Set() - const switchName = getNamedImportLocalName(imp, 'Switch') - if (switchName) { - switchLocalName = switchName - foundNames.add('Switch') - } - const fclName = getNamedImportLocalName(imp, 'FormControlLabel') - if (fclName) { - fclLocalName = fclName - foundNames.add('FormControlLabel') - } - if (foundNames.size > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundNames.size >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) - } + for (const named of getAllImports(rootNode, { type: 'named', name: 'Switch', from: MUI_BARREL_SOURCE })) { + switchLocalName = named.alias + if (!barrelNames.includes('Switch')) { + barrelNames.push('Switch') } } - - return { switchLocalName, fclLocalName, importNodesToRemove, barrelImportsToPrune } -} - -function addBuiImport( - rootNode: SgNode, - importNodesToRemove: SgNode[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const alreadyImported = getNamedImportLocalName(existingImport, 'Switch') !== null - if (!alreadyImported) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const text = namedImports.text() - const inner = text.slice(1, -1).trim() - const names = inner - .split(',') - .map((n) => n.trim()) - .filter(Boolean) - names.push('Switch') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { Switch } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - } + for (const named of getAllImports(rootNode, { type: 'named', name: 'FormControlLabel', from: MUI_BARREL_SOURCE })) { + fclLocalName = named.alias + if (!barrelNames.includes('FormControlLabel')) { + barrelNames.push('FormControlLabel') } - return false } - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - const buiImport = `import { Switch } from '${BUI_SOURCE}';` - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false - } - - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false + return { switchLocalName, fclLocalName, defaultSources, barrelNames } } function getElementName(opening: SgNode): string | null { @@ -367,7 +222,7 @@ function extractControlSwitch(fclOpening: SgNode, switchLocalName: string): } function transformElements( - rootNode: SgNode, + rootNode: SgNode, switchLocalName: string, fclLocalName: string | null, edits: Edit[], @@ -521,7 +376,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { switchLocalName, fclLocalName, importNodesToRemove, barrelImportsToPrune } = collectSwitchImports(rootNode) + const { switchLocalName, fclLocalName, defaultSources, barrelNames } = collectSwitchImports(rootNode) if (!switchLocalName) { return Promise.resolve(null) @@ -529,27 +384,58 @@ const transform: Codemod = (root) => { const { preserveImport, migrated } = transformElements(rootNode, switchLocalName, fclLocalName, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Switch' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue + if (barrelNames.length > 0) { + const removedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) { + const matching = item.specifiers.filter((spec) => removedNames.has(spec.importedName)) + if (matching.length === 0) { + continue + } + if (matching.length >= item.specifiers.length) { + migrationMetric.increment({ action: 'import-removed' }) + } else { + migrationMetric.increment({ action: 'import-pruned' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tabs-to-bui-tabs.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tabs-to-bui-tabs.ts index 66f8b7af..31e63753 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tabs-to-bui-tabs.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tabs-to-bui-tabs.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -22,84 +23,11 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - interface TabImports { localNames: Map - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, { source: string; names: string[] }> -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - return '' - } - - const typeOnly = imp.text().trimStart().startsWith('import type') - const isMultiline = imp.text().includes('\n') - const specTexts = remainingSpecs.map((spec) => spec.text()) - const typeKw = typeOnly ? 'type ' : '' - - return isMultiline - ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` - : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` -} - -function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { - edits.push(imp.replace(getPrunedBarrelImportText(imp, namesToRemove, source))) - migrationMetric.increment({ action: 'import-removed' }) + defaultSources: string[] + coreBarrelNames: string[] + labBarrelNames: string[] } function withTodoComment(comment: string, elementText: string): string { @@ -109,148 +37,43 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function collectTabImports(rootNode: SgNode): TabImports { +function collectTabImports(rootNode: SgNode): TabImports { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + const defaultSources: string[] = [] + const coreBarrelNames: string[] = [] + const labBarrelNames: string[] = [] const corePaths = ['Tabs', 'Tab'] + const labPaths = ['TabContext', 'TabList', 'TabPanel'] for (const componentName of corePaths) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + defaultSources.push(`@material-ui/core/${componentName}`) + } + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: '@material-ui/core' })) { + localNames.set(named.alias, componentName) + if (!coreBarrelNames.includes(componentName)) { + coreBarrelNames.push(componentName) } - importNodesToRemove.push(imp) } } - const labPaths = ['TabContext', 'TabList', 'TabPanel'] for (const componentName of labPaths) { - for (const imp of findImportStatementsFrom(rootNode, `@material-ui/lab/${componentName}`)) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, componentName) - } - importNodesToRemove.push(imp) - } - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const namesToRemove: string[] = [] - for (const componentName of corePaths) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - namesToRemove.push(componentName) - } - } - if (namesToRemove.length > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (namesToRemove.length >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: namesToRemove }) - } - } - } - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab')) { - const namesToRemove: string[] = [] - for (const componentName of labPaths) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - namesToRemove.push(componentName) - } - } - if (namesToRemove.length > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (namesToRemove.length >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, { source: '@material-ui/lab', names: namesToRemove }) - } - } - } - - return { localNames, importNodesToRemove, importSpecifiersToRemove } -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - importSpecifiersToRemove: Map, { source: string; names: string[] }>, - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - } else { - // No stable anchor — fold the BUI import into a partial barrel prune so we - // don't apply two conflicting replacements to the same import node. - const [barrelToFold] = [...importSpecifiersToRemove.entries()] - if (barrelToFold) { - const [imp, { source, names: namesToRemove }] = barrelToFold - const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) - edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - return false - } - - if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true + const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/lab/${componentName}` }) + if (defaultImport) { + localNames.set(defaultImport.alias, componentName) + defaultSources.push(`@material-ui/lab/${componentName}`) + } + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: '@material-ui/lab' })) { + localNames.set(named.alias, componentName) + if (!labBarrelNames.includes(componentName)) { + labBarrelNames.push(componentName) } } } - migrationMetric.increment({ action: 'import-added' }) - return false + return { localNames, defaultSources, coreBarrelNames, labBarrelNames } } function getElementName(opening: SgNode): string | null { @@ -625,7 +448,7 @@ function transformChildren(element: SgNode, localNames: Map } function transformTabElements( - rootNode: SgNode, + rootNode: SgNode, localNames: Map, edits: Edit[], ): { usedBuiNames: Set; preserveImport: boolean; migrated: boolean } { @@ -831,7 +654,7 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectTabImports(rootNode) + const { localNames, defaultSources, coreBarrelNames, labBarrelNames } = collectTabImports(rootNode) if (localNames.size === 0) { return null @@ -839,33 +662,59 @@ const transform: Codemod = async (root) => { const { usedBuiNames, preserveImport, migrated } = transformTabElements(rootNode, localNames, edits) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport( - rootNode, - [...usedBuiNames], - importNodesToRemove, - importSpecifiersToRemove, - edits, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...usedBuiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { - if (handledBarrelIds.has(imp.id())) { + for (const [barrel, barrelNames] of [ + ['@material-ui/core', coreBarrelNames], + ['@material-ui/lab', labBarrelNames], + ] as const) { + if (barrelNames.length === 0) { continue } - pruneBarrelImportSpecifiers(imp, source, names, edits) + const removedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: barrel })) { + const matching = item.specifiers.filter((spec) => removedNames.has(spec.importedName)) + if (matching.length > 0) { + migrationMetric.increment({ action: 'import-removed' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: barrel }) + if (edit) { + edits.push(...edit) + } } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-textfield-to-bui-textfield.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-textfield-to-bui-textfield.ts index c8cafeff..901b0995 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-textfield-to-bui-textfield.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-textfield-to-bui-textfield.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -44,126 +45,31 @@ function withTodoComment(comment: string, elementText: string): string { ` } -function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { - const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) - const remaining: string[] = [] - for (const spec of specifiers) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const importedName = identifiers[0]?.text() - if (importedName && !specifiersToRemove.has(importedName)) { - remaining.push(spec.text()) - } - } - - if (remaining.length === 0) { - return '' - } - - const sourceNode = importStmt.find({ rule: { kind: 'string' } }) - const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` - - if (remaining.length <= 2) { - return `import { ${remaining.join(', ')} } from ${sourceText};` - } - return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` -} - -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function collectTextFieldImports(rootNode: SgNode): { +function collectTextFieldImports(rootNode: SgNode): { textFieldLocalName: string | null - importNodesToRemove: SgNode[] - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] + hasDefaultImport: boolean + hasBarrelImport: boolean } { - let textFieldLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/TextField')) { - textFieldLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const localName = getNamedImportLocalName(imp, 'TextField') - if (localName) { - textFieldLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - barrelImportsToPrune.push({ imp, namesToRemove: new Set(['TextField']) }) - } - } - } + const textFieldDefault = getImport(rootNode, { type: 'default', from: '@material-ui/core/TextField' }) + const textFieldBarrelNamed = getImport(rootNode, { type: 'named', name: 'TextField', from: MUI_BARREL_SOURCE }) - return { textFieldLocalName, importNodesToRemove, barrelImportsToPrune } + return { + textFieldLocalName: textFieldBarrelNamed?.alias ?? textFieldDefault?.alias ?? null, + hasDefaultImport: textFieldDefault !== null, + hasBarrelImport: textFieldBarrelNamed !== null, + } } -function collectFormControlLocalNames(rootNode: SgNode): Set { +function collectFormControlLocalNames(rootNode: SgNode): Set { const names = new Set() - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/FormControl')) { - const name = getDefaultImportName(imp) - if (name) { - names.add(name) - } + const formControlDefault = getAllImports(rootNode, { type: 'default', from: '@material-ui/core/FormControl' }) + for (const imp of formControlDefault) { + names.add(imp.alias) } - for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { - const localName = getNamedImportLocalName(imp, 'FormControl') - if (localName) { - names.add(localName) - } + for (const named of getAllImports(rootNode, { type: 'named', name: 'FormControl', from: MUI_BARREL_SOURCE })) { + names.add(named.alias) } return names @@ -191,84 +97,6 @@ function findParentFormControlOpening(el: SgNode, formControlNames: Set, - names: string[], - importNodesToRemove: SgNode[], - barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], - edits: Edit[], - handledBarrelIds: Set, -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (existingImport) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImported = new Set() - const existingTexts: string[] = [] - for (const spec of specifiers) { - const imported = getImportedName(spec) - if (imported) { - existingImported.add(imported) - } - existingTexts.push(spec.text()) - } - for (const name of names) { - if (!existingImported.has(name)) { - existingTexts.push(name) - existingImported.add(name) - } - } - existingTexts.sort() - edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - } - return false - } - - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...barrelImportsToPrune.map(({ imp }) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - migrationMetric.increment({ action: 'import-added' }) - return false - } - - const [barrelToFold] = barrelImportsToPrune - if (barrelToFold) { - const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) - edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) - handledBarrelIds.add(barrelToFold.imp.id()) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-pruned' }) - return false - } - - if (importNodesToRemove.length >= 1) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } - - migrationMetric.increment({ action: 'import-added' }) - return false -} - function getElementName(opening: SgNode): string | null { for (const child of opening.children()) { if (child.is('identifier') || child.is('member_expression')) { @@ -527,7 +355,7 @@ function resolveTargetComponent(opening: SgNode): string { } function transformTextFieldElements( - rootNode: SgNode, + rootNode: SgNode, textFieldLocalName: string, formControlNames: Set, edits: Edit[], @@ -741,7 +569,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { textFieldLocalName, importNodesToRemove, barrelImportsToPrune } = collectTextFieldImports(rootNode) + const { textFieldLocalName, hasDefaultImport, hasBarrelImport } = collectTextFieldImports(rootNode) if (!textFieldLocalName) { return Promise.resolve(null) @@ -755,34 +583,55 @@ const transform: Codemod = (root) => { edits, ) - let replacedImport = false - const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport( - rootNode, - [...buiNames], - importNodesToRemove, - barrelImportsToPrune, - edits, - handledBarrelIds, - ) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [...buiNames].sort().map((name) => ({ name })), + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } } if (!preserveImport) { - for (const { imp, namesToRemove } of barrelImportsToPrune) { - if (handledBarrelIds.has(imp.id())) { - continue + if (hasBarrelImport) { + for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) { + if (item.specifiers.some((spec) => spec.importedName === 'TextField')) { + if (item.specifiers.length <= 1) { + migrationMetric.increment({ action: 'import-removed' }) + } else { + migrationMetric.increment({ action: 'import-pruned' }) + } + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: ['TextField'], from: MUI_BARREL_SOURCE }) + if (edit) { + edits.push(...edit) } - edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) - migrationMetric.increment({ action: 'import-pruned' }) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + if (hasDefaultImport) { + const edit = removeImport(rootNode, { type: 'default', from: '@material-ui/core/TextField' }) + if (edit) { + edits.push(...edit) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } } diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tooltip-to-bui-tooltip.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tooltip-to-bui-tooltip.ts index d1fe0978..1aa8277c 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tooltip-to-bui-tooltip.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-tooltip-to-bui-tooltip.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -5,6 +6,7 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-tooltip-to-bui-tooltip') const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' const TODO_PROPS = new Set([ 'leaveDelay', @@ -22,160 +24,21 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - -function collectTooltipImports(rootNode: SgNode): { - tooltipLocalName: string | null - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, string[]> -} { +function collectTooltipImports(rootNode: SgNode): string | null { let tooltipLocalName: string | null = null - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, string[]>() - - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Tooltip')) { - tooltipLocalName = getDefaultImportName(imp) - importNodesToRemove.push(imp) - } - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - const localName = getNamedImportLocalName(imp, 'Tooltip') - if (localName) { - tooltipLocalName = localName - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (allSpecifiers.length <= 1) { - importNodesToRemove.push(imp) - } else { - importSpecifiersToRemove.set(imp, ['Tooltip']) - } - } - } - - return { tooltipLocalName, importNodesToRemove, importSpecifiersToRemove } -} - -function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) - }) - - if (remainingSpecs.length === 0) { - edits.push(imp.replace('')) - } else { - const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') - edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) - } - migrationMetric.increment({ action: 'import-removed' }) -} - -function addBuiImport( - rootNode: SgNode, - names: string[], - importNodesToRemove: SgNode[], - edits: Edit[], -): boolean { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const existingImportedNames = new Set( - specifiers.map((spec) => getImportedName(spec)).filter((n): n is string => n !== null), - ) - const namesToAdd = names.filter((name) => !existingImportedNames.has(name)) - if (namesToAdd.length > 0) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const existing = specifiers.map((spec) => spec.text()) - existing.push(...namesToAdd) - existing.sort() - edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - const sortedNames = [...namesToAdd].sort() - edits.push( - existingImport.replace( - `${existingImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`, - ), - ) - migrationMetric.increment({ action: 'import-added' }) - } - } - return false + // Default import: import Tooltip from '@material-ui/core/Tooltip' + const deepImport = getImport(rootNode, { type: 'default', from: '@material-ui/core/Tooltip' }) + if (deepImport) { + tooltipLocalName = deepImport.alias } - const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null - const sortedNames = [...names].sort() - const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) - } else if (importNodesToRemove.length > 0) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(buiImport)) - migrationMetric.increment({ action: 'import-added' }) - return true - } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\n${buiImport}`)) - } + // Named import from barrel: import { Tooltip } from '@material-ui/core' + for (const named of getAllImports(rootNode, { type: 'named', name: 'Tooltip', from: MUI_BARREL_SOURCE })) { + tooltipLocalName = named.alias } - migrationMetric.increment({ action: 'import-added' }) - return false + return tooltipLocalName } function withTodoComment(comment: string, elementText: string): string { @@ -445,7 +308,7 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { tooltipLocalName, importNodesToRemove, importSpecifiersToRemove } = collectTooltipImports(rootNode) + const tooltipLocalName = collectTooltipImports(rootNode) if (!tooltipLocalName) { return null @@ -458,19 +321,50 @@ const transform: Codemod = async (root) => { return early } - const buiNames = ['Tooltip', 'TooltipTrigger'] - const replacedImport = addBuiImport(rootNode, buiNames, importNodesToRemove, edits) + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Tooltip' }, { name: 'TooltipTrigger' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) + } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { - migrationMetric.increment({ action: 'import-removed' }) - continue - } - edits.push(imp.replace('')) + const deepEdit = removeImport(rootNode, { type: 'default', from: '@material-ui/core/Tooltip' }) + if (deepEdit) { + edits.push(...deepEdit) migrationMetric.increment({ action: 'import-removed' }) } - for (const [imp, namesToRemove] of importSpecifiersToRemove) { - pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) { + const hasTooltip = item.specifiers.some((spec) => spec.importedName === 'Tooltip') + if (!hasTooltip) { + continue + } + const edit = removeImport(rootNode, { + type: 'named', + specifiers: ['Tooltip'], + from: MUI_BARREL_SOURCE, + }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } } const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-typography-to-text.ts b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-typography-to-text.ts index 7d961f37..47295d84 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-typography-to-text.ts +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/migrate-mui-typography-to-text.ts @@ -1,3 +1,4 @@ +import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports' import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' @@ -57,183 +58,45 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { - return rootNode.findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string', - has: { - kind: 'string_fragment', - regex: `^${escapeRegex(source)}$`, - }, - }, - }, - }) -} - -function getDefaultImportName(imp: SgNode): string | null { - const clause = imp.find({ rule: { kind: 'import_clause' } }) - if (!clause) { - return null - } - for (const child of clause.children()) { - if (child.is('identifier')) { - return child.text() - } - } - return null -} - -function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { - for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - const [importedNameNode] = identifiers - if (importedNameNode?.text() === targetName) { - const localNameNode = identifiers[1] ?? importedNameNode - return localNameNode.text() - } - } - return null -} - -function getImportedName(spec: SgNode): string | null { - const identifiers = spec.findAll({ - rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, - }) - return identifiers[0]?.text() ?? null -} - interface ImportCollectionResult { localNames: Map - importNodesToRemove: SgNode[] - importSpecifiersToRemove: Map, string[]> + defaultSources: string[] + barrelNames: string[] } -function collectTypographyImports(rootNode: SgNode): ImportCollectionResult { +function collectTypographyImports(rootNode: SgNode): ImportCollectionResult { const localNames = new Map() - const importNodesToRemove: SgNode[] = [] - const importSpecifiersToRemove = new Map, string[]>() + const defaultSources: string[] = [] + const barrelNames: string[] = [] // Default import: import Typography from '@material-ui/core/Typography' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Typography')) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, 'Typography') - } - importNodesToRemove.push(imp) + const typographyDefault = getImport(rootNode, { type: 'default', from: '@material-ui/core/Typography' }) + if (typographyDefault) { + localNames.set(typographyDefault.alias, 'Typography') + defaultSources.push('@material-ui/core/Typography') } // Default import: import DialogContentText from '@material-ui/core/DialogContentText' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/DialogContentText')) { - const name = getDefaultImportName(imp) - if (name) { - localNames.set(name, 'DialogContentText') - } - importNodesToRemove.push(imp) - } - - // Named imports from barrel: import { Typography, DialogContentText } from '@material-ui/core' - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - let foundCount = 0 - - for (const componentName of TARGET_COMPONENTS) { - const localName = getNamedImportLocalName(imp, componentName) - if (localName) { - localNames.set(localName, componentName) - foundCount++ - } - } - - if (foundCount > 0) { - const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundCount >= allSpecifiers.length) { - importNodesToRemove.push(imp) - } else { - const toRemove: string[] = [] - for (const componentName of TARGET_COMPONENTS) { - if (getNamedImportLocalName(imp, componentName)) { - toRemove.push(componentName) - } - } - importSpecifiersToRemove.set(imp, toRemove) - } - } - } - - return { localNames, importNodesToRemove, importSpecifiersToRemove } -} - -function pruneBarrelImportSpecifiers( - imp: SgNode, - namesToRemove: string[], - edits: Edit[], - appendTextImport = false, -): void { - const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { - const importedName = getImportedName(spec) - return importedName !== null && !namesToRemove.includes(importedName) + const dialogContentTextDefault = getImport(rootNode, { + type: 'default', + from: '@material-ui/core/DialogContentText', }) - - if (remainingSpecs.length === 0) { - edits.push(imp.replace('')) - } else { - const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') - let replacement = `import { ${specTexts} } from '@material-ui/core';` - if (appendTextImport) { - replacement += `\nimport { Text } from '${BUI_SOURCE}';` - migrationMetric.increment({ action: 'import-added' }) - } - edits.push(imp.replace(replacement)) + if (dialogContentTextDefault) { + localNames.set(dialogContentTextDefault.alias, 'DialogContentText') + defaultSources.push('@material-ui/core/DialogContentText') } - migrationMetric.increment({ action: 'import-removed' }) -} -function addTextToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): void { - const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) - const existingImport = existingImports[0] ?? null - - if (existingImport) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const hasText = specifiers.some((spec) => getImportedName(spec) === 'Text') - if (!hasText) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const names = specifiers.map((spec) => spec.text()) - names.push('Text') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { Text } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) + // Named imports from barrel: import { Typography, DialogContentText } from '@material-ui/core' + for (const componentName of TARGET_COMPONENTS) { + for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: '@material-ui/core' })) { + localNames.set(named.alias, componentName) + if (!barrelNames.includes(componentName)) { + barrelNames.push(componentName) } } - return } - const removeIds = new Set(importNodesToRemove.map((imp) => imp.id())) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !removeIds.has(imp.id())) ?? null - - if (importNodesToRemove.length === 1 && !anchorImport) { - const [importNode] = importNodesToRemove - if (importNode) { - edits.push(importNode.replace(`import { Text } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-removed' }) - } - } else { - const [firstImport] = allImports - if (firstImport) { - edits.push(firstImport.replace(`import { Text } from '${BUI_SOURCE}';\n${firstImport.text()}`)) - } else if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Text } from '${BUI_SOURCE}';`)) - } - } - - migrationMetric.increment({ action: 'import-added' }) + return { localNames, defaultSources, barrelNames } } function getAttrStringValue( @@ -362,7 +225,11 @@ function buildTextElement(el: SgNode, isSelfClosing: boolean, newProps: str return `${children}` } -function transformTypographyElements(rootNode: SgNode, localNames: Map, edits: Edit[]): boolean { +function transformTypographyElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): boolean { let migrated = false const jsxElements = rootNode.findAll({ rule: { @@ -498,7 +365,7 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectTypographyImports(rootNode) + const { localNames, defaultSources, barrelNames } = collectTypographyImports(rootNode) if (localNames.size === 0) { return null @@ -506,31 +373,53 @@ const transform: Codemod = async (root) => { const migrated = transformTypographyElements(rootNode, localNames, edits) - for (const imp of importNodesToRemove) { - if ( - migrated && - importNodesToRemove.length === 1 && - findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 && - imp.id() === importNodesToRemove[0]?.id() - ) { - continue + if (migrated) { + const addEdit = addImport(rootNode, { + type: 'named', + specifiers: [{ name: 'Text' }], + from: BUI_SOURCE, + }) + if (addEdit) { + edits.push(addEdit) + const hasBuiNamedImport = + rootNode.find({ + rule: { + kind: 'import_statement', + all: [ + { + has: { + kind: 'string', + has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` }, + }, + }, + { has: { kind: 'import_clause', has: { kind: 'named_imports' } } }, + ], + }, + }) !== null + migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' }) } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) } - let addedTextViaBarrelPrune = false - - for (const [imp, namesToRemove] of importSpecifiersToRemove) { - const appendTextImport = migrated && findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 - if (appendTextImport) { - addedTextViaBarrelPrune = true + if (barrelNames.length > 0) { + const removedNames = new Set(barrelNames) + for (const item of listImports(rootNode, { from: '@material-ui/core' })) { + const matching = item.specifiers.filter((spec) => removedNames.has(spec.importedName)) + if (matching.length > 0) { + migrationMetric.increment({ action: 'import-removed' }) + } + } + const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: '@material-ui/core' }) + if (edit) { + edits.push(...edit) } - pruneBarrelImportSpecifiers(imp, namesToRemove, edits, appendTextImport) } - if (migrated && !addedTextViaBarrelPrune) { - addTextToBuiImport(rootNode, importNodesToRemove, edits) + for (const source of defaultSources) { + const edit = removeImport(rootNode, { type: 'default', from: source }) + if (edit) { + edits.push(...edit) + migrationMetric.increment({ action: 'import-removed' }) + } } const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/merge-existing-bui/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/merge-existing-bui/expected.tsx index 03121d3f..a6786b87 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/merge-existing-bui/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/merge-existing-bui/expected.tsx @@ -1,7 +1,4 @@ - - - -import { Accordion, AccordionPanel, AccordionTrigger, Button } from '@backstage/ui'; +import { Button, Accordion, AccordionPanel, AccordionTrigger } from '@backstage/ui'; const MyComponent = () => ( <> diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/simple-accordion/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/simple-accordion/expected.tsx index a79bcad6..ae0ce79b 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/simple-accordion/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-accordion-to-bui-accordion/simple-accordion/expected.tsx @@ -1,7 +1,5 @@ import { Accordion, AccordionPanel, AccordionTrigger } from '@backstage/ui'; - - const MyComponent = () => ( Body content here ); diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/merge-existing-bui/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/merge-existing-bui/expected.tsx index c4e04d1d..32cdbbc3 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/merge-existing-bui/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/merge-existing-bui/expected.tsx @@ -1,5 +1,4 @@ - -import { Alert, Button } from '@backstage/ui'; +import { Button, Alert } from '@backstage/ui'; const MyComponent = () => ( <> diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/with-title/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/with-title/expected.tsx index 36ce2a63..648ad822 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/with-title/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-alert-to-bui-alert/with-title/expected.tsx @@ -1,6 +1,5 @@ import { Alert } from '@backstage/ui'; - const MyComponent = () => ( ); diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/combobox-success/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/combobox-success/expected.tsx index 17613ac6..8a95f772 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/combobox-success/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/combobox-success/expected.tsx @@ -1,6 +1,5 @@ import { Combobox } from '@backstage/ui'; - const FruitPicker = ({ value, onChange, diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/render-input-size-small/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/render-input-size-small/expected.tsx index 3f98dc6b..df2f51ed 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/render-input-size-small/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/render-input-size-small/expected.tsx @@ -1,6 +1,5 @@ import { Combobox } from '@backstage/ui'; - const FruitPicker = ({ value, onChange, diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/search-autocomplete/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/search-autocomplete/expected.tsx index 79c3e3a5..30226d91 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/search-autocomplete/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/search-autocomplete/expected.tsx @@ -1,6 +1,5 @@ import { SearchAutocomplete } from '@backstage/ui'; - const SearchBox = ({ query, setQuery, diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/size-small/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/size-small/expected.tsx index 3f98dc6b..df2f51ed 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/size-small/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-autocomplete-to-combobox/size-small/expected.tsx @@ -1,6 +1,5 @@ import { Combobox } from '@backstage/ui'; - const FruitPicker = ({ value, onChange, diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-avatar-to-bui-avatar/merge-existing-bui/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-avatar-to-bui-avatar/merge-existing-bui/expected.tsx index 4f8b54be..d5a4a3cb 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-avatar-to-bui-avatar/merge-existing-bui/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-avatar-to-bui-avatar/merge-existing-bui/expected.tsx @@ -1,5 +1,4 @@ - -import { Avatar, Button } from '@backstage/ui'; +import { Button, Avatar } from '@backstage/ui'; const MyComponent = () => ( <> diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-button-to-bui-button/merge-existing-bui/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-button-to-bui-button/merge-existing-bui/expected.tsx index d18a1f4a..209954b5 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-button-to-bui-button/merge-existing-bui/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-button-to-bui-button/merge-existing-bui/expected.tsx @@ -1,4 +1,3 @@ - import { Alert, Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/complex-header-todo/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/complex-header-todo/expected.tsx index 9cc9512f..80f4fd35 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/complex-header-todo/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/complex-header-todo/expected.tsx @@ -1,6 +1,4 @@ - import CardHeader from '@material-ui/core/CardHeader'; - import Avatar from '@material-ui/core/Avatar'; import { Card, CardBody } from '@backstage/ui'; diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/header-content-actions/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/header-content-actions/expected.tsx index 83a38c72..5023aa4b 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/header-content-actions/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/header-content-actions/expected.tsx @@ -1,8 +1,5 @@ import { Card, CardBody, CardFooter, CardHeader } from '@backstage/ui'; - - - const MyCard = () => ( DetailsBody copy ); diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/media-todo/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/media-todo/expected.tsx index 507b4537..f328de18 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/media-todo/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/media-todo/expected.tsx @@ -1,8 +1,6 @@ - import CardMedia from '@material-ui/core/CardMedia'; import { Card, CardBody } from '@backstage/ui'; - const MyCard = () => ( <> {/* TODO(backstage-codemod): CardMedia has no BUI equivalent — migrate manually */} diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/simple-card/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/simple-card/expected.tsx index 323fca8e..c03c99e8 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/simple-card/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-card-to-bui-card/simple-card/expected.tsx @@ -1,6 +1,5 @@ import { Card, CardBody } from '@backstage/ui'; - const MyCard = () => ( Hello ); diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-chip-to-tag/merge-existing-bui/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-chip-to-tag/merge-existing-bui/expected.tsx index fb9d8f53..2949e7fd 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-chip-to-tag/merge-existing-bui/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-chip-to-tag/merge-existing-bui/expected.tsx @@ -1,4 +1,3 @@ - import { Button, Tag } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/controlled-dialog/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/controlled-dialog/expected.tsx index 3ba106d7..cbf14d95 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/controlled-dialog/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/controlled-dialog/expected.tsx @@ -1,8 +1,5 @@ import { Button, Dialog, DialogBody, DialogFooter, DialogHeader } from '@backstage/ui'; - - - const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( !isOpen && onClose()}>ConfirmAre you sure? ); diff --git a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/footer-button-attr-order/expected.tsx b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/footer-button-attr-order/expected.tsx index 86947f5f..a8f73101 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/footer-button-attr-order/expected.tsx +++ b/codemods/misc/mui4-to-bui-migration-recipe/tests/migrate-mui-dialog-to-bui-dialog/footer-button-attr-order/expected.tsx @@ -1,8 +1,5 @@ import { Button, Dialog, DialogBody, DialogFooter, DialogHeader } from '@backstage/ui'; - - - const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( !isOpen && onClose()}>ConfirmAre you sure?