paperlint 4.0.0 → 4.1.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 (172) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +31 -11
  3. package/CONTRIBUTING.md +23 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +22 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +102 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +29 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -0
  16. package/dist/adapters/latex/index.js +18 -0
  17. package/dist/adapters/latex/index.js.map +1 -0
  18. package/dist/adapters/latex/nodes.d.ts +54 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  20. package/dist/adapters/latex/nodes.js +93 -0
  21. package/dist/adapters/latex/nodes.js.map +1 -0
  22. package/dist/adapters/latex/outline.d.ts +10 -0
  23. package/dist/adapters/latex/outline.d.ts.map +1 -0
  24. package/dist/adapters/latex/outline.js +39 -0
  25. package/dist/adapters/latex/outline.js.map +1 -0
  26. package/dist/adapters/latex/parse.d.ts +42 -0
  27. package/dist/adapters/latex/parse.d.ts.map +1 -0
  28. package/dist/adapters/latex/parse.js +94 -0
  29. package/dist/adapters/latex/parse.js.map +1 -0
  30. package/dist/adapters/latex/rendered.d.ts +10 -0
  31. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  32. package/dist/adapters/latex/rendered.js +39 -0
  33. package/dist/adapters/latex/rendered.js.map +1 -0
  34. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  35. package/dist/adapters/pdfjs/fill.js +12 -12
  36. package/dist/adapters/pdfjs/fill.js.map +1 -1
  37. package/dist/build.js +4 -4
  38. package/dist/build.js.map +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +29 -5
  41. package/dist/cli.js.map +1 -1
  42. package/dist/domain/tex-document.d.ts +84 -0
  43. package/dist/domain/tex-document.d.ts.map +1 -0
  44. package/dist/domain/tex-document.js +43 -0
  45. package/dist/domain/tex-document.js.map +1 -0
  46. package/dist/facts-file.d.ts.map +1 -1
  47. package/dist/facts-file.js +2 -2
  48. package/dist/facts-file.js.map +1 -1
  49. package/dist/new-paper.d.ts +5 -0
  50. package/dist/new-paper.d.ts.map +1 -1
  51. package/dist/new-paper.js +3 -0
  52. package/dist/new-paper.js.map +1 -1
  53. package/dist/package-dirs.d.ts +5 -0
  54. package/dist/package-dirs.d.ts.map +1 -0
  55. package/dist/package-dirs.js +14 -0
  56. package/dist/package-dirs.js.map +1 -0
  57. package/dist/ports/latex.d.ts +24 -0
  58. package/dist/ports/latex.d.ts.map +1 -0
  59. package/dist/ports/latex.js +2 -0
  60. package/dist/ports/latex.js.map +1 -0
  61. package/dist/presets.d.ts +15 -2
  62. package/dist/presets.d.ts.map +1 -1
  63. package/dist/presets.js +19 -6
  64. package/dist/presets.js.map +1 -1
  65. package/dist/tex-requirements.d.ts +11 -0
  66. package/dist/tex-requirements.d.ts.map +1 -1
  67. package/dist/tex-requirements.js +14 -8
  68. package/dist/tex-requirements.js.map +1 -1
  69. package/dist/tex-venue-rules.d.ts +83 -0
  70. package/dist/tex-venue-rules.d.ts.map +1 -0
  71. package/dist/tex-venue-rules.js +254 -0
  72. package/dist/tex-venue-rules.js.map +1 -0
  73. package/dist/venue-rules.d.ts +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/docs/configuration.md +3 -2
  76. package/docs/e2e.md +69 -40
  77. package/docs/install.md +1 -1
  78. package/docs/prior-art/README.md +1 -1
  79. package/docs/prior-art/package-location.md +5 -5
  80. package/docs/prior-art/repro/README.md +1 -1
  81. package/docs/prior-art/test-tooling.md +21 -10
  82. package/docs/rules/tex/required-section.md +73 -0
  83. package/docs/rules/tex/template.md +62 -0
  84. package/docs/rules/tex/venue-leftover.md +71 -0
  85. package/docs/rules.md +70 -48
  86. package/docs/testing.md +17 -4
  87. package/docs/texlive-install-decision.md +1 -1
  88. package/docs/toolchain.md +2 -2
  89. package/eslint.config.mjs +155 -15
  90. package/fixtures/build-e2e/acmart/expect.json +26 -0
  91. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  92. package/fixtures/build-e2e/aidc/expect.json +14 -0
  93. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  94. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  95. package/fixtures/build-e2e/broken/expect.json +11 -0
  96. package/fixtures/build-e2e/cite/expect.json +27 -0
  97. package/fixtures/build-e2e/empty/expect.json +12 -0
  98. package/fixtures/build-e2e/fallback/expect.json +53 -0
  99. package/fixtures/build-e2e/guards/expect.json +6 -0
  100. package/fixtures/build-e2e/no-source/expect.json +10 -0
  101. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.ts +1 -1
  104. package/fixtures/render-paper/build-clean.sh +3 -5
  105. package/package.json +13 -2
  106. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  107. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  108. package/presets/aidc.jsonc +73 -0
  109. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  110. package/presets/ieee-conference.jsonc +66 -0
  111. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  112. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  113. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  114. package/scripts/check.harness.ts +64 -10
  115. package/scripts/check.test.ts +60 -13
  116. package/scripts/check.ts +83 -30
  117. package/scripts/test.test.ts +1 -1
  118. package/scripts/test.ts +1 -1
  119. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  120. package/skills/paper-pipeline/SKILL.md +2 -2
  121. package/skills/paper-pipeline/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  123. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  124. package/skills/render-paper/SKILL.md +2 -2
  125. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  126. package/skills/render-paper/check-render.sh +16 -63
  127. package/skills/render-paper/render-paper.harness.mjs +17 -59
  128. package/src/adapters/banal/index.test.ts +1 -1
  129. package/src/adapters/banal/invocation.ts +1 -1
  130. package/src/adapters/banal/output.test.ts +1 -1
  131. package/src/adapters/banal/run.test.ts +1 -1
  132. package/src/adapters/banal/xml.test.ts +1 -1
  133. package/src/adapters/latex/document-class.ts +147 -0
  134. package/src/adapters/latex/hidden.ts +70 -0
  135. package/src/adapters/latex/index.test.ts +361 -0
  136. package/src/adapters/latex/index.ts +55 -0
  137. package/src/adapters/latex/nodes.ts +141 -0
  138. package/src/adapters/latex/outline.ts +64 -0
  139. package/src/adapters/latex/parse.ts +136 -0
  140. package/src/adapters/latex/rendered.ts +59 -0
  141. package/src/adapters/pdfjs/fill.test.ts +1 -1
  142. package/src/adapters/pdfjs/fill.ts +18 -10
  143. package/src/build.harness.mjs +5 -7
  144. package/src/build.ts +4 -4
  145. package/src/cli.harness.mjs +1 -1
  146. package/src/cli.test.ts +2 -8
  147. package/src/cli.ts +33 -8
  148. package/src/domain/tex-document.test.ts +80 -0
  149. package/src/domain/tex-document.ts +110 -0
  150. package/src/engine.harness.mjs +1 -1
  151. package/src/facts-file.test.ts +4 -7
  152. package/src/facts-file.ts +2 -2
  153. package/src/latex-loop.harness.ts +1 -1
  154. package/src/new-paper.test.ts +28 -3
  155. package/src/new-paper.ts +8 -0
  156. package/src/package-dirs.ts +17 -0
  157. package/src/paper-settings-commands.test.ts +6 -1
  158. package/src/ports/latex.ts +29 -0
  159. package/src/presets.test.ts +65 -4
  160. package/src/presets.ts +38 -15
  161. package/src/rule-cases.test.ts +474 -0
  162. package/src/rule-docs.test.ts +159 -0
  163. package/src/tex-requirements.harness.ts +13 -23
  164. package/src/tex-requirements.ts +35 -8
  165. package/src/tex-venue-rules.test.ts +659 -0
  166. package/src/tex-venue-rules.ts +429 -0
  167. package/src/toolchain.harness.mjs +1 -1
  168. package/src/venue-rules.test.ts +96 -4
  169. package/src/venue-rules.ts +1 -1
  170. package/templates/paper/paper.tex +2 -1
  171. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  172. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,147 @@
