Waarom het bouwen van een Rust LSP lastig is

Ik ben geen matklad, maar ik werk inmiddels al een tijdje aan Rust Glancer, een experimentele Rust LSP. Het is waarschijnlijk het meest interessante en ambitieuze project waaraan ik ooit heb gewerkt, en ik wil graag enkele dingen delen die ik tijdens dit proces heb geleerd.

Dit wordt een (hopelijk samenhangend) verhaal over hoe Rust LSP's werken, vanuit het perspectief van zowel rust-analyzer als Rust Glancer: hoe dingen die eenvoudig lijken lastig blijken te zijn, dingen die lastig lijken nóg lastiger blijken, en hoe zaken die ik niet verwachtte dat ze bestonden, toch aanwezig zijn.

Natuurlijk kan één blogpost niet alles dekken. Dit is een technisch, maar architecturaal overzicht in plaats van een diepe duik in een specifiek onderwerp. Die diepe duiken komen in aparte posts, mits ik niet te lui ben.

Houd er rekening mee dat het bouwen van een LSP betekent dat je nuttige antwoorden moet genereren op basis van onvolledige informatie.

Disclaimer: Ik ben geen expert in het bouwen van LSP's. Het doel van dit bericht is om lezers geïnteresseerd te maken in de interne werking en valkuilen van LSP's, in plaats van een ondubbelzinnig en formeel ontwerpoverzicht te geven. Ik probeer bewust compiler-jargon te vermijden en gebruik op veel plaatsen benaderende formuleringen om te focussen op de algemene betekenis in plaats van op precisie.

Waar begint een LSP?

Een LSP-server heeft twee uiteinden: de server die het Language Server Protocol implementeert (het onderdeel waar requests naar worden gestuurd en waar goed gevormde responses op worden ontvangen) en de feitelijke status die we willen ontsluiten (de queries die daadwerkelijk doen wat ze moeten doen en opereren over een soort geïndexeerde status).

Het eerste deel lijkt een opgelost probleem, zeker gezien het bestaan van tower-lsp-server. Maar dat is niet helemaal waar. Laten we daar beginnen en geleidelijk overgaan naar indexering zodra dat nodig is.

De LSP begint met een initialize request van de client. Voordat de server hierop reageert, doet de client niets. Zodra de response is verzonden, stuurt de client een initialized notificatie en begint de LSP-communicatie.

Het probleem is: wanneer reageer je op dit request? Wanneer de server start, heb je niets. Je weet niets over het project, en pas wanneer je dit request ontvangt, weet je over welke codebase we het hebben. Om queries te kunnen beantwoorden, moeten we deze "indexeren".

Blokkeren we de communicatie totdat alles is geïndexeerd? Dan moeten gebruikers 10 tot 100 seconden wachten terwijl de editor vrijwel onbruikbaar is. Dat is geen optie. Beginnen we direct? Maar wat antwoorden we dan op de eerste query over het momenteel geopende bestand? Veroorzaakt dat niet gewoon een blokkade op dat punt?

Hier openbaart zich het eerste grote verschil tussen de compiler en de LSP. Een compiler heeft een binaire definitie van "klaar": het binary is gecompileerd of niet. Een LSP is anders: we kunnen bijna onmiddellijk nuttige resultaten leveren. We hebben niet in de eerste milliseconde complete informatie nodig; we moeten zo snel mogelijk iets nuttigs sturen.

Het antwoord is: we moeten de minimale hoeveelheid nuttig werk verrichten die het verwerken van queries mogelijk maakt. Zowel rust-analyzer als Rust Glancer valideren daarom enkel de verstrekte configuratie en reageren direct. rust-analyzer plant de workspace-ontdekking in na de response, terwijl Rust Glancer passief blijft tot de eerste query binnenkomt.

Zodra de handshake is voltooid, beginnen de echte queries, zoals textDocument/didOpen, textDocument/inlayHint, en textDocument/documentSymbol. Hier explodeert de complexiteit.

