雑記帳

整理しない情報集

更新情報

CSVパーサーを作る

公開日:

カテゴリ: JavaScript

ちょっとCSV形式のデータを扱いたくなったのですが、意外とちゃんとしたCSVパーサーが見当たらないので、仕様やパーサーの仕組みの勉強がてら作ってみました。

CSVのフォーマットについて

CSVは「Comma Separated Value」あるいは「Character Separated Value」の略で、カンマ(あるいは何らかの文字)を区切り文字としたデータ転送用のフォーマット形式です。

その名の通りカンマで区切られているだけなので、様々なプログラミング言語において標準で使えるsplit(",")系のメソッドでパースできると思いきや、区切るためではないカンマを判別しなければならない等、ちゃんとした実装は意外と大変です。

CSVのフォーマットはRFC4180で標準化されていますが、標準化されたのが2005年ということもあり、実装は割とバラバラです。またこのRFCを読み進めると、使用できる文字が%x20-21 / %x23-2B / %x2D-7Eの範囲となっており、完全に準拠すると日本語文字列は使えません。

(余談ですが、JSONはCSVほど単純な構造ではないため、言語標準でパーサーが用意されていたり(JSはJSON.parse())、デファクトスタンダードに近いライブラリ(Jacksonなど)が存在していることが多いです)

ということで結局RFCに準拠せず、独自にCSVのパーサーを作成してみます。まずは主な仕様を読んでみます。

  • 区切り文字はカンマ、改行はCRLF
  • フィールドにCRLFやカンマを含む場合は二重引用符でフィールド全体を括る
  • 二重引用符で括ったフィールドで二重引用符を使う場合は、2個続けて書いてエスケープする

単純に作る

基本的に1文字ずつ読み取って、文字や状態に応じて配列に入れていくのが最も単純で高速だと思います。

const parseCSV = (text: string) => {
	const result: string[][] = [];
	let row: string[] = [];
	let str = "";
	let quote = false;
	for (let i = 0; i < text.length; i++) {
		const char = text.slice(i, i + 1);
		if (!quote && char === `"`) {
			if (str) throw new Error("Invalid format");
			quote = true;
		} else if (!quote && char === ",") {
			row.push(str);
			str = "";
		} else if (!quote && text.slice(i, i + 2) === "\r\n") {
			row.push(str);
			str = "";
			result.push(row);
			row = [];
			i++;
		} else if (quote && text.slice(i, i + 2) === `""`) {
			str += `"`;
			i++;
		} else if (quote && char === `"`) {
			quote = false;
		} else {
			str += char;
		}
	}
	if (str.length) row.push(str);
	if (row.length) result.push(row);
	return result;
};

パーサーコンビネータを作ってみる

上のパーサーで基本的に事足りますが、それだけでは面白くないので、ちょっと別の実装を試してみます。

先程のRFC4180にはABNFの構文が記載されています。

file = [header CRLF] record *(CRLF record) [CRLF]
header = name *(COMMA name)
record = field *(COMMA field)
name = field
field = (escaped / non-escaped)
escaped = DQUOTE *(TEXTDATA / COMMA / CR / LF / 2DQUOTE) DQUOTE
non-escaped = *TEXTDATA
COMMA = %x2C
CR = %x0D
DQUOTE =  %x22
LF = %x0A
CRLF = CR LF
TEXTDATA =  %x20-21 / %x23-2B / %x2D-7E

全部そのまま使うわけではありませんが、1文字ずつif文で分岐するよりもこの表記に沿った実装をした方が直感的です。そこでパーサーコンビネータと呼ばれる手法を使って、おおよそこの通りにパースしてみようと思います。

パーサーコンビネータは以下の記事に分かりやすい解説があります。本記事では、以下の記事をベースに組み立てていきます。

リーダークラスの作成

テキストを1文字ずつ読み進めていくクラスを作成します。基本的に参考先の実装とほとんど変わりません。String.prototpe.slice()は範囲外の値を指定すると空文字が返ります。

class Reader {
	private text: string;
	private pos: number;

	constructor(text: string, pos = 0) {
		this.text = text;
		this.pos = pos;
	}

	peek() {
		return this.text.slice(this.pos, this.pos + 1);
	}

	next() {
		this.pos++;
	}

	eq(reader: Reader) {
		return reader.text === this.text && reader.pos === this.pos;
	}

	clone() {
		return new Reader(this.text, this.pos);
	}

