Tree-sitterを使ってEmacsのimenuをカスタマイズしてみる

Emacs Advent Calendar 2021 21日目の記事です。(空いていたので埋めました。)

Tree-sitter

Tree-sitterAtomで採用されていることで有名なパーサージェネレータです。 JSで記述した文法を渡すことでパーサーを生成してくれる仕組みですが、現在多くの言語サポートが入っていて、 よく利用する言語であればこれらのパーサーを利用して高速な構文解析と、その結果としてシンタックスハイライトなどの機能を様々なエディタで利用することができます。

tree-sitter.github.io

elisp-tree-sitter.el

emacs-tree-sitter.el はTree-sitterのEmacs バインディングです。 emacs-tree-sitterはシンタックスハイライトの提供の他に、Tree-sitterを利用するためのelisp APIを提供しています。 このAPIを利用することで、Tree-sitterを利用した様々なパッケージの開発が簡単にできるようになりました。

Tree-sitterについては、EmacsConf 2021でも Emacs News Highlights で触れられていたり、 他にもTalkがあったりするので、今後の動向についても楽しみです。

Debug-mode

tree-sitter-debug-mode を利用することで現在のコードに対する構文木を確認することができます。 この機能はTree-sitterへのクエリを構築したりする際にとても便利です。

例えば、以下のようなJavaScriptコードの構文木を参照すると、

    const aaa = () => {
      console.log("hey");
    };
    
    function bbb() {
      console.log("hello");
    }
    
    let ccc = "foo";
    const ddd = "bar";
    var eee = 1;
    
    class Sample {
      test(arg) {
        console.log(arg);
      }
    }

以下のようなツリーを見ることができます。

    program:
      lexical_declaration:
        variable_declarator:
          identifier:
          arrow_function:
        formal_parameters:
        statement_block:
          expression_statement:
            call_expression:
              member_expression:
            identifier:
            property_identifier:
              arguments:
            string:
      function_declaration:
        identifier:
        formal_parameters:
        statement_block:
          expression_statement:
        call_expression:
          member_expression:
            identifier:
            property_identifier:
          arguments:
            string:
      lexical_declaration:
        variable_declarator:
          identifier:
          string:
      lexical_declaration:
        variable_declarator:
          identifier:
          string:
      variable_declaration:
        variable_declarator:
          identifier:
          number:
      class_declaration:
        identifier:
        class_body:
          method_definition:
        property_identifier:
        formal_parameters:
          identifier:
        statement_block:
          expression_statement:
            call_expression:
              member_expression:
            identifier:
            property_identifier:
              arguments:
            identifier:

構文木へのアクセス

Tree-sitterの構文木Lisp-style クエリや、カーソルオブジェクトを通したトラバースを利用してアクセスできます。 構文木シンタックスノードで構成されているため、これらの結果もノードとして受けとることができます。 このノードにノードタイプやコード上の位置情報などが含まれていて、インスペクティングAPIを通じてアクセスできます。

imenuを拡張してみる

せっかくTree-sitterのAPIが利用できるので、Tree-sitterを利用してJavaScript構文解析をカスタムしてみます。 Emacsではimenuというコードアウトライン機能が提供されていますが、この結果をいい感じにできないか試してみます。

arrow functionを抽出してみる

Tree-sitterを利用して、現在のソースコードで定義されている arrow_function が代入された変数のアウトラインを作成してみます。

    // example
    const plusOne = (num) => {
      return 1 + num;
    }

arrow_function まわりの構文木はこんな感じになっています。

    lexical_declaration:
      variable_declarator:
        identifier:
        arrow_function:
          formal_parameters:
          statement_block:
        expression_statement:
              ....

とりあえず fun という名前をつけて arrow_function のノードを抽出します。(今回は名前つけなくてもいいんですが。。。)

    (tsc-query-captures
     (tsc-make-query (tree-sitter-require 'javascript) [(((arrow_function) @fun))])
     (tsc-root-node tree-sitter-tree)
     #'ts--buffer-substring-no-properties) ; -> [(fun . #<...>) (fun . #<...>)]

この結果は () => {}シンタックスノードなので、これが代入されている変数のノードをここからトラバースします。

    (defun parse-sibling-identifier (node)
      (let ((sibling (tsc-get-prev-named-sibling node)))
        (when (and (not (null sibling))
               (equal (tsc-node-type sibling) 'identifier))
          (parse-node sibling))))

特定したノードをインスペクトして、ポジションとシンボル名を取得します。

    (defun parse-node (node)
      (let* ((beg (tsc-node-start-byte node))
         (symbol (tsc-node-text node)))
        (cons symbol beg)))

全体はこんな形になりました。

    (defun arrow-func-index-test ()
      (let ((vec (tsc-query-captures
           (tsc-make-query (tree-sitter-require 'javascript) [(((arrow_function) @fun))])
           (tsc-root-node tree-sitter-tree)
           #'ts--buffer-substring-no-properties)))
        (mapcar #'(lambda (elem)
            (parse-sibling-identifier (cdr elem))) vec)))
    
    (defun parse-sibling-identifier (node)
      (let ((sibling (tsc-get-prev-named-sibling node)))
        (when (and (not (null sibling))
               (equal (tsc-node-type sibling) 'identifier))
          (parse-node sibling))))
    
    (defun parse-node (node)
      (let* ((beg (tsc-node-start-byte node))
         (symbol (tsc-node-text node)))
        (cons symbol beg)))

imenuに表示する

上記のスニペットで作成したalistを imenu-create-index-function から返すようにしてみると、arrow functionの一覧をimenuで利用できるようになります。

    (add-hook 'js-mode-hook (lambda ()
                  (setq imenu-create-index-function #'arrow-func-index-test)))

まとめ

Tree-sitterが提供するAPIを利用してJavaScriptのアウトラインを見てみるところまでやってみました。 印象としてとても便利で、使い勝手のいいツールだと思いました。 JavaScriptで言うと、exportしているシンボルのアウトラインであるとか、importやinline requireしている箇所のアウトラインなど、 IDEとはちょっと違ったアプローチのアウトラインを作成したりすることが簡単にできそうです。 Tree-sitter、面白い!