Skip to content

Commit 157091c

Browse files
kim-emclaude
andcommitted
feat(CrossRefAttribute): add info-view widget for cross-reference tags
When a Mathlib declaration carries `@[stacks ...]`, `@[kerodon ...]`, or `@[wikidata ...]`, the info view now shows the upstream label / description when the cursor sits on the attribute. The fetch is on-demand via an RPC call from the widget to the Lean server, which shells out to `curl`; we deliberately don't fetch at attribute-elaboration time so offline builds stay clean and the only network access is when an editing session opens the panel. Two new modules and a small refactor to the existing one: * `Mathlib/Tactic/CrossRef/Fetch.lean` (new) holds the `Database` enum and the snippet-fetch logic. The companion `scripts/crossref-snippet.lean` in the previous PR reimplements the same logic standalone for CI usage; the two are kept in sync deliberately so the CI script doesn't need a Mathlib build. * `Mathlib/Tactic/Widget/CrossRefHover.lean` (new) defines the widget itself: an `RpcEncodable` props record, a `RequestM` RPC method that calls `fetchSnippet`, and a `mk_rpc_widget%` Component that renders the result as Html. * `Mathlib/Tactic/CrossRefAttribute.lean` (modified) imports the two new modules, removes the now-duplicated `Database` / `databaseURL` / `databaseLabel` definitions, and calls `Widget.savePanelWidgetInfo` in each attribute's `add` handler. Tag storage, docstring rewriting, and the trace commands are untouched. If `curl` is missing or the upstream site is unreachable, the widget renders an inline error instead of blocking the LSP. The existing `MathlibTest/CrossRefAttribute.lean` suite continues to pass — none of the behaviour visible to it changes. Stacked on #39662 (scripts only). A third follow-up will add a GitHub Actions workflow that posts a once-per-PR comment with tag / signature / snippet tables. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bb78642 commit 157091c

5 files changed

Lines changed: 392 additions & 25 deletions

File tree

Mathlib.lean

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7124,6 +7124,7 @@ public import Mathlib.Tactic.Contrapose
71247124
public import Mathlib.Tactic.Conv
71257125
public import Mathlib.Tactic.Convert
71267126
public import Mathlib.Tactic.Core
7127+
public import Mathlib.Tactic.CrossRef.Fetch
71277128
public import Mathlib.Tactic.CrossRefAttribute
71287129
public import Mathlib.Tactic.DSimpPercent
71297130
public import Mathlib.Tactic.DeclarationNames
@@ -7378,6 +7379,7 @@ public import Mathlib.Tactic.Widget.Calc
73787379
public import Mathlib.Tactic.Widget.CommDiag
73797380
public import Mathlib.Tactic.Widget.CongrM
73807381
public import Mathlib.Tactic.Widget.Conv
7382+
public import Mathlib.Tactic.Widget.CrossRefHover
73817383
public import Mathlib.Tactic.Widget.GCongr
73827384
public import Mathlib.Tactic.Widget.InteractiveUnfold
73837385
public import Mathlib.Tactic.Widget.LibraryRewrite

Mathlib/Tactic.lean

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public import Mathlib.Tactic.Contrapose
7474
public import Mathlib.Tactic.Conv
7575
public import Mathlib.Tactic.Convert
7676
public import Mathlib.Tactic.Core
77+
public import Mathlib.Tactic.CrossRef.Fetch
7778
public import Mathlib.Tactic.CrossRefAttribute
7879
public import Mathlib.Tactic.DSimpPercent
7980
public import Mathlib.Tactic.DeclarationNames
@@ -328,6 +329,7 @@ public import Mathlib.Tactic.Widget.Calc
328329
public import Mathlib.Tactic.Widget.CommDiag
329330
public import Mathlib.Tactic.Widget.CongrM
330331
public import Mathlib.Tactic.Widget.Conv
332+
public import Mathlib.Tactic.Widget.CrossRefHover
331333
public import Mathlib.Tactic.Widget.GCongr
332334
public import Mathlib.Tactic.Widget.InteractiveUnfold
333335
public import Mathlib.Tactic.Widget.LibraryRewrite

