diff --git a/Cargo.toml b/Cargo.toml index 7e7fa4e..56acd35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ description = """ Data-driven tests in Rust """ +[[test]] +name = "datatest_stable" +harness = false + [build-dependencies] version_check = "0.9.1" diff --git a/build.rs b/build.rs index d671658..91b4f22 100644 --- a/build.rs +++ b/build.rs @@ -8,4 +8,4 @@ fn main() { println!("cargo:rustc-cfg=feature=\"stable\""); } println!("cargo:rustc-env=RUSTC_BOOTSTRAP=1"); -} \ No newline at end of file +} diff --git a/datatest-derive/src/lib.rs b/datatest-derive/src/lib.rs index 0fb8b00..fcf3915 100644 --- a/datatest-derive/src/lib.rs +++ b/datatest-derive/src/lib.rs @@ -2,19 +2,14 @@ #![deny(unused_must_use)] extern crate proc_macro; -#[macro_use] -extern crate syn; -#[macro_use] -extern crate quote; -extern crate proc_macro2; - use proc_macro2::{Span, TokenStream}; +use quote::quote; use std::collections::HashMap; use syn::parse::{Parse, ParseStream, Result as ParseResult}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; use syn::token::Comma; -use syn::{ArgCaptured, FnArg, Ident, ItemFn, Pat}; +use syn::{braced, parse_macro_input, ArgCaptured, FnArg, Ident, ItemFn, Pat}; type Error = syn::parse::Error; @@ -253,14 +248,15 @@ fn files_internal( let orig_func_name = &func_item.ident; let (kind, bencher_param) = if info.bench { - (quote!(BenchFn), quote!(bencher: &mut ::datatest::__internal::Bencher,)) + ( + quote!(BenchFn), + quote!(bencher: &mut ::datatest::__internal::Bencher,), + ) } else { (quote!(TestFn), quote!()) }; - let registration = test_registration(channel); - - // Adding `#[allow(unused_attributes)]` to `#orig_func` to allow `#[ignore]` attribute + let registration = test_registration(channel, &desc_ident); let output = quote! { #registration #[automatically_derived] @@ -347,14 +343,13 @@ impl Parse for DataTestArgs { } } - /// Wrapper that turns on behavior that works on stable Rust. #[proc_macro_attribute] pub fn data_stable( args: proc_macro::TokenStream, func: proc_macro::TokenStream, ) -> proc_macro::TokenStream { - files_internal(args, func, Channel::Stable) + data_internal(args, func, Channel::Stable) } /// Wrapper that turns on behavior that works only on nightly Rust. @@ -374,7 +369,7 @@ fn data_internal( let mut func_item = parse_macro_input!(func as ItemFn); let cases: DataTestArgs = parse_macro_input!(args as DataTestArgs); let cases = match cases { - DataTestArgs::Literal(path) => quote!(::datatest::yaml(#path)), + DataTestArgs::Literal(path) => quote!(datatest::yaml(#path)), DataTestArgs::Expression(expr) => quote!(#expr), }; @@ -430,8 +425,7 @@ fn data_internal( ) }; - let registration = test_registration(channel); - + let registration = test_registration(channel, &desc_ident); let output = quote! { #registration #[automatically_derived] @@ -472,7 +466,28 @@ fn data_internal( output.into() } - -fn test_registration(channel: Channel) -> TokenStream { - quote!(#[test_case]) -} \ No newline at end of file +fn test_registration(channel: Channel, desc_ident: &syn::Ident) -> TokenStream { + match channel { + // On nightly, we rely on `custom_test_frameworks` feature + Channel::Nightly => quote!(#[test_case]), + // On stable, we use `ctor` crate to build a registry of all our tests + Channel::Stable => { + let registration_fn = + syn::Ident::new(&format!("{}__REGISTRATION", desc_ident), desc_ident.span()); + let tokens = quote! { + #[allow(non_snake_case)] + #[datatest::__internal::ctor] + fn #registration_fn() { + use ::datatest::__internal::RegistrationNode; + static mut REGISTRATION: RegistrationNode = RegistrationNode { + descriptor: &#desc_ident, + next: None, + }; + // This runs only once during initialization, so should be safe + ::datatest::__internal::register(unsafe { &mut REGISTRATION }); + } + }; + tokens + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 7d7284c..a939373 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,12 +115,17 @@ mod data; mod files; mod runner; +/// Internal re-exports for the procedural macro to use. #[doc(hidden)] pub mod __internal { pub use crate::data::{DataBenchFn, DataTestDesc, DataTestFn}; pub use crate::files::{DeriveArg, FilesTestDesc, FilesTestFn, TakeArg}; pub use crate::runner::assert_test_result; pub use crate::test::Bencher; + pub use ctor::ctor; + + // To maintain registry on stable channel + pub use crate::runner::{register, RegistrationNode}; } pub use crate::runner::runner; diff --git a/src/runner.rs b/src/runner.rs index 35047b5..6f198d1 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -2,6 +2,7 @@ use crate::data::{DataTestDesc, DataTestFn}; use crate::files::{FilesTestDesc, FilesTestFn}; use crate::test::{ShouldPanic, TestDesc, TestDescAndFn, TestFn, TestName}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicPtr, Ordering}; fn derive_test_name(root: &Path, path: &Path, test_name: &str) -> String { let relative = path.strip_prefix(root).unwrap_or_else(|_| { @@ -217,6 +218,27 @@ fn adjust_for_test_name(opts: &mut crate::test::TestOpts, name: &str) { } } +pub struct RegistrationNode { + pub descriptor: &'static dyn TestDescriptor, + pub next: Option<&'static RegistrationNode>, +} + +static REGISTRY: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + +pub fn register(new: &mut RegistrationNode) { + let reg = ®ISTRY; + let mut current = reg.load(Ordering::SeqCst); + loop { + let previous = reg.compare_and_swap(current, new, Ordering::SeqCst); + if previous == current { + new.next = unsafe { previous.as_ref() }; + return; + } else { + current = previous; + } + } +} + /// Custom test runner. Expands test definitions given in the format our test framework understands /// ([DataTestDesc]) into definitions understood by Rust test framework ([TestDescAndFn] structs). /// For regular tests, mapping is one-to-one, for our data driven tests, we generate as many @@ -259,23 +281,14 @@ pub fn runner(tests: &[&dyn TestDescriptor]) { let mut rendered: Vec = Vec::new(); for input in tests.iter() { - match input.as_datatest_desc() { - DatatestTestDesc::Test(test) => { - // Make a copy as we cannot take ownership - rendered.push(TestDescAndFn { - desc: test.desc.clone(), - testfn: clone_testfn(&test.testfn), - }) - } - DatatestTestDesc::FilesTest(files) => { - render_files_test(files, &mut rendered); - adjust_for_test_name(&mut opts, &files.name); - } - DatatestTestDesc::DataTest(data) => { - render_data_test(data, &mut rendered); - adjust_for_test_name(&mut opts, &data.name); - } - } + render_test_descriptor(*input, &mut opts, &mut rendered); + } + + // Gather tests registered via our registry (stable channel) + let mut current = unsafe { REGISTRY.load(Ordering::SeqCst).as_ref() }; + while let Some(node) = current { + render_test_descriptor(node.descriptor, &mut opts, &mut rendered); + current = node.next; } // Run tests via standard runner! @@ -286,6 +299,30 @@ pub fn runner(tests: &[&dyn TestDescriptor]) { } } +fn render_test_descriptor( + input: &dyn TestDescriptor, + opts: &mut crate::test::TestOpts, + rendered: &mut Vec, +) { + match input.as_datatest_desc() { + DatatestTestDesc::Test(test) => { + // Make a copy as we cannot take ownership + rendered.push(TestDescAndFn { + desc: test.desc.clone(), + testfn: clone_testfn(&test.testfn), + }) + } + DatatestTestDesc::FilesTest(files) => { + render_files_test(files, rendered); + adjust_for_test_name(opts, &files.name); + } + DatatestTestDesc::DataTest(data) => { + render_data_test(data, rendered); + adjust_for_test_name(opts, &data.name); + } + } +} + pub trait Termination { fn is_success(&self) -> bool; } @@ -296,7 +333,7 @@ impl Termination for () { } } -impl Termination for Result { +impl Termination for Result { fn is_success(&self) -> bool { self.is_ok() } diff --git a/tests/datatest.rs b/tests/datatest.rs index 1858e3d..92194c9 100644 --- a/tests/datatest.rs +++ b/tests/datatest.rs @@ -1,195 +1,5 @@ -#![cfg_attr(feature = "nightly", feature(custom_test_frameworks))] -#![cfg_attr(feature = "nightly", test_runner(datatest::runner))] +#![cfg(feature = "nightly")] +#![feature(custom_test_frameworks)] +#![test_runner(datatest::runner)] -use serde::Deserialize; -use std::fmt; -use std::path::Path; - -/// File-driven tests are defined via `#[files(...)]` attribute. -/// -/// The first argument to the attribute is the path to the test data (relative to the crate root -/// directory). -/// -/// The second argument is a block of mappings, each mapping defines the rules of deriving test -/// function arguments. -/// -/// Exactly one mapping should be a "pattern" mapping, defined as ` in ""`. `` -/// is a regular expression applied to every file found in the test directory. For each file path -/// matching the regular expression, test runner will create a new test instance. -/// -/// Other mappings are "template" mappings, they define the template to use for deriving the file -/// paths. Each template have a syntax of a [replacement string] from [`regex`] crate. -/// -/// [replacement string]: https://docs.rs/regex/*/regex/struct.Regex.html#method.replace -/// [regex]: https://docs.rs/regex/*/regex/ -#[datatest::files("tests/test-cases", { - // Pattern is defined via `in` operator. Every file from the `directory` above will be matched - // against this regular expression and every matched file will produce a separate test. - input in r"^(.*)\.input\.txt", - // Template defines a rule for deriving dependent file name based on captures of the pattern. - output = r"${1}.output.txt", -})] -#[test] -fn files_test_strings(input: &str, output: &str) { - assert_eq!(format!("Hello, {}!", input), output); -} - -/// Same as above, but always panics, so marked by `#[ignore]` -#[ignore] -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt", - output = r"${1}.output.txt", -})] -#[test] -fn files_tests_not_working_yet_and_never_will(input: &str, output: &str) { - assert_eq!(input, output, "these two will never match!"); -} - -/// Can declare with `&std::path::Path` to get path instead of the content -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt", - output = r"${1}.output.txt", -})] -#[test] -fn files_test_paths(input: &Path, output: &Path) { - let input = input.display().to_string(); - let output = output.display().to_string(); - // Check output path is indeed input path with `input` => `output` - assert_eq!(input.replace("input", "output"), output); -} - -/// Can also take slices -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt", - output = r"${1}.output.txt", -})] -#[test] -fn files_test_slices(input: &[u8], output: &[u8]) { - let mut actual = b"Hello, ".to_vec(); - actual.extend(input); - actual.push(b'!'); - assert_eq!(actual, output); -} - -fn is_ignore(path: &Path) -> bool { - path.display().to_string().ends_with("case-02.input.txt") -} - -/// Ignore first test case! -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt" if !is_ignore, - output = r"${1}.output.txt", -})] -#[test] -fn files_test_ignore(input: &str) { - assert_eq!(input, "Kylie"); -} - -/// Regular tests are also allowed! -#[test] -fn simple_test() { - let palindrome = "never odd or even".replace(' ', ""); - let reversed = palindrome.chars().rev().collect::(); - - assert_eq!(palindrome, reversed) -} - -/// Regular tests are also allowed! Also, could be ignored the same! -#[test] -#[ignore] -fn simple_test_ignored() { - panic!("ignored test!") -} - -/// This test case item does not implement [`std::fmt::Display`], so only line number is shown in -/// the test name. -#[derive(Deserialize)] -struct GreeterTestCase { - name: String, - expected: String, -} - -impl fmt::Display for GreeterTestCase { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(&self.name) - } -} - -/// Data-driven tests are defined via `#[datatest::data(..)]` attribute. -/// -/// This attribute specifies a test file with test cases. Currently, the test file have to be in -/// YAML format. This file is deserialized into `Vec`, where `T` is the type of the test function -/// argument (which must implement `serde::Deserialize`). Then, for each element of the vector, a -/// separate test instance is created and executed. -/// -/// Name of each test is derived from the test function module path, test case line number and, -/// optionall, from the [`ToString`] implementation of the test case data (if either [`ToString`] -/// or [`std::fmt::Display`] is implemented). -#[datatest::data("tests/tests.yaml")] -#[test] -fn data_test_line_only(data: &GreeterTestCase) { - assert_eq!(data.expected, format!("Hi, {}!", data.name)); -} - -/// Can take as value, too -#[datatest::data("tests/tests.yaml")] -#[test] -fn data_test_take_owned(mut data: GreeterTestCase) { - data.expected += "boo!"; - data.name += "!boo"; - assert_eq!(data.expected, format!("Hi, {}!", data.name)); -} - -#[ignore] -#[datatest::data("tests/tests.yaml")] -#[test] -fn data_test_line_only_hoplessly_broken(_data: &GreeterTestCase) { - panic!("this test always fails, but this is okay because we marked it as ignored!") -} - -/// Can also take string inputs -#[datatest::data("tests/strings.yaml")] -#[test] -fn data_test_string(data: String) { - let half = data.len() / 2; - assert_eq!(data[0..half], data[half..]); -} - -/// Can also use `::datatest::yaml` explicitly -#[datatest::data(::datatest::yaml("tests/strings.yaml"))] -#[test] -fn data_test_yaml(data: String) { - let half = data.len() / 2; - assert_eq!(data[0..half], data[half..]); -} - -// Experimental API: allow custom test cases - -struct StringTestCase { - input: String, - output: String, -} - -fn load_test_cases(path: &str) -> Vec<::datatest::DataTestCaseDesc> { - let input = std::fs::read_to_string(path).unwrap(); - let lines = input.lines().collect::>(); - lines - .chunks(2) - .enumerate() - .map(|(idx, line)| ::datatest::DataTestCaseDesc { - case: StringTestCase { - input: line[0].to_string(), - output: line[1].to_string(), - }, - name: Some(line[0].to_string()), - location: format!("line {}", idx * 2), - }) - .collect() -} - -/// Can have custom deserialization for data tests -#[datatest::data(load_test_cases("tests/cases.txt"))] -#[test] -fn data_test_custom(data: StringTestCase) { - assert_eq!(data.output, format!("Hello, {}!", data.input)); -} +include!("tests/mod.rs"); diff --git a/tests/datatest_stable.rs b/tests/datatest_stable.rs new file mode 100644 index 0000000..4a2ad01 --- /dev/null +++ b/tests/datatest_stable.rs @@ -0,0 +1,6 @@ +#[cfg(feature = "stable")] +include!("tests/mod.rs"); + +fn main() { + datatest::runner(&[]); +} diff --git a/tests/tests/mod.rs b/tests/tests/mod.rs new file mode 100644 index 0000000..d126b81 --- /dev/null +++ b/tests/tests/mod.rs @@ -0,0 +1,192 @@ +use serde::Deserialize; +use std::fmt; +use std::path::Path; + +/// File-driven tests are defined via `#[files(...)]` attribute. +/// +/// The first argument to the attribute is the path to the test data (relative to the crate root +/// directory). +/// +/// The second argument is a block of mappings, each mapping defines the rules of deriving test +/// function arguments. +/// +/// Exactly one mapping should be a "pattern" mapping, defined as ` in ""`. `` +/// is a regular expression applied to every file found in the test directory. For each file path +/// matching the regular expression, test runner will create a new test instance. +/// +/// Other mappings are "template" mappings, they define the template to use for deriving the file +/// paths. Each template have a syntax of a [replacement string] from [`regex`] crate. +/// +/// [replacement string]: https://docs.rs/regex/*/regex/struct.Regex.html#method.replace +/// [regex]: https://docs.rs/regex/*/regex/ +#[datatest::files("tests/test-cases", { +// Pattern is defined via `in` operator. Every file from the `directory` above will be matched +// against this regular expression and every matched file will produce a separate test. +input in r"^(.*)\.input\.txt", +// Template defines a rule for deriving dependent file name based on captures of the pattern. +output = r"${1}.output.txt", +})] +#[test] +fn files_test_strings(input: &str, output: &str) { + assert_eq!(format!("Hello, {}!", input), output); +} + +/// Same as above, but always panics, so marked by `#[ignore]` +#[ignore] +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt", +output = r"${1}.output.txt", +})] +#[test] +fn files_tests_not_working_yet_and_never_will(input: &str, output: &str) { + assert_eq!(input, output, "these two will never match!"); +} + +/// Can declare with `&std::path::Path` to get path instead of the content +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt", +output = r"${1}.output.txt", +})] +#[test] +fn files_test_paths(input: &Path, output: &Path) { + let input = input.display().to_string(); + let output = output.display().to_string(); + // Check output path is indeed input path with `input` => `output` + assert_eq!(input.replace("input", "output"), output); +} + +/// Can also take slices +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt", +output = r"${1}.output.txt", +})] +#[test] +fn files_test_slices(input: &[u8], output: &[u8]) { + let mut actual = b"Hello, ".to_vec(); + actual.extend(input); + actual.push(b'!'); + assert_eq!(actual, output); +} + +fn is_ignore(path: &Path) -> bool { + path.display().to_string().ends_with("case-02.input.txt") +} + +/// Ignore first test case! +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt" if !is_ignore, +output = r"${1}.output.txt", +})] +#[test] +fn files_test_ignore(input: &str) { + assert_eq!(input, "Kylie"); +} + +/// Regular tests are also allowed! +#[test] +fn simple_test() { + let palindrome = "never odd or even".replace(' ', ""); + let reversed = palindrome.chars().rev().collect::(); + + assert_eq!(palindrome, reversed) +} + +/// Regular tests are also allowed! Also, could be ignored the same! +#[test] +#[ignore] +fn simple_test_ignored() { + panic!("ignored test!") +} + +/// This test case item does not implement [`std::fmt::Display`], so only line number is shown in +/// the test name. +#[derive(Deserialize)] +struct GreeterTestCase { + name: String, + expected: String, +} + +impl fmt::Display for GreeterTestCase { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.name) + } +} + +/// Data-driven tests are defined via `#[datatest::data(..)]` attribute. +/// +/// This attribute specifies a test file with test cases. Currently, the test file have to be in +/// YAML format. This file is deserialized into `Vec`, where `T` is the type of the test function +/// argument (which must implement `serde::Deserialize`). Then, for each element of the vector, a +/// separate test instance is created and executed. +/// +/// Name of each test is derived from the test function module path, test case line number and, +/// optionall, from the [`ToString`] implementation of the test case data (if either [`ToString`] +/// or [`std::fmt::Display`] is implemented). +#[datatest::data("tests/tests.yaml")] +#[test] +fn data_test_line_only(data: &GreeterTestCase) { + assert_eq!(data.expected, format!("Hi, {}!", data.name)); +} + +/// Can take as value, too +#[datatest::data("tests/tests.yaml")] +#[test] +fn data_test_take_owned(mut data: GreeterTestCase) { + data.expected += "boo!"; + data.name += "!boo"; + assert_eq!(data.expected, format!("Hi, {}!", data.name)); +} + +#[ignore] +#[datatest::data("tests/tests.yaml")] +#[test] +fn data_test_line_only_hoplessly_broken(_data: &GreeterTestCase) { + panic!("this test always fails, but this is okay because we marked it as ignored!") +} + +/// Can also take string inputs +#[datatest::data("tests/strings.yaml")] +#[test] +fn data_test_string(data: String) { + let half = data.len() / 2; + assert_eq!(data[0..half], data[half..]); +} + +/// Can also use `::datatest::yaml` explicitly +#[datatest::data(::datatest::yaml("tests/strings.yaml"))] +#[test] +fn data_test_yaml(data: String) { + let half = data.len() / 2; + assert_eq!(data[0..half], data[half..]); +} + +// Experimental API: allow custom test cases + +struct StringTestCase { + input: String, + output: String, +} + +fn load_test_cases(path: &str) -> Vec<::datatest::DataTestCaseDesc> { + let input = std::fs::read_to_string(path).unwrap(); + let lines = input.lines().collect::>(); + lines + .chunks(2) + .enumerate() + .map(|(idx, line)| ::datatest::DataTestCaseDesc { + case: StringTestCase { + input: line[0].to_string(), + output: line[1].to_string(), + }, + name: Some(line[0].to_string()), + location: format!("line {}", idx * 2), + }) + .collect() +} + +/// Can have custom deserialization for data tests +#[datatest::data(load_test_cases("tests/cases.txt"))] +#[test] +fn data_test_custom(data: StringTestCase) { + assert_eq!(data.output, format!("Hello, {}!", data.input)); +}