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    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}