タイトルのとおりです。
作ったもの
tsserver には、ファイルを移動させると相対パスを自動で修正する機能があります。
例えば A.tsx を B.tsx にリネームすると、A.tsx を import していた箇所が自動で修正されます。
import {Component} from './components/A'; // これが './components/B' に修正される
この機能は language server のものなので、エディタ上でしか使えません。なので、language server を利用して相対パスの自動修正をしてくれる mv コマンドを作りました。
動作イメージ
成果物
動かし方:
- 以下のコードを
mv.mjs
という名前でプロジェクトのルート(node_modules があるフォルダ)に置く
chmod +x mv.mjs
する
./mv.mjs components/A.tsx components/B.tsx
で動く!
colors と diff に依存しているので、まだインストールしてない場合はインストールしてください。
#!/usr/bin/env node
import 'colors';
import Diff from 'diff';
import { spawn } from 'node:child_process';
import { promisify } from 'node:util';
import {
readFile as fsReadFile,
writeFile as fsWriteFile,
rename,
} from 'node:fs';
import { resolve, extname } from 'node:path';
import readline from 'node:readline';
const readFile = promisify(fsReadFile),
writeFile = promisify(fsWriteFile);
const getEditsForFileRename = async (from, to) => {
const tsserver = spawn('./node_modules/typescript/bin/tsserver', []);
tsserver.stdin.write(`${JSON.stringify({
type: 'request',
seq: 0,
command: 'open',
arguments: {
file: from,
},
})}
${JSON.stringify({
type: 'request',
seq: 1,
command: 'getEditsForFileRename',
arguments: {
oldFilePath: from,
newFilePath: to,
},
})}
`);
for await (const message of (function* f() {
while (true) {
yield new Promise((resolve) =>
tsserver.stdout.on('data', (chunk) => {
resolve(chunk.toString());
})
);
}
})()) {
for (const data of JSON.stringify(message).split('\\n')) {
try {
const json = JSON.parse(data.replace(/\\"/g, '"'));
if (
json.type !== 'response' ||
json.command !== 'getEditsForFileRename'
) {
continue;
}
tsserver.kill();
return json;
} catch {}
}
}
};
const applyTextChanges = (file, textChanges) =>
textChanges
.sort((a, b) =>
a.start.line === b.start.line
? a.start.offset - b.start.offset
: a.start.line - b.start.line
)
.reduce((res, { start, end, newText }) => {
const lines = res.split('\n');
const textToChange =
start.line === end.line
? lines[start.line - 1].slice(start.offset - 1, end.offset - 1)
: [
lines[start.line - 1].slice(start.offset - 1),
...lines.slice(start.line, end.line - 1),
lines[end.line - 1].slice(0, end.offset - 1),
].join('\n');
return `${res.slice(
0,
lines.slice(0, start.line - 1).join('\n').length + start.offset
)}${newText}${res.slice(
lines.slice(0, start.line - 1).join('\n').length +
start.offset +
textToChange.length
)}`;
}, file);
const main = async (from, to) => {
if (!['.js', '.ts', '.tsx'].includes(extname(from))) {
rename(from, to, (err) => {
if (err) {
console.error(err);
}
});
return;
}
const { body } = await getEditsForFileRename(from, to);
for (const { fileName, textChanges } of body) {
const file = await readFile(fileName, 'utf8');
const newContent = applyTextChanges(file, textChanges);
const diff = Diff.diffChars(file, newContent);
console.log(`${fileName}:`);
for (const part of diff) {
const color = part.added ? 'green' : part.removed ? 'red' : 'grey';
process.stderr.write(part.value[color]);
}
}
const confirmInterface = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise((resolve) =>
confirmInterface.question('これらの変更をしますか? (y/n) ', (ans) => {
confirmInterface.pause();
resolve(ans);
})
);
if (answer.toLowerCase() !== 'y' && answer.toLowerCase !== 'yes') {
console.log('キャンセルしました');
return;
}
for (const { fileName, textChanges } of body) {
await writeFile(
fileName,
applyTextChanges(await readFile(fileName, 'utf8'), textChanges),
'utf8'
);
}
rename(from, to, (err) => {
if (err) {
console.error(err);
}
});
console.log('変更しました');
};
const [from, to] = process.argv.slice(2);
if (typeof from !== 'string') {
console.log('missing file operand');
process.exit(1);
}
if (typeof to !== 'string') {
console.log(`missing destination file operand after '${from}'`);
process.exit(1);
}
const absolutePathFrom = resolve(process.cwd(), from);
const absolutePathTo = resolve(process.cwd(), to);
main(absolutePathFrom, absolutePathTo);
(ほんとはグローバルインストールできるコマンドとして配布したかったですが、ここまでで丸一日かかったので後日時間があればやります…)