Skip to content

Commit 5b6365d

Browse files
authored
feat: control environment linters with Lean.Option (#13893)
This PR makes environment linters (the declaration-level checks run by `lake lint --builtin-lint`) controlled by `Lean.Option`s, just like ordinary linters. Each environment linter is tied to a boolean option, so you enable or disable it per declaration with `set_option linter.X false in ...` and across a lint run with the new `lake lint --linters=linter.X,-linter.Y` flag. Using `--lint-only` with the same syntax will only collect information from the specified linters and will not run the default on linters. The previous `lake lint` flags `--extra`, `--lint-all`, and the `builtin_nolint` attribute, are removed in favour of this option-based control. Whether a linter applies to a declaration is decided by the value of its controlling option at the time that declaration was elaborated. `Lean.addDecl` snapshots the resolved value of every registered environment-linter option for each new declaration (also from `Elab.MutualDef`, where the inner `addDecl` runs on an async environment) and stores it in an environment extension that `lake builtin-lint` reads. The `@[builtin_env_linter linter.X]` attribute now takes the controlling option name, and the registry maps option name to linter declaration. `--linters` is translated into `weak` option overrides applied to the lint build. `isAutoDecl` moves to a new `Lean.AutoDecl` module so `Lean.addDecl` can use it. Builds on #13852 (builtin linter sets); `linter.extra` becomes a linter set whose members are the existing extra linters. @claude was used to update the help string in Lake CLI, readapt the tests (that were then manually cleaned) and to write this PR description.
1 parent 50e5bb3 commit 5b6365d

23 files changed

Lines changed: 603 additions & 443 deletions

File tree

src/Init.lean

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,3 @@ public import Init.BinderNameHint
4848
public import Init.Task
4949
public import Init.MethodSpecsSimp
5050
public import Init.LawfulBEqTactics
51-
public import Init.Linter

src/Init/Linter.lean

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/Lean.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public import Lean.Log
3737
public import Lean.Linter
3838
public import Lean.SubExpr
3939
public import Lean.LabelAttribute
40+
public import Lean.AutoDecl
4041
public import Lean.AddDecl
4142
public import Lean.Replay
4243
public import Lean.PrivateName

src/Lean/AddDecl.lean

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ prelude
99
public import Lean.Meta.Sorry
1010
public import Lean.Util.CollectAxioms
1111
public import Lean.OriginalConstKind
12+
public import Lean.AutoDecl
13+
import Lean.Linter.Init
1214
import Lean.Compiler.MetaAttr
1315
import all Lean.OriginalConstKind -- for accessing `privateConstKindsExt`
1416

@@ -28,7 +30,19 @@ private def Environment.addDeclAux (env : Environment) (opts : Options) (decl :
2830
(cancelTk? : Option IO.CancelToken := none) : Except Kernel.Exception Environment :=
2931
env.addDeclCore (Core.getMaxHeartbeats opts).toUSize decl cancelTk? (!debug.skipKernelTC.get opts)
3032

31-
33+
open Linter in
34+
/--
35+
Saves the state of `Lean.Option`s associated with environment linters into `envLinterSnapshotExt`
36+
-/
37+
def snapshotEnvLinterOptions (declName : Name) : CoreM Unit := do
38+
let envLinterOpts ← envLinterOptionsRef.get
39+
unless envLinterOpts.isEmpty do
40+
let linterOptions ← getLinterOptions
41+
unless ← isAutoDeclOrPrivate_Internal declName do
42+
let mut snapshot : NameMap Bool := {}
43+
for opt in envLinterOpts do
44+
snapshot := snapshot.insert opt.name (getLinterValue opt linterOptions)
45+
modifyEnv (envLinterSnapshotExt.insert · declName snapshot)
3246

3347
private def isNamespaceName : Name → Bool
3448
| .str .anonymous _ => true
@@ -80,13 +94,7 @@ def warnIfUsesSorry (decl : Declaration) : CoreM Unit := do
8094
builtin_initialize
8195
registerTraceClass `addDecl
8296

83-
/--
84-
Adds the given declaration to the environment's private scope, deriving a suitable presentation in
85-
the public scope if under the module system and if the declaration is not private. If `forceExpose`
86-
is true, exposes the declaration body, i.e. preserves the full representation in the public scope,
87-
independently of `Environment.isExporting` and even for theorems.
88-
-/
89-
def addDecl (decl : Declaration) (forceExpose := false) : CoreM Unit :=
97+
private def addDeclCore (decl : Declaration) (forceExpose : Bool) : CoreM Unit :=
9098
withTraceNode `addDecl (fun _ => return m!"adding declarations {decl.getNames}") do
9199
-- register namespaces for newly added constants; this used to be done by the kernel itself
92100
-- but that is incompatible with moving it to a separate task
@@ -208,6 +216,15 @@ where
208216
return
209217
catch _ => pure ()
210218

219+
/--
220+
Adds the given declaration to the environment's private scope, deriving a suitable presentation in
221+
the public scope if under the module system and if the declaration is not private. If `forceExpose`
222+
is true, exposes the declaration body, i.e. preserves the full representation in the public scope,
223+
independently of `Environment.isExporting` and even for theorems.
224+
-/
225+
def addDecl (decl : Declaration) (forceExpose := false) : CoreM Unit := do
226+
addDeclCore decl forceExpose
227+
discard <| decl.getTopLevelNames.mapM snapshotEnvLinterOptions
211228

212229
def addAndCompile (decl : Declaration) (logCompileErrors : Bool := true)
213230
(markMeta : Bool := false) : CoreM Unit := do

src/Lean/AutoDecl.lean

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Authors: Wojciech Różowski
5+
6+
Copyright (c) 2020 Floris van Doorn. All rights reserved.
7+
Released under Apache 2.0 license as described in the file LICENSE.
8+
Authors: Floris van Doorn, Robert Y. Lewis, Gabriel Ebner
9+
-/
10+
11+
module
12+
13+
prelude
14+
public import Lean.Structure
15+
public import Lean.CoreM
16+
17+
public section
18+
19+
namespace Lean
20+
/--
21+
Returns true if `decl` is an automatically generated declaration.
22+
23+
Also returns true if `decl` is an internal name or created during macro
24+
expansion.
25+
-/
26+
def isAutoDeclOrPrivate_Internal (decl : Name) : CoreM Bool := do
27+
if decl.hasMacroScopes then return true
28+
if decl.isInternal then return true
29+
let env ← getEnv
30+
if isReservedName env decl then return true
31+
if let Name.str n s := decl then
32+
if (← isAutoDeclOrPrivate_Internal n) then return true
33+
if s.startsWith "proof_"
34+
|| s.startsWith "match_"
35+
|| s.startsWith "unsafe_"
36+
|| s.startsWith "grind_"
37+
then return true
38+
if env.isConstructor n && s ∈ ["injEq", "inj", "sizeOf_spec", "elim", "noConfusion"] then
39+
return true
40+
if let ConstantInfo.inductInfo _ := env.find? n then
41+
if s.startsWith "brecOn_" || s.startsWith "below_" then return true
42+
if s ∈ [casesOnSuffix, recOnSuffix, brecOnSuffix, belowSuffix,
43+
"ndrec", "ndrecOn", "noConfusionType", "noConfusion", "ofNat", "toCtorIdx", "ctorIdx",
44+
"ctorElim", "ctorElimType"] then
45+
return true
46+
if let some _ := isSubobjectField? env n (.mkSimple s) then
47+
return true
48+
-- Coinductive/inductive lattice-theoretic predicates:
49+
if let ConstantInfo.inductInfo _ := env.find? (Name.str n "_functor") then
50+
if s == "functor_unfold" || s == casesOnSuffix || s == "mutual" then return true
51+
if env.isConstructor (Name.str (Name.str n "_functor") s) then return true
52+
pure false
53+
54+
end Lean

src/Lean/Elab/DeclModifiers.lean

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ module
88
prelude
99
public import Lean.DocString.Add
1010
public import Lean.Linter.Init
11-
import Lean.Linter.EnvLinter.Nolint
1211
meta import Lean.Parser.Command
1312

1413
public section

src/Lean/Linter/EnvLinter/Basic.lean

Lines changed: 24 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ Authors: Floris van Doorn, Robert Y. Lewis, Gabriel Ebner
1010
module
1111

1212
prelude
13-
public import Lean.Structure
1413
public import Lean.Elab.InfoTree.Main
15-
import Lean.ExtraModUses
16-
public import Lean.Linter.EnvLinter.Nolint
14+
public import Lean.AutoDecl
1715

1816
public section
1917

@@ -36,39 +34,6 @@ metadata is stored in the `EnvLinter` structure. We define two attributes:
3634
the linter with name `linterName`.
3735
-/
3836

39-
/--
40-
Returns true if `decl` is an automatically generated declaration.
41-
42-
Also returns true if `decl` is an internal name or created during macro
43-
expansion.
44-
-/
45-
def isAutoDecl (decl : Name) : CoreM Bool := do
46-
if decl.hasMacroScopes then return true
47-
if decl.isInternal then return true
48-
let env ← getEnv
49-
if isReservedName env decl then return true
50-
if let Name.str n s := decl then
51-
if (← isAutoDecl n) then return true
52-
if s.startsWith "proof_"
53-
|| s.startsWith "match_"
54-
|| s.startsWith "unsafe_"
55-
|| s.startsWith "grind_"
56-
then return true
57-
if env.isConstructor n && s ∈ ["injEq", "inj", "sizeOf_spec", "elim", "noConfusion"] then
58-
return true
59-
if let ConstantInfo.inductInfo _ := env.find? n then
60-
if s.startsWith "brecOn_" || s.startsWith "below_" then return true
61-
if s ∈ [casesOnSuffix, recOnSuffix, brecOnSuffix, belowSuffix,
62-
"ndrec", "ndrecOn", "noConfusionType", "noConfusion", "ofNat", "toCtorIdx", "ctorIdx",
63-
"ctorElim", "ctorElimType"] then
64-
return true
65-
if let some _ := isSubobjectField? env n (.mkSimple s) then
66-
return true
67-
-- Coinductive/inductive lattice-theoretic predicates:
68-
if let ConstantInfo.inductInfo _ := env.find? (Name.str n "_functor") then
69-
if s == "functor_unfold" || s == casesOnSuffix || s == "mutual" then return true
70-
if env.isConstructor (Name.str (Name.str n "_functor") s) then return true
71-
pure false
7237

7338
/-- An environment linting test for the `lake builtin-lint` command. -/
7439
structure EnvLinter where
@@ -84,46 +49,50 @@ structure EnvLinter where
8449

8550
/-- A `NamedEnvLinter` is an environment linter associated to a particular declaration. -/
8651
structure NamedEnvLinter extends EnvLinter where
87-
/-- The name of the named linter. This is just the declaration name without the namespace. -/
88-
name : Name
52+
/-- The option name associated to the linter. -/
53+
optName : Name
8954
/-- The linter declaration name -/
9055
declName : Name
9156

92-
/-- Gets an environment linter by declaration name. -/
93-
def getEnvLinter (name declName : Name) : CoreM NamedEnvLinter := unsafe
94-
return { ← evalConstCheck EnvLinter ``EnvLinter declName with name, declName }
57+
/-- Gets an environment linter by option name. -/
58+
def getEnvLinter (optName declName : Name) : CoreM NamedEnvLinter := unsafe
59+
return { ← evalConstCheck EnvLinter ``EnvLinter declName with optName, declName }
9560

9661
/-- Defines the `envLinterExt` extension for adding an environment linter to the default set. -/
9762
builtin_initialize envLinterExt :
98-
SimplePersistentEnvExtension (Name × Bool) (NameMap (Name × Bool)) ←
99-
let addEntryFn := fun m (n, b) => m.insert (n.updatePrefix .anonymous) (n, b)
63+
SimplePersistentEnvExtension (Name × Name) (NameMap Name) ←
64+
let addEntryFn := fun m (optName, declName) => m.insert optName declName
10065
registerSimplePersistentEnvExtension {
10166
addImportedFn := fun nss =>
10267
nss.foldl (init := {}) fun m ns => ns.foldl (init := m) addEntryFn
10368
addEntryFn
10469
}
10570

10671
/--
107-
Defines the `@[builtin_env_linter]` attribute for adding a linter to the default set.
108-
The form `@[builtin_env_linter extra]` will not add the linter to the default set,
109-
but it can be selected by `lake builtin-lint --extra`.
110-
111-
Linters are named using their declaration names, without the namespace. These must be distinct.
72+
Defines the `builtin_env_linter` attribute for registering an environment linter.
73+
Each environment linter needs to have a registered Boolean-value `Lean.Option` that will be
74+
associated with it by providing the option name in the attribute, i.e.
75+
`@[builtin_env_linter linter.envLinter.myLinter]`.
11276
-/
113-
syntax (name := builtin_env_linter) "builtin_env_linter" &" extra"? : attr
77+
syntax (name := builtin_env_linter) "builtin_env_linter " ident : attr
11478

11579
builtin_initialize registerBuiltinAttribute {
11680
name := `builtin_env_linter
11781
descr := "Use this declaration as a linting test in `lake builtin-lint`"
11882
add := fun decl stx kind => do
119-
let dflt := stx[1].isNone
12083
unless kind == .global do throwError "invalid attribute `builtin_env_linter`, must be global"
121-
let shortName := decl.updatePrefix .anonymous
122-
if let some (declName, _) := (envLinterExt.getState (← getEnv)).find? shortName then
84+
let optName ← match stx with
85+
| `(attr| builtin_env_linter $id:ident) => pure id.getId
86+
| _ => throwError "invalid `builtin_env_linter` syntax: expected an option name argument"
87+
let env ← getEnv
88+
unless env.contains optName do
89+
throwError "invalid attribute `builtin_env_linter`, no constant named `{optName}`; \
90+
did you forget `register_builtin_option {optName} : Bool := ...`?"
91+
if let some declName := (envLinterExt.getState env).find? optName then
12392
Elab.addConstInfo stx declName
12493
throwError
125-
"invalid attribute `builtin_env_linter`, linter `{shortName}` has already been declared"
126-
let isPublic := !isPrivateName decl; let isMeta := isMarkedMeta (← getEnv) decl
94+
"invalid attribute `builtin_env_linter`, linter `{optName}` has already been declared"
95+
let isPublic := !isPrivateName decl; let isMeta := isMarkedMeta env decl
12796
unless isPublic && isMeta do
12897
throwError "invalid attribute `builtin_env_linter`, \
12998
declaration `{.ofConstName decl}` must be marked as `public` and `meta`\
@@ -133,7 +102,7 @@ builtin_initialize registerBuiltinAttribute {
133102
unless ← (isDefEq constInfo.type (mkConst ``EnvLinter)).run' do
134103
throwError "`{.ofConstName decl}` must have type `{.ofConstName ``EnvLinter}`, got \
135104
`{constInfo.type}`"
136-
modifyEnv fun env => envLinterExt.addEntry env (decl, dflt)
105+
modifyEnv fun env => envLinterExt.addEntry env (optName, decl)
137106
}
138107

139108
end Lean.Linter.EnvLinter

src/Lean/Linter/EnvLinter/Frontend.lean

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module
1111

1212
prelude
1313
public import Lean.Linter.EnvLinter.Basic
14+
public import Lean.Linter.Init
1415
import Lean.DeclarationRange
1516
import Lean.Util.Path
1617
import Lean.CoreM
@@ -32,48 +33,36 @@ inductive LintVerbosity
3233
| high
3334
deriving Inhabited, DecidableEq, Repr
3435

35-
/-- Which set of linters to run. -/
36-
inductive LintScope
37-
/-- Run only default linters. -/
38-
| default
39-
/-- Run default linters together with the non-default (extra) linters. -/
40-
| extra
41-
/-- Run all linters (default + extra). -/
42-
| all
43-
deriving Inhabited, DecidableEq, Repr
44-
45-
/-- `getChecks` produces a list of linters to run.
46-
47-
If `runOnly` is populated, only those named linters are run (regardless of `scope`).
48-
Otherwise, linter selection depends on `scope`:
49-
- `default`: only linters with `isDefault = true`
50-
- `extra`: linters with `isDefault = true` together with linters with `isDefault = false`
51-
- `all`: all linters -/
52-
def getChecks (scope : LintScope := .default) (runOnly : Option (List Name) := none) :
53-
CoreM (Array NamedEnvLinter) := do
36+
/--
37+
Getter for the registered environment linters. The result is sorted by the linter option name.
38+
-/
39+
def getEnvLinters (opts? : Option LinterOptions := none) : CoreM (Array NamedEnvLinter) := do
5440
let mut result := #[]
55-
for (name, declName, isDefault) in envLinterExt.getState (← getEnv) do
56-
let shouldRun := match runOnly with
57-
| some only => only.contains name
58-
| none => match scope with
59-
| .default => isDefault
60-
| .extra => true
61-
| .all => true
62-
if shouldRun then
63-
let linter ← getEnvLinter name declName
64-
result := result.binInsert (·.name.lt ·.name) linter
41+
for (optName, declName) in envLinterExt.getState (← getEnv) do
42+
if opts?.all (isLinterEnabledByOptions optName) then
43+
let linter ← getEnvLinter optName declName
44+
result := result.binInsert (·.optName.lt ·.optName) linter
6545
pure result
6646

47+
/--
48+
Queries the `envLinterSnapshotExt` to see if a given environment linter is enabled for the given
49+
declaration.
50+
-/
51+
def isLinterEnabledFor (env : Environment) (linter : NamedEnvLinter) (decl : Name) : Bool :=
52+
match getEnvLinterSnapshotEntry? env decl linter.optName with
53+
| some b => b
54+
| none => false
6755

6856
/--
6957
Runs all the specified linters on all the specified declarations in parallel,
7058
producing a list of results.
7159
-/
7260
def lintCore (decls : Array Name) (linters : Array NamedEnvLinter) :
7361
CoreM (Array (NamedEnvLinter × Std.HashMap Name MessageData)) := do
62+
let env ← getEnv
7463
let tasks : Array (NamedEnvLinter × Array (Name × Task (Except Exception <| Option MessageData))) ←
7564
linters.mapM fun linter => do
76-
let decls decls.filterM (shouldBeLinted linter.name)
65+
let decls := decls.filter (isLinterEnabledFor env linter ·)
7766
(linter, ·) <$> decls.mapM fun decl => (decl, ·) <$> do
7867
let act : MetaM (Option MessageData) := do
7968
linter.test decl
@@ -148,7 +137,7 @@ def formatLinterResults
148137
(results : Array (NamedEnvLinter × Std.HashMap Name MessageData))
149138
(decls : Array Name)
150139
(groupByFilename : Bool)
151-
(whereDesc : String) (scope : LintScope := .default)
140+
(whereDesc : String)
152141
(verbose : LintVerbosity) (numLinters : Nat) (useErrorFormat : Bool := false) :
153142
CoreM MessageData := do
154143
let formattedResults ← results.filterMapM fun (linter, results) => do
@@ -158,23 +147,19 @@ def formatLinterResults
158147
groupedByFilename results (useErrorFormat := useErrorFormat)
159148
else
160149
printWarnings results
161-
pure $ some m!"/- The `{linter.name}` linter reports:\n{linter.errorsFound} -/\n{warnings}\n"
150+
pure $ some m!"/- The `{linter.optName}` linter reports:\n{linter.errorsFound} -/\n{warnings}\n"
162151
else if verbose = LintVerbosity.high then
163152
pure $ some m!"/- OK: {linter.noErrorsFound} -/"
164153
else
165154
pure none
166155
let mut s := MessageData.joinSep formattedResults.toList Format.line
167-
let numAutoDecls := (← decls.filterM isAutoDecl).size
156+
let numAutoDecls := (← decls.filterM isAutoDeclOrPrivate_Internal).size
168157
let failed := results.map (·.2.size) |>.foldl (·+·) 0
169158
unless verbose matches LintVerbosity.low do
170159
s := m!"-- Found {failed} error{if failed == 1 then "" else "s"
171160
} in {decls.size - numAutoDecls} declarations (plus {
172161
numAutoDecls} automatically generated ones) {whereDesc
173162
} with {numLinters} linters\n\n{s}"
174-
match scope with
175-
| .default => s := m!"{s}-- (non-default linters skipped)\n"
176-
| .extra => pure ()
177-
| .all => pure ()
178163
pure s
179164

180165
/-- Get the list of declarations in the current module. -/

0 commit comments

Comments
 (0)