|
| 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 " " " " |
| 132 | + |>.replace "&" "&" |
| 133 | + |>.replace "<" "<" |
| 134 | + |>.replace ">" ">" |
| 135 | + |>.replace """ "\"" |
| 136 | + |>.replace "'" "'" |
| 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