Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: parse shuttle::endpoint macro #490

Merged
15 changes: 15 additions & 0 deletions codegen/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
mod main;
mod next;

use next::App;
use proc_macro::TokenStream;
use proc_macro_error::proc_macro_error;
use syn::{parse_macro_input, File};

#[proc_macro_error]
#[proc_macro_attribute]
pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream {
main::r#impl(attr, item)
}

#[proc_macro_error]
#[proc_macro]
pub fn app(item: TokenStream) -> TokenStream {
let mut file = parse_macro_input!(item as File);
// todo: handle error
oddgrd marked this conversation as resolved.
Show resolved Hide resolved
let app = App::from_file(&mut file);
quote::quote!(
#file
#app
)
.into()
}
2 changes: 1 addition & 1 deletion codegen/src/main/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,6 @@ mod tests {
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
t.compile_fail("tests/ui/main/*.rs");
}
}
268 changes: 265 additions & 3 deletions codegen/src/next/mod.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,145 @@
use proc_macro_error::emit_error;
use quote::{quote, ToTokens};
use syn::{Ident, LitStr};
use syn::{
parenthesized, parse::Parse, parse2, punctuated::Punctuated, token::Paren, Expr, File, Ident,
Item, ItemFn, Lit, LitStr, Token,
};

#[derive(Debug, Eq, PartialEq)]
struct Endpoint {
route: LitStr,
method: Ident,
function: Ident,
}

#[derive(Debug, Eq, PartialEq)]
struct Parameter {
key: Ident,
equals: Token![=],
value: Expr,
}

#[derive(Debug, Eq, PartialEq)]
struct Params {
params: Punctuated<Parameter, Token![,]>,
paren_token: Paren,
}

impl Parse for Parameter {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
Ok(Self {
key: input.parse()?,
equals: input.parse()?,
value: input.parse()?,
})
}
}

impl Parse for Params {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let content;
Ok(Self {
paren_token: parenthesized!(content in input),
params: content.parse_terminated(Parameter::parse)?,
})
}
}

impl Endpoint {
fn from_item_fn(item: &mut ItemFn) -> Option<Self> {
let function = item.sig.ident.clone();

let params = if let Some(attribute) = item.attrs.get(0) {
attribute.tokens.clone()
} else {
return None;
};

item.attrs.clear();
oddgrd marked this conversation as resolved.
Show resolved Hide resolved

let params: Params = match parse2(params) {
Ok(params) => params,
Err(err) => {
emit_error!(
err.span(),
err;
hint = "The endpoint takes a comma-separated list of keys and values: `endpoint(method = get, route = \"/hello\")`"
);
return None;
}
};

// use paren span for missing argument errors
oddgrd marked this conversation as resolved.
Show resolved Hide resolved
let paren = params.paren_token;

if params.params.is_empty() {
emit_error!(
paren.span,
"missing endpoint arguments";
hint = "The endpoint takes two arguments: `endpoint(method = get, route = \"/hello\")`"
);
return None;
}

let mut route = None;
let mut method = None;

for Parameter { key, value, .. } in params.params {
let key_ident = key.clone();
match key.to_string().as_str() {
"method" => {
if let Expr::Path(path) = value {
method = Some(path.path.segments[0].ident.clone());
oddgrd marked this conversation as resolved.
Show resolved Hide resolved
};
}
"route" => {
if let Expr::Lit(literal) = value {
if let Some(Lit::Str(literal)) = Some(literal.lit) {
oddgrd marked this conversation as resolved.
Show resolved Hide resolved
route = Some(literal);
}
}
}
_ => {
emit_error!(
key_ident,
"invalid endpoint argument";
hint = "Only `method` and `route` are valid endpoint arguments."
);
oddgrd marked this conversation as resolved.
Show resolved Hide resolved
return None;
}
}
}

let route = if let Some(route) = route {
route
} else {
emit_error!(
paren.span,
"no route provided";
hint = "Add a route to your endpoint: `#[shuttle_codegen::endpoint(method = get, route = \"/hello\")]`"
);
return None;
};

let method = if let Some(method) = method {
method
} else {
emit_error!(
paren.span,
"no method provided";
hint = "Add a method to your endpoint: `#[shuttle_codegen::endpoint(method = get, route = \"/hello\")]`"
);
return None;
oddgrd marked this conversation as resolved.
Show resolved Hide resolved
};

Some(Endpoint {
route,
method,
function,
})
}
}

