jqより便利そうなTUIツールtsonが良さげな件

ども、ゴリラです。

みなさん`jq`を使っていますか？とても便利なのでおそらく多くの方は`jq`を使っていると思います。
ぼくもその一人ですが、最近JSONをツリー状にして編集、フィルタリング、保存できたら便利では？と思い立って`tson`っていうTUIツールを作りました。
意外と便利だったので、紹介していきます。

<a href="https://github.com/skanehira/tson"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/skanehira/tson.png" width="460px"></a>

# どんな感じ？
ウホウホウ
![tson-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/6d89e430-c517-44f6-86e6-6fe3013ae853.gif)

## 対応OS
Mac/Linuxのみになります。（Windowsだと画面が崩れます）
将来的にはWindowsにも対応するつもりです。

## 機能
便利だろうと思った機能を実装しました。次になります。

- ファイル、URLからJSONを読み込み、ツリー化
- フィルタリング
- 編集、追加、削除
- ファイルに保存
- 外部エディタを使ってJSONを編集する

## 使い方
とてもシンプルです。以下の3つの方法があります。

- ファイルから直接読み込む：`tson < file.json`
- パイプ`|`で標準出力から読み込む：`curl http://gorilla/api | tson`
- `-url`引数を使ってURLのレスポンスから読み込む：`tson -url http://gorilla/api`

### URLから読み込みについて
`-url`で指定したURLからJSONを読み込むことができますが、これは簡易なHTTP GETなため複雑なことはできません。
GET以外のPOSTやヘッダーをつけてリクエストを発行したいときは`curl`といったコマンドを使用して`|`でtsonに流し込むと良いでしょう。

## 具体的な使い方について
`tson`の便利なところはインタラクティブにJSONを編集できるところです。TUIツールならではですね。
個人的におすすめなキーバインドをいくつか紹介します。より詳細なキーバインドは[こちら](https://github.com/skanehira/tson#keybinding)を参照してください。

### 絞り込み
`/`で検索ダイアログが表示されるので、検索したい文字を入力してください。
入力するたび結果が即反映されます。特に長いJSONの場合キー名を入力して絞り込みするときに便利でしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/90f03964-3eb5-ea71-814c-0e671185ca1b.png)

### 折りたたみ
`H`でvalueノードをすべて折りたたみ、Keyノードだけ残します。これは長いJSONの全体像を把握するのに役に立ちます。

- 折りたたむ前
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/761abcf5-d5fc-0655-31f7-657ba3134839.png)

- 折りたたんだ後
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/99ee0f23-4fc2-af2f-9191-bea60afac7f4.png)

現在のkeyノードのみ折りたたみたい場合は`h`を使用します。
valueを知りたいときは`l`で現在のノードを展開できます。また全体を展開したいときは`L`を使用します。

### 外部エディタを使ってJSONを編集
`e`で`$EDITOR`に設定されているエディタを使用してJSONを編集することができます。
エディタで編集したJSONを保存して終了すると、制御はtsonに戻り編集した結果も反映されます。

- 編集前
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/64be5d9a-e8cb-63bb-eff2-03145d109395.png)

- エディタを使って編集
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/085dd3ca-365e-97aa-a394-1e66ca288296.png)

- 編集後
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/a46c47c1-cce6-c984-7020-7749be4e82db.png)

ただ、エディタを使用して`tson`も戻ったあとに1キーストロークが反応しなくなります。
つまり1回目に入力したキーが効かないです。これは`tview`が使用している`tcell`のバグのようです。
現在まだ修正されていないのと`tcell`作者があんまり直す気がないので、修正されるのを待つよりもこちらが直したほう早いと思いますが、tcellナニモワカラナイので、時間はかかりますが自分が頑張って治そうと思います。

### ノード間ジャンプ
`ctrl-j`/`ctrl-k`でノード間のジャンプができます。子ノードの数が多く、次のノードに移動したいときに便利です。
例えば、次のように`range`ノードから`index`ノードにジャンプするときに使用したりします。

- 移動前
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/bc2f5635-36a1-668b-71f5-bed2770b724b.png" width="460px"></img>

- 移動後
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/8a517250-7f16-1635-0c3c-429d7d06cfed.png" width="460px"></img>

### ファイルに保存
`s`で現在のツリー状態をファイルに保存できます。もちろん出力もJSONです。
例えば`tson -url https://gorilla/api`でレスポンスを確認したあとにそれを出力したいときに役たちます。

- 出力前
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/81c8616a-6da8-1856-10de-d5ef4917d364.png" width="460px"></img>

- 出力後
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/90bb2e0d-f869-f69a-d5ab-f9db175f510a.png" width="460px"></img>

## 実装
すこし実装について書いていきます。実際ソースコードを読んでもらった方がわかりやすいのかもしれません。
まず、JSONは以下の型が定義されています。

`false/null/true/object/array/number/string`

