Skip to content

Commit 4ba4247

Browse files
kim-emclaude
andcommitted
feat(scripts): add cross-reference tag tooling
Add two standalone scripts to support review of PRs that add `@[stacks]`, `@[kerodon]`, or `@[wikidata]` attributes: * `scripts/crossref-snippet.lean` fetches a one-line label/description for one or more tags from the upstream database. Wikidata uses the `wbgetentities` API (with recovery when one bad QID poisons a batch); Stacks and Kerodon use the documented Gerby `/data/tag/<TAG>/content/statement` endpoint with a tolerant HTML→text strip that survives `<` inside math. Exit codes distinguish all-resolved (0) from missing (2) from transient network failure (3). When `CROSSREF_CACHE_DIR` is set, responses are memoised per `(database, tag)` so repeat lookups are instant. * `scripts/extract-crossref-tags.lean` walks a `.lean` file (or the added lines of a git diff range) and emits TSV of every cross-reference attribute it finds, paired with the declaration it decorates. A byte-level scanner correctly skips string literals and line/block comments, handles multi-attribute blocks (`@[simp, stacks 01AB]`), multi-line attribute blocks, doc comments between attribute and declaration, and modifier keywords (`private`, `noncomputable`, …). Signatures are collapsed to one line for downstream consumption. Both scripts run via `lake env lean --run` and depend only on Lean core (plus `curl` on `PATH` for the fetcher), so no Mathlib build is required and a future CI workflow can drive them on untrusted PR files without elaborating PR code. Companion follow-ups (separate PRs) will add an LSP widget that surfaces these snippets in the editor and a GitHub Actions workflow that posts a once-per-PR comment with tag / signature / snippet tables and fails CI on unresolved tags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70be100 commit 4ba4247

2 files changed

Lines changed: 976 additions & 0 deletions

File tree

