mizdra's blog

ぽよぐらみんぐ

@.css-modules-kit/ts-plugin を Emacs で動かすまでの覚書

@.css-modules-kit/ts-plugin を Neovim で動かすまでの覚書 - mizdra's blog の Emacs 版。

Emacs のインストール

macOS なら brew install --cask emacs でインストールできる *1

一応 https://emacsformacosx.com/ から dmg も落とせるけど、dmg からインストールすると emacs コマンドがパスの通ったディレクトリに配置されなくて、ちょっと面倒だった。brew install --cask emacs であれば emacs コマンドもパス通ったディレクトリに配置してくれる。

設定ファイルの置き場所

~/.config/emacs があれば ~/.config/emacs/init.el に、なければ ~/.emacs/init.el に配置される。~/.config/emacs~/.emacs の両方がある時は後者が優先される。

ちなみに emacs --init-directory=~/.config/emacs-test . のようにして Emacs を起動すると、~/.config/emacs-test が設定ファイルの置き場所として使われる。デバッグ目的で初期設定の Emacs を起動したい時や、設定を切り替えたい時に便利。

Emacs Lisp の書き方

init.el は Emacs Lisp で書く。以下の公式ドキュメントを読んで書き方を学ぶと良い。

実際に実行しながら挙動を確かめたいなら、Emacs で M-: (print "Hello World!") などと打ち込むと良い。Emacs Lisp を実行できる。あとは *scratch* バッファに記入して C-j で実行するのも便利。以下のドキュメントに色々方法がまとまってる。

Mode という概念について

Emacs にはエディタの動作を切り替える仕組みがあり、これを「Mode」と呼ぶ。Mode には major mode と minor mode の二種類がある。major mode は特定のファイルタイプに特化した機能を提供するもので、1つのファイルにつき 1つだけ割り当てられる。一方 minor mode は1つのファイルに複数割り当て可能なもの。

例えば typescript-ts-mode という major mode がある。これは TypeScript 向けにシンタックスハイライトなどを提供したり、forward-sexp / backward-sexp などのコマンドによる文字移動を TypeScript に特化した挙動にする。

一方 Eglot (Emacs 組み込みの Language Client) は major mode ではなく、minor mode として動作する。Language Client が特定のファイルに紐づいて動作するわけではなく、エディタ横断で動作する類のもののため、minor mode として実装されている。

major mode は VS Code でいうところの Language Identifiers のような役割も兼ねている。この major mode の名前を使って、Language Server との紐付けを行うことになる。

major mode について

Emacs 29 以降では以下の major mode が組み込まれており、基本的にはこれらを使うのが良い。

  • js-ts-mode: JavaScript 向けの major mode
  • typescript-ts-mode: TypeScript 向けの major mode
  • tsx-ts-mode: TSX 向けの major mode
  • css-ts-mode: CSS 向けの major mode

ちなみに *-ts-modets の部分は tree-sitter の略。tree-sitter を使ってパースをして AST-aware なシンタックスハイライトをしている実装になってて、このような名前になってる。

補足: major mode の名前に -ts- が付いている理由

何故わざわざ -ts- 付けているのか疑問に思うかもしれないが、どうやら歴史的経緯によるものらしい。元々 Emacs には組み込みや 3rd-party で tree-sitter 実装ではない major mode (js-modetypescript-mode) が存在していた。ただそれらの major mode では正規表現を使ってコードを解析しており、ユーザの期待通りのシンタックスハイライトなどを実現できない問題があったようだ。そこで Emacs 29 になって tree-sitter をベースにした major mode が実装された。

しかし古い major mode と新しい major mode は本質的に挙動が異なるものである。突然 js-mode が tree-sitter ベースになると、古い major mode の挙動に依存した init.el が壊れてしまう。またそもそも Emacs のビルドには tree-sitter は必須ではなく、オプショナルである。tree-sitter がない場合も Emacs が機能するようになっていないといけない。後方互換性、そして tree-sitter なしで Emacs がビルドされているケースに対応するため、tree-sitter ベースの major mode はオプトイン (既存の major mode と名前を分けてデフォルトでは有効化しない) とされているようだった。

tree-sitter ベースの major mode を有効化する

tree-sitter ベースの major mode はオプトインで、デフォルトでは有効化されていない。そのためまずはそれらを有効化する設定を書く必要がある。init.el を以下のように編集すれば良い。

