paperlint 8.0.0 → 9.0.0

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 (356) hide show
  1. package/CLAUDE.md +9 -25
  2. package/CONTRIBUTING.md +5 -4
  3. package/README.md +4 -3
  4. package/dist/adapters/bibtex/index.d.ts +3 -0
  5. package/dist/adapters/bibtex/index.d.ts.map +1 -0
  6. package/dist/adapters/bibtex/index.js +103 -0
  7. package/dist/adapters/bibtex/index.js.map +1 -0
  8. package/dist/adapters/latex/filecontents.d.ts +33 -0
  9. package/dist/adapters/latex/filecontents.d.ts.map +1 -0
  10. package/dist/adapters/latex/filecontents.js +65 -0
  11. package/dist/adapters/latex/filecontents.js.map +1 -0
  12. package/dist/adapters/latex/includes.d.ts +3 -3
  13. package/dist/adapters/latex/includes.d.ts.map +1 -1
  14. package/dist/adapters/latex/includes.js +18 -10
  15. package/dist/adapters/latex/includes.js.map +1 -1
  16. package/dist/adapters/latex/index.d.ts.map +1 -1
  17. package/dist/adapters/latex/index.js +2 -0
  18. package/dist/adapters/latex/index.js.map +1 -1
  19. package/dist/adapters/latex/nodes.d.ts +12 -0
  20. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  21. package/dist/adapters/latex/nodes.js +39 -0
  22. package/dist/adapters/latex/nodes.js.map +1 -1
  23. package/dist/adapters/latex/parse.d.ts +7 -1
  24. package/dist/adapters/latex/parse.d.ts.map +1 -1
  25. package/dist/adapters/latex/parse.js +55 -6
  26. package/dist/adapters/latex/parse.js.map +1 -1
  27. package/dist/adapters/memory/index.d.ts +3 -0
  28. package/dist/adapters/memory/index.d.ts.map +1 -1
  29. package/dist/adapters/memory/index.js +9 -0
  30. package/dist/adapters/memory/index.js.map +1 -1
  31. package/dist/adapters/references/index.d.ts.map +1 -1
  32. package/dist/adapters/references/index.js +27 -24
  33. package/dist/adapters/references/index.js.map +1 -1
  34. package/dist/adapters/sources-record/index.d.ts +9 -0
  35. package/dist/adapters/sources-record/index.d.ts.map +1 -0
  36. package/dist/adapters/sources-record/index.js +68 -0
  37. package/dist/adapters/sources-record/index.js.map +1 -0
  38. package/dist/adapters/tex-output/blg.d.ts +9 -0
  39. package/dist/adapters/tex-output/blg.d.ts.map +1 -0
  40. package/dist/adapters/tex-output/blg.js +20 -0
  41. package/dist/adapters/tex-output/blg.js.map +1 -0
  42. package/dist/adapters/tex-output/fls.d.ts +8 -0
  43. package/dist/adapters/tex-output/fls.d.ts.map +1 -0
  44. package/dist/adapters/tex-output/fls.js +16 -0
  45. package/dist/adapters/tex-output/fls.js.map +1 -0
  46. package/dist/adapters/tex-output/index.d.ts +7 -0
  47. package/dist/adapters/tex-output/index.d.ts.map +1 -0
  48. package/dist/adapters/tex-output/index.js +7 -0
  49. package/dist/adapters/tex-output/index.js.map +1 -0
  50. package/dist/bib-rules.d.ts +47 -0
  51. package/dist/bib-rules.d.ts.map +1 -0
  52. package/dist/bib-rules.js +127 -0
  53. package/dist/bib-rules.js.map +1 -0
  54. package/dist/build.d.ts +29 -1
  55. package/dist/build.d.ts.map +1 -1
  56. package/dist/build.js +40 -24
  57. package/dist/build.js.map +1 -1
  58. package/dist/claim-provenance.d.ts +2 -2
  59. package/dist/cli.d.ts +6 -1
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +30 -26
  62. package/dist/cli.js.map +1 -1
  63. package/dist/domain/paper-source.d.ts +1 -10
  64. package/dist/domain/paper-source.d.ts.map +1 -1
  65. package/dist/domain/paper-source.js +34 -24
  66. package/dist/domain/paper-source.js.map +1 -1
  67. package/dist/domain/paper-sources.d.ts +72 -0
  68. package/dist/domain/paper-sources.d.ts.map +1 -0
  69. package/dist/domain/paper-sources.js +18 -0
  70. package/dist/domain/paper-sources.js.map +1 -0
  71. package/dist/domain/sources-record.d.ts +102 -0
  72. package/dist/domain/sources-record.d.ts.map +1 -0
  73. package/dist/domain/sources-record.js +38 -0
  74. package/dist/domain/sources-record.js.map +1 -0
  75. package/dist/domain/tex-run.d.ts +25 -0
  76. package/dist/domain/tex-run.d.ts.map +1 -0
  77. package/dist/domain/tex-run.js +79 -0
  78. package/dist/domain/tex-run.js.map +1 -0
  79. package/dist/eslint-rules/paper-context.d.ts.map +1 -1
  80. package/dist/eslint-rules/paper-context.js +4 -3
  81. package/dist/eslint-rules/paper-context.js.map +1 -1
  82. package/dist/eslint-rules/paper-typography.d.ts +0 -9
  83. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  84. package/dist/eslint-rules/paper-typography.js +12 -5
  85. package/dist/eslint-rules/paper-typography.js.map +1 -1
  86. package/dist/latex-log.d.ts +10 -0
  87. package/dist/latex-log.d.ts.map +1 -1
  88. package/dist/latex-log.js +10 -0
  89. package/dist/latex-log.js.map +1 -1
  90. package/dist/latex-loop.d.ts +2 -0
  91. package/dist/latex-loop.d.ts.map +1 -1
  92. package/dist/latex-loop.js.map +1 -1
  93. package/dist/package-dirs.d.ts.map +1 -1
  94. package/dist/package-dirs.js +3 -2
  95. package/dist/package-dirs.js.map +1 -1
  96. package/dist/paper-includes.d.ts +4 -7
  97. package/dist/paper-includes.d.ts.map +1 -1
  98. package/dist/paper-includes.js +29 -21
  99. package/dist/paper-includes.js.map +1 -1
  100. package/dist/paper-prose.d.ts +2 -2
  101. package/dist/paper-record.d.ts +25 -0
  102. package/dist/paper-record.d.ts.map +1 -0
  103. package/dist/paper-record.js +39 -0
  104. package/dist/paper-record.js.map +1 -0
  105. package/dist/paper-sources.d.ts +66 -0
  106. package/dist/paper-sources.d.ts.map +1 -0
  107. package/dist/paper-sources.js +92 -0
  108. package/dist/paper-sources.js.map +1 -0
  109. package/dist/ports/bib-reader.d.ts +18 -0
  110. package/dist/ports/bib-reader.d.ts.map +1 -0
  111. package/dist/ports/bib-reader.js +2 -0
  112. package/dist/ports/bib-reader.js.map +1 -0
  113. package/dist/ports/check-references.d.ts +9 -4
  114. package/dist/ports/check-references.d.ts.map +1 -1
  115. package/dist/ports/latex.d.ts +15 -0
  116. package/dist/ports/latex.d.ts.map +1 -1
  117. package/dist/ports/sources-codec.d.ts +11 -0
  118. package/dist/ports/sources-codec.d.ts.map +1 -0
  119. package/dist/ports/sources-codec.js +2 -0
  120. package/dist/ports/sources-codec.js.map +1 -0
  121. package/dist/ports/tex-output.d.ts +17 -0
  122. package/dist/ports/tex-output.d.ts.map +1 -0
  123. package/dist/ports/tex-output.js +2 -0
  124. package/dist/ports/tex-output.js.map +1 -0
  125. package/dist/recorded-bibliography.d.ts +89 -0
  126. package/dist/recorded-bibliography.d.ts.map +1 -0
  127. package/dist/recorded-bibliography.js +136 -0
  128. package/dist/recorded-bibliography.js.map +1 -0
  129. package/dist/reference-rules.d.ts +5 -5
  130. package/dist/reference-rules.d.ts.map +1 -1
  131. package/dist/reference-rules.js +150 -77
  132. package/dist/reference-rules.js.map +1 -1
  133. package/dist/references.d.ts +71 -16
  134. package/dist/references.d.ts.map +1 -1
  135. package/dist/references.js +110 -26
  136. package/dist/references.js.map +1 -1
  137. package/dist/register-bands.d.ts +3 -3
  138. package/dist/register-bands.d.ts.map +1 -1
  139. package/dist/register-bands.js.map +1 -1
  140. package/dist/register.d.ts +2 -2
  141. package/dist/sources-record.d.ts +38 -0
  142. package/dist/sources-record.d.ts.map +1 -0
  143. package/dist/sources-record.js +123 -0
  144. package/dist/sources-record.js.map +1 -0
  145. package/dist/sources-rules.d.ts +44 -0
  146. package/dist/sources-rules.d.ts.map +1 -0
  147. package/dist/sources-rules.js +76 -0
  148. package/dist/sources-rules.js.map +1 -0
  149. package/dist/tex-paper.d.ts +10 -15
  150. package/dist/tex-paper.d.ts.map +1 -1
  151. package/dist/tex-paper.js +24 -40
  152. package/dist/tex-paper.js.map +1 -1
  153. package/dist/tex-venue-rules.d.ts +3 -3
  154. package/dist/tex-venue-rules.d.ts.map +1 -1
  155. package/dist/tex-venue-rules.js.map +1 -1
  156. package/docs/configuration.md +12 -4
  157. package/docs/design/paper-sources.md +197 -0
  158. package/docs/package-shape-options.md +1 -1
  159. package/docs/references.md +34 -8
  160. package/docs/rules/bib/reachable-entry.md +64 -0
  161. package/docs/rules/paper/sources-fresh.md +81 -0
  162. package/docs/rules.md +62 -57
  163. package/eslint-rules/paper-context.ts +4 -3
  164. package/eslint-rules/paper-typography.test.mjs +12 -72
  165. package/eslint-rules/paper-typography.ts +13 -6
  166. package/eslint.config.mjs +22 -0
  167. package/fixtures/build-e2e/aidc/paper.tex +1 -1
  168. package/fixtures/build-e2e/anon-ack/paper.tex +1 -1
  169. package/fixtures/build-e2e/anon-author/paper.tex +1 -1
  170. package/fixtures/build-e2e/anon-bib/paper.tex +1 -1
  171. package/fixtures/build-e2e/anon-href/paper.tex +1 -1
  172. package/fixtures/build-e2e/anon-macro/paper.tex +1 -1
  173. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +1 -1
  174. package/fixtures/paper-sources/p1/paper.tex +30 -0
  175. package/fixtures/paper-sources/p1/refs.bib +6 -0
  176. package/fixtures/paper-sources/p1/sections/dead.tex +6 -0
  177. package/fixtures/paper-sources/p1/sections/intro.tex +7 -0
  178. package/fixtures/paper-sources/p1/sections/parked.tex +6 -0
  179. package/fixtures/paper-sources/p1/tex-truth.json +37 -0
  180. package/fixtures/paper-sources/p1/versions/2026-01-01-review.tex +4 -0
  181. package/fixtures/paper-sources/v1-stale/paper.tex +14 -0
  182. package/fixtures/paper-sources/v1-stale/refs.bib +6 -0
  183. package/fixtures/paper-sources/v1-stale/tex-truth.json +32 -0
  184. package/fixtures/paper-sources/v10-ifthenelse/anon.bib +1 -0
  185. package/fixtures/paper-sources/v10-ifthenelse/paper.tex +9 -0
  186. package/fixtures/paper-sources/v10-ifthenelse/real.bib +1 -0
  187. package/fixtures/paper-sources/v10-ifthenelse/tex-truth.json +28 -0
  188. package/fixtures/paper-sources/v11-two-blocks/paper.tex +12 -0
  189. package/fixtures/paper-sources/v11-two-blocks/tex-truth.json +30 -0
  190. package/fixtures/paper-sources/v13-percent-text/paper.tex +6 -0
  191. package/fixtures/paper-sources/v13-percent-text/refs.bib +3 -0
  192. package/fixtures/paper-sources/v13-percent-text/tex-truth.json +30 -0
  193. package/fixtures/paper-sources/v15-after-end/paper.tex +6 -0
  194. package/fixtures/paper-sources/v15-after-end/refs.bib +1 -0
  195. package/fixtures/paper-sources/v15-after-end/tex-truth.json +17 -0
  196. package/fixtures/paper-sources/v16-included-block/bibblock.tex +3 -0
  197. package/fixtures/paper-sources/v16-included-block/paper.tex +7 -0
  198. package/fixtures/paper-sources/v16-included-block/refs.bib +1 -0
  199. package/fixtures/paper-sources/v16-included-block/tex-truth.json +35 -0
  200. package/fixtures/paper-sources/v17-include-order/bibsetup.tex +1 -0
  201. package/fixtures/paper-sources/v17-include-order/first.bib +1 -0
  202. package/fixtures/paper-sources/v17-include-order/later.bib +1 -0
  203. package/fixtures/paper-sources/v17-include-order/paper.tex +7 -0
  204. package/fixtures/paper-sources/v17-include-order/tex-truth.json +40 -0
  205. package/fixtures/paper-sources/v2-overwrite/paper.tex +14 -0
  206. package/fixtures/paper-sources/v2-overwrite/refs.bib +6 -0
  207. package/fixtures/paper-sources/v2-overwrite/tex-truth.json +30 -0
  208. package/fixtures/paper-sources/v22-parked-thebibliography/paper.tex +7 -0
  209. package/fixtures/paper-sources/v22-parked-thebibliography/tex-truth.json +17 -0
  210. package/fixtures/paper-sources/v23-parked-include/old.bib +1 -0
  211. package/fixtures/paper-sources/v23-parked-include/paper.tex +7 -0
  212. package/fixtures/paper-sources/v23-parked-include/parked.tex +1 -0
  213. package/fixtures/paper-sources/v23-parked-include/refs.bib +1 -0
  214. package/fixtures/paper-sources/v23-parked-include/tex-truth.json +28 -0
  215. package/fixtures/paper-sources/v24-quoted-fields/paper.tex +6 -0
  216. package/fixtures/paper-sources/v24-quoted-fields/refs.bib +5 -0
  217. package/fixtures/paper-sources/v24-quoted-fields/tex-truth.json +32 -0
  218. package/fixtures/paper-sources/v25-string-differs/paper.tex +10 -0
  219. package/fixtures/paper-sources/v25-string-differs/refs.bib +2 -0
  220. package/fixtures/paper-sources/v25-string-differs/tex-truth.json +32 -0
  221. package/fixtures/paper-sources/v26-comment-in-names/one.bib +1 -0
  222. package/fixtures/paper-sources/v26-comment-in-names/paper.tex +7 -0
  223. package/fixtures/paper-sources/v26-comment-in-names/tex-truth.json +32 -0
  224. package/fixtures/paper-sources/v26-comment-in-names/two.bib +1 -0
  225. package/fixtures/paper-sources/v27-shared-string/abbreviations.bib +2 -0
  226. package/fixtures/paper-sources/v27-shared-string/paper.tex +6 -0
  227. package/fixtures/paper-sources/v27-shared-string/references.bib +6 -0
  228. package/fixtures/paper-sources/v27-shared-string/tex-truth.json +31 -0
  229. package/fixtures/paper-sources/v28-shared-string-block/abbreviations.bib +2 -0
  230. package/fixtures/paper-sources/v28-shared-string-block/paper.tex +14 -0
  231. package/fixtures/paper-sources/v28-shared-string-block/tex-truth.json +33 -0
  232. package/fixtures/paper-sources/v29-generated-input/paper.tex +9 -0
  233. package/fixtures/paper-sources/v29-generated-input/tex-truth.json +24 -0
  234. package/fixtures/paper-sources/v3-declared/paper.bib +6 -0
  235. package/fixtures/paper-sources/v3-declared/paper.tex +6 -0
  236. package/fixtures/paper-sources/v3-declared/tex-truth.json +28 -0
  237. package/fixtures/paper-sources/v30-name-next-line/paper.tex +14 -0
  238. package/fixtures/paper-sources/v30-name-next-line/tex-truth.json +30 -0
  239. package/fixtures/paper-sources/v4-commented/paper.tex +14 -0
  240. package/fixtures/paper-sources/v4-commented/refs.bib +6 -0
  241. package/fixtures/paper-sources/v4-commented/tex-truth.json +28 -0
  242. package/fixtures/paper-sources/v5-percent-entry/paper.tex +20 -0
  243. package/fixtures/paper-sources/v5-percent-entry/tex-truth.json +37 -0
  244. package/fixtures/paper-sources/v6-unclosed/paper.tex +6 -0
  245. package/fixtures/paper-sources/v6-unclosed/refs.bib +5 -0
  246. package/fixtures/paper-sources/v6-unclosed/tex-truth.json +37 -0
  247. package/fixtures/paper-sources/v8-jobname/paper.tex +9 -0
  248. package/fixtures/paper-sources/v8-jobname/tex-truth.json +30 -0
  249. package/fixtures/paper-sources/v9-two-bibliographies/one.bib +1 -0
  250. package/fixtures/paper-sources/v9-two-bibliographies/paper.tex +7 -0
  251. package/fixtures/paper-sources/v9-two-bibliographies/tex-truth.json +35 -0
  252. package/fixtures/paper-sources/v9-two-bibliographies/two.bib +1 -0
  253. package/fixtures/paper-typography/clean-paper/paper.tex +1 -1
  254. package/fixtures/paper-typography/messy-paper/paper.tex +1 -1
  255. package/package.json +2 -10
  256. package/scripts/check.test.ts +23 -1
  257. package/scripts/check.ts +7 -3
  258. package/scripts/coverage-config.test.ts +19 -10
  259. package/scripts/layer-legacy-frozen.test.ts +41 -2
  260. package/scripts/layer-legacy-frozen.ts +31 -2
  261. package/skills/grade-paper-writing/SKILL.md +4 -3
  262. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -2
  263. package/skills/grade-paper-writing/prose-lint.mjs +11 -2
  264. package/skills/grade-paper-writing/prose-lint.test.ts +52 -4
  265. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +83 -33
  266. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +67 -115
  267. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +128 -121
  268. package/skills/verify-citations/SKILL.md +7 -4
  269. package/skills/verify-citations/SKILL.md.spec.ts +6 -3
  270. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +17 -19
  271. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +129 -46
  272. package/skills/verify-citations/scripts/bib-authors.d.mts +22 -2
  273. package/skills/verify-citations/scripts/bib-authors.mjs +57 -111
  274. package/skills/verify-citations/scripts/verify-cites.cli.test.ts +16 -0
  275. package/skills/verify-citations/scripts/verify-cites.d.mts +19 -3
  276. package/skills/verify-citations/scripts/verify-cites.mjs +42 -108
  277. package/skills/verify-citations/scripts/verify-cites.test.mjs +23 -36
  278. package/src/adapters/bibtex/index.test.ts +163 -0
  279. package/src/adapters/bibtex/index.ts +155 -0
  280. package/src/adapters/latex/filecontents.ts +114 -0
  281. package/src/adapters/latex/includes.ts +18 -10
  282. package/src/adapters/latex/index.test.ts +73 -0
  283. package/src/adapters/latex/index.ts +2 -0
  284. package/src/adapters/latex/nodes.ts +64 -0
  285. package/src/adapters/latex/parse.test.ts +38 -0
  286. package/src/adapters/latex/parse.ts +67 -6
  287. package/src/adapters/memory/index.ts +17 -0
  288. package/src/adapters/references/index.test.ts +75 -21
  289. package/src/adapters/references/index.ts +37 -32
  290. package/src/adapters/sources-record/index.test.ts +87 -0
  291. package/src/adapters/sources-record/index.ts +82 -0
  292. package/src/adapters/tex-output/blg.ts +27 -0
  293. package/src/adapters/tex-output/fls.ts +24 -0
  294. package/src/adapters/tex-output/index.test.ts +171 -0
  295. package/src/adapters/tex-output/index.ts +12 -0
  296. package/src/bib-rules.test.ts +356 -0
  297. package/src/bib-rules.ts +228 -0
  298. package/src/bibliography-at.test.ts +286 -0
  299. package/src/build.test.ts +210 -0
  300. package/src/build.ts +100 -35
  301. package/src/claim-provenance.test.ts +33 -7
  302. package/src/claim-provenance.ts +2 -2
  303. package/src/cli.harness.mjs +20 -10
  304. package/src/cli.test.ts +14 -10
  305. package/src/cli.ts +37 -28
  306. package/src/domain/paper-source.test.ts +58 -20
  307. package/src/domain/paper-source.ts +38 -42
  308. package/src/domain/paper-sources.ts +94 -0
  309. package/src/domain/sources-record.test.ts +71 -0
  310. package/src/domain/sources-record.ts +136 -0
  311. package/src/domain/tex-run.test.ts +228 -0
  312. package/src/domain/tex-run.ts +139 -0
  313. package/src/heading-case.test.ts +24 -10
  314. package/src/latex-log.ts +20 -0
  315. package/src/latex-loop.harness.ts +1 -0
  316. package/src/latex-loop.ts +2 -0
  317. package/src/linted-set.test.ts +9 -1
  318. package/src/new-paper.harness.mjs +3 -2
  319. package/src/new-paper.test.ts +9 -5
  320. package/src/package-dirs.ts +3 -2
  321. package/src/paper-includes.test.ts +157 -38
  322. package/src/paper-includes.ts +39 -35
  323. package/src/paper-prose.test.ts +34 -5
  324. package/src/paper-prose.ts +2 -2
  325. package/src/paper-record.test.ts +173 -0
  326. package/src/paper-record.ts +66 -0
  327. package/src/paper-settings-commands.test.ts +5 -2
  328. package/src/paper-sources.ts +151 -0
  329. package/src/ports/bib-reader.ts +27 -0
  330. package/src/ports/check-references.ts +7 -3
  331. package/src/ports/latex.ts +19 -0
  332. package/src/ports/sources-codec.ts +11 -0
  333. package/src/ports/tex-output.ts +17 -0
  334. package/src/recorded-bibliography.ts +297 -0
  335. package/src/reference-rules.ts +217 -104
  336. package/src/references.test.ts +520 -177
  337. package/src/references.ts +185 -37
  338. package/src/register-bands.test.ts +5 -1
  339. package/src/register-bands.ts +3 -5
  340. package/src/register.test.ts +6 -1
  341. package/src/register.ts +2 -2
  342. package/src/rule-cases.test.ts +68 -12
  343. package/src/rule-docs.test.ts +0 -1
  344. package/src/sources-record.test.ts +308 -0
  345. package/src/sources-record.ts +231 -0
  346. package/src/sources-rules.test.ts +152 -0
  347. package/src/sources-rules.ts +117 -0
  348. package/src/tex-paper.test.ts +148 -78
  349. package/src/tex-paper.ts +31 -61
  350. package/src/tex-venue-rules.test.ts +36 -8
  351. package/src/tex-venue-rules.ts +3 -5
  352. package/dist/eslint-rules/bib-reachable-entry.d.ts +0 -22
  353. package/dist/eslint-rules/bib-reachable-entry.d.ts.map +0 -1
  354. package/dist/eslint-rules/bib-reachable-entry.js +0 -75
  355. package/dist/eslint-rules/bib-reachable-entry.js.map +0 -1
  356. package/eslint-rules/bib-reachable-entry.ts +0 -76
