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
@@ -0,0 +1,114 @@
1
+ /**
2
+ * THE `filecontents` BLOCKS OF A SOURCE — where a `.bib` TeX wrote stands in the text that wrote it.
3
+ * Read from the parse tree, never decided: which file a block writes, and whether TeX ran it, is
4
+ * TeX's answer (`written` in `_build/sources.json`); this reader only says where each block's text
5
+ * is, so a finding about an entry TeX read can be shown at the block that holds it
6
+ * (docs/design/paper-sources.md §1).
7
+ *
8
+ * unified-latex reads a `filecontents` environment as verbatim; its option and file name are the
9
+ * first things in that node, white space between them — a line break too: `\begin{filecontents*}` with
10
+ * `{refs.bib}` on the next line writes `refs.bib` (measured with pdflatex) — and its body starts on the
11
+ * line after the name, as LaTeX's own reader starts it.
12
+ * A block is live when the tree keeps it: a comment is a node of its own, `\iffalse … \fi` is cut at
13
+ * parse (`conditionals.ts`), and nothing after `\end{document}` is read (`liveRoot`).
14
+ */
15
+ import type * as Ast from "@unified-latex/unified-latex-types";
16
+ import type { Filecontents } from "../../ports/latex.ts";
17
+ import {
18
+ argumentPieces,
19
+ inPlace,
20
+ isNode,
21
+ placeOf,
22
+ visited,
23
+ type ArgumentPiece,
24
+ type Node,
25
+ } from "./nodes.ts";
26
+ import { liveRoot, parseLatex, type ParsedTex } from "./parse.ts";
27
+
28
+ const FILECONTENTS: ReadonlySet<string> = new Set([
29
+ "filecontents",
30
+ "filecontents*",
31
+ ]);
32
+
33
+ /** Names and options as TeX reads them: off the tree, a comma between two names. */
34
+ type Name = readonly ArgumentPiece[];
35
+
36
+ /** What a name or an option is written as: its pieces joined, white space trimmed off. */
37
+ export const written = (name: Name): string =>
38
+ name
39
+ .map((p) => p.text)
40
+ .join("")
41
+ .trim();
42
+
43
+ /** What may stand before the name: nothing, or one `[options]` — anything else is a block with no name. */
44
+ const OPTION = /^(?:\[[^\]]*\])?$/;
45
+
46
+ /** A block's head: its name's group, and its options. */
47
+ interface Head {
48
+ readonly name: Node;
49
+ readonly options: readonly string[];
50
+ }
51
+
52
+ /** The head of a block whose text is `text`: the first group, with only white space and an option before it. */
53
+ function headOf(text: string): Head | null {
54
+ const nodes = parseLatex(text).root.content;
55
+ const at = nodes.findIndex((x) => x.type === "group");
56
+ const option = written(argumentPieces(nodes.slice(0, Math.max(at, 0)), null));
57
+ const name = nodes[at];
58
+ if (name === undefined || !OPTION.test(option)) return null;
59
+ return {
60
+ name,
61
+ options: option
62
+ .replace(/^\[|\]$/g, "")
63
+ .split(",")
64
+ .map((o) => o.trim()),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * One verbatim `filecontents` node as a block: its name, option, and body. unified-latex reads the
70
+ * whole environment as verbatim, so its text is parsed on its own and the name read off that tree like
71
+ * a declaration's (`headOf`). The body starts on the line after the one the name ends on; what TeX
72
+ * ignores after the name on that line is no body.
73
+ */
74
+ export function blockOf(
75
+ src: string,
76
+ n: Readonly<Ast.VerbatimEnvironment>,
77
+ jobname: string,
78
+ ): readonly Filecontents[] {
79
+ return inPlace(placeOf(n), (span) => {
80
+ // The node runs from `\begin{env}` to past `\end{env}`.
81
+ const headAt = span.start + `\\begin{${n.env}}`.length;
82
+ const end = span.end - `\\end{${n.env}}`.length;
83
+ const head = headOf(src.slice(headAt, end));
84
+ if (head === null) return [];
85
+ return inPlace(placeOf(head.name), (named) => {
86
+ const after = headAt + named.end;
87
+ // What TeX ignores after the name, up to the end of its line.
88
+ const rest = src.slice(after, end).split("\n", 1).join("");
89
+ return [
90
+ {
91
+ writes: written(argumentPieces([head.name], jobname)),
92
+ overwrite: head.options.some(
93
+ (o) => o === "overwrite" || o === "force",
94
+ ),
95
+ span,
96
+ body: { start: Math.min(after + rest.length + 1, end), end },
97
+ },
98
+ ];
99
+ });
100
+ });
101
+ }
102
+
103
+ const isBlock = (n: Node): n is Readonly<Ast.VerbatimEnvironment> =>
104
+ n.type === "verbatim" && FILECONTENTS.has(n.env);
105
+
106
+ /** Every live `filecontents` block of a source whose main file's name is `jobname`, in source order. */
107
+ export const filecontentsOf = (
108
+ t: ParsedTex,
109
+ jobname: string,
110
+ ): readonly Filecontents[] =>
111
+ visited(liveRoot(t), () => false)
112
+ .filter(isNode)
113
+ .filter(isBlock)
114
+ .flatMap((n) => blockOf(t.src, n, jobname));
@@ -4,6 +4,7 @@ import type { Include } from "../../domain/paper-source.ts";
4
4
  import type { Span } from "../../domain/tex-document.ts";
5
5
  import { isDefinition } from "./hidden.ts";
6
6
  import {
7
+ argumentPieces,
7
8
  inPlace,
8
9
  isNode,
9
10
  macroPlace,
@@ -13,7 +14,7 @@ import {
13
14
  type Macro,
14
15
  type Node,
15
16
  } from "./nodes.ts";
16
- import type { ParsedTex } from "./parse.ts";
17
+ import { liveRoot, type ParsedTex } from "./parse.ts";
17
18
 
18
19
  const INCLUDE_MACROS: ReadonlyMap<string, Include["macro"]> = new Map([
19
20
  ["input", "input"],
@@ -22,15 +23,22 @@ const INCLUDE_MACROS: ReadonlyMap<string, Include["macro"]> = new Map([
22
23
  ]);
23
24
 
24
25
  /**
25
- * The path an include names, as written: its braced argument, or — TeX's own form, `\input name` —
26
- * the name up to the next space. unified-latex gives the unbraced form an argument of the first
27
- * token only (`sections` of `sections/a`), so the source is read for it.
26
+ * The path an include names: its braced argument read off the tree as TeX reads it (a comment is no
27
+ * part of it; a macro stays as written: nothing here expands one), or — TeX's own form, `\\input name`
28
+ * — the name up to the next space. unified-latex gives the unbraced form an argument of the first
29
+ * token only (`sections` of `sections/a`), so the source is read for that one.
28
30
  */
29
31
  function argumentText(src: string, m: Macro): string | null {
30
- const start = mandatory(m)[0]?.content[0]?.position?.start.offset;
31
- if (start === undefined) return null;
32
+ const arg = mandatory(m)[0];
33
+ const start = arg?.content[0]?.position?.start.offset;
34
+ if (arg === undefined || start === undefined) return null;
35
+ // The parser gives both forms `{` as their open mark; the source says which one was written.
36
+ if (src[start - 1] === "{")
37
+ return argumentPieces(arg.content, null)
38
+ .map((p) => p.text)
39
+ .join("");
32
40
  const rest = src.slice(start);
33
- const stop = rest.search(src[start - 1] === "{" ? /\}/u : /[\s{}%]/u);
41
+ const stop = rest.search(/[\s{}%]/u);
34
42
  return stop < 0 ? rest : rest.slice(0, stop);
35
43
  }
36
44
 
@@ -44,11 +52,11 @@ function includeOf(src: string, m: Macro): readonly Include[] {
44
52
 
45
53
  /**
46
54
  * Every `\input`, `\include` and `\subfile` of the source, in source order — not in a comment (a
47
- * comment is a node of its own) and not in a macro definition's body, which is read where the macro
48
- * is used, and nothing here expands macros.
55
+ * comment is a node of its own), not in a macro definition's body, which is read where the macro
56
+ * is used, and nothing here expands macros; not after `\end{document}`, which TeX never reads.
49
57
  */
50
58
  export const includesOf = (t: ParsedTex): readonly Include[] =>
51
- visited(t.root, isDefinition)
59
+ visited(liveRoot(t), isDefinition)
52
60
  .filter(isNode)
53
61
  .flatMap((n) => (n.type === "macro" ? includeOf(t.src, n) : []));
54
62
 
@@ -697,3 +697,76 @@ describe("bodyEmphasis — each style, and edge cases", () => {
697
697
  expect(e && src.slice(e.span.start, e.span.end)).toBe("\\textbf{see it}");
698
698
  });
699
699
  });
700
+
701
+ describe("latexReader.filecontents — the blocks that write a file, where they stand", () => {
702
+ const SRC =
703
+ "\\documentclass{x}\n" +
704
+ "\\begin{filecontents*}[overwrite]{refs.bib}\n@misc{a, title={A}}\n\\end{filecontents*}\n" +
705
+ "\\begin{document}\n" +
706
+ "\\begin{filecontents}{\\jobname.bib}\n@misc{b, title={B}}\n\\end{filecontents}\n" +
707
+ "\\end{document}\n" +
708
+ "\\begin{filecontents}{late.bib}\n@misc{c}\n\\end{filecontents}\n";
709
+
710
+ it("each live block, in order: the file it writes (\\jobname expanded), [overwrite], its span and body", () => {
711
+ const blocks = latexReader.filecontents(SRC, "paper");
712
+ expect(
713
+ blocks.map((b) => [
714
+ b.writes,
715
+ b.overwrite,
716
+ SRC.slice(b.body.start, b.body.end),
717
+ ]),
718
+ ).toEqual([
719
+ ["refs.bib", true, "@misc{a, title={A}}\n"],
720
+ ["paper.bib", false, "@misc{b, title={B}}\n"],
721
+ ]);
722
+ expect(SRC.slice(blocks[0]?.span.start, blocks[0]?.span.end)).toMatch(
723
+ /^\\begin\{filecontents\*\}.*\\end\{filecontents\*\}$/s,
724
+ );
725
+ });
726
+
727
+ it("a block inside a comment, or after \\end{document}, is not one TeX runs", () => {
728
+ expect(
729
+ latexReader.filecontents(
730
+ "% \\begin{filecontents}{x.bib}\n% @misc{z}\n% \\end{filecontents}\n\\begin{document}\n\\end{document}\n",
731
+ "paper",
732
+ ),
733
+ ).toEqual([]);
734
+ });
735
+
736
+ it("a block that names no file writes none: a group after its body's first text is no name, and a body may hold none", () => {
737
+ expect(
738
+ [
739
+ "\\begin{filecontents}\n@misc{z}\n\\end{filecontents}\n",
740
+ "\\begin{filecontents}\nplain text\n\\end{filecontents}\n",
741
+ ].flatMap((block) =>
742
+ latexReader.filecontents(
743
+ `${block}\\begin{document}\n\\end{document}\n`,
744
+ "paper",
745
+ ),
746
+ ),
747
+ ).toEqual([]);
748
+ });
749
+ });
750
+
751
+ describe("latexReader.filecontents — a file name on a line of its own", () => {
752
+ it("🔴 a name on the line after \\begin, the option before it or not: TeX takes it, and the body starts on the line after the name", () => {
753
+ // Measured with pdflatex: `\\begin{filecontents*}` then `{refs.bib}` on the next line writes
754
+ // refs.bib from the lines after the name (fixtures/paper-sources/v30-name-next-line).
755
+ const src =
756
+ "\\begin{filecontents*}\n{refs.bib}\n@misc{a, title={A}}\n\\end{filecontents*}\n" +
757
+ "\\begin{filecontents}[overwrite]\n {two.bib}\n@misc{b}\n\\end{filecontents}\n" +
758
+ "\\begin{document}\n\\end{document}\n";
759
+ expect(
760
+ latexReader
761
+ .filecontents(src, "paper")
762
+ .map((b) => [
763
+ b.writes,
764
+ b.overwrite,
765
+ src.slice(b.body.start, b.body.end),
766
+ ]),
767
+ ).toEqual([
768
+ ["refs.bib", false, "@misc{a, title={A}}\n"],
769
+ ["two.bib", true, "@misc{b}\n"],
770
+ ]);
771
+ });
772
+ });
@@ -33,6 +33,7 @@ import { parseLatex } from "./parse.ts";
33
33
  import { bodyEmphasis, bodyProse } from "./prose.ts";
34
34
  import { renderedRuns } from "./rendered.ts";
35
35
  import { layoutOverridesOf } from "./layout.ts";
36
+ import { filecontentsOf } from "./filecontents.ts";
36
37
 
37
38
  export {
38
39
  documentClassOf,
@@ -69,4 +70,5 @@ export const latexReader: LatexReader = {
69
70
  template: parseTemplate,
70
71
  withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
71
72
  layoutOverrides: (src) => layoutOverridesOf(parseLatex(src)),
73
+ filecontents: (src, jobname) => filecontentsOf(parseLatex(src), jobname),
72
74
  };
@@ -156,3 +156,67 @@ export const isArgument = (v: Visited): v is Argument =>
156
156
  /** A node, as opposed to a node list or an argument (which visit also reports). */
157
157
  export const isNode = (v: Visited): v is Node =>
158
158
  !isList(v) && v.type !== "argument" && v.type !== "root";
159
+
160
+ /** A piece of an argument as TeX reads it: text, or a macro this reader leaves as written (`\x`). */
161
+ export interface ArgumentPiece {
162
+ readonly text: string;
163
+ readonly macro: boolean;
164
+ }
165
+
166
+ /**
167
+ * An argument's nodes as TeX reads them, off the tree: a comment is no part of it, a group's braces
168
+ * are none, and white space is one space. `\jobname` is the main file's name when `jobname` is given;
169
+ * every other macro — `\jobnamebib` included, a control sequence of its own — is a piece of its own,
170
+ * written as it stands, and so is anything else (math, verbatim) a name cannot hold.
171
+ */
172
+ export function argumentPieces(
173
+ nodes: readonly Node[],
174
+ jobname: string | null,
175
+ ): readonly ArgumentPiece[] {
176
+ return nodes.flatMap((n) => pieceOf(n, jobname));
177
+ }
178
+
179
+ /** The nodes a name cannot hold as text: an environment, math, verbatim. */
180
+ type Nameless = Extract<
181
+ Node,
182
+ {
183
+ readonly type:
184
+ | "root"
185
+ | "environment"
186
+ | "mathenv"
187
+ | "inlinemath"
188
+ | "displaymath"
189
+ | "verb"
190
+ | "verbatim";
191
+ }
192
+ >;
193
+ const NAMELESS: ReadonlySet<string> = new Set([
194
+ "root",
195
+ "environment",
196
+ "mathenv",
197
+ "inlinemath",
198
+ "displaymath",
199
+ "verb",
200
+ "verbatim",
201
+ ]);
202
+ const isNameless = (n: Node): n is Nameless => NAMELESS.has(n.type);
203
+
204
+ /** One node of an argument as `argumentPieces` reads it. */
205
+ function pieceOf(n: Node, jobname: string | null): readonly ArgumentPiece[] {
206
+ if (isNameless(n)) return [{ text: `\\${n.type}`, macro: true }];
207
+ switch (n.type) {
208
+ case "comment":
209
+ return [];
210
+ case "string":
211
+ return [{ text: n.content, macro: false }];
212
+ case "whitespace":
213
+ case "parbreak":
214
+ return [{ text: " ", macro: false }];
215
+ case "group":
216
+ return argumentPieces(n.content, jobname);
217
+ case "macro":
218
+ return n.content === "jobname" && jobname !== null
219
+ ? [{ text: jobname, macro: false }]
220
+ : [{ text: `\\${n.content}`, macro: true }];
221
+ }
222
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `parseLatex` keeps the trees of a bounded amount of source text: an editor session must not hold a
3
+ * tree for every version of the paper it has seen.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { parseLatex } from "./index.ts";
7
+
8
+ describe("parseLatex's memo", () => {
9
+ /** A distinct source of `kib` KiB that parses quickly: one long comment. */
10
+ const source = (tag: string, kib: number) =>
11
+ `${tag}\n%${"x".repeat(kib * 1024)}`;
12
+
13
+ it("returns the same tree for the same text while it is kept", () => {
14
+ const s = source("same", 1);
15
+ expect(parseLatex(s)).toBe(parseLatex(s));
16
+ });
17
+
18
+ it("keeps up to 512 KiB of source: a paper's ten files stay, read in a cycle", () => {
19
+ const files = Array.from({ length: 11 }, (_, i) =>
20
+ source(`f${String(i)}`, 30),
21
+ );
22
+ const first = files.map((f) => parseLatex(f));
23
+ expect(files.map((f) => parseLatex(f))).toEqual(first);
24
+ expect(files.every((f, i) => parseLatex(f) === first[i])).toBe(true);
25
+ });
26
+
27
+ it("past 512 KiB the text used least recently goes first, a recently used one stays", () => {
28
+ const big = Array.from({ length: 6 }, (_, i) =>
29
+ source(`b${String(i)}`, 100),
30
+ );
31
+ const trees = big.slice(0, 5).map((f) => parseLatex(f));
32
+ // Touch the oldest: it becomes the most recent, so the second is the one evicted.
33
+ expect(parseLatex(big[0] ?? "")).toBe(trees[0]);
34
+ parseLatex(big[5] ?? "");
35
+ expect(parseLatex(big[0] ?? "")).toBe(trees[0]);
36
+ expect(parseLatex(big[1] ?? "")).not.toBe(trees[1]);
37
+ });
38
+ });
@@ -59,10 +59,13 @@ export const KEY_SIGNATURES: Readonly<
59
59
  "subfile",
60
60
  "bibliography",
61
61
  "bibliographystyle",
62
- "addbibresource",
63
62
  ],
64
63
  "m",
65
64
  ),
65
+ // biblatex's resources take options (`[location=remote]`) before the name.
66
+ ...signed(["addbibresource", "addglobalbib", "addsectionbib"], "o m"),
67
+ // bibentry's: writes `\bibdata` as `\bibliography` does, and typesets nothing.
68
+ ...signed(["nobibliography"], "m"),
66
69
  ...signed(["includegraphics"], "s o o m"),
67
70
  ...signed(["usepackage", "RequirePackage", "documentclass"], "o m o"),
68
71
  ]);
@@ -134,9 +137,67 @@ const parser = once(() =>
134
137
  }),
