id:mizdra は CSS Modules Kit という CSS Modules のためのツールキットを作っている。そのツールキットの中に @css-modules-kit/ts-plugin というものがある。CSS Modules の言語機能を提供するための TypeScript Language Service Plugin である。
一応これは任意のエディタで動かせるように作ってる。実際、VS Code / Zed などで動くことを確認してる。しかし、Neovim で動くことはまだ確認できてなかった。いや、動くはずなんだけど Neovim の使い方がさっぱりわからなくて... 何度か挑戦したけど、LSP の設定の仕方とか、plugin のインストールとかが難しくて挫折した。あとキーバインドが全然わからん。completion のウインドウってどうやって出すの? そもそも TypeScript Language Service Plugin が読み込まれているか確認するために tsserver.log を見たいのだけど、どうやったら見れるの?
...という状態だったのだけど、この度 Claude に色々聞きながらようやく @css-modules-kit/ts-plugin を動かすことができた。忘れないように覚えたことを記事にまとめておく。
Neovim のインストール
macOS なら Homebrew でインストールできる。
インストールしたら nvim で起動できるはず。
設定ファイルの置き場所
OS によって設定ファイルの置き場所は異なるが、macOS なら ~/.config/nvim か $XDG_CONFIG_HOME/nvim になる。$XDG_CONFIG_HOME がセットされてない時は前者が、セットされてる時は後者が使われる。
ここに init.vim や init.lua といった vimrc と呼ばれる設定ファイルを置くと、それが起動時に実行されるらしい。世の中的には init.lua で書くのが主流っぽい。
ちなみに NVIM_APPNAME=nvim-test nvim のようにして Neovim を起動すると、~/.config/nvim-test が設定ファイルの置き場所として使われる。デバッグ目的で初期設定の Neovim を起動したい時や、設定を切り替えたい時に便利。
Lua の書き方
init.lua は Lua で書く。以下の公式ドキュメントを読んで書き方を学ぶと良い。
LLM に「<あなたがよく使う言語> を書く人向けに Lua の記法や作法、違いを簡潔に教えてもらえますか?」と聞くのも良い。
実際に実行しながら挙動を確かめたいなら、Neovim のノーマルモードで :lua print("Hello!") などと打ち込むと良い。Lua を実行できる。
Neovim の LSP サポートについて
Neovim は LSP のサポートを組み込んでいる。ただし、デフォルトではどの Language Server も起動されないようになっている。ユーザが Language Server を起動するための設定が必要になる。
まず Language Server をインストールする必要がある。TypeScript の Language Server は typescript-language-server と vtsls の二種類がある。今回は typescript-language-server を使ってみる。以下のコマンドでインストールできる。
npm i -g typescript-language-server typescript
そして init.lua に以下のような設定を書く。
-- 補完を有効にするための設定 vim.cmd[[set completeopt+=menuone,noselect,popup]] vim.api.nvim_create_autocmd('LspAttach', { callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) if client and client:supports_method('textDocument/completion') then vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) end end, }) -- TypeScript の Language Server の設定 vim.lsp.config('ts_ls', { init_options = { hostInfo = 'neovim' }, cmd = { 'typescript-language-server', '--stdio' }, filetypes = { 'javascript', 'javascriptreact', 'javascript.jsx', 'typescript', 'typescriptreact', 'typescript.tsx', }, root_markers = { 'tsconfig.json', 'jsconfig.json', 'package.json', '.git' }, }) -- TypeScript の Language Server を有効に vim.lsp.enable('ts_ls')
何故か補完を有効にするには vim.api.nvim_create_autocmd が必要らしい。デフォルトで補完有効になってて良い気がするけど、なんでそうなってないんでしょうね。
VS Code ユーザなら以下のように CTRL-Space で補完ウインドウが出るようにしておくと便利だと思う。
vim.keymap.set('i', '<c-space>', function() vim.lsp.completion.get() end)
その他 LSP に関する設定は以下を参照。LSP 関連のキーバインドもここに書いてある。
- https://neovim.io/doc/user/lsp.html
- https://neovim.io/doc/user/lsp.html#_global-defaults
- https://neovim.io/doc/user/lsp.html#_buffer-local-defaults
nvim-lspconfig
世の中の人皆同じような設定を書くので、よく使われる Language Server の共通設定がまとめられた nvim-lspconfig Package が存在する。これを使うのがデファクトらしい。
早速 nvim-lspconfig の使い方を紹介したいところだが、その前に「Plugin」と「Package」という用語について説明する。
「Plugin」と「Package」
どちらも Vim に由来する用語である。「Plugin」は Vim スクリプトファイルをまとめたもので、「Package」は Plugin を含むディレクトリを指す。
Package は ~/.config/nvim/pack/ に以下のようなディレクトリ構成で配置される。
~/.config/nvim/pack/
├─ nvim/
│ ├─ start/
│ │ ├─ start-pkg-1/
│ │ │ └─ plugin/
│ │ │ └─ start-pkg-1.lua
│ │ └─ start-pkg-2/
│ │ └─ plugin/
│ │ └─ start-pkg-2.lua
│ └─ opt/
│ ├─ opt-pkg-1/
│ │ └─ plugin/
│ │ └─ opt-pkg-1.lua
│ └─ opt-pkg-2/
│ └─ plugin/
│ └─ opt-pkg-2.lua
└─ multiple-plugins-pkg/
└─ start/
├─ plugin-1/
│ └─ multiple-plugins-pkg-plugin-1.lua
└─ plugin-2/
└─ multiple-plugins-pkg-plugin-2.lua
pack/nvim/start/* にあるのが「"start" package」と呼ばれるもので、Neovim の起動時に自動で実行される *1。pack/nvim/opt/* にあるのが「"opt" package」で、:packadd した時に実行される。ちなみに Package は Plugin を複数持つこともできて、その例が pack/multiple-plugins-pkg である *2。なんだかややこしいが、pack/* にあるものも、pack/*/{start,opt}/* にあるものも Package と呼ぶらしい? とにかく Plugin を含むディレクトリなら Package と呼ぶ世界観に見える。
id:mizdra の理解が間違っていたら誰かツッコんでほしい!
nvim-lspconfig のインストールとセットアップ
話を戻すと、nvim-lspconfig は Package である。よってこれをインストールする必要がある。nvim-lspconfig の README にインストール方法がいくつか書かれているので、そのどれかの方法でインストールすれば良い。
ミニマムにやりたいなら git clone か vim.pack を使う方法が良い。後者は Neovim の組み込みの Plugin Manager *3 なのだが、まだ実験的な機能で stable では使えない。そこで今回は git clone を使ってインストールしてみる。
git clone https://github.com/neovim/nvim-lspconfig ~/.config/nvim/pack/nvim/start/nvim-lspconfig
これで LSP の設定を以下のように記述できる。
-- 補完を有効にするための設定 vim.cmd[[set completeopt+=menuone,noselect,popup]] vim.api.nvim_create_autocmd('LspAttach', { callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) if client and client:supports_method('textDocument/completion') then vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) end end, }) -- TypeScript の Language Server を有効に vim.lsp.enable('ts_ls')
これだけみると、「nvim-lspconfig から提供される Language Server の設定はどこで読み込んでるんだ?」とびっくりするかも (
id:mizdra はびっくりした)。実は vim.lsp.enable('ts_ls') を実行した時に読み込まれている。Package 内の lsp/* から Language Server の設定が自動で探索される挙動となってて、結果として nvim-lspconfig/lsp/ts_ls.lua が読み込まれている。
@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.lua を書く。
-- 補完を有効にするための設定 vim.cmd[[set completeopt+=menuone,noselect,popup]] vim.api.nvim_create_autocmd('LspAttach', { callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) if client and client:supports_method('textDocument/completion') then vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) end end, }) -- @css-modules-kit/ts-plugin を使うための設定 local npm_root = vim.trim(vim.fn.system('npm root -g')) local ts_ls_default = vim.lsp.config.ts_ls vim.lsp.config('ts_ls', { filetypes = vim.list_extend(ts_ls_default.filetypes, { 'css' }), init_options = { plugins = { { name = '@css-modules-kit/ts-plugin', location = npm_root, languages = { 'css' }, }, }, }, }) -- TypeScript の Language Server を有効に vim.lsp.enable('ts_ls')
やっていることとしては、
@css-modules-kit/ts-pluginを plugin として読み込むようにinit_options.pluginsの部分- これで
*.ts,*.tsx上で CSS Modules 向けの言語機能が使えるようになる
- CSS ファイル の Language Server として
typescript-language-serverが使われるようにfiletypes = ...の部分- これで
*.css上で CSS Modules 向けの言語機能が使えるようになる
filetypes = ... の部分がないと *.css に対する LSP の Request (textDocument/definition や textDocument/rename) が一切 typescript-language-server に届かず、CSS 上で色々な言語機能が機能しなくなってしまう。*.ts, *.css どちらの LSP Request も受け取れるように、このような設定が必要になる。
これで @css-modules-kit/ts-plugin が Neovim でも動くようになった。めでたしめでたし。
おまけ: vim.lsp.config の挙動
vim.lsp.config('ts_ls', {...}) と書くと、Pacakge 経由で読み込まれた config などといい感じにマージされる仕様となってる。
マージは vim.tbl_deep_extend() で行われる。テーブル (JavaScript のオブジェクトに相当) 同士をディープマージしてくれる。テーブルは再起的に辿ってマージしてくれるけど、配列はたどってくれないので、注意が必要 *4。
今回書いた設定で filetypes: vim.list_extend(ts_ls_default.filetypes, { 'css' }) と書いたのはそのため。filetypes: { 'css' } だと typescript-language-server が *.ts の Language Server として使われなくなってしまう。
おまけ: Language Server のデバッグテクニック
Neovim で :checkhealth と打つと実際に読み込まれた Language Server の起動オプションを表示できる。うまく設定できてるのか確かめる時に便利。

TypeScript Language Service Plugin や typescript-language-server が出力するログを見るには、tsserver.log を出力すると良い。TSS_LOG 環境変数を設定して Neovim を起動すれば良い。
TSS_LOG="-level verbose -file /tmp/tsserver.log" nvim .
*1:https://neovim.io/doc/user/pack.html#_creating-vim-packages でそう呼ばれてる
*2:公式ドキュメントでも https://neovim.io/doc/user/pack.html#_creating-vim-packages でこうした Package を紹介してる。
*3:ところでなんで「Package Manager」と呼ばないのでしょうね?
*4:ちなみに Lua において配列はテーブルの特殊系 (数値をインデックスに持つテーブル) で、厳密には配列もテーブルである。vim.tbl_deep_extend ではテーブルのインデックスを見て、配列っぽいテーブルなら再起的に辿らず、それ以外のテーブルなら辿る挙動になってる。ref: https://github.com/neovim/neovim/blob/6b4ec2264e1d8ba027b85f3883d532c5068be92a/runtime/lua/vim/_core/shared.lua#L552