use crate::route::{args::RouteArgs, attrs::split_doc, uri::endpoint_uri_impl};
fn lines(input: &[&str]) -> Vec<String> {
input.iter().map(|line| (*line).to_owned()).collect()
}
mod doc_comments {
use super::{lines, split_doc};
fn words(text: Option<&str>) -> Vec<String> {
text.map(|text| text.split_whitespace().map(str::to_owned).collect())
.unwrap_or_default()
}
#[test]
fn every_arrangement_of_blank_lines_splits_without_losing_a_word() {
for width in 0..=5usize {
for pattern in 0..(1usize << width) {
let blank: Vec<bool> = (0..width).map(|bit| pattern & (1 << bit) != 0).collect();
let rendered: Vec<String> = blank
.iter()
.enumerate()
.map(|(index, &is_blank)| {
if is_blank {
" ".to_owned()
} else {
format!(" w{index}")
}
})
.collect();
let (summary, description) = split_doc(&rendered);
let boundary = blank.iter().position(|&is_blank| is_blank).unwrap_or(width);
let expected_summary: Vec<String> =
(0..boundary).map(|index| format!("w{index}")).collect();
let expected_description: Vec<String> = ((boundary + 1)..width)
.filter(|index| !blank[*index])
.map(|index| format!("w{index}"))
.collect();
assert_eq!(
words(summary.as_deref()),
expected_summary,
"summary for {blank:?}"
);
assert_eq!(
words(description.as_deref()),
expected_description,
"description for {blank:?}"
);
assert_ne!(summary.as_deref(), Some(""), "summary for {blank:?}");
assert_ne!(
description.as_deref(),
Some(""),
"description for {blank:?}"
);
}
}
}
#[test]
fn a_single_line_doc_comment_is_all_summary() {
let (summary, description) = split_doc(&lines(&[" Fetch a single user."]));
assert_eq!(summary.as_deref(), Some("Fetch a single user."));
assert_eq!(description, None);
}
#[test]
fn the_first_paragraph_is_the_summary_and_the_rest_the_description() {
let (summary, description) = split_doc(&lines(&[
" Fetch a single user.",
"",
" Includes soft-deleted accounts.",
" Requires the `users:read` scope.",
]));
assert_eq!(summary.as_deref(), Some("Fetch a single user."));
assert_eq!(
description.as_deref(),
Some("Includes soft-deleted accounts.\nRequires the `users:read` scope.")
);
}
#[test]
fn a_wrapped_first_paragraph_joins_into_one_summary() {
let (summary, description) = split_doc(&lines(&[
" Fetch a single user by",
" its identifier.",
"",
" More detail.",
]));
assert_eq!(
summary.as_deref(),
Some("Fetch a single user by its identifier.")
);
assert_eq!(description.as_deref(), Some("More detail."));
}
#[test]
fn a_paragraph_break_survives_into_the_description() {
let (_, description) = split_doc(&lines(&[" Summary.", "", " First.", "", " Second."]));
assert_eq!(description.as_deref(), Some("First.\n\nSecond."));
}
#[test]
fn an_absent_doc_comment_yields_neither() {
let (summary, description) = split_doc(&[]);
assert_eq!(summary, None);
assert_eq!(description, None);
}
#[test]
fn trailing_blank_lines_do_not_become_an_empty_description() {
let (summary, description) = split_doc(&lines(&[" Fetch a user.", "", " "]));
assert_eq!(summary.as_deref(), Some("Fetch a user."));
assert_eq!(description, None);
}
}
mod typed_uri {
use quote::ToTokens;
use syn::ItemFn;
use super::endpoint_uri_impl;
#[derive(Debug, PartialEq, Eq)]
struct Emission {
parameters: Vec<String>,
builder: String,
checks_names: bool,
}
fn emitted(function: &ItemFn, path: &str, variables: &[&str]) -> Emission {
let variables: Vec<String> = variables.iter().map(|name| (*name).to_owned()).collect();
let tokens = endpoint_uri_impl(function, path, &variables).expect("a valid endpoint");
let file: syn::File = syn::parse2(tokens).expect("the expansion parses as items");
let block = file
.items
.iter()
.find_map(|item| match item {
syn::Item::Impl(block) => Some(block),
_ => None,
})
.expect("an inherent impl");
let constructor = block
.items
.iter()
.find_map(|item| match item {
syn::ImplItem::Fn(function) => Some(function),
_ => None,
})
.expect("a `relative_uri` constructor");
assert_eq!(constructor.sig.ident, "relative_uri");
let parameters = constructor
.sig
.inputs
.iter()
.map(|input| {
let syn::FnArg::Typed(argument) = input else {
panic!("`relative_uri` is an associated function, not a method")
};
format!(
"{}: {}",
argument.pat.to_token_stream(),
argument.ty.to_token_stream()
)
})
.collect();
let [syn::Stmt::Expr(syn::Expr::Call(call), None)] = &constructor.block.stmts[..] else {
panic!("`relative_uri` is one call")
};
let syn::Expr::Path(callee) = call.func.as_ref() else {
panic!("the call names a path")
};
Emission {
parameters,
builder: callee
.path
.segments
.last()
.expect("a builder name")
.ident
.to_string(),
checks_names: file
.items
.iter()
.any(|item| matches!(item, syn::Item::Const(_))),
}
}
#[test]
fn a_route_without_parameters_takes_none() {
let function: ItemFn = syn::parse_quote! {
async fn report() {}
};
assert_eq!(
emitted(&function, "/reports", &[]),
Emission {
parameters: vec![],
builder: "endpoint_uri".to_owned(),
checks_names: false,
}
);
}
#[test]
fn a_route_with_path_variables_takes_the_extracted_path_type() {
let function: ItemFn = syn::parse_quote! {
async fn report(Path(path): Path<ReportPath>) {}
};
assert_eq!(
emitted(&function, "/reports/{name}", &["name"]),
Emission {
parameters: vec!["path: ReportPath".to_owned()],
builder: "endpoint_uri_with_path".to_owned(),
checks_names: true,
}
);
}
#[test]
fn a_route_with_a_query_extractor_takes_the_extracted_query_type() {
let function: ItemFn = syn::parse_quote! {
async fn report(Query(query): Query<ReportQuery>) {}
};
assert_eq!(
emitted(&function, "/reports", &[]),
Emission {
parameters: vec!["query: ReportQuery".to_owned()],
builder: "endpoint_uri_with_query".to_owned(),
checks_names: false,
}
);
}
#[test]
fn a_route_extracting_both_takes_both_in_that_order() {
let function: ItemFn = syn::parse_quote! {
async fn report(Path(path): Path<ReportPath>, Query(query): Query<ReportQuery>) {}
};
assert_eq!(
emitted(&function, "/reports/{name}", &["name"]),
Emission {
parameters: vec![
"path: ReportPath".to_owned(),
"query: ReportQuery".to_owned(),
],
builder: "endpoint_uri_with_path_and_query".to_owned(),
checks_names: true,
}
);
}
fn cases() -> Vec<(
&'static str,
ItemFn,
&'static str,
&'static [&'static str],
&'static str,
)> {
vec![
(
"a Path<T> extractor on a route with no variables",
syn::parse_quote!(
async fn report(Path(path): Path<ReportPath>) {}
),
"/reports",
&[],
"no path variables",
),
(
"path variables with no Path<T> extractor",
syn::parse_quote!(
async fn report() {}
),
"/reports/{name}",
&["name"],
"no Path<T> extractor",
),
(
"a Path extractor with no type argument",
syn::parse_quote!(
async fn report(Path(path): Path) {}
),
"/reports/{name}",
&["name"],
"Path needs one type argument",
),
(
"a Path extractor whose argument is not a type",
syn::parse_quote!(
async fn report(Path(path): Path<'a>) {}
),
"/reports/{name}",
&["name"],
"Path needs one type argument",
),
(
"two Path<T> extractors on one handler",
syn::parse_quote!(
async fn report(Path(one): Path<A>, Path(two): Path<B>) {}
),
"/reports/{name}",
&["name"],
"only once",
),
]
}
#[test]
fn each_case_raises_the_diagnostic_it_names() {
for (description, function, path, variables, expected) in cases() {
let variables: Vec<String> = variables.iter().map(|name| (*name).to_owned()).collect();
let Err(error) = endpoint_uri_impl(&function, path, &variables) else {
panic!("{description} must be rejected");
};
let reported = error.to_string();
assert!(
reported.contains(expected),
"{description}: expected a diagnostic containing {expected:?}, got {reported:?}"
);
}
}
#[test]
fn every_typed_uri_diagnostic_has_a_case() {
const SOURCE: &str = include_str!("uri.rs");
let sites = SOURCE.matches("syn::Error::new(").count();
assert_eq!(
cases().len(),
sites,
"`uri.rs` raises {sites} diagnostic(s) and {} have a case",
cases().len()
);
}
}
mod arguments {
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use super::RouteArgs;
fn cases() -> Vec<(&'static str, TokenStream2, &'static str)> {
vec![
(
"a bare word that is not `catch_panics`",
quote!(path = "/health", nonsense),
"expected a path string literal",
),
(
"a second tag, which would silently discard the first",
quote!(path = "/health", tag = Users, tag = Admin),
"already names a tag",
),
(
"an argument no attribute reads",
quote!(path = "/health", nonsense = "x"),
"unknown route argument",
),
(
"a list where a value belongs",
quote!(path = "/health", tag(Users)),
"expected `name = value`, not a list",
),
(
"no path at all",
quote!(operation_id = "getHealth"),
"needs a path",
),
(
"a path that is not a string",
quote!(path = 7),
"expected a string literal",
),
(
"a tag that is not a path expression",
quote!(path = "/health", tag = "Users"),
"expected the name of a type deriving `Tag`",
),
(
"a tag naming more than one segment",
quote!(path = "/health", tag = tags::Users),
"expected the name of a type deriving `Tag`",
),
]
}
#[test]
fn each_case_raises_the_diagnostic_it_names() {
for (description, tokens, expected) in cases() {
let Err(error) = RouteArgs::parse(tokens) else {
panic!("{description} must be rejected");
};
let reported = error.to_string();
assert!(
reported.contains(expected),
"{description}: expected a diagnostic containing {expected:?}, got {reported:?}"
);
}
}
#[test]
fn every_route_argument_diagnostic_has_a_case() {
const SOURCE: &str = include_str!("args.rs");
let sites = SOURCE.matches("syn::Error::new(").count();
assert_eq!(
cases().len(),
sites,
"`args.rs` raises {sites} diagnostic(s) and {} have a case; an argument rule added \
without one is a rule that can stop firing silently",
cases().len()
);
}
#[test]
fn a_per_method_attribute_rejects_a_method_argument() {
let Err(error) = RouteArgs::parse(quote!(path = "/health", method = "POST")) else {
panic!("a per-method attribute must not accept `method`")
};
assert!(error.to_string().contains("unknown route argument"));
}
#[test]
fn catch_panics_is_a_bare_route_option() {
let args = RouteArgs::parse(quote!(path = "/health", catch_panics))
.expect("valid route arguments");
assert!(args.catch_panics);
}
#[test]
fn the_arguments_parse_alike_in_every_order() {
let pieces: [TokenStream2; 4] = [
quote!(path = "/health"),
quote!(operation_id = "getHealth"),
quote!(tag = Users),
quote!(catch_panics),
];
for order in permutations(&[0, 1, 2, 3]) {
let tokens = order
.iter()
.map(|index| pieces[*index].clone())
.reduce(|left, right| quote!(#left, #right))
.expect("four pieces");
let args = RouteArgs::parse(tokens).unwrap_or_else(|error| {
panic!("order {order:?} must parse, got {error}");
});
assert_eq!(args.path.value(), "/health", "order {order:?}");
assert_eq!(
args.operation_id.map(|id| id.value()).as_deref(),
Some("getHealth"),
"order {order:?}"
);
assert_eq!(
args.tag.map(|tag| tag.to_string()).as_deref(),
Some("Users"),
"order {order:?}"
);
assert!(args.catch_panics, "order {order:?}");
}
}
fn permutations(items: &[usize]) -> Vec<Vec<usize>> {
if items.len() <= 1 {
return vec![items.to_vec()];
}
let mut orders = Vec::new();
for (index, item) in items.iter().enumerate() {
let mut rest = items.to_vec();
rest.remove(index);
for mut tail in permutations(&rest) {
tail.insert(0, *item);
orders.push(tail);
}
}
orders
}
}