feat: add deprecated_module (#13002)

This PR adds a `deprecated_module` command that marks the current module
as deprecated. When another module imports a deprecated module, a
warning is emitted during elaboration suggesting replacement imports.

Example usage:
```lean
deprecated_module "use NewModule instead" (since := "2026-03-30")
```

The warning message is optional but recommended. The `since` parameter
is required. Warnings can be disabled, by setting
`linter.deprecated.module` option to false in the command line. Because
the check happens when importing , using `set_option
linter.deprecated.module` in the source file won't affect the warnings.
Instead, a whole file can be marked not to display depreciation
warnings, by putting a comment `deprecated_module: ignore` next to
`module` keyword. Similarly, individual keywords can be silenced.

A `#show_deprecated_modules` command is also provided for inspecting
which modules in the current environment are deprecated.
`linter.deprecated.module` has no effect on this command, and hence one
can view deprecated modules, even when having warnings silenced.
This commit is contained in:
Wojciech Różowski 2026-04-01 15:40:43 +01:00 committed by GitHub
parent 8b52f4e8f7
commit eac9315962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 343 additions and 2 deletions

View file

@ -0,0 +1,45 @@
/-
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.Compiler.ModPkgExt
public section
namespace Lean
structure DeprecatedModuleEntry where
message? : Option String := none
since? : Option String := none
deriving Inhabited
register_builtin_option linter.deprecated.module : Bool := {
defValue := true
descr := "if true, generate warnings when importing deprecated modules"
}
builtin_initialize deprecatedModuleExt : ModuleEnvExtension <| Option DeprecatedModuleEntry ←
registerModuleEnvExtension <| pure none
def Environment.getDeprecatedModuleByIdx? (env : Environment) (idx : ModuleIdx) : Option DeprecatedModuleEntry :=
deprecatedModuleExt.getStateByIdx? env idx |>.join
def Environment.setDeprecatedModule (entry : Option DeprecatedModuleEntry) (env : Environment) : Environment :=
deprecatedModuleExt.setState env entry
def formatDeprecatedModuleWarning (env : Environment) (idx : ModuleIdx) (modName : Name)
(entry : DeprecatedModuleEntry) : String :=
let msg := entry.message?.getD ""
let replacements := env.header.moduleData[idx.toNat]!.imports.filter fun imp =>
imp.module != `Init
let lines := replacements.foldl (init := "") fun acc imp =>
acc ++ s!"import {imp.module}\n"
s!"{msg}\n\
'{modName}' has been deprecated: please replace this import by\n\n\
{lines}"
end Lean

View file

@ -14,6 +14,7 @@ public import Lean.Elab.Open
import Init.Data.Nat.Order
import Init.Data.Order.Lemmas
import Init.System.Platform
import Lean.DeprecatedModule
public section
@ -716,6 +717,45 @@ where
let env ← getEnv
IO.eprintln (← env.dbgFormatAsyncState)
/-- Elaborate `deprecated_module`, marking the current module as deprecated. -/
@[builtin_command_elab Parser.Command.deprecated_module]
def elabDeprecatedModule : CommandElab
| `(Parser.Command.deprecated_module| deprecated_module $[$msg?]? $[(since := $since?)]?) => do
let message? := msg?.map TSyntax.getString
let since? := since?.map TSyntax.getString
if (deprecatedModuleExt.getState (← getEnv)).isSome then
logWarning "module is already marked as deprecated"
if since?.isNone then
logWarning "`deprecated_module` should specify the date or library version \
at which the deprecation was introduced, using `(since := \"...\")`"
modifyEnv fun env => env.setDeprecatedModule (some { message?, since? })
| _ => throwUnsupportedSyntax
/-- Elaborate `#show_deprecated_modules`, displaying all deprecated modules. -/
@[builtin_command_elab Parser.Command.showDeprecatedModules]
def elabShowDeprecatedModules : CommandElab := fun _ => do
let env ← getEnv
let mut parts : Array String := #["Deprecated modules\n"]
for h : idx in [:env.header.moduleNames.size] do
if let some entry := env.getDeprecatedModuleByIdx? idx then
let modName := env.header.moduleNames[idx]
let msg := match entry.message? with
| some str => s!"message '{str}'"
| none => "no message"
let replacements := env.header.moduleData[idx]!.imports.filter fun imp =>
imp.module != `Init
parts := parts.push s!"'{modName}' deprecates to\n{replacements.map (·.module)}\nwith {msg}\n"
-- Also show the current module's deprecation if set.
if let some entry := deprecatedModuleExt.getState env then
let modName := env.mainModule
let msg := match entry.message? with
| some str => s!"message '{str}'"
| none => "no message"
let replacements := env.imports.filter fun imp =>
imp.module != `Init
parts := parts.push s!"'{modName}' deprecates to\n{replacements.map (·.module)}\nwith {msg}\n"
logInfo (String.intercalate "\n" parts.toList)
@[builtin_command_elab Parser.Command.deprecatedSyntax] def elabDeprecatedSyntax : CommandElab := fun stx => do
let id := stx[1]
let kind ← liftCoreM <| checkSyntaxNodeKindAtNamespaces id.getId (← getCurrNamespace)

View file

@ -9,6 +9,7 @@ prelude
public import Lean.Parser.Module
meta import Lean.Parser.Module
import Lean.Compiler.ModPkgExt
public import Lean.DeprecatedModule
public section
@ -42,12 +43,66 @@ def HeaderSyntax.toModuleHeader (stx : HeaderSyntax) : ModuleHeader where
abbrev headerToImports := @HeaderSyntax.imports
/--
Check imported modules for deprecation and emit warnings.
The `-- deprecated_module: ignore` comment can be placed on the `module` keyword to suppress
all warnings, or on individual `import` statements to suppress specific ones.
This follows the same pattern as `-- shake: keep` in Lake shake.
The `headerStx?` parameter carries the header syntax used for checking trailing comments.
When called from the Language Server, the main header syntax may have its trailing trivia
stripped by `unsetTrailing` for caching purposes, so `origHeaderStx?` can supply the original
(untrimmed) syntax to preserve `-- deprecated_module: ignore` annotations on the last import.
-/
def checkDeprecatedImports
(env : Environment) (imports : Array Import) (opts : Options)
(inputCtx : Parser.InputContext) (startPos : String.Pos.Raw) (messages : MessageLog)
(headerStx? : Option HeaderSyntax := none)
(origHeaderStx? : Option HeaderSyntax := none)
: MessageLog := Id.run do
let mut opts := opts
let mut ignoreDeprecatedImports : NameSet := {}
if let some headerStx := origHeaderStx? <|> headerStx? then
match headerStx with
| `(Parser.Module.header| $[module%$moduleTk]? $[prelude%$_]? $importsStx*) =>
if moduleTk.any (·.getTrailing?.any (·.toString.contains "deprecated_module: ignore")) then
opts := linter.deprecated.module.set opts false
for impStx in importsStx do
if impStx.raw.getTrailing?.any (·.toString.contains "deprecated_module: ignore") then
match impStx with
| `(Parser.Module.import| $[public%$_]? $[meta%$_]? import $[all%$_]? $n) =>
ignoreDeprecatedImports := ignoreDeprecatedImports.insert n.getId
| _ => pure ()
| _ => pure ()
if !linter.deprecated.module.get opts then
return messages
imports.foldl (init := messages) fun messages imp =>
if ignoreDeprecatedImports.contains imp.module then
messages
else
match env.getModuleIdx? imp.module with
| some idx =>
match env.getDeprecatedModuleByIdx? idx with
| some entry =>
let pos := inputCtx.fileMap.toPosition startPos
messages.add {
fileName := inputCtx.fileName
pos := pos
severity := .warning
data := .tagged ``deprecatedModuleExt <| formatDeprecatedModuleWarning env idx imp.module entry
}
| none => messages
| none => messages
def processHeaderCore
(startPos : String.Pos.Raw) (imports : Array Import) (isModule : Bool)
(opts : Options) (messages : MessageLog) (inputCtx : Parser.InputContext)
(trustLevel : UInt32 := 0) (plugins : Array System.FilePath := #[]) (leakEnv := false)
(mainModule := Name.anonymous) (package? : Option PkgId := none)
(arts : NameMap ImportArtifacts := {})
(headerStx? : Option HeaderSyntax := none)
(origHeaderStx? : Option HeaderSyntax := none)
: IO (Environment × MessageLog) := do
let level := if isModule then
if Elab.inServer.get opts then
@ -66,6 +121,7 @@ def processHeaderCore
let pos := inputCtx.fileMap.toPosition startPos
pure (env, messages.add { fileName := inputCtx.fileName, data := toString e, pos := pos })
let env := env.setMainModule mainModule |>.setModulePackage package?
let messages := checkDeprecatedImports env imports opts inputCtx startPos messages headerStx? origHeaderStx?
return (env, messages)
/--
@ -82,6 +138,7 @@ backwards compatibility measure not compatible with the module system.
: IO (Environment × MessageLog) := do
processHeaderCore header.startPos header.imports header.isModule
opts messages inputCtx trustLevel plugins leakEnv mainModule
(headerStx? := header)
def parseImports (input : String) (fileName : Option String := none) : IO (Array Import × Position × MessageLog) := do
let fileName := fileName.getD "<input>"

View file

@ -478,11 +478,11 @@ where
}
result? := some {
parserState
processedSnap := (← processHeader ⟨trimmedStx⟩ parserState)
processedSnap := (← processHeader ⟨trimmedStx⟩ stx parserState)
}
}
processHeader (stx : HeaderSyntax) (parserState : Parser.ModuleParserState) :
processHeader (stx : HeaderSyntax) (origStx : HeaderSyntax) (parserState : Parser.ModuleParserState) :
LeanProcessingM (SnapshotTask HeaderProcessedSnapshot) := do
let ctx ← read
SnapshotTask.ofIO none none (.some ⟨0, ctx.endPos⟩) <|
@ -498,6 +498,7 @@ where
let (headerEnv, msgLog) ← Elab.processHeaderCore (leakEnv := true)
stx.startPos setup.imports setup.isModule setup.opts .empty ctx.toInputContext
setup.trustLevel setup.plugins setup.mainModuleName setup.package? setup.importArts
(headerStx? := stx) (origHeaderStx? := origStx)
let stopTime := (← IO.monoNanosNow).toFloat / 1000000000
let diagnostics := (← Snapshot.Diagnostics.ofMessageLog msgLog)
if msgLog.hasErrors then

