The Lean Language Reference

26.3. Implementing Linters🔗

Many large-scale projects require custom linters that enforce project-specific standards. The steps for defining a new linter are:

  1. Registering an option to control the new linter.

  2. Determining whether the linter should be a command linter, module linter, stateful linter, or environment linter.

  3. Deciding which code quality metrics, if any, should be saved.

  4. Implementing any package-level code quality checks that are needed.

The first step in defining a linter is to register the option that controls it. Options are registered using Lean.Option.registerOption : commandregister_option, which takes a default value and a description for the new option. The default value determines whether the linter is enabled or disabled by default. In a module, this option should be both public and in the meta phase. Linters should be controlled by a Bool option. Further options that configure a linter, such as options that set thresholds, may be added within the linter's own namespace.

Linters should use logLint or Lean.Linter.logLintIf instead of Lean.logWarning to display lint warnings. These helpers ensure that the linter warning's message is formatted consistently. They also internally tag it as a linter message, which causes Lake and Lean to include it in lake lint's output. logLintIf additionally resolves the linter option's value correctly, taking linter.all and linter sets into consideration. logLint should be used in contexts where the linter's option has already been checked.

Some linters need to check whether they are enabled before they are ready to log a warning. For example, computationally expensive linters that do not compute any useful code quality metrics may wish to terminate early if they are not enabled. These linters should check whether they are enabled using helpers that correctly take linter sets and linter.all into account. This is done by first calling Lean.Linter.getLinterOptions, and then checking the option's value using Lean.Linter.getLinterValue.

Finally, the implementation of each linter should wrap command-specific checks with Lean.withSetOptionIn. Without this wrapper, uses of Lean.Parser.Command.set_option : command`set_option <id> <value>` sets the option `<id>` to `<value>`. Depending on the type of the option, the value can be `true`, `false`, a string, or a numeral. Options are used to configure behavior of Lean as well as user-defined extensions. The setting is active until the end of the current `section` or `namespace` or the end of the file. Auto-completion is available for `<id>` to list available options. `set_option <id> <value> in <command>` sets the option for just a single command: ``` set_option pp.all true in #check 1 + 1 ``` Similarly, `set_option <id> <value> in` can also be used inside terms and tactics to set an option only in a single term or tactic. set_option...Lean.Parser.Command.in : commandin ... around the command being linted are not taken into account. Command linters and stateful linters should wrap it around their entire implementation, while module linters should wrap operations on single commands.

🔗def
Lean.Linter.logLintIf {m : Type → Type} [Monad m] [Lean.MonadLog m] [Lean.AddMessageContext m] [Lean.MonadOptions m] [Lean.MonadEnv m] (linterOption : Lean.Option Bool) (stx : Lean.Syntax) (msg : Lean.MessageData) : m Unit
Lean.Linter.logLintIf {m : Type → Type} [Monad m] [Lean.MonadLog m] [Lean.AddMessageContext m] [Lean.MonadOptions m] [Lean.MonadEnv m] (linterOption : Lean.Option Bool) (stx : Lean.Syntax) (msg : Lean.MessageData) : m Unit

If linterOption is enabled, print a linter warning message at the position determined by stx.

Whether a linter option is enabled or not is determined by the following sequence:

  1. If it is set, then the value determines whether or not it is enabled.

  2. Otherwise, if linter.all is set, then its value determines whether or not the option is enabled.

  3. Otherwise, if any of the linter sets containing the option is enabled, it is enabled. (Only enabled linter sets are considered: explicitly disabling a linter set will revert the linters it contains to their default behavior.)

  4. Otherwise, the default value determines whether or not it is enabled.

🔗def
Lean.Linter.logLint {m : Type → Type} [Monad m] [Lean.MonadLog m] [Lean.AddMessageContext m] [Lean.MonadOptions m] (linterOption : Lean.Option Bool) (stx : Lean.Syntax) (msg : Lean.MessageData) : m Unit
Lean.Linter.logLint {m : Type → Type} [Monad m] [Lean.MonadLog m] [Lean.AddMessageContext m] [Lean.MonadOptions m] (linterOption : Lean.Option Bool) (stx : Lean.Syntax) (msg : Lean.MessageData) : m Unit
🔗def
Lean.Linter.getLinterValue (opt : Lean.Option Bool) (o : Lean.Linter.LinterOptions) : Bool
Lean.Linter.getLinterValue (opt : Lean.Option Bool) (o : Lean.Linter.LinterOptions) : Bool
🔗def
Lean.Linter.getLinterOptions {m : Type → Type} [Monad m] [Lean.MonadOptions m] [Lean.MonadEnv m] : m Lean.Linter.LinterOptions
Lean.Linter.getLinterOptions {m : Type → Type} [Monad m] [Lean.MonadOptions m] [Lean.MonadEnv m] : m Lean.Linter.LinterOptions
🔗opaque
Lean.withSetOptionIn {α : Type} (cmd : Lean.Syntax → Lean.Elab.Command.CommandElabM α) : Lean.Syntax → Lean.Elab.Command.CommandElabM α
Lean.withSetOptionIn {α : Type} (cmd : Lean.Syntax → Lean.Elab.Command.CommandElabM α) : Lean.Syntax → Lean.Elab.Command.CommandElabM α