@@ -5,14 +5,30 @@
5
5
  // Only what `src/` imports is declared. The `.mjs` is not typechecked, so a declaration nobody
6
6
  // compiles against would drift without anyone noticing.
7
7
 
8
- /** One entry as `parseBib` returns it: the BibTeX key as `id`, plus the fields it recognised. */
8
+ /** One citation as `citationsOf` returns it: the BibTeX key as `id`, plus the fields it recognised. */
9
9
  export interface Citation {
10
10
  readonly id: string;
11
11
  readonly [field: string]: unknown;
12
12
  }
13
13
 
14
- /** Every entry of a BibTeX text, `@comment`/`@string`/`@preamble` skipped. */
15
- export declare function parseBib(text: string): Citation[];
14
+ /** A name of a name list, in its parts (`BibName`). */
15
+ export interface ReadName {
16
+ readonly lastName?: string;
17
+ readonly firstName?: string;
18
+ readonly prefix?: string;
19
+ readonly suffix?: string;
20
+ readonly name?: string;
21
+ }
22
+
23
+ /** An entry as the bibtex reader reads it (`BibEntry`, src/domain/paper-sources.ts), as far as citations use it. */
24
+ export interface ReadEntry {
25
+ readonly key: string;
26
+ readonly fields: Readonly<Record<string, string>>;
27
+ readonly names: Readonly<Record<string, readonly ReadName[]>>;
28
+ }
29
+
30
+ /** The citations of a bibliography's entries, as the bibtex reader read them. */
31
+ export declare function citationsOf(entries: readonly ReadEntry[]): Citation[];
16
32
 
