WSL2のNeovim設定をWindows 11でも使えるようにした

プライベートで使っているNeovimの設定を、WSL2からWindows 11へ持っていけるか確認しました。普段の開発場所はWSL2です。リポジトリもLinux側に置いており、Cドライブを/mnt/c経由で編集する運用にはしていません。

設定を調べると、LinuxのPATH、miseのインストール先、Python仮想環境、/dev/tty、Windows IME用のzenhan.exeが混ざっていました。そのままコピーすれば、一部のプロジェクトでしか動かない環境をもう一つ作るところでした。

まず、OS判定と外部コマンドを探す処理を一か所へ集めました。そのうえで、Windows 11の一時領域へ設定、プラグイン、Masonを隔離して試しました。LazyVimの設定は読めて、DAPとOilも読み込めました。ただし、Windows側に言語ランタイムがなければ、そのまま開発は始められません。

Lua設定は共通にする。実行ファイルとキャッシュはOSごとに用意する。差が出る処理は一か所へ集める。今回、移植性を上げるために決めたのはこの3つです。

設定だけを移し、プラグインとキャッシュは共有しない

Neovimの設定場所はOSで異なります。Neovimの公式ドキュメントでは、標準の設定場所はWSL2などのUnix系で~/.config/nvim、Windowsで~/AppData/Local/nvimです。

一方、プラグインやMasonはstdpath("data")、キャッシュはstdpath("cache")へ入ります。設定ファイルが同じでも、実行ファイルを含むデータ領域はOSごとに分ける必要があります。

共有・コピーする
  init.lua / lua/ / queries/ / lazy-lock.json / lazyvim.json / stylua.toml

OSごとに作り直す
  lazy.nvimのプラグイン本体
  MasonのLSP・DAP・formatter
  キャッシュとstate

queries/には自分で変更したMarkdownのハイライト設定があります。ここを省くとエディターは起動しても表示が元の設定と変わります。stylua.tomlは起動に必須ではありませんが、整形の動作をそろえるために含めました。NeovimのプラグインがOSごとに入れる実行ファイルは、同じデータ領域で共有しません。WSL2からWindowsのzenhan.exeを呼ぶような明示的な連携とは分けて考えます。設定を一つのGitリポジトリで管理しても、プラグイン本体、Mason、キャッシュはOSごとに作り直します。

いつものWindows設定に触れずに試す

移植テストでは、普段使うWindows側のNeovim設定を変更しませんでした。コピー元のNeovim設定ディレクトリでPowerShellを開き、現在のセッションだけXDGの保存先を一時ディレクトリへ変えます。

$sourceConfig = (Get-Location).Path
$initLua = Join-Path $sourceConfig "init.lua"

if (-not (Test-Path -LiteralPath $initLua)) {
  throw "init.luaがあるNeovim設定ディレクトリで実行してください"
}

$testRoot = Join-Path $env:TEMP (
  "nvim-portable-" + (Get-Date -Format "yyyyMMdd-HHmmss")
)

$env:XDG_CONFIG_HOME = Join-Path $testRoot "config"
$env:XDG_DATA_HOME   = Join-Path $testRoot "data"
$env:XDG_STATE_HOME  = Join-Path $testRoot "state"
$env:XDG_CACHE_HOME  = Join-Path $testRoot "cache"

$env:NVIM_APPNAME = "nvim-portable"
$configDir = Join-Path $env:XDG_CONFIG_HOME $env:NVIM_APPNAME
New-Item -ItemType Directory -Force $configDir | Out-Null

$required = @("init.lua", "lua")
foreach ($name in $required) {
  if (-not (Test-Path -LiteralPath (Join-Path $sourceConfig $name))) {
    throw "コピー元に必須の設定がありません: $name"
  }
}

@("init.lua", "lua", "queries", "lazy-lock.json", "lazyvim.json", "stylua.toml") |
  ForEach-Object {
    $item = Join-Path $sourceConfig $_
    if (Test-Path -LiteralPath $item) {
      Copy-Item -LiteralPath $item -Destination $configDir -Recurse -Force
    }
  }

nvim --headless -i NONE "+lua print(vim.fn.stdpath('config'))" +qa

