diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d329bbe --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "test-each" +description = "Generate tests at compile-time based on file resources" +documentation = "https://docs.rs/test-each" +readme = "README.md" +version.workspace = true +authors.workspace = true +edition.workspace = true +repository.workspace = true +homepage.workspace = true +license-file.workspace = true +keywords.workspace = true +categories.workspace = true + +[workspace.package] +version = "0.1.0" +authors = ["Remo Pas "] +edition = "2021" +repository = "https://github.com/remkop22/test-each" +homepage = "https://github.com/remkop22/test-each" +license-file = "LICENSE" +keywords = ["test", "proc-macro", "codegen"] +categories = ["development-tools::testing", "development-tools::build-utils", "filesystem"] + +[lib] +doctest = false + +[workspace] +members = ["test-each-codegen"] + +[dependencies] +test-each-codegen = { version = "0.1.0", path = "test-each-codegen" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b6b5e9a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Remo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d758f48 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ + +# test-each + +Generate tests at compile-time based on files and directories. + +## Usage + +This crate contains three attributes that all generate tests based on a file glob pattern. Each attribute generates tests with different argument types. The generated tests will be named after sanitized versions of the file names. + +### Text files + +Receive file contents as `&'static str` with `test_each::file`. This ignores any matched directories. + +```rust +#[test_each::file("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: + +```rust +#[test] +fn test_file_foo_txt_0() { + test_file(include_str("data/foo.txt")) +} + +#[test] +fn test_file_bar_txt_1() { + test_file(include_str("data/bar.txt")) +} +``` + +### Binary files + +Receive file contents as `&'static [u8]` with `test_each::blob`. This ignores any matched directories. + +```rust +#[test_each::blob("data/*.bin")] +fn test_bytes(content: &[u8]) { + // check contents +} +``` + +Declare a second parameter in order to additionally receive the path of file. + +```rust +#[test_each::blob("data/*.bin")] +fn test_bytes(content: &[u8], path: PathBuf) { + // check contents and path +} +``` + +### Paths to files and directories + +Receive file path as `PathBuf` with `test_each::path`. This includes any matched directories. + +```rust +#[test_each::path("data/*")] +fn test_bytes(path: PathBuf) { + // check path +} +``` + +## 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. +To fix this issue add a build file that emits `cargo-rerun-if-changed={}`. + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9230414 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,63 @@ +#![deny(missing_docs)] +#![doc = include_str!("../README.md")] + +#[doc(inline)] +/// Generate a series of tests that receive file contents as strings, +/// based on the result of a glob pattern. +/// +/// This excludes any matched directories. +/// +/// # Usage +/// ```rust +/// #[test_each::file("data/*.txt")] +/// fn test_file(content: &str) { +/// // test contents +/// } +/// ``` +/// +/// Add a second parameter of type `PathBuf` to receive the path of the file. +/// ```rust +/// #[test_each::file("data/*.txt")] +/// fn test_file(content: &str, path: PathBuf) { +/// // test contents +/// } +/// ``` +pub use test_each_codegen::test_each_file as file; + +#[doc(inline)] +/// Generate a series of tests that receive file contents as byte slices, +/// based on the result of a glob pattern. +/// +/// This excludes any matched directories. +/// +/// # Usage +/// ```rust +/// #[test_each::blob("data/*.bin")] +/// fn test_bytes(content: &[u8]) { +/// // test contents +/// } +/// ``` +/// +/// Add a second parameter of type `PathBuf` to receive the path of the file. +/// ```rust +/// #[test_each::blob("data/*.bin")] +/// fn test_bytes(content: &[u8], path: PathBuf) { +/// // test contents +/// } +/// ``` +pub use test_each_codegen::test_each_blob as blob; + +#[doc(inline)] +/// Generate a series of tests that receive file paths, +/// based on the result of a glob pattern. +/// +/// This includes any matched directories. +/// +/// # Usage +/// ```rust +/// #[test_each::path("data/*")] +/// fn test_paths(path: PathBuf) { +/// // test contents +/// } +/// ``` +pub use test_each_codegen::test_each_path as path; diff --git a/test-each-codegen/Cargo.toml b/test-each-codegen/Cargo.toml new file mode 100644 index 0000000..40e9cd2 --- /dev/null +++ b/test-each-codegen/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "test-each-codegen" +description = "Internal proc-macro crate for `test-each`" +version.workspace = true +authors.workspace = true +edition.workspace = true +repository.workspace = true +homepage.workspace = true +license-file.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +proc-macro = true + +[dependencies] +quote = "1.0.28" +syn = { version = "2.0.17", features = ["full"] } +glob = "0.3.1" +proc-macro2 = "1.0.59" diff --git a/test-each-codegen/src/lib.rs b/test-each-codegen/src/lib.rs new file mode 100644 index 0000000..7f99f4c --- /dev/null +++ b/test-each-codegen/src/lib.rs @@ -0,0 +1,121 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote, ToTokens}; +use syn::{ + parse::Parser, parse_macro_input, punctuated::Punctuated, Error, ItemFn, LitStr, Result, Token, +}; + +#[proc_macro_attribute] +pub fn test_each_file(attrs: TokenStream, input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemFn); + match test_each(attrs, input, Kind::File) { + Ok(output) => output, + Err(err) => err.into_compile_error().into(), + } +} + +#[proc_macro_attribute] +pub fn test_each_blob(attrs: TokenStream, input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemFn); + match test_each(attrs, input, Kind::Blob) { + Ok(output) => output, + Err(err) => err.into_compile_error().into(), + } +} + +#[proc_macro_attribute] +pub fn test_each_path(attrs: TokenStream, input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemFn); + match test_each(attrs, input, Kind::Path) { + Ok(output) => output, + Err(err) => err.into_compile_error().into(), + } +} + +enum Kind { + File, + Blob, + 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()]; + + 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 files = glob::glob(pattern).map_err(|err| { + Error::new( + lits[0].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)) + })?; + + 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 test_name = format_ident!("{}_{}{}", name, file_name, i); + + let path = file + .canonicalize() + .map_err(|err| Error::new(lits[0].span(), format!("could not read file: {}", err)))? + .to_string_lossy() + .to_string(); + + let into_path = quote!(::std::path::PathBuf::from(#path)); + + let call = match kind { + Kind::File if n_args < 2 => quote!(#name(include_str!(#path))), + Kind::File => quote!(#name(include_str!(#path), #into_path)), + Kind::Blob if n_args < 2 => quote!(#name(include_bytes!(#path))), + Kind::Blob => quote!(#name(include_bytes!(#path), #into_path)), + Kind::Path => quote!(#name(#into_path)), + }; + + functions.push(quote! { + #[test] + #vis fn #test_name() #ret { + #call + } + }); + } + + Ok(quote!( #(#functions)* ).into()) +} + +fn make_safe_ident(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + + for c in value.chars() { + if c.is_alphanumeric() { + result.push(c); + } else { + result.push('_'); + } + } + + result +} diff --git a/tests/data/bar.txt b/tests/data/bar.txt new file mode 100644 index 0000000..fe4c170 --- /dev/null +++ b/tests/data/bar.txt @@ -0,0 +1,2 @@ +hello world +bar.txt diff --git a/tests/data/foo.txt b/tests/data/foo.txt new file mode 100644 index 0000000..5441e84 --- /dev/null +++ b/tests/data/foo.txt @@ -0,0 +1,2 @@ +hello world +foo.txt diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..8216e4b --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,29 @@ +use std::{io::BufRead, path::PathBuf}; + +#[test_each::file("tests/data/*.txt")] +fn test_file(content: &str) { + assert_eq!(Some("hello world"), content.lines().next()) +} + +#[test_each::file("tests/data/*.txt")] +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")] +fn test_blob(content: &[u8]) { + assert_eq!( + Some(b"hello world".to_vec()), + BufRead::split(content, b'\n').next().transpose().unwrap() + ) +} + +#[test_each::path("tests/data/*.txt")] +fn test_path(path: PathBuf) { + match path.file_name().and_then(|s| s.to_str()) { + Some("foo.txt" | "bar.txt") => {} + other => panic!("expected either `foo.txt` or `bar.txt` found: {:?}", other), + } +}