feat: lake: add support for running text linters from lake lint (#13513)
This PR extends `lake lint --builtin-lint` to also support text linters (i.e. those using `logLint`/`logLintIf`), in addition to the environment linters added in #13431. Text-linter warnings emitted during the build are persisted into each module's `.olean` via a new `Lean.Linter.lintLogExt` environment extension; `lake lint` re-runs the build for the target modules and reads the entries back, reporting them alongside the environment linter output. --------- Co-authored-by: Mac Malone <tydeu@hatpress.net> Co-authored-by: Thomas R. Murrills <68410468+thorimur@users.noreply.github.com>
This commit is contained in:
parent
432d11541b
commit
1a15db69ec
26 changed files with 407 additions and 42 deletions
|
|
@ -10,6 +10,7 @@ public import Lean.Language.Lean
|
|||
public import Lean.Server.References
|
||||
public import Lean.Util.Profiler
|
||||
import Lean.Compiler.Options
|
||||
import Lean.Linter.PersistentLintLog
|
||||
|
||||
public section
|
||||
|
||||
|
|
@ -195,6 +196,9 @@ def runFrontend
|
|||
|
||||
if let some oleanFileName := oleanFileName? then
|
||||
profileitIO ".olean serialization" finalOpts do
|
||||
let allMessages := snaps.getAll.foldl
|
||||
(init := (.empty : MessageLog)) (fun acc s => acc ++ s.diagnostics.msgLog)
|
||||
let env ← Linter.recordLints env allMessages
|
||||
writeModule (writeIR := !Compiler.compiler.postponeCompile.get finalOpts) env oleanFileName
|
||||
|
||||
if let some ileanFileName := ileanFileName? then
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ public import Lean.Linter.UnusedSimpArgs
|
|||
public import Lean.Linter.Coe
|
||||
public import Lean.Linter.GlobalAttributeIn
|
||||
public import Lean.Linter.EnvLinter
|
||||
public import Lean.Linter.PersistentLintLog
|
||||
|
|
|
|||
|
|
@ -65,6 +65,15 @@ register_builtin_option linter.all : Bool := {
|
|||
descr := "enable all linters"
|
||||
}
|
||||
|
||||
register_builtin_option linter.clippy : Bool := {
|
||||
defValue := false
|
||||
descr := "enables the set of clippy linters — linters that are turned off by default and \
|
||||
only available via `lake lint`. A clippy linter early-returns unless this option is true."
|
||||
}
|
||||
|
||||
def getLinterClippy (o : LinterOptions) : Bool :=
|
||||
o.get linter.clippy.name linter.clippy.defValue
|
||||
|
||||
def getLinterAll (o : LinterOptions) (defValue := linter.all.defValue) : Bool :=
|
||||
o.get linter.all.name defValue
|
||||
|
||||
|
|
|
|||
41
src/Lean/Linter/PersistentLintLog.lean
Normal file
41
src/Lean/Linter/PersistentLintLog.lean
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/-
|
||||
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
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Lean.Environment
|
||||
public import Lean.Message
|
||||
|
||||
public section
|
||||
|
||||
namespace Lean.Linter
|
||||
|
||||
structure LintEntry where
|
||||
linter : Name
|
||||
message : SerialMessage
|
||||
|
||||
builtin_initialize lintLogExt :
|
||||
PersistentEnvExtension LintEntry LintEntry (Array LintEntry) ←
|
||||
registerPersistentEnvExtension {
|
||||
mkInitial := pure #[]
|
||||
addImportedFn := fun _ => pure #[]
|
||||
addEntryFn := Array.push
|
||||
exportEntriesFn := id
|
||||
}
|
||||
|
||||
def getAllLints (env : Environment) : Array (Name × Array LintEntry) :=
|
||||
env.header.moduleNames.mapIdx fun i mod =>
|
||||
(mod, lintLogExt.getModuleEntries env i)
|
||||
|
||||
def recordLints (env : Environment) (messages : MessageLog) : BaseIO Environment := do
|
||||
messages.reportedPlusUnreported.foldlM (init := env) fun env m => do
|
||||
let kind := m.data.kind
|
||||
if kind.isAnonymous then
|
||||
return env
|
||||
let sm ← m.serialize
|
||||
return lintLogExt.addEntry env { linter := kind, message := sm }
|
||||
|
||||
end Lean.Linter
|
||||
|
|
@ -27,6 +27,17 @@ public structure BuildConfig extends LogConfig where
|
|||
showSuccess : Bool := false
|
||||
/-- File to save input-to-output mappings from the build of the workspace's root -/
|
||||
outputsFile? : Option FilePath := none
|
||||
/--
|
||||
Per-package Lean option overrides, applied to every module whose owning
|
||||
package's `baseName` appears as a key. When `recFetchSetup` builds module
|
||||
`M`, the `LeanOptions` associated with `M.pkg.baseName` (if any) are appended
|
||||
to `M.leanOptions`, overriding clashing entries.
|
||||
|
||||
Used by `lake lint` to inject `linter.clippy`/`linter.all` into every module
|
||||
of a target package (so transitively-imported first-party modules capture
|
||||
linter-tagged warnings), without touching dependencies.
|
||||
-/
|
||||
leanOptOverrides : Lean.NameMap Lean.LeanOptions := {}
|
||||
|
||||
/--
|
||||
Whether the build should show progress information.
|
||||
|
|
@ -91,3 +102,7 @@ public instance [Pure m] : MonadLift LakeM (BuildT m) where
|
|||
|
||||
@[inline] public def getIsQuiet [Functor m] [MonadBuild m] : m Bool :=
|
||||
(· == .quiet) <$> getVerbosity
|
||||
|
||||
@[inline] public def getLeanOptOverrides [Functor m] [MonadBuild m]
|
||||
: m (Lean.NameMap Lean.LeanOptions) :=
|
||||
(·.leanOptOverrides) <$> getBuildConfig
|
||||
|
|
|
|||
|
|
@ -540,6 +540,7 @@ def Module.recFetchSetup (mod : Module) : FetchM (Job ModuleSetup) := ensureJob
|
|||
| some false => addTrace depTrace; addTrace libTrace; addPlatformTrace
|
||||
| some true => addTrace depTrace
|
||||
let {dynlibs, plugins} ← computeModuleDeps impLibs externLibs dynlibs plugins
|
||||
let extra := (← getLeanOptOverrides).find? mod.pkg.baseName |>.getD {}
|
||||
return {
|
||||
name := mod.name
|
||||
isModule := header.isModule
|
||||
|
|
@ -548,7 +549,7 @@ def Module.recFetchSetup (mod : Module) : FetchM (Job ModuleSetup) := ensureJob
|
|||
importArts := info.directArts
|
||||
dynlibs := dynlibs.map (·.path)
|
||||
plugins := plugins.map (·.path)
|
||||
options := mod.leanOptions
|
||||
options := mod.leanOptions ++ extra
|
||||
}
|
||||
|
||||
/-- The `ModuleFacetConfig` for the builtin `setupFacet`. -/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ module
|
|||
|
||||
prelude
|
||||
public import Lean.Linter.EnvLinter
|
||||
public import Lean.Linter.PersistentLintLog
|
||||
import Lean.CoreM
|
||||
import Lake.Config.Workspace
|
||||
|
||||
|
|
@ -20,16 +21,37 @@ public structure Args where
|
|||
scope : Linter.EnvLinter.LintScope := .default
|
||||
/-- Run only the specified linters. -/
|
||||
only : Array Name := #[]
|
||||
/-- Skip the up-to-date build check. -/
|
||||
force : Bool := false
|
||||
/-- The list of root modules to lint. -/
|
||||
mods : Array Name := #[]
|
||||
|
||||
/--
|
||||
Run the builtin environment linters on the given modules.
|
||||
public def leanOptOverrides (args : Args) : LeanOptions :=
|
||||
let enableAll : Array LeanOption :=
|
||||
#[⟨`linter.clippy, .ofBool true⟩, ⟨`linter.all, .ofBool true⟩]
|
||||
if !args.only.isEmpty then
|
||||
LeanOptions.ofArray enableAll
|
||||
else
|
||||
match args.scope with
|
||||
| .default => {}
|
||||
| .clippy => LeanOptions.ofArray #[⟨`linter.clippy, .ofBool true⟩]
|
||||
| .all => LeanOptions.ofArray enableAll
|
||||
|
||||
private def collectTextLints
|
||||
(env : Environment) (args : Args) (pkgRoot : Name) : Array Linter.LintEntry :=
|
||||
let matchOnly (linter : Name) : Bool :=
|
||||
args.only.isEmpty || args.only.any (fun n => n.isSuffixOf linter)
|
||||
let matchScope (linter : Name) : Bool :=
|
||||
if !args.only.isEmpty then true
|
||||
else match args.scope with
|
||||
| .default => linter != `linter.clippy
|
||||
| .clippy => linter == `linter.clippy
|
||||
| .all => true
|
||||
Linter.getAllLints env |>.foldl (init := #[]) fun acc (mod, entries) =>
|
||||
if pkgRoot.isPrefixOf mod then
|
||||
entries.foldl (init := acc) fun acc e =>
|
||||
if matchOnly e.linter && matchScope e.linter then acc.push e else acc
|
||||
else
|
||||
acc
|
||||
|
||||
Assumes Lean's search path has already been properly configured.
|
||||
-/
|
||||
public def run (args : Args) : IO UInt32 := do
|
||||
let mods := args.mods
|
||||
if mods.isEmpty then
|
||||
|
|
@ -44,11 +66,19 @@ public def run (args : Args) : IO UInt32 := do
|
|||
for mod in mods do
|
||||
unsafe Lean.enableInitializersExecution
|
||||
let env ← importModules #[{ module := mod }, envLinterModule] {} (trustLevel := 1024) (loadExts := true)
|
||||
let (result, _) ← CoreM.toIO (ctx := { fileName := "", fileMap := default }) (s := { env }) do
|
||||
|
||||
let textEntries := collectTextLints env args mod.getRoot
|
||||
let textFailed := !textEntries.isEmpty
|
||||
if textFailed then
|
||||
IO.println s!"-- Text linter diagnostics in {mod}:"
|
||||
for e in textEntries do
|
||||
IO.print e.message.toString
|
||||
|
||||
let (declFailed, _) ← CoreM.toIO (ctx := { fileName := "", fileMap := default }) (s := { env }) do
|
||||
let decls ← Linter.EnvLinter.getDeclsInPackage mod.getRoot
|
||||
let linters ← Linter.EnvLinter.getChecks (scope := scope) (runOnly := runOnly)
|
||||
if linters.isEmpty then
|
||||
IO.println s!"-- No linters registered for {mod}."
|
||||
IO.println s!"-- No environment linters registered for {mod}."
|
||||
return false
|
||||
let results ← Linter.EnvLinter.lintCore decls linters
|
||||
let failed := results.any (!·.2.isEmpty)
|
||||
|
|
@ -58,10 +88,11 @@ public def run (args : Args) : IO UInt32 := do
|
|||
(groupByFilename := true) (useErrorFormat := true)
|
||||
s!"in {mod}" (scope := if args.only.isEmpty then scope else .all) .medium linters.size
|
||||
IO.print (← fmtResults.toString)
|
||||
else
|
||||
else unless textFailed do
|
||||
IO.println s!"-- Linting passed for {mod}."
|
||||
return failed
|
||||
if result then
|
||||
|
||||
if textFailed || declFailed then
|
||||
anyFailed := true
|
||||
|
||||
return if anyFailed then 1 else 0
|
||||
|
|
|
|||
|
|
@ -251,18 +251,23 @@ USAGE:
|
|||
By default, runs the package's configured lint driver. If `builtinLint` is
|
||||
set to `true` in the package configuration, builtin lints also run.
|
||||
|
||||
Builtin linting (`--builtin-lint`, `--builtin-only`, `--clippy`, `--lint-all`,
|
||||
`--lint-only`, or `builtinLint = true` in the package configuration) drives a
|
||||
build of the targeted modules with the requested linter options enabled.
|
||||
The lint driver path on its own does not trigger a build.
|
||||
|
||||
Positional `MODULE` arguments narrow only the builtin lints; if omitted,
|
||||
the workspace's default target roots are used. The lint driver is invoked
|
||||
with `lintDriverArgs` from the package config plus any arguments after
|
||||
`--`; the `MODULE` list is not passed to it.
|
||||
|
||||
OPTIONS:
|
||||
--builtin-lint run builtin environment linters
|
||||
--builtin-lint run builtin environment and text linters
|
||||
--builtin-only run only builtin linters, skip the lint driver
|
||||
--clippy run only non-default (clippy) builtin linters
|
||||
--lint-all run all builtin linters (default + clippy)
|
||||
--lint-only <name> run only the specified builtin linter (repeatable)
|
||||
--force skip the up-to-date build check
|
||||
--lint-all run all registered linters, including defaults, clippy,
|
||||
and any other disabled-by-default linters
|
||||
--lint-only <name> run only the specified linter (repeatable)
|
||||
|
||||
A lint driver can be configured by either setting the `lintDriver` package
|
||||
configuration option or by tagging a script or executable `@[lint_driver]`.
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ def lakeLongOption : (opt : String) → CliM PUnit
|
|||
modifyThe LakeOptions fun opts =>
|
||||
{opts with runBuiltinLint := true, builtinLint.only := opts.builtinLint.only.push name.toName}
|
||||
-- Shared options
|
||||
| "--force" => modifyThe LakeOptions ({· with shake.force := true, builtinLint.force := true})
|
||||
| "--force" => modifyThe LakeOptions ({· with shake.force := true})
|
||||
-- Shake options
|
||||
| "--keep-implied" => modifyThe LakeOptions ({· with shake.keepImplied := true})
|
||||
| "--keep-prefix" => modifyThe LakeOptions ({· with shake.keepPrefix := true})
|
||||
|
|
@ -972,16 +972,29 @@ protected def checkTest : CliM PUnit := do
|
|||
noArgsRem do exit <| if pkg.testDriver.isEmpty then 1 else 0
|
||||
|
||||
private def runBuiltinLint
|
||||
(ws : Workspace) (args : BuiltinLint.Args) (mods : Array Lean.Name) : CliM UInt32 := do
|
||||
let mods := if mods.isEmpty then ws.defaultTargetRoots else mods
|
||||
(opts : LakeOptions) (ws : Workspace) (specifiedMods : Array Lean.Name)
|
||||
: CliM UInt32 := do
|
||||
let mods := if specifiedMods.isEmpty then ws.defaultTargetRoots else specifiedMods
|
||||
if mods.isEmpty then
|
||||
error "no modules specified and there are no applicable default targets"
|
||||
let args := opts.builtinLint
|
||||
let args := {args with mods}
|
||||
unless args.force do
|
||||
let specs ← parseTargetSpecs ws (mods.map (s!"+{·}") |>.toList)
|
||||
let upToDate ← ws.checkNoBuild <| buildSpecs specs
|
||||
unless upToDate do
|
||||
error "there are out of date oleans; run `lake build` or fetch them from a cache first"
|
||||
let specs ← parseTargetSpecs ws (mods.map (s!"+{·}") |>.toList)
|
||||
let lintOpts := BuiltinLint.leanOptOverrides args
|
||||
let overrides : Lean.NameMap Lean.LeanOptions :=
|
||||
if lintOpts.values.isEmpty then
|
||||
{}
|
||||
else
|
||||
mods.foldl (init := ({} : Lean.NameMap Lean.LeanOptions))
|
||||
fun m modName =>
|
||||
match ws.findTargetModule? modName with
|
||||
| some mod => m.insert mod.pkg.baseName lintOpts
|
||||
| none => m
|
||||
let buildCfg := { mkBuildConfig opts with
|
||||
outLv := .error
|
||||
leanOptOverrides := overrides
|
||||
}
|
||||
ws.runBuild (buildSpecs specs) buildCfg
|
||||
Lean.searchPathRef.set ws.augmentedLeanPath
|
||||
BuiltinLint.run args
|
||||
|
||||
|
|
@ -995,7 +1008,7 @@ protected def lint : CliM PUnit := do
|
|||
let mut exitCode : UInt32 := 0
|
||||
if doBuiltinLint then
|
||||
let mods := (← takeArgs).toArray.map (·.toName)
|
||||
exitCode ← runBuiltinLint ws opts.builtinLint mods
|
||||
exitCode ← runBuiltinLint opts ws mods
|
||||
if hasDriver then
|
||||
let driverExitCode ← noArgsRem do
|
||||
ws.root.lint opts.subArgs (mkBuildConfig opts) |>.run (mkLakeContext ws)
|
||||
|
|
|
|||
65
tests/elab/persistent_lint_log.lean
Normal file
65
tests/elab/persistent_lint_log.lean
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import Lean
|
||||
|
||||
open Lean
|
||||
|
||||
/-! ## Tests for the persistent lint log extension -/
|
||||
|
||||
/--
|
||||
Builds a `MessageLog` containing a single tagged warning whose `MessageData.kind` is
|
||||
`linter.dummy`, runs `Linter.recordLints`, and returns the resulting extension state.
|
||||
-/
|
||||
def testRecordLints : CoreM (Array (Name × String)) := do
|
||||
let env ← getEnv
|
||||
let tagged : MessageData := .tagged `linter.dummy (m!"unused variable 'x'")
|
||||
let msg : Message := {
|
||||
fileName := "Test.lean"
|
||||
pos := ⟨3, 5⟩
|
||||
severity := .warning
|
||||
data := tagged
|
||||
}
|
||||
let log : MessageLog := MessageLog.empty.add msg
|
||||
let env ← Linter.recordLints env log
|
||||
return (Linter.lintLogExt.getState env).map fun e =>
|
||||
(e.linter, e.message.data)
|
||||
|
||||
/-- info: #[(`linter.dummy, "unused variable 'x'")] -/
|
||||
#guard_msgs in
|
||||
#eval testRecordLints
|
||||
|
||||
/-- Untagged messages must be ignored. -/
|
||||
def testRecordLintsIgnoresUntagged : CoreM Nat := do
|
||||
let env ← getEnv
|
||||
let msg : Message := {
|
||||
fileName := "Test.lean"
|
||||
pos := ⟨1, 1⟩
|
||||
severity := .error
|
||||
data := m!"plain error with no tag"
|
||||
}
|
||||
let log : MessageLog := MessageLog.empty.add msg
|
||||
let env ← Linter.recordLints env log
|
||||
return (Linter.lintLogExt.getState env).size
|
||||
|
||||
/-- info: 0 -/
|
||||
#guard_msgs in
|
||||
#eval testRecordLintsIgnoresUntagged
|
||||
|
||||
/-- Multiple tagged messages are all recorded, in log order. -/
|
||||
def testRecordLintsMultiple : CoreM (Array Name) := do
|
||||
let env ← getEnv
|
||||
let mk (kind : Name) (txt : String) : Message := {
|
||||
fileName := "Test.lean"
|
||||
pos := ⟨1, 1⟩
|
||||
severity := .warning
|
||||
data := .tagged kind (m!"{txt}")
|
||||
}
|
||||
let log : MessageLog :=
|
||||
MessageLog.empty
|
||||
|>.add (mk `linter.a "a")
|
||||
|>.add (mk `linter.b "b")
|
||||
|>.add (mk `linter.a "a2")
|
||||
let env ← Linter.recordLints env log
|
||||
return (Linter.lintLogExt.getState env).map (·.linter)
|
||||
|
||||
/-- info: #[`linter.a, `linter.b, `linter.a] -/
|
||||
#guard_msgs in
|
||||
#eval testRecordLintsMultiple
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import Lean.Linter.EnvLinter
|
||||
|
||||
open Lean Meta
|
||||
open Lean Meta Lean.Linter Lean.Elab.Command
|
||||
|
||||
-- A dummy clippy linter that flags any declaration whose name ends with "Clippy".
|
||||
@[builtin_env_linter clippy] public meta def dummyClippy : Lean.Linter.EnvLinter.EnvLinter where
|
||||
|
|
@ -10,3 +10,14 @@ open Lean Meta
|
|||
if declName.toString.endsWith "Clippy" then
|
||||
return some "declaration name ends with 'Clippy'"
|
||||
return none
|
||||
|
||||
-- A dummy clippy text linter: fires on every `declaration` command, but only
|
||||
-- when `linter.clippy = true`. Tags its warnings with `linter.clippy` via
|
||||
-- `logLint`, which is how `lake lint --clippy` identifies clippy-scope entries.
|
||||
def dummyClippyTextLinter : Linter where
|
||||
run cmdStx := do
|
||||
unless getLinterClippy (← getLinterOptions) do return
|
||||
unless cmdStx.getKind == ``Lean.Parser.Command.declaration do return
|
||||
logLint linter.clippy cmdStx m!"clippy text linter saw a declaration"
|
||||
|
||||
initialize addLinter dummyClippyTextLinter
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import Main.Sub
|
||||
|
||||
-- This uses `def` for a Prop — the `defLemma` linter should flag this.
|
||||
def shouldBeTheorem : 1 = 1 := rfl
|
||||
|
||||
|
|
|
|||
15
tests/lake/tests/builtin-lint/Main/Sub.lean
Normal file
15
tests/lake/tests/builtin-lint/Main/Sub.lean
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Env-linter violation: `def` on a Prop — should be caught by `defLemma`
|
||||
-- regardless of build-time options, since env linters run post-build.
|
||||
def shouldBeTheoremInSub : 1 = 1 := rfl
|
||||
|
||||
-- Default text-linter violation: `linter.unusedVariables` has `defValue := true`,
|
||||
-- so it fires on any build and the warning lands in Main.Sub.olean unconditionally.
|
||||
def unusedVarInSub : Nat :=
|
||||
let unusedInSub := 7
|
||||
3
|
||||
|
||||
-- Non-default text-linter violation: `linter.missingDocs` has `defValue := false`,
|
||||
-- so it only fires when the module was compiled with `linter.all = true`. Under
|
||||
-- the current per-module override scheme, Main.Sub does NOT get that flag
|
||||
-- (only `Main` does), so this warning will be absent from Main.Sub.olean.
|
||||
def undocumentedInSub : Nat := 99
|
||||
11
tests/lake/tests/builtin-lint/TextLints.lean
Normal file
11
tests/lake/tests/builtin-lint/TextLints.lean
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- `linter.unusedVariables` has `defValue := true`, so it fires during the normal
|
||||
-- build that backs `lake lint --builtin-lint`. The warning is captured in the
|
||||
-- olean via `lintLogExt` and re-emitted by `lake lint`.
|
||||
def unusedVarFixture : Nat :=
|
||||
let unusedLet := 5
|
||||
3
|
||||
|
||||
-- `linter.missingDocs` has `defValue := false`, so it only fires when Lake's
|
||||
-- build is invoked with `linter.all = true` (i.e. `--lint-all` or `--lint-only`).
|
||||
-- This public def has no docstring and will be flagged accordingly.
|
||||
def undocumentedPublicDef : Nat := 42
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
rm -rf .lake
|
||||
rm -f lakefile.toml lake-manifest.json Main.lean Clean.lean Linters.lean ClippyViolations.lean
|
||||
rm -f lakefile.toml lake-manifest.json Main.lean Clean.lean Linters.lean ClippyViolations.lean TextLints.lean
|
||||
rm -rf Main
|
||||
rm -rf dep
|
||||
|
|
|
|||
5
tests/lake/tests/builtin-lint/dep/Dep.lean
Normal file
5
tests/lake/tests/builtin-lint/dep/Dep.lean
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- A public def without a docstring. `linter.missingDocs` (defValue=false) only
|
||||
-- fires when `linter.all=true` is injected during the build. This file lives in
|
||||
-- a non-root package (`dep`), so the option override must be keyed by the
|
||||
-- `dep` package's baseName for the warning to be captured.
|
||||
def undocumentedInDep : Nat := 7
|
||||
4
tests/lake/tests/builtin-lint/dep/lakefile.toml
Normal file
4
tests/lake/tests/builtin-lint/dep/lakefile.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name = "dep"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "Dep"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import Lean.Linter.EnvLinter
|
||||
|
||||
open Lean Meta
|
||||
open Lean Meta Lean.Linter Lean.Elab.Command
|
||||
|
||||
-- A dummy clippy linter that flags any declaration whose name ends with "Clippy".
|
||||
@[builtin_env_linter clippy] public meta def dummyClippy : Lean.Linter.EnvLinter.EnvLinter where
|
||||
|
|
@ -10,3 +10,14 @@ open Lean Meta
|
|||
if declName.toString.endsWith "Clippy" then
|
||||
return some "declaration name ends with 'Clippy'"
|
||||
return none
|
||||
|
||||
-- A dummy clippy text linter: fires on every `declaration` command, but only
|
||||
-- when `linter.clippy = true`. Tags its warnings with `linter.clippy` via
|
||||
-- `logLint`, which is how `lake lint --clippy` identifies clippy-scope entries.
|
||||
def dummyClippyTextLinter : Linter where
|
||||
run cmdStx := do
|
||||
unless getLinterClippy (← getLinterOptions) do return
|
||||
unless cmdStx.getKind == ``Lean.Parser.Command.declaration do return
|
||||
logLint linter.clippy cmdStx m!"clippy text linter saw a declaration"
|
||||
|
||||
initialize addLinter dummyClippyTextLinter
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import Main.Sub
|
||||
|
||||
-- This uses `def` for a Prop — the `defLemma` linter should flag this.
|
||||
def shouldBeTheorem : 1 = 1 := rfl
|
||||
|
||||
|
|
|
|||
15
tests/lake/tests/builtin-lint/input/Main/Sub.lean
Normal file
15
tests/lake/tests/builtin-lint/input/Main/Sub.lean
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Env-linter violation: `def` on a Prop — should be caught by `defLemma`
|
||||
-- regardless of build-time options, since env linters run post-build.
|
||||
def shouldBeTheoremInSub : 1 = 1 := rfl
|
||||
|
||||
-- Default text-linter violation: `linter.unusedVariables` has `defValue := true`,
|
||||
-- so it fires on any build and the warning lands in Main.Sub.olean unconditionally.
|
||||
def unusedVarInSub : Nat :=
|
||||
let unusedInSub := 7
|
||||
3
|
||||
|
||||
-- Non-default text-linter violation: `linter.missingDocs` has `defValue := false`,
|
||||
-- so it only fires when the module was compiled with `linter.all = true`. Under
|
||||
-- the current per-module override scheme, Main.Sub does NOT get that flag
|
||||
-- (only `Main` does), so this warning will be absent from Main.Sub.olean.
|
||||
def undocumentedInSub : Nat := 99
|
||||
11
tests/lake/tests/builtin-lint/input/TextLints.lean
Normal file
11
tests/lake/tests/builtin-lint/input/TextLints.lean
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- `linter.unusedVariables` has `defValue := true`, so it fires during the normal
|
||||
-- build that backs `lake lint --builtin-lint`. The warning is captured in the
|
||||
-- olean via `lintLogExt` and re-emitted by `lake lint`.
|
||||
def unusedVarFixture : Nat :=
|
||||
let unusedLet := 5
|
||||
3
|
||||
|
||||
-- `linter.missingDocs` has `defValue := false`, so it only fires when Lake's
|
||||
-- build is invoked with `linter.all = true` (i.e. `--lint-all` or `--lint-only`).
|
||||
-- This public def has no docstring and will be flagged accordingly.
|
||||
def undocumentedPublicDef : Nat := 42
|
||||
5
tests/lake/tests/builtin-lint/input/dep/Dep.lean
Normal file
5
tests/lake/tests/builtin-lint/input/dep/Dep.lean
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- A public def without a docstring. `linter.missingDocs` (defValue=false) only
|
||||
-- fires when `linter.all=true` is injected during the build. This file lives in
|
||||
-- a non-root package (`dep`), so the option override must be keyed by the
|
||||
-- `dep` package's baseName for the warning to be captured.
|
||||
def undocumentedInDep : Nat := 7
|
||||
4
tests/lake/tests/builtin-lint/input/dep/lakefile.toml
Normal file
4
tests/lake/tests/builtin-lint/input/dep/lakefile.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name = "dep"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "Dep"
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
name = "lint"
|
||||
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations"]
|
||||
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations", "TextLints"]
|
||||
|
||||
[[require]]
|
||||
name = "dep"
|
||||
path = "dep"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "Main"
|
||||
globs = "Main.*"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "Clean"
|
||||
|
|
@ -12,3 +17,6 @@ name = "Linters"
|
|||
|
||||
[[lean_lib]]
|
||||
name = "ClippyViolations"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "TextLints"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
name = "lint"
|
||||
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations"]
|
||||
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations", "TextLints"]
|
||||
|
||||
[[require]]
|
||||
name = "dep"
|
||||
path = "dep"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "Main"
|
||||
globs = "Main.*"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "Clean"
|
||||
|
|
@ -12,3 +17,6 @@ name = "Linters"
|
|||
|
||||
[[lean_lib]]
|
||||
name = "ClippyViolations"
|
||||
|
||||
[[lean_lib]]
|
||||
name = "TextLints"
|
||||
|
|
|
|||
|
|
@ -4,19 +4,32 @@ source ../common.sh
|
|||
./clean.sh
|
||||
cp -r input/* .
|
||||
|
||||
# --builtin-lint should fail with a clear message when oleans are not built
|
||||
lake_out lint --builtin-lint || true
|
||||
match_pat 'out of date oleans' produced.out
|
||||
|
||||
# up-to-date check is per-module: building only Clean should let us lint Clean
|
||||
test_run build Clean
|
||||
# --builtin-lint drives the build itself; we do not need to `lake build` first.
|
||||
# Linting Clean should succeed (no violations) and implicitly build Clean.
|
||||
test_run lint --builtin-only Clean
|
||||
|
||||
# but linting Main (not yet built) should still fail the up-to-date check
|
||||
lake_out lint --builtin-only Main || true
|
||||
match_pat 'out of date oleans' produced.out
|
||||
# --- Text linter capture (persistent lint log) ---
|
||||
|
||||
test_run build
|
||||
# Default scope: `linter.unusedVariables` (defValue=true) fires during the build,
|
||||
# is captured in `lintLogExt`, and is re-emitted by `lake lint` post-build.
|
||||
# `linter.missingDocs` (defValue=false) must NOT fire without --lint-all/--lint-only.
|
||||
lake_out lint --builtin-only TextLints || true
|
||||
match_pat 'unused variable `unusedLet`' produced.out
|
||||
no_match_pat 'missing doc string' produced.out
|
||||
|
||||
# --lint-all enables all linters, so missingDocs fires too.
|
||||
lake_out lint --lint-all TextLints || true
|
||||
match_pat 'unused variable `unusedLet`' produced.out
|
||||
match_pat 'missing doc string for public def undocumentedPublicDef' produced.out
|
||||
|
||||
# --lint-only filters entries by suffix match against the linter name.
|
||||
lake_out lint --lint-only missingDocs TextLints || true
|
||||
match_pat 'missing doc string for public def undocumentedPublicDef' produced.out
|
||||
no_match_pat 'unused variable' produced.out
|
||||
|
||||
lake_out lint --lint-only unusedVariables TextLints || true
|
||||
match_pat 'unused variable `unusedLet`' produced.out
|
||||
no_match_pat 'missing doc string' produced.out
|
||||
|
||||
# --builtin-lint should detect the defLemma violation in Main (the default target)
|
||||
lake_out lint --builtin-lint || true
|
||||
|
|
@ -38,23 +51,63 @@ lake_out lint --lint-only defLemma || true
|
|||
match_pat 'shouldBeTheorem' produced.out
|
||||
no_match_pat 'badUnivDecl' produced.out
|
||||
|
||||
# --- Transitive-import behaviour ---
|
||||
# `Main` (a default target) imports `Main.Sub`. Both live under the `Main.*`
|
||||
# module-name prefix, so `getDeclsInPackage Main` covers them and
|
||||
# `collectTextLints` filters by `Main.isPrefixOf mod`. Overrides are keyed by
|
||||
# package, so passing any module of a package flips the flag for every module
|
||||
# in that package.
|
||||
|
||||
# Env linters run post-build against `importModules`-loaded decls, so
|
||||
# `defLemma` catches `shouldBeTheoremInSub` regardless of override scope.
|
||||
lake_out lint --builtin-lint Main || true
|
||||
match_pat 'shouldBeTheoremInSub' produced.out
|
||||
|
||||
# `linter.unusedVariables` (defValue=true) fires on every build, so its entry
|
||||
# lands in `Main.Sub.olean` unconditionally.
|
||||
match_pat 'unused variable `unusedInSub`' produced.out
|
||||
|
||||
# Explicit arg with --lint-all: the override applies to the whole package of
|
||||
# `Main`, so `Main.Sub` is also built with `linter.all=true` and the
|
||||
# missingDocs warning IS captured.
|
||||
lake_out lint --lint-all Main || true
|
||||
match_pat 'missing doc string for public def undocumentedInSub' produced.out
|
||||
|
||||
# No args: override is keyed by the root package; same effect on Main.Sub.
|
||||
lake_out lint --lint-all || true
|
||||
match_pat 'missing doc string for public def undocumentedInSub' produced.out
|
||||
|
||||
# Clean module has no violations; exit code should be 0
|
||||
test_run lint --builtin-only Clean
|
||||
|
||||
# Without --clippy, the clippy linter should not run
|
||||
# Without --clippy, the clippy linters (both the env linter and the dummy clippy
|
||||
# text linter in Linters.lean) must not run.
|
||||
lake_out lint --builtin-only ClippyViolations || true
|
||||
no_match_pat 'badNameClippy' produced.out
|
||||
no_match_pat 'clippy text linter saw a declaration' produced.out
|
||||
|
||||
# --clippy should run only non-default (clippy) linters
|
||||
# --clippy should run only non-default (clippy) linters, including the clippy
|
||||
# text linter which tags its warnings with `linter.clippy`.
|
||||
lake_out lint --clippy ClippyViolations || true
|
||||
match_pat 'badNameClippy' produced.out
|
||||
match_pat "declaration name ends with 'Clippy'" produced.out
|
||||
match_pat 'clippy text linter saw a declaration' produced.out
|
||||
# --clippy should not run default linters
|
||||
no_match_pat 'shouldBeTheorem' produced.out
|
||||
|
||||
# --lint-all should run both default and clippy linters
|
||||
# --clippy on TextLints: the default `linter.unusedVariables` entry is filtered
|
||||
# out because its tag is not `linter.clippy`. The file has no clippy-tagged
|
||||
# linter, so the clippy-scope text-linter output should be empty.
|
||||
lake_out lint --clippy TextLints || true
|
||||
no_match_pat 'unused variable' produced.out
|
||||
no_match_pat 'missing doc string' produced.out
|
||||
|
||||
# --lint-all should run both default and clippy linters, for both the
|
||||
# declaration-linter flow (badNameClippy from `dummyClippy`) and the text-linter
|
||||
# flow (the `linter.clippy`-tagged warning from `dummyClippyTextLinter`).
|
||||
lake_out lint --lint-all ClippyViolations || true
|
||||
match_pat 'badNameClippy' produced.out
|
||||
match_pat 'clippy text linter saw a declaration' produced.out
|
||||
|
||||
# Multiple --lint-only flags accumulate: both named linters should run
|
||||
lake_out lint --lint-only defLemma --lint-only checkUnivs || true
|
||||
|
|
@ -146,3 +199,16 @@ match_pat 'lint-driver:' produced.out
|
|||
|
||||
# builtinLint = true + lint driver: check-lint succeeds
|
||||
test_run -f with-driver.lean check-lint
|
||||
|
||||
# --- Non-root package as a lint target ---
|
||||
# `Dep` lives in a path-based dependency (`dep`), not in the root package.
|
||||
# Specifying it on the command line must key the linter option override by
|
||||
# the *dep* package's baseName, not the root's, so that `linter.all=true`
|
||||
# reaches `Dep` during build and `missingDocs` is captured in its olean.
|
||||
lake_out lint --lint-all Dep || true
|
||||
match_pat 'missing doc string for public def undocumentedInDep' produced.out
|
||||
|
||||
# Baseline: without `--lint-all`, no override is injected, so `missingDocs`
|
||||
# stays at its default (off) and produces no entry for `Dep`.
|
||||
lake_out lint --builtin-only Dep || true
|
||||
no_match_pat 'missing doc string for public def undocumentedInDep' produced.out
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue