LoginSignup
13
6

NeovimとVSCodeと私

Posted at

TD;LR

VSCodeとNeovimを使った開発環境の構築について記載します。

背景

簡単に私のテキストエディタ遍歴を書いておくと以下のとおりです。

  • 2003年頃: 中学2年生のときにTeraPadというテキストエディタに出会う。この頃はウェブサイトを作ったり、フリーで配布されていたPerlで書かれたCGIを少し編集したりするのに使っていた。
  • 2004年ごろ: viに出会う。この頃は自宅サーバを稼働させ始めて、SSHでログインしてファイルを編集するのに使っていた。プログラミングはもっぱらTeraPadで行っていた
  • 2009年頃: Vimに出会う。この頃、vimrcを編集することを知り、素viと比較して格段に使用性が向上した。しばらく経ってからEmacsに出会う。Emacsはこの当時からOSと呼ばれているぐらいに機能が充実しており、すぐに改宗した。それ以降はずっとEmacsを使っているため、基本はEmacs keybindingを使っていた。
  • 2017年ごろ: 会社に入社後、VSCodeに出会う。これがlspとの出会いであった。それ以前のテキストエディタはそれぞれ自前の補完機能があったが、vscodeの補完機能は格段に優れていた。この補完機能を使うためだけに、Emacsから改宗した。とはいうものの、Emacs keybindingは相変わらず使い続けていた。
  • 2022年: Github Copilotの登場。正直、Copilotが出た瞬間は二度とVSCodeから乗り換えないと誓った。
  • 2023年: Neovimに出会う。VSCodeの唯一の欠点はSSH経由でのリモート開発が不便であることだった。そこでSSH経由で快適に開発できるテキストエディタを探していたところNeoVimに出会った。それまではVimかEmacsかの選択だったが、luaで設定ファイルやプラグインをかけること、Github Copilotのプラグインがあることが決め手だった。Neovimに切り替えると誓って、最初のコードはX線吸収スペクトルの解析用のプログラムの作成だったが、Vimのキーバインディングが入っていたせいかすんなり使えた。あまりにもすんなりと使えたため、NeoVimに全面的に切り替えることにした。
  • 現在: Neovimをprimary editorにしていたが、VSCodeのJupyter Notebookの拡張が忘れられず、Jupyterを使うときだけVSCodeに戻すこととした。ここで問題が生じるのがNeovimとVSCodeの使用感。正直Neovimの使用感が良すぎて、VSCodeに戻る気の辛かったが、いくつのプラグインを入れることでなんとかVSCodeでも違和感なく開発できる環境になった。

Neovimを使ったワークフロー

Neovimを使う上で欠かせないのがTerminal Emulator、Tmux、その他cliツールである。これらを組み合わせることで、Neovimの使用感が格段に向上する。以下に私が使っているツールを記載する。

  • Terminal Emulator: WezTerm (Terminalでありながら、画像の表示などもできてしまうスグレモノ。Rustで書かれている。)
  • Tmux: Terminal multiplexer。SSHでログインしているときに、切断してしまっても大丈夫になるスグレモノ。ウィンドウやセッションの管理はすべてTmuxにまかせている。
  • FZF: Fuzzy finder。ファイルやバッファの選択を行うときに使う。Tmuxのセッションを作成するとき、SSHのログイン候補、Python環境の選択など色々なことに使えて、かなり重宝する。

ちなみにFSFとTmuxを組み合わせたスクリプトとして tmux-sessionizer.sh というものがあり、これはかなり便利。Youtuberのtheprimagenが作成したもので、予め登録したディレクトリをfzfで表示させて、そこからTmuxのセッションを生成するというもので、tmuxのkeybindingに割り当てて使う。

#!/usr/bin/env bash

