0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ターミナルで CSV を `cat` すると崩れて読めない問題を Rust CLI で解決した — 自前 RFC 4180 パーサ + 型推論色付け、依存は clap だけ

0
Posted at

cat customers.csv で開くと列が崩れて読めない、表計算アプリを開くまでもないけど中身を 8 行だけ確認したい、という日に毎週 1 回くらい遭遇する。csv-peek customers.csv で 1 秒で揃った表になり、数字は右揃えで色付き、日付は黄色、bool はマゼンタ で出る。Rust CLI、依存は clap だけ、stripped で約 600 KB、合計 51 テスト。

csv-peek が customers.csv を 8 行レンダリングしたターミナル画面。box-drawing 罫線で揃った表になり、id 列はシアンで右揃え、日付列は黄色、active 列はマゼンタ、note 列はテキストデフォルトで表示されている

📦 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 つだけ:

  1. 依存が clap 1 個だけ で済むので、cargo build --release が 9 秒、stripped で約 600 KB の Alpine バイナリになる
  2. 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\nfoobar として読み飛ばす。

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 一覧 から。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?