primarysource 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,1067 @@
1
+ """Agent-facing views over the library, addressed by citation refs.
2
+
3
+ One reference grammar is used everywhere, and every view both accepts and
4
+ emits it, so a page footer's "next:" IS the citation of where the next page
5
+ starts (resumable, readable, and citable as-is):
6
+
7
+ RFC9000 whole document (header, abstract, outline)
8
+ RFC9000§9.6 a section, subsections included
9
+ RFC9000§9.6¶3 paragraph 3 of §9.6 (RFCXML pn ids for RFC
10
+ 8650+, txtseg paragraphs for older RFCs)
11
+ RFC9000#section-9.6-3 the same paragraph by its RFC Editor id/anchor
12
+ RFC9000#table-2 any RFCXML block id (tables, figures, refs)
13
+ RFC9000:2964-2979 a txt line range (any document)
14
+
15
+ Accepted spellings: 'rfc 9000', 'BCP14' (aliases follow to the RFC),
16
+ 'section 9.6' / 's9.6' for §, 'p3' / 'para 3' for ¶.
17
+
18
+ Pages are cut on block (paragraph) boundaries within a character budget,
19
+ never mid-sentence, and text is the RFC Editor's own txt minus page
20
+ furniture, so line numbers stay true to the canonical file. Everything
21
+ returns plain text: JSON-escaping prose costs tokens for nothing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import bisect
27
+ import re
28
+ import sqlite3
29
+ import sys
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ from primarysource.errors import LibError
34
+
35
+ from . import cite as _cite
36
+ from . import query as q
37
+ from .index import search as _s
38
+ from .index.rfcxml import _FURNITURE
39
+
40
+ CHARS_PER_TOKEN = 4
41
+ NORM_RE = re.compile(r"\b(MUST NOT|MUST|SHALL NOT|SHALL|SHOULD NOT|SHOULD|"
42
+ r"NOT RECOMMENDED|RECOMMENDED|MAY|REQUIRED|OPTIONAL)\b")
43
+
44
+
45
+ class RefError(LibError, ValueError):
46
+ """Bad or unresolvable reference, or a library not ready to answer. A
47
+ `LibError`: `hint` names the tool call to make next and `action` the
48
+ command a human runs (PLAN.md §5.10); the MCP server adds the ref forms
49
+ to a ref problem that names neither."""
50
+
51
+
52
+ def _fetch_failed(exc: BaseException) -> bool:
53
+ """A fetch from rfc-editor.org failed: an OSError, or an httpx error.
54
+ httpx is imported only by the code that fetches, so when it is not
55
+ loaded no httpx error can have been raised."""
56
+ if isinstance(exc, OSError):
57
+ return True
58
+ httpx = sys.modules.get("httpx")
59
+ return httpx is not None and isinstance(exc, httpx.HTTPError)
60
+
61
+
62
+ @dataclass
63
+ class Ref:
64
+ doc: str # as asked ('BCP14')
65
+ served: str # document carrying the text ('RFC2119')
66
+ sec: str = "" # canonical sec_num
67
+ para: int | None = None
68
+ pn: str = "" # RFCXML block id
69
+ start: int | None = None
70
+ end: int | None = None
71
+
72
+ def is_doc(self) -> bool:
73
+ return not (self.sec or self.pn or self.para or self.start)
74
+
75
+
76
+ _REF = re.compile(
77
+ r"^\s*(?P<doc>(?:rfc|bcp|std|fyi)?[\s\-_]*\d{1,5})\s*"
78
+ r"(?:(?:§|section\s+|sect?\.?\s*|s(?=\d|[A-Z]\b|[A-Z]\.))\s*"
79
+ r"(?P<sec>[^¶:#]+?))?\s*"
80
+ r"(?:(?:¶|para\s*|p)\s*(?P<para>\d+))?\s*"
81
+ r"(?:#(?P<pn>[\w.\-]+))?\s*"
82
+ r"(?::\s*(?P<a>\d+)(?:\s*[-–]\s*(?P<b>\d+))?)?\s*$", re.I)
83
+
84
+
85
+ def _require_library(conn: sqlite3.Connection) -> None:
86
+ from . import library
87
+ if not library.initialized(conn):
88
+ raise RefError(*library.not_initialized(conn))
89
+
90
+
91
+ def _ensure_text(conn: sqlite3.Connection, doc: str) -> None:
92
+ """Fetch + index a document on first use (see rfclib.library)."""
93
+ from . import library
94
+ from .config import NetworkDisabled
95
+ try:
96
+ library.ensure_indexed(conn, doc)
97
+ except NetworkDisabled as e:
98
+ raise RefError(e.message, e.hint, e.action) from None
99
+ except Exception as e:
100
+ if not _fetch_failed(e):
101
+ raise
102
+ raise RefError(f"could not fetch {doc} from rfc-editor.org ({e})",
103
+ "retry the same call later",
104
+ "rfclib sync (while online)") from None
105
+
106
+
107
+ def parse_ref(conn: sqlite3.Connection, raw: str,
108
+ need_text: bool = True) -> Ref:
109
+ """need_text: fetch and index the document if it is not local yet
110
+ (False for views that only need the catalog, e.g. errata)."""
111
+ m = _REF.match(raw or "")
112
+ if not m:
113
+ raise RefError(f"can't parse ref {raw!r}")
114
+ _require_library(conn)
115
+ try:
116
+ doc = q.normalize_doc_id(m.group("doc"))
117
+ q.get_doc_row(conn, doc)
118
+ except (ValueError, q.NotFound) as e:
119
+ raise RefError(f"unknown document in {raw!r}: {e}") from None
120
+ if need_text:
121
+ _ensure_text(conn, q.resolve(conn, doc))
122
+ sec = _s.norm_sec(m.group("sec")) if m.group("sec") else ""
123
+ ref = Ref(doc, q.resolve(conn, doc), sec,
124
+ int(m.group("para")) if m.group("para") else None,
125
+ m.group("pn") or "",
126
+ int(m.group("a")) if m.group("a") else None,
127
+ int(m.group("b")) if m.group("b") else None)
128
+ head = _heading_by_pn(conn, ref.served, ref.pn) if ref.pn else None
129
+ if head is not None:
130
+ # '#section-9.6' is a section anchor (html), not a paragraph
131
+ ref.sec, ref.pn = head["sec_num"], ""
132
+ if not ref.sec: # unnumbered section: address it by lines
133
+ ref.start, ref.end = head["start_line"], head["end_line"]
134
+ elif ref.pn.startswith("section-") and not has_xml(conn, ref.served):
135
+ m2 = re.fullmatch(r"section-(\d+(?:\.\d+)*)", ref.pn)
136
+ if m2: # txt-only doc, html-style section anchor
137
+ ref.sec, ref.pn = m2.group(1), ""
138
+ if ref.para is not None:
139
+ if not ref.sec:
140
+ raise RefError(f"¶ needs a section: e.g. '{doc}§9.6¶{ref.para}'")
141
+ h = _heading(conn, ref.served, ref.sec)
142
+ if h is None:
143
+ raise RefError(
144
+ f"{ref.served} is not paragraph-indexed (run `rfclib index "
145
+ f"build`). Use a section or line ref instead.")
146
+ ref.pn = f"{h['pn']}-{ref.para}"
147
+ if not _block(conn, ref.served, ref.pn):
148
+ n = conn.execute(
149
+ "SELECT COUNT(*) FROM blocks WHERE doc_id=? AND pn LIKE ? "
150
+ "AND kind<>'heading'", (ref.served, h["pn"] + "-%")
151
+ ).fetchone()[0]
152
+ raise RefError(f"no ¶{ref.para} in {doc}§{ref.sec} "
153
+ f"(it has {n} top-level blocks)")
154
+ elif ref.pn and not _block(conn, ref.served, ref.pn):
155
+ raise RefError(f"no block #{ref.pn} in {ref.served}")
156
+ return ref
157
+
158
+
159
+ # --------------------------------------------------------------------------
160
+ # lookups
161
+
162
+ def _block(conn, doc: str, pn: str):
163
+ return conn.execute("SELECT * FROM blocks WHERE doc_id=? AND pn=?",
164
+ (doc, pn)).fetchone()
165
+
166
+
167
+ def _heading(conn, doc: str, sec: str):
168
+ return conn.execute(
169
+ "SELECT * FROM blocks WHERE doc_id=? AND kind='heading' AND sec_num=?",
170
+ (doc, sec)).fetchone() if sec else None
171
+
172
+
173
+ def _heading_by_pn(conn, doc: str, pn: str):
174
+ return conn.execute(
175
+ "SELECT * FROM blocks WHERE doc_id=? AND kind='heading' AND pn=?",
176
+ (doc, pn)).fetchone()
177
+
178
+
179
+ def _blocks_in(conn, doc: str, a: int, b: int) -> list[sqlite3.Row]:
180
+ return conn.execute(
181
+ "SELECT * FROM blocks WHERE doc_id=? AND kind<>'heading' AND "
182
+ "start_line>=? AND start_line<=? ORDER BY ord", (doc, a, b)).fetchall()
183
+
184
+
185
+ def _frag(conn, doc: str, pn: str, sec: str) -> str:
186
+ """URL fragment for a ref. Paragraph pn ids are real anchors only in
187
+ RFCXML-rendered HTML (RFC 8650+); the RFC Editor's HTML for older RFCs
188
+ anchors sections only, so their paragraphs link to the section."""
189
+ if pn and conn.execute("SELECT 1 FROM files WHERE doc_id=? AND fmt='xml'",
190
+ (doc,)).fetchone():
191
+ return pn
192
+ return _s.url_frag(sec) if sec else ""
193
+
194
+
195
+ def has_xml(conn, doc: str) -> bool:
196
+ return conn.execute("SELECT 1 FROM blocks WHERE doc_id=? LIMIT 1",
197
+ (doc,)).fetchone() is not None
198
+
199
+
200
+ def block_ref(doc: str, row) -> str:
201
+ """'RFC9000§9.6¶3' when the block is a numbered paragraph of a numbered
202
+ section, else 'RFC9000#<pn>'."""
203
+ pn, sec = row["pn"], row["sec_num"]
204
+ m = re.fullmatch(r"(section-.+)-(\d+)", pn)
205
+ if sec and m and row["kind"] != "heading":
206
+ return f"{doc}§{sec}¶{m.group(2)}"
207
+ return f"{doc}#{pn}"
208
+
209
+
210
+ def sec_ref(doc: str, sec: str, a: int | None = None,
211
+ b: int | None = None) -> str:
212
+ if sec:
213
+ return f"{doc}§{sec}"
214
+ return f"{doc}:{a}-{b}" if a else doc
215
+
216
+
217
+ def _meta(conn, doc: str) -> dict:
218
+ return q._row_to_meta(conn, q.get_doc_row(conn, doc))
219
+
220
+
221
+ def _doc_line(conn, ref: Ref, meta: dict) -> str:
222
+ if meta["is_current"]:
223
+ state = "current"
224
+ else:
225
+ direct = meta["relations"].get("obsoleted_by", [])
226
+ now = current_successors(conn, ref.served)
227
+ state = ("OBSOLETE, superseded by " + (", ".join(direct) or "?")
228
+ + (f" (current: {', '.join(now)})"
229
+ if now and set(now) != set(direct) else ""))
230
+ via = f" (via {ref.doc})" if ref.doc != ref.served else ""
231
+ return (f"{_cite.label(ref.served)}{via} — {meta.get('title') or ''} | "
232
+ f"{meta.get('current_status') or meta.get('status') or ''} | "
233
+ f"{(meta.get('pub_date') or '')[:7]} | {state}")
234
+
235
+
236
+ def _clean_lines(lines: list[str], a: int, b: int) -> list[tuple[int, str]]:
237
+ """(line_no, text) for [a, b] minus page furniture, blank runs -> one."""
238
+ out: list[tuple[int, str]] = []
239
+ blank = False
240
+ for n in range(a, min(b, len(lines)) + 1):
241
+ ln = lines[n - 1].replace("\f", "").rstrip()
242
+ if any(p.search(ln) for p in _FURNITURE):
243
+ continue
244
+ if not ln.strip():
245
+ if out and not blank:
246
+ blank = True
247
+ continue
248
+ if blank:
249
+ out.append((0, ""))
250
+ blank = False
251
+ out.append((n, ln))
252
+ return out
253
+
254
+
255
+ def _text(conn, ref: Ref, mirror_root: Path) -> list[str]:
256
+ text, _served, _p = q._served_text(conn, ref.served, "txt", mirror_root)
257
+ if text is None:
258
+ raise RefError(f"{ref.served} has no txt rendering locally or "
259
+ "upstream", f"read('{ref.served}', format='html')")
260
+ return text.splitlines()
261
+
262
+
263
+ def _scope(conn, ref: Ref, total: int) -> tuple[int, int, str, str]:
264
+ """(start, end, sec_num, sec_title) the ref addresses; a paragraph ref
265
+ scopes to its enclosing section, starting at the paragraph."""
266
+ if ref.pn:
267
+ b = _block(conn, ref.served, ref.pn)
268
+ h = _heading(conn, ref.served, b["sec_num"]) if b["sec_num"] else None
269
+ if h is None: # unnumbered section: run to the block's own section
270
+ return b["start_line"], b["end_line"], b["sec_num"], b["sec_title"]
271
+ return b["start_line"], h["end_line"], b["sec_num"], b["sec_title"]
272
+ if ref.sec:
273
+ span = q._resolve_section_arg(conn, ref.served, ref.sec)
274
+ if not span.get("ok"):
275
+ similar = ""
276
+ if span.get("suggestions"):
277
+ similar = "; similar: " + ", ".join(
278
+ f"{ref.doc}§{x}" for x in span["suggestions"])
279
+ raise RefError(f"no section {ref.sec} in {ref.served}{similar}",
280
+ f"outline('{ref.doc}')")
281
+ return (span["start_line"], span["end_line"], span["sec_num"],
282
+ span.get("sec_title") or "")
283
+ a = max(1, ref.start or 1)
284
+ b = min(total, ref.end or total)
285
+ if b < a:
286
+ raise RefError(f"empty line range {a}-{b} ({ref.served} has "
287
+ f"{total} lines)")
288
+ return a, b, "", ""
289
+
290
+
291
+ def _neighbours(conn, ref: Ref, sec: str, end: int) -> str:
292
+ rows = _s.toc(conn, ref.served)
293
+ nxt = next((r for r in rows if r["start_line"] > end and r["sec_num"]),
294
+ None)
295
+ bits = []
296
+ if sec and "." in sec:
297
+ bits.append(f"up: {ref.doc}§{sec.rsplit('.', 1)[0]}")
298
+ if nxt:
299
+ bits.append(f"after: {ref.doc}§{nxt['sec_num']} "
300
+ f"\"{nxt['title']}\"")
301
+ return " | ".join(bits)
302
+
303
+
304
+ # --------------------------------------------------------------------------
305
+ # read
306
+
307
+ def read(conn: sqlite3.Connection, raw: str, *, mirror_root: Path,
308
+ max_tokens: int = 2000, view: str = "text",
309
+ lang: str | None = None) -> str:
310
+ ref = parse_ref(conn, raw)
311
+ view = (view or "text").lower()
312
+ if view not in ("text", "normative", "code", "refs"):
313
+ raise RefError("view must be text | normative | code | refs")
314
+ if view == "refs":
315
+ return links(conn, raw, direction="out")
316
+ budget = max(400, int(max_tokens) * CHARS_PER_TOKEN)
317
+ meta = _meta(conn, ref.served)
318
+ if ref.is_doc() and view == "text":
319
+ return _doc_overview(conn, ref, meta, mirror_root)
320
+ lines = _text(conn, ref, mirror_root)
321
+ a, b, sec, title = _scope(conn, ref, len(lines))
322
+ head = _doc_line(conn, ref, meta)
323
+ label = sec_ref(ref.doc, sec) if sec else f"{ref.doc}:{a}-{b}"
324
+ frag = _frag(conn, ref.served, ref.pn, sec)
325
+ errata = _cite.errata_note(conn, ref.served, sec)
326
+ top = (f"{head}\n{label}" + (f' "{title}"' if title else "")
327
+ + f" | lines {a}-{b} | {errata}\n"
328
+ f"{q.anchor_url(meta, frag, ref.served)}\n")
329
+ if view == "normative":
330
+ body, nxt = _normative(conn, ref, lines, a, b, budget)
331
+ elif view == "code":
332
+ body, nxt = _code(conn, ref, a, b, budget, lang)
333
+ else:
334
+ body, nxt = _page(conn, ref, lines, a, b, budget)
335
+ foot = [f"next: {nxt}" if nxt else "end of scope"]
336
+ nb = _neighbours(conn, ref, sec, b)
337
+ if nb:
338
+ foot.append(nb)
339
+ return top + "\n" + body.rstrip() + "\n\n— " + " | ".join(foot)
340
+
341
+
342
+ def _page(conn, ref: Ref, lines: list[str], a: int, b: int,
343
+ budget: int) -> tuple[str, str | None]:
344
+ """Text of [a, b] up to budget, cut on unit boundaries.
345
+
346
+ Units are RFCXML blocks (each preceded by a [§x¶n] marker, so every
347
+ paragraph on the page is citable) and subsection headings; txt-only
348
+ documents fall back to blank-line paragraphs. The next-page cursor is a
349
+ ¶ ref while the page stays inside the scope's own section, else a line
350
+ range to the end of the scope, so paging a section never stops at the
351
+ end of its first subsection."""
352
+ marks: dict[int, tuple[str, str]] = {} # line -> (label, sec_num)
353
+ if has_xml(conn, ref.served):
354
+ for blk in _blocks_in(conn, ref.served, a, b):
355
+ marks.setdefault(blk["start_line"], (
356
+ block_ref(ref.doc, blk)[len(ref.doc):], blk["sec_num"]))
357
+ for h in conn.execute(
358
+ "SELECT sec_num, start_line FROM blocks WHERE doc_id=? AND "
359
+ "kind='heading' AND start_line>? AND start_line<=?",
360
+ (ref.served, a, b)):
361
+ marks[h["start_line"]] = ("", h["sec_num"])
362
+ units: list[tuple[str | None, str, list[tuple[int, str]]]] = []
363
+ for n, ln in _clean_lines(lines, a, b):
364
+ if not units or n in marks or (not marks and n == 0):
365
+ lab, sec = marks.get(n, (None, ""))
366
+ units.append((lab, sec, []))
367
+ units[-1][2].append((n, ln))
368
+ own_sec = ref.sec or (_block(conn, ref.served, ref.pn)["sec_num"]
369
+ if ref.pn else "")
370
+ out: list[str] = []
371
+ size = 0
372
+ for lab, sec, ls in units:
373
+ first = next((n for n, _ in ls if n), b)
374
+ usize = sum(len(x) + 1 for _, x in ls)
375
+ if out and size + usize > budget:
376
+ if lab and sec == own_sec:
377
+ return "\n".join(out), f"{ref.doc}{lab}"
378
+ return "\n".join(out), (f"{ref.doc}:{first}-{b}"
379
+ + (f" (starts at {lab or '§' + sec})"
380
+ if sec else ""))
381
+ if lab:
382
+ out.append(f"[{lab}]")
383
+ for n, x in ls:
384
+ if out and size + len(x) > budget * 1.5 and n:
385
+ # one unit bigger than a page (giant table/figure): lines
386
+ return "\n".join(out), f"{ref.doc}:{n}-{b}"
387
+ out.append(x)
388
+ size += len(x) + 1
389
+ return "\n".join(out), None
390
+
391
+
392
+ def _cursor(conn, ref: Ref, row, b: int) -> str:
393
+ """Next-page ref for block `row`: its own ref while it belongs to the
394
+ scope's section (a block ref scopes to its OWN section, so a block in a
395
+ subsection would end the paging early), else a line range to b."""
396
+ own = ref.sec or (_block(conn, ref.served, ref.pn)["sec_num"]
397
+ if ref.pn else None)
398
+ if own is not None and row["sec_num"] == own:
399
+ return block_ref(ref.doc, row)
400
+ return f"{ref.doc}:{row['start_line']}-{b}"
401
+
402
+
403
+ def _normative(conn, ref: Ref, lines, a, b, budget):
404
+ out, size = [], 0
405
+ if has_xml(conn, ref.served):
406
+ rows = [r for r in _blocks_in(conn, ref.served, a, b) if r["bcp14"]]
407
+ for r in rows:
408
+ item = f"[{block_ref(ref.doc, r)}] ({r['bcp14']}) {r['text']}"
409
+ if out and size + len(item) > budget:
410
+ return "\n\n".join(out), _cursor(conn, ref, r, b) + \
411
+ " (view=normative)"
412
+ out.append(item)
413
+ size += len(item) + 2
414
+ if not out:
415
+ return "(no BCP 14 keywords in this scope)", None
416
+ return "\n\n".join(out), None
417
+ # txt-only: uppercase keyword sentences (RFC 2119 convention; older
418
+ # documents may use the words loosely)
419
+ text = " ".join(t for _n, t in _clean_lines(lines, a, b) if t)
420
+ text = re.sub(r"\s+", " ", text)
421
+ for sent in re.split(r"(?<=[.;:])\s+(?=[A-Z(\"])", text):
422
+ found = sorted(set(NORM_RE.findall(sent)))
423
+ if not found:
424
+ continue
425
+ item = f"({','.join(found)}) {sent.strip()}"
426
+ if out and size + len(item) > budget:
427
+ return "\n\n".join(out) + "\n[more; narrow the ref]", None
428
+ out.append(item)
429
+ size += len(item) + 2
430
+ return ("\n\n".join(out) if out else
431
+ "(no uppercase BCP 14 keywords in this scope)"), None
432
+
433
+
434
+ def _code(conn, ref: Ref, a, b, budget, lang):
435
+ if not has_xml(conn, ref.served):
436
+ raise RefError(f"view=code needs paragraph blocks; {ref.served} "
437
+ "is not indexed yet. Use grep(doc, pattern) or read "
438
+ "the section text.")
439
+ rows = [r for r in _blocks_in(conn, ref.served, a, b)
440
+ if r["kind"] in ("sourcecode", "artwork")
441
+ and (not lang or r["lang"].lower() == lang.lower())]
442
+ if not rows:
443
+ langs = sorted({r["lang"] for r in conn.execute(
444
+ "SELECT DISTINCT lang FROM blocks WHERE doc_id=? AND kind="
445
+ "'sourcecode'", (ref.served,)) if r["lang"]})
446
+ return ("(no code/figures in this scope"
447
+ + (f"; this doc has: {', '.join(langs)}" if langs else "")
448
+ + ")"), None
449
+ out, size = [], 0
450
+ for r in rows:
451
+ item = (f"[{block_ref(ref.doc, r)}] {r['kind']}"
452
+ + (f" type={r['lang']}" if r["lang"] else "")
453
+ + f" L{r['start_line']}-{r['end_line']}\n{r['text']}")
454
+ if out and size + len(item) > budget:
455
+ return "\n\n".join(out), _cursor(conn, ref, r, b) + " (view=code)"
456
+ out.append(item)
457
+ size += len(item) + 2
458
+ return "\n\n".join(out), None
459
+
460
+
461
+ def _doc_overview(conn, ref: Ref, meta: dict, mirror_root: Path) -> str:
462
+ abstract = re.sub(r"\s+", " ", meta.get("abstract") or "").strip()
463
+ out = [_doc_line(conn, ref, meta), q._url_of(meta), ""]
464
+ if abstract:
465
+ out += [abstract[:900] + ("…" if len(abstract) > 900 else ""), ""]
466
+ out.append(outline(conn, ref.doc, depth=1, header=False))
467
+ return "\n".join(out)
468
+
469
+
470
+ # --------------------------------------------------------------------------
471
+ # outline
472
+
473
+ def outline(conn: sqlite3.Connection, doc: str, depth: int | None = 2,
474
+ contains: str | None = None, header: bool = True,
475
+ max_rows: int = 200) -> str:
476
+ _require_library(conn)
477
+ try:
478
+ did = q.normalize_doc_id(doc)
479
+ q.get_doc_row(conn, did)
480
+ except (ValueError, q.NotFound) as e:
481
+ raise RefError(f"unknown document {doc!r}: {e}") from None
482
+ served = q.resolve(conn, did)
483
+ _ensure_text(conn, served)
484
+ all_rows = _s.toc(conn, served, contains=contains)
485
+ if not all_rows:
486
+ if contains and _s.toc(conn, served):
487
+ raise RefError(f"no section of {served} has {contains!r} in its "
488
+ "number or title",
489
+ f"outline('{did}') without contains, or "
490
+ f"grep('{did}', {contains!r})")
491
+ raise RefError(f"{served} is not section-indexed yet",
492
+ action="rfclib index build")
493
+ want = None if contains else depth # a filter searches every level
494
+ rows = [r for r in all_rows if want is None or not r["sec_num"]
495
+ or r["sec_num"].count(".") + 1 <= want]
496
+ note = ""
497
+ while len(rows) > max_rows and (want or 9) > 1:
498
+ want = (want or 9) - 1
499
+ rows = [r for r in all_rows if not r["sec_num"]
500
+ or r["sec_num"].count(".") + 1 <= want]
501
+ note = f" (reduced to depth {want} to fit; pass depth=None or contains=)"
502
+ out = []
503
+ if header:
504
+ meta = _meta(conn, served)
505
+ out.append(_doc_line(conn, Ref(did, served), meta))
506
+ out.append(f"{len(rows)} of {len(all_rows)} sections, depth "
507
+ f"{want if want else 'all'}{note}")
508
+ for r in rows:
509
+ sn = r["sec_num"]
510
+ ind = " " * sn.count(".") if sn and not sn.startswith("Appendix") \
511
+ else ""
512
+ tag = f"§{sn} " if sn else ""
513
+ extra = " (no own text; spans its subsections)" \
514
+ if r.get("parts") == 0 else ""
515
+ out.append(f"{ind}{tag}{r['title']} L{r['start_line']}-"
516
+ f"{r['end_line']}{extra}")
517
+ out.append(f"read: read('{did}§<num>') | unnumbered: read('{did}:<a>-<b>')")
518
+ return "\n".join(out)
519
+
520
+
521
+ # --------------------------------------------------------------------------
522
+ # grep
523
+
524
+ def grep(conn: sqlite3.Connection, doc: str, pattern: str, *,
525
+ mirror_root: Path, max_hits: int = 25,
526
+ ignore_case: bool = True) -> str:
527
+ ref = parse_ref(conn, doc)
528
+ try:
529
+ rx = re.compile(pattern, re.I if ignore_case else 0)
530
+ except re.error:
531
+ rx = re.compile(re.escape(pattern), re.I if ignore_case else 0)
532
+ lines = _text(conn, ref, mirror_root)
533
+ a, b = 1, len(lines)
534
+ if not ref.is_doc():
535
+ a, b, _sec, _t = _scope(conn, ref, len(lines))
536
+ blocks = _blocks_in(conn, ref.served, 1, len(lines)) if has_xml(
537
+ conn, ref.served) else []
538
+ bstarts = [r["start_line"] for r in blocks]
539
+ marks = [(r["start_line"], r["sec_num"]) for r in conn.execute(
540
+ "SELECT DISTINCT start_line, sec_num FROM chunks WHERE doc_id=? "
541
+ "ORDER BY start_line", (ref.served,))]
542
+ mstarts = [m[0] for m in marks]
543
+
544
+ def where(n: int) -> str:
545
+ i = bisect.bisect_right(bstarts, n) - 1
546
+ if i >= 0 and blocks[i]["end_line"] >= n:
547
+ return block_ref(ref.doc, blocks[i])
548
+ j = bisect.bisect_right(mstarts, n) - 1
549
+ if j >= 0 and marks[j][1]:
550
+ return f"{ref.doc}§{marks[j][1]}"
551
+ return f"{ref.doc}:{n}"
552
+
553
+ hits, out = 0, []
554
+ for n in range(a, b + 1):
555
+ ln = lines[n - 1]
556
+ if any(p.search(ln) for p in _FURNITURE) or not rx.search(ln):
557
+ continue
558
+ hits += 1
559
+ if len(out) < max_hits:
560
+ out.append(f"{where(n)} L{n}: {ln.strip()[:160]}")
561
+ if not hits:
562
+ return (f"no match for /{pattern}/ in {ref.doc}"
563
+ + ("" if ref.is_doc() else f" lines {a}-{b}")
564
+ + ". Try search_all for the whole corpus.")
565
+ more = (f"\n[{hits - len(out)} more; narrow the pattern or scope]"
566
+ if hits > len(out) else "")
567
+ return f"{hits} match(es) in {ref.doc}\n" + "\n".join(out) + more
568
+
569
+
570
+ # --------------------------------------------------------------------------
571
+ # cite
572
+
573
+ def cite(conn: sqlite3.Connection, raw: str, *, mirror_root: Path,
574
+ style: str = "verbose") -> str:
575
+ ref = parse_ref(conn, raw)
576
+ if not ref.pn:
577
+ sec = ref.sec or None
578
+ lines = (f"{ref.start}-{ref.end or ref.start}" if ref.start
579
+ else None)
580
+ out = _cite.cite(conn, ref.doc, section=sec, lines=lines,
581
+ style=style, mirror_root=mirror_root)
582
+ if out.startswith("CANNOT CITE"):
583
+ raise RefError(out)
584
+ return out
585
+ blk = _block(conn, ref.served, ref.pn)
586
+ meta = _meta(conn, ref.doc)
587
+ br = block_ref(ref.doc, blk)
588
+ short = _cite.label(ref.doc) + br[len(ref.doc):]
589
+ url = q.anchor_url(meta, _frag(conn, ref.served, ref.pn, blk["sec_num"]),
590
+ ref.served)
591
+ if style == "short":
592
+ return short
593
+ if style == "markdown":
594
+ return f"[{short}]({url})"
595
+ if style == "bibtex":
596
+ return _cite.cite(conn, ref.doc, style="bibtex")
597
+ body, _ = _cite.span_text(conn, ref.served, mirror_root=mirror_root,
598
+ lines=f"{blk['start_line']}-{blk['end_line']}")
599
+ status = meta.get("current_status") or meta.get("status") or ""
600
+ successor = ", ".join(meta["relations"].get("obsoleted_by", []))
601
+ state = "current" if meta["is_current"] else \
602
+ f"OBSOLETE by {successor or 'unknown'}"
603
+ sec_t = f' "{blk["sec_title"]}"' if blk["sec_title"] else ""
604
+ tail = (f"{short}{sec_t} lines {blk['start_line']}-{blk['end_line']} — "
605
+ f"{meta.get('title') or ''} ({(meta.get('pub_date') or '')[:7]}, "
606
+ f"{status}, {state})\n{url} | "
607
+ f"{_cite.errata_note(conn, ref.served, blk['sec_num'])} | "
608
+ f"span sha256:{q.span_hash(body)}")
609
+ if style == "comment":
610
+ return "\n".join("# " + ln for ln in tail.split("\n"))
611
+ return tail
612
+
613
+
614
+ # --------------------------------------------------------------------------
615
+ # links
616
+
617
+ def links(conn: sqlite3.Connection, raw: str, direction: str = "out",
618
+ limit: int = 30) -> str:
619
+ ref = parse_ref(conn, raw)
620
+ direction = (direction or "out").lower()
621
+ if direction == "related":
622
+ from . import subjects as subj_mod
623
+ if not subj_mod.subjects_ready(conn):
624
+ raise RefError("the subject tree is not in the library yet",
625
+ action="rfclib subjects sync")
626
+ r = subj_mod.related(conn, ref.served, limit=limit)
627
+ subs = ", ".join(s.get("name", "") for s in r.get("subjects", []))
628
+ out = [f"{ref.served} subjects: {subs or '(none)'}",
629
+ "documents sharing subjects:"]
630
+ for d in r.get("nearby_docs", [])[:limit]:
631
+ out.append(f" {d.get('doc_id')} — {d.get('title') or ''}"
632
+ f" ({d.get('shared_subjects')} shared)")
633
+ return "\n".join(out)
634
+ if direction not in ("out", "in"):
635
+ raise RefError("direction must be out | in | related")
636
+ sec = ref.sec
637
+ if ref.pn:
638
+ blk = _block(conn, ref.served, ref.pn)
639
+ sec = blk["sec_num"]
640
+ like = f"{sec}.%"
641
+ if direction == "out":
642
+ q_ = ("SELECT dst_doc peer, dst_sec psec, src_sec ssec, normative "
643
+ "FROM xrefs WHERE doc_id=?")
644
+ args: list = [ref.served]
645
+ if ref.pn:
646
+ q_ += " AND src_pn=?"
647
+ args.append(ref.pn)
648
+ elif sec:
649
+ q_ += " AND (src_sec=? OR src_sec LIKE ?)"
650
+ args += [sec, like]
651
+ else:
652
+ q_ = ("SELECT doc_id peer, src_sec psec, dst_sec ssec, normative "
653
+ "FROM xrefs WHERE dst_doc=?")
654
+ args = [ref.served]
655
+ if sec:
656
+ q_ += " AND (dst_sec=? OR dst_sec LIKE ?)"
657
+ args += [sec, like]
658
+ rows = conn.execute(q_, args).fetchall()
659
+ agg: dict[str, dict] = {}
660
+ for r in rows:
661
+ e = agg.setdefault(r["peer"], {"psec": set(), "ssec": set(),
662
+ "norm": False})
663
+ if r["psec"]:
664
+ e["psec"].add(r["psec"])
665
+ if r["ssec"]:
666
+ e["ssec"].add(r["ssec"])
667
+ e["norm"] |= bool(r["normative"])
668
+ scope = sec_ref(ref.doc, sec) if sec else ref.doc
669
+ out = []
670
+ if agg:
671
+ info = {d["doc_id"]: d for d in conn.execute(
672
+ "SELECT doc_id, title, current_status, is_current FROM docs "
673
+ "WHERE doc_id IN (%s)" % ",".join("?" * len(agg)), list(agg))}
674
+ order = sorted(agg, key=lambda p: (not agg[p]["norm"],
675
+ -len(agg[p]["psec"]), p))
676
+ verb = "cites" if direction == "out" else "is cited by"
677
+ out.append(f"{scope} {verb} {len(agg)} document(s) "
678
+ "(section-precise):")
679
+ for p in order[:limit]:
680
+ e, d = agg[p], info.get(p, {})
681
+ psecs = ", ".join(f"§{s}" for s in sorted(e["psec"],
682
+ key=_s_key)[:8])
683
+ ssecs = ", ".join(f"§{s}" for s in sorted(e["ssec"],
684
+ key=_s_key)[:6])
685
+ cur = "" if not d or d["is_current"] else " OBSOLETE"
686
+ if d and not d["title"]: # BCP/STD/FYI alias row
687
+ d = dict(d, title=f"(series alias of {q.resolve(conn, p)})")
688
+ if direction == "out":
689
+ out.append(f" {p} {psecs or '(whole doc)'}"
690
+ f"{' [normative]' if e['norm'] else ''} — "
691
+ f"{(d['title'] if d else '') or ''}{cur}"
692
+ + (f" ← from {ssecs}" if ssecs and not sec
693
+ else ""))
694
+ else:
695
+ out.append(f" {p} {psecs or ''} → {ssecs or '(whole doc)'}"
696
+ f" — {(d['title'] if d else '') or ''}{cur}")
697
+ if len(agg) > limit:
698
+ out.append(f" … {len(agg) - limit} more")
699
+ # whole-document citation graph mined from txt (all eras)
700
+ if not sec:
701
+ from . import graph as ref_graph
702
+ g = ref_graph.get_refs(conn, ref.served, limit=limit)
703
+ side = g["outbound"] if direction == "out" else g["inbound"]
704
+ rest = [x for x in side if x["doc_id"] not in agg]
705
+ if rest:
706
+ out.append(f"{'cites' if direction == 'out' else 'cited by'} "
707
+ f"(reference lists, {len(rest)} more):")
708
+ for x in rest[:limit]:
709
+ secs = (" §" + ", §".join(x["sections"][:5])
710
+ if x["sections"] else "")
711
+ out.append(f" {x['doc_id']}{secs} [{'/'.join(x['kinds'])}]"
712
+ f" — {x['title'] or ''}"
713
+ + ("" if x["is_current"] else " OBSOLETE"))
714
+ if not out:
715
+ return (f"no section-level citations recorded for {scope}"
716
+ + ("" if has_xml(conn, ref.served) else
717
+ " (try without a section for reference-list edges)"))
718
+ return "\n".join(out)
719
+
720
+
721
+ def _s_key(s: str):
722
+ return [(0, int(p), "") if p.isdigit() else (1, 0, p)
723
+ for p in re.split(r"[.\s]", s)]
724
+
725
+
726
+ # --------------------------------------------------------------------------
727
+ # browse (subject taxonomy) and errata, as compact text
728
+
729
+ def subjects_tree(conn: sqlite3.Connection, parent: str | None = None,
730
+ contains: str | None = None, limit: int = 120) -> str:
731
+ """One level of the RFC Editor subject tree (default: the ~19 roots),
732
+ or every subject matching `contains` with its path. Each line carries
733
+ the slug to pass on and the document count."""
734
+ from . import subjects as subj_mod
735
+ if not subj_mod.subjects_ready(conn):
736
+ raise RefError("the subject tree is not in the library yet",
737
+ action="rfclib subjects sync")
738
+ kids = {r["parent"]: r["n"] for r in conn.execute(
739
+ "SELECT parent, COUNT(*) n FROM subjects GROUP BY parent")}
740
+
741
+ def line(r, ind=""):
742
+ k = kids.get(r["slug"], 0)
743
+ return (f"{ind}{r['slug']} — {r['name']} ({r['doc_count'] or 0} docs"
744
+ + (f", {k} subtopics" if k else "") + ")")
745
+
746
+ if contains:
747
+ rows = subj_mod.list_subjects(conn, contains=contains, limit=limit)
748
+ if not rows:
749
+ raise RefError(f"no subject matches {contains!r}",
750
+ "browse() lists the top level")
751
+ par = {r["slug"]: r["parent"] for r in conn.execute(
752
+ "SELECT slug, parent FROM subjects")}
753
+ out = [f"{len(rows)} subject(s) matching {contains!r}:"]
754
+ for r in rows:
755
+ path, p = [], r["parent"]
756
+ while p and len(path) < 6:
757
+ path.append(p)
758
+ p = par.get(p)
759
+ out.append(line(r) + (f" [in {' > '.join(reversed(path))}]"
760
+ if path else ""))
761
+ return "\n".join(out)
762
+ if parent:
763
+ row = conn.execute("SELECT slug FROM subjects WHERE slug=? OR "
764
+ "lower(name)=lower(?)", (parent, parent)).fetchone()
765
+ if row is None:
766
+ raise RefError(f"unknown subject {parent!r}",
767
+ f"browse(contains={parent!r})")
768
+ parent = row["slug"]
769
+ rows = conn.execute(
770
+ "SELECT * FROM subjects WHERE " + ("parent=?" if parent else
771
+ "(parent IS NULL OR parent='')")
772
+ + " ORDER BY ord, name", (parent,) if parent else ()).fetchall()
773
+ head = (f"subtopics of {parent}:" if parent else
774
+ f"RFC Editor subject tree, top level ({len(rows)} roots):")
775
+ tail = ("browse(subject='<slug>') lists documents; "
776
+ "browse(parent='<slug>') goes one level down; "
777
+ "browse(contains='http') searches names")
778
+ return "\n".join([head] + [line(r, " ") for r in rows[:limit]] + [tail])
779
+
780
+
781
+ def subject_docs(conn: sqlite3.Connection, subject: str, offset: int = 0,
782
+ limit: int = 30, include_obsolete: bool = False) -> str:
783
+ from . import subjects as subj_mod
784
+ try:
785
+ g = subj_mod.get_subject(conn, subject, include_obsolete=include_obsolete,
786
+ limit=limit, offset=offset)
787
+ except subj_mod.SubjectNotFound as e:
788
+ raise RefError(str(e), f"browse(contains={subject!r})") from None
789
+ s = g["subject"]
790
+ out = [f"{s['slug']} — {s['name']}: {g['docs_total']} document(s)"
791
+ + (" incl. subtopics" if g.get("children") else "")
792
+ + ("" if include_obsolete else ", current only")]
793
+ if s.get("description"):
794
+ out.append(s["description"])
795
+ if g.get("children"):
796
+ out.append("subtopics: " + ", ".join(
797
+ c["slug"] if isinstance(c, dict) else str(c)
798
+ for c in g["children"][:25]))
799
+ for d in g["docs"]:
800
+ out.append(f" {d['doc_id']} — {d['title']} | "
801
+ f"{d.get('current_status') or ''} | "
802
+ f"{(d.get('pub_date') or '')[:7]}")
803
+ if g.get("next_offset") is not None:
804
+ out.append(f"next: browse(subject='{s['slug']}', "
805
+ f"offset={g['next_offset']})")
806
+ return "\n".join(out)
807
+
808
+
809
+ def errata_text(conn: sqlite3.Connection, doc: str, status: str = "verified",
810
+ section: str | None = None, max_tokens: int = 2000) -> str:
811
+ """Errata for a document as compact text: verified only by default,
812
+ each with section, type, and the corrected text."""
813
+ from . import errata as errata_mod
814
+ ref = parse_ref(conn, doc, need_text=False)
815
+ section = section or ref.sec or None # 'RFC791§3.1' narrows too
816
+ res = errata_mod.ensure(conn, ref.served)
817
+ rows = res["results"]
818
+ want = (status or "verified").lower()
819
+ sel = [e for e in rows if want == "all" or e["status"].lower() == want
820
+ or (want == "open" and e["status"] in ("Reported",
821
+ "Held for Document Update"))]
822
+ if section:
823
+ sec = _s.norm_sec(section)
824
+ sel = [e for e in sel if sec and sec in (e.get("section") or "")]
825
+ counts: dict[str, int] = {}
826
+ for e in rows:
827
+ counts[e["status"]] = counts.get(e["status"], 0) + 1
828
+ summary = ", ".join(f"{v} {k}" for k, v in sorted(counts.items())) \
829
+ or "none"
830
+ out = [f"errata for {ref.served}: {summary} "
831
+ f"(source {res.get('source')}, {(res.get('fetched_at') or '')[:10]})"]
832
+ if not sel:
833
+ out.append(f"(no {want} errata" + (f" on §{section}" if section
834
+ else "") + ")")
835
+ return "\n".join(out)
836
+ budget, size = max(400, max_tokens * CHARS_PER_TOKEN), 0
837
+ for e in sel:
838
+ def clip(t, n=700):
839
+ t = (t or "").strip()
840
+ return t if len(t) <= n else t[:n] + " …"
841
+ item = (f"\n[eid {e['eid']}] {e['status']} {e.get('type') or ''} — "
842
+ f"section: {(e.get('section') or '?').strip()[:60]}\n"
843
+ f" says: {clip(e.get('says'))}\n"
844
+ f" should say: {clip(e.get('should_say'))}"
845
+ + (f"\n notes: {clip(e.get('notes'), 300)}"
846
+ if e.get("notes") else ""))
847
+ if size + len(item) > budget and size:
848
+ out.append(f"\n[{len(sel) - sel.index(e)} more; pass section= "
849
+ "or a larger max_tokens]")
850
+ break
851
+ out.append(item)
852
+ size += len(item)
853
+ out.append(f"\nhttps://www.rfc-editor.org/errata/{ref.served.lower()}")
854
+ return "\n".join(out)
855
+
856
+
857
+ # --------------------------------------------------------------------------
858
+ # search and info, as compact text
859
+
860
+ _MARK = re.compile(r">>>(.+?)<<<")
861
+
862
+
863
+ def _hit_ref(conn, h: dict) -> tuple[str, str]:
864
+ """(paragraph-precise ref, section ref) for a section hit: the block of
865
+ the section that carries most of the snippet's highlighted terms."""
866
+ doc = h["doc_id"]
867
+ sec = (f"{doc}§{h['sec_num']}" if h.get("sec_num")
868
+ else f"{doc}:{h['start_line']}-{h['end_line']}")
869
+ terms = {t.lower() for t in _MARK.findall(h.get("snippet") or "")}
870
+ best, n_best = None, 0
871
+ if terms:
872
+ for b in conn.execute(
873
+ "SELECT pn, kind, sec_num, text FROM blocks WHERE doc_id=? "
874
+ "AND kind<>'heading' AND start_line BETWEEN ? AND ? "
875
+ "ORDER BY ord", (doc, h["start_line"], h["end_line"])):
876
+ low = b["text"].lower()
877
+ n = sum(1 for t in terms if t in low)
878
+ if n > n_best:
879
+ best, n_best = b, n
880
+ return (block_ref(doc, best) if best is not None else sec), sec
881
+
882
+
883
+ def _status_tag(row: dict) -> str:
884
+ st = row.get("current_status") or ""
885
+ cur = row.get("is_current", 1)
886
+ obs = row.get("obsoleted_by") or (row.get("relations") or {}).get(
887
+ "obsoleted_by", [])
888
+ tag = st + (", current" if cur else
889
+ ", OBSOLETE" + (f" by {', '.join(obs)}" if obs else ""))
890
+ return tag
891
+
892
+
893
+ _STOP = {"a", "an", "and", "are", "as", "at", "be", "by", "does", "for",
894
+ "from", "how", "in", "is", "it", "of", "on", "or", "the", "to",
895
+ "what", "when", "which", "with", "both", "must", "should", "can"}
896
+
897
+
898
+ def _title_noise(query: str, h: dict) -> bool:
899
+ """A hit backed only by a document-title match whose title covers less
900
+ than half of the query's content words ('Content-Centric Networking'
901
+ for 'Content-Length'): hybrid fusion surfaces these Abstracts, but they
902
+ are noise unless the document is plausibly what the query is about."""
903
+ if (h.get("sources") or "") != "title":
904
+ return False
905
+ words = [w for w in re.findall(r"[a-z0-9]+(?:\.[0-9]+)*", query.lower())
906
+ if w not in _STOP]
907
+ if not words:
908
+ return False
909
+ title = set(re.findall(r"[a-z0-9]+(?:\.[0-9]+)*",
910
+ (h.get("title") or "").lower()))
911
+ return sum(1 for w in words if w in title) * 2 <= len(words)
912
+
913
+
914
+ def search_text(conn: sqlite3.Connection, query: str, *,
915
+ scope: str = "sections", mode: str = "hybrid",
916
+ limit: int = 8, include_obsolete: bool = False,
917
+ subject: str | None = None, status: str | None = None,
918
+ stream: str | None = None, detail: str = "concise") -> str:
919
+ from . import library
920
+ from .index import embed as ix_embed, hybrid as ix_hybrid
921
+ _require_library(conn)
922
+ scope, mode = (scope or "sections").lower(), (mode or "hybrid").lower()
923
+ limit = max(1, min(int(limit), 30))
924
+ detailed = (detail or "").lower() == "detailed"
925
+ if scope in ("documents", "docs", "rfcs") or subject or status or stream:
926
+ from . import subjects as subj_mod
927
+ try:
928
+ hits = q.search(conn, query, limit=limit,
929
+ include_obsolete=include_obsolete, stream=stream,
930
+ status=status, subject=subject)
931
+ except subj_mod.SubjectNotFound as e:
932
+ raise RefError(str(e), f"browse(contains={subject!r})") from None
933
+ if not hits:
934
+ return (f"no documents match {query!r}"
935
+ + (f" in subject {subject!r}" if subject else "")
936
+ + ". Try scope='sections' or fewer words.")
937
+ out = [f"{len(hits)} document(s) for {query!r}:"]
938
+ for i, h in enumerate(hits, 1):
939
+ subs = ", ".join(s["name"] for s in h.get("subjects", [])[:3])
940
+ out.append(f"{i}. {h['doc_id']} — {h['title']} | "
941
+ f"{_status_tag(h)} | {(h.get('pub_date') or '')[:7]}"
942
+ + (f" | {subs}" if subs and detailed else ""))
943
+ out.append("next: read('<doc>') for abstract + outline")
944
+ return "\n".join(out)
945
+ try:
946
+ if mode in ("exact", "bm25", "fts"):
947
+ hits = _s.search_chunks(conn, query, limit=limit,
948
+ include_obsolete=include_obsolete)
949
+ elif mode in ("semantic", "knn", "concept"):
950
+ hits = ix_embed.semantic_search(conn, query, limit=limit,
951
+ include_obsolete=include_obsolete)
952
+ else:
953
+ hits = ix_hybrid.hybrid_search(conn, query, limit=limit + 6,
954
+ include_obsolete=include_obsolete)
955
+ except Exception as e: # noqa: BLE001 - index missing or FTS syntax
956
+ raise RefError(f"search failed ({e})",
957
+ "mode='exact' takes FTS5 syntax: quoted phrases, "
958
+ "AND/OR, prefix*, NEAR(a b, 40)",
959
+ "rfclib index build (and rfclib index embed for "
960
+ "mode='semantic')") from None
961
+ hits = [h for h in hits if not _title_noise(query, h)][:limit]
962
+ if not hits:
963
+ note = library.coverage_note(conn)
964
+ return ((note + "\n") if note else "") + (
965
+ f"no sections match {query!r}. Try mode='semantic' for a "
966
+ "paraphrase, or scope='documents'.")
967
+ out = [f"{len(hits)} section hit(s) for {query!r} (mode={mode}):"]
968
+ note = library.coverage_note(conn)
969
+ if note:
970
+ out.insert(0, note)
971
+ for i, h in enumerate(hits, 1):
972
+ ref, sec = _hit_ref(conn, h)
973
+ snip = re.sub(r"\s+", " ", (h.get("snippet") or "")).strip()
974
+ title = h.get("sec_title") or ""
975
+ line = (f"{i}. {ref} \"{title}\" — {h.get('title') or ''} | "
976
+ f"{_status_tag(h)}")
977
+ if detailed:
978
+ line += (f" | L{h['start_line']}-{h['end_line']}"
979
+ + (f" | {h['sources']}" if h.get("sources") else "")
980
+ + (f" | section {sec}" if sec != ref else ""))
981
+ out.append(line)
982
+ if snip:
983
+ out.append(" " + snip[:220])
984
+ out.append("next: read('<ref>') — each page ends with the ref to "
985
+ "continue from")
986
+ return "\n".join(out)
987
+
988
+
989
+ def info_text(conn: sqlite3.Connection, doc: str) -> str:
990
+ """Bibliographic record + status graph + aliases + errata summary."""
991
+ _require_library(conn)
992
+ try:
993
+ did = q.normalize_doc_id(doc)
994
+ meta = q.get_metadata(conn, did)
995
+ except (ValueError, q.NotFound) as e:
996
+ raise RefError(f"unknown document {doc!r}: {e}") from None
997
+ rel = meta["relations"]
998
+ served = q.resolve(conn, did)
999
+ out = [f"{_cite.label(did)} — {meta.get('title') or '(series alias)'}"]
1000
+ if meta.get("alias_of"):
1001
+ out.append("is: " + "; ".join(
1002
+ f"{a['doc_id']} \"{a.get('title')}\" ({_status_tag(a)})"
1003
+ for a in meta["alias_of"]))
1004
+ fields = [
1005
+ ("status", f"{meta.get('current_status') or ''}"
1006
+ + (f" (published as {meta['status']})"
1007
+ if meta.get("status") and meta.get("status")
1008
+ != meta.get("current_status") else "")),
1009
+ ("state", "current" if meta["is_current"] else "OBSOLETE"),
1010
+ ("published", (meta.get("pub_date") or "")[:7]),
1011
+ ("authors", ", ".join(a if isinstance(a, str) else (a.get("name") or "")
1012
+ for a in meta.get("authors") or [])),
1013
+ ("stream", " / ".join(x for x in (meta.get("stream"), meta.get("wg"),
1014
+ meta.get("area")) if x)),
1015
+ ("doi", meta.get("doi") or ""),
1016
+ ]
1017
+ for k in ("obsoleted_by", "obsoletes", "updated_by", "updates", "alias"):
1018
+ if rel.get(k):
1019
+ fields.append((k.replace("_", " "), ", ".join(rel[k])))
1020
+ subs = ", ".join(s["name"] for s in meta.get("subjects", []))
1021
+ if subs:
1022
+ fields.append(("subjects", subs))
1023
+ fields.append(("errata", _cite.errata_note(conn, served)
1024
+ .removeprefix("errata: ")))
1025
+ fields.append(("url", q._url_of(meta)))
1026
+ out += [f"{k}: {v}" for k, v in fields if v]
1027
+ ab = re.sub(r"\s+", " ", meta.get("abstract") or "").strip()
1028
+ if ab:
1029
+ out.append("abstract: " + (ab[:700] + "…" if len(ab) > 700 else ab))
1030
+ if not meta["is_current"] and rel.get("obsoleted_by"):
1031
+ now = current_successors(conn, did)
1032
+ if now:
1033
+ out.append(f"→ current replacement: {', '.join(now)}"
1034
+ + (f" (via {', '.join(rel['obsoleted_by'])})"
1035
+ if set(now) != set(rel["obsoleted_by"]) else "")
1036
+ + f"; read('{now[0]}')")
1037
+ return "\n".join(out)
1038
+
1039
+
1040
+ def current_successors(conn: sqlite3.Connection, doc: str) -> list[str]:
1041
+ """Follow obsoleted_by until current documents: RFC2616 -> RFC7230..7235
1042
+ (themselves obsolete) -> RFC9110, RFC9111, RFC9112."""
1043
+ seen, frontier, out = {doc}, [doc], []
1044
+ while frontier:
1045
+ nxt = []
1046
+ for d in frontier:
1047
+ for r in conn.execute(
1048
+ "SELECT DISTINCT r.src AS s, x.is_current AS cur FROM "
1049
+ "doc_relations r JOIN docs x ON x.doc_id = r.src WHERE "
1050
+ "r.dst=? AND r.kind='obsoletes' UNION SELECT DISTINCT "
1051
+ "r.dst, x.is_current FROM doc_relations r JOIN docs x ON "
1052
+ "x.doc_id = r.dst WHERE r.src=? AND r.kind='obsoleted_by'",
1053
+ (d, d)):
1054
+ if r[0] in seen:
1055
+ continue
1056
+ seen.add(r[0])
1057
+ (out if r[1] else nxt).append(r[0])
1058
+ frontier = nxt
1059
+ return sorted(set(out), key=lambda x: (x[:3], int(x[3:]) if x[3:].isdigit() else 0))
1060
+
1061
+
1062
+ def recent_text(conn: sqlite3.Connection, limit: int = 15,
1063
+ series: str = "RFC") -> str:
1064
+ rows = q.list_recent(conn, n=limit, series=series)
1065
+ return "\n".join([f"{len(rows)} most recent {series} documents:"] + [
1066
+ f" {r['doc_id']} — {r['title']} | {r.get('current_status') or ''}"
1067
+ f" | {(r.get('pub_date') or '')[:7]}" for r in rows])