Skip to content

Commit e2cfa8a

Browse files
authored
Forward attributes on spec! it cases to the generated #[test] (#3)
Attributes written before an `it` (#[ignore], #[should_panic], #[cfg(...)], and so on) are now parsed and emitted on the generated test function, so the DSL matches what attribute-style #[test_suite] already allows. Attributes on hooks (before/after/before_each/after_each) are rejected with a clear compile error, since a hook isn't a standalone function and the attribute would otherwise be dropped silently.
1 parent 9748e44 commit e2cfa8a

4 files changed

Lines changed: 175 additions & 20 deletions

File tree

crates/spectacular-macros/src/spec.rs

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ use crate::{
99
wrap_test_body,
1010
};
1111

12+
/// A parsed `it` case ready for code generation:
13+
/// `(description, fn name, body, is_async, params, forwarded attributes)`.
14+
type SpecTest = (
15+
String,
16+
Ident,
17+
proc_macro2::TokenStream,
18+
bool,
19+
Vec<PipeParam>,
20+
Vec<syn::Attribute>,
21+
);
22+
1223
/// A parsed parameter from pipe syntax: `|name: &Type, name2: Type|`
1324
pub(crate) struct PipeParam {
1425
pat: syn::Pat,
@@ -30,8 +41,14 @@ pub(crate) enum SpecItem {
3041
// body async return_type input_params
3142
AfterEach(proc_macro2::TokenStream, bool, Vec<PipeParam>),
3243
// body async params
33-
It(String, proc_macro2::TokenStream, bool, Vec<PipeParam>),
34-
// body async params
44+
It(
45+
String,
46+
proc_macro2::TokenStream,
47+
bool,
48+
Vec<PipeParam>,
49+
Vec<syn::Attribute>,
50+
),
51+
// body async params attrs
3552
Other(proc_macro2::TokenStream),
3653
}
3754

@@ -63,6 +80,19 @@ fn parse_pipe_params(input: ParseStream) -> syn::Result<Vec<PipeParam>> {
6380
Ok(params)
6481
}
6582

83+
/// Reject attributes on a construct that can't carry them. Attributes are only
84+
/// forwarded on `it` test cases; hooks and the `suite;`/runtime markers are not
85+
/// standalone functions, so an attribute there would be silently dropped.
86+
fn reject_attrs(attrs: &[syn::Attribute], keyword: &str) -> syn::Result<()> {
87+
if let Some(attr) = attrs.first() {
88+
return Err(syn::Error::new_spanned(
89+
attr,
90+
format!("attributes are not supported on `{keyword}` (only on `it` test cases)"),
91+
));
92+
}
93+
Ok(())
94+
}
95+
6696
/// Parse optional `-> Type` return type.
6797
fn parse_return_type(input: ParseStream) -> syn::Result<Option<syn::Type>> {
6898
if input.peek(Token![->]) {
@@ -97,6 +127,10 @@ impl Parse for SpecModule {
97127
let mut items = Vec::new();
98128

99129
while !content.is_empty() {
130+
// Leading `#[...]` attributes are forwarded to `it` test cases and
131+
// re-attached to plain items; they're rejected on hooks/markers.
132+
let attrs = content.call(syn::Attribute::parse_outer)?;
133+
100134
// Check for `async` keyword first
101135
if content.peek(Token![async]) {
102136
let fork = content.fork();
@@ -112,10 +146,17 @@ impl Parse for SpecModule {
112146
let params = parse_pipe_params(&content)?;
113147
let body;
114148
braced!(body in content);
115-
items.push(SpecItem::It(desc.value(), body.parse()?, true, params));
149+
items.push(SpecItem::It(
150+
desc.value(),
151+
body.parse()?,
152+
true,
153+
params,
154+
attrs,
155+
));
116156
continue;
117157
}
118158
"before_each" => {
159+
reject_attrs(&attrs, "before_each")?;
119160
let _: Token![async] = content.parse()?;
120161
let _: Ident = content.parse()?;
121162
let params = parse_pipe_params(&content)?;
@@ -126,6 +167,7 @@ impl Parse for SpecModule {
126167
continue;
127168
}
128169
"after_each" => {
170+
reject_attrs(&attrs, "after_each")?;
129171
let _: Token![async] = content.parse()?;
130172
let _: Ident = content.parse()?;
131173
let params = parse_pipe_params(&content)?;
@@ -141,7 +183,7 @@ impl Parse for SpecModule {
141183
}
142184
// Fall through: parse as regular item (e.g. `async fn helper()`)
143185
let item: syn::Item = content.parse()?;
144-
items.push(SpecItem::Other(quote! { #item }));
186+
items.push(SpecItem::Other(quote! { #(#attrs)* #item }));
145187
continue;
146188
}
147189

@@ -150,18 +192,21 @@ impl Parse for SpecModule {
150192
let kw: Ident = fork.parse()?;
151193
match kw.to_string().as_str() {
152194
"suite" => {
195+
reject_attrs(&attrs, "suite")?;
153196
let _: Ident = content.parse()?;
154197
content.parse::<Token![;]>()?;
155198
items.push(SpecItem::Suite);
156199
continue;
157200
}
158201
"tokio" => {
202+
reject_attrs(&attrs, "tokio")?;
159203
let _: Ident = content.parse()?;
160204
content.parse::<Token![;]>()?;
161205
items.push(SpecItem::Runtime(Runtime::Tokio));
162206
continue;
163207
}
164208
"async_std" => {
209+
reject_attrs(&attrs, "async_std")?;
165210
let _: Ident = content.parse()?;
166211
content.parse::<Token![;]>()?;
167212
items.push(SpecItem::Runtime(Runtime::AsyncStd));
@@ -173,10 +218,17 @@ impl Parse for SpecModule {
173218
let params = parse_pipe_params(&content)?;
174219
let body;
175220
braced!(body in content);
176-
items.push(SpecItem::It(desc.value(), body.parse()?, false, params));
221+
items.push(SpecItem::It(
222+
desc.value(),
223+
body.parse()?,
224+
false,
225+
params,
226+
attrs,
227+
));
177228
continue;
178229
}
179230
"before_each" => {
231+
reject_attrs(&attrs, "before_each")?;
180232
let _: Ident = content.parse()?;
181233
let params = parse_pipe_params(&content)?;
182234
let ret_ty = parse_return_type(&content)?;
@@ -186,6 +238,7 @@ impl Parse for SpecModule {
186238
continue;
187239
}
188240
"after_each" => {
241+
reject_attrs(&attrs, "after_each")?;
189242
let _: Ident = content.parse()?;
190243
let params = parse_pipe_params(&content)?;
191244
let body;
@@ -194,6 +247,7 @@ impl Parse for SpecModule {
194247
continue;
195248
}
196249
"before" => {
250+
reject_attrs(&attrs, "before")?;
197251
let _: Ident = content.parse()?;
198252
let ret_ty = parse_return_type(&content)?;
199253
if content.peek(syn::token::Brace) {
@@ -206,6 +260,7 @@ impl Parse for SpecModule {
206260
}
207261
}
208262
"after" => {
263+
reject_attrs(&attrs, "after")?;
209264
let _: Ident = content.parse()?;
210265
let params = parse_pipe_params(&content)?;
211266
if content.peek(syn::token::Brace) {
@@ -219,13 +274,13 @@ impl Parse for SpecModule {
219274
}
220275
_ => {
221276
let item: syn::Item = content.parse()?;
222-
items.push(SpecItem::Other(quote! { #item }));
277+
items.push(SpecItem::Other(quote! { #(#attrs)* #item }));
223278
continue;
224279
}
225280
}
226281
}
227282
let item: syn::Item = content.parse()?;
228-
items.push(SpecItem::Other(quote! { #item }));
283+
items.push(SpecItem::Other(quote! { #(#attrs)* #item }));
229284
}
230285

231286
Ok(SpecModule { vis, ident, items })
@@ -254,13 +309,7 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
254309
let mut after_each_body: Option<proc_macro2::TokenStream> = None;
255310
let mut after_each_is_async = false;
256311
let mut after_each_params: Vec<PipeParam> = Vec::new();
257-
let mut tests: Vec<(
258-
String,
259-
Ident,
260-
proc_macro2::TokenStream,
261-
bool,
262-
Vec<PipeParam>,
263-
)> = Vec::new();
312+
let mut tests: Vec<SpecTest> = Vec::new();
264313
let mut other_items: Vec<proc_macro2::TokenStream> = Vec::new();
265314

266315
for item in parsed.items {
@@ -318,9 +367,9 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
318367
after_each_is_async = is_async;
319368
after_each_params = params;
320369
}
321-
SpecItem::It(desc, body, is_async, params) => {
370+
SpecItem::It(desc, body, is_async, params, attrs) => {
322371
let fn_name = format_ident!("{}", slugify(&desc));
323-
tests.push((desc, fn_name, body, is_async, params));
372+
tests.push((desc, fn_name, body, is_async, params, attrs));
324373
}
325374
SpecItem::Other(tokens) => {
326375
other_items.push(tokens);
@@ -329,7 +378,7 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
329378
}
330379

331380
// Validate: async items require a runtime
332-
let any_async = tests.iter().any(|(_, _, _, is_async, _)| *is_async)
381+
let any_async = tests.iter().any(|(_, _, _, is_async, _, _)| *is_async)
333382
|| before_each_is_async
334383
|| after_each_is_async;
335384

@@ -361,7 +410,7 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
361410
.iter()
362411
.chain(before_each_params.iter())
363412
.chain(after_each_params.iter())
364-
.chain(tests.iter().flat_map(|(_, _, _, _, p)| p.iter()));
413+
.chain(tests.iter().flat_map(|(_, _, _, _, p, _)| p.iter()));
365414
for p in ref_sources {
366415
if p.is_ref
367416
&& let Some(inner) = ref_inner_type(&p.ty)
@@ -382,7 +431,7 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
382431
// Detect inline mode from consumers: tests or after_each have `_`-typed params
383432
let has_infer_consumers = tests
384433
.iter()
385-
.any(|(_, _, _, _, params)| params.iter().any(|p| is_type_infer(&p.ty)))
434+
.any(|(_, _, _, _, params, _)| params.iter().any(|p| is_type_infer(&p.ty)))
386435
|| after_each_params.iter().any(|p| is_type_infer(&p.ty));
387436
let before_each_needs_inline = !has_before_each_ctx && has_infer_consumers;
388437
let after_each_needs_inline = after_each_params.iter().any(|p| is_type_infer(&p.ty));
@@ -514,7 +563,7 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
514563

515564
let test_fn_defs: Vec<proc_macro2::TokenStream> = tests
516565
.iter()
517-
.map(|(_desc, fn_name, body, is_async, test_params)| {
566+
.map(|(_desc, fn_name, body, is_async, test_params, attrs)| {
518567
// A test needs async if it's declared async or any hook it uses is async
519568
let test_needs_async = *is_async || before_each_is_async || after_each_is_async;
520569

@@ -755,6 +804,7 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
755804
let inner = wrap_async_test_body(pre, body_with_bindings, post, needs_catch);
756805

757806
quote! {
807+
#(#attrs)*
758808
#test_attr
759809
async fn #fn_name() {
760810
#inner
@@ -764,6 +814,7 @@ pub(crate) fn expand(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2
764814
let inner = wrap_test_body(pre, body_with_bindings, post, needs_catch);
765815

766816
quote! {
817+
#(#attrs)*
767818
#[test]
768819
fn #fn_name() {
769820
#inner

crates/spectacular/src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,34 @@ pub use spectacular_macros::suite;
438438
/// }
439439
/// # fn main() {}
440440
/// ```
441+
///
442+
/// # Attributes on `it`
443+
///
444+
/// Attributes written before an `it` case are forwarded to the generated
445+
/// `#[test]` function, so `#[ignore]`, `#[should_panic]`, `#[cfg(...)]`, and
446+
/// the like work just as they do on a plain test:
447+
///
448+
/// ```
449+
/// use spectacular::spec;
450+
///
451+
/// spec! {
452+
/// mod with_attrs {
453+
/// #[should_panic(expected = "boom")]
454+
/// it "panics as expected" {
455+
/// panic!("boom");
456+
/// }
457+
///
458+
/// #[ignore]
459+
/// it "skipped by default" {
460+
/// // only runs with `--ignored`
461+
/// }
462+
/// }
463+
/// }
464+
/// # fn main() {}
465+
/// ```
466+
///
467+
/// Attributes on hooks (`before`, `after`, `before_each`, `after_each`) are not
468+
/// supported and produce a compile error.
441469
pub use spectacular_macros::spec;
442470

443471
/// Marks a module as a test suite using standard Rust attribute syntax.

crates/spectacular/tests/integration.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,3 +1148,50 @@ fn after_runs_at_process_exit_even_when_filtered() {
11481148
"`#[after]` ran but received the wrong `#[before]` context at exit"
11491149
);
11501150
}
1151+
1152+
// ===== Attribute passthrough on spec! `it` cases =====
1153+
//
1154+
// Attributes written before an `it` are forwarded to the generated `#[test]`
1155+
// function, so standard test attributes work in the DSL just like in
1156+
// attribute style.
1157+
1158+
spec! {
1159+
mod spec_attr_passthrough {
1160+
it "a normal test still runs" {
1161+
assert_eq!(1 + 1, 2);
1162+
}
1163+
1164+
// Forwarded to `#[test]`: the body panics and the test passes.
1165+
#[should_panic(expected = "boom")]
1166+
it "should_panic is applied" {
1167+
panic!("boom");
1168+
}
1169+
1170+
// Forwarded to `#[test]`: this test is skipped by the default runner.
1171+
// If the attribute were dropped, the panic would fail the run.
1172+
#[ignore]
1173+
it "ignore is applied" {
1174+
panic!("an ignored test must never run");
1175+
}
1176+
1177+
// Excluded from compilation entirely. The body would not type-check if
1178+
// it were compiled, which proves the `#[cfg]` gate reached the item.
1179+
#[cfg(any())]
1180+
it "cfg-excluded never compiles" {
1181+
let _: () = "definitely not a unit value";
1182+
}
1183+
}
1184+
}
1185+
1186+
// Passthrough also works on `async it`.
1187+
spec! {
1188+
mod spec_attr_passthrough_async {
1189+
tokio;
1190+
1191+
#[should_panic(expected = "async boom")]
1192+
async it "should_panic on an async it" {
1193+
tokio::task::yield_now().await;
1194+
panic!("async boom");
1195+
}
1196+
}
1197+
}

docs/src/content/docs/guides/spec-dsl.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,35 @@ The macro detects `_`-typed params in tests or `after_each` and inlines the `bef
284284

285285
Hooks without return types or `_` params continue to work as fire-and-forget (unchanged).
286286

287+
## Attributes on tests
288+
289+
Attributes written before an `it` case are forwarded to the generated `#[test]` function, so the standard test attributes work here too:
290+
291+
```rust
292+
use spectacular::spec;
293+
294+
spec! {
295+
mod with_attrs {
296+
#[should_panic(expected = "boom")]
297+
it "panics as expected" {
298+
panic!("boom");
299+
}
300+
301+
#[ignore]
302+
it "skipped unless you pass --ignored" {
303+
// ...
304+
}
305+
306+
#[cfg(feature = "integration")]
307+
it "only compiled with the integration feature" {
308+
// ...
309+
}
310+
}
311+
}
312+
```
313+
314+
This also works on `async it`. Attributes on hooks (`before`, `after`, `before_each`, `after_each`) aren't supported and produce a compile error.
315+
287316
## Visibility
288317

289318
The generated module inherits the visibility you declare:

0 commit comments

Comments
 (0)