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