/- Copyright (c) 2020 Marc Huisinga. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Authors: Marc Huisinga, Wojciech Nawrocki -/ import Init.System.IO import Std.Data.RBMap import Lean.Environment import Lean.Server.Snapshots import Lean.Data.Lsp import Lean.Data.Json.FromToJson namespace Lean namespace Server open Lsp /-- A document editable in the sense that we track the environment and parser state after each command so that edits can be applied without recompiling code appearing earlier in the file. -/ structure EditableDocument where version : Nat text : FileMap /- The first snapshot is that after the header. -/ header : Snapshots.Snapshot /- Subsequent snapshots occur after each command. -/ -- TODO(WN): These should probably be asynchronous Tasks snapshots : List Snapshots.Snapshot namespace EditableDocument open Elab /-- Compiles the contents of a Lean file. -/ def compileDocument (version : Nat) (text : FileMap) : IO (MessageLog × EditableDocument) := do let headerSnap ← Snapshots.compileHeader text.source let (cmdSnaps, msgLog) ← Snapshots.compileCmdsAfter text.source headerSnap let docOut : EditableDocument := ⟨version, text, headerSnap, cmdSnaps⟩ pure (msgLog, docOut) /-- Given `changePos`, the UTF-8 offset of a change into the pre-change source, and the new document, updates editable doc state. -/ def updateDocument (doc : EditableDocument) (changePos : String.Pos) (newVersion : Nat) (newText : FileMap) : IO (MessageLog × EditableDocument) := do let recompileEverything := do -- TODO free compacted regions compileDocument newVersion newText /- If the change occurred before the first command or there are no commands yet, recompile everything. -/ match doc.snapshots.head? with | none => recompileEverything | some firstSnap => if firstSnap.beginPos > changePos then recompileEverything else -- NOTE(WN): endPos is greedy in that it consumes input until the next token, -- so a change on some whitespace after a command recompiles it. We could -- be more precise. let validSnaps := doc.snapshots.filter (fun snap => snap.endPos < changePos) -- The lowest-in-the-file snapshot which is still valid. let lastSnap := validSnaps.getLastD doc.header let (snaps, msgLog) ← Snapshots.compileCmdsAfter newText.source lastSnap let newDoc := { version := newVersion, header := doc.header, text := newText, snapshots := validSnaps ++ snaps : EditableDocument } pure (msgLog, newDoc) end EditableDocument open EditableDocument open IO open Std (RBMap RBMap.empty) open JsonRpc abbrev DocumentMap := RBMap DocumentUri EditableDocument (fun a b => Decidable.decide (a < b)) structure ServerContext where hIn hOut : FS.Stream openDocumentsRef : IO.Ref DocumentMap -- TODO (requestsInFlight : IO.Ref (Array (Task (Σ α, Response α)))) abbrev ServerM := ReaderT ServerContext IO def findOpenDocument (key : DocumentUri) : ServerM EditableDocument := fun st => do let openDocuments ← st.openDocumentsRef.get; match openDocuments.find? key with | some doc => pure doc | none => throw (userError $ "got unknown document URI (" ++ key ++ ")") def updateOpenDocuments (key : DocumentUri) (val : EditableDocument) : ServerM Unit := fun st => st.openDocumentsRef.modify (fun documents => documents.insert key val) def readLspMessage : ServerM JsonRpc.Message := fun st => Lsp.readLspMessage st.hIn def readLspRequestAs (expectedMethod : String) (α : Type) [FromJson α] : ServerM (Request α) := fun st => Lsp.readLspRequestAs st.hIn expectedMethod α def readLspNotificationAs (expectedMethod : String) (α : Type) [FromJson α] : ServerM α := fun st => Lsp.readLspNotificationAs st.hIn expectedMethod α def writeLspNotification {α : Type} [ToJson α] (method : String) (params : α) : ServerM Unit := fun st => Lsp.writeLspNotification st.hOut method params def writeLspResponse {α : Type} [ToJson α] (id : RequestID) (params : α) : ServerM Unit := fun st => Lsp.writeLspResponse st.hOut id params /-- Clears diagnostics for the document version 'version'. -/ -- TODO(WN): how to clear all diagnostics? Sending version 'none' doesn't seem to work def clearDiagnostics (uri : DocumentUri) (version : Nat) : ServerM Unit := writeLspNotification "textDocument/publishDiagnostics" { uri := uri, version? := version, diagnostics := #[] : PublishDiagnosticsParams } def sendDiagnostics (uri : DocumentUri) (doc : EditableDocument) (log : MessageLog) : ServerM Unit := do let diagnostics ← log.msgs.mapM fun msg => liftM $ msgToDiagnostic doc.text msg writeLspNotification "textDocument/publishDiagnostics" { uri := uri, version? := doc.version, diagnostics := diagnostics.toArray : PublishDiagnosticsParams } def handleDidOpen (p : DidOpenTextDocumentParams) : ServerM Unit := do let doc := p.textDocument -- NOTE(WN): `toFileMap` marks line beginnings as immediately following -- "\n", which should be enough to handle both LF and CRLF correctly. -- This is because LSP always refers to characters by (line, column), -- so if we get the line number correct it shouldn't matter that there -- is a CR there. let text := doc.text.toFileMap let (msgLog, newDoc) ← compileDocument doc.version text updateOpenDocuments doc.uri newDoc sendDiagnostics doc.uri newDoc msgLog private def replaceLspRange (text : FileMap) (r : Lsp.Range) (newText : String) : FileMap := let start := text.lspPosToUtf8Pos r.start; let «end» := text.lspPosToUtf8Pos r.«end»; let pre := text.source.extract 0 start; let post := text.source.extract «end» text.source.bsize; (pre ++ newText ++ post).toFileMap def handleDidChange (p : DidChangeTextDocumentParams) : ServerM Unit := do let docId := p.textDocument let changes := p.contentChanges let oldDoc ← findOpenDocument docId.uri let some newVersion ← pure docId.version? | throw (userError "expected version number") if newVersion <= oldDoc.version then do throw (userError "got outdated version number") else changes.forM fun change => match change with | TextDocumentContentChangeEvent.rangeChange (range : Range) (newText : String) => do let startOff := oldDoc.text.lspPosToUtf8Pos range.start let newDocText := replaceLspRange oldDoc.text range newText let (msgLog, newDoc) ← monadLift $ updateDocument oldDoc startOff newVersion newDocText updateOpenDocuments docId.uri newDoc -- Clients don't have to clear diagnostics, so we clear them -- for the *previous* version here. clearDiagnostics docId.uri oldDoc.version sendDiagnostics docId.uri newDoc msgLog | TextDocumentContentChangeEvent.fullChange (text : String) => throw (userError "TODO impl computing the diff of two sources.") def handleDidClose (p : DidCloseTextDocumentParams) : ServerM Unit := fun st => do -- TODO free compacted regions st.openDocumentsRef.modify (fun openDocuments => openDocuments.erase p.textDocument.uri) def handleHover (p : HoverParams) : ServerM Json := pure Json.null def parseParams (paramType : Type) [FromJson paramType] (params : Json) : ServerM paramType := match fromJson? params with | some parsed => pure parsed | none => throw (userError "got param with wrong structure") def handleNotification (method : String) (params : Json) : ServerM Unit := do let h := (fun paramType [FromJson paramType] (handler : paramType → ServerM Unit) => parseParams paramType params >>= handler) match method with | "textDocument/didOpen" => h DidOpenTextDocumentParams handleDidOpen | "textDocument/didChange" => h DidChangeTextDocumentParams handleDidChange | "textDocument/didClose" => h DidCloseTextDocumentParams handleDidClose | "$/cancelRequest" => pure () -- TODO when we're async | _ => throw (userError "got unsupported notification method") def handleRequest (id : RequestID) (method : String) (params : Json) : ServerM Unit := do let h := (fun paramType responseType [FromJson paramType] [ToJson responseType] (handler : paramType → ServerM responseType) => parseParams paramType params >>= handler >>= writeLspResponse id) match method with | "textDocument/hover" => h HoverParams Json handleHover | _ => throw (userError $ "got unsupported request: " ++ method ++ "; params: " ++ toString params) partial def mainLoop (_ : Unit) : ServerM Unit := do let msg ← readLspMessage match msg with | Message.request id "shutdown" _ => writeLspResponse id (Json.null) | Message.request id method (some params) => do handleRequest id method (toJson params); mainLoop () | Message.notification method (some params) => do handleNotification method (toJson params); mainLoop () | _ => throw (userError "got invalid JSON-RPC message") def mkLeanServerCapabilities : ServerCapabilities := { textDocumentSync? := some { openClose := true, change := TextDocumentSyncKind.incremental, willSave := false, willSaveWaitUntil := false, save? := none }, hoverProvider := true } def initAndRunServer (i o : FS.Stream) : IO Unit := do let openDocumentsRef ← IO.mkRef (RBMap.empty : DocumentMap) let x : ServerM Unit := do -- ignore InitializeParams for MWE let r ← readLspRequestAs "initialize" InitializeParams writeLspResponse r.id { capabilities := mkLeanServerCapabilities, serverInfo? := some { name := "Lean 4 server", version? := "0.0.1" } : InitializeResult } discard <| readLspNotificationAs "initialized" InitializedParams mainLoop () let Message.notification "exit" none ← readLspMessage | throw (userError "Expected an Exit Notification.") x.run ⟨i, o, openDocumentsRef⟩ namespace Test def runWithInputFile (fn : String) (searchPath : Option String) : IO Unit := do let o ← IO.getStdout let e ← IO.getStderr FS.withFile fn FS.Mode.read fun hFile => do Lean.initSearchPath searchPath try Lean.Server.initAndRunServer (FS.Stream.ofHandle hFile) o catch err => e.putStrLn (toString err) end Test end Server end Lean