公開日: 2025年2月28日

【JavaScript】 CSV パーサーを自作する

JavaScript の勉強を兼ねて CSV パーサーを自作してみました。仕様として、カンマが「"」でくくられていた場合は、ただの文字として扱います。また、「"」でくくられた中で「"」を使いたい場合は「"」自身でエスケープします。

なお、JavaScript 初心者が書いたプログラムであるため、コードが汚かったり、アンチパターンが含まれていたり、バグが潜んでいたりするかもしれませんがご了承ください。

CSV パーサーの仕様

CSV パーサーの仕様は次のとおりとします。一般的な CSV パーサーとは異なるところがあるかもしれません。

  • 入力された文字列を、文字列の終端が現れるまでパースする
  • 文字列の終端は CRLF または LF とする
  • 行の末尾が「,」だった場合は、返却するレコードの末尾に空のフィールド ('') を追加する
  • 「,」が連続して現れた場合は、返却するレコードの当該位置に空のフィールド ('') を挿入する
  • フィールドは「"」でくくられていてもよい
  • フィールドが「"」でくくられていた場合、くくっている「"」は除去する
  • 同 その中に現れる「,」は普通の文字として扱う
  • 同 「"」が連続して現れた場合は「"」のエスケープとして扱う
  • 解析結果は文字列の配列 (string[]) で返す

API の仕様は次のとおりとします。

/**
 * CSV 文字列をレコードまたはレコードの配列にして返す
 * @param {string} text - 解析したい文字列
 * @returns {string[]|string[][]} - 解析結果
 */
parse(text)
  • textが 1 行の場合 (改行コードを含まない場合):
    • textをそのまま CSV パーサーに渡す
    • CSV パーサーから受け取った結果 (string[]) をそのまま返す
  • textが複数行の場合 (改行コードを含んでいる場合):
    • textを改行コードで分割し、それぞれ CSV パーサーに渡す
    • CSV パーサーから受け取った各行の結果 (string[]) を配列 (string[][]) にして返す
  • 空行と「#」で始まる行は無視する

以上をふまえて、だいたい次のようなテストケースをパスできれば良しとします。

入力期待値
hello

[ 'hello' ]

hello, world

[ 'hello', 'world' ]

hello, "world"

[ 'hello', 'world' ]

hello, world,

[ 'hello', 'world', '' ]

hello,, world

[ 'hello', '', 'world' ]

hell,o, "w,orld"

[ 'hell', 'o', 'w,orld' ]

"say ""hello, world!"" to the world."

[ 'say "hello, world!" to the world.' ]

上の各ケースをすべて "\n" で連結

上の各期待値を要素とする配列

CSV パーサーの作成方針

機械的にsplit(",")で区切った場合、「hello, world!」のような単純なケースなら問題なく動作しますが、「say, "hello, world!", to, the, world」のようなケースでは意に反して「hello」と「world!」の間が裂かれてしまいます。正規表現を使って実現できないかな?と頑張ってはみたものの、私には無理でした。そこでここでは、1 文字ずつ地道に解析していく方法をとることにします。

CSV パーサーの設計

「"」が現れたときの処理がややこしいことにならないように、「イベント」と「状態」で問題を考えてみます。「イベント」というのは状態を遷移させるためのトリガのことで、今考えている問題の場合は、入力される 1 文字 1 文字がイベントです。「状態」は何があるかというと、「開始状態」と「終了状態」、あとは要求仕様にもよりますが、今回の場合は

  • 状態 1: 普通の状態 (普通にカンマ区切り処理を行っている状態)
  • 状態 2: 「"」でくくられた中にいる状態
  • 状態 3: 「"」がエスケープされるか判断している状態

の 3 つでしょうか。

例えば上のほうで出てきた「say, "hello, world!", to, the, world」の例だと、「s」「a」「y」「,」「 」のところまでは「状態 1」です。その次は「"」が現れるので「状態 2」に遷移し、そのまま「!」のところまで処理します (このときは途中で「,」が現れても普通の文字として扱います)。するとまた「"」が現れますが、今度は「状態 3」に遷移してこの「"」がエスケープのための「"」なのか、くくられた状態を閉じるための「"」なのかを見極めようとします。ここで次に来る文字が「"」ではなく「,」なので、くくられた状態を抜けて「状態 1」に戻ります。文章で書くとなんのこっちゃですので、状態遷移図にしてみました:

