badness_parser/parser/conditional.rs
1//! Static recognition of TeX conditional control words — which `\if…`-named
2//! command *opens* a `\fi`-terminated conditional, and which `\else`/`\or`/`\fi`
3//! divides or closes one.
4//!
5//! Shared by the grammar (which builds [`SyntaxKind::CONDITIONAL`] nodes behind
6//! a shape gate) and the linter's `ConditionalIndex` (which derives branch paths
7//! for `duplicate-label`/`duplicate-package`), so the two read the *same* name
8//! sets and the *same* state machine — the same arrangement as
9//! [`super::lexer::expl_toggle`].
10//!
11//! What that buys is precise, and worth stating precisely: the two can never
12//! disagree about **what an opener is**. They can still reach different verdicts
13//! on a given token, because each feeds this scan a different stream on purpose.
14//! The parser walks every token and suppresses openers inside an expl3 region
15//! (in-region layout is the formatter's); the linter walks `COMMAND` nodes and
16//! skips definition-command spans wholesale (`\def\stopit{\fi}` carries a `\fi`,
17//! it does not run one), which the parser has no need to do because its own brace
18//! anchor already refuses to pair across the body's group. Those are deliberate
19//! per-consumer filters layered *around* a shared recognizer, not two recognizers.
20//!
21//! Recognition is **pair-and-trust**: a lowercase-`if`-prefixed name opens a
22//! conditional unless it is a known brace-argument macro. That leaves two
23//! families to subtract, both measured over latex2e/latex3/pgf/latexindent:
24//!
25//! - [`NOT_FI_TERMINATED`] — `\ifthenelse`, `\iftoggle`, the etoolbox test
26//! family (102 occurrences). These take `{true}{false}` arguments and are never
27//! `\fi`-terminated. Subtracting them is load-bearing rather than cosmetic:
28//! shape alone does not merely *fail* on one, it *mis-pairs*. In
29//! `latexindent`'s `test-cases/ifelsefi/issue-250.tex` an `\ifnumgreater` nests
30//! inside a real `\ifluatex`, so trusting it would steal the enclosing
31//! conditional's `\fi`.
32//! - [`OPERAND_SKIPS`] — `\newif\if@foo` (574 occurrences) and
33//! `\let\ifpdf\iftrue`, where the `\ifX` sits in an *operand* slot and is data
34//! being declared or aliased, not live control flow.
35//!
36//! Both are curated compiled-in facts, on the same footing as the verbatim and
37//! math environment tables: static lexical knowledge, never the mutable signature
38//! database (`AGENTS.md` decision #8). A missing entry costs a desynced stack in
39//! the linter and a demoted (never a mis-built) node in the parser, so the set
40//! extends freely.
41//!
42//! [`SyntaxKind::CONDITIONAL`]: crate::syntax::SyntaxKind::CONDITIONAL
43
44/// `if*`-named control words that are **not** `\fi`-terminated conditionals:
45/// ordinary macros taking brace arguments (`{true}{false}`), which must not open
46/// a conditional. Curated — amsmath's `\iff` arrow, ifthen's `\ifthenelse`,
47/// babel's `\iflanguage`, and etoolbox's test family.
48const NOT_FI_TERMINATED: &[&str] = &[
49 "iff", // amsmath: the ⟺ arrow, not a conditional
50 "ifthenelse", // ifthen/xifthen: {test}{then}{else}
51 "iflanguage", // babel: {lang}{then}{else}
52 "iftoggle", // etoolbox toggles: {toggle}{then}{else}
53 // etoolbox def/cs/str/bool/num/dim tests, all brace-argument shaped:
54 "ifdef",
55 "ifcsdef",
56 "ifundef",
57 "ifcsundef",
58 "ifdefmacro",
59 "ifcsmacro",
60 "ifdefempty",
61 "ifcsempty",
62 "ifdefvoid",
63 "ifcsvoid",
64 "ifdefstring",
65 "ifcsstring",
66 "ifdefequal",
67 "ifcsequal",
68 "ifbool",
69 "ifboolexpr",
70 "ifboolexpe",
71 "ifstrequal",
72 "ifstrempty",
73 "ifblank",
74 "ifnumcomp",
75 "ifnumequal",
76 "ifnumgreater",
77 "ifnumless",
78 "ifnumodd",
79 "ifdimcomp",
80 "ifdimequal",
81 "ifdimgreater",
82 "ifdimless",
83];
84
85/// Commands whose next N *control words* are operands (tokens being tested or
86/// aliased), not live control flow: `\if`/`\ifx`/`\ifcat` compare two tokens,
87/// eTeX's `\ifdefined` tests one, `\newif\ifmyflag` declares one, and
88/// `\let\ifpdf\iftrue` aliases two. `\ifcsname` is handled separately
89/// (skip until `\endcsname`); `\ifincsname` takes no operand at all.
90///
91/// Counting *control words* rather than TeX tokens is the deliberate
92/// approximation: `\if ab\ifsomething` compares the characters `a` and `b`, so
93/// TeX's own count would put `\ifsomething` outside the operand slots. Reading
94/// character tokens here would mean modelling `\if`'s expansion, which is exactly
95/// the meaning the syntactic layer does not carry.
96const OPERAND_SKIPS: &[(&str, u8)] = &[
97 ("if", 2),
98 ("ifx", 2),
99 ("ifcat", 2),
100 ("ifdefined", 1),
101 ("newif", 1),
102 ("let", 2),
103];
104
105/// The `\ifcsname` … `\endcsname` pair, whose body names a control sequence
106/// character by character and so contains no live conditionals.
107const CSNAME_OPENER: &str = "ifcsname";
108const CSNAME_CLOSER: &str = "endcsname";
109
110/// A control word that divides or closes a conditional.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum FlowWord {
113 /// `\else` — opens the alternative branch.
114 Else,
115 /// `\or` — opens the next `\ifcase` branch.
116 Or,
117 /// `\fi` — closes the conditional.
118 Fi,
119}
120
121/// Classify a control word's *name* (no leading backslash) as a conditional
122/// divider or closer. Stateless: `\else`/`\or`/`\fi` are never anything else.
123pub fn flow_word(name: &str) -> Option<FlowWord> {
124 match name {
125 "else" => Some(FlowWord::Else),
126 "or" => Some(FlowWord::Or),
127 "fi" => Some(FlowWord::Fi),
128 _ => None,
129 }
130}
131
132/// Pair-and-trust: a lowercase-`if`-prefixed name opens a conditional unless it
133/// is a known brace-argument macro. Reads the *name*, no leading backslash.
134///
135/// Positional context (an operand slot, an `\ifcsname` body) is **not** checked
136/// here — that needs the running state [`OpenerScan`] carries.
137pub fn is_conditional_opener(name: &str) -> bool {
138 name.starts_with("if") && !NOT_FI_TERMINATED.contains(&name)
139}
140
141/// How many following control words `name` claims as operands, if any.
142pub fn operand_skips(name: &str) -> Option<u8> {
143 OPERAND_SKIPS
144 .iter()
145 .find(|(op, _)| *op == name)
146 .map(|&(_, n)| n)
147}
148
149/// What a control word does to the conditional structure, once positional
150/// context is taken into account.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum Word {
153 /// Opens a conditional.
154 Opens,
155 /// Divides or closes the innermost open conditional.
156 Flow(FlowWord),
157 /// Neither — an ordinary command, or an `\ifX` sitting in an operand slot.
158 Inert,
159}
160
161/// The running state that turns [`is_conditional_opener`] into a positional
162/// decision: the operand-slot countdown and the `\ifcsname` body.
163///
164/// Feed the control words in document order through [`Self::visit`] — the parser
165/// in a pre-pass over the token stream, the linter walking `COMMAND` nodes in
166/// preorder.
167///
168/// The state is a running countdown, so *whether* a word is visited is itself a
169/// decision, and the two consumers make it differently on purpose (module doc).
170/// The rule is which question the filter answers. Tokens the document does not
171/// **execute** must not be visited at all — the linter withholds the whole span of
172/// a `\def` body, because a `\let` carried inside one must not arm the countdown
173/// for the code after it. Tokens that merely have no *node* to build are visited
174/// and then discarded from the result — the parser does this for expl3 regions,
175/// so an in-region `\ifcsname` still opens and closes its skip window for the
176/// words that follow.
177#[derive(Debug, Clone, Copy, Default)]
178pub struct OpenerScan {
179 /// Remaining control words claimed as operands by an earlier command.
180 pending_skips: u8,
181 /// Inside an `\ifcsname` … `\endcsname` body.
182 in_csname: bool,
183}
184
185impl OpenerScan {
186 /// A fresh scan, at the start of a document.
187 pub fn new() -> Self {
188 Self::default()
189 }
190
191 /// Classify the next control word `name` (no leading backslash) and advance
192 /// the state.
193 pub fn visit(&mut self, name: &str) -> Word {
194 let flow = flow_word(name);
195 if self.in_csname {
196 if name == CSNAME_CLOSER {
197 self.in_csname = false;
198 return Word::Inert;
199 }
200 if flow.is_none() {
201 return Word::Inert;
202 }
203 // Malformed input (an `\ifcsname` never closed): a flow word
204 // re-enables interpretation rather than going dark to EOF.
205 self.in_csname = false;
206 }
207 if self.pending_skips > 0 {
208 if flow.is_none() {
209 self.pending_skips -= 1;
210 return Word::Inert;
211 }
212 self.pending_skips = 0;
213 }
214 if let Some(flow) = flow {
215 return Word::Flow(flow);
216 }
217 let opens = is_conditional_opener(name);
218 if opens && name == CSNAME_OPENER {
219 self.in_csname = true;
220 } else if let Some(n) = operand_skips(name) {
221 self.pending_skips = n;
222 }
223 if opens { Word::Opens } else { Word::Inert }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn scan(names: &[&str]) -> Vec<Word> {
232 let mut s = OpenerScan::new();
233 names.iter().map(|n| s.visit(n)).collect()
234 }
235
236 #[test]
237 fn a_plain_conditional_opens_and_closes() {
238 assert_eq!(
239 scan(&["ifnum", "else", "fi"]),
240 [
241 Word::Opens,
242 Word::Flow(FlowWord::Else),
243 Word::Flow(FlowWord::Fi)
244 ]
245 );
246 }
247
248 #[test]
249 fn newif_declares_rather_than_opens() {
250 assert_eq!(scan(&["newif", "if@foo"]), [Word::Inert, Word::Inert]);
251 }
252
253 #[test]
254 fn ifx_operands_are_inert_even_when_if_named() {
255 assert_eq!(
256 scan(&["ifx", "ifpdf", "iftrue"]),
257 [Word::Opens, Word::Inert, Word::Inert]
258 );
259 }
260
261 #[test]
262 fn let_aliases_two_tokens_without_opening() {
263 assert_eq!(
264 scan(&["let", "ifpdf", "iftrue"]),
265 [Word::Inert, Word::Inert, Word::Inert]
266 );
267 }
268
269 #[test]
270 fn brace_argument_tests_open_nothing() {
271 assert_eq!(
272 scan(&["ifthenelse", "ifnumgreater", "iftoggle", "iff"]),
273 [Word::Inert; 4]
274 );
275 }
276
277 #[test]
278 fn a_flow_word_cancels_a_pending_operand_run() {
279 assert_eq!(
280 scan(&["ifx", "ifone", "else", "ifnum"]),
281 [
282 Word::Opens,
283 Word::Inert,
284 Word::Flow(FlowWord::Else),
285 Word::Opens
286 ]
287 );
288 }
289
290 #[test]
291 fn csname_bodies_hold_no_conditionals() {
292 assert_eq!(
293 scan(&["ifcsname", "ifnum", "endcsname", "ifdim"]),
294 [Word::Opens, Word::Inert, Word::Inert, Word::Opens]
295 );
296 }
297
298 #[test]
299 fn an_unclosed_csname_reopens_at_a_flow_word() {
300 assert_eq!(
301 scan(&["ifcsname", "ifnum", "fi", "ifdim"]),
302 [
303 Word::Opens,
304 Word::Inert,
305 Word::Flow(FlowWord::Fi),
306 Word::Opens
307 ]
308 );
309 }
310}