use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::mem;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use proc_macro2::Span;
use syn::punctuated::Punctuated;
use syn::spanned::Spanned as _;
use syn::visit_mut::{self, VisitMut};
use syn::{
AttrStyle, Attribute, FnArg, Ident, Item, ItemFn, ItemMod, Meta, ReturnType, Type, UseTree,
token,
};
use crate::cli::RuntimeChoice;
use crate::detect;
use crate::report::Report;
use crate::test_context::{Plan as TestContextPlan, Resolver as TestContextResolver};
#[non_exhaustive]
enum AttrAction {
DropResolved,
DropWithWarning(Option<(Span, String)>),
Keep,
}
#[non_exhaustive]
struct CfgAttrTestRewriter {
rewrites: usize,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Outcome {
pub changed: bool,
pub needs_anyhow: bool,
pub original_snippets: Vec<String>,
pub runtimes_used: BTreeSet<RuntimeChoice>,
}
struct Rewriter<'res, 'rep> {
default_runtime: RuntimeChoice,
file_path: PathBuf,
file_scope_runtimes: BTreeSet<RuntimeChoice>,
file_scope_test_context_plan: Option<TestContextPlan>,
mod_depth: usize,
preserve_originals: bool,
report: &'rep mut Report,
rewrite: Outcome,
source: Arc<str>,
stripped_any_test_context_attr: bool,
test_contexts: &'res TestContextResolver,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
enum ReturnKind {
Other,
Result,
UnitExplicit,
UnitImplicit,
}
impl Rewriter<'_, '_> {
fn apply_signature_rewrite(&mut self, func: &mut ItemFn, had_resolved_test_context: bool) {
if func.sig.asyncness.is_none() {
func.sig.asyncness = Some(token::Async(func.sig.fn_token.span));
}
if func.sig.inputs.is_empty() {
} else if !had_resolved_test_context {
self.warn_span(
func.sig.ident.span(),
"test fn has a non-trivial parameter list; preserved verbatim \u{2014} verify the suite's `test = ...` path matches the intended context type",
);
} else {
}
match fn_return_kind(&func.sig.output) {
ReturnKind::UnitImplicit | ReturnKind::UnitExplicit | ReturnKind::Result => {
}
ReturnKind::Other => {
self.warn_span(
func.sig.ident.span(),
"test fn returned a non-Result, non-unit type; wrapping in `Result<(), ::rudzio::BoxError>` and discarding the return value",
);
let inner: syn::Block = func.block.as_ref().clone();
let new_block: syn::Block = syn::parse_quote! {{
let _unused = { #inner };
::core::result::Result::Ok(())
}};
*func.block = new_block;
func.sig.output =
syn::parse_quote! { -> ::core::result::Result<(), ::rudzio::BoxError> };
}
}
}
fn build_split_child_mod(
&self,
ident: &str,
ctx_key: Option<&str>,
runtime_path: &syn::Path,
fns: Vec<Item>,
) -> ItemMod {
let outer_plan = ctx_key.and_then(|key| self.test_contexts.plan_for(key));
let (suite_path, test_path) = outer_plan.map_or_else(
|| {
(
make_static_path("::rudzio::common::context::Suite"),
make_static_path("::rudzio::common::context::Test"),
)
},
|plan| {
let base = plan.module_path.as_deref().unwrap_or("crate");
(
make_static_path(&format!("{base}::{}", plan.suite_ident)),
make_static_path(&format!("{base}::{}", plan.bridge_ident)),
)
},
);
let suite_attr: Attribute = syn::parse_quote! {
#[::rudzio::suite([
(
runtime = #runtime_path,
suite = #suite_path,
test = #test_path,
),
])]
};
let mut child_items: Vec<Item> = Vec::with_capacity(fns.len().saturating_add(2));
child_items.push(syn::parse_quote! {
use super::*;
});
if let Some(plan) = outer_plan {
let base = plan.module_path.as_deref().unwrap_or("crate");
let bridge_path: syn::Path =
make_static_path(&format!("{base}::{}", plan.bridge_ident));
child_items.push(syn::parse_quote! {
use #bridge_path;
});
} else {
child_items.push(syn::parse_quote! {
use ::rudzio::common::context::Test;
});
}
child_items.extend(fns);
let mod_ident = Ident::new(ident, Span::call_site());
let mut child: ItemMod = syn::parse_quote! {
#suite_attr
mod #mod_ident {}
};
child.content = Some((token::Brace::default(), child_items));
child
}
fn capture_original_snippet(&self, func: &ItemFn) -> String {
let span_start = func
.attrs
.iter()
.map(|attr| attr.span().byte_range().start)
.min()
.unwrap_or_else(|| func.sig.fn_token.span.byte_range().start);
let span_end = func.block.span().byte_range().end;
let src: &str = &self.source;
src.get(span_start..span_end).unwrap_or("").to_owned()
}
fn ensure_tests_binary_has_main(&self, file: &mut syn::File) {
if !is_tests_binary_root(&self.file_path) {
return;
}
if !self.rewrite.changed {
return;
}
if file.items.iter().any(item_is_fn_main) {
return;
}
let main_fn: Item = syn::parse_quote! {
#[::rudzio::main]
fn main() {}
};
file.items.push(main_fn);
}
fn pop_resolved_test_context_plan(&self, attrs: &[Attribute]) -> Option<TestContextPlan> {
for attr in attrs {
if let Some(path) = detect::as_test_context(attr) {
let key = detect::path_to_string(&path);
if let Some(plan) = self.test_contexts.plan_for(&key) {
return Some(plan.clone());
}
}
}
None
}
fn split_module_by_ctx_groups(
&mut self,
module: &mut ItemMod,
groups: &BTreeSet<Option<String>>,
runtime: RuntimeChoice,
runtime_path: &syn::Path,
) {
let Some((brace, items)) = module.content.take() else {
return;
};
let mut shared: Vec<Item> = Vec::new();
let mut buckets: BTreeMap<Option<String>, Vec<Item>> = groups
.iter()
.cloned()
.map(|key| (key, Vec::new()))
.collect();
for item in items {
let bucket_key = match &item {
Item::Fn(func)
if func
.attrs
.iter()
.any(|attr| detect::classify_test_attr(attr).is_some()) =>
{
Some(func.attrs.iter().find_map(|attr| {
let path = detect::as_test_context(attr)?;
let key = detect::path_to_string(&path);
self.test_contexts.plan_for(&key).map(|_| key)
}))
}
Item::Const(_)
| Item::Enum(_)
| Item::ExternCrate(_)
| Item::Fn(_)
| Item::ForeignMod(_)
| Item::Impl(_)
| Item::Macro(_)
| Item::Mod(_)
| Item::Static(_)
| Item::Struct(_)
| Item::Trait(_)
| Item::TraitAlias(_)
| Item::Type(_)
| Item::Union(_)
| Item::Use(_)
| Item::Verbatim(_)
| _ => None,
};
if let Some(key) = bucket_key
&& let Some(bucket) = buckets.get_mut(&key)
{
bucket.push(item);
} else {
shared.push(item);
}
}
let mut new_items = shared;
for (key, fns) in buckets {
if fns.is_empty() {
continue;
}
let child_ident = key.as_deref().map_or_else(
|| "tests_default".to_owned(),
|key_str| format!("tests_with_{}", last_segment_snake(key_str)),
);
let child = self.build_split_child_mod(&child_ident, key.as_deref(), runtime_path, fns);
new_items.push(Item::Mod(child));
}
hoist_inner_attrs_on_mod(module);
module.content = Some((brace, new_items));
let _: bool = self.rewrite.runtimes_used.insert(runtime);
self.rewrite.changed = true;
self.warn_span(
module.ident.span(),
"module mixed `#[test_context(...)]` and plain tests; split into per-context child modules so each suite tuple has the right `test = ...` path. Suite blocks remain inside the original `#[cfg(test)] mod` for dev-dep visibility.",
);
}
fn strip_companion_test_attrs(&mut self, attrs: &mut Vec<Attribute>) -> bool {
let mut actions: Vec<AttrAction> = Vec::with_capacity(attrs.len());
let mut had_resolved_test_context = false;
for attr in attrs.iter() {
if detect::is_should_panic_attr(attr) {
actions.push(AttrAction::DropWithWarning(Some((
attr.span(),
"#[should_panic] stripped; rudzio does not support panic-expectation \u{2014} rewrite the body to assert the panic manually".to_owned(),
))));
continue;
}
if let Some(path) = detect::as_test_context(attr) {
self.stripped_any_test_context_attr = true;
let key = detect::path_to_string(&path);
if self.test_contexts.plan_for(&key).is_some() {
had_resolved_test_context = true;
actions.push(AttrAction::DropResolved);
} else {
actions.push(AttrAction::DropWithWarning(Some((
attr.span(),
format!(
"#[test_context({key})] stripped without generating a bridge: no `impl AsyncTestContext for {key}` was found in this crate. Finish the migration by hand."
),
))));
}
continue;
}
actions.push(AttrAction::Keep);
}
for action in &actions {
if let AttrAction::DropWithWarning(Some((span, msg))) = action {
self.warn_span(*span, msg.clone());
}
}
let mut iter = actions.into_iter();
attrs.retain(|_attr| matches!(iter.next(), Some(AttrAction::Keep)));
had_resolved_test_context
}
fn try_convert_fn(&mut self, func: &mut ItemFn) {
let matched_idx_and_kind = func
.attrs
.iter()
.enumerate()
.find_map(|(i, attr)| detect::classify_test_attr(attr).map(|kind| (i, kind)));
let Some((idx, detected)) = matched_idx_and_kind else {
return;
};
if has_self_receiver(func) {
self.warn_span(
func.sig.ident.span(),
"test fn takes `self` receiver; rudzio tests are free fns \u{2014} skipping",
);
return;
}
if func.attrs.iter().any(detect::is_rstest_attr)
|| func.sig.inputs.iter().any(fn_arg_has_rstest_attr)
{
self.warn_span(
func.sig.ident.span(),
"test fn uses rstest (`#[rstest]` / `#[case]` / `#[values]`); left unchanged \u{2014} rudzio has no parameterised-test equivalent, rewrite by hand",
);
return;
}
if has_non_ctx_shaped_params(func) {
self.warn_span(
func.sig.ident.span(),
"test fn has parameters that don't look like a single `&T` / `&mut T` context borrow (likely rstest #[case] / #[values]); left unchanged \u{2014} rudzio has no parameterised-test equivalent, rewrite by hand",
);
return;
}
for extra in &detected.extra_tokio_args {
self.warn_span(
func.sig.ident.span(),
format!("#[tokio::test] arg `{extra}` dropped; rudzio does not forward it"),
);
}
if let Some(msg) = detected.kind.needs_compat_warning() {
self.warn_span(func.sig.ident.span(), msg);
}
let original_snippet = self
.preserve_originals
.then(|| self.capture_original_snippet(func));
replace_attr_with_rudzio_test(&mut func.attrs, idx);
let resolved_plan = self.pop_resolved_test_context_plan(&func.attrs);
let had_resolved_test_context = resolved_plan.is_some();
let _stripped: bool = self.strip_companion_test_attrs(&mut func.attrs);
if let Some(plan) = resolved_plan.as_ref() {
rewrite_ctx_param_to_bridge(func, &plan.ctx_ident, &plan.bridge_ident);
if self.mod_depth == 0 && self.file_scope_test_context_plan.is_none() {
self.file_scope_test_context_plan = Some(plan.clone());
}
}
self.apply_signature_rewrite(func, had_resolved_test_context);
let runtime = detected
.kind
.forced_runtime()
.unwrap_or(self.default_runtime);
let _: bool = self.rewrite.runtimes_used.insert(runtime);
if self.mod_depth == 0
&& let Some(forced) = detected.kind.forced_runtime()
{
let _: bool = self.file_scope_runtimes.insert(forced);
}
if let Some(snippet) = original_snippet {
let snippet_idx = self.rewrite.original_snippets.len();
self.rewrite.original_snippets.push(snippet);
let sentinel = format!(" __RUDZIO_MIGRATE_ORIGINAL_PLACEHOLDER_{snippet_idx}__");
let attr: Attribute = syn::parse_quote! { #[doc = #sentinel] };
func.attrs.insert(0, attr);
}
self.rewrite.changed = true;
self.report.add_converted(1);
}
fn try_promote_cfg_test_mod(&mut self, module: &mut ItemMod) {
if module.content.is_none() {
return;
}
if !module_has_any_test_fn(module) {
return;
}
if has_rudzio_suite(&module.attrs) {
return;
}
if !module.attrs.iter().any(is_cfg_test_attr) {
return;
}
rewrite_cfg_test_to_cfg_any(&mut module.attrs);
let runtime = unanimous_inner_runtime(module).unwrap_or(self.default_runtime);
let runtime_path = make_static_path(runtime.suite_path());
let groups = group_test_fns_by_ctx(module, self.test_contexts);
if groups.len() > 1 {
self.split_module_by_ctx_groups(module, &groups, runtime, &runtime_path);
return;
}
let resolved_ctx = first_resolved_test_context(module, self.test_contexts);
let (suite_path, test_path) = if let Some(plan) = resolved_ctx {
let base = plan.module_path.as_deref().unwrap_or("crate");
if plan.module_path.is_none() {
self.warn_span(
module.ident.span(),
format!(
"bridge for `{}` was generated in `{}`, which isn't reachable from `crate::` (typically because it lives under `tests/`). Emitted `crate::{}` as a best-effort placeholder \u{2014} adjust the `suite = ...` / `test = ...` paths by hand to match your test binary's module tree.",
plan.ctx_ident,
plan.impl_file.display(),
plan.suite_ident,
),
);
}
(
make_static_path(&format!("{base}::{}", plan.suite_ident)),
make_static_path(&format!("{base}::{}", plan.bridge_ident)),
)
} else {
(
make_static_path("::rudzio::common::context::Suite"),
make_static_path("::rudzio::common::context::Test"),
)
};
let suite_attr: Attribute = syn::parse_quote! {
#[::rudzio::suite([
(
runtime = #runtime_path,
suite = #suite_path,
test = #test_path,
),
])]
};
hoist_inner_attrs_on_mod(module);
module.attrs.push(suite_attr);
let _: bool = self.rewrite.runtimes_used.insert(runtime);
self.rewrite.changed = true;
}
fn warn_span(&mut self, span: Span, message: impl Into<String>) {
let range = span.byte_range();
let offset = range.start;
let len = range.end.saturating_sub(range.start);
self.report.warn_with_span(
self.file_path.clone(),
span.start().line,
offset,
len,
Arc::clone(&self.source),
message,
);
}
fn wrap_file_scope_test_fns(&mut self, file: &mut syn::File) {
let indices: Vec<usize> = file
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
if let Item::Fn(func) = item
&& fn_has_rudzio_test_attr(func)
{
Some(i)
} else {
None
}
})
.collect();
let Some(&first_idx) = indices.first() else {
return;
};
let mut fns: Vec<Item> = Vec::with_capacity(indices.len());
for &i in indices.iter().rev() {
fns.push(file.items.remove(i));
}
fns.reverse();
let runtime = if self.file_scope_runtimes.len() == 1 {
self.file_scope_runtimes
.iter()
.next()
.copied()
.unwrap_or(self.default_runtime)
} else {
self.default_runtime
};
let runtime_path = make_static_path(runtime.suite_path());
let (suite_path, test_path) = self.file_scope_test_context_plan.as_ref().map_or_else(
|| {
(
make_static_path("::rudzio::common::context::Suite"),
make_static_path("::rudzio::common::context::Test"),
)
},
|plan| {
let base = plan.module_path.as_deref().unwrap_or("crate");
(
make_static_path(&format!("{base}::{}", plan.suite_ident)),
make_static_path(&format!("{base}::{}", plan.bridge_ident)),
)
},
);
let cleaned_fns: Vec<Item> = fns
.into_iter()
.map(|item| {
if let Item::Fn(mut func) = item {
func.attrs.retain(|attr| !is_cfg_test_attr(attr));
Item::Fn(func)
} else {
item
}
})
.collect();
let mut synth_items: Vec<Item> = Vec::with_capacity(cleaned_fns.len().saturating_add(3));
synth_items.push(syn::parse_quote! {
use super::*;
});
if let Some(plan) = &self.file_scope_test_context_plan {
let base = plan.module_path.as_deref().unwrap_or("crate");
let bridge_path: syn::Path =
make_static_path(&format!("{base}::{}", plan.bridge_ident));
synth_items.push(syn::parse_quote! {
use #bridge_path;
});
}
synth_items.extend(cleaned_fns);
let mut synth: ItemMod = syn::parse_quote! {
#[cfg(any(test, rudzio_test))]
#[::rudzio::suite([
(
runtime = #runtime_path,
suite = #suite_path,
test = #test_path,
),
])]
mod tests {}
};
synth.content = Some((token::Brace::default(), synth_items));
file.items.insert(first_idx, Item::Mod(synth));
let _: bool = self.rewrite.runtimes_used.insert(runtime);
self.rewrite.changed = true;
}
}
impl VisitMut for CfgAttrTestRewriter {
#[inline]
fn visit_attribute_mut(&mut self, i: &mut Attribute) {
if rewrite_cfg_attr_test_attr(i) {
self.rewrites = self.rewrites.saturating_add(1);
}
visit_mut::visit_attribute_mut(self, i);
}
}
impl VisitMut for Rewriter<'_, '_> {
#[inline]
fn visit_file_mut(&mut self, i: &mut syn::File) {
for item in &mut i.items {
if let Item::Mod(module) = item {
self.try_promote_cfg_test_mod(module);
}
}
visit_mut::visit_file_mut(self, i);
self.wrap_file_scope_test_fns(i);
self.ensure_tests_binary_has_main(i);
if self.stripped_any_test_context_attr {
prune_test_context_macro_imports(i);
}
}
#[inline]
fn visit_item_fn_mut(&mut self, i: &mut ItemFn) {
if i.attrs.iter().any(detect::is_rstest_attr)
|| i.sig.inputs.iter().any(fn_arg_has_rstest_attr)
{
self.warn_span(
i.sig.ident.span(),
"test fn uses rstest (`#[rstest]` / `#[case]` / `#[values]`); left unchanged \u{2014} rudzio has no parameterised-test equivalent, rewrite by hand",
);
return;
}
self.try_convert_fn(i);
visit_mut::visit_item_fn_mut(self, i);
}
#[inline]
fn visit_item_mod_mut(&mut self, i: &mut ItemMod) {
self.try_promote_cfg_test_mod(i);
self.mod_depth = self.mod_depth.saturating_add(1);
visit_mut::visit_item_mod_mut(self, i);
self.mod_depth = self.mod_depth.saturating_sub(1);
prune_unused_test_context_import(i);
if self.stripped_any_test_context_attr
&& let Some((_, items)) = &mut i.content
{
prune_test_context_macro_imports_in_items(items);
}
}
}
#[inline]
pub fn apply(
source: Arc<str>,
file: &mut syn::File,
default_runtime: RuntimeChoice,
preserve_originals: bool,
test_contexts: &TestContextResolver,
file_path: &Path,
report: &mut Report,
) -> Outcome {
let mut walker = Rewriter {
default_runtime,
file_path: file_path.to_path_buf(),
file_scope_runtimes: BTreeSet::new(),
file_scope_test_context_plan: None,
mod_depth: 0,
preserve_originals,
report,
rewrite: Outcome {
changed: false,
needs_anyhow: false,
original_snippets: Vec::new(),
runtimes_used: BTreeSet::new(),
},
source,
stripped_any_test_context_attr: false,
test_contexts,
};
walker.visit_file_mut(file);
let cfg_attr_rewrites = rewrite_cfg_attr_test_in_file(file);
if cfg_attr_rewrites > 0 {
walker.rewrite.changed = true;
}
walker.rewrite
}
fn collect_runtime_hints(items: &[Item], out: &mut BTreeSet<RuntimeChoice>) {
for item in items {
if let Item::Fn(func) = item {
for attr in &func.attrs {
if let Some(detected) = detect::classify_test_attr(attr)
&& let Some(runtime) = detected.kind.forced_runtime()
{
let _: bool = out.insert(runtime);
}
}
continue;
}
if let Item::Mod(sub) = item
&& let Some((_, inner)) = &sub.content
{
collect_runtime_hints(inner, out);
}
}
}
fn drop_test_context_leaf(tree: &mut UseTree) -> bool {
match tree {
UseTree::Name(name) if name.ident == "test_context" => true,
UseTree::Rename(rename) if rename.ident == "test_context" => true,
UseTree::Group(group) => {
let kept: Vec<UseTree> = mem::take(&mut group.items)
.into_iter()
.filter_map(|mut inner| {
if drop_test_context_leaf(&mut inner) {
None
} else {
Some(inner)
}
})
.collect();
for inner in kept {
group.items.push(inner);
}
group.items.is_empty()
}
UseTree::Path(path_use) => drop_test_context_leaf(&mut path_use.tree),
UseTree::Glob(_) | UseTree::Name(_) | UseTree::Rename(_) => false,
}
}
fn first_resolved_test_context<'res>(
module: &ItemMod,
resolver: &'res TestContextResolver,
) -> Option<&'res TestContextPlan> {
let Some((_, items)) = &module.content else {
return None;
};
for item in items {
if let Item::Fn(func) = item {
for attr in &func.attrs {
if let Some(path) = detect::as_test_context(attr) {
let key = detect::path_to_string(&path);
if let Some(plan) = resolver.plan_for(&key) {
return Some(plan);
}
}
}
}
}
None
}
fn fn_arg_has_rstest_attr(arg: &FnArg) -> bool {
match arg {
FnArg::Typed(pat_type) => pat_type.attrs.iter().any(detect::is_rstest_attr),
FnArg::Receiver(recv) => recv.attrs.iter().any(detect::is_rstest_attr),
}
}
fn fn_has_rudzio_test_attr(func: &ItemFn) -> bool {
func.attrs.iter().any(|attr| {
let path = detect::path_to_string(attr.path());
path == "::rudzio::test" || path == "rudzio::test"
})
}
fn fn_return_kind(ret: &ReturnType) -> ReturnKind {
let ReturnType::Type(_, ty) = ret else {
return ReturnKind::UnitImplicit;
};
if let Type::Tuple(tuple) = ty.as_ref()
&& tuple.elems.is_empty()
{
return ReturnKind::UnitExplicit;
}
if let Type::Path(path_ty) = ty.as_ref() {
let last = path_ty
.path
.segments
.last()
.map(|seg| seg.ident.to_string())
.unwrap_or_default();
return if last == "Result" {
ReturnKind::Result
} else {
ReturnKind::Other
};
}
ReturnKind::Other
}
fn group_test_fns_by_ctx(
module: &ItemMod,
resolver: &TestContextResolver,
) -> BTreeSet<Option<String>> {
let mut groups: BTreeSet<Option<String>> = BTreeSet::new();
let Some((_, items)) = &module.content else {
return groups;
};
for item in items {
if let Item::Fn(func) = item {
if !func
.attrs
.iter()
.any(|attr| detect::classify_test_attr(attr).is_some())
{
continue;
}
let key = func.attrs.iter().find_map(|attr| {
let path = detect::as_test_context(attr)?;
let key_str = detect::path_to_string(&path);
resolver.plan_for(&key_str).map(|_| key_str)
});
let _: bool = groups.insert(key);
}
}
groups
}
fn has_non_ctx_shaped_params(func: &ItemFn) -> bool {
let n = func.sig.inputs.len();
if n == 0 {
return false;
}
if n > 1 {
return true;
}
let Some(arg) = func.sig.inputs.first() else {
return false;
};
let FnArg::Typed(pat_type) = arg else {
return true;
};
!matches!(&*pat_type.ty, Type::Reference(_))
}
fn has_rudzio_suite(attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| {
detect::path_to_string(attr.path()) == "rudzio::suite"
|| detect::path_to_string(attr.path()) == "::rudzio::suite"
})
}
fn has_self_receiver(func: &ItemFn) -> bool {
func.sig
.inputs
.iter()
.any(|arg| matches!(arg, FnArg::Receiver(_)))
}
fn hoist_inner_attrs_on_mod(module: &mut ItemMod) {
for attr in &mut module.attrs {
if matches!(attr.style, AttrStyle::Inner(_)) {
attr.style = AttrStyle::Outer;
}
}
}
fn is_cfg_test_attr(attr: &Attribute) -> bool {
if detect::path_to_string(attr.path()) != "cfg" {
return false;
}
let Meta::List(list) = &attr.meta else {
return false;
};
list.tokens.to_string().trim() == "test"
}
fn is_tests_binary_root(path: &Path) -> bool {
let mut components: Vec<&OsStr> = path.components().map(Component::as_os_str).collect();
let Some(tests_idx) = components
.iter()
.position(|seg| *seg == OsStr::new("tests"))
else {
return false;
};
let rel = components.split_off(tests_idx.saturating_add(1));
if let [single] = rel.as_slice() {
return Path::new(single).extension().is_some_and(|ext| ext == "rs");
}
matches!(rel.as_slice(), [_, last] if *last == OsStr::new("mod.rs"))
}
fn item_has_any_test_fn(item: &Item) -> bool {
if let Item::Fn(func) = item {
return func
.attrs
.iter()
.any(|attr| detect::classify_test_attr(attr).is_some());
}
if let Item::Mod(module) = item {
return module_has_any_test_fn(module);
}
false
}
fn item_is_fn_main(item: &Item) -> bool {
if let Item::Fn(func) = item {
func.sig.ident == "main"
} else {
false
}
}
fn last_segment_snake(path: &str) -> String {
let last = path.rsplit("::").next().unwrap_or(path);
let mut out = String::with_capacity(last.len().saturating_add(4));
for (i, ch) in last.chars().enumerate() {
if ch.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
fn make_static_path(text: &str) -> syn::Path {
syn::parse_str::<syn::Path>(text).unwrap_or_else(|_err| syn::parse_quote!(crate))
}
fn module_has_any_test_fn(module: &ItemMod) -> bool {
let Some((_, items)) = &module.content else {
return false;
};
items.iter().any(item_has_any_test_fn)
}
fn prune_test_context_macro_imports(file: &mut syn::File) {
prune_test_context_macro_imports_in_items(&mut file.items);
}
fn prune_test_context_macro_imports_in_items(items: &mut Vec<Item>) {
items.retain_mut(|item| {
let Item::Use(use_item) = item else {
return true;
};
let root_test_context_match =
matches!(&use_item.tree, UseTree::Path(path) if path.ident == "test_context");
if !root_test_context_match {
return true;
}
if let UseTree::Path(path) = &mut use_item.tree {
let _empty = drop_test_context_leaf(&mut path.tree);
}
!tree_is_empty(&use_item.tree)
});
}
fn prune_unused_test_context_import(module: &mut ItemMod) {
let Some((_, items)) = &mut module.content else {
return;
};
items.retain(|item| {
let Item::Use(use_item) = item else {
return true;
};
let tokens = quote::ToTokens::to_token_stream(&use_item.tree).to_string();
let normalized: String = tokens.split_whitespace().collect::<Vec<_>>().join("");
!(normalized == "test_context::test_context"
|| normalized == "::test_context::test_context")
});
}
fn replace_attr_with_rudzio_test(attrs: &mut [Attribute], idx: usize) {
let new_attr: Attribute = syn::parse_quote! { #[::rudzio::test] };
if let Some(slot) = attrs.get_mut(idx) {
*slot = new_attr;
}
}
fn rewrite_cfg_attr_test_attr(attr: &mut Attribute) -> bool {
if !attr.path().is_ident("cfg_attr") {
return false;
}
let Meta::List(list) = &attr.meta else {
return false;
};
let Ok(metas): Result<Punctuated<Meta, syn::Token![,]>, _> =
list.parse_args_with(Punctuated::<Meta, syn::Token![,]>::parse_terminated)
else {
return false;
};
let first_is_bare_test = matches!(
metas.first(),
Some(Meta::Path(path)) if path.is_ident("test")
);
if !first_is_bare_test {
return false;
}
let rest: Vec<&Meta> = metas.iter().skip(1).collect();
let rebuilt: Attribute = if rest.is_empty() {
syn::parse_quote!(#[cfg_attr(any(test, rudzio_test))])
} else {
syn::parse_quote!(#[cfg_attr(any(test, rudzio_test), #(#rest),*)])
};
*attr = rebuilt;
true
}
fn rewrite_cfg_attr_test_in_file(file: &mut syn::File) -> usize {
let mut walker = CfgAttrTestRewriter { rewrites: 0 };
walker.visit_file_mut(file);
walker.rewrites
}
fn rewrite_cfg_test_to_cfg_any(attrs: &mut [Attribute]) {
for attr in attrs.iter_mut() {
if is_cfg_test_attr(attr) {
*attr = syn::parse_quote!(#[cfg(any(test, rudzio_test))]);
}
}
}
fn rewrite_ctx_param_to_bridge(func: &mut ItemFn, ctx_ident: &str, bridge_ident: &str) {
let Some(first) = func.sig.inputs.first_mut() else {
return;
};
let FnArg::Typed(pat_type) = first else {
return;
};
let Type::Reference(type_ref) = &mut *pat_type.ty else {
return;
};
let Type::Path(type_path) = &mut *type_ref.elem else {
return;
};
let Some(last) = type_path.path.segments.last_mut() else {
return;
};
if last.ident == ctx_ident {
last.ident = Ident::new(bridge_ident, last.ident.span());
}
}
fn tree_is_empty(tree: &UseTree) -> bool {
match tree {
UseTree::Path(path_use) => tree_is_empty(&path_use.tree),
UseTree::Group(group) => group.items.is_empty(),
UseTree::Glob(_) | UseTree::Name(_) | UseTree::Rename(_) => false,
}
}
fn unanimous_inner_runtime(module: &ItemMod) -> Option<RuntimeChoice> {
let Some((_, items)) = &module.content else {
return None;
};
let mut runtimes: BTreeSet<RuntimeChoice> = BTreeSet::new();
collect_runtime_hints(items, &mut runtimes);
if runtimes.len() == 1 {
runtimes.into_iter().next()
} else {
None
}
}