図 1: 状態遷移図
図 1: 状態遷移図

漏れのないように状態遷移表にすると、次のようになります:

状態\イベントカンマdqeosその他の文字
normal

自分自身

indq

fin

自分自身

indq

自分自身

escape?

fin

自分自身

escape?

normal

indq

fin

normal

CSV パーサーの実装

それでは、上に書いたような CSV パーサーを JavaScript で実装していきます。ソースコードの全文は次の場所にあるので、必要に応じて参照してください。

まず、パーサー全体をクラスにし、プライベートなメンバー変数として#state#fieldsを持たせます (メンバー名やメソッド名の頭に「#」を付けると、クラス外からのアクセスを禁止することができます)。#stateには現在の状態を格納します。#fieldsは文字列の配列であり、ここに解析結果を格納します。つまり解析中にカンマで切り出した文字列 (フィールドと呼びます) をこの配列につっ込んでいきます。

class CsvParser {
    #state = undefined;     // 現在の状態を保持する
    #fields = undefined;    // フィールドを格納する配列
}

イベントは、カンマを表す「comma」、ダブルクォーテーションを表す「dq」、文字列の終端を表す「eos」、それ以外の文字を表す「others」の 4 つを定義します。プロパティの値をなぜ string 型にしているのかについては後述します (状態遷移表のところで)。

#ev = Object.freeze({comma: "comma", dq: "dq", eos: "eos", others: "others"});

次に状態ですが、これはオブジェクトとして定義します。状態オブジェクトは次のプロパティを持ちます。

  • key: 状態の名前をそのまま文字列にしたもの。どう使うかは後述 (状態遷移表のところで)
  • entry: その状態に遷移したとき、最初に 1 回だけ実行する処理
  • do: その状態にいる間、実行する処理
  • exit: その状態を抜けるときに 1 回だけ実行する処理

例えば「普通の状態」を疑似コードで書くと次のようになります。

normal: {
    key: "normal",
    entry: (ch) => { フィールドを "" で初期化 },
    do: (ch) => { ch がカンマならフィールドを確定、そうでなければフィールドに ch を連結 },
    exit: (ch) => { なにもしない },
}

JavaScript になじみがないと「() => {}」という記述は面食らうかもしれません。詳しくは次のリンク先などを参照してください。

次に状態遷移表です。前出の状態遷移表をほぼそのまま JavaScript のオブジェクトで記述しています。ただし、今回は終了状態で行いたい処理が特になかったので、fin という状態は設けていません。単にundefinedを返して状態遷移を終了させるようにしました。

#table = {
    normal: {
        comma: this.#states.normal,
        dq: this.#states.indq,
        eos: undefined,
        others: this.#states.normal,
    },
    indq: {
        comma: this.#states.indq,
        dq: this.#states.escape,
        eos: undefined,
        others: this.#states.indq,
    },
    escape: {
        comma: this.#states.normal,
        dq: this.#states.indq,
        eos: undefined,
        others: this.#states.normal,
    },
};

このテーブルの使い方ですが、例えば現在の状態が indq で、入力された文字がダブルクォーテーションだった場合の遷移先を#table["indq"]["dq"]でゲットすることができます。これは「ブラケット記法」によるプロパティへのアクセスです。上のほうでイベントのデータ型を文字列にしたり、状態オブジェクトにわざわざ key プロパティを持たせたりしたのは、これがやりたかったからです。

最後に、入力された文字によって状態を遷移させる処理を作ります。疑似コードで書くと次のような感じです。

#input(ch) {
    const next = #table[#state.key][#event(ch)];  // 遷移先の状態を取得
    if (遷移先の状態 ≠ 現在の状態) {
        #state.exit(ch);
        if (遷移先の状態が undefined) {
            #fin();
            return;
        }
        #state = next;    // 現在の状態を遷移先の状態に書き換える
        #state.entry(ch);
    } else {
        #state.do(ch);
    }
}

感想

たかだか 250 行くらいのプログラムを作るのにずいぶん苦労したものです。でも未知の世界だった JavaScript の理解がだいぶん進んだようにも思います。第一印象が最悪だった「アロー関数」とも打ち解けて、今ではすっかりマブダチです 笑

なお JavaScript の勉強にあたっては、下記のサイトが大変参考になりました。

広告