0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ターミナルでCmd+Click = ファイルパスから直接 nvim で該当ファイル開くようにする(WezTerm + Neovim)

0
Posted at

#ターミナルに表示されたファイルパスを Cmd+Click するだけで Neovim にファイルが開く。そんな開発体験を WezTerm の open-uri イベントハンドラで実現したので、仕組みと設定方法を紹介します。

モチベーション

開発中、ビルドエラーやlint、grep の出力にファイルパスが頻繁に表示されます。

internal/handler/user.go:42:15: undefined: ErrNotFound
src/components/App.tsx:18:5: 'useState' is defined but never used
config/settings.yaml:12: mapping values are not allowed here

このパスを目で追ってエディタで手動で開く、という作業を毎回やるのは地味にストレスです。「クリックしたら開いてほしい」
——これを WezTerm と Neovim の連携で解決しました。あらゆる言語・ファイル形式に対応しています。

全体のアーキテクチャ

処理の流れは 3 ステップ。

1. hyperlink_rules でファイルパスを検出しカスタム URI に変換
2. Cmd+Click のマウスバインディングで OpenLinkAtMouseCursor を発火
3. open-uri イベントハンドラが URI を受け取り、nvim でファイルを開く
+-----------------+     +--------------------+     +------------------+
| hyperlink_rules | --> | Mouse Binding      | --> | open-uri handler |
| ファイルパスを   |     | Cmd+Click で発火   |     | nvim でファイル   |
| 検出・URI に変換 |     | OpenLinkAtMouse    |     | を開く           |
|                 |     | Cursor             |     |                  |
+-----------------+     +--------------------+     +------------------+

Step 1:ハイパーリンクルールでファイルパスを検出する

WezTerm の hyperlink_rules は、ターミナル出力に対して正規表現マッチを行い、マッチした文字列をクリック可能なリンクに変換する機能です。

あらゆるファイル形式に対応するため、2 つのルールを使い分けます。

-- デフォルトルール(URL 等)を維持
config.hyperlink_rules = wezterm.default_hyperlink_rules()

-- ルール 1:ディレクトリ付きファイルパスにマッチ(拡張子不問)
-- 例: src/main.py:10:5, ./config.yaml, ~/project/handler.go
table.insert(config.hyperlink_rules, {
  regex = [[[~.]?[\w.\-@]*(?:/[\w.\-@]+)+\.\w+(?::\d+(?::\d+)?)?]],
  format = 'file://$0',
})

-- ルール 2:ファイル名のみ(ディレクトリなし)の場合は主要な拡張子にマッチ
-- 例: main.go:42, app.tsx:18, settings.yaml
table.insert(config.hyperlink_rules, {
  regex = [[\b[\w\-]+\.(?:go|py|pyi|js|jsx|mjs|cjs|ts|tsx|mts|lua|rs|rb|c|cc|cpp|h|hpp|java|kt|swift|sh|bash|zsh|yaml|yml|toml|json|jsonc|xml|html|htm|css|scss|less|sql|proto|graphql|md|txt|rst|csv|log|conf|cfg|ini|env|mod|sum|lock|vue|svelte|astro|tf|nix|zig|dart|ex|exs|erl|hs|pl|pm|mk|cmake|gradle|prisma|sol)(?::\d+(?::\d+)?)?\b]],
  format = 'file://$0',
})

2 つのルールに分けている理由

