Skip to content

Commit 939db59

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 7b74a10 commit 939db59

5 files changed

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