diff --git a/package-lock.json b/package-lock.json index c45410cf..25821dbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1440,6 +1440,10 @@ "resolved": "recipes/util-log-to-console-log", "link": true }, + "node_modules/@nodejs/util-print-to-console-log": { + "resolved": "recipes/util-print-to-console-log", + "link": true + }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -4128,6 +4132,17 @@ "@codemod.com/jssg-types": "^1.0.3" } }, + "recipes/util-print-to-console-log": { + "name": "@nodejs/util-print-to-console-log", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "utils": { "name": "@nodejs/codemod-utils", "version": "0.0.0", diff --git a/recipes/util-print-to-console-log/README.md b/recipes/util-print-to-console-log/README.md new file mode 100644 index 00000000..c0e2f517 --- /dev/null +++ b/recipes/util-print-to-console-log/README.md @@ -0,0 +1,46 @@ +# `util.print, util.puts, util.debug, util.error` DEP0026,DEP0027,DEP0028,DEP0029 + +This recipe transforms the usage of log functions from util, `print`, `puts`, `debug`, `error` to use `console.log()` or `console.error()`. + +See [DEP0026](https://nodejs.org/api/deprecations.html#DEP0026). +See [DEP0027](https://nodejs.org/api/deprecations.html#DEP0027). +See [DEP0028](https://nodejs.org/api/deprecations.html#DEP0028). +See [DEP0029](https://nodejs.org/api/deprecations.html#DEP0029). + +## Example + +**Before:** + +```js +const util = require("node:util"); + +util.print("Hello world"); +util.puts("Hello world"); +util.debug("Hello world"); +util.error("Hello world"); +``` + +**After:** + +```js +console.log("Hello world"); +console.log("Hello world"); +console.error("Hello world"); +console.error("Hello world"); +``` + +**Before:** + +```js +const { print, error } = require("node:util"); + +print("Application started"); +error("Processing request"); +``` + +After: + +```js +console.log("Application started"); +console.error("Processing request"); +``` diff --git a/recipes/util-print-to-console-log/codemod.yaml b/recipes/util-print-to-console-log/codemod.yaml new file mode 100644 index 00000000..24009f41 --- /dev/null +++ b/recipes/util-print-to-console-log/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/util-print-to-console-log" +version: 1.0.0 +description: Handle DEP0026, DEP0027, DEP0028, DEP0029 via transforming `util.print|puts|debug|error()` to `console.log|error()` +author: Bruno Rodrigues +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/util-print-to-console-log/package.json b/recipes/util-print-to-console-log/package.json new file mode 100644 index 00000000..67da2e7b --- /dev/null +++ b/recipes/util-print-to-console-log/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/util-print-to-console-log", + "version": "1.0.0", + "description": "Handle DEP0026, DEP0027, DEP0028, DEP0029 via transforming `util.print|puts|debug|error()` to `console.log|error()`", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/util-print-to-console-log", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Bruno Rodrigues", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/util-print-to-console-log/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/util-print-to-console-log/src/workflow.ts b/recipes/util-print-to-console-log/src/workflow.ts new file mode 100644 index 00000000..b0ccd038 --- /dev/null +++ b/recipes/util-print-to-console-log/src/workflow.ts @@ -0,0 +1,126 @@ +import type { + Edit, + Kinds, + Range, + Rule, + SgNode, + SgRoot, + TypesMap, +} from "@codemod.com/jssg-types/main"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; +import { removeBinding } from "@nodejs/codemod-utils/ast-grep/remove-binding"; +import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; + +type BindingToReplace = { + rule: Rule; + node: SgNode>; + binding: string; + replaceFn: (arg: string) => string; +}; + +const updates = [ + { + oldBind: "$.print", + replaceFn: (arg: string) => `console.log(${arg})`, + }, + { + oldBind: "$.puts", + replaceFn: (arg: string) => `console.log(${arg})`, + }, + { + oldBind: "$.debug", + replaceFn: (arg: string) => `console.error(${arg})`, + }, + { + oldBind: "$.error", + replaceFn: (arg: string) => `console.error(${arg})`, + }, +]; + +/* + * Transforms `util.print($$$ARG)` usage to `console.log($$$ARG)`. + * Transforms `util.puts($$$ARG)` usage to `console.log($$$ARG)`. + * Transforms `util.debug($$$ARG)` usage to `console.error($$$ARG)`. + * Transforms `util.error($$$ARG)` usage to `console.error($$$ARG)`. + * + * Steps: + * + * Locate all `util.print|puts|debug|error` import imports, noting the replacement rule, import node, and binding name. + * + * For each binding, replace calls to util.print|puts|debug|error($$$ARG) with the new console.log|error format, + * and determine if the import line should be updated or removed. + * + * Apply all changes, removing or updating the import line as needed. + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + const bindsToReplace: BindingToReplace[] = []; + + const nodeRequires = getNodeRequireCalls(root, "util"); + const nodeImports = getNodeImportStatements(root, "util"); + const importRequireStatement = [...nodeRequires, ...nodeImports]; + + if (!importRequireStatement.length) return null; + + for (const node of importRequireStatement) { + for (const update of updates) { + const bind = resolveBindingPath(node, update.oldBind); + + // if `fn` function ins't coming from `node:util` + if (!bind) continue; + + bindsToReplace.push({ + rule: { + pattern: `${bind}($$$ARG)`, + }, + node, + binding: bind, + replaceFn: update.replaceFn, + }); + } + } + + for (const bind of bindsToReplace) { + const matches = rootNode.findAll({ + rule: bind.rule, + }); + + for (const match of matches) { + const args = match.getMultipleMatches("ARG"); + + const argsStr = args + .map((arg) => { + const text = arg.text(); + if (text === ",") { + // if arg is a comman, add a space at end + return text.padEnd(2, " "); + } + return text; + }) + .join(""); + + const replace = match.replace(bind.replaceFn(argsStr)); + edits.push(replace); + + const result = removeBinding(bind.node, bind.binding.split(".").at(0)); + + if (result?.lineToRemove) { + linesToRemove.push(result.lineToRemove); + } + + if (result?.edit) { + edits.push(result.edit); + } + } + } + + if (!edits.length) return; + + const sourceCode = rootNode.commitEdits(edits); + + return removeLines(sourceCode, linesToRemove); +} diff --git a/recipes/util-print-to-console-log/tests/expected/debug.js b/recipes/util-print-to-console-log/tests/expected/debug.js new file mode 100644 index 00000000..16c7cf20 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/debug.js @@ -0,0 +1,2 @@ + +console.error("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/expected/error.js b/recipes/util-print-to-console-log/tests/expected/error.js new file mode 100644 index 00000000..16c7cf20 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/error.js @@ -0,0 +1,2 @@ + +console.error("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/expected/esm-import.mjs b/recipes/util-print-to-console-log/tests/expected/esm-import.mjs new file mode 100644 index 00000000..3fd202c3 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/esm-import.mjs @@ -0,0 +1,2 @@ + +console.log("Server listening on port 3000"); diff --git a/recipes/util-print-to-console-log/tests/expected/esm-namespace-import.mjs b/recipes/util-print-to-console-log/tests/expected/esm-namespace-import.mjs new file mode 100644 index 00000000..214701e1 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/esm-namespace-import.mjs @@ -0,0 +1,2 @@ + +console.log("Debug message"); diff --git a/recipes/util-print-to-console-log/tests/expected/multiple-arguments.js b/recipes/util-print-to-console-log/tests/expected/multiple-arguments.js new file mode 100644 index 00000000..819d6080 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/multiple-arguments.js @@ -0,0 +1,2 @@ + +console.log("User:", "john", "logged in"); diff --git a/recipes/util-print-to-console-log/tests/expected/multiple-imports.cjs b/recipes/util-print-to-console-log/tests/expected/multiple-imports.cjs new file mode 100644 index 00000000..16f7568d --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/multiple-imports.cjs @@ -0,0 +1,5 @@ +const { inspect } = require("node:util"); + +console.log("Starting application"); +const formatted = inspect({ foo: "bar" }); +console.log(formatted); diff --git a/recipes/util-print-to-console-log/tests/expected/named-import-print-only.js b/recipes/util-print-to-console-log/tests/expected/named-import-print-only.js new file mode 100644 index 00000000..402bee3d --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/named-import-print-only.js @@ -0,0 +1,3 @@ + +console.log("Application started"); +console.log("Processing request"); diff --git a/recipes/util-print-to-console-log/tests/expected/print.js b/recipes/util-print-to-console-log/tests/expected/print.js new file mode 100644 index 00000000..d5fe29bc --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/print.js @@ -0,0 +1,2 @@ + +console.log("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/expected/puts.js b/recipes/util-print-to-console-log/tests/expected/puts.js new file mode 100644 index 00000000..d5fe29bc --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/puts.js @@ -0,0 +1,2 @@ + +console.log("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/expected/real-scenario.js b/recipes/util-print-to-console-log/tests/expected/real-scenario.js new file mode 100644 index 00000000..bb7441e3 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/real-scenario.js @@ -0,0 +1,16 @@ +import http from "node:http"; + +console.log("Debug message"); + +const PORT = process.env.PORT || 3000; + +const server = http.createServer((req, res) => { + console.log(`[${req.method}] ${req.url}`); + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain"); + res.end("Hello World\n"); +}); + +server.listen(PORT, () => { + console.log(`Server running at http://localhost:${PORT}/`); +}); diff --git a/recipes/util-print-to-console-log/tests/expected/simple-scenario.js b/recipes/util-print-to-console-log/tests/expected/simple-scenario.js new file mode 100644 index 00000000..d5fe29bc --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/simple-scenario.js @@ -0,0 +1,2 @@ + +console.log("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/expected/with-interpolation.js b/recipes/util-print-to-console-log/tests/expected/with-interpolation.js new file mode 100644 index 00000000..bfb1d9d7 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/expected/with-interpolation.js @@ -0,0 +1,3 @@ + +const port = 3000; +console.log(`Server started on port ${port}`); diff --git a/recipes/util-print-to-console-log/tests/input/debug.js b/recipes/util-print-to-console-log/tests/input/debug.js new file mode 100644 index 00000000..c1c5db94 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/debug.js @@ -0,0 +1,3 @@ +const util = require("node:util"); + +util.debug("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/input/error.js b/recipes/util-print-to-console-log/tests/input/error.js new file mode 100644 index 00000000..420871a5 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/error.js @@ -0,0 +1,3 @@ +const util = require("node:util"); + +util.error("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/input/esm-import.mjs b/recipes/util-print-to-console-log/tests/input/esm-import.mjs new file mode 100644 index 00000000..b0fd6cd6 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/esm-import.mjs @@ -0,0 +1,3 @@ +import { print } from "node:util"; + +print("Server listening on port 3000"); diff --git a/recipes/util-print-to-console-log/tests/input/esm-namespace-import.mjs b/recipes/util-print-to-console-log/tests/input/esm-namespace-import.mjs new file mode 100644 index 00000000..7f4209ac --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/esm-namespace-import.mjs @@ -0,0 +1,3 @@ +import * as util from "node:util"; + +util.print("Debug message"); diff --git a/recipes/util-print-to-console-log/tests/input/multiple-arguments.js b/recipes/util-print-to-console-log/tests/input/multiple-arguments.js new file mode 100644 index 00000000..30a602eb --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/multiple-arguments.js @@ -0,0 +1,3 @@ +const util = require("node:util"); + +util.print("User:", "john", "logged in"); diff --git a/recipes/util-print-to-console-log/tests/input/multiple-imports.cjs b/recipes/util-print-to-console-log/tests/input/multiple-imports.cjs new file mode 100644 index 00000000..5660a72a --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/multiple-imports.cjs @@ -0,0 +1,5 @@ +const { print, inspect } = require("node:util"); + +print("Starting application"); +const formatted = inspect({ foo: "bar" }); +console.log(formatted); diff --git a/recipes/util-print-to-console-log/tests/input/named-import-print-only.js b/recipes/util-print-to-console-log/tests/input/named-import-print-only.js new file mode 100644 index 00000000..ffc961e9 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/named-import-print-only.js @@ -0,0 +1,4 @@ +const { print } = require("node:util"); + +print("Application started"); +print("Processing request"); diff --git a/recipes/util-print-to-console-log/tests/input/print.js b/recipes/util-print-to-console-log/tests/input/print.js new file mode 100644 index 00000000..ea0515c3 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/print.js @@ -0,0 +1,3 @@ +const util = require("node:util"); + +util.print("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/input/puts.js b/recipes/util-print-to-console-log/tests/input/puts.js new file mode 100644 index 00000000..6642934b --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/puts.js @@ -0,0 +1,3 @@ +const util = require("node:util"); + +util.puts("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/input/real-scenario.js b/recipes/util-print-to-console-log/tests/input/real-scenario.js new file mode 100644 index 00000000..10094ca9 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/real-scenario.js @@ -0,0 +1,17 @@ +import util from "node:util"; +import http from "node:http"; + +util.print("Debug message"); + +const PORT = process.env.PORT || 3000; + +const server = http.createServer((req, res) => { + util.print(`[${req.method}] ${req.url}`); + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain"); + res.end("Hello World\n"); +}); + +server.listen(PORT, () => { + util.print(`Server running at http://localhost:${PORT}/`); +}); diff --git a/recipes/util-print-to-console-log/tests/input/simple-scenario.js b/recipes/util-print-to-console-log/tests/input/simple-scenario.js new file mode 100644 index 00000000..ea0515c3 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/simple-scenario.js @@ -0,0 +1,3 @@ +const util = require("node:util"); + +util.print("Hello world"); diff --git a/recipes/util-print-to-console-log/tests/input/with-interpolation.js b/recipes/util-print-to-console-log/tests/input/with-interpolation.js new file mode 100644 index 00000000..a0366ea2 --- /dev/null +++ b/recipes/util-print-to-console-log/tests/input/with-interpolation.js @@ -0,0 +1,4 @@ +const { print } = require("node:util"); + +const port = 3000; +print(`Server started on port ${port}`); diff --git a/recipes/util-print-to-console-log/workflow.yaml b/recipes/util-print-to-console-log/workflow.yaml new file mode 100644 index 00000000..6c6274e3 --- /dev/null +++ b/recipes/util-print-to-console-log/workflow.yaml @@ -0,0 +1,25 @@ +# 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: Handle DEP0026, DEP0027, DEP0028, DEP0029 via transforming `util.print|puts|debug|error()` to `console.log|error()` + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript diff --git a/utils/src/ast-grep/remove-lines.test.ts b/utils/src/ast-grep/remove-lines.test.ts index 0109ec57..3951380f 100644 --- a/utils/src/ast-grep/remove-lines.test.ts +++ b/utils/src/ast-grep/remove-lines.test.ts @@ -124,4 +124,35 @@ describe("remove-lines", () => { assert.strictEqual(expected, output); }); + + it("Should remove the correct lines when receiving duplicated ranges to remove", () => { + const code = dedent` + line 1 + line 2 + line 3 + line 4 + `; + + const expected = dedent` + line 1 + line 4 + `; + + const range: Range = { + start: { + line: 1, + column: 0, + index: 0, + }, + end: { + line: 2, + column: 0, + index: 0, + }, + }; + + const output = removeLines(code, [range, range, range, range, range]); + + assert.strictEqual(expected, output); + }); }); diff --git a/utils/src/ast-grep/remove-lines.ts b/utils/src/ast-grep/remove-lines.ts index 052fbcf4..4a693b4d 100644 --- a/utils/src/ast-grep/remove-lines.ts +++ b/utils/src/ast-grep/remove-lines.ts @@ -3,6 +3,19 @@ import type { Range } from "@codemod.com/jssg-types/main"; export function removeLines(sourceCode: string, ranges: Range[]) { const lines = sourceCode.split("\n"); + // Remove duplicate ranges to prevent attempting to delete the same range multiple times, + // which could cause inconsistent behavior + const uniqueRemoves = new Map(); + + for (const range of ranges) { + const key = `start-line:${range.start.line},end-line:${range.end.line},start-column:${range.start.column},end-column:${range.end.column}`; + if (!uniqueRemoves.has(key)) { + uniqueRemoves.set(key, range); + } + } + + ranges = Array.from(uniqueRemoves.values()); + let removeCounter = 0; for (const range of ranges) { const start = range.start.line - removeCounter;