lean4-htt/src/Lean/Server/References.lean
Marc Huisinga 78a72741c6
fix: jump to correct definition when names overlap (#3656)
Fixes #1170.

This PR adds the module name to `RefIdent` in order to distinguish
conflicting names from different files. This also fixes related issues
in find-references or the call hierarchy feature.
It also adds some docstrings and stylistically refactors a bunch of
code.
2024-03-14 16:21:19 +00:00

523 lines
20 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) 2021 Joscha Mennicken. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Authors: Joscha Mennicken
-/
prelude
import Lean.Data.Lsp.Internal
import Lean.Server.Utils
/-! # Representing collected and deduplicated definitions and usages -/
set_option linter.missingDocs true
namespace Lean.Server
open Lsp Lean.Elab Std
/--
Global reference. Used by the language server to figure out which identifiers refer to which
other identifiers across the whole project.
-/
structure Reference where
/-- Identifier of this reference. -/
ident : RefIdent
/-- Identifiers that are logically identical to this reference. -/
aliases : Array RefIdent := #[]
/-- Range where this reference occurs. -/
range : Lsp.Range
/-- Syntax of this reference. -/
stx : Syntax
/-- `ContextInfo` at the point of elaboration of this reference. -/
ci : ContextInfo
/-- Additional `InfoTree` information for this reference. -/
info : Info
/-- Whether this reference declares `ident`. -/
isBinder : Bool
/-- Definition and usages of an identifier within a single module. -/
structure RefInfo where
/--
Definition `Reference` of the identifier.
Is equal to `none` if e.g. the definition is outside of the module where this `RefInfo` is used.
-/
definition : Option Reference
/-- All usage `Reference`s of the identifier in a single module. -/
usages : Array Reference
namespace RefInfo
/-- No definition, no usages. -/
def empty : RefInfo := ⟨none, #[]⟩
/--
Adds `ref` to `i`.
If `i` has no `definition` and `ref` is a declaration, it becomes the `definition`.
If `i` already has a `definition` and `ref` is also a declaration, it is not added to `i`.
Otherwise, `ref` is added to `i.usages`.
-/
def addRef (i : RefInfo) (ref : Reference) : RefInfo :=
match i, ref with
| { definition := none, .. }, { isBinder := true, .. } =>
{ i with definition := ref }
| { definition := some .., .. }, { isBinder := true, .. } =>
i
| { usages, .. }, { isBinder := false, .. } =>
{ i with usages := usages.push ref }
/-- Converts `i` to a JSON-serializable `Lsp.RefInfo`. -/
def toLspRefInfo (i : RefInfo) : BaseIO Lsp.RefInfo := do
let refToRefInfoLocation (ref : Reference) : BaseIO RefInfo.Location := do
let parentDeclName? := ref.ci.parentDecl?
let .ok parentDeclRanges? ← EIO.toBaseIO <| ref.ci.runCoreM do
let some parentDeclName := parentDeclName?
| return none
findDeclarationRanges? parentDeclName
-- we only use `CoreM` to get access to a `MonadEnv`, but these are currently all `IO`
| unreachable!
return {
range := ref.range
parentDecl? := do
let parentDeclName ← parentDeclName?
let parentDeclRange := (← parentDeclRanges?).range.toLspRange
let parentDeclSelectionRange := (← parentDeclRanges?).selectionRange.toLspRange
return ⟨parentDeclName, parentDeclRange, parentDeclSelectionRange⟩
}
let definition? ← i.definition.mapM refToRefInfoLocation
let usages ← i.usages.mapM refToRefInfoLocation
return {
definition? := definition?
usages := usages
}
end RefInfo
/-- All references from within a module for all identifiers used in a single module. -/
def ModuleRefs := HashMap RefIdent RefInfo
namespace ModuleRefs
/-- Adds `ref` to the `RefInfo` corresponding to `ref.ident` in `self`. See `RefInfo.addRef`. -/
def addRef (self : ModuleRefs) (ref : Reference) : ModuleRefs :=
let refInfo := self.findD ref.ident RefInfo.empty
self.insert ref.ident (refInfo.addRef ref)
/-- Converts `refs` to a JSON-serializable `Lsp.ModuleRefs`. -/
def toLspModuleRefs (refs : ModuleRefs) : BaseIO Lsp.ModuleRefs := do
let refs ← refs.toList.mapM fun (k, v) => do
return (k, ← v.toLspRefInfo)
return HashMap.ofList refs
end ModuleRefs
end Lean.Server
namespace Lean.Lsp.RefInfo
open Server
/-- No definition, no usages -/
def empty : RefInfo := ⟨ none, #[] ⟩
/-- Combines the `usages` of `a` and `b` and prefers the `definition?` of `b` over that of `a`. -/
def merge (a : RefInfo) (b : RefInfo) : RefInfo where
definition? := b.definition?.orElse fun _ => a.definition?
usages := a.usages.append b.usages
/--
Finds the first definition or usage in `self` where the `RefInfo.Location.range`
contains the given `pos`. The `includeStop` parameter can be used to toggle between closed-interval
and half-open-interval behavior for the range. Closed-interval behavior matches the expectation of
VSCode when selecting an identifier at a cursor position (see #767).
-/
def findReferenceLocation?
(self : RefInfo)
(pos : Lsp.Position)
(includeStop : Bool := false)
: Option Location := do
if let some loc := self.definition? then
if contains loc.range pos then
return loc
for loc in self.usages do
if contains loc.range pos then
return loc
none
where
contains (range : Lsp.Range) (pos : Lsp.Position) : Bool :=
range.start <= pos && (if includeStop then pos <= range.end else pos < range.end)
/-- Checks whether any of the ranges in `self.definition?` or `self.usages` contains `pos`. -/
def contains (self : RefInfo) (pos : Lsp.Position) (includeStop := false) : Bool := Id.run do
(self.findReferenceLocation? pos includeStop).isSome
end Lean.Lsp.RefInfo
namespace Lean.Lsp.ModuleRefs
open Server
/--
Find all identifiers in `self` with a reference in this module that contains `pos` in its range.
-/
def findAt
(self : ModuleRefs)
(pos : Lsp.Position)
(includeStop := false)
: Array RefIdent := Id.run do
let mut result := #[]
for (ident, info) in self.toArray do
if info.contains pos includeStop then
result := result.push ident
result
/-- Finds the first range in `self` that contains `pos`. -/
def findRange? (self : ModuleRefs) (pos : Lsp.Position) (includeStop := false) : Option Range := do
for (_, info) in self.toList do
if let some loc := info.findReferenceLocation? pos includeStop then
return loc.range
none
end Lean.Lsp.ModuleRefs
namespace Lean.Server
open IO
open Lsp
open Elab
/-- Content of individual `.ilean` files -/
structure Ilean where
/-- Version number of the ilean format. -/
version : Nat := 3
/-- Name of the module that this ilean data has been collected for. -/
module : Name
/-- All references of this module. -/
references : Lsp.ModuleRefs
deriving FromJson, ToJson
namespace Ilean
/-- Reads and parses the .ilean file at `path`. -/
def load (path : System.FilePath) : IO Ilean := do
let content ← FS.readFile path
match Json.parse content >>= fromJson? with
| Except.ok ilean => pure ilean
| Except.error msg => throwServerError s!"Failed to load ilean at {path}: {msg}"
end Ilean
/-! # Collecting and deduplicating definitions and usages -/
/--
Determines the `RefIdent` for the `Info` `i` of an identifier in `module` and
whether it is a declaration.
-/
def identOf (module : Name) (i : Info) : Option (RefIdent × Bool) :=
match i with
| Info.ofTermInfo ti => match ti.expr with
| Expr.const n .. => some (RefIdent.const module n, ti.isBinder)
| Expr.fvar id => some (RefIdent.fvar module id, ti.isBinder)
| _ => none
| Info.ofFieldInfo fi => some (RefIdent.const module fi.projName, false)
| Info.ofOptionInfo oi => some (RefIdent.const module oi.declName, false)
| _ => none
/-- Finds all references in `trees`. -/
def findReferences (text : FileMap) (trees : Array InfoTree) : Array Reference :=
Id.run <| StateT.run' (s := #[]) do
for tree in trees do
tree.visitM' (postNode := fun ci info _ => do
let mod := ci.env.header.mainModule
let some (ident, isBinder) := identOf mod info
| return
let some range := info.range?
| return
if info.stx.getHeadInfo matches .original .. then -- we are not interested in canonical syntax here
modify (·.push { ident, range := range.toLspRange text, stx := info.stx, ci, info, isBinder }))
get
/--
There are several different identifiers that should be considered equal for the purpose of finding
all references of an identifier:
- `FVarId`s of a function parameter in the function's signature and body
- Chains of helper definitions like those created for do-reassignment `x := e`
- Overlapping definitions like those defined by `where` declarations that define both an FVar
(for local usage) and a constant (for non-local usage)
- Identifiers connected by `FVarAliasInfo` such as variables before and after `match` generalization
In the first three cases that are not explicitly denoted as aliases with an `FVarAliasInfo`, the
corresponding `Reference`s have the exact same range.
This function finds all definitions that have the exact same range as another definition or usage
and collapses them into a single identifier. It also collapses identifiers connected by
an `FVarAliasInfo`.
When collapsing identifiers, it prefers using a `RefIdent.const name` over a `RefIdent.fvar id` for
all identifiers that are being collapsed into one.
-/
partial def combineIdents (trees : Array InfoTree) (refs : Array Reference) : Array Reference := Id.run do
-- Deduplicate definitions based on their exact range
let mut posMap : HashMap Lsp.Range RefIdent := HashMap.empty
for ref in refs do
if let { ident, range, isBinder := true, .. } := ref then
posMap := posMap.insert range ident
let idMap := useConstRepresentatives <| buildIdMap posMap
let mut refs' := #[]
for ref in refs do
let id := ref.ident
if idMap.contains id then
refs' := refs'.push { ref with ident := findCanonicalRepresentative idMap id, aliases := #[id] }
else if !idMap.contains id then
refs' := refs'.push ref
refs'
where
useConstRepresentatives (idMap : HashMap RefIdent RefIdent)
: HashMap RefIdent RefIdent := Id.run do
let insertIntoClass classesById id :=
let representative := findCanonicalRepresentative idMap id
let «class» := classesById.findD representative ∅
let classesById := classesById.erase representative -- make `«class»` referentially unique
let «class» := «class».insert id
classesById.insert representative «class»
-- collect equivalence classes
let mut classesById : HashMap RefIdent (HashSet RefIdent) := ∅
for ⟨id, baseId⟩ in idMap.toArray do
classesById := insertIntoClass classesById id
classesById := insertIntoClass classesById baseId
let mut r := ∅
for ⟨currentRepresentative, «class»⟩ in classesById.toArray do
-- find best representative (ideally a const if available)
let mut bestRepresentative := currentRepresentative
for id in «class» do
bestRepresentative :=
match bestRepresentative, id with
| .fvar ma a, .fvar .. => .fvar ma a
| .fvar .., .const mb b => .const mb b
| .const ma a, .fvar .. => .const ma a
| .const ma a, .const .. => .const ma a
-- compress `idMap` so that all identifiers in a class point to the best representative
for id in «class» do
if id != bestRepresentative then
r := r.insert id bestRepresentative
return r
findCanonicalRepresentative (idMap : HashMap RefIdent RefIdent) (id : RefIdent) : RefIdent := Id.run do
let mut canonicalRepresentative := id
while idMap.contains canonicalRepresentative do
canonicalRepresentative := idMap.find! canonicalRepresentative
return canonicalRepresentative
buildIdMap posMap := Id.run <| StateT.run' (s := HashMap.empty) do
-- map fvar defs to overlapping fvar defs/uses
for ref in refs do
let baseId := ref.ident
if let some id := posMap.find? ref.range then
insertIdMap id baseId
-- apply `FVarAliasInfo`
trees.forM (·.visitM' (postNode := fun ci info _ => do
if let .ofFVarAliasInfo ai := info then
-- FVars can only be aliases of FVars of the same file / module
let mod := ci.env.header.mainModule
insertIdMap (.fvar mod ai.id) (.fvar mod ai.baseId)))
get
-- poor man's union-find; see also `findCanonicalBinder`
insertIdMap id baseId := do
let idMap ← get
let id := findCanonicalRepresentative idMap id
let baseId := findCanonicalRepresentative idMap baseId
if baseId != id then
modify (·.insert id baseId)
/--
Groups `refs` by identifier and range s.t. references with the same identifier and range
are added to the `aliases` of the representative of the group.
Yields to separate groups for declaration and usages if `allowSimultaneousBinderUse` is set.
-/
def dedupReferences (refs : Array Reference) (allowSimultaneousBinderUse := false) : Array Reference := Id.run do
let mut refsByIdAndRange : HashMap (RefIdent × Option Bool × Lsp.Range) Reference := HashMap.empty
for ref in refs do
let isBinder := if allowSimultaneousBinderUse then some ref.isBinder else none
let key := (ref.ident, isBinder, ref.range)
refsByIdAndRange := match refsByIdAndRange[key] with
| some ref' => refsByIdAndRange.insert key { ref' with aliases := ref'.aliases ++ ref.aliases }
| none => refsByIdAndRange.insert key ref
let dedupedRefs := refsByIdAndRange.fold (init := #[]) fun refs _ ref => refs.push ref
return dedupedRefs.qsort (·.range < ·.range)
/--
Finds all references in `trees` and deduplicates the result.
See `dedupReferences` and `combineIdents`.
-/
def findModuleRefs (text : FileMap) (trees : Array InfoTree) (localVars : Bool := true)
(allowSimultaneousBinderUse := false) : ModuleRefs := Id.run do
let mut refs :=
dedupReferences (allowSimultaneousBinderUse := allowSimultaneousBinderUse) <|
combineIdents trees <|
findReferences text trees
if !localVars then
refs := refs.filter fun
| { ident := RefIdent.fvar .., .. } => false
| _ => true
refs.foldl (init := HashMap.empty) fun m ref => m.addRef ref
/-! # Collecting and maintaining reference info from different sources -/
/-- References from ilean files and current ilean information from file workers. -/
structure References where
/-- References loaded from ilean files -/
ileans : HashMap Name (System.FilePath × Lsp.ModuleRefs)
/-- References from workers, overriding the corresponding ilean files -/
workers : HashMap Name (Nat × Lsp.ModuleRefs)
namespace References
/-- No ilean files, no information from workers. -/
def empty : References := { ileans := HashMap.empty, workers := HashMap.empty }
/-- Adds the contents of an ilean file `ilean` at `path` to `self`. -/
def addIlean (self : References) (path : System.FilePath) (ilean : Ilean) : References :=
{ self with ileans := self.ileans.insert ilean.module (path, ilean.references) }
/-- Removes the ilean file data at `path` from `self`. -/
def removeIlean (self : References) (path : System.FilePath) : References :=
let namesToRemove := self.ileans.toList.filter (fun (_, p, _) => p == path)
|>.map (fun (n, _, _) => n)
namesToRemove.foldl (init := self) fun self name =>
{ self with ileans := self.ileans.erase name }
/--
Updates the worker references in `self` with the `refs` of the worker managing the module `name`.
Replaces the current references with `refs` if `version` is newer than the current version managed
in `refs` and otherwise merges the reference data if `version` is equal to the current version.
-/
def updateWorkerRefs (self : References) (name : Name) (version : Nat) (refs : Lsp.ModuleRefs) : References := Id.run do
if let some (currVersion, _) := self.workers.find? name then
if version > currVersion then
return { self with workers := self.workers.insert name (version, refs) }
if version == currVersion then
let current := self.workers.findD name (version, HashMap.empty)
let merged := refs.fold (init := current.snd) fun m ident info =>
m.findD ident Lsp.RefInfo.empty |>.merge info |> m.insert ident
return { self with workers := self.workers.insert name (version, merged) }
return self
/--
Replaces the worker references in `self` with the `refs` of the worker managing the module `name`
if `version` is newer than the current version managed in `refs`.
-/
def finalizeWorkerRefs (self : References) (name : Name) (version : Nat) (refs : Lsp.ModuleRefs) : References := Id.run do
if let some (currVersion, _) := self.workers.find? name then
if version < currVersion then
return self
return { self with workers := self.workers.insert name (version, refs) }
/-- Erases all worker references in `self` for the worker managing `name`. -/
def removeWorkerRefs (self : References) (name : Name) : References :=
{ self with workers := self.workers.erase name }
/-- Yields a map from all modules to all of their references. -/
def allRefs (self : References) : HashMap Name Lsp.ModuleRefs :=
let ileanRefs := self.ileans.toArray.foldl (init := HashMap.empty) fun m (name, _, refs) => m.insert name refs
self.workers.toArray.foldl (init := ileanRefs) fun m (name, _, refs) => m.insert name refs
/--
Yields all references in `self` for `ident`, as well as the `DocumentUri` that each
reference occurs in.
-/
def allRefsFor
(self : References)
(srcSearchPath : SearchPath)
(ident : RefIdent)
: IO (Array (DocumentUri × Lsp.RefInfo)) := do
let refsToCheck := match ident with
| RefIdent.const .. => self.allRefs.toArray
| RefIdent.fvar identModule .. =>
match self.allRefs.find? identModule with
| none => #[]
| some refs => #[(identModule, refs)]
let mut result := #[]
for (module, refs) in refsToCheck do
let some info := refs.find? ident
| continue
let some path ← srcSearchPath.findModuleWithExt "lean" module
| continue
-- Resolve symlinks (such as `src` in the build dir) so that files are
-- opened in the right folder
let uri := System.Uri.pathToUri <| ← IO.FS.realPath path
result := result.push (uri, info)
return result
/-- Yields all references in `module` at `pos`. -/
def findAt (self : References) (module : Name) (pos : Lsp.Position) (includeStop := false) : Array RefIdent := Id.run do
if let some refs := self.allRefs.find? module then
return refs.findAt pos includeStop
#[]
/-- Yields the first reference in `module` at `pos`. -/
def findRange? (self : References) (module : Name) (pos : Lsp.Position) (includeStop := false) : Option Range := do
let refs ← self.allRefs.find? module
refs.findRange? pos includeStop
/-- Location and parent declaration of a reference. -/
structure DocumentRefInfo where
/-- Location of the reference. -/
location : Location
/-- Parent declaration of the reference. -/
parentInfo? : Option RefInfo.ParentDecl
/-- Yields locations and parent declaration for all references referring to `ident`. -/
def referringTo
(self : References)
(srcSearchPath : SearchPath)
(ident : RefIdent)
(includeDefinition : Bool := true)
: IO (Array DocumentRefInfo) := do
let mut result := #[]
for (moduleUri, info) in ← self.allRefsFor srcSearchPath ident do
if includeDefinition then
if let some ⟨range, parentDeclInfo?⟩ := info.definition? then
result := result.push ⟨⟨moduleUri, range⟩, parentDeclInfo?⟩
for ⟨range, parentDeclInfo?⟩ in info.usages do
result := result.push ⟨⟨moduleUri, range⟩, parentDeclInfo?⟩
return result
/-- Yields the definition location of `ident`. -/
def definitionOf?
(self : References)
(ident : RefIdent)
(srcSearchPath : SearchPath)
: IO (Option DocumentRefInfo) := do
for (moduleUri, info) in ← self.allRefsFor srcSearchPath ident do
let some ⟨definitionRange, definitionParentDeclInfo?⟩ := info.definition?
| continue
return some ⟨⟨moduleUri, definitionRange⟩, definitionParentDeclInfo?⟩
return none
/-- Yields all definitions matching the given `filter`. -/
def definitionsMatching
(self : References)
(srcSearchPath : SearchPath)
(filter : Name → Option α)
(maxAmount? : Option Nat := none) : IO $ Array (α × Location) := do
let mut result := #[]
for (module, refs) in self.allRefs.toList do
let some path ← srcSearchPath.findModuleWithExt "lean" module
| continue
let uri := System.Uri.pathToUri <| ← IO.FS.realPath path
for (ident, info) in refs.toList do
let (RefIdent.const _ name, some ⟨definitionRange, _⟩) := (ident, info.definition?)
| continue
let some a := filter name
| continue
result := result.push (a, ⟨uri, definitionRange⟩)
if let some maxAmount := maxAmount? then
if result.size >= maxAmount then
return result
return result
end References
end Lean.Server