Skip to main content

miniextendr_macros/
misplaced_dots.rs

1//! Diagnostic for a Rust `...` parameter that is not the last one (#1737).
2//!
3//! Rust's `...` parameter parses only in last position, so
4//! `fn f(sources: ..., dosing_type: Strs)` is not a function to `syn`. The item
5//! fails `ItemFn` (and, as a method, `ItemImpl` / `ItemTrait`), which would leave
6//! `#[miniextendr]` with its struct/enum message. [`find`] recognises that shape
7//! at token level so the fallback can name the cause and the `&Dots` spelling.
8//!
9//! Mirror: `miniextendr-lint/src/misplaced_dots.rs` appends the same text to the
10//! lint's "failed to parse" warning. Keep the detector and [`message`] in sync.
11
12use proc_macro2::{Delimiter, Spacing, TokenStream, TokenTree};
13
14/// A Rust `...` parameter followed by another parameter.
15pub(crate) struct MisplacedDots {
16    /// The parameter's name; `None` for unnamed dots (a bare `...` or `_: ...`).
17    pub(crate) name: Option<String>,
18    /// The three `.` tokens, so a diagnostic can span the whole `...`.
19    pub(crate) dots: TokenStream,
20}
21
22/// The diagnostic for a `...` parameter that is not last, named `name` (`None` for unnamed dots).
23///
24/// Unnamed dots get `_dots`: `_: &Dots` would work on a function (its `_`
25/// parameters are renamed) but not on a method, where the `&Dots` parameter
26/// needs a plain name. No spelling of Rust `...` parses before another
27/// parameter, so the hint names `&Dots`, never `_: ...`.
28pub(crate) fn message(name: Option<&str>) -> String {
29    let param = name.unwrap_or("_dots");
30    ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("Rust\'s `...` is only valid as the last parameter; write a dots parameter that is not last as `{0}: &Dots` (`miniextendr_api::dots::Dots`), which is R\'s `...` at that position. See https://a2-ai.github.io/miniextendr/manual/dots-typed-list/#formals-after",
                param))
    })format!(
31        "Rust's `...` is only valid as the last parameter; write a dots parameter that is not \
32         last as `{param}: &Dots` (`miniextendr_api::dots::Dots`), which is R's `...` at that \
33         position. See https://a2-ai.github.io/miniextendr/manual/dots-typed-list/#formals-after"
34    )
35}
36
37/// The first `...` parameter that another parameter follows, in any `fn` signature in `tokens`.
38pub(crate) fn find(tokens: TokenStream) -> Option<MisplacedDots> {
39    let tts: Vec<TokenTree> = tokens.into_iter().collect();
40    for (i, tt) in tts.iter().enumerate() {
41        match tt {
42            TokenTree::Ident(kw) if kw == "fn" => {
43                if let Some(TokenTree::Ident(_)) = tts.get(i + 1)
44                    && let Some(params) = params_group(&tts[i + 2..])
45                    && let Some(found) = in_params(params)
46                {
47                    return Some(found);
48                }
49            }
50            // Impl and trait bodies hold methods.
51            TokenTree::Group(group) => {
52                if let Some(found) = find(group.stream()) {
53                    return Some(found);
54                }
55            }
56            _ => {}
57        }
58    }
59    None
60}
61
62/// The parameter list after `fn name`: the first parenthesized group outside the
63/// generics' angle brackets.
64fn params_group(after_name: &[TokenTree]) -> Option<TokenStream> {
65    let mut depth = 0usize;
66    let mut after_minus = false;
67    for tt in after_name {
68        match tt {
69            TokenTree::Group(group) if depth == 0 => {
70                return (group.delimiter() == Delimiter::Parenthesis).then(|| group.stream());
71            }
72            TokenTree::Punct(p) if p.as_char() == '<' => depth += 1,
73            // The `>` of `->` (in a `Fn() -> T` bound) closes nothing.
74            TokenTree::Punct(p) if p.as_char() == '>' && !after_minus => {
75                depth = depth.checked_sub(1)?;
76            }
77            _ if depth == 0 => return None,
78            _ => {}
79        }
80        after_minus = #[allow(non_exhaustive_omitted_patterns)] match tt {
    TokenTree::Punct(p) if p.as_char() == '-' && p.spacing() == Spacing::Joint
        => true,
    _ => false,
}matches!(tt, TokenTree::Punct(p) if p.as_char() == '-' && p.spacing() == Spacing::Joint);
81    }
82    None
83}
84
85/// A `...` parameter in `params` with another parameter after it (a trailing comma is fine).
86fn in_params(params: TokenStream) -> Option<MisplacedDots> {
87    let tts: Vec<TokenTree> = params.into_iter().collect();
88    (0..tts.len()).find_map(|i| {
89        let dots = dots_at(&tts, i)?;
90        let comma_next = #[allow(non_exhaustive_omitted_patterns)] match tts.get(i + 3) {
    Some(TokenTree::Punct(p)) if p.as_char() == ',' => true,
    _ => false,
}matches!(tts.get(i + 3), Some(TokenTree::Punct(p)) if p.as_char() == ',');
91        if !comma_next || tts.len() <= i + 4 {
92            return None;
93        }
94        let name = match (
95            i.checked_sub(2).map(|j| &tts[j]),
96            i.checked_sub(1).map(|j| &tts[j]),
97        ) {
98            (Some(TokenTree::Ident(name)), Some(TokenTree::Punct(colon)))
99                if colon.as_char() == ':' && name != "_" =>
100            {
101                Some(name.to_string())
102            }
103            _ => None,
104        };
105        Some(MisplacedDots { name, dots })
106    })
107}
108
109/// The three `.` tokens of a `...` that starts at `tts[i]`.
110fn dots_at(tts: &[TokenTree], i: usize) -> Option<TokenStream> {
111    let three = tts.get(i..i + 3)?;
112    // The first two dots are joint (`...`, not `. . .`); the last one's spacing
113    // depends on what follows it.
114    let dot = |tt: &TokenTree, joint: bool| #[allow(non_exhaustive_omitted_patterns)] match tt {
    TokenTree::Punct(p) if
        p.as_char() == '.' && (!joint || p.spacing() == Spacing::Joint) =>
        true,
    _ => false,
}matches!(tt, TokenTree::Punct(p) if p.as_char() == '.' && (!joint || p.spacing() == Spacing::Joint));
115    (dot(&three[0], true) && dot(&three[1], true) && dot(&three[2], false))
116        .then(|| three.iter().cloned().collect())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn name_of(src: &str) -> Option<Option<String>> {
124        find(src.parse().unwrap()).map(|found| found.name)
125    }
126
127    #[test]
128    fn named_dots_before_another_parameter() {
129        assert_eq!(
130            name_of("pub fn f(sources: ..., dosing_type: Strs) {}"),
131            Some(Some("sources".to_string()))
132        );
133    }
134
135    #[test]
136    fn bare_dots_before_another_parameter() {
137        assert_eq!(name_of("fn f(x: i32, ..., y: i32) {}"), Some(None));
138    }
139
140    /// `_: ...` is unnamed dots too, so the hint names `_dots: &Dots`, which a
141    /// method accepts (`_: &Dots` there needs a plain name).
142    #[test]
143    fn wild_dots_before_another_parameter_are_unnamed() {
144        assert_eq!(name_of("fn f(x: i32, _: ..., y: i32) {}"), Some(None));
145        assert_eq!(
146            name_of("impl T { fn m(&self, _: ..., n: i32) {} }"),
147            Some(None)
148        );
149    }
150
151    #[test]
152    fn method_in_an_impl_body() {
153        assert_eq!(
154            name_of("impl T { pub fn m<'a>(&self, rest: ..., n: &'a str) {} }"),
155            Some(Some("rest".to_string()))
156        );
157    }
158
159    #[test]
160    fn generics_with_a_fn_bound() {
161        assert_eq!(
162            name_of("fn f<F: Fn(i32) -> i32>(rest: ..., f: F) {}"),
163            Some(Some("rest".to_string()))
164        );
165    }
166
167    #[test]
168    fn last_dots_are_fine() {
169        assert_eq!(name_of("fn f(x: i32, rest: ...) {}"), None);
170        assert_eq!(name_of("fn f(x: i32, rest: ...,) {}"), None);
171        assert_eq!(name_of("fn f(x: i32, ...) {}"), None);
172        assert_eq!(name_of("fn f(x: i32, _: ...) {}"), None);
173    }
174
175    #[test]
176    fn explicit_dots_type_is_fine() {
177        assert_eq!(name_of("fn f(rest: &Dots, x: i32) {}"), None);
178    }
179
180    #[test]
181    fn message_names_the_parameter() {
182        let msg = message(Some("sources"));
183        assert!(msg.starts_with("Rust's `...` is only valid as the last parameter"));
184        assert!(msg.contains("`sources: &Dots`"));
185        assert!(message(None).contains("`_dots: &Dots`"));
186    }
187}