前回のTerraformについての投稿からもうすぐ5年になります。先日から駆け出しエンジニアを名乗っており、練習のためにEmacsでJSX (React)のコードを書いています。現在はLanguage Server Protocol (LSP)のおかげで、ほとんどの言語はどのエディタでも快適に書けるようになりました。この記事では、最も基本的なReactおよびSvelteのプロジェクトで使える、Emacsの設定を紹介します。
言語サーバのインストールには、Nixとdirenvを利用します。Nixを使っていない人も、Emacsの設定については同じです。
Emacsの設定
どのマクロを使うか(use-package, leaf.el, setup.el, etc.)
Emacsの設定といえばuse-packageが有名です。いくつかの代替があり、System Craftersによる以下のビデオではsetup.elとleaf.elが紹介されています。
System Crafters Live! - Investigating use-package Alternatives - YouTube
結論からいうと、どのマクロパッケージを使ってもいいと思います。自分自身でマクロを定義する人もいます。
私はuse-packageを使っていましたが、今年からsetup.elを使っています。(zk-phiさんによる同名のライブラリがあるようですが、それとは別物です。)
マクロパッケージによって起動時間が変わるという話もありますが、私のEmacsは、benchmark-init (time-subtract after-init-time before-init-time)
で計測すると概ね0.3秒未満で起動し、現状に不満はありません。
この記事では、私が使っているsetup.elと、ユーザが最も多いと思われるuse-packageのコードを併記することにします。
typescript-tsx-mode を定義する
typescript-mode でTSX (JSX)を編集する場合、こちらのコメントで示されている通り、define-derived-mode を使って typescript-tsx-mode を定義するのがおすすめです。define-derived-mode を使うことで、 typescript-mode の振る舞いを「継承」する別名の major mode を定義することができます。
;; https://github.com/emacs-typescript/typescript.el/issues/4#issuecomment-849355222
(define-derived-mode typescript-tsx-mode typescript-mode
"typescript-tsx")
setup.el の設定コードは以下の通りです。
(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 の場合は以下の通りになるようです。
;; 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をインストールします。lsp-modeのほうがGitHubのスターが多いのですが、私がフォローしている人たちの多くはeglotを使っています。eglotのほうがEmacsの組み込みの機能とうまく統合されているようです。
特別な設定はほとんど必要ないのですが、TSXを編集するために、以下のように typescript-tsx-mode の eglot-language-id を設定する必要があります。先ほど定義した typescript-tsx-mode がここで生きます。eglot-language-id を設定していない typescript-mode で編集すると、 LSP のフォーマット機能を使ってフォーマットしたときに、TSXの部分が崩れます。
(put 'typescript-tsx-mode 'eglot-language-id "typescriptreact")
Svelte の場合は、まだsvelteserverの設定がeglotのリポジトリに入っていないので、以下のようにサーバを登録します。
(add-to-list 'eglot-server-programs '(svelte-mode . ("svelteserver" "--stdio")))
setup.el の設定は以下の通りです。
(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 の場合はたぶん以下のようになるでしょう。
(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 の場合も、同様に typescript-tsx-mode に対して構文を関連づける必要があります。具体的には以下のコードが必要になります。
(add-to-list 'tree-sitter-major-mode-language-alist
'(typescript-tsx-mode . tsx))
setup.elの場合は以下の設定コードです。
(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 を利用していたのですが、プロファイリングしてみると smartparens-mode はパフォーマンスに少なからぬ影響があることがわかりました。現在は代わりに puni を利用しています。
puni では、 soft deletion のコマンドを自分自身で定義することができます。JSX ではデフォルトの puni-kill-line がうまく働かないので、JSX用のコマンドを定義したほうがいいです。
具体的には以下のコードになります。
;;;###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 パッケージの tagedit-kill コマンドで開始タグから終了タグまでの要素全体を削除します。そうでないときは puni で削除します。開始タグの中だけを削除したり、閉じタグの直前までを削除したりと、状況に応じて適切にsoft deletionしてくれるように定義してあります。
ちなみにこの関数は Svelte の編集でも動いてくれるようです。
あとは以下のような関数を定義して、 puni-mode-hook に追加します。
(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))))
(add-hook 'puni-mode-hook #'akirak-puni-mode-setup)
(setup (:package puni)
(:hook-into prog-mode-hook
sgml-mode-hook
nxml-mode-hook))
(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で閉じタグを入力するために以下の関数を定義しました。
;;; 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 に割り当てています。
(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の設定は以下の通りです。
(setup (:package envrc)
(:with-mode envrc-global-mode
(:hook-into after-init-hook)))
use-package の場合は以下の通りです。
(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を追い抜いて最も多くのパッケージが登録されている(しかもそのほとんどが最新バージョン)パッケージレポジトリです。Nixパッケージマネージャを使うことで、macOSおよびほとんどのLinuxディストリビューションにおいてもNixOSのパッケージを使うことができます。
Nixを使う人は、 home-manager を使うのが便利です。いわゆるdotfilesの管理にも、home-managerを使うことで、異なるOSの間で互換性を保ちながら環境を管理することができます。私は2019年からhome-managerを使っていて、WSLでもUbuntuでもほとんど同じEmacs環境で作業していました。
home-managerでdirenvを有効にするには、以下の設定を使います。
programs.direnv = {
enable = true;
nix-direnv = {
enable = true;
};
enableBashIntegration = true;
enableZshIntegration = true;
};
nix-direnv を有効にすることで、 Nixのシェル環境をdirenvに渡すことができるようになります。
各プロジェクトの設定
あとはプロジェクト毎に言語サーバ等の設定が必要になります。
プロジェクトは既にあることが前提ですが、たとえばReact + TypeScriptのViteを使うプロジェクトは以下の方法で初期化できます。
## 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 として保存します。
{
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 の値を以下の通りにします。
[
pkgs.nodejs
pkgs.nodePackages.pnpm
pkgs.nodePackages.svelte-language-server
]
ターミナルで nix develop
を実行すると、 pnpm や typescript-language-server などが利用できるシェル環境に入ります。
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 に保存するのがおすすめです。以下は見本です。
((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のどの環境においても利用することができます。