Skip to main content

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