🎹

Emacs に TidalCycles のコード補完機能を追加する

2022/03/19に公開

Emacs で TidalCycles を使う場合、Atom などのエディタと違いデフォルトではコード補完機能[1]がない。lsp (haskell-language-server) を使えば動くのかもしれないが、TidalCycles の動く環境が GHCi のためか自分の環境ではうまくいかなかった。そこで Emacs にコード補完を提供する company-mode について少し調べてみると、思いの外シンプルだったので自作することにした。
https://github.com/sumisonic/company-tidal

使い方

上記リポジトリの company-tidal.elload-path に追加し

(add-to-list 'company-backends 'company-tidal)

company-backendsに追加すると使える。

company-mode について

まず company-mode の backend がどのように動いているかについてはこの記事が非常に参考になった(これを読まなかったら自分で書こうとは思わなかった)。
https://ifritjp.github.io/documents/emacs/company-mode/

company-mode がどのようにコード補完を行っているのかはほぼこの記事に書かれているので、ここではそれを TidalCycles でどうやって使うのかを書く[2]
まず backend のコード本体から。
※ 以下、記載するコードは実際に使っているコードの抜粋なので主要でない部分の説明は省く。

(defun company-tidal (command &optional arg &rest ignored)
  "Company backend for TidalCycles"
  (interactive (list 'interactive))
  (cl-case command
    (interactive (company-begin-backend 'company-tidal))
    ;; 初期化処理
    ;; 'tidal-send-string が存在しない場合は起動しない
    (init (if (fboundp 'tidal-send-string)
              (unless company-tidal--initialized
                (add-hook 'company-completion-cancelled-hook 'company-tidal--completion-cancelled)
                (with-current-buffer tidal-buffer
                  (add-hook 'comint-preoutput-filter-functions 'company-tidal--preoutput-filter))
                (setq company-tidal--initialized t))
            (error "no tidal process running?")))
    (prefix (and (eq major-mode 'tidal-mode)
                 (company-grab-symbol)))
    (candidates (cons :async
                      (lambda (callback)
                        (company-tidal--find-candidates arg callback))))
    (annotation (cons :async
                      (lambda (callback)
                        (company-tidal--find-annotation arg callback))))
    (post-completion (company-tidal--post-completion arg))
  ))

大まかな流れとしては init で初期化処理したのち、
coandidates で補完候補を、annotation で型情報を非同期で取得し、
post-completion で仕上げの処理をしている。

補完候補の取得

TidalCycles は Haskell の repl(GHCi)で動いていて、幸いにも GHCi は :complete で補完機能を提供している。
例えばsをいう文字で100件補完候補を取得する場合はこのように書く。

tidal> :complete repl 100 "s"
100 444 ""
"s"
"sBusses"
"sConfig"
"sCxs"
"sDefault"
"sGlobalFMV"
...以下省略

これをemacs-lispで実行すれば補完候補を取得できる。

(defun company-tidal--find-candidates (string callback)
  "補完のリクエスト"
  (company-tidal--create-request
   (concat ":complete repl " (format "%d" company-tidal-candidates-limit) " \"" string "\"" "\n")
   (lambda (output)
     (let* ((outputs (split-string output "\n"))
            (outputs-without-prompt (seq-remove (lambda (item) (string-match "tidal>" item)) outputs))
            (outputs-without-quotes (seq-map (lambda (item) (string-trim item "\"" "\"")) outputs-without-prompt))
            (outputs-without-duplicate (delq nil (delete-dups outputs-without-quotes)))
            (result (seq-drop outputs-without-duplicate 1)))
       (funcall callback result)))))

TidalCycles(GHCi)にコマンドを送る

tidal-mode で提供している tidal-send-string でコマンドを送ることができる。

(defun company-tidal--create-request (req completion)
  "tidal (comint) にリクエストを送る"
  (setq company-tidal--processing t)
  (setq company-tidal--request-completion completion)
  (tidal-send-string req))

tidal-mode は comint-mode でシェルを生成していて、tidal-send-string の実態は comint-send-string だ。これを add-hook でキャッチすれば出力値を取得できる。
初期化処理で add-hook (comint-preoutput-filter-functions) を追加している。

(init (if (fboundp 'tidal-send-string)
      (unless company-tidal--initialized
	(add-hook 'company-completion-cancelled-hook 'company-tidal--completion-cancelled)
	(with-current-buffer tidal-buffer
	  (add-hook 'comint-preoutput-filter-functions 'company-tidal--preoutput-filter))
	(setq company-tidal--initialized t))
    (error "no tidal process running?")))

これで company-tidal--preoutput-filteroutput に出力値が入ってくるので、あとはこれをレスポンスとして返してやればよい。ここではリクエスト時に設定したクロージャ company-tidal--request-completion にレスポンスを出力している。

(defun company-tidal--preoutput-filter (output)
  "comint の出力フィルタ"
  (cond
   ;; 補完のリクエストがある場合は出力をしない
   (company-tidal--request-completion
    (let* ((match-prompt (string-match "tidal>" output)))
      ;; output を company-tidal--output に結合
      (setq company-tidal--output (concat company-tidal--output output))
      ;; "output に tidal> プロンプトがある場合、company-tidal--request-completion に出力"
      (when (and match-prompt)
        (let* ((output company-tidal--output)
               (request-completion company-tidal--request-completion))
          (funcall request-completion output)
          (setq company-tidal--output nil)
          (setq company-tidal--request-completion nil)
          )))
    "")
   ;; 処理中の場合は出力をしない
   (company-tidal--processing "")
   ;; 通常の出力
   (t output)))

戻り値として "" を返しているのは、tidal-mode の出力フレームに大量の補完候補が流れてしまうからだ。preoutput を使うのはそれが理由で、ここで""にフィルタリングしている。

型情報の取得

これだけでも補完としては充分だが、せっかくなのでannotationに型情報も追加してみる。といっても GHCi で:typeを実行するだけで要領は同じだ。

(defun company-tidal--find-annotation (string callback)
  "annotation リクエスト"
  (company-tidal--create-request
   (concat ":type (" string ")\n")
   (lambda (output)
      (let* ((outputs (split-string output "\n"))
       (outputs-without-prompt (seq-remove (lambda (item) (string-match "tidal>" item)) outputs))
       (outputs-without-nil (delq nil outputs-without-prompt))
       (type-info (apply #'concatenate 'string outputs-without-nil))
       ;; :: で分けて関数名を削除
       (type-info-split-name (split-string type-info "::"))
       (type-info-without-name (seq-drop type-info-split-name 1))
       (result (apply #'concatenate 'string type-info-without-name)))
        (funcall callback (concat "::" result))))))

仕上げとキャンセル処理

tidal-mode の出力をフィルタリングしているフラグ company-tidal--processingnil にして、通常の出力に戻している。

(defun company-tidal--finish ()
  "終了処理"
  (setq company-tidal--processing nil))

(defun company-tidal--post-completion (string)
  "完了処理"
  (company-tidal--finish))

(defun company-tidal--completion-cancelled (arg)
  "キャンセル処理"
  (company-tidal--finish))

完成


脚注
  1. Atom では :browse Sound.Tidal.Context で初期化時に全ての補完情報を取得しているようだ。この手法のメリットとしては初期化が終われば高速に補完ができる。ただし Sound.Tidal.Context 上の関数しか補完ができないデメリットがある。今回は自作関数含め、全て補完するために都度 :complete で補完候補を取得する方法を取った。 ↩︎

  2. TidalCycles というよりは、ほぼ GHCi の話なので、少し改造すれば汎用的に GHCi で使えるコードになる(はず...)。 ↩︎

Discussion