1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# simple_json_formatter
JSON を整形するライブラリとコマンドです。依存はなく、std だけで書いています。
数値は元の書き方のまま出します。1.50 も 1E5 も桁の多い整数も、そのまま残ります。
オブジェクトのキーは、並べ替えを指さない限り、出てきた順に出します。
英語の説明は [README.md](./README.md) にあります。
## 使う
ライブラリでは format に文字列と Options を渡します。末尾に改行は付きません。
```
use simple_json_formatter::{Indent, Options, format};
let options = Options {
indent: Indent::Tab,
..Options::default()
};
let text = format(r#"{"name":"日本語","tag":["a","b"]}"#, &options)?;
```
コマンドは、ファイルを指さなければ標準入力を読んで標準出力へ出します。
```
simple_json_formatter data.json 整形して標準出力へ出す
cat data.json | simple_json_formatter -t 標準入力をタブで整形する
simple_json_formatter -w data/*.json ファイルをその場で書き換える
simple_json_formatter --check a.json 整形済みかどうかだけ見る
```
## オプション
インデントは既定で空白 2 つです。`-i N` で空白の数を 0 から 64 までの間で変え、`-t` でタブにします。
`-c` は空白をすべて落として 1 行にまとめます。
`--max-width N` を付けると、この桁数に収まる配列とオブジェクトを 1 行にまとめます。
桁はインデントとキーと末尾のカンマまで入れて、行全体で数えます。
CJK と絵文字は 2 桁、タブは 4 桁です。
`-s` はオブジェクトのキーを辞書順に並べ替えます。入れ子の中まで効きます。
`-a` は ASCII 以外の文字を `\uXXXX` にします。BMP の外の文字はサロゲートペアにします。
`-w` は入力ファイルを整形した結果で上書きします。中身が変わらないファイルには書き込みません。
同じディレクトリに一時ファイルを書いてから置き換えるので、途中で止まっても元のファイルは壊れません。
権限は元のファイルから引き継ぎ、シンボリックリンクはリンク先を書き換えます。
そのため、ファイルのあるディレクトリにも書き込める必要があります。
`--check` はファイルを書き換えず、整形済みでないファイルの名前を出します。
終了コードは、0 が正常、1 が `--check` で整形済みでないファイルあり、2 がエラーです。
## 出力例
入力:
```
{"name":"日本語","tag":["a","b","c"],"nested":{"z":1,"a":{"deep":[1,2,3]}}}
```
`-t`:
```
{
"name": "日本語",
"tag": [
"a",
"b",
"c"
],
"nested": {
"z": 1,
"a": {
"deep": [
1,
2,
3
]
}
}
}
```
`--max-width 60`:
```
{
"name": "日本語",
"tag": ["a", "b", "c"],
"nested": {"z": 1, "a": {"deep": [1, 2, 3]}}
}
```
`-c -a`:
```
{"name":"\u65e5\u672c\u8a9e","tag":["a","b","c"],"nested":{"z":1,"a":{"deep":[1,2,3]}}}
```
## 読み方
受け付けるのは RFC 8259 の JSON だけです。コメントや末尾のカンマはエラーにします。
エラーには行と列を付けます。列はバイトではなく文字の数で数えます。
```
simple_json_formatter: data.json: 2 行 8 列: tru は JSON の値ではありません
```
エラーの文言に入力の文字を出すとき、見えない文字や表示の向きを変える文字は `U+202E` のように符号で出します。
未知の語は先頭の 20 文字までしか出しません。
どちらも、入力で端末の表示を崩されないようにするためです。
文字列のエスケープは、一度解いてから書き直します。`\u3042` は「あ」に、`\/` は `/` になります。
同じキーが 2 回出てきたときは、両方とも残します。
先頭の BOM は読むときに取り除きます。入れ子は 128 段までです。
## 速さ
JSON の速さ比べでよく使われる 3 つのファイルで、serde_json 1.0.151 と比べました。
文字列を受け取ってから整形した文字列を返すまでを Apple M4 で測り、中央値を取っています。
ファイルの読み書きは含みません。
serde_json は、何も付けない場合と、`preserve_order` と `arbitrary_precision` を付けた場合の 2 通りで測りました。
後者はキーの順番と数値の書き方を保つ設定で、原型の jsonfmt もこの設定で使っていました。
出力はこのライブラリと 1 バイトも違いません。
何も付けない serde_json はキーを辞書順に並べ替え、数値も書き換えるので、出力は同じではありません。
整形にかかった時間を、このライブラリ、何も付けない serde_json、2 つを付けた serde_json の順に並べます。
- canada.json(2.2MB、数値が多い): 5.3ms、8.8ms、20.8ms
- citm_catalog.json(1.7MB、オブジェクトが多い): 1.9ms、3.3ms、4.4ms
- twitter.json(0.6MB、日本語の文字列が多い): 0.9ms、1.6ms、1.9ms
`-c` で 1 行にする場合も速さの順は変わらず、canada.json では 4.2ms、6.1ms、18.7ms でした。
測り直すときは、compare/fetch.py でデータを取ってきてから、次の 2 つを動かします。
後のほうが `preserve_order` と `arbitrary_precision` を付けた serde_json です。
```
cargo run --release --manifest-path compare/bench/Cargo.toml -- \
compare/data/canada.json \
compare/data/citm_catalog.json \
compare/data/twitter.json
cargo run --release --manifest-path compare/bench/Cargo.toml --features exact -- \
compare/data/canada.json \
compare/data/citm_catalog.json \
compare/data/twitter.json
```
コマンドで 72MB のファイルを整形すると、起動と読み書きを含めて 0.7 秒でした。
メモリは、いろいろな値が混ざったデータで入力の 7 倍ほど使います。
`[0,0,0,...]` のように小さな値ばかりのデータでは 20 倍ほどになります。
入力は全部メモリに読み込むので、信用できない大きな入力を扱うときは、先に大きさを確かめてください。
## 原型との違い
jsonfmt という原型をもとにしています。
原型は serde_json、clap、anyhow を使っていましたが、ここでは使いません。
原型にあった `--tab-width`、`--space-after-colon`、`--escape-slash`、`--eol`、`--no-final-newline`、`--jsonl`、`-o` は外しました。
改行は LF だけで、末尾には必ず改行を 1 つ付けます。
原型の `--max-width` はキーの長さを数えていなかったので、行が幅からはみ出していました。
ここではキーも数えます。
原型は同じキーが 2 回あると後の値だけを残していましたが、ここでは両方とも残します。
## テスト
```
cargo test
```
tests/format.rs が整形の結果を、tests/parse.rs が壊れた JSON のエラーの位置と文言を確かめます。
compare/ には、外から確かめるための Python のスクリプトと、速さを比べるプログラムを置いています。
パッケージには入りません。Python 3.12 以降で動きます。
先にリリースビルドを作り、fetch.py で JSONTestSuite と速さ比べのデータを取ってきてから動かします。
```
cargo build --release
python3 compare/fetch.py JSONTestSuite と速さ比べのデータを取ってくる
python3 compare/suite.py JSONTestSuite の 318 件を通す
python3 compare/random_test.py ランダムな JSON を Python の json.dumps と 1 文字ずつ突き合わせる
python3 compare/sanity.py 突き合わせが、わざとずらしたものを見逃さないかを確かめる
python3 compare/fuzz.py 壊した JSON 5000 件で、受け付けるかどうかを Python と比べる
python3 compare/long_string.py --max-width の境目に長い文字列を置いて突き合わせる
python3 compare/cli_test.py コマンドのオプション、終了コード、-w、--check
python3 compare/attack.py 悪意のある入力と、メモリが入力の何倍になるか
python3 compare/leak.py メモリリーク(macOS だけ)
python3 compare/gen_big.py 速さを見るための大きな JSON を compare/big/ に作る
```
leak.py の前には、`cargo build --release --manifest-path compare/bench/Cargo.toml --bin repeat` が要ります。
## ライセンス
MIT と Apache-2.0 の二択です。好きなほうを選んでください。
- [LICENSE-MIT](./LICENSE-MIT)
- [LICENSE-APACHE](./LICENSE-APACHE)