/- Copyright (c) 2022 Sebastian Ullrich. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Authors: Sebastian Ullrich, Mario Carneiro -/ prelude import Lean.Elab.Command import Lean.Linter.Util set_option linter.missingDocs true -- keep it documented /-! # Unused variable Linter This file implements the unused variable linter, which runs automatically on all commands and reports any local variables that are never referred to, using information from the info tree. It is not immediately obvious but this is a surprisingly expensive check without some optimizations. The main complication is that it can be difficult to determine what constitutes a "use". For example, we would like this to be considered a use of `x`: ``` def foo (x : Nat) : Nat := by assumption ``` The final proof term is `fun x => x` so clearly `x` was used, but we can't make use of this because the final proof term is after we have abstracted over the original `fvar` for `x`. If we look further into the tactic state we can see the `fvar` show up in the instantiation to the original goal metavariable `?m : Nat := x`, but it is not always the case that we can follow metavariable instantiations to determine what happened after the fact, because tactics might skip the goal metavariable and instantiate some other metavariable created prior to it instead. Instead, we use a (much more expensive) overapproximation, which is just to look through the entire metavariable context looking for occurrences of `x`. We use caching to ensure that this is still linear in the size of the info tree, even though there are many metavariable contexts in all the intermediate stages of elaboration; these are highly similar and make use of `PersistentHashMap` so there is a lot of subterm sharing we can take advantage of. ## The `@[unused_variables_ignore_fn]` attribute Some occurrences of variables are deliberately unused, or at least we don't want to lint on unused variables in these positions. For example: ``` def foo (x : Nat) : (y : Nat) → Nat := fun _ => x -- ^ don't lint this unused variable because it is public API ``` They are generally a syntactic criterion, so we allow adding custom `IgnoreFunction`s so that external syntax can also opt in to lint suppression, like so: ``` macro (name := foobarKind) "foobar " name:ident : command => `(def foo ($name : Nat) := 0) foobar n -- linted because n is unused in the macro expansion @[unused_variables_ignore_fn] def ignoreFoobar : Lean.Linter.IgnoreFunction := fun _ stack _ => stack.matches [``foobarKind] foobar n -- not linted ``` -/ namespace Lean.Linter open Lean.Elab.Command Lean.Server Std /-- Enables or disables all unused variable linter warnings -/ register_builtin_option linter.unusedVariables : Bool := { defValue := true, descr := "enable the 'unused variables' linter" } /-- Enables or disables unused variable linter warnings in function arguments -/ register_builtin_option linter.unusedVariables.funArgs : Bool := { defValue := true, descr := "enable the 'unused variables' linter to mark unused function arguments" } /-- Enables or disables unused variable linter warnings in patterns -/ register_builtin_option linter.unusedVariables.patternVars : Bool := { defValue := true, descr := "enable the 'unused variables' linter to mark unused pattern variables" } /-- Gets the status of `linter.unusedVariables` -/ def getLinterUnusedVariables (o : Options) : Bool := getLinterValue linter.unusedVariables o /-- Gets the status of `linter.unusedVariables.funArgs` -/ def getLinterUnusedVariablesFunArgs (o : Options) : Bool := o.get linter.unusedVariables.funArgs.name (getLinterUnusedVariables o) /-- Gets the status of `linter.unusedVariables.patternVars` -/ def getLinterUnusedVariablesPatternVars (o : Options) : Bool := o.get linter.unusedVariables.patternVars.name (getLinterUnusedVariables o) /-- An `IgnoreFunction` receives: * a `Syntax.ident` for the unused variable * a `Syntax.Stack` with the location of this piece of syntax in the command * The `Options` set locally to this syntax and should return `true` to indicate that the lint should be suppressed, or `false` to proceed with linting as usual (other `IgnoreFunction`s may still say it is ignored). A variable is only linted if it is unused and no `IgnoreFunction` returns `true` on this syntax. -/ abbrev IgnoreFunction := Syntax → Syntax.Stack → Options → Bool /-- Interpret an `IgnoreFunction` from the environment. -/ unsafe def mkIgnoreFnImpl (constName : Name) : ImportM IgnoreFunction := do let { env, opts, .. } ← read match env.find? constName with | none => throw ↑s!"unknown constant '{constName}'" | some info => unless info.type.isConstOf ``IgnoreFunction do throw ↑s!"unexpected unused_variables_ignore_fn at '{constName}', must be of type `Lean.Linter.IgnoreFunction`" IO.ofExcept <| env.evalConst IgnoreFunction opts constName @[inherit_doc mkIgnoreFnImpl, implemented_by mkIgnoreFnImpl] opaque mkIgnoreFn (constName : Name) : ImportM IgnoreFunction /-- The list of builtin `IgnoreFunction`s. -/ builtin_initialize builtinUnusedVariablesIgnoreFnsRef : IO.Ref <| Array IgnoreFunction ← IO.mkRef #[] /-- Adds a new builtin `IgnoreFunction`. This function should only be used from within the `Lean` package. -/ def addBuiltinUnusedVariablesIgnoreFn (h : IgnoreFunction) : IO Unit := builtinUnusedVariablesIgnoreFnsRef.modify (·.push h) /-- An extension which keeps track of registered `IgnoreFunction`s. -/ builtin_initialize unusedVariablesIgnoreFnsExt : PersistentEnvExtension Name (Name × IgnoreFunction) (List Name × Array IgnoreFunction) ← registerPersistentEnvExtension { mkInitial := return ([], ← builtinUnusedVariablesIgnoreFnsRef.get) addImportedFn := fun as => do ([], ·) <$> as.foldlM (init := ← builtinUnusedVariablesIgnoreFnsRef.get) fun s as => as.foldlM (init := s) fun s n => s.push <$> mkIgnoreFn n addEntryFn := fun (entries, s) (n, h) => (n::entries, s.push h) exportEntriesFn := fun s => s.1.reverse.toArray statsFn := fun s => format "number of local entries: " ++ format s.1.length } /-- Adds the `@[{builtin_}unused_variables_ignore_fn]` attribute, which is applied to declarations of type `IgnoreFunction` for use by the unused variables linter. -/ builtin_initialize let mkAttr (builtin : Bool) (name : Name) := registerBuiltinAttribute { name descr := (if builtin then "(builtin) " else "") ++ "Marks a function of type `Lean.Linter.IgnoreFunction` for suppressing unused variable warnings" applicationTime := .afterCompilation add := fun decl stx kind => do Attribute.Builtin.ensureNoArgs stx unless kind == AttributeKind.global do throwError "invalid attribute '{name}', must be global" unless (← getConstInfo decl).type.isConstOf ``IgnoreFunction do throwError "invalid attribute '{name}', must be of type `Lean.Linter.IgnoreFunction`" let env ← getEnv if builtin then let h := mkConst decl declareBuiltin decl <| mkApp (mkConst ``addBuiltinUnusedVariablesIgnoreFn) h else setEnv <| unusedVariablesIgnoreFnsExt.addEntry env (decl, ← mkIgnoreFn decl) } mkAttr true `builtin_unused_variables_ignore_fn mkAttr false `unused_variables_ignore_fn /-- `variable (unused : Nat)` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack _ => stack.matches [`null, none, `null, ``Lean.Parser.Command.variable]) /-- `structure Foo where unused : Nat` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack _ => stack.matches [`null, none, `null, ``Lean.Parser.Command.structure]) /-- `inductive Foo where | unused : Foo` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack _ => stack.matches [`null, none, `null, none, ``Lean.Parser.Command.inductive] && (stack.get? 3 |>.any fun (stx, pos) => pos == 0 && [``Lean.Parser.Command.optDeclSig, ``Lean.Parser.Command.declSig].any (stx.isOfKind ·))) /-- * `structure Foo where foo (unused : Nat) : Nat` * `inductive Foo where | mk (unused : Nat) : Foo` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack _ => stack.matches [`null, none, `null, ``Lean.Parser.Command.optDeclSig, none] && (stack.get? 4 |>.any fun (stx, _) => [``Lean.Parser.Command.ctor, ``Lean.Parser.Command.structSimpleBinder].any (stx.isOfKind ·))) /-- * `opaque foo (unused : Nat) : Nat` * `axiom foo (unused : Nat) : Nat` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack _ => stack.matches [`null, none, `null, ``Lean.Parser.Command.declSig, none] && (stack.get? 4 |>.any fun (stx, _) => [``Lean.Parser.Command.opaque, ``Lean.Parser.Command.axiom].any (stx.isOfKind ·))) /-- Definition with foreign definition * `@[extern "bla"] def foo (unused : Nat) : Nat := ...` * `@[implemented_by bla] def foo (unused : Nat) : Nat := ...` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack _ => stack.matches [`null, none, `null, none, none, ``Lean.Parser.Command.declaration] && (stack.get? 3 |>.any fun (stx, _) => stx.isOfKind ``Lean.Parser.Command.optDeclSig || stx.isOfKind ``Lean.Parser.Command.declSig) && (stack.get? 5 |>.any fun (stx, _) => match stx[0] with | `(Lean.Parser.Command.declModifiersT| $[$_:docComment]? @[$[$attrs:attr],*] $[$vis]? $[noncomputable]?) => attrs.any (fun attr => attr.raw.isOfKind ``Parser.Attr.extern || attr matches `(attr| implemented_by $_)) | _ => false)) /-- Dependent arrow * `def foo : (unused : Nat) → Nat := id` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack _ => stack.matches [`null, ``Lean.Parser.Term.explicitBinder, ``Lean.Parser.Term.depArrow]) /-- Function argument in let declaration (when `linter.unusedVariables.funArgs` is false) * `def foo := let x (unused : Nat) := 1; x` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack opts => !getLinterUnusedVariablesFunArgs opts && stack.matches [`null, none, `null, ``Lean.Parser.Term.letIdDecl, none] && (stack.get? 3 |>.any fun (_, pos) => pos == 1) && (stack.get? 5 |>.any fun (stx, _) => !stx.isOfKind ``Lean.Parser.Command.whereStructField)) /-- Function argument in declaration signature (when `linter.unusedVariables.funArgs` is false) * `def foo (unused : Nat) := 1` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack opts => !getLinterUnusedVariablesFunArgs opts && stack.matches [`null, none, `null, none] && (stack.get? 3 |>.any fun (stx, pos) => pos == 0 && [``Lean.Parser.Command.optDeclSig, ``Lean.Parser.Command.declSig].any (stx.isOfKind ·))) /-- Function argument in function definition (when `linter.unusedVariables.funArgs` is false) * `def foo := fun (unused : Nat) => 1` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack opts => !getLinterUnusedVariablesFunArgs opts && (stack.matches [`null, ``Lean.Parser.Term.basicFun] || stack.matches [``Lean.Parser.Term.typeAscription, `null, ``Lean.Parser.Term.basicFun])) /-- In pattern (when `linter.unusedVariables.patternVars` is false) * `def foo := match 0 with | unused => 1` -/ builtin_initialize addBuiltinUnusedVariablesIgnoreFn (fun _ stack opts => !getLinterUnusedVariablesPatternVars opts && stack.any fun (stx, pos) => (stx.isOfKind ``Lean.Parser.Term.matchAlt && pos == 1) || (stx.isOfKind ``Lean.Parser.Tactic.inductionAltLHS && pos == 2)) /-- Get the current list of `IgnoreFunction`s. -/ def getUnusedVariablesIgnoreFns : CommandElabM (Array IgnoreFunction) := do return (unusedVariablesIgnoreFnsExt.getState (← getEnv)).2 namespace UnusedVariables /-- Collect into a heterogeneous collection of objects with external storage. This uses pointer identity and does not store the objects, so it is important not to store the last pointer to an object in the map, or it can be freed and reused, resulting in incorrect behavior. Returns `true` if the object was not already in the set. -/ unsafe def insertObjImpl {α : Type} (set : IO.Ref (HashSet USize)) (a : α) : IO Bool := do if (← set.get).contains (ptrAddrUnsafe a) then return false set.modify (·.insert (ptrAddrUnsafe a)) return true @[inherit_doc insertObjImpl, implemented_by insertObjImpl] opaque insertObj {α : Type} (set : IO.Ref (HashSet USize)) (a : α) : IO Bool /-- Collects into `fvarUses` all `fvar`s occurring in the `Expr`s in `assignments`. This implementation respects subterm sharing in both the `PersistentHashMap` and the `Expr` to ensure that pointer-equal subobjects are not visited multiple times, which is important in practice because these expressions are very frequently highly shared. -/ partial def visitAssignments (set : IO.Ref (HashSet USize)) (fvarUses : IO.Ref (HashSet FVarId)) (assignments : Array (PersistentHashMap MVarId Expr)) : IO Unit := do MonadCacheT.run do for assignment in assignments do visitNode assignment.root where /-- Visit a `PersistentHashMap.Node`, collecting all fvars in it into `fvarUses` -/ visitNode node : MonadCacheT Expr Unit IO Unit := do if ← insertObj set node then match node with | .entries entries => for e in entries do visitEntry e | .collision _ vs _ => for e in vs do visitExpr e /-- Visit a `PersistentHashMap.Entry`, collecting all fvars in it into `fvarUses` -/ visitEntry e : MonadCacheT Expr Unit IO Unit := do if ← insertObj set e then match e with | .entry _ e => visitExpr e | .ref node => visitNode node | .null => pure () /-- Visit an `Expr`, collecting all fvars in it into `fvarUses` -/ visitExpr e : MonadCacheT Expr Unit IO Unit := do if ← insertObj set e then ForEachExpr.visit (e := e) fun e => do match e with | .fvar id => fvarUses.modify (·.insert id); return false | _ => return e.hasFVar /-- Given `aliases` as a map from an alias to what it aliases, we get the original term by recursion. This has no cycle detection, so if `aliases` contains a loop then this function will recurse infinitely. -/ partial def followAliases (aliases : HashMap FVarId FVarId) (x : FVarId) : FVarId := match aliases.find? x with | none => x | some y => followAliases aliases y /-- Information regarding an `FVarId` definition. -/ structure FVarDefinition where /-- The user-provided name of the `FVarId` -/ userName : Name /-- A (usually `.ident`) syntax for the defined variable -/ stx : Syntax /-- The options set locally to this part of the syntax (used by `IgnoreFn`) -/ opts : Options /-- The list of all `FVarId`s that are considered as being defined at this position. This can have more than one element if multiple variables are declared by the same syntax, such as the `h` in `if h : c then ... else ...`. We only report an unused variable at this span if all variables in `aliases` are unused. -/ aliases : Array FVarId /-- The main data structure used to collect information from the info tree regarding unused variables. -/ structure References where /-- The set of all (ranges corresponding to) global definitions that are made in the syntax. For example in `mutual def foo := ... def bar := ... where baz := ... end` this would be the spans for `foo`, `bar`, and `baz`. Global definitions are always treated as used. (It would be nice to be able to detect unused global definitions but this requires more information than the linter framework can provide.) -/ constDecls : HashSet String.Range := .empty /-- The collection of all local declarations, organized by the span of the declaration. We collapse all declarations declared at the same position into a single record using `FVarDefinition.aliases`. -/ fvarDefs : HashMap String.Range FVarDefinition := .empty /-- The set of `FVarId`s that are used directly. These may or may not be aliases. -/ fvarUses : HashSet FVarId := .empty /-- A mapping from alias to original FVarId. We don't guarantee that the value is not itself an alias, but we use `followAliases` when adding new elements to try to avoid long chains. -/ -- TODO: use a `UnionFind` data structure here fvarAliases : HashMap FVarId FVarId := .empty /-- Collection of all `MetavarContext`s following the execution of a tactic. We trawl these if needed to find additional `fvarUses`. -/ assignments : Array (PersistentHashMap MVarId Expr) := #[] /-- Collect information from the `infoTrees` into `References`. See `References` for more information about the return value. -/ def collectReferences (infoTrees : Array Elab.InfoTree) (cmdStxRange : String.Range) : StateRefT References IO Unit := do for tree in infoTrees do tree.visitM' (preNode := fun ci info _ => do match info with | .ofTermInfo ti => match ti.expr with | .const .. => if ti.isBinder then let some range := info.range? | return let .original .. := info.stx.getHeadInfo | return -- we are not interested in canonical syntax here modify fun s => { s with constDecls := s.constDecls.insert range } | .fvar id .. => let some range := info.range? | return let .original .. := info.stx.getHeadInfo | return -- we are not interested in canonical syntax here if ti.isBinder then -- This is a local variable declaration. let some ldecl := ti.lctx.find? id | return -- Skip declarations which are outside the command syntax range, like `variable`s -- (it would be confusing to lint these), or those which are macro-generated if !cmdStxRange.contains range.start || ldecl.userName.hasMacroScopes then return let opts := ci.options -- we have to check for the option again here because it can be set locally if !getLinterUnusedVariables opts then return let stx := skipDeclIdIfPresent info.stx if let .str _ s := stx.getId then -- If the variable name is `_foo` then it is intentionally (possibly) unused, so skip. -- This is the suggested way to silence the warning if s.startsWith "_" then return -- Record this either as a new `fvarDefs`, or an alias of an existing one modify fun s => if let some ref := s.fvarDefs.find? range then { s with fvarDefs := s.fvarDefs.insert range { ref with aliases := ref.aliases.push id } } else { s with fvarDefs := s.fvarDefs.insert range { userName := ldecl.userName, stx, opts, aliases := #[id] } } else -- Found a direct use, keep track of it modify fun s => { s with fvarUses := s.fvarUses.insert id } | _ => pure () | .ofTacticInfo ti => -- Keep track of the `MetavarContext` after a tactic for later modify fun s => { s with assignments := s.assignments.push ti.mctxAfter.eAssignment } | .ofFVarAliasInfo i => -- record any aliases we find modify fun s => let id := followAliases s.fvarAliases i.baseId { s with fvarAliases := s.fvarAliases.insert i.id id } | _ => pure ()) where /-- Since declarations attach the declaration info to the `declId`, we skip that to get to the `.ident` if possible. -/ skipDeclIdIfPresent (stx : Syntax) : Syntax := if stx.isOfKind ``Lean.Parser.Command.declId then stx[0] else stx /-- Reports unused variable warnings on each command. Use `linter.unusedVariables` to disable. -/ def unusedVariables : Linter where run cmdStx := do unless getLinterUnusedVariables (← getOptions) do return -- NOTE: `messages` is local to the current command if (← get).messages.hasErrors then return let some cmdStxRange := cmdStx.getRange? | return let infoTrees := (← get).infoState.trees.toArray if (← infoTrees.anyM (·.hasSorry)) then return -- Run the main collection pass, resulting in `s : References`. let (_, s) ← (collectReferences infoTrees cmdStxRange).run {} -- If there are no local defs then there is nothing to do if s.fvarDefs.isEmpty then return -- Resolve all recursive references in `fvarAliases`. -- At this point everything in `fvarAliases` is guaranteed not to be itself an alias, -- and should point to some element of `FVarDefinition.aliases` in `s.fvarDefs` let fvarAliases : HashMap FVarId FVarId := s.fvarAliases.fold (init := {}) fun m id baseId => m.insert id (followAliases s.fvarAliases baseId) -- Collect all non-alias fvars corresponding to `fvarUses` by resolving aliases in the list. let fvarUsesRef ← IO.mkRef <| fvarAliases.fold (init := s.fvarUses) fun fvarUses id baseId => if fvarUses.contains id then fvarUses.insert baseId else fvarUses -- Collect ignore functions let ignoreFns ← getUnusedVariablesIgnoreFns let mut initializedMVars := false let mut unused := #[] -- For each variable definition, check to see if it is used for (range, { userName, stx := declStx, opts, aliases }) in s.fvarDefs.toArray do let fvarUses ← fvarUsesRef.get -- If any of the `fvar`s corresponding to this declaration is (an alias of) a variable in -- `fvarUses`, then it is used if aliases.any fun id => fvarUses.contains (fvarAliases.findD id id) then continue -- If this is a global declaration then it is (potentially) used after the command if s.constDecls.contains range then continue -- Get the syntax stack for this variable declaration let some ((id', _) :: stack) := cmdStx.findStack? (·.getRange?.any (·.includes range)) | continue -- If it is blacklisted by an `ignoreFn` then skip it if id'.isIdent && ignoreFns.any (· declStx stack opts) then continue -- Evaluate ignore functions again on macro expansion outputs if ← infoTrees.anyM fun tree => do let some macroExpansions ← collectMacroExpansions? range tree | return false return macroExpansions.any fun expansion => -- in a macro expansion, there may be multiple leafs whose (synthetic) range -- includes `range`, so accept strict matches only if let some (_ :: stack) := expansion.output.findStack? (·.getRange?.any (·.includes range)) (fun stx => stx.isIdent && stx.getRange?.any (· == range)) then ignoreFns.any (· declStx stack opts) else false then continue -- Visiting the metavariable assignments is expensive so we delay initialization if !initializedMVars then -- collect additional `fvarUses` from tactic assignments visitAssignments (← IO.mkRef {}) fvarUsesRef s.assignments initializedMVars := true let fvarUses ← fvarUsesRef.get -- Redo the initial check because `fvarUses` could be bigger now if aliases.any fun id => fvarUses.contains (fvarAliases.findD id id) then continue -- If we made it this far then the variable is unused and not ignored unused := unused.push (declStx, userName) -- Sort the outputs by position for (declStx, userName) in unused.qsort (·.1.getPos?.get! < ·.1.getPos?.get!) do logLint linter.unusedVariables declStx m!"unused variable `{userName}`" builtin_initialize addLinter unusedVariables end UnusedVariables end Linter /-- Returns true if this is a message produced by the unused variable linter. This is used to give these messages an additional "faded" style in the editor. -/ def MessageData.isUnusedVariableWarning (msg : MessageData) : Bool := msg.hasTag (· == Linter.linter.unusedVariables.name)