Skip to content

Commit 336b68e

Browse files
authored
feat: 'unknown identifier' code actions (leanprover#7665)
This PR adds support for code actions that resolve 'unknown identifier' errors by either importing the missing declaration or by changing the identifier to one from the environment. <details> <summary>Demo (Click to open)</summary> ![Demo](https://github.com/user-attachments/assets/ba575860-b76d-4213-8cd7-a5525cd60287) </details> Specifically, the following kinds of code actions are added by this PR, all of which are triggered on 'unknown identifier' errors: - A code action to import the module containing the identifier at the text cursor position. - A code action to change the identifier at the text cursor position to one from the environment. - A source action to import the modules for all unambiguous identifiers in the file. ### Details When clicking on an identifier with an 'unknown identifier' diagnostic, after a debounce delay of 1000ms, the language server looks up the (potentially partial) identifier at the position of the cursor in the global reference data structure by fuzzy-matching against all identifiers and collects the 10 closest matching entries. This search accounts for open namespaces at the position of the cursor, including the namespace of the type / expected type when using dot notation. The 10 closest matching entries are then offered to the user as code actions: - If the suggested identifier is not contained in the environment, a code action that imports the module that the identifier is contained in and changes the identifier to the suggested one is offered. The suggestion is inserted in a "minimal" manner, i.e. by accounting for open namespaces. - If the suggested identifier is contained in the environment, a code action that only changes the identifier to the suggested one is offered. - If the suggested identifier is not contained in the environment and the suggested identifier is a perfectly unambiguous match, a source action to import all unambiguous in the file is offered. The source action to import all unambiguous identifiers can also always be triggered by right-clicking in the document and selecting the 'Source Action...' entry. At the moment, for large projects, the search for closely matching identifiers in the global reference data structure is still a bit slow. I hope to optimize it next quarter. ### Implementation notes - Since the global reference data structure is in the watchdog process, whereas the elaboration information is in the file worker process, this PR implements support for file worker -> watchdog requests, including a new `$/lean/queryModule` request that can be used by the file worker to request global identifier information. - To identify 'unknown identifier' errors, several 'unknown identifier' errors in the elaborator are tagged with a new tag. - The debounce delay of 1000ms is necessary because VS Code will re-request code actions while editing an unknown identifier and also while hovering over the identifier. - We also implement cancellation for these 'unknown identifier' code actions. Once the file worker responds to the request as having been cancelled, the watchdog cancels its computation of all corresponding file worker -> watchdog requests, too. - Aliases (i.e. `export`) are currently not accounted for. I've found that we currently don't handle them correctly in auto-completion, too, so we will likely add support for this later when fixing the corresponding auto-completion issue. - The new code actions added by this request support incrementality.
1 parent 5df4e48 commit 336b68e

18 files changed

+1025
-233
lines changed

src/Lean/Data/Lsp/Diagnostics.lean

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -145,34 +145,12 @@ structure DiagnosticWith (α : Type) where
145145
/-- An array of related diagnostic information, e.g. when symbol-names within a scope collide all definitions can be marked via this property. -/
146146
relatedInformation? : Option (Array DiagnosticRelatedInformation) := none
147147
/-- A data entry field that is preserved between a `textDocument/publishDiagnostics` notification and `textDocument/codeAction` request. -/
148-
data?: Option Json := none
148+
data? : Option Json := none
149149
deriving Inhabited, BEq, ToJson, FromJson
150150

151151
def DiagnosticWith.fullRange (d : DiagnosticWith α) : Range :=
152152
d.fullRange?.getD d.range
153153

154-
attribute [local instance] Ord.arrayOrd in
155-
/-- Restriction of `DiagnosticWith` to properties that are displayed to users in the InfoView. -/
156-
private structure DiagnosticWith.UserVisible (α : Type) where
157-
range : Range
158-
fullRange? : Option Range
159-
severity? : Option DiagnosticSeverity
160-
code? : Option DiagnosticCode
161-
source? : Option String
162-
message : α
163-
tags? : Option (Array DiagnosticTag)
164-
relatedInformation? : Option (Array DiagnosticRelatedInformation)
165-
deriving Ord
166-
167-
/-- Extracts user-visible properties from the given `DiagnosticWith`. -/
168-
private def DiagnosticWith.UserVisible.ofDiagnostic (d : DiagnosticWith α)
169-
: DiagnosticWith.UserVisible α :=
170-
{ d with }
171-
172-
/-- Compares `DiagnosticWith` instances modulo non-user-facing properties. -/
173-
def compareByUserVisible [Ord α] (a b : DiagnosticWith α) : Ordering :=
174-
compare (DiagnosticWith.UserVisible.ofDiagnostic a) (DiagnosticWith.UserVisible.ofDiagnostic b)
175-
176154
abbrev Diagnostic := DiagnosticWith String
177155

178156
/-- Parameters for the [`textDocument/publishDiagnostics` notification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics). -/

src/Lean/Data/Lsp/Internal.lean

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Authors: Joscha Mennicken
77
prelude
88
import Lean.Expr
99
import Lean.Data.Lsp.Basic
10+
import Lean.Data.JsonRpc
1011
import Std.Data.TreeMap
1112

1213
set_option linter.missingDocs true -- keep it documented
@@ -201,4 +202,62 @@ structure LeanStaleDependencyParams where
201202
staleDependency : DocumentUri
202203
deriving FromJson, ToJson
203204

205+
/-- LSP type for `Lean.OpenDecl`. -/
206+
inductive OpenNamespace
207+
/-- All declarations in `«namespace»` are opened, except for `exceptions`. -/
208+
| allExcept («namespace» : Name) (exceptions : Array Name)
209+
/-- The declaration `«from»` is renamed to `to`. -/
210+
| renamed («from» : Name) (to : Name)
211+
deriving FromJson, ToJson
212+
213+
/-- Query in the `$/lean/queryModule` watchdog <- worker request. -/
214+
structure LeanModuleQuery where
215+
/-- Identifier (potentially partial) to query. -/
216+
identifier : String
217+
/--
218+
Namespaces that are open at the position of `identifier`.
219+
Used for accurately matching declarations against `identifier` in context.
220+
-/
221+
openNamespaces : Array OpenNamespace
222+
deriving FromJson, ToJson
223+
224+
/--
225+
Used in the `$/lean/queryModule` watchdog <- worker request, which is used by the worker to
226+
extract information from the .ilean information in the watchdog.
227+
-/
228+
structure LeanQueryModuleParams where
229+
/--
230+
The request ID in the context of which this worker -> watchdog request was emitted.
231+
Used for cancelling this request in the watchdog.
232+
-/
233+
sourceRequestID : JsonRpc.RequestID
234+
/-- Module queries for extracting .ilean information in the watchdog. -/
235+
queries : Array LeanModuleQuery
236+
deriving FromJson, ToJson
237+
238+
/-- Result entry of a module query. -/
239+
structure LeanIdentifier where
240+
/-- Module that `decl` is defined in. -/
241+
module : Name
242+
/-- Full name of the declaration that matches the query. -/
243+
decl : Name
244+
/-- Whether this `decl` matched the query exactly. -/
245+
isExactMatch : Bool
246+
deriving FromJson, ToJson
247+
248+
/--
249+
Result for a single module query.
250+
Identifiers in the response are sorted descendingly by how well they match the query.
251+
-/
252+
abbrev LeanQueriedModule := Array LeanIdentifier
253+
254+
/-- Response for the `$/lean/queryModule` watchdog <- worker request. -/
255+
structure LeanQueryModuleResponse where
256+
/--
257+
Results for each query in `LeanQueryModuleParams`.
258+
Positions correspond to `queries` in the parameter of the request.
259+
-/
260+
queryResults : Array LeanQueriedModule
261+
deriving FromJson, ToJson, Inhabited
262+
204263
end Lean.Lsp

src/Lean/Elab/App.lean

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,8 +1222,8 @@ private def resolveLValAux (e : Expr) (eType : Expr) (lval : LVal) : TermElabM L
12221222
-- Then search the environment
12231223
if let some (baseStructName, fullName) ← findMethod? structName (.mkSimple fieldName) then
12241224
return LValResolution.const baseStructName structName fullName
1225-
throwLValError e eType
1226-
m!"invalid field '{fieldName}', the environment does not contain '{Name.mkStr structName fieldName}'"
1225+
let msg := mkUnknownIdentifierMessage m!"invalid field '{fieldName}', the environment does not contain '{Name.mkStr structName fieldName}'"
1226+
throwLValError e eType msg
12271227
| none, LVal.fieldName _ _ (some suffix) _ =>
12281228
if e.isConst then
12291229
throwUnknownConstant (e.constName! ++ suffix)
@@ -1502,7 +1502,7 @@ where
15021502
else if let some (fvar, []) ← resolveLocalName idNew then
15031503
return fvar
15041504
else
1505-
throwError "invalid dotted identifier notation, unknown identifier `{idNew}` from expected type{indentExpr expectedType}"
1505+
throwUnknownIdentifier m!"invalid dotted identifier notation, unknown identifier `{idNew}` from expected type{indentExpr expectedType}"
15061506
catch
15071507
| ex@(.error ..) =>
15081508
match (← unfoldDefinition? resultType) with
@@ -1550,7 +1550,7 @@ private partial def elabAppFn (f : Syntax) (lvals : List LVal) (namedArgs : Arra
15501550
| `(@$_) => throwUnsupportedSyntax -- invalid occurrence of `@`
15511551
| `(_) => throwError "placeholders '_' cannot be used where a function is expected"
15521552
| `(.$id:ident) =>
1553-
addCompletionInfo <| CompletionInfo.dotId f id.getId (← getLCtx) expectedType?
1553+
addCompletionInfo <| CompletionInfo.dotId id id.getId (← getLCtx) expectedType?
15541554
let fConst ← resolveDotName id expectedType?
15551555
let s ← observing do
15561556
-- Use (force := true) because we want to record the result of .ident resolution even in patterns

src/Lean/Elab/Term.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1971,7 +1971,7 @@ where
19711971
isValidAutoBoundImplicitName n (relaxedAutoImplicit.get (← getOptions)) then
19721972
throwAutoBoundImplicitLocal n
19731973
else
1974-
throwError "unknown identifier '{Lean.mkConst n}'"
1974+
throwUnknownIdentifier m!"unknown identifier '{Lean.mkConst n}'"
19751975
mkConsts candidates explicitLevels
19761976

19771977
/--

src/Lean/Exception.lean

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,31 @@ protected def throwError [Monad m] [MonadError m] (msg : MessageData) : m α :=
6969
let (ref, msg) ← AddErrorMessageContext.add ref msg
7070
throw <| Exception.error ref msg
7171

72+
/--
73+
Tag used for `unknown identifier` messages.
74+
This tag is used by the 'import unknown identifier' code action to detect messages that should
75+
prompt the code action.
76+
-/
77+
def unknownIdentifierMessageTag : Name := `unknownIdentifier
78+
79+
/--
80+
Creates a `MessageData` that is tagged with `unknownIdentifierMessageTag`.
81+
This tag is used by the 'import unknown identifier' code action to detect messages that should
82+
prompt the code action.
83+
-/
84+
def mkUnknownIdentifierMessage (msg : MessageData) : MessageData :=
85+
MessageData.tagged unknownIdentifierMessageTag msg
86+
87+
/--
88+
Throw an unknown identifier error message that is tagged with `unknownIdentifierMessageTag`.
89+
See also `mkUnknownIdentifierMessage`.
90+
-/
91+
def throwUnknownIdentifier [Monad m] [MonadError m] (msg : MessageData) : m α :=
92+
Lean.throwError <| mkUnknownIdentifierMessage msg
93+
7294
/-- Throw an unknown constant error message. -/
7395
def throwUnknownConstant [Monad m] [MonadError m] (constName : Name) : m α :=
74-
Lean.throwError m!"unknown constant '{.ofConstName constName}'"
96+
throwUnknownIdentifier m!"unknown constant '{.ofConstName constName}'"
7597

7698
/-- Throw an error exception using the given message data and reference syntax. -/
7799
protected def throwErrorAt [Monad m] [MonadError m] (ref : Syntax) (msg : MessageData) : m α := do

src/Lean/Server/AsyncList.lean

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ partial def getFinishedPrefix : AsyncList ε α → BaseIO (List α × Option ε
8080
else pure ⟨[], none, false
8181

8282
partial def getFinishedPrefixWithTimeout (xs : AsyncList ε α) (timeoutMs : UInt32)
83-
(cancelTk? : Option (ServerTask Unit) := none) : BaseIO (List α × Option ε × Bool) := do
83+
(cancelTks : List (ServerTask Unit) := []) : BaseIO (List α × Option ε × Bool) := do
8484
let timeoutTask : ServerTask (Unit ⊕ Except ε (AsyncList ε α)) ←
8585
if timeoutMs == 0 then
8686
pure <| ServerTask.pure (Sum.inl ())
@@ -100,21 +100,17 @@ where
100100
| delayed tl =>
101101
let tl : ServerTask (Except ε (AsyncList ε α)) := tl
102102
let tl := tl.mapCheap .inr
103-
let cancelTk? := do return (← cancelTk?).mapCheap .inl
104-
let tasks : { t : List _ // t.length > 0 } :=
105-
match cancelTk? with
106-
| none => ⟨[tl, timeoutTask], by exact Nat.zero_lt_succ _⟩
107-
| some cancelTk => ⟨[tl, cancelTk, timeoutTask], by exact Nat.zero_lt_succ _⟩
108-
let r ← ServerTask.waitAny tasks.val (h := tasks.property)
103+
let cancelTks := cancelTks.map (·.mapCheap .inl)
104+
let r ← ServerTask.waitAny (tl :: cancelTks ++ [timeoutTask])
109105
match r with
110106
| .inl _ => return ⟨[], none, false-- Timeout or cancellation - stop waiting
111107
| .inr (.ok tl) => go timeoutTask tl
112108
| .inr (.error e) => return ⟨[], some e, true
113109

114110
partial def getFinishedPrefixWithConsistentLatency (xs : AsyncList ε α) (latencyMs : UInt32)
115-
(cancelTk? : Option (ServerTask Unit) := none) : BaseIO (List α × Option ε × Bool) := do
111+
(cancelTks : List (ServerTask Unit) := []) : BaseIO (List α × Option ε × Bool) := do
116112
let timestamp ← IO.monoMsNow
117-
let r ← xs.getFinishedPrefixWithTimeout latencyMs cancelTk?
113+
let r ← xs.getFinishedPrefixWithTimeout latencyMs cancelTks
118114
let passedTimeMs := (← IO.monoMsNow) - timestamp
119115
let remainingLatencyMs := (latencyMs.toNat - passedTimeMs).toUInt32
120116
sleepWithCancellation remainingLatencyMs
@@ -123,14 +119,14 @@ where
123119
sleepWithCancellation (sleepDurationMs : UInt32) : BaseIO Unit := do
124120
if sleepDurationMs == 0 then
125121
return
126-
let some cancelTk := cancelTk?
127-
| IO.sleep sleepDurationMs
128-
return
129-
ifcancelTk.hasFinished then
122+
if cancelTks.isEmpty then
123+
IO.sleep sleepDurationMs
124+
return
125+
ifcancelTks.anyM (·.hasFinished) then
130126
return
131127
let sleepTask ← Lean.Server.ServerTask.BaseIO.asTask do
132128
IO.sleep sleepDurationMs
133-
ServerTask.waitAny [sleepTask, cancelTk]
129+
ServerTask.waitAny <| sleepTask :: cancelTks
134130

135131
end AsyncList
136132

src/Lean/Server/CodeActions/Basic.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def handleCodeActionResolve (param : CodeAction) : RequestM (RequestTask CodeAct
147147
let doc ← readDoc
148148
let some data := param.data?
149149
| throw (RequestError.invalidParams "Expected a data field on CodeAction.")
150-
let data : CodeActionResolveData ← liftExcept <| Except.mapError RequestError.invalidParams <| fromJson? data
150+
let data ← RequestM.parseRequestParams CodeActionResolveData data
151151
let pos := doc.meta.text.lspPosToUtf8Pos data.params.range.end
152152
withWaitFindSnap doc (fun s => s.endPos ≥ pos)
153153
(notFoundX := throw <| RequestError.internalError "snapshot not found")

0 commit comments

Comments
 (0)