# Language Server Protocol ## 1. Avant LSP : le chaos Avant 2016, chaque éditeur devait réimplémenter la logique d'analyse de code pour chaque langage. Résultat : avec **M éditeurs** et **N langages**, il faut écrire **M×N intégrations** — une par combinaison éditeur/langage. ### Exemple : Jedi **Jedi** est une librairie Python d'analyse statique (autocomplétion, goto, hover). Elle était bonne — mais chaque éditeur devait écrire son propre plugin pour l'utiliser : - `jedi-vim` pour Vim - `emacs-jedi` pour Emacs - `SublimeJEDI` pour Sublime Text - une intégration maison dans PyCharm - etc. Chacun gérait différemment le démarrage du process, le parsing des résultats, le cache. Quand Jedi sortait une nouvelle version, certains plugins cassaient, d'autres pas. Les mainteneurs de plugins n'étaient pas les auteurs de Jedi. ### Autres exemples - **Tern.js** (JavaScript) : même problème, un plugin par éditeur pour avoir de la complétion JS - **Racer** (Rust) : l'outil de complétion Rust avant rust-analyzer, intégré différemment partout - **ctags / cscope** : l'approche "goto definition" de l'époque — juste du parsing regex de fichiers sources, pas d'analyse sémantique réelle En pratique : - Les auteurs de langages passaient leur temps à écrire des plugins éditeur - Chaque plugin avait ses propres bugs, ses propres lacunes - Quand le langage évoluait, il fallait mettre à jour chaque plugin séparément --- ## 2. Pourquoi LSP existe Microsoft crée LSP en 2016 pour découpler VS Code du support TypeScript. L'idée centrale : **standardiser le protocole de communication**, pas l'implémentation. - Un seul serveur par langage - Un seul client par éditeur - **M + N intégrations** au lieu de M × N : chaque éditeur implémente le protocole une fois, chaque langage écrit son serveur une fois Aujourd'hui : - 100+ language servers (rust-analyzer, clangd, pyright, gopls...) - Tous les éditeurs majeurs : VS Code, Neovim, Emacs, JetBrains, Zed... - La spec est maintenue par Microsoft mais ouverte ### Mais… on a toujours plusieurs serveurs par langage ? Oui — et c'est normal. LSP résout le problème d'**intégration éditeur**, pas le problème de **concurrence entre outils**. Plusieurs serveurs coexistent pour un même langage pour des raisons légitimes : - **Spécialisation** : pyright fait du type checking, ruff-lsp fait du linting/formatting, pylsp est généraliste. Ils sont complémentaires, pas concurrents. - **Trade-offs différents** : vitesse vs. précision, strictness vs. permissivité. ty (Astral) mise sur la vitesse, pyright sur la profondeur d'analyse. - **Raisons business / licence** : Pylance est closed source (Microsoft), donc la communauté a construit pylsp. La compétition pousse l'innovation. - **Algorithmes d'analyse différents** : le protocole LSP est juste le transport — l'analyse statique en dessous est un problème dur, et différentes approches donnent différents résultats. La clé : **les éditeurs n'ont pas besoin de savoir**. Neovim ne fait pas la différence entre pyright et ty — il parle LSP aux deux. La diversité est côté serveur, ce qui est sain. C'est exactement l'inverse du monde d'avant : avant, la diversité chaotique était côté intégrations éditeur. --- ## 2bis. Historique : l'évolution du protocole LSP n'est pas sorti complet en 2016. Il a évolué par itérations, chaque version répondant à des besoins concrets qui remontaient du terrain. ### 2016 — Fondations (1.x → 2.x) La version initiale sort en juin 2016 en même temps que VS Code, principalement pour TypeScript. Elle couvre l'essentiel du quotidien : - `textDocument/completion` — autocomplétion - `textDocument/hover` — info au survol - `textDocument/definition` — goto definition - `textDocument/references` — find references - `textDocument/publishDiagnostics` — erreurs et warnings (modèle push) - `textDocument/didOpen`, `didChange`, `didClose` — document sync La version 2.x apporte peu après le **versioning des documents** — chaque `didChange` porte un numéro de version pour que le serveur détecte les deltas hors-ordre. ### 2017 — Standardisation (3.0) **Février 2017.** Première version vraiment "standardisée", avec deux apports structurels : - **Capabilities** : la négociation formalisée au moment du handshake. Client et serveur déclarent ce qu'ils supportent, ce qui permet l'interopérabilité sans casser les serveurs qui n'implémentent pas tout. - **Dynamic registration** : un serveur peut enregistrer ou désenregistrer des capabilities *après* le handshake — utile quand certaines features ne sont disponibles qu'après l'indexation complète du projet. ### 2018 — Expansion (3.6 → 3.14) Une série de versions qui étoffe les features de navigation et de refactoring : - **3.6** *(février 2018)* : `typeDefinition`, `implementation`, **workspace folders** (support des monorepos multi-racines) - **3.8** *(juin 2018)* : les code actions peuvent désormais retourner des `WorkspaceEdit` directement — plus besoin d'un aller-retour `executeCommand` - **3.13** *(septembre 2018)* : **file operations** — créer, renommer, supprimer des fichiers depuis un `WorkspaceEdit`. Première brique vers le refactoring multi-fichiers. - **3.14** *(décembre 2018)* : `declaration`, **location links** — les goto-def peuvent retourner des ranges précis (la cible exacte, pas juste la ligne) ### 2020 — Maturité (3.15 → 3.16) Deux versions importantes qui s'attaquent à des lacunes de fond. **3.15** *(janvier 2020)* : **progress reporting**. Jusqu'ici, les opérations longues (indexation au démarrage, find-all-references sur un gros projet) étaient des boîtes noires. Le serveur peut maintenant envoyer des notifications `$/progress` avec un pourcentage — l'éditeur peut afficher une barre de progression. **3.16** *(décembre 2020)* : la version la plus riche de l'histoire du protocole. - **Semantic tokens** : le serveur peut colorier le code *sémantiquement* (ce `foo` est un paramètre, cet autre `foo` est une variable locale). Bien supérieur à la coloration syntaxique regex ou treesitter pour les langages à typage complexe. - **Call hierarchy** : naviguer entre appelants et appelés d'une fonction — une feature attendue depuis longtemps. - **Change annotations** : les `WorkspaceEdit` peuvent maintenant porter des annotations (description, confirmation requise) — la base pour un refactoring interactif. ### 2022 — Modernisation (3.17) **Mai 2022.** Une version qui répond à plusieurs critiques structurelles. - **Pull diagnostics** : le client peut demander les diagnostics quand il est prêt (`textDocument/diagnostic`), au lieu de les recevoir en push non contrôlé. Résout le problème de backpressure — cf. section sur les limites. - **Inlay hints** : annotations visuelles inline (types inférés, noms de paramètres) sans modifier le source. Fonctionnalité popularisée par JetBrains, enfin standardisée. - **Type hierarchy** : naviguer dans l'arbre d'héritage des types. - **Notebook document support** : premier support natif des notebooks Jupyter dans la spec — `notebookDocument/didOpen`, `didChange`, etc. Un modèle distinct du modèle `textDocument` pour gérer les cellules. - **Inline values** : afficher des valeurs de variables directement dans le code pendant le debugging. ### 2024 — L'IA s'invite (3.18) **3.18** *(finalisé en 2024)* : la principale nouveauté est **`textDocument/inlineCompletion`** — un endpoint standardisé pour les completions de type Copilot (suggestions de plusieurs lignes, déclenchées de façon asynchrone). Jusqu'ici chaque outil (GitHub Copilot, Codeium, Supermaven) implémentait son propre protocole propriétaire. D'autres ajouts en 3.18 : - Formatting de plusieurs ranges en une seule requête - Snippets dans les `WorkspaceEdit` - Refresh des folding ranges - Support des `activeParameter = null` dans signature help ### 2025 — L'ère de l'édition agentique L'adoption de 3.18 est généralisée, mais l'essentiel des évolutions n'est plus dans la spec LSP elle-même — c'est l'**écosystème autour** qui se transforme. **MCP (Model Context Protocol)** — Anthropic publie en novembre 2024 un protocole complémentaire à LSP : là où LSP connecte un éditeur à un analyseur de code, MCP connecte un modèle IA à des outils (fichiers, bases de données, APIs). Les deux coexistent. **Édition agentique** — VS Code Copilot Edits, Cursor Composer, Zed AI : les assistants ne se contentent plus de compléter une ligne, ils éditent plusieurs fichiers à la fois. Le protocole `workspace/applyEdit` et les `WorkspaceEdit` de LSP deviennent les primitives que les agents utilisent pour proposer des modifications. **L'écosystème Python bouge** — Astral annonce **ty**, un type checker Python écrit en Rust, conçu pour être 10-100× plus rapide que pyright sur les gros projets. Les premières versions publiques sortent mi-2025. **Vers 3.19** — des propositions circulent autour de : - Meilleure gestion des agents (annulation par batch, streaming des edits) - Support structuré des diagnostics liés à l'IA - Server-to-server communication (toujours en discussion) ### Ce qui n'a pas encore de standard Malgré 8 ans d'évolution, plusieurs besoins restent sans réponse dans la spec : - **Cache persistant entre sessions** — chaque serveur réinvente le sien - **Communication server-to-server** — toujours pas de mécanisme - **Refactoring sémantique complexe** (extract method, move file) — toujours ad-hoc via `codeAction` - **Transport binaire** (MessagePack, protobuf) — discuté, jamais formalisé - **Remote development natif** — toujours résolu côté éditeur, pas dans la spec --- ## 3. C'est quoi un language server ? Un language server est un **programme autonome** qui comprend un langage de programmation de façon **sémantique** — et qui répond aux questions d'un éditeur sur le code en temps réel. ### Syntaxe vs sémantique C'est la distinction clé. Deux niveaux de compréhension du code : | | Syntaxe | Sémantique | |--|---------|------------| | **Question** | Ce code est-il bien formé ? | Que signifie ce code ? | | **Exemple** | `def foo():` est du Python valide | `foo` retourne un `str`, est défini ligne 42 | | **Outils** | treesitter, regex, coloration | language server | | **Portée** | un fichier | toute la codebase | La coloration syntaxique n'a pas besoin de comprendre ce que fait le code — elle reconnaît des patterns. Le language server, lui, doit savoir que `x` dans `return x` est le même `x` que dans le paramètre de la fonction, que ce paramètre est de type `str`, et que la fonction est appelée à trois endroits. ### Ce qu'un language server contient Pour répondre à ces questions, un serveur maintient en mémoire : - **Un parser** : transforme le texte en AST (arbre syntaxique) - **Un binder** : résout les noms, construit les scopes - **Un type checker / inférence** : détermine le type de chaque expression - **Un index** : position de chaque symbole dans toute la codebase C'est pour ça qu'ils sont lourds à démarrer — ils doivent indexer le projet entier avant de pouvoir répondre. ### Ce que ce n'est pas - **Pas un interpréteur** : il n'exécute pas le code - **Pas un compilateur** : il ne produit pas d'exécutable - **Pas un linter CLI** : il ne tourne pas à la demande, il reste en mémoire et répond en continu ### L'analogie C'est un **compilateur en attente**. Un compilateur lit du code, l'analyse entièrement, produit un binaire et s'arrête. Un language server fait la même analyse, mais reste en vie, répond aux questions, et met à jour son modèle à chaque frappe. --- ## 4. Comment ça fonctionne ### Transport LSP repose sur **JSON-RPC 2.0**. Le serveur est un process séparé, lancé par l'éditeur, qui communique via : - `stdin/stdout` (le plus courant — simple, pas de port à gérer) - TCP / WebSocket (pour les cas distribués, ex: serveur distant) ``` ┌─────────────────┐ JSON-RPC ┌──────────────────────┐ │ │ ──── request ────► │ │ │ Éditeur/Client │ │ Language Server │ │ (VS Code, vim) │ ◄─── response ──── │ (pyright, pylsp...) │ │ │ ◄─ notification ── │ │ └─────────────────┘ └──────────────────────┘ (client LSP) stdin/stdout (server LSP) ``` Chaque message a un header HTTP-like suivi d'un body JSON : ``` Content-Length: 151\r\n \r\n { "jsonrpc": "2.0", "id": 1, "method": "textDocument/hover", "params": { "textDocument": { "uri": "file:///project/main.py" }, "position": { "line": 4, "character": 12 } } } ``` Le `Content-Length` est critique : sans framing, JSON-RPC sur un stream n'a pas de délimiteur naturel. ### Le handshake : capabilities Au démarrage, client et serveur **négocient leurs capacités**. Ni l'un ni l'autre n'est obligé de tout supporter — un serveur minimal peut ne répondre qu'au hover. ``` client ──► initialize ──────────────────────────────► server (rootUri, clientCapabilities) client ◄── response ◄───────────────────────────────── server (serverCapabilities) client ──► initialized (notification) ───────────────► server (le serveur peut maintenant traiter des requêtes) ``` Exemple de `serverCapabilities` renvoyé par pyright : ```json { "hoverProvider": true, "completionProvider": { "triggerCharacters": [".", "\"", "'"] }, "definitionProvider": true, "referencesProvider": true, "textDocumentSync": { "openClose": true, "change": 2 } } ``` `"change": 2` signifie incremental sync. `"change": 1` serait full sync. ### Les 3 types de messages | Type | Direction | Description | |------|-----------|---------| | **Request** | client → server | Attend une réponse (`id` présent) | | **Response** | server → client | Associée à un `id` de request | | **Notification** | les deux | Fire-and-forget, pas de réponse (`id` absent) | Les diagnostics (erreurs, warnings) sont envoyés par le serveur comme **notifications** — il ne répond pas à une question, il pousse l'information quand il est prêt. ### Document sync Le serveur ne lit pas les fichiers sur le disque — l'éditeur lui envoie le contenu en mémoire, y compris les modifications non sauvegardées. ``` client ──► textDocument/didOpen ───► server (à l'ouverture du fichier) client ──► textDocument/didChange ──► server (à chaque modification) client ──► textDocument/didClose ───► server (à la fermeture) ``` Deux modes de sync : - **Full** : envoie le fichier entier à chaque frappe. Simple à implémenter, coûteux sur les gros fichiers. - **Incremental** : envoie uniquement le delta — la range modifiée et le nouveau texte. ```json { "contentChanges": [{ "range": { "start": { "line": 4, "character": 0 }, "end": { "line": 4, "character": 6 } }, "text": "return" }] } ``` Le serveur maintient son propre état du document en mémoire et applique les deltas. ### Exemple bout-en-bout : un hover Tu survoles `os.path.join` dans VS Code. Voici ce qui se passe : ``` 1. Tu positionnes le curseur sur "join" 2. VS Code envoie : textDocument/hover { uri, position: {line:12, character:8} } 3. Pyright reçoit la requête : - consulte son AST en mémoire pour ce fichier - résout le symbole à cette position → os.path.join - cherche la signature et la docstring dans ses stubs typeshed 4. Pyright répond : { "contents": { "kind": "markdown", "value": "```python\ndef join(__a: str, *paths: str) -> str\n```\nJoin path components..." }, "range": { "start": {...}, "end": {...} } } 5. VS Code affiche le tooltip ``` Tout ça en quelques millisecondes — ou pas, selon la complexité de l'analyse. ### Les features principales | Méthode | Déclencheur | Ce que fait le serveur | |---------|-------------|------------------------| | `textDocument/hover` | survol curseur | retourne type + docstring | | `textDocument/completion` | frappe / raccourci | retourne liste de suggestions | | `textDocument/publishDiagnostics` | après didChange | pousse erreurs et warnings | | `textDocument/definition` | go-to-def | retourne position de la définition | | `textDocument/references` | find references | retourne toutes les utilisations | | `textDocument/codeAction` | ampoule / quick fix | retourne des actions applicables | | `textDocument/formatting` | format document | retourne les edits à appliquer | ### Pourquoi la perf peut poser problème LSP est **async par design**, mais plusieurs facteurs peuvent dégrader l'expérience : **Requêtes qui s'accumulent** Chaque frappe peut déclencher simultanément un `didChange`, une `completion`, un `hover`. Si le serveur est lent, les requêtes s'empilent. Certains clients annulent les requêtes obsolètes (`$/cancelRequest`), d'autres non. **Document sync coûteux** Même en mode incremental, le serveur doit recalculer son AST après chaque delta. Sur un fichier de 5000 lignes avec des types complexes, ça coûte cher à chaque frappe. **Traitement séquentiel** Beaucoup de serveurs traitent les requêtes dans un seul thread. Une requête lente (ex: `references` sur un gros projet) bloque toutes les suivantes — y compris les completions qui devraient être instantanées. **Pas de backpressure** Le protocole n'a pas de mécanisme natif pour que le serveur dise "ralentis, je suis saturé". Le client continue d'envoyer. **Comment les bons serveurs s'en sortent** - **Calcul incrémental** : ne recalculer que ce qui a changé. rust-analyzer utilise [salsa](https://github.com/salsa-rs/salsa), un framework de memoization incrémentale. Pyright a son propre système similaire. - **Cancellation** : supporter `$/cancelRequest` pour abandonner les requêtes obsolètes avant de les traiter. - **Priorisation** : les completions et hover passent avant les find-references. - **Parallel workers** : traiter certaines requêtes en parallèle (ex: diagnostics en background). --- ## 4bis. Les limites qui ont émergé avec le temps LSP a résolu un vrai problème — mais au fil des années, des frictions structurelles sont apparues. Ce ne sont pas des bugs, ce sont des conséquences des choix de design initiaux. ### 1. JSON-RPC : l'overhead de sérialisation Tout le protocole repose sur du JSON texte. Chaque frappe peut déclencher plusieurs requêtes (completion, hover, didChange), chacune sérialisée, envoyée sur stdin/stdout, puis désérialisée de l'autre côté. Sur un fichier ordinaire c'est négligeable. Sur un fichier de 10 000 lignes avec des types complexes et des completions fréquentes, la sérialisation JSON devient une part non nulle de la latence. Des formats binaires (MessagePack, protobuf) auraient été plus efficaces — mais JSON a gagné pour sa lisibilité et sa simplicité d'implémentation. C'est un compromis conscient qui pèse aujourd'hui. ### 2. Les serveurs ne se parlent pas Quand basedpyright, ruff et pylsp tournent en parallèle sur le même fichier, chacun a sa propre copie de l'AST en mémoire, son propre état du document, sa propre vue de la codebase. Ils ne partagent rien et ne peuvent pas se coordonner. Conséquence concrète : un code action de pylsp ne peut pas tenir compte du diagnostic de basedpyright sur la même ligne. Un fix automatique de ruff ne sait pas qu'il va casser un type que basedpyright était en train d'analyser. Chaque serveur est une île. Le protocole n'a aucun mécanisme de communication server-to-server. ### 3. Cold start et absence de cache standardisé À chaque ouverture d'éditeur, le serveur repart de zéro : parse tous les fichiers, reconstruit les graphes de dépendances, réindexe les stubs. Sur un projet de taille moyenne, ça peut prendre plusieurs secondes avant que les premières completions apparaissent. rust-analyzer et pyright ont tous deux implémenté des caches persistants sur disque de leur côté — mais c'est du code custom, hors-spec. Le protocole LSP n'a aucune notion de cache entre sessions. Chaque implémentation doit réinventer ça seule. ### 4. Mémoire : plusieurs processus, plusieurs AST Trois serveurs actifs = trois processus séparés = l'AST du projet chargé trois fois en RAM. Sur un gros monorepo avec une stack basedpyright + ruff + pylsp, la consommation mémoire peut dépasser 1-2 Go. La spec ne prévoit aucun mécanisme de partage d'état ou de mutualisation entre serveurs. C'est un coût structurel du modèle "un process par serveur". ### 5. Refactoring : non standardisé `textDocument/rename` permet de renommer un symbole — c'est à peu près tout ce que la spec garantit. Les opérations de refactoring plus complexes (extraire une méthode, inliner une variable, déplacer un fichier et mettre à jour tous les imports) sont implémentées via `codeAction` de façon ad-hoc, avec des formats propriétaires à chaque serveur. Il n'y a pas de standard LSP pour "refactoring". Un outil qui voudrait orchestrer des refactorings multi-serveurs (ex: renommer un symbole ET mettre à jour la doc) doit bricoler. C'est une des raisons pour lesquelles les IDE comme PyCharm, qui ont tout in-process, restent supérieurs pour les refactorings complexes. ### 6. Remote development : hors-spec Le transport stdin/stdout suppose implicitement que le serveur tourne sur la même machine que l'éditeur. C'est une hypothèse qui s'effondre dès qu'on travaille avec WSL, Docker, un serveur distant via SSH, ou un environnement cloud. VS Code a dû inventer une couche entière (les extensions Remote Development) pour faire tourner le serveur LSP du côté distant et forwarder le protocole. C'est une solution propriétaire, non standardisée, qui ne fonctionne qu'avec VS Code. Les autres éditeurs (Neovim, Emacs) ont leurs propres approches, toutes différentes. ### 7. Qualité d'implémentation très inégale La spec LSP définit le protocole, pas la qualité des implémentations. Il n'existe pas de test suite officielle de conformité. Résultat : deux serveurs qui déclarent tous les deux supporter `textDocument/codeAction` peuvent se comporter très différemment sur les cas limites. Les clients doivent souvent ajouter des workarounds spécifiques à chaque serveur. ### 8. Les notebooks et le contenu non-linéaire LSP est fondamentalement conçu pour des fichiers texte linéaires. Les notebooks Jupyter ont un modèle cellule-par-cellule où chaque cellule est un fragment de code exécuté dans un state partagé — ce qui ne mappe pas naturellement sur un unique `textDocument`. Les solutions existantes (Pylance dans VS Code, jupyter-lsp) maintiennent un "virtual document" qui concatène toutes les cellules et fait correspondre les positions. C'est un hack fonctionnel, mais qui introduit des décalages de positions et des artifacts. La spec LSP n'a pas de concept natif de "document fragmenté". --- ## 5. Zoom : basedpyright sous le capot basedpyright est un fork communautaire de pyright (Microsoft). Même cœur, mais des différences importantes côté LSP. On peut l'utiliser comme fil rouge pour rendre concrets tous les concepts de la section 3. ### Capabilities déclarées Voici ce que basedpyright envoie dans sa réponse `initialize` : ```json { "textDocumentSync": { "openClose": true, "change": 2, "willSave": true }, "hoverProvider": true, "completionProvider": { "triggerCharacters": [".", "\"", "'"], "resolveProvider": true }, "signatureHelpProvider": { "triggerCharacters": ["(", ","] }, "definitionProvider": true, "typeDefinitionProvider": true, "implementationProvider": true, "referencesProvider": true, "renameProvider": true, "codeActionProvider": true, "workspaceSymbolProvider": true, "semanticTokensProvider": { "full": true }, "inlayHintProvider": true, "diagnosticProvider": true } ``` `"change": 2` = incremental sync. `semanticTokensProvider` et `inlayHintProvider` sont absents de pyright — c'est basedpyright qui les réimplémente en open source (features qui n'existaient que dans Pylance, l'extension closed-source de Microsoft). ### Comment il gère le document sync basedpyright ne lit jamais les fichiers sur le disque pendant une session — il vit uniquement sur les deltas envoyés par le client via `didChange`. En interne, il maintient un objet `Program` qui contient : - La liste de tous les fichiers trackés (`_sourceFileList`) - Un index URI → fichier (`_sourceFileMap`) - Le **graphe de dépendances** entre fichiers (qui importe qui) Quand un `didChange` arrive : 1. Le delta est appliqué au document en mémoire 2. Le parse tree du fichier est **invalidé** 3. Tous les fichiers qui importent ce fichier sont aussi marqués comme stale 4. La re-analyse est **lazy** — elle ne se déclenche que quand une requête (hover, completion) en a besoin ### Pipeline d'analyse en 4 étapes ``` Source text │ ▼ 1. Tokenizer — lexing │ ▼ 2. Parser — AST │ ▼ 3. Binder — résolution des symboles, scopes → "bind diagnostics" │ ▼ 4. TypeEvaluator — inférence de types, lazy, à la demande + Checker — validation sémantique → "type diagnostics" ``` L'étape 4 est **lazy** : quand tu survoles `os.path.join`, basedpyright n'évalue pas les types de tout le fichier — il descend récursivement depuis ce nœud jusqu'à avoir assez d'information. ### Comment il gère les problèmes de perf **Worker threads** L'analyse lourde tourne dans des **worker threads Node.js** séparés du thread principal. Le thread principal reste disponible pour répondre aux requêtes interactives (hover, completion) pendant que les workers font l'analyse en fond. ``` Main thread Worker thread ────────── ───────────── LSP requests ←→ Background analysis hover Type checking completion Diagnostics ``` **Calcul incrémental via le graphe de dépendances** Quand `models.py` est modifié, seuls `services.py` et `api.py` (qui l'importent) sont re-analysés — pas l'ensemble du workspace. Cela donne 3 à 5x plus de rapidité sur des gros projets par rapport à une re-analyse complète. **Cancellation** Chaque handler reçoit un `CancellationToken`. À chaque étape coûteuse, le code vérifie `token.isCancellationRequested` et s'arrête si le client a envoyé `$/cancelRequest`. Une requête annulée renvoie quand même une réponse avec le code d'erreur `RequestCancelled` — c'est une exigence JSON-RPC. **Priorisation implicite** Il n'y a pas de queue explicite avec priorités — c'est architectural : - Hover et completion : traités dans le thread principal, immédiatement - Diagnostics : poussés dans les workers, n'impactent pas la latence interactive - `diagnosticMode: "openFilesOnly"` (défaut dans basedpyright) : n'analyse que les fichiers ouverts, pas le workspace entier **Pull diagnostics (LSP 3.17+)** En plus du modèle push classique (`publishDiagnostics`), basedpyright supporte le modèle pull : le client demande les diagnostics quand il est prêt via `textDocument/diagnostic`. Le client garde ainsi le contrôle sur la fréquence des mises à jour. ### File watching : un cas concret de bug de perf basedpyright enregistre dynamiquement des watchers pour : - `**/*.py`, `**/*.pyi` — fichiers source et stubs - `**/pyrightconfig.json`, `**/pyproject.toml` — config - `**` — tout le reste (imports externes, venvs...) Problème connu dans pyright : il envoie la registration de watcher deux fois avec des IDs différents, créant des descripteurs de fichiers dupliqués. Sur macOS (`kqueue`), ça peut épuiser les file descriptors et bloquer l'initialisation. basedpyright corrige ça en rajoutant un **fallback côté serveur** si le client ne supporte pas le dynamic registration — le serveur fait lui-même le polling système plutôt que de compter sur le client. ### Ce que basedpyright ajoute sur pyright côté LSP | Feature | pyright | basedpyright | |---------|---------|--------------| | Semantic tokens | Non | Oui | | Inlay hints | Non | Oui | | File watcher fallback | Non | Oui | | `serverInfo` dans initialize | Absent (bug) | Présent | | Docstrings multi-plateforme | Non (runtime scraping) | Oui (bundlées) | | Pull diagnostics | Oui | Oui | | Background worker threads | Oui | Oui | --- ## 4. L'écosystème Python ### Vue d'ensemble | Serveur | Rôle principal | Langage | Licence | Extensible ? | |---------|---------------|---------|---------|--------------| | **pyright** | Type checking statique | TypeScript | MIT | Non | | **basedpyright** | Fork de pyright + features Pylance | TypeScript | MIT | Non | | **pylance** | Pyright + extensions Microsoft | TypeScript | Closed (VS Code only) | Non | | **ty** | Type checker nouvelle génération (Astral) | Rust | MIT | Non (pas encore) | | **zuban** | Type checker rapide par l'auteur de Jedi | Rust | MIT | Non | > **Note** : `zuban` est écrit par David Halter, l'auteur de Jedi — la lib citée en section 1 comme exemple typique du chaos pré-LSP. La boucle est bouclée. | **pylsp** | LSP généraliste Python | Python | MIT | **Oui** — via `pluggy` | | **ruff** (`ruff server`) | Linting / formatting ultra-rapide | Rust | MIT | Non (règles built-in) | | **jedi-language-server** | Wrapper LSP autour de Jedi | Python | MIT | Non | ### Spécialisation, pas concurrence Chaque serveur résout un problème différent : - **Type checking** : pyright, basedpyright, ty, zuban - **Linting / formatting** : ruff - **Refactoring / completions classiques** : pylsp, jedi-language-server - **Tout-en-un Microsoft** : pylance Ce ne sont pas des alternatives parfaites — c'est plutôt un buffet. Une stack typique combine **plusieurs serveurs en parallèle** : ``` Neovim / VS Code │ ├──► basedpyright (types, hover, goto) ├──► ruff server (lint, format, fix) └──► pylsp (règles custom via plugins) ``` L'éditeur reçoit les diagnostics des trois en même temps et les affiche côte à côte. ### Le cas pylsp : pourquoi il reste pertinent Sur le papier, pylsp paraît dépassé par pyright (plus rapide, types plus précis) et ruff (lint plus rapide). Mais il a un avantage que personne d'autre n'a : **un système de plugins**. pylsp utilise [`pluggy`](https://pluggy.readthedocs.io/) — le même framework de plugins que pytest. N'importe quel package Python peut s'enregistrer comme plugin et ajouter : - Des diagnostics custom (`pylsp_lint`) - Des completions (`pylsp_completions`) - Des code actions (`pylsp_code_actions`) - Du hover (`pylsp_hover`) - Etc. Quelques plugins notables : - `pylsp-mypy` — intègre mypy comme source de diagnostics - `pylsp-rope` — refactoring avancé via Rope - `python-lsp-ruff` — ruff comme plugin (alternative à ruff server) - `pylsp-inlay-hints` — inlay hints custom ### Comment l'éditeur orchestre plusieurs serveurs Côté éditeur, chaque serveur LSP tourne dans son propre process. L'éditeur : - Envoie `didChange` à **tous les serveurs** branchés sur le fichier - Agrège les diagnostics de tous les serveurs (chacun publie sous son namespace) - Pour les requêtes "réponse unique" (goto-definition, hover) : choisit selon une priorité configurable, ou affiche les réponses combinées Exemple de config Neovim : ```lua require('lspconfig').basedpyright.setup{} require('lspconfig').ruff.setup{} require('lspconfig').pylsp.setup{ settings = { pylsp = { plugins = { -- désactive ce que les autres serveurs font déjà mieux pycodestyle = { enabled = false }, pyflakes = { enabled = false }, -- ne garde que les plugins custom my_company_rules = { enabled = true }, } } } } ``` C'est le pattern qui permet la démo : basedpyright continue de faire son travail, et on greffe nos règles métier dans pylsp sans toucher au reste. --- ## 5. Démo : un plugin pylsp custom ### L'idée basedpyright fait le type checking, ruff fait le lint. Mais ils ne connaissent pas **nos règles métier**. Exemple concret : dans notre codebase, on veut signaler tout usage direct de `datetime.now()` parce qu'on a une fonction interne `clock.now()` qui gère le fuseau horaire et les mocks de tests. Un nouveau dev qui arrive ne le sait pas — un diagnostic dans son éditeur, c'est mieux qu'un commentaire en review. C'est exactement le genre de chose qu'on ne peut pas demander à pyright. Mais en 30 lignes de Python on l'ajoute à pylsp. ### Anatomie d'un plugin pylsp Un plugin pylsp est un **package Python** comme un autre, qui : 1. Expose des fonctions décorées avec `@hookimpl` 2. S'enregistre via un entry point dans `pyproject.toml` 3. Est découvert automatiquement par `pluggy` au démarrage de pylsp ``` my_lsp_plugin/ ├── pyproject.toml └── my_lsp_plugin/ └── __init__.py ← les hooks ``` ### Le code minimum ```python # my_lsp_plugin/__init__.py import ast from pylsp import hookimpl @hookimpl def pylsp_lint(workspace, document): diagnostics = [] try: tree = ast.parse(document.source) except SyntaxError: return diagnostics for node in ast.walk(tree): if ( isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "now" and isinstance(node.func.value, ast.Name) and node.func.value.id == "datetime" ): diagnostics.append({ "source": "company-rules", "range": { "start": {"line": node.lineno - 1, "character": node.col_offset}, "end": {"line": node.end_lineno - 1, "character": node.end_col_offset}, }, "message": "Utiliser clock.now() au lieu de datetime.now()", "severity": 2, # 1=Error, 2=Warning, 3=Info, 4=Hint }) return diagnostics ``` ### L'enregistrement via entry point ```toml # pyproject.toml [project] name = "my-lsp-plugin" version = "0.1.0" dependencies = ["python-lsp-server"] [project.entry-points."pylsp"] my_rules = "my_lsp_plugin" ``` Le groupe `pylsp` est inspecté par pluggy au démarrage du serveur. `pip install -e .` suffit pour que pylsp découvre le plugin. ### Aller plus loin : code actions Détecter, c'est bien. Proposer un fix automatique, c'est mieux. On ajoute un second hook : ```python @hookimpl def pylsp_code_actions(config, workspace, document, range, context): actions = [] for diag in context.get("diagnostics", []): if diag.get("source") == "company-rules": actions.append({ "title": "Remplacer par clock.now()", "kind": "quickfix", "diagnostics": [diag], "edit": { "changes": { document.uri: [{ "range": diag["range"], "newText": "clock.now()", }] } }, }) return actions ``` L'éditeur affiche maintenant une ampoule à côté du diagnostic, qui propose le fix en un clic. ### Le scénario de démo 1. Ouvrir un fichier Python avec `datetime.now()` dedans 2. Montrer que basedpyright et ruff sont OK (pas d'erreur) 3. Installer le plugin → `pip install -e .` 4. Redémarrer pylsp → le warning apparaît 5. Cliquer sur l'ampoule → le code est remplacé 6. Montrer dans le panneau LSP : trois serveurs coexistent (basedpyright, ruff, pylsp) ### Pour aller plus loin - D'autres hooks utiles : `pylsp_hover`, `pylsp_completions`, `pylsp_definitions`, `pylsp_references` - Configuration par-utilisateur : lire `config.plugin_settings("my_rules")` pour activer/désactiver des règles - Test : `pylsp` peut être testé en unit en instanciant un `Workspace` et `Document` directement - Distribution interne : publier sur un PyPI privé, ou installer via git URL dans le `pyproject.toml` du projet