シェルスクリプトでjqを使わずsedなど基本コマンドのみでJSONを破綻なく解釈

## 概要

シェルスクリプトで`jq`コマンドなど使わず、`sed`などの基本コマンドのみでJSONを破綻なく解釈したい。
破綻なくというのはこの記事では以下を指す。

* 文字列に記号が入っていても問題ない
* 配列、オブジェクトのネスト構造を、ちゃんとネスト構造として解釈する

## 何ができるようになるか

最初に約30行の処理でJSONを変形することで、以降の処理で、主に`sed`を使いJSONを破綻なく解釈できるようになる。
例として、以下のJSONを扱う。

```json
{
    "startAt": 0,
    "total": 8,
    "issues": [
        {
            "id": "100",
            "fields": {
                "summary": "test100",
                "description": "test test test test",
                "iconUrl": "http://localhost:8000/images/icons/test.gif"
            }
        },
        {
            "id": "101",
            "fields": {
                "summary": "test101",
                "description": "test \":[]{} test",
                "iconUrl": "http://localhost:8000/images/icons/test.gif"
            }
        }
    ]
}

```

`issues[1].fields.description`の値を取得したい場合、

```sh
root_val=$(cat | normalize_and_encode)
root=$(printf %b "$root_val" | tr -d '{}' | sed 's/,/\n/g')
issues_val=$(printf %s "$root" | sed -n 's/^"issues"://p')
issues=$(printf %b "$issues_val" | tr -d '[]' | sed 's/,/\n/g')
issue_val=$(printf %s "$issues" | sed -n 2p)
issue=$(printf %b "$issue_val" | tr -d '{}' | sed 's/,/\n/g')
fields_val=$(printf %s "$issue" | sed -n 's/^"fields"://p')
fields=$(printf %b "$fields_val" | tr -d '{}' | sed 's/,/\n/g')
description_val=$(printf %s "$fields" | sed -n 's/^"description"://p')
description=$(printf %b "$(printf %s "$description_val" | sed 's/"//g')")
printf 'description: `%s`\n' "$description"
```

で、

```
description: `test ":[]{} test`
```

と期待する結果が得られるようになる。
上記のように記号が文字列に含まれていても破綻なく扱える。

## アイデア

* 最もネストが深い配列・オブジェクトをエンコードし、それらの表現に`[]{},`の記号が含まれないようにする
* 上記を全体で`[]{},`が含まれなくなるまで繰り返す
* この文字列にデコードを1回行うと、最も浅い配列・オブジェクトのみが`[]{},`で解釈できるようになる
* 上記により、最も浅い配列・オブジェクトを簡単にUNIXが扱いやすい1要素1行の形式に整形できるようになる
* 子の配列・オブジェクトを扱いたい場合は、その部分のみ取り出すことで、上記の処理を再帰的に適用できる
* エンコードとデコードには、エスケープとエスケープ解釈を活用する

## 必要な実装

必要な実装は、最初のJSON変換のみで、その全実装は以下。

```sh
normalize_and_encode() (
  json=$(cat \
    | sed 's/\\"/\\042/g' \
    | sed 's/"[^"]*"/\n&\n/g' \
    | sed '/^"/!s/ //g' \
    | sed '/^"/{
      s/,/\\054/g
      s/\[/\\133/g
      s/\]/\\135/g
      s/{/\\173/g
      s/}/\\175/g
    }' \
    | tr -d '\n'
  )
  while printf %s "$json" | grep '[[{]' > /dev/null; do
    json=$(printf %s "$json" \
      | sed 's/[[{][^][}{]*[]}]/\n&\n/g' \
      | sed '/^[[{].*[]}]$/{
        s/\\/\\\\/g
        s/,/\\054/g
        s/\[/\\133/g
        s/\]/\\135/g
        s/{/\\173/g
        s/}/\\175/g
      }' \
      | tr -d '\n'
    )
  done
  printf %s "$json"
)
```

## 処理の説明

### 前半：JSONの正規化

```sh
  json=$(cat \
    | sed 's/\\"/\\042/g' \
    | sed 's/"[^"]*"/\n&\n/g' \
    | sed '/^"/!s/ //g' \
    | sed '/^"/{
      s/,/\\054/g
      s/\[/\\133/g
      s/\]/\\135/g
      s/{/\\173/g
      s/}/\\175/g
    }' \
    | tr -d '\n'
  )
```

JSONを今回の処理に都合良いように正規化する。
主に文字列にJSONの配列・オブジェクト構造を表す文字が入らないよう、`",[]{}`を`\NNN`のエスケープ表現に変更している。
`\NNN`のエスケープ表現はJSON仕様には含まれないので、実際には正規化の範囲を超えている。

`\uHHHH`であれば、JSON仕様に合致するのだが、
POSIXの`printf`のエスケープ解釈の仕様には`\NNN`は含まれているが、`\uHHHH`は含まれない。

