paperlint 6.3.0 → 7.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.
- package/CONTRIBUTING.md +4 -2
- package/README.md +20 -13
- package/dist/adapters/latex/conditionals.d.ts +21 -0
- package/dist/adapters/latex/conditionals.d.ts.map +1 -0
- package/dist/adapters/latex/conditionals.js +75 -0
- package/dist/adapters/latex/conditionals.js.map +1 -0
- package/dist/adapters/latex/headings.d.ts +10 -0
- package/dist/adapters/latex/headings.d.ts.map +1 -0
- package/dist/adapters/latex/headings.js +129 -0
- package/dist/adapters/latex/headings.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +3 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +3 -2
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -1
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -5
- package/dist/cli.js.map +1 -1
- package/dist/domain/body-pages.d.ts +29 -0
- package/dist/domain/body-pages.d.ts.map +1 -1
- package/dist/domain/body-pages.js +25 -0
- package/dist/domain/body-pages.js.map +1 -1
- package/dist/domain/heading-case.d.ts +100 -0
- package/dist/domain/heading-case.d.ts.map +1 -0
- package/dist/domain/heading-case.js +365 -0
- package/dist/domain/heading-case.js.map +1 -0
- package/dist/domain/tex-document.d.ts +32 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js +11 -0
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/eslint-rules/latex-language.d.ts.map +1 -1
- package/dist/eslint-rules/latex-language.js +16 -3
- package/dist/eslint-rules/latex-language.js.map +1 -1
- package/dist/facts-file.d.ts +3 -3
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +3 -3
- package/dist/facts-file.js.map +1 -1
- package/dist/folder-venue-rule.js +6 -6
- package/dist/folder-venue-rule.js.map +1 -1
- package/dist/heading-case.d.ts +48 -0
- package/dist/heading-case.d.ts.map +1 -0
- package/dist/heading-case.js +191 -0
- package/dist/heading-case.js.map +1 -0
- package/dist/paper-includes.d.ts +3 -0
- package/dist/paper-includes.d.ts.map +1 -1
- package/dist/paper-includes.js +17 -2
- package/dist/paper-includes.js.map +1 -1
- package/dist/ports/latex.d.ts +7 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/preset-card-rule.d.ts +100 -0
- package/dist/preset-card-rule.d.ts.map +1 -0
- package/dist/preset-card-rule.js +221 -0
- package/dist/preset-card-rule.js.map +1 -0
- package/dist/presets.d.ts +29 -5
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +104 -68
- package/dist/presets.js.map +1 -1
- package/dist/submission.d.ts +8 -0
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +19 -7
- package/dist/submission.js.map +1 -1
- package/dist/tex-requirements.d.ts +42 -3
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +34 -5
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +6 -3
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +29 -10
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/venue-rules.d.ts +2 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +55 -7
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +13 -1
- package/docs/e2e.md +14 -12
- package/docs/optional-rules.md +4 -3
- package/docs/rules/format/page-limit.md +19 -2
- package/docs/rules/paper/folder-venue-leftover.md +1 -1
- package/docs/rules/talk/profile.md +1 -1
- package/docs/rules/tex/heading-case.md +424 -0
- package/docs/rules/tex/template.md +25 -5
- package/docs/rules/tex/venue-leftover.md +13 -0
- package/docs/rules.md +74 -57
- package/docs/submission.md +5 -1
- package/eslint-rules/latex-language.test.mjs +12 -0
- package/eslint-rules/latex-language.ts +17 -4
- package/eslint-rules/paper-typography.test.mjs +12 -0
- package/eslint.config.mjs +59 -1
- package/fixtures/build-e2e/acmart/paper.tex +1 -1
- package/fixtures/build-e2e/fallback/expect.json +3 -3
- package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr/expect.json +18 -0
- package/fixtures/build-e2e/msr/paper.tex +49 -0
- package/fixtures/build-e2e/msr/paperlint.json +1 -0
- package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
- package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
- package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
- package/lib/skill-checks.mjs +31 -0
- package/package.json +7 -1
- package/presets/acm-sigconf.jsonc +5 -0
- package/presets/acm-sigconf.md +43 -0
- package/presets/agenticdev.jsonc +27 -6
- package/presets/agenticdev.md +151 -0
- package/presets/aidc.jsonc +6 -0
- package/presets/aidc.md +75 -0
- package/presets/aisec.jsonc +5 -1
- package/presets/aisec.md +117 -0
- package/presets/ieee-conference.jsonc +5 -0
- package/presets/ieee-conference.md +38 -0
- package/presets/msr.jsonc +71 -0
- package/presets/msr.md +94 -0
- package/presets/realm.jsonc +7 -2
- package/presets/realm.md +166 -0
- package/presets/tex/agenticdev.tex +1 -1
- package/presets/tex-base.jsonc +6 -0
- package/presets/tex-base.md +30 -0
- package/presets/venue-profile.schema.json +65 -1
- package/skills/camera-ready/SKILL.md +8 -1
- package/skills/camera-ready/SKILL.md.spec.ts +7 -0
- package/skills/find-venue/SKILL.md +6 -5
- package/skills/find-venue/SKILL.md.spec.ts +5 -4
- package/skills/paper-pipeline/SKILL.md +5 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
- package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
- package/skills/study-accepted-papers/SKILL.md +3 -3
- package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
- package/skills/submission-portal/SKILL.md +2 -2
- package/skills/submission-portal/SKILL.md.spec.ts +1 -1
- package/skills/submit-paper/SKILL.md +34 -22
- package/skills/submit-paper/SKILL.md.spec.ts +33 -21
- package/skills/submit-paper/references/publishers/acm.md +1 -1
- package/src/adapters/latex/conditionals.ts +130 -0
- package/src/adapters/latex/headings.ts +163 -0
- package/src/adapters/latex/index.test.ts +65 -0
- package/src/adapters/latex/index.ts +3 -0
- package/src/adapters/latex/parse.ts +3 -2
- package/src/build.ts +2 -2
- package/src/cli.test.ts +50 -7
- package/src/cli.ts +28 -6
- package/src/domain/body-pages.test.ts +144 -0
- package/src/domain/body-pages.ts +61 -1
- package/src/domain/heading-case.test.ts +660 -0
- package/src/domain/heading-case.ts +538 -0
- package/src/domain/tex-document.ts +36 -0
- package/src/domain/venue-name.test.ts +13 -7
- package/src/facts-file.test.ts +4 -4
- package/src/facts-file.ts +6 -6
- package/src/folder-venue-rule.test.ts +10 -10
- package/src/folder-venue-rule.ts +6 -6
- package/src/heading-case.test.ts +500 -0
- package/src/heading-case.ts +275 -0
- package/src/init.test.ts +1 -1
- package/src/new-paper.test.ts +37 -6
- package/src/paper-includes.test.ts +26 -0
- package/src/paper-includes.ts +20 -2
- package/src/paper-settings-commands.test.ts +26 -11
- package/src/ports/latex.ts +7 -0
- package/src/preset-card-rule.test.ts +267 -0
- package/src/preset-card-rule.ts +384 -0
- package/src/presets.test.ts +259 -41
- package/src/presets.ts +161 -75
- package/src/rule-cases.test.ts +136 -50
- package/src/submission.test.ts +44 -4
- package/src/submission.ts +28 -7
- package/src/talk-rules.test.ts +6 -3
- package/src/tex-requirements.harness.ts +25 -11
- package/src/tex-requirements.test.ts +127 -1
- package/src/tex-requirements.ts +91 -12
- package/src/tex-venue-rules.test.ts +196 -21
- package/src/tex-venue-rules.ts +47 -16
- package/src/venue-rules.test.ts +180 -8
- package/src/venue-rules.ts +71 -10
- package/templates/paper/paper.tex +1 -1
- package/skills/submit-paper/references/venues/agenticdev.md +0 -171
- package/skills/submit-paper/references/venues/aisec.md +0 -105
- package/skills/submit-paper/references/venues/realm.md +0 -155
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `\iffalse … \fi`: text TeX never reads. Authors park old titles, sections and paragraphs there,
|
|
3
|
+
* and every reader of the tree — the outline, the rendered text, the headings the case judge reads,
|
|
4
|
+
* the includes — would otherwise take them for the paper. So the branch is cut out of the tree once,
|
|
5
|
+
* when the source is parsed (`parseLatex`), rather than pruned per walk like `hidden.ts` prunes: a
|
|
6
|
+
* walk can skip a node, but the rendered text reads the strings of a node list directly, and they
|
|
7
|
+
* would come back.
|
|
8
|
+
*
|
|
9
|
+
* What is cut: from `\iffalse` to its matching `\fi`, both included, counting the conditionals
|
|
10
|
+
* nested in between. An `\else` at the same depth ends the dead part: what follows it is read, and
|
|
11
|
+
* the matching `\fi` is cut. A conditional is a macro whose name starts with `if`, except
|
|
12
|
+
* `\ifthenelse` (the `ifthen` package's command, which takes arguments and has no `\fi`). An
|
|
13
|
+
* `\iffalse` with no `\fi` cuts to the end of its list, as TeX would read to the end of the file.
|
|
14
|
+
*
|
|
15
|
+
* Only `\iffalse` is decided. `\iftrue`, `\if\venue1` and every switch made with `\newif` depend on
|
|
16
|
+
* a value this reader does not compute, so both of their branches stay.
|
|
17
|
+
*/
|
|
18
|
+
import type * as Ast from "@unified-latex/unified-latex-types";
|
|
19
|
+
|
|
20
|
+
type Node = Readonly<Ast.Node>;
|
|
21
|
+
|
|
22
|
+
/** Where the scan of one node list stands: reading, inside a dead branch, or in its `\else`. */
|
|
23
|
+
type Mode =
|
|
24
|
+
| { readonly kind: "live" }
|
|
25
|
+
| { readonly kind: "dead"; readonly depth: number }
|
|
26
|
+
| { readonly kind: "else"; readonly depth: number };
|
|
27
|
+
|
|
28
|
+
/** One node against the mode before it: the mode after it, and whether the node is kept. */
|
|
29
|
+
interface Step {
|
|
30
|
+
readonly mode: Mode;
|
|
31
|
+
readonly keep: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LIVE: Mode = { kind: "live" };
|
|
35
|
+
|
|
36
|
+
const isMacro = (n: Node, name: string): boolean =>
|
|
37
|
+
n.type === "macro" && n.content === name;
|
|
38
|
+
|
|
39
|
+
/** A macro that opens a conditional TeX closes with `\fi`. */
|
|
40
|
+
const opensConditional = (n: Node): boolean =>
|
|
41
|
+
n.type === "macro" &&
|
|
42
|
+
n.content.startsWith("if") &&
|
|
43
|
+
n.content !== "ifthenelse";
|
|
44
|
+
|
|
45
|
+
/** Inside the dead branch: everything is cut; nesting is counted, `\else` at depth 1 ends it. */
|
|
46
|
+
function stepDead(depth: number, n: Node): Step {
|
|
47
|
+
if (opensConditional(n))
|
|
48
|
+
return { mode: { kind: "dead", depth: depth + 1 }, keep: false };
|
|
49
|
+
if (isMacro(n, "fi"))
|
|
50
|
+
return {
|
|
51
|
+
mode: depth === 1 ? LIVE : { kind: "dead", depth: depth - 1 },
|
|
52
|
+
keep: false,
|
|
53
|
+
};
|
|
54
|
+
if (isMacro(n, "else") && depth === 1)
|
|
55
|
+
return { mode: { kind: "else", depth }, keep: false };
|
|
56
|
+
return { mode: { kind: "dead", depth }, keep: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** In the `\else` branch: everything is kept but the `\fi` that closes the `\iffalse`. */
|
|
60
|
+
function stepElse(depth: number, n: Node): Step {
|
|
61
|
+
if (opensConditional(n))
|
|
62
|
+
return { mode: { kind: "else", depth: depth + 1 }, keep: true };
|
|
63
|
+
if (isMacro(n, "fi"))
|
|
64
|
+
return depth === 1
|
|
65
|
+
? { mode: LIVE, keep: false }
|
|
66
|
+
: { mode: { kind: "else", depth: depth - 1 }, keep: true };
|
|
67
|
+
return { mode: { kind: "else", depth }, keep: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function step(mode: Mode, n: Node): Step {
|
|
71
|
+
switch (mode.kind) {
|
|
72
|
+
case "live":
|
|
73
|
+
return isMacro(n, "iffalse")
|
|
74
|
+
? { mode: { kind: "dead", depth: 1 }, keep: false }
|
|
75
|
+
: { mode, keep: true };
|
|
76
|
+
case "dead":
|
|
77
|
+
return stepDead(mode.depth, n);
|
|
78
|
+
case "else":
|
|
79
|
+
return stepElse(mode.depth, n);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A node list without its `\iffalse` branches, each kept node cleaned the same way. */
|
|
84
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- the tree's own content type is a mutable array
|
|
85
|
+
function liveList(nodes: readonly Node[]): Ast.Node[] {
|
|
86
|
+
const { kept } = nodes.reduce<{
|
|
87
|
+
readonly mode: Mode;
|
|
88
|
+
readonly kept: readonly Ast.Node[];
|
|
89
|
+
}>(
|
|
90
|
+
(acc, n) => {
|
|
91
|
+
const next = step(acc.mode, n);
|
|
92
|
+
return {
|
|
93
|
+
mode: next.mode,
|
|
94
|
+
kept: next.keep ? [...acc.kept, liveNode(n)] : acc.kept,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
{ mode: LIVE, kept: [] },
|
|
98
|
+
);
|
|
99
|
+
return [...kept];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- the tree's own argument type is mutable
|
|
103
|
+
const liveArgs = (args: readonly Readonly<Ast.Argument>[]): Ast.Argument[] =>
|
|
104
|
+
args.map((a) => ({ ...a, content: liveList(a.content) }));
|
|
105
|
+
|
|
106
|
+
/** A node with its content and its arguments' content cleaned; a node with neither, as it is. */
|
|
107
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- the tree's own node type is mutable
|
|
108
|
+
function liveNode(n: Node): Ast.Node {
|
|
109
|
+
if (n.type === "macro")
|
|
110
|
+
return n.args === undefined ? n : { ...n, args: liveArgs(n.args) };
|
|
111
|
+
if (n.type === "environment" || n.type === "mathenv")
|
|
112
|
+
return {
|
|
113
|
+
...n,
|
|
114
|
+
content: liveList(n.content),
|
|
115
|
+
...(n.args === undefined ? {} : { args: liveArgs(n.args) }),
|
|
116
|
+
};
|
|
117
|
+
if (n.type === "group" || n.type === "inlinemath" || n.type === "displaymath")
|
|
118
|
+
return { ...n, content: liveList(n.content) };
|
|
119
|
+
return n;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The tree without what `\iffalse … \fi` hides; the same tree when the source has no `\iffalse`. */
|
|
123
|
+
export const withoutFalseBranches = (
|
|
124
|
+
src: string,
|
|
125
|
+
root: Readonly<Ast.Root>,
|
|
126
|
+
// eslint-disable-next-line functional/prefer-immutable-types -- `ParsedTex.root` is the tree's own mutable type
|
|
127
|
+
): Ast.Root =>
|
|
128
|
+
src.includes("\\iffalse")
|
|
129
|
+
? { ...root, content: liveList(root.content) }
|
|
130
|
+
: root;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paper's `\title` and the titles of its headings as the case judge reads them: each as one run
|
|
3
|
+
* of text, every character with the source offset it came from.
|
|
4
|
+
*
|
|
5
|
+
* What a title says is its strings and spaces, and the words a formatting command wraps
|
|
6
|
+
* (`\emph{very}` says "very"). A label, a footnote or an index entry says nothing in the title and
|
|
7
|
+
* is left out. Anything else — math, `\texttt`, `\cite`, `\textsc`, an accent, an environment — is
|
|
8
|
+
* something the reader sees and the judge does not read, so it stands as `OPAQUE`, one character,
|
|
9
|
+
* where it is: the judge then knows a word is first or last only when nothing stands before or
|
|
10
|
+
* after it, and never reads a word that touches one.
|
|
11
|
+
*/
|
|
12
|
+
import { hasAtLeast } from "remeda";
|
|
13
|
+
import type {
|
|
14
|
+
HeadingLevel,
|
|
15
|
+
Segment,
|
|
16
|
+
TitleArgument,
|
|
17
|
+
TitledCommand,
|
|
18
|
+
TitledHeading,
|
|
19
|
+
} from "../../domain/tex-document.ts";
|
|
20
|
+
import { LINE_BREAK, OPAQUE } from "../../domain/tex-document.ts";
|
|
21
|
+
import { isDefinition, isUnrendered } from "./hidden.ts";
|
|
22
|
+
import {
|
|
23
|
+
inPlace,
|
|
24
|
+
isNode,
|
|
25
|
+
mandatory,
|
|
26
|
+
optional,
|
|
27
|
+
placeOf,
|
|
28
|
+
stringText,
|
|
29
|
+
visited,
|
|
30
|
+
type Macro,
|
|
31
|
+
type Node,
|
|
32
|
+
} from "./nodes.ts";
|
|
33
|
+
import type { ParsedTex } from "./parse.ts";
|
|
34
|
+
|
|
35
|
+
const LEVELS: ReadonlyMap<string, HeadingLevel> = new Map<string, HeadingLevel>(
|
|
36
|
+
[
|
|
37
|
+
["section", "section"],
|
|
38
|
+
["subsection", "subsection"],
|
|
39
|
+
["subsubsection", "subsubsection"],
|
|
40
|
+
["paragraph", "paragraph"],
|
|
41
|
+
["subparagraph", "subparagraph"],
|
|
42
|
+
],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Formatting commands: they say what their argument says. */
|
|
46
|
+
const TRANSPARENT: ReadonlySet<string> = new Set([
|
|
47
|
+
"emph",
|
|
48
|
+
"textit",
|
|
49
|
+
"textbf",
|
|
50
|
+
"textsl",
|
|
51
|
+
"textup",
|
|
52
|
+
"textmd",
|
|
53
|
+
"textnormal",
|
|
54
|
+
"textsf",
|
|
55
|
+
"textrm",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** Commands that break the line: the title goes on as a second part, a subtitle. */
|
|
59
|
+
const LINE_BREAKS: ReadonlySet<string> = new Set([
|
|
60
|
+
"\\\\",
|
|
61
|
+
"newline",
|
|
62
|
+
"linebreak",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Commands that put nothing in the title's words: a label, a footnote, an index entry. */
|
|
66
|
+
const INVISIBLE: ReadonlySet<string> = new Set([
|
|
67
|
+
"label",
|
|
68
|
+
"footnote",
|
|
69
|
+
"thanks",
|
|
70
|
+
"index",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The command a macro is, for the case judge: the paper's `\title`, a heading at its level, or none.
|
|
75
|
+
* `\title` is read wherever it stands — in the preamble, where acmart and IEEEtran put it, or in a
|
|
76
|
+
* file the paper includes.
|
|
77
|
+
*/
|
|
78
|
+
const commandOf = (m: Macro): readonly TitledCommand[] => {
|
|
79
|
+
if (m.content === "title") return [{ kind: "title" }];
|
|
80
|
+
const level = LEVELS.get(m.content);
|
|
81
|
+
return level === undefined ? [] : [{ kind: "heading", level }];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** A macro that is neither left out of the title nor read as the words it wraps. */
|
|
85
|
+
const isOpaqueMacro = (m: Macro): boolean =>
|
|
86
|
+
!INVISIBLE.has(m.content) &&
|
|
87
|
+
!TRANSPARENT.has(m.content) &&
|
|
88
|
+
!LINE_BREAKS.has(m.content);
|
|
89
|
+
|
|
90
|
+
/** What one macro says in a title. */
|
|
91
|
+
function macroSegments(m: Macro): readonly Segment[] {
|
|
92
|
+
if (INVISIBLE.has(m.content)) return [];
|
|
93
|
+
if (LINE_BREAKS.has(m.content)) return textAt(m, LINE_BREAK);
|
|
94
|
+
if (TRANSPARENT.has(m.content))
|
|
95
|
+
return segmentsOf(mandatory(m).at(-1)?.content);
|
|
96
|
+
return textAt(m, OPAQUE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A piece of text at the start of a node. */
|
|
100
|
+
const textAt = (n: Node, text: string): readonly Segment[] =>
|
|
101
|
+
inPlace(placeOf(n), (s) => [{ text, at: s.start }]);
|
|
102
|
+
|
|
103
|
+
/** What one node says in a title: text as itself, a formatting command's words, anything else opaque. */
|
|
104
|
+
function nodeSegments(n: Node): readonly Segment[] {
|
|
105
|
+
if (n.type === "string") return textAt(n, stringText(n.content));
|
|
106
|
+
if (n.type === "whitespace" || n.type === "parbreak") return textAt(n, " ");
|
|
107
|
+
// A comment ends its line: the words on either side of it are two words.
|
|
108
|
+
if (n.type === "comment") return textAt(n, " ");
|
|
109
|
+
if (n.type === "group") return segmentsOf(n.content);
|
|
110
|
+
if (n.type === "macro") return macroSegments(n);
|
|
111
|
+
return textAt(n, OPAQUE);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A group that is the very next node after a command the parser has no signature for (`\ours{…}`, a macro of the
|
|
116
|
+
* author's own): the parser leaves such a group beside the macro, but TeX hands it to the macro as an
|
|
117
|
+
* argument, so it is part of what the macro prints and not words of the title. A chain of groups
|
|
118
|
+
* (`\findingdes{label}{a sentence}`) is all argument.
|
|
119
|
+
*/
|
|
120
|
+
function isArgumentGroup(nodes: readonly Node[], n: Node, i: number): boolean {
|
|
121
|
+
const prev = nodes[i - 1];
|
|
122
|
+
if (prev === undefined || n.type !== "group") return false;
|
|
123
|
+
return prev.type === "macro"
|
|
124
|
+
? isOpaqueMacro(prev)
|
|
125
|
+
: isArgumentGroup(nodes, prev, i - 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const segmentsOf = (nodes: readonly Node[] | undefined): readonly Segment[] => {
|
|
129
|
+
const list = nodes ?? [];
|
|
130
|
+
return list.flatMap((n, i) =>
|
|
131
|
+
isArgumentGroup(list, n, i) ? [] : nodeSegments(n),
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** One argument's contents → the heading it makes, or none when it says nothing. */
|
|
136
|
+
function titled(
|
|
137
|
+
command: TitledCommand,
|
|
138
|
+
argument: TitleArgument,
|
|
139
|
+
nodes: readonly Node[] | undefined,
|
|
140
|
+
): readonly TitledHeading[] {
|
|
141
|
+
const segments = segmentsOf(nodes);
|
|
142
|
+
return hasAtLeast(segments, 1)
|
|
143
|
+
? [{ command, argument, title: { segments } }]
|
|
144
|
+
: [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One titled command → the short title (`[…]`) and the title (the last `{…}`), each judged. */
|
|
148
|
+
const headingsOfMacro = (m: Macro): readonly TitledHeading[] =>
|
|
149
|
+
commandOf(m).flatMap((command) => [
|
|
150
|
+
...titled(command, "short", optional(m)?.content),
|
|
151
|
+
...titled(command, "title", mandatory(m).at(-1)?.content),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The paper's `\title` and every `\section`, `\subsection`, `\subsubsection`, `\paragraph` and
|
|
156
|
+
* `\subparagraph`, starred or not, each with its optional short title, in document order. What the
|
|
157
|
+
* PDF never shows is pruned (`isUnrendered`), and so is a macro definition's body: a heading in a
|
|
158
|
+
* comment environment or in a definition is not a heading where it is written.
|
|
159
|
+
*/
|
|
160
|
+
export const headingsOf = (t: ParsedTex): readonly TitledHeading[] =>
|
|
161
|
+
visited(t.root, (v, parent) => isUnrendered(v, parent) || isDefinition(v))
|
|
162
|
+
.filter(isNode)
|
|
163
|
+
.flatMap((n) => (n.type === "macro" ? headingsOfMacro(n) : []));
|
|
@@ -324,6 +324,71 @@ describe("outlineOf — a heading that is never rendered is not in the outline",
|
|
|
324
324
|
});
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
+
describe("\\iffalse … \\fi — a branch TeX never reads is not in the tree any reader walks", () => {
|
|
328
|
+
const dead = [
|
|
329
|
+
"A",
|
|
330
|
+
"\\iffalse",
|
|
331
|
+
"\\section{Old Title}",
|
|
332
|
+
"dead words",
|
|
333
|
+
"\\ifx\\a\\b nested\\fi",
|
|
334
|
+
"still dead",
|
|
335
|
+
"\\fi",
|
|
336
|
+
"B",
|
|
337
|
+
].join("\n");
|
|
338
|
+
const doc = (body: string) => `\\begin{document}\n${body}\n\\end{document}`;
|
|
339
|
+
|
|
340
|
+
it("🔴 outline, headings and rendered text skip it, nested conditionals included", () => {
|
|
341
|
+
const t = parseLatex(doc(dead));
|
|
342
|
+
expect(outlineOf(t).sections).toEqual([]);
|
|
343
|
+
expect(latexReader.headings(doc(dead))).toEqual([]);
|
|
344
|
+
// Guards: the dead strings are gone from the list itself, so A and B are one run.
|
|
345
|
+
expect(
|
|
346
|
+
renderedRuns(t).map((r) => runText(r).replace(/\s+/gu, " ").trim()),
|
|
347
|
+
).toEqual(["A B"]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("the \\else branch of an \\iffalse is read, and the text after its \\fi", () => {
|
|
351
|
+
const src = doc(
|
|
352
|
+
"\\iffalse\n\\section{Old}\n\\else\n\\section{New}\n\\fi\n\\section{After}",
|
|
353
|
+
);
|
|
354
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
355
|
+
"New",
|
|
356
|
+
"After",
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("inside a group too, and `\\ifthenelse` (which has no \\fi) does not swallow the rest", () => {
|
|
361
|
+
const src = doc(
|
|
362
|
+
"{\\iffalse\\section{Old}\\ifthenelse{x}{y}{z}\\fi}\\section{Real}",
|
|
363
|
+
);
|
|
364
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
365
|
+
"Real",
|
|
366
|
+
]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("inside an environment with arguments, in math, and an \\else holding its own conditional", () => {
|
|
370
|
+
const src = doc(
|
|
371
|
+
[
|
|
372
|
+
"\\begin{minipage}{4cm}\\iffalse\\section{Old}\\fi\\section{Kept}\\end{minipage}",
|
|
373
|
+
"$x \\iffalse y \\fi$ \\[ z \\] \\begin{align*} a \\end{align*}",
|
|
374
|
+
"\\iffalse\\section{Old}\\else\\ifx\\a\\b\\section{Inner}\\fi\\section{New}\\fi",
|
|
375
|
+
].join("\n"),
|
|
376
|
+
);
|
|
377
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
378
|
+
"Kept",
|
|
379
|
+
"Inner",
|
|
380
|
+
"New",
|
|
381
|
+
]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("an \\iffalse that is never closed leaves out everything after it, as TeX does", () => {
|
|
385
|
+
const src = doc("\\section{Real}\n\\iffalse\n\\section{Old}");
|
|
386
|
+
expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
|
|
387
|
+
"Real",
|
|
388
|
+
]);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
327
392
|
describe("renderedRuns", () => {
|
|
328
393
|
it("runs of strings and spaces, each character mapped to its source offset", () => {
|
|
329
394
|
const src = "A~B \\emph{C D}\n\n E";
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
parseTemplate,
|
|
27
27
|
replaceDocumentClass,
|
|
28
28
|
} from "./document-class.ts";
|
|
29
|
+
import { headingsOf } from "./headings.ts";
|
|
29
30
|
import { documentBodyOf, includesOf } from "./includes.ts";
|
|
30
31
|
import { outlineOf } from "./outline.ts";
|
|
31
32
|
import { parseLatex } from "./parse.ts";
|
|
@@ -48,6 +49,7 @@ export {
|
|
|
48
49
|
textOf,
|
|
49
50
|
} from "./nodes.ts";
|
|
50
51
|
export { documentBodyOf, includesOf } from "./includes.ts";
|
|
52
|
+
export { headingsOf } from "./headings.ts";
|
|
51
53
|
export { outlineOf } from "./outline.ts";
|
|
52
54
|
export { parseLatex, type ParsedTex } from "./parse.ts";
|
|
53
55
|
export { bodyEmphasis, bodyProse } from "./prose.ts";
|
|
@@ -58,6 +60,7 @@ export { layoutOverridesOf } from "./layout.ts";
|
|
|
58
60
|
export const latexReader: LatexReader = {
|
|
59
61
|
documentClass: (src) => documentClassOf(parseLatex(src)),
|
|
60
62
|
outline: (src) => outlineOf(parseLatex(src)),
|
|
63
|
+
headings: (src) => headingsOf(parseLatex(src)),
|
|
61
64
|
renderedRuns: (src) => renderedRuns(parseLatex(src)),
|
|
62
65
|
bodyProse: (src) => bodyProse(parseLatex(src)),
|
|
63
66
|
bodyEmphasis: (src) => bodyEmphasis(parseLatex(src)),
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type * as Ast from "@unified-latex/unified-latex-types";
|
|
13
13
|
import { getParser } from "@unified-latex/unified-latex-util-parse";
|
|
14
14
|
import { once } from "remeda";
|
|
15
|
+
import { withoutFalseBranches } from "./conditionals.ts";
|
|
15
16
|
|
|
16
17
|
const signed = (
|
|
17
18
|
names: readonly string[],
|
|
@@ -133,9 +134,9 @@ const parser = once(() =>
|
|
|
133
134
|
}),
|
|
134
135
|
);
|
|
135
136
|
|
|
136
|
-
/** A LaTeX source → its tree. */
|
|
137
|
+
/** A LaTeX source → its tree, without the branches `\iffalse … \fi` hides (`conditionals.ts`). */
|
|
137
138
|
export const parseLatex = (src: string): ParsedTex => ({
|
|
138
139
|
src,
|
|
139
|
-
root: parser().parse(src),
|
|
140
|
+
root: withoutFalseBranches(src, parser().parse(src)),
|
|
140
141
|
[PARSED]: true,
|
|
141
142
|
});
|
package/src/build.ts
CHANGED
|
@@ -121,7 +121,7 @@ export interface PaperFacts {
|
|
|
121
121
|
readonly name: string;
|
|
122
122
|
readonly options: readonly string[];
|
|
123
123
|
} | null;
|
|
124
|
-
/** The
|
|
124
|
+
/** The id of the venue preset `paperlint.json` extends (`agenticdev`), or null. */
|
|
125
125
|
readonly venue: string | null;
|
|
126
126
|
/** Paper-supplied build scripts found on disk — reported as ignored. */
|
|
127
127
|
readonly ignoredScripts: readonly string[];
|
|
@@ -239,7 +239,7 @@ export function readFacts(paperDir: string): PaperFacts {
|
|
|
239
239
|
return {
|
|
240
240
|
main,
|
|
241
241
|
documentclass: documentclassOf(ast),
|
|
242
|
-
venue: venue?.
|
|
242
|
+
venue: venue?.id ?? null,
|
|
243
243
|
ignoredScripts: IGNORED_SCRIPTS.filter((s) =>
|
|
244
244
|
existsSync(join(paperDir, s)),
|
|
245
245
|
),
|
package/src/cli.test.ts
CHANGED
|
@@ -10,7 +10,12 @@ import { existsSync } from "node:fs";
|
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { test } from "vitest";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
runNode,
|
|
15
|
+
useTempDir,
|
|
16
|
+
venuePreset,
|
|
17
|
+
writeTree,
|
|
18
|
+
} from "../test/support.ts";
|
|
14
19
|
import type { runToolchain } from "./toolchain.ts";
|
|
15
20
|
import {
|
|
16
21
|
chooseVenue,
|
|
@@ -195,8 +200,12 @@ test("runHook: a runtime with no cli.js beside it, and a hook killed by a signal
|
|
|
195
200
|
test("chooseVenue: a preset by path (inside and outside the paper), by prefixed name, and a broken one", async () => {
|
|
196
201
|
const dir = join(root, "venues");
|
|
197
202
|
writeTree(dir, {
|
|
198
|
-
"papers/p/mine.jsonc": JSON.stringify(
|
|
199
|
-
|
|
203
|
+
"papers/p/mine.jsonc": JSON.stringify(
|
|
204
|
+
venuePreset("mine", { extends: "paperlint:agenticdev" }),
|
|
205
|
+
),
|
|
206
|
+
"shared/ours.jsonc": JSON.stringify(
|
|
207
|
+
venuePreset("ours", { extends: "paperlint:agenticdev" }),
|
|
208
|
+
),
|
|
200
209
|
"shared/broken.jsonc": "{ nope",
|
|
201
210
|
});
|
|
202
211
|
const paperDir = join(dir, "papers", "p");
|
|
@@ -394,6 +403,38 @@ test("silentOptionalRules: a config with no rules turns nothing on", async () =>
|
|
|
394
403
|
assert.deepEqual(await silentOptionalRules(eslint, [], {}), []);
|
|
395
404
|
});
|
|
396
405
|
|
|
406
|
+
const lastPageOn = {
|
|
407
|
+
rules: [
|
|
408
|
+
{ basePath: ".", rules: { "pdf/last-page-balance": "error" as const } },
|
|
409
|
+
],
|
|
410
|
+
};
|
|
411
|
+
const eslintWith = (rules: Record<string, unknown>) => ({
|
|
412
|
+
calculateConfigForFile: () => Promise.resolve({ rules }),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("silentOptionalRules: a rule on for a paper is reached", async () => {
|
|
416
|
+
const eslint = eslintWith({ "pdf/last-page-balance": [2, {}] });
|
|
417
|
+
assert.deepEqual(
|
|
418
|
+
await silentOptionalRules(eslint, ["p/paper.tex"], lastPageOn),
|
|
419
|
+
[],
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("silentOptionalRules: a rule a paper's own block turns off is reached — the way out of a preset's", async () => {
|
|
424
|
+
const eslint = eslintWith({ "pdf/last-page-balance": [0] });
|
|
425
|
+
assert.deepEqual(
|
|
426
|
+
await silentOptionalRules(eslint, ["p/paper.tex"], lastPageOn),
|
|
427
|
+
[],
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("silentOptionalRules: a rule no block names for any paper is silent — a glob that matched nothing", async () => {
|
|
432
|
+
assert.deepEqual(
|
|
433
|
+
await silentOptionalRules(eslintWith({}), ["p/paper.tex"], lastPageOn),
|
|
434
|
+
["pdf/last-page-balance"],
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
397
438
|
test("init with no path and --format md sets up the current directory", async () => {
|
|
398
439
|
const dir = writeTree(join(root, "init-here"), { "package.json": "{}" });
|
|
399
440
|
const r = await cli(
|
|
@@ -645,10 +686,12 @@ test("parseArgs: submission's flags — two values and two switches", () => {
|
|
|
645
686
|
const submissionPaper = (dir: string, site: string): void => {
|
|
646
687
|
writeTree(dir, {
|
|
647
688
|
"package.json": "{}",
|
|
648
|
-
"venue.jsonc": JSON.stringify(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
689
|
+
"venue.jsonc": JSON.stringify(
|
|
690
|
+
venuePreset("venue", {
|
|
691
|
+
extends: "paperlint:aidc",
|
|
692
|
+
portal: { kind: "hotcrp", url: site },
|
|
693
|
+
}),
|
|
694
|
+
),
|
|
652
695
|
"papers/p/paperlint.json": JSON.stringify({
|
|
653
696
|
extends: "../../venue.jsonc",
|
|
654
697
|
submission: { id: 7 },
|
package/src/cli.ts
CHANGED
|
@@ -42,7 +42,12 @@ import {
|
|
|
42
42
|
asEslintResults,
|
|
43
43
|
} from "./structure.ts";
|
|
44
44
|
import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
FRAGMENT_FILES,
|
|
47
|
+
includeBlocks,
|
|
48
|
+
paperBodies,
|
|
49
|
+
unreadLines,
|
|
50
|
+
} from "./paper-includes.ts";
|
|
46
51
|
import { prepareEngine } from "./build-engine.ts";
|
|
47
52
|
import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
|
|
48
53
|
import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
|
|
@@ -78,6 +83,7 @@ import {
|
|
|
78
83
|
REGISTER_BAND_RULE_LEVELS,
|
|
79
84
|
registerBandRules,
|
|
80
85
|
} from "./register-bands.ts";
|
|
86
|
+
import { headingCaseRule } from "./heading-case.ts";
|
|
81
87
|
import {
|
|
82
88
|
paperRules,
|
|
83
89
|
identityField,
|
|
@@ -291,6 +297,18 @@ const TEX_RULE_DEPS = {
|
|
|
291
297
|
latex: latexReader,
|
|
292
298
|
};
|
|
293
299
|
|
|
300
|
+
/** The register band rules and `tex/heading-case`: the rules of the `tex` plugin that read a paper's words. */
|
|
301
|
+
const REGISTER_AND_CASE_RULES = {
|
|
302
|
+
...registerBandRules(TEX_RULE_DEPS),
|
|
303
|
+
"heading-case": headingCaseRule(TEX_RULE_DEPS),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** The rules of the files `paper.tex` includes, each read on its own as a fragment (#144). */
|
|
307
|
+
const FRAGMENT_RULES = {
|
|
308
|
+
"future-promise": texBuild["future-promise"],
|
|
309
|
+
"heading-case": REGISTER_AND_CASE_RULES["heading-case"],
|
|
310
|
+
};
|
|
311
|
+
|
|
294
312
|
/** What the talk rules read with: the disk, the shipped presets, and the three media readers. */
|
|
295
313
|
const TALK_RULE_DEPS = {
|
|
296
314
|
files: nodeFiles,
|
|
@@ -402,7 +420,7 @@ export function buildConfig(
|
|
|
402
420
|
...texVenueRules(TEX_RULE_DEPS),
|
|
403
421
|
"claim-provenance": claimProvenanceRule(TEX_RULE_DEPS),
|
|
404
422
|
register: registerRule(TEX_RULE_DEPS),
|
|
405
|
-
...
|
|
423
|
+
...REGISTER_AND_CASE_RULES,
|
|
406
424
|
},
|
|
407
425
|
},
|
|
408
426
|
paper: { rules: texPaperRules },
|
|
@@ -449,12 +467,12 @@ export function buildConfig(
|
|
|
449
467
|
const fragments: readonly ConfigBlock[] = texLanguage
|
|
450
468
|
? [
|
|
451
469
|
{
|
|
452
|
-
files: [
|
|
470
|
+
files: [...FRAGMENT_FILES],
|
|
453
471
|
ignores: ["**/paper.tex"],
|
|
454
472
|
plugins: {
|
|
455
473
|
tex: {
|
|
456
474
|
languages: { latex: texLanguage },
|
|
457
|
-
rules:
|
|
475
|
+
rules: FRAGMENT_RULES,
|
|
458
476
|
},
|
|
459
477
|
paper: {
|
|
460
478
|
rules: {
|
|
@@ -623,7 +641,11 @@ export async function silentOptionalRules(
|
|
|
623
641
|
for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
|
|
624
642
|
// Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
|
|
625
643
|
const cfg = ComputedConfig.parse(await eslint.calculateConfigForFile(f));
|
|
626
|
-
for
|
|
644
|
+
// Reached is "some block names the rule for this paper", on OR off: a paper that turns off what
|
|
645
|
+
// its venue preset turned on is the documented way out, and reads the same as a glob that missed
|
|
646
|
+
// only if "off" is counted as "not there" — which refused every such paper.
|
|
647
|
+
for (const id of turnedOn)
|
|
648
|
+
if (cfg?.rules?.[id] !== undefined) reached.add(id);
|
|
627
649
|
}
|
|
628
650
|
return [...turnedOn].filter((id) => !reached.has(id));
|
|
629
651
|
}
|
|
@@ -1162,7 +1184,7 @@ export async function createPaperAt(
|
|
|
1162
1184
|
|
|
1163
1185
|
/** A venue `paperlint new` will write, with what the messages need to say about it. */
|
|
1164
1186
|
export interface VenueChoice extends VenueSetting {
|
|
1165
|
-
/** The preset's word in messages (`
|
|
1187
|
+
/** The preset's word in messages: the venue's name (`AgenticDev`), else the file name (`my-workshop`). */
|
|
1166
1188
|
readonly label: string;
|
|
1167
1189
|
/** The preset's kinds; empty when it sets no page limit. */
|
|
1168
1190
|
readonly kinds: readonly string[];
|