Given a function cmd on command syntax, returns a new function that first peels off and evaluates set_option ... in ... syntax recursively, updating the options accordingly, and then invokes cmd on the inner syntax. The result type is arbitrary, so that besides CommandElabs this can also wrap functions that return values, such as the phases of a stateful linter.

This is expected to be used in linters, after elaboration is complete. It is not appropriate for ordinary elaboration of outer set_options, since it

  • does not update the infotrees with info for the set_option commands

  • silently ignores failures in setting the given options (e.g. we do not error if the option is unknown or the wrong type of value is provided), as these should have been reported during original elaboration.

26.3.1. Command Linters🔗

Command linters are the simplest form of linter. A command linter is a value of type Lean.Elab.Command.Linter, which is a structure with a run field of type Syntax → CommandElabM Unit. The addLinter function saves a new command linter, and must be run in an Lean.Parser.Command.initialize : commandinitialize block. The Lean.Parser.Command.initialize : commandinitialize block must be in the meta phase when used in a module.

Command linters are run after the elaboration of each command, concurrently with the elaboration of the rest of the file. The linter's run function is applied to the command's syntax in a context where the command's info tree is available. Any environment modifications made by the linter are discarded, but new message log entries, code actions, and code quality metrics are saved.

🔗structure
run : Lean.Syntax → Lean.Elab.Command.CommandElabM Unit
name : Lean.Name
Counting Tactics

This custom linter issues a warning when the number of tactics in a command exceeds a configurable threshold. Tactics are found by processing the command's info tree, recording the unique starting positions of original syntax with tactic info whose kind is a tactic kind. Deduplication is needed because tactic info is saved for macro expansion steps and repeated executions of the same tactic. The linter terminates early if it is disabled, rather than waiting until it is time to log the message to make the decision.