	revert(reader: Reader) {
		if (reader.text !== this.text) throw new Error("Invalid");
		this.pos = reader.pos;
	}
}

パーサーコンビネータの実装

こちらもTypeScript向けに実装を変えているところを除けば概ね同じです。今回のCSVパーサーで使わないものは実装していません。

JavaScriptには関数型インターフェイスは存在しないので、適当にクラス化しています。orをメソッドチェーンにして使わない人は、クラス化しなくても実装できると思います(単純に関数を渡していけばOKです)。

type Validator = (char: string) => boolean;
type ParserFunc<T> = (reader: Reader) => T;

class Parser<T> {
	private func: ParserFunc<T>;

	constructor(func: ParserFunc<T>) {
		this.func = func;
	}

	parse(reader: Reader) {
		return this.func(reader);
	}

	or<T>(parser: Parser<T>) {
		return Parser.or(this, parser);
	}

	static satisfy(validate: Validator) {
		return new Parser<string>((reader) => {
			const char = reader.peek();
			if (!validate(char)) throw new Error("not satisfied");
			reader.next();
			return char;
		});
	}

	static or<T>(parser1: Parser<T>, parser2: Parser<T>) {
		return new Parser<T>((reader) => {
			const tmp = reader.clone();
			try {
				return parser1.parse(reader);
			} catch (e) {
				if (!reader.eq(tmp)) throw e;
				return parser2.parse(reader);
			}
		});
	}

	static many(parser: Parser<string>) {
		return new Parser((reader) => {
			let str = "";
			try {
				for (;;) str += parser.parse(reader);
			} catch {}
			return str;
		});
	}

	static try<T>(parser: Parser<T>) {
		return new Parser<T>((reader) => {
			const tmp = reader.clone();
			try {
				return parser.parse(reader);
			} catch (e) {
				reader.revert(tmp);
				throw e;
			}
		});
	}
}

基本的にはsatisfy()で文字をチェックしてパーサーを生成し、そのパーサーを組み合わせてテキストデータをパースしていきます。

CSVパーサーの作成

ということで本編です。

今回はクラスの静的関数として実装していきます。すべて静的関数で実装するため、biomeを初期設定で使っている場合はエラーになります。

// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
class CSVParser {
}

次に区切り文字や改行文字を定義していきます。

class CSVParser {
	private static readonly SEPARATOR_CHAR = ",";
	private static readonly DQUOTE_CHAR = `"`;
	private static readonly CR_CHAR = "\r";
	private static readonly LF_CHAR = "\n";
	private static readonly ANY_CHAR = "[\\p{L}\\p{M}\\p{N}\\p{P}\\p{S}  ]";
}

ABNFの通りに実装すると日本語が使用できないため適宜拡張します。このあたりは用途に応じて値を定義しましょう。

今回はES2024で追加された正規表現のUnicode Sets(vモード)を使って、文字にあたるカテゴリをすべて対象にしています。また半角・全角スペースを加えておきます。

  • L: Letter
  • M: Mark
  • N: Number
  • P: Punctuation
  • S: Symbol

ちなみにUnicodeの私用領域は${Co}として定義されています。スペースは${Zs}として定義されていますが、半角スペースや全角スペースだけでなくゼロ幅スペースなども含むため、今回はカテゴリ丸ごとは追加していません。

class CSVParser {
	private static readonly SAFE_REGEXP =
		`[${this.ANY_CHAR}--[${this.DQUOTE_CHAR}${this.SEPARATOR_CHAR}]]`;
}

次にABNFのnon-escapedで使われている項目の正規表現文字列を作成します。vモードの正規表現では、なんと差集合が使えるため、[[親集合]--[除外する文字の集合]]で二重引用符と区切り文字を除外します。

class CSVParser {
	private static readonly SEPARATOR = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.SEPARATOR_CHAR)),
	);
	private static readonly DQUOTE = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.DQUOTE_CHAR)),
	);
	private static readonly CR = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.CR_CHAR)),
	);
	private static readonly LF = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.LF_CHAR)),
	);
	private static readonly UNESCAPED = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.SAFE_REGEXP, "v")),
	);
}

ここまで定義してきた正規表現で文字を判定するパーサーを作成します。match()は結果がある場合は配列、無い場合はnullが返ってくるため、否定演算子を2回実行して論理型に変換します。