impl ToTokens for Endpoint {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let Self {
Expand All @@ -33,10 +165,30 @@ impl ToTokens for Endpoint {
}
}

#[derive(Debug, Eq, PartialEq)]
pub(crate) struct App {
endpoints: Vec<Endpoint>,
}

impl App {
pub(crate) fn from_file(file: &mut File) -> Self {
let endpoints = file
.items
.iter_mut()
.filter_map(|item| {
if let Item::Fn(item_fn) = item {
Some(item_fn)
} else {
None
}
})
.filter_map(Endpoint::from_item_fn)
.collect();

Self { endpoints }
}
}

impl ToTokens for App {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let Self { endpoints } = self;
Expand Down Expand Up @@ -133,9 +285,9 @@ mod tests {
use quote::quote;
use syn::parse_quote;

use crate::next::App;
use crate::next::{App, Parameter};

use super::Endpoint;
use super::{Endpoint, Params};

#[test]
fn endpoint_to_token() {
Expand Down Expand Up @@ -189,4 +341,114 @@ mod tests {

assert_eq!(actual.to_string(), expected.to_string());
}

#[test]
fn parse_endpoint() {
let mut input = parse_quote! {
#[shuttle_codegen::endpoint(method = get, route = "/hello")]
async fn hello() -> &'static str {
"Hello, World!"
}

};

let actual = Endpoint::from_item_fn(&mut input).unwrap();
let expected = Endpoint {
route: parse_quote!("/hello"),
method: parse_quote!(get),
function: parse_quote!(hello),
};

assert_eq!(actual, expected);

assert!(
input.attrs.is_empty(),
"expected attributes to be stripped since there is no macro for them"
);
}

#[test]
fn parse_parameter() {
// test method param
let tests: Vec<(Parameter, Parameter)> = vec![
(
// parsing an identifier
parse_quote! {
method = get
},
Parameter {
key: parse_quote!(method),
equals: parse_quote!(=),
value: parse_quote!(get),
},
),
(
// parsing a string literal
parse_quote! {
route = "/hello"
},
Parameter {
key: parse_quote!(route),
equals: parse_quote!(=),
value: parse_quote!("/hello"),
},
),
];
for (actual, expected) in tests {
assert_eq!(actual, expected);
}
}

#[test]
fn parse_params() {
let actual: Params = parse_quote![(method = get, route = "/hello")];

let mut expected = Params {
params: Default::default(),
paren_token: Default::default(),
};
expected.params.push(parse_quote!(method = get));
expected.params.push(parse_quote!(route = "/hello"));

assert_eq!(actual, expected);
}

#[test]
fn parse_app() {
let mut input = parse_quote! {
#[shuttle_codegen::endpoint(method = get, route = "/hello")]
async fn hello() -> &'static str {
"Hello, World!"
}

#[shuttle_codegen::endpoint(method = post, route = "/goodbye")]
async fn goodbye() -> &'static str {
"Goodbye, World!"
}
};

let actual = App::from_file(&mut input);
let expected = App {
endpoints: vec![
Endpoint {
route: parse_quote!("/hello"),
method: parse_quote!(get),
function: parse_quote!(hello),
},
Endpoint {
route: parse_quote!("/goodbye"),
method: parse_quote!(post),
function: parse_quote!(goodbye),
},
],
};

assert_eq!(actual, expected);
}

#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/next/*.rs");
}
}
6 changes: 6 additions & 0 deletions codegen/tests/ui/next/invalid-endpoint-param.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
shuttle_codegen::app! {
#[shuttle_codegen::endpoint(method = get, route = "/goodbye", invalid = bad)]
async fn goodbye() -> &'static str {
"Goodbye, World!"
}
}
14 changes: 14 additions & 0 deletions codegen/tests/ui/next/invalid-endpoint-param.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
error: invalid endpoint argument

= help: Only `method` and `route` are valid endpoint arguments.

--> tests/ui/next/invalid-endpoint-param.rs:2:67
|
2 | #[shuttle_codegen::endpoint(method = get, route = "/goodbye", invalid = bad)]
| ^^^^^^^

error[E0601]: `main` function not found in crate `$CRATE`
--> tests/ui/next/invalid-endpoint-param.rs:6:2
|
6 | }
| ^ consider adding a `main` function to `$DIR/tests/ui/next/invalid-endpoint-param.rs`
11 changes: 11 additions & 0 deletions codegen/tests/ui/next/invalid-endpoint-syntax.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
shuttle_codegen::app! {
#[shuttle_codegen::endpoint(method = get, route = "/hello" extra = abundant)]
async fn hello() -> &'static str {
"Hello, World!"
}

#[shuttle_codegen::endpoint(method = get, route = "/goodbye", invalid)]
async fn goodbye() -> &'static str {
"Goodbye, World!"
}
}
23 changes: 23 additions & 0 deletions codegen/tests/ui/next/invalid-endpoint-syntax.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
error: expected `,`

= help: The endpoint takes a comma-separated list of keys and values: `endpoint(method = get, route = "/hello")`
oddgrd marked this conversation as resolved.
Show resolved Hide resolved

--> tests/ui/next/invalid-endpoint-syntax.rs:2:64
|
2 | #[shuttle_codegen::endpoint(method = get, route = "/hello" extra = abundant)]
| ^^^^^

error: expected `=`

= help: The endpoint takes a comma-separated list of keys and values: `endpoint(method = get, route = "/hello")`

--> tests/ui/next/invalid-endpoint-syntax.rs:7:74
|
7 | #[shuttle_codegen::endpoint(method = get, route = "/goodbye", invalid)]
| ^

error[E0601]: `main` function not found in crate `$CRATE`
--> tests/ui/next/invalid-endpoint-syntax.rs:11:2
|
11 | }
| ^ consider adding a `main` function to `$DIR/tests/ui/next/invalid-endpoint-syntax.rs`
Loading