Langages

C’est la partie la plus intéressante de la configuration, et celle qui demande le plus d’explication : quatre plugins doivent se mettre d’accord sur ce que veut dire « supporter Python ».

Le problème

Ajouter un langage touche normalement quatre endroits : le serveur à activer, le paquet à installer, le parser treesitter à compiler, le formateur à brancher. Quatre fichiers, quatre conventions de nommage différentes — le paquet mason s’appelle typescript-language-server là où le serveur lspconfig s’appelle ts_ls — et un oubli qui ne se manifeste que trois semaines plus tard, sur un fichier qu’on n’ouvre jamais.

La solution : une source unique

Tout est déclaré dans lua/config/languages.lua, et rien ailleurs. Une entrée par langage, quatre champs, tous optionnels :

python = {
  servers = { 'basedpyright', 'ruff' },   -- noms lspconfig, pour vim.lsp.enable()
  mason = { 'basedpyright', 'ruff' },     -- noms de paquets mason, à installer
  parsers = { 'python' },                 -- parsers treesitter
  formatters = {                          -- conform : filetype -> chaîne
    python = { 'ruff_organize_imports', 'ruff_format' },
  },
},

Le fichier expose ensuite quatre fonctions, et chaque consommateur appelle la sienne :

FonctionConsommée parPour
servers()lua/plugins/lsp.luavim.lsp.enable()
mason_packages()lua/plugins/lsp.lual’installation automatique
parsers()lua/plugins/treesitter.luala compilation des grammaires
formatters_by_ft()lua/plugins/format.luaconform

L’agrégation déduplique : prettier est déclaré par cinq langages et n’est installé qu’une fois. Et formatters_by_ft() lève une erreur explicite si deux langages revendiquent le même type de fichier — une collision silencieuse serait impossible à diagnostiquer.

Ajouter un langage, c’est donc ajouter une table de quatre lignes. C’est tout l’intérêt du dispositif, et la raison pour laquelle il vaut ses cinquante lignes de code d’agrégation.

Les réglages par serveur, à part

Un second tableau, M.server_config, porte les réglages propres à chaque serveur. Pourquoi séparé ? Parce qu’un serveur peut couvrir plusieurs langages : ts_ls sert JS, TS et le bloc <script> d’un fichier .vue. Ses réglages n’appartiennent donc à aucune entrée de langage en particulier.

Le piège du runtimepath

Ces réglages sont appliqués par vim.lsp.config() après le chargement de nvim-lspconfig, et pas en déposant un fichier lsp/ts_ls.lua à la racine de la configuration. Ce n’est pas un détail de style.

Le répertoire de configuration est en position 1 du runtimepath, nvim-lspconfig en position 10. Neovim fusionne les fichiers lsp/ dans l’ordre du chemin, donc celui du plugin est appliqué par-dessus le nôtre. Les tables imbriquées comme settings survivent à cette fusion ; une liste, non. La clé filetypes de ts_ls perdrait son entrée vue, et les fichiers .vue cesseraient silencieusement d’être typés.

L’installation automatique

mason installe, mais il faut lui dire quoi. mason-lspconfig faisait ce pont ; il a été remplacé par une douzaine de lignes dans lua/plugins/lsp.lua qui parcourent mason_packages() et installent ce qui manque.

Un détail compte : le code vérifie package:is_installing() avant de lancer une installation, parce que tree-sitter-cli peut déjà être en vol — c’est la spécification treesitter qui le possède et l’installe au démarrage. Sans cette vérification, Package:install() lève une assertion.

L’autre point à connaître est quand ça se produit : lspconfig charge sur BufReadPre, donc un nvim lancé à vide n’installe aucun serveur. Il faut ouvrir un fichier. Les parsers treesitter, eux, s’installent dès le démarrage.

Le LSP, sans framework

