GitHubのトレンドを眺めていたら、Automattic/harper が上のほうに来ていました。「オフラインで、プライバシーファーストな文法チェッカ。速くて、オープンソースで、Rust製」。説明文からしてもう主張が強いんですよね。
自分はフロントエンドを5〜6年やってきて、最近は英語のUIコピーやREADME、エラーメッセージの英文を書く機会がじわじわ増えてきました。そのたびにブラウザ拡張のGrammarlyに頼るんですが、あれって書いたものが全部サーバに飛んでいく前提だし、社内のコードやドキュメントに対して気軽に使っていいのか毎回ちょっと悩むところで。
harperが謳っているのは、その「サーバに送らない」を出発点に、ローカルで数ミリ秒で終わる、という話でした。公式には16GBのn-gramデータが要るLanguageToolとの比較まで出していて、メモリは1/50で済むと。うたい文句は強いので、逆に自分の手で動かして確かめたくなり、使い捨てのコンテナに放り込んでみました。
試した環境
- ホストはWindows + Docker Desktop。検証は使い捨ての
rust:1-bookwormコンテナの中だけで完結させています - コンテナ内: Debian 12 bookworm / Rust 1.97.1
- 対象は
Automattic/harperの commitefa59c33(harper-coreは v2.6.0)
ホスト側には何もインストールしていません。コンテナを立てて、その中で git clone から実行まで全部やって、終わったら丸ごと捨てる、というやり方です。
いきなり詰まった: CLIがcrates.ioに居ない
最初は軽い気持ちで「cargo install 一発でしょ」と思っていました。READMEにも harper-ls のcrates.ioバッジが貼ってあるし。
ところが試したいのは対話的なLSPではなく、コマンドラインで文章を放り込んで結果を見るやつ。リポジトリを覗くと harper-cli というクレートはあるのに、その Cargo.toml にこう書いてあって。
[package]
name = "harper-cli"
version = "0.1.0"
publish = false
publish = false。つまりこれはcrates.ioには上がっていなくて、ソースからビルドするしかない、と。仕方ないのでワークスペースを指定してビルドしたら、release profileで 2分40秒 かかりました。
$ cargo build -p harper-cli --release
Compiling harper-cli v0.1.0 (/work/harper/harper-cli)
Finished `release` profile [optimized] target(s) in 2m 40s
で、出来上がったバイナリの --help を見て、ここで一番「なるほど」となったんですが。
$ harper-cli --help
A debugging tool for the Harper grammar checker
harper-cli は自分自身を「Harper文法チェッカのデバッグ用ツール」だと名乗っているんですね。公式ドキュメントを追い直すと、想定されている本来の入口はエディタに常駐する harper-ls(LSP)や、WebAssemblyで動く harper.js のほう。CLIはあくまで開発・デバッグ用の裏口、という位置づけでした。ここは後半の速度の話にもつながってきます。
実際にlintしてみる
気を取り直して、わざと間違いを詰め込んだ英文を食わせてみました。
$ cat sample.md
I could care less about the the meeting.
Their going to definately need an new plan.
Its a very unique idea that alot of people has.
$ harper-cli --no-color lint sample.md
sample.md: 9 lints before overlap removal, 7 after
出力がなかなか良くて、ariadne を使った、コンパイラのエラーみたいな注釈付きで指摘が返ってきます。抜粋するとこんな感じ。
2 │ Their going to definately need an new plan.
│ ──┬── ─────┬────
│ ╰──────────────────────── [Grammar::TheirToTheyre] (pri 31): Did you mean `they're`?
│ ╰────── [Spelling::SpellCheck] (pri 63): Did you mean to spell `definately` this way?
3 │ Its a very unique idea that alot of people has.
│ ─┬─ ──┬─
│ ╰── [Punctuation::ItsContraction] (pri 54): Use `it's` ... not the possessive `its`.
│ ╰── [BoundaryError::OpenCompounds] (pri 31): `a lot` should be written as two words.
3行のサンプルから、重複除去後で7つのルールが引っかかりました。Their→they're、definately の綴り、an new→a new(AnA)、Its→it's、alot→a lot、それに the the の重複。
個人的にちょっと感心したのが Word Choice::VeryUnique で、「unique(唯一の)は絶対的な語だから very で強めるのはおかしい、special や rare を検討しては」と返してきたところ。この手の「意味的におかしい修飾」まで拾うのは、単なるスペルチェッカとは一味違うなと思いました。
想定外だったのは「拾わないもの」のほう
ここが今回いちばん現実を知った部分で。実は上のサンプル、I could care less(正しくは couldn't care less)と people has(主述の不一致、people have が正しい)もわざと入れておいたんです。
でも、harperはこの2つを指摘しませんでした。JSONで拾った指摘対象を並べると、こうなります。
$ harper-cli --no-color lint --format json sample.md | grep matched_text
"matched_text": "an"
"matched_text": "Its"
"matched_text": "alot"
"matched_text": "the the"
"matched_text": "definately"
"matched_text": "Their"
"matched_text": "very unique"
could care less も people has も居ない。つまりharperは、文全体を深く構文解析して主述の一致を検証したり、慣用句の言い回しを判定したり、というタイプではなくて、「高精度で自信を持って言える指摘だけを、速く返す」方向に振ってあるんだな、と理解しました。誤検知でうるさくなるくらいなら黙る、という設計思想は、正直エディタで常時走らせる前提なら好みが分かれるところですが自分は好きです。
もうひとつ面白かったのが、重複除去まわりの挙動。デフォルトだと alot は OpenCompounds(=a lot にしろ)として1件だけ出ます。ところがルールをスペルチェックだけに絞ると、同じ alot が今度は綴り誤りとして出てくる。
$ harper-cli --no-color lint --only SpellCheck sample.md
sample.md: 2 lints
│ ... [Spelling::SpellCheck] (pri 63): Did you mean to spell `definately` this way?
│ ... [Spelling::SpellCheck] (pri 63): Did you mean to spell `alot` this way?
同じ箇所に複数のルールが当たったとき、優先度で勝ったものだけを残すのがデフォルト、というわけですね。-o(overlapを残す)で全部見ることもできます。
方言の違いはちゃんと効く
米・英の綴り分けも試してみました。colour という英国綴りを1行入れて、-d(dialect)を切り替えるだけ。
$ echo "The colour of the car is unique." | harper-cli --no-color lint -d us
│ ╰──── [Spelling::SpellCheck] (pri 63): Did you mean to spell `colour` this way?
$ echo "The colour of the car is unique." | harper-cli --no-color lint -d british
(指摘なし)
-d us だと colour は綴り誤り扱い、-d british だと素通り。当たり前といえば当たり前なんですが、こういう地味なところがちゃんと動くと信頼できるなと感じます。
速度は「本当にミリ秒」か
最後に、うたい文句の速さを測りました。harper自身のREADMEを60回連結して、228KB / 約20,940語のそこそこ大きい文書を作って食わせてみます。
| 対象 | サイズ | 実測 | 検出数 |
|---|---|---|---|
| sample.md | 3行 | 約0.30秒 | 7 |
| big.md | 228KB / 20,940語 | 0.611秒 | 180 |
ここでちょっと引っかかったのが、3行しかないサンプルでも0.3秒かかっていること。228KBの巨大ファイルとの差が0.3秒ほどしかないんですよね。要するに、プロセス起動時に約0.3秒の固定コスト(辞書やモデルのロードだと思われます)があって、そこから先の実際のlintはかなり速い、という構造でした。
つまり「ミリ秒でlintする」という主張は、lint処理そのものについては嘘じゃない。ただCLIで1ファイルごとに叩くと、毎回この0.3秒の初期化を払うことになります。ここで前半の「本命はCLIじゃなくLSP」という話に戻ってくるわけで、エディタに常駐するLSPなら初期化は起動時に1回きり。以降の1キーストロークごとのチェックはまさにミリ秒で返る、という設計になっている。CLIの数字だけ見て「思ったより遅い?」と早合点しかけたんですが、使い方の想定を踏まえると腑に落ちました。
ちなみにCLIの出力はデフォルトだとルール別・種別別の集計がかなり長めに出るので、CIやスクリプトから使うなら --format json(診断はstdout、Noteなどはstderrに分かれます)で受けて、lintがあれば終了コード1、という素直な作りを使うのが良さそうです。このあたりは自分の使い方次第ですね。
試してみての所感
フロントの立場で言うと、harperは「エディタに harper-ls を挿して、英語のUIコピーやドキュメントを書くときのオフラインな一次チェックに使う」のがいちばん刺さりそうだなと思いました。書いたものが外に出ていかない、というのはやっぱり安心感が違うので。
一方で、今回わかったとおり主述の一致や慣用句までは踏み込まない高精度・軽量路線なので、これ一本で英文レビューが要らなくなるわけではない。あくまで「明らかなミスを速く潰す最初のふるい」として置いておいて、最終的な言い回しは人間(か、必要なら重量級のツール)が見る、という二段構えが現実的かなと。
CLIは今回みたいにCIで機械的にlintを回したり、挙動を確かめたりするデバッグ用途に取っておく、という住み分けで自分は使っていくつもりです。もっと良い使い方があったら教えてください。