また、GNU coreutilsの`printf`の`\uHHHH`は、今回必要な`",[]{}`に対応する表現はinvalidになってしまう。

[Re: /usr/bin/printf: invalid universal character name](https://lists.gnu.org/archive/html/bug-coreutils/2008-05/msg00068.html)

上記のように、`\uHHHH`では、エスケープ解釈に支障あるため、仕方なく`\NNN`のエスケープ表現にしている。

以下、順番に説明していく。

```sh
  json=$(cat \
    | sed 's/\\"/\\042/g' \
```
↑文字列中の`\"`を`\042`に置換していく。
これで、`"`はJSON全体で文字列を囲うことにしか使われなくなることが保証される。

```sh
    | sed 's/"[^"]*"/\n&\n/g' \
```

↑文字列が文字列のみで1行となるように改行を入れる。

```sh
    | sed '/^"/!s/ //g' \
```

↑`"`で始まっていない行、つまり文字列でない行では、スペースを削除する。

```sh
    | sed '/^"/{
      s/,/\\054/g
      s/\[/\\133/g
      s/\]/\\135/g
      s/{/\\173/g
      s/}/\\175/g
    }' \
```

↑`"`で始まる行、つまり文字列の行では、JSON構造に使われる文字`,[]{}`、を`\NNN`でのエスケープ表現にする。
これで、文字`,[]{}`は、JSON全体でJSON構造の表現にしか使われないことが保証できる。
後述の処理で`:`を単独で判定することはないため、`:`を対象にする必要はない。

```sh
    | tr -d '\n'
```

↑処理都合で追加していた改行と、元からあった不要な改行を削除する。
（JSONでは文字列中には制御文字は使えないため、文字列中に改行はないはず）

この変換で例のJSONは、

```json
{"startAt":0,"total":8,"issues":[{"id":"100","fields":{"summary":"test100","description":"test test test test","iconUrl":"http://localhost:8000/images/icons/test.gif"}},{"id":"101","fields":{"summary":"test101","description":"test \042:\133\135\173\175 test","iconUrl":"http://localhost:8000/images/icons/test.gif"}}]}
```

となる。

文字列中の記号が、

```json
"description":"test \042:\133\135\173\175 test"
```

と`\NNN`のエスケープ表現に変換されている点が注目すべき点だ。

### 後半：再帰的にエンコード

ネストが最も深い配列・オブジェクトをエスケープし、適用対象がなくなるまで繰り返す。

```sh
  while printf %s "$json" | grep '[[{]' > /dev/null; do
    json=$(printf %s "$json" \
      | sed 's/[[{][^][}{]*[]}]/\n&\n/g' \
      | sed '/^[[{].*[]}]$/{
        s/\\/\\\\/g
        s/,/\\054/g
        s/\[/\\133/g
        s/\]/\\135/g
        s/{/\\173/g
        s/}/\\175/g
      }' \
      | tr -d '\n'
    )
  done
  printf %s "$json"
```

順番に解説していく。

```sh
  while printf %s "$json" | grep '[[{]' > /dev/null; do
```
↑`$json`に`[`か`{`が含まれなくなるまで、後続のエスケープ処理を繰り返す。
以降もそうだが、POSIXでは`echo`はエスケープ解釈するかどうかが明確ではないので、
エスケープ解釈しないことを保証したい文字列出力では`printf %s`を使っていく。

```sh
    json=$(printf %s "$json" \
      | sed 's/[[{][^][}{]*[]}]/\n&\n/g' \
```
↑ネストが最も深い配列・オブジェクト、つまりネストしていない配列・オブジェクトが単体の行となるよう分割する。
正規表現が少し分かりづらいが、
`[`か`{`で始まり、`][}{`を含まない部分を読み進め、`]`か`}`で終わる、と判定することで、上記を実現している。

```sh
      | sed '/^[[{].*[]}]$/{
        s/\\/\\\\/g
        s/,/\\054/g
        s/\[/\\133/g
        s/\]/\\135/g
        s/{/\\173/g
        s/}/\\175/g
      }' \
```

↑上記で単体の行になった、ネストしていない配列・オブジェクトに対して、`,[]{}`を`\NNN`でのエスケープ表現に変更する。
直前の加工後なため、`[`か`{`で始まり、`]`か`}`で終わるという判定に簡略化できている。
これによって、これらには`,[]{}`が含まれなくなる。
また、`\`自体をエスケープすることにより、多重にエスケープできるようにしている。

```sh
      | tr -d '\n'
    )
  done
```

↑処理都合で挿入していた改行を削除する。
そして、ここまでの結果が`$json`に再代入される。
そして最初の判定に戻り、再帰的にエスケープ処理が繰り返される。

```sh
  printf %s "$json"
