This PR fixes an assertion violation introduced in #7149 where the monotonic progress assumption was violated by request cancellation.
229 lines
9.1 KiB
Text
229 lines
9.1 KiB
Text
/-
|
||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||
Released under Apache 2.0 license as described in the file LICENSE.
|
||
Authors: Marc Huisinga
|
||
-/
|
||
prelude
|
||
import Lean.Server.GoTo
|
||
import Lean.Server.Requests
|
||
|
||
namespace Lean.Elab
|
||
|
||
def InlayHintLinkLocation.toLspLocation (srcSearchPath : SearchPath) (text : FileMap)
|
||
(l : InlayHintLinkLocation) : IO (Option Lsp.Location) := do
|
||
let some uri ← Server.documentUriFromModule srcSearchPath l.module
|
||
| return none
|
||
return some {
|
||
uri
|
||
range := text.utf8RangeToLspRange l.range
|
||
}
|
||
|
||
def InlayHintLabelPart.toLspInlayHintLabelPart (srcSearchPath : SearchPath) (text : FileMap)
|
||
(p : InlayHintLabelPart) : IO Lsp.InlayHintLabelPart := do
|
||
let location? ← p.location?.bindM fun loc => loc.toLspLocation srcSearchPath text
|
||
let tooltip? := do return .markdown { kind := .markdown, value := ← p.tooltip? }
|
||
return {
|
||
value := p.value
|
||
location?,
|
||
tooltip?
|
||
}
|
||
|
||
def InlayHintLabel.toLspInlayHintLabel (srcSearchPath : SearchPath) (text : FileMap) : InlayHintLabel → IO Lsp.InlayHintLabel
|
||
| .name n => do return .name n
|
||
| .parts p => do return .parts <| ← p.mapM (·.toLspInlayHintLabelPart srcSearchPath text)
|
||
|
||
def InlayHintKind.toLspInlayHintKind : InlayHintKind → Lsp.InlayHintKind
|
||
| .type => .type
|
||
| .parameter => .parameter
|
||
|
||
def InlayHintTextEdit.toLspTextEdit (text : FileMap) (e : InlayHintTextEdit) : Lsp.TextEdit := {
|
||
range := text.utf8RangeToLspRange e.range
|
||
newText := e.newText
|
||
}
|
||
|
||
def InlayHintInfo.toLspInlayHint (srcSearchPath : SearchPath) (text : FileMap) (i : InlayHintInfo) : IO Lsp.InlayHint := do
|
||
return {
|
||
position := text.utf8PosToLspPos i.position
|
||
label := ← i.label.toLspInlayHintLabel srcSearchPath text
|
||
kind? := i.kind?.map (·.toLspInlayHintKind)
|
||
textEdits? := some <| i.textEdits.map (·.toLspTextEdit text)
|
||
tooltip? := do return .markdown { kind := .markdown, value := ← i.tooltip? }
|
||
paddingLeft? := i.paddingLeft
|
||
paddingRight? := i.paddingRight
|
||
}
|
||
|
||
end Lean.Elab
|
||
|
||
namespace Lean.Server.FileWorker
|
||
open Lsp
|
||
|
||
def applyEditToHint? (hintMod : Name) (ihi : Elab.InlayHintInfo) (range : String.Range) (newText : String) : Option Elab.InlayHintInfo := do
|
||
let isLabelLocAffectedByEdit :=
|
||
match ihi.label with
|
||
| .name _ => false
|
||
| .parts ps =>
|
||
ps.any fun p =>
|
||
p.location?.any fun loc =>
|
||
loc.module == hintMod && range.overlaps loc.range (includeFirstStop := true)
|
||
-- We always include the stop of the edit range because insertion edit ranges may be empty,
|
||
-- but we must still invalidate the inlay hint in this case.
|
||
let isInlayHintInvalidatedByEdit :=
|
||
range.contains ihi.position (includeStop := true) ||
|
||
ihi.textEdits.any (range.overlaps ·.range (includeFirstStop := true)) ||
|
||
isLabelLocAffectedByEdit
|
||
if isInlayHintInvalidatedByEdit then
|
||
none
|
||
let byteOffset : Int := (newText.toSubstring.bsize : Int) - (range.bsize : Int)
|
||
let shift (p : String.Pos) : String.Pos :=
|
||
if range.stop < p then
|
||
⟨p.byteIdx + byteOffset |>.toNat⟩
|
||
else if p < range.start then
|
||
p
|
||
else -- `range.start <= p && p <= range.stop`
|
||
panic! s!"Got position {p} that should have been invalidated by edit at range {range.start}-{range.stop}"
|
||
let shiftRange (r : String.Range) : String.Range := ⟨shift r.start, shift r.stop⟩
|
||
return { ihi with
|
||
position := shift ihi.position
|
||
textEdits := ihi.textEdits.map fun edit => { edit with range := shiftRange edit.range }
|
||
label :=
|
||
match ihi.label with
|
||
| .name s => .name s
|
||
| .parts ps => .parts <| ps.map fun p => { p with
|
||
location? := p.location?.map fun loc =>
|
||
if loc.module == hintMod then
|
||
{ loc with range := shiftRange loc.range }
|
||
else
|
||
loc
|
||
}
|
||
}
|
||
|
||
structure InlayHintState where
|
||
oldInlayHints : Array Elab.InlayHintInfo
|
||
oldFinishedSnaps : Nat
|
||
lastEditTimestamp? : Option Nat
|
||
deriving TypeName, Inhabited
|
||
|
||
def InlayHintState.init : InlayHintState := {
|
||
oldInlayHints := #[]
|
||
oldFinishedSnaps := 0
|
||
lastEditTimestamp? := none
|
||
}
|
||
|
||
def handleInlayHints (p : InlayHintParams) (s : InlayHintState) :
|
||
RequestM (LspResponse (Array InlayHint) × InlayHintState) := do
|
||
let ctx ← read
|
||
let text := ctx.doc.meta.text
|
||
let range := text.lspRangeToUtf8Range p.range
|
||
let srcSearchPath := ctx.srcSearchPath
|
||
-- We delay sending inlay hints by 3000ms to avoid inlay hint flickering on the client.
|
||
-- VS Code already has a mechanism for this, but it is not sufficient.
|
||
let inlayHintEditDelayMs := 3000
|
||
let timestamp ← IO.monoMsNow
|
||
let editDelayMs :=
|
||
match s.lastEditTimestamp? with
|
||
| none => 0
|
||
| some lastEditTimestamp =>
|
||
let timeSinceLastEditMs := timestamp - lastEditTimestamp
|
||
inlayHintEditDelayMs - timeSinceLastEditMs
|
||
let (snaps, _, isComplete) ← ctx.doc.cmdSnaps.getFinishedPrefixWithConsistentLatency editDelayMs.toUInt32 (cancelTk? := ctx.cancelTk.cancellationTask)
|
||
-- This cancellation check is crucial for the invariant `finishedSnaps >= oldFinishedSnaps` below.
|
||
RequestM.checkCancelled
|
||
let snaps := snaps.toArray
|
||
let finishedSnaps := snaps.size
|
||
let oldFinishedSnaps := s.oldFinishedSnaps
|
||
-- File processing is monotonic modulo `didChange` notifications and cancellation.
|
||
assert! finishedSnaps >= oldFinishedSnaps
|
||
-- VS Code emits inlay hint requests *every time the user scrolls*. This is reasonably expensive,
|
||
-- so in addition to re-using old inlay hints from parts of the file that haven't been processed
|
||
-- yet, we also re-use old inlay hints from parts of the file that have been processed already
|
||
-- with the current state of the document.
|
||
let invalidOldInlayHintsRange : String.Range := {
|
||
start := snaps[oldFinishedSnaps - 1]?.map (·.endPos) |>.getD ⟨0⟩
|
||
stop := snaps[finishedSnaps - 1]?.map (·.endPos) |>.getD ⟨0⟩
|
||
}
|
||
let oldInlayHints :=
|
||
s.oldInlayHints.filter fun (ihi : Elab.InlayHintInfo) =>
|
||
! invalidOldInlayHintsRange.contains ihi.position
|
||
let newInlayHints : Array Elab.InlayHintInfo ← (·.2) <$> StateT.run (s := #[]) do
|
||
for s in snaps[oldFinishedSnaps:] do
|
||
s.infoTree.visitM' (postNode := fun ci i _ => do
|
||
let .ofCustomInfo i := i
|
||
| return
|
||
let some ih := Elab.InlayHint.ofCustomInfo? i
|
||
| return
|
||
let ih ← ci.runMetaM ih.lctx ih.resolveDeferred
|
||
modify (·.push ih.toInlayHintInfo))
|
||
let allInlayHints := newInlayHints ++ oldInlayHints
|
||
let inlayHintsInRange := allInlayHints.filter (range.contains (includeStop := true) ·.position)
|
||
let lspInlayHints ← inlayHintsInRange.mapM (·.toLspInlayHint srcSearchPath text)
|
||
let r := { response := lspInlayHints, isComplete }
|
||
let s := { s with
|
||
oldInlayHints := allInlayHints
|
||
oldFinishedSnaps := finishedSnaps
|
||
}
|
||
return (r, s)
|
||
|
||
def handleInlayHintsDidChange (p : DidChangeTextDocumentParams)
|
||
: StateT InlayHintState RequestM Unit := do
|
||
let meta := (← read).doc.meta
|
||
let text := meta.text
|
||
let srcSearchPath := (← read).srcSearchPath
|
||
let .ok modName? ← EIO.toBaseIO <| do
|
||
let some path := System.Uri.fileUriToPath? meta.uri
|
||
| return none
|
||
let some mod ← searchModuleNameOfFileName path srcSearchPath
|
||
| return some <| ← moduleNameOfFileName path none
|
||
return some mod
|
||
| return
|
||
let modName := modName?.getD .anonymous -- `.anonymous` occurs in untitled files
|
||
let s ← get
|
||
let mut updatedOldInlayHints := #[]
|
||
for ihi in s.oldInlayHints do
|
||
let mut ihi := ihi
|
||
let mut inlayHintInvalidated := false
|
||
for c in p.contentChanges do
|
||
let .rangeChange changeRange newText := c
|
||
| set <| { s with oldInlayHints := #[] } -- `fullChange` => all old inlay hints invalidated
|
||
return
|
||
let changeRange := text.lspRangeToUtf8Range changeRange
|
||
let some ihi' := applyEditToHint? modName ihi changeRange newText
|
||
| -- Change in some position of inlay hint => inlay hint invalidated
|
||
inlayHintInvalidated := true
|
||
break
|
||
ihi := ihi'
|
||
if ! inlayHintInvalidated then
|
||
updatedOldInlayHints := updatedOldInlayHints.push ihi
|
||
let isInlayHintInsertionEdit := p.contentChanges.all fun c => Id.run do
|
||
let .rangeChange changeRange newText := c
|
||
| return false
|
||
let changeRange := text.lspRangeToUtf8Range changeRange
|
||
let edit := ⟨changeRange, newText⟩
|
||
return s.oldInlayHints.any (·.textEdits.contains edit)
|
||
let timestamp ← IO.monoMsNow
|
||
let lastEditTimestamp? :=
|
||
if isInlayHintInsertionEdit then
|
||
-- For some stupid reason, VS Code doesn't remove the inlay hint when applying it, so we
|
||
-- try to figure out whether the edit was an insertion of an inlay hint and then respond
|
||
-- to the request without latency so that it inserted ASAP.
|
||
none
|
||
else
|
||
some timestamp
|
||
set <| { s with
|
||
oldInlayHints := updatedOldInlayHints
|
||
oldFinishedSnaps := 0
|
||
lastEditTimestamp?
|
||
}
|
||
|
||
builtin_initialize
|
||
registerPartialStatefulLspRequestHandler
|
||
"textDocument/inlayHint"
|
||
"workspace/inlayHint/refresh"
|
||
500
|
||
InlayHintParams
|
||
(Array InlayHint)
|
||
InlayHintState
|
||
.init
|
||
handleInlayHints
|
||
handleInlayHintsDidChange
|
||
|
||
end Lean.Server.FileWorker
|