;; tree-sitter のセットアップと構文定義のインストール
(use-package treesit
  :ensure nil
  :config
  (setq treesit-font-lock-level 4)
  (setq treesit-language-source-alist
        (append
         '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src")
           (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
           (tsx        "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")
           (css        "https://github.com/tree-sitter/tree-sitter-css" "master" "src"))
         treesit-language-source-alist))
  (add-hook 'emacs-startup-hook
    (lambda ()
      (dolist (lang (mapcar #'car treesit-language-source-alist))
        (unless (treesit-language-available-p lang)
          (treesit-install-language-grammar lang))))))

;; major mode の設定
(add-to-list 'auto-mode-alist '("\\.js\\'"  . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.ts\\'"  . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode))
(add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode))

実は Emacs には tree-sitter の構文定義の情報が組み込まれていない。そのため、ただ *.tstypescript-ts-mode を紐づけてもシンタックスハイライトなどが効かない。そこで構文定義を外部からインストールしてくる必要がある。それが設定の前半部分。後半部分が major mode の紐付け。

これで JavaScript/TypeScript/CSS のシンタックスハイライトが機能するようになる。

補足: 構文定義が Emacs に組み込まれていない理由

tree-sitter の構文定義くらい Emacs に組み込んであったら良いのだが、色々理由があってできてないらしい。構文定義のビルドには Node.js と node-gyp が必要なため、Emacs のビルドにそれらが必須になるのは困るとか。構文定義のビルド方法の詳細に Emacs が依存したくないとか。

補足: jsx-ts-mode がない理由

*.tsxtsx-ts-mode に紐づけるのに *.jsxjsx-ts-mode に紐付けないのがびっくりするかも (id:mizdra はびっくりした)。何故こうしているかというと、Emacs には jsx-ts-mode なる major mode が存在しないから。というかそもそも JSX 用の tree-sitter の構文定義が存在しない。JavaScript と JSX で統一されているらしい。

Emacs 側の議論はちゃんと追ってないけど、多分 tree-sitter の構文定義が統一されているので major mode も統一しているのだろう。

Language Server の設定

先ほど少し触れたが、Emacs には Eglot という Language Client が組み込まれている。major mode と Language Server の起動コマンドの組のリストからなる eglot-server-programs という変数があり、この定義に従って Language Server が起動される。ちなみにデフォルトでは JavaScript と TypeScript では typescript-language-server が Language Server の実装として使われる。

Eglot は minor mode であり、デフォルトでは起動されない。特定の major mode が ON になったら、Eglot を起動するような設定を書く必要がある。

まずは Language Server をインストールしておく。

$ npm i -g typescript-language-server typescript

そして use-package:hookeglot-ensure を使って以下のような設定を書く。

;; tree-sitter のセットアップと構文定義のインストール
(use-package treesit
  :ensure nil
  :config
  (setq treesit-font-lock-level 4)
  (setq treesit-language-source-alist
        (append
         '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src")
           (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
           (tsx        "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")
           (css        "https://github.com/tree-sitter/tree-sitter-css" "master" "src"))
         treesit-language-source-alist))
  (add-hook 'emacs-startup-hook
    (lambda ()
      (dolist (lang (mapcar #'car treesit-language-source-alist))
        (unless (treesit-language-available-p lang)
          (treesit-install-language-grammar lang))))))

;; major mode の設定
(add-to-list 'auto-mode-alist '("\\.js\\'"  . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.ts\\'"  . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode))
(add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode))

;; Language Server のセットアップ
(use-package eglot
  :ensure nil
  :hook ((js-ts-mode         . eglot-ensure)
         (typescript-ts-mode . eglot-ensure)
         (tsx-ts-mode        . eglot-ensure)))

これで JavaScript/TypeScript で Language Server が起動するようになったはず。

@css-modules-kit/ts-plugin のセットアップ

@css-modules-kit/ts-plugin は TypeScript Language Service Plugin なので、TypeScript の Language Server に読み込ませる、というステップが必要になる。

まずは @css-modules-kit/ts-plugin をインストールしておく。

npm i -g @css-modules-kit/ts-plugin

そして init.el を以下のように書き換える。

;; tree-sitter のセットアップと構文定義のインストール
(use-package treesit
  :ensure nil
  :config
  (setq treesit-font-lock-level 4)
  (setq treesit-language-source-alist
        (append
         '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src")
           (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
           (tsx        "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")
           (css        "https://github.com/tree-sitter/tree-sitter-css" "master" "src"))
         treesit-language-source-alist))
  (add-hook 'emacs-startup-hook
    (lambda ()
      (dolist (lang (mapcar #'car treesit-language-source-alist))
        (unless (treesit-language-available-p lang)
          (treesit-install-language-grammar lang))))))

;; major mode の設定
(add-to-list 'auto-mode-alist '("\\.js\\'"  . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.ts\\'"  . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode))
(add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode))

;; Language Server のセットアップ
(use-package eglot
  :ensure nil
  :hook ((js-ts-mode         . eglot-ensure)
         (typescript-ts-mode . eglot-ensure)
         (tsx-ts-mode        . eglot-ensure)
         (css-ts-mode        . eglot-ensure))
  :config
  (require 'subr-x) ;; string-trim
  (setq npm-root
        (string-trim (shell-command-to-string "npm root -g")))
  (add-to-list
    'eglot-server-programs
    `(((js-ts-mode :language-id "javascript")
       (typescript-ts-mode :language-id "typescript")
       (tsx-ts-mode :language-id "typescriptreact")
       (css-ts-mode :language-id "css"))
      . ("typescript-language-server" "--stdio"
          :initializationOptions
          ((plugins
            . [((name      . "@css-modules-kit/ts-plugin")
                (location  . ,npm-root)
                (languages . ["css"]))]))))))

やってることは Neovim の時とほぼ同じ。CSS でも typescript-language-server が起動されるようにしつつ、@css-modules-kit/ts-plugin を plugin として読み込ませている。:hook(css-ts-mode . eglot-ensure) を足しているのもポイント。これがないとそもそも CSS で Eglot が起動してくれない。

厳密には eglot-server-programs にデフォルトの JavaScript/TypeScript の Language Server を起動する設定が残っているから、それを新しいもので置換したほうが行儀は良いと思う。ただ Eglot は eglot-server-programs の先頭からリストを探索して major mode に一致するものがあったらそれを使う挙動になっている。そのため、add-to-list で先頭に設定を追加してしまえば、古いものがリスト内の残っていても特に問題がない。

という訳でこれで @css-modules-kit/ts-plugin が Emacs でも動くようになった。めでたしめでたし。

youtu.be

おまけ: CSS Language Server と共存できない問題について

勘の良い人は気づいたかもしれないが、Eglot は 1 つの major mode に複数の Language Server を起動することができない。eglot-server-programs の先頭に定義されている Language Server が優先して起動されることになる。

つまり今回 id:mizdra が紹介した init.el では、*.css に対して TypeScript/CSS 両方の Language Server を起動できない。TypeScript の Language Server だけが起動される。そのため @css-modules-kit/ts-plugin が提供する言語機能 (クラスセレクターの Go to Definition や Find All References など) は機能するが、CSS の Language Server が提供する言語機能 (プロパティの補完など) は機能しない。

この問題については長らく Eglot の discussion で議論されていたのだけど、つい最近になって Eglot ではサポートしないという方針になったようだった。

代わりに https://github.com/joaotavora/rassumfrassumhttps://github.com/thefrontside/lspx のような LSP マルチプレクサの使用を推奨している模様。まあ気持ちはわかる... わかるが Emacs 組み込みのパッケージなのだから組み込みで Multiple Server サポートしてほしいなー。

@css-modules-kit/ts-plugin を使いつつ、*.css に対して TypeScript/CSS 両方の Language Server の両方を起動するなら多分以下のような設定になるはず。

;; Language Server のセットアップ
(use-package eglot
  :ensure nil
  :hook ((js-ts-mode         . eglot-ensure)
         (typescript-ts-mode . eglot-ensure)
         (tsx-ts-mode        . eglot-ensure)
         (css-ts-mode        . eglot-ensure))
  :config
  (require 'subr-x) ;; string-trim
  (setq npm-root
        (string-trim (shell-command-to-string "npm root -g")))
  (add-to-list
    'eglot-server-programs
    `(((js-ts-mode :language-id "javascript")
       (typescript-ts-mode :language-id "typescript")
       (tsx-ts-mode :language-id "typescriptreact")
       (css-ts-mode :language-id "css"))
      . ("lspx" "--lsp" "typescript-language-server --stdio" "--lsp" "vscode-css-language-server"
          :initializationOptions
          ((plugins
            . [((name      . "@css-modules-kit/ts-plugin")
                (location  . ,npm-root)
                (languages . ["css"]))]))))))

けどこれ、vscode-css-language-server にも initializationOptions が渡ってしまうし、そもそも vscode-css-language-server*.ts の LSP Request が飛んでしまうし、全然ダメな気がする。lspx では上手くいかなさそう。rassumfrassum もドキュメント読む限りはこういうケースには対応してなさそう。打つ手なし...

まあ CSS の Language Server が提供する言語機能 (プロパティの補完など) は機能しないのは諦めてもらうということで...

おまけ: Language Server のデバッグテクニック

TypeScript Language Service Plugin や typescript-language-server が出力するログを見るには、tsserver.log を出力すると良い。TSS_LOG 環境変数を設定して Emacs を起動すれば良い。

TSS_LOG="-level verbose -file /tmp/tsserver.log" emacs -nw .

*1:Nonfree systems という見出しの下に macOS が書いてあって GNU らしさがある。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.