View file

@ -638,6 +638,27 @@ An internal bootstrapping command that reinterprets a Markdown docstring as Vers
-/
@[builtin_command_parser] def «docs_to_verso» := leading_parser
"docs_to_verso " >> sepBy1 ident ", "
/--
`deprecated_module` marks the current module as deprecated.
When another module imports a deprecated module, a warning is emitted during elaboration.
```
deprecated_module "use NewModule instead" (since := "2026-03-19")
```
The warning message is optional but recommended.
The warning can be disabled with `set_option linter.deprecated.module false` or
`-Dlinter.deprecated.module=false`.
-/
@[builtin_command_parser] def «deprecated_module» := leading_parser
"deprecated_module" >> optional (ppSpace >> strLit) >> optional (" (" >> nonReservedSymbol "since" >> " := " >> strLit >> ")")
/--
`#show_deprecated_modules` displays all modules in the current environment that have been
marked with `deprecated_module`.
-/
@[builtin_command_parser] def showDeprecatedModules := leading_parser
"#show_deprecated_modules"
def optionValue := nonReservedSymbol "true" <|> nonReservedSymbol "false" <|> strLit <|> numLit
/--

View file

@ -0,0 +1,30 @@
module
import Std.Data
deprecated_module (since := "30-03-2026")
/--
info: Deprecated modules
'elab.deprecatedModule' deprecates to
#[Std.Data]
with no message
-/
#guard_msgs in
#show_deprecated_modules
/-- warning: module is already marked as deprecated -/
#guard_msgs in
deprecated_module "custom message" (since := "30-03-2026")
/--
info: Deprecated modules
'elab.deprecatedModule' deprecates to
#[Std.Data]
with message 'custom message'
-/
#guard_msgs in
#show_deprecated_modules

