Skip to content

Commit

Permalink
initial
Browse files Browse the repository at this point in the history
  • Loading branch information
remkop22 committed May 30, 2023
0 parents commit 725d96c
Show file tree
Hide file tree
Showing 10 changed files with 362 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/target
/Cargo.lock
32 changes: 32 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <remo.pas22@gmail.com>"]
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" }
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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={<glob directories>}`.

63 changes: 63 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 20 additions & 0 deletions test-each-codegen/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
121 changes: 121 additions & 0 deletions test-each-codegen/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<TokenStream> {
let lits = Punctuated::<LitStr, Token![,]>::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
}
2 changes: 2 additions & 0 deletions tests/data/bar.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
hello world
bar.txt
2 changes: 2 additions & 0 deletions tests/data/foo.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
hello world
foo.txt
29 changes: 29 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
@@ -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),
}
}

0 comments on commit 725d96c

Please sign in to comment.