Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Lean/Linter/Clippy.lean
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ module

prelude
public import Lean.Linter.Clippy.UnnecessarySeqFocus
public import Lean.Linter.Clippy.UnreachableTactic
2 changes: 1 addition & 1 deletion src/Lean/Linter/Clippy/UnnecessarySeqFocus.lean
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def unnecessarySeqFocusLinter : Linter where run := withSetOptionIn fun stx => d
let mut last : Lean.Syntax.Range := ⟨0, 0⟩
for (r, stx) in let _ := @lexOrd; let _ := @ltOfOrd.{0}; unused.qsort (key ·.1 < key ·.1) do
if last.start ≤ r.start && r.stop ≤ last.stop then continue
Linter.logLint linter.clippy.unnecessarySeqFocus stx
Linter.logLintIfClippy linter.clippy.unnecessarySeqFocus stx
"Used `tac1 <;> tac2` where `(tac1; tac2)` would suffice"
last := r

Expand Down
129 changes: 129 additions & 0 deletions src/Lean/Linter/Clippy/UnreachableTactic.lean
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/-
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Authors: Wojciech Różowski

Copyright (c) 2022 Mario Carneiro. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Authors: Mario Carneiro
-/
module

prelude
public import Lean.Elab.Command
public import Lean.Linter.Basic
public import Lean.Parser.Syntax
public import Init.Try

public section

namespace Lean.Linter.Clippy
open Elab Command

/--
Enables the 'unreachable tactic' linter. This will warn on any tactics that are never executed.
For example, in `example : True := by trivial <;> done`, the tactic `done` is never executed
because `trivial` produces no subgoals; you could put `sorry` or `apply I_don't_exist`
or anything else there and no error would result.

A common source of such things is `simp <;> tac` in the case that `simp` improves and
closes a subgoal that was previously being closed by `tac`.
-/
register_builtin_option linter.clippy.unreachableTactic : Bool := {
defValue := false
descr := "enable the 'unreachable tactic' linter"
}
namespace UnreachableTactic

/-- The monad for collecting used tactic syntaxes. -/
abbrev M := StateRefT (Std.HashMap Lean.Syntax.Range Syntax) IO

/--
A list of blacklisted syntax kinds, which are expected to have subterms that contain
unevaluated tactics.
-/
initialize ignoreTacticKindsRef : IO.Ref NameHashSet ←
IO.mkRef <| (∅ : NameHashSet)
|>.insert ``Parser.Term.binderTactic
|>.insert ``Lean.Parser.Term.dynamicQuot
|>.insert ``Lean.Parser.Tactic.quotSeq
|>.insert ``Lean.Parser.Tactic.tacticStop_
|>.insert ``Lean.Parser.Command.notation
|>.insert ``Lean.Parser.Command.mixfix
|>.insert ``Lean.Parser.Command.registerTryTactic
|>.insert ``Lean.Parser.Tactic.discharger

/-- Is this a syntax kind that contains intentionally unevaluated tactic subterms? -/
def isIgnoreTacticKind (ignoreTacticKinds : NameHashSet) (k : SyntaxNodeKind) : Bool :=
match k with
| .str _ "quot" => true
| _ => ignoreTacticKinds.contains k

/--
Adds a new syntax kind whose children will be ignored by the `unreachableTactic` linter.
This should be called from an `initialize` block.
-/
def addIgnoreTacticKind (kind : SyntaxNodeKind) : IO Unit :=
ignoreTacticKindsRef.modify (·.insert kind)

variable (ignoreTacticKinds : NameHashSet) (isTacKind : SyntaxNodeKind → Bool) in
/-- Accumulates the set of tactic syntaxes that should be evaluated at least once. -/
@[specialize] partial def getTactics (stx : Syntax) : M Unit := do
if let .node _ k args := stx then
if !isIgnoreTacticKind ignoreTacticKinds k then
args.forM getTactics
if isTacKind k then
if let some r := stx.getRange? true then
modify fun m => m.insert r stx

mutual

/-- Search for tactic executions in the info tree and remove executed tactic syntaxes. -/
partial def eraseUsedTacticsList (trees : PersistentArray InfoTree) : M Unit :=
trees.forM eraseUsedTactics