これは[RFC 8259](http://www.asahi-net.or.jp/~ax2s-kmtn/internet/rfc8259j.html)で詳しく定義されているので、興味ある方は読んでみてください。
ちなみに、numberは`int`と`float`が含まれています。

### JSONの読み込みからツリー生成
JSONからツリーを作成するときの大まかの処理の流れは次になります。

1. JSONをGoの型にパース(`json.Unmarshal()`を使用)
2. objectかarrayかliteralかで処理を分岐し、objectとarrayの中身を再帰的に処理していく

objectとarrayはarray、object、literalを持つことができるので、
どのように型を判定して適切な処理を行うのか、この再帰処理がかなり難航しました。

#### JSONの読み込み
JSONを読み込むときに、空インターフェイスを用意します。なぜならJSONがどんな形でも対応できるようにするためです。

```go
b, err := ioutil.ReadAll(in)
if err != nil {
	log.Println(err)
	return nil, err
}

var i interface{}
if err := json.Unmarshal(b, &i); err != nil {
	return nil, err
}
```

あとは`json.Unmarshal`がJSONをよしなにGoの型にしてくれます。
JSONの型に対応するGoの型は次になります。

| JSON     | Go                         |
|----------|----------------------------|
| `string` | `string`                   |
| `number` | `int/float64`              |
| `object` | `[]map[string]interface{}` |
| `array`  | `[]interface{}`            |
| `true`   | `true`                     |
| `false`  | `false`                    |
| `null`   | `nil`                      |

ちなみに、ツリーを作成するときにどのノードがどの型なのか、という情報を持たせる必要があります。
なぜなら、この情報はツリーをJSONに書き出すときに使用するからです。

#### ツリー生成
パース後のinterfaceを`type`を使って型ごとに処理を行います

```go
switch node := node.(type) {
case map[string]interface{}:
	// objectの場合の処理
case []interface{}:
	// arrayの場合の処理
default:
	// literalの場合の処理
}
```

objectとarrayは基本的に`t.AddNode()`を使って再帰処理を行います。
それと同時に親ノードが`array`なのか`object`なのかといった情報を`Referrence`というオブジェクトにセットしています。
なぜノードの型情報が必要かというと、JSONに書き出すときも再帰的にノードを辿っていきますが、
`array`か`object`かによって処理が変わるので、その判定に使用します。

ちなみに、次のコードは`array`の場合の処理です。

```go
case []interface{}:
	for _, v := range node {
		id := uuid.Must(uuid.NewV4()).String()
		switch v.(type) {
		case map[string]interface{}:
			objectNode := tview.NewTreeNode("{object}").
				SetChildren(t.AddNode(v)).SetReference(Reference{ID: id, JSONType: Object})
			nodes = append(nodes, objectNode)
		case []interface{}:
			arrayNode := tview.NewTreeNode("{array}").
				SetChildren(t.AddNode(v)).SetReference(Reference{ID: id, JSONType: Array})
			nodes = append(nodes, arrayNode)
		default:
			nodes = append(nodes, t.AddNode(v)...)
		}
	}
```

literalの場合、型チェックは`reflect`パッケージを使用して次のようにチェックします。
`ValueType`はJSONを書き出すときに`null`で書き出すのか`"null"`で書き出すのかといった判定に使用します。

JSONの書き出しはこうった情報がないと正しく書き出せないので、ここがキモになります。

```go
default:
	ref := reflect.ValueOf(node)
	var valueType ValueType
	switch ref.Kind() {
	case reflect.Int:
		valueType = Int
	case reflect.Float64:
		valueType = Float
	case reflect.Bool:
		valueType = Boolean
	default:
		if node == nil {
			valueType = Null
		} else {
			valueType = String
		}
	}

	id := uuid.Must(uuid.NewV4()).String()
	nodes = append(nodes, t.NewNodeWithLiteral(node).
		SetReference(Reference{ID: id, JSONType: Value, ValueType: valueType}))
```

ちなみに、`ValueType`の定義は以下のようになっています。

```go
type ValueType int

const (
	Int ValueType = iota + 1
	String
	Float
	Boolean
	Null
)
```

### JSONの書き出し
JSONの書き出しですが、こちらはとてもシンプルです。
`JSONType`で`array`か`object`かそれ以外かで処理を分けます。
こちらも再帰的にノードを辿りながらJSONを組み上げていきます。

`parseValue`で`ValueType`を見て`int`なのか`string`なのかといった値の種類別で変換処理を行います。

```go
func (g *Gui) makeJSON(node *tview.TreeNode) interface{} {
	ref := node.GetReference().(Reference)
	children := node.GetChildren()

	switch ref.JSONType {
	case Object:
		i := make(map[string]interface{})
		for _, n := range children {
			i[n.GetText()] = g.makeJSON(n)
		}
		return i
	case Array:
		var i []interface{}
		for _, n := range children {
			i = append(i, g.makeJSON(n))
		}
		return i
	case Key:
		v := node.GetChildren()[0]
		if v.GetReference().(Reference).JSONType == Value {
			return g.parseValue(v)
		}
		return map[string]interface{}{
			node.GetText(): g.makeJSON(v),
		}
	}

	return g.parseValue(node)
}

func (g *Gui) parseValue(node *tview.TreeNode) interface{} {
	v := node.GetText()
	ref := node.GetReference().(Reference)

	switch ref.ValueType {
	case Int:
		i, _ := strconv.Atoi(v)
		return i
	case Float:
		f, _ := strconv.ParseFloat(v, 64)
		return f
	case Boolean:
		b, _ := strconv.ParseBool(v)
		return b
	case Null:
		return nil
	}

	return v
}
```

ざっくりですが、以上が`tson`の処理の一部になります。

## 今後について
`tson`は今後も機能を追加していきます。
具体的に言うと[Postman](https://www.getpostman.com/)のような、ヘッダーやリクエストボディなどを自由に編集してHTTPリクエストを発行できる機能を追加する予定です。

## おまけ
デモの中で[gjo](https://github.com/skanehira/gjo)というツールを使いましたが、これは画像のように`key=value`の組み合わせで簡単にJSON文字列を生成できるツールです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/00387b62-6827-f536-2a12-f868d88c1529.png)

`curl -X POST -H "Content-Type: application/json" https://gorilla/api -d $(gjo name=gorilla)`
って感じでcurlと組み合わせて使用できるので、よかったら使ってみてください。

## 最後に
`tson`はまだまだ使いにくいところがあります。
まだまだ改善の余地があるので、この記事を見てGo詳しくなくても、
OSSを作るのをチャレンジしてみたい方や一緒に作ってみたい方はぜひ声かけてください。お待ちしています。