1
+ /** The paper's `\documentclass`, a preset's template, and setting a class in a source. */
2
+ import {
3
+ documentClassLine,
4
+ venueClass,
5
+ type ClassLine,
6
+ type DocumentClass,
7
+ } from "../../domain/tex-document.ts";
8
+ import {
9
+ collapse,
10
+ macroPlace,
11
+ mandatory,
12
+ optional,
13
+ textOf,
14
+ type Node,
15
+ type Macro,
16
+ } from "./nodes.ts";
17
+ import { parseLatex, type ParsedTex } from "./parse.ts";
18
+
19
+ const isDocumentClass = (n: Node): n is Macro =>
20
+ n.type === "macro" && n.content === "documentclass";
21
+
22
+ const isComma = (n: Node): boolean => n.type === "string" && n.content === ",";
23
+
24
+ /** Nodes split wherever `at` holds, the separators dropped: a comma inside braces is its group's. */
25
+ function splitWhere(
26
+ nodes: readonly Node[],
27
+ at: (n: Node) => boolean,
28
+ ): readonly (readonly Node[])[] {
29
+ const cuts = nodes.flatMap((n, i) => (at(n) ? [i] : []));
30
+ return [-1, ...cuts].map((from, k) =>
31
+ nodes.slice(from + 1, cuts[k] ?? nodes.length),
32
+ );
33
+ }
34
+
35
+ /** The source text a run of nodes spans; its markup-free text if unplaced. */
36
+ function sourceOf(src: string, nodes: readonly Node[]): string {
37
+ const start = nodes[0]?.position?.start.offset;
38
+ const end = nodes.at(-1)?.position?.end.offset;
39
+ return start === undefined || end === undefined
40
+ ? textOf(nodes)
41
+ : src.slice(start, end);
42
+ }
43
+
44
+ /**
45
+ * One option as TeX reads it, whitespace collapsed. A line comment is not part of it, and it joins
46
+ * its two sides with no space: `foo=bar% note⏎baz` is `foo=barbaz`.
47
+ */
48
+ const optionText = (src: string, part: readonly Node[]): string =>
49
+ collapse(
50
+ splitWhere(part, (n) => n.type === "comment")
51
+ .map((run) => sourceOf(src, run))
52
+ .join(""),
53
+ );
54
+
55
+ const classNode = (t: ParsedTex): Macro | undefined =>
56
+ t.root.content.find(isDocumentClass);
57
+
58
+ /** A `\documentclass` node as a class line: empty, or the class with its options as read. */
59
+ function classLine(src: string, node: Macro): ClassLine {
60
+ const place = macroPlace(node);
61
+ const cls = collapse(textOf(mandatory(node)[0]?.content));
62
+ if (cls === "") return { kind: "empty", place };
63
+ const options = splitWhere(optional(node)?.content ?? [], isComma)
64
+ .map((part) => optionText(src, part))
65
+ .filter(Boolean);
66
+ return { kind: "class", cls, options, place };
67
+ }
68
+
69
+ /**
70
+ * The first `\documentclass` at the top of the tree: missing; empty (`\documentclass{}`); or the
71
+ * class with each of its options as written (`foo={a,b}` stays one option, braces kept), whitespace
72
+ * collapsed. Its place spans the macro and its arguments.
73
+ */
74
+ export function documentClassOf(t: ParsedTex): ClassLine {
75
+ const node = classNode(t);
76
+ return node === undefined ? { kind: "missing" } : classLine(t.src, node);
77
+ }
78
+
79
+ /**
80
+ * A preset's `template` → the class it names: a whole `\documentclass[…]{…}` line, or a bare class
81
+ * name (`article`), which names no option. Anything else is null.
82
+ */
83
+ export function parseTemplate(text: string): DocumentClass | null {
84
+ const t = parseLatex(text);
85
+ const [only, ...rest] = t.root.content.filter(
86
+ (n) => n.type !== "whitespace" && n.type !== "comment",
87
+ );
88
+ if (only === undefined || rest.length > 0) return null;
89
+ const line = documentClassOf(t);
90
+ if (line.kind === "class") return { cls: line.cls, options: line.options };
91
+ return only.type === "string" ? { cls: only.content, options: [] } : null;
92
+ }
93
+
94
+ /** `src` with `text` in place of `[from, to)`. */
95
+ const splice = (src: string, from: number, to: number, text: string): string =>
96
+ src.slice(0, from) + text + src.slice(to);
97
+
98
+ /**
99
+ * `options` written into the class line's own source: before the `]` of its `[…]` — right after the
100
+ * last node inside, or right after the `[` when there is none — or as a new `[…]` after the name.
101
+ * The bytes between `[` and `]` stay as the author wrote them, comments and braces included.
102
+ */
103
+ function addOptions(
104
+ src: string,
105
+ node: Macro,
106
+ line: { readonly start: number; readonly empty: boolean },
107
+ options: readonly string[],
108
+ ): string {
109
+ const list = options.join(",");
110
+ const nameEnd = line.start + 1 + node.content.length; // `\` and the macro's name
111
+ const opt = optional(node);
112
+ if (opt === undefined) return splice(src, nameEnd, nameEnd, `[${list}]`);
113
+ const close =
114
+ opt.content.at(-1)?.position?.end.offset ?? src.indexOf("[", nameEnd) + 1;
115
+ return splice(src, close, close, line.empty ? list : `,${list}`);
116
+ }
117
+
118
+ /**
119
+ * The source with its `\documentclass` line changed to satisfy `want` (see `venueClass`): another
120
+ * class is replaced by the template's line; the same class gains the options it lacks in place. A
121
+ * source whose class already satisfies `want`, or that has no class line the parser could place, is
122
+ * returned as it is.
123
+ */
124
+ export function replaceDocumentClass(
125
+ t: ParsedTex,
126
+ want: DocumentClass,
127
+ ): string {
128
+ const node = classNode(t);
129
+ if (node === undefined) return t.src;
130
+ const line = classLine(t.src, node);
131
+ if (line.kind !== "class" || line.place.kind === "unplaced") return t.src;
132
+ const { start, end } = line.place.span;
133
+ const change = venueClass(line, want);
134
+ switch (change.kind) {
135
+ case "keep":
136
+ return t.src;
137
+ case "replace":
138
+ return splice(t.src, start, end, documentClassLine(change.by));
139
+ case "add":
140
+ return addOptions(
141
+ t.src,
142
+ node,
143
+ { start, empty: line.options.length === 0 },
144
+ change.options,
145
+ );
146
+ }
147
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What a reader of the PDF never sees, as a walk over the tree prunes it: comments, math and code,
3
+ * the bibliography's body, and the arguments that are keys, labels, files or URLs. The rendered
4
+ * text and the outline both prune with it.
5
+ *
6
+ * A macro definition's body is NOT pruned here. It is text once the macro is used, and nothing here
7
+ * expands macros, so the rendered text reads the body where it is written — a definition naming
8
+ * another venue is a leftover whether it is used or not. The outline prunes it on top
9
+ * (`isDefinition`): a heading in a definition body is not a section where it is written.
10
+ */
11
+ import {
12
+ isArgument,
13
+ isNode,
14
+ type Argument,
15
+ type Node,
16
+ type Visited,
17
+ } from "./nodes.ts";
18
+ import { DEFINITION_MACROS, KEY_ARGUMENT, KEY_SIGNATURES } from "./parse.ts";
19
+
20
+ /** Environments whose body is not text a reader sees as prose: code, the bibliography, comments. */
21
+ const HIDDEN_ENVS: ReadonlySet<string> = new Set([
22
+ "thebibliography",
23
+ "verbatim",
24
+ "Verbatim",
25
+ "lstlisting",
26
+ "minted",
27
+ "comment",
28
+ ]);
29
+
30
+ /** Node types a reader does not see as prose: comments, code, math. */
31
+ const HIDDEN_TYPES: ReadonlySet<Node["type"]> = new Set<Node["type"]>([
32
+ "comment",
33
+ "verbatim",
34
+ "verb",
35
+ "inlinemath",
36
+ "displaymath",
37
+ "mathenv",
38
+ ]);
39
+
40
+ const KEY_MACROS: ReadonlySet<string> = new Set(Object.keys(KEY_SIGNATURES));
41
+
42
+ /** A key macro that prints none of its arguments (`\label`, `\url`, …). */
43
+ const isHiddenMacro = (name: string): boolean =>
44
+ KEY_MACROS.has(name) && !KEY_ARGUMENT.has(name);
45
+
46
+ const isHiddenNode = (n: Node): boolean =>
47
+ HIDDEN_TYPES.has(n.type) ||
48
+ (n.type === "environment" && HIDDEN_ENVS.has(n.env)) ||
49
+ (n.type === "macro" && isHiddenMacro(n.content));
50
+
51
+ /**
52
+ * The one argument of a partly printed macro (`KEY_ARGUMENT`) that is a key or a URL. What holds an
53
+ * argument is a macro or an environment; the first three tests only narrow the type.
54
+ */
55
+ const isKeyArgument = (a: Argument, parent: Visited | undefined): boolean =>
56
+ parent !== undefined &&
57
+ isNode(parent) &&
58
+ parent.type === "macro" &&
59
+ parent.args?.indexOf(a) === KEY_ARGUMENT.get(parent.content);
60
+
61
+ /** Whether the walk prunes `v` (given the node that holds it): see the module comment. */
62
+ export const isUnrendered = (
63
+ v: Visited,
64
+ parent: Visited | undefined,
65
+ ): boolean =>
66
+ isArgument(v) ? isKeyArgument(v, parent) : isNode(v) && isHiddenNode(v);
67
+
68
+ /** A macro that defines another macro or an environment (`\newcommand`, `\def`, …). */
69
+ export const isDefinition = (v: Visited): boolean =>
70
+ isNode(v) && v.type === "macro" && DEFINITION_MACROS.has(v.content);
@@ -0,0 +1,361 @@
1
+ /**
2
+ * The LaTeX adapter on real parses: the class line in each of its states, a preset's template, the
3
+ * class `paperlint new` writes, the outline, and the rendered text. Trees come only from
4
+ * `parseLatex`; the one helper that reads a node's position is tested on the shapes a position can
5
+ * take.
6
+ */
7
+ import type * as Ast from "@unified-latex/unified-latex-types";
8
+ import { describe, expect, it } from "vitest";
9
+ import { runText, spanIn } from "../../domain/tex-document.ts";
10
+ import {
11
+ documentClassOf,
12
+ latexReader,
13
+ macroPlace,
14
+ mandatory,
15
+ optional,
16
+ outlineOf,
17
+ parseLatex,
18
+ parseTemplate,
19
+ placeOf,
20
+ renderedRuns,
21
+ replaceDocumentClass,
22
+ textOf,
23
+ } from "./index.ts";
24
+
25
+ const IEEE = { cls: "IEEEtran", options: ["conference", "compsoc"] };
26
+
27
+ describe("documentClassOf — the class line's three states", () => {
28
+ it("a class: each option whitespace-collapsed, and the span of the whole macro, arguments included", () => {
29
+ const src = "% x\n\\documentclass[ conference ,\n compsoc]{IEEEtran}\n";
30
+ expect(documentClassOf(parseLatex(src))).toEqual({
31
+ kind: "class",
32
+ cls: "IEEEtran",
33
+ options: ["conference", "compsoc"],
34
+ place: {
35
+ kind: "at",
36
+ span: { start: 4, end: src.indexOf("}") + 1 },
37
+ },
38
+ });
39
+ });
40
+
41
+ it("🔴 empty: \\documentclass{} names no class, and stands where it stands — not missing", () => {
42
+ expect(documentClassOf(parseLatex("%\n\\documentclass{}\n"))).toEqual({
43
+ kind: "empty",
44
+ place: { kind: "at", span: { start: 2, end: 16 } },
45
+ });
46
+ });
47
+
48
+ it("missing: no \\documentclass at the top of the tree", () => {
49
+ expect(
50
+ documentClassOf(parseLatex("\\begin{document}\\end{document}")),
51
+ ).toEqual({ kind: "missing" });
52
+ });
53
+
54
+ it("a group in the class name and a paragraph break are read as text", () => {
55
+ const line = documentClassOf(parseLatex("\\documentclass{{IEEE}\n\ntran}"));
56
+ expect(line.kind === "class" && line.cls).toBe("IEEE tran");
57
+ });
58
+ });
59
+
60
+ describe("placeOf / macroPlace — a node the parser gave no position is unplaced, never offset 0", () => {
61
+ const at = (start: number, end: number) => ({
62
+ start: { offset: start, line: 1, column: start + 1 },
63
+ end: { offset: end, line: 1, column: end + 1 },
64
+ });
65
+ it("placeOf: a position is a span; none is unplaced", () => {
66
+ expect(placeOf({ position: at(3, 7) })).toEqual({
67
+ kind: "at",
68
+ span: { start: 3, end: 7 },
69
+ });
70
+ expect(placeOf({})).toEqual({ kind: "unplaced" });
71
+ });
72
+
73
+ it("macroPlace: unplaced without a position; with one, to past the last argument that holds text", () => {
74
+ const bare: Ast.Macro = { type: "macro", content: "documentclass" };
75
+ expect(macroPlace(bare)).toEqual({ kind: "unplaced" });
76
+ const withArgs: Ast.Macro = {
77
+ type: "macro",
78
+ content: "section",
79
+ position: at(0, 8),
80
+ args: [
81
+ { type: "argument", content: [], openMark: "", closeMark: "" },
82
+ {
83
+ type: "argument",
84
+ content: [{ type: "string", content: "A", position: at(9, 10) }],
85
+ openMark: "{",
86
+ closeMark: "}",
87
+ },
88
+ { type: "argument", content: [], openMark: "[", closeMark: "]" },
89
+ ],
90
+ };
91
+ expect(macroPlace(withArgs)).toEqual({
92
+ kind: "at",
93
+ span: { start: 0, end: 11 },
94
+ });
95
+ });
96
+ });
97
+
98
+ describe("a macro with no arguments, and markup in a name", () => {
99
+ const bare: Ast.Macro = { type: "macro", content: "foo" };
100
+ it("has no mandatory or optional arguments, and its place is its name", () => {
101
+ expect(mandatory(bare)).toEqual([]);
102
+ expect(optional(bare)).toBe(undefined);
103
+ expect(
104
+ macroPlace({
105
+ ...bare,
106
+ position: {
107
+ start: { offset: 2, line: 1, column: 3 },
108
+ end: { offset: 6, line: 1, column: 7 },
109
+ },
110
+ }),
111
+ ).toEqual({ kind: "at", span: { start: 2, end: 6 } });
112
+ });
113
+
114
+ it("textOf drops markup it cannot read as text, and reads nothing as empty", () => {
115
+ expect(textOf([bare, { type: "string", content: "x" }])).toBe("x");
116
+ expect(textOf(undefined)).toBe("");
117
+ });
118
+ });
119
+
120
+ describe("parseTemplate — a preset's template", () => {
121
+ it.each<[string, { cls: string; options: string[] } | null]>([
122
+ ["\\documentclass[conference,compsoc]{IEEEtran}", IEEE],
123
+ ["article", { cls: "article", options: [] }],
124
+ [" acmart\n", { cls: "acmart", options: [] }],
125
+ ["two words", null],
126
+ ["% c\n\\documentclass{article}\n", { cls: "article", options: [] }],
127
+ ["\\documentclass{article} trailing", null],
128
+ ["\\documentclass[a]{}", null],
129
+ ["", null],
130
+ ])("%j → %j", (text, want) => {
131
+ expect(parseTemplate(text)).toEqual(want);
132
+ });
133
+ });
134
+
135
+ const replaced = (src: string) => replaceDocumentClass(parseLatex(src), IEEE);
136
+
137
+ describe("replaceDocumentClass — the class `paperlint new` writes", () => {
138
+ it("replaces the whole line, arguments included, and keeps everything around it", () => {
139
+ expect(replaced("% c\n\\documentclass[11pt]{article}\n\\title{x}\n")).toBe(
140
+ "% c\n\\documentclass[conference,compsoc]{IEEEtran}\n\\title{x}\n",
141
+ );
142
+ });
143
+
144
+ it("a class that already satisfies the template keeps the author's own options", () => {
145
+ const src = "\\documentclass[compsoc,conference,a4paper]{IEEEtran}\nx";
146
+ expect(replaced(src)).toBe(src);
147
+ });
148
+
149
+ it("the right class missing a required option keeps the author's options and adds the missing ones", () => {
150
+ expect(
151
+ replaced("\\documentclass[review,anonymous,conference]{IEEEtran}\nx"),
152
+ ).toBe("\\documentclass[review,anonymous,conference,compsoc]{IEEEtran}\nx");
153
+ });
154
+
155
+ it("an option with a braced value keeps its braces, its commas and its spacing", () => {
156
+ expect(replaced("\\documentclass[review, foo={a,b}]{IEEEtran}\nx")).toBe(
157
+ "\\documentclass[review, foo={a,b},conference,compsoc]{IEEEtran}\nx",
158
+ );
159
+ });
160
+
161
+ it("no class, or an empty one, is returned as it is", () => {
162
+ expect(replaced("just text")).toBe("just text");
163
+ expect(replaced("\\documentclass{}\nx")).toBe("\\documentclass{}\nx");
164
+ });
165
+
166
+ it("the port does the same over a source", () => {
167
+ expect(
168
+ latexReader.withDocumentClass("\\documentclass{article}", IEEE),
169
+ ).toBe("\\documentclass[conference,compsoc]{IEEEtran}");
170
+ });
171
+ });
172
+
173
+ describe("replaceDocumentClass — missing options are added in place, the author's bytes kept", () => {
174
+ it("🔴 the missing options go before the `]`: the author's bytes, a comment included, stay as written", () => {
175
+ expect(replaced("\\documentclass[foo=bar% note\nbaz]{IEEEtran}\nx")).toBe(
176
+ "\\documentclass[foo=bar% note\nbaz,conference,compsoc]{IEEEtran}\nx",
177
+ );
178
+ const acm = "\\documentclass[review,% reason\nanonymous]{acmart}\nx";
179
+ expect(
180
+ replaceDocumentClass(parseLatex(acm), {
181
+ cls: "acmart",
182
+ options: ["sigconf"],
183
+ }),
184
+ ).toBe("\\documentclass[review,% reason\nanonymous,sigconf]{acmart}\nx");
185
+ });
186
+
187
+ it("a trailing comment in the options: the missing ones go on the next line, before the `]`", () => {
188
+ expect(replaced("\\documentclass[review% c\n]{IEEEtran}")).toBe(
189
+ "\\documentclass[review% c\n,conference,compsoc]{IEEEtran}",
190
+ );
191
+ });
192
+
193
+ it("no options, an empty `[]`, a blank `[ ]`: the options are written without a stray comma", () => {
194
+ expect(replaced("\\documentclass{IEEEtran}\nx")).toBe(
195
+ "\\documentclass[conference,compsoc]{IEEEtran}\nx",
196
+ );
197
+ expect(replaced("\\documentclass[]{IEEEtran}")).toBe(
198
+ "\\documentclass[conference,compsoc]{IEEEtran}",
199
+ );
200
+ expect(replaced("\\documentclass[ ]{IEEEtran}")).toBe(
201
+ "\\documentclass[ conference,compsoc]{IEEEtran}",
202
+ );
203
+ });
204
+
205
+ it("reading the options: a line comment joins its two sides with no space, as TeX does", () => {
206
+ const read = (src: string) => {
207
+ const line = documentClassOf(parseLatex(src));
208
+ return line.kind === "class" && line.options;
209
+ };
210
+ expect(read("\\documentclass[review,% reason\nanonymous]{acmart}")).toEqual(
211
+ ["review", "anonymous"],
212
+ );
213
+ expect(read("\\documentclass[foo=bar% note\nbaz]{IEEEtran}")).toEqual([
214
+ "foo=barbaz",
215
+ ]);
216
+ });
217
+ });
218
+
219
+ describe("outlineOf — a heading's title", () => {
220
+ it("keeps the text of formatting macros and drops keys such as a label", () => {
221
+ const o = outlineOf(
222
+ parseLatex("\\section*{LLM \\textbf{Usage} Statement\\label{s:llm}}"),
223
+ );
224
+ expect(o.sections.map((h) => h.title)).toEqual(["LLM Usage Statement"]);
225
+ });
226
+ });
227
+
228
+ describe("outlineOf — a formatting macro's text is its last mandatory argument", () => {
229
+ it.each<[string, string]>([
230
+ [
231
+ "\\section*{\\textcolor{red}{LLM Usage Statement}}",
232
+ "LLM Usage Statement",
233
+ ],
234
+ [
235
+ "\\section*{LLM \\emph{Usage} \\textbf{Statement}}",
236
+ "LLM Usage Statement",
237
+ ],
238
+ ["\\section*{See \\href{https://x.org}{the site}}", "See the site"],
239
+ [
240
+ "\\section*{\\texorpdfstring{LLM Usage Statement}{Short}}",
241
+ "LLM Usage Statement",
242
+ ],
243
+ ["\\section*{\\color{red}LLM Usage Statement}", "LLM Usage Statement"],
244
+ ["\\section*{LLM~Usage~Statement}", "LLM Usage Statement"],
245
+ [
246
+ "\\section*{\\colorbox{yellow}{LLM Usage} Statement}",
247
+ "LLM Usage Statement",
248
+ ],
249
+ [
250
+ "\\section*{\\fontsize{10}{12}\\selectfont LLM \\vspace{1em}Usage\\hspace{2pt} Statement}",
251
+ "LLM Usage Statement",
252
+ ],
253
+ [
254
+ "\\section*{\\setlength{\\parskip}{0pt}\\addtolength{\\parskip}{1pt}LLM Usage Statement}",
255
+ "LLM Usage Statement",
256
+ ],
257
+ ])("%s → %s", (src, title) => {
258
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
259
+ title,
260
+ ]);
261
+ });
262
+ });
263
+
264
+ describe("outlineOf", () => {
265
+ it("sections in order with titles collapsed, the first back matter, and the document's end", () => {
266
+ const src =
267
+ "\\documentclass{article}\\section{Pre}\n\\begin{document}\n\\section{A B}\n" +
268
+ "\\section*{C}\n\\appendix\n\\section{D}\n\\bibliography{r}\n\\end{document}";
269
+ const o = outlineOf(parseLatex(src));
270
+ expect(o.sections.map((h) => h.title)).toEqual(["A B", "C", "D"]);
271
+ expect(o.backMatter).toBe(src.indexOf("\\appendix"));
272
+ expect(o.end).toBe(src.length - 1);
273
+ const [a] = o.sections;
274
+ expect(
275
+ a?.place.kind === "at" && src.slice(a.place.span.start, a.place.span.end),
276
+ ).toBe("\\section{A B}");
277
+ });
278
+
279
+ it("a bibliography environment is back matter too; a fragment has no end; no back matter is null", () => {
280
+ const src = "\\section{A}\\begin{thebibliography}{1}\\end{thebibliography}";
281
+ expect(latexReader.outline(src)).toMatchObject({
282
+ backMatter: src.indexOf("\\begin"),
283
+ end: null,
284
+ });
285
+ expect(latexReader.outline("\\section{A}").backMatter).toBe(null);
286
+ });
287
+ });
288
+
289
+ describe("outlineOf — a heading that is never rendered is not in the outline", () => {
290
+ it.each<[string, string]>([
291
+ [
292
+ "a comment environment",
293
+ "\\begin{comment}\\section{Hidden}\\end{comment}",
294
+ ],
295
+ ["a \\newcommand body", "\\newcommand{\\x}{\\section{Hidden}}"],
296
+ ["a \\def body", "\\def\\y{\\section{Hidden}}"],
297
+ ["a \\newenvironment body", "\\newenvironment{e}{\\section{Hidden}}{}"],
298
+ ])("%s", (_, hidden) => {
299
+ const src = `\\begin{document}${hidden}\\section{Real}\\end{document}`;
300
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
301
+ "Real",
302
+ ]);
303
+ });
304
+ });
305
+
306
+ describe("renderedRuns", () => {
307
+ it("runs of strings and spaces, each character mapped to its source offset", () => {
308
+ const src = "A~B \\emph{C D}\n\n E";
309
+ const runs = renderedRuns(parseLatex(src));
310
+ expect(runs.map(runText)).toEqual(["A B ", "C D", " E"]);
311
+ const [first] = runs;
312
+ expect(first && spanIn(first, 2, 3)).toEqual({ start: 2, end: 3 });
313
+ });
314
+
315
+ it("comments, math, code, keys and the bibliography are not text a reader sees", () => {
316
+ const src =
317
+ "x % AISec\n$AISec$ \\cite{AISec} \\label{AISec} \\url{https://AISec.cc}\n" +
318
+ "\\begin{verbatim}AISec\\end{verbatim}\\begin{thebibliography}{1}\\bibitem{a} AISec\\end{thebibliography}";
319
+ expect(
320
+ latexReader
321
+ .renderedRuns(src)
322
+ .map(runText)
323
+ .some((t) => t.includes("AISec")),
324
+ ).toBe(false);
325
+ });
326
+
327
+ it("a citation's key is hidden, its notes in square brackets are text", () => {
328
+ const runs = latexReader
329
+ .renderedRuns("x \\citep[Presented at AISec][p.~3]{zzkey} y")
330
+ .map(runText);
331
+ expect(runs.some((t) => t.includes("Presented at AISec"))).toBe(true);
332
+ expect(runs.some((t) => t.includes("zzkey"))).toBe(false);
333
+ });
334
+
335
+ it("\\href: its URL is hidden, its link text is text", () => {
336
+ const runs = latexReader
337
+ .renderedRuns("x \\href{https://example.org/AISec}{the zzlink text} y")
338
+ .map(runText);
339
+ expect(runs.some((t) => t.includes("AISec"))).toBe(false);
340
+ expect(runs.some((t) => t.includes("the zzlink text"))).toBe(true);
341
+ });
342
+
343
+ it("a macro definition's body is read like prose: the rule does not expand macros", () => {
344
+ const src =
345
+ "\\newcommand{\\oldvenue}{AISec} \\renewcommand\\b{BISec} \\def\\c{CISec} x";
346
+ const text = latexReader.renderedRuns(src).map(runText).join("|");
347
+ expect(
348
+ ["AISec", "BISec", "CISec", "x"].filter((w) => text.includes(w)),
349
+ ).toEqual(["AISec", "BISec", "CISec", "x"]);
350
+ });
351
+
352
+ it("the options of other key macros stay hidden: they are settings, not prose", () => {
353
+ const src = "\\includegraphics[AISec]{f} \\usepackage[AISec]{p}";
354
+ expect(
355
+ latexReader
356
+ .renderedRuns(src)
357
+ .map(runText)
358
+ .some((t) => t.includes("AISec")),
359
+ ).toBe(false);
360
+ });
361
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Reads a paper.tex and answers three questions — checks nothing itself: `documentClassOf` (which
3
+ * class, with which options, and where), `outlineOf` (the sections in order, where the back matter
4
+ * starts, where the document ends) and `renderedRuns` (the text a reader sees, each character with
5
+ * its source offset). Used by `tex/template`, `tex/required-section`, `tex/venue-leftover` and
6
+ * `paperlint new`, through the `LatexReader` port (`src/ports/latex.ts`); `src/cli.ts` wires it.
7
+ *
8
+ * ── WHY THE PARSE TREE AND NOT THE `tex/latex` PROJECTION ────────────────────────
9
+ * The ESLint language's projection (`eslint-rules/latex-language.ts`) blanks the preamble,
10
+ * `\documentclass`, `\author` and every other macro in its OPAQUE list, because prose rules must
11
+ * not see markup. These questions are about exactly that markup: the class line, a `\section*`
12
+ * title, an `\institution{…}` in the author block. So the source is parsed here with unified-latex,
13
+ * the parser the language already uses.
14
+ *
15
+ * ── STATES, NOT NULL ─────────────────────────────────────────────────────────────
16
+ * A class line is missing, empty or a class; a node the parser gave no position is `unplaced`, not
17
+ * offset 0; a run of text is a non-empty list of contiguous segments, and a range outside it has
18
+ * no span (`spanIn` in `src/domain/tex-document.ts`). A tree travels only with its own source
19
+ * (`ParsedTex`, minted by `parseLatex` alone).
20
+ */
21
+ import type { LatexReader } from "../../ports/latex.ts";
22
+ import {
23
+ documentClassOf,
24
+ parseTemplate,
25
+ replaceDocumentClass,
26
+ } from "./document-class.ts";
27
+ import { outlineOf } from "./outline.ts";
28
+ import { parseLatex } from "./parse.ts";
29
+ import { renderedRuns } from "./rendered.ts";
30
+
31
+ export {
32
+ documentClassOf,
33
+ parseTemplate,
34
+ replaceDocumentClass,
35
+ } from "./document-class.ts";
36
+ export {
37
+ collapse,
38
+ macroPlace,
39
+ mandatory,
40
+ optional,
41
+ placeOf,
42
+ textOf,
43
+ } from "./nodes.ts";
44
+ export { outlineOf } from "./outline.ts";
45
+ export { parseLatex, type ParsedTex } from "./parse.ts";
46
+ export { renderedRuns } from "./rendered.ts";
47
+
48
+ /** The `LatexReader` port over unified-latex: each call parses its source once. */
49
+ export const latexReader: LatexReader = {
50
+ documentClass: (src) => documentClassOf(parseLatex(src)),
51
+ outline: (src) => outlineOf(parseLatex(src)),
52
+ renderedRuns: (src) => renderedRuns(parseLatex(src)),
53
+ template: parseTemplate,
54
+ withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
55
+ };