From fc8a23a73e910c9a1131fbc04e599c90f3d4c8b5 Mon Sep 17 00:00:00 2001 From: Khagan Karimov Date: Sun, 12 Oct 2025 23:20:40 -0600 Subject: [PATCH 1/3] Add dynamic analysis (brute force) --- Cargo.lock | 18 + Cargo.toml | 7 +- src/analysis.rs | 240 +++++++++--- src/bin/trait-winnower.rs | 188 ++++++++-- src/cli.rs | 45 ++- src/config.rs | 27 ++ src/dynamic_analysis/common.rs | 366 +++++++++++++++++++ src/dynamic_analysis/edit.rs | 312 ++++++++++++++++ src/dynamic_analysis/mod.rs | 7 + src/lib.rs | 1 + src/static_analysis/ir.rs | 5 + src/static_analysis/mod.rs | 5 + tests/expected/trait_sandbox/Cargo.lock | 7 + tests/expected/trait_sandbox/Cargo.toml | 10 + tests/expected/trait_sandbox/src/a.rs | 77 ++++ tests/expected/trait_sandbox/src/b.rs | 59 +++ tests/expected/trait_sandbox/src/c.rs | 36 ++ tests/expected/trait_sandbox/src/d.rs | 0 tests/expected/trait_sandbox/src/lib.rs | 79 ++++ tests/expected/trait_sandbox/src/traits.rs | 41 +++ tests/expected/trait_sandbox/test.rs | 0 tests/test_files/trait_sandbox/Cargo.lock | 7 + tests/test_files/trait_sandbox/Cargo.toml | 10 + tests/test_files/trait_sandbox/src/a.rs | 45 +++ tests/test_files/trait_sandbox/src/b.rs | 32 ++ tests/test_files/trait_sandbox/src/c.rs | 19 + tests/test_files/trait_sandbox/src/lib.rs | 39 ++ tests/test_files/trait_sandbox/src/traits.rs | 25 ++ tests/trait_sandbox_tests.rs | 123 +++++++ 29 files changed, 1747 insertions(+), 83 deletions(-) create mode 100644 src/dynamic_analysis/common.rs create mode 100644 src/dynamic_analysis/edit.rs create mode 100644 src/dynamic_analysis/mod.rs create mode 100644 src/static_analysis/ir.rs create mode 100644 src/static_analysis/mod.rs create mode 100644 tests/expected/trait_sandbox/Cargo.lock create mode 100644 tests/expected/trait_sandbox/Cargo.toml create mode 100644 tests/expected/trait_sandbox/src/a.rs create mode 100644 tests/expected/trait_sandbox/src/b.rs create mode 100644 tests/expected/trait_sandbox/src/c.rs create mode 100644 tests/expected/trait_sandbox/src/d.rs create mode 100644 tests/expected/trait_sandbox/src/lib.rs create mode 100644 tests/expected/trait_sandbox/src/traits.rs create mode 100644 tests/expected/trait_sandbox/test.rs create mode 100644 tests/test_files/trait_sandbox/Cargo.lock create mode 100644 tests/test_files/trait_sandbox/Cargo.toml create mode 100644 tests/test_files/trait_sandbox/src/a.rs create mode 100644 tests/test_files/trait_sandbox/src/b.rs create mode 100644 tests/test_files/trait_sandbox/src/c.rs create mode 100644 tests/test_files/trait_sandbox/src/lib.rs create mode 100644 tests/test_files/trait_sandbox/src/traits.rs create mode 100644 tests/trait_sandbox_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 75b25c5..076240f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -381,6 +390,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "predicates" version = "3.1.3" @@ -619,14 +634,17 @@ dependencies = [ "assert_fs", "clap", "colored", + "crc32fast", "globset", "ignore", + "paste", "predicates", "prettyplease", "proc-macro2", "quote", "serde", "syn", + "tempfile", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index c1cb8d6..3ed8a1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,16 +10,19 @@ ignore = "0.4.23" serde = { version = "1.0.225", features = ["derive"] } toml = "0.9.6" globset = "0.4.16" -syn = { version = "2", features = ["full", "visit"] } +syn = { version = "2", features = ["full", "visit", "visit-mut", "parsing", "printing"] } quote = "1" colored = "3.0.0" -proc-macro2 = { version = "1.0.101", features = ["span-locations"] } prettyplease = "0.2.37" +crc32fast = "1.5.0" +paste = "1.0.15" +proc-macro2 = { version = "1.0.101", features = ["span-locations"] } [dev-dependencies] assert_cmd = "2.0.17" predicates = "3.1.3" assert_fs = "1.1.3" +tempfile = "3.12.1" [[bin]] name = "trait-winnower" diff --git a/src/analysis.rs b/src/analysis.rs index c218e08..489ee09 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -9,6 +9,9 @@ use syn::{ TraitItemFn, Type, TypeParamBound, punctuated::Punctuated, token::Plus, visit::Visit, }; +use paste::paste; +use proc_macro2::Span; + /// Reference to a Rust item in the AST. pub enum ItemRef<'ast> { /// A free-standing function. @@ -41,10 +44,9 @@ pub enum ItemRef<'ast> { /// A lightweight identity/label for an inspected item. pub struct ItemKey<'ast> { - /// Which item this is (incl. AST). - pub item: ItemRef<'ast>, - /// Human-readable label. - pub label: String, + item: ItemRef<'ast>, + label: String, + span: Span, } /// Generate label-formatting helpers on `ItemKey`. @@ -74,6 +76,39 @@ define_item_labels! { trait_method_label (trait_name, method) => "// trait {}::{}"; } +impl<'ast> ItemKey<'ast> { + /// Convenience: require an ident or explain why not. + #[inline] + pub fn ident(&self) -> Option<&'ast syn::Ident> { + self.ident_opt() + } + + /// Get the item. + #[inline] + pub fn item(&self) -> &ItemRef<'ast> { + &self.item + } + + /// Get the span of the item. + #[inline] + pub fn span(&self) -> Span { + self.span + } + + #[inline] + fn ident_opt(&self) -> Option<&'ast syn::Ident> { + match self.item { + ItemRef::Func(f) => Some(&f.sig.ident), + ItemRef::Struct(s) => Some(&s.ident), + ItemRef::Enum(e) => Some(&e.ident), + ItemRef::Trait(t) => Some(&t.ident), + ItemRef::Impl(_) => None, + ItemRef::ImplMethod { method, .. } => Some(&method.sig.ident), + ItemRef::TraitMethod { method, .. } => Some(&method.sig.ident), + } + } +} + impl<'ast> std::fmt::Display for ItemKey<'ast> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.label) @@ -81,13 +116,25 @@ impl<'ast> std::fmt::Display for ItemKey<'ast> { } macro_rules! define_bounds_types { - ( $( $name:ident ),+ $(,)? ) => { + ( $( $name:ident,)+ $(,)? ) => { $( #[allow(missing_docs, reason = "macro-generated code")] - struct $name<'ast> { + pub struct $name<'ast> { item: ItemKey<'ast>, - _type_params: Vec, - _where_preds: Vec, + type_params: Vec, + where_preds: Vec, + } + + impl<'ast> $name<'ast> { + #[allow(missing_docs, reason = "macro-generated code")] + pub fn type_param_bounds(&self) -> &[TypeParamBounds] { &self.type_params } + + #[allow(missing_docs, reason = "macro-generated code")] + pub fn where_bounds(&self) -> &[WhereTypeBounds] { &self.where_preds } + + #[allow(missing_docs, reason = "macro-generated code")] + pub fn item_key(&self) -> &ItemKey<'ast> { &self.item } + } )+ }; @@ -114,6 +161,38 @@ pub struct ItemBounds<'ast> { structs: Vec>, } +macro_rules! define_bounds_slice { + ( $( $method:ident, $field:ident, $ty:ty )+ ) => { + paste! { + $( + impl<'ast> ItemBounds<'ast> { + #[allow(missing_docs, reason = "macro-generated")] + pub fn $method(&self) -> &$ty { + &self.$field + } + + #[allow(missing_docs, reason = "macro-generated")] + pub fn [< $method _mut >](&mut self) -> &mut $ty { + &mut self.$field + } + } + )+ + } + }; +} + +paste! { + define_bounds_slice! { + fns, fns, Vec> + traits, traits, Vec> + trait_methods, trait_methods, Vec> + impl_methods, impl_methods, Vec> + enums, enums, Vec> + structs, structs, Vec> + impls, impls, Vec> + } +} + impl<'ast> ItemBounds<'ast> { /// Parse a file from disk. pub fn parse_file(path: &std::path::Path) -> TraitError { @@ -164,14 +243,58 @@ struct Collector<'ast> { out: ItemBounds<'ast>, } -struct TypeParamBounds { - _ident: Ident, - _bounds: Punctuated, +/// Where a bound lives on a type parameter in the function's generic list. +pub struct TypeParamBounds { + ident: Ident, + bounds: Punctuated, + param_index: usize, +} + +impl TypeParamBounds { + /// The identifier of the type parameter. + #[inline] + pub fn ident(&self) -> &Ident { + &self.ident + } + + /// The bounds of the type parameter. + #[inline] + pub fn bounds(&self) -> &Punctuated { + &self.bounds + } + + /// The index of the type parameter in the generic list. + #[inline] + pub fn param_index(&self) -> usize { + self.param_index + } +} + +/// Where a bound lives on a type parameter in the function's generic list. +pub struct WhereTypeBounds { + ty: Box, + bounds: Punctuated, + pred_index: usize, } -struct WhereTypeBounds { - _ty: Type, - _bounds: Punctuated, +impl WhereTypeBounds { + /// The bounded type. + #[inline] + pub fn bounded_ty(&self) -> &Type { + &self.ty + } + + /// The bounds of the type parameter. + #[inline] + pub fn bounds(&self) -> &Punctuated { + &self.bounds + } + + /// The index of the type parameter in the generic list. + #[inline] + pub fn pred_index(&self) -> usize { + self.pred_index + } } impl<'ast> Collector<'ast> { @@ -179,11 +302,13 @@ impl<'ast> Collector<'ast> { use syn::{GenericParam, TypeParam}; gens.params .iter() - .filter_map(|p| match p { + .enumerate() + .filter_map(|(idx, p)| match p { GenericParam::Type(TypeParam { ident, bounds, .. }) if !bounds.is_empty() => { Some(TypeParamBounds { - _ident: ident.clone(), - _bounds: bounds.clone(), + ident: ident.clone(), + bounds: bounds.clone(), + param_index: idx, }) } _ => None, @@ -194,13 +319,14 @@ impl<'ast> Collector<'ast> { fn where_bounds(&self, gens: &syn::Generics) -> Vec { let mut out = Vec::new(); if let Some(wc) = &gens.where_clause { - for pred in wc.predicates.iter() { + for (pred_index, pred) in wc.predicates.iter().enumerate() { if let syn::WherePredicate::Type(t) = pred && !t.bounds.is_empty() { out.push(WhereTypeBounds { - _ty: t.bounded_ty.clone(), - _bounds: t.bounds.clone(), + ty: Box::new(t.bounded_ty.clone()), + bounds: t.bounds.clone(), + pred_index, }); } } @@ -224,67 +350,74 @@ impl<'ast> Visit<'ast> for Collector<'ast> { fn visit_item(&mut self, i: &'ast Item) { match i { Item::Fn(f) => { - let label = ItemKey::fn_label(&f.sig.ident.to_string()); + let name = f.sig.ident.to_string(); + let label = ItemKey::fn_label(&name); self.push_if_any(&f.sig.generics, |this, tp, wb| { this.out.fns.push(FnBounds { item: ItemKey { item: ItemRef::Func(f), label: label.clone(), + span: f.sig.ident.span(), }, - _type_params: tp, - _where_preds: wb, + type_params: tp, + where_preds: wb, }); }); } Item::Struct(s) => { - let label = ItemKey::struct_label(&s.ident.to_string()); + let name = s.ident.to_string(); + let label = ItemKey::struct_label(&name); self.push_if_any(&s.generics, |this, tp, wb| { this.out.structs.push(StructBounds { item: ItemKey { item: ItemRef::Struct(s), label: label.clone(), + span: s.ident.span(), }, - _type_params: tp, - _where_preds: wb, + type_params: tp, + where_preds: wb, }); }); } Item::Enum(e) => { - let label = ItemKey::enum_label(&e.ident.to_string()); + let name = e.ident.to_string(); + let label = ItemKey::enum_label(&name); self.push_if_any(&e.generics, |this, tp, wb| { this.out.enums.push(EnumBounds { item: ItemKey { item: ItemRef::Enum(e), label: label.clone(), + span: e.ident.span(), }, - _type_params: tp, - _where_preds: wb, + type_params: tp, + where_preds: wb, }); }); } Item::Trait(t) => { - let label = ItemKey::trait_label(&t.ident.to_string()); + let trait_name = t.ident.to_string(); + let label = ItemKey::trait_label(&trait_name); self.push_if_any(&t.generics, |this, tp, wb| { this.out.traits.push(TraitBounds { item: ItemKey { item: ItemRef::Trait(t), label: label.clone(), + span: t.ident.span(), }, - _type_params: tp, - _where_preds: wb, + type_params: tp, + where_preds: wb, }); }); // Trait methods: generics live on the method *signature*. for it in &t.items { if let syn::TraitItem::Fn(m) = it { - let mlabel = ItemKey::trait_method_label( - &t.ident.to_string(), - &m.sig.ident.to_string(), - ); + let trait_name = t.ident.to_string(); + let mlabel = + ItemKey::trait_method_label(&trait_name, &m.sig.ident.to_string()); self.push_if_any(&m.sig.generics, |this, tp, wb| { this.out.trait_methods.push(TraitMethodBounds { item: ItemKey { @@ -293,9 +426,10 @@ impl<'ast> Visit<'ast> for Collector<'ast> { method: m, }, label: mlabel.clone(), + span: m.sig.ident.span(), }, - _type_params: tp, - _where_preds: wb, + type_params: tp, + where_preds: wb, }); }); } @@ -312,15 +446,15 @@ impl<'ast> Visit<'ast> for Collector<'ast> { ItemKey::impl_inherent_label(&self_ty_str) }; - // Impl header bounds (on the impl itself) self.push_if_any(&im.generics, |this, tp, wb| { this.out.impls.push(ImplBounds { item: ItemKey { item: ItemRef::Impl(im), label: impl_label.clone(), + span: im.impl_token.span, }, - _type_params: tp, - _where_preds: wb, + type_params: tp, + where_preds: wb, }); }); @@ -341,9 +475,10 @@ impl<'ast> Visit<'ast> for Collector<'ast> { method: m, }, label: mlabel.clone(), + span: m.sig.ident.span(), }, - _type_params: tp, - _where_preds: wb, + type_params: tp, + where_preds: wb, }); }); } @@ -352,7 +487,7 @@ impl<'ast> Visit<'ast> for Collector<'ast> { _ => {} } - // continue into nested modules, etc. + syn::visit::visit_item(self, i); } } @@ -427,6 +562,21 @@ mod tests { Ok(()) } + #[test] + fn item_bounds_fn_in_module_records_path() -> TraitError<()> { + let src = r#" + mod outer { + fn foo() {} + } + "#; + let file = syn::parse_file(src)?; + let items = ItemBounds::collect_items_in_file(&file)?; + assert_eq!(items.fns().len(), 1); + let info = &items.fns()[0]; + assert_eq!(info.item.label, "// fn foo"); + Ok(()) + } + #[test] fn item_bounds_struct() -> TraitError<()> { let src = r#" @@ -575,3 +725,5 @@ mod tests { Ok(()) } } + +// TODO: Check supertraits and their methods. diff --git a/src/bin/trait-winnower.rs b/src/bin/trait-winnower.rs index 2d5422b..f223421 100644 --- a/src/bin/trait-winnower.rs +++ b/src/bin/trait-winnower.rs @@ -1,16 +1,16 @@ -// src/main.rs +// src/bin/trait-winnower.rs //! Trait Winnower CLI binary. #![deny(missing_docs)] use clap::Parser; -use colored::Colorize; use std::path::PathBuf; use trait_winnower::analysis::ItemBounds; use trait_winnower::cli; use trait_winnower::config::Config; use trait_winnower::discover::Discover; +use trait_winnower::dynamic_analysis::edit::PruneItem; use trait_winnower::error::TraitError; use trait_winnower::info::TraitInfo; use trait_winnower::target::TargetKind; @@ -18,6 +18,20 @@ use trait_winnower::target::TargetKind; fn main() -> TraitError<()> { let args = cli::Cli::parse(); let verbosity = args.verbose; + let brute_force = args.brute_force; + let top = match args.number_of_items.as_deref() { + Some(s) + if s.eq_ignore_ascii_case("all") + || s.eq_ignore_ascii_case("max") + || s.eq_ignore_ascii_case("maxx") => + { + usize::MAX + } + Some(s) => s.parse::().unwrap_or(10), + None => 10, + }; + + let target_type = args.target_type; match args.command { // init: initializes project config (e.g., default path); @@ -39,40 +53,156 @@ fn main() -> TraitError<()> { cli::Commands::Prune { target } => { let kind = TargetKind::get_target(target)?; match &kind { - TargetKind::SingleFile(p) => { - println!("(dry-run) would modify 1 file:\n {}", p.display()) + TargetKind::SingleFile(_p) => { + if brute_force { + eprintln!("Brute force is not supported for single files"); + std::process::exit(1); + } } TargetKind::Crate(root) | TargetKind::Workspace(root) => { - let cfg = Config::default(); + let cfg = Config::load_or_default(root)?; let files = Discover::discover_rs_files(root, &cfg.include, &cfg.exclude)?; - println!("(dry-run) would modify {} files", files.len()); + if brute_force { + for f in files.iter().take(top) { + // Avoid extra allocations by borrowing path directly + let file = ItemBounds::parse_file(f)?; + let mut items = ItemBounds::collect_items_in_file(&file)?; + + // Execute pruning based on the specified target + match target_type { + cli::TargetType::All => { + PruneItem::prune_function_bounds( + f, + root, + &mut file.clone(), + items.fns_mut(), + &cfg.cargo_check, + )?; + PruneItem::prune_impl_bounds( + f, + root, + &mut file.clone(), + items.impls_mut(), + &cfg.cargo_check, + )?; + PruneItem::prune_trait_bounds( + f, + root, + &mut file.clone(), + items.traits_mut(), + &cfg.cargo_check, + )?; + PruneItem::prune_trait_method_bounds( + f, + root, + &mut file.clone(), + items.trait_methods_mut(), + &cfg.cargo_check, + )?; + PruneItem::prune_impl_method_bounds( + f, + root, + &mut file.clone(), + items.impl_methods_mut(), + &cfg.cargo_check, + )?; + PruneItem::prune_enum_bounds( + f, + root, + &mut file.clone(), + items.enums_mut(), + &cfg.cargo_check, + )?; + PruneItem::prune_struct_bounds( + f, + root, + &mut file.clone(), + items.structs_mut(), + &cfg.cargo_check, + )?; + } + cli::TargetType::Function => { + PruneItem::prune_function_bounds( + f, + root, + &mut file.clone(), + items.fns_mut(), + &cfg.cargo_check, + )?; + } + cli::TargetType::Impl => { + PruneItem::prune_impl_bounds( + f, + root, + &mut file.clone(), + items.impls_mut(), + &cfg.cargo_check, + )?; + } + cli::TargetType::Trait => { + PruneItem::prune_trait_bounds( + f, + root, + &mut file.clone(), + items.traits_mut(), + &cfg.cargo_check, + )?; + } + cli::TargetType::TraitMethod => { + PruneItem::prune_trait_method_bounds( + f, + root, + &mut file.clone(), + items.trait_methods_mut(), + &cfg.cargo_check, + )?; + } + cli::TargetType::ImplMethod => { + PruneItem::prune_impl_method_bounds( + f, + root, + &mut file.clone(), + items.impl_methods_mut(), + &cfg.cargo_check, + )?; + } + cli::TargetType::Enum => { + PruneItem::prune_enum_bounds( + f, + root, + &mut file.clone(), + items.enums_mut(), + &cfg.cargo_check, + )?; + } + cli::TargetType::Struct => { + PruneItem::prune_struct_bounds( + f, + root, + &mut file.clone(), + items.structs_mut(), + &cfg.cargo_check, + )?; + } + } + } + } } } } // check: per-file items at -vv (capped by --top), global top-traits summary always. - cli::Commands::Check { target, top } => { + cli::Commands::Check { target } => { let kind = TargetKind::get_target(target)?; - let top = match top.as_deref() { - Some(s) - if s.eq_ignore_ascii_case("all") - || s.eq_ignore_ascii_case("max") - || s.eq_ignore_ascii_case("maxx") => - { - usize::MAX - } - Some(s) => s.parse::().unwrap_or(10), - None => 10, - }; match &kind { TargetKind::SingleFile(p) => { let file = ItemBounds::parse_file(p)?; let items = ItemBounds::collect_items_in_file(&file)?; if verbosity > 1 { - for item in items.iter_all_items().take(top) { - TraitInfo::show_item(item); + for item in items.fns().iter().take(top) { + TraitInfo::show_item(item.item_key()); if verbosity > 2 { - TraitInfo::debug_print_itemref(&item.item); + TraitInfo::debug_print_itemref(item.item_key().item()); } } } @@ -81,22 +211,16 @@ fn main() -> TraitError<()> { let cfg = Config::load_or_default(root)?; let files = Discover::discover_rs_files(root, &cfg.include, &cfg.exclude)?; - for f in files.iter().take(top) { - let file = ItemBounds::parse_file(f)?; + for file in files.iter().take(top) { + let file = ItemBounds::parse_file(file)?; let items = ItemBounds::collect_items_in_file(&file)?; - if verbosity > 1 { - println!( - "// {}:", - f.display().to_string().italic().truecolor(0x00, 0xA6, 0x52) - ); - for item in items.iter_all_items().take(top) { - TraitInfo::show_item(item); + for item in items.fns().iter().take(top) { + TraitInfo::show_item(item.item_key()); if verbosity > 2 { - TraitInfo::debug_print_itemref(&item.item); + TraitInfo::debug_print_itemref(item.item_key().item()); } } - println!(); } } } diff --git a/src/cli.rs b/src/cli.rs index 9cfeb86..51568ac 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,9 +2,30 @@ #![deny(missing_docs)] -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use std::path::PathBuf; +/// Target types for pruning trait bounds. +#[derive(Debug, Clone, ValueEnum)] +pub enum TargetType { + /// Prune all types of trait bounds (default). + All, + /// Prune function trait bounds. + Function, + /// Prune impl trait bounds. + Impl, + /// Prune trait trait bounds. + Trait, + /// Prune trait method trait bounds. + TraitMethod, + /// Prune impl method trait bounds. + ImplMethod, + /// Prune enum trait bounds. + Enum, + /// Prune struct trait bounds. + Struct, +} + /// Reduce unnecessary Rust trait requirements. #[derive(Parser, Debug)] #[command( @@ -29,6 +50,24 @@ pub struct Cli { #[arg(short, long, global = true)] pub quiet: bool, + /// Brute force removal of trait bounds. + #[arg(short, long, global = true)] + pub brute_force: bool, + + /// Show only the top N trait bounds. + #[arg(short, long, global = true)] + pub number_of_items: Option, + + /// Type of target to operate on. + #[arg( + short = 't', + long = "target-type", + value_name = "TARGET_TYPE", + default_value = "all", + global = true + )] + pub target_type: TargetType, + /// Subcommand to run. #[command(subcommand)] pub command: Commands, @@ -58,9 +97,5 @@ pub enum Commands { Check { /// Target to check. Defaults to ".". target: Option, - - /// Show only the top N trait bounds. - #[arg(short, long)] - top: Option, }, } diff --git a/src/config.rs b/src/config.rs index 101fa50..e384678 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,26 @@ use crate::error::TraitError; use serde::{Deserialize, Serialize}; use std::{fs, path::Path, path::PathBuf}; +/// Configuration for cargo check execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CargoCheckConfig { + /// Cargo check arguments (e.g., ["--workspace", "--all-features", "--all-targets", "--quiet"]). + pub args: Vec, +} + +impl Default for CargoCheckConfig { + fn default() -> Self { + Self { + args: vec![ + "--workspace".into(), + "--all-features".into(), + "--all-targets".into(), + "--quiet".into(), + ], + } + } +} + /// Config struct for trait-winnower. #[derive(Debug, Serialize, Deserialize)] pub struct Config { @@ -14,6 +34,8 @@ pub struct Config { pub include: Vec, /// Exclude files. pub exclude: Vec, + /// Cargo check configuration. + pub cargo_check: CargoCheckConfig, } impl Default for Config { @@ -25,6 +47,7 @@ impl Default for Config { "**/.git/**".into(), "**/tests/**".into(), ], + cargo_check: CargoCheckConfig::default(), } } } @@ -48,6 +71,10 @@ impl Config { if cfg.exclude.is_empty() { cfg.exclude = Config::default().exclude; } + // If cargo_check is not specified in the config, use defaults + if cfg.cargo_check.args.is_empty() { + cfg.cargo_check = CargoCheckConfig::default(); + } Ok(cfg) } else { Ok(Config::default()) diff --git a/src/dynamic_analysis/common.rs b/src/dynamic_analysis/common.rs new file mode 100644 index 0000000..a4eee9d --- /dev/null +++ b/src/dynamic_analysis/common.rs @@ -0,0 +1,366 @@ +// src/dynamic_analysis/common.rs +//! Common types and logic for dynamic analysis of trait bounds. + +#![deny(missing_docs)] + +use crate::analysis::{ + EnumBounds, FnBounds, ImplBounds, ImplMethodBounds, StructBounds, TraitBounds, + TraitMethodBounds, TypeParamBounds, WhereTypeBounds, +}; +use crate::config::CargoCheckConfig; +use crate::error::TraitError; + +use anyhow::Context; +use quote::ToTokens; +use std::path::Path; +use std::process::{Command, ExitStatus}; +use syn::GenericParam; +use syn::{Ident, Type, TypeParamBound}; +use syn::{WherePredicate, punctuated::Punctuated, token::Comma}; + +/// A structural coordinate describing precisely and concretely the location of a trait/lifetime bound +#[derive(Clone)] +pub enum BoundSite { + /// Bound is on a type parameter like T: Display + Debug.
+ /// For example, fn foo() { ... } + TypeParam { + /// The type parameter identifier (T). + ident: Ident, + /// Index of the type parameter in generics (e.g. T is 0 in ). + param_index: usize, + /// Index of the bound for this type param (0 for first, etc.). + bound_index: usize, + }, + /// Bound is in a where clause predicate, like where T: Debug + Send. + /// For example, fn foo(...) where T: Clone, MyTy: Debug + WhereClause { + /// The type/lifetime being bounded (e.g. T or MyType). + ty: Box, + /// Index of predicate in the where-clause predicate list. + pred_index: usize, + /// Index of bound within that where-clause predicate. + bound_index: usize, + }, +} + +impl core::fmt::Debug for BoundSite { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + BoundSite::TypeParam { + ident, + param_index, + bound_index, + } => f + .debug_struct("TypeParam") + .field("ident", &ident.to_string()) + .field("param_index", param_index) + .field("bound_index", bound_index) + .finish(), + BoundSite::WhereClause { + ty, + pred_index, + bound_index, + } => f + .debug_struct("WhereClause") + .field("ty", &ty.to_token_stream()) + .field("pred_index", pred_index) + .field("bound_index", bound_index) + .finish(), + } + } +} + +/// Represents a possible (removable) trait/lifetime bound on an item (function, impl, ...). +#[derive(Clone)] +pub struct BoundCandidate { + /// The coordinate describing the structural and logical location (type param vs where, index, etc.). + pub site: BoundSite, + /// The bound atom itself (e.g., Clone, ?Sized, 'a). + pub bound: TypeParamBound, +} + +impl std::fmt::Debug for BoundCandidate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BoundCandidate") + .field("site", &self.site) + .field("bound", &Self::to_tokens_string(&self.bound)) + .finish() + } +} + +impl BoundCandidate { + #[inline] + fn to_tokens_string(bound: &TypeParamBound) -> String { + bound.to_token_stream().to_string() + } + + #[inline] + fn push_type_param_candidates(out: &mut Vec, tp: &TypeParamBounds) { + for (bound_index, bound) in tp.bounds().iter().cloned().enumerate() { + out.push(BoundCandidate { + site: BoundSite::TypeParam { + ident: tp.ident().clone(), + param_index: tp.param_index(), + bound_index, + }, + bound, + }); + } + } + + #[inline] + fn push_where_candidates(out: &mut Vec, wb: &WhereTypeBounds) { + for (bound_index, bound) in wb.bounds().iter().cloned().enumerate() { + out.push(BoundCandidate { + site: BoundSite::WhereClause { + ty: Box::new(wb.bounded_ty().clone()), + pred_index: wb.pred_index(), + bound_index, + }, + bound, + }); + } + } +} + +/// Macro generating a collect_*_candidates() function for each analysis struct. +macro_rules! define_collect_candidate_fns { + ( $( ($func:ident, $bounds:ident) ),+ $(,)? ) => { + $( + impl BoundCandidate { + #[allow(missing_docs, reason = "macro-generated code")] + pub fn $func(bounds: &$bounds<'_>) -> Vec { + let mut out = Vec::new(); + for tp in bounds.type_param_bounds() { + Self::push_type_param_candidates(&mut out, tp); + } + for wb in bounds.where_bounds() { + Self::push_where_candidates(&mut out, wb); + } + out + } + } + )+ + }; +} + +define_collect_candidate_fns! { + (collect_function_candidates, FnBounds), + (collect_trait_method_candidates, TraitMethodBounds), + (collect_impl_method_candidates, ImplMethodBounds), + (collect_trait_candidates, TraitBounds), + (collect_impl_candidates, ImplBounds), + (collect_enum_candidates, EnumBounds), + (collect_struct_candidates, StructBounds), +} + +/// A stateless utility for removing a bound from a generics block in-place. +pub struct Remove; + +impl Remove { + /// Remove a single trait/lifetime bound from a generic block by its coordinates. + pub fn apply_to_item_with_generics( + item: &mut T, + candidate: &BoundCandidate, + ) -> bool { + match &candidate.site { + BoundSite::TypeParam { + param_index, + bound_index, + .. + } => Self::remove_tp_bound_by_index(item.generics_mut(), *param_index, *bound_index), + BoundSite::WhereClause { + pred_index, + bound_index, + .. + } => Self::remove_where_bound_by_index(item.generics_mut(), *pred_index, *bound_index), + } + } + + fn remove_tp_bound_by_index( + generics: &mut syn::Generics, + param_index: usize, + bound_index: usize, + ) -> bool { + let Some(GenericParam::Type(tp)) = generics.params.iter_mut().nth(param_index) else { + return false; + }; + let removed = Self::remove_punctuated_at(&mut tp.bounds, bound_index); + if removed && tp.bounds.is_empty() { + tp.colon_token = None; + } + removed + } + + fn remove_where_bound_by_index( + generics: &mut syn::Generics, + pred_index: usize, + bound_index: usize, + ) -> bool { + let Some(wc) = generics.where_clause.as_mut() else { + return false; + }; + let Some(pred) = wc.predicates.iter_mut().nth(pred_index) else { + return false; + }; + if let syn::WherePredicate::Type(tp) = pred { + let removed = Self::remove_punctuated_at(&mut tp.bounds, bound_index); + if removed && tp.bounds.is_empty() { + wc.predicates = + Self::drop_predicate_at(std::mem::take(&mut wc.predicates), pred_index); + if wc.predicates.is_empty() { + generics.where_clause = None; + } + } + removed + } else { + false + } + } + + fn remove_punctuated_at(list: &mut Punctuated, idx: usize) -> bool + where + T: Clone, + P: Default, + { + if idx >= list.len() { + return false; + } + let mut kept = Vec::with_capacity(list.len().saturating_sub(1)); + for (i, val) in list.iter().cloned().enumerate() { + if i != idx { + kept.push(val); + } + } + *list = { + let mut out = Punctuated::new(); + let mut it = kept.into_iter(); + if let Some(first) = it.next() { + out.push_value(first); + for v in it { + out.push_punct(P::default()); + out.push_value(v); + } + } + out + }; + true + } + + fn drop_predicate_at( + preds: Punctuated, + idx: usize, + ) -> Punctuated { + if idx >= preds.len() { + return preds; + } + let mut kept = Vec::with_capacity(preds.len() - 1); + for (i, p) in preds.into_pairs().enumerate() { + if i != idx { + kept.push(p.into_value()); + } + } + let mut out = Punctuated::new(); + let mut it = kept.into_iter(); + if let Some(first) = it.next() { + out.push_value(first); + for v in it { + out.push_punct(Comma::default()); + out.push_value(v); + } + } + out + } +} +/// A result of running cargo check. +#[derive(Debug)] +pub struct CommandOutput { + /// The status of the cargo check. + pub status: ExitStatus, + /// The stdout of the cargo check. + pub stdout: String, + /// The stderr of the cargo check. + pub stderr: String, +} + +/// A result of removing a bound. +#[derive(Debug)] +pub enum BoundRemovalOutcome { + /// The bound was removed and cargo check was successful. + Removed { + /// The output of the cargo check. + check: CommandOutput, + }, + /// The bound was retained and cargo check was successful. + Retained { + /// The output of the cargo check. + check: CommandOutput, + }, + /// The bound was skipped. + Skipped, +} + +/// A result of removing a bound. +#[derive(Debug)] +pub struct BoundRemovalResult { + /// The candidate that was removed. + pub candidate: BoundCandidate, + /// The outcome of the removal attempt. + pub outcome: BoundRemovalOutcome, +} + +/// A utility for running cargo check. +pub struct CargoCheck; + +impl CargoCheck { + /// Run cargo check with the given configuration. + pub fn run_cargo_check(root: &Path, config: &CargoCheckConfig) -> TraitError { + let mut command = Command::new("cargo"); + command.arg("check"); + for arg in &config.args { + command.arg(arg); + } + let output = command + .current_dir(root) + .output() + .with_context(|| format!("running cargo check in {}", Self::display(root)))?; + Ok(CommandOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + + #[inline] + fn display(path: &Path) -> String { + path.to_string_lossy().into_owned() + } +} + +/// A trait for items that have generics. +pub trait HasGenerics { + /// Get a mutable reference to the generics of the item. + fn generics_mut(&mut self) -> &mut syn::Generics; +} + +macro_rules! impl_has_generics { + ($($t:ty => ($($access:tt)+)),* $(,)?) => { + $( + impl HasGenerics for $t { + fn generics_mut(&mut self) -> &mut syn::Generics { + &mut self $($access)+ + } + } + )* + }; +} + +impl_has_generics! { + syn::ItemFn => (.sig.generics), + syn::ItemImpl => (.generics), + syn::ItemTrait => (.generics), + syn::ItemStruct => (.generics), + syn::ImplItemFn => (.sig.generics), + syn::TraitItemFn => (.sig.generics), + syn::ItemEnum => (.generics), +} diff --git a/src/dynamic_analysis/edit.rs b/src/dynamic_analysis/edit.rs new file mode 100644 index 0000000..0b096df --- /dev/null +++ b/src/dynamic_analysis/edit.rs @@ -0,0 +1,312 @@ +// src/dynamic_analysis/edit.rs +//! Edit dynamic analysis of trait bounds. + +#![deny(missing_docs)] + +use crate::config::CargoCheckConfig; +use crate::dynamic_analysis::common::{ + BoundCandidate, BoundRemovalOutcome, BoundRemovalResult, CargoCheck, HasGenerics, +}; +use crate::error::TraitError; +use anyhow::Context; +use proc_macro2::Span; +use std::fs; +use syn::visit_mut::VisitMut; + +/// Traversal that locates the *exact* target item by its anchor Span +pub struct BoundEditor<'a, T: HasGenerics> { + target_ident: Option<&'a syn::Ident>, + target_anchor: Span, + candidate: &'a BoundCandidate, + modified: bool, + _phantom: std::marker::PhantomData, +} + +impl<'a, T: HasGenerics> BoundEditor<'a, T> { + /// Construct a new editor for the given anchor/ident/candidate. + pub fn new( + target_ident: Option<&'a syn::Ident>, + target_anchor: Span, + candidate: &'a BoundCandidate, + ) -> Self { + Self { + target_ident, + target_anchor, + candidate, + modified: false, + _phantom: std::marker::PhantomData, + } + } + + /// Returns true if the item was modified. + #[inline] + pub fn modified(&self) -> bool { + self.modified + } + + /// Compare two spans for equality using byte ranges when available. + #[inline] + fn spans_equal(&self, span1: Span, span2: Span) -> bool { + if span1.byte_range() == span2.byte_range() { + return true; + } + if span1.file() != span2.file() { + return false; + } + let start1 = span1.start(); + let start2 = span2.start(); + let end1 = span1.end(); + let end2 = span2.end(); + start1.line == start2.line + && start1.column == start2.column + && end1.line == end2.line + && end1.column == end2.column + } + + #[inline] + fn try_edit_node( + &mut self, + node: &mut N, + node_ident: Option<&syn::Ident>, + node_anchor: Span, + ) { + if self.modified { + return; + } + if !self.spans_equal(node_anchor, self.target_anchor) { + return; + } + if let (Some(want), Some(got)) = (self.target_ident, node_ident) + && *want != *got + { + return; + } + self.modified = crate::dynamic_analysis::common::Remove::apply_to_item_with_generics( + node, + self.candidate, + ); + } +} + +impl<'a, T: HasGenerics> VisitMut for BoundEditor<'a, T> { + fn visit_item_mod_mut(&mut self, node: &mut syn::ItemMod) { + if self.modified { + return; + } + syn::visit_mut::visit_item_mod_mut(self, node); + } + + fn visit_item_fn_mut(&mut self, node: &mut syn::ItemFn) { + let id = node.sig.ident.clone(); + let anchor = id.span(); + self.try_edit_node(node, Some(&id), anchor); + } + + fn visit_item_impl_mut(&mut self, node: &mut syn::ItemImpl) { + let anchor = node.impl_token.span; + self.try_edit_node(node, None, anchor); + if !self.modified { + syn::visit_mut::visit_item_impl_mut(self, node); + } + } + + fn visit_item_trait_mut(&mut self, node: &mut syn::ItemTrait) { + let id = node.ident.clone(); + let anchor = id.span(); + self.try_edit_node(node, Some(&id), anchor); + if !self.modified { + syn::visit_mut::visit_item_trait_mut(self, node); + } + } + + fn visit_item_struct_mut(&mut self, node: &mut syn::ItemStruct) { + let id = node.ident.clone(); + let anchor = id.span(); + self.try_edit_node(node, Some(&id), anchor); + } + + fn visit_item_enum_mut(&mut self, node: &mut syn::ItemEnum) { + let id = node.ident.clone(); + let anchor = id.span(); + self.try_edit_node(node, Some(&id), anchor); + } + + fn visit_impl_item_fn_mut(&mut self, node: &mut syn::ImplItemFn) { + let id = node.sig.ident.clone(); + let anchor = id.span(); + self.try_edit_node(node, Some(&id), anchor); + } + + fn visit_trait_item_fn_mut(&mut self, node: &mut syn::TraitItemFn) { + let id = node.sig.ident.clone(); + let anchor = id.span(); + self.try_edit_node(node, Some(&id), anchor); + } +} + +#[inline] +fn hash_bytes(s: &str) -> u32 { + crc32fast::hash(s.as_bytes()) +} +struct CandidateTrialConfig<'a> { + file_path: &'a std::path::Path, + crate_root: &'a std::path::Path, + working: &'a syn::File, + target_ident: Option<&'a syn::Ident>, + target_anchor: Span, + candidate: &'a BoundCandidate, + current_src: &'a str, + current_hash: u32, + cargo_check_config: &'a CargoCheckConfig, +} +impl<'a> CandidateTrialConfig<'a> { + fn try_candidate_once( + config: CandidateTrialConfig<'_>, + ) -> TraitError<(bool, BoundRemovalOutcome, String, u32)> { + let mut try_working = config.working.clone(); + let mut editor = + BoundEditor::::new(config.target_ident, config.target_anchor, config.candidate); + editor.visit_file_mut(&mut try_working); + if !editor.modified() { + return Ok(( + false, + BoundRemovalOutcome::Skipped, + config.current_src.to_owned(), + config.current_hash, + )); + } + + let updated_src = prettyplease::unparse(&try_working); + let updated_hash = hash_bytes(&updated_src); + + if updated_hash == config.current_hash { + return Ok(( + false, + BoundRemovalOutcome::Skipped, + config.current_src.to_owned(), + config.current_hash, + )); + } + + fs::write(config.file_path, &updated_src) + .with_context(|| format!("writing updated {}", config.file_path.display()))?; + let check = CargoCheck::run_cargo_check(config.crate_root, config.cargo_check_config)?; + + if check.status.success() { + Ok(( + true, + BoundRemovalOutcome::Removed { check }, + updated_src, + updated_hash, + )) + } else { + fs::write(config.file_path, config.current_src) + .with_context(|| format!("reverting {}", config.file_path.display()))?; + Ok(( + false, + BoundRemovalOutcome::Retained { check }, + config.current_src.to_owned(), + config.current_hash, + )) + } + } +} +/// A trait for items that can be pruned. +pub struct PruneItem; + +macro_rules! make_pruner { + ( $( name: $name:ident, item_ty: $item_ty:ty, bounds_ty: $bounds_ty:ty, collect_candidates: $collect:expr $(,)? );+ $(;)? ) => { + $( + impl PruneItem { + #[allow(missing_docs, reason = "macro-generated")] + pub fn $name( + file_path: &std::path::Path, + crate_root: &std::path::Path, + syntax: &mut syn::File, + bounds: &mut Vec<$bounds_ty>, + cargo_check_config: &CargoCheckConfig, + ) -> crate::error::TraitError> { + let original_src = fs::read_to_string(file_path) + .with_context(|| format!("reading {}", file_path.display()))?; + let original_hash = hash_bytes(&original_src); + let mut outcomes = Vec::new(); + let mut working = syntax.clone(); + let mut current_src = original_src.clone(); + let mut current_hash = original_hash; + let i = 0; + + while i < bounds.len() { + let bounds_item = &bounds[i]; + let item_key = bounds_item.item_key(); + let target_ident = item_key.ident(); + let target_anchor = item_key.span(); + + let candidates: Vec = ($collect)(bounds_item); + let mut removed_any = false; + + for candidate in &candidates { + let config = CandidateTrialConfig { + file_path, + crate_root, + working: &working, + target_ident, + target_anchor, + candidate, + current_src: ¤t_src, + current_hash, + cargo_check_config, + }; + let (accepted, outcome, new_src, new_hash) = CandidateTrialConfig::try_candidate_once::<$item_ty>(config)?; + outcomes.push(BoundRemovalResult { candidate: candidate.clone(), outcome }); + + if accepted { + let mut tmp = working.clone(); + let mut editor = + BoundEditor::<$item_ty>::new(target_ident, target_anchor, candidate); + editor.visit_file_mut(&mut tmp); + debug_assert!(editor.modified()); + working = tmp; + *syntax = working.clone(); + current_src = new_src; + current_hash = new_hash; + removed_any = true; + break; + } + } + + if removed_any { + continue; + } else { + bounds.remove(i); + } + } + + Ok(outcomes) + } + } + )+ + }; +} + +make_pruner! { + name: prune_function_bounds, item_ty: syn::ItemFn, bounds_ty: crate::analysis::FnBounds<'_>, + collect_candidates: |b: &crate::analysis::FnBounds<'_>| { BoundCandidate::collect_function_candidates(b) }; + + name: prune_struct_bounds, item_ty: syn::ItemStruct, bounds_ty: crate::analysis::StructBounds<'_>, + collect_candidates: |b: &crate::analysis::StructBounds<'_>| { BoundCandidate::collect_struct_candidates(b) }; + + name: prune_enum_bounds, item_ty: syn::ItemEnum, bounds_ty: crate::analysis::EnumBounds<'_>, + collect_candidates: |b: &crate::analysis::EnumBounds<'_>| { BoundCandidate::collect_enum_candidates(b)}; + + name: prune_impl_bounds, item_ty: syn::ItemImpl, bounds_ty: crate::analysis::ImplBounds<'_>, + collect_candidates: |b: &crate::analysis::ImplBounds<'_>| { BoundCandidate::collect_impl_candidates(b) }; + + name: prune_trait_bounds, item_ty: syn::ItemTrait, bounds_ty: crate::analysis::TraitBounds<'_>, + collect_candidates: |b: &crate::analysis::TraitBounds<'_>| { BoundCandidate::collect_trait_candidates(b) }; + + name: prune_trait_method_bounds, item_ty: syn::TraitItemFn, bounds_ty: crate::analysis::TraitMethodBounds<'_>, + collect_candidates: |b: &crate::analysis::TraitMethodBounds<'_>| { BoundCandidate::collect_trait_method_candidates(b) }; + + name: prune_impl_method_bounds, item_ty: syn::ImplItemFn, bounds_ty: crate::analysis::ImplMethodBounds<'_>, + collect_candidates: |b: &crate::analysis::ImplMethodBounds<'_>| { BoundCandidate::collect_impl_method_candidates(b) }; +} diff --git a/src/dynamic_analysis/mod.rs b/src/dynamic_analysis/mod.rs new file mode 100644 index 0000000..6aadfa3 --- /dev/null +++ b/src/dynamic_analysis/mod.rs @@ -0,0 +1,7 @@ +// src/removals/mod.rs +//! Module for handling trait removals and related functionality. + +#![deny(missing_docs)] + +pub mod common; +pub mod edit; diff --git a/src/lib.rs b/src/lib.rs index d0fd145..163fa7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod analysis; pub mod cli; pub mod config; pub mod discover; +pub mod dynamic_analysis; pub mod error; pub mod info; pub mod target; diff --git a/src/static_analysis/ir.rs b/src/static_analysis/ir.rs new file mode 100644 index 0000000..e9455b9 --- /dev/null +++ b/src/static_analysis/ir.rs @@ -0,0 +1,5 @@ +// src/static_analysis/ir.rs +//! Intermediate representation for static analysis. + +#![deny(missing_docs)] + diff --git a/src/static_analysis/mod.rs b/src/static_analysis/mod.rs new file mode 100644 index 0000000..f71d38b --- /dev/null +++ b/src/static_analysis/mod.rs @@ -0,0 +1,5 @@ +// src/static_analysis/mod.rs + +#![deny(missing_docs)] + +pub mod ir; \ No newline at end of file diff --git a/tests/expected/trait_sandbox/Cargo.lock b/tests/expected/trait_sandbox/Cargo.lock new file mode 100644 index 0000000..4600416 --- /dev/null +++ b/tests/expected/trait_sandbox/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "trait_sandbox" +version = "0.1.0" diff --git a/tests/expected/trait_sandbox/Cargo.toml b/tests/expected/trait_sandbox/Cargo.toml new file mode 100644 index 0000000..1f98ccd --- /dev/null +++ b/tests/expected/trait_sandbox/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "trait_sandbox" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lib] +name = "trait_sandbox" +path = "src/lib.rs" \ No newline at end of file diff --git a/tests/expected/trait_sandbox/src/a.rs b/tests/expected/trait_sandbox/src/a.rs new file mode 100644 index 0000000..9fa6ebf --- /dev/null +++ b/tests/expected/trait_sandbox/src/a.rs @@ -0,0 +1,77 @@ +//! Free functions with various bound placements and (un)usage. +/// Bound on type param; **unused** in body. +pub fn unused_bound_clone(x: T) -> T { + x +} +/// Bound on type param; **used** in body. +pub fn used_bound_clone(x: T) -> T { + let _y = x.clone(); + x +} +/// `where`-clause bound; **unused** in body. +pub fn where_unused_default(x: T) -> T { + x +} +/// `where`-clause bound; **used** in body. +pub fn where_used_default(x: Option) -> T +where + T: Default, +{ + x.unwrap_or_default() +} +/// HRTB bound; **used** in body. +pub fn hrtb_used(f: F) -> usize +where + for<'a> F: Fn(&'a str) -> usize, +{ + f("hello") +} +/// HRTB bound; **unused** in body. +pub fn hrtb_unused() {} + + +// //! Free functions with various bound placements and (un)usage. + +// /// Bound on type param; **unused** in body. +// pub fn unused_bound_clone(x: T) -> T { +// // NOTE: we never call `x.clone()`, so `Clone` is removable if not required elsewhere. +// x +// } + +// /// Bound on type param; **used** in body. +// pub fn used_bound_clone(x: T) -> T { +// let _y = x.clone(); // Uses Clone +// x +// } + +// /// `where`-clause bound; **unused** in body. +// pub fn where_unused_default(x: T) -> T +// where +// T: Default, // Default not used +// { +// x +// } + +// /// `where`-clause bound; **used** in body. +// pub fn where_used_default(x: Option) -> T +// where +// T: Default + Clone, // Default used; Clone not required here (left in to test multi-bound pruning) +// { +// x.unwrap_or_default() +// } + +// /// HRTB bound; **used** in body. +// pub fn hrtb_used(f: F) -> usize +// where +// for<'a> F: Fn(&'a str) -> usize, +// { +// f("hello") +// } + +// /// HRTB bound; **unused** in body. +// pub fn hrtb_unused() +// where +// for<'a> F: Fn(&'a str) -> usize, // not used +// { +// // no-op +// } diff --git a/tests/expected/trait_sandbox/src/b.rs b/tests/expected/trait_sandbox/src/b.rs new file mode 100644 index 0000000..39e63e6 --- /dev/null +++ b/tests/expected/trait_sandbox/src/b.rs @@ -0,0 +1,59 @@ +//! Impl blocks: bounds on the impl vs. on methods. +/// Simple wrapper to exercise generic method bounds. +pub struct Wrapper(pub T); +impl Wrapper { + /// Method-level bound; **used** (copying from `&self` requires `T: Copy`). + pub fn copied(&self) -> T + where + T: Copy, + { + self.0 + } + /// Method-level `where` bound; **unused** in body. + pub fn id(&self) { + let _ = &self.0; + } +} +/// Impl-level bound; **used** in method body (`T::default()`). +impl Wrapper +where + T: Default, +{ + pub fn new_default() -> Self { + Self(T::default()) + } +} + + +// //! Impl blocks: bounds on the impl vs. on methods. + +// /// Simple wrapper to exercise generic method bounds. +// pub struct Wrapper(pub T); + +// impl Wrapper { +// /// Method-level bound; **used** (copying from `&self` requires `T: Copy`). +// pub fn copied(&self) -> T +// where +// T: Copy, +// { +// self.0 +// } + +// /// Method-level `where` bound; **unused** in body. +// pub fn id(&self) +// where +// T: Ord, // not used +// { +// let _ = &self.0; // no Ord usage +// } +// } + +// /// Impl-level bound; **used** in method body (`T::default()`). +// impl Wrapper +// where +// T: Default, +// { +// pub fn new_default() -> Self { +// Self(T::default()) +// } +// } diff --git a/tests/expected/trait_sandbox/src/c.rs b/tests/expected/trait_sandbox/src/c.rs new file mode 100644 index 0000000..7a20780 --- /dev/null +++ b/tests/expected/trait_sandbox/src/c.rs @@ -0,0 +1,36 @@ +//! Traits with supertraits and `where Self: ...` clauses. +use crate::traits::{Sub, Super, Thing}; +/// Function requiring a `Sub` (which implies `Super: Debug`); **used** (via `Debug` formatting). +pub fn uses_super_via_sub(t: &T) -> String { + format!("{:?}", t) +} +/// Function with **unused** supertrait in signature. +/// We require `Super` but never debug-print; should be removable if not implied elsewhere. +pub fn super_unused(_t: &T) -> usize { + 42 +} +/// Returns a Thing to help link trait usage across modules. +pub fn make_thing() -> Thing { + Thing { n: 0 } +} + + +// //! Traits with supertraits and `where Self: ...` clauses. + +// use crate::traits::{Sub, Super, Thing}; + +// /// Function requiring a `Sub` (which implies `Super: Debug`); **used** (via `Debug` formatting). +// pub fn uses_super_via_sub(t: &T) -> String { +// format!("{:?}", t) +// } + +// /// Function with **unused** supertrait in signature. +// /// We require `Super` but never debug-print; should be removable if not implied elsewhere. +// pub fn super_unused(_t: &T) -> usize { +// 42 +// } + +// /// Returns a Thing to help link trait usage across modules. +// pub fn make_thing() -> Thing { +// Thing { n: 0 } +// } diff --git a/tests/expected/trait_sandbox/src/d.rs b/tests/expected/trait_sandbox/src/d.rs new file mode 100644 index 0000000..e69de29 diff --git a/tests/expected/trait_sandbox/src/lib.rs b/tests/expected/trait_sandbox/src/lib.rs new file mode 100644 index 0000000..406345c --- /dev/null +++ b/tests/expected/trait_sandbox/src/lib.rs @@ -0,0 +1,79 @@ +//! Test crate for trait-bound pruning. + +pub mod traits; +pub mod a; +pub mod b; +pub mod c; + +// Re-exports so a single module path works in tests/tools. +pub use a::*; +pub use b::*; +pub use c::*; +pub use traits::*; + +#[cfg(test)] +mod smoke { + use super::*; + + #[test] + fn it_compiles_and_runs() { + // a.rs + let _ = unused_bound_clone(10); // Clone **unused** + let _ = used_bound_clone(String::from("x")); // Clone used + let _ = where_unused_default(7u8); // Default **unused** + let _ = where_used_default(Some(5u32)); // Default used + let _ = hrtb_used(|s: &str| s.len()); // HRTB used + hrtb_unused:: usize>(); // HRTB **unused** + + // b.rs + let w = Wrapper(3u32); + let _ = w.copied(); // Copy used (method-level bound) + let _ = Wrapper::::new_default(); // Default used (impl-level bound) + w.id(); // Ord **unused** (method-level where) + + // c.rs + let t = Thing { n: 1 }; + t.touch(); // `where Self: Sized + Clone` (Sized used; Clone **unused**) + assert!(format!("{:?}", t).len() > 0); // Super: Debug used via Super + } +} + +// //! Test crate for trait-bound pruning. + +// pub mod traits; +// pub mod a; +// pub mod b; +// pub mod c; + +// // Re-exports so a single module path works in tests/tools. +// pub use a::*; +// pub use b::*; +// pub use c::*; +// pub use traits::*; + +// #[cfg(test)] +// mod smoke { +// use super::*; + +// #[test] +// fn it_compiles_and_runs() { +// // a.rs +// let _ = unused_bound_clone(10); // Clone **unused** +// let _ = used_bound_clone(String::from("x")); // Clone used +// let _ = where_unused_default(7u8); // Default **unused** +// let _ = where_used_default(Some(5u32)); // Default used +// let _ = hrtb_used(|s: &str| s.len()); // HRTB used +// hrtb_unused:: usize>(); // HRTB **unused** + +// // b.rs +// let w = Wrapper(3u32); +// let _ = w.copied(); // Copy used (method-level bound) +// let _ = Wrapper::::new_default(); // Default used (impl-level bound) +// w.id(); // Ord **unused** (method-level where) + +// // c.rs +// let t = Thing { n: 1 }; +// t.touch(); // `where Self: Sized + Clone` (Sized used; Clone **unused**) +// assert!(format!("{:?}", t).len() > 0); // Super: Debug used via Super +// } +// } diff --git a/tests/expected/trait_sandbox/src/traits.rs b/tests/expected/trait_sandbox/src/traits.rs new file mode 100644 index 0000000..e1a2927 --- /dev/null +++ b/tests/expected/trait_sandbox/src/traits.rs @@ -0,0 +1,41 @@ +//! Custom traits to exercise different patterns. +use core::fmt::Debug; +pub trait Super: Debug {} +pub trait Sub: Super {} +pub trait SelfWhere { + fn touch(&self) {} +} +#[derive(Clone, Debug)] +pub struct Thing { + pub n: i32, +} +impl Super for Thing {} +impl Sub for Thing {} +impl SelfWhere for Thing {} + + +// //! Custom traits to exercise different patterns. + +// use core::fmt::Debug; + +// // Supertrait chain +// pub trait Super: Debug {} +// pub trait Sub: Super {} + +// // A trait with a `where Self: ...` clause. We'll partly use it. +// pub trait SelfWhere +// where +// Self: Sized + Clone, // Unused +// { +// fn touch(&self) {} +// } + +// // A trivial type implementing the above. +// #[derive(Clone, Debug)] +// pub struct Thing { +// pub n: i32, +// } + +// impl Super for Thing {} +// impl Sub for Thing {} +// impl SelfWhere for Thing {} diff --git a/tests/expected/trait_sandbox/test.rs b/tests/expected/trait_sandbox/test.rs new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_files/trait_sandbox/Cargo.lock b/tests/test_files/trait_sandbox/Cargo.lock new file mode 100644 index 0000000..4600416 --- /dev/null +++ b/tests/test_files/trait_sandbox/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "trait_sandbox" +version = "0.1.0" diff --git a/tests/test_files/trait_sandbox/Cargo.toml b/tests/test_files/trait_sandbox/Cargo.toml new file mode 100644 index 0000000..1f98ccd --- /dev/null +++ b/tests/test_files/trait_sandbox/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "trait_sandbox" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lib] +name = "trait_sandbox" +path = "src/lib.rs" \ No newline at end of file diff --git a/tests/test_files/trait_sandbox/src/a.rs b/tests/test_files/trait_sandbox/src/a.rs new file mode 100644 index 0000000..d982abb --- /dev/null +++ b/tests/test_files/trait_sandbox/src/a.rs @@ -0,0 +1,45 @@ +//! Free functions with various bound placements and (un)usage. + +/// Bound on type param; **unused** in body. +pub fn unused_bound_clone(x: T) -> T { + // NOTE: we never call `x.clone()`, so `Clone` is removable if not required elsewhere. + x +} + +/// Bound on type param; **used** in body. +pub fn used_bound_clone(x: T) -> T { + let _y = x.clone(); // Uses Clone + x +} + +/// `where`-clause bound; **unused** in body. +pub fn where_unused_default(x: T) -> T +where + T: Default, // Default not used +{ + x +} + +/// `where`-clause bound; **used** in body. +pub fn where_used_default(x: Option) -> T +where + T: Default + Clone, // Default used; Clone not required here (left in to test multi-bound pruning) +{ + x.unwrap_or_default() +} + +/// HRTB bound; **used** in body. +pub fn hrtb_used(f: F) -> usize +where + for<'a> F: Fn(&'a str) -> usize, +{ + f("hello") +} + +/// HRTB bound; **unused** in body. +pub fn hrtb_unused() +where + for<'a> F: Fn(&'a str) -> usize, // not used +{ + // no-op +} diff --git a/tests/test_files/trait_sandbox/src/b.rs b/tests/test_files/trait_sandbox/src/b.rs new file mode 100644 index 0000000..f490728 --- /dev/null +++ b/tests/test_files/trait_sandbox/src/b.rs @@ -0,0 +1,32 @@ +//! Impl blocks: bounds on the impl vs. on methods. + +/// Simple wrapper to exercise generic method bounds. +pub struct Wrapper(pub T); + +impl Wrapper { + /// Method-level bound; **used** (copying from `&self` requires `T: Copy`). + pub fn copied(&self) -> T + where + T: Copy, + { + self.0 + } + + /// Method-level `where` bound; **unused** in body. + pub fn id(&self) + where + T: Ord, // not used + { + let _ = &self.0; // no Ord usage + } +} + +/// Impl-level bound; **used** in method body (`T::default()`). +impl Wrapper +where + T: Default, +{ + pub fn new_default() -> Self { + Self(T::default()) + } +} diff --git a/tests/test_files/trait_sandbox/src/c.rs b/tests/test_files/trait_sandbox/src/c.rs new file mode 100644 index 0000000..28c9e67 --- /dev/null +++ b/tests/test_files/trait_sandbox/src/c.rs @@ -0,0 +1,19 @@ +//! Traits with supertraits and `where Self: ...` clauses. + +use crate::traits::{Sub, Super, Thing}; + +/// Function requiring a `Sub` (which implies `Super: Debug`); **used** (via `Debug` formatting). +pub fn uses_super_via_sub(t: &T) -> String { + format!("{:?}", t) +} + +/// Function with **unused** supertrait in signature. +/// We require `Super` but never debug-print; should be removable if not implied elsewhere. +pub fn super_unused(_t: &T) -> usize { + 42 +} + +/// Returns a Thing to help link trait usage across modules. +pub fn make_thing() -> Thing { + Thing { n: 0 } +} diff --git a/tests/test_files/trait_sandbox/src/lib.rs b/tests/test_files/trait_sandbox/src/lib.rs new file mode 100644 index 0000000..2dd7db0 --- /dev/null +++ b/tests/test_files/trait_sandbox/src/lib.rs @@ -0,0 +1,39 @@ +//! Test crate for trait-bound pruning. + +pub mod traits; +pub mod a; +pub mod b; +pub mod c; + +// Re-exports so a single module path works in tests/tools. +pub use a::*; +pub use b::*; +pub use c::*; +pub use traits::*; + +#[cfg(test)] +mod smoke { + use super::*; + + #[test] + fn it_compiles_and_runs() { + // a.rs + let _ = unused_bound_clone(10); // Clone **unused** + let _ = used_bound_clone(String::from("x")); // Clone used + let _ = where_unused_default(7u8); // Default **unused** + let _ = where_used_default(Some(5u32)); // Default used + let _ = hrtb_used(|s: &str| s.len()); // HRTB used + hrtb_unused:: usize>(); // HRTB **unused** + + // b.rs + let w = Wrapper(3u32); + let _ = w.copied(); // Copy used (method-level bound) + let _ = Wrapper::::new_default(); // Default used (impl-level bound) + w.id(); // Ord **unused** (method-level where) + + // c.rs + let t = Thing { n: 1 }; + t.touch(); // `where Self: Sized + Clone` (Sized used; Clone **unused**) + assert!(format!("{:?}", t).len() > 0); // Super: Debug used via Super + } +} diff --git a/tests/test_files/trait_sandbox/src/traits.rs b/tests/test_files/trait_sandbox/src/traits.rs new file mode 100644 index 0000000..2a94eb2 --- /dev/null +++ b/tests/test_files/trait_sandbox/src/traits.rs @@ -0,0 +1,25 @@ +//! Custom traits to exercise different patterns. + +use core::fmt::Debug; + +// Supertrait chain +pub trait Super: Debug {} +pub trait Sub: Super {} + +// A trait with a `where Self: ...` clause. We'll partly use it. +pub trait SelfWhere +where + Self: Sized + Clone, // Sized used (method needs a receiver). Clone intentionally **unused** +{ + fn touch(&self) {} +} + +// A trivial type implementing the above. +#[derive(Clone, Debug)] +pub struct Thing { + pub n: i32, +} + +impl Super for Thing {} +impl Sub for Thing {} +impl SelfWhere for Thing {} diff --git a/tests/trait_sandbox_tests.rs b/tests/trait_sandbox_tests.rs new file mode 100644 index 0000000..b5c276b --- /dev/null +++ b/tests/trait_sandbox_tests.rs @@ -0,0 +1,123 @@ +//! Integration tests for trait-winnower prune functionality. + +use std::fs; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; +use trait_winnower::analysis::ItemBounds; +use trait_winnower::config::Config; +use trait_winnower::discover::Discover; + +/// Helper function to copy directory recursively +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + if src.is_dir() { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + } + Ok(()) +} + +/// Helper function to collect all bounds from a directory +fn collect_bounds_from_dir(dir_path: &Path) -> Result, Box> { + let cfg = Config::load_or_default(dir_path)?; + let files = Discover::discover_rs_files(dir_path, &cfg.include, &cfg.exclude)?; + + let mut all_bounds = Vec::new(); + + for file in files { + let parsed_file = ItemBounds::parse_file(&file)?; + let items = ItemBounds::collect_items_in_file(&parsed_file)?; + + // Collect all items with their bounds + for item in items.iter_all_items() { + let label = item.to_string(); + all_bounds.push(label); + } + } + + all_bounds.sort(); + Ok(all_bounds) +} + +#[test] +fn test_prune_trait_sandbox() -> Result<(), Box> { + // Setup paths + let test_files_dir = Path::new("tests/test_files/trait_sandbox"); + let expected_dir = Path::new("tests/expected/trait_sandbox"); + + // Verify test directories exist + assert!( + test_files_dir.exists(), + "Test files directory does not exist: {:?}", + test_files_dir + ); + assert!( + expected_dir.exists(), + "Expected directory does not exist: {:?}", + expected_dir + ); + + // Create temporary directory + let temp_dir = TempDir::new()?; + let temp_path = temp_dir.path(); + + // Copy test files to temporary directory + copy_dir_recursive(test_files_dir, temp_path)?; + + // Build the trait-winnower binary path + let binary_path = if cfg!(debug_assertions) { + "target/debug/trait-winnower" + } else { + "target/release/trait-winnower" + }; + + // Run trait-winnower prune command + let output = Command::new(binary_path) + .args(&["prune", "-n", "all", "-t", "all", "--brute-force"]) + .arg(temp_path) + .output()?; + + // Check if command succeeded + assert!( + output.status.success(), + "trait-winnower prune failed with status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + // Collect bounds from the pruned directory + let pruned_bounds = collect_bounds_from_dir(temp_path)?; + + // Collect bounds from the expected directory + let expected_bounds = collect_bounds_from_dir(expected_dir)?; + + // Compare bounds + assert_eq!( + pruned_bounds.len(), + expected_bounds.len(), + "Number of bounds differs. Pruned: {}, Expected: {}", + pruned_bounds.len(), + expected_bounds.len() + ); + for (i, (pruned, expected)) in pruned_bounds.iter().zip(expected_bounds.iter()).enumerate() { + assert_eq!( + pruned, expected, + "Bound at index {} differs.\nPruned: {:?}\nExpected: {:?}", + i, pruned, expected + ); + } + + println!("All bounds and file contents match expected results!"); + Ok(()) +} From 766b2d9cad20b72a28b8d925f70998628a1f5912 Mon Sep 17 00:00:00 2001 From: Khagan Karimov Date: Mon, 13 Oct 2025 14:39:41 -0600 Subject: [PATCH 2/3] Update test to avoid No File Error --- tests/trait_sandbox_tests.rs | 44 ++++++++++++++---------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/tests/trait_sandbox_tests.rs b/tests/trait_sandbox_tests.rs index b5c276b..c3337cb 100644 --- a/tests/trait_sandbox_tests.rs +++ b/tests/trait_sandbox_tests.rs @@ -40,8 +40,7 @@ fn collect_bounds_from_dir(dir_path: &Path) -> Result, Box Result<(), Box> { let test_files_dir = Path::new("tests/test_files/trait_sandbox"); let expected_dir = Path::new("tests/expected/trait_sandbox"); - // Verify test directories exist - assert!( - test_files_dir.exists(), - "Test files directory does not exist: {:?}", - test_files_dir - ); - assert!( - expected_dir.exists(), - "Expected directory does not exist: {:?}", - expected_dir - ); + assert!(test_files_dir.exists(), "Missing {:?}", test_files_dir); + assert!(expected_dir.exists(), "Missing {:?}", expected_dir); - // Create temporary directory + // Temporary working directory let temp_dir = TempDir::new()?; let temp_path = temp_dir.path(); - - // Copy test files to temporary directory copy_dir_recursive(test_files_dir, temp_path)?; - // Build the trait-winnower binary path + // Ensure the binary exists + Command::new("cargo") + .args(&["build", "--bin", "trait-winnower"]) + .status() + .expect("Failed to build trait-winnower binary before running test"); + let binary_path = if cfg!(debug_assertions) { "target/debug/trait-winnower" } else { "target/release/trait-winnower" }; - // Run trait-winnower prune command + // Run the prune command let output = Command::new(binary_path) .args(&["prune", "-n", "all", "-t", "all", "--brute-force"]) .arg(temp_path) .output()?; - // Check if command succeeded assert!( output.status.success(), - "trait-winnower prune failed with status: {}\nstdout: {}\nstderr: {}", + "trait-winnower prune failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + String::from_utf8_lossy(&output.stderr), ); - // Collect bounds from the pruned directory + // Compare the collected bounds let pruned_bounds = collect_bounds_from_dir(temp_path)?; - - // Collect bounds from the expected directory let expected_bounds = collect_bounds_from_dir(expected_dir)?; - // Compare bounds assert_eq!( pruned_bounds.len(), expected_bounds.len(), @@ -110,6 +99,7 @@ fn test_prune_trait_sandbox() -> Result<(), Box> { pruned_bounds.len(), expected_bounds.len() ); + for (i, (pruned, expected)) in pruned_bounds.iter().zip(expected_bounds.iter()).enumerate() { assert_eq!( pruned, expected, @@ -118,6 +108,6 @@ fn test_prune_trait_sandbox() -> Result<(), Box> { ); } - println!("All bounds and file contents match expected results!"); + println!("[+] All bounds and file contents match expected results!"); Ok(()) -} +} \ No newline at end of file From 7b59e82985594e45de92b0426efd3ef169cc7441 Mon Sep 17 00:00:00 2001 From: Khagan Karimov Date: Mon, 13 Oct 2025 14:41:10 -0600 Subject: [PATCH 3/3] Cargo fmt --- tests/trait_sandbox_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/trait_sandbox_tests.rs b/tests/trait_sandbox_tests.rs index c3337cb..70b070e 100644 --- a/tests/trait_sandbox_tests.rs +++ b/tests/trait_sandbox_tests.rs @@ -110,4 +110,4 @@ fn test_prune_trait_sandbox() -> Result<(), Box> { println!("[+] All bounds and file contents match expected results!"); Ok(()) -} \ No newline at end of file +}