135
138
  );
136
139
 
140
+ /**
141
+ * The trees parsed last, by their source: a tree is a pure function of its text, and one lint of a
142
+ * paper asks for the same texts many times — every rule that reads the paper (`readPaper`)
143
+ * parses `paper.tex`, its includes and the assembled whole again. Measured 2026-10-06 on the
144
+ * accepted ACM paper of the corpus: 0.9 s per reading of the paper's sources, seven of them per lint,
145
+ * 50–76 ms with the memo.
146
+ *
147
+ * Bounded by the SOURCE TEXT it holds (UTF-16 units, about bytes for TeX), least recently used out.
148
+ * A count bound is wrong both ways: 64 trees held 64 versions of one file in an editor (186 MiB for a
149
+ * 48 KiB paper), and 8 trees thrashed on a paper with ten includes — the rules read them in a cycle,
150
+ * so every read missed and the corpus lint timed out. `MEMO_BYTES` holds the largest paper of the corpus
151
+ * (150 KB of sources, and the assembled whole) and about ten versions of a 48 KiB file.
152
+ */
153
+ const MEMO_BYTES = 512 * 1024;
154
+ const memo = new Map<string, ParsedTex>();
155
+
156
+ /** The texts to evict, least recent first, so that `adding` more fits under `MEMO_BYTES`. */
157
+ const evicted = (adding: number): readonly string[] => {
158
+ const keys = [...memo.keys()];
159
+ const total = keys.reduce((n, k) => n + k.length, adding);
160
+ const { out } = keys.reduce<{
161
+ readonly left: number;
162
+ readonly out: readonly string[];
163
+ }>(
164
+ (acc, k) =>
165
+ acc.left > MEMO_BYTES
166
+ ? { left: acc.left - k.length, out: [...acc.out, k] }
167
+ : acc,
168
+ { left: total, out: [] },
169
+ );
170
+ return out;
171
+ };
172
+
137
173
  /** A LaTeX source → its tree, without the branches `\iffalse … \fi` hides (`conditionals.ts`). */
