Skip to content

Commit dd57725

Browse files
authored
feat: @[suggest_for] attribute to inform replacements (#11367)
This PR introduces a new annotation that allows definitions to describe plausible-but-wrong name variants for the purpose of improving error messages. This PR just adds the notation and extra functionality; a stage0 update will allow standard Lean functions to have suggestion annotations. (Hence the changelog-no tag: this should go in the changelog when some preliminary annotations are actually added.) ## Example ```lean4 inductive MyBool where | tt | ff attribute [suggest_for MyBool.true] MyBool.tt attribute [suggest_for MyBool.false] MyBool.ff @[suggest_for MyBool.not] def MyBool.swap : MyBool → MyBool | tt => ff | ff => tt /-- error: Unknown constant `MyBool.true` Hint: Perhaps you meant `MyBool.tt` in place of `MyBool.true`: M̵y̵B̵o̵o̵l̵.̵t̵r̵u̵e̵M̲y̲B̲o̲o̲l̲.̲t̲t̲ -/ #guard_msgs in example := MyBool.true /-- error: Invalid field `not`: The environment does not contain `MyBool.not`, so it is not possible to project the field `not` from an expression MyBool.tt of type `MyBool` Hint: Perhaps you meant one of these in place of `MyBool.not`: [apply] `MyBool.swap`: MyBool.tt.swap -/ #guard_msgs in example := MyBool.tt.not ```
1 parent e548fa4 commit dd57725

File tree

5 files changed

+411
-4
lines changed

5 files changed

+411
-4
lines changed

src/Init/Notation.lean

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,20 @@ existing code. It may be removed in a future version of the library.
633633
syntax (name := deprecated) "deprecated" (ppSpace ident)? (ppSpace str)?
634634
(" (" &"since" " := " str ")")? : attr
635635

636+
/--
637+
The attribute `@[suggest_for]` on a declaration suggests likely ways in which
638+
someone might **incorrectly** refer to a definition.
639+
640+
* `@[suggest_for String.endPos]` on the definition of `String.rawEndPos` suggests that `"str".endPos` might be correctable to `"str".rawEndPos`.
641+
* `@[suggest_for Either Result]` on the definition of `Except` suggests that `Either Nat String` might be correctable to `Except Nat String`.
642+
643+
The namespace of the suggestions is always relative to the root namespace. In the namespace `X.Y`,
644+
adding an annotation `@[suggest_for Z.bar]` to `def Z.foo` will suggest `X.Y.Z.foo` only as a
645+
replacement for `Z.foo`. If your intent is to suggest `X.Y.Z.foo` as a replacement for
646+
`X.Y.Z.bar`, you must instead use the annotation `@[suggest_for X.Y.Z.bar]`.
647+
-/
648+
syntax (name := suggest_for) "suggest_for" (ppSpace ident)+ : attr
649+
636650
/--
637651
The `@[coe]` attribute on a function (which should also appear in a
638652
`instance : Coe A B := ⟨myFn⟩` declaration) allows the delaborator to show

src/Lean/Elab/App.lean

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ prelude
99
public import Lean.Meta.Tactic.ElimInfo
1010
public import Lean.Elab.Binders
1111
public import Lean.Elab.RecAppSyntax
12+
public import Lean.IdentifierSuggestion
1213
import all Lean.Elab.ErrorUtils
1314

1415
public section
@@ -1368,7 +1369,7 @@ private def resolveLValAux (e : Expr) (eType : Expr) (lval : LVal) : TermElabM L
13681369
return LValResolution.const `Function `Function fullName
13691370
match e.getAppFn, suffix? with
13701371
| Expr.const c _, some suffix =>
1371-
throwUnknownConstant (c ++ suffix)
1372+
throwUnknownConstantWithSuggestions (c ++ suffix)
13721373
| _, _ =>
13731374
throwInvalidFieldAt ref fieldName fullName
13741375
| .forallE .., .fieldIdx .. =>
@@ -1394,7 +1395,7 @@ private def resolveLValAux (e : Expr) (eType : Expr) (lval : LVal) : TermElabM L
13941395
| _, _ =>
13951396
match e.getAppFn, lval with
13961397
| Expr.const c _, .fieldName _ref _fieldName (some suffix) _fullRef =>
1397-
throwUnknownConstant (c ++ suffix)
1398+
throwUnknownConstantWithSuggestions (c ++ suffix)
13981399
| _, .fieldName .. =>
13991400
throwNamedError lean.invalidField m!"Invalid field notation: Field projection operates on \
14001401
types of the form `C ...` where C is a constant. The expression{indentExpr e}\nhas \
@@ -1413,10 +1414,24 @@ where
14131414
m!"Invalid field `{fieldName}`: The environment does not contain `{fullName}`, so it is not \
14141415
possible to project the field `{fieldName}` from an expression{indentExpr e}\nof \
14151416
type{inlineExprTrailing eType}"
1417+
1418+
-- Possible alternatives provided with `@[suggest_for]` annotations
1419+
let suggestions := (← Lean.getSuggestions fullName).filter (·.getPrefix = fullName.getPrefix)
1420+
let suggestForHint ←
1421+
if suggestions.size = 0 then
1422+
pure .nil
1423+
else
1424+
m!"Perhaps you meant one of these in place of `{fullName}`:".hint (suggestions.map fun suggestion => {
1425+
suggestion := suggestion.getString!,
1426+
toCodeActionTitle? := .some (s!"Suggested replacement: {e}.{·}"),
1427+
diffGranularity := .all,
1428+
messageData? := .some m!"`{.ofConstName suggestion}`: {e}.{suggestion.getString!}",
1429+
}) ref
1430+
14161431
-- By using `mkUnknownIdentifierMessage`, the tag `Lean.unknownIdentifierMessageTag` is
14171432
-- incorporated within the message, as required for the "import unknown identifier" code action.
14181433
-- The "outermost" lean.invalidField name is the only one that triggers an error explanation.
1419-
throwNamedErrorAt ref lean.invalidField msg
1434+
throwNamedErrorAt ref lean.invalidField (msg ++ suggestForHint)
14201435

14211436

14221437
/-- whnfCore + implicit consumption.

src/Lean/Expr.lean

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -952,18 +952,30 @@ def isCharLit : Expr → Bool
952952
| app (const c _) a => c == ``Char.ofNat && a.isRawNatLit
953953
| _ => false
954954

955+
/--
956+
If the expression is a constant, return that name.
957+
Otherwise panic.
958+
-/
955959
def constName! : Expr → Name
956960
| const n _ => n
957961
| _ => panic! "constant expected"
958962

963+
/--
964+
If the expression is a constant, return that name.
965+
Otherwise return `Option.none`.
966+
-/
959967
def constName? : Expr → Option Name
960968
| const n _ => some n
961969
| _ => none
962970

963-
/-- If the expression is a constant, return that name. Otherwise return `Name.anonymous`. -/
971+
/--
972+
If the expression is a constant, return that name.
973+
Otherwise return `Name.anonymous`.
974+
-/
964975
def constName (e : Expr) : Name :=
965976
e.constName?.getD Name.anonymous
966977

978+
967979
def constLevels! : Expr → List Level
968980
| const _ ls => ls
969981
| _ => panic! "constant expected"

src/Lean/IdentifierSuggestion.lean

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/-
2+
Copyright (c) 2025 Microsoft Corporation. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Authors: Rob Simmons
5+
-/
6+
module
7+
8+
prelude
9+
public import Lean.Attributes
10+
public import Lean.Exception
11+
public import Lean.Meta.Hint
12+
public import Lean.Elab.DeclModifiers
13+
public import Lean.ResolveName
14+
import all Lean.Elab.ErrorUtils
15+
16+
namespace Lean
17+
18+
set_option doc.verso true
19+
20+
builtin_initialize identifierSuggestionForAttr : ParametricAttribute (Name × Array Name) ←
21+
registerParametricAttribute {
22+
name := `suggest_for,
23+
descr := "suggest other (incorrect, not-existing) identifiers that someone might use when they actually want this definition",
24+
getParam := fun trueDeclName stx => do
25+
let `(attr| suggest_for $altNames:ident*) := stx
26+
| throwError "Invalid `[suggest_for]` attribute syntax"
27+
let ns := trueDeclName.getPrefix
28+
return (trueDeclName, altNames.map (·.getId))
29+
}
30+
31+
public def getSuggestions [Monad m] [MonadEnv m] (fullName : Name) : m (Array Name) := do
32+
let mut possibleReplacements := #[]
33+
let (_, allSuggestions) := identifierSuggestionForAttr.ext.getState (← getEnv)
34+
for (_, trueName, suggestions) in allSuggestions do
35+
for suggestion in suggestions do
36+
if fullName = suggestion then
37+
possibleReplacements := possibleReplacements.push trueName
38+
return possibleReplacements.qsort (lt := lt)
39+
where
40+
-- Ensure the result of getSuggestions is stable (if arbitrary)
41+
lt : Name -> Name -> Bool
42+
| .anonymous, _ => false
43+
| .str _ _, .anonymous => true
44+
| .num _ _, .anonymous => true
45+
| .str _ _, .num _ _ => true
46+
| .num _ _, .str _ _ => false
47+
| .num a n, .num b m => n < m || n == m && lt a b
48+
| .str a s1, .str b s2 => s1 < s2 || s1 == s2 && lt a b
49+
50+
/--
51+
Throw an unknown constant error message, potentially suggesting alternatives using
52+
{name}`suggest_for` attributes. (Like {name}`throwUnknownConstantAt` but with suggestions.)
53+
54+
The "Unknown constant `<id>`" message will fully qualify the name, whereas the
55+
-/
56+
public def throwUnknownConstantWithSuggestions (constName : Name) : CoreM α := do
57+
let suggestions ← getSuggestions constName
58+
let ref ← getRef
59+
let hint ← if suggestions.size = 0 then
60+
pure MessageData.nil
61+
else
62+
-- Modify suggestions to have the same structure as the user-provided identifier, but only
63+
-- if that doesn't cause ambiguity.
64+
let rawId := (← getRef).getId
65+
let env ← getEnv
66+
let ns ← getCurrNamespace
67+
let openDecls ← getOpenDecls
68+
let modifySuggestion := match constName.eraseSuffix? rawId with
69+
| .none => id
70+
| .some prefixName => fun (suggestion : Name) =>
71+
let candidate := suggestion.replacePrefix prefixName .anonymous
72+
if (ResolveName.resolveGlobalName env {} ns openDecls candidate |>.length) != 1 then
73+
suggestion
74+
else
75+
candidate
76+
77+
let alternative := if h : suggestions.size = 1 then m!"`{.ofConstName suggestions[0]}`" else m!"one of these"
78+
m!"Perhaps you meant {alternative} in place of `{.ofName rawId}`:".hint (suggestions.map fun suggestion =>
79+
let modified := modifySuggestion suggestion
80+
{
81+
suggestion := s!"{modified}",
82+
toCodeActionTitle? := .some (s!"Suggested replacement: {·}"),
83+
diffGranularity := .all,
84+
-- messageData? := .some m!"replace `{.ofName rawId}` with `{.ofName modified}`",
85+
}) ref
86+
throwUnknownIdentifierAt (declHint := constName) ref (m!"Unknown constant `{.ofConstName constName}`" ++ hint)

0 commit comments

Comments
 (0)