class CSVParser {
	private static readonly CRLF = Parser.try(
		new Parser((reader) => {
			this.CR.parse(reader);
			this.LF.parse(reader);
		}),
	);
	private static readonly DQUOTE2 = Parser.try(
		new Parser((reader) => {
			this.DQUOTE.parse(reader);
			return this.DQUOTE.parse(reader);
		}),
	);
}

2文字以上で判定するパターンを定義します。今回はCRLFとエスケープした二重引用符が該当します。参考先ではsequence()が定義されていますが、今回はこの2点だけだったので使っていません。

CRLFは区切り文字で返り値を使わないので返り値なし、二重引用符はエスケープ文字を除外したものを返しています。このあたりは人によって実装方法が異なるかもしれません。

class CSVParser {
	private static readonly UNESCAPED_STR = Parser.many(this.UNESCAPED);
	private static readonly ESCAPED_STR = new Parser((reader) => {
		let str = "";
		this.DQUOTE.parse(reader);
		str = Parser.many(
			this.UNESCAPED.or(this.SEPARATOR)
				.or(this.CR)
				.or(this.LF)
				.or(this.DQUOTE2),
		).parse(reader);
		this.DQUOTE.parse(reader);
		return str;
	});
}

ここまで作成した文字パーサーで、エスケープする文字列とエスケープしない文字列のパーサーをそれぞれ作成します。ABNFではescapednon-escapedの定義です。

エスケープしない文字列は、そのままmany()を使用して0回以上繰り返し実行するパーサーにします。エスケープする文字列は最初と最後で二重引用符を読み取った上で、二重引用符内で使える文字すべてをmany()に流します。

なお、本来はエスケープしない文字列でカンマで終わる行はNGですが、今回は実装していません。

class CSVParser {
	private static readonly FIELD = new Parser((reader) => {
		return this.ESCAPED_STR.or(this.UNESCAPED_STR).parse(reader);
	});
}

カンマで区切られた1つのフィールドを定義します。先ほど作成したエスケープする文字列とエスケープしない文字列のどちらかを選択します。ABNFではfieldの定義です。

class CSVParser {
	private static split<T>(parser: Parser<T>, separator: Parser<unknown>) {
		return new Parser((reader) => {
			const result: T[] = [];
			result.push(parser.parse(reader));
			for (;;) {
				try {
					separator.parse(reader);
				} catch {
					break;
				}
				result.push(parser.parse(reader));
			}
			return result;
		});
	}
}

CSVパーサーのメインとなる処理です。区切り文字で分割し配列を返すパーサーです。まずフィールドをパースし、区切り文字が続く場合は繰り返し次のフィールドをパースしていきます。

class CSVParser {
	private static readonly RECORD = this.split(this.FIELD, this.SEPARATOR);
	private static readonly FILE = this.split(this.RECORD, this.CRLF);
}

ということで、split()を使用したメイン処理を用いてrecordfileを作成します。これでパーサーは完成です。

class CSVParser {
	static parse(text: string) {
		const reader = new Reader(text);
		try {
			const result = CSVParser.FILE.parse(reader);
			if (reader.peek().length > 0) throw new Error();
			return result;
		} catch {
			throw new Error("Invalid format");
		}
	}
}

最後に、このパーサーを呼び出す処理を実装します。パース中にエラーが発生した場合、処理終了後にリーダーに値が残っている場合はフォーマット違反としてエラーを返す処理を追加して完成です。

headernamerecordfieldと等価かつ任意項目のため、パーサー側では判別できません。この処理を入れたい場合はパース完了後にparse()内に入れることになると思います。

CSVParser.parse('aaa,bbb\r\n"ccc",ddd\r\n"e\r\ne\r\ne","f""f""f"');

以上でCSVパーサーの完成です。単純なパーサーより処理が多いですが、こちらは標準的なCSV以外にも応用が効きます。今回は勉強目的の意味合いが強いですが、実践でも色々なことに使えそうですね。

最後にここまでの記述をまとめたものを畳んで置いておきます。

使用方法

import { CSVParser } from "ファイルパス";
CSVParser.parse("パースする文字列");

ソース

class Reader {
	private text: string;
	private pos: number;

	constructor(text: string, pos = 0) {
		this.text = text;
		this.pos = pos;
	}

	peek() {
		return this.text.slice(this.pos, this.pos + 1);
	}

	next() {
		this.pos++;
	}