138
- export const parseLatex = (src: string): ParsedTex => ({
139
- src,
140
- root: withoutFalseBranches(src, parser().parse(src)),
141
- [PARSED]: true,
142
- });
174
+ export function parseLatex(src: string): ParsedTex {
175
+ const hit = memo.get(src);
176
+ const parsed: ParsedTex = hit ?? {
177
+ src,
178
+ root: withoutFalseBranches(src, parser().parse(src)),
179
+ [PARSED]: true,
180
+ };
181
+ // Most recent last: a hit moves to the end, and the first keys are the ones used least recently.
182
+ // eslint-disable-next-line functional/immutable-data -- the bounded memo, see above
183
+ memo.delete(src);
184
+ // eslint-disable-next-line functional/immutable-data -- the bounded memo, see above
185
+ evicted(src.length).forEach((k) => memo.delete(k));
186
+ // eslint-disable-next-line functional/immutable-data -- the bounded memo, see above
187
+ memo.set(src, parsed);
188
+ return parsed;
189
+ }
190
+
191
+ /**
192
+ * The part of a source's tree TeX reads: everything up to and with the `document` environment.
193
+ * TeX stops at `\end{document}`, so a `\bibliography`, a `thebibliography`, a block or an `\input`
194
+ * parked after it is never read. A source with no `document` environment is read whole.
195
+ */
196
+ export function liveRoot(t: ParsedTex): Readonly<Ast.Root> {
197
+ const end = t.root.content.findIndex(
198
+ (n) => n.type === "environment" && n.env === "document",
199
+ );
200
+ return end < 0
201
+ ? t.root
202
+ : { ...t.root, content: t.root.content.slice(0, end + 1) };
203
+ }
@@ -5,6 +5,7 @@
5
5
  import { absolutePath } from "../../domain/paths.ts";
6
6
  import type { Download } from "../../ports/download.ts";
7
7
  import type { Files } from "../../ports/files.ts";
8
+ import type { ListDir } from "../../ports/talk-media.ts";
8
9
  import type { Command, ProcessExit, RunProcess } from "../../ports/process.ts";
9
10
  import type { NotAPromise, Scratch, Workspace } from "../../ports/workspace.ts";
10
11
  import { err, ok } from "../../domain/result.ts";
@@ -55,6 +56,22 @@ export function memoryFiles(
55
56
  };
56
57
  }
57
58
 
59
+ /** `ListDir` over `memoryFiles`: the names directly under a directory, none when it holds no file. */
60
+ export const memoryListDir =
61
+ (files: MemoryFiles): ListDir =>
62
+ (dir) => [
63
+ ...new Set(
64
+ [...files.map.keys()]
65
+ .filter((k) => k.startsWith(`${dir}/`))
66
+ .map((k) =>
67
+ k
68
+ .slice(dir.length + 1)
69
+ .split("/", 1)
70
+ .join(""),
71
+ ),
72
+ ),
73
+ ];
74
+
58
75
  export interface MemoryWorkspace extends Workspace {
59
76
  /** Every file written into a scratch directory, in order. */
60
77
  readonly written: {