Drie interessante feiten over het LSP-protocol:

  1. Het bestandssysteem is onzichtbaar: Het LSP-protocol wil niet dat je nadenkt over het bestandssysteem; er zijn alleen documenten en edits. Dit is logisch, want vaak is een document niet opgeslagen. Echter, analyse van een enkel bestand in isolatie is al snel onvoldoende. Om dit op te lossen is een virtueel bestandssysteem (VFS) en "source generations" nodig (identificatoren van de status van de broncode op het moment van een request) om race-conditions te voorkomen.
  2. Parallelle verwerking: Een query zoals het zoeken naar referenties is zwaar, terwijl een hover goedkoop is. Je kunt dus niet één query per keer uitvoeren; lees-queries moeten parallel lopen. Wanneer de status verandert, moeten lopende queries die nu verouderde data gebruiken, worden geannuleerd.
  3. Refresh-mechanismen: De auteurs van het protocol hebben instrumenten toegevoegd zoals workspace/inlayHint/refresh. Hiermee kan de server zeggen: "Oeps, probeer het nu opnieuw", en een actueel antwoord sturen nadat de initiële respons leeg was. Let wel: niet alles kan worden ververst (zoals document symbolen).

De server in actie

Om document symbolen te beantwoorden, hoeven we in feite maar één ding te indexeren: het momenteel geopende bestand. Dit is een perfect voorbeeld van hoe een LSP snel nuttig kan zijn. Je hoeft het bestand alleen maar te parsen; een AST (of eigenlijk een CST) vertelt je al welke structuren, traits en functies aanwezig zijn.

Een textDocument/hover over een type in fn foo(a: Bar) {} is lastiger. Dit vereist semantische analyse. Je moet weten waar de entiteit onder de cursor vandaan komt. Hiervoor zijn definition maps nodig: opgeloste kaarten van "wat is waar zichtbaar" voor elke crate en module.

Het proces ziet er ongeveer zo uit: Parse bestand → Bouw item-tree vanuit AST/CST → Los modules op en bouw definition map → Controleer wat zichtbaar is onder de cursor → Vind dit via defmaps → Extraheer documentatie → Toon resultaat.

Inlay hints zijn nog complexer omdat ze binnen de bodies van functies verschijnen. Voor goede inlay hints is type-inferentie nodig.

De "eindbaas" van de LSP is echter textDocument/references. Wanneer een gebruiker op een functiedefinitie klikt, moet de server alle gebruiksplekken in de gehele workspace-graph vinden. Bij een veelgebruikte term als Option moet de server door duizenden bodies gaan en precies dit specifieke Option onderscheiden van andere items met dezelfde naam. Hiervoor wordt vaak een "reference search plan" gebruikt via tekstmatching om het aantal te scannen bestanden te beperken.

Verschillen in implementatie: rust-analyzer vs. Rust Glancer

Beide tools gebruiken lagen zoals parsing, item trees, defmaps en semantische lagen, maar ze berekenen deze data anders:

  • rust-analyzer gebruikt Salsa, een incrementele database. Inputs en logica worden gedefinieerd, en outputs worden lui berekend en gememoiseerd. Er is strikt genomen geen "indexering"; alles wordt berekend wanneer het wordt gevraagd. Om lag te voorkomen, gebruikt rust-analyzer cache priming, waarbij de workspace tot aan de semantische laag preventief wordt geïndexeerd.
  • Rust Glancer focust op laag RAM-gebruik en instant herstarts. Het probeert zo veel mogelijk werk vooraf te doen (eager indexing) en de status naar het bestandssysteem te schrijven. Bij een herstart is de indexering bijna direct klaar omdat de status al op schijf staat.

Eén workspace, twee workspaces