17
33
  /** The title reduced to what identifies it: case, braces and punctuation removed. */
18
34
  export declare function titleIdentity(title: string): string;
@@ -47,7 +47,13 @@
47
47
 
48
48
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
49
49
  import { homedir } from "node:os";
50
- import { dirname, join, extname } from "node:path";
50
+ import { dirname, join, extname, resolve } from "node:path";
51
+ import { bibReader } from "#src/adapters/bibtex/index";
52
+ import { latexReader } from "#src/adapters/latex/index";
53
+ import { nodeFiles } from "#src/adapters/node/index";
54
+ import { sourcesCodec } from "#src/adapters/sources-record/index";
55
+ import { absolutePath } from "#src/domain/paths";
56
+ import { bibliographyAt, bibliographyUnreadWhy } from "#src/paper-sources";
51
57
  import { createHash } from "node:crypto";
52
58
  import {
53
59
  consumerContactEmail,
@@ -588,116 +594,32 @@ export function reduceVerdict(citation, evidence, commitFlags = []) {
588
594
  }
589
595
 
590
596
  // ─────────────────────────────────────────────────────────────────────────────
591
- // .bib PARSER — small, best-effort. Extracts @article/@inproceedings/... entries
592
- // and (loosely) \bibitem blocks. Not a full BibTeX grammar; enough to seed cites.
597
+ // CITATIONS — from the entries the bibtex reader read (src/ports/bib-reader.ts).
593
598
  // ─────────────────────────────────────────────────────────────────────────────
594
599
 
600
+ /** A name of a name list, written out: `First von Last, Jr.`, or the literal it was. */
601
+ const writtenName = (n) =>
602
+ n.name ??
603
+ [n.firstName, n.prefix, n.lastName, n.suffix].filter(Boolean).join(" ");
604
+
595
605
  /**
596
- * field(body, name) — extract a BibTeX field value from an entry body.
597
- * (a) anchors `name` so `title` does NOT also match `booktitle` (S4a);
598
- * (b) reads BRACE-delimited values with balanced-brace scanning, so a protected
599
- * acronym `{Evaluating {LLM}-based ...}` is captured whole, not truncated at
600
- * the inner `}` (S4b);
601
- * also handles "quoted" and bare (numeric) values.
606
+ * The citations of a bibliography's entries, as the bibtex reader read them: the key as `id`, and
607
+ * the doi, title, year, authors and arXiv eprint each entry has.
602
608
  */
603
- function bibField(body, name) {
604
- const re = new RegExp(`(?<![a-zA-Z])${name}\\s*=\\s*`, "i");
605
- const fm = re.exec(body);
606
- if (!fm) return undefined;
607
- let i = fm.index + fm[0].length;
608
- const ch = body[i];
609
- if (ch === "{") {
610
- let depth = 0;
611
- let j = i;
612
- for (; j < body.length; j++) {
613
- if (body[j] === "{") depth++;
614
- else if (body[j] === "}") {
615
- depth--;
616
- if (depth === 0) break;
617
- }
618
- }
619
- return body
620
- .slice(i + 1, j)
621
- .replace(/\s+/g, " ")
622
- .trim();
623
- }
624
- if (ch === '"') {
625
- let j = i + 1;
626
- while (j < body.length && body[j] !== '"') j++;
627
- return body
628
- .slice(i + 1, j)
629
- .replace(/\s+/g, " ")
630
- .trim();
631
- }
632
- // bare value (e.g. year = 2017,) — up to the next comma / newline / close brace
633
- let j = i;
634
- while (j < body.length && !/[,\n}]/.test(body[j])) j++;
635
- const v = body.slice(i, j).replace(/\s+/g, " ").trim();
636
- return v || undefined;
637
- }
638
-
639
- export function parseBib(text) {
640
- const cites = [];
641
- // @type{key, — then read the entry body with BALANCED-BRACE scanning so that
642
- // one-line entries and indented / same-line closing braces (`\n }` or `...}`)
643
- // are captured, not dropped by a `\n}`-at-column-0 requirement (S4c).
644
- const headRe = /@(\w+)\s*\{\s*([^,\s}]+)\s*,/g;
645
- let m;
646
- while ((m = headRe.exec(text)) !== null) {
647
- const type = m[1].toLowerCase();
648
- if (type === "comment" || type === "string" || type === "preamble")
649
- continue;
650
- const key = m[2].trim();
651
- // Balanced scan from just after the key's comma to the entry's closing brace.
652
- let depth = 1; // the entry's opening `{` was already consumed by headRe
653
- let j = headRe.lastIndex;
654
- for (; j < text.length && depth > 0; j++) {
655
- if (text[j] === "{") depth++;
656
- else if (text[j] === "}") depth--;
657
- }
658
- const body = text.slice(headRe.lastIndex, j - 1);
659
- headRe.lastIndex = j; // resume scanning AFTER this entry
660
-
661
- const c = { id: key };
662
- const doi = bibField(body, "doi");
663
- const title = bibField(body, "title");
664
- const year = bibField(body, "year");
665
- const author = bibField(body, "author");
666
- const eprint = bibField(body, "eprint");
667
- const archive =
668
- bibField(body, "archiveprefix") || bibField(body, "eprinttype");
669
- if (doi) c.doi = normalizeDoi(doi);
670
- if (title) c.title = title.replace(/[{}]/g, "");
671
- if (year) c.year = year;
672
- if (author) c.authors = author;
673
- if (eprint && (!archive || /arxiv/i.test(archive)))
674
- c.arxiv = normalizeArxiv(eprint);
675
- cites.push(c);
676
- }
677
- if (cites.length > 0) return cites;
678
-
679
- // Fallback: \bibitem — grab arXiv ids / DOIs out of each block, best-effort.
680
- const itemRe =
681
- /\\bibitem(?:\[[^\]]*\])?\{([^}]+)\}([\s\S]*?)(?=\\bibitem|\\end\{thebibliography\}|$)/g;
682
- while ((m = itemRe.exec(text)) !== null) {
683
- const key = m[1].trim();
684
- const block = m[2];
685
- const c = { id: key };
686
- const doiM = block.match(/10\.\d{4,9}\/[-._;()/:A-Z0-9]+/i);
687
- const arxivM = block.match(/arXiv:\s*(\d{4}\.\d{4,5})/i);
688
- const yearM = block.match(/\b(19|20)\d{2}\b/);
689
- if (doiM) c.doi = normalizeDoi(doiM[0]);
690
- if (arxivM) c.arxiv = normalizeArxiv(arxivM[1]);
691
- if (yearM) c.year = yearM[0];
692
- // crude title: longest quoted or {\em ...} run
693
- const titleM =
694
- block.match(/``([^']{6,}?)''/) ||
695
- block.match(/\\emph\{([^}]{6,}?)\}/) ||
696
- block.match(/\{\\em\s+([^}]{6,}?)\}/);
697
- if (titleM) c.title = titleM[1].replace(/\s+/g, " ").trim();
698
- cites.push(c);
699
- }
700
- return cites;
609
+ export function citationsOf(entries) {
610
+ return entries.map((e) => {
611
+ const f = e.fields;
612
+ const c = { id: e.key };
613
+ if (f.doi) c.doi = normalizeDoi(f.doi);
614
+ if (f.title) c.title = f.title.replace(/\s+/g, " ").trim();
615
+ if (f.year) c.year = f.year;
616
+ const authors = (e.names.author ?? []).map(writtenName).join(" and ");
617
+ if (authors) c.authors = authors;
618
+ const archive = f.archiveprefix || f.eprinttype;
619
+ if (f.eprint && (!archive || /arxiv/i.test(archive)))
620
+ c.arxiv = normalizeArxiv(f.eprint);
621
+ return c;
622
+ });
701
623
  }
702
624
 
703
625
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1212,11 +1134,23 @@ export async function verifyCitationLive(
1212
1134
  // CLI
1213
1135
  // ─────────────────────────────────────────────────────────────────────────────
1214
1136
 
1137
+ /** What `bibliographyAt` needs to read a `.bib` (src/paper-sources.ts, the one answer for a path). */
1138
+ const DEPS = {
1139
+ files: nodeFiles,
1140
+ codec: sourcesCodec,
1141
+ latex: latexReader,
1142
+ bib: bibReader,
1143
+ };
1144
+
1215
1145
  function loadInput(argv) {
1216
1146
  const path = argv.find((a) => !a.startsWith("-"));
1147
+ if (path && extname(path).toLowerCase() === ".bib") {
1148
+ const r = bibliographyAt(absolutePath(resolve(path)), DEPS);
1149
+ if (!r.ok) throw new Error(bibliographyUnreadWhy(r.error));
1150
+ return citationsOf(r.value.texts.flatMap((t) => t.entries));
1151
+ }
1217
1152
  const raw =
1218
1153
  path && path !== "-" ? readFileSync(path, "utf8") : readFileSync(0, "utf8"); // stdin
1219
- if (path && extname(path).toLowerCase() === ".bib") return parseBib(raw);
1220
1154
  const parsed = JSON.parse(raw);
1221
1155
  return Array.isArray(parsed) ? parsed : [parsed];
1222
1156
  }
@@ -24,7 +24,7 @@ import {
24
24
  checkNvd,
25
25
  checkCommit,
26
26
  reduceVerdict,
27
- parseBib,
27
+ citationsOf,
28
28
  parseArxivFeed,
29
29
  normalizeDoi,
30
30
  normalizeArxiv,
@@ -33,6 +33,12 @@ import {
33
33
  titleIdentity,
34
34
  isValidCveId,
35
35
  } from "./verify-cites.mjs";
36
+ import { bibReader } from "#src/adapters/bibtex/index";
37
+ import { absolutePath } from "#src/domain/paths";
38
+
39
+ /** The citations of a BibTeX text, read by the one bibtex reader. */
40
+ const citationsIn = (bib) =>
41
+ citationsOf(bibReader.readFile(absolutePath("/p/refs.bib"), bib).entries);
36
42
 
37
43
  /** One vitest case per check, named by it. */
38
44
  function ok(cond, name) {
@@ -377,7 +383,7 @@ console.log("\n(extra) .bib parser best-effort extraction:");
377
383
  eprint={1512.03385}
378
384
  }
379
385
  `;
380
- const cites = parseBib(bib);
386
+ const cites = citationsIn(bib);
381
387
  eq(cites.length, 2, "parsed 2 entries");
382
388
  eq(cites[0].doi, "10.5555/3295222.3295349", "entry 1 doi");
383
389
  eq(cites[1].arxiv, "1512.03385", "entry 2 arxiv eprint");
@@ -724,7 +730,7 @@ console.log("\n[S4] .bib parser edge cases:");
724
730
  title = {The Real Paper Title},
725
731
  year = {2021}
726
732
  }`;
727
- const a = parseBib(bibA)[0];
733
+ const a = citationsIn(bibA)[0];
728
734
  eq(a.title, "The Real Paper Title", "title anchored — not booktitle");
729
735
 
730
736
  // (b) brace-protected acronym must not truncate at the inner brace.
@@ -733,14 +739,14 @@ console.log("\n[S4] .bib parser edge cases:");
733
739
  year = {2026}
734
740
  }`;
735
741
  eq(
736
- parseBib(bibB)[0].title,
742
+ citationsIn(bibB)[0].title,
737
743
  "Evaluating LLM-based Systems for Code Review",
738
744
  "brace-protected acronym kept whole",
739
745
  );
740
746
 
741
747
  // (c1) one-line entry (no \n} at column 0).
742
748
  const bibC1 = `@article{k3, title = {A One Line Entry}, year = {2020}, doi = {10.1/abc}}`;
743
- const c1 = parseBib(bibC1)[0];
749
+ const c1 = citationsIn(bibC1)[0];
744
750
  eq(c1.title, "A One Line Entry", "one-line entry title parsed");
745
751
  eq(c1.doi, "10.1/abc", "one-line entry doi parsed");
746
752
 
@@ -750,14 +756,14 @@ console.log("\n[S4] .bib parser edge cases:");
750
756
  year = {2019}
751
757
  }`;
752
758
  eq(
753
- parseBib(bibC2)[0].title,
759
+ citationsIn(bibC2)[0].title,
754
760
  "Indented Closing Brace",
755
761
  "indented-closing-brace entry parsed",
756
762
  );
757
763
 
758
764
  // all four parse together, in sequence (balanced-brace scan resumes correctly).
759
765
  eq(
760
- parseBib(bibA + "\n" + bibB + "\n" + bibC1 + "\n" + bibC2).length,
766
+ citationsIn(bibA + "\n" + bibB + "\n" + bibC1 + "\n" + bibC2).length,
761
767
  4,
762
768
  "all 4 entries parsed in sequence",
763
769
  );
@@ -796,15 +802,6 @@ console.log("\n[S4] .bib parser edge cases:");
796
802
  });
