neovimと textlintを使ってテキスト文章にlintをかける話 on Windows

掲題の話は、Googleで検索すると、いくつか記事がヒットします。

ただ、恐らく記事を書いた方に macOS/linux 利用者が多いのか、Windowsでは色々うまくいかなかったので、その記録です。

この記事は 2024/02/29 うるう年の肉の日の夜に記載しております。今日は肉を食べませんでしたけど。

なお、私自身は Vimmerと言えるほどneovim等に詳しくありません。
何か気が付いた事、こうやればもっと楽に出来るよ等あればご教示いただけますと幸いです。

## 事前告知

- 空白を含むパスのファイルを編集する場合、efm-langserverから textlintをかける事は対応が難しいです
- ネット上では textlintで mason使わない方が良いのでは的な意見も散見されますが、masonで進めています
- 私の環境では以下でちゃんと動いていますが、バージョンが変わるとダメになる可能性があります
- SHIFT-JIS/DOS改行のテキストファイルについては未確認です

## 環境

### ベース

- Windows 11 Pro 22H2
- neovim v0.9.5
- node v20.11.1
- jq 1.7.1

### nvim周り

- mason.nvim (mason/mason-lspconfig)
- nvim-lspconfig
- efm-langserver
- textlint

## 導入

neovim, node.jsの導入、neovim pluginの mason、nvim-lspconfigの導入等は省略します。
Googleで検索すればたくさん出てきますので😅。

### jqの導入

textlintの出力は、json形式に構造化することでプログラム的に扱いやすくなります。
これを加工し、efm-langserverに渡してnvimの画面に反映させます。

Windows 11だと `winget install jqlang.jq` で入ります。
私は chocolateyも併用しているので `choco install jq` で入れました。

nvimの `:terminal` から jq が起動出来る事を確認しておきましょう。

### textlintとefmの導入

textlintと efm-langserver(efm)を masonから導入します。

ここも `:Mason` からインストールするだけなので詳細な手順は省略しますが、以下を読んで実際に動作するようになっている事を確認しておきましょう。

### masonでの導入による副作用と確認

masonでlanguage serverを導入することで以下のような状況になります。

- nvim環境の中では PATHとして `%LOCALAPPDATA%\nvim-data\mason\bin` がパスの**先頭**に設定される
- 導入した Language Serverを起動する `.CMD`ファイルが、このパスに作成される

ここで重要なのが、masonによりPATHの先頭に設定されることです。

`npm install -g` 等で別途textlintを導入していたとしても、**nvimの中では masonで導入したtextlintが優先されます**。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128512/29497eeb-36df-65ee-92d0-ef2d9e03ecb7.png)

textlintはコマンドとして導入されている方も多いかと思います。その場合には masonでの導入は重ねて行う必要は無いでしょう。

いずれにせよ、事前確認として nvimの `:terminal` でPATHを確認できます。また、その中で `textlint` や `efm-langserver` 等のコマンドが叩けることを確認しておきましょう。

### mason-textlint-helper.luaの導入

これは私が作成したモジュールです。[こちら][mason-textlint-helper]にあります。

textlintへのモジュールの追加や、.textlintrc.json 等の取り扱いが面倒だったので作成しました。

cloneするとかcopyするとかして、nvimの設定フォルダのluaフォルダ配下に置いてください。

[mason-textlint-helper]: https://github.com/yasuoohno/mason-textlint-helper/blob/main/mason-textlint-helper.lua

### jqのfilterファイルを作っておく

Windowsのコマンドラインは少し呪われています。これに jq のfilter呪文を理解させる事は困難が伴います。無理をすると、うまく動かずSAN[^SAN]値を減少させる危険があります。

このため、コマンドラインから指定せず、filterファイルを使って指定します。
nvimの設定ファイル中から導出することが簡単な、nvimの構成ファイルディレクトリに保存する事とします。

`nvim "%LOCALAPPDATA%\nvim\textlint.jq"` 等として、以下の内容の `textlint.jq` を作ります。

```jq
.[] | .filePath as $filePath | .messages[] | "1;\($filePath):\(.line):\(.column):\n2;\(.message | split("\n")[0])\n3;[\(.ruleId)]"
```

