diff --git a/src/Lean/Elab/Frontend.lean b/src/Lean/Elab/Frontend.lean index fd3760667d..27e75d2daf 100644 --- a/src/Lean/Elab/Frontend.lean +++ b/src/Lean/Elab/Frontend.lean @@ -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 diff --git a/src/Lean/Linter.lean b/src/Lean/Linter.lean index 7f0dcc6417..2586a2032d 100644 --- a/src/Lean/Linter.lean +++ b/src/Lean/Linter.lean @@ -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 diff --git a/src/Lean/Linter/Basic.lean b/src/Lean/Linter/Basic.lean index 052c9fc1b3..1066bcd942 100644 --- a/src/Lean/Linter/Basic.lean +++ b/src/Lean/Linter/Basic.lean @@ -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 diff --git a/src/Lean/Linter/PersistentLintLog.lean b/src/Lean/Linter/PersistentLintLog.lean new file mode 100644 index 0000000000..a12e49475b --- /dev/null +++ b/src/Lean/Linter/PersistentLintLog.lean @@ -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 diff --git a/src/lake/Lake/Build/Context.lean b/src/lake/Lake/Build/Context.lean index 8f2eda8d46..3ef2ce8e7f 100644 --- a/src/lake/Lake/Build/Context.lean +++ b/src/lake/Lake/Build/Context.lean @@ -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 diff --git a/src/lake/Lake/Build/Module.lean b/src/lake/Lake/Build/Module.lean index 18094f8690..83ac5af6da 100644 --- a/src/lake/Lake/Build/Module.lean +++ b/src/lake/Lake/Build/Module.lean @@ -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`. -/ diff --git a/src/lake/Lake/CLI/BuiltinLint.lean b/src/lake/Lake/CLI/BuiltinLint.lean index ac4fa1aab3..e7c5cf1ec0 100644 --- a/src/lake/Lake/CLI/BuiltinLint.lean +++ b/src/lake/Lake/CLI/BuiltinLint.lean @@ -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 diff --git a/src/lake/Lake/CLI/Help.lean b/src/lake/Lake/CLI/Help.lean index 6ea8f0ff97..1822aae4e9 100644 --- a/src/lake/Lake/CLI/Help.lean +++ b/src/lake/Lake/CLI/Help.lean @@ -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 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 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]`. diff --git a/src/lake/Lake/CLI/Main.lean b/src/lake/Lake/CLI/Main.lean index a9e59aaf93..3efb002aad 100644 --- a/src/lake/Lake/CLI/Main.lean +++ b/src/lake/Lake/CLI/Main.lean @@ -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) diff --git a/tests/elab/persistent_lint_log.lean b/tests/elab/persistent_lint_log.lean new file mode 100644 index 0000000000..938b3391bc --- /dev/null +++ b/tests/elab/persistent_lint_log.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/Linters.lean b/tests/lake/tests/builtin-lint/Linters.lean index 56f4eda6eb..355dc527e6 100644 --- a/tests/lake/tests/builtin-lint/Linters.lean +++ b/tests/lake/tests/builtin-lint/Linters.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/Main.lean b/tests/lake/tests/builtin-lint/Main.lean index d4a85b8fae..dab87ea09f 100644 --- a/tests/lake/tests/builtin-lint/Main.lean +++ b/tests/lake/tests/builtin-lint/Main.lean @@ -1,3 +1,5 @@ +import Main.Sub + -- This uses `def` for a Prop — the `defLemma` linter should flag this. def shouldBeTheorem : 1 = 1 := rfl diff --git a/tests/lake/tests/builtin-lint/Main/Sub.lean b/tests/lake/tests/builtin-lint/Main/Sub.lean new file mode 100644 index 0000000000..dfe131b8b0 --- /dev/null +++ b/tests/lake/tests/builtin-lint/Main/Sub.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/TextLints.lean b/tests/lake/tests/builtin-lint/TextLints.lean new file mode 100644 index 0000000000..4ad277fe4b --- /dev/null +++ b/tests/lake/tests/builtin-lint/TextLints.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/clean.sh b/tests/lake/tests/builtin-lint/clean.sh index ed07cb2816..d2d1a8e4c9 100755 --- a/tests/lake/tests/builtin-lint/clean.sh +++ b/tests/lake/tests/builtin-lint/clean.sh @@ -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 diff --git a/tests/lake/tests/builtin-lint/dep/Dep.lean b/tests/lake/tests/builtin-lint/dep/Dep.lean new file mode 100644 index 0000000000..e510240880 --- /dev/null +++ b/tests/lake/tests/builtin-lint/dep/Dep.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/dep/lakefile.toml b/tests/lake/tests/builtin-lint/dep/lakefile.toml new file mode 100644 index 0000000000..2da9cef8b2 --- /dev/null +++ b/tests/lake/tests/builtin-lint/dep/lakefile.toml @@ -0,0 +1,4 @@ +name = "dep" + +[[lean_lib]] +name = "Dep" diff --git a/tests/lake/tests/builtin-lint/input/Linters.lean b/tests/lake/tests/builtin-lint/input/Linters.lean index 56f4eda6eb..355dc527e6 100644 --- a/tests/lake/tests/builtin-lint/input/Linters.lean +++ b/tests/lake/tests/builtin-lint/input/Linters.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/input/Main.lean b/tests/lake/tests/builtin-lint/input/Main.lean index d4a85b8fae..dab87ea09f 100644 --- a/tests/lake/tests/builtin-lint/input/Main.lean +++ b/tests/lake/tests/builtin-lint/input/Main.lean @@ -1,3 +1,5 @@ +import Main.Sub + -- This uses `def` for a Prop — the `defLemma` linter should flag this. def shouldBeTheorem : 1 = 1 := rfl diff --git a/tests/lake/tests/builtin-lint/input/Main/Sub.lean b/tests/lake/tests/builtin-lint/input/Main/Sub.lean new file mode 100644 index 0000000000..dfe131b8b0 --- /dev/null +++ b/tests/lake/tests/builtin-lint/input/Main/Sub.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/input/TextLints.lean b/tests/lake/tests/builtin-lint/input/TextLints.lean new file mode 100644 index 0000000000..4ad277fe4b --- /dev/null +++ b/tests/lake/tests/builtin-lint/input/TextLints.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/input/dep/Dep.lean b/tests/lake/tests/builtin-lint/input/dep/Dep.lean new file mode 100644 index 0000000000..e510240880 --- /dev/null +++ b/tests/lake/tests/builtin-lint/input/dep/Dep.lean @@ -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 diff --git a/tests/lake/tests/builtin-lint/input/dep/lakefile.toml b/tests/lake/tests/builtin-lint/input/dep/lakefile.toml new file mode 100644 index 0000000000..2da9cef8b2 --- /dev/null +++ b/tests/lake/tests/builtin-lint/input/dep/lakefile.toml @@ -0,0 +1,4 @@ +name = "dep" + +[[lean_lib]] +name = "Dep" diff --git a/tests/lake/tests/builtin-lint/input/lakefile.toml b/tests/lake/tests/builtin-lint/input/lakefile.toml index 40455136b4..d26a6eef12 100644 --- a/tests/lake/tests/builtin-lint/input/lakefile.toml +++ b/tests/lake/tests/builtin-lint/input/lakefile.toml @@ -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" diff --git a/tests/lake/tests/builtin-lint/lakefile.toml b/tests/lake/tests/builtin-lint/lakefile.toml index 40455136b4..d26a6eef12 100644 --- a/tests/lake/tests/builtin-lint/lakefile.toml +++ b/tests/lake/tests/builtin-lint/lakefile.toml @@ -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" diff --git a/tests/lake/tests/builtin-lint/test.sh b/tests/lake/tests/builtin-lint/test.sh index 811b8b6cac..0fd16aab37 100755 --- a/tests/lake/tests/builtin-lint/test.sh +++ b/tests/lake/tests/builtin-lint/test.sh @@ -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