diff --git a/package-lock.json b/package-lock.json index a4deddbf..865045ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -432,6 +432,10 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@nodejs/ansi-colors-to-styletext": { + "resolved": "recipes/ansi-colors-to-styletext", + "link": true + }, "node_modules/@nodejs/axios-to-whatwg-fetch": { "resolved": "recipes/axios-to-whatwg-fetch", "link": true @@ -986,6 +990,17 @@ "dev": true, "license": "MIT" }, + "recipes/ansi-colors-to-styletext": { + "name": "@nodejs/ansi-colors-to-styletext", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.3" + } + }, "recipes/axios-to-whatwg-fetch": { "name": "@nodejs/axios-to-whatwg-fetch", "version": "1.0.0", diff --git a/recipes/ansi-colors-to-styletext/README.md b/recipes/ansi-colors-to-styletext/README.md new file mode 100644 index 00000000..962930b5 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/README.md @@ -0,0 +1,60 @@ +# ansi-colors to util.styleText + +This recipe migrates from the external `ansi-colors` package to Node.js's built-in `util.styleText` API. It transforms ansi-colors method calls to use the native Node.js styling functionality. + +## Usage + +Run this codemod with: + +```sh +npx codemod @nodejs/ansi-colors-to-styletext +``` + +## Examples + +```diff +- import ac from 'ansi-colors'; ++ import { styleText, stripVTControlCharacters } from 'node:util'; + +- console.log(ac.red('Error message')); ++ console.log(styleText('red', 'Error message')); + +- console.log(ac.green('Success message')); ++ console.log(styleText('green', 'Success message')); + +- console.log(ac.unstyle(ac.bold.blue('Info message'))); ++ console.log(stripVTControlCharacters(styleText(['bold', 'blue'], 'Info message'))); +``` + +```diff +- const ac = require('ansi-colors'); ++ const { styleText } = require('node:util'); + +- console.log(ac.bold.red('Critical error')); ++ console.log(styleText(['bold', 'red'], 'Critical error')); +``` + +```diff +- const { red, blue } = require('ansi-colors'); ++ const { styleText } = require('node:util'); + +- console.log(red('Error')); ++ console.log(styleText('red', 'Error')); + +- console.log(blue('Info')); ++ console.log(styleText('blue', 'Info')); +``` + +## Compatibility + +- **Removes ansi-colors dependency** from package.json automatically +- **Supports all ansi-colors methods**: colors, background colors, text modifiers, and chained styles +- **Unsupported methods**: `enabled`, `visible`, `unstyle`, `alias`, `theme`, `create` (warnings will be shown) + +## Limitations + +- **Runtime toggles** like `ac.enabled = false` require manual intervention +- **Custom themes and aliases** need to be rewritten as plain objects +- **Dynamic imports with `.then()`** are not transformed and require manual migration + + diff --git a/recipes/ansi-colors-to-styletext/codemod.yaml b/recipes/ansi-colors-to-styletext/codemod.yaml new file mode 100644 index 00000000..c21d49af --- /dev/null +++ b/recipes/ansi-colors-to-styletext/codemod.yaml @@ -0,0 +1,28 @@ +schema_version: "1.0" +name: "@nodejs/ansi-colors-to-styletext" +version: 1.0.0 +capabilities: + - fs + - child_process +description: "Migrate from ansi-colors package to Node.js util.styleText API" +author: Shamya Haria +license: MIT +workflow: workflow.yaml +category: migration +repository: https://github.com/nodejs/userland-migrations + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - nodejs + - ansi-colors + - styletext + +registry: + access: public + visibility: public diff --git a/recipes/ansi-colors-to-styletext/package.json b/recipes/ansi-colors-to-styletext/package.json new file mode 100644 index 00000000..43991d9d --- /dev/null +++ b/recipes/ansi-colors-to-styletext/package.json @@ -0,0 +1,26 @@ +{ + "name": "@nodejs/ansi-colors-to-styletext", + "version": "1.0.0", + "description": "Migrate from ansi-colors package to Node.js util.styleText API", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests && npx codemod jssg test -l json ./src/remove-dependencies.ts ./tests/remove-dependencies --allow-child-process --allow-fs --strictness cst", + "test:workflow": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests", + "test:remove-dependencies": "npx codemod jssg test -l json ./src/remove-dependencies.ts ./tests/remove-dependencies --allow-child-process --allow-fs --strictness cst" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/ansi-colors-to-styletext", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Shamya Haria", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/ansi-colors-to-styletext/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.3" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts b/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts new file mode 100644 index 00000000..73f42041 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts @@ -0,0 +1,13 @@ +import type { Transform } from '@codemod.com/jssg-types/main'; +import type Json from '@codemod.com/jssg-types/langs/json'; +import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; + +const transform: Transform = async (root) => { + return removeDependencies(['ansi-colors', '@types/ansi-colors'], { + packageJsonPath: root.filename(), + runInstall: false, + persistFileWrite: false, + }); +}; + +export default transform; diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts new file mode 100644 index 00000000..df8bfae9 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -0,0 +1,738 @@ +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; + +const ANSI_COLORS_BINDING = 'ansi-colors'; + +const COMPATIBILITY_MAP = { + gray: 'blackBright', + grey: 'blackBright', +}; + +const API_REPLACEMENTS = { + unstyle: 'stripVTControlCharacters', +}; + +const UNSUPPORTED_API_WARNINGS = { + enabled: `util.styleText has no equivalent runtime instance flag. Map this configuration to environment variables instead: set process.env.NO_COLOR='1' or NODE_DISABLE_COLORS='1' before application initialization.`, + visible: `util.styleText lacks a visual toggling mechanism and will always return a string wrapper. Please guard the call site explicitly: const out = visible ? styleText('red', msg) : '';`, + stripColor: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, + hasAnsi: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, + hasColor: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, + alias: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, + theme: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, + create: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, + define: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, +}; + +const UNSUPPORTED_APIS = Object.keys(UNSUPPORTED_API_WARNINGS); + +type RequiredApi = 'styleText' | 'stripVTControlCharacters'; + +/** + * Main codemod entry point. + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const requiredApis = new Set(); + const statements = getModuleDependencies(root, ANSI_COLORS_BINDING); + + if (!statements.length) return null; + + for (const statement of statements) { + const initialEditCount = edits.length; + const destructuredNames = getDestructuredNames(statement); + + if (destructuredNames.length > 0) { + processDestructuredImports( + rootNode, + destructuredNames, + edits, + requiredApis, + ); + } else { + const binding = getDefaultBinding(statement); + + if (binding) { + checkUnsupportedApis(rootNode, binding, root); + + processDefaultImports( + rootNode, + binding, + edits, + requiredApis, + ); + } + } + + if (edits.length > initialEditCount) { + const importReplacement = createImportReplacement( + statement, + requiredApis, + ); + + if (importReplacement) { + edits.push(statement.replace(importReplacement)); + } + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +/** + * Builds the replacement import line based on whether the original was + * ESM, CJS, or dynamic. + */ +function createImportReplacement( + statement: SgNode, + requiredApis: Set, +): string { + const imports = [...requiredApis].join(', '); + + if (!imports) return ''; + + const kind = statement.kind(); + + if (kind === 'import_statement') { + return `import { ${imports} } from 'node:util';`; + } + + if (kind === 'variable_declarator') { + if (statement.field('value')?.kind() === 'await_expression') { + return `{ ${imports} } = await import('node:util')`; + } + + return `{ ${imports} } = require('node:util')`; + } + + return ''; +} + +/** + * Resolves the local binding name for default and namespace imports. + */ +function getDefaultBinding(statement: SgNode): string | null { + const kind = statement.kind(); + + if (kind === 'import_statement') { + const defaultImport = statement.find({ + rule: { + kind: 'identifier', + inside: { + kind: 'import_clause', + not: { + any: [ + { has: { kind: 'named_imports' } }, + { has: { kind: 'namespace_import' } }, + ], + }, + }, + }, + }); + + if (defaultImport) return defaultImport.text(); + + const namespaceImport = statement.find({ + rule: { + kind: 'identifier', + inside: { kind: 'namespace_import' }, + }, + }); + + return namespaceImport?.text() ?? null; + } + + if (kind === 'variable_declarator') { + const nameField = statement.field('name'); + + if (nameField?.kind() === 'identifier') { + return nameField.text(); + } + } + + return null; +} + +/** + * Collects named import bindings from ESM and CJS destructured statements. + */ +function getDestructuredNames( + statement: SgNode, +): Array<{ imported: string; local: string }> { + const names: Array<{ imported: string; local: string }> = []; + const kind = statement.kind(); + + if (kind === 'import_statement') { + const namedImports = statement.find({ + rule: { kind: 'named_imports' }, + }); + + if (namedImports) { + for (const specifier of namedImports.findAll({ + rule: { kind: 'import_specifier' }, + })) { + const importedName = specifier.field('name'); + const alias = specifier.field('alias'); + + if (importedName) { + const imported = importedName.text(); + const local = alias ? alias.text() : imported; + const mappedImported = + COMPATIBILITY_MAP[ + imported as keyof typeof COMPATIBILITY_MAP + ]; + + names.push({ + imported: mappedImported ?? imported, + local, + }); + } + } + } + } else if (kind === 'variable_declarator') { + const nameField = statement.field('name'); + + if (nameField?.kind() === 'object_pattern') { + const properties = nameField.findAll({ + rule: { + any: [ + { kind: 'shorthand_property_identifier_pattern' }, + { kind: 'pair_pattern' }, + ], + }, + }); + + for (const prop of properties) { + if ( + prop.kind() === + 'shorthand_property_identifier_pattern' + ) { + const name = prop.text(); + const mappedImported = + COMPATIBILITY_MAP[ + name as keyof typeof COMPATIBILITY_MAP + ]; + + names.push({ + imported: mappedImported ?? name, + local: name, + }); + } else if (prop.kind() === 'pair_pattern') { + const key = prop.field('key'); + const value = prop.field('value'); + + if (key && value) { + const imported = key.text(); + const mappedImported = + COMPATIBILITY_MAP[ + imported as keyof typeof COMPATIBILITY_MAP + ]; + + names.push({ + imported: mappedImported ?? imported, + local: value.text(), + }); + } + } + } + } + } + + return names; +} + +/** + * Walks a member expression chain and returns the ordered style names, + * or null if the chain doesn't start from the expected binding. + */ +function extractChainedStyles( + node: SgNode, + binding: string, +): string[] | null { + const objectNode = node.field('object'); + const propertyNode = node.field('property'); + + if ( + !objectNode || + !propertyNode || + propertyNode.kind() !== 'property_identifier' + ) { + return null; + } + + const propertyName = propertyNode.text(); + + if ( + UNSUPPORTED_APIS.includes(propertyName) || + propertyName in API_REPLACEMENTS + ) { + return null; + } + + const normalizedName = + COMPATIBILITY_MAP[ + propertyName as keyof typeof COMPATIBILITY_MAP + ] ?? propertyName; + + if (objectNode.kind() === 'identifier') { + if (objectNode.text() !== binding) return null; + + return [normalizedName]; + } + + if (objectNode.kind() === 'member_expression') { + const nested = extractChainedStyles(objectNode, binding); + + if (!nested) return null; + + return [...nested, normalizedName]; + } + + return null; +} + +/** + * Emits targeted warnings for ansi-colors APIs with no util.styleText equivalent. + */ +function checkUnsupportedApis( + rootNode: SgNode, + binding: string, + root: SgRoot, +): void { + const memberExpressions = rootNode.findAll({ + rule: { kind: 'member_expression' }, + }); + + for (const memberExpr of memberExpressions) { + const objectNode = memberExpr.field('object'); + const propertyNode = memberExpr.field('property'); + + if (!objectNode || !propertyNode) continue; + if (objectNode.text() !== binding) continue; + if (propertyNode.kind() !== 'property_identifier') continue; + + const propertyName = propertyNode.text(); + + // `unstyle` has a native Node.js equivalent, so it should not + // produce an unsupported API warning. + if (propertyName in API_REPLACEMENTS) continue; + + if (!UNSUPPORTED_APIS.includes(propertyName)) continue; + + const filename = root.filename(); + const { start } = memberExpr.range(); + const message = + UNSUPPORTED_API_WARNINGS[ + propertyName as keyof typeof UNSUPPORTED_API_WARNINGS + ]; + + console.warn( + `${filename}:${start.line}:${start.column}: ${message}`, + ); + } +} + +/** + * Transforms calls from destructured bindings. + * + * red('text') becomes: + * styleText('red', 'text') + * + * unstyle('text') becomes: + * stripVTControlCharacters('text') + */ +function processDestructuredImports( + rootNode: SgNode, + destructuredNames: Array<{ imported: string; local: string }>, + edits: Edit[], + requiredApis: Set, +): void { + for (const { local, imported } of destructuredNames) { + const replacement = API_REPLACEMENTS[ + imported as keyof typeof API_REPLACEMENTS + ]; + + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + pattern: `${local}($$$ARGS)`, + }, + }); + + for (const call of calls) { + const args = call.field('arguments'); + + if (!args) continue; + + const textArg = args.text().slice(1, -1); + + if (replacement) { + requiredApis.add( + replacement as RequiredApi, + ); + + edits.push( + call.replace( + `${replacement}(${textArg})`, + ), + ); + } else { + requiredApis.add('styleText'); + + edits.push( + call.replace( + `styleText('${imported}', ${textArg})`, + ), + ); + } + } + } +} + +/** + * Represents the transformation that can be applied to an ansi-colors + * call expression. + */ +type CallTransformation = { + replacement: string; + requiredApis: RequiredApi[]; +}; + +/** + * Returns the transformation for a call expression if it is an + * ansi-colors call belonging to the provided binding. + * + * Examples: + * + * colors.unstyle(value) + * -> + * stripVTControlCharacters(value) + * + * colors.bold.red(value) + * -> + * styleText(['bold', 'red'], value) + */ +function getCallTransformation( + call: SgNode, + binding: string, +): CallTransformation | null { + const functionNode = call.field('function'); + + if (functionNode?.kind() !== 'member_expression') { + return null; + } + + const propertyNode = functionNode.field('property'); + const objectNode = functionNode.field('object'); + + if ( + propertyNode?.kind() === 'property_identifier' && + propertyNode.text() in API_REPLACEMENTS && + objectNode?.kind() === 'identifier' && + objectNode.text() === binding + ) { + const replacement = + API_REPLACEMENTS[ + propertyNode.text() as keyof typeof API_REPLACEMENTS + ]; + + return { + replacement, + requiredApis: [replacement as RequiredApi], + }; + } + + const styles = extractChainedStyles( + functionNode, + binding, + ); + + if (!styles?.length) return null; + + const styleTextArgument = + styles.length === 1 + ? `'${styles[0]}'` + : `[${styles.map(style => `'${style}'`).join(', ')}]`; + + return { + replacement: `styleText(${styleTextArgument}`, + requiredApis: ['styleText'], + }; +} + +/** + * Compares two AST positions. + */ +function positionBeforeOrEqual( + a: { line: number; column: number }, + b: { line: number; column: number }, +): boolean { + return ( + a.line < b.line || + (a.line === b.line && a.column <= b.column) + ); +} + +/** + * Returns true when `outer` completely contains `inner`. + */ +function rangeContains( + outer: ReturnType['range']>, + inner: ReturnType['range']>, +): boolean { + return ( + positionBeforeOrEqual(outer.start, inner.start) && + positionBeforeOrEqual(inner.end, outer.end) + ); +} + +/** + * Returns only the outermost relevant calls from a collection of calls. + * + * This prevents overlapping edits such as: + * + * colors.unstyle(colors.bold('hello')) + * + * from producing separate edits for both calls. + * + * The outer call is edited once, and its nested calls are rendered + * recursively. + */ +function getOutermostCalls( + calls: SgNode[], +): SgNode[] { + return calls.filter(call => { + const callRange = call.range(); + + return !calls.some(other => { + if (other === call) return false; + + const otherRange = other.range(); + + if (!rangeContains(otherRange, callRange)) { + return false; + } + + // Equal ranges are not considered containment. + return ( + otherRange.start.line !== callRange.start.line || + otherRange.start.column !== callRange.start.column || + otherRange.end.line !== callRange.end.line || + otherRange.end.column !== callRange.end.column + ); + }); + }); +} + +/** + * Returns relevant nested calls that are not themselves contained by + * another relevant nested call. + * + * For: + * + * colors.unstyle( + * colors.bold( + * colors.blue('hello') + * ) + * ) + * + * the first level returned here is `colors.bold(...)`. + * `colors.blue(...)` is handled recursively by that call. + */ +function getOutermostNestedCalls( + args: SgNode, + binding: string, +): SgNode[] { + const candidates = args.findAll({ + rule: { kind: 'call_expression' }, + }).filter(call => { + return getCallTransformation(call, binding) !== null; + }); + + return getOutermostCalls(candidates); +} + +/** + * Recursively transforms ansi-colors calls nested inside an argument list. + * + * This is the important part for cases such as: + * + * colors.unstyle(colors.bold.blue('\u001b[34mhello\u001b[39m')) + * + * which becomes: + * + * stripVTControlCharacters(styleText(['bold', 'blue'], '\u001b[34mhello\u001b[39m')) + * + * The ANSI escape sequences are intentionally preserved here because + * `stripVTControlCharacters` is the outer operation that removes them. + */ +function transformNestedArguments( + args: SgNode, + binding: string, + requiredApis: Set, +): string { + let text = args.text().slice(1, -1); + + const nestedCalls = getOutermostNestedCalls( + args, + binding, + ); + + if (!nestedCalls.length) { + return text; + } + + /** + * `args.text()` and the nested call texts are both sourced from the + * same original AST, so processing the calls in source order lets us + * safely replace repeated nested expressions as well. + */ + nestedCalls.sort((a, b) => { + const aRange = a.range(); + const bRange = b.range(); + + if (aRange.start.line !== bRange.start.line) { + return aRange.start.line - bRange.start.line; + } + + return aRange.start.column - bRange.start.column; + }); + + let searchFrom = 0; + + for (const nestedCall of nestedCalls) { + const originalText = nestedCall.text(); + const transformedText = transformCallExpression( + nestedCall, + binding, + requiredApis, + ); + + if (transformedText === originalText) continue; + + const index = text.indexOf( + originalText, + searchFrom, + ); + + if (index === -1) continue; + + text = + text.slice(0, index) + + transformedText + + text.slice(index + originalText.length); + + searchFrom = + index + + transformedText.length; + } + + return text; +} + +/** + * Recursively transforms one ansi-colors call expression. + * + * This function does not create an AST edit itself. It renders the + * transformed call as a string so an outer call can incorporate the + * transformed result into its own replacement. + */ +function transformCallExpression( + call: SgNode, + binding: string, + requiredApis: Set, +): string { + const transformation = getCallTransformation( + call, + binding, + ); + + if (!transformation) { + return call.text(); + } + + for (const api of transformation.requiredApis) { + requiredApis.add(api); + } + + const args = call.field('arguments'); + + if (!args) { + return call.text(); + } + + const transformedArgs = transformNestedArguments( + args, + binding, + requiredApis, + ); + + if ( + transformation.replacement === + 'stripVTControlCharacters' + ) { + return `stripVTControlCharacters(${transformedArgs})`; + } + + return `${transformation.replacement}, ${transformedArgs})`; +} + +/** + * Transforms chained member calls, including nested ansi-colors calls. + * + * ac.bold.red('text') becomes: + * styleText(['bold', 'red'], 'text') + * + * ac.unstyle('text') becomes: + * stripVTControlCharacters('text') + * + * Nested calls are transformed recursively: + * + * ac.unstyle(ac.bold.red('text')) + * + * becomes: + * stripVTControlCharacters(styleText(['bold', 'red'], 'text')) + */ +function processDefaultImports( + rootNode: SgNode, + binding: string, + edits: Edit[], + requiredApis: Set, +): void { + const calls = rootNode + .findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }) + .filter(call => { + return getCallTransformation(call, binding) !== null; + }); + + /** + * Only edit the outermost relevant call. Nested calls are rendered + * recursively as part of the outer replacement, which avoids + * overlapping edits. + */ + const outermostCalls = getOutermostCalls(calls); + + for (const call of outermostCalls) { + const replacement = transformCallExpression( + call, + binding, + requiredApis, + ); + + if (replacement === call.text()) continue; + + edits.push(call.replace(replacement)); + } +} diff --git a/recipes/ansi-colors-to-styletext/tests/basic-color/expected.js b/recipes/ansi-colors-to-styletext/tests/basic-color/expected.js new file mode 100644 index 00000000..45cb524e --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/basic-color/expected.js @@ -0,0 +1,3 @@ +const { styleText } = require('node:util'); + +console.log(styleText('red', 'Error message')); diff --git a/recipes/ansi-colors-to-styletext/tests/basic-color/input.js b/recipes/ansi-colors-to-styletext/tests/basic-color/input.js new file mode 100644 index 00000000..50f48cf6 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/basic-color/input.js @@ -0,0 +1,3 @@ +const ansi = require('ansi-colors'); + +console.log(ansi.red('Error message')); diff --git a/recipes/ansi-colors-to-styletext/tests/chained-styles/expected.js b/recipes/ansi-colors-to-styletext/tests/chained-styles/expected.js new file mode 100644 index 00000000..5cb97e89 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/chained-styles/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require('node:util'); + +console.log(styleText(['bold', 'red'], 'Critical error')); +console.log(styleText(['bgBlue', 'white', 'bold'], 'HEADER')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/chained-styles/input.js b/recipes/ansi-colors-to-styletext/tests/chained-styles/input.js new file mode 100644 index 00000000..f7a8061b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/chained-styles/input.js @@ -0,0 +1,4 @@ +const ac = require('ansi-colors'); + +console.log(ac.bold.red('Critical error')); +console.log(ac.bgBlue.white.bold('HEADER')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/expected.js b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/expected.js new file mode 100644 index 00000000..3b36aa2f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/expected.js @@ -0,0 +1,5 @@ +const { styleText } = require('node:util'); + +console.log(styleText('red', 'Error')); +console.log(styleText('blue', 'Info')); +console.log(styleText('bold', 'Important')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/input.js b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/input.js new file mode 100644 index 00000000..d6dcf16a --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/input.js @@ -0,0 +1,5 @@ +const { red, blue, bold } = require('ansi-colors'); + +console.log(red('Error')); +console.log(blue('Info')); +console.log(bold('Important')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/expected.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/expected.js new file mode 100644 index 00000000..aacd4619 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/expected.js @@ -0,0 +1,4 @@ +const { styleText } = await import('node:util'); + +console.log(styleText('red', 'Error')); +console.log(styleText(['bold', 'green'], 'Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/input.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/input.js new file mode 100644 index 00000000..5b0b431a --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/input.js @@ -0,0 +1,4 @@ +const ac = await import('ansi-colors'); + +console.log(ac.red('Error')); +console.log(ac.bold.green('Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/expected.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/expected.js new file mode 100644 index 00000000..5896a512 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/expected.js @@ -0,0 +1,4 @@ +import('ansi-colors').then((ac) => { + console.log(ac.red('Error')); + console.log(ac.bold.green('Success')); +}); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/input.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/input.js new file mode 100644 index 00000000..5896a512 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/input.js @@ -0,0 +1,4 @@ +import('ansi-colors').then((ac) => { + console.log(ac.red('Error')); + console.log(ac.bold.green('Success')); +}); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/esm-default-import/expected.js b/recipes/ansi-colors-to-styletext/tests/esm-default-import/expected.js new file mode 100644 index 00000000..3b597123 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/esm-default-import/expected.js @@ -0,0 +1,4 @@ +import { styleText } from 'node:util'; + +console.log(styleText('red', 'Error')); +console.log(styleText(['bold', 'green'], 'Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/esm-default-import/input.js b/recipes/ansi-colors-to-styletext/tests/esm-default-import/input.js new file mode 100644 index 00000000..838d2868 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/esm-default-import/input.js @@ -0,0 +1,4 @@ +import ac from 'ansi-colors'; + +console.log(ac.red('Error')); +console.log(ac.bold.green('Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/gray-alias/expected.js b/recipes/ansi-colors-to-styletext/tests/gray-alias/expected.js new file mode 100644 index 00000000..7d5059bf --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/gray-alias/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require('node:util'); + +console.log(styleText('blackBright', 'Hint text')); +console.log(styleText('blackBright', 'Another hint')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/gray-alias/input.js b/recipes/ansi-colors-to-styletext/tests/gray-alias/input.js new file mode 100644 index 00000000..d7a49025 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/gray-alias/input.js @@ -0,0 +1,4 @@ +const ac = require('ansi-colors'); + +console.log(ac.gray('Hint text')); +console.log(ac.grey('Another hint')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/expected.js b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/expected.js new file mode 100644 index 00000000..5a87241b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/expected.js @@ -0,0 +1,5 @@ +import { styleText } from 'node:util'; +import { styleText } from 'node:util'; + +console.log(styleText(['bold', 'blue'], 'Header')); +console.log(styleText('red', 'Error')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/input.js b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/input.js new file mode 100644 index 00000000..b2071b8d --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/input.js @@ -0,0 +1,5 @@ +import ac from 'ansi-colors'; +import { red } from 'ansi-colors'; + +console.log(ac.bold.blue('Header')); +console.log(red('Error')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/multiple-uses/expected.js b/recipes/ansi-colors-to-styletext/tests/multiple-uses/expected.js new file mode 100644 index 00000000..8774adc7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/multiple-uses/expected.js @@ -0,0 +1,5 @@ +const { styleText } = require('node:util'); + +console.log(styleText('red', 'Error')); +console.log(styleText('green', 'Success')); +console.log(styleText('blue', 'Info')); diff --git a/recipes/ansi-colors-to-styletext/tests/multiple-uses/input.js b/recipes/ansi-colors-to-styletext/tests/multiple-uses/input.js new file mode 100644 index 00000000..b290699b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/multiple-uses/input.js @@ -0,0 +1,5 @@ +const ansi = require('ansi-colors'); + +console.log(ansi.red('Error')); +console.log(ansi.green('Success')); +console.log(ansi.blue('Info')); diff --git a/recipes/ansi-colors-to-styletext/tests/no-match/expected.js b/recipes/ansi-colors-to-styletext/tests/no-match/expected.js new file mode 100644 index 00000000..271b3129 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/no-match/expected.js @@ -0,0 +1,3 @@ +const chalk = require('chalk'); + +console.log(chalk.red('text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/no-match/input.js b/recipes/ansi-colors-to-styletext/tests/no-match/input.js new file mode 100644 index 00000000..271b3129 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/no-match/input.js @@ -0,0 +1,3 @@ +const chalk = require('chalk'); + +console.log(chalk.red('text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/expected.json b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/expected.json new file mode 100644 index 00000000..33d25829 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/expected.json @@ -0,0 +1,10 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/input.json b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/input.json new file mode 100644 index 00000000..490b36b8 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/input.json @@ -0,0 +1,12 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "ansi-colors": "^4.1.3", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/ansi-colors": "^1.0.0", + "typescript": "^5.6.0" + } +} \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/reusable-functions/expected.js b/recipes/ansi-colors-to-styletext/tests/reusable-functions/expected.js new file mode 100644 index 00000000..c005ca59 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/reusable-functions/expected.js @@ -0,0 +1,3 @@ +const { styleText } = require('node:util'); +const errorStyle = (msg) => styleText(['bold', 'red'], msg); +const status = level === 'error' ? styleText(['bold', 'red'], 'boom') : styleText('yellow', 'slow'); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/reusable-functions/input.js b/recipes/ansi-colors-to-styletext/tests/reusable-functions/input.js new file mode 100644 index 00000000..cbea1ae5 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/reusable-functions/input.js @@ -0,0 +1,3 @@ +const ac = require('ansi-colors'); +const errorStyle = (msg) => ac.bold.red(msg); +const status = level === 'error' ? ac.bold.red('boom') : ac.yellow('slow'); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/string-concatenation/expected.js b/recipes/ansi-colors-to-styletext/tests/string-concatenation/expected.js new file mode 100644 index 00000000..288996b7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/string-concatenation/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require('node:util'); + +console.log('Hello, ' + styleText('green', 'World') + '!'); +console.log(styleText(['bgRedBright', 'white'], ' ERR ') + ' ' + styleText('red', 'File not found')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/string-concatenation/input.js b/recipes/ansi-colors-to-styletext/tests/string-concatenation/input.js new file mode 100644 index 00000000..3641b676 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/string-concatenation/input.js @@ -0,0 +1,4 @@ +const ac = require('ansi-colors'); + +console.log('Hello, ' + ac.green('World') + '!'); +console.log(ac.bgRedBright.white(' ERR ') + ' ' + ac.red('File not found')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/template-literals/expected.js b/recipes/ansi-colors-to-styletext/tests/template-literals/expected.js new file mode 100644 index 00000000..8c4cfdea --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/template-literals/expected.js @@ -0,0 +1,6 @@ +const { styleText } = require('node:util'); +const file = 'server.js'; +const line = '42'; + +console.log(`${styleText(['bold', 'red'], '[ERR]')} ${styleText('dim', file)}:${styleText('dim', line)}`); +console.log(`Multi-badge: ${styleText(['bgRed', 'white'], ' ERR ')} ${styleText(['bgGreen', 'black'], ' OK ')}`); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/template-literals/input.js b/recipes/ansi-colors-to-styletext/tests/template-literals/input.js new file mode 100644 index 00000000..59771b8f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/template-literals/input.js @@ -0,0 +1,6 @@ +const ac = require('ansi-colors'); +const file = 'server.js'; +const line = '42'; + +console.log(`${ac.bold.red('[ERR]')} ${ac.dim(file)}:${ac.dim(line)}`); +console.log(`Multi-badge: ${ac.bgRed.white(' ERR ')} ${ac.bgGreen.black(' OK ')}`); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-bis/expected.js b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/expected.js new file mode 100644 index 00000000..cd9af2df --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/expected.js @@ -0,0 +1,15 @@ +import { stripVTControlCharacters, styleText } from 'node:util'; + +const foo = stripVTControlCharacters(styleText(['bold', 'blue'], 'hello')); + +/** + * @param {string} text + * @returns {string} + */ +const restyle = (text) => { + const stripped = stripVTControlCharacters(text); + + return styleText(['bold', 'blue'], stripped); +} + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-bis/input.js b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/input.js new file mode 100644 index 00000000..99100c24 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/input.js @@ -0,0 +1,15 @@ +import colors from 'ansi-colors'; + +const foo = colors.unstyle(colors.bold.blue('hello')); + +/** + * @param {string} text + * @returns {string} + */ +const restyle = (text) => { + const stripped = colors.unstyle(text); + + return colors.bold.blue(stripped); +} + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/expected.js b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/expected.js new file mode 100644 index 00000000..2560db7f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/expected.js @@ -0,0 +1,5 @@ +const { stripVTControlCharacters } = require('node:util'); + +const foo = stripVTControlCharacters('\u001b[31mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/input.js b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/input.js new file mode 100644 index 00000000..3d3b88a3 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/input.js @@ -0,0 +1,5 @@ +const ac = require('ansi-colors'); + +const foo = ac.unstyle('\u001b[31mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle/expected.js b/recipes/ansi-colors-to-styletext/tests/unstyle/expected.js new file mode 100644 index 00000000..7868a3e4 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle/expected.js @@ -0,0 +1,5 @@ +import { stripVTControlCharacters } from 'node:util'; + +const foo = stripVTControlCharacters('\u001b[34mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle/input.js b/recipes/ansi-colors-to-styletext/tests/unstyle/input.js new file mode 100644 index 00000000..e7bf2692 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle/input.js @@ -0,0 +1,5 @@ +import colors from 'ansi-colors'; + +const foo = colors.unstyle('\u001b[34mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js new file mode 100644 index 00000000..996ceee2 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js @@ -0,0 +1,2 @@ +const ac = require('ansi-colors'); +ac.enabled = false; \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js new file mode 100644 index 00000000..996ceee2 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js @@ -0,0 +1,2 @@ +const ac = require('ansi-colors'); +ac.enabled = false; \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js new file mode 100644 index 00000000..eacf6422 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js @@ -0,0 +1,7 @@ +const { styleText } = require('node:util'); +ac.enabled = false; +ac.visible = false; +ac.alias('error', ac.bold.red); +ac.theme({ error: ac.bold.red }); + +console.log(styleText('red', 'text')); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js new file mode 100644 index 00000000..8fa02523 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js @@ -0,0 +1,7 @@ +const ac = require('ansi-colors'); +ac.enabled = false; +ac.visible = false; +ac.alias('error', ac.bold.red); +ac.theme({ error: ac.bold.red }); + +console.log(ac.red('text')); diff --git a/recipes/ansi-colors-to-styletext/tests/with-import/expected.js b/recipes/ansi-colors-to-styletext/tests/with-import/expected.js new file mode 100644 index 00000000..7e778034 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/with-import/expected.js @@ -0,0 +1,5 @@ +import { styleText } from 'node:util'; + +const error = styleText('red', 'Error'); +const success = styleText('green', 'Success'); +const important = styleText('bold', 'Important'); diff --git a/recipes/ansi-colors-to-styletext/tests/with-import/input.js b/recipes/ansi-colors-to-styletext/tests/with-import/input.js new file mode 100644 index 00000000..e75e039a --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/with-import/input.js @@ -0,0 +1,5 @@ +import { red, green, bold } from 'ansi-colors'; + +const error = red('Error'); +const success = green('Success'); +const important = bold('Important'); diff --git a/recipes/ansi-colors-to-styletext/workflow.yaml b/recipes/ansi-colors-to-styletext/workflow.yaml new file mode 100644 index 00000000..e68a1534 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/workflow.yaml @@ -0,0 +1,42 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Migrate from ansi-colors to Node.js built-in util.styleText API + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + + - id: remove-dependencies + name: Remove ansi-colors dependency + type: automatic + steps: + - name: Detect package manager and remove ansi-colors dependency + js-ast-grep: + js_file: src/remove-dependencies.ts + base_path: . + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + language: typescript + capabilities: + - child_process + - fs \ No newline at end of file diff --git a/utils/src/ast-grep/update-binding.ts b/utils/src/ast-grep/update-binding.ts index fcde97b2..b88532b8 100644 --- a/utils/src/ast-grep/update-binding.ts +++ b/utils/src/ast-grep/update-binding.ts @@ -185,7 +185,7 @@ function handleNamedImportBindings( }, }); - if (Boolean(namespaceImport) && namespaceImport.text() === options.old) { + if (namespaceImport && namespaceImport.text() === options.old) { if (options?.new) { // Namespace imports can only be replaced with a single binding const newName = Array.isArray(options.new) ? options.new[0] : options.new;