なお、jqの filterは、[こちら](https://ryota2357.com/blog/2023/textlint-with-efm-nvimlsp/)で紹介されていたものを利用させていただいております🙇。

[^SAN]: 詳しくは[こちら](https://ja.wikipedia.org/wiki/%E3%82%AF%E3%83%88%E3%82%A5%E3%83%AB%E3%83%95%E3%81%AE%E5%91%BC%E3%81%B3%E5%A3%B0_(TRPG))の正気と狂気を参照してください。

### mason-lspconfigの setup-handler の追加

mason-lspconfigの setup handler にefmを追加します。

初期化時に 先ほど作った textlint.jq へのパス等を導出しておきます。

その後 lspconfig で efmをセットアップします。

```lua
local path_separator  = package.config:sub(1, 1)
local config_path = vim.fn.stdpath("config")
local is_windows = vim.fn.has('win32') == 1
local normalize_path_separator = is_windows and function(path)
    return path:gsub('/', '\\')
end or function(path)
    return path:gsub('\\', '/')
end

require("mason-lspconfig").setup_handlers {

    --[[ .... snip .... ]]

    -- efm for textlint
    ["efm"] = function()
        -- The .textlintrc file is searched in the following locations:
        --
        -- * The directory of current buffer file when this function is called.
        -- * Traverses up through the parent directories of it.
        -- * If not found, it looks '.textlintrc.json' in the configuration directory of nvim.
        --
        -- If you place the .textlintrc file in a folder with .git, set useRootMarkers to true.

        local useRootMarkers = false

        -- using textlint command under mason packages.
        local textlint_bin = "textlint"
        local config_path = vim.fn.stdpath("config")

        local textlint_config = ''
        if not useRootMarkers then
            local textlint_helper = require('mason-textlint-helper')
            local textlint_config_path = textlint_helper.SearchTextLintRC()

            if textlint_config_path == '' then
                textlint_config_path = config_path .. path_separator .. '.textlintrc.json'
            end

            if vim.fn.filereadable(textlint_config_path) == 1 then
                textlint_config_path = normalize_path_separator(textlint_config_path)
                textlint_config = " -c " .. textlint_config_path

                -- set textlint config to textlint_helper.lua
                textlint_helper.TextLintLSConfig = textlint_config_path
            end
        end

        local textlint_cmd = textlint_bin .. textlint_config .. " -f json ${INPUT}"

        -- jq filter for textlint output
        local textlint_jq_filter = config_path .. path_separator .. 'textlint.jq'

        local jq_cmd = "jq -r -f " .. normalize_path_separator(textlint_jq_filter)

        local textlint = {
            -- https://github.com/mattn/efm-langserver/blob/master/schema.md
            lintCommand = textlint_cmd .. " | " .. jq_cmd,
            lintFormats = { '%E1;%f:%l:%c:', '%C2;%m', '%C3;%m%Z' },
            lintIgnoreExitCode = true,
            lintOnSave = true,
        }

        require("lspconfig").efm.setup {
            settings = {
                rootMarkers = {
                    ".git/",
                },
                languages = {
                    markdown = { textlint },
                    text = { textlint },
                }
            },
            filetypes = {
                "markdown",
                "text",
            },
            single_file_support = true,
        }
    end
```

以下に、ここでの注意点を書いておきます。

#### lintCommand

正常に動作する例:  
`lintCommand = textlint_bin .. " -f json ${INPUT} | jq -r -f " .. textlint_jq_filter`

このlintCommandですが、実際にコマンドラインに投入されるタイミングで、ダブルクォートがエスケープされてコマンドに投入されます。

このエスケープをCMD.EXEが理解できず、そのままtextlintコマンドに渡されてしまいます。

以下の様にすると 対象ファイルを読み込む事が出来ずに textlintが動きません。

エラーになる例:  
`lintCommand = textlint_bin .. ' -f json "${INPUT}" | jq -r -f ' .. textlint_jq_filter`

`${INPUT}`をダブルクォートで挟んでいるかどうかの違いです。

この違いは 編集中のファイルのパスが半角スペースを含むときに関係します。

ダブルクォートが無い場合、`${INPUT}`が複数のパラメータとしてパースされ、正しくファイルが読み込まれなくなります。

同様の事から `textlint_bin` や `textlint_jq_fileter` で指定しているパスもダブルクォートが使えません。

これらのファイルのパスには半角スペースが含まれない様に注意してください。

#### lintIgnoreExitCode

[lint-ignore-exit-code]を見ると、デフォルトが `true` となっているので不要かと思いましたが、
この行が無いと エラー扱いになってtextlintのメッセージが一瞬表示された後消えてしまいました。

[lint-ignore-exit-code]: https://github.com/mattn/efm-langserver/blob/master/schema.md#languages_pattern1_items_lint-ignore-exit-code

#### lintOnSave

バッファで編集中の文章に対しては、textlintが実行できませんので、`lintOnSave` を `true` にしてあります。

この場合ファイルを開いただけでは textlint が実行されず結果は表示されません。`:w` で保存すると textlint が実行されます。

この動作を変更したい場合は lintOnSave を `false` にしてください。

#### rootMakers が効かない

こちらの[Issue](https://github.com/mattn/efm-langserver/issues/225)が私の環境でも起きております。
rootMakersの 一つ目のエントリが '.git/' 以外の場合は project rootの detectに失敗してしまいます。

必ずしもgitで管理しているわけではなく、これだととても作業がしにくいので、代わりに自前で .textlintrc.json を探す様にしています。検索は以下の様に行っています。

1. **setup handlerが呼ばれたタイミングで、カレントバッファ**のファイルのあるディレクトリ
1. カレントバッファのディレクトリの上位ディレクトリ
1. 見つからなければ nvim の設定ディレクトリにある .textlintrc.json
1. それも無ければコマンドラインには何も渡さずに textlintを実行 (efm-langserverのrootMarkers次第となります)

rootMarkersの問題が無い場合、または gitプロジェクト以外のtextファイルはlintを通さなくて良い場合は、setup handler中の useRootMarkers を trueにしてください。

### rule等の導入

__ここは、textlintを masonで導入している方が対象です。__

rule等は、textlintの環境に追加する必要があります。

一番簡単なのは、`%LOCALAPPDATA%\nvim-data\mason\packages\textlint` に移動して、`npm install --save` で導入することかと思います。例えばこんな感じ

```batch
cd %LOCALAPPDATA%\nvim-data\mason\packages\textlint

npm install --save textlint-rule-preset-japanese textlint-rule-preset-ja-technical-writing textlint-filter-rule-comments textlint-filter-rule-allowlist
```

前述の [mason-textlint-helper] で、TextLintInstall を実行することで
[mason-textlint-helperの中に記載されているモジュール](https://github.com/yasuoohno/mason-textlint-helper/blob/main/mason-textlint-helper.lua#L5-L10)を導入することができます。

以下でモジュールの関数をコマンドに登録してやって、`:TextLintInstall` で導入されます。
バックグラウンドで導入されるため、実行後しばらくお待ちください。

```lua
vim.cmd([[command! TextLintInstall lua require('mason-textlint-helper').TextLintInstall()]])
```

::: note warn
TextLintInstallでインストール完了時に完了した事を表示していたのですが、} ぐらいしか見えないかもしれません。
:::

### (Option) .textlintrc.jsonを編集するコマンドの追加

以下を `init.lua`等に追加し、`:TextLintEditConfig` を実行することで、現在使われているであろう設定ファイルを開く事ができます。

```lua
vim.cmd([[command! TextLintEditConfig lua require('mason-textlint-helper').TextLintEditConfig()]])
```

### (Option) 設定ファイルが見つからない場合のfallback先 .textlintrc.jsonを作成する

nvimを起動したときに、設定ファイルが見つからない場合は、
nvimの設定フォルダにある `.textlintrc.json` が読み込まれます。

設定ファイルが見つからない時に textlint を実行しないで良い場合は、このファイルを作成しないでください。

```json
{
  "rules": {
    "preset-japanese": true
  }
}
```

## おわり

思ったより長くなってしまいましたので、時間が足りず推敲できておりませんが リリースしておきます🙇。