Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

LSP のファイル名変更機能を使った mv コマンドを実装した

タイトルのとおりです。

作ったもの

tsserver には、ファイルを移動させると相対パスを自動で修正する機能があります。

例えば A.tsx を B.tsx にリネームすると、A.tsx を import していた箇所が自動で修正されます。

import {Component} from './components/A'; // これが './components/B' に修正される

この機能は language server のものなので、エディタ上でしか使えません。なので、language server を利用して相対パスの自動修正をしてくれる mv コマンドを作りました。

動作イメージ

成果物

動かし方:

  1. 以下のコードを mv.mjs という名前でプロジェクトのルート(node_modules があるフォルダ)に置く
  2. chmod +x mv.mjs する
  3. ./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);

(ほんとはグローバルインストールできるコマンドとして配布したかったですが、ここまでで丸一日かかったので後日時間があればやります…)