	eq(reader: Reader) {
		return reader.text === this.text && reader.pos === this.pos;
	}

	clone() {
		return new Reader(this.text, this.pos);
	}

	revert(reader: Reader) {
		if (reader.text !== this.text) throw new Error("Invalid");
		this.pos = reader.pos;
	}
}

type Validator = (char: string) => boolean;
type ParserFunc<T> = (reader: Reader) => T;

class Parser<T> {
	private func: ParserFunc<T>;

	constructor(func: ParserFunc<T>) {
		this.func = func;
	}

	parse(reader: Reader) {
		return this.func(reader);
	}

	or<T>(parser: Parser<T>) {
		return Parser.or(this, parser);
	}

	static satisfy(validate: Validator) {
		return new Parser<string>((reader) => {
			const char = reader.peek();
			if (!validate(char)) throw new Error("not satisfied");
			reader.next();
			return char;
		});
	}

	static or<T>(parser1: Parser<T>, parser2: Parser<T>) {
		return new Parser<T>((reader) => {
			const tmp = reader.clone();
			try {
				return parser1.parse(reader);
			} catch (e) {
				if (!reader.eq(tmp)) throw e;
				return parser2.parse(reader);
			}
		});
	}

	static many(parser: Parser<string>) {
		return new Parser((reader) => {
			let str = "";
			try {
				for (;;) str += parser.parse(reader);
			} catch {}
			return str;
		});
	}

	static try<T>(parser: Parser<T>) {
		return new Parser<T>((reader) => {
			const tmp = reader.clone();
			try {
				return parser.parse(reader);
			} catch (e) {
				reader.revert(tmp);
				throw e;
			}
		});
	}
}

// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
export class CSVParser {
	private static readonly SEPARATOR_CHAR = ",";
	private static readonly DQUOTE_CHAR = `"`;
	private static readonly CR_CHAR = "\r";
	private static readonly LF_CHAR = "\n";
	private static readonly ANY_CHAR = "[\\p{L}\\p{M}\\p{N}\\p{P}\\p{S}  ]";

	private static readonly SAFE_REGEXP =
		`[${this.ANY_CHAR}--[${this.DQUOTE_CHAR}${this.SEPARATOR_CHAR}]]`;

	private static readonly SEPARATOR = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.SEPARATOR_CHAR)),
	);
	private static readonly DQUOTE = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.DQUOTE_CHAR)),
	);
	private static readonly CR = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.CR_CHAR)),
	);
	private static readonly LF = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.LF_CHAR)),
	);
	private static readonly UNESCAPED = Parser.satisfy(
		(char) => !!char.match(new RegExp(this.SAFE_REGEXP, "v")),
	);
	private static readonly CRLF = Parser.try(
		new Parser((reader) => {
			this.CR.parse(reader);
			this.LF.parse(reader);
		}),
	);
	private static readonly DQUOTE2 = Parser.try(
		new Parser((reader) => {
			this.DQUOTE.parse(reader);
			return this.DQUOTE.parse(reader);
		}),
	);

	private static readonly UNESCAPED_STR = Parser.many(this.UNESCAPED);
	private static readonly ESCAPED_STR = new Parser((reader) => {
		let str = "";
		this.DQUOTE.parse(reader);
		str = Parser.many(
			this.UNESCAPED.or(this.SEPARATOR)
				.or(this.CR)
				.or(this.LF)
				.or(this.DQUOTE2),
		).parse(reader);
		this.DQUOTE.parse(reader);
		return str;
	});

	private static readonly FIELD = new Parser((reader) => {
		return this.ESCAPED_STR.or(this.UNESCAPED_STR).parse(reader);
	});

	private static split<T>(parser: Parser<T>, separator: Parser<unknown>) {
		return new Parser((reader) => {
			const result: T[] = [];
			result.push(parser.parse(reader));
			for (;;) {
				try {
					separator.parse(reader);
				} catch {
					break;
				}
				result.push(parser.parse(reader));
			}
			return result;
		});
	}

	private static readonly RECORD = this.split(this.FIELD, this.SEPARATOR);
	private static readonly FILE = this.split(this.RECORD, this.CRLF);

	static parse(text: string) {
		const reader = new Reader(text);
		try {
			const result = CSVParser.FILE.parse(reader);
			if (reader.peek().length > 0) throw new Error();
			return result;
		} catch {
			throw new Error("Invalid format");
		}
	}
}

カテゴリ: JavaScript