miniextendr_macros/r_macro/grammar.rs
1//! Conservative R-grammar validation for the `r!` proc-macro.
2//!
3//! This module implements a **reject-only-known-bad** strategy: walk the
4//! `proc_macro2::TokenStream` and emit a `syn::Error` for constructs that R's
5//! parser is guaranteed to reject. Anything that cannot be confidently
6//! classified as an error is accepted silently.
7//!
8//! # Non-goals
9//!
10//! A complete R grammar over Rust tokens is not achievable:
11//! - Single-quoted strings (`'hello'`) and backtick-quoted names (\`foo\`)
12//! already die at the Rust lexer — nothing to validate.
13//! - `%op%` tokenises as `%`, ident, `%` — looks like two leading `%` ops;
14//! we accept it rather than risk false positives.
15//! - R formulas (`~`), `?`, unary operators and other niche constructs may
16//! look syntactically ambiguous — we accept those too.
17//!
18//! # Accepted forms (must never be rejected)
19//!
20//! - `;`-statement sequences (`a <- 1L; b <- 2L; a + b`)
21//! - `<-` assignment (tokenises as `<` + `-` joint punct)
22//! - `<<-` assignment (tokenises as `<` + `<` + `-`)
23//! - `->` assignment (tokenises as `-` + `>` joint punct)
24//! - `%in%`, `%*%`, and all other `%op%` operators
25//! - Empty (missing) arguments anywhere: `x[,1]`, `f(, x)`, `matrix(, 2, 2)` —
26//! R's `sublist` grammar allows empty slots in every call form, `(` and `[`
27//! alike (they become the missing-arg sentinel at evaluation)
28//! - `2 ** 3` — R's parser accepts `**` as an undocumented synonym for `^`
29//! - `~` formulas
30//! - `\(x) x+1` lambda syntax (R 4.1+; `\` alone doesn't survive Rust
31//! lexing unless inside a string literal, so no R-side validation needed)
32
33use proc_macro2::{Delimiter, Group, Punct, Spacing, Span, TokenStream, TokenTree};
34use syn::Error;
35
36/// Returns `Ok(())` if the token stream passes the conservative validator,
37/// or an `Err` spanned to the first problematic token pair/group.
38pub(crate) fn validate(tokens: &TokenStream) -> Result<(), Error> {
39 let flat: Vec<TokenTree> = flatten_top_level(tokens);
40 validate_sequence(&flat)
41}
42
43// region: Sequence-level checks
44
45fn validate_sequence(seq: &[TokenTree]) -> Result<(), Error> {
46 if seq.is_empty() {
47 return Ok(());
48 }
49
50 // Check for trailing binary operator at end of sequence.
51 check_trailing_binary_op(seq)?;
52
53 // Check consecutive binary operators (neither unary-capable).
54 check_consecutive_binary_ops(seq)?;
55
56 // Walk each token; recurse into groups.
57 let mut i = 0;
58 while i < seq.len() {
59 let tt = &seq[i];
60 if let TokenTree::Group(g) = tt {
61 validate_group(g, seq, i)?;
62 }
63 i += 1;
64 }
65
66 Ok(())
67}
68
69// endregion
70
71// region: Group-level checks
72
73fn validate_group(g: &Group, outer_seq: &[TokenTree], pos: usize) -> Result<(), Error> {
74 let inner: Vec<TokenTree> = flatten_top_level(&g.stream());
75
76 match g.delimiter() {
77 Delimiter::Parenthesis => {
78 // Check whether this parenthesized group is a function call.
79 // It is a call if the preceding token is an ident or a closing
80 // group (i.e. `f(...)`, `f(...)()`, `obj$field(...)`).
81 //
82 // Empty (missing) call arguments — `f(, x)`, `f(x,,y)` — are NOT
83 // checked: R's `sublist` grammar allows empty slots in every call
84 // form (`matrix(, 2, 2)` is idiomatic R), same production as
85 // `x[,1]`.
86 let is_call = pos > 0 && {
87 match &outer_seq[pos - 1] {
88 TokenTree::Ident(_) => true,
89 TokenTree::Group(prev) => matches!(
90 prev.delimiter(),
91 Delimiter::Parenthesis | Delimiter::Bracket
92 ),
93 _ => false,
94 }
95 };
96
97 if !is_call {
98 // Standalone parenthesised expression — must not be empty after
99 // a binary operator (`x + ()` is an error).
100 if inner.is_empty()
101 && pos > 0
102 && let Some(op_span) = preceding_binary_op_span(outer_seq, pos - 1)
103 {
104 return Err(Error::new(
105 op_span,
106 "R syntax error: binary operator followed by empty parentheses `()` — \
107 expected an operand",
108 ));
109 }
110 }
111
112 // Recurse into call arguments split by top-level commas.
113 for arg in split_by_comma(&inner) {
114 validate_sequence(arg)?;
115 }
116
117 // Also validate control-flow keywords before this group.
118 validate_control_flow_keyword(outer_seq, pos, g.span())?;
119 }
120
121 Delimiter::Bracket => {
122 for arg in split_by_comma(&inner) {
123 validate_sequence(arg)?;
124 }
125 }
126
127 Delimiter::Brace => {
128 // `{` blocks: validate each `;`-separated statement.
129 for stmt in split_by_semicolon(&inner) {
130 if !stmt.is_empty() {
131 validate_sequence(stmt)?;
132 }
133 }
134 }
135
136 Delimiter::None => {
137 validate_sequence(&inner)?;
138 }
139 }
140
141 Ok(())
142}
143
144// endregion
145
146// region: Control-flow keyword validation
147
148/// Validates that control-flow keywords (`if`, `while`, `for`, `function`, `repeat`)
149/// are followed by the correct next token.
150fn validate_control_flow_keyword(
151 seq: &[TokenTree],
152 paren_pos: usize,
153 paren_span: Span,
154) -> Result<(), Error> {
155 if paren_pos == 0 {
156 return Ok(());
157 }
158 // The token immediately before the `(` group.
159 let kw = match &seq[paren_pos - 1] {
160 TokenTree::Ident(id) => id.to_string(),
161 _ => return Ok(()),
162 };
163
164 match kw.as_str() {
165 "if" | "while" => {
166 // `if (cond)` / `while (cond)` — must be followed by at least one
167 // token (the body), and the parenthesised group must be non-empty.
168 let g = match &seq[paren_pos] {
169 TokenTree::Group(g) => g,
170 _ => return Ok(()),
171 };
172 let inner: Vec<TokenTree> = flatten_top_level(&g.stream());
173 if inner.is_empty() {
174 return Err(Error::new(
175 paren_span,
176 format!(
177 "R syntax error: `{kw}` condition is empty — \
178 `{kw} ()` is not valid R"
179 ),
180 ));
181 }
182 // Must be followed by a body token.
183 if paren_pos + 1 >= seq.len() {
184 return Err(Error::new(
185 paren_span,
186 format!(
187 "R syntax error: `{kw} (...)` has no body — \
188 expected a consequent expression after the condition"
189 ),
190 ));
191 }
192 }
193 "for" => {
194 // `for (ident in seq)` — the parenthesised group must contain
195 // exactly: ident `in` tokens.
196 let g = match &seq[paren_pos] {
197 TokenTree::Group(g) => g,
198 _ => return Ok(()),
199 };
200 let inner: Vec<TokenTree> = flatten_top_level(&g.stream());
201 if inner.is_empty() {
202 return Err(Error::new(
203 paren_span,
204 "R syntax error: `for` loop variable list is empty — \
205 expected `for (ident in seq)`",
206 ));
207 }
208 // Check for presence of `in` keyword.
209 let has_in = inner
210 .iter()
211 .any(|t| matches!(t, TokenTree::Ident(id) if id == "in"));
212 if !has_in {
213 return Err(Error::new(
214 paren_span,
215 "R syntax error: `for` loop missing `in` — \
216 expected `for (ident in seq)`",
217 ));
218 }
219 // Must have a body after.
220 if paren_pos + 1 >= seq.len() {
221 return Err(Error::new(
222 paren_span,
223 "R syntax error: `for (ident in seq)` has no body — \
224 expected a loop body after the `for` header",
225 ));
226 }
227 }
228 "function" => {
229 // `function(args)` — the `(` is the argument list; already validated as non-call.
230 // No additional rule needed beyond that the group exists.
231 }
232 _ => {}
233 }
234
235 Ok(())
236}
237
238// endregion
239
240// region: Trailing binary operator check
241
242/// Returns an error if the sequence ends with a binary operator.
243///
244/// Token-pair awareness: `<` + `-` joint = `<-` (assignment, not a trailing `<`);
245/// `<` + `<` + `-` = `<<-` (also assignment); `-` + `>` joint = `->` (assignment).
246fn check_trailing_binary_op(seq: &[TokenTree]) -> Result<(), Error> {
247 if seq.is_empty() {
248 return Ok(());
249 }
250
251 // Find the "logical last token", ignoring `;` (statement separator is fine).
252 let logical_end = seq
253 .iter()
254 .rposition(|t| !is_semicolon(t))
255 .unwrap_or(seq.len().saturating_sub(1));
256
257 // For a trailing operator to be an error, the token at logical_end must be
258 // a "pure binary" operator (i.e. not unary-capable), and must NOT be the
259 // first component of a multi-token assignment form.
260
261 let span = match &seq[logical_end] {
262 TokenTree::Punct(p) => {
263 let ch = p.as_char();
264 // Check for assignment forms ending here that are actually valid:
265 // `<-`: ch=`-`, preceded by `<`
266 // `<<-`: ch=`-`, preceded by two `<`
267 // `->`: ch=`>`, preceded by `-`
268 // `<<=`: well-formed? Not R; skip.
269 if ch == '-' && logical_end > 0 && punct_char_at(seq, logical_end - 1) == Some('<') {
270 return Ok(()); // `<-` assignment
271 }
272 if ch == '>' && logical_end > 0 && punct_char_at(seq, logical_end - 1) == Some('-') {
273 return Ok(()); // `->` assignment
274 }
275 if is_pure_binary_op(p) {
276 p.span()
277 } else {
278 return Ok(());
279 }
280 }
281 _ => return Ok(()),
282 };
283
284 Err(Error::new(
285 span,
286 "R syntax error: expression ends with a binary operator — \
287 expected a right-hand operand after the operator",
288 ))
289}
290
291// endregion
292
293// region: Consecutive binary operators check
294
295/// Returns an error if two consecutive non-unary binary operators appear
296/// with nothing between them (`x * * y`, `x / / y`).
297///
298/// Unary-capable operators (`+`, `-`, `!`, `~`, `?`) are never flagged —
299/// they chain legally (`x - - y`). Joint-spaced pairs are never flagged
300/// either: that skips every multi-char operator (`<-`, `->`, `<=`, `%%`,
301/// `%in%`'s delimiters) *and* `**`, which R's parser accepts as an
302/// undocumented synonym for `^`.
303fn check_consecutive_binary_ops(seq: &[TokenTree]) -> Result<(), Error> {
304 // Build a simplified view: only keep non-whitespace punct tokens and check
305 // for pairs of non-unary binary operators.
306 //
307 // We are very conservative: only flag when we see two *definitely*
308 // non-unary operators in a row, and neither is part of a multi-char
309 // operator form (`<=`, `>=`, `==`, `!=`, `->`, `<-`, `<<-`, `**`).
310
311 let puncts: Vec<(usize, char, Spacing, Span)> = seq
312 .iter()
313 .enumerate()
314 .filter_map(|(i, t)| {
315 if let TokenTree::Punct(p) = t {
316 Some((i, p.as_char(), p.spacing(), p.span()))
317 } else {
318 None
319 }
320 })
321 .collect();
322
323 // Look for adjacent pure-binary-non-unary punct pairs where both are
324 // at top-level (not separated by non-punct tokens).
325 for w in puncts.windows(2) {
326 let (i0, c0, spacing0, span0) = w[0];
327 let (i1, c1, _spacing1, span1) = w[1];
328
329 // Adjacent: no non-punct tokens between them (i1 == i0 + 1) AND
330 // the first token's spacing is Alone (no joint continuation).
331 if i1 != i0 + 1 {
332 continue;
333 }
334
335 // Skip multi-char compound operators that are valid R:
336 // `<-` (`<` then `-` joint), `->` (`-` then `>` joint),
337 // `<<-`, `<=`, `>=`, `==`, `!=`, `**` (R doesn't have this but
338 // still skip to avoid false positives), `&&`, `||`.
339 // We skip ALL joint pairs conservatively.
340 if spacing0 == Spacing::Joint {
341 continue;
342 }
343
344 // Both tokens must be purely non-unary binary operators to flag.
345 // Unary-capable: `+`, `-`, `!`, `~`, `?`.
346 let non_unary_binary = |c: char| matches!(c, '*' | '/' | '^' | '%' | '@' | '$');
347
348 if non_unary_binary(c0) && non_unary_binary(c1) {
349 // `x * * y`, `x / / y`, etc. — invalid R (both Alone-spaced,
350 // so this is never `**`/`%%`, which arrive Joint-spaced).
351 let _ = span1; // used in error below
352 return Err(Error::new(
353 span0,
354 format!(
355 "R syntax error: consecutive binary operators `{c0}` and `{c1}` — \
356 expected an operand between them"
357 ),
358 ));
359 }
360 }
361
362 Ok(())
363}
364
365// endregion
366
367// region: Helpers
368
369fn flatten_top_level(ts: &TokenStream) -> Vec<TokenTree> {
370 ts.clone().into_iter().collect()
371}
372
373fn split_by_comma(seq: &[TokenTree]) -> Vec<&[TokenTree]> {
374 let mut parts: Vec<&[TokenTree]> = Vec::new();
375 let mut start = 0;
376 for (i, tt) in seq.iter().enumerate() {
377 if is_comma(tt) {
378 parts.push(&seq[start..i]);
379 start = i + 1;
380 }
381 }
382 parts.push(&seq[start..]);
383 parts
384}
385
386fn split_by_semicolon(seq: &[TokenTree]) -> Vec<&[TokenTree]> {
387 let mut parts: Vec<&[TokenTree]> = Vec::new();
388 let mut start = 0;
389 for (i, tt) in seq.iter().enumerate() {
390 if is_semicolon(tt) {
391 parts.push(&seq[start..i]);
392 start = i + 1;
393 }
394 }
395 parts.push(&seq[start..]);
396 parts
397}
398
399fn is_comma(tt: &TokenTree) -> bool {
400 matches!(tt, TokenTree::Punct(p) if p.as_char() == ',')
401}
402
403fn is_semicolon(tt: &TokenTree) -> bool {
404 matches!(tt, TokenTree::Punct(p) if p.as_char() == ';')
405}
406
407/// Returns `true` for operators that are clearly binary and NOT unary-capable
408/// in R. `+`, `-`, `!`, `~`, `?` are unary-capable so we return `false`.
409fn is_pure_binary_op(p: &Punct) -> bool {
410 // We only flag unambiguously binary operators at end-of-sequence.
411 // Conservatively keep this list small.
412 matches!(p.as_char(), '*' | '/' | '^' | '%' | '@' | '$')
413}
414
415fn punct_char_at(seq: &[TokenTree], idx: usize) -> Option<char> {
416 match &seq[idx] {
417 TokenTree::Punct(p) => Some(p.as_char()),
418 _ => None,
419 }
420}
421
422/// Returns the span of a binary operator token at `pos` if it is a binary op.
423/// Used to anchor the "empty paren after binary op" diagnostic.
424fn preceding_binary_op_span(seq: &[TokenTree], pos: usize) -> Option<Span> {
425 let p = match &seq[pos] {
426 TokenTree::Punct(p) => p,
427 _ => return None,
428 };
429 // Full set of binary operators (include `+` and `-` here since `x + ()` is an error
430 // even though `+` can be unary — it's not unary when there's already a left operand,
431 // but we'd need a parser to know that). Stay conservative: only pure-binary.
432 if is_pure_binary_op(p) {
433 Some(p.span())
434 } else {
435 None
436 }
437}
438
439// endregion
440
441// region: Unit tests
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use proc_macro2::TokenStream;
447
448 fn ok(src: &str) {
449 let ts: TokenStream = src.parse().expect("not a valid Rust token stream");
450 assert!(
451 validate(&ts).is_ok(),
452 "expected Ok for {src:?}, got: {:?}",
453 validate(&ts)
454 );
455 }
456
457 fn err(src: &str) {
458 let ts: TokenStream = src.parse().expect("not a valid Rust token stream");
459 assert!(validate(&ts).is_err(), "expected Err for {src:?}, got Ok");
460 }
461
462 // region: Trailing binary operators (should be rejected)
463
464 #[test]
465 fn trailing_multiply() {
466 err("x *");
467 }
468
469 #[test]
470 fn trailing_divide() {
471 err("x /");
472 }
473
474 #[test]
475 fn trailing_caret() {
476 err("x ^");
477 }
478
479 // region: Things that should pass
480
481 #[test]
482 fn arithmetic_expression() {
483 ok("1L + 2L");
484 }
485
486 #[test]
487 fn assignment_arrow() {
488 // `<-` tokenises as `<` then `-` in proc_macro2 (joint)
489 ok(".x <- 41L + 1L");
490 }
491
492 #[test]
493 fn forward_arrow_assignment() {
494 ok("41L + 1L -> .y");
495 }
496
497 #[test]
498 fn semicolon_sequence() {
499 ok("a <- 7L ; a * 6L");
500 }
501
502 #[test]
503 fn multi_statement_with_env_form() {
504 // The env form is stripped before validation, but the body `local_val <- 7L; local_val * 6L`
505 // should be valid.
506 ok("local_val <- 7L ; local_val * 6L");
507 }
508
509 #[test]
510 fn trailing_semicolon_ok() {
511 ok("x <- 1L ;");
512 }
513
514 #[test]
515 fn nchar_call() {
516 ok(r#"nchar("hello")"#);
517 }
518
519 #[test]
520 fn index_with_empty_first_arg() {
521 // x[,1] — valid R matrix indexing; second comma-slot is non-empty
522 // This is a bracket group, not a function call.
523 ok("x[,1]");
524 }
525
526 #[test]
527 fn empty_call_args_are_valid_missing_args() {
528 // R's sublist grammar allows empty slots in every call form — they
529 // become the missing-arg sentinel. `matrix(, 2, 2)` is idiomatic.
530 ok("f(, x)");
531 ok("f(x,,y)");
532 ok("matrix(, 2, 2)");
533 }
534
535 #[test]
536 fn double_star_is_r_power_synonym() {
537 // R's parser accepts `**` as an undocumented synonym for `^`.
538 ok("2 ** 3");
539 }
540
541 #[test]
542 fn empty_function_call() {
543 // f() is valid
544 ok("f()");
545 }
546
547 #[test]
548 fn percent_in_percent() {
549 // %in% tokenises as `%` ident `%` — must pass
550 ok("x % in % y");
551 }
552
553 #[test]
554 fn tilde_formula() {
555 ok("y ~ x");
556 }
557
558 #[test]
559 fn logical_operators() {
560 ok("x && y");
561 ok("x || y");
562 }
563
564 // region: Control flow
565
566 #[test]
567 fn if_else_valid() {
568 ok("if (x > 0) x else - x");
569 }
570
571 #[test]
572 fn while_valid() {
573 ok("while (i < 10) i <- i + 1L");
574 }
575
576 #[test]
577 fn for_valid() {
578 ok("for (i in 1:10) print(i)");
579 }
580
581 // region: Control flow errors
582
583 #[test]
584 fn if_empty_condition() {
585 err("if () x");
586 }
587
588 #[test]
589 fn if_no_body() {
590 err("if (x)");
591 }
592
593 #[test]
594 fn for_missing_in() {
595 err("for (x) {}");
596 }
597
598 #[test]
599 fn for_empty() {
600 err("for () {}");
601 }
602
603 // region: Consecutive non-unary binary operators
604
605 #[test]
606 fn double_star() {
607 err("x * * y");
608 }
609
610 #[test]
611 fn double_slash() {
612 err("x / / y");
613 }
614
615 #[test]
616 fn trailing_empty_call_arg_ok() {
617 ok("f(x,)");
618 }
619
620 #[test]
621 fn empty_paren_after_binary_at() {
622 // `x @ ()` — `@` is a pure binary operator
623 err("x @ ()");
624 }
625}
626
627// endregion