scripts/crossref-snippet.lean

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
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+
import Lean.Data.Json
7+
import Std.Data.HashMap
8+
9+
/-!
10+
# Fetch a one-line snippet about a cross-reference tag
11+
12+
For each given tag, this script fetches a short label/description from the
13+
upstream database (Wikidata, the Stacks Project, or Kerodon) and writes one
14+
TSV record per tag to stdout:
15+
16+
```
17+
<tag>\t<title>\t<description>
18+
<tag>\tERROR\tmissing
19+
<tag>\tERROR\tnetwork
20+
```
21+
22+
Exit codes:
23+
* `0` — every tag resolved.
24+
* `2` — at least one tag was confirmed missing upstream.
25+
* `3` — at least one tag failed with a network / parse error (and no `missing`).
26+
27+
Usage:
28+
```sh
29+
lake env lean --run scripts/crossref-snippet.lean wikidata Q42 Q12345
30+
lake env lean --run scripts/crossref-snippet.lean stacks 01AB 02CD
31+
lake env lean --run scripts/crossref-snippet.lean kerodon 0001 0009
32+
```
33+
34+
The companion `scripts/extract-crossref-tags.lean` finds the tags to feed in.
35+
Together they back the CI bot and LSP widget described in the planning notes,
36+
but this script is self-contained: it depends only on `curl` on `PATH` and
37+
`Lean.Data.Json` from Lean core, so it can be invoked directly with
38+
`lake env lean --run` without any Mathlib build.
39+
40+
If the environment variable `CROSSREF_CACHE_DIR` is set, fetched responses are
41+
cached there as one file per `(database, tag)` and re-used on subsequent calls.
42+
-/
43+
44+
open Lean
45+
46+
/-- The three supported cross-reference databases. -/
47+
inductive Database where
48+
| wikidata
49+
| stacks
50+
| kerodon
51+
deriving BEq, Inhabited
52+
53+
/-- Parse the database argument. -/
54+
def Database.ofString? : String → Option Database
55+
| "wikidata" => some .wikidata
56+
| "stacks" => some .stacks
57+
| "kerodon" => some .kerodon
58+
| _ => none
59+
60+
/-- Short name used in cache filenames and error messages. -/
61+
def Database.name : Database → String
62+
| .wikidata => "wikidata"
63+
| .stacks => "stacks"
64+
| .kerodon => "kerodon"
65+
66+
/-- Outcome of fetching the snippet for a single tag. -/
67+
inductive Result where
68+
| ok (title : String) (snippet : String)
69+
| missing
70+
| network (reason : String)
71+
72+
/-- The `User-Agent` curl sends. Wikidata's API will throttle anonymous clients
73+
without one, so we identify ourselves. -/
74+
def userAgent : String :=
75+
"mathlib-crossref-bot/1 (https://github.com/leanprover-community/mathlib4)"
76+
77+
/-- Make a GET request and return `(http-status, body)`. Status is `0` if curl
78+
itself failed. We append the HTTP status to the body via `-w '\n%{http_code}'`
79+
and recover it from the final line — that avoids juggling a temp file. -/
80+
def fetchUrl (url : String) : IO (Nat × String) := do
81+
let output ← IO.Process.output {
82+
cmd := "curl"
83+
args := #["-sSL", "--max-time", "10", "-A", userAgent,
84+
"-w", "\n%{http_code}", url]
85+
}
86+
if output.exitCode != 0 then return (0, "")
87+
let parts := output.stdout.splitOn "\n"
88+
match parts.reverse with
89+
| last :: rest =>
90+
let body := "\n".intercalate rest.reverse
91+
let status := last.trimAscii.toString.toNat?.getD 0
92+
return (status, body)
93+
| [] => return (0, "")
94+
95+
/-- Replace any tab, newline, or carriage return with a single space and collapse
96+
runs of whitespace. The TSV output is one record per line, so we have to keep
97+
each field on one line; markdown-table escaping is a downstream concern. -/
98+
def flattenWhitespace (s : String) : String :=
99+
let go : Char → (String × Bool) → (String × Bool) := fun c (acc, prevSpace) =>
100+
let isWs := c == ' ' || c == '\t' || c == '\n' || c == '\r'
101+
if isWs then
102+
if prevSpace || acc.isEmpty then (acc, true)
103+
else (acc.push ' ', true)
104+
else
105+
(acc.push c, false)
106+
let (out, _) := s.toList.foldl (fun st c => go c st) ("", false)
107+
out.trimAscii.toString
108+
109+
/-- Best-effort HTML→text: drop everything from `<x` (where `x` is a letter,
110+
`/`, or `!`) up to and including the matching `>`, decode a handful of
111+
entities, collapse whitespace. The first-character check matters because
112+
the Stacks/Kerodon snippets embed LaTeX math like `0 < 1`, and a dumber
113+
strip-everything-between-angle-brackets pass would eat the rest of the line. -/
114+
def stripHtml (html : String) : String :=
115+
let chars := html.toList
116+
let rec go : List Char → Bool → String → String
117+
| [], _, acc => acc
118+
| '<' :: rest, false, acc =>
119+
match rest with
120+
| c :: _ =>
121+
if c.isAlpha || c == '/' || c == '!' then
122+
go rest true acc
123+
else
124+
go rest false (acc.push '<')
125+
| [] => acc.push '<'
126+
| '>' :: rest, true, acc => go rest false acc
127+
| _ :: rest, true, acc => go rest true acc
128+
| c :: rest, false, acc => go rest false (acc.push c)
129+
let raw := go chars false ""
130+
let decoded := raw
131+
|>.replace "&nbsp;" " "
132+
|>.replace "&amp;" "&"
133+
|>.replace "&lt;" "<"
134+
|>.replace "&gt;" ">"
135+
|>.replace "&quot;" "\""
136+
|>.replace "&#39;" "'"
137+
flattenWhitespace decoded
138+
139+
/-- Walk a path of object keys in a `Json` value, returning the leaf as a string
140+
if every step succeeds and the leaf is a string. -/
141+
def jsonStrPath? (j : Json) (path : List String) : Option String :=
142+
let rec go (cur : Json) : List String → Option String
143+
| [] => cur.getStr?.toOption
144+
| k :: rest =>
145+
match cur.getObjVal? k with
146+
| .ok next => go next rest
147+
| .error _ => none
148+
go j path
149+
150+
/-- Take elements of a list in fixed-size chunks. -/
151+
partial def chunkList (n : Nat) (xs : List α) : List (List α) :=
152+
if xs.isEmpty then []
153+
else
154+
let k := n.max 1
155+
(xs.take k) :: chunkList n (xs.drop k)
156+
157+
/-- Locate the per-tag cache file, if `CROSSREF_CACHE_DIR` is set. -/
158+
def cacheFile? (db : Database) (tag : String) : IO (Option System.FilePath) := do
159+
match ← IO.getEnv "CROSSREF_CACHE_DIR" with
160+
| none => return none
161+
| some dir =>
162+
let path : System.FilePath := dir
163+
IO.FS.createDirAll path
164+
return some (path / s!"{db.name}-{tag}.json")
165+
166+
/-- Read a cached raw response body, if one exists. -/
167+
def cacheLoad (db : Database) (tag : String) : IO (Option String) := do
168+
match ← cacheFile? db tag with
169+
| none => return none
170+
| some f =>
171+
if ← f.pathExists then
172+
return some (← IO.FS.readFile f)
173+
else
174+
return none
175+
176+
/-- Save a raw response body to the cache. Empty body marks "known missing". -/
177+
def cacheStore (db : Database) (tag : String) (body : String) : IO Unit := do
178+
match ← cacheFile? db tag with
179+
| none => return ()
180+
| some f => IO.FS.writeFile f body
181+
182+
/-! ## Wikidata -/
183+
184+
/-- Wikidata's `wbgetentities` endpoint supports up to 50 IDs per request; we
185+
batch to amortise the HTTP cost. -/
186+
def wikidataBatchSize : Nat := 50
187+
188+
/-- Pull the English label and description out of one entity payload. -/
189+
def wikidataResultOf (ent : Json) : Result :=
190+
match ent.getObjVal? "missing" with
191+
| .ok _ => .missing
192+
| _ =>
193+
let label := jsonStrPath? ent ["labels", "en", "value"] |>.getD ""
194+
let desc := jsonStrPath? ent ["descriptions", "en", "value"] |>.getD ""
195+
.ok (flattenWhitespace label) (flattenWhitespace desc)
196+
197+
/-- Outcome of inspecting one parsed Wikidata response. -/
198+
inductive WikidataParse where
199+
/-- Per-QID results for every input id. -/
200+
| results (rs : List (String × Result))
201+
/-- Wikidata refused the whole batch because one specific id was malformed
202+
(`no-such-entity`). We pull that id out, mark it missing, and retry the rest. -/
203+
| retryWithout (badId : String)
204+
/-- Transient failure (`maxlag`, `ratelimit`, …) — every id maps to `network`. -/
205+
| transient (reason : String)
206+
207+
/-- Inspect a parsed `wbgetentities` response. Wikidata returns a per-batch
208+
top-level `error` rather than a per-id flag when any single id is malformed,
209+
so we have to read `error.id` and retry without it. -/
210+
def parseWikidataResponse (qids : List String) (json : Json) : WikidataParse :=
211+
match json.getObjVal? "error" with
212+
| .ok err =>
213+
let code := jsonStrPath? err ["code"] |>.getD ""
214+
let info := jsonStrPath? err ["info"] |>.getD code
215+
if code == "no-such-entity" then
216+
match jsonStrPath? err ["id"] with
217+
| some bad => .retryWithout bad
218+
| none => .results (qids.map fun q => (q, .missing))
219+
else
220+
.transient s!"wikidata {code}: {info}"
221+
| _ =>
222+
match json.getObjVal? "entities" with
223+
| .error _ => .transient "wikidata: no `entities` field"
224+
| .ok entities =>
225+
.results <| qids.map fun q =>
226+
match entities.getObjVal? q with
227+
| .error _ => (q, .missing)
228+
| .ok ent => (q, wikidataResultOf ent)
229+
230+
/-- Fetch one batch of QIDs from the live Wikidata API. Retries with the
231+
offending id removed whenever Wikidata refuses the whole batch over a single
232+
malformed identifier. -/
233+
partial def fetchWikidataBatch (qids : List String) : IO (List (String × Result)) := do
234+
if qids.isEmpty then return []
235+
let ids := "|".intercalate qids
236+
-- `maxlag` is intended for bots performing writes; reads should not throttle.
237+
let url := s!"https://www.wikidata.org/w/api.php?action=wbgetentities&ids={ids}\
238+
&languages=en&props=labels%7Cdescriptions&format=json"
239+
let (status, body) ← fetchUrl url
240+
if status != 200 then
241+
return qids.map fun q => (q, .network s!"wikidata HTTP {status}")
242+
match Json.parse body with
243+
| .error e => return qids.map fun q => (q, .network s!"wikidata json: {e}")
244+
| .ok json =>
245+
match parseWikidataResponse qids json with
246+
| .transient r => return qids.map fun q => (q, .network r)
247+
| .retryWithout bad =>
248+
-- Mark `bad` as missing and retry the rest. Anchor preserves input order.
249+
let rest := qids.filter (· != bad)
250+
let restResults ← fetchWikidataBatch rest
251+
cacheStore .wikidata bad ""
252+
let table : Std.HashMap String Result :=
253+
restResults.foldl (fun m (k, v) => m.insert k v) ∅
254+
return qids.map fun q =>
255+
if q == bad then (q, .missing)
256+
else (q, table.getD q (.network "wikidata: lost from response"))
257+
| .results results =>
258+
if let .ok entities := json.getObjVal? "entities" then
259+
for (q, _) in results do
260+
if let .ok ent := entities.getObjVal? q then
261+
cacheStore .wikidata q ent.compress
262+
-- Cache the bogus marker so repeated lookups of a known-missing id stay cheap.
263+
for (q, r) in results do
264+
match r with
265+
| .missing => cacheStore .wikidata q ""
266+
| _ => pure ()
267+
return results
268+
269+
/-- Public Wikidata fetch: serve cached entries directly, batch the rest. -/
270+
def fetchWikidata (qids : List String) : IO (List (String × Result)) := do
271+
let mut cached : Std.HashMap String Result := ∅
272+
let mut todo : Array String := #[]
273+
for q in qids do
274+
match ← cacheLoad .wikidata q with
275+
| some body =>
276+
if body.isEmpty then
277+
cached := cached.insert q .missing
278+
else
279+
match Json.parse body with
280+
| .ok ent => cached := cached.insert q (wikidataResultOf ent)
281+
| .error _ => todo := todo.push q
282+
| none => todo := todo.push q
283+
let mut fresh : Std.HashMap String Result := ∅
284+
for batch in chunkList wikidataBatchSize todo.toList do
285+
for (q, r) in (← fetchWikidataBatch batch) do
286+
fresh := fresh.insert q r
287+
return qids.map fun q =>
288+
if let some r := cached[q]? then (q, r)
289+
else if let some r := fresh[q]? then (q, r)
290+
else (q, .network "missing from response")
291+
292+
/-! ## Stacks / Kerodon (Gerby) -/
293+
294+
/-- The base URL for a Gerby-style database. -/
295+
def Database.gerbyBase? : Database → Option String
296+
| .stacks => some "https://stacks.math.columbia.edu"
297+
| .kerodon => some "https://kerodon.net"
298+
| .wikidata => none
299+
300+
/-- Return the substring of `s` that lies after the first occurrence of `needle`.
301+
`none` if `needle` is absent. -/
302+
def afterFirst? (s needle : String) : Option String :=
303+
let parts := s.splitOn needle
304+
match parts with
305+
| _ :: rest@(_ :: _) => some (needle.intercalate rest)
306+
-- exactly one piece means the needle was never matched.
307+
| _ => none
308+
309+
/-- Take everything in `s` up to (but not including) the first `c`. -/
310+
def takeUntilChar (s : String) (c : Char) : String :=
311+
String.ofList (s.toList.takeWhile (· != c))
312+
313+
/-- Pull the environment type (`Lemma`, `Proposition`, …) and reference number
314+
from the `/content/statement` HTML. Both Stacks and Kerodon wrap each tag in
315+
`<article class="env-{TYPE}" id="{TAG}">` and lead with
316+
`<a ...>Lemma <span data-tag="...">14.32.3</span>.</a>`. -/
317+
def parseGerbyTitle (html : String) : String :=
318+
let envType :=
319+
match afterFirst? html "class=\"env-" with
320+
| none => ""
321+
| some rest => takeUntilChar rest '"'
322+
let reference :=
323+
match afterFirst? html "data-tag=\"" with
324+
| none => ""
325+
| some afterAttr =>
326+
-- Skip past the `...">` that closes the opening tag.
327+
match afterFirst? afterAttr ">" with
328+
| none => ""
329+
| some inside => takeUntilChar inside '<'
330+
let cap := envType.capitalize
331+
flattenWhitespace (if reference.isEmpty then cap else s!"{cap} {reference}")
332+
333+
/-- Fetch one tag from a Gerby-based database. We always request the
334+
`/content/statement` endpoint: it is defined for every tag (the `/structure`
335+
endpoint only resolves for nodes with children) and gives us the rendered
336+
statement directly. -/
337+
def fetchGerby (db : Database) (tag : String) : IO Result := do
338+
if let some cached ← cacheLoad db tag then
339+
if cached.isEmpty then return .missing
340+
let title := parseGerbyTitle cached
341+
let snippet := stripHtml cached
342+
return .ok title snippet
343+
let some base := db.gerbyBase? | return .network s!"{db.name}: no Gerby base"
344+
let url := s!"{base}/data/tag/{tag}/content/statement"
345+
let (status, body) ← fetchUrl url
346+
if status != 200 then
347+
return .network s!"{db.name} HTTP {status}"
348+
if body.trimAscii.toString == "This tag does not exist." then
349+
cacheStore db tag ""
350+
return .missing
351+
cacheStore db tag body
352+
let title := parseGerbyTitle body
353+
let snippet := stripHtml body
354+
if title.isEmpty && snippet.isEmpty then
355+
return .network s!"{db.name}: could not parse statement"
356+
return .ok title snippet
357+
358+
def fetchGerbyAll (db : Database) (tags : List String) : IO (List (String × Result)) :=
359+
tags.mapM fun t => return (t, ← fetchGerby db t)
360+
361+
/-! ## Top-level dispatch -/
362+
363+
def Database.fetch (db : Database) (tags : List String) : IO (List (String × Result)) :=
364+
match db with
365+
| .wikidata => fetchWikidata tags
366+
| .stacks => fetchGerbyAll db tags
367+
| .kerodon => fetchGerbyAll db tags
368+
369+
def Result.emit (tag : String) : Result → IO Unit
370+
| .ok title snippet => IO.println s!"{tag}\t{title}\t{snippet}"
371+
| .missing => IO.println s!"{tag}\tERROR\tmissing"
372+
| .network reason => IO.println s!"{tag}\tERROR\tnetwork: {reason}"
373+
374+
def usage : IO Unit := do
375+
IO.eprintln "Usage: lake env lean --run scripts/crossref-snippet.lean <database> <tag> [<tag>...]"
376+
IO.eprintln " <database>: wikidata | stacks | kerodon"
377+
378+
def main (args : List String) : IO UInt32 := do
379+
match args with
380+
| dbStr :: tag :: rest =>
381+
let some db := Database.ofString? dbStr
382+
| usage; return 64
383+
let results ← db.fetch (tag :: rest)
384+
let mut sawMissing := false
385+
let mut sawNetwork := false
386+
for (t, r) in results do
387+
r.emit t
388+
match r with
389+
| .missing => sawMissing := true
390+
| .network _ => sawNetwork := true
391+
| _ => pure ()
392+
if sawMissing then return 2
393+
if sawNetwork then return 3
394+
return 0
395+
| _ => usage; return 64

0 commit comments

Comments
 (0)