797
803
  eq(n.doi, "10.1/y", "normalizeIdentifiers cleans doi");
798
804
  eq(n.arxiv, "1234.5678", "normalizeIdentifiers cleans arxiv");
799
- // the exact live-reproduced break: a \bibitem DOI with a swallowed sentence period.
800
- const bib = `\\begin{thebibliography}{9}
801
- \\bibitem{ccs23} A. Author. Title. In Proc. CCS. doi:10.1145/3576915.3623218. 2023.
802
- \\end{thebibliography}`;
803
- eq(
804
- parseBib(bib)[0].doi,
805
- "10.1145/3576915.3623218",
806
- "bibitem DOI trailing period not swallowed",
807
- );
808
805
  }
809
806
 
810
807
  // ── Edges of the pure layer: the defensive arms, each with the input that reaches it ──────────
@@ -916,32 +913,22 @@ test("reduceVerdict: an arXiv id missed only by a non-authoritative source is un
916
913
  });
917
914
  });
918
915
 
919
- test("parseBib: @comment/@string/@preamble are skipped, a bare value is read, an empty one is absent", () => {
916
+ test("citationsOf: @comment/@string/@preamble are no citations; a quoted and a bare value are read; authors written out", () => {
920
917
  const bib = [
921
918
  "@comment{c, not an entry}",
922
- "@string{s, x = {y}}",
923
- '@article{k1, title = "Quoted Title", year = 2017, eprint = {1706.03762}, archiveprefix = {arXiv}}',
924
- "@misc{k2, title = {T}, year = ,}",
925
- ].join("\n");
926
- assert.deepEqual(parseBib(bib), [
927
- { id: "k1", title: "Quoted Title", year: "2017", arxiv: "1706.03762" },
928
- { id: "k2", title: "T" },
929
- ]);
930
- });
931
-
932
- test("parseBib's \\bibitem fallback reads an arXiv id and an \\emph title", () => {
933
- const text = [
934
- "\\begin{thebibliography}{9}",
935
- "\\bibitem{a} A. Author. \\emph{A Long Enough Title}. arXiv:1706.03762, 2017.",
936
- "\\end{thebibliography}",
919
+ "@string{s = {y}}",
920
+ '@article{k1, title = "Quoted Title", year = 2017, eprint = {1706.03762}, archiveprefix = {arXiv}, author = {Vaswani, Ashish and von Neumann, John}}',
921
+ "@misc{k2, title = {T}, eprint = {x}, archiveprefix = {HAL}}",
937
922
  ].join("\n");
938
- assert.deepEqual(parseBib(text), [
923
+ assert.deepEqual(citationsIn(bib), [
939
924
  {
940
- id: "a",
941
- arxiv: "1706.03762",
925
+ id: "k1",
926
+ title: "Quoted Title",
942
927
  year: "2017",
943
- title: "A Long Enough Title",
928
+ authors: "Ashish Vaswani and John von Neumann",
929
+ arxiv: "1706.03762",
944
930
  },
931
+ { id: "k2", title: "T" },
945
932
  ]);
946
933
  });
947
934
 
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The bibtex reader over @retorquere/bibtex-parser: where each entry stands, its fields as text, its
3
+ * name lists as names, the `@string` and `@preamble` a copy is compared on, and an entry it cannot
4
+ * finish. What bibtex reads beyond it is the post-build check's (src/references.ts), measured on the
5
+ * planted papers in src/paper-sources.test.ts.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { describe, expect, it } from "vitest";
11
+ import { absolutePath } from "../../domain/paths.ts";
12
+ import { bibReader } from "./index.ts";
13
+
14
+ const PATH = absolutePath("/p/refs.bib");
15
+ const read = (text: string) => bibReader.readFile(PATH, text);
16
+
17
+ describe("the bibtex reader", () => {
18
+ it("places each entry in the text, a block's within the file that holds it", () => {
19
+ const tex =
20
+ "\\begin{filecontents*}{refs.bib}\n@misc{a, title={A}}\n@Article(b, title={B})\n\\end{filecontents*}\n";
21
+ const body = { start: tex.indexOf("@misc"), end: tex.indexOf("\\end") };
22
+ const bib = bibReader.read(absolutePath("/p/paper.tex"), tex, body);
23
+ expect(
24
+ bib.entries.map((e) => [
25
+ e.type,
26
+ e.key,
27
+ tex.slice(e.span.start, e.span.end),
28
+ ]),
29
+ ).toEqual([
30
+ ["misc", "a", "@misc{a, title={A}}"],
31
+ ["article", "b", "@Article(b, title={B})"],
32
+ ]);
33
+ });
34
+
35
+ it("reads fields as text: LaTeX accents, a link, a list field joined", () => {
36
+ const [e] = read(
37
+ '@book{k, title={M{\\"u}ller}, howpublished={\\url{https://x.org}}, publisher={A and B}}',
38
+ ).entries;
39
+ // The parser writes `{\"u}` as u and a combining diaeresis: compared composed.
40
+ expect(
41
+ Object.fromEntries(
42
+ Object.entries(e?.fields ?? {}).map(([k, v]) => [
43
+ k,
44
+ v.normalize("NFC"),
45
+ ]),
46
+ ),
47
+ ).toEqual({
48
+ title: "Müller",
49
+ howpublished: '<a href="https://x.org">https://x.org</a>',
50
+ publisher: "A and B",
51
+ });
52
+ });
53
+
54
+ it("reads a name list as names: family part, particle, suffix, a literal kept whole", () => {
55
+ const [e] = read(
56
+ "@misc{k, author={Lovelace, Ada and von Neumann, Jr., John and {World Health Organization} and others}}",
57
+ ).entries;
58
+ expect(e?.names).toEqual({
59
+ author: [
60
+ { lastName: "Lovelace", firstName: "Ada" },
61
+ {
62
+ lastName: "Neumann",
63
+ firstName: "John",
64
+ prefix: "von",
65
+ suffix: "Jr.",
66
+ },
67
+ { name: "World Health Organization" },
68
+ { lastName: "others" },
69
+ ],
70
+ });
71
+ });
72
+ });
73
+
74
+ describe("the bibtex reader — what a copy is compared on, and what it leaves to bibtex", () => {
75
+ it("reads @preamble commands, and expands each @string where an entry uses it", () => {
76
+ const bib = read(
77
+ '@string{v = "Venue"}\n@preamble{"\\x"}\n@misc{k, note = v}\n@string{v = "Later"}',
78
+ );
79
+ expect([bib.preamble, bib.entries[0]?.fields]).toEqual([
80
+ ['"\\x"'],
81
+ { note: "Venue" },
82
+ ]);
83
+ });
84
+
85
+ it("keeps each entry as bibtex takes it: the value it used, LaTeX and braces as written, whitespace folded", () => {
86
+ const bib = read(
87
+ '@string{v = "Venue"}\n@Misc{k, note = v # " 2", author = {G{\\"o}del, Kurt}, title = {{X}}, keywords = {b, a}}\n@string{v = "Later"}',
88
+ );
89
+ expect(bib.written).toEqual([
90
+ {
91
+ type: "misc",
92
+ key: "k",
93
+ fields: {
94
+ note: "Venue 2",
95
+ author: 'G{\\"o}del, Kurt',
96
+ title: "{X}",
97
+ keywords: "a, b",
98
+ },
99
+ },
100
+ ]);
101
+ });
102
+
103
+ it("an entry it cannot finish is no entry — bibtex reports that file (v6, exit 2)", () => {
104
+ const text = readFileSync(
105
+ join(
106
+ dirname(fileURLToPath(import.meta.url)),
107
+ "../../../fixtures/paper-sources/v6-unclosed/refs.bib",
108
+ ),
109
+ "utf8",
110
+ );
111
+ expect(read(text).entries.map((e) => e.key)).toEqual(["a1", "a3"]);
112
+ });
113
+ });
114
+
115
+ describe("the bibtex reader — databases in sequence, as bibtex reads them", () => {
116
+ // bibtex reads the databases of `\\bibliography{abbreviations,references}` one after the other, and an
117
+ // `@string` the first defines is in force in the second (fixtures/paper-sources/v27-shared-string:
118
+ // its .bbl typesets the expanded author and booktitle).
119
+ const ABBR =
120
+ '@string{venue = "Proceedings of the {\\"O}ld Venue"}\n@string{who = "Lovelace, Ada"}\n';
121
+ const REFS =
122
+ "@inproceedings{late, author = who, booktitle = venue # {, Main}}\n";
123
+ const SECOND = absolutePath("/p/references.bib");
124
+
125
+ it("🔴 an @string an earlier database defined is in force in a later one: read as one file holding both", () => {
126
+ const first = read(ABBR);
127
+ const second = bibReader.readFile(SECOND, REFS, first.strings);
128
+ const whole = read(ABBR + REFS);
129
+ expect([
130
+ second.entries.map((e) => [e.fields, e.names]),
131
+ second.written,
132
+ ]).toEqual([whole.entries.map((e) => [e.fields, e.names]), whole.written]);
133
+ expect(second.written[0]?.fields).toEqual({
134
+ author: "Lovelace, Ada",
135
+ booktitle: 'Proceedings of the {\\"O}ld Venue, Main',
136
+ });
137
+ });
138
+
139
+ it("the @strings in force after a database are those before it and its own, a redefinition winning; a block reads with them too", () => {
140
+ const first = read(ABBR);
141
+ const second = bibReader.readFile(
142
+ SECOND,
143
+ '@string{venue = "Newer"}\n',
144
+ first.strings,
145
+ );
146
+ expect([second.inherited, second.strings]).toEqual([
147
+ first.strings,
148
+ { VENUE: "Newer", WHO: "Lovelace, Ada" },
149
+ ]);
150
+ const tex = `\\begin{filecontents*}{refs.bib}\n${REFS}\\end{filecontents*}\n`;
151
+ const body = { start: tex.indexOf("@"), end: tex.indexOf("\\end") };
152
+ expect(
153
+ bibReader.read(absolutePath("/p/paper.tex"), tex, body, second.strings)
154
+ .written[0]?.fields.booktitle,
155
+ ).toBe("Newer, Main");
156
+ });
157
+
158
+ it("read alone, a database has nothing inherited: a name no @string defines stays unresolved", () => {
159
+ const alone = bibReader.readFile(SECOND, REFS);
160
+ expect([alone.inherited, alone.strings]).toEqual([{}, {}]);
161
+ expect(alone.written[0]?.fields.author).not.toBe("Lovelace, Ada");
162
+ });
163
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The bibtex reader over `@retorquere/bibtex-parser` — the one `.bib` reader of paperlint
3
+ * (`src/ports/bib-reader.ts`).
4
+ *
5
+ * It reads a well-formed database as bibtex does: entries, fields (LaTeX read into text, so `{\"u}`
6
+ * is `ü` and `\url{x}` a link), name lists split into names, `@string`s expanded, `@preamble`. A
7
+ * second reading keeps each field as bibtex takes it, LaTeX as written: what two databases are
8
+ * compared by (`sameDatabase`), where `{\"u}` and `ü` must stay different. A malformed
9
+ * database is bibtex's to report: the build runs bibtex and fails with bibtex's own lines. The two
10
+ * readers also differ on one well-formed shape: bibtex has no comment syntax, so an entry behind
11
+ * `%` or inside `@comment{…}` is one bibtex reads and this reader does not. The build records what
12
+ * bibtex read, and `paper/refs-checked` names such an entry (`src/references.ts`).
13
+ *
14
+ * The parser gives each entry's text (`input`), not its place: each is found in the body in order.
15
+ *
16
+ * A database read after others starts with the `@string`s they left in force (`inherited`), as bibtex
17
+ * reads a paper's databases in sequence with one table of them: the parser takes them as its table of
18
+ * predefined `@string`s, which the database's own definitions override from where they stand. Each
19
+ * database is still parsed as its own text, so every entry keeps its place in the file that holds it.
20
+ */
21
+ import {
22
+ parse,
23
+ type Creator,
24
+ type Entry,
25
+ type Library,
26
+ type Options,
27
+ } from "@retorquere/bibtex-parser";
28
+ import type {
29
+ BibEntry,
30
+ BibName,
31
+ BibStrings,
32
+ BibText,
33
+ WrittenEntry,
34
+ } from "../../domain/paper-sources.ts";
35
+ import type { AbsolutePath } from "../../domain/paths.ts";
36
+ import type { Span } from "../../domain/tex-document.ts";
37
+ import type { BibReader } from "../../ports/bib-reader.ts";
38
+
39
+ /** A title as written, not sentence-cased; no field kept as raw LaTeX. */
40
+ const OPTIONS: Readonly<Options> = { sentenceCase: false, verbatimFields: [] };
41
+
42
+ /**
43
+ * Every field as bibtex takes it: `@string`s expanded where they are used, LaTeX left as written (no
44
+ * conversion, no name split, no outer braces dropped).
45
+ */
46
+ const AS_WRITTEN: Readonly<Options> = {
47
+ raw: true,
48
+ sentenceCase: false,
49
+ verbatimFields: [/.*/],
50
+ removeOuterBraces: [],
51
+ };
52
+
53
+ /** A name's parts, those it has: the parser leaves the others out. */
54
+ const nameOf = (c: Readonly<Creator>): BibName =>
55
+ Object.fromEntries(
56
+ Object.entries({
57
+ lastName: c.lastName,
58
+ firstName: c.firstName,
59
+ prefix: c.prefix,
60
+ suffix: c.suffix,
61
+ name: c.name,
62
+ }).filter((kv): kv is [string, string] => kv[1] !== undefined),
63
+ );
64
+
65
+ const isText = (x: unknown): x is string => typeof x === "string";
66
+ const isName = (x: unknown): x is Readonly<Creator> =>
67
+ typeof x === "object" && x !== null;
68
+
69
+ /** An entry's fields and name lists, from the parser's one record of both. */
70
+ function fieldsOf(e: Readonly<Entry>): Pick<BibEntry, "fields" | "names"> {
71
+ const pairs = Object.entries(e.fields);
72
+ const isNames = (k: string): boolean => e.mode[k] === "creatorlist";
73
+ return {
74
+ // A list field (`keywords`, `publisher`) is joined as BibTeX writes it.
75
+ fields: Object.fromEntries(
76
+ pairs
77
+ .filter(([k]) => !isNames(k))
78
+ .map(([k, v]) => [k, [v].flat().filter(isText).join(" and ")]),
79
+ ),
80
+ names: Object.fromEntries(
81
+ pairs
82
+ .filter(([k]) => isNames(k))
83
+ .map(([k, v]) => [k, [v].flat().filter(isName).map(nameOf)]),
84
+ ),
85
+ };
86
+ }
87
+
88
+ /** Each entry the parser read, placed in the text: found in `source` in order, `source` at `offset`. */
89
+ function placed(
90
+ parsed: readonly Readonly<Entry>[],
91
+ source: string,
92
+ offset: number,
93
+ ): readonly BibEntry[] {
94
+ return parsed.reduce<{
95
+ readonly from: number;
96
+ readonly found: readonly BibEntry[];
97
+ }>(
98
+ (acc, e) => {
99
+ const at = source.indexOf(e.input, acc.from);
100
+ const end = at + e.input.length;
101
+ const span = { start: offset + at, end: offset + end };
102
+ const entry = { type: e.type.toLowerCase(), key: e.key, span };
103
+ return { from: end, found: [...acc.found, { ...entry, ...fieldsOf(e) }] };
104
+ },
105
+ { from: 0, found: [] },
106
+ ).found;
107
+ }
108
+
109
+ /** The entries the parser finished: one it could not keeps no text — it is malformed, and bibtex reports it. */
110
+ const finished = (lib: Readonly<Library>): readonly Readonly<Entry>[] =>
111
+ lib.entries.filter((e) => e.input !== "");
112
+
113
+ /** An entry as bibtex takes it; a list field (`keywords`) as one value. */
114
+ const writtenOf = (e: Readonly<Entry>): WrittenEntry => ({
115
+ type: e.type.toLowerCase(),
116
+ key: e.key,
117
+ fields: Object.fromEntries(
118
+ Object.entries(e.fields).map(([k, v]) => [
119
+ k,
120
+ [v].flat().filter(isText).join(", "),
121
+ ]),
122
+ ),
123
+ });
124
+
125
+ /**
126
+ * The database in `body` of `text`, the text of the file at `path`, read with the `@string`s
127
+ * `inherited` in force. The parser is handed a copy of them: it writes into its own.
128
+ */
129
+ function read(
130
+ path: AbsolutePath,
131
+ text: string,
132
+ body: Span,
133
+ inherited: BibStrings = {},
134
+ ): BibText {
135
+ const source = text.slice(body.start, body.end);
136
+ const lib = parse(source, { ...OPTIONS, strings: { ...inherited } });
137
+ const asWritten = parse(source, { ...AS_WRITTEN, strings: { ...inherited } });
138
+ return {
139
+ path,
140
+ text,
141
+ body,
142
+ entries: placed(finished(lib), source, body.start),
143
+ written: finished(asWritten).map(writtenOf),
144
+ preamble: lib.preamble,
145
+ inherited,
146
+ // Its own as bibtex takes them: the reading that keeps LaTeX as written.
147
+ strings: { ...inherited, ...asWritten.strings },
148
+ };
149
+ }
150
+
151
+ export const bibReader: BibReader = {
152
+ read,
153
+ readFile: (path, text, inherited) =>
154
+ read(path, text, { start: 0, end: text.length }, inherited),
155
+ };