diff --git a/README.md b/README.md index d758f48..1d70b7d 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,35 @@ This crate contains three attributes that all generate tests based on a file glo ### Text files -Receive file contents as `&'static str` with `test_each::file`. This ignores any matched directories. +Receive file contents as [`&'static str`](std::str) with [`test_each::file`](crate::file). This ignores any matched directories. ```rust -#[test_each::file("data/*.txt")] +#[test_each::file(glob = "data/*.txt")] fn test_file(content: &str) { // check contents } ``` -If data contains the files `foo.txt` and `bar.txt`, the following code will be generated: +If `data` contains the files `foo.txt` and `bar.txt`, the following code will be generated: ```rust #[test] -fn test_file_foo_txt_0() { +fn test_file_foo() { test_file(include_str("data/foo.txt")) } #[test] -fn test_file_bar_txt_1() { +fn test_file_bar() { test_file(include_str("data/bar.txt")) } ``` ### Binary files -Receive file contents as `&'static [u8]` with `test_each::blob`. This ignores any matched directories. +Receive file contents as [`&'static [u8]`](std::slice) with [`test_each::blob`](crate::file). This ignores any matched directories. ```rust -#[test_each::blob("data/*.bin")] +#[test_each::blob(glob = "data/*.bin")] fn test_bytes(content: &[u8]) { // check contents } @@ -46,7 +46,7 @@ fn test_bytes(content: &[u8]) { Declare a second parameter in order to additionally receive the path of file. ```rust -#[test_each::blob("data/*.bin")] +#[test_each::blob(glob = "data/*.bin")] fn test_bytes(content: &[u8], path: PathBuf) { // check contents and path } @@ -54,15 +54,31 @@ fn test_bytes(content: &[u8], path: PathBuf) { ### Paths to files and directories -Receive file path as `PathBuf` with `test_each::path`. This includes any matched directories. +Receive file path as [`PathBuf`](std::path::PathBuf) with [`test_each::path`](crate::path). This includes any matched directories. ```rust -#[test_each::path("data/*")] +#[test_each::path(glob = "data/*")] fn test_bytes(path: PathBuf) { // check path } ``` +### Customizing the function name + +By default the name of the generated test will consist of the escaped file name without extension. Use the `name` attribute to change how the function names are formatted. + +Use `name(segments = )` to add `n` amount of path segments (from right to left) to the name. + +Use `name(index)` to add a unique index to the end of the test name. This will prevent name collisions. + +Use `name(extension)` to include the file extension the end of the test name. + +```rust +/// The generated function name will be `test_file_bar_baz_data_txt_0` +#[test_each::file(glob = "foo/bar/baz/data.txt", name(segments = 3, index, extension))] +fn test_file(_: &str) {} +``` + ## Notes Any change to an already included file will correctly trigger a recompilation, but creating a new file that matches the glob might not cause a recompilation. diff --git a/src/lib.rs b/src/lib.rs index 9230414..8912637 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,19 +9,32 @@ /// /// # Usage /// ```rust -/// #[test_each::file("data/*.txt")] +/// #[test_each::file(glob = "data/*.txt")] /// fn test_file(content: &str) { /// // test contents /// } /// ``` /// -/// Add a second parameter of type `PathBuf` to receive the path of the file. +/// Add a second parameter of type [`PathBuf`](std::path::PathBuf) to receive the path of the file. /// ```rust /// #[test_each::file("data/*.txt")] /// fn test_file(content: &str, path: PathBuf) { /// // test contents /// } /// ``` +/// +/// ## Customizing the function name +/// +/// Use `name(segments = )` to use up to `n` path segments in the generated function name. +/// +/// Use `name(extension)` to include the file extension in the generated function name. +/// +/// Use `name(index)` to include a unique index in the generated function name. +/// +/// ```rust +/// #[test_each::file("data/*.txt", name(segments = 2, extension, index))] +/// fn test_file(_: &str) { } +/// ``` pub use test_each_codegen::test_each_file as file; #[doc(inline)] @@ -38,13 +51,26 @@ pub use test_each_codegen::test_each_file as file; /// } /// ``` /// -/// Add a second parameter of type `PathBuf` to receive the path of the file. +/// Add a second parameter of type [`PathBuf`](std::path::PathBuf) to receive the path of the file. /// ```rust /// #[test_each::blob("data/*.bin")] /// fn test_bytes(content: &[u8], path: PathBuf) { /// // test contents /// } /// ``` +/// +/// ## Customizing the function name +/// +/// Use `name(segments = )` to use up to `n` path segments in the generated function name. +/// +/// Use `name(extension)` to include the file extension in the generated function name. +/// +/// Use `name(index)` to include a unique index in the generated function name. +/// +/// ```rust +/// #[test_each::blob("data/*.txt", name(segments = 2, extension, index))] +/// fn test_file(_: &[u8]) { } +/// ``` pub use test_each_codegen::test_each_blob as blob; #[doc(inline)] @@ -60,4 +86,17 @@ pub use test_each_codegen::test_each_blob as blob; /// // test contents /// } /// ``` +/// +/// ## Customizing the function name +/// +/// Use `name(segments = )` to use up to `n` path segments in the generated function name. +/// +/// Use `name(extension)` to include the file extension in the generated function name. +/// +/// Use `name(index)` to include a unique index in the generated function name. +/// +/// ```rust +/// #[test_each::path("data/*.txt", name(segments = 2, extension, index))] +/// fn test_file(_: PathBuf) { } +/// ``` pub use test_each_codegen::test_each_path as path; diff --git a/test-each-codegen/src/lib.rs b/test-each-codegen/src/lib.rs index 7f99f4c..132da0b 100644 --- a/test-each-codegen/src/lib.rs +++ b/test-each-codegen/src/lib.rs @@ -1,12 +1,63 @@ use proc_macro::TokenStream; use quote::{format_ident, quote, ToTokens}; use syn::{ - parse::Parser, parse_macro_input, punctuated::Punctuated, Error, ItemFn, LitStr, Result, Token, + meta::ParseNestedMeta, parse_macro_input, spanned::Spanned, Error, ItemFn, LitInt, LitStr, + Result, }; +struct Attrs { + glob: Option, + segments: usize, + index: bool, + extension: bool, +} + +impl Attrs { + pub fn new() -> Self { + Self { + glob: None, + segments: 1, + index: false, + extension: false, + } + } + + fn parse(&mut self, meta: ParseNestedMeta) -> Result<()> { + if meta.path.is_ident("glob") { + let glob: LitStr = meta.value()?.parse()?; + self.glob = Some(glob.value()); + } else if meta.path.is_ident("name") { + meta.parse_nested_meta(|nested| { + if nested.path.is_ident("segments") { + let path_segments: LitInt = nested.value()?.parse()?; + self.segments = path_segments.base10_parse()?; + } else if nested.path.is_ident("index") { + self.index = true + } else if nested.path.is_ident("extension") { + self.extension = true + } else { + return Err(nested.error( + "unsupported property, specify `segments = `, `index` or `extension`", + )); + } + + Ok(()) + })?; + } else { + return Err(meta.error("unsupported property, specify `glob` or `name`")); + } + + Ok(()) + } +} + #[proc_macro_attribute] -pub fn test_each_file(attrs: TokenStream, input: TokenStream) -> TokenStream { +pub fn test_each_file(args: TokenStream, input: TokenStream) -> TokenStream { + let mut attrs = Attrs::new(); + let attr_parser = syn::meta::parser(|meta| attrs.parse(meta)); let input = parse_macro_input!(input as ItemFn); + parse_macro_input!(args with attr_parser); + match test_each(attrs, input, Kind::File) { Ok(output) => output, Err(err) => err.into_compile_error().into(), @@ -14,8 +65,12 @@ pub fn test_each_file(attrs: TokenStream, input: TokenStream) -> TokenStream { } #[proc_macro_attribute] -pub fn test_each_blob(attrs: TokenStream, input: TokenStream) -> TokenStream { +pub fn test_each_blob(args: TokenStream, input: TokenStream) -> TokenStream { + let mut attrs = Attrs::new(); + let attr_parser = syn::meta::parser(|meta| attrs.parse(meta)); let input = parse_macro_input!(input as ItemFn); + parse_macro_input!(args with attr_parser); + match test_each(attrs, input, Kind::Blob) { Ok(output) => output, Err(err) => err.into_compile_error().into(), @@ -23,8 +78,12 @@ pub fn test_each_blob(attrs: TokenStream, input: TokenStream) -> TokenStream { } #[proc_macro_attribute] -pub fn test_each_path(attrs: TokenStream, input: TokenStream) -> TokenStream { +pub fn test_each_path(args: TokenStream, input: TokenStream) -> TokenStream { + let mut attrs = Attrs::new(); + let attr_parser = syn::meta::parser(|meta| attrs.parse(meta)); let input = parse_macro_input!(input as ItemFn); + parse_macro_input!(args with attr_parser); + match test_each(attrs, input, Kind::Path) { Ok(output) => output, Err(err) => err.into_compile_error().into(), @@ -37,53 +96,55 @@ enum Kind { Path, } -fn test_each(attrs: TokenStream, input: ItemFn, kind: Kind) -> Result { - let lits = Punctuated::::parse_terminated.parse(attrs)?; - let mut functions = vec![input.clone().to_token_stream()]; +fn test_each(attrs: Attrs, input: ItemFn, kind: Kind) -> Result { + let mut functions = vec![input.to_token_stream()]; - let name = input.sig.ident; - let vis = input.vis; - let ret = input.sig.output; + let name = &input.sig.ident; + let vis = &input.vis; + let ret = &input.sig.output; let n_args = input.sig.inputs.len(); - if lits.len() != 1 { - return Err(Error::new( - name.span(), - "expected a single path glob literal", - )); - } - - let pattern = &lits[0].value(); + let pattern = attrs + .glob + .as_ref() + .ok_or_else(|| Error::new(input.span(), "missing `glob` attribute"))?; - let files = glob::glob(pattern).map_err(|err| { - Error::new( - lits[0].span(), - format!("invalid path glob pattern: {}", err), - ) - })?; + let files = glob::glob(pattern) + .map_err(|err| Error::new(input.span(), format!("invalid path glob pattern: {}", err)))?; for (i, file) in files.enumerate() { - let file = file.map_err(|err| { - Error::new(lits[0].span(), format!("could not read directory: {}", err)) - })?; + let mut file = file + .map_err(|err| Error::new(input.span(), format!("could not read directory: {}", err)))? + .canonicalize() + .map_err(|err| Error::new(input.span(), format!("could not read file: {}", err)))?; match kind { Kind::File | Kind::Blob if file.is_dir() => continue, _ => {} }; - let file_name = file - .file_name() - .map(|name| format!("{}_", make_safe_ident(&name.to_string_lossy()))) - .unwrap_or_default(); + let path = file.to_string_lossy().to_string(); + + if !attrs.extension { + file.set_extension(""); + } - let test_name = format_ident!("{}_{}{}", name, file_name, i); + let mut path_segments = file + .iter() + .rev() + .take(attrs.segments) + .map(|s| make_safe_ident(&s.to_string_lossy())) + .collect::>(); - let path = file - .canonicalize() - .map_err(|err| Error::new(lits[0].span(), format!("could not read file: {}", err)))? - .to_string_lossy() - .to_string(); + path_segments.reverse(); + + let path_name = path_segments.join("_"); + + let test_name = if attrs.index { + format_ident!("{}_{}_{}", name, path_name, i) + } else { + format_ident!("{}_{}", name, path_name) + }; let into_path = quote!(::std::path::PathBuf::from(#path)); @@ -117,5 +178,5 @@ fn make_safe_ident(value: &str) -> String { } } - result + result.trim_matches('_').to_string() } diff --git a/tests/integration.rs b/tests/integration.rs index 8216e4b..88406a7 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,18 +1,18 @@ use std::{io::BufRead, path::PathBuf}; -#[test_each::file("tests/data/*.txt")] +#[test_each::file(glob = "tests/data/*.txt", name(index))] fn test_file(content: &str) { assert_eq!(Some("hello world"), content.lines().next()) } -#[test_each::file("tests/data/*.txt")] +#[test_each::file(glob = "tests/data/*.txt", name(segments = 2, extension))] fn test_file_with_path(content: &str, path: PathBuf) { let mut lines = content.lines(); assert_eq!(Some("hello world"), lines.next()); assert_eq!(path.file_name().and_then(|s| s.to_str()), lines.next()); } -#[test_each::blob("tests/data/*.txt")] +#[test_each::blob(glob = "tests/data/*.txt", name(segments = 3, extension, index))] fn test_blob(content: &[u8]) { assert_eq!( Some(b"hello world".to_vec()), @@ -20,7 +20,7 @@ fn test_blob(content: &[u8]) { ) } -#[test_each::path("tests/data/*.txt")] +#[test_each::path(glob = "tests/data/*.txt")] fn test_path(path: PathBuf) { match path.file_name().and_then(|s| s.to_str()) { Some("foo.txt" | "bar.txt") => {}