Steps/Linter.leanmodule public meta import Lean.Linter.Basic public meta import Lean.Elab.InfoTree.Util open Lean Elab Command open Std (HashSet) meta section public register_option linter.tacticCount : Bool := { defValue := true descr := "warn when a command runs many tactics" } public register_option linter.tacticCount.max : Nat := { defValue := 100 descr := "the number of tactics a command may run before \ `linter.tacticCount` warns" } open Parser in /-- Whether `kind` is the syntax kind of a tactic, as opposed to a tactic sequence or block. -/ def isTacticKind (env : Environment) (kind : SyntaxNodeKind) : Bool := match (parserExtension.getState env).categories.find? `tactic with | some category => category.kinds.contains kind | none => false /-- The number of distinct tactics from the source file that were run while elaborating `tree`. -/ def countTactics (env : Environment) (tree : InfoTree) : Nat := let positions : HashSet String.Pos.Raw := tree.foldInfo (init := {}) fun _ info positions => match info with | .ofTacticInfo ti => match ti.stx.getHeadInfo, ti.stx.getPos? with | .original .., some pos => if isTacticKind env ti.stx.getKind then positions.insert pos else positions | _, _ => positions | _ => positions positions.size open Linter in /-- Warns when a command runs more than `linter.tacticCount.max` tactics. -/ def tacticCount : Linter where run := withSetOptionIn fun stx => do -- Don't compute the result if it will never be shown unless getLinterValue linter.tacticCount (← getLinterOptions) do return let env ← getEnv let count := (← getInfoState).trees.foldl (init := 0) fun n t => n + countTactics env t let limit := linter.tacticCount.max.get (← getOptions) if count > limit then Linter.logLint linter.tacticCount stx m!"this command runs \ {count} tactics, more than {limit}" initialize addLinter tacticCount

This module demonstrates that the linter runs and that it respects locally set options:

Steps/Test.leanmodule import Steps.Linter set_option linter.tacticCount.max 4 theorem short (n : Nat) : n + 0 = n := n:Nat⊢ n + 0 = n All goals completed! 🐙 this command runs 5 tactics, more than 4 Note: This linter can be disabled with `set_option linter.tacticCount false`theorem long (a b : Nat) : a + b = b + a := a:Natb:Nat⊢ a + b = b + a induction a with b:Nat⊢ 0 + b = b + 0 All goals completed! 🐙 b:Nata:Natih:a + b = b + a⊢ a + 1 + b = b + (a + 1) b:Nata:Natih:a + b = b + a⊢ (a + b).succ = b + (a + 1) b:Nata:Natih:a + b = b + a⊢ (b + a).succ = b + (a + 1) All goals completed! 🐙 set_option linter.tacticCount false in theorem silenced (a b : Nat) : a + b = b + a := a:Natb:Nat⊢ a + b = b + a induction a with b:Nat⊢ 0 + b = b + 0 All goals completed! 🐙 b:Nata:Natih:a + b = b + a⊢ a + 1 + b = b + (a + 1) b:Nata:Natih:a + b = b + a⊢ (a + b).succ = b + (a + 1) b:Nata:Natih:a + b = b + a⊢ (b + a).succ = b + (a + 1) All goals completed! 🐙
this command runs 5 tactics, more than 4

Note: This linter can be disabled with `set_option linter.tacticCount false`

26.3.2. Module Linters🔗

Module linters run after each module's elaboration is completed. Module linters have type Lean.Elab.Command.ModuleLinter, which is a structure with a run function of type Array Syntax → CommandElabM Unit. The function is called on all the top-level commands from the module. Because the linter is invoked after the whole module is elaborated, its warnings do not appear until the very end of elaboration.

Unlike command linters, module linters do not have access to the commands' info trees and cannot save code actions. They do, however, have access to the module's final environment. This means that they are primarily useful for syntactic checks. If info trees are needed for a whole-file check, consider using a stateful linter instead.

The addModuleLinter function saves a new module linter, and must be run in an Lean.Parser.Command.initialize : commandinitialize block. The Lean.Parser.Command.initialize : commandinitialize block must be in the meta phase when used in a module.

🔗structure
run : Array Lean.Syntax → Lean.Elab.Command.CommandElabM Unit
name : Lean.Name
Limiting the Number of Declarations

The module Limit.Linter contains a module linter that issues a warning when a module contains too many declarations. The limit is determined by the value of an option. Even though the warning is attached to the first declaration that exceeds the limit, it does not appear until the entire file is elaborated because it is implemented as a module linter.

Limit/Linter.leanmodule public meta import Lean.Linter.Basic open Lean Elab Command meta section public register_option linter.declarationLimit : Bool := { defValue := true descr := "warn when a file contains more declarations than \ `linter.declarationLimit.max`" } public register_option linter.declarationLimit.max : Nat := { defValue := 200 descr := "the number of declarations that a file may contain" } /-- Warns at the first declaration past the limit. -/ def declarationLimit : ModuleLinter where run cmds := do let limit := linter.declarationLimit.max.get (← getOptions) let decls := cmds.filter (·.isOfKind ``Parser.Command.declaration) if h : limit < decls.size then Linter.logLintIf linter.declarationLimit decls[limit] m!"this file contains {decls.size} declarations, more than \ {limit}" initialize addModuleLinter declarationLimit

This module sets the maximum declaration count to 2:

Limit/Test.leanmodule import Limit.Linter set_option linter.declarationLimit.max 2 def a := 1 def b := 2 this file contains 3 declarations, more than 2 Note: This linter can be disabled with `set_option linter.declarationLimit false`def c := 3

Building the file demonstrates the linter warning:

this file contains 3 declarations, more than 2

Note: This linter can be disabled with `set_option linter.declarationLimit false`

26.3.3. Stateful Linters🔗

Like command linters, stateful linters are invoked after each command is elaborated. Similarly, they also have access to the command's info tree and environment. Unlike command linters, they are not independent of the results of linting prior commands. Instead, they have a state that is accumulated throughout the module. This provides the opportunity to check non-local properties, while still emitting a warning as soon as the violation has occurred rather than waiting for the rest of the file to be processed.

Furthermore, stateful linters are run in two phases, called the “early” and “late” phases. After command elaboration is complete, the early phases of all stateful linters are run, followed by all late phases. This allows cooperation: an early phase might compute data that is used in the late phases of multiple linters. The early phase is optional, and many useful stateful linters have no early phase. Stateful linters may read the saved state of any other stateful linter, not just their own, via handles returned by linter registration.

Stateful linters are registered using Lean.Elab.Command.registerStatefulLinter. Like the other linter registration functions, it must be invoked in an Lean.Parser.Command.initialize : commandinitialize block in the meta phase. The linter must be public. The registration function takes the implementations of the early and late phases as arguments. Unlike the other linter registrations, registration returns a useful value; this value is a handle by which the linter's state can be read. StatefulLinter and registerStatefulLinter both take two type parameters; the first is the state that is threaded between commands, the second is the data computed in the early phase.

Stateful linters are also run at the end of the module. Because they've had the opportunity to collect information from each command, they can provide feedback that module linters cannot.

🔗structure
Lean.Elab.Command.StatefulLinter (σ τ : Type) : Type
Lean.Elab.Command.StatefulLinter (σ τ : Type) : Type

A handle to a registered stateful linter, returned by registerStatefulLinter. σ is the linter's persistent state, and τ is the pre-phase output type.

🔗opaque
Lean.Elab.Command.registerStatefulLinter {σ τ : Type} (init : σ) (pre : Lean.Syntax → σ → Lean.Elab.Command.PrevStateFn → Lean.Elab.Command.CommandElabM (Option τ) := fun x x_1 x_2 => pure none) (post : Lean.Syntax → σ → Option τ → Lean.Elab.Command.PrevStateFn → Lean.Elab.Command.PreStateFn → Lean.Elab.Command.CommandElabM σ) : IO (Lean.Elab.Command.StatefulLinter σ τ)
Lean.Elab.Command.registerStatefulLinter {σ τ : Type} (init : σ) (pre : Lean.Syntax → σ → Lean.Elab.Command.PrevStateFn → Lean.Elab.Command.CommandElabM (Option τ) := fun x x_1 x_2 => pure none) (post : Lean.Syntax → σ → Option τ → Lean.Elab.Command.PrevStateFn → Lean.Elab.Command.PreStateFn → Lean.Elab.Command.CommandElabM σ) : IO (Lean.Elab.Command.StatefulLinter σ τ)

Registers a stateful linter and returns its handle (used by other linters to read its state). Must be called during initialization.

Lifecycle Phases

  • pre: Reads the previous command's persistant state (via readPrevPostState) and optionally outputs a pre-phase state (type Option τ).

  • post: Reads the previous command's state and all current pre-phase outputs (via readPrevPostState and readCurrentPreState), then produces the new state (type σ) for the next command.

State Access Rules

  • Self: Access its own persistent and pre-phase states directly via selfPrevPostState and selfCurrentPreState.

  • Others: Access other linters' states only via the readPrevPostState and readCurrentPreState closures that take typed handles of other linters.

🔗def

A typed accessor to a linter's previous state.

🔗def

A typed accessor to a linter's pre-phase output.

Enforcing a File Layout

This stateful linter checks that a module has a particular structure. It should start with a module docstring, then set any options that are needed, and then finally include declarations. The linter's state is a simple state machine that tracks this ordering. When it encounters a command, it checks the invariant locally and updates the state.

Layout/Linter.leanmodule public meta import Lean.Linter.Basic open Lean Elab Command meta section public register_option linter.fileLayout : Bool := { defValue := true descr := "warn when commands appear out of the expected order in a \ file" } public inductive Region where | moduleDoc | setup | body deriving Inhabited, Ord, DecidableEq instance : LT Region := ltOfOrd def Region.describe : Region → String | .moduleDoc => "module docstring" | .setup => "setup command" | .body => "declaration" open Parser in def Region.ofCommand (stx : Syntax) : Option Region := if stx.isOfKind ``Command.moduleDoc then some .moduleDoc else if stx.isOfKind ``Command.open || stx.isOfKind ``Command.set_option || stx.isOfKind ``Command.universe || stx.isOfKind ``Command.variable then some .setup else if stx.isOfKind ``Command.declaration then some .body else none public structure LayoutState where current : Option Region := none deriving Inhabited def checkLayout (stx : Syntax) (st : LayoutState) : CommandElabM LayoutState := do let some region := Region.ofCommand stx | return st if let some current := st.current then if region < current then Linter.logLintIf linter.fileLayout stx m!"{region.describe} after {current.describe}" else if region != .moduleDoc then Linter.logLintIf linter.fileLayout stx m!"the first command in a file should be a module docstring" let current := st.current.getD region return { current := some (if region > current then region else current) } public initialize fileLayoutLinter : StatefulLinter LayoutState Unit ← registerStatefulLinter {} (post := fun stx st _ _ _ => withSetOptionIn (checkLayout · st) stx)

This file contains three violations: it begins without a module documentation comment, it sets an option after a declaration, and it places a module documentation comment after a command:

Layout/Test.leanmodule import Layout.Linter the first command in a file should be a module docstring Note: This linter can be disabled with `set_option linter.fileLayout false`open Nat module docstring after setup command Note: This linter can be disabled with `set_option linter.fileLayout false`/-! This module docstring comes too late. -/ def x := 1 setup command after declaration Note: This linter can be disabled with `set_option linter.fileLayout false`set_option autoImplicit false theorem y : x = 1 := rfl
the first command in a file should be a module docstring

Note: This linter can be disabled with `set_option linter.fileLayout false`
setup command after declaration

Note: This linter can be disabled with `set_option linter.fileLayout false`
module docstring after setup command

Note: This linter can be disabled with `set_option linter.fileLayout false`

26.3.4. Environment Linters🔗

Environment linters are run by lake lint after a project is built when --builtin-lint is enabled or the builtinLint field is enabled in the Lake configuration. After Lake builds the project, its built-in linter iterates over each library target's root module, importing the module and then running the configured environment linters on the resulting environment. Each environment linter consists of a test routine along with success and failure messages. Unlike command and module linters, environment linters do not need to be transitively imported by the modules that they check.

Like other linters, environment linters are controlled by options. Unlike the linter itself, the option declaration must be transitively imported. Options that control environment linters are specially registered, and the values of these options are stored during elaboration so that the linter can retrieve the option values from the imported environment, even those that are locally set using Lean.Parser.Command.in : commandin.

To define an environment linter, do the following:

  1. In one module, register a public option in the meta phase that controls the linter. In an Lean.Parser.Command.initialize : commandinitialize block, run addEnvLinterOption to instruct Lean to track the option's value during elaboration. This module should be transitively imported by every module in which the linter is to be used. The initialize block must run in the meta phase.

  2. In a second module, define the linter itself. Use the Lean.Linter.EnvLinter.builtin_env_linter : attrDefines the `builtin_env_linter` attribute for registering an environment linter. Each environment linter needs to have a registered Boolean-value `Lean.Option` that will be associated with it by providing the option name in the attribute, i.e. `@[builtin_env_linter linter.envLinter.myLinter]`. builtin_env_linter attribute to associate the linter with the option. This module does not need to be imported by a module in order to check it, but it must be transitively imported by the root module of the target being linted.

attributeEnvironment Linters
attr ::= ...
    | Defines the `builtin_env_linter` attribute for registering an environment linter.
Each environment linter needs to have a registered Boolean-value `Lean.Option` that will be
associated with it by providing the option name in the attribute, i.e.
`@[builtin_env_linter linter.envLinter.myLinter]`.
builtin_env_linter ident

Registers an environment linter that is controlled by the indicated option name.

🔗structure

An environment linting test for the lake builtin-lint command.

test : Lean.Name → Lean.MetaM (Option Lean.MessageData)

test defines a test to perform on every declaration. It should never fail. Returning none signifies a passing test. Returning some msg reports a failing test with error msg.

noErrorsFound : Lean.MessageData

noErrorsFound is the message printed when all tests are negative

errorsFound : Lean.MessageData

errorsFound is printed when at least one test is positive

isDefault : Bool

If isDefault is false, this linter is only run when is passed.

API Completeness Linter

Generally speaking, a data structure that provides a get! function should also provide get? and getD. These functions need not be defined in the same module, however. An environment linter can ensure that every namespace in a project that defines any of these names also defines the others.

The linter itself consists of two modules: Lints.Options defines the option that controls the linter and should be imported by the entire project, while Lints.GetVariants only needs to be imported by the root module:

module public meta import Lean.Linter.Init open Lean meta section public register_option linter.envLinter.getVariants : Bool := { defValue := true descr := "warn when a namespace defines some but not all of `get!`, \ `get?`, and `getD`" } initialize Linter.addEnvLinterOption linter.envLinter.getVariants module public import Lints.Options public meta import Lean.Linter.EnvLinter.Basic open Lean Linter EnvLinter meta section /-- The variants that are expected to be defined together. -/ def getVariants : List String := ["get!", "get?", "getD"] @[builtin_env_linter linter.envLinter.getVariants] public def getVariantsLinter : EnvLinter where test declName := do let .str ns variant := declName | return none unless getVariants.contains variant do return none let env ← getEnv let missing := getVariants.filter fun v => !env.contains (.str ns v) if missing.isEmpty then return none let missing := missing.map fun v => m!"`{Name.str ns v}`" return some m!"`{declName}` is defined, but \ {MessageData.andList missing} \ {if missing.length == 1 then "is" else "are"} not" noErrorsFound := "Every namespace defines all of `get!`, `get?`, and \ `getD`." errorsFound := "THE FOLLOWING NAMESPACES DEFINE ONLY SOME OF `get!`, \ `get?`, AND `getD`:"

The library itself contains a variety of data structures, with their implementations in multiple modules:

module import Lints.Options public structure Stack where items : Array Nat public def Stack.get! (s : Stack) (i : Nat) : Nat := s.items[i]! module import Lints.Options public structure Queue where items : Array Nat public def Queue.get! (q : Queue) (i : Nat) : Nat := q.items[i]! module import Lints.Options public import Data.Stack public def Stack.get? (s : Stack) (i : Nat) : Option Nat := s.items[i]? public def Stack.getD (s : Stack) (i : Nat) (default : Nat) : Nat := s.items.getD i default

The root module imports all the implementation modules along with the linter:

module public import Lints.GetVariants public import Data.Stack public import Data.Queue public import Data.Safe

Linting the package returns the following warnings:

$ lake lint --builtin-only
-- Found 1 error in 12 declarations (plus 24 automatically generated ones) in Data with 1 linters

/- The `linter.envLinter.getVariants` linter reports:
THE FOLLOWING NAMESPACES DEFINE ONLY SOME OF `get!`, `get?`, AND `getD`: -/
-- Data.Queue
./Data/Queue.lean:7:1: error: Queue.get! `Queue.get!` is defined, but `Queue.get?` and `Queue.getD` are not

26.3.5. Recording Code Quality Entries🔗

Each saved value is referred to as an Entry. They are saved using Lean.Linter.logCodeQualityEntry. Analogous to logLintIf, logCodeQualityEntryIf takes a linter option name and logs the entry if the corresponding linter is enabled, taking linter sets, linter.all, and the --lint-only option into account.

Code quality entries relate a metric name, a source, and a value. The metric name may be any string, but to avoid overlap, it should typically include the linter's option name as a prefix. Using only the linter's name risks conflicting with the automatic counts of linter warnings that Lake emits as code quality metrics. The helpers Lean.Linter.findCodeQualitySource and Lean.Linter.findCodeQualitySource? determine a Source from syntax parsed from the file, with the former falling back to the current module if it cannot find a declaration. Sources may only be modules or declarations, and there is no way to attribute an entry to a smaller unit. Instead, dictionary values can be used that encode the finer structure. Values may be floating-point values or dictionaries that map strings to floating-point numbers. Integral measures, such as counts, should also use floating-point numbers.

The same metric name may be logged any number of times for a given source. Clients are responsible for aggregating metrics appropriately.

🔗structure
🔗def
Lean.Linter.logCodeQualityEntry {m : Type → Type} [Monad m] [Lean.MonadEnv m] (e : Lean.Linter.CodeQuality.Entry) : m Unit
Lean.Linter.logCodeQualityEntry {m : Type → Type} [Monad m] [Lean.MonadEnv m] (e : Lean.Linter.CodeQuality.Entry) : m Unit

Records the code quality entry e into codeQualityLogExt so that it is persisted into the .olean and can be recovered by consumers via getAllCodeQualityEntries.

This can be safely used in Linters. While regular Lean.Linters, module linters, and stateful linters all have their environment changes discarded after running, entries they log are captured per command (see Command.State.codeQualityEntryTasks) and merged into the final environment in runFrontend.

The entry is recorded without a linter attribution, so it is recorded unconditionally and no linter selection flag (e.g. lake lint --lint-only) can suppress it. Inside a linter guarded by an option, use logCodeQualityEntryIf instead; this variant is meant for unconditional metrics not tied to any linter option.

🔗def
Lean.Linter.logCodeQualityEntryIf {m : Type → Type} [Monad m] [Lean.MonadOptions m] [Lean.MonadEnv m] (linterOption : Lean.Option Bool) (e : Lean.Linter.CodeQuality.Entry) : m Unit
Lean.Linter.logCodeQualityEntryIf {m : Type → Type} [Monad m] [Lean.MonadOptions m] [Lean.MonadEnv m] (linterOption : Lean.Option Bool) (e : Lean.Linter.CodeQuality.Entry) : m Unit

Similar to logLintIf, but for logCodeQualityEntry - i.e. it logs an entry only if the provided linter option is enabled, taking linter.all and linter sets into account. The entry is recorded with linterOption.name as its attribution, so consumers can filter it by linter selection (e.g. lake lint --lint-only).

🔗def
Lean.Linter.findMatchingDecl? {m : Type → Type} [Monad m] [Lean.Elab.MonadInfoTree m] [Lean.MonadEnv m] [Lean.MonadFileMap m] (stx : Lean.Syntax) : m (Option Lean.Name)
Lean.Linter.findMatchingDecl? {m : Type → Type} [Monad m] [Lean.Elab.MonadInfoTree m] [Lean.MonadEnv m] [Lean.MonadFileMap m] (stx : Lean.Syntax) : m (Option Lean.Name)

Find the declaration in the current file that stx belongs to.

Searches the info trees of the current command for declarations (see getNewDecls) whose declaration range contains the range of stx, and returns the one whose range starts earliest. Returns none if stx has no position or no declaration's range contains it (e.g. for commands such as #check or open).

🔗def
Lean.Linter.findCodeQualitySource {m : Type → Type} [Monad m] [Lean.Elab.MonadInfoTree m] [Lean.MonadEnv m] [Lean.MonadFileMap m] (stx : Lean.Syntax) : m Lean.Linter.CodeQuality.Source
Lean.Linter.findCodeQualitySource {m : Type → Type} [Monad m] [Lean.Elab.MonadInfoTree m] [Lean.MonadEnv m] [Lean.MonadFileMap m] (stx : Lean.Syntax) : m Lean.Linter.CodeQuality.Source

Build a code-quality Source attributing stx to the declaration it belongs to (see findMatchingDecl?), falling back to the current module if no declaration matches.

🔗def
Lean.Linter.findCodeQualitySource? {m : Type → Type} [Monad m] [Lean.Elab.MonadInfoTree m] [Lean.MonadEnv m] [Lean.MonadFileMap m] (stx : Lean.Syntax) : m (Option Lean.Linter.CodeQuality.Source)
Lean.Linter.findCodeQualitySource? {m : Type → Type} [Monad m] [Lean.Elab.MonadInfoTree m] [Lean.MonadEnv m] [Lean.MonadFileMap m] (stx : Lean.Syntax) : m (Option Lean.Linter.CodeQuality.Source)

Build a code-quality Source attributing stx to the declaration it belongs to (see findMatchingDecl?), or none if no declaration matches.

Measuring Simp Lemma Usage

Even though linters are primarily intended to warn about potentially problematic code, they can also be used to just gather metrics, with warnings emitted by later checks. This example demonstrates a command linter that logs metrics about the usage of explicit simp lemmas, which could provide insights into missing @[simp] annotations in a library. These metrics are then collected and summarized by a small Lean program.

name = "simpstats"
defaultTargets = ["Code", "simp-stats"]

[[lean_lib]]
name = "Lints"

[[lean_lib]]
name = "Code"

[[lean_exe]]
name = "simp-stats"
root = "SimpStats"

The linter first traverses the syntax of the command with simpLemmaStarts, which finds the source location of each explicit simp lemma. Lemmas are identified by their starting position because a simp lemma list allows the lemmas to be applied to arguments. The simpUsage linter itself then uses the info tree to discover constant references that begin at the identified starting positions, logging a metric that contains a dictionary that maps fully-resolved names to occurrence counts.

module public meta import Lean.Linter.Basic public meta import Lean.Linter.Util open Lean Elab Command Linter open Std (HashSet) meta section public register_option linter.simpUsage : Bool := { defValue := true descr := "record which lemmas are passed to `simp` as code quality \ metrics" } partial def simpLemmaStarts (stx : Syntax) : Array String.Pos.Raw := Id.run do let mut starts := #[] if stx.isOfKind ``Parser.Tactic.simpLemma then -- The lemma itself follows the optional `↓`/`↑` and `←` modifiers. if let some pos := stx[2].getPos? then starts := starts.push pos for arg in stx.getArgs do starts := starts ++ simpLemmaStarts arg return starts def simpUsage : Linter where run := withSetOptionIn fun stx => do unless getLinterValue linter.simpUsage (← getLinterOptions) do return let starts := simpLemmaStarts stx if starts.isEmpty then return -- Each lemma occurrence is identified by its position, since the -- info tree can contain several nodes for the same syntax. let mut found : HashSet (String.Pos.Raw × Name) := {} for tree in (← getInfoState).trees do found := tree.foldInfo (init := found) fun _ info found => match info with | .ofTermInfo ti => match ti.expr.constName?, ti.stx.getRange? with | some n, some r => if starts.contains r.start then found.insert (r.start, n) else found | _, _ => found | _ => found let mut counts : Std.TreeMap String Float := {} for (_, n) in found do counts := counts.alter n.toString fun c => some (c.getD 0.0 + 1) unless counts.isEmpty do Linter.logCodeQualityEntryIf linter.simpUsage { name := "linter.simpUsage.lemmas" source := ← Linter.findCodeQualitySource stx value := .dict counts } initialize addLinter simpUsage

This script takes the collected metrics from a project and summarizes them. The function parseEntries parses a sequence of JSON values, while main processes the result and sorts the simp lemmas by their usage count.

import Lean.Data.Json open Lean /-- Parses a sequence of JSON values, as printed by `lake lint --code-quality`. -/ def parseEntries (s : String) : Except String (Array Json) := let p : Std.Internal.Parsec.String.Parser (Array Json) := do Std.Internal.Parsec.String.ws let entries ← Std.Internal.Parsec.many Json.Parser.anyCore Std.Internal.Parsec.eof return entries p.run s def main : IO UInt32 := do let input ← (← IO.getStdin).readToEnd let entries ← IO.ofExcept (parseEntries input) let mut totals : Std.TreeMap String Float := {} for entry in entries do let .ok "linter.simpUsage.lemmas" := entry.getObjValAs? String "name" | continue let .ok (.obj dict) := do let v ← entry.getObjVal? "value" let d ← v.getObjVal? "dict" d.getObjVal? "dictionary" | continue for (lemma, count) in dict do let .ok n := count.getNum? | continue totals := totals.insert lemma (totals.getD lemma 0 + n.toFloat) let sorted := totals.toArray.qsort fun (_, a) (_, b) => a > b for (lemma, n) in sorted do IO.println s!"{n.toUInt64} {lemma}" return 0

The project itself contains four lemmas, t1 through t4, spread across two modules:

module public import Code.Lemmas import Lints.SimpUsage public theorem t4 (n : Nat) : n * 1 = n := n:Nat⊢ n * 1 = n All goals completed! 🐙 module import Lints.SimpUsage public theorem t1 (n : Nat) : n + 0 = n := n:Nat⊢ n + 0 = n All goals completed! 🐙 public theorem t2 (n m : Nat) : n + m = m + n := n:Natm:Nat⊢ n + m = m + n All goals completed! 🐙 public theorem t3 (n : Nat) : 0 + n + 0 = n := n:Nat⊢ 0 + n + 0 = n All goals completed! 🐙

After building, the code quality metrics list the uses of explicit simp lemmas, and the sorting script summarizes them:

$ lake lint --code-quality
{"value": {"dict": {"dictionary": {"Nat.add_zero": 1}}},
 "source": {"declaration": {"name": "t1", "module": "Code.Lemmas"}},
 "name": "linter.simpUsage.lemmas"}
{"value": {"dict": {"dictionary": {"Nat.add_comm": 1}}},
 "source": {"declaration": {"name": "t2", "module": "Code.Lemmas"}},
 "name": "linter.simpUsage.lemmas"}
{"value": {"dict": {"dictionary": {"Nat.zero_add": 1, "Nat.add_zero": 1}}},
 "source": {"declaration": {"name": "t3", "module": "Code.Lemmas"}},
 "name": "linter.simpUsage.lemmas"}
{"value": {"dict": {"dictionary": {"Nat.mul_one": 1}}},
 "source": {"declaration": {"name": "t4", "module": "Code"}},
 "name": "linter.simpUsage.lemmas"}
$ lake lint --code-quality | lake -q exe simp-stats
2 Nat.add_zero
1 Nat.zero_add
1 Nat.add_comm
1 Nat.mul_one

26.3.6. Package Code Quality Checks🔗

Package code quality checks are an opportunity to measure a property of an entire library or executable target, as opposed to a declaration or module in isolation. They are configured in the Lake configuration file or on the command line, and do not need to be imported by the code that they are measuring. Package checks emit code quality entries. Because they run as part of Lean and Lake, they have access to Lean's metaprogramming features and do not need to build their own ad hoc mechanisms. Scripts that enforce a limit or invariant on a whole package only need to parse JSON, rather than interacting with Lean code in complicated ways.

Package checks are enabled by adding their defining modules to the checks field in the Lake configuration or by using Lake's --checks option. Within the configured package check modules, each check is a public definition of type PackageCheck in the meta phase that is registered with the package_code_quality_check attribute. Given an argument of type PackageCheckContext, a package check may use any feature of MetaM to compute an array of code quality entries. The context specifies the library or executable's root module along with the workspace's source search path. Package checks should not throw exceptions to indicate that the package violated an invariant or desired property; instead, these issues should be returned as entries and then checked by a consumer of the data.

Enabled checks are run only when the --code-quality or --checks options are passed—lake lint will not otherwise run them. For each root module, all registered package checks are run concurrently, and their resulting entries are appended to the overall collection of quality metrics. Modules that are included in libraries through their globs field are not considered as roots. Custom targets are not included, even if they build Lean code, and neither are non-default library and executable targets. If modules are specified on the command line, they are checked instead of the default targets' root modules. Exceptions thrown by package checks are written to standard error, but do not terminate linting. If any exceptions were thrown, lake lint returns a non-zero exit code.

Checks are run in an environment in which the root module is imported. Modules that are imported by multiple root modules can be checked multiple times by package checks. If the root module is a module using the module system, then only public and server information is available in this environment; private declarations are not visible. If the root module does not use the module system, then private information is also visible. Each root module is checked in a fresh environment.

attributePackage Code Quality Checks
attr ::= ...
    | package_code_quality_check

Registers a package code quality check.

🔗structure

Global inputs provided by the driver to every code quality check.

srcSearchPath : System.SearchPath
topLevelModule : Lean.Name
Finding Duplicate Theorems

Because package checks have access to an entire library, they can be used to find duplicate theorems. This example check is limited: it only finds theorems that match each other almost exactly, and does not take reorderings of hypotheses or unfoldings of definitions into account. A more realistic check could use a more nuanced similarity measure, reporting theorems that exceed some threshold measure.

First, the Checks module is added to the package's checks field:

name = "dups"
defaultTargets = ["Code"]
checks = ["Checks"]

[[lean_lib]]
name = "Checks"

[[lean_lib]]
name = "Code"

Its single check starts by computing equivalence classes of theorems. Theorems are identified by checking whether their types are propositions, and internal theorems produced as helpers for other declarations are skipped. Non-internal theorems are sorted into equivalence classes using a hash table; the BEq instance for Lean.Expr and its corresponding Hashable Lean.Expr instance check equality modulo renaming of bound variables and changes of binder info. Only modules in the root module's package are searched. As a result, theorems that differ only by hypothesis naming or status as implicit parameters are grouped, while those that re-order hypotheses are not. After the equivalence classes are identified, each element of each class with more than one element is given a metrics entry that lists the entire class.

module public meta import Lean.Linter.CodeQuality.Frontend public meta import Lean.Compiler.ModPkgExt open Lean Meta Linter CodeQuality meta section @[package_code_quality_check] public def duplicateTheorems : PackageCheck where run ctx := do let env ← getEnv let some rootIdx := env.getModuleIdx? ctx.topLevelModule | return #[] let pkg := env.getModulePackageByIdx? rootIdx -- Group the theorems of the package's modules by their statements. -- Each equivalence class is a dictionary that maps its members' -- names to 1.0 to facilitate reporting. let mut classes : ExprMap (Std.TreeMap String Float) := {} let mut theorems := #[] for h : i in 0 ... env.header.moduleData.size do unless env.getModulePackageByIdx? i == pkg do continue for declName in env.header.moduleData[i].constNames do if declName.isInternalDetail then continue let some info := env.find? declName | continue unless ← isProp info.type do continue classes := classes.alter info.type fun dups? => some ((dups?.getD {}).insert declName.toString 1.0) let modName := env.header.moduleNames[i]! theorems := theorems.push (modName, declName, info.type) -- Report each theorem that has duplicates. let mut entries := #[] for (modName, declName, type) in theorems do let some dups := classes[type]? | continue if dups.size < 2 then continue entries := entries.push { name := "duplicateTheorems" source := .declaration modName declName value := .dict dups } return entries

The code itself contains multiple restatements of the same theorem, three of which fall into the same equivalence class. zero_add' and add_zero_rev have different statements.

module public theorem add_zero' (n : Nat) : n + 0 = n := rfl public theorem zero_add' (n : Nat) : 0 + n = n := Nat.zero_add n module public import Code.Basic public theorem add_zero'' (m : Nat) : m + 0 = m := rfl public theorem also_add_zero (k : Nat) : k + 0 = k := add_zero' k public theorem add_zero_rev (n : Nat) : n = n + 0 := rfl module public import Code.Basic public import Code.Extra

Each of the three versions of the theorem is marked with the entire equivalence class in the output:

$ lake lint --code-quality
{"value":
 {"dict":
  {"dictionary": {"also_add_zero": 1, "add_zero''": 1, "add_zero'": 1}}},
 "source": {"declaration": {"name": "add_zero'", "module": "Code.Basic"}},
 "name": "duplicateTheorems"}
{"value":
 {"dict":
  {"dictionary": {"also_add_zero": 1, "add_zero''": 1, "add_zero'": 1}}},
 "source": {"declaration": {"name": "add_zero''", "module": "Code.Extra"}},
 "name": "duplicateTheorems"}
{"value":
 {"dict":
  {"dictionary": {"also_add_zero": 1, "add_zero''": 1, "add_zero'": 1}}},
 "source": {"declaration": {"name": "also_add_zero", "module": "Code.Extra"}},
 "name": "duplicateTheorems"}