ルール 対象 拡張子制限
ルール 1 / を含むパス(src/main.py なし(すべてマッチ)
ルール 2 ファイル名のみ(main.go 主要な拡張子のみ

ディレクトリ付きパスであればファイルパスであることがほぼ確実なので、拡張子を問わずマッチさせます。一方、ファイル名のみの場合はドメイン名(example.com)等との誤マッチを防ぐため、プログラミングで使われる主要な拡張子に限定しています。

ポイント

  • regex は Rust の正規表現記法(fancy-regex)で書きます
  • (?::\d+(?::\d+)?)?:行番号:列番号 のオプショナルマッチに対応
  • $0 でマッチ全体を URI に変換(行番号・列番号も含む)
  • wezterm.default_hyperlink_rules() で URL のクリック等デフォルト動作を維持

これにより、ターミナル上の internal/handler/user.go:42:15 のようなテキストがクリック可能なリンクに変換され、Cmd を押しながらホバーすると下線が表示されるようになります。

Step 2:Cmd+Click のマウスバインディング

config.mouse_bindings = {
  {
    event = { Up = { streak = 1, button = "Left" } },
    mods = "CMD",
    action = wezterm.action.OpenLinkAtMouseCursor,
  },
}

OpenLinkAtMouseCursor はマウスカーソル直下のハイパーリンクを開くアクションです。通常はデフォルトブラウザで URL を開きますが、WezTerm では open-uri イベントをフックすることでこの動作をカスタマイズできます。

eventUp(マウスボタンを離した時)を指定しているのは、ドラッグによるテキスト選択との競合を避けるためです。

Step 3:open-uri イベントハンドラ(本体)

ここが今回の設定の核心です。URI からファイルパスと行番号を解析し、nvim でファイルを開きます。

wezterm.on("open-uri", function(window, pane, uri)
  local path_info = uri:gsub("file://", "")

  -- (1) file:line:col を解析
  local path, line = path_info:match('^(.+):(%d+):%d+$')
  if not path then
    path, line = path_info:match('^(.+):(%d+)$')
  end
  if not path then
    path = path_info
  end

  -- (2) 相対パス → 絶対パスへの解決
  if path:sub(1, 1) ~= "/" then
    local cwd_url = pane:get_current_working_dir()
    if cwd_url then
      local cwd = cwd_url.file_path
                  or tostring(cwd_url):gsub("file://[^/]*", "")
      cwd = cwd:gsub("/$", "")
      path = cwd .. "/" .. path
    end
  end

  -- (3) 既存の nvim タブを探す
  local mux_window = window:mux_window()
  for i, tab in ipairs(mux_window:tabs()) do
    for _, tab_pane in ipairs(tab:panes()) do
      local process = tab_pane:get_foreground_process_name()
      if process and process:find("nvim") then
        -- nvim タブに切り替え
        window:perform_action(
          wezterm.action.ActivateTab(i - 1), pane
        )
        -- Escape → wincmd l → edit でファイルを開く
        local cmd = "vim.cmd('wincmd l') vim.cmd('edit "
        if line then
          cmd = cmd .. "+" .. line .. " "
        end
        cmd = cmd .. path .. "')"
        tab_pane:send_text("\x1b:lua " .. cmd .. "\r")
        return false
      end
    end
  end

  -- (4) nvim が見つからなければ新しいタブで開く
  local cwd_url = pane:get_current_working_dir()
  local cwd = nil
  if cwd_url then
    cwd = cwd_url.file_path
          or tostring(cwd_url):gsub("file://[^/]*", "")
    cwd = cwd:gsub("/$", "")
  end

  local args = { "/opt/homebrew/bin/nvim", "." }
  if line then
    table.insert(args, "+" .. line)
  end
  table.insert(args, path)

  window:perform_action(
    wezterm.action.SpawnCommandInNewTab {
      cwd = cwd or "",
      args = args,
    },
    pane
  )
  return false
end)

処理の詳細解説

(1) ファイルパスと行番号の解析

ハイパーリンクルールが :行番号:列番号 も含めてマッチするため、URI からこれらを分離します。

file://src/handler.go:42:15  →  path = "src/handler.go",  line = "42"
file://app.tsx:18            →  path = "app.tsx",          line = "18"
file://config.yaml           →  path = "config.yaml",      line = nil

(2) 相対パスの解決

ビルドツールや linter の出力はプロジェクトルートからの相対パスです。pane:get_current_working_dir() でクリック元ペインの作業ディレクトリを取得し、絶対パスに変換します。

クリック元ペインの cwd: /Users/me/projects/myapp
出力のパス:            internal/handler/user.go
解決後:                /Users/me/projects/myapp/internal/handler/user.go

(3) 既存の nvim タブへのディスパッチ

mux_window:tabs() で現在のウィンドウ内の全タブを走査し、フォアグラウンドプロセスが nvim であるペインを探します。見つかった場合:

  1. ActivateTab でそのタブに切り替え
  2. send_text で nvim にキーストロークを送信(行番号がある場合は +行番号 付き)

送信しているテキストの内訳:

文字列 意味
\x1b Escape キー。インサートモードやコマンドラインモードから抜ける
:lua ... Vim の Ex コマンドで Lua を実行
vim.cmd('wincmd l') カーソルを右のウィンドウに移動(neo-tree 等のサイドバーを避ける)
vim.cmd('edit +42 ...') 指定パスのファイルを行番号付きで開く
\r Enter キーでコマンド実行

wincmd l を挟んでいるのは、左側に neo-tree などのファイルツリーが開いている場合に、メインのエディタウィンドウでファイルを開くためです。

(4) nvim がなければ新規タブで起動

既存の nvim タブが見つからない場合、SpawnCommandInNewTab で新しいタブを作成し、nvim . +行番号 <filepath> として起動します。.(カレントディレクトリ)を引数に含めることで、neo-tree 等のプラグインがプロジェクトツリーを正しく認識できます。

return false の重要性

ハンドラの末尾で return false を返しています。これは WezTerm に「デフォルトの URI 処理(ブラウザで開く)をスキップせよ」と伝えるためです。これを忘れると、nvim で開くと同時にブラウザでも file:// URL を開こうとしてしまいます。

実際の使用フロー

1. ターミナルでビルドや lint を実行
   → internal/handler/user.go:42:15: undefined: ErrNotFound
   → src/components/App.tsx:18:5: 'useState' is defined but never used

2. Cmd を押しながらファイルパスをホバー
   → パスに下線が表示される(.go, .tsx, .py 等あらゆるファイルが対象)

3. クリック
   → 既に nvim が開いているタブに自動で切り替わる
   → nvim のメインウィンドウにファイルが該当行で開く

nvim が起動していない場合は、新しいタブで自動的に nvim が立ち上がり、プロジェクトツリーとともにファイルが開きます。

ハマりどころと Tips

OSC 7 が必要

pane:get_current_working_dir() でペインの作業ディレクトリを取得するには、シェルが OSC 7 エスケープシーケンスでカレントディレクトリを WezTerm に通知している必要があります。

zsh の場合、以下を .zshrc に追加します(多くの場合デフォルトで設定済み):

# WezTerm にカレントディレクトリを通知
precmd() {
  printf '\e]7;file://%s%s\e\\' "$HOST" "$PWD"
}

hyperlink_rules は上書きされる

config.hyperlink_rules を設定すると、WezTerm のデフォルトルール(https:// URL の検出など)が上書きされます。必ず wezterm.default_hyperlink_rules() でデフォルトルールを取得してから table.insert で追加してください。

-- ⭕ デフォルトルールを維持しつつ追加
config.hyperlink_rules = wezterm.default_hyperlink_rules()
table.insert(config.hyperlink_rules, { ... })

-- ❌ デフォルトルールが消えてしまう
config.hyperlink_rules = { { ... } }

対応拡張子のカスタマイズ

ルール 2(ファイル名のみのマッチ)は、誤マッチ防止のため拡張子を限定しています。対象外の拡張子を追加したい場合は、正規表現の拡張子リストに追記してください。

-- 例: .prisma と .graphql を追加
regex = [[\b[\w\-]+\.(?:go|py|...|prisma|graphql)(?::\d+(?::\d+)?)?\b]],

なお、ディレクトリ付きのパス(path/to/file.xyz)はルール 1 で拡張子を問わずマッチするため、追記は不要です。


WezTerm の hyperlink_rules + open-uri イベントハンドラを組み合わせることで、ターミナル出力のファイルパスから直接 Neovim でファイルを開く体験を実現できました。

設定のポイントは 3 つ:

  1. hyperlink_rules で 2 つのルールを使い分け、あらゆるファイル形式のパスを検出・URI に変換
  2. mouse_bindings で Cmd+Click に OpenLinkAtMouseCursor を割り当て
  3. open-uri ハンドラ で URI から行番号を解析し、既存 nvim タブへのディスパッチまたは新規起動を行う
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?