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

Start on improving error handeling and add crate docs #1

Merged
merged 5 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ proc-macro = true

[dependencies]
quote = "1.0.33"
proc-macro2 = "1.0.67"
syn = {version="2.0.33", features=["full"]}
proc-macro2 = "1.0.69"
syn = {version="2.0.39", features=["full"]}
pathdiff = "0.2.1"
itertools = "0.11.0"
proc-macro-error = "1.0.4"
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Test Each File

[![github](https://img.shields.io/badge/github-8da0cb?style=for-the-badge&labelColor=555555&logo=github)](https://github.com/binary-banter/test-each-file)
 [![crates-io](https://img.shields.io/badge/crates.io-fc8d62?style=for-the-badge&labelColor=555555&logo=rust)](https://crates.io/crates/test_each_file)
 [![docs-rs](https://img.shields.io/badge/docs.rs-66c2a5?style=for-the-badge&labelColor=555555&logo=docs.rs)](https://docs.rs/test_each_file)

Easily generate tests for files in a specified directory for comprehensive testing.

A simple example of the macro is shown below:

```rust
test_each_file! { in "./resources" => test }

Expand All @@ -12,7 +17,8 @@ fn test(content: &str) {
```

Given the following file structure:
```

```txt
- resources
- a.txt
- b.txt
Expand All @@ -23,6 +29,7 @@ Given the following file structure:
```

The macro expands to:

```rust
#[test]
fn a() {
Expand All @@ -37,7 +44,7 @@ fn b() {

mod extra {
use super::*;

#[test]
fn c() {
test(include_str!("../resources/extra/c.txt"))
Expand All @@ -48,17 +55,20 @@ mod extra {
## Generate submodule

The tests can automatically be inserted into a module, by using the `as` keyword. For example:

```rust
test_each_file! { in "./resources" as example => test }
```

This will wrap the tests above in an additional `mod example { ... }`.
This feature is useful when `test_each_file!` is used multiple times in a single file, to prevent that the generated tests have the same name.
This feature is useful when `test_each_file!` is used multiple times in a single file, to prevent that the generated
tests have the same name.

## File grouping

Sometimes it may be preferable to write a test that takes the contents of multiple files as input.
A common use-case for this is testing a function that performs a transformation from a given input (`.in` file) to an output (`.out` file).
A common use-case for this is testing a function that performs a transformation from a given input (`.in` file) to an
output (`.out` file).

```rust
test_each_file! { for ["in", "out"] in "./resources" => test }
Expand All @@ -70,7 +80,7 @@ fn test([input, output]: [&str; 2]) {

Both the `.in` and `.out` files must exist and be located in the same directory, as demonstrated below:

```
```txt
- resources
- a.in
- a.out
Expand All @@ -88,11 +98,13 @@ Note that `.in` and `.out` are just examples here - any number of unique extensi
## More examples

The expression that is called on each file can also be a closure, for example:

```rust
test_each_file! { in "./resources" => |c: &str| assert!(c.contains("Hello World")) }
```

All the options above can be combined, for example:

```rust
test_each_file! { for ["in", "out"] in "./resources" as example => |[a, b]: [&str; 2]| assert_eq!(a, b) }
```
95 changes: 58 additions & 37 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#![doc = include_str!("../README.md")]
use pathdiff::diff_paths;
use proc_macro2::{Ident, TokenStream};
use proc_macro_error::{abort, abort_call_site, proc_macro_error};
use std::collections::{HashMap, HashSet};
use std::fs::canonicalize;
use std::path::{Path, PathBuf};
Expand All @@ -10,50 +12,62 @@ use syn::punctuated::Punctuated;
use syn::{bracketed, parse_macro_input, Expr, LitStr, Token};

struct ForEachFile {
path: String,
prefix: Option<Ident>,
path: LitStr,
module: Option<Ident>,
function: Expr,
extensions: Vec<String>,
}

impl Parse for ForEachFile {
fn parse(input: ParseStream) -> syn::Result<Self> {
let extensions = if input.peek(Token![for]) {
input.parse::<Token![for]>()?;

let content;
bracketed!(content in input);

let extensions = Punctuated::<LitStr, Token![,]>::parse_terminated(&content)?
.into_iter()
.map(|s| s.value())
.collect::<Vec<_>>();
assert!(
!extensions.is_empty(),
"Expected at least one extension to be given."
);

extensions
} else {
vec![]
// Parse extensions if the keyword `for` is used. Aborts if no extensions are given.
let extensions = input
.parse::<Token![for]>()
.and_then(|_| {
let content;
bracketed!(content in input);

match Punctuated::<LitStr, Token![,]>::parse_separated_nonempty(&content) {
Ok(extensions) => Ok(extensions
.into_iter()
.map(|extension| extension.value())
.collect()),
Err(e) => abort!(e.span(), "Expected at least one extension to be given."),
}
})
.unwrap_or_default();

// Parse the path to the tests.
if let Err(e) = input.parse::<Token![in]>() {
abort!(e.span(), "Expected the keyword `in` before the path.");
};

input.parse::<Token![in]>()?;
let path = input.parse::<LitStr>()?.value();
let path = match input.parse::<LitStr>() {
Ok(path) => path,
Err(e) => abort!(e.span(), "Expected a path after the keyword 'in'."),
};

let prefix = if input.peek(Token![as]) {
input.parse::<Token![as]>()?;
Some(input.parse::<Ident>()?)
} else {
None
let module = input
.parse::<Token![as]>()
.and_then(|_| match input.parse::<Ident>() {
Ok(module) => Ok(module),
Err(e) => abort!(e.span(), "Expected a module to be given."),
})
.ok();

// Parse function to call.
if let Err(e) = input.parse::<Token![=>]>() {
abort!(e.span(), "Expected `=>` before the function to call.");
};

input.parse::<Token![=>]>()?;
let function = input.parse::<Expr>()?;
let function = match input.parse::<Expr>() {
Ok(function) => function,
Err(e) => abort!(e.span(), "Expected a function to call after `=>`."),
};

Ok(Self {
path,
prefix,
module,
function,
extensions,
})
Expand All @@ -68,7 +82,6 @@ struct Tree {

impl Tree {
fn new(base: &Path, ignore_extensions: bool) -> Self {
assert!(base.is_dir());
let mut tree = Self::default();
for entry in base.read_dir().unwrap() {
let mut entry = entry.unwrap().path();
Expand All @@ -83,7 +96,7 @@ impl Tree {
Self::new(entry.as_path(), ignore_extensions),
);
} else {
panic!("Unsupported path.")
abort_call_site!(format!("Unsupported path: {:#?}.", entry))
}
}
tree
Expand All @@ -92,7 +105,7 @@ impl Tree {

fn generate_from_tree(tree: &Tree, parsed: &ForEachFile, stream: &mut TokenStream) {
for file in &tree.here {
let mut diff = diff_paths(file, &parsed.path).unwrap();
let mut diff = diff_paths(file, parsed.path.value()).unwrap();
diff.set_extension("");
let file_name = diff.file_name().unwrap().to_str().unwrap();

Expand Down Expand Up @@ -136,22 +149,30 @@ fn generate_from_tree(tree: &Tree, parsed: &ForEachFile, stream: &mut TokenStrea
use super::*;
#sub_stream
}
})
});
}
}

/// Easily generate tests for files in a specified directory for comprehensive testing.
///
/// See crate level documentation for details.
#[proc_macro]
#[proc_macro_error]
pub fn test_each_file(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let parsed = parse_macro_input!(input as ForEachFile);

if !Path::new(&parsed.path.value()).is_dir() {
abort!(parsed.path.span(), "Given directory does not exist");
}

let mut tokens = TokenStream::new();
let files = Tree::new(parsed.path.as_ref(), !parsed.extensions.is_empty());
let files = Tree::new(parsed.path.value().as_ref(), !parsed.extensions.is_empty());
generate_from_tree(&files, &parsed, &mut tokens);

if let Some(prefix) = parsed.prefix {
if let Some(module) = parsed.module {
tokens = quote! {
#[cfg(test)]
mod #prefix {
mod #module {
use super::*;
#tokens
}
Expand Down