これらの環境変数は、このPowerShellを閉じれば普段の値へ戻ります。NVIM_APPNAMEも分けたため、テスト中にプラグインが大量に入っても、既存のAppData/Local/nvim-dataへ混ざりません。一時ディレクトリに入ったプラグインやキャッシュは残るため、検証後は$testRootを確認してから削除します。

最初は一時ディレクトリのパスが長すぎて、プラグインのキャッシュがWindowsのパス長制限に当たりました。隔離先の名前を上の例のように短くすると先へ進めました。設定そのものではなく、テスト環境の作り方で発生したエラーです。

あちこちのif Windowsをplatform.luaへ集めた

各プラグイン設定でif Windows thenを繰り返すのではなく、OSと実行ファイルの差を小さなモジュールへまとめました。

local M = {}

M.is_windows = vim.fn.has("win32") == 1
M.is_wsl = not M.is_windows
  and (vim.env.WSL_DISTRO_NAME ~= nil or vim.env.WSL_INTEROP ~= nil)
M.path_separator = M.is_windows and ";" or ":"

function M.executable(name)
  local path = vim.fn.exepath(name)
  return path ~= "" and path or name
end

function M.mason_executable(name)
  local mason_bin = vim.fs.joinpath(vim.fn.stdpath("data"), "mason", "bin")
  local suffixes = M.is_windows and { ".cmd", ".exe", ".bat", "" } or { "" }

  for _, suffix in ipairs(suffixes) do
    local candidate = vim.fs.joinpath(mason_bin, name .. suffix)
    if vim.fn.executable(candidate) == 1 then
      return candidate
    end
  end

  return M.executable(name)
end

return M

WindowsのPATH区切りは;、WSL2は:です。Masonのbinに作られるコマンドも、Windowsでは.cmd.exeになる場合があります。パスを文字列連結せずvim.fs.joinpath()、設定場所はstdpath()から作るようにしました。

Masonの公式READMEでは、パッケージは標準でNeovimのデータディレクトリへ入り、実行ファイルはMasonのbinへ集約されます。lua/config/platform.luaへ置き、必要な設定からrequire("config.platform")で読むようにしました。各プラグイン設定にWSL2固有のパスを書かずに済みます。

固定パスを追いかけるのをやめた

設定を追うほど、環境依存が多かったのはLSPよりも実行時のツールだと分かりました。Python、.NET、Node.js、Goで、それぞれ別の固定パスが残っていました。

Python

仮想環境のPythonは、WindowsならScripts/python.exe、WSL2ならbin/pythonです。VIRTUAL_ENVを先に見て、その後にプロジェクト直下の.venvvenvenv.envを探します。

.NET

以前はmiseの特定ディレクトリをPATHへ直接追加していました。現在はDOTNET_ROOT、WSL2で使うmise、PATH上のdotnetという順で探します。デバッグ対象のDLLも固定せず、現在のC#ファイルに最も近いcsprojからMSBuildのTargetPathを取得します。

Node.jsとTypeScript

特定のNode.js 24のインストール先を設定へ書くのをやめ、PATH上のnodeを使います。js-debug-adapter本体だけは、OSごとに作られたstdpath("data")/mason/packagesから組み立てます。

Go

通常のDelveデバッグは両OSで使える構成です。ただし、ターミナルUIへ標準入力を渡すための/dev/ttyだけはWindowsに存在しません。この機能はWSL2専用として残し、Windowsで実行した時は警告して終了します。

共通化できない機能を無理に隠すより、どこからOS依存になるか分かる方が保守しやすいと判断しました。

*.csprojはルート目印になっていなかった

移植確認中に、OSとは別の不具合も見つかりました。プロジェクトルートの目印として、次のように書いていました。

{ "go.mod", "pyproject.toml", "*.sln", "*.csproj" }

しかしvim.fs.root()へ渡す文字列はファイル名であり、globではありません。*.csprojという名前のファイルを探す状態でした。現在は拡張子を判定する関数へ変更し、新しい.slnxも含めています。

function(name)
  local extension = vim.fs.ext(name)
  return extension == "sln"
    or extension == "slnx"
    or extension == "csproj"
end

これはWindows対策というより、「起動したディレクトリによってデバッグできたりできなかったりする」原因の除去です。移植テストは、暗黙に動いていた前提を見つける機会にもなりました。

