diff --git a/package-lock.json b/package-lock.json index f4b20d93..57a9c7c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1473,6 +1473,10 @@ "resolved": "recipes/crypto-rsa-pss-update", "link": true }, + "node_modules/@nodejs/dirent-path-to-parent-path": { + "resolved": "recipes/dirent-path-to-parent-path", + "link": true + }, "node_modules/@nodejs/fs-access-mode-constants": { "resolved": "recipes/fs-access-mode-constants", "link": true @@ -4290,6 +4294,17 @@ "@codemod.com/jssg-types": "^1.0.3" } }, + "recipes/dirent-path-to-parent-path": { + "name": "@nodejs/dirent-path-to-parent-path", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + } + }, "recipes/fs-access-mode-constants": { "name": "@nodejs/fs-access-mode-constants", "version": "1.0.0", diff --git a/recipes/dirent-path-to-parent-path/README.md b/recipes/dirent-path-to-parent-path/README.md new file mode 100644 index 00000000..291e6986 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/README.md @@ -0,0 +1,51 @@ +# `dirent.path` DEP0178 + +This codemod transforms the usage of `dirent.path` to use `dirent.parentPath`. + +See [DEP0178](https://nodejs.org/api/deprecations.html#DEP0178). + +## Example + +**Before:** + +```js +const { readdir } = require('node:fs/promises'); + +const entries = await readdir('/some/path', { withFileTypes: true }); +for (const dirent of entries) { + console.log(dirent.path); +} +``` + +**After:** + +```js +const { readdir } = require('node:fs/promises'); + +const entries = await readdir('/some/path', { withFileTypes: true }); +for (const dirent of entries) { + console.log(dirent.parentPath); +} +``` + +**Before:** + +```js +import { opendir } from 'node:fs/promises'; + +const dir = await opendir('./'); +for await (const dirent of dir) { + console.log(`Found ${dirent.name} in ${dirent.path}`); +} +``` + +**After:** + +```js +import { opendir } from 'node:fs/promises'; + +const dir = await opendir('./'); +for await (const dirent of dir) { + console.log(`Found ${dirent.name} in ${dirent.parentPath}`); +} +``` diff --git a/recipes/dirent-path-to-parent-path/codemod.yaml b/recipes/dirent-path-to-parent-path/codemod.yaml new file mode 100644 index 00000000..554dfbcf --- /dev/null +++ b/recipes/dirent-path-to-parent-path/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/dirent-path-to-parent-path" +version: 1.0.0 +description: Handle DEP0178 via transforming `dirent.path` to `dirent.parentPath`. +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/dirent-path-to-parent-path/package.json b/recipes/dirent-path-to-parent-path/package.json new file mode 100644 index 00000000..bef9e652 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/package.json @@ -0,0 +1,25 @@ +{ + "name": "@nodejs/dirent-path-to-parent-path", + "version": "1.0.0", + "description": "Handle DEP0178 via transforming `dirent.path` to `dirent.parentPath`", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./", + "testu": "npx codemod jssg test -l typescript -u ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/dirent-path-to-parent-path", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Bruno Rodrigues", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/dirent-path-to-parent-path/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/dirent-path-to-parent-path/src/workflow.ts b/recipes/dirent-path-to-parent-path/src/workflow.ts new file mode 100644 index 00000000..221e79a1 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/src/workflow.ts @@ -0,0 +1,333 @@ +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { + getNodeImportCalls, + getNodeImportStatements, +} from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; +import { getScope } from '@nodejs/codemod-utils/ast-grep/get-scope'; +import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +type BindingToReplace = { + node: SgNode; + binding: string; + variables: { bind: SgNode; scope: SgNode }[]; +}; + +type DirArray = { + node: SgNode | SgNode; + scope: SgNode; +}; + +type DirValue = { + node: + | SgNode + | SgNode + | SgNode + | SgNode + | SgNode + | SgNode + | SgNode; + scope: SgNode; +}; + +type DirDestructuredValue = { + node: SgNode; + scope: SgNode; +}; + +const handledFn = ['$.readdir', '$.readdirSync', '$.opendir']; + +const handledModules = ['fs', 'fs/promises']; + +/* + * Transforms `dirent.path` usage to `dirent.parentPath`. + * + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + const bindsToReplace: BindingToReplace[] = []; + const dirArrays: DirArray[] = []; + const dirValues: DirValue[] = []; + const dirDestructuredValues: DirDestructuredValue[] = []; + + const importRequireStatement: SgNode[] = []; + for (const mod of handledModules) { + importRequireStatement.push(...getNodeRequireCalls(root, mod)); + importRequireStatement.push(...getNodeImportStatements(root, mod)); + importRequireStatement.push(...getNodeImportCalls(root, mod)); + } + + if (!importRequireStatement.length) return null; + + for (const node of importRequireStatement) { + for (const fn of handledFn) { + const bind = resolveBindingPath(node, fn); + + if (!bind) continue; + + bindsToReplace.push({ + node, + binding: bind, + variables: [], + }); + } + } + + for (const bind of bindsToReplace) { + const matches = rootNode.findAll<'variable_declarator'>({ + rule: { + any: [ + // created variables without await + { + kind: 'variable_declarator', + has: { + field: 'value', + kind: 'call_expression', + has: { + field: 'function', + kind: bind.binding.includes('.') + ? 'member_expression' + : 'identifier', + pattern: `${bind.binding}`, + }, + }, + }, + // created variables with await + { + kind: 'variable_declarator', + has: { + field: 'value', + kind: 'await_expression', + has: { + kind: 'call_expression', + has: { + field: 'function', + kind: bind.binding.includes('.') + ? 'member_expression' + : 'identifier', + pattern: `${bind.binding}`, + }, + }, + }, + }, + ], + }, + }); + + for (const match of matches) { + dirArrays.push({ node: match, scope: getScope(match) }); + } + + const functionCalls = rootNode.findAll<'call_expression'>({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: bind.binding.includes('.') ? 'member_expression' : 'identifier', + pattern: `${bind.binding}`, + }, + }, + }); + + for (const functionCall of functionCalls) { + const params = functionCall + .field('arguments') + .children() + .filter((arg) => + ['string', 'object', 'arrow_function', 'identifier'].includes( + arg.kind(), + ), + ); + + //if it had 3 params it means the third will be an callbackFn as fs.readdir, fs.opendir docs + if (params.length === 3) { + const arrowFn = params[2] as SgNode; + if (arrowFn.kind() === 'arrow_function') { + const args = arrowFn.field('parameters'); + const params = args.findAll<'identifier'>({ + rule: { + kind: 'identifier', + }, + }); + + if (params.length === 2) { + dirArrays.push({ node: params[1], scope: arrowFn.field('body') }); + } + } + } + } + } + + for (const dirArray of dirArrays) { + const pattern = + dirArray.node.kind() === 'variable_declarator' + ? (dirArray.node as SgNode) + .field('name') + .text() + : dirArray.node.text(); + + const forOfScenarios = dirArray.scope.findAll<'for_in_statement'>({ + rule: { + kind: 'for_in_statement', + has: { + field: 'right', + kind: 'identifier', + pattern, + }, + }, + }); + + for (const forOf of forOfScenarios) { + const leftBind = forOf.field('left'); + const forOfBody = forOf.field('body'); + + dirValues.push({ + node: leftBind, + scope: forOfBody, + }); + } + + const forScenarios = dirArray.scope.findAll<'for_statement'>({ + rule: { + kind: 'for_statement', + }, + }); + + for (const forScenario of forScenarios) { + const matches = forScenario.field('body').findAll({ + rule: { + kind: 'identifier', + pattern, + }, + }); + + for (const match of matches) { + const parent = match.parent(); + + if (parent.kind() === 'subscript_expression') { + if (parent.parent().kind() === 'member_expression') { + dirValues.push({ + node: parent as SgNode, + scope: forScenario, + }); + } + if (parent.parent().kind() === 'variable_declarator') { + const dirVar = ( + parent.parent() as SgNode + ).field('name'); + + dirValues.push({ + node: dirVar, + scope: forScenario, + }); + } + } + } + } + + const arrMethods = dirArray.scope.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + has: { + field: 'object', + kind: 'identifier', + pattern, + }, + }, + }, + }); + + for (const arrMethod of arrMethods) { + const stmt = getScope(arrMethod, 'expression_statement'); + + const arrowFns = stmt.findAll({ + rule: { + kind: 'arrow_function', + }, + }); + + for (const arrowFn of arrowFns) { + const parameters = + arrowFn.field('parameters') || arrowFn.field('parameter'); + const fnBody = arrowFn.field('body'); + + const param = parameters?.find<'identifier'>({ + rule: { + kind: 'identifier', + }, + }); + + if (param) { + dirValues.push({ + node: param, + scope: fnBody, + }); + } + + const paramDestructured = parameters?.find<'object_pattern'>({ + rule: { + kind: 'object_pattern', + }, + }); + + if (paramDestructured) { + dirDestructuredValues.push({ + node: paramDestructured, + scope: fnBody, + }); + } + } + } + } + + for (const dirValue of dirValues) { + const pathUses = dirValue.scope.findAll<'member_expression'>({ + rule: { + kind: 'member_expression', + pattern: `${dirValue.node.text()}.path`, + }, + }); + + for (const uses of pathUses) { + edits.push(uses.field('property').replace('parentPath')); + } + } + + for (const dirDestructuredValue of dirDestructuredValues) { + const pathBind = + dirDestructuredValue.node.find<'shorthand_property_identifier_pattern'>({ + rule: { + kind: 'shorthand_property_identifier_pattern', + regex: 'path', + }, + }); + + if (pathBind) { + edits.push(pathBind.replace('parentPath')); + + const pathUses = dirDestructuredValue.scope.findAll<'member_expression'>({ + rule: { + kind: 'identifier', + pattern: 'path', + }, + }); + + for (const pahtUse of pathUses) { + edits.push(pahtUse.replace('parentPath')); + } + } + } + + if (!edits.length) return; + + const sourceCode = rootNode.commitEdits(edits); + + return removeLines(sourceCode, linesToRemove); +} diff --git a/recipes/dirent-path-to-parent-path/tests/expected/01.js b/recipes/dirent-path-to-parent-path/tests/expected/01.js new file mode 100644 index 00000000..ed6ad0a1 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/01.js @@ -0,0 +1,6 @@ +const { readdir } = require('node:fs/promises'); + +const entries = await readdir('/some/path', { withFileTypes: true }); +for (const dirent of entries) { + console.log(dirent.parentPath); +} diff --git a/recipes/dirent-path-to-parent-path/tests/expected/02.js b/recipes/dirent-path-to-parent-path/tests/expected/02.js new file mode 100644 index 00000000..412bffd7 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/02.js @@ -0,0 +1,7 @@ +import { readdir } from 'node:fs/promises'; + +const entries = await readdir('./directory', { withFileTypes: true }); +entries.forEach((dirent) => { + const fullPath = `${dirent.parentPath}/${dirent.name}`; + console.log(fullPath); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/expected/03.js b/recipes/dirent-path-to-parent-path/tests/expected/03.js new file mode 100644 index 00000000..83122ae4 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/03.js @@ -0,0 +1,9 @@ +const fs = require('node:fs'); + +fs.readdir('/path', { withFileTypes: true }, (err, dirents) => { + if (err) throw err; + + dirents.forEach(({ name, parentPath, isDirectory }) => { + console.log(`${name} in ${parentPath}`); + }); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/expected/04.js b/recipes/dirent-path-to-parent-path/tests/expected/04.js new file mode 100644 index 00000000..2ec9fed8 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/04.js @@ -0,0 +1,6 @@ +import { opendir } from 'node:fs/promises'; + +const dir = await opendir('./'); +for await (const dirent of dir) { + console.log(`Found ${dirent.name} in ${dirent.parentPath}`); +} diff --git a/recipes/dirent-path-to-parent-path/tests/expected/05.js b/recipes/dirent-path-to-parent-path/tests/expected/05.js new file mode 100644 index 00000000..7f6338a1 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/05.js @@ -0,0 +1,6 @@ +const { readdirSync } = require('node:fs'); + +const entries = readdirSync('./', { withFileTypes: true }); +const files = entries.filter(dirent => { + return dirent.isFile() && dirent.parentPath.includes('src'); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/expected/06.js b/recipes/dirent-path-to-parent-path/tests/expected/06.js new file mode 100644 index 00000000..da0670d5 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/06.js @@ -0,0 +1,9 @@ +import { join } from 'node:path'; +import { readdir } from 'node:fs/promises'; + +async function getFilePaths(directory) { + const dirents = await readdir(directory, { withFileTypes: true }); + return dirents + .filter(dirent => dirent.isFile()) + .map(dirent => join(dirent.parentPath, dirent.name)); +} diff --git a/recipes/dirent-path-to-parent-path/tests/expected/07.js b/recipes/dirent-path-to-parent-path/tests/expected/07.js new file mode 100644 index 00000000..2aa6b9df --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/07.js @@ -0,0 +1,12 @@ +const fs = require('node:fs'); + +function processDirectory(path) { + const entries = fs.readdirSync(path, { withFileTypes: true }); + + return entries.map(dirent => ({ + name: dirent.name, + directory: dirent.parentPath, + type: dirent.isDirectory() ? 'dir' : 'file', + fullPath: `${dirent.parentPath}/${dirent.name}` + })); +} diff --git a/recipes/dirent-path-to-parent-path/tests/expected/08.js b/recipes/dirent-path-to-parent-path/tests/expected/08.js new file mode 100644 index 00000000..1153b2f2 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/08.js @@ -0,0 +1,16 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +async function walkDirectory(dirPath) { + const dirents = await readdir(dirPath, { withFileTypes: true }); + + for (const dirent of dirents) { + const currentPath = join(dirent.parentPath, dirent.name); + + if (dirent.isDirectory()) { + await walkDirectory(currentPath); + } else { + console.log(`File: ${currentPath}`); + } + } +} diff --git a/recipes/dirent-path-to-parent-path/tests/expected/09.js b/recipes/dirent-path-to-parent-path/tests/expected/09.js new file mode 100644 index 00000000..bbd80312 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/09.js @@ -0,0 +1,6 @@ +const { readdir } = require('node:fs/promises'); + +const entries = await readdir('/some/path', { withFileTypes: true }); +for (const i = 0; i { + const fullPath = `${parentPath}/${name}`; + console.log(fullPath); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/expected/12.js b/recipes/dirent-path-to-parent-path/tests/expected/12.js new file mode 100644 index 00000000..fd6711d5 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/expected/12.js @@ -0,0 +1,9 @@ +const myFS = await import('node:fs'); + +function test(){ + const entries = myFS.readdir('./directory', { withFileTypes: true }); + entries.forEach(({parentPath, name}) => { + const fullPath = `${parentPath}/${name}`; + console.log(fullPath); + }); +} diff --git a/recipes/dirent-path-to-parent-path/tests/input/01.js b/recipes/dirent-path-to-parent-path/tests/input/01.js new file mode 100644 index 00000000..a27565c8 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/01.js @@ -0,0 +1,6 @@ +const { readdir } = require('node:fs/promises'); + +const entries = await readdir('/some/path', { withFileTypes: true }); +for (const dirent of entries) { + console.log(dirent.path); +} diff --git a/recipes/dirent-path-to-parent-path/tests/input/02.js b/recipes/dirent-path-to-parent-path/tests/input/02.js new file mode 100644 index 00000000..58cd8471 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/02.js @@ -0,0 +1,7 @@ +import { readdir } from 'node:fs/promises'; + +const entries = await readdir('./directory', { withFileTypes: true }); +entries.forEach((dirent) => { + const fullPath = `${dirent.path}/${dirent.name}`; + console.log(fullPath); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/input/03.js b/recipes/dirent-path-to-parent-path/tests/input/03.js new file mode 100644 index 00000000..6176decf --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/03.js @@ -0,0 +1,9 @@ +const fs = require('node:fs'); + +fs.readdir('/path', { withFileTypes: true }, (err, dirents) => { + if (err) throw err; + + dirents.forEach(({ name, path, isDirectory }) => { + console.log(`${name} in ${path}`); + }); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/input/04.js b/recipes/dirent-path-to-parent-path/tests/input/04.js new file mode 100644 index 00000000..1fae05e3 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/04.js @@ -0,0 +1,6 @@ +import { opendir } from 'node:fs/promises'; + +const dir = await opendir('./'); +for await (const dirent of dir) { + console.log(`Found ${dirent.name} in ${dirent.path}`); +} diff --git a/recipes/dirent-path-to-parent-path/tests/input/05.js b/recipes/dirent-path-to-parent-path/tests/input/05.js new file mode 100644 index 00000000..4adfc04f --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/05.js @@ -0,0 +1,6 @@ +const { readdirSync } = require('node:fs'); + +const entries = readdirSync('./', { withFileTypes: true }); +const files = entries.filter(dirent => { + return dirent.isFile() && dirent.path.includes('src'); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/input/06.js b/recipes/dirent-path-to-parent-path/tests/input/06.js new file mode 100644 index 00000000..ad45b9dc --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/06.js @@ -0,0 +1,9 @@ +import { join } from 'node:path'; +import { readdir } from 'node:fs/promises'; + +async function getFilePaths(directory) { + const dirents = await readdir(directory, { withFileTypes: true }); + return dirents + .filter(dirent => dirent.isFile()) + .map(dirent => join(dirent.path, dirent.name)); +} diff --git a/recipes/dirent-path-to-parent-path/tests/input/07.js b/recipes/dirent-path-to-parent-path/tests/input/07.js new file mode 100644 index 00000000..42f7ada6 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/07.js @@ -0,0 +1,12 @@ +const fs = require('node:fs'); + +function processDirectory(path) { + const entries = fs.readdirSync(path, { withFileTypes: true }); + + return entries.map(dirent => ({ + name: dirent.name, + directory: dirent.path, + type: dirent.isDirectory() ? 'dir' : 'file', + fullPath: `${dirent.path}/${dirent.name}` + })); +} diff --git a/recipes/dirent-path-to-parent-path/tests/input/08.js b/recipes/dirent-path-to-parent-path/tests/input/08.js new file mode 100644 index 00000000..3479e98d --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/08.js @@ -0,0 +1,16 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +async function walkDirectory(dirPath) { + const dirents = await readdir(dirPath, { withFileTypes: true }); + + for (const dirent of dirents) { + const currentPath = join(dirent.path, dirent.name); + + if (dirent.isDirectory()) { + await walkDirectory(currentPath); + } else { + console.log(`File: ${currentPath}`); + } + } +} diff --git a/recipes/dirent-path-to-parent-path/tests/input/09.js b/recipes/dirent-path-to-parent-path/tests/input/09.js new file mode 100644 index 00000000..182f4d05 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/09.js @@ -0,0 +1,6 @@ +const { readdir } = require('node:fs/promises'); + +const entries = await readdir('/some/path', { withFileTypes: true }); +for (const i = 0; i { + const fullPath = `${path}/${name}`; + console.log(fullPath); +}); diff --git a/recipes/dirent-path-to-parent-path/tests/input/12.js b/recipes/dirent-path-to-parent-path/tests/input/12.js new file mode 100644 index 00000000..986ce281 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/input/12.js @@ -0,0 +1,9 @@ +const myFS = await import('node:fs'); + +function test(){ + const entries = myFS.readdir('./directory', { withFileTypes: true }); + entries.forEach(({path, name}) => { + const fullPath = `${path}/${name}`; + console.log(fullPath); + }); +} diff --git a/recipes/dirent-path-to-parent-path/workflow.yaml b/recipes/dirent-path-to-parent-path/workflow.yaml new file mode 100644 index 00000000..addde060 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/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 DEP0178 via transforming `dirent.path` to `dirent.parentPath` + 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/get-scope.test.ts b/utils/src/ast-grep/get-scope.test.ts new file mode 100644 index 00000000..012bddb2 --- /dev/null +++ b/utils/src/ast-grep/get-scope.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import astGrep from '@ast-grep/napi'; +import dedent from 'dedent'; +import { getScope } from './get-scope.ts'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; +import type { SgRoot } from '@codemod.com/jssg-types/main'; + +describe('get-scope-node', () => { + it('should return thre entire code', () => { + const code = dedent` + const x = 'first line' + const y = 'second line' + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'string_fragment'>({ + rule: { + kind: 'identifier', + pattern: 'x', + }, + }); + + const scope = getScope(node!); + + assert.notEqual(scope, null); + assert.equal(scope?.text(), code); + }); + + it('should return function body from arrow function', () => { + const code = dedent` + const x = 'first line' + const teste = () => { + const y = 'second line' + } + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'string_fragment'>({ + rule: { + kind: 'identifier', + pattern: 'y', + }, + }); + + const scope = getScope(node!); + + assert.notEqual(scope, null); + assert.equal( + scope?.text(), + dedent`{ + const y = 'second line' + }`, + ); + }); + + it('should return function body from anonymous functions', () => { + const code = dedent` + const x = 'first line' + const teste = function() { + const y = 'second line' + } + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'string_fragment'>({ + rule: { + kind: 'identifier', + pattern: 'y', + }, + }); + + const scope = getScope(node!); + + assert.notEqual(scope, null); + assert.equal( + scope?.text(), + dedent`{ + const y = 'second line' + }`, + ); + }); + + it('should return function body from named functions', () => { + const code = dedent` + const x = 'first line' + function teste() { + const y = 'second line' + } + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'string_fragment'>({ + rule: { + kind: 'identifier', + pattern: 'y', + }, + }); + + const scope = getScope(node!); + + assert.notEqual(scope, null); + assert.equal( + scope?.text(), + dedent`{ + const y = 'second line' + }`, + ); + }); + + it('should return if block', () => { + const code = dedent` + const x = 'first line' + if(x === 'first line') { + const y = 'second line' + } + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'string_fragment'>({ + rule: { + kind: 'identifier', + pattern: 'y', + }, + }); + + const scope = getScope(node!); + + assert.notEqual(scope, null); + assert.equal( + scope?.text(), + dedent`{ + const y = 'second line' + }`, + ); + }); + + it('should return for block', () => { + const code = dedent` + const x = 'first line' + for(const i = 0; i < 10; i++) { + const y = 'second line' + } + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'string_fragment'>({ + rule: { + kind: 'identifier', + pattern: 'y', + }, + }); + + const scope = getScope(node!); + + assert.notEqual(scope, null); + assert.equal( + scope?.text(), + dedent`{ + const y = 'second line' + }`, + ); + }); + + it('should return forOf block', () => { + const code = dedent` + const x = 'first line' + const arr = [1,2] + for(const i of arr) { + const y = 'second line' + } + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'string_fragment'>({ + rule: { + kind: 'identifier', + pattern: 'y', + }, + }); + + const scope = getScope(node!); + + assert.notEqual(scope, null); + assert.equal( + scope?.text(), + dedent`{ + const y = 'second line' + }`, + ); + }); + + it('should return custom node line', () => { + const code = dedent` + const x = 'first line' + const arr = [1,2] + for(const i of arr) { + const y = 'second line' + } + `; + + const rootNode = astGrep.parse(astGrep.Lang.JavaScript, code) as SgRoot; + const node = rootNode.root().find<'identifier'>({ + rule: { + kind: 'identifier', + pattern: 'y', + }, + }); + + const scope = getScope(node!, 'lexical_declaration'); + + assert.notEqual(scope, null); + assert.equal(scope?.text(), `const y = 'second line'`); + }); +}); diff --git a/utils/src/ast-grep/get-scope.ts b/utils/src/ast-grep/get-scope.ts new file mode 100644 index 00000000..26272d85 --- /dev/null +++ b/utils/src/ast-grep/get-scope.ts @@ -0,0 +1,25 @@ +import type { SgNode } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +/** + * Traverses up the AST tree to find the enclosing scope of a given node. + * + * @param node - The AST node to find the scope for + * @param customParent - Optional custom parent node type to stop at + * @returns The scope node (statement_block, program, or custom parent) or null if not found + * + */ +export const getScope = (node: SgNode, customParent?: string) => { + let parentNode = node.parent(); + + while (parentNode !== null) { + switch (parentNode.kind()) { + case 'statement_block': + case 'program': + case customParent: + return parentNode; + default: + parentNode = parentNode.parent(); + } + } +};