Skip to content

Commit e982bf9

Browse files
Vierkantorgrunweg
andauthored
feat: implement "linter sets" that can be turned on as a group (#8106)
This PR adds a `register_linter_set` command for declaring linter sets. The `getLinterValue` function now checks if the present linter is contained in a set that has been enabled (using the `set_option` command or on the command line). The implementation stores linter set membership in an environment extension. As a consequence, we need to pass more data to `getLinterValue`: the argument of ype `Options` has been replaced with a `LinterOptions`, which you can access by writing `getLinterOptions` instead of `getOptions`. (The alternative I considered is to modify the `Options` structure. The current approach seems a bit higher-level and lower-impact.) The logic for checking whether a linter should be enabled now goes in four steps: 1. If the linter has been explicitly en/disabled, return that. 2. If `linter.all` has been explicitly set, return that. 3. If the linter is in any set that has been enabled, return true. 4. Return the default setting for the linter. Reasoning: * The linter's explicit setting should take precedence. * We want to be able to disable all but the explicitly enabled linters with `linter.all`, so it should take precedence over linter sets. * We want to progressively enable more linters as they become available, so the check over sets should be *any*. * Falling back to the default value last, ensures compatibility with the current way we define linters. The public-facing API currently does not allow modifying sets: all linters have to be added when the set is declared. This way, there is one place where all the contents of the set are listed. Linter sets can be declared to contain linters that have not been declared (yet): this allows declaring linter sets low down in the import hierarchy when not all the requested linters are defined yet. --------- Co-authored-by: grunweg <[email protected]>
1 parent 4efef57 commit e982bf9

File tree

12 files changed

+232
-25
lines changed

12 files changed

+232
-25
lines changed

src/Lean/Elab/Tactic/Simpa.lean

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ namespace Lean.Elab.Tactic.Simpa
2424
open Lean Parser.Tactic Elab Meta Term Tactic Simp Linter
2525

2626
/-- Gets the value of the `linter.unnecessarySimpa` option. -/
27-
def getLinterUnnecessarySimpa (o : Options) : Bool :=
27+
def getLinterUnnecessarySimpa (o : LinterOptions) : Bool :=
2828
getLinterValue linter.unnecessarySimpa o
2929

3030
deriving instance Repr for UseImplicitLambdaResult
@@ -42,7 +42,7 @@ deriving instance Repr for UseImplicitLambdaResult
4242
dischargeWrapper.with fun discharge? => do
4343
let (some (_, g), stats) ← simpGoal (← getMainGoal) ctx (simprocs := simprocs)
4444
(simplifyTarget := true) (discharge? := discharge?)
45-
| if getLinterUnnecessarySimpa (← getOptions) then
45+
| if getLinterUnnecessarySimpa (← getLinterOptions) then
4646
logLint linter.unnecessarySimpa (← getRef) "try 'simp' instead of 'simpa'"
4747
return {}
4848
g.withContext do
@@ -78,7 +78,7 @@ deriving instance Repr for UseImplicitLambdaResult
7878
logUnassignedAndAbort (← filterOldMVars (← getMVars e) mvarCounterSaved)
7979
closeMainGoal `simpa (checkUnassigned := false) h
8080
| none =>
81-
if getLinterUnnecessarySimpa (← getOptions) then
81+
if getLinterUnnecessarySimpa (← getLinterOptions) then
8282
if let .fvar h := e then
8383
if (← getLCtx).getRoundtrippingUserName? h |>.isSome then
8484
logLint linter.unnecessarySimpa (← getRef)

src/Lean/Linter.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ import Lean.Linter.UnusedVariables
1212
import Lean.Linter.MissingDocs
1313
import Lean.Linter.Omit
1414
import Lean.Linter.List
15+
import Lean.Linter.Sets

src/Lean/Linter/Basic.lean

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,68 @@ Authors: Lars König
55
-/
66
prelude
77
import Lean.Data.Options
8+
import Lean.MonadEnv
89
import Lean.Log
910

1011
namespace Lean.Linter
1112

13+
/-- Linter sets are represented as a map from linter name to set name,
14+
to make it easy to look up which sets to check for enabling a linter.
15+
-/
16+
def LinterSets := NameMap (Array Name)
17+
deriving EmptyCollection, Inhabited
18+
19+
/-- Insert a set into a `LinterSets` map.
20+
21+
`entry.1` is the name of the linter set,
22+
`entry.2` contains the names of the set's linter options.
23+
-/
24+
def insertLinterSetEntry (map : LinterSets) (setName : Name) (options : NameSet) : LinterSets :=
25+
options.fold (init := map) fun map linterName =>
26+
map.insert linterName ((map.findD linterName #[]).push setName)
27+
28+
builtin_initialize linterSetsExt : SimplePersistentEnvExtension (Name × NameSet) LinterSets ← Lean.registerSimplePersistentEnvExtension {
29+
addImportedFn := mkStateFromImportedEntries (Function.uncurry <| insertLinterSetEntry ·) {}
30+
addEntryFn := (Function.uncurry <| insertLinterSetEntry ·)
31+
toArrayFn es := es.toArray
32+
}
33+
34+
/-- The `LinterOptions` structure is used to determine whether given linters are enabled.
35+
36+
This structure contains all the data required to do so, the `Options` set on the command line
37+
or by the `set_option` command, and the `LinterSets` that have been declared.
38+
39+
A single structure holding this data is useful since we want `getLinterValue` to be a pure
40+
function: determinining the `LinterSets` would otherwise require a `MonadEnv` instance.
41+
-/
42+
structure LinterOptions where
43+
toOptions : Options
44+
linterSets : LinterSets
45+
46+
def LinterOptions.get [KVMap.Value α] (o : LinterOptions) := o.toOptions.get (α := α)
47+
def LinterOptions.get? [KVMap.Value α] (o : LinterOptions) := o.toOptions.get? (α := α)
48+
49+
def _root_.Lean.Options.toLinterOptions [Monad m] [MonadEnv m] (o : Options) : m LinterOptions := do
50+
let linterSets := linterSetsExt.getState (← getEnv)
51+
return { toOptions := o, linterSets }
52+
53+
/-- Return the set of linter sets that this option is contained in. -/
54+
def LinterOptions.getSet (o : LinterOptions) (opt : Lean.Option α) : Array Name :=
55+
o.linterSets.findD opt.name #[]
56+
57+
def getLinterOptions [Monad m] [MonadOptions m] [MonadEnv m] : m LinterOptions := do
58+
(← getOptions).toLinterOptions
59+
1260
register_builtin_option linter.all : Bool := {
1361
defValue := false
1462
descr := "enable all linters"
1563
}
1664

17-
def getLinterAll (o : Options) (defValue := linter.all.defValue) : Bool := o.get linter.all.name defValue
65+
def getLinterAll (o : LinterOptions) (defValue := linter.all.defValue) : Bool :=
66+
o.get linter.all.name defValue
1867

19-
def getLinterValue (opt : Lean.Option Bool) (o : Options) : Bool := o.get opt.name (getLinterAll o opt.defValue)
68+
def getLinterValue (opt : Lean.Option Bool) (o : LinterOptions) : Bool :=
69+
o.get opt.name (getLinterAll o <| (o.getSet opt).any (o.get? · == some true) || opt.defValue)
2070

2171
def logLint [Monad m] [MonadLog m] [AddMessageContext m] [MonadOptions m]
2272
(linterOption : Lean.Option Bool) (stx : Syntax) (msg : MessageData) : m Unit :=
@@ -29,8 +79,11 @@ If `linterOption` is enabled, print a linter warning message at the position det
2979
Whether a linter option is enabled or not is determined by the following sequence:
3080
1. If it is set, then the value determines whether or not it is enabled.
3181
2. Otherwise, if `linter.all` is set, then its value determines whether or not the option is enabled.
32-
3. Otherwise, the default value determines whether or not it is enabled.
82+
3. Otherwise, if any of the linter sets containing the option is enabled, it is enabled.
83+
(Only enabled linter sets are considered: explicitly disabling a linter set
84+
will revert the linters it contains to their default behavior.)
85+
4. Otherwise, the default value determines whether or not it is enabled.
3386
-/
34-
def logLintIf [Monad m] [MonadLog m] [AddMessageContext m] [MonadOptions m]
87+
def logLintIf [Monad m] [MonadLog m] [AddMessageContext m] [MonadOptions m] [MonadEnv m]
3588
(linterOption : Lean.Option Bool) (stx : Syntax) (msg : MessageData) : m Unit := do
36-
if getLinterValue linterOption (← getOptions) then logLint linterOption stx msg
89+
if getLinterValue linterOption (← getLinterOptions) then logLint linterOption stx msg

src/Lean/Linter/Builtin.lean

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ register_builtin_option linter.suspiciousUnexpanderPatterns : Bool := {
1414
descr := "enable the 'suspicious unexpander patterns' linter"
1515
}
1616

17-
def getLinterSuspiciousUnexpanderPatterns (o : Options) : Bool := getLinterValue linter.suspiciousUnexpanderPatterns o
17+
def getLinterSuspiciousUnexpanderPatterns (o : LinterOptions) : Bool := getLinterValue linter.suspiciousUnexpanderPatterns o
1818

1919
def suspiciousUnexpanderPatterns : Linter where
2020
run cmdStx := do
21-
unless getLinterSuspiciousUnexpanderPatterns (← getOptions) do
21+
unless getLinterSuspiciousUnexpanderPatterns (← getLinterOptions) do
2222
return
2323

2424
-- check `[app_unexpander _]` defs defined by pattern matching

src/Lean/Linter/Deprecated.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def getDeprecatedNewName (env : Environment) (declName : Name) : Option Name :=
4848
(← deprecatedAttr.getParam? env declName).newName?
4949

5050
def checkDeprecated [Monad m] [MonadEnv m] [MonadLog m] [AddMessageContext m] [MonadOptions m] (declName : Name) : m Unit := do
51-
if getLinterValue linter.deprecated (← getOptions) then
51+
if getLinterValue linter.deprecated (← getLinterOptions) then
5252
let some attr := deprecatedAttr.getParam? (← getEnv) declName | pure ()
5353
logWarning <| .tagged ``deprecatedAttr <|
5454
m!"`{.ofConstName declName true}` has been deprecated" ++ match attr.text? with

src/Lean/Linter/MissingDocs.lean

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ register_builtin_option linter.missingDocs : Bool := {
1919
descr := "enable the 'missing documentation' linter"
2020
}
2121

22-
def getLinterMissingDocs (o : Options) : Bool := getLinterValue linter.missingDocs o
22+
def getLinterMissingDocs (o : LinterOptions) : Bool := getLinterValue linter.missingDocs o
2323

2424

2525
namespace MissingDocs
@@ -68,7 +68,7 @@ def getHandlers (env : Environment) : NameMap Handler := (missingDocsExt.getStat
6868
partial def missingDocs : Linter where
6969
run stx := do
7070
if let some h := (getHandlers (← getEnv)).find? stx.getKind then
71-
h (getLinterMissingDocs (← getOptions)) stx
71+
h (getLinterMissingDocs (← getLinterOptions)) stx
7272

7373
builtin_initialize addLinter missingDocs
7474

src/Lean/Linter/Sets.lean

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/-
2+
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Authors: Anne Baanen
5+
-/
6+
prelude
7+
import Lean.Elab.Command
8+
import Lean.Linter.Basic
9+
10+
namespace Lean.Linter
11+
12+
/-- Add a new linter set that contains the given linters. -/
13+
def insertLinterSet [MonadEnv m] (setName : Name) (linterNames : NameSet) : m Unit :=
14+
modifyEnv (linterSetsExt.addEntry · (setName, linterNames))
15+
16+
/-- `registerSet` wraps `registerOption` by setting relevant values. -/
17+
def registerSet (setName : Name) (ref : Name := by exact decl_name%) : IO (Lean.Option Bool) := do
18+
registerOption setName {
19+
declName := ref
20+
defValue := false
21+
group := "linterSet"
22+
descr := ""
23+
}
24+
return { name := setName, defValue := false }
25+
26+
open Lean.Elab.Command in
27+
/-- Declare a new linter set by giving the set of options that will be enabled along with the set. -/
28+
elab doc?:(docComment)? "register_linter_set" name:ident " := " decl:ident* : command => do
29+
insertLinterSet name.getId <| decl.foldl (init := ∅) fun names name => names.insert name.getId
30+
let initializer ← `($[$doc?]? initialize $name : Lean.Option Bool ← Lean.Linter.registerSet $(quote name.getId))
31+
withMacroExpansion (← getRef) initializer <| elabCommand initializer
32+
33+
34+
end Lean.Linter

src/Lean/Linter/UnusedVariables.lean

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,29 +104,29 @@ register_builtin_option linter.unusedVariables.analyzeTactics : Bool := {
104104
}
105105

106106
/-- Gets the status of `linter.unusedVariables` -/
107-
def getLinterUnusedVariables (o : Options) : Bool :=
107+
def getLinterUnusedVariables (o : LinterOptions) : Bool :=
108108
getLinterValue linter.unusedVariables o
109109

110110
/-- Gets the status of `linter.unusedVariables.funArgs` -/
111-
def getLinterUnusedVariablesFunArgs (o : Options) : Bool :=
111+
def getLinterUnusedVariablesFunArgs (o : LinterOptions) : Bool :=
112112
o.get linter.unusedVariables.funArgs.name (getLinterUnusedVariables o)
113113

114114
/-- Gets the status of `linter.unusedVariables.patternVars` -/
115-
def getLinterUnusedVariablesPatternVars (o : Options) : Bool :=
115+
def getLinterUnusedVariablesPatternVars (o : LinterOptions) : Bool :=
116116
o.get linter.unusedVariables.patternVars.name (getLinterUnusedVariables o)
117117

118118
/-- An `IgnoreFunction` receives:
119119
120120
* a `Syntax.ident` for the unused variable
121121
* a `Syntax.Stack` with the location of this piece of syntax in the command
122-
* The `Options` set locally to this syntax
122+
* The `LinterOptions` set locally to this syntax
123123
124124
and should return `true` to indicate that the lint should be suppressed,
125125
or `false` to proceed with linting as usual (other `IgnoreFunction`s may still
126126
say it is ignored). A variable is only linted if it is unused and no
127127
`IgnoreFunction` returns `true` on this syntax.
128128
-/
129-
abbrev IgnoreFunction := Syntax → Syntax.Stack → Options → Bool
129+
abbrev IgnoreFunction := Syntax → Syntax.Stack → LinterOptions → Bool
130130

131131
/-- Interpret an `IgnoreFunction` from the environment. -/
132132
unsafe def mkIgnoreFnImpl (constName : Name) : ImportM IgnoreFunction := do
@@ -381,7 +381,8 @@ structure References where
381381

382382
/-- Collect information from the `infoTrees` into `References`.
383383
See `References` for more information about the return value. -/
384-
partial def collectReferences (infoTrees : Array Elab.InfoTree) (cmdStxRange : String.Range) :
384+
partial def collectReferences (infoTrees : Array Elab.InfoTree) (cmdStxRange : String.Range)
385+
(linterSets : LinterSets) :
385386
StateRefT References IO Unit := ReaderT.run (r := false) <| go infoTrees none
386387
where
387388
go infoTrees ctx? := do
@@ -422,7 +423,7 @@ where
422423
-- Skip declarations which are outside the command syntax range, like `variable`s
423424
-- (it would be confusing to lint these), or those which are macro-generated
424425
if !cmdStxRange.contains range.start || ldecl.userName.hasMacroScopes then return true
425-
let opts := ci.options
426+
let opts : LinterOptions := { toOptions := ci.options, linterSets }
426427
-- we have to check for the option again here because it can be set locally
427428
if !getLinterUnusedVariables opts then return true
428429
let stx := skipDeclIdIfPresent info.stx
@@ -435,7 +436,7 @@ where
435436
if let some ref := s.fvarDefs[range]? then
436437
{ s with fvarDefs := s.fvarDefs.insert range { ref with aliases := ref.aliases.push id } }
437438
else
438-
{ s with fvarDefs := s.fvarDefs.insert range { userName := ldecl.userName, stx, opts, aliases := #[id] } }
439+
{ s with fvarDefs := s.fvarDefs.insert range { userName := ldecl.userName, stx, opts := opts.toOptions, aliases := #[id] } }
439440
else
440441
-- Found a direct use, keep track of it
441442
modify fun s => { s with fvarUses := s.fvarUses.insert id }
@@ -471,7 +472,7 @@ private def hasSorry (stx : Syntax) : Bool :=
471472
/-- Reports unused variable warnings on each command. Use `linter.unusedVariables` to disable. -/
472473
def unusedVariables : Linter where
473474
run cmdStx := do
474-
unless getLinterUnusedVariables (← getOptions) do
475+
unless getLinterUnusedVariables (← getLinterOptions) do
475476
return
476477

477478
-- NOTE: `messages` is local to the current command
@@ -488,8 +489,10 @@ def unusedVariables : Linter where
488489

489490
let infoTrees := (← get).infoState.trees.toArray
490491

492+
let linterSets := linterSetsExt.getState (← getEnv)
493+
491494
-- Run the main collection pass, resulting in `s : References`.
492-
let (_, s) ← (collectReferences infoTrees cmdStxRange).run {}
495+
let (_, s) ← (collectReferences infoTrees cmdStxRange linterSets).run {}
493496

494497
-- If there are no local defs then there is nothing to do
495498
if s.fvarDefs.isEmpty then return
@@ -526,7 +529,8 @@ def unusedVariables : Linter where
526529
| continue
527530

528531
-- If it is blacklisted by an `ignoreFn` then skip it
529-
if id'.isIdent && ignoreFns.any (· declStx stack opts) then continue
532+
let linterOpts ← opts.toLinterOptions
533+
if id'.isIdent && ignoreFns.any (· declStx stack linterOpts) then continue
530534

531535
-- Evaluate ignore functions again on macro expansion outputs
532536
if ← infoTrees.anyM fun tree => do
@@ -539,7 +543,7 @@ def unusedVariables : Linter where
539543
(·.getRange?.any (·.includes range))
540544
(fun stx => stx.isIdent && stx.getRange?.any (· == range))
541545
then
542-
ignoreFns.any (· declStx stack opts)
546+
ignoreFns.any (· declStx stack linterOpts)
543547
else
544548
false
545549
then
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import LinterSet.Def
2+
3+
open Lean Linter
4+
5+
def fooInitially : MetaM Unit := do
6+
-- linter.foo1 and linter.foo2 are declared to be false by default
7+
assert! !(getLinterValue linter.foo1 (← getLinterOptions))
8+
assert! !(getLinterValue linter.foo2 (← getLinterOptions))
9+
-- linter.foo_true is declared to be true by default
10+
assert! (getLinterValue linter.foo_true (← getLinterOptions))
11+
pure ()
12+
13+
#eval fooInitially
14+
15+
set_option linter.set.foo true
16+
17+
def fooSetTrue : MetaM Unit := do
18+
-- All linters in the `foo` set should now be true
19+
assert! (getLinterValue linter.foo1 (← getLinterOptions))
20+
assert! (getLinterValue linter.foo2 (← getLinterOptions))
21+
assert! (getLinterValue linter.foo_true (← getLinterOptions))
22+
pure ()
23+
24+
#eval fooSetTrue
25+
26+
section OverrideFalse
27+
28+
set_option linter.foo2 false
29+
30+
def overrideFalse : MetaM Unit := do
31+
-- The overridden linter should now be false.
32+
assert! (getLinterValue linter.foo1 (← getLinterOptions))
33+
assert! !(getLinterValue linter.foo2 (← getLinterOptions))
34+
assert! (getLinterValue linter.foo_true (← getLinterOptions))
35+
pure ()
36+
37+
#eval overrideFalse
38+
39+
end OverrideFalse
40+
41+
section AllFalse
42+
43+
set_option linter.all false
44+
45+
def allSetFalse : MetaM Unit := do
46+
-- All linters, including those in the `foo` set, should now be false
47+
assert! !(getLinterValue linter.foo1 (← getLinterOptions))
48+
assert! !(getLinterValue linter.foo2 (← getLinterOptions))
49+
assert! !(getLinterValue linter.foo_true (← getLinterOptions))
50+
pure ()
51+
52+
#eval allSetFalse
53+
54+
end AllFalse
55+
56+
set_option linter.set.foo false
57+
58+
def fooSetFalse : MetaM Unit := do
59+
-- All linters in the `foo` set should now be back to default.
60+
assert! !(getLinterValue linter.foo1 (← getLinterOptions))
61+
assert! !(getLinterValue linter.foo2 (← getLinterOptions))
62+
assert! (getLinterValue linter.foo_true (← getLinterOptions))
63+
pure ()
64+
65+
#eval fooSetFalse
66+
67+
section AllTrue
68+
69+
set_option linter.all true
70+
71+
/-- Running this code will check that all linters in the `foo` set are true.
72+
73+
This docstring is required due to `linter.all` being true(!). -/
74+
def allSetTrue : MetaM Unit := do
75+
-- All linters, including those in the `foo` set, should now be true
76+
assert! (getLinterValue linter.foo1 (← getLinterOptions))
77+
assert! (getLinterValue linter.foo2 (← getLinterOptions))
78+
assert! (getLinterValue linter.foo_true (← getLinterOptions))
79+
pure ()
80+
81+
#eval allSetTrue
82+
83+
end AllTrue
84+
85+
/-! Test setting user options from lakefile. -/
86+
87+
open Lean
88+
89+
def barFromLakefile : MetaM Unit := do
90+
assert! (getLinterValue linter.bar (← getLinterOptions))
91+
92+
#eval barFromLakefile
93+

0 commit comments

Comments
 (0)