Skip to content

Commit 4e28544

Browse files
fix: hover and highlight info from builtin assert docstring roles (#14039)
This PR fixes a bug where the builting docstring roles for asserting equalities did not properly highlight their contents for downstream consumers of rich docstring info, and exposes a structure that was mistakenly made private. I used Claude while making this PR.
1 parent 67dffca commit 4e28544

3 files changed

Lines changed: 226 additions & 40 deletions

File tree

src/Lean/Elab/DocString/Builtin.lean

Lines changed: 81 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,22 @@ structure Data.ModuleName where
115115
deriving TypeName, Repr
116116

117117

118-
private def onlyCode [Monad m] [MonadError m] (xs : TSyntaxArray `inline) : m StrLit := do
119-
if h : xs.size = 1 then
120-
match xs[0] with
121-
| `(inline|code($s)) => return s
118+
119+
private def onlyCodes [Monad m] [MonadError m] (stxs : TSyntaxArray `inline) : m (Array StrLit) := do
120+
let mut codes := #[]
121+
for stx in stxs do
122+
match stx with
123+
| `(inline|code($s)) => codes := codes.push s
124+
| `(inline|$s:str) =>
125+
unless s.getString.all (·.isWhitespace) do
126+
throwErrorAt stx "Expected code"
122127
| other => throwErrorAt other "Expected code"
123-
else
124-
throwError "Expected precisely 1 code argument"
128+
return codes
129+
130+
private def onlyCode [Monad m] [MonadError m] (stxs : TSyntaxArray `inline) : m StrLit := do
131+
let codes ← onlyCodes stxs
132+
if h : codes.size = 1 then return codes[0]
133+
else throwError "Expected precisely 1 code argument"
125134

126135
private def strLitRange [Monad m] [MonadFileMap m] (s : StrLit) : m Lean.Syntax.Range := do
127136
let pos := (s.raw.getPos? (canonicalOnly := true)).get!
@@ -1171,10 +1180,21 @@ def option (xs : TSyntaxArray `inline) : DocM (Inline ElabInline) := do
11711180
let decl ← getOptionDecl optionName
11721181
pushInfoLeaf <| .ofOptionInfo { stx := id, optionName, declName := decl.declName }
11731182
validateOptionValue optionName decl val
1183+
let valStr :=
1184+
match val with
1185+
| .ofString v => v.quote
1186+
| v => toString v
1187+
let valHl : DocHighlight ←
1188+
match val with
1189+
| .ofBool b =>
1190+
let constName := if b then ``Bool.true else ``Bool.false
1191+
let sig ← PrettyPrinter.ppSignature constName
1192+
pure <| .const constName sig.fmt
1193+
| _ => pure <| .literal stx[3].getKind none
11741194
let code := #[
11751195
("set_option", some .keyword), (" ", none),
1176-
(toString stx[1][0].getId, some <| .option optionName decl.declName), (" ", none),
1177-
(toString stx[2].getAtomVal, some <| .literal stx[2].getKind none)
1196+
(toString optionName, some <| .option optionName decl.declName), (" ", none),
1197+
(valStr, some valHl)
11781198
]
11791199
return .other {name := ``Data.SetOption, val := .mk <| Data.SetOption.mk ⟨code⟩} #[
11801200
.code s.getString
@@ -1205,35 +1225,51 @@ def option (xs : TSyntaxArray `inline) : DocM (Inline ElabInline) := do
12051225
return .code s.getString
12061226

12071227

1208-
private def assertContents : ParserFn :=
1209-
whitespace >>
1210-
nodeFn nullKind
1211-
(termParser.fn >>
1212-
chFn '=' (trailingWs := true) >>
1213-
termParser.fn >>
1214-
optionalFn (symbolFn ":" >> termParser.fn))
1228+
private def assertTermContents : ParserFn :=
1229+
whitespace >> termParser.fn
12151230

12161231
/--
1217-
Asserts that an equality holds.
1232+
Asserts that two terms are definitionally equal.
1233+
1234+
The terms are provided as two separate code elements, optionally followed by a third code element
1235+
that contains the type at which the terms are elaborated e.g.
1236+
`` {assert'}[`Nat.zero` `Nat.zero` `Nat`] ``.
12181237
1219-
This doesn't use the equality type because it is needed in the prelude, before the = notation is
1220-
introduced.
1238+
Unlike `assert`, this role doesn't use the equality type, because it is needed in the prelude,
1239+
before the `=` notation is introduced.
12211240
-/
12221241
@[builtin_doc_role]
12231242
def assert' (xs : TSyntaxArray `inline) : DocM (Inline ElabInline) := do
1224-
let s ← onlyCode xs
1225-
let stx ← parseStrLit assertContents s
1226-
let ty? ←
1227-
withoutErrToSorry <|
1228-
if stx[3][1].isMissing then -- no colon
1229-
pure none
1230-
else -- type after colon
1231-
some <$> elabType stx[3][1]
1232-
let lhs ← elabExtraTerm stx[0] ty?
1233-
let rhs ← elabExtraTerm stx[2] ty?
1234-
unless ← Meta.withTransparency .all <| Meta.isDefEq lhs rhs do
1235-
throwErrorAt stx m!"Expected {lhs} = {rhs}, which is {← Meta.whnf lhs} = {← Meta.whnf rhs}, reducing to {← Meta.reduceAll lhs} = {← Meta.reduceAll rhs} but they are not equal."
1236-
pure (.code s.getString)
1243+
let codes ← onlyCodes xs
1244+
let (lhsCode, rhsCode, tyCode?) ←
1245+
match h : codes.size with
1246+
| 2 => pure (codes[0], codes[1], none)
1247+
| 3 => pure (codes[0], codes[1], some codes[2])
1248+
| _ => throwError "Expected two or three code arguments: the two sides of the equality, \
1249+
optionally followed by their type, but got {codes.size} arguments."
1250+
let lhsStx ← parseStrLit assertTermContents lhsCode
1251+
let rhsStx ← parseStrLit assertTermContents rhsCode
1252+
let tyStx? ← tyCode?.mapM (parseStrLit assertTermContents)
1253+
withSaveInfoContext do
1254+
let ty? ← withoutErrToSorry <|
1255+
match tyStx? with
1256+
| some tyStx => some <$> elabType tyStx
1257+
| none => pure none
1258+
let lhs ← elabExtraTerm lhsStx ty?
1259+
let rhs ← elabExtraTerm rhsStx ty?
1260+
unless ← Meta.withTransparency .all <| Meta.isDefEq lhs rhs do
1261+
throwError m!"Expected {lhs} = {rhs}, which is {← Meta.whnf lhs} = {← Meta.whnf rhs}, reducing to {← Meta.reduceAll lhs} = {← Meta.reduceAll rhs} but they are not equal."
1262+
let str := lhsCode.getString ++ " = " ++ rhsCode.getString ++
1263+
(tyCode?.map (" : " ++ ·.getString) |>.getD "")
1264+
let trees ← getInfoTrees
1265+
if trees.size > 0 then
1266+
let mut code := (← highlightSyntax trees lhsStx) ++ " = " ++ (← highlightSyntax trees rhsStx)
1267+
if let some tyStx := tyStx? then
1268+
code := code ++ " : " ++ (← highlightSyntax trees tyStx)
1269+
return .other {name := ``Data.LeanTerm, val := .mk <| Data.LeanTerm.mk code} #[.code str]
1270+
else
1271+
-- No info
1272+
return .code str
12371273

12381274
/--
12391275
Asserts that an equality holds.
@@ -1242,14 +1278,20 @@ Asserts that an equality holds.
12421278
def assert (xs : TSyntaxArray `inline) : DocM (Inline ElabInline) := do
12431279
let s ← onlyCode xs
12441280
let stx ← parseStrLit termParser.fn s
1245-
let ty ← withoutErrToSorry <| elabType stx
1246-
match_expr (← Meta.whnf ty) with
1247-
| Eq _ lhs rhs =>
1248-
unless (← Meta.isDefEq lhs rhs) do
1249-
throwErrorAt stx m!"Expected {lhs} = {rhs}, but they are not definitionally equal"
1250-
| _ => throwErrorAt stx m!"Expected equality type"
1251-
1252-
pure (.code s.getString)
1281+
withSaveInfoContext do
1282+
let ty ← withoutErrToSorry <| elabType stx
1283+
match_expr (← Meta.whnf ty) with
1284+
| Eq _ lhs rhs =>
1285+
unless (← Meta.isDefEq lhs rhs) do
1286+
throwErrorAt stx m!"Expected {lhs} = {rhs}, but they are not definitionally equal"
1287+
| _ => throwErrorAt stx m!"Expected equality type"
1288+
let trees ← getInfoTrees
1289+
if trees.size > 0 then
1290+
let tm := Data.LeanTerm.mk (← highlightSyntax trees stx)
1291+
return .other {name := ``Data.LeanTerm, val := .mk tm} #[.code s.getString]
1292+
else
1293+
-- No info
1294+
return .code s.getString
12531295

12541296
/--
12551297
Opens a namespace in the remainder of the documentation comment.

src/Lean/Elab/DocString/Builtin/Keywords.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ open scoped Lean.Doc.Syntax
2323
set_option linter.missingDocs true
2424

2525
/-- The code represents an atom drawn from some syntax. -/
26-
structure Data.Atom where
26+
public structure Data.Atom where
2727
/-- The syntax kind's name. -/
2828
name : Name
2929
/-- The syntax category -/

tests/elab/versoDocs.lean

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,3 +754,147 @@ info: <kw>(</kw> <lit kind="num" type="Nat">2</lit> <kw>*</kw> <kw>(</kw>
754754
doc.text.flatMap (findInBlock ``Data.LeanTerm) |>.map docCodeStr |>.forM (IO.println ·)
755755

756756
end HygieneInfoTests
757+
758+
/-!
759+
Test that the {lit}`{option}` role, when given full {lit}`set_option` syntax, stores the actual
760+
option name and value in its {lit}`Data.SetOption` display code.
761+
-/
762+
763+
section SetOptionRoleTests
764+
open Doc Elab
765+
766+
private partial def findSetOptionInInline : Inline ElabInline → Array DocCode
767+
| .other container _ =>
768+
if let some (so : Data.SetOption) := container.val.get? Data.SetOption then
769+
#[so.term]
770+
else #[]
771+
| .emph xs | .bold xs | .concat xs | .link xs _ | .footnote _ xs =>
772+
xs.flatMap findSetOptionInInline
773+
| .text .. | .code .. | .math .. | .linebreak .. | .image .. => #[]
774+
775+
private partial def findSetOptionInBlock : Block ElabInline ElabBlock → Array DocCode
776+
| .para inlines => inlines.flatMap findSetOptionInInline
777+
| .concat blocks | .blockquote blocks => blocks.flatMap findSetOptionInBlock
778+
| .dl items => items.flatMap fun ⟨x, y⟩ =>
779+
x.flatMap findSetOptionInInline ++ y.flatMap findSetOptionInBlock
780+
| .ol _ xs | .ul xs => xs.flatMap fun ⟨x⟩ => x.flatMap findSetOptionInBlock
781+
| .other .. | .code .. => #[]
782+
783+
/--
784+
Setting options:
785+
* {option}`set_option pp.all true`
786+
* {option}`set_option maxHeartbeats 1000`
787+
* {option}`set_option trace.profiler.output "out.json"`
788+
-/
789+
def setOptionDisplay := ()
790+
791+
/--
792+
info: <kw>set_option</kw> ⏎
793+
<option name="pp.all" decl="Lean.pp.all">pp.all</option> ⏎
794+
<const name="Bool.true" sig="Bool.true : Bool">true</const>
795+
<kw>set_option</kw> ⏎
796+
<option name="maxHeartbeats" decl="Lean.maxHeartbeats">maxHeartbeats</option> ⏎
797+
<lit kind="num" type=none>1000</lit>
798+
<kw>set_option</kw> ⏎
799+
<option name="trace.profiler.output" decl="Lean.trace.profiler.output">trace.profiler.output</option> ⏎
800+
<lit kind="str" type=none>"out.json"</lit>
801+
-/
802+
#guard_msgs in
803+
#eval show TermElabM Unit from do
804+
let some (.inr doc) ← findInternalDocString? (← getEnv) ``setOptionDisplay
805+
| throwError "expected verso doc"
806+
doc.text.flatMap findSetOptionInBlock |>.map docCodeStr |>.forM (IO.println ·)
807+
808+
end SetOptionRoleTests
809+
810+
/-!
811+
Test that the {lit}`assert` and {lit}`assert'` roles save elaboration info and store highlighted
812+
{lit}`Data.LeanTerm` payloads (previously they returned bare code with no hover information), and
813+
that {lit}`assert'` takes the two sides of the equality (and optionally the type at which they are
814+
compared) as separate code elements, so that it can be used without the {lit}`=` notation.
815+
-/
816+
817+
section AssertRoleTests
818+
open Doc Elab
819+
820+
/--
821+
{assert}`Nat.zero = Nat.zero`
822+
823+
{assert'}[`Nat.zero` `Nat.zero`]
824+
825+
{assert'}[`Nat.zero` `Nat.zero` `Nat`]
826+
-/
827+
def assertDisplay := ()
828+
829+
/--
830+
info: <const name="Nat.zero" sig="Nat.zero : Nat">Nat.zero</const> ⏎
831+
<kw>=</kw> ⏎
832+
<const name="Nat.zero" sig="Nat.zero : Nat">Nat.zero</const>
833+
<const name="Nat.zero" sig="Nat.zero : Nat">Nat.zero</const> = ⏎
834+
<const name="Nat.zero" sig="Nat.zero : Nat">Nat.zero</const>
835+
<const name="Nat.zero" sig="Nat.zero : Nat">Nat.zero</const> = ⏎
836+
<const name="Nat.zero" sig="Nat.zero : Nat">Nat.zero</const> : ⏎
837+
<const name="Nat" sig="Nat : Type">Nat</const>
838+
-/
839+
#guard_msgs in
840+
#eval show TermElabM Unit from do
841+
let some (.inr doc) ← findInternalDocString? (← getEnv) ``assertDisplay
842+
| throwError "expected verso doc"
843+
doc.text.flatMap (findInBlock ``Data.LeanTerm) |>.map docCodeStr |>.forM (IO.println ·)
844+
845+
/--
846+
error: Expected Nat.zero = Nat.zero.succ, which is Nat.zero = 1, reducing to Nat.zero = 1 but they are not equal.
847+
-/
848+
#guard_msgs in
849+
/-! {assert'}[`Nat.zero` `Nat.succ Nat.zero`] -/
850+
851+
/--
852+
error: Expected two or three code arguments: the two sides of the equality, optionally followed by their type, but got 1 arguments.
853+
-/
854+
#guard_msgs in
855+
/-! {assert'}[`Nat.zero`] -/
856+
857+
end AssertRoleTests
858+
859+
/-!
860+
Test that the {name}`kw` and {name}`kw?` roles store their payloads as {name}`Lean.Doc.Data.Atom`.
861+
-/
862+
863+
section KwAtomPublicTests
864+
open Doc Elab
865+
866+
private partial def findAtomInInline : Inline ElabInline → Array (Name × Data.Atom)
867+
| .other container _ =>
868+
if let some (a : Data.Atom) := container.val.get? Data.Atom then
869+
#[(container.name, a)]
870+
else #[]
871+
| .emph xs | .bold xs | .concat xs | .link xs _ | .footnote _ xs =>
872+
xs.flatMap findAtomInInline
873+
| .text .. | .code .. | .math .. | .linebreak .. | .image .. => #[]
874+
875+
private partial def findAtomInBlock : Block ElabInline ElabBlock → Array (Name × Data.Atom)
876+
| .para inlines => inlines.flatMap findAtomInInline
877+
| .concat blocks | .blockquote blocks => blocks.flatMap findAtomInBlock
878+
| .dl items => items.flatMap fun ⟨x, y⟩ =>
879+
x.flatMap findAtomInInline ++ y.flatMap findAtomInBlock
880+
| .ol _ xs | .ul xs => xs.flatMap fun ⟨x⟩ => x.flatMap findAtomInBlock
881+
| .other .. | .code .. => #[]
882+
883+
/--
884+
{kw (cat := term)}`Type`
885+
-/
886+
def kwAtomDisplay := ()
887+
888+
/--
889+
info: container: Lean.Doc.Data.Atom
890+
category: term
891+
-/
892+
#guard_msgs in
893+
#eval show TermElabM Unit from do
894+
let some (.inr doc) ← findInternalDocString? (← getEnv) ``kwAtomDisplay
895+
| throwError "expected verso doc"
896+
for (containerName, atom) in doc.text.flatMap findAtomInBlock do
897+
IO.println s!"container: {containerName}"
898+
IO.println s!"category: {atom.category}"
899+
900+
end KwAtomPublicTests

0 commit comments

Comments
 (0)