View file

@ -0,0 +1,38 @@
/-
Tests for the `deprecated_module` command.
-/
-- Missing since (message is optional)
/--
warning: `deprecated_module` should specify the date or library version at which the deprecation was introduced, using `(since := "...")`
-/
#guard_msgs in
deprecated_module
-- Missing since with message (also warns about duplicate since module is already marked above)
/--
warning: module is already marked as deprecated
---
warning: `deprecated_module` should specify the date or library version at which the deprecation was introduced, using `(since := "...")`
-/
#guard_msgs in
deprecated_module "use NewModule instead"
-- No message, with since (also warns about duplicate)
/-- warning: module is already marked as deprecated -/
#guard_msgs in
deprecated_module (since := "2026-03-19")
-- Both message and since: only duplicate warning
/--
warning: module is already marked as deprecated
-/
#guard_msgs in
deprecated_module "use NewModule instead" (since := "2026-03-19")
-- Duplicate deprecated_module: warns about already being marked (standalone confirmation)
/--
warning: module is already marked as deprecated
-/
#guard_msgs in
deprecated_module "use SomethingElse instead" (since := "2026-03-20")

View file

@ -0,0 +1,4 @@
module
import DeprecatedModule.Old
import DeprecatedModule.OldNoMessage

View file

@ -0,0 +1,4 @@
module -- deprecated_module: ignore
import DeprecatedModule.Old
import DeprecatedModule.OldNoMessage

