タイトルのとおりです。
作ったもの
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); // tsserver に getEditsForFileName request を送信して、結果を取得する 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()); }) ); } })()) { // getEditsForFileRename のレスポンスが出るまでループする for (const data of JSON.stringify(message).split('\\n')) { // JSON じゃないものが入りうるので、try で囲んでいる 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) => { // js, ts, tsx 以外は普通に rename する 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);
(ほんとはグローバルインストールできるコマンドとして配布したかったですが、ここまでで丸一日かかったので後日時間があればやります…)