Pretty JSONを通してSublime Textプラグインの挙動を探ってみた

Sublime TextのPretty JSONプラグインで期待通り動作しない部分があったため、この機会にプラグインの理解を深めてみました。
jqの結果でCRノイズが発生する問題も修正しています。

はじめに

想定する読者

  • Sublime Text 3をある程度使いこなしていること
  • Pythonのソースコードを読むことができること

環境

環境 バージョン
Sublime Text 3 Build 3126
Pretty JSON 2016.09.01.14.51.10 (3.0.7)
OS Windows10

参考

情報古い上にSublime Text2って書いてありますが、Sublime Text3のドキュメントからのリンクなので信用していいはず…

How to Create a Sublime Text 2 Plugin

APIリファレンスは公式の以下ページが便利です。

API Reference – Sublime Text 3 Documentation

Pretty JSONプラグインとは

Jsonを整形/検証したり、jqを使って結果を抽出したりすることができるSublime Textプラグインです。
XMLに変換したりすることもできます。

dzhibas/SublimePrettyJson

なお、YAML版も存在します。

修正したい挙動

  • jqをインストールしたが JSON query with ./jq の機能が使えない
  • JSON query with ./jq の結果がCRLF改行で表示される

ソースコードの場所

以下の場所に配置されています。

C:\Users\<ユーザ名>\AppData\Roaming\Sublime Text 3\Packages\Pretty JSON

勿論、事前にPackage Controlでインストールしておいて下さい。

ソースコードの概要

大事なファイルについて概要をまとめます。

ファイル名 概要
package-metadata.json パッケージ情報
Main.sublime-menu 追加するメニュー
Pretty JSON.sublime-settings パッケージの設定ファイル
Default.sublime-commands コマンドパレットで追加するコマンド
PrettyJson.py ソースコードのMain

では、修正した挙動の関係箇所を追っていきましょう。

プラグインコードの概要

コマンドのタイプは3種類ありますが、主に使うのは以下2つです。

コマンド名 使用用途 実行方法
Text commands テキスト編集エリアに直接操作を行う view.run_command(‘…’)
Window commands 一度Windowで入力を促す window.run_command(‘…’)

Main.sublime-menu で定義されたコマンド名と PrettyJson.pyで定義されたクラス名が連動します。

例えば、Main.sublime-menuに以下の記述がある場合

    {
        "caption": "説明とか",
        "command": "hello_world"
    }

PrettyJson.pyに記述された以下のrunが実行されます。

class HelloWorldCommand(sublime_plugin.TextCommand):
    def run(self):
        # TODO: Hello World!!

クラス名suffixのCommandを削除して、CamelCaseをlower_caseに変換したものがcommandと判定されるわけですね。

イマイチよく分からないかもしれませんが、雰囲気が分かったら先に進みましょう。

jqの機能が使えない

原因の特定

先ほどの知識を元に該当箇所を特定していきます。
Main.sublime-menuを調べると、commandが特定できます。

    {
        "caption": "Pretty JSON: JSON query with ./jq",
        "command": "jq_pretty_json"
    }

jq_pretty_jsonですね。
さて、これに紐づくクラス名をPrettyJson.pyから探してみましょう。

class JqPrettyJson(sublime_plugin.WindowCommand):
    """
    Allows work with ./jq
    """
    def run(self):
        check_jq()
        if jq_exits:
            self.window.show_input_panel("Enter ./jq filter expression", ".",
                                         self.done, None, None)
        else:
            sublime.status_message('./jq tool is not available on your system. http://stedolan.github.io/jq')

suffixのCommandがありませんが、無くても問題ないのかもしれません。
sublime_plugin.WindowCommand を継承しているので、Window commandsであることも分かります。

jq_exitsがTrueであればパネルが表示されるので、恐らくここがFalseです。
jq_exists のtypoのような気がします..

jq_exitsはグローバル変数で、しかも check_jq でコッソリ変更されています。(エグイ)

def check_jq():
    global jq_exits
    global jq_init

    if not jq_init:
        jq_init = True
        try:
            # checking if ./jq tool is available so we can use it
            s = subprocess.Popen(["jq", "--version"],
                                 stdin=subprocess.PIPE,
                                 stderr=subprocess.PIPE,
                                 stdout=subprocess.PIPE)
            out, err = s.communicate()
            jq_exits = True
        except OSError:
            os_exception = sys.exc_info()[1]
            print(str(os_exception))
            jq_exits = False

subprocess.Popenに注目です。
jq --version の結果が成功すればjq_exitsはTrueになるので、これが失敗しています。

解決方法

jqに環境変数を通しておけばOKです。

jqの結果がCRLF改行で表示される

jqで絞込はできるのですが、結果のViewにCRという文字が表示されてしまいます。

原因の特定

ソースコードの挙動把握

先ほどのソースコードをもう一度見てみます。