ESCでIMEを半角に戻す処理は任意依存にする

WSL2のNeovimからESCを押した時、Windows IMEを半角へ戻すためにzenhan.exeを使っています。Neovimのiminsertだけでは、WSL2の外側にあるWindows IMEを確実に切り替えられませんでした。

完全に依存をなくすのではなく、ZENHAN_PATHまたはOSごとの候補から見つかった時だけ実行します。呼び出しはjobstart()にし、ESC操作が外部プロセスの終了を待たないようにしました。

Cドライブのマウントが遅い問題は残ります。そのため、WSL2のリポジトリやNeovimのプラグインを/mnt/cへ移してはいません。Windows側の小さなIMEヘルパーを呼ぶことと、開発リポジトリ全体をNTFS越しに扱うことは分けています。

Windowsでも設定は読めた。ただ、ツールが足りなかった

Windows版Neovim 0.12.2から、隔離した設定を起動しました。LazyVimのプラグイン設定を読み込み、Git、ripgrep、fzf、lazygit、Node.js、npm、Yazi、zenhan.exeもWindows側の実行ファイルとして認識できました。

fdは入っていませんでした。Snacks Pickerのgrepはripgrepで動いても、fdを前提にする探索機能には別途導入が必要です。

Masonパッケージの導入も隔離領域で試しました。以下は当時の調査メモに残した結果です。成功したものと、言語ランタイム不足で失敗したものを分けています。元のインストール出力は保存していないため、件数を検証済みの集計値としては扱いません。

結果 パッケージ
成功 bash-language-server、golangci-lint、js-debug-adapter、markdown-toc、markdownlint-cli2、netcoredbg、prettier、shfmt、stylua
Python不足で失敗 debugpy、sqlfluff
Go不足で失敗 delve、gofumpt、goimports
.NET SDK不足で失敗 csharpier、fantomas、roslyn-language-server

調査メモでは、失敗したものは設定ファイルのパスではなく、Windows側のPython、Go、.NET SDKが不足していました。dotnet.exeが存在してもSDKがなければ、C#のビルドやRoslyn Language Serverは動きません。

Masonは便利ですが、言語本体まで常に用意してくれるわけではありません。Windowsへ移す時は、Neovim、CLIツール、言語ランタイム、Masonパッケージを別々に確認します。

ブラウザプレビューは残したが、警告も残った

Markdownはブラウザでプレビューしたいので、iamcco/markdown-preview.nvimは削除しませんでした。一方、Windowsの隔離環境でビルドした時、同梱Webアプリのnpm依存関係に監査警告が表示されました。警告の件数と内訳は元出力を保存していないため、ここでは再現できる数値として載せません。

この表示だけでNeovim全体が直ちに侵害されるとは判断できません。確認できたのは、ビルド時に依存関係の監査警告が出たことまでです。個人環境で機能を残す判断と、管理された端末へ持ち込めるかという判断は分けています。この記事で確認したのは、私物環境の設定が技術的にWindowsでも起動できることまでです。

また、プロジェクトの.vscode/launch.jsontasks.jsonはプログラムやコマンドを起動できます。現在はNeovimのvim.secure.read()を通し、内容を信頼した場合だけDAPへ読み込むようにしました。

同じ設定を使えても、同じ環境にはならない

最終的に、最新設定をWindows 11の隔離環境へ再度コピーし、Oilのゴミ箱設定、DAP、NetCoreDbgまで読み込めることを確認しました。WSL2側の設定はバックアップを取ってから更新し、Windows側の既存設定は変更していません。

移植できたのはLua設定です。Python、Go、.NET SDK、Node.js、各OS向けのMasonパッケージまで同一になるわけではありません。

私はWSL2ではLinux側のファイルシステムで開発し、WindowsではWindows版NeovimとWindows側のデータ領域を使います。設定は共通にできました。ただ、Python、Go、.NET、Node.jsまでWindows側へそろえないと、同じ開発環境にはなりませんでした。

起動時の設定とDAPの遅延読込はLazyVimの起動を見直し、DAPとIME設定を後回しにした、言語別デバッグはAでは動くのにBでは動かないNeovimのデバッグ設定を見直したへ分けています。

確認した公式情報

確認日: 2026年9月15日

コメント

タイトルとURLをコピーしました