diff --git a/utils/README.md b/utils/README.md index b5d97ea5..d644eea8 100644 --- a/utils/README.md +++ b/utils/README.md @@ -72,7 +72,7 @@ import { resolveBindingPath } from '@nodejs/codemod-utils'; #### `removeBinding(node, binding)` -Removes a specific binding from destructured imports/requires, or removes the entire statement if it's the only binding. +Removes a specific binding from imports/requires, or removes the entire statement if it's the only binding. ```typescript import { removeBinding } from '@nodejs/codemod-utils'; @@ -82,6 +82,26 @@ import { removeBinding } from '@nodejs/codemod-utils'; // Given: const { isNativeError } = require('node:util'); // removeBinding(node, 'isNativeError') → Returns line range to remove entire statement + +// Given: const util = require('node:util'); +// removeBinding(node, 'util') → Returns line range to remove entire statement +``` + +#### `updateBinding(node, { old, new })` + +Updates a specific binding from imports/requires. It can be used to replace, add, or remove bindings. + +```typescript +import { updateBinding } from '@nodejs/codemod-utils'; + +// Given: const { isNativeError } = require('node:util'); +// updateBinding(node, {old: 'isNativeError', new: 'types'}) → Edit to: const { types } = require('node:util'); + +// Given: const { isNativeError } = require('node:util'); +// updateBinding(node, {old: undefined, new: 'types'}) → Edit to: const { isNativeError, types } = require('node:util'); + +// Given: const { isNativeError, types } = require('node:util'); +// updateBinding(node, {old: isNativeError, new: undefined}) → Works exactly as removeBinding util: const { types } = require('node:util'); ``` ### Code Manipulation diff --git a/utils/src/ast-grep/remove-binding.test.ts b/utils/src/ast-grep/remove-binding.test.ts index bcc5db25..e4d0b73a 100644 --- a/utils/src/ast-grep/remove-binding.test.ts +++ b/utils/src/ast-grep/remove-binding.test.ts @@ -3,6 +3,8 @@ import { describe, it } from "node:test"; import astGrep from "@ast-grep/napi"; import dedent from "dedent"; import { removeBinding } from "./remove-binding.ts"; +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; describe("remove-binding", () => { it("should remove the entire require statement when the only imported binding is removed", () => { @@ -11,7 +13,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const requireStatement = node.find({ rule: { @@ -35,7 +37,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const requireStatement = node.find({ rule: { @@ -55,7 +57,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -79,7 +81,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const requireStatement = node.find({ rule: { @@ -101,7 +103,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const requireStatement = node.find({ rule: { @@ -125,7 +127,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -149,7 +151,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -169,7 +171,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -193,7 +195,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -212,7 +214,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -236,7 +238,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -258,7 +260,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -277,7 +279,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -301,7 +303,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -323,7 +325,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -345,7 +347,7 @@ describe("remove-binding", () => { `; const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); - const node = rootNode.root(); + const node = rootNode.root() as SgNode; const importStatement = node.find({ rule: { @@ -360,4 +362,68 @@ describe("remove-binding", () => { assert.strictEqual(change?.lineToRemove, undefined); assert.strictEqual(sourceCode, `const { types: { isMap } } = require("util");`); }); + + it("Should remove the line in member expression scenarios", () => { + const code = dedent` + const Buffer = require("buffer").Buffer; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = removeBinding(importStatement!, "Buffer"); + + assert.notEqual(change, undefined); + assert.strictEqual(change?.edit, undefined); + assert.deepEqual(change?.lineToRemove, { + end: { + column: 40, + index: 40, + line: 0, + }, + start: { + column: 0, + index: 0, + line: 0, + }, + }); + }); + + it("Should remove the line when the accessed property is different from the identifier", () => { + const code = dedent` + const Buffer = require("buffer").SlowBuffer + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = removeBinding(importStatement!, "Buffer"); + + assert.notEqual(change, undefined); + assert.strictEqual(change?.edit, undefined); + assert.deepEqual(change?.lineToRemove, { + end: { + column: 43, + index: 43, + line: 0, + }, + start: { + column: 0, + index: 0, + line: 0, + }, + }); + }); }); diff --git a/utils/src/ast-grep/remove-binding.ts b/utils/src/ast-grep/remove-binding.ts index 7e479a1f..3cea6711 100644 --- a/utils/src/ast-grep/remove-binding.ts +++ b/utils/src/ast-grep/remove-binding.ts @@ -1,13 +1,6 @@ -import type { SgNode, Edit, Range } from "@codemod.com/jssg-types/main"; -import type Js from "@codemod.com/jssg-types/langs/javascript"; - -const requireKinds = ["lexical_declaration", "variable_declarator"]; -const importKinds = ["import_statement", "import_clause"]; - -type RemoveBindingReturnType = { - edit?: Edit; - lineToRemove?: Range; -}; +import { updateBinding } from './update-binding.ts'; +import type { SgNode, Kinds } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; /** * Removes a specific binding from an import or require statement. @@ -42,175 +35,11 @@ type RemoveBindingReturnType = { * ``` */ export function removeBinding( - node: SgNode, - binding: string, -): RemoveBindingReturnType | undefined { - const nodeKind = node.kind().toString(); - - const identifier = node.find({ - rule: { - any: [ - { - kind: "identifier", - inside: { - kind: "variable_declarator", - }, - }, - { - kind: "identifier", - inside: { - kind: "import_clause", - }, - }, - ], - }, - }); - - if (identifier && identifier.text() === binding) { - return { - lineToRemove: node.range(), - }; - } - - if (requireKinds.includes(nodeKind)) { - return handleNamedRequireBindings(node, binding); - } - - if (importKinds.includes(nodeKind)) { - return handleNamedImportBindings(node, binding); - } -} - -function handleNamedImportBindings( - node: SgNode, + node: SgNode | SgNode>, binding: string, -): RemoveBindingReturnType | undefined { - const namespaceImport = node.find({ - rule: { - kind: "identifier", - inside: { - kind: "namespace_import", - }, - }, - }); - - if (Boolean(namespaceImport) && namespaceImport.text() === binding) { - return { - lineToRemove: node.range(), - }; - } - - const namedImports = node.findAll({ - rule: { - kind: "import_specifier", - // ignore imports with alias (renamed imports) - not: { - has: { - field: "alias", - kind: "identifier", - }, - }, - }, - }); - - for (const namedImport of namedImports) { - const text = namedImport.text(); - if (text === binding) { - if (namedImports.length === 1) { - return { - lineToRemove: node.range(), - }; - } - - const namedImportsNode = node.find({ - rule: { - kind: "named_imports", - }, - }); - const restNamedImports = namedImports.map((d) => d.text()).filter((d) => d !== binding); - - return { - edit: namedImportsNode.replace(`{ ${restNamedImports.join(", ")} }`), - }; - } - } - - const renamedImports = node.findAll({ - rule: { - has: { - field: "alias", - kind: "identifier", - }, - }, +) { + return updateBinding(node, { + old: binding, + new: undefined, }); - - for (const renamedImport of renamedImports) { - if (renamedImport.text() === binding) { - if (renamedImports.length === 1 && namedImports.length === 0) { - return { - lineToRemove: node.range(), - }; - } - - const namedImportsNode = node.find({ - rule: { - kind: "named_imports", - }, - }); - - const aliasStatement = renamedImports.map((alias) => alias.parent()); - - const restNamedImports = [...namedImports, ...aliasStatement] - .map((d) => d.text()) - .filter((d) => d !== renamedImport.parent().text()); - - return { - edit: namedImportsNode.replace(`{ ${restNamedImports.join(", ")} }`), - }; - } - } -} - -function handleNamedRequireBindings( - node: SgNode, - binding: string, -): RemoveBindingReturnType | undefined { - const objectPattern = node.find({ - rule: { - kind: "object_pattern", - }, - }); - - if (!objectPattern) return; - - const declarations = node.findAll({ - rule: { - kind: "shorthand_property_identifier_pattern", - }, - }); - - if (declarations.length === 0) return; - - if (declarations.length === 1) { - return { - lineToRemove: node.range(), - }; - } - - for (const declaration of declarations) { - if (declaration.text() === binding) { - const parent = declaration.parent(); - const parentDeclarations = parent.findAll({ - rule: { - kind: "shorthand_property_identifier_pattern", - }, - }); - - const restDeclarations = parentDeclarations.map((d) => d.text()).filter((d) => d !== binding); - - return { - edit: parent.replace(`{ ${restDeclarations.join(", ")} }`), - }; - } - } } diff --git a/utils/src/ast-grep/update-binding.test.ts b/utils/src/ast-grep/update-binding.test.ts new file mode 100644 index 00000000..0303823f --- /dev/null +++ b/utils/src/ast-grep/update-binding.test.ts @@ -0,0 +1,599 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import astGrep from "@ast-grep/napi"; +import dedent from "dedent"; +import { updateBinding } from "./update-binding.ts"; +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; + +describe("update-binding", () => { + it("should update only the specified named import while preserving other named imports", () => { + const code = dedent` + const { types, diff } = require('node:util'); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "types", + new: "newTypes", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "const { diff, newTypes } = require('node:util');"); + }); + + it("should update the specified named import", () => { + const code = dedent` + const { types } = require('node:util'); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "types", + new: "newTypes", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, undefined); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "const { newTypes } = require('node:util');"); + }); + + it("should remove the entire require statement when the only imported binding is removed", () => { + const code = dedent` + const util = require('node:util'); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "util", + }); + + assert.notEqual(change, null); + assert.strictEqual(change?.edit, undefined); + assert.deepEqual(change?.lineToRemove, { + start: { line: 0, column: 0, index: 0 }, + end: { line: 0, column: 34, index: 34 }, + }); + }); + + it("should update only the specified named import while preserving other named imports", () => { + const code = dedent` + import { types, diff } = from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "types", + new: "newTypes", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "import { diff, newTypes } = from 'node:util';"); + }); + + it("should remove the specified named import while preserving other named imports", () => { + const code = dedent` + import { types, diff } = from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "types", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "import { diff } = from 'node:util';"); + }); + + it("should remove the entire import statement when the only imported binding is removed", () => { + const code = dedent` + import util from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "util", + }); + + assert.notEqual(change, null); + assert.deepEqual(change?.lineToRemove, { + start: { line: 0, column: 0, index: 0 }, + end: { line: 0, column: 29, index: 29 }, + }); + assert.strictEqual(change?.edit, undefined); + }); + + it("should remove the entire import statement when removing the only named import", () => { + const code = dedent` + import { types } from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "types", + }); + + assert.notEqual(change, null); + assert.deepEqual(change?.lineToRemove, { + start: { line: 0, column: 0, index: 0 }, + end: { line: 0, column: 34, index: 34 }, + }); + assert.strictEqual(change?.edit, undefined); + }); + + it("should remove the entire import line when only one aliased binding is imported and it matches the alias", () => { + const code = dedent` + import { types as utilTypes } from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "utilTypes", + }); + + assert.notEqual(change, null); + assert.deepEqual(change?.lineToRemove, { + start: { line: 0, column: 0, index: 0 }, + end: { line: 0, column: 47, index: 47 }, + }); + assert.strictEqual(change?.edit, undefined); + }); + + it("should update the entire import line when only one aliased binding is imported and it matches the alias", () => { + const code = dedent` + import { types as utilTypes } from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "utilTypes", + new: "newTypes", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "import { newTypes as utilTypes } from 'node:util';"); + }); + + it("should remove only the aliased import binding when it matches the provided alias", () => { + const code = dedent` + import { types as utilTypes, diff } from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "utilTypes", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "import { diff } from 'node:util';"); + }); + + it("should remove the entire require statement when the only imported binding is removed", () => { + const code = dedent` + const util = require('node:util'); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "util", + }); + + assert.notEqual(change, null); + assert.strictEqual(change?.edit, undefined); + assert.deepEqual(change?.lineToRemove, { + start: { line: 0, column: 0, index: 0 }, + end: { line: 0, column: 34, index: 34 }, + }); + }); + + it("should return undefined when the binding does not match the imported name", () => { + const code = dedent` + const util = require('node:util'); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + // line 12 it was imported as util, and here is passed types to be removed + const change = updateBinding(requireStatement!, { + old: "types", + }); + + assert.equal(change, undefined); + }); + + it("should remove the entire require statement when removing the only named import", () => { + const code = dedent` + const { types } = require('node:util'); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(importStatement!, { + old: "types", + }); + + assert.notEqual(change, null); + assert.strictEqual(change?.edit, undefined); + assert.deepEqual(change?.lineToRemove, { + start: { line: 0, column: 0, index: 0 }, + end: { line: 0, column: 39, index: 39 }, + }); + }); + + it("should update the destructured variable", () => { + const code = dedent` + const { mainModule } = process; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "mainModule", + new: "newMainModule", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "const { newMainModule } = process;"); + }); + + it("should remove the entire import statement when the only namespace import is removed", () => { + const code = dedent` + import * as util from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "util", + }); + + assert.notEqual(change, null); + assert.deepEqual(change?.lineToRemove, { + start: { line: 0, column: 0, index: 0 }, + end: { line: 0, column: 34, index: 34 }, + }); + assert.strictEqual(change?.edit, undefined); + }); + + it("should update the namespace binding when newBinding is passed", () => { + const code = dedent` + import * as util from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "util", + new: "newUtil", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "import * as newUtil from 'node:util';"); + }); + + it("should return undefined when trying to update a binding that does not exist in the import statement", () => { + const code = dedent` + import { types, diff } from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "none", + new: "newNone", + }); + + assert.equal(change, undefined); + }); + + it("should remove only the aliased import binding when it matches the provided alias", () => { + const code = dedent` + import { types as utilTypes, diff } from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "utilTypes", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual(sourceCode, "import { diff } from 'node:util';"); + }); + + it("should update only the aliased import binding when it matches the provided alias among multiple aliased imports", () => { + const code = dedent` + import { types as utilTypes, diff as utilDiffs } from 'node:util'; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const importStatement = node.find({ + rule: { + kind: "import_statement", + }, + }); + + const change = updateBinding(importStatement!, { + old: "utilTypes", + new: "newTypes", + }); + const sourceCode = node.commitEdits([change?.edit!]); + + assert.notEqual(change, null); + assert.strictEqual(change?.lineToRemove, undefined); + assert.strictEqual( + sourceCode, + "import { newTypes as utilTypes, diff as utilDiffs } from 'node:util';", + ); + }); + + it("Should update destructured property access from require statement to named import", () => { + const code = dedent` + const Test = require("buffer").SlowBuffer; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "Test", + new: "Buffer", + }); + + assert.notEqual(change, undefined); + assert.strictEqual(change?.lineToRemove, undefined); + + const sourceCode = node.commitEdits([change?.edit!]); + + assert.strictEqual(sourceCode, `const { Buffer } = require("buffer");`); + }); + + it("Should remove entire require when property access exists require statement", () => { + const code = dedent` + const SlowBuffer = require("buffer").SlowBuffer; + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "SlowBuffer", + }); + + assert.notEqual(change, undefined); + assert.strictEqual(change.edit, undefined); + assert.deepEqual(change?.lineToRemove, { + end: { + column: 48, + index: 48, + line: 0, + }, + start: { + column: 0, + index: 0, + line: 0, + }, + }); + }); + + it("If named import already exists it just needs to remove the old reference", () => { + const code = dedent` + const { SlowBuffer, Buffer } = require("buffer"); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + old: "SlowBuffer", + new: "Buffer", + }); + + assert.notEqual(change, undefined); + assert.strictEqual(change?.lineToRemove, undefined); + + const sourceCode = node.commitEdits([change?.edit!]); + + assert.strictEqual(sourceCode, `const { Buffer } = require("buffer");`); + }); + + it("When oldBinding is not passed, should create new binding in require", () => { + const code = dedent` + const { SlowBuffer } = require("buffer"); + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code); + const node = rootNode.root() as SgNode; + + const requireStatement = node.find({ + rule: { + kind: "lexical_declaration", + }, + }); + + const change = updateBinding(requireStatement!, { + new: "Buffer", + }); + + assert.notEqual(change, undefined); + assert.strictEqual(change?.lineToRemove, undefined); + + const sourceCode = node.commitEdits([change?.edit!]); + + assert.strictEqual(sourceCode, `const { SlowBuffer, Buffer } = require("buffer");`); + }); +}); diff --git a/utils/src/ast-grep/update-binding.ts b/utils/src/ast-grep/update-binding.ts new file mode 100644 index 00000000..058bc978 --- /dev/null +++ b/utils/src/ast-grep/update-binding.ts @@ -0,0 +1,351 @@ +import type { SgNode, Edit, Range, Kinds } from "@codemod.com/jssg-types/main"; +import type Js from "@codemod.com/jssg-types/langs/javascript"; + +const requireKinds = ["lexical_declaration", "variable_declarator"]; +const importKinds = ["import_statement", "import_clause"]; + +type UpdateBindingReturnType = { + edit?: Edit; + lineToRemove?: Range; +}; + +type UpdateBindingOptions = { + old?: string; + new?: string; +}; + +/** + * Update or remove a specific binding from an import or require statement. + * + * Analyzes the provided AST node to find and update a specific binding from destructured imports. + * If `newBinding` is provided in options, the binding will be replaced with the new name. + * If `newBinding` is not provided, the binding will be removed. + * If the binding is the only one in the statement and no replacement is provided, the entire import line is marked for removal. + * + * @param node - The AST node representing the import or require statement + * @param options - Optional configuration object + * @param options.old - The name of the binding to update or remove (e.g., "isNativeError") + * @param options.new - The new binding name to replace the old one. If not provided, the binding is removed. + * @returns An object containing either an edit operation or a line range to remove, or undefined if no binding found + * + * @example + * ```typescript + * // Given an import: const {types, isNativeError} = require("node:util") + * // And binding: "isNativeError", options: {newBinding: "isError"} + * // Returns: an edit object that transforms to: const {types, isError} = require("node:util") + * ``` + * + * @example + * ```typescript + * // Given an import: const {types, isNativeError} = require("node:util") + * // And binding: "isNativeError", options: undefined + * // Returns: an edit object that transforms to: const {types} = require("node:util") + * ``` + * + * @example + * ```typescript + * // Given an import: const {isNativeError} = require("node:util") + * // And binding: "isNativeError", options: {newBinding: "isError"} + * // Returns: an edit object that transforms to: const {isError} = require("node:util") + * ``` + * + * @example + * ```typescript + * // Given an import: const {isNativeError} = require("node:util") + * // And binding: "isNativeError", options: undefined + * // Returns: {lineToRemove: Range} to remove the entire line + * ``` + * + * @example + * ```typescript + * // Given an import: const util = require("node:util") + * // And binding: "isNativeError" + * // Returns: undefined (no destructured binding found) + * ``` + */ +export function updateBinding( + node: SgNode, + options?: UpdateBindingOptions, +): UpdateBindingReturnType { + const nodeKind = node.kind().toString(); + + const namespaceImport = node.find({ + rule: { + any: [ + { + kind: "identifier", + inside: { + kind: "variable_declarator", + // this `not rule` ensures that expressions like `require("something").NamedImport` are ignored + // because we only want the namespace to be returned here + not: { + has: { + field: "value", + kind: "member_expression", + }, + }, + inside: { + kind: "lexical_declaration", + }, + }, + }, + { + kind: "identifier", + inside: { + kind: "import_clause", + }, + }, + ], + }, + }); + + if (!options?.new && namespaceImport && namespaceImport.text() === options?.old) { + return { + lineToRemove: node.range(), + }; + } + + if (requireKinds.includes(nodeKind)) { + return handleNamedRequireBindings(node, options); + } + + if (importKinds.includes(nodeKind)) { + return handleNamedImportBindings(node, options); + } +} + +function handleNamedImportBindings( + node: SgNode, + options: UpdateBindingOptions, +): UpdateBindingReturnType { + const namespaceImport = node.find({ + rule: { + kind: "identifier", + inside: { + kind: "namespace_import", + }, + }, + }); + + if (Boolean(namespaceImport) && namespaceImport.text() === options.old) { + if (options?.new) { + return { + edit: namespaceImport.replace(options.new), + }; + } + + return { + lineToRemove: node.range(), + }; + } + + const namedImports = node.findAll({ + rule: { + kind: "import_specifier", + // ignore imports with alias (renamed imports) + not: { + has: { + field: "alias", + kind: "identifier", + }, + }, + }, + }); + + for (const namedImport of namedImports) { + const text = namedImport.text(); + if (text === options.old) { + if (!options?.new && namedImports.length === 1) { + return { + lineToRemove: node.range(), + }; + } + + return { + edit: updateObjectPattern(namedImports, options.old, options.new), + }; + } + } + + const aliasedImports = node.findAll({ + rule: { + has: { + field: "alias", + kind: "identifier", + }, + }, + }); + + for (const renamedImport of aliasedImports) { + if (renamedImport.text() === options.old) { + if (!options?.new && aliasedImports.length === 1 && namedImports.length === 0) { + return { + lineToRemove: node.range(), + }; + } + + const namedImportsNode = node.find({ + rule: { + kind: "named_imports", + }, + }); + + if (options?.new) { + for (const renamedImport of aliasedImports) { + if (renamedImport.text() === options.old) { + const importName = renamedImport.parent().find({ + rule: { + has: { + field: "name", + kind: "identifier", + }, + }, + }); + return { + edit: importName.replace(options.new), + }; + } + } + } else { + const aliasStatement = aliasedImports.map((alias) => alias.parent()); + const newNamedImports = [...namedImports, ...aliasStatement] + .map((d) => d.text()) + .filter((d) => d !== renamedImport.parent().text()); + + return { + edit: namedImportsNode.replace(`{ ${newNamedImports.join(", ")} }`), + }; + } + } + } +} + +function handleNamedRequireBindings( + node: SgNode, + options: UpdateBindingOptions, +): UpdateBindingReturnType { + const requireWithMemberExpression = node.find({ + rule: { + kind: "variable_declarator", + all: [ + { + has: { + field: "name", + kind: "identifier", + pattern: options.old, + }, + }, + { + has: { + field: "value", + kind: "member_expression", + has: { + field: "property", + kind: "property_identifier", + }, + }, + }, + ], + }, + }); + + if (requireWithMemberExpression) { + if (!options?.new) { + return { + lineToRemove: node.range(), + }; + } + + const reqNode = node.find({ + rule: { + kind: "call_expression", + pattern: "require($ARGS)", + }, + }); + + return { + edit: node.replace(`const { ${options.new} } = ${reqNode.text()};`), + }; + } + + const objectPattern = node.find({ + rule: { + kind: "object_pattern", + }, + }); + + if (!objectPattern) return; + + const declarations = node.findAll({ + rule: { + kind: "shorthand_property_identifier_pattern", + }, + }); + + if (!options?.new && declarations.length === 1) { + return { + lineToRemove: node.range(), + }; + } + + return { + edit: updateObjectPattern(declarations, options.old, options.new), + }; +} + +function updateObjectPattern( + previouses: SgNode[], + oldBinding?: string, + newBinding?: string, +): Edit { + let newObjectPattern: string[] = []; + + let parentNode; + for (const previous of previouses) { + if (!oldBinding) { + parentNode = previous.parent(); + } + if (previous.text() === oldBinding) { + parentNode = previous.parent(); + break; + } + } + + const bindings = parentNode.findAll({ + rule: { + any: [ + { + kind: "shorthand_property_identifier_pattern", + }, + { + kind: "import_specifier", + not: { + has: { + field: "alias", + kind: "identifier", + }, + }, + }, + ], + }, + }); + + let needAddNewBinding = true; + for (const binding of bindings) { + if (binding.text() === oldBinding) { + continue; + } + + if (binding.text() === newBinding) { + needAddNewBinding = false; + } + + newObjectPattern.push(binding.text()); + } + + if (newBinding && needAddNewBinding) { + newObjectPattern.push(newBinding); + } + + return parentNode.replace(`{ ${newObjectPattern.join(", ")} }`); +}