Construire un serveur LSP minimaliste pour enrichir votre éditeur
Le Language Server Protocol, ou LSP, est devenu une pierre angulaire de l'écosystème moderne des éditeurs de code. Créé à l'origine par Microsoft pour standardifier la façon dont les fonctionnalités avancées (auto-complétion, diagnostics, navigation) sont exposées aux éditeurs, ce protocole permet aujourd'hui à des outils comme VS Code, Neovim ou Eclipse Theia de partager une même intelligence de langage sans réécriture.
Construire son propre serveur LSP représente un excellent exercice pour comprendre comment fonctionne l'intelligence derrière votre éditeur quotidien. En quelques centaines de lignes, on peut doter un langage fictif, ou même un sous-ensemble de JavaScript ou Python, de la coloration sémantique, du formatage à la volée et du survol de documentation.
Ce guide propose de créer un serveur minimal en Node.js, capable de dialoguer avec un client compatible LSP via JSON-RPC. Nous verrons comment initialiser le protocole, déclarer les capacités prises en charge, et répondre aux principales requêtes émises par l'éditeur.
L'objectif n'est pas de rivaliser avec typescript-language-server ou pyright, mais bien de saisir la mécanique interne : la sérialisation des messages, la gestion asynchrone des réponses, et le couplage entre la logique métier du langage et la couche de transport.
Architecture générale du protocole
Le LSP repose sur un modèle client-serveur où l'éditeur agit en tant que client qui initie la connexion, et le serveur de langage répond aux requêtes tout en émettant des notifications. Les deux parties communiquent via des messages JSON conformes à la spécification JSON-RPC 2.0, échangés soit par stdin/stdout, soit via un socket TCP selon les besoins de déploiement.
Chaque message JSON-RPC possède une structure précise : un champ jsonrpc à "2.0", un identifiant pour les requêtes attendues, une méthode sous forme de chaîne et, selon le cas, des paramètres et un résultat. Les notifications, dépourvues d'identifiant, servent aux événements unidirectionnels comme l'ouverture d'un fichier ou les changements de diagnostic.
Cette séparation stricte entre transport, format de message et logique métier permet à un même serveur de dialoguer avec plusieurs éditeurs sans adaptation. Le serveur ignore tout du client ; il ne connaît que les méthodes LSP standardisées et les capacités qu'il expose.
| Méthode | Direction | Type | Rôle |
|---|---|---|---|
initialize |
Client → Serveur | Requête | Établir la session et négocier les capacités |
textDocument/didOpen |
Client → Serveur | Notification | Signaler l'ouverture d'un document |
textDocument/completion |
Client → Serveur | Requête | Demander des propositions d'auto-complétion |
textDocument/publishDiagnostics |
Serveur → Client | Notification | Pousser les erreurs et avertissements |
shutdown |
Client → Serveur | Requête | Terminer proprement la session |
Préparer l'environnement et les dépendances
Pour démarrer, un simple npm init -y suivi de l'installation de quelques bibliothèques suffit. vscode-languageserver fourni par Microsoft offre des types TypeScript fidèles à la spécification, ce qui évite de redéfinir manuellement chaque interface de message.
Côté organisation, séparer le code en modules facilite la maintenance : un fichier pour la gestion du transport, un autre pour les capacités, et un dernier pour les handlers de fonctionnalités. Cette découpe reflète la séparation des préoccupations prônée par la spécification.
Un bon point de départ consiste à créer un fichier server.js qui lit les messages entrants depuis process.stdin et formate les réponses vers process.stdout. Le décodage des en-têtes de type Content-Length est essentiel puisque chaque message JSON-RPC est préfixé par sa taille en octets.
Déclarer les capacités du serveur
Lors de la phase d'initialisation, le client envoie ses capacités tandis que le serveur répond avec les siennes. Cette négociation permet à chaque partie de savoir précisément quelles méthodes elle peut invoquer et lesquelles elle doit ignorer sans erreur.
Les capacités côté serveur couvrent généralement la complétion, la définition (aller à la définition), les références, le hover, le formatage de document et les diagnostics. Chacune se déclare sous forme de propriété booléenne ou d'objet détaillant les options retenues.
Une stratégie pragmatique consiste à activer uniquement les capacités que l'on implémente réellement, afin d'éviter que le client n'envoie des requêtes qui resteraient sans réponse et provoqueraient des délais d'attente visibles dans l'interface.
Gérer la communication JSON-RPC
Le cœur technique d'un serveur LSP réside dans sa boucle de lecture. Chaque message reçu commence par un en-tête Content-Length: <nombre> suivi d'une ligne vide, puis du payload JSON. Le serveur doit accumuler les fragments jusqu'à disposer du nombre exact d'octets promis avant de tenter un JSON.parse.
Une fois le message interprété, le serveur distingue trois cas : les requêtes (avec id), les notifications (sans id mais avec method), et les réponses (contenant id et result ou error). Cette discrimination conditionne la façon dont on construit la réponse sortante.
Pour les requêtes, on encapsule le résultat dans { jsonrpc: "2.0", id, result }. Pour les erreurs, on utilise le champ error avec un code standardisé. Les notifications, elles, n'attendent aucune réponse et partent simplement vers la sortie standard.
Implémenter les fonctionnalités essentielles
La complétion représente souvent la première fonctionnalité implémentée par un débutant. Elle consiste à retourner un tableau d'items, chacun avec un label, un kind (mot-clé, fonction, variable) et, facultativement, une documentation. Le client se charge ensuite de les afficher dans la liste déroulante.
Les diagnostics transforment le serveur en outil d'analyse statique léger. En parcourant le texte reçu, on peut détecter des motifs simples : variables non déclarées, parenthèses non fermées, ou même des fuites de mémoire potentielles, à l'image des approches évoquées dans ce guide dédié à JavaScript.
Le hover et la définition complètent l'expérience. Le premier fournit une documentation contextuelle au survol d'un symbole, le second permet de sauter vers sa déclaration. Ces deux fonctionnalités s'appuient généralement sur une table de symboles construite lors de l'analyse du document.
Bonnes pratiques pour enrichir le serveur
- Reconstruire la table de symboles à chaque notification
didChangepour rester cohérent avec le contenu affiché. - Encapsuler les calculs coûteux dans des fonctions asynchrones afin de ne pas bloquer la boucle principale.
- Logger les messages sur stderr pour ne jamais perturber le flux JSON-RPC circulant sur stdout.
- Tester chaque méthode individuellement avec un client factice avant d'intégrer le serveur dans un éditeur réel.
Connecter le serveur à un éditeur
Une fois le serveur fonctionnel en ligne de commande, son intégration dans un éditeur se fait via un fichier de configuration. VS Code, par exemple, accepte un languageClient dans package.json qui pointe vers la commande de lancement et les extensions de fichiers concernées.
Pour Neovim, des plugins comme nvim-lspconfig permettent d'enregistrer le serveur via une simple déclaration Lua. Sublime Text et Helix proposent des mécanismes analogues, preuve que le LSP atteint désormais la majorité des éditeurs modernes.
L'ajustement final concerne les performances. Un serveur trop lent pénalise la fluidité de l'éditeur ; il convient donc de mesurer le temps de réponse moyen de chaque méthode et d'optimiser les goulets d'étranglement, qu'il s'agisse de l'analyse lexicale, de la résolution de symboles ou de la sérialisation JSON.
Pistes pour aller plus loin
- Ajouter le support du reformatage avec un formateur externe comme Prettier.
- Implémenter la recherche de références croisées entre plusieurs fichiers.
- Diffuser le serveur sous forme de paquet installable via npm ou un gestionnaire interne.
- Diffuser votre implémentation et bénéficier des retours d'expérience partagés par la communauté pour l'améliorer.
Maîtriser la construction d'un serveur LSP ouvre une porte discrète mais déterminante vers la compréhension approfondie des outils que vous utilisez chaque jour. Plutôt que de considérer l'intelligence de votre éditeur comme une boîte noire, vous voilà armé pour l'ausculter, l'étendre ou même la remplacer par votre propre vision. Lancez-vous dès maintenant : créez un dépôt, écrivez les premières lignes de votre serveur, et observez votre éditeur prendre vie sous vos doigts.