lean4-htt/src/Lean/Server/FileWorker/InlayHints.lean
Marc Huisinga 970732ea11
fix: inlay hint assertion violation (#7164)
This PR fixes an assertion violation introduced in #7149 where the
monotonic progress assumption was violated by request cancellation.
2025-02-20 13:03:44 +00:00

229 lines
9.1 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) 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