View file

@ -0,0 +1,4 @@
module
import DeprecatedModule.OldNoMessage
import DeprecatedModule.Old -- deprecated_module: ignore

View file

@ -0,0 +1,4 @@
module
import DeprecatedModule.Old -- deprecated_module: ignore
import DeprecatedModule.OldNoMessage

View file

@ -0,0 +1,3 @@
module
import DeprecatedModule.Old -- deprecated_module: ignore

View file

@ -0,0 +1,4 @@
module -- deprecated_module: ignore
import DeprecatedModule.Old
import DeprecatedModule.OldNoMessage

View file

@ -0,0 +1,6 @@
module -- deprecated_module: ignore
import DeprecatedModule.Old -- deprecated_module: ignore
import DeprecatedModule.OldNoMessage
#show_deprecated_modules

View file

@ -0,0 +1,3 @@
module
def newFunction := 42

View file

@ -0,0 +1,5 @@
module
import DeprecatedModule.New
deprecated_module "use DeprecatedModule.New instead" (since := "2026-03-19")

View file

@ -0,0 +1,6 @@
module
import DeprecatedModule.New
deprecated_module "first deprecation" (since := "2026-03-19")
deprecated_module "second deprecation" (since := "2026-03-20")

View file

@ -0,0 +1,3 @@
module
deprecated_module (since := "2026-03-19")

View file

@ -0,0 +1,3 @@
module
import DeprecatedModule.Old

View file

@ -0,0 +1,3 @@
module
import DeprecatedModule.Transitive

View file

@ -0,0 +1,20 @@
name = "deprecated_module"
defaultTargets = ["Main"]
[[lean_lib]]
name = "Main"
roots = [
"DeprecatedModule.New",
"DeprecatedModule.Old",
"DeprecatedModule.OldNoMessage",
"DeprecatedModule.OldDouble",
"DeprecatedModule.Transitive",
"DeprecatedModule.Consumer",
"DeprecatedModule.TransitiveConsumer",
"DeprecatedModule.ConsumerIgnoreAll",
"DeprecatedModule.ConsumerIgnoreOne",
"DeprecatedModule.ConsumerIgnoreWhitespace",
"DeprecatedModule.ConsumerIgnoreOnlyImport",
"DeprecatedModule.ConsumerIgnoreLastImport",
"DeprecatedModule.ConsumerShowDeprecated",
]

View file

@ -0,0 +1,37 @@
rm -rf .lake/build
# Build Main library — includes all test modules
capture lake build Main
# With-message format: custom message on its own line, then deprecation with replacement imports
check_out_contains "use DeprecatedModule.New instead"
check_out_contains "'DeprecatedModule.Old' has been deprecated: please replace this import by"
check_out_contains "import DeprecatedModule.New"
# Without-message format: deprecation with replacement imports but no custom message
check_out_contains "'DeprecatedModule.OldNoMessage' has been deprecated: please replace this import by"
# OldDouble has two deprecated_module commands — second triggers duplicate warning
check_out_contains "module is already marked as deprecated"
# TransitiveConsumer only imports Transitive (which imports Old) — no direct import, no warning
# (covered implicitly: if transitive warnings leaked, we'd see extra output)
# ConsumerIgnoreOne: "deprecated_module: ignore" on Old import only — OldNoMessage should still warn
check_out_contains "ConsumerIgnoreOne.lean:1:0: 'DeprecatedModule.OldNoMessage' has been deprecated"
# ConsumerIgnoreOnlyImport: single import with "deprecated_module: ignore" — no warning
if grep -Fq "ConsumerIgnoreOnlyImport.lean" "$CAPTURED.out.produced"; then
fail "ConsumerIgnoreOnlyImport should not produce any deprecation warning"
fi
# ConsumerIgnoreLastImport: "deprecated_module: ignore" on last import (Old) — OldNoMessage should
# still warn, but Old should be suppressed
check_out_contains "ConsumerIgnoreLastImport.lean:1:0: 'DeprecatedModule.OldNoMessage' has been deprecated"
if grep -Fq "ConsumerIgnoreLastImport.lean:1:0: 'DeprecatedModule.Old' has been deprecated" "$CAPTURED.out.produced"; then
fail "ConsumerIgnoreLastImport should not warn about Old (annotated with deprecated_module: ignore)"
fi
# ConsumerShowDeprecated: #show_deprecated_modules should still list deprecated modules
# even when warnings are suppressed via "deprecated_module: ignore"
check_out_contains "ConsumerShowDeprecated.lean:6:0: Deprecated modules"