Skip to content

Commit 3f0d35a

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 680eb0d commit 3f0d35a

5 files changed

Lines changed: 387 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: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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+
/-- The outcome of trying to fetch a snippet. -/
77+
public inductive SnippetOutcome where
78+
/-- Upstream returned a (title, description). Either may be empty. -/
79+
| ok (title : String) (description : String)
80+
/-- The tag was authoritatively missing upstream. -/
81+
| missing
82+
/-- A transient problem (network, parse, …). -/
83+
| network (reason : String)
84+
deriving Repr
85+
86+
/-- The `User-Agent` curl sends. Wikidata's API will throttle anonymous clients
87+
without one, so we identify ourselves. -/
88+
private def userAgent : String :=
89+
"mathlib-crossref-widget/1 (https://github.com/leanprover-community/mathlib4)"
90+
91+
/-- Make a GET request and return `(http-status, body)`. Status is `0` if curl
92+
itself failed. We append the HTTP status to the body via `-w '\n%{http_code}'`
93+
and recover it from the final line — that avoids juggling a temp file. -/
94+
private def fetchUrl (url : String) : IO (Nat × String) := do
95+
let output ← IO.Process.output {
96+
cmd := "curl"
97+
args := #["-sSL", "--max-time", "10", "-A", userAgent,
98+
"-w", "\n%{http_code}", url]
99+
}
100+
if output.exitCode != 0 then return (0, "")
101+
let parts := output.stdout.splitOn "\n"
102+
match parts.reverse with
103+
| last :: rest =>
104+
let body := "\n".intercalate rest.reverse
105+
let status := last.trimAscii.toString.toNat?.getD 0
106+
return (status, body)
107+
| [] => return (0, "")
108+
109+
/-- Replace runs of whitespace by a single space and strip leading/trailing ws. -/
110+
private def flattenWhitespace (s : String) : String :=
111+
let go : Char → (String × Bool) → (String × Bool) := fun c (acc, prevSpace) =>
112+
let isWs := c == ' ' || c == '\t' || c == '\n' || c == '\r'
113+
if isWs then
114+
if prevSpace || acc.isEmpty then (acc, true)
115+
else (acc.push ' ', true)
116+
else
117+
(acc.push c, false)
118+
let (out, _) := s.toList.foldl (fun st c => go c st) ("", false)
119+
out.trimAscii.toString
120+
121+
/-- Best-effort HTML→text. We treat `<x…>` as a tag only when `x` is a letter,
122+
`/`, or `!`, so a literal `<` inside LaTeX (`0 < 1`) is preserved. -/
123+
private def stripHtml (html : String) : String :=
124+
let chars := html.toList
125+
let rec go : List Char → Bool → String → String
126+
| [], _, acc => acc
127+
| '<' :: rest, false, acc =>
128+
match rest with
129+
| c :: _ =>
130+
if c.isAlpha || c == '/' || c == '!' then go rest true acc
131+
else go rest false (acc.push '<')
132+
| [] => acc.push '<'
133+
| '>' :: rest, true, acc => go rest false acc
134+
| _ :: rest, true, acc => go rest true acc
135+
| c :: rest, false, acc => go rest false (acc.push c)
136+
let raw := go chars false ""
137+
let decoded := raw
138+
|>.replace "&nbsp;" " "
139+
|>.replace "&amp;" "&"
140+
|>.replace "&lt;" "<"
141+
|>.replace "&gt;" ">"
142+
|>.replace "&quot;" "\""
143+
|>.replace "&#39;" "'"
144+
flattenWhitespace decoded
145+
146+
open Lean in
147+
/-- Walk a path of object keys in a `Json` value, returning the leaf as a
148+
string if every step succeeds and the leaf is a string. -/
149+
private def jsonStrPath? (j : Json) (path : List String) : Option String :=
150+
let rec go (cur : Json) : List String → Option String
151+
| [] => cur.getStr?.toOption
152+
| k :: rest =>
153+
match cur.getObjVal? k with
154+
| .ok next => go next rest
155+
| .error _ => none
156+
go j path
157+
158+
/-! ### Wikidata -/
159+
160+
open Lean in
161+
private def fetchWikidata (qid : String) : IO SnippetOutcome := do
162+
let url := s!"https://www.wikidata.org/w/api.php?action=wbgetentities&ids={qid}\
163+
&languages=en&props=labels%7Cdescriptions&format=json"
164+
let (status, body) ← fetchUrl url
165+
if status != 200 then return .network s!"wikidata HTTP {status}"
166+
match Json.parse body with
167+
| .error e => return .network s!"wikidata json: {e}"
168+
| .ok json =>
169+
match json.getObjVal? "error" with
170+
| .ok err =>
171+
let code := jsonStrPath? err ["code"] |>.getD ""
172+
let info := jsonStrPath? err ["info"] |>.getD code
173+
if code == "no-such-entity" then return .missing
174+
else return .network s!"wikidata {code}: {info}"
175+
| _ =>
176+
match json.getObjVal? "entities" with
177+
| .error _ => return .network "wikidata: no `entities` field"
178+
| .ok entities =>
179+
match entities.getObjVal? qid with
180+
| .error _ => return .missing
181+
| .ok ent =>
182+
match ent.getObjVal? "missing" with
183+
| .ok _ => return .missing
184+
| _ =>
185+
let label := jsonStrPath? ent ["labels", "en", "value"] |>.getD ""
186+
let desc := jsonStrPath? ent ["descriptions", "en", "value"] |>.getD ""
187+
return .ok (flattenWhitespace label) (flattenWhitespace desc)
188+
189+
/-! ### Stacks / Kerodon (Gerby) -/
190+
191+
/-- The base URL for a Gerby-style database, or `none` for Wikidata. -/
192+
private def gerbyBase? : Database → Option String
193+
| .stacks => some "https://stacks.math.columbia.edu"
194+
| .kerodon => some "https://kerodon.net"
195+
| .wikidata => none
196+
197+
/-- Return the substring of `s` after the first occurrence of `needle`,
198+
or `none` if `needle` is absent. -/
199+
private def afterFirst? (s needle : String) : Option String :=
200+
let parts := s.splitOn needle
201+
match parts with
202+
| _ :: rest@(_ :: _) => some (needle.intercalate rest)
203+
| _ => none
204+
205+
/-- Take everything up to (but not including) the first occurrence of `c`. -/
206+
private def takeUntilChar (s : String) (c : Char) : String :=
207+
String.ofList (s.toList.takeWhile (· != c))
208+
209+
/-- Pull the environment type (`Lemma`, `Proposition`, …) and reference number
210+
from the `/content/statement` HTML. Both Stacks and Kerodon wrap each tag in
211+
`<article class="env-{TYPE}" id="{TAG}">` and lead with
212+
`<a ...>Lemma <span data-tag="...">14.32.3</span>.</a>`. -/
213+
private def parseGerbyTitle (html : String) : String :=
214+
let envType :=
215+
match afterFirst? html "class=\"env-" with
216+
| none => ""
217+
| some rest => takeUntilChar rest '"'
218+
let reference :=
219+
match afterFirst? html "data-tag=\"" with
220+
| none => ""
221+
| some afterAttr =>
222+
match afterFirst? afterAttr ">" with
223+
| none => ""
224+
| some inside => takeUntilChar inside '<'
225+
let cap := envType.capitalize
226+
flattenWhitespace (if reference.isEmpty then cap else s!"{cap} {reference}")
227+
228+
private def fetchGerby (db : Database) (tag : String) : IO SnippetOutcome := do
229+
let some base := gerbyBase? db | return .network s!"{db.name}: no Gerby base"
230+
let url := s!"{base}/data/tag/{tag}/content/statement"
231+
let (status, body) ← fetchUrl url
232+
if status != 200 then return .network s!"{db.name} HTTP {status}"
233+
-- Gerby returns HTTP 200 even for missing tags; detect via body text.
234+
if body.trimAscii.toString == "This tag does not exist." then return .missing
235+
let title := parseGerbyTitle body
236+
let snippet := stripHtml body
237+
if title.isEmpty && snippet.isEmpty then
238+
return .network s!"{db.name}: could not parse statement"
239+
return .ok title snippet
240+
241+
/-- Fetch one snippet from the appropriate upstream database. -/
242+
public def fetchSnippet (db : Database) (tag : String) : IO SnippetOutcome :=
243+
match db with
244+
| .wikidata => fetchWikidata tag
245+
| _ => fetchGerby db tag
246+
247+
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)