badness_parser/semantic/xparse.rs
1//! Parser for the **xparse argument specification** mini-language — the string in
2//! the second group of `\NewDocumentCommand{\foo}{<spec>}{…}` (and the environment
3//! variants). It is *parsed*, never executed (AGENTS.md decision #1): we read the
4//! shape of each argument, not its processing.
5//!
6//! The full grammar is tokenized so the cursor never desyncs on a type's trailing
7//! material (delimiter tokens, `{default}` groups, embellishment sets). But our
8//! [`ArgSpec`] model only distinguishes a `{…}` [`ArgKind::Brace`] from a `[…]`
9//! [`ArgKind::Bracket`] slot, because that is all the CST produces and all a
10//! consumer (the formatter's arity glue) can act on. So an [`ArgSpec`] is emitted
11//! **only** for argument types that yield an actual `{…}`/`[…]` node:
12//!
13//! - `m` → required brace; `o`, `O{default}` → optional bracket.
14//! - `r⟨t1⟩⟨t2⟩` / `R⟨…⟩{default}` (required delimited) and `d⟨t1⟩⟨t2⟩` /
15//! `D⟨…⟩{default}` (optional delimited) → an [`ArgSpec`] **only** when the
16//! delimiters are literally `[`/`]` (bracket) or `{`/`}` (brace); other delimiters
17//! (`(`/`)`, `<`/`>`, …) produce no CST node, so no slot.
18//! - `s` (star), `t⟨token⟩` (optional token), `v` (verbatim), and `e`/`E`
19//! (embellishments) produce no `{…}`/`[…]` node, so no slot — but their trailing
20//! material is still consumed.
21//!
22//! This keeps the emitted slot count equal to the `GROUP`/`OPTIONAL` nodes the
23//! greedy parser actually attaches, which is what the formatter counts. Modifiers
24//! (`+`, `!`) and argument processors (`>{…}`) are skipped. Unknown type letters
25//! stop the scan (conservative: never panic, never invent slots).
26
27use super::signature::{ArgKind, ArgSpec, ContentKind};
28
29/// Parse an xparse argument-spec string into the `{…}`/`[…]` argument slots it
30/// declares, in order. See the module docs for the type-by-type mapping.
31pub fn parse_spec(spec: &str) -> Vec<ArgSpec> {
32 let chars: Vec<char> = spec.chars().collect();
33 let mut cursor = Cursor {
34 chars: &chars,
35 i: 0,
36 };
37 let mut args = Vec::new();
38
39 loop {
40 cursor.skip_modifiers();
41 cursor.skip_ws();
42 let Some(c) = cursor.bump() else { break };
43 match c {
44 'm' => args.push(brace(true)),
45 'o' => args.push(bracket(false)),
46 'O' => {
47 cursor.skip_group();
48 args.push(bracket(false));
49 }
50 // Required (`r`/`R`) and optional (`d`/`D`) delimited args: a slot only
51 // when the delimiters are the bracket/brace pair the CST models.
52 'r' | 'R' | 'd' | 'D' => {
53 let required = matches!(c, 'r' | 'R');
54 let open = cursor.read_token();
55 let close = cursor.read_token();
56 if matches!(c, 'R' | 'D') {
57 cursor.skip_group(); // the {default}
58 }
59 if let Some(kind) = delimiter_kind(open.as_deref(), close.as_deref()) {
60 args.push(ArgSpec {
61 required,
62 kind,
63 content: ContentKind::Opaque,
64 });
65 }
66 }
67 't' => {
68 cursor.read_token(); // the test token; yields no node
69 }
70 'e' => {
71 cursor.skip_group(); // {<tokens>}
72 }
73 'E' => {
74 cursor.skip_group(); // {<tokens>}
75 cursor.skip_group(); // {<defaults>}
76 }
77 // `s` (star), `v` (verbatim): consumed, no `{…}`/`[…]` node.
78 's' | 'v' => {}
79 // Unknown letter: stop rather than guess and miscount.
80 _ => break,
81 }
82 }
83
84 args
85}
86
87fn brace(required: bool) -> ArgSpec {
88 ArgSpec {
89 required,
90 kind: ArgKind::Brace,
91 content: ContentKind::Opaque,
92 }
93}
94
95fn bracket(required: bool) -> ArgSpec {
96 ArgSpec {
97 required,
98 kind: ArgKind::Bracket,
99 content: ContentKind::Opaque,
100 }
101}
102
103/// The `ArgKind` for a delimited arg whose delimiters are `open`/`close`, or `None`
104/// when the pair is not one the CST produces a node for.
105fn delimiter_kind(open: Option<&str>, close: Option<&str>) -> Option<ArgKind> {
106 match (open, close) {
107 (Some("["), Some("]")) => Some(ArgKind::Bracket),
108 (Some("{"), Some("}")) => Some(ArgKind::Brace),
109 _ => None,
110 }
111}
112
113/// A char cursor over the spec string with the small consumption primitives the
114/// xparse types need.
115struct Cursor<'a> {
116 chars: &'a [char],
117 i: usize,
118}
119
120impl Cursor<'_> {
121 fn peek(&self) -> Option<char> {
122 self.chars.get(self.i).copied()
123 }
124
125 fn bump(&mut self) -> Option<char> {
126 let c = self.peek()?;
127 self.i += 1;
128 Some(c)
129 }
130
131 fn skip_ws(&mut self) {
132 while self.peek().is_some_and(char::is_whitespace) {
133 self.i += 1;
134 }
135 }
136
137 /// Skip the type-prefix modifiers that may precede any argument type: `+`
138 /// (long), `!` (no-leading-space), and `>{processor}` argument processors.
139 fn skip_modifiers(&mut self) {
140 loop {
141 self.skip_ws();
142 match self.peek() {
143 Some('+') | Some('!') => self.i += 1,
144 Some('>') => {
145 self.i += 1;
146 self.skip_group();
147 }
148 _ => break,
149 }
150 }
151 }
152
153 /// Read a single spec token after optional whitespace: a control sequence
154 /// (`\` + a letter run, or `\` + one symbol) or a single character. Used for the
155 /// delimiter tokens of `r`/`R`/`d`/`D` and the test token of `t`.
156 fn read_token(&mut self) -> Option<String> {
157 self.skip_ws();
158 let first = self.bump()?;
159 if first != '\\' {
160 return Some(first.to_string());
161 }
162 let mut token = String::from('\\');
163 match self.peek() {
164 Some(c) if c.is_ascii_alphabetic() => {
165 while self.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
166 token.push(self.bump().expect("peeked"));
167 }
168 }
169 Some(_) => token.push(self.bump().expect("peeked")),
170 None => {}
171 }
172 Some(token)
173 }
174
175 /// If the next non-whitespace char opens a `{…}` group, skip the whole balanced
176 /// group (nested braces included). A no-op otherwise — tolerant of a malformed
177 /// spec missing the group a type would normally carry.
178 fn skip_group(&mut self) {
179 self.skip_ws();
180 if self.peek() != Some('{') {
181 return;
182 }
183 let mut depth = 0;
184 while let Some(c) = self.bump() {
185 match c {
186 '{' => depth += 1,
187 '}' => {
188 depth -= 1;
189 if depth == 0 {
190 return;
191 }
192 }
193 _ => {}
194 }
195 }
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn kinds(spec: &str) -> Vec<(bool, ArgKind)> {
204 parse_spec(spec)
205 .into_iter()
206 .map(|a| (a.required, a.kind))
207 .collect()
208 }
209
210 #[test]
211 fn mandatory_and_optional_basics() {
212 assert_eq!(
213 kinds("m o m"),
214 vec![
215 (true, ArgKind::Brace),
216 (false, ArgKind::Bracket),
217 (true, ArgKind::Brace),
218 ]
219 );
220 }
221
222 #[test]
223 fn optional_with_default_consumes_group() {
224 // The `{0}` default must not be read as another argument.
225 assert_eq!(
226 kinds("O{0} m"),
227 vec![(false, ArgKind::Bracket), (true, ArgKind::Brace)]
228 );
229 }
230
231 #[test]
232 fn star_and_token_yield_no_slot() {
233 assert_eq!(kinds("s m"), vec![(true, ArgKind::Brace)]);
234 // `t` consumes its test token (`*`), leaving just the `m`.
235 assert_eq!(kinds("t* m"), vec![(true, ArgKind::Brace)]);
236 }
237
238 #[test]
239 fn verbatim_yields_no_slot() {
240 assert_eq!(kinds("v"), vec![]);
241 }
242
243 #[test]
244 fn bracket_delimited_maps_to_bracket() {
245 // `d[]` and `r[]` are `[…]`-delimited, so they yield a bracket slot.
246 assert_eq!(kinds("d[]"), vec![(false, ArgKind::Bracket)]);
247 assert_eq!(kinds("r[]"), vec![(true, ArgKind::Bracket)]);
248 }
249
250 #[test]
251 fn paren_delimited_yields_no_slot() {
252 // `()`-delimited args produce no CST node, so no slot — but the two
253 // delimiter tokens are still consumed, so a trailing `m` is found.
254 assert_eq!(kinds("d() m"), vec![(true, ArgKind::Brace)]);
255 assert_eq!(kinds("r<> m"), vec![(true, ArgKind::Brace)]);
256 }
257
258 #[test]
259 fn required_delimited_with_default_consumes_group() {
260 // `R(){default}`: two delimiter tokens then a default group, then `m`.
261 assert_eq!(kinds("R(){x} m"), vec![(true, ArgKind::Brace)]);
262 // `D[]{default}`: bracket-delimited optional with a default → one bracket
263 // slot, then `m`.
264 assert_eq!(
265 kinds("D[]{x} m"),
266 vec![(false, ArgKind::Bracket), (true, ArgKind::Brace)]
267 );
268 }
269
270 #[test]
271 fn embellishments_consume_their_groups() {
272 // `e{^_}` consumes one group; `E{^_}{\d\d}` consumes two. Neither yields a
273 // slot, so only the `m` remains.
274 assert_eq!(kinds("e{^_} m"), vec![(true, ArgKind::Brace)]);
275 assert_eq!(kinds("E{^_}{00} m"), vec![(true, ArgKind::Brace)]);
276 }
277
278 #[test]
279 fn modifiers_and_processors_skipped() {
280 assert_eq!(kinds("+m"), vec![(true, ArgKind::Brace)]);
281 assert_eq!(kinds("!o"), vec![(false, ArgKind::Bracket)]);
282 assert_eq!(kinds(">{\\TrimSpaces} m"), vec![(true, ArgKind::Brace)]);
283 }
284
285 #[test]
286 fn empty_and_whitespace_specs() {
287 assert_eq!(kinds(""), vec![]);
288 assert_eq!(kinds(" "), vec![]);
289 assert_eq!(
290 kinds(" m o "),
291 vec![(true, ArgKind::Brace), (false, ArgKind::Bracket)]
292 );
293 }
294
295 #[test]
296 fn unknown_letter_stops_scan() {
297 // A garbage letter halts parsing; the `m` before it is kept, the rest dropped.
298 assert_eq!(kinds("m z m"), vec![(true, ArgKind::Brace)]);
299 }
300
301 #[test]
302 fn control_sequence_delimiters_consumed() {
303 // `d\langle\rangle`: control-word delimiter tokens, non-bracket → no slot,
304 // but both are consumed so the `m` is reached.
305 assert_eq!(kinds("d\\langle\\rangle m"), vec![(true, ArgKind::Brace)]);
306 }
307}