Mathlib/Tactic/CrossRef/Fetch.lean

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Authors: Kim Morrison
5+
-/
6+
module
7+
8+
public meta import Lean.Data.Json
9+
public import Mathlib.Init
10+
11+
/-!
12+
# Fetching cross-reference snippets
13+
14+
The Mathlib counterpart of `scripts/crossref-snippet.lean`: given a database
15+
(`stacks`, `kerodon`, or `wikidata`) and a tag, fetch a one-line
16+
`(title, description)` snippet from the upstream site.
17+
18+
This module is used by `Mathlib/Tactic/Widget/CrossRefHover.lean` so that the
19+
LSP widget on a cross-reference attribute can call back into the server to
20+
resolve a snippet on demand. The script in PR 1 of this stack reimplements
21+
roughly the same logic; we keep that script self-contained (no Mathlib import,
22+
runs via `lake env lean --run`) and this module isolated from `IO.Process`
23+
dependency-wise, so the two evolve in parallel.
24+
25+
The `Database` enum and its `URL` / `label` projections also live here; they
26+
are re-exported through `Mathlib.Tactic.CrossRefAttribute` for the attribute
27+
infrastructure.
28+
-/
29+
30+
namespace Mathlib.CrossRef
31+
32+
/-- The supported external databases.
33+
34+
When adding a new case, also update each of these:
35+
- `databaseURL`, `databaseLabel`, `Database.name`, `Database.ofName?`,
36+
the per-database branch in `fetchSnippet`, and `gerbyBase?` below;
37+
- the parser, `syntax (name := ...)`, `registerBuiltinAttribute`, and
38+
`#X_tags` trace command in `Mathlib/Tactic/CrossRefAttribute.lean`;
39+
- the parallel `Database` enum and per-database fetch implementation in
40+
`scripts/crossref-snippet.lean`;
41+
- the `databases` list in `scripts/extract-crossref-tags.lean`;
42+
- the `pretty` dict in `mathlib-ci`'s
43+
`scripts/crossref_review/crossref-pr-comment.py`. -/
44+
public inductive Database where
45+
| kerodon
46+
| stacks
47+
| wikidata
48+
deriving BEq, Hashable, Repr
49+
50+
/-- The base URL for an external database's tag pages. Always ends with `/`. -/
51+
public def databaseURL : Database → String
52+
| .kerodon => "https://kerodon.net/tag/"
53+
| .stacks => "https://stacks.math.columbia.edu/tag/"
54+
| .wikidata => "https://www.wikidata.org/wiki/"
55+
56+
/-- The display label used in docstring links and trace output. -/
57+
public def databaseLabel : Database → String
58+
| .kerodon => "Kerodon Tag"
59+
| .stacks => "Stacks Tag"
60+
| .wikidata => "Wikidata"
61+
62+
/-- A short machine-readable tag (`"kerodon"`, `"stacks"`, `"wikidata"`)
63+
that's stable to round-trip through JSON. Used by the widget. -/
64+
public def Database.name : Database → String
65+
| .kerodon => "kerodon"
66+
| .stacks => "stacks"
67+
| .wikidata => "wikidata"
68+
69+
/-- Parse the short name back into a `Database`. -/
70+
public def Database.ofName? : String → Option Database
71+
| "kerodon" => some .kerodon
72+
| "stacks" => some .stacks
73+
| "wikidata" => some .wikidata
74+
| _ => none
75+
76+
/-- `Database.name` and `Database.ofName?` roundtrip. This catches at compile time
77+
any drift between the two when a new case is added to `Database`. -/
78+
public theorem Database.ofName?_name (d : Database) : Database.ofName? d.name = some d := by
79+
cases d <;> rfl
80+
81+
/-- The outcome of trying to fetch a snippet. -/
82+
public inductive SnippetOutcome where
83+
/-- Upstream returned a (title, description). Either may be empty. -/
84+
| ok (title : String) (description : String)
85+
/-- The tag was authoritatively missing upstream. -/
86+
| missing
87+
/-- A transient problem (network, parse, …). -/
88+
| network (reason : String)
89+
deriving Repr
90+
91+
/-- The `User-Agent` curl sends. Wikidata's API will throttle anonymous clients
92+
without one, so we identify ourselves. -/
93+
private def userAgent : String :=
94+
"mathlib-crossref-widget/1 (https://github.com/leanprover-community/mathlib4)"
95+
96+
/-- Make a GET request and return `(http-status, body)`. Status is `0` if curl
97+
itself failed. We append the HTTP status to the body via `-w '\n%{http_code}'`
98+
and recover it from the final line — that avoids juggling a temp file. -/
99+
private def fetchUrl (url : String) : IO (Nat × String) := do
100+
let output ← IO.Process.output {
101+
cmd := "curl"
102+
args := #["-sSL", "--max-time", "10", "-A", userAgent,
103+
"-w", "\n%{http_code}", url]
104+
}
105+
if output.exitCode != 0 then return (0, "")
106+
let parts := output.stdout.splitOn "\n"
107+
match parts.reverse with
108+
| last :: rest =>
109+
let body := "\n".intercalate rest.reverse
110+
let status := last.trimAscii.toString.toNat?.getD 0
111+
return (status, body)
112+
| [] => return (0, "")
113+
114+
/-- Replace runs of whitespace by a single space and strip leading/trailing ws. -/
115+
private def flattenWhitespace (s : String) : String :=
116+
let go : Char → (String × Bool) → (String × Bool) := fun c (acc, prevSpace) =>
117+
let isWs := c == ' ' || c == '\t' || c == '\n' || c == '\r'
118+
if isWs then
119+
if prevSpace || acc.isEmpty then (acc, true)
120+
else (acc.push ' ', true)
121+
else
122+
(acc.push c, false)
123+
let (out, _) := s.toList.foldl (fun st c => go c st) ("", false)
124+
out.trimAscii.toString
125+
126+
/-- Best-effort HTML→text. We treat `<x…>` as a tag only when `x` is a letter,
127+
`/`, or `!`, so a literal `<` inside LaTeX (`0 < 1`) is preserved. -/
128+
private def stripHtml (html : String) : String :=
129+
let chars := html.toList
130+
let rec go : List Char → Bool → String → String
131+
| [], _, acc => acc
132+
| '<' :: rest, false, acc =>
133+
match rest with
134+
| c :: _ =>
135+
if c.isAlpha || c == '/' || c == '!' then go rest true acc
136+
else go rest false (acc.push '<')
137+
| [] => acc.push '<'
138+
| '>' :: rest, true, acc => go rest false acc
139+
| _ :: rest, true, acc => go rest true acc
140+
| c :: rest, false, acc => go rest false (acc.push c)
141+
let raw := go chars false ""
142+
let decoded := raw
143+
|>.replace "&nbsp;" " "
144+
|>.replace "&amp;" "&"
145+
|>.replace "&lt;" "<"
146+
|>.replace "&gt;" ">"
147+
|>.replace "&quot;" "\""
148+
|>.replace "&#39;" "'"
149+
flattenWhitespace decoded
150+
151+
open Lean in
152+
/-- Walk a path of object keys in a `Json` value, returning the leaf as a
153+
string if every step succeeds and the leaf is a string. -/
154+
private def jsonStrPath? (j : Json) (path : List String) : Option String :=
155+
let rec go (cur : Json) : List String → Option String
156+
| [] => cur.getStr?.toOption
157+
| k :: rest =>
158+
match cur.getObjVal? k with
159+
| .ok next => go next rest
160+
| .error _ => none
161+
go j path
162+
163+
/-! ### Wikidata -/
164+
165+
open Lean in
166+
private def fetchWikidata (qid : String) : IO SnippetOutcome := do
167+
let url := s!"https://www.wikidata.org/w/api.php?action=wbgetentities&ids={qid}\
168+
&languages=en&props=labels%7Cdescriptions&format=json"
169+
let (status, body) ← fetchUrl url
170+
if status != 200 then return .network s!"wikidata HTTP {status}"
171+
match Json.parse body with
172+
| .error e => return .network s!"wikidata json: {e}"
173+
| .ok json =>
174+
match json.getObjVal? "error" with
175+
| .ok err =>
176+
let code := jsonStrPath? err ["code"] |>.getD ""
177+
let info := jsonStrPath? err ["info"] |>.getD code
178+
if code == "no-such-entity" then return .missing
179+
else return .network s!"wikidata {code}: {info}"
180+
| _ =>
181+
match json.getObjVal? "entities" with
182+
| .error _ => return .network "wikidata: no `entities` field"
183+
| .ok entities =>
184+
match entities.getObjVal? qid with
185+
| .error _ => return .missing
186+
| .ok ent =>
187+
match ent.getObjVal? "missing" with
188+
| .ok _ => return .missing
189+
| _ =>
190+
let label := jsonStrPath? ent ["labels", "en", "value"] |>.getD ""
191+
let desc := jsonStrPath? ent ["descriptions", "en", "value"] |>.getD ""
192+
return .ok (flattenWhitespace label) (flattenWhitespace desc)
193+
194+
/-! ### Stacks / Kerodon (Gerby) -/
195+
196+
/-- The base URL for a Gerby-style database, or `none` for Wikidata. -/
197+
private def gerbyBase? : Database → Option String
198+
| .stacks => some "https://stacks.math.columbia.edu"
199+
| .kerodon => some "https://kerodon.net"
200+
| .wikidata => none
201+
202+
/-- Return the substring of `s` after the first occurrence of `needle`,
203+
or `none` if `needle` is absent. -/
204+
private def afterFirst? (s needle : String) : Option String :=
205+
let parts := s.splitOn needle
206+
match parts with
207+
| _ :: rest@(_ :: _) => some (needle.intercalate rest)
208+
| _ => none
209+
210+
/-- Take everything up to (but not including) the first occurrence of `c`. -/
211+
private def takeUntilChar (s : String) (c : Char) : String :=
212+
String.ofList (s.toList.takeWhile (· != c))
213+
214+
/-- Pull the environment type (`Lemma`, `Proposition`, …) and reference number
215+
from the `/content/statement` HTML. Both Stacks and Kerodon wrap each tag in
216+
`<article class="env-{TYPE}" id="{TAG}">` and lead with
217+
`<a ...>Lemma <span data-tag="...">14.32.3</span>.</a>`. -/
218+
private def parseGerbyTitle (html : String) : String :=
219+
let envType :=
220+
match afterFirst? html "class=\"env-" with
221+
| none => ""
222+
| some rest => takeUntilChar rest '"'
223+
let reference :=
224+
match afterFirst? html "data-tag=\"" with
225+
| none => ""
226+
| some afterAttr =>
227+
match afterFirst? afterAttr ">" with
228+
| none => ""
229+
| some inside => takeUntilChar inside '<'
230+
let cap := envType.capitalize
231+
flattenWhitespace (if reference.isEmpty then cap else s!"{cap} {reference}")
232+
233+
private def fetchGerby (db : Database) (tag : String) : IO SnippetOutcome := do
234+
let some base := gerbyBase? db | return .network s!"{db.name}: no Gerby base"
235+
let url := s!"{base}/data/tag/{tag}/content/statement"
236+
let (status, body) ← fetchUrl url
237+
if status != 200 then return .network s!"{db.name} HTTP {status}"
238+
-- Gerby returns HTTP 200 even for missing tags; detect via body text.
239+
if body.trimAscii.toString == "This tag does not exist." then return .missing
240+
let title := parseGerbyTitle body
241+
let snippet := stripHtml body
242+
if title.isEmpty && snippet.isEmpty then
243+
return .network s!"{db.name}: could not parse statement"
244+
return .ok title snippet
245+
246+
/-- Fetch one snippet from the appropriate upstream database. -/
247+
public def fetchSnippet (db : Database) (tag : String) : IO SnippetOutcome :=
248+
match db with
249+
| .wikidata => fetchWikidata tag
250+
| _ => fetchGerby db tag
251+
252+
end Mathlib.CrossRef