/-- Search for tactic executions in the info tree and remove executed tactic syntaxes. -/
partial def eraseUsedTactics : InfoTree → M Unit
| .node i c => do
if let .ofTacticInfo i := i then
if let some r := i.stx.getRange? true then
modify (·.erase r)
eraseUsedTacticsList c
| .context _ t => eraseUsedTactics t
| .hole _ => pure ()

end

@[inherit_doc linter.clippy.unreachableTactic]
def unreachableTacticLinter : Linter where run := withSetOptionIn fun stx => do
unless getLinterValueClippy linter.clippy.unreachableTactic (← getLinterOptions)
&& (← getInfoState).enabled do
return
if (← get).messages.hasErrors then
return
let cats := (Parser.parserExtension.getState (← getEnv)).categories
-- These lookups may fail when the linter is run in a fresh, empty environment
let some tactics := Parser.ParserCategory.kinds <$> cats.find? `tactic
| return
let some convs := Parser.ParserCategory.kinds <$> cats.find? `conv
| return
let trees ← getInfoTrees
let go : M Unit := do
getTactics (← ignoreTacticKindsRef.get) (fun k => tactics.contains k || convs.contains k) stx
eraseUsedTacticsList trees
let (_, map) ← go.run {}
let unreachable := map.toArray
let key (r : Lean.Syntax.Range) := (r.start.byteIdx, (-r.stop.byteIdx : Int))
let mut last : Lean.Syntax.Range := ⟨0, 0⟩
for (r, stx) in let _ := @lexOrd; let _ := @ltOfOrd.{0}; unreachable.qsort (key ·.1 < key ·.1) do
-- if stx.getKind ∈ [``Batteries.Tactic.unreachable, ``Batteries.Tactic.unreachableConv] then
-- continue
if last.start ≤ r.start && r.stop ≤ last.stop then continue
logLintIfClippy linter.clippy.unreachableTactic stx "this tactic is never executed"
last := r

builtin_initialize addLinter unreachableTacticLinter
end UnreachableTactic


end Lean.Linter.Clippy
2 changes: 2 additions & 0 deletions tests/lake/tests/builtin-lint/ClippyViolations.lean
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ example : True := by
-- The component `Dup` appears consecutively in this declaration's name —
-- the builtin clippy `dupNamespace` env linter should flag it.
def Dup.Dup.violation : Nat := 2

def unreachableTacticViolation : True := by trivial <;> done
5 changes: 5 additions & 0 deletions tests/lake/tests/builtin-lint/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ no_match_pat 'clippy text linter saw a declaration' produced.out
no_match_pat 'tac1 <;> tac2' produced.out
# Builtin clippy env linter `dupNamespace` is non-default, so it stays silent.
no_match_pat 'Dup.Dup.violation' produced.out
# Builtin clippy text linter `unreachableTactic` is non-default, so silent.
no_match_pat 'this tactic is never executed' produced.out

# --clippy should run only non-default (clippy) linters, including the clippy
# text linter which tags its warnings with `linter.clippy`.
Expand All @@ -102,6 +104,8 @@ match_pat 'tac1 <;> tac2' produced.out
# Builtin `dupNamespace` env linter fires under --clippy.
match_pat 'Dup.Dup.violation' produced.out
match_pat "namespace .*Dup.* is duplicated" produced.out
# Builtin `unreachableTactic` clippy text linter fires under --clippy.
match_pat 'this tactic is never executed' produced.out
# --clippy should not run default linters
no_match_pat 'shouldBeTheorem' produced.out

Expand All @@ -119,6 +123,7 @@ lake_out lint --lint-all ClippyViolations || true
match_pat 'badNameClippy' produced.out
match_pat 'clippy text linter saw a declaration' produced.out
match_pat 'tac1 <;> tac2' produced.out
match_pat 'this tactic is never executed' produced.out

# --lint-only unnecessarySeqFocus runs only the clippy text linter.
lake_out lint --lint-only unnecessarySeqFocus ClippyViolations || true
Expand Down
Loading