Depuis Neovim 0.11, l’activation d’un serveur passe par l’API native. nvim-lspconfig n’est plus un framework : il ne fournit que les fichiers lsp/*.lua de centaines de serveurs, que l’API native consomme.

vim.lsp.config('*', {
  capabilities = require('blink.cmp').get_lsp_capabilities(),
})

languages.apply_server_config()
install_missing_packages()

vim.lsp.enable(languages.servers())

vim.lsp.config('*', …) annonce à tous les serveurs, en une fois, ce que le moteur de complétion sait faire. Les réglages par serveur se fusionnent par-dessus.

Un seul raccourci LSP est ajouté à la main, gd — c’est le seul que Neovim 0.12 ne fournit pas. Il est posé dans une autocommande LspAttach avec buffer = event.buf, donc il n’existe que dans les buffers où un serveur répond réellement. <leader>th suit la même logique, avec en plus un test de capacité : le raccourci n’est créé que si le serveur sait produire des inlay hints.

Ce qui ne se devine pas

Ces points ont tous coûté du temps à diagnostiquer. Ils sont commentés dans le code, et valent d’être lus avant de reproduire la configuration ailleurs.

Vue exige deux serveurs. Depuis vue_ls v3, le takeover mode n’existe plus : ts_ls doit charger @vue/typescript-plugin et inclure vue dans ses filetypes pour comprendre le <script> d’un .vue. Le plugin est fourni dans le paquet mason vue-language-server, d’où le chemin en dur vers stdpath('data').

Python fait tourner deux serveurs, volontairement. basedpyright pour les types et la complétion, ruff pour le lint et le formatage. Le survol de ruff est désactivé via on_attach, sinon K afficherait la réponse du premier serveur à répondre — donc au hasard.

biome et eslint ne se marchent pas dessus. Tous deux déclarent workspace_required = true : chacun ne s’attache que dans un projet portant son propre fichier de configuration. Les deux peuvent donc être activés sans conflit.

biome lint, prettier formate. biome reste actif comme serveur pour ses diagnostics sur JS/TS/JSON/CSS, mais ne formate plus rien. Comme un formateur est déclaré pour chacun de ces types, le repli lsp_format = 'fallback' de conform ne demande jamais à biome de formater. Et biome ignore de toute façon les .scss — il annonce « no files were processed » et laisse le fichier intact, sans erreur.

Le prettier du projet est prioritaire. conform résout la commande via from_node_modules, donc un node_modules/.bin/prettier local et son .prettierrc passent devant l’installation mason. Attention si le projet a un biome.json sans configuration prettier : les deux outils peuvent diverger sur les guillemets ou les virgules finales.

Vue et Svelte sont formatés par leur LSP. Leur serveur embarque prettier et le plugin correspondant ; un prettier nu ne sait pas analyser ces fichiers. Ces deux langages n’ont donc pas d’entrée formatters, et tombent sur le repli LSP.

Rust n’est pas dans servers. rustaceanvim démarre rust_analyzer lui-même. L’activer aussi par vim.lsp.enable() attacherait deux clients à chaque buffer Rust. rust-analyzer est bien installé par mason, mais jamais activé par cette voie — et rustfmt vient avec la chaîne d’outils Rust, donc il est absent de la liste mason.

shellcheck n’est pas branché à la main. bashls le détecte sur le $PATH et remonte ses résultats comme diagnostics. Il suffit qu’il soit installé.

Neovim distingue sh, bash et zsh comme trois types de fichiers. Un formateur déclaré pour bash ne s’applique pas à un .sh, que Neovim classe en sh.

La complétion

blink.cmp remplace nvim-cmp et son écosystème de sources : lsp, path, snippets et buffer sont intégrés, il n’y a donc plus un plugin par source à maintenir en cohérence. La seule source externe est lazydev, avec un score_offset = 100 pour qu’elle passe devant les suggestions de lua_ls sur les chemins de require.

Le plugin est épinglé en 1.*. La raison est pratique : lazy télécharge alors le moteur de recherche floue Rust précompilé, au lieu d’exiger un cargo build local.

Le préréglage enter conserve les habitudes de nvim-cmp — Entrée accepte, Ctrl + e annule, Ctrl + espace ouvre, Tab circule dans les emplacements d’un snippet.

Le formatage

conform formate à l’enregistrement, avec un timeout_ms = 500 et un repli sur le LSP quand aucun formateur n’est déclaré pour le type de fichier. <leader>f formate à la demande.

:ConformInfo répond à la question qui se pose toujours en premier quand le formatage fait autre chose que prévu : quels formateurs s’appliquent à ce buffer, et lesquels ont été trouvés sur le disque.