Mathlib/Tactic/CrossRefAttribute.lean

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ module
77

88
public meta import Lean.Elab.Command
99
public import Mathlib.Init
10+
public meta import Mathlib.Tactic.CrossRef.Fetch
11+
public meta import Mathlib.Tactic.Widget.CrossRefHover
1012

1113
/-!
1214
# Cross-reference attributes
@@ -18,39 +20,26 @@ to entries in external mathematical databases:
1820
* `@[kerodon TAG]` — [Kerodon](https://kerodon.net/tag/)
1921
* `@[wikidata QID]` — [Wikidata](https://www.wikidata.org)
2022
21-
Each attribute records the cross-reference in an environment extension and appends
22-
a link to the declaration's docstring.
23+
Each attribute records the cross-reference in an environment extension, appends
24+
a link to the declaration's docstring, and attaches the `CrossRefHoverPanel`
25+
widget to the attribute syntax so the info view shows the upstream label /
26+
description when the cursor sits on the tag.
2327
24-
The shared infrastructure (`Database`, `Tag`, `tagExt`, `addCrossRefDoc`,
28+
The `Database` enum and the `databaseURL` / `databaseLabel` projections live in
29+
`Mathlib.Tactic.CrossRef.Fetch`; the widget itself in
30+
`Mathlib.Tactic.Widget.CrossRefHover`.
31+
32+
The remaining shared infrastructure (`Tag`, `tagExt`, `addCrossRefDoc`,
2533
`traceCrossRefs`) is database-agnostic; per-database code defines a parser, the
2634
attribute syntax, and the trace command.
2735
-/
2836

2937
public meta section
3038

31-
open Lean Elab
39+
open Lean Elab Server
3240

3341
namespace Mathlib.CrossRef
3442

35-
/-- The supported external databases -/
36-
inductive Database where
37-
| kerodon
38-
| stacks
39-
| wikidata
40-
deriving BEq, Hashable
41-
42-
/-- The base URL for an external database's tag pages. Always ends with `/`. -/
43-
def databaseURL : Database → String
44-
| .kerodon => "https://kerodon.net/tag/"
45-
| .stacks => "https://stacks.math.columbia.edu/tag/"
46-
| .wikidata => "https://www.wikidata.org/wiki/"
47-
48-
/-- The display label used in docstring links and trace output. -/
49-
def databaseLabel : Database → String
50-
| .kerodon => "Kerodon Tag"
51-
| .stacks => "Stacks Tag"
52-
| .wikidata => "Wikidata"
53-
5443
/-- A cross-reference from a Mathlib declaration to an entry in an external database. -/
5544
structure Tag where
5645
/-- The name of the declaration carrying the cross-reference. -/
@@ -86,6 +75,16 @@ def addCrossRefDoc (db : Database) (decl : Name) (idStr comment : String) : Core
8675
addDocStringCore decl <| "\n\n".intercalate ([oldDoc, link].filter (· != ""))
8776
addTagEntry decl db idStr comment
8877

78+
/-- Attach the `CrossRefHoverPanel` info-view widget to the attribute syntax
79+
`attrStx`. This is what makes the info view show the upstream snippet when the
80+
cursor lands on a cross-reference tag. -/
81+
def attachCrossRefWidget (db : Database) (tag comment : String) (attrStx : Syntax) :
82+
CoreM Unit :=
83+
Widget.savePanelWidgetInfo
84+
CrossRefHoverPanel.javascriptHash
85+
(rpcEncode { database := db.name, tag := tag, comment := comment : CrossRefHoverProps })
86+
attrStx
87+
8988
open Parser
9089

9190
/-! ### Stacks (and Kerodon) parser -/
@@ -228,7 +227,10 @@ initialize Lean.registerBuiltinAttribute {
228227
| `(attr| stacks $tag $[$comment]?) => return (Database.stacks, tag, comment)
229228
| `(attr| kerodon $tag $[$comment]?) => return (Database.kerodon, tag, comment)
230229
| _ => throwUnsupportedSyntax
231-
addCrossRefDoc db decl (← tag.getStacksTag) ((comment.map (·.getString)).getD "")
230+
let tagStr ← tag.getStacksTag
231+
let commentStr := (comment.map (·.getString)).getD ""
232+
addCrossRefDoc db decl tagStr commentStr
233+
attachCrossRefWidget db tagStr commentStr stx
232234
-- docstrings are immutable once an asynchronous elaboration task has been started
233235
applicationTime := .beforeElaboration
234236
}
@@ -250,7 +252,10 @@ initialize Lean.registerBuiltinAttribute {
250252
let (id, comment) := ← match stx with
251253
| `(attr| wikidata $id $[$comment]?) => return (id, comment)
252254
| _ => throwUnsupportedSyntax
253-
addCrossRefDoc .wikidata decl (← id.getWikidataId) ((comment.map (·.getString)).getD "")
255+
let tagStr ← id.getWikidataId
256+
let commentStr := (comment.map (·.getString)).getD ""
257+
addCrossRefDoc .wikidata decl tagStr commentStr
258+
attachCrossRefWidget .wikidata tagStr commentStr stx
254259
-- docstrings are immutable once an asynchronous elaboration task has been started
255260
applicationTime := .beforeElaboration
256261
}

0 commit comments

Comments
 (0)