EmacsのReact/Svelte編集環境（Eglot + Tree-Sitter + Puni、Nix flake版）

前回の[Terraformについての投稿](https://qiita.com/akirak/items/ef842e92b702a8ef59a6)からもうすぐ5年になります。先日から[駆け出しエンジニア](https://zenn.dev/akirak/articles/dc2342d3b97148)を名乗っており、練習のためにEmacsでJSX (React)のコードを書いています。現在はLanguage Server Protocol (LSP)のおかげで、ほとんどの言語はどのエディタでも快適に書けるようになりました。この記事では、最も基本的なReactおよびSvelteのプロジェクトで使える、Emacsの設定を紹介します。

言語サーバのインストールには、[Nix](https://nixos.org/)とdirenvを利用します。Nixを使っていない人も、Emacsの設定については同じです。

## Emacsの設定

### どのマクロを使うか(use-package, leaf.el, setup.el, etc.)

Emacsの設定といえば[use-package](https://github.com/jwiegley/use-package)が有名です。いくつかの代替があり、System Craftersによる以下のビデオでは[setup.el](https://www.emacswiki.org/emacs/SetupEl)と[leaf.el](https://github.com/conao3/leaf.el)が紹介されています。

[System Crafters Live! - Investigating use-package Alternatives - YouTube](https://www.youtube.com/watch?v=ZolOUgXHgbo)

結論からいうと、どのマクロパッケージを使ってもいいと思います。自分自身でマクロを定義する人もいます。

私はuse-packageを使っていましたが、今年からsetup.elを使っています。（zk-phiさんによる[同名のライブラリ](https://github.com/zk-phi/setup)があるようですが、それとは別物です。）

マクロパッケージによって起動時間が変わるという話もありますが、私のEmacsは、~~[benchmark-init](https://github.com/dholm/benchmark-init-el)~~ `(time-subtract after-init-time before-init-time)` で計測すると概ね0.3秒未満で起動し、現状に不満はありません。

この記事では、私が使っているsetup.elと、ユーザが最も多いと思われるuse-packageのコードを併記することにします。

### typescript-tsx-mode を定義する

typescript-mode でTSX (JSX)を編集する場合、[こちらのコメント](https://github.com/emacs-typescript/typescript.el/issues/4#issuecomment-849355222)で示されている通り、define-derived-mode を使って typescript-tsx-mode を定義するのがおすすめです。define-derived-mode を使うことで、 typescript-mode の振る舞いを「継承」する別名の major mode を定義することができます。

```emacs-lisp
;; https://github.com/emacs-typescript/typescript.el/issues/4#issuecomment-849355222
(define-derived-mode typescript-tsx-mode typescript-mode
  "typescript-tsx")
```

setup.el の設定コードは以下の通りです。

```emacs-lisp
(setup typescript-tsx-mode
  (:file-match "\\.tsx\\'")

  ;; https://github.com/emacs-typescript/typescript.el/issues/4#issuecomment-849355222
  (define-derived-mode typescript-tsx-mode typescript-mode
    "typescript-tsx"))
```

use-package の場合は以下の通りになるようです。

```emacs-lisp
;; Adapted from https://github.com/emacs-typescript/typescript.el/issues/4#issuecomment-873485004
(use-package typescript-mode
  :ensure t
  :init
  (define-derived-mode typescript-tsx-mode typescript-mode "tsx")
  :config
  (add-to-list 'auto-mode-alist '("\\.tsx?\\'" . typescript-tsx-mode)))
```

### eglot (LSP)

EmacsでLSPを利用するために、[eglot](https://github.com/joaotavora/eglot)をインストールします。[lsp-mode](https://github.com/emacs-lsp/lsp-mode/)のほうがGitHubのスターが多いのですが、私がフォローしている人たちの多くはeglotを使っています。eglotのほうがEmacsの組み込みの機能と[うまく統合されている](https://github.com/joaotavora/eglot#how-does-eglot-work)ようです。

特別な設定はほとんど必要ないのですが、TSXを編集するために、以下のように typescript-tsx-mode の eglot-language-id を設定する必要があります。先ほど定義した typescript-tsx-mode がここで生きます。eglot-language-id を設定していない typescript-mode で編集すると、 LSP のフォーマット機能を使ってフォーマットしたときに、TSXの部分が崩れます。

```emacs-lisp
(put 'typescript-tsx-mode 'eglot-language-id "typescriptreact")
```

Svelte の場合は、まだsvelteserverの設定がeglotのリポジトリに入っていないので、以下のようにサーバを登録します。

```emacs-lisp
(add-to-list 'eglot-server-programs '(svelte-mode . ("svelteserver" "--stdio")))
```

setup.el の設定は以下の通りです。

```emacs-lisp
(setup (:package eglot)

  (:when-loaded
    (add-to-list 'eglot-server-programs
                 '(svelte-mode . ("svelteserver" "--stdio"))))

  (put 'typescript-tsx-mode 'eglot-language-id "typescriptreact")

  (add-hook 'eglot-managed-mode-hook #'akirak/eglot-setup-buffer)

  (defun akirak/eglot-setup-buffer ()
    (if (eglot-managed-p)
        (add-hook 'before-save-hook #'eglot-format-buffer nil t)
      (remove-hook 'before-save-hook #'eglot-format-buffer t))))
```

use-package の場合はたぶん以下のようになるでしょう。

```emacs-lisp
(use-package eglot
  :ensure t

  :config
  (add-to-list 'eglot-server-programs
               '(svelte-mode . ("svelteserver" "--stdio")))

  (put 'typescript-tsx-mode 'eglot-language-id "typescriptreact")

  (defun akirak/eglot-setup-buffer ()
    (if (eglot-managed-p)
        (add-hook 'before-save-hook #'eglot-format-buffer nil t)
      (remove-hook 'before-save-hook #'eglot-format-buffer t)))

  :hook
  (eglot-managed-mode . akirak/eglot-setup-buffer))
```

### tree-sitter

[tree-sitter](https://github.com/emacs-tree-sitter/elisp-tree-sitter) の場合も、同様に typescript-tsx-mode に対して構文を関連づける必要があります。具体的には以下のコードが必要になります。

```emacs-lisp
(add-to-list 'tree-sitter-major-mode-language-alist
             '(typescript-tsx-mode . tsx))
```

setup.elの場合は以下の設定コードです。

```emacs-lisp
(setup (:package tree-sitter)
  (:hook tree-sitter-hl-mode)

  (:when-loaded
    ;; Based on https://github.com/emacs-typescript/typescript.el/issues/4#issuecomment-849355222
    (add-to-list 'tree-sitter-major-mode-language-alist
                 '(typescript-tsx-mode . tsx))))

(setup (:package tree-sitter-langs)
  (:with-mode tree-sitter-mode
    (:hook-into typescript-mode-hook
                typescript-tsx-mode-hook)))
```

use-packageの設定例は省略します。

### puni

Emacsでコードを編集するときは、何かstructured editingのパッケージを利用すると便利です。たとえば soft deletion の機能によって、括弧の入れ子関係を壊さずにテキストを削除したりすることができます。

私は以前 [smartparens](https://github.com/Fuco1/smartparens) を利用していたのですが、プロファイリングしてみると smartparens-mode はパフォーマンスに少なからぬ影響があることがわかりました。現在は代わりに [puni](https://github.com/AmaiKinono/puni) を利用しています。

puni では、 soft deletion のコマンドを[自分自身で定義](https://github.com/AmaiKinono/puni#define-your-own-soft-deletion-commands)することができます。JSX ではデフォルトの puni-kill-line がうまく働かないので、JSX用のコマンドを定義したほうがいいです。

具体的には以下のコードになります。

```emacs-lisp
;;;###autoload
(defun akirak-puni-jsx-setup ()
  "Setup puni bindings for jsx."
  (interactive)
  (local-set-key [remap puni-kill-line] #'akirak-puni-jsx-kill-line))

;;;###autoload
(defun akirak-puni-jsx-kill-line ()
  (interactive)
  (if (looking-at (rx (* blank) "<"))
      (tagedit-kill)
    (puni-soft-delete-by-move #'akirak-puni-jsx-end-of-soft-kill
                              nil
                              'beyond
                              ;; 'within
                              'kill
                              'delete-one)))

(defun akirak-puni-jsx-end-of-soft-kill ()
  (cond
   ((eolp)
    (forward-char))
   ;; Kill content inside a tag (i.e. between "<" and ">")
   ((and (looking-back (rx "<" (* (not (any "{>"))))
                       (line-beginning-position)))
    (if (re-search-forward (rx (? "/") ">") (line-end-position) t)
        (goto-char (match-beginning 0))
      (end-of-line)))
   ;; Kill content inside a tag pair (i.e. between an open tag and end tag)
   ((looking-back (rx ">" (* (not (any "<"))))
                  (line-beginning-position))
    (if (re-search-forward "<" (line-end-position) t)
        (goto-char (match-beginning 0))
      (end-of-line)))
   (t
    (end-of-line))))
```

カーソルが < の前にあるときは、 [tagedit](https://github.com/magnars/tagedit) パッケージの tagedit-kill コマンドで開始タグから終了タグまでの要素全体を削除します。そうでないときは puni で削除します。開始タグの中だけを削除したり、閉じタグの直前までを削除したりと、状況に応じて適切にsoft deletionしてくれるように定義してあります。

ちなみにこの関数は Svelte の編集でも動いてくれるようです。

あとは以下のような関数を定義して、 puni-mode-hook に追加します。

```emacs-lisp
(defun akirak-puni-mode-setup ()
  (cl-case (derived-mode-p 'typescript-tsx-mode
                           'js-jsx-mode
                           'svelte-mode)
    ((typescript-tsx-mode js-jsx-mode svelte-mode)
     (akirak-puni-jsx-setup))))
```

```emacs-lisp
(add-hook 'puni-mode-hook #'akirak-puni-mode-setup)
```

```emacs-lisp
(setup (:package puni)
  (:hook-into prog-mode-hook
              sgml-mode-hook
              nxml-mode-hook))
```

```emacs-lisp
(use-package puni
  :hook
  (prog-mode sgml-mode nxml-mode))
```

### JSXの中で閉じタグを入力するコマンド

HTMLを編集するときに閉じタグをタイプするのは煩わしいので、エディタによる支援がほしいです。Emacsには sgml-mode があり、 sgml-close-tag コマンドを使うと閉じタグを C-c C-e で入力することができます。この機能は sgml-mode を継承しているモードで利用でき、 html-mode はもちろん、 svelte-mode でもうまく動いてくれるようです。

typescript-mode (typescript-tsx-mode) では、残念ながらこのコマンドを直接利用することはできません。typescript-mode は sgml-mode を継承しておらず、構文としてもTypeScriptの中にテンプレートが埋め込まれている構造のため、バッファ全体をSGMLとして解析することができません。

私はJSX/TSXで閉じタグを入力するために以下の関数を定義しました。

```emacs-lisp
;;; akirak-jsx.el ---  -*- lexical-binding: t -*-

(require 'tsc)
(require 'tree-sitter)
(require 'sgml-mode)

;;;###autoload
(defun akirak-jsx-close-tag ()
  (interactive)
  (let* ((initial (point))
         (ppss (syntax-ppss))
         (start (save-excursion
                  (goto-char (ppss-innermost-start ppss))
                  (tsc-node-end-position (tree-sitter-node-at-pos))))
         (end (save-excursion
                (goto-char (ppss-innermost-start ppss))
                (condition-case _
                    (forward-sexp)
                  ;; forward-sexp may fail due to unmatching tags in JSX, so
                  ;; work around it
                  (error
                   (if-let (close (matching-paren (char-after)))
                       ;; This can produce a wrong result, but it seems to work
                       ;; in most cases
                       (search-forward (char-to-string close) nil t)
                     (error "No idea what to do"))))
                (backward-char)
                (tsc-node-start-position (tree-sitter-node-at-pos)))))
    (goto-char start)
    (when (re-search-forward (rx symbol-start "return" symbol-end)
                             end t)
      (setq start (point)))
    (let* ((orig (buffer-substring-no-properties start end))
           (str (with-temp-buffer
                  (insert orig)
                  (sgml-mode)
                  (goto-char (1+ (- initial start)))
                  (push-mark)
                  (sgml-close-tag)
                  (buffer-substring-no-properties (mark) (point)))))
      (goto-char initial)
      (insert str))))

(provide 'akirak-jsx)
;;; akirak-jsx.el ends here
```

処理の内容としては、JSX/TSXのテンプレートの部分だけを選択して、一時的なバッファにテキストをコピー、sgml-modeに切り替えて閉じタグを挿入し、差分のテキストを元のバッファにコピーしています。

この関数を、以下のように sgml-mode の sgml-close-tag と同じ C-c C-e に割り当てています。

```emacs-lisp
(with-eval-after-load 'typescript-mode
  (define-key typescript-tsx-mode-map (kbd "C-c C-e") #'akirak-jsx-close-tag))
```

### envrc

Emacsからdirenvを利用する場合、以下のいずれかのパッケージが必要です。

-   envrc
-   direnv-mode

ここではenvrcを利用することにします。setup.elの設定は以下の通りです。

```emacs-lisp
(setup (:package envrc)
  (:with-mode envrc-global-mode
    (:hook-into after-init-hook)))
```

use-package の場合は以下の通りです。

```emacs-lisp
(use-package envrc
  :ensure t
  :hook
  (after-init . envrc-global-mode))
```

## direnvのインストール

私は開発環境を Nix + direnv で管理しています。この組み合わせを使うことで、 Node や Python 等のバージョンがプロジェクトごとに異なっても問題なく対応できます。Pythonのvirtualenvと同様のことが、どの言語に対してもできます。

Nix の代替として asdf という選択肢もありますが、 Nix はプログラミング言語だけでなくアプリケーションも管理でき、NixOSならばマシンの構成全体を宣言的に書くこともできます。Nixのほうがasdfより便利だと思います。

LSPを使う場合は言語サーバをインストールする必要がありますが、Nix + direnvを使えば各プロジェクトの開発環境に言語サーバを含めることができます。NixOSのNixpkgsは現在、Arch LinuxのAURを追い抜いて[最も多くのパッケージが登録されている](https://repology.org/repositories/statistics/total)（しかもそのほとんどが最新バージョン）パッケージレポジトリです。Nixパッケージマネージャを使うことで、macOSおよびほとんどのLinuxディストリビューションにおいてもNixOSのパッケージを使うことができます。

Nixを使う人は、 [home-manager](https://github.com/nix-community/home-manager) を使うのが便利です。いわゆるdotfilesの管理にも、home-managerを使うことで、異なるOSの間で互換性を保ちながら環境を管理することができます。私は2019年からhome-managerを使っていて、WSLでもUbuntuでもほとんど同じEmacs環境で作業していました。

home-managerでdirenvを有効にするには、以下の設定を使います。

```nix
programs.direnv = {
  enable = true;
  nix-direnv = {
    enable = true;
  };
  enableBashIntegration = true;
  enableZshIntegration = true;
};
```

nix-direnv を有効にすることで、 Nixのシェル環境をdirenvに渡すことができるようになります。

## 各プロジェクトの設定

あとはプロジェクト毎に言語サーバ等の設定が必要になります。

プロジェクトは既にあることが前提ですが、たとえばReact + TypeScriptのViteを使うプロジェクトは以下の方法で初期化できます。

```sh
## Enter an environment in which node and pnpm are available
nix-shell -p nodejs -p nodePackages.pnpm
## Scaffold a project interactively
pnpm init vite@latest hello

cd hello
pnpm install
git init
```

### flake.nixの追加

以下の内容を flake.nix として保存します。

```nix
{
  description = "Minimal React project";

  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }:
    flake-utils.lib.eachDefaultSystem
    (system: let
      pkgs = import nixpkgs {
        inherit system;
      };
    in {
      devShells.default = pkgs.mkShell {
        buildInputs = [
          pkgs.nodejs
          pkgs.nodePackages.pnpm
          pkgs.nodePackages.typescript
          pkgs.nodePackages.typescript-language-server
          pkgs.nodePackages.vscode-css-languageserver-bin
        ];
      };
    });
}
```

Svelteの場合は、 buildInputs の値を以下の通りにします。

```nix
[
  pkgs.nodejs
  pkgs.nodePackages.pnpm
  pkgs.nodePackages.svelte-language-server
]
```

ターミナルで `nix develop` を実行すると、 pnpm や typescript-language-server などが利用できるシェル環境に入ります。

```sh
nix develop
```

ですが、このままではEmacsから typescript-language-server のバイナリが見えません。direnvが必要です。

### .envrc

以下の内容を .envrc として保存します。

```
use flake
```

Emacsで `M-x envrc-allow` を実行すると、buildInputsに指定したパッケージのバイナリがそのプロジェクトの中で利用できるようになります。プロジェクトの中の適当なTSXファイルを開いて、 eglot を実行すると、LSPがそのファイルのバッファで有効になります。

### .dir-locals.el

インデント等の設定はプロジェクトごとに異なるので、Emacsを使っている人は各プロジェクトの .dir-locals.el に保存するのがおすすめです。以下は見本です。

```emacs-lisp
((json-mode . ((js-indent-level . 2)
               (tab-width . 2)))
 (typescript-mode . ((eval . (eglot-ensure))
                     (typescript-indent-level . 2)
                     (tab-width . 2))))
```

eglot-ensure によって eglot が自動的に有効になります。eglot-format-buffer でフォーマットするときのインデントの幅には、 tab-width の値が適用されます。

typescript-tsx-mode は typescript-mode の derived mode なので、これらの設定は typescript-tsx-mode においても有効になります。

## まとめ

以上の設定によって、ReactのTSXをEmacsの中で快適に編集できるようになりました。Nix + home-managerのおかげで、これらの設定はLinux、macOS、WSL 2のどの環境においても利用することができます。