Skip to content

Commit 6cf7302

Browse files
authored
feat: add rimraf codemod (#104)
Adds a new codemod to migrate from `rimraf`. It will use `rm` or `rmSync` where appropriate. If `glob` is set, it will use `tinyglobby` to find the files and later `rm` or `rmSync` them.
1 parent 6314d33 commit 6cf7302

File tree

14 files changed

+495
-1
lines changed

14 files changed

+495
-1
lines changed

codemods/rimraf/index.js

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { ts } from '@ast-grep/napi';
2+
3+
/**
4+
* @typedef {import('../../types.js').Codemod} Codemod
5+
* @typedef {import('../../types.js').CodemodOptions} CodemodOptions
6+
* @typedef {import('@ast-grep/napi').Edit} Edit
7+
*/
8+
9+
const defaultOptions = `{recursive: true, force: true}`;
10+
/**
11+
* @param {boolean} useRequire
12+
* @param {string} quoteType
13+
* @param {string[]} names
14+
* @param {string} source
15+
* @returns {string}
16+
*/
17+
const computeImport = (useRequire, quoteType, names, source) => {
18+
if (useRequire) {
19+
return `const {${names.join(', ')}} = require(${quoteType}${source}${quoteType});`;
20+
}
21+
return `import {${names.join(', ')}} from ${quoteType}${source}${quoteType};`;
22+
};
23+
24+
/**
25+
* @param {CodemodOptions} [options]
26+
* @returns {Codemod}
27+
*/
28+
export default function (options) {
29+
return {
30+
name: 'rimraf',
31+
transform: ({ file }) => {
32+
const ast = ts.parse(file.source);
33+
const root = ast.root();
34+
const imports = root.findAll({
35+
rule: {
36+
any: [
37+
{
38+
pattern: {
39+
context: "import * as $NAME from 'rimraf'",
40+
strictness: 'relaxed',
41+
},
42+
},
43+
{
44+
pattern: {
45+
context: "import {$$$NAMES} from 'rimraf'",
46+
strictness: 'relaxed',
47+
},
48+
},
49+
{
50+
pattern: {
51+
context: "import $NAME from 'rimraf'",
52+
strictness: 'relaxed',
53+
},
54+
},
55+
{
56+
pattern: {
57+
context: "const {$$$NAMES} = require('rimraf')",
58+
strictness: 'relaxed',
59+
},
60+
},
61+
{
62+
pattern: {
63+
context: "const $NAME = require('rimraf')",
64+
strictness: 'relaxed',
65+
},
66+
},
67+
],
68+
},
69+
});
70+
71+
if (imports.length === 0) {
72+
return file.source;
73+
}
74+
75+
/** @type {Edit[]} */
76+
const edits = [];
77+
let quoteType = "'";
78+
/** @type {string[]} */
79+
const localNames = [];
80+
let isCommonJS = false;
81+
82+
for (const imp of imports) {
83+
const importSource = imp.field('source');
84+
const requireCall = imp.find('require($SOURCE)');
85+
let source = null;
86+
87+
if (importSource) {
88+
// ESM
89+
source = importSource.text();
90+
} else {
91+
// CJS
92+
source = requireCall?.getMatch('SOURCE')?.text();
93+
}
94+
95+
if (!source) {
96+
continue;
97+
}
98+
99+
if (!isCommonJS) {
100+
isCommonJS = requireCall !== null;
101+
}
102+
103+
if (source?.startsWith('"')) {
104+
quoteType = '"';
105+
}
106+
107+
const importedNames = imp.getMultipleMatches('NAMES');
108+
const importedName = imp.getMatch('NAME');
109+
110+
// Its a default or namespace import
111+
if (importedName) {
112+
const importedNameText = importedName.text();
113+
localNames.push(
114+
importedNameText,
115+
`${importedNameText}.$_METHOD`,
116+
`${importedNameText}.$_METHOD.sync`,
117+
);
118+
}
119+
120+
for (const importSpecifier of importedNames) {
121+
const importedName = importSpecifier.field('name');
122+
const value = importSpecifier.field('value');
123+
124+
let localNameText;
125+
126+
if (importedName) {
127+
// ESM
128+
const localName = importSpecifier.field('alias') ?? importedName;
129+
localNameText = localName.text();
130+
} else if (value) {
131+
// CJS
132+
localNameText = value.text();
133+
} else {
134+
localNameText = importSpecifier.text();
135+
}
136+
137+
localNames.push(localNameText, `${localNameText}.sync`);
138+
}
139+
}
140+
141+
const usagePatterns = [];
142+
for (const name of localNames) {
143+
usagePatterns.push(
144+
{
145+
pattern: `${name}($PATH, $OPTIONS)`,
146+
},
147+
{
148+
pattern: `${name}($PATH)`,
149+
},
150+
);
151+
}
152+
153+
const usages = root.findAll({
154+
rule: {
155+
any: usagePatterns,
156+
},
157+
});
158+
159+
let seenSync = false;
160+
let seenAsync = false;
161+
let seenGlob = false;
162+
163+
for (const usage of usages) {
164+
const functionNode = usage.field('function');
165+
const functionText = functionNode?.text();
166+
const isSync =
167+
functionText !== undefined &&
168+
(functionText.includes('sync') || functionText?.includes('Sync'));
169+
170+
if (!seenSync) {
171+
seenSync = isSync;
172+
}
173+
if (!seenAsync) {
174+
seenAsync = !isSync;
175+
}
176+
177+
const fsName = isSync ? 'rmSync' : 'rm';
178+
const options = usage.getMatch('OPTIONS');
179+
const path = usage.getMatch('PATH');
180+
let optionsText = defaultOptions;
181+
182+
// Shouldn't be possible
183+
if (path === null) {
184+
continue;
185+
}
186+
187+
if (options) {
188+
if (options.kind() === 'object') {
189+
const globOption = options
190+
.children()
191+
.find(
192+
(child) =>
193+
child.field('key')?.text() === 'glob' &&
194+
child.field('value')?.text() !== 'false',
195+
);
196+
if (globOption) {
197+
const globValue = globOption.field('value')?.text();
198+
const globParams =
199+
globValue !== null && globValue !== 'true'
200+
? `${path.text()}, ${globValue}`
201+
: path.text();
202+
seenGlob = true;
203+
// Indentation will be a mess, but do we care? use a formatter
204+
edits.push(
205+
usage.replace(
206+
`
207+
Promise.all(
208+
(await glob(${globParams})).map((filePath) =>
209+
${fsName}(filePath, ${defaultOptions}))
210+
)
211+
`.trim(),
212+
),
213+
);
214+
continue;
215+
}
216+
217+
const optionsObjectText = options.text();
218+
const bracketIndex = optionsObjectText.indexOf('{');
219+
const beforeBracket = optionsObjectText.slice(0, bracketIndex);
220+
const afterBracket = optionsObjectText.slice(bracketIndex + 1);
221+
const afterBracketNextLine =
222+
afterBracket.startsWith('\r') || afterBracket.startsWith('\n');
223+
const afterBracketSpace = afterBracketNextLine ? '' : ' ';
224+
optionsText = `${beforeBracket}{recursive: true, force: true,${afterBracketSpace}${afterBracket}`;
225+
} else {
226+
optionsText = options.text();
227+
}
228+
}
229+
230+
edits.push(usage.replace(`${fsName}(${path.text()}, ${optionsText})`));
231+
}
232+
233+
if (imports.length > 0) {
234+
const [firstImport, ...remainingImports] = imports;
235+
236+
let replacedImports = [];
237+
238+
if (seenAsync) {
239+
replacedImports.push(
240+
computeImport(isCommonJS, quoteType, ['rm'], 'node:fs/promises'),
241+
);
242+
}
243+
244+
if (seenSync) {
245+
replacedImports.push(
246+
computeImport(isCommonJS, quoteType, ['rmSync'], 'node:fs'),
247+
);
248+
}
249+
250+
if (seenGlob) {
251+
replacedImports.push(
252+
computeImport(isCommonJS, quoteType, ['glob'], 'tinyglobby'),
253+
);
254+
}
255+
256+
edits.push(firstImport.replace(replacedImports.join('\n')));
257+
258+
for (const imp of remainingImports) {
259+
edits.push(imp.replace(''));
260+
}
261+
}
262+
263+
return root.commitEdits(edits);
264+
},
265+
};
266+
}

index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import qs from './codemods/qs/index.js';
127127
import reflectGetprototypeof from './codemods/reflect.getprototypeof/index.js';
128128
import reflectOwnkeys from './codemods/reflect.ownkeys/index.js';
129129
import regexpPrototypeFlags from './codemods/regexp.prototype.flags/index.js';
130+
import rimraf from './codemods/rimraf/index.js';
130131
import setprototypeof from './codemods/setprototypeof/index.js';
131132
import splitLines from './codemods/split-lines/index.js';
132133
import stringPrototypeAt from './codemods/string.prototype.at/index.js';
@@ -294,6 +295,7 @@ export const codemods = {
294295
"reflect.getprototypeof": reflectGetprototypeof,
295296
"reflect.ownkeys": reflectOwnkeys,
296297
"regexp.prototype.flags": regexpPrototypeFlags,
298+
"rimraf": rimraf,
297299
"setprototypeof": setprototypeof,
298300
"split-lines": splitLines,
299301
"string.prototype.at": stringPrototypeAt,
@@ -321,4 +323,4 @@ export const codemods = {
321323
"typed-array-length": typedArrayLength,
322324
"typedarray.prototype.slice": typedarrayPrototypeSlice,
323325
"xtend": xtend,
324-
};
326+
};

test/fixtures/rimraf/cjs/after.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const {rm} = require('node:fs/promises');
2+
const {rmSync} = require('node:fs');
3+
4+
await rm('./dist', {recursive: true, force: true});
5+
6+
export async function foo() {
7+
await rm('./dist', {recursive: true, force: true});
8+
9+
const someConst = './dist';
10+
await rm(someConst, {recursive: true, force: true});
11+
}
12+
13+
rmSync('./dist', {recursive: true, force: true});

test/fixtures/rimraf/cjs/before.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const {rimraf} = require('rimraf');
2+
3+
await rimraf('./dist');
4+
5+
export async function foo() {
6+
await rimraf('./dist');
7+
8+
const someConst = './dist';
9+
await rimraf(someConst);
10+
}
11+
12+
rimraf.sync('./dist');

test/fixtures/rimraf/cjs/result.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const {rm} = require('node:fs/promises');
2+
const {rmSync} = require('node:fs');
3+
4+
await rm('./dist', {recursive: true, force: true});
5+
6+
export async function foo() {
7+
await rm('./dist', {recursive: true, force: true});
8+
9+
const someConst = './dist';
10+
await rm(someConst, {recursive: true, force: true});
11+
}
12+
13+
rmSync('./dist', {recursive: true, force: true});

test/fixtures/rimraf/esm/after.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {rm} from 'node:fs/promises';
2+
import {glob} from 'tinyglobby';
3+
4+
await rm('./dist', {recursive: true, force: true});
5+
6+
export async function foo() {
7+
await rm('./dist', {recursive: true, force: true});
8+
9+
const someConst = './dist';
10+
await rm(someConst, {recursive: true, force: true});
11+
}
12+
13+
// maxRetries
14+
await rm('./dist', {recursive: true, force: true, maxRetries: 10});
15+
16+
// retryDelay
17+
await rm('./dist', {recursive: true, force: true, retryDelay: 1000});
18+
19+
// glob
20+
await Promise.all(
21+
(await glob('./dist/*.js')).map((filePath) =>
22+
rm(filePath, {recursive: true, force: true}))
23+
);
24+
25+
// glob options
26+
await Promise.all(
27+
(await glob('./dist/*.js', {
28+
dot: false
29+
})).map((filePath) =>
30+
rm(filePath, {recursive: true, force: true}))
31+
);
32+
33+
// filter
34+
// Note: filters are not migrated yet
35+
await rm('./dist', {recursive: true, force: true,
36+
filter: () => {
37+
// some function, whatever
38+
}
39+
});

0 commit comments

Comments
 (0)