class JqPrettyJson(sublime_plugin.WindowCommand):
    """
    Allows work with ./jq
    """
    def run(self):
        check_jq()
        if jq_exits:
            self.window.show_input_panel("Enter ./jq filter expression", ".",
                                         self.done, None, None)
        else:
            sublime.status_message('./jq tool is not available on your system. http://stedolan.github.io/jq')

self.window.show_input_panel はパネルを表示して、入力された内容を第3引数の関数に渡します。
この場合は JqPrettyJson.done(<入力文字列>) ですね。

    def done(self, query):
        try:
            p = subprocess.Popen(["jq", query],
                                 stdout=subprocess.PIPE,
                                 stderr=subprocess.PIPE,
                                 stdin=subprocess.PIPE)

            raw_json = self.get_content()

            if SUBLIME_MAJOR_VERSION < 3:
                if sys.platform != 'win32':
                    out, err = p.communicate(bytes(raw_json))
                else:
                    out, err = p.communicate(unicode(raw_json).encode('utf-8'))
            else:
                out, err = p.communicate(bytes(raw_json, "utf-8"))
            output = out.decode("UTF-8").strip()
            if output:
                view = self.window.new_file()
                view.run_command("jq_pretty_json_out", {"jq_output": output})
                view.set_syntax_file("Packages/JavaScript/JSON.tmLanguage")

        except OSError:
            exc = sys.exc_info()[1]
            sublime.status_message(str(exc))

query が入力文字列なので、.items[].idと入力した場合は以下のコマンド結果がoutに代入されます。

jq .items[].id <jsonのエンコードされた文字列>

再びoutをUnicodeに戻し、結果が存在すれば新しいファイルに書き込みして完了です。

原因特定のための調査

さて、CRが混入された原因は何でしょうか?
CR改行が確実に使用される箇所.. それは当然subprocessを使用した際の戻り値となります。

ちょっとログを仕込んでみました。

            if SUBLIME_MAJOR_VERSION < 3:
                if sys.platform != 'win32':
                    out, err = p.communicate(bytes(raw_json))
                else:
                    out, err = p.communicate(unicode(raw_json).encode('utf-8'))
            else:
                out, err = p.communicate(bytes(raw_json, "utf-8"))
                print("out")
                print(out)
            output = out.decode("UTF-8").strip()
            print("output")
            print(output)

結果は以下の様になりました。

out
b'1\r\n2\r\n3\r\n'
output
1
CR
2
CR
3

予想通り、p.communicateの結果にはCRが含まれます。

解決方法

outputに代入する箇所で replace(os.linesep, "\n") を追加し、改行コードを\nに統一します。

    def done(self, query):
        try:
            p = subprocess.Popen(["jq", query],
                                 stdout=subprocess.PIPE,
                                 stderr=subprocess.PIPE,
                                 stdin=subprocess.PIPE)

            raw_json = self.get_content()

            if SUBLIME_MAJOR_VERSION < 3:
                if sys.platform != 'win32':
                    out, err = p.communicate(bytes(raw_json))
                else:
                    out, err = p.communicate(unicode(raw_json).encode('utf-8'))
            else:
                out, err = p.communicate(bytes(raw_json, "utf-8"))
            output = out.decode("UTF-8").replace(os.linesep, "\n").strip()
            if output:
                view = self.window.new_file()
                view.run_command("jq_pretty_json_out", {"jq_output": output})
                view.set_syntax_file("Packages/JavaScript/JSON.tmLanguage")

「強制的にLF改行にしてしまってもいいのか?」 という疑問は残ります。
SublimeTextの仕様か分かりませんが、この点は問題なさそうです。

その根拠として以下の点を確認しました。

  • 改行コード設定の値にかかわらず、エディタ上で改行は全て\nとして扱われる
  • 実際のファイルをバイナリエディタで確かめると、改行コードの設定で指定した改行コードになっている

恐らく、Pythonが文字列をUnicodeとして扱うように Sublime Textは改行を\nとして扱っているのでしょう。
ただ、view.insertで挿入された場合はその処理を通らないのではないかと思っています。

class JqPrettyJsonOut(sublime_plugin.TextCommand):
    def run(self, edit, jq_output=''):
        self.view.insert(edit, 0, jq_output)

9ヶ月前くらいに解決していないIssueも挙がってますね… 誰も反応していないのはWindowsユーザ少ないからなのでしょうか。。

carriage return issue with ./jq · Issue #62 · dzhibas/SublimePrettyJson

説明に自信ありませんが、折角なのでPull Requestを出してみたいと思います。

総括

Sublime TextのPretty JSONプラグインを通して、Sublime Textプラグインの挙動を探ってみました。
そして、上手く動作動作しない箇所の原因特定および修正をしてみました。

機会があったら私自身も便利プラグインを作ってみたいと思います。
リクエストパラメータの加工や、その場でリクエストとかしてみたいですね。

追記

プルリクしてみました。

Fix issue #62 by tadashi-aikawa · Pull Request #80 · dzhibas/SublimePrettyJson

Sublime textのコード見たわけではないんで推測多めですが、事象は改善されるので通ると嬉しいな。

コメントを残す