if [[ $# -eq 1 ]]; then
	selected=$1
else
	selected=$(find ~ ~/python ~/rust ~/dotenv ~/Documents  /Volumes ~/dev ~/dev/cpp ~/work ~/analysis ~/vault ~/feff ~/fdmnes ~/docker ~/miniforge3/envs ~/marp -mindepth 1 -maxdepth 1 -type d | fzf)
fi

if [[ -z $selected ]]; then
	exit 0
fi

selected_name=$(basename "$selected" | tr . _)
tmux_running=$(pgrep tmux)

if [[ -z $TMUX ]] && [[ -z $tmux_running ]]; then
	tmux new-session -s $selected_name -c $selected
	exit 0
fi

if ! tmux has-session -t=$selected_name 2>/dev/null; then
	tmux new-session -ds $selected_name -c $selected
fi

tmux switch-client -t $selected_name
  • Ripgrep: Neovim内で利用。ファイル検索などはrgかgrepを使っている。
  • Ctrl+z, fg: Neovimから一時的に抜け出すときに使う。ターミナルでなにか実行したいときはterminalで行うのがいいと思う。
  • zsh: シェル。今まではlinuxではデフォルトのbash, Macではzshを使っていたが、使用感を統一するためにzshに統一した。補完機能とvi keybindingがかなり優秀なので重宝している。
  • Arch Linux: Hyprlandが比較的新しいソフトウェアなので、追従性を考えてローリングリリースのArch Linuxを採用している。pacmanとAURが優秀なのでかなり助かっている。昔使っていたFreeBSDのportsをイメージしていたが、pacmanとAURの方がはるかに優秀で助かっている。
  • Hyprland: Tiling window manager。かなり使いやすい。設定が多少面倒だが、これのためにArch Linuxを使っていると言っても過言ではない。基本的には各ウィンドウにそれぞれのプログラムを起動しておいてcmd+数字で移動することが多い。
  • Neovim: これが一番重要。

Neovimの設定

基本的にはlazyvimを中心に、少し設定を追加している。基本的には公式の通り設定すれば良いと思うが、以下の設定はあまり書いてないので備忘録として記載する。

normalモードに戻ると半角全角を自動で切り替えてくれるプラグイン

return {
    "keaising/im-select.nvim",
    vscode = true,
    config = function()
        require("im_select").setup({
            -- IM will be set to `default_im_select` in `normal` mode
            -- For Windows/WSL, default: "1033", aka: English US Keyboard
            -- For macOS, default: "com.apple.keylayout.ABC", aka: US
            -- For Linux, default:
            --               "keyboard-us" for Fcitx5
            --               "1" for Fcitx
            --               "xkb:us::eng" for ibus
            -- You can use `im-select` or `fcitx5-remote -n` to get the IM's name
            -- default_im_select  = "com.apple.keylayout.ABC",

            -- Can be binary's name or binary's full path,
            -- e.g. 'im-select' or '/usr/local/bin/im-select'
            -- For Windows/WSL, default: "im-select.exe"
            -- For macOS, default: "im-select"
            -- For Linux, default: "fcitx5-remote" or "fcitx-remote" or "ibus"
            -- default_command = 'im-select.exe',

            -- Restore the default input method state when the following events are triggered
            set_default_events = { "VimEnter", "FocusGained", "InsertLeave", "CmdlineLeave" },

            -- Restore the previous used input method state when the following events
            -- are triggered, if you don't want to restore previous used im in Insert mode,
            -- e.g. deprecated `disable_auto_restore = 1`, just let it empty
            -- as `set_previous_events = {}`
            -- set_previous_events = { "InsertEnter" },

            -- Show notification about how to install executable binary when binary missed
            keep_quiet_on_no_binary = false,

            -- Async run `default_command` to switch IM or not
            async_switch_im = true,
        })
    end,
}

lazyvimの公式と少し異なる点としてはnvim-cmp.luaの設定が挙げられる。これはGithub Copilotが暴発して若干うっとおしいのを抑える役割がある。以下に設定ファイルと記載する。

return {
    "hrsh7th/nvim-cmp",
    version = false, -- last release is way too old
    event = "InsertEnter",
    dependencies = {
        "hrsh7th/cmp-nvim-lsp",
        "hrsh7th/cmp-buffer",
        "hrsh7th/cmp-path",
        "saadparwaiz1/cmp_luasnip",
    },
    opts = function()
        local has_words_before = function()
            unpack = unpack or table.unpack
            local line, col = unpack(vim.api.nvim_win_get_cursor(0))
            return col ~= 0 and vim.api.nvim_buf_get_lines(0, line - 1, line, true)[1]:sub(col, col):match("%s") == nil
        end

        local luasnip = require("luasnip")
        local cmp = require("cmp")
        return {
            preselect = "none",
            completion = {
                completeopt = "menu,menuone,noinsert,noselect",
            },
            snippet = {
                expand = function(args)
                    require("luasnip").lsp_expand(args.body)
                end,
            },
            mapping = cmp.mapping.preset.insert({
                ["<C-n>"] = cmp.mapping.select_next_item({ behavior = cmp.SelectBehavior.Insert }),
                ["<C-p>"] = cmp.mapping.select_prev_item({ behavior = cmp.SelectBehavior.Insert }),
                ["<C-b>"] = cmp.mapping.scroll_docs(-4),
                ["<C-f>"] = cmp.mapping.scroll_docs(4),
                ["<C-Space>"] = cmp.mapping.complete(),
                ["<C-e>"] = cmp.mapping.abort(),
                ["<CR>"] = cmp.mapping.confirm({ select = false }), -- Accept currently selected item. Set `select` to `false` to only confirm explicitly selected items.
                ["<S-CR>"] = cmp.mapping.confirm({
                    behavior = cmp.ConfirmBehavior.Replace,
                    select = true,
                }), -- Accept currently selected item. Set `select` to `false` to only confirm explicitly selected items.
                ["<Tab>"] = cmp.mapping(function(fallback)
                    if cmp.visible() then
                        cmp.select_next_item()
                    -- You could replace the expand_or_jumpable() calls with expand_or_locally_jumpable()
                    -- this way you will only jump inside the snippet region
                    elseif luasnip.expand_or_jumpable() then
                        luasnip.expand_or_jump()
                    elseif has_words_before() then
                        cmp.complete()
                    else
                        fallback()
                    end
                end, { "i", "s" }),
                ["<S-Tab>"] = cmp.mapping(function(fallback)
                    if cmp.visible() then
                        cmp.select_prev_item()
                    elseif luasnip.jumpable(-1) then
                        luasnip.jump(-1)
                    else
                        fallback()
                    end
                end, { "i", "s" }),
            }),
            sources = cmp.config.sources({
                { name = "copilot" },
                { name = "nvim_lsp" },
                { name = "luasnip" },
                { name = "buffer" },
                { name = "path" },
            }),
            formatting = {
                format = function(_, item)
                    local icons = require("lazyvim.config").icons.kinds
                    if icons[item.kind] then
                        item.kind = icons[item.kind] .. item.kind
                    end
                    return item
                end,
            },
            experimental = {
                ghost_text = {
                    hl_group = "LspCodeLens",
                },
            },
        }
    end,
}

keymaps.luaはかなり重要で、wrap機能(行の橋で←or→で業界編できる機能)やGithub Copilotを<space>coでオンオフを切り替えられるように設定している。Github Copilotよりもlspを重視するのでデフォルトではオフにしている。ちなみに後々出てくるが、Vscode内でneovimを使おうとすると、キーバインディングが鑑賞する可能性があるため、vim.g.vscodeでガードしている。

keymaps.luaの設定ファイルを記載すると以下のとおりである。

vim.opt.whichwrap = vim.opt.whichwrap + "b,s,<,>,[,]"

if vim.g.vscode then
    local vscode = require("vscode-neovim")

    vim.opt.clipboard = "unnamedplus"

    vim.keymap.set("n", "<leader>l", function()
        vscode.action("workbench.view.extensions")
    end)

    vim.keymap.set("n", "<leader>e", function()
        vscode.action("workbench.view.explorer")
    end)
else
    local copilot_on = false
    vim.api.nvim_create_user_command("CopilotToggle", function()
        if copilot_on then
            vim.cmd("Copilot disable")
            print("Copilot OFF")
        else
            vim.cmd("Copilot enable")
            print("Copilot ON")
        end
        copilot_on = not copilot_on
    end, { nargs = 0 })

    vim.keymap.set("n", "<leader>co", ":CopilotToggle<CR>", { noremap = true, silent = true })
end

Vscodeの設定

Vscodeは以下のプラグインを入れている。

  • VSCode Neovim : Neovimを呼び出すためのプラグイン。Neovimを読んでいるため、Neovim用のプラグインも動作する。
  • Error Lens : Diagnosticsをinlineに表示するプラグイン。Lazyvimではデフォルトでlinterのdiagnosticsがinlineで表示され便利だったので、このプラグインを使うことで同じことができる。
  • Jupyter
  • Jupyter keymaps

lazyvimで設定されるデフォルトのキーバインディングに加えて、vscode側でkeybindingを変えてやる必要があり、以下にkeybinding.jsonを記載する。

[
  // NAVIGATION
  {
    "key": "ctrl+t",
    "command": "workbench.action.terminal.focus"
  },
  {
    "key": "ctrl+t",
    "command": "workbench.action.focusActiveEditorGroup",
    "when": "terminalFocus"
  },
  {
    "key": "ctrl+shift+j",
    "command": "workbench.action.togglePanel"
  },
  {
    "key": "ctrl+shift+n",
    "command": "workbench.action.terminal.new",
    "when": "terminalFocus"
  },
  {
    "key": "ctrl+shift+w",
    "command": "workbench.action.terminal.kill",
    "when": "terminalFocus"
  },

  // Copilot
  {
    "key": "ctrl+shift+q",
    "command": "github.copilot.toggleCopilot"
  },

  // Jupyter Notebook
  {
    "key": "i",
    "command": "notebook.cell.edit",
    "when": "notebookCellListFocused && notebookEditable && !editorHoverFocused && !inputFocus"
  },
  {
    "key": "ctrl+[",
    "command": "notebook.cell.edit",
    "when": "notebookCellListFocused && notebookEditable && !editorHoverFocused && !inputFocus"
  },
  {
    "key": "shift+o",
    "command": "notebook.cell.insertCodeCellAboveAndFocusContainer",
    "when": "notebookEditorFocused && !inputFocus && !notebookOutputInputFocused"
  },
  {
    "key": "o",
    "command": "notebook.cell.insertCodeCellBelowAndFocusContainer",
    "when": "notebookEditorFocused && !inputFocus && !notebookOutputInputFocused"
  },
  {
    "key": "p",
    "command": "notebook.cell.paste",
    "when": "notebookEditorFocused && !inputFocus && !notebookOutputInputFocused"
  },

  // Vim related

  // list navigation
  {
    "key": "g g",
    "command": "list.focusFirst",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "shift+g",
    "command": "list.focusLast",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "h",
    "command": "list.collapse",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "j",
    "command": "list.focusDown",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "k",
    "command": "list.focusUp",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "l",
    "command": "list.select",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "ctrl+u",
    "command": "list.focusPageUp",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "ctrl+d",
    "command": "list.focusPageDown",
    "when": "listFocus && !inputFocus"
  },
  {
    "key": "/",
    "command": "list.toggleKeyboardNavigation",
    "when": "listFocus && !inputFocus && listSupportsKeyboardNavigation"
  },

  // Explorer
  {
    "key": "r",
    "command": "renameFile",
    "when": "explorerViewletVisible && filesExplorerFocus && !explorerResourceIsRoot && !explorerResourceReadonly && !inputFocus"
  },
  {
    "key": "d",
    "command": "deleteFile",
    "when": "explorerViewletVisible && filesExplorerFocus && !explorerResourceReadonly && !inputFocus"
  },
  {
    "key": "y",
    "command": "filesExplorer.copy",
    "when": "explorerViewletVisible && filesExplorerFocus && !explorerResourceIsRoot && !inputFocus"
  },
  {
    "key": "x",
    "command": "filesExplorer.cut",
    "when": "explorerViewletVisible && filesExplorerFocus && !explorerResourceIsRoot && !inputFocus"
  },
  {
    "key": "p",
    "command": "filesExplorer.paste",
    "when": "explorerViewletVisible && filesExplorerFocus && !explorerResourceReadonly && !inputFocus"
  },
  {
    "key": "v",
    "command": "explorer.openToSide",
    "when": "explorerViewletFocus && explorerViewletVisible && !inputFocus"
  },
  {
    "key": "a",
    "command": "explorer.newFile",
    "when": "filesExplorerFocus && !inputFocus"
  },
  {
    "key": "shift+a",
    "command": "explorer.newFolder",
    "when": "filesExplorerFocus && !inputFocus"
  },
  {
    "key": "n",
    "command": "explorer.newFile",
    "when": "filesExplorerFocus && !inputFocus"
  },
  {
    "key": "shift+n",
    "command": "explorer.newFolder",
    "when": "filesExplorerFocus && !inputFocus"
  },
  {
    "key": "escape",
    "command": "workbench.action.focusActiveEditorGroup",
    "when": "sideBarFocus"
  },
  {
    "key": "ctrl+[",
    "command": "workbench.action.focusActiveEditorGroup",
    "when": "sideBarFocus"
  },
  {
    "key": "ctrl+e",
    "command": "workbench.view.explorer"
  },
  {
    "key": "ctrl+l",
    "command": "workbench.view.extensions"
  },
  {
    "key": "space e",
    "command": "workbench.action.toggleSidebarVisibility",
    "when": "sideBarFocus && !inputFocus"
  }
]

keybindを適切に設定することでVSCode自体はマウスを使わなくても良くなるため、サイドバーやその他不要なものはすべて排除することができる。

VSCodeの画面
vscode-neovim.png

結論

Neovim及びVSCodeを使った開発環境の構築について記載した。Neovimは基本的にはTerminal Emulator、Tmux、その他cliツールを組み合わせることで、かなり快適な環境を構築することができる。VSCodeはElectronベースであることからJupyterなどのプログラムと非常に相性が良いものの、テキストエディタとしてはNeovimよりも若干使いづらい。今回の設定を行うことでほぼ同等の使用感を得ることができる。

最後に

日本はまだNeovimの普及率がアメリカと比べると高くないように感じます。私の勝手な想像では、Neovimがエディタ単体ではなく、周辺のcliを組み合わせて初めて真価が発揮されるため、そもそもどこから手をつけていいかわからない人が多いのではないかと感じています。少しでもNeovimの利用者が増えること期待して、記事を書きました。

13
6
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
13
6