Een geopende map is niet altijd één enkele Rust-workspace. Het kan vijf mappen bevatten waarvan er drie workspaces zijn, of het kan een enkele crate zijn zonder workspace-structuur.

Hoe bepaalt een LSP wat het project is? Dit hangt af van de client en de implementatie. rust-analyzer is zeer proactief: het scant recursief naar Cargo.toml bestanden en probeert zelfs buiten de projectmap te kijken om de gebruiker te helpen.

Rust Glancer is restrictiever: het vereist een Cargo.toml in de scope en begint pas met indexeren wanneer een workspace daadwerkelijk wordt geopend.

Een ander probleem is wanneer één workspace wel correct is gevormd en een andere niet. In rust-analyzer kan een fout in één workspace de hele server in een foutstatus brengen. Rust Glancer hanteert een router-model waarbij elke workspace een apart proces (engine) is. Een crash in één editor/workspace betekent dus geen globale crash, wat ook helpt bij het verminderen van geheugenfragmentatie.

Wanneer een compiler niet genoeg is

Het LSP-protocol is geoptimaliseerd voor editors. Het spreekt in regels en kolommen, en vereist dat de server UTF-16 begrijpt. Omdat werken met UTF-16 en kolommen onhandig is, bouwen servers vaak een brug naar offsets en UTF-8, inclusief line-indexes voor efficiënte conversie.

Daarnaast moet een LSP omgaan met metadata zoals regelafbrekingen (\r\n vs \n) en de Rust-editie (om te weten of gen een identifier of een keyword is).

De uitdaging van indexering

Indexering is het moeilijkste onderdeel en verloopt doorgaans in deze fasen:

  1. Parsing: Tekst omzetten naar een CST-representatie.
  2. Item tree building: Extractie van items (structs, visibiliteit, etc.) uit de CST.
  3. Definition map building: Bepalen welke modules bestaan en wat ze exporteren.
  4. Macro resolution: Macro's expanderen, wat weer invloed heeft op de defmaps.
  5. Item index building: Het linken van items (bijv. welke impl hoort bij welke struct).
  6. Body resolution: Het analyseren van de inhoud van functies, inclusief type-inferentie en trait-solving.

Dit proces is niet lineair maar iteratief ("fixed loops"), omdat informatie uit een latere fase (zoals macro-expansie) weer invloed heeft op eerdere fasen (zoals de defmap).

Omgaan met foutieve code

Een compiler is strikt: als de code incorrect is, stopt hij. Een LSP kan dat niet. Code in een IDE is bijna altijd tijdelijk incorrect omdat de gebruiker midden in het typen zit.

De parser moet dus altijd slagen, ongeacht wat de gebruiker typt. De LSP moet "gissen" naar de intentie. Als er fn fo aan het einde van een bestand staat, moet de server begrijpen dat de gebruiker waarschijnlijk een functie wil declareren en daar passende snippets voor aanbieden.

De Cursor: De god van de LSP

Alles in de editor draait om de cursor. De vraag is: hoe ga je van een positie in het bestand naar een specifiek semantisch element?

  • rust-analyzer zoekt naar bronnen op basis van syntax-nodes die matchen met het semantische element.
  • Rust Glancer gebruikt een span-based approach. Omdat Rust Glancer volledige semantische analyse op schijf heeft, kan het effectief filteren op bestanden en bodies die de cursorpositie raken om het meest precieze symbool te vinden.

Bij completions (aanvullingen) gaat het niet alleen om wat er onder de cursor staat, maar wat er omheen staat:

  • Staat de cursor na een punt? → Zoek methoden voor dit type.
  • Staat de cursor na ::? → Zoek geassocieerde items of paden.
  • Staat de cursor in een struct-initializer User { na$ }? → Zoek velden van de struct.

Uiteindelijk is het bouwen van een LSP een spel van het gissen van de intentie van de gebruiker op basis van een potentieel incorrecte status. Dat is lastig, maar ook erg leuk.