Skip to main content

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    /// Classify each name in order, returning the verdicts.
232    fn scan(names: &[&str]) -> Vec<Word> {
233        let mut s = OpenerScan::new();
234        names.iter().map(|n| s.visit(n)).collect()
235    }
236
237    #[test]
238    fn a_plain_conditional_opens_and_closes() {
239        assert_eq!(
240            scan(&["ifnum", "else", "fi"]),
241            [
242                Word::Opens,
243                Word::Flow(FlowWord::Else),
244                Word::Flow(FlowWord::Fi)
245            ]
246        );
247    }
248
249    #[test]
250    fn newif_declares_rather_than_opens() {
251        // `\newif\if@foo`: the `\ifX` is the flag being declared, not an opener.
252        assert_eq!(scan(&["newif", "if@foo"]), [Word::Inert, Word::Inert]);
253    }
254
255    #[test]
256    fn ifx_operands_are_inert_even_when_if_named() {
257        // `\ifx\ifpdf\iftrue`: both operands are tokens being compared.
258        assert_eq!(
259            scan(&["ifx", "ifpdf", "iftrue"]),
260            [Word::Opens, Word::Inert, Word::Inert]
261        );
262    }
263
264    #[test]
265    fn let_aliases_two_tokens_without_opening() {
266        // `\let\ifpdf\iftrue`.
267        assert_eq!(
268            scan(&["let", "ifpdf", "iftrue"]),
269            [Word::Inert, Word::Inert, Word::Inert]
270        );
271    }
272
273    #[test]
274    fn brace_argument_tests_open_nothing() {
275        assert_eq!(
276            scan(&["ifthenelse", "ifnumgreater", "iftoggle", "iff"]),
277            [Word::Inert; 4]
278        );
279    }
280
281    #[test]
282    fn a_flow_word_cancels_a_pending_operand_run() {
283        // `\ifx\a\else`: the `\else` is real flow, not `\ifx`'s second operand,
284        // and it must not leave the countdown armed for what follows.
285        assert_eq!(
286            scan(&["ifx", "ifone", "else", "ifnum"]),
287            [
288                Word::Opens,
289                Word::Inert,
290                Word::Flow(FlowWord::Else),
291                Word::Opens
292            ]
293        );
294    }
295
296    #[test]
297    fn csname_bodies_hold_no_conditionals() {
298        assert_eq!(
299            scan(&["ifcsname", "ifnum", "endcsname", "ifdim"]),
300            [Word::Opens, Word::Inert, Word::Inert, Word::Opens]
301        );
302    }
303
304    #[test]
305    fn an_unclosed_csname_reopens_at_a_flow_word() {
306        assert_eq!(
307            scan(&["ifcsname", "ifnum", "fi", "ifdim"]),
308            [
309                Word::Opens,
310                Word::Inert,
311                Word::Flow(FlowWord::Fi),
312                Word::Opens
313            ]
314        );
315    }
316}