textlint の対象ファイルが多過ぎるとエラーが出るので分割実行する

textlint を使って文書校正をしている。

GitBook など、多数の Markdown ファイルを扱っている場合、textlint の実行時に JavaScript heap out of memory エラーが発生してしまうことがある。

メッセージのとおり、ヒープサイズが足りないことによるエラーで、Mac ではほぼ見かけないが Windows では時々起こる。ヒープサイズを大きくするよう設定変更しても良いのだが、分割実行したら少しは改善するかな? と思い、こんなスクリプトを書いてみた。

/*!
 * textlint を分割実行する Node.js スクリプト
 * 
 * コマンドラインで実行するとヒープサイズが足りなくなることがあるため
 * サブディレクトリごとに textlint を分割実行する
 */

const fs = require('fs');
const TextLintEngine = require('textlint').TextLintEngine;

/** textlint 対象の Markdown ファイルが格納されているベースディレクトリ */
const baseDir = './text/';

/** textlint 対象外にするサブディレクトリがあれば指定する */
const ignoreDirectories = [
  'styles',
  'scripts'
];

/** textlint のエンジン */
const engine = new TextLintEngine();

/** Lint チェックエラーの総数をカウントする */
let problemsTotal = 0;

/**
 * textlint 対象とするファイルもしくはディレクトリのパスを取得する
 * 
 * @return {Promise.<string[], Error} textlint 対象とするファイルもしくはディレクトリのパスの配列を Resolve する
 */
const getTargets = () => {
  return new Promise((resolve, reject) => {
    // ベースディレクトリ配下のファイル・ディレクトリを検索する
    fs.readdir(baseDir, (error, list) => {
      if (error) {
        return reject(error);
      }
      
      // textlint 対象にするファイルもしくはディレクトリのパスを格納する配列
      const targets = [];
      
      list.forEach((item) => {
        // ベースディレクトリの直下にある .md ファイル
        const isMarkdownFile = item.endsWith('.md');
        // ディレクトリのうち、対象外とするディレクトリでないもの
        const isTargetDirectory = fs.statSync(baseDir + item).isDirectory() && !ignoreDirectories.includes(item);
        
        // textlint 対象を抜き出す
        if (isMarkdownFile || isTargetDirectory) {
          targets.push(baseDir + item);
        }
      });
      
      resolve(targets);
    });
  });
};

/**
 * textlint を実行し結果を出力する
 * 
 * @param {string} targetPath textlint 対象とするファイルもしくはディレクトリのパス
 * @return {Promise.<void>} textlint チェックエラーの有無に関わらず Resolve する関数を返す
 */
const executeTextlint = (targetPath) => {
  return new Promise((resolve) => {
    console.log(`Execute : ${targetPath}`);
    
    engine.executeOnFiles([targetPath])
      .then((results) => {
        if (engine.isErrorResults(results)) {
          // textlint チェックエラーがある場合
          // エラーメッセージ数を集計し加算する
          problemsTotal += results
            .map((result) => {
              return result.messages.length;
            })
            .reduce((prevCount, currentCount) => {
              return prevCount + currentCount;
            }, 0);
          
          // メッセージを整形して出力する
          console.log(engine.formatResults(results));
        }
        else {
          // textlint チェックエラーなし : 空行の入れ方を textlint チェックエラーの出力仕様と合わせておく
          console.log('\nAll passed!\n');
        }
        
        resolve();
      });
  });
};

// textlint 対象のファイルまたはディレクトリを配列で取得し、要素ごとに textlint を実行する
getTargets()
  .then((targets) => {
    return targets
      .map((targetPath) => {
        // 「Promise を返す関数」の配列に変換する
        return () => {
          return executeTextlint(targetPath);
        };
      })
      .reduce((prevFunc, currentFunc) => {
        // 第2引数 (initialValue) の Promise.resolve() から Promise チェーンを構築し直列実行する
        return prevFunc.then(currentFunc);
      }, Promise.resolve());
  })
  .then(() => {
    // 総エラー件数を出力する
    console.log(`Problems Total : ${problemsTotal}`);
    
    // エラーがある場合は異常終了の終了コードを設定する
    if (problemsTotal !== 0) {
      // 非同期処理の終了を待って安全に process.exit() するため exit イベントに追加する
      process.on('exit', () => {
        process.exit(1);
      });
    }
  })
  .catch((error) => {
    console.error(error);
    
    process.on('exit', () => {
      process.exit(1);
    });
  });

これを textlint-all.js などという名前で保存し、textlint を行いたいプロジェクトに置いておく。そして、$ textlint ./text/ といったコマンドラインからの呼び出しではなく、$ node textlint-all.js という風にこのスクリプトファイルを実行するように運用を変えるのだ。

このスクリプトは、./text/ 配下にサブディレクトリがある前提で getTargets() 関数を作っている。例えば以下のような構成だ。

プロジェクトルート
├ package.json
├ textlint-all.js (このファイル)
├ .textlintrc (TextLintEngine が自動的に参照してくれる)
└ text/
   ├ README.md
   ├ はじめに/
   │ ├ ほげほげ.md
   │ └ ふがふが.md
   ├ ○○について/
   │ ├ ○○の概要/
   │ │ ├ ○○とは.md
   │ │ └ ○○の特徴.md
   │ └ ○○のサンプル/
   │ │ └ ○○で◇◇するサンプル.md
   ├ さいごに/
   │ └ ふーばー.md
   ├ styles/ (textlint チェックに無関係なディレクトリ)
   │ └ styles.css
   └ scripts/ (textlint チェックに無関係なディレクトリ)
      └ scripts.js

このようなディレクトリ構成の時、baseDir 変数でドキュメント群のルートディレクトリを ./text/ ディレクトリに設定している。

そのうえで、./text/ 配下の .md ファイルとサブディレクトリを検索するのだが、変数 ignoreDirectories で指定しているとおり、./text/styles/./text/scripts/ ディレクトリは textlint 対象と見なさないようにしている。

結局、getTargets() で抽出できる配列としては、

const targets = [
  './text/README.md',
  './text/はじめに/',
  './text/○○について/',
  './text/さいごに/'
]

といった内容になる。

そしてこれをそれぞれ TextLintEngine にかけて textlint を実行していくワケだが、並列実行してしまってはヒープメモリが不足しそうなので、直列実行してやる必要がある。そのために Promise チェーンを作っている。

1つのサブディレクトリで textlint を実行し、Promise を返す処理は executeTextlint() 関数にしてあるので、先程の配列 targets.map().reduce() で変換していき、以下のような Promise チェーンを構築している。

// 生成イメージとしてはこんな感じ
Promise.resolve()
  .then(() => { return executeTextlint('./text/README.md'    ); })
  .then(() => { return executeTextlint('./text/はじめに/'    ); })
  .then(() => { return executeTextlint('./text/○○について/'); })
  .then(() => { return executeTextlint('./text/さいごに/'    ); });

全てが終わったら、総エラー件数を確認して終了コードを決めて終わらせる、という流れだ。

こうしてサブディレクトリごとに textlint するようにしたところ、ヒープ領域不足のエラーも出なくなり、うまく行っている。

上のスクリプトはプロジェクト構成に合わせて修正が必要かもしれないが、textlint 以外にもヒープサイズ不足によるエラーを回避する際の方法の一つとして、参考にしてもらえるかとは思う。