lean4-htt/src/Lean/Server/Watchdog.lean
Marc Huisinga 9009c1ac91
fix: ilean loading performance (#4900)
This PR roughly halves the time needed to load the .ilean files by
optimizing the JSON parser and the conversion from JSON to Lean data
structures.

The code is optimized roughly as follows:
- String operations are inlined more aggressively
- Parsers are changed to use new `String.Iterator` functions `curr'` and
`next'` that receive a proof and hence do not need to perform an
additional check
- The `RefIdent` of .ilean files now uses a `String` instead of a `Name`
to avoid the expensive parse step from `String` to `Name` (despite the
fact that we only very rarely actually need a `Name` in downstream code)
- Instead of `List`s and `Subarray`s, the JSON to Lean conversion now
directly passes around arrays and array indices to avoid redundant
boxing
- Parsec's `peek?` sometimes generates redundant `Option` wrappers
because the generation of basic blocks interferes with the ctor-match
optimization, so it is changed to use an `isEof` check where possible
- Early returns and inline-do-blocks cause the code generator to
generate new functions, which then interfere with optimizations, so they
are now avoided
- Mutual defs are used instead of unspecialized passing of higher-order
functions to generate faster code
- The object parser is made tail-recursive

This PR also fixes a stack overflow in `Lean.Json.compress` that would
occur with long lists and adds a benchmark for the .ilean roundtrip
(compressed pretty-printing -> parsing).
2024-08-29 11:51:48 +00:00

1196 lines
47 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/-
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
-/
prelude
import Init.System.IO
import Init.Data.ByteArray
import Lean.Data.RBMap
import Lean.Util.Paths
import Lean.Data.FuzzyMatching
import Lean.Data.Json
import Lean.Data.Lsp
import Lean.Server.Utils
import Lean.Server.Requests
import Lean.Server.References
/-!
For general server architecture, see `README.md`. This module implements the watchdog process.
## Watchdog state
Most LSP clients only send us file diffs, so to facilitate sending entire file contents to freshly
restarted workers, the watchdog needs to maintain the current state of each file. It can also use
this state to detect changes to the header and thus restart the corresponding worker, freeing its
imports.
TODO(WN):
We may eventually want to keep track of approximately (since this isn't knowable exactly) where in
the file a worker crashed. Then on restart, we tell said worker to only parse up to that point and
query the user about how to proceed (continue OR allow the user to fix the bug and then continue OR
..). Without this, if the crash is deterministic, users may be confused about why the server
seemingly stopped working for a single file.
## Watchdog <-> worker communication
The watchdog process and its file worker processes communicate via LSP. If the necessity arises, we
might add non-standard commands similarly based on JSON-RPC. Most requests and notifications are
forwarded to the corresponding file worker process, with the exception of these notifications:
- textDocument/didOpen: Launch the file worker, create the associated watchdog state and launch a
task to asynchronously receive LSP packets from the worker (e.g. request
responses).
- textDocument/didChange: Update the local file state so that it can be resent to restarted workers.
Then forward the `didChange` notification.
- textDocument/didClose: Signal a shutdown to the file worker and remove the associated watchdog
state.
Moreover, we don't implement the full protocol at this level:
- Upon starting, the `initialize` request is forwarded to the worker, but it must not respond with
its server capabilities. Consequently, the watchdog will not send an `initialized` notification to
the worker.
- After `initialize`, the watchdog sends the corresponding `didOpen` notification with the full
current state of the file. No additional `didOpen` notifications will be forwarded to the worker
process.
- `$/cancelRequest` notifications are forwarded to all file workers.
- File workers are always terminated with an `exit` notification, without previously receiving a
`shutdown` request. Similarly, they never receive a `didClose` notification.
## Watchdog <-> client communication
The watchdog itself should implement the LSP standard as closely as possible. However we reserve the
right to add non-standard extensions in case they're needed, for example to communicate tactic
state.
-/
namespace Lean.Server.Watchdog
open IO
open Lsp
open JsonRpc
open System.Uri
section Utils
def workerCfg : Process.StdioConfig := {
stdin := Process.Stdio.piped
stdout := Process.Stdio.piped
-- We pass workers' stderr through to the editor.
stderr := Process.Stdio.inherit
}
/-- Events that worker-specific tasks signal to the main thread. -/
inductive WorkerEvent where
| terminated
| importsChanged
| crashed (e : IO.Error)
| ioError (e : IO.Error)
inductive CrashOrigin
| fileWorkerToClientForwarding
| clientToFileWorkerForwarding
inductive WorkerState where
/-- The watchdog can detect a crashed file worker in two places: When trying to send a message
to the file worker and when reading a request reply.
In the latter case, the forwarding task terminates and delegates a `crashed` event to the
main task. Then, in both cases, the file worker has its state set to `crashed` and requests
that are in-flight are errored. Upon receiving the next packet for that file worker, the file
worker is restarted and the packet is forwarded to it. If the crash was detected while writing
a packet, we queue that packet until the next packet for the file worker arrives. -/
| crashed (queuedMsgs : Array JsonRpc.Message) (origin : CrashOrigin)
| running
abbrev PendingRequestMap := RBMap RequestID JsonRpc.Message compare
end Utils
section FileWorker
structure FileWorker where
doc : DocumentMeta
proc : Process.Child workerCfg
commTask : Task WorkerEvent
state : WorkerState
-- This should not be mutated outside of namespace FileWorker,
-- as it is used as shared mutable state
/-- The pending requests map contains all requests that have been received from the LSP client,
but were not answered yet.
We need them for forwarding cancellation requests to the correct worker as well as cleanly
aborting requests on worker crashes. -/
pendingRequestsRef : IO.Ref PendingRequestMap
namespace FileWorker
def stdin (fw : FileWorker) : FS.Stream :=
FS.Stream.ofHandle fw.proc.stdin
def stdout (fw : FileWorker) : FS.Stream :=
FS.Stream.ofHandle fw.proc.stdout
def erasePendingRequest (fw : FileWorker) (id : RequestID) : IO Unit :=
fw.pendingRequestsRef.modify fun pendingRequests => pendingRequests.erase id
def errorPendingRequests (fw : FileWorker) (hError : FS.Stream) (code : ErrorCode) (msg : String)
: IO Unit := do
let pendingRequests ← fw.pendingRequestsRef.modifyGet
fun pendingRequests => (pendingRequests, RBMap.empty)
for ⟨id, _⟩ in pendingRequests do
hError.writeLspResponseError { id := id, code := code, message := msg }
def queuedMsgs (fw : FileWorker) : Array JsonRpc.Message :=
match fw.state with
| .running => #[]
| .crashed queuedMsgs _ => queuedMsgs
end FileWorker
end FileWorker
section ServerM
abbrev FileWorkerMap := RBMap DocumentUri FileWorker compare
abbrev ImportMap := RBMap DocumentUri (RBTree DocumentUri compare) compare
/-- Global import data for all open files managed by this watchdog. -/
structure ImportData where
/-- For every open file, the files that it imports. -/
imports : ImportMap
/-- For every open file, the files that it is imported by. -/
importedBy : ImportMap
/-- Updates `d` with the new set of `imports` for the file `uri`. -/
def ImportData.update (d : ImportData) (uri : DocumentUri) (imports : RBTree DocumentUri compare)
: ImportData := Id.run do
let oldImports := d.imports.findD uri ∅
let removedImports := oldImports.diff imports
let addedImports := imports.diff oldImports
let mut importedBy := d.importedBy
for removedImport in removedImports do
let importedByRemovedImport' := importedBy.find! removedImport |>.erase uri
if importedByRemovedImport'.isEmpty then
importedBy := importedBy.erase removedImport
else
importedBy := importedBy.insert removedImport importedByRemovedImport'
for addedImport in addedImports do
importedBy :=
importedBy.findD addedImport ∅
|>.insert uri
|> importedBy.insert addedImport
let imports :=
if imports.isEmpty then
d.imports.erase uri
else
d.imports.insert uri imports
return { imports, importedBy }
/--
Sets the imports of `uri` in `d` to the empty set.
-/
def ImportData.eraseImportsOf (d : ImportData) (uri : DocumentUri) : ImportData :=
d.update uri ∅
structure RequestIDTranslation where
sourceUri : DocumentUri
localID : RequestID
abbrev PendingServerRequestMap := RBMap RequestID RequestIDTranslation compare
structure ServerRequestData where
pendingServerRequests : PendingServerRequestMap
freshServerRequestID : Nat
def ServerRequestData.trackOutboundRequest
(data : ServerRequestData)
(sourceUri : DocumentUri)
(localID : RequestID)
: RequestID × ServerRequestData :=
let globalID := data.freshServerRequestID
let data := {
pendingServerRequests := data.pendingServerRequests.insert globalID ⟨sourceUri, localID⟩
freshServerRequestID := globalID + 1
}
(globalID, data)
def ServerRequestData.translateInboundResponse
(data : ServerRequestData)
(globalID : RequestID)
: Option RequestIDTranslation × ServerRequestData :=
let translation? := data.pendingServerRequests.find? globalID
let data := { data with pendingServerRequests := data.pendingServerRequests.erase globalID }
(translation?, data)
structure ServerContext where
hIn : FS.Stream
hOut : FS.Stream
hLog : FS.Stream
/-- Command line arguments. -/
args : List String
fileWorkersRef : IO.Ref FileWorkerMap
/-- We store these to pass them to workers. -/
initParams : InitializeParams
workerPath : System.FilePath
srcSearchPath : System.SearchPath
references : IO.Ref References
serverRequestData : IO.Ref ServerRequestData
importData : IO.Ref ImportData
abbrev ServerM := ReaderT ServerContext IO
def updateFileWorkers (val : FileWorker) : ServerM Unit := do
(←read).fileWorkersRef.modify (fun fileWorkers => fileWorkers.insert val.doc.uri val)
def findFileWorker? (uri : DocumentUri) : ServerM (Option FileWorker) :=
return (← (←read).fileWorkersRef.get).find? uri
def eraseFileWorker (uri : DocumentUri) : ServerM Unit := do
let s ← read
s.importData.modify (·.eraseImportsOf uri)
s.fileWorkersRef.modify (fun fileWorkers => fileWorkers.erase uri)
let some module ← s.srcSearchPath.searchModuleNameOfUri uri
| return
s.references.modify fun refs => refs.removeWorkerRefs module
def log (msg : String) : ServerM Unit := do
let st ← read
st.hLog.putStrLn msg
st.hLog.flush
def handleIleanInfoUpdate (fw : FileWorker) (params : LeanIleanInfoParams) : ServerM Unit := do
let s ← read
let some module ← s.srcSearchPath.searchModuleNameOfUri fw.doc.uri
| return
s.references.modify fun refs =>
refs.updateWorkerRefs module params.version params.references
def handleIleanInfoFinal (fw : FileWorker) (params : LeanIleanInfoParams) : ServerM Unit := do
let s ← read
let some module ← s.srcSearchPath.searchModuleNameOfUri fw.doc.uri
| return
s.references.modify fun refs =>
refs.finalizeWorkerRefs module params.version params.references
/--
Updates the global import data with the import closure provided by the file worker after it
successfully processed its header.
-/
def handleImportClosure (fw : FileWorker) (params : LeanImportClosureParams) : ServerM Unit := do
let s ← read
s.importData.modify fun importData =>
importData.update fw.doc.uri (.ofList params.importClosure.toList)
/-- Creates a Task which forwards a worker's messages into the output stream until an event
which must be handled in the main watchdog thread (e.g. an I/O error) happens. -/
private partial def forwardMessages (fw : FileWorker) : ServerM (Task WorkerEvent) := do
let o := (←read).hOut
let rec loop : ServerM WorkerEvent := do
try
let msg ← fw.stdout.readLspMessage
-- Re. `o.writeLspMessage msg`:
-- Writes to Lean I/O channels are atomic, so these won't trample on each other.
match msg with
| Message.response id _ => do
fw.erasePendingRequest id
o.writeLspMessage msg
| Message.responseError id _ _ _ => do
fw.erasePendingRequest id
o.writeLspMessage msg
| Message.request id method params? =>
let globalID ← (←read).serverRequestData.modifyGet
(·.trackOutboundRequest fw.doc.uri id)
o.writeLspMessage (Message.request globalID method params?)
| Message.notification "$/lean/ileanInfoUpdate" params =>
if let some params := params then
if let Except.ok params := FromJson.fromJson? <| ToJson.toJson params then
handleIleanInfoUpdate fw params
| Message.notification "$/lean/ileanInfoFinal" params =>
if let some params := params then
if let Except.ok params := FromJson.fromJson? <| ToJson.toJson params then
handleIleanInfoFinal fw params
| Message.notification "$/lean/importClosure" params =>
if let some params := params then
if let Except.ok params := FromJson.fromJson? <| ToJson.toJson params then
handleImportClosure fw params
| _ => o.writeLspMessage msg
catch err =>
-- If writeLspMessage from above errors we will block here, but the main task will
-- quit eventually anyways if that happens
let exitCode ← fw.proc.wait
-- Remove surviving descendant processes, if any, such as from nested builds.
-- On Windows, we instead rely on elan doing this.
try fw.proc.kill catch _ => pure ()
match exitCode with
| 0 =>
-- Worker was terminated
fw.errorPendingRequests o ErrorCode.contentModified
(s!"The file worker for {fw.doc.uri} has been terminated. "
++ "Either the header has changed, or the file was closed, "
++ " or the server is shutting down.")
-- one last message to clear the diagnostics for this file so that stale errors
-- do not remain in the editor forever.
o.writeLspMessage <| mkPublishDiagnosticsNotification fw.doc #[]
return WorkerEvent.terminated
| 2 =>
return .importsChanged
| _ =>
-- Worker crashed
let (errorCode, errorCausePointer) :=
if exitCode = 1 then
(ErrorCode.workerExited, "see stderr for exception")
else
(ErrorCode.workerCrashed, "likely due to a stack overflow or a bug")
fw.errorPendingRequests o errorCode
s!"Server process for {fw.doc.uri} crashed, {errorCausePointer}."
o.writeLspMessage <| mkFileProgressAtPosNotification fw.doc 0 (kind := LeanFileProgressKind.fatalError)
return WorkerEvent.crashed err
loop
let task ← IO.asTask (loop $ ←read) Task.Priority.dedicated
return task.map fun
| Except.ok ev => ev
| Except.error e => WorkerEvent.ioError e
def startFileWorker (m : DocumentMeta) : ServerM Unit := do
(← read).hOut.writeLspMessage <| mkFileProgressAtPosNotification m 0
let st ← read
let workerProc ← Process.spawn {
toStdioConfig := workerCfg
cmd := st.workerPath.toString
args := #["--worker"] ++ st.args.toArray ++ #[m.uri]
-- open session for `kill` above
setsid := true
}
let pendingRequestsRef ← IO.mkRef (RBMap.empty : PendingRequestMap)
let initialDependencyBuildMode := m.dependencyBuildMode
let updatedDependencyBuildMode :=
if initialDependencyBuildMode matches .once then
-- By sending the first `didOpen` notification, we build the dependencies once
-- => no future builds
.never
else
initialDependencyBuildMode
-- The task will never access itself, so this is fine
let fw : FileWorker := {
doc := { m with dependencyBuildMode := updatedDependencyBuildMode}
proc := workerProc
commTask := Task.pure WorkerEvent.terminated
state := WorkerState.running
pendingRequestsRef := pendingRequestsRef
}
let commTask ← forwardMessages fw
let fw : FileWorker := { fw with commTask := commTask }
fw.stdin.writeLspRequest ⟨0, "initialize", st.initParams⟩
fw.stdin.writeLspNotification {
method := "textDocument/didOpen"
param := {
textDocument := {
uri := m.uri
languageId := "lean"
version := m.version
text := m.text.source
}
dependencyBuildMode? := initialDependencyBuildMode
: LeanDidOpenTextDocumentParams
}
}
updateFileWorkers fw
def terminateFileWorker (uri : DocumentUri) : ServerM Unit := do
let some fw ← findFileWorker? uri
| return
try
fw.stdin.writeLspMessage (Message.notification "exit" none)
catch _ =>
/- The file worker must have crashed just when we were about to terminate it!
That's fine - just forget about it then.
(on didClose we won't need the crashed file worker anymore,
when the header changed we'll start a new one right after
anyways and when we're shutting down the server
it's over either way.) -/
return
eraseFileWorker uri
def handleCrash (uri : DocumentUri) (queuedMsgs : Array JsonRpc.Message) (origin: CrashOrigin) : ServerM Unit := do
let some fw ← findFileWorker? uri
| return
updateFileWorkers { fw with state := WorkerState.crashed queuedMsgs origin }
def tryDischargeQueuedMessages (uri : DocumentUri) (queuedMsgs : Array JsonRpc.Message) : ServerM Unit := do
let some fw ← findFileWorker? uri
| throwServerError "Cannot find file worker for '{uri}'."
let mut crashedMsgs := #[]
-- Try to discharge all queued msgs, tracking the ones that we can't discharge
for msg in queuedMsgs do
try
fw.stdin.writeLspMessage msg
catch _ =>
crashedMsgs := crashedMsgs.push msg
if ¬ crashedMsgs.isEmpty then
handleCrash uri crashedMsgs .clientToFileWorkerForwarding
/-- Tries to write a message, sets the state of the FileWorker to `crashed` if it does not succeed
and restarts the file worker if the `crashed` flag was already set. Just logs an error if
there is no FileWorker at this `uri`.
Messages that couldn't be sent can be queued up via the queueFailedMessage flag and
will be discharged after the FileWorker is restarted. -/
def tryWriteMessage
(uri : DocumentUri)
(msg : JsonRpc.Message)
(queueFailedMessage := true)
(restartCrashedWorker := false)
: ServerM Unit := do
let some fw ← findFileWorker? uri
| return
match fw.state with
| WorkerState.crashed queuedMsgs _ =>
let mut queuedMsgs := queuedMsgs
if queueFailedMessage then
queuedMsgs := queuedMsgs.push msg
if !restartCrashedWorker then
return
-- restart the crashed FileWorker
eraseFileWorker uri
startFileWorker fw.doc
tryDischargeQueuedMessages uri queuedMsgs
| WorkerState.running =>
let initialQueuedMsgs :=
if queueFailedMessage then
#[msg]
else
#[]
try
fw.stdin.writeLspMessage msg
catch _ =>
handleCrash uri initialQueuedMsgs .clientToFileWorkerForwarding
/--
Sends a notification to the file worker identified by `uri` that its dependency `staleDependency`
is out-of-date.
-/
def notifyAboutStaleDependency (uri : DocumentUri) (staleDependency : DocumentUri)
: ServerM Unit :=
let notification := Notification.mk "$/lean/staleDependency" {
staleDependency := staleDependency
: LeanStaleDependencyParams
}
tryWriteMessage uri notification (queueFailedMessage := false)
end ServerM
section RequestHandling
open FuzzyMatching
def findDefinitions (p : TextDocumentPositionParams) : ServerM <| Array Location := do
let srcSearchPath := (← read).srcSearchPath
let some module ← srcSearchPath.searchModuleNameOfUri p.textDocument.uri
| return #[]
let references ← (← read).references.get
let mut definitions := #[]
for ident in references.findAt module p.position (includeStop := true) do
if let some ⟨definitionLocation, _⟩ ← references.definitionOf? ident srcSearchPath then
definitions := definitions.push definitionLocation
return definitions
def handleReference (p : ReferenceParams) : ServerM (Array Location) := do
let srcSearchPath := (← read).srcSearchPath
let some module ← srcSearchPath.searchModuleNameOfUri p.textDocument.uri
| return #[]
let references ← (← read).references.get
let mut result := #[]
for ident in references.findAt module p.position (includeStop := true) do
let identRefs ← references.referringTo srcSearchPath ident
p.context.includeDeclaration
result := result.append <| identRefs.map (·.location)
return result
/--
Used in `CallHierarchyItem.data?` to retain all the data needed to quickly re-identify the
call hierarchy item.
-/
structure CallHierarchyItemData where
module : Name
name : Name
deriving FromJson, ToJson
/--
Extracts the CallHierarchyItemData from `item.data?` and returns `none` if this is not possible.
-/
def CallHierarchyItemData.fromItem? (item : CallHierarchyItem) : Option CallHierarchyItemData := do
fromJson? (← item.data?) |>.toOption
private def callHierarchyItemOf?
(refs : References)
(ident : RefIdent)
(srcSearchPath : SearchPath)
: IO (Option CallHierarchyItem) := do
let some ⟨definitionLocation, parentDecl?⟩ ← refs.definitionOf? ident srcSearchPath
| return none
match ident with
| .const definitionModule definitionNameString =>
let definitionName := definitionNameString.toName
-- If we have a constant with a proper name, use it.
-- If `callHierarchyItemOf?` is used either on the name of a definition itself or e.g. an
-- `inductive` constructor, this is the right thing to do and using the parent decl is
-- the wrong thing to do.
-- Remove private header from name
let label := Lean.privateToUserName? definitionName |>.getD definitionName
return some {
name := label.toString
kind := SymbolKind.constant
uri := definitionLocation.uri
range := definitionLocation.range,
selectionRange := definitionLocation.range
data? := toJson {
module := definitionModule.toName
name := definitionName
: CallHierarchyItemData
}
}
| _ =>
let some ⟨parentDeclNameString, parentDeclRange, parentDeclSelectionRange⟩ := parentDecl?
| return none
let parentDeclName := parentDeclNameString.toName
let some definitionModule ← srcSearchPath.searchModuleNameOfUri definitionLocation.uri
| return none
-- Remove private header from name
let label := Lean.privateToUserName? parentDeclName |>.getD parentDeclName
return some {
name := label.toString
kind := SymbolKind.constant
uri := definitionLocation.uri
range := parentDeclRange,
selectionRange := parentDeclSelectionRange
data? := toJson {
-- Assumption: The parent declaration of a reference lives in the same module
-- as the reference.
module := definitionModule
name := parentDeclName
: CallHierarchyItemData
}
}
def handlePrepareCallHierarchy (p : CallHierarchyPrepareParams)
: ServerM (Array CallHierarchyItem) := do
let srcSearchPath := (← read).srcSearchPath
let some module ← srcSearchPath.searchModuleNameOfUri p.textDocument.uri
| return #[]
let references ← (← read).references.get
let idents := references.findAt module p.position (includeStop := true)
let items ← idents.filterMapM fun ident =>
callHierarchyItemOf? references ident srcSearchPath
return items.qsort (·.name < ·.name)
def handleCallHierarchyIncomingCalls (p : CallHierarchyIncomingCallsParams)
: ServerM (Array CallHierarchyIncomingCall) := do
let some itemData := CallHierarchyItemData.fromItem? p.item
| return #[]
let srcSearchPath := (← read).srcSearchPath
let references ← (← read).references.get
let identRefs ← references.referringTo srcSearchPath (.const itemData.module.toString itemData.name.toString) false
let incomingCalls ← identRefs.filterMapM fun ⟨location, parentDecl?⟩ => do
let some ⟨parentDeclNameString, parentDeclRange, parentDeclSelectionRange⟩ := parentDecl?
| return none
let parentDeclName := parentDeclNameString.toName
let some refModule ← srcSearchPath.searchModuleNameOfUri location.uri
| return none
-- Remove private header from name
let label := Lean.privateToUserName? parentDeclName |>.getD parentDeclName
return some {
«from» := {
name := label.toString
kind := SymbolKind.constant
uri := location.uri
range := parentDeclRange
selectionRange := parentDeclSelectionRange
data? := toJson {
module := refModule
name := parentDeclName
: CallHierarchyItemData
}
}
fromRanges := #[location.range]
}
return collapseSameIncomingCalls incomingCalls |>.qsort (·.«from».name < ·.«from».name)
where
collapseSameIncomingCalls (incomingCalls : Array CallHierarchyIncomingCall)
: Array CallHierarchyIncomingCall :=
let grouped := incomingCalls.groupByKey (·.«from»)
let collapsed := grouped.toArray.map fun ⟨_, group⟩ => {
«from» := group[0]!.«from»
fromRanges := group.concatMap (·.fromRanges)
}
collapsed
def handleCallHierarchyOutgoingCalls (p : CallHierarchyOutgoingCallsParams)
: ServerM (Array CallHierarchyOutgoingCall) := do
let some itemData := CallHierarchyItemData.fromItem? p.item
| return #[]
let srcSearchPath := (← read).srcSearchPath
let some module ← srcSearchPath.searchModuleNameOfUri p.item.uri
| return #[]
let references ← (← read).references.get
let some refs := references.allRefs[module]?
| return #[]
let items ← refs.toArray.filterMapM fun ⟨ident, info⟩ => do
let outgoingUsages := info.usages.filter fun usage => Id.run do
let some parentDecl := usage.parentDecl?
| return false
return itemData.name == parentDecl.name.toName
let outgoingUsages := outgoingUsages.map (·.range)
if outgoingUsages.isEmpty then
return none
let some item ← callHierarchyItemOf? references ident srcSearchPath
| return none
-- filter local defs from outgoing calls
if item.name == p.item.name then
return none
return some ⟨item, outgoingUsages⟩
return collapseSameOutgoingCalls items |>.qsort (·.to.name < ·.to.name)
where
collapseSameOutgoingCalls (outgoingCalls : Array CallHierarchyOutgoingCall)
: Array CallHierarchyOutgoingCall :=
let grouped := outgoingCalls.groupByKey (·.to)
let collapsed := grouped.toArray.map fun ⟨_, group⟩ => {
to := group[0]!.to
fromRanges := group.concatMap (·.fromRanges)
}
collapsed
def handleWorkspaceSymbol (p : WorkspaceSymbolParams) : ServerM (Array SymbolInformation) := do
if p.query.isEmpty then
return #[]
let references ← (← read).references.get
let srcSearchPath := (← read).srcSearchPath
let symbols ← references.definitionsMatching srcSearchPath (maxAmount? := none)
fun name =>
let name := privateToUserName? name |>.getD name
if let some score := fuzzyMatchScoreWithThreshold? p.query name.toString then
some (name.toString, score)
else
none
return symbols
|>.qsort (fun ((_, s1), _) ((_, s2), _) => s1 > s2)
|>.extract 0 100 -- max amount
|>.map fun ((name, _), location) =>
{ name, kind := SymbolKind.constant, location }
def handlePrepareRename (p : PrepareRenameParams) : ServerM (Option Range) := do
-- This just checks that the cursor is over a renameable identifier
let srcSearchPath := (← read).srcSearchPath
let some module ← srcSearchPath.searchModuleNameOfUri p.textDocument.uri
| return none
let references ← (← read).references.get
return references.findRange? module p.position (includeStop := true)
def handleRename (p : RenameParams) : ServerM Lsp.WorkspaceEdit := do
if (String.toName p.newName).isAnonymous then
throwServerError s!"Can't rename: `{p.newName}` is not an identifier"
let mut refs : Std.HashMap DocumentUri (RBMap Lsp.Position Lsp.Position compare) := ∅
for { uri, range } in (← handleReference { p with context.includeDeclaration := true }) do
refs := refs.insert uri <| (refs.getD uri ∅).insert range.start range.end
-- We have to filter the list of changes to put the ranges in order and
-- remove any duplicates or overlapping ranges, or else the rename will not apply
let changes := refs.fold (init := ∅) fun changes uri map => Id.run do
let mut last := ⟨0, 0⟩
let mut arr := #[]
for (start, stop) in map do
if last ≤ start then
arr := arr.push { range := ⟨start, stop⟩, newText := p.newName }
last := stop
return changes.insert uri arr
return { changes? := some changes }
end RequestHandling
section NotificationHandling
def handleDidOpen (p : LeanDidOpenTextDocumentParams) : ServerM Unit :=
let doc := p.textDocument
/- Note (kmill): LSP always refers to characters by (line, column),
so converting CRLF to LF preserves line and column numbers. -/
startFileWorker ⟨doc.uri, doc.version, doc.text.crlfToLf.toFileMap, p.dependencyBuildMode?.getD .always⟩
def handleDidChange (p : DidChangeTextDocumentParams) : ServerM Unit := do
let doc := p.textDocument
let changes := p.contentChanges
let some fw ← findFileWorker? p.textDocument.uri
-- Global search and replace in VS Code will send `didChange` to files that were never opened.
| return
let oldDoc := fw.doc
let newVersion := doc.version?.getD 0
if changes.isEmpty then
return
let newDocText := foldDocumentChanges changes oldDoc.text
let newDoc : DocumentMeta := ⟨doc.uri, newVersion, newDocText, oldDoc.dependencyBuildMode⟩
updateFileWorkers { fw with doc := newDoc }
let notification := Notification.mk "textDocument/didChange" p
tryWriteMessage doc.uri notification (restartCrashedWorker := true)
/--
When a file is saved, notifies all file workers for files that depend on this file that this
specific import is now stale so that the file worker can issue a diagnostic asking users to
restart the file.
-/
def handleDidSave (p : DidSaveTextDocumentParams) : ServerM Unit := do
let s ← read
let fileWorkers ← s.fileWorkersRef.get
let importData ← s.importData.get
let dependents := importData.importedBy.findD p.textDocument.uri ∅
for ⟨uri, _⟩ in fileWorkers do
if ! dependents.contains uri then
continue
notifyAboutStaleDependency uri p.textDocument.uri
def handleDidClose (p : DidCloseTextDocumentParams) : ServerM Unit :=
terminateFileWorker p.textDocument.uri
def handleDidChangeWatchedFiles (p : DidChangeWatchedFilesParams) : ServerM Unit := do
let importData ← (← read).importData.get
let references := (← read).references
let oleanSearchPath ← Lean.searchPathRef.get
let ileans ← oleanSearchPath.findAllWithExt "ilean"
for change in p.changes do
let some path := fileUriToPath? change.uri
| continue
match path.extension with
| "lean" =>
let dependents := importData.importedBy.findD change.uri ∅
for dependent in dependents do
notifyAboutStaleDependency dependent change.uri
| "ilean" =>
if let FileChangeType.Deleted := change.type then
references.modify (fun r => r.removeIlean path)
else if ileans.contains path then
try
let ilean ← Ilean.load path
if let FileChangeType.Changed := change.type then
references.modify (fun r => r.removeIlean path |>.addIlean path ilean)
else
references.modify (fun r => r.addIlean path ilean)
catch
-- ilean vanished, ignore error
| .noFileOrDirectory .. => references.modify (·.removeIlean path)
| e => throw e
| _ => continue
def handleCancelRequest (p : CancelParams) : ServerM Unit := do
let fileWorkers ← (←read).fileWorkersRef.get
for ⟨uri, fw⟩ in fileWorkers do
-- Cancelled requests still require a response, so they can't be removed
-- from the pending requests map.
if (← fw.pendingRequestsRef.get).contains p.id then
tryWriteMessage uri (Notification.mk "$/cancelRequest" p) (queueFailedMessage := false)
def forwardNotification {α : Type} [ToJson α] [FileSource α] (method : String) (params : α)
: ServerM Unit :=
tryWriteMessage (fileSource params) (Notification.mk method params) (queueFailedMessage := true)
end NotificationHandling
section MessageHandling
def parseParams (paramType : Type) [FromJson paramType] (params : Json) : ServerM paramType :=
match fromJson? params with
| Except.ok parsed =>
pure parsed
| Except.error inner =>
throwServerError s!"Got param with wrong structure: {params.compress}\n{inner}"
def forwardRequestToWorker (id : RequestID) (method : String) (params : Json) : ServerM Unit := do
let uri: DocumentUri ←
-- This request is handled specially.
if method == "$/lean/rpc/connect" then
let ps ← parseParams Lsp.RpcConnectParams params
pure <| fileSource ps
else match (← routeLspRequest method params) with
| Except.error e =>
(←read).hOut.writeLspResponseError <| e.toLspResponseError id
return
| Except.ok uri => pure uri
let some fw ← findFileWorker? uri
/- Clients may send requests to closed files, which we respond to with an error.
For example, VSCode sometimes sends requests just after closing a file,
and RPC clients may also do so, e.g. due to remaining timers. -/
| do
(←read).hOut.writeLspResponseError
{ id := id
/- Some clients (VSCode) also send requests *before* opening a file. We reply
with `contentModified` as that does not display a "request failed" popup. -/
code := ErrorCode.contentModified
message := s!"Cannot process request to closed file '{uri}'" }
return
let r := Request.mk id method params
fw.pendingRequestsRef.modify (·.insert id r)
tryWriteMessage uri r
def handleRequest (id : RequestID) (method : String) (params : Json) : ServerM Unit := do
let handle α β [FromJson α] [ToJson β] (handler : α → ServerM β) : ServerM Unit := do
let hOut := (← read).hOut
try
let params ← parseParams α params
let result ← handler params
hOut.writeLspResponse ⟨id, result⟩
catch
-- TODO Do fancier error handling, like in file worker?
| e => hOut.writeLspResponseError {
id := id
code := ErrorCode.internalError
message := s!"Failed to process request {id}: {e}"
}
-- If a definition is in a different, modified file, the ilean data should
-- have the correct location while the olean still has outdated info from
-- the last compilation. This is easier than catching the client's reply and
-- fixing the definition's location afterwards, but it doesn't work for
-- go-to-type-definition.
if method == "textDocument/definition" || method == "textDocument/declaration" then
let params ← parseParams TextDocumentPositionParams params
let definitions ← findDefinitions params
if !definitions.isEmpty then
(← read).hOut.writeLspResponse ⟨id, definitions⟩
return
match method with
| "textDocument/references" =>
handle ReferenceParams (Array Location) handleReference
| "workspace/symbol" =>
handle WorkspaceSymbolParams (Array SymbolInformation) handleWorkspaceSymbol
| "textDocument/prepareCallHierarchy" =>
handle CallHierarchyPrepareParams (Array CallHierarchyItem) handlePrepareCallHierarchy
| "callHierarchy/incomingCalls" =>
handle CallHierarchyIncomingCallsParams (Array CallHierarchyIncomingCall)
handleCallHierarchyIncomingCalls
| "callHierarchy/outgoingCalls" =>
handle Lsp.CallHierarchyOutgoingCallsParams (Array CallHierarchyOutgoingCall)
handleCallHierarchyOutgoingCalls
| "textDocument/prepareRename" =>
handle PrepareRenameParams (Option Range) handlePrepareRename
| "textDocument/rename" =>
handle RenameParams WorkspaceEdit handleRename
| _ =>
forwardRequestToWorker id method params
def handleNotification (method : String) (params : Json) : ServerM Unit := do
let handle := fun α [FromJson α] (handler : α → ServerM Unit) =>
parseParams α params >>= handler
match method with
| "textDocument/didOpen" =>
handle _ handleDidOpen
| "textDocument/didChange" =>
handle DidChangeTextDocumentParams handleDidChange
| "textDocument/didClose" =>
handle DidCloseTextDocumentParams handleDidClose
| "textDocument/didSave" =>
handle DidSaveTextDocumentParams handleDidSave
| "workspace/didChangeWatchedFiles" =>
handle DidChangeWatchedFilesParams handleDidChangeWatchedFiles
| "$/cancelRequest" =>
handle CancelParams handleCancelRequest
| "$/lean/rpc/connect" =>
handle RpcConnectParams (forwardNotification method)
| "$/lean/rpc/release" =>
handle RpcReleaseParams (forwardNotification method)
| "$/lean/rpc/keepAlive" =>
handle RpcKeepAliveParams (forwardNotification method)
| _ =>
-- implementation-dependent notifications can be safely ignored
if !"$/".isPrefixOf method then
(←read).hLog.putStrLn s!"Got unsupported notification: {method}"
def handleResponse (id : RequestID) (result : Json) : ServerM Unit := do
let some translation ← (← read).serverRequestData.modifyGet (·.translateInboundResponse id)
| return
tryWriteMessage translation.sourceUri (Response.mk translation.localID result)
def handleResponseError
(id : RequestID)
(code : ErrorCode)
(message : String)
(data? : Option Json)
: ServerM Unit := do
let some translation ← (← read).serverRequestData.modifyGet (·.translateInboundResponse id)
| return
tryWriteMessage translation.sourceUri (ResponseError.mk translation.localID code message data?)
end MessageHandling
section MainLoop
def shutdown : ServerM Unit := do
let fileWorkers ← (←read).fileWorkersRef.get
for ⟨uri, _⟩ in fileWorkers do
terminateFileWorker uri
for ⟨_, fw⟩ in fileWorkers do
discard <| IO.wait fw.commTask
inductive ServerEvent where
| workerEvent (fw : FileWorker) (ev : WorkerEvent)
| clientMsg (msg : JsonRpc.Message)
| clientError (e : IO.Error)
def runClientTask : ServerM (Task ServerEvent) := do
let st ← read
let readMsgAction : IO ServerEvent := do
/- Runs asynchronously. -/
let msg ← st.hIn.readLspMessage
pure <| ServerEvent.clientMsg msg
let clientTask := (← IO.asTask readMsgAction).map fun
| Except.ok ev => ev
| Except.error e => ServerEvent.clientError e
return clientTask
partial def mainLoop (clientTask : Task ServerEvent) : ServerM Unit := do
let st ← read
let workers ← st.fileWorkersRef.get
let mut workerTasks := #[]
for (_, fw) in workers do
-- When the forwarding task crashes, its return value will be stuck at
-- `WorkerEvent.crashed _`.
-- We want to handle this event only once, not over and over again,
-- so once the state becomes `WorkerState.crashed _ .fileWorkerToClientForwarding`
-- as a result of `WorkerEvent.crashed _`, we stop handling this event until
-- eventually the file worker is restarted by a notification from the client.
-- We do not want to filter the forwarding task in case of
-- `WorkerState.crashed _ .clientToFileWorkerForwarding`, since the forwarding task
-- exit code may still contain valuable information in this case (e.g. that the imports changed).
if !(fw.state matches WorkerState.crashed _ .fileWorkerToClientForwarding) then
workerTasks := workerTasks.push <| fw.commTask.map (ServerEvent.workerEvent fw)
let ev ← IO.waitAny (clientTask :: workerTasks.toList)
match ev with
| ServerEvent.clientMsg msg =>
match msg with
| Message.request id "shutdown" _ =>
shutdown
st.hOut.writeLspResponse ⟨id, Json.null⟩
| Message.request id method (some params) =>
handleRequest id method (toJson params)
mainLoop (←runClientTask)
| Message.response id result =>
handleResponse id result
mainLoop (←runClientTask)
| Message.responseError id code message data? =>
handleResponseError id code message data?
mainLoop (←runClientTask)
| Message.notification method (some params) =>
handleNotification method (toJson params)
mainLoop (←runClientTask)
| _ => throwServerError "Got invalid JSON-RPC message"
| ServerEvent.clientError e => throw e
| ServerEvent.workerEvent fw ev =>
match ev with
| WorkerEvent.ioError e =>
throwServerError s!"IO error while processing events for {fw.doc.uri}: {e}"
| WorkerEvent.crashed _ =>
handleCrash fw.doc.uri fw.queuedMsgs .fileWorkerToClientForwarding
mainLoop clientTask
| WorkerEvent.terminated =>
throwServerError <| "Internal server error: got termination event for worker that "
++ "should have been removed"
| .importsChanged =>
let uri := fw.doc.uri
let queuedMsgs := fw.queuedMsgs
startFileWorker fw.doc
tryDischargeQueuedMessages uri queuedMsgs
mainLoop clientTask
end MainLoop
def mkLeanServerCapabilities : ServerCapabilities := {
textDocumentSync? := some {
openClose := true
change := TextDocumentSyncKind.incremental
willSave := false
willSaveWaitUntil := false
save? := some { includeText := true }
}
-- refine
completionProvider? := some {
triggerCharacters? := some #["."]
resolveProvider := true
}
hoverProvider := true
declarationProvider := true
definitionProvider := true
typeDefinitionProvider := true
referencesProvider := true
callHierarchyProvider := true
renameProvider? := some {
prepareProvider := true
}
workspaceSymbolProvider := true
documentHighlightProvider := true
documentSymbolProvider := true
foldingRangeProvider := true
semanticTokensProvider? := some {
legend := {
tokenTypes := SemanticTokenType.names
tokenModifiers := SemanticTokenModifier.names
}
full := true
range := true
}
codeActionProvider? := some {
resolveProvider? := true,
codeActionKinds? := some #["quickfix", "refactor"]
}
}
def initAndRunWatchdogAux : ServerM Unit := do
let st ← read
try
discard $ st.hIn.readLspNotificationAs "initialized" InitializedParams
st.hOut.writeLspRequest {
id := RequestID.str "register_lean_watcher"
method := "client/registerCapability"
param := some {
registrations := #[ {
id := "lean_watcher"
method := "workspace/didChangeWatchedFiles"
registerOptions := some <| toJson {
watchers := #[ { globPattern := "**/*.lean" }, { globPattern := "**/*.ilean" } ]
: DidChangeWatchedFilesRegistrationOptions }
} ]
: RegistrationParams }
}
let clientTask ← runClientTask
mainLoop clientTask
catch err =>
shutdown
throw err
while true do
let msg: JsonRpc.Message ←
try
st.hIn.readLspMessage
catch _ =>
/-
NOTE(WN): It looks like instead of sending the `exit` notification,
VSCode sometimes just closes the stream. In that case, pretend we got an `exit`.
-/
pure (Message.notification "exit" none)
match msg with
| .notification "exit" none =>
break
| .request id _ _ =>
-- The LSP spec suggests that requests after 'shutdown' should be errored in this manner.
st.hOut.writeLspResponseError {
id,
code := .invalidRequest,
message := "Request received after 'shutdown' request."
}
| _ =>
-- Ignore all other message types.
continue
def findWorkerPath : IO System.FilePath := do
let mut workerPath ← IO.appPath
if let some path := (←IO.getEnv "LEAN_SYSROOT") then
workerPath := System.FilePath.mk path / "bin" / "lean"
|>.addExtension System.FilePath.exeExtension
if let some path := (←IO.getEnv "LEAN_WORKER_PATH") then
workerPath := System.FilePath.mk path
return workerPath
/--
Starts loading .ileans present in the search path asynchronously in an IO task.
This ensures that server startup is not blocked by loading the .ileans.
In return, while the .ileans are being loaded, users will only get incomplete
results in requests that need references.
-/
def startLoadingReferences (references : IO.Ref References) : IO Unit := do
-- Discard the task; there isn't much we can do about this failing,
-- but we should try to continue server operations regardless
let _ ← IO.asTask do
let oleanSearchPath ← Lean.searchPathRef.get
for path in ← oleanSearchPath.findAllWithExt "ilean" do
try
let ilean ← Ilean.load path
references.modify fun refs =>
refs.addIlean path ilean
catch _ =>
-- could be a race with the build system, for example
-- ilean load errors should not be fatal, but we *should* log them
-- when we add logging to the server
pure ()
def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
let workerPath ← findWorkerPath
let srcSearchPath ← initSrcSearchPath
let references ← IO.mkRef .empty
startLoadingReferences references
let fileWorkersRef ← IO.mkRef (RBMap.empty : FileWorkerMap)
let serverRequestData ← IO.mkRef {
pendingServerRequests := RBMap.empty
freshServerRequestID := 0
}
let importData ← IO.mkRef ⟨RBMap.empty, RBMap.empty⟩
let i ← maybeTee "wdIn.txt" false i
let o ← maybeTee "wdOut.txt" true o
let e ← maybeTee "wdErr.txt" true e
let initRequest ← i.readLspRequestAs "initialize" InitializeParams
o.writeLspResponse {
id := initRequest.id
result := {
capabilities := mkLeanServerCapabilities
serverInfo? := some {
name := "Lean 4 Server"
version? := "0.2.0"
}
: InitializeResult
}
}
ReaderT.run initAndRunWatchdogAux {
hIn := i
hOut := o
hLog := e
args := args
fileWorkersRef := fileWorkersRef
initParams := initRequest.param
workerPath
srcSearchPath
references
serverRequestData
importData
: ServerContext
}
@[export lean_server_watchdog_main]
def watchdogMain (args : List String) : IO UInt32 := do
let i ← IO.getStdin
let o ← IO.getStdout
let e ← IO.getStderr
try
initAndRunWatchdog args i o e
return 0
catch err =>
e.putStrLn s!"Watchdog error: {err}"
return 1
end Lean.Server.Watchdog