paperlint 4.1.0 → 4.2.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.
- package/CLAUDE.md +24 -0
- package/CONTRIBUTING.md +7 -0
- package/dist/adapters/latex/document-class.d.ts +6 -5
- package/dist/adapters/latex/document-class.d.ts.map +1 -1
- package/dist/adapters/latex/document-class.js +18 -10
- package/dist/adapters/latex/document-class.js.map +1 -1
- package/dist/adapters/latex/includes.d.ts +12 -0
- package/dist/adapters/latex/includes.d.ts.map +1 -0
- package/dist/adapters/latex/includes.js +42 -0
- package/dist/adapters/latex/includes.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +9 -5
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +8 -1
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/nodes.d.ts +5 -1
- package/dist/adapters/latex/nodes.d.ts.map +1 -1
- package/dist/adapters/latex/nodes.js +6 -2
- package/dist/adapters/latex/nodes.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +1 -0
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/latex/prose.d.ts +8 -0
- package/dist/adapters/latex/prose.d.ts.map +1 -0
- package/dist/adapters/latex/prose.js +353 -0
- package/dist/adapters/latex/prose.js.map +1 -0
- package/dist/claim-provenance.d.ts +34 -0
- package/dist/claim-provenance.d.ts.map +1 -0
- package/dist/claim-provenance.js +146 -0
- package/dist/claim-provenance.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +20 -0
- package/dist/cli.js.map +1 -1
- package/dist/domain/paper-source.d.ts +58 -0
- package/dist/domain/paper-source.d.ts.map +1 -0
- package/dist/domain/paper-source.js +109 -0
- package/dist/domain/paper-source.js.map +1 -0
- package/dist/domain/sentences.d.ts +26 -0
- package/dist/domain/sentences.d.ts.map +1 -0
- package/dist/domain/sentences.js +48 -0
- package/dist/domain/sentences.js.map +1 -0
- package/dist/domain/tex-document.d.ts +39 -4
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/eslint-rules/paper-typography.d.ts +7 -0
- package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
- package/dist/eslint-rules/paper-typography.js +53 -1
- package/dist/eslint-rules/paper-typography.js.map +1 -1
- package/dist/ports/latex.d.ts +12 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/register.d.ts +40 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +134 -0
- package/dist/register.js.map +1 -0
- package/dist/tex-paper.d.ts +19 -0
- package/dist/tex-paper.d.ts.map +1 -0
- package/dist/tex-paper.js +105 -0
- package/dist/tex-paper.js.map +1 -0
- package/dist/tex-venue-rules.d.ts +7 -2
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +31 -17
- package/dist/tex-venue-rules.js.map +1 -1
- package/docs/rules/tex/claim-provenance.md +83 -0
- package/docs/rules/tex/missing-input.md +58 -0
- package/docs/rules/tex/register.md +77 -0
- package/docs/rules/tex/required-section.md +3 -1
- package/docs/rules/tex/template.md +12 -0
- package/docs/rules/tex/venue-leftover.md +4 -2
- package/docs/rules.md +3 -0
- package/eslint-rules/paper-typography.test.mjs +48 -1
- package/eslint-rules/paper-typography.ts +72 -1
- package/eslint.config.mjs +1 -0
- package/fixtures/real-markdown-paper/baseline.ts +8 -7
- package/package.json +2 -1
- package/skills/cold-read-diff/SKILL.md +31 -10
- package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
- package/skills/draft-paper/SKILL.md +2 -2
- package/skills/draft-paper/SKILL.md.spec.ts +1 -1
- package/skills/grade-paper-writing/SKILL.md +109 -44
- package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
- package/skills/harden-paper/SKILL.md +35 -36
- package/skills/harden-paper/SKILL.md.spec.ts +34 -35
- package/skills/paper-adversarial-review/SKILL.md +2 -2
- package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/SKILL.md +3 -3
- package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
- package/skills/paper-pipeline/references/writing-craft.md +53 -18
- package/skills/pc-panel-review/SKILL.md +36 -11
- package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
- package/src/adapters/latex/document-class.ts +20 -12
- package/src/adapters/latex/includes.ts +66 -0
- package/src/adapters/latex/index.test.ts +175 -1
- package/src/adapters/latex/index.ts +14 -4
- package/src/adapters/latex/nodes.ts +11 -2
- package/src/adapters/latex/parse.ts +1 -0
- package/src/adapters/latex/prose.ts +484 -0
- package/src/claim-provenance.test.ts +352 -0
- package/src/claim-provenance.ts +217 -0
- package/src/cli.ts +20 -0
- package/src/domain/paper-source.test.ts +156 -0
- package/src/domain/paper-source.ts +233 -0
- package/src/domain/sentences.ts +67 -0
- package/src/domain/tex-document.ts +36 -3
- package/src/ports/latex.ts +13 -0
- package/src/register.test.ts +165 -0
- package/src/register.ts +177 -0
- package/src/rule-cases.test.ts +54 -4
- package/src/tex-paper.test.ts +131 -0
- package/src/tex-paper.ts +152 -0
- package/src/tex-venue-rules.test.ts +87 -5
- package/src/tex-venue-rules.ts +62 -30
- package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
- package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body's prose as passages a sentence cannot cross — for a rule that judges SENTENCES, where the
|
|
3
|
+
* rendered runs (`rendered.ts`) are too fine: a run ends at every macro, so one sentence around a
|
|
4
|
+
* `\cite` or an `\emph{…}` is several runs there and one passage here.
|
|
5
|
+
*
|
|
6
|
+
* Kept: the text a reader sees, from the start of the document (the abstract included) to the
|
|
7
|
+
* appendix or the bibliography; the marks that say where a claim comes from — a citation, a link, a
|
|
8
|
+
* cross-reference — where they stand; math, as its source.
|
|
9
|
+
*
|
|
10
|
+
* Left out: headings — a run-in one too, a bold or italic phrase ending in `.` or `:` that opens a
|
|
11
|
+
* paragraph or an item (`\textbf{Threats.} …`) — captions and list-item labels (each also ends a passage), the title block,
|
|
12
|
+
* floats and tables, code, comments, macro definitions, the groups that follow a macro the parser
|
|
13
|
+
* gave no signature (`\institution{…}`), and everything from the back matter on. A footnote is its
|
|
14
|
+
* own passage; one that cites or links leaves a citation mark in the sentence it hangs from.
|
|
15
|
+
*/
|
|
16
|
+
import type * as Ast from "@unified-latex/unified-latex-types";
|
|
17
|
+
import { hasAtLeast, sortBy } from "remeda";
|
|
18
|
+
import type { Owner, Passage, ProsePiece } from "../../domain/tex-document.ts";
|
|
19
|
+
import {
|
|
20
|
+
argumentsOf,
|
|
21
|
+
inPlace,
|
|
22
|
+
macroPlace,
|
|
23
|
+
mandatory,
|
|
24
|
+
placeOf,
|
|
25
|
+
stringText,
|
|
26
|
+
type Macro,
|
|
27
|
+
type Node,
|
|
28
|
+
} from "./nodes.ts";
|
|
29
|
+
import { CITATION_MACROS, DEFINITION_MACROS, type ParsedTex } from "./parse.ts";
|
|
30
|
+
|
|
31
|
+
/** What the walk yields: a piece, the end of a passage, the start of the back matter, a footnote. */
|
|
32
|
+
type Event =
|
|
33
|
+
| { readonly kind: "piece"; readonly piece: ProsePiece }
|
|
34
|
+
| { readonly kind: "break" }
|
|
35
|
+
| { readonly kind: "end" }
|
|
36
|
+
| { readonly kind: "aside"; readonly events: readonly Event[] };
|
|
37
|
+
|
|
38
|
+
const BREAK: Event = { kind: "break" };
|
|
39
|
+
const END: Event = { kind: "end" };
|
|
40
|
+
|
|
41
|
+
/** Environments whose body is not running prose: floats, tables, algorithms, drawings, code. */
|
|
42
|
+
const SKIPPED_ENVS: ReadonlySet<string> = new Set([
|
|
43
|
+
"figure",
|
|
44
|
+
"figure*",
|
|
45
|
+
"table",
|
|
46
|
+
"table*",
|
|
47
|
+
"tabular",
|
|
48
|
+
"tabular*",
|
|
49
|
+
"tabularx",
|
|
50
|
+
"longtable",
|
|
51
|
+
"wrapfigure",
|
|
52
|
+
"wraptable",
|
|
53
|
+
"subfigure",
|
|
54
|
+
"subtable",
|
|
55
|
+
"algorithm",
|
|
56
|
+
"algorithm*",
|
|
57
|
+
"algorithmic",
|
|
58
|
+
"tikzpicture",
|
|
59
|
+
"lstlisting",
|
|
60
|
+
"verbatim",
|
|
61
|
+
"Verbatim",
|
|
62
|
+
"minted",
|
|
63
|
+
"comment",
|
|
64
|
+
"CCSXML",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** Macros that start the back matter: nothing after them is the body. */
|
|
68
|
+
const BACK_MATTER: ReadonlySet<string> = new Set([
|
|
69
|
+
"appendix",
|
|
70
|
+
"bibliography",
|
|
71
|
+
"printbibliography",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Macros that point at a numbered place in the paper. */
|
|
75
|
+
const REFERENCE_MACROS: ReadonlySet<string> = new Set([
|
|
76
|
+
"ref",
|
|
77
|
+
"cref",
|
|
78
|
+
"Cref",
|
|
79
|
+
"autoref",
|
|
80
|
+
"Autoref",
|
|
81
|
+
"eqref",
|
|
82
|
+
"pageref",
|
|
83
|
+
"nameref",
|
|
84
|
+
"vref",
|
|
85
|
+
"Vref",
|
|
86
|
+
"cpageref",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/** Macros that name a source outside the paper: a citation (`\nocite` prints nothing), or a link. */
|
|
90
|
+
const SOURCE_MACROS: ReadonlySet<string> = new Set([
|
|
91
|
+
...[...CITATION_MACROS].filter((m) => m !== "nocite"),
|
|
92
|
+
"url",
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/** Macros that end a passage and whose arguments are not prose: headings, captions, the title block. */
|
|
96
|
+
const BREAK_MACROS: ReadonlySet<string> = new Set([
|
|
97
|
+
"part",
|
|
98
|
+
"chapter",
|
|
99
|
+
"section",
|
|
100
|
+
"subsection",
|
|
101
|
+
"subsubsection",
|
|
102
|
+
"paragraph",
|
|
103
|
+
"subparagraph",
|
|
104
|
+
"caption",
|
|
105
|
+
"captionof",
|
|
106
|
+
"bibitem",
|
|
107
|
+
"maketitle",
|
|
108
|
+
"title",
|
|
109
|
+
"subtitle",
|
|
110
|
+
"author",
|
|
111
|
+
"date",
|
|
112
|
+
"newpage",
|
|
113
|
+
"clearpage",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/** Formatting macros whose last mandatory argument is prose, read where it stands. */
|
|
117
|
+
const INLINE_MACROS: ReadonlySet<string> = new Set([
|
|
118
|
+
"emph",
|
|
119
|
+
"textbf",
|
|
120
|
+
"textit",
|
|
121
|
+
"texttt",
|
|
122
|
+
"textsc",
|
|
123
|
+
"textsf",
|
|
124
|
+
"textrm",
|
|
125
|
+
"textsl",
|
|
126
|
+
"textup",
|
|
127
|
+
"textmd",
|
|
128
|
+
"textnormal",
|
|
129
|
+
"underline",
|
|
130
|
+
"uline",
|
|
131
|
+
"mbox",
|
|
132
|
+
"hbox",
|
|
133
|
+
"text",
|
|
134
|
+
"enquote",
|
|
135
|
+
"textcolor",
|
|
136
|
+
"colorbox",
|
|
137
|
+
"fbox",
|
|
138
|
+
"textsuperscript",
|
|
139
|
+
"textsubscript",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
/** Macros that typeset one character or a space. */
|
|
143
|
+
const CHARACTER_MACROS: ReadonlyMap<string, string> = new Map([
|
|
144
|
+
["%", "%"],
|
|
145
|
+
["&", "&"],
|
|
146
|
+
["$", "$"],
|
|
147
|
+
["#", "#"],
|
|
148
|
+
["_", "_"],
|
|
149
|
+
["{", "{"],
|
|
150
|
+
["}", "}"],
|
|
151
|
+
["S", "§"],
|
|
152
|
+
["P", "¶"],
|
|
153
|
+
["textpercent", "%"],
|
|
154
|
+
["ldots", "…"],
|
|
155
|
+
["dots", "…"],
|
|
156
|
+
["textellipsis", "…"],
|
|
157
|
+
["textendash", "–"],
|
|
158
|
+
["textemdash", "—"],
|
|
159
|
+
...[
|
|
160
|
+
",",
|
|
161
|
+
" ",
|
|
162
|
+
";",
|
|
163
|
+
":",
|
|
164
|
+
"quad",
|
|
165
|
+
"qquad",
|
|
166
|
+
"enspace",
|
|
167
|
+
"thinspace",
|
|
168
|
+
"\\",
|
|
169
|
+
"newline",
|
|
170
|
+
].map((m) => [m, " "] as const),
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const piece = (p: ProsePiece): Event => ({ kind: "piece", piece: p });
|
|
174
|
+
|
|
175
|
+
/** `s`, typeset where `n` starts. */
|
|
176
|
+
const textAt = (n: Node, s: string): readonly Event[] =>
|
|
177
|
+
inPlace(placeOf(n), (span) => [
|
|
178
|
+
piece({ kind: "text", segment: { text: s, at: span.start } }),
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The character a macro typesets, placed at the macro's LAST source character: a sentence that ends
|
|
183
|
+
* on `\%` then spans the whole macro, not its backslash.
|
|
184
|
+
*/
|
|
185
|
+
const characterAt = (m: Macro, ch: string): readonly Event[] =>
|
|
186
|
+
inPlace(placeOf(m), (span) => [
|
|
187
|
+
piece({ kind: "text", segment: { text: ch, at: span.end - 1 } }),
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/** A mark over a macro and its arguments. */
|
|
191
|
+
const mark = (m: Macro, owner: Owner): readonly Event[] =>
|
|
192
|
+
inPlace(macroPlace(m), (span) => [piece({ kind: "owner", owner, span })]);
|
|
193
|
+
|
|
194
|
+
/** Math, as its source, where it stands. */
|
|
195
|
+
const math = (t: ParsedTex, n: Node): readonly Event[] =>
|
|
196
|
+
inPlace(placeOf(n), (span) => [
|
|
197
|
+
piece({ kind: "math", tex: t.src.slice(span.start, span.end), span }),
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
/** The content of a macro's last mandatory argument: what a formatting macro or a footnote typesets. */
|
|
201
|
+
const lastArgument = (m: Macro): readonly Node[] =>
|
|
202
|
+
mandatory(m).at(-1)?.content ?? [];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Whether a walk yielded a citation or a link. A footnote nested inside that cites has already left
|
|
206
|
+
* its own citation mark among these events, so the top level is enough.
|
|
207
|
+
*/
|
|
208
|
+
const cites = (events: readonly Event[]): boolean =>
|
|
209
|
+
events.some(
|
|
210
|
+
(e) =>
|
|
211
|
+
e.kind === "piece" &&
|
|
212
|
+
e.piece.kind === "owner" &&
|
|
213
|
+
e.piece.owner === "citation",
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* What a list item typesets: the parser hands an `\item` its text as the last argument (the
|
|
218
|
+
* `[label]` before it is not prose).
|
|
219
|
+
*/
|
|
220
|
+
const itemBody = (m: Macro): readonly Node[] =>
|
|
221
|
+
argumentsOf(m)
|
|
222
|
+
.slice(-1)
|
|
223
|
+
.flatMap((a) => a.content);
|
|
224
|
+
|
|
225
|
+
/** A footnote: its own passage, and a citation mark in the host sentence when it cites or links. */
|
|
226
|
+
function footnote(t: ParsedTex, m: Macro): readonly Event[] {
|
|
227
|
+
const inside = walkList(t, lastArgument(m));
|
|
228
|
+
const aside: Event = { kind: "aside", events: inside };
|
|
229
|
+
return cites(inside) ? [...mark(m, "citation"), aside] : [aside];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Macros with events of their own: `\href{url}{text}` is a link whose text is prose; a footnote is a
|
|
234
|
+
* passage of its own; a list item ends a passage and opens the next with its text.
|
|
235
|
+
*/
|
|
236
|
+
const OWN_EVENTS: ReadonlyMap<
|
|
237
|
+
string,
|
|
238
|
+
(t: ParsedTex, m: Macro) => readonly Event[]
|
|
239
|
+
> = new Map([
|
|
240
|
+
[
|
|
241
|
+
"href",
|
|
242
|
+
(t: ParsedTex, m: Macro) => [
|
|
243
|
+
...mark(m, "citation"),
|
|
244
|
+
...walkList(t, lastArgument(m)),
|
|
245
|
+
],
|
|
246
|
+
],
|
|
247
|
+
["footnote", footnote],
|
|
248
|
+
[
|
|
249
|
+
"item",
|
|
250
|
+
(t: ParsedTex, m: Macro) => [BREAK, ...walkList(t, itemBody(m), true)],
|
|
251
|
+
],
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
function macroEvents(t: ParsedTex, m: Macro): readonly Event[] {
|
|
255
|
+
const name = m.content;
|
|
256
|
+
if (DEFINITION_MACROS.has(name)) return [];
|
|
257
|
+
if (BACK_MATTER.has(name)) return [END];
|
|
258
|
+
if (REFERENCE_MACROS.has(name)) return mark(m, "reference");
|
|
259
|
+
if (SOURCE_MACROS.has(name)) return mark(m, "citation");
|
|
260
|
+
const own = OWN_EVENTS.get(name);
|
|
261
|
+
if (own !== undefined) return own(t, m);
|
|
262
|
+
if (BREAK_MACROS.has(name)) return [BREAK];
|
|
263
|
+
if (INLINE_MACROS.has(name)) return walkList(t, lastArgument(m));
|
|
264
|
+
const ch = CHARACTER_MACROS.get(name);
|
|
265
|
+
return ch === undefined ? [] : characterAt(m, ch);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function environmentEvents(
|
|
269
|
+
t: ParsedTex,
|
|
270
|
+
e: Readonly<Ast.Environment>,
|
|
271
|
+
): readonly Event[] {
|
|
272
|
+
if (e.type === "mathenv") return math(t, e);
|
|
273
|
+
if (e.env === "thebibliography") return [END];
|
|
274
|
+
if (SKIPPED_ENVS.has(e.env)) return [BREAK];
|
|
275
|
+
return [BREAK, ...walkList(t, e.content, true), BREAK];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type NodeOf<K extends Node["type"]> = Extract<Node, { readonly type: K }>;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Inline and display math (an equation environment is typed as an environment, and read there),
|
|
282
|
+
* what ends a passage, and what holds no prose.
|
|
283
|
+
*/
|
|
284
|
+
const isMath = (n: Node): n is NodeOf<"inlinemath" | "displaymath"> =>
|
|
285
|
+
n.type === "inlinemath" || n.type === "displaymath";
|
|
286
|
+
const isBreak = (n: Node): n is NodeOf<"parbreak" | "verbatim"> =>
|
|
287
|
+
n.type === "parbreak" || n.type === "verbatim";
|
|
288
|
+
const isSilent = (n: Node): n is NodeOf<"comment" | "verb" | "root"> =>
|
|
289
|
+
n.type === "comment" || n.type === "verb" || n.type === "root";
|
|
290
|
+
|
|
291
|
+
function nodeEvents(t: ParsedTex, n: Node): readonly Event[] {
|
|
292
|
+
if (isMath(n)) return math(t, n);
|
|
293
|
+
if (isBreak(n)) return [BREAK];
|
|
294
|
+
if (isSilent(n)) return [];
|
|
295
|
+
switch (n.type) {
|
|
296
|
+
case "string":
|
|
297
|
+
return textAt(n, stringText(n.content));
|
|
298
|
+
case "whitespace":
|
|
299
|
+
return textAt(n, " ");
|
|
300
|
+
case "group":
|
|
301
|
+
return walkList(t, n.content);
|
|
302
|
+
case "environment":
|
|
303
|
+
case "mathenv":
|
|
304
|
+
return environmentEvents(t, n);
|
|
305
|
+
case "macro":
|
|
306
|
+
return macroEvents(t, n);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Where a node list stands with respect to a macro the parser attached no arguments to: in prose,
|
|
312
|
+
* in that macro's arguments, or inside one of its `[…]` arguments.
|
|
313
|
+
*/
|
|
314
|
+
type ArgumentState = "prose" | "arguments" | "optional";
|
|
315
|
+
|
|
316
|
+
/** A macro whose `{…}` and `[…]` after it are its arguments, unattached: not a formatting or a character macro. */
|
|
317
|
+
const isUnsignedMacro = (n: Node): boolean =>
|
|
318
|
+
n.type === "macro" &&
|
|
319
|
+
n.args === undefined &&
|
|
320
|
+
!INLINE_MACROS.has(n.content) &&
|
|
321
|
+
!CHARACTER_MACROS.has(n.content);
|
|
322
|
+
|
|
323
|
+
const isString = (n: Node, s: string): boolean =>
|
|
324
|
+
n.type === "string" && n.content === s;
|
|
325
|
+
|
|
326
|
+
/** A `{…}` group, or digits glued to the macro (`\if\conference1`): an argument it takes. */
|
|
327
|
+
const isMandatoryArgument = (n: Node): boolean =>
|
|
328
|
+
n.type === "group" || (n.type === "string" && /^\d+$/u.test(n.content));
|
|
329
|
+
|
|
330
|
+
/** One node's step: whether it is skipped as an argument, and the state after it. */
|
|
331
|
+
function step(
|
|
332
|
+
state: ArgumentState,
|
|
333
|
+
n: Node,
|
|
334
|
+
): { readonly skip: boolean; readonly state: ArgumentState } {
|
|
335
|
+
if (state === "optional")
|
|
336
|
+
return { skip: true, state: isString(n, "]") ? "arguments" : "optional" };
|
|
337
|
+
if (state === "arguments" && isMandatoryArgument(n))
|
|
338
|
+
return { skip: true, state: "arguments" };
|
|
339
|
+
if (state === "arguments" && isString(n, "["))
|
|
340
|
+
return { skip: true, state: "optional" };
|
|
341
|
+
return { skip: false, state: isUnsignedMacro(n) ? "arguments" : "prose" };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The nodes of a list that are prose: without the `{…}`, `[…]` and glued digits right after a
|
|
346
|
+
* macro the parser gave no signature — `\institution{…}`, `\ccsdesc[500]{…}`, `\tool{}`,
|
|
347
|
+
* `\if\conference1` — which are its arguments, not text. A formatting macro's group (`\enquote{…}` without csquotes' signature) stays prose.
|
|
348
|
+
*/
|
|
349
|
+
const proseNodes = (list: readonly Node[]): readonly Node[] =>
|
|
350
|
+
list.reduce<{
|
|
351
|
+
readonly kept: readonly Node[];
|
|
352
|
+
readonly state: ArgumentState;
|
|
353
|
+
}>(
|
|
354
|
+
(acc, n) => {
|
|
355
|
+
const next = step(acc.state, n);
|
|
356
|
+
return {
|
|
357
|
+
kept: next.skip ? acc.kept : [...acc.kept, n],
|
|
358
|
+
state: next.state,
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
{ kept: [], state: "prose" },
|
|
362
|
+
).kept;
|
|
363
|
+
|
|
364
|
+
/** Formatting macros a run-in heading is set in: `\textbf{Correctness gate.} Each task…`. */
|
|
365
|
+
const RUN_IN_MACROS: ReadonlySet<string> = new Set([
|
|
366
|
+
"textbf",
|
|
367
|
+
"textit",
|
|
368
|
+
"emph",
|
|
369
|
+
"textsc",
|
|
370
|
+
"underline",
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
/** The text a list of events typesets, marks and math left out. */
|
|
374
|
+
const typeset = (events: readonly Event[]): string =>
|
|
375
|
+
events
|
|
376
|
+
.map((e) =>
|
|
377
|
+
e.kind === "piece" && e.piece.kind === "text" ? e.piece.segment.text : "",
|
|
378
|
+
)
|
|
379
|
+
.join("");
|
|
380
|
+
|
|
381
|
+
/** A formatting macro whose phrase ends in `.` or `:` — a run-in heading, when it opens a paragraph. */
|
|
382
|
+
const isRunInHeading = (t: ParsedTex, n: Node): boolean =>
|
|
383
|
+
n.type === "macro" &&
|
|
384
|
+
RUN_IN_MACROS.has(n.content) &&
|
|
385
|
+
/[.:]$/u.test(typeset(walkList(t, lastArgument(n))).trim());
|
|
386
|
+
|
|
387
|
+
/** Macros that typeset no text, and so leave a paragraph's opening where it was. */
|
|
388
|
+
const SILENT_AT_OPENING: ReadonlySet<string> = new Set([
|
|
389
|
+
"noindent",
|
|
390
|
+
"label",
|
|
391
|
+
"vspace",
|
|
392
|
+
"smallskip",
|
|
393
|
+
"medskip",
|
|
394
|
+
"bigskip",
|
|
395
|
+
"phantomsection",
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
/** Nodes that leave a paragraph's opening where it was: spaces, comments, macros that typeset nothing. */
|
|
399
|
+
const keepsOpening = (n: Node): boolean =>
|
|
400
|
+
n.type === "whitespace" ||
|
|
401
|
+
n.type === "comment" ||
|
|
402
|
+
(n.type === "macro" && SILENT_AT_OPENING.has(n.content));
|
|
403
|
+
|
|
404
|
+
/** Nodes after which a paragraph opens: a paragraph break, and a macro that ends a passage (a heading). */
|
|
405
|
+
const opensAfter = (n: Node): boolean =>
|
|
406
|
+
n.type === "parbreak" || (n.type === "macro" && BREAK_MACROS.has(n.content));
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A list's events. `opens` says the list starts a paragraph (the body, an environment's content);
|
|
410
|
+
* a run-in heading that opens a paragraph ends the passage before it and is left out, like a heading.
|
|
411
|
+
*/
|
|
412
|
+
function walkList(
|
|
413
|
+
t: ParsedTex,
|
|
414
|
+
list: readonly Node[],
|
|
415
|
+
opens = false,
|
|
416
|
+
): readonly Event[] {
|
|
417
|
+
return proseNodes(list).reduce<{
|
|
418
|
+
readonly events: readonly Event[];
|
|
419
|
+
readonly opening: boolean;
|
|
420
|
+
}>(
|
|
421
|
+
(acc, n) => ({
|
|
422
|
+
events: [
|
|
423
|
+
...acc.events,
|
|
424
|
+
...(acc.opening && isRunInHeading(t, n) ? [BREAK] : nodeEvents(t, n)),
|
|
425
|
+
],
|
|
426
|
+
opening: opensAfter(n) || (acc.opening && keepsOpening(n)),
|
|
427
|
+
}),
|
|
428
|
+
{ events: [], opening: opens },
|
|
429
|
+
).events;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The events before the back matter starts. */
|
|
433
|
+
const untilEnd = (events: readonly Event[]): readonly Event[] => {
|
|
434
|
+
const end = events.findIndex((e) => e.kind === "end");
|
|
435
|
+
return end < 0 ? events : events.slice(0, end);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** Events → passages: pieces between breaks, each footnote a passage of its own. */
|
|
439
|
+
function passagesOf(events: readonly Event[]): readonly Passage[] {
|
|
440
|
+
const { done, current } = events.reduce<{
|
|
441
|
+
readonly done: readonly (readonly ProsePiece[])[];
|
|
442
|
+
readonly current: readonly ProsePiece[];
|
|
443
|
+
}>(
|
|
444
|
+
(acc, e) =>
|
|
445
|
+
e.kind === "piece"
|
|
446
|
+
? { done: acc.done, current: [...acc.current, e.piece] }
|
|
447
|
+
: e.kind === "break"
|
|
448
|
+
? { done: [...acc.done, acc.current], current: [] }
|
|
449
|
+
: acc,
|
|
450
|
+
{ done: [], current: [] },
|
|
451
|
+
);
|
|
452
|
+
const asides = events.flatMap((e) =>
|
|
453
|
+
e.kind === "aside" ? passagesOf(untilEnd(e.events)) : [],
|
|
454
|
+
);
|
|
455
|
+
const own = [...done, current].flatMap((pieces) =>
|
|
456
|
+
hasAtLeast(pieces, 1) ? [{ pieces }] : [],
|
|
457
|
+
);
|
|
458
|
+
return [...own, ...asides];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const isDocument = (n: Node): n is Readonly<Ast.Environment> =>
|
|
462
|
+
n.type === "environment" && n.env === "document";
|
|
463
|
+
|
|
464
|
+
/** A passage with something in it besides spaces. */
|
|
465
|
+
const hasProse = (p: Passage): boolean =>
|
|
466
|
+
p.pieces.some((x) => x.kind !== "text" || x.segment.text.trim() !== "");
|
|
467
|
+
|
|
468
|
+
/** Where a passage starts in the source. */
|
|
469
|
+
const startOf = (p: Passage): number => {
|
|
470
|
+
const first = p.pieces[0];
|
|
471
|
+
return first.kind === "text" ? first.segment.at : first.span.start;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The body's prose, in document order: the content of the `document` environment (the whole
|
|
476
|
+
* source when there is none), up to the first back-matter node.
|
|
477
|
+
*/
|
|
478
|
+
export function bodyProse(t: ParsedTex): readonly Passage[] {
|
|
479
|
+
const body = t.root.content.find(isDocument)?.content ?? t.root.content;
|
|
480
|
+
return sortBy(
|
|
481
|
+
passagesOf(untilEnd(walkList(t, body, true))).filter(hasProse),
|
|
482
|
+
startOf,
|
|
483
|
+
);
|
|
484
|
+
}
|