cat customers.csvで開くと列が崩れて読めない、表計算アプリを開くまでもないけど中身を 8 行だけ確認したい、という日に毎週 1 回くらい遭遇する。csv-peek customers.csvで 1 秒で揃った表になり、数字は右揃えで色付き、日付は黄色、bool はマゼンタ で出る。Rust CLI、依存は clap だけ、stripped で約 600 KB、合計 51 テスト。
📦 GitHub: https://github.com/sen-ltd/csv-peek
$ cat customers.csv
id,name,email,signup_date,active,balance,note
1,Alice Suzuki,alice@example.com,2024-01-15,true,1280.50,"first 10 customers"
2,Bob Tanaka,bob@example.com,2024-02-03,true,42.00,
3,Carol Yamada,carol@example.com,2024-02-20,false,0.00,"churned, refunded"
…
$ csv-peek customers.csv
┌────┬─────────────────┬────────────────────┬─────────────┬────────┬─────────┬────────────────────┐
│ id │ name │ email │ signup_date │ active │ balance │ note │
├────┼─────────────────┼────────────────────┼─────────────┼────────┼─────────┼────────────────────┤
│ 1 │ Alice Suzuki │ alice@example.com │ 2024-01-15 │ true │ 1280.50 │ first 10 customers │
│ 2 │ Bob Tanaka │ bob@example.com │ 2024-02-03 │ true │ 42.00 │ │
│ 3 │ Carol Yamada │ carol@example.com │ 2024-02-20 │ false │ 0.00 │ churned, refunded │
…
既存ツールとの位置取り
csv crate の csv::Reader::from_path で書けば 30 行で済むのは事実。にもかかわらず手書きする理由は 2 つだけ:
-
依存が
clap1 個だけ で済むので、cargo build --releaseが 9 秒、stripped で約 600 KB の Alpine バイナリになる -
RFC 4180 を自前で書くのが記事になる — ルールは 4 つだけだが、
""、\r\n、"foo\nbar"を全部ちゃんと処理するステートマシンは見ておく価値がある
実プロダクションでは csv crate 一択。これは「実装してみたデモ」枠。
RFC 4180 のステートマシン
実装は 4 状態で書ける:
enum State {
Start, // フィールド境界
Unquoted, // 引用符なしフィールドの中
Quoted, // "..." の中
QuotedQuote, // 引用符内で `"` を見たところ
}
QuotedQuote がトリッキー。引用符内で " を見ても、それが エスケープ ("") の前半なのか、フィールドの終わりなのかは次の 1 byte を見ないと決まらない。
State::QuotedQuote => match b {
b'"' => {
// `""` だった → リテラル `"` を 1 個 emit して Quoted に戻る
field.push(b'"');
state = State::Quoted;
}
c if c == self.delim => {
// フィールド終了
fields.push(...);
state = State::Start;
}
b'\r' | b'\n' => {
// レコード終了
return Ok(Some(...));
}
other => {
// `"foo"bar` のような不正入力。strict なら Err、本実装は recover
field.push(other);
state = State::Unquoted;
}
}
最後の other 分岐は厳格なパーサなら reject するが、目で見るための CLI なので、壊れたファイルでも何かは表示したい。"foo"bar\n は foobar として読み飛ばす。
CRLF / LF / CR の混在
CSV は Mac (\r) / Unix (\n) / Windows (\r\n) のどれもがレコード区切りに登場し、しかも 同じファイル内に混ざる ことがある (Excel が出すファイルでよく見る)。
\r を見たら、次の 1 byte が \n なら CRLF として消費、そうでなければ CR 単体として消費して残りはバッファに戻す。peek が必要 → 自前バッファ管理:
b'\r' => {
fields.push(...);
if let Some(nb) = self.next_byte()? {
if nb != b'\n' {
self.pos -= 1; // peek 失敗、戻す
}
}
return Ok(Some(...));
}
列型推論
各列が Empty / Bool / Int / Float / Date / Text のどれかに分類される。最も広い型が勝つ 単純な widening:
fn widen(a: ColType, b: ColType) -> ColType {
if matches!(a, Empty) { return b; }
if matches!(b, Empty) { return a; }
if (a == Int && b == Float) || (a == Float && b == Int) {
return Float;
}
if a == b { a } else { Text }
}
つまり:
-
[1, 2, 3]→Int -
[1, 2.5, 3]→Float(Int → Float に格上げ) -
["", 1, "", 2]→Int(Empty は吸収) -
[1, "alice", 3]→Text(混合)
それぞれの値を classify で見て:
fn classify(v: &str) -> ColType {
let s = v.trim();
if s.is_empty() { return Empty; }
if matches_bool(s) { return Bool; }
if s.parse::<i64>().is_ok() { return Int; }
if s.parse::<f64>().is_ok() && !s.eq_ignore_ascii_case("nan") { return Float; }
if matches_date(s) { return Date; }
Text
}
f64::parse は "NaN" を受け取るので、明示的に除外している (NaN を含む列が numeric 扱いされると右揃えされて見栄えが崩れる)。
日付検出は range 検証なし の安いマッチで、YYYY-MM-DD または YYYY/MM/DD + 任意のタイムゾーン部だけ:
fn matches_date(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() < 10 { return false; }
let dash_or_slash = |b| b == b'-' || b == b'/';
bytes[0..4].iter().all(|b| b.is_ascii_digit())
&& dash_or_slash(bytes[4])
&& bytes[5..7].iter().all(|b| b.is_ascii_digit())
&& dash_or_slash(bytes[7])
&& bytes[8..10].iter().all(|b| b.is_ascii_digit())
&& (bytes.len() == 10 || matches!(bytes[10], b' ' | b'T'))
}
2024-13-99 も通る、それで構わない。型推論はカレンダーではない。
ANSI palette: 分岐ゼロのホットループ
Palette::cyan() のような accessor が &'static str を返す。color が無効なら 空文字列 を返すので、hot loop で if palette.enabled { ... } の分岐が出ない:
pub struct Palette { enabled: bool }
impl Palette {
pub fn cyan(&self) -> &'static str {
if self.enabled { "\x1b[36m" } else { "" }
}
pub fn reset(&self) -> &'static str {
if self.enabled { "\x1b[0m" } else { "" }
}
}
// レンダリング側
write!(w, " {}{}{} {}", palette.cyan(), value, palette.reset(), border)?;
color disabled だと write! に空文字列が混じるだけで、コードパス自体はまったく同じ。Rust の最適化は空 &str の write を fold して消すので、最終的にバイナリが分岐ゼロでフォーマット。
このパターンは hexview (#137) が確立した house style で、同じ書き方を全 Rust エントリで使い回している。
TTY 検出 — atty crate を使わない
atty は deprecated で std に取り込まれた:
use std::io::IsTerminal;
let stdout = io::stdout();
let stdout_is_tty = stdout.is_terminal();
let no_color = args.no_color
|| std::env::var_os("NO_COLOR").is_some()
|| !stdout_is_tty;
これで csv-peek file.csv | less -R のようにパイプされたら自動で color off になる。NO_COLOR 環境変数も尊重 (https://no-color.org の仕様)。
delimiter 自動検出
引数で --delim , を指定しなければ、最初の行の中で , \t ; | の数を数えて最も多いやつを使う:
pub fn guess_delimiter(sample: &str) -> u8 {
let candidates = [b',', b'\t', b';', b'|'];
let line = sample.lines().next().unwrap_or("");
let mut best = (b',', 0usize);
for &c in &candidates {
let count = line.bytes().filter(|&b| b == c).count();
if count > best.1 {
best = (c, count);
}
}
best.0
}
ヒット 0 の場合 (1 列の CSV) は , を返してフォールバック。tab-separated や semicolon-separated でも何もせずにそのまま動く。
テスト 51 件
CLI で動かす integration テストは assert_cmd + predicates:
#[test]
fn handles_quoted_fields_with_commas() {
let f = fixture("name,bio\nAlice,\"hello, world\"\n");
cli().arg(f.path()).arg("--ascii").assert()
.success()
.stdout(predicate::str::contains("hello, world"));
}
#[test]
fn no_color_env_disables_escape_sequences() {
let f = fixture("a,b\n1,2\n");
let assert = Command::cargo_bin("csv-peek").unwrap()
.arg(f.path()).arg("--ascii")
.env("NO_COLOR", "1")
.assert().success();
let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(!out.contains("\x1b["));
}
unit テストは csv パーサのエッジケース (引用符内のカンマ、引用符内の改行、"" エスケープ、recovery)、型推論の境界 (Empty 吸収、Int → Float promote)、palette の有効/無効状態、padding と truncate の Unicode 対応。
cargo test 一発で 34 unit + 17 integration = 51 件 通過。
触る
git clone https://github.com/sen-ltd/csv-peek
cd csv-peek
cargo build --release
./target/release/csv-peek sample.csv
または:
docker build -t csv-peek .
docker run --rm -v "$(pwd)":/data -t csv-peek customers.csv
-t フラグでコンテナに TTY を認識させると ANSI color が出る。なしだと自動で --no-color 相当に落ちる (パイプ時と同じ挙動)。
ソース: https://github.com/sen-ltd/csv-peek — MIT、合計 ~700 行 (Rust)、依存 1 個 (clap)、stripped 約 600 KB、51 テスト。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