```

↑ループが終わったら結果を出力する。

この処理で、例のJSONは、

```
\173"startAt":0\054"total":8\054"issues":\\133\\\\173"id":"100"\\\\054"fields":\\\\\\\\173"summary":"test100"\\\\\\\\054"description":"test test test test"\\\\\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\\\\\175\\\\175\\054\\\\173"id":"101"\\\\054"fields":\\\\\\\\173"summary":"test101"\\\\\\\\054"description":"test \\\\\\\\\\\\\\\\042:\\\\\\\\\\\\\\\\133\\\\\\\\\\\\\\\\135\\\\\\\\\\\\\\\\173\\\\\\\\\\\\\\\\175 test"\\\\\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\\\\\175\\\\175\\135\175
```

のようになる。
ネストが深い配列・オブジェクトはネストの数だけ多重にエスケープされている。
`\`は、エスケープの度に2倍に増えていく。

## 変形したJSONの扱い方

`normalize_and_encode`で変形したJSONの扱い方を説明していく

### 値をオブジェクトとして解釈し1要素1行の文字列にする

```sh
object=$(printf %b "$value" | tr -d '{}' | sed 's/,/\n/g')
```

順番に説明していく。

```sh
  printf %b "$value"
```

↑エスケープを解釈している。Bashでは`echo -e`でもよいが、POSIXに準拠すると上記になる。
これで、最も浅いオブジェクトのみ`{,,,}`の構造が表れる。

```sh
| tr -d '{}'
```

↑エスケープ解釈で文字になった最も浅いオブジェクトの`{`と`}`を削除する。
最初と最後の文字の削除になるはず。

```sh
| sed -z 's/,/\n/g'
```
↑エスケープ解釈で文字になった最も浅いオブジェクトの`,`を改行に変換する。
これで、最も浅いオブジェクトが1要素1行となりUNIXコマンドで扱いやすい形式になる。

例のJSONに1回適用すると、

```
"startAt":0
"total":8
"issues":\133\\173"id":"100"\\054"fields":\\\\173"summary":"test100"\\\\054"description":"test test test test"\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\175\\175\054\\173"id":"101"\\054"fields":\\\\173"summary":"test101"\\\\054"description":"test \\\\\\\\042:\\\\\\\\133\\\\\\\\135\\\\\\\\173\\\\\\\\175 test"\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\175\\175\135
```

となる。
1階層目のオブジェクトが1要素1行になっているのが分かる。3要素のオブジェクトなので3行になっている。
また、2階層以降の配列・オブジェクトはエスケープされていて、`[]{},`を含まないことが分かる。

### オブジェクトのキーに対応する値を取り出す

キー名が`name`の値を取り出す場合、以下になる。

```sh
value=$(printf %s "$object" | sed -n 's/^"name"://p')
```

`"name":`で始まる行で`"name":`を削除しつつ、その行を出力している。
これで値部分のみを取り出すことができる。
この値がオブジェクトの場合は、「値をオブジェクトとして解釈し1要素1行の文字列にする」を再度適用することができる。
配列の場合は、「値を配列として解釈し1要素1行の文字列にする」を適用することができる。

### 値を配列として解釈し1要素1行の文字列にする

```sh
array=$(printf %b "$value" | tr -d '[]' | sed 's/,/\n/g')
```

「値をオブジェクトとして解釈し1要素1行の文字列にする」との違いは、
`tr`での`{}`が`[]`になっているのみ。

例のJSONで、ルートオブジェクトの`issues`の値に適用すると、

```
\173"id":"100"\054"fields":\\173"summary":"test100"\\054"description":"test test test test"\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\175\175
\173"id":"101"\054"fields":\\173"summary":"test101"\\054"description":"test \\\\042:\\\\133\\\\135\\\\173\\\\175 test"\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\175\175
```

となる。
2要素の配列なので2行になっている。

### 配列の指定インデックスに対応する値を取り出す

配列の2番目のインデックスの値を取り出す場合、以下になる。

```sh
value=$(printf %s "$array" | sed -n 2p)
```

`sed`の基本的な使い方で、指定行を取り出している。

オブジェクトで値を取り出した場合と同様に、取り出した値が、オブジェクト・配列の場合、
「値をオブジェクトとして解釈し1要素1行の文字列にする」、「値を配列として解釈し1要素1行の文字列にする」を適用することができる。

### 値を文字列として解釈する

```sh
string=$(printf %b "$(printf %s "$value" | sed 's/"//g')")
```

文字列を囲う`"`を削除し、その中身をエスケープ解釈している。
前述の処理により、文字列中に`"`は含まれていないことが保証されている。
`printf %b`が解釈できるエスケープは、JSON文字列のエスケープの仕様と一致していない部分もあるがおおよそうまく動作する。
例えば、`\uHHHH`はPOSIX仕様には含まれないが、Bashのビルトイン`printf`では解釈できる。

## まとめ

シンプルなアイデアにより、シェルスクリプトで`sed`などの基本コマンドのみでJSONを破綻なく解釈できる方法が確立した。