diff --git a/Cargo.lock b/Cargo.lock index da40e5b..75b25c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -173,6 +173,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -223,7 +232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -402,6 +411,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -465,7 +484,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -543,7 +562,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -599,9 +618,15 @@ dependencies = [ "assert_cmd", "assert_fs", "clap", + "colored", + "globset", "ignore", "predicates", + "prettyplease", + "proc-macro2", + "quote", "serde", + "syn", "toml", ] @@ -660,7 +685,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -669,13 +694,38 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -685,58 +735,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.0" diff --git a/Cargo.toml b/Cargo.toml index 556d987..c1cb8d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,12 @@ anyhow = "1.0.99" 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"] } +quote = "1" +colored = "3.0.0" +proc-macro2 = { version = "1.0.101", features = ["span-locations"] } +prettyplease = "0.2.37" [dev-dependencies] assert_cmd = "2.0.17" @@ -17,4 +23,4 @@ assert_fs = "1.1.3" [[bin]] name = "trait-winnower" -path = "src/bin/trait-winnower.rs" \ No newline at end of file +path = "src/bin/trait-winnower.rs" diff --git a/src/analysis.rs b/src/analysis.rs new file mode 100644 index 0000000..c218e08 --- /dev/null +++ b/src/analysis.rs @@ -0,0 +1,577 @@ +// src/analysis.rs +//! Analysis of Rust code. + +#![deny(missing_docs)] + +use crate::error::TraitError; +use syn::{ + Ident, ImplItemFn, Item, ItemEnum, ItemFn, ItemImpl, ItemStruct, ItemTrait, Path as SynPath, + TraitItemFn, Type, TypeParamBound, punctuated::Punctuated, token::Plus, visit::Visit, +}; + +/// Reference to a Rust item in the AST. +pub enum ItemRef<'ast> { + /// A free-standing function. + Func(&'ast ItemFn), + /// A struct definition. + Struct(&'ast ItemStruct), + /// An enum definition. + Enum(&'ast ItemEnum), + /// A trait definition. + Trait(&'ast ItemTrait), + /// An impl block. + Impl(&'ast ItemImpl), + /// A method in an impl block (inherent or trait impl). + ImplMethod { + /// The type being implemented for. + self_ty: &'ast Type, + /// The trait path, if this is a trait impl. + trait_path: Option<&'ast SynPath>, + /// The method itself. + method: &'ast ImplItemFn, + }, + /// A method in a trait definition. + TraitMethod { + /// The trait's identifier. + trait_ident: &'ast Ident, + /// The method itself. + method: &'ast TraitItemFn, + }, +} + +/// 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, +} + +/// Generate label-formatting helpers on `ItemKey`. +/// Each arm is `$fn_name: (args...) => "fmt";` and returns `String`. +macro_rules! define_item_labels { + ( $( $fn_name:ident ( $($arg:ident),* ) => $fmt:expr ; )* ) => { + impl<'ast> ItemKey<'ast> { + $( + #[allow(missing_docs, reason = "macro-generated code")] + #[inline] + pub fn $fn_name ( $( $arg: &str ),* ) -> String { + format!($fmt, $( $arg ),*) + } + )* + } + }; +} + +define_item_labels! { + fn_label (name) => "// fn {}"; + struct_label (name) => "// struct {}"; + enum_label (name) => "// enum {}"; + trait_label (name) => "// trait {}"; + impl_inherent_label (self_ty) => "// impl {}"; + impl_trait_label (trait_path, self_ty) => "// impl {} for {}"; + impl_method_label (owner, method) => "// {}::{}"; + trait_method_label (trait_name, method) => "// trait {}::{}"; +} + +impl<'ast> std::fmt::Display for ItemKey<'ast> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.label) + } +} + +macro_rules! define_bounds_types { + ( $( $name:ident ),+ $(,)? ) => { + $( + #[allow(missing_docs, reason = "macro-generated code")] + struct $name<'ast> { + item: ItemKey<'ast>, + _type_params: Vec, + _where_preds: Vec, + } + )+ + }; +} + +define_bounds_types! { + FnBounds, + TraitMethodBounds, + ImplMethodBounds, + TraitBounds, + ImplBounds, + EnumBounds, + StructBounds, +} + +/// A collection of items found in a file. +pub struct ItemBounds<'ast> { + fns: Vec>, + traits: Vec>, + impls: Vec>, + trait_methods: Vec>, + impl_methods: Vec>, + enums: Vec>, + structs: Vec>, +} + +impl<'ast> ItemBounds<'ast> { + /// Parse a file from disk. + pub fn parse_file(path: &std::path::Path) -> TraitError { + let src = std::fs::read_to_string(path)?; + Ok(syn::parse_file(&src)?) + } + + /// Main entry: parse a file from disk and collect items. + pub fn collect_items_in_file(file: &'ast syn::File) -> TraitError> { + Self::collect_items_from_src(file) + } + + /// Iterate over all items. + pub fn iter_all_items(&self) -> impl Iterator> { + self.fns + .iter() + .map(|f| &f.item) + .chain(self.traits.iter().map(|t| &t.item)) + .chain(self.impls.iter().map(|i| &i.item)) + .chain(self.trait_methods.iter().map(|t| &t.item)) + .chain(self.impl_methods.iter().map(|i| &i.item)) + .chain(self.enums.iter().map(|e| &e.item)) + .chain(self.structs.iter().map(|s| &s.item)) + } + + fn collect_items_from_src(file: &'ast syn::File) -> TraitError> { + let mut v = Collector { + out: ItemBounds::empty(), + }; + v.visit_file(file); + Ok(v.out) + } + + fn empty() -> Self { + Self { + fns: Vec::new(), + traits: Vec::new(), + impls: Vec::new(), + trait_methods: Vec::new(), + impl_methods: Vec::new(), + enums: Vec::new(), + structs: Vec::new(), + } + } +} + +struct Collector<'ast> { + out: ItemBounds<'ast>, +} + +struct TypeParamBounds { + _ident: Ident, + _bounds: Punctuated, +} + +struct WhereTypeBounds { + _ty: Type, + _bounds: Punctuated, +} + +impl<'ast> Collector<'ast> { + fn type_param_bounds(&self, gens: &syn::Generics) -> Vec { + use syn::{GenericParam, TypeParam}; + gens.params + .iter() + .filter_map(|p| match p { + GenericParam::Type(TypeParam { ident, bounds, .. }) if !bounds.is_empty() => { + Some(TypeParamBounds { + _ident: ident.clone(), + _bounds: bounds.clone(), + }) + } + _ => None, + }) + .collect() + } + + 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() { + if let syn::WherePredicate::Type(t) = pred + && !t.bounds.is_empty() + { + out.push(WhereTypeBounds { + _ty: t.bounded_ty.clone(), + _bounds: t.bounds.clone(), + }); + } + } + } + out + } + + fn push_if_any(&mut self, gens: &syn::Generics, mut push: F) + where + F: FnMut(&mut Self, Vec, Vec), + { + let tp = self.type_param_bounds(gens); + let wb = self.where_bounds(gens); + if !tp.is_empty() || !wb.is_empty() { + push(self, tp, wb); + } + } +} + +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()); + self.push_if_any(&f.sig.generics, |this, tp, wb| { + this.out.fns.push(FnBounds { + item: ItemKey { + item: ItemRef::Func(f), + label: label.clone(), + }, + _type_params: tp, + _where_preds: wb, + }); + }); + } + + Item::Struct(s) => { + let label = ItemKey::struct_label(&s.ident.to_string()); + self.push_if_any(&s.generics, |this, tp, wb| { + this.out.structs.push(StructBounds { + item: ItemKey { + item: ItemRef::Struct(s), + label: label.clone(), + }, + _type_params: tp, + _where_preds: wb, + }); + }); + } + + Item::Enum(e) => { + let label = ItemKey::enum_label(&e.ident.to_string()); + self.push_if_any(&e.generics, |this, tp, wb| { + this.out.enums.push(EnumBounds { + item: ItemKey { + item: ItemRef::Enum(e), + label: label.clone(), + }, + _type_params: tp, + _where_preds: wb, + }); + }); + } + + Item::Trait(t) => { + let label = ItemKey::trait_label(&t.ident.to_string()); + self.push_if_any(&t.generics, |this, tp, wb| { + this.out.traits.push(TraitBounds { + item: ItemKey { + item: ItemRef::Trait(t), + label: label.clone(), + }, + _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(), + ); + self.push_if_any(&m.sig.generics, |this, tp, wb| { + this.out.trait_methods.push(TraitMethodBounds { + item: ItemKey { + item: ItemRef::TraitMethod { + trait_ident: &t.ident, + method: m, + }, + label: mlabel.clone(), + }, + _type_params: tp, + _where_preds: wb, + }); + }); + } + } + } + + Item::Impl(im) => { + use quote::ToTokens; + let trait_path_ref: Option<&'ast syn::Path> = im.trait_.as_ref().map(|(_, p, _)| p); + let self_ty_str = im.self_ty.to_token_stream().to_string(); + let impl_label = if let Some(tp) = trait_path_ref { + ItemKey::impl_trait_label(&tp.to_token_stream().to_string(), &self_ty_str) + } else { + 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(), + }, + _type_params: tp, + _where_preds: wb, + }); + }); + + // Impl methods (method generics are on the signature) + for ii in &im.items { + if let syn::ImplItem::Fn(m) = ii { + let owner = trait_path_ref + .map(|tp| format!("{} for {}", tp.to_token_stream(), self_ty_str)) + .unwrap_or_else(|| self_ty_str.clone()); + let mlabel = ItemKey::impl_method_label(&owner, &m.sig.ident.to_string()); + + self.push_if_any(&m.sig.generics, |this, tp, wb| { + this.out.impl_methods.push(ImplMethodBounds { + item: ItemKey { + item: ItemRef::ImplMethod { + self_ty: &im.self_ty, + trait_path: trait_path_ref, + method: m, + }, + label: mlabel.clone(), + }, + _type_params: tp, + _where_preds: wb, + }); + }); + } + } + } + + _ => {} + } + // continue into nested modules, etc. + syn::visit::visit_item(self, i); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt; + + enum Label<'a> { + Eq(&'a str), + StartsWith(&'a str), + Contains(&'a str), + } + + impl<'a> fmt::Display for Label<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Label::Eq(s) => write!(f, "== {:?}", s), + Label::StartsWith(s) => write!(f, "starts_with {:?}", s), + Label::Contains(s) => write!(f, "contains {:?}", s), + } + } + } + + /// Helper: collect all item labels from a source string. + fn labels_from_src(src: &str) -> TraitError> { + let file = syn::parse_file(src)?; + let items = ItemBounds::collect_items_in_file(&file)?; + Ok(items.iter_all_items().map(|i| i.label.clone()).collect()) + } + + fn assert_has(labels: &[String], expected: &[Label<'_>]) { + for want in expected { + let ok = match want { + Label::Eq(s) => labels.iter().any(|l| l == s), + Label::StartsWith(s) => labels.iter().any(|l| l.starts_with(s)), + Label::Contains(s) => labels.iter().any(|l| l.contains(s)), + }; + assert!(ok, "missing label that {}", want); + } + } + + fn assert_none(labels: &[String]) { + assert!( + labels.is_empty(), + "expected no interesting items, but got: {:?}", + labels + ); + } + + #[test] + fn item_bounds_fn() -> TraitError<()> { + let src = r#" + fn foo() where T: Clone { + let x: i32 = 1; + } + "#; + let labels = labels_from_src(src)?; + assert_eq!(labels.len(), 1); + assert_has(&labels, &[Label::Eq("// fn foo")]); + Ok(()) + } + + #[test] + fn item_bounds_fn_no_bounds() -> TraitError<()> { + let src = r#" + fn bar() {} + "#; + let labels = labels_from_src(src)?; + assert_none(&labels); + Ok(()) + } + + #[test] + fn item_bounds_struct() -> TraitError<()> { + let src = r#" + struct Bar where T: Clone { + a: T, + } + "#; + let labels = labels_from_src(src)?; + assert_eq!(labels.len(), 1); + assert_has(&labels, &[Label::Eq("// struct Bar")]); + Ok(()) + } + + #[test] + fn item_bounds_struct_no_bounds() -> TraitError<()> { + let src = r#" + struct Baz { + a: i32, + } + "#; + let labels = labels_from_src(src)?; + assert_none(&labels); + Ok(()) + } + + #[test] + fn item_bounds_enum() -> TraitError<()> { + let src = r#" + enum Baz where T: Clone { + A(T), + B, + } + "#; + let labels = labels_from_src(src)?; + assert_eq!(labels.len(), 1); + assert_has(&labels, &[Label::Eq("// enum Baz")]); + Ok(()) + } + + #[test] + fn item_bounds_enum_no_bounds() -> TraitError<()> { + let src = r#" + enum Qux { + A, + B, + } + "#; + let labels = labels_from_src(src)?; + assert_none(&labels); + Ok(()) + } + + #[test] + fn item_bounds_trait_and_methods() -> TraitError<()> { + let src = r#" + trait Qux where T: Clone { + fn a(&self) where T: Default; + fn b(&self) -> i32; + } + "#; + let labels = labels_from_src(src)?; + assert_has( + &labels, + &[Label::Eq("// trait Qux"), Label::Eq("// trait Qux::a")], + ); + // Should not collect trait method b (no bounds) + assert!(!labels.iter().any(|l| l == "// trait Qux::b")); + Ok(()) + } + + #[test] + fn item_bounds_trait_and_methods_no_bounds() -> TraitError<()> { + let src = r#" + trait Empty { + fn a(&self); + fn b(&self) -> i32; + } + "#; + let labels = labels_from_src(src)?; + assert_none(&labels); + Ok(()) + } + + #[test] + fn item_bounds_impl_trait_and_methods() -> TraitError<()> { + let src = r#" + trait T { fn m(&self); } + struct S; + impl T for S where T: Clone { + fn m(&self) where T: Default {} + fn n(&self) {} + } + "#; + let labels = labels_from_src(src)?; + assert_has( + &labels, + &[Label::StartsWith("// impl T for S"), Label::Contains("::m")], + ); + assert!(!labels.iter().any(|l| l.contains("::n"))); + Ok(()) + } + + #[test] + fn item_bounds_impl_trait_and_methods_no_bounds() -> TraitError<()> { + let src = r#" + trait T { fn m(&self); } + struct S; + impl T for S { + fn m(&self) {} + } + "#; + let labels = labels_from_src(src)?; + assert_none(&labels); + Ok(()) + } + + #[test] + fn item_bounds_impl_inherent_and_methods() -> TraitError<()> { + let src = r#" + struct S; + impl S where T: Clone { + fn foo(&self) where T: Default {} + fn bar(&self) {} + } + "#; + let labels = labels_from_src(src)?; + assert_has( + &labels, + &[Label::StartsWith("// impl S"), Label::Contains("::foo")], + ); + assert!(!labels.iter().any(|l| l.contains("::bar"))); + Ok(()) + } + + #[test] + fn item_bounds_impl_inherent_and_methods_no_bounds() -> TraitError<()> { + let src = r#" + struct S; + impl S { + fn foo(&self) {} + fn bar(&self) {} + } + "#; + let labels = labels_from_src(src)?; + assert_none(&labels); + Ok(()) + } +} diff --git a/src/bin/trait-winnower.rs b/src/bin/trait-winnower.rs index 8c7e3e4..2d5422b 100644 --- a/src/bin/trait-winnower.rs +++ b/src/bin/trait-winnower.rs @@ -4,28 +4,30 @@ #![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::error::TraitError; +use trait_winnower::info::TraitInfo; use trait_winnower::target::TargetKind; fn main() -> TraitError<()> { let args = cli::Cli::parse(); + let verbosity = args.verbose; match args.command { // init: initializes project config (e.g., default path); cli::Commands::Init { path, force } => { - // Default to current directory if not provided. let mut root: PathBuf = path.unwrap_or_else(|| PathBuf::from(".")); - - if root.is_file() { - if let Some(parent) = root.parent() { - root = parent.to_path_buf(); - } + if root.is_file() + && let Some(parent) = root.parent() + { + root = parent.to_path_buf(); } - let path_written = Config::write_default_config_at(root.as_path(), force)?; println!( "{} .trait-winnower.toml at {}", @@ -33,19 +35,73 @@ fn main() -> TraitError<()> { path_written.display() ); } - // prune: prunes undue/overly-strong trait bounds while preserving correctness. cli::Commands::Prune { target } => { - let _kind = TargetKind::get_target(target)?; - // todo!(); + let kind = TargetKind::get_target(target)?; + match &kind { + TargetKind::SingleFile(p) => { + println!("(dry-run) would modify 1 file:\n {}", p.display()) + } + TargetKind::Crate(root) | TargetKind::Workspace(root) => { + let cfg = Config::default(); + let files = Discover::discover_rs_files(root, &cfg.include, &cfg.exclude)?; + println!("(dry-run) would modify {} files", files.len()); + } + } } + // check: per-file items at -vv (capped by --top), global top-traits summary always. + cli::Commands::Check { target, top } => { + 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); + if verbosity > 2 { + TraitInfo::debug_print_itemref(&item.item); + } + } + } + } + TargetKind::Crate(root) | TargetKind::Workspace(root) => { + 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)?; + let items = ItemBounds::collect_items_in_file(&file)?; - // check: scans and warns about likely unnecessary trait bounds (no edits). - cli::Commands::Check { target } => { - let _kind = TargetKind::get_target(target)?; - // todo!(); + 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); + if verbosity > 2 { + TraitInfo::debug_print_itemref(&item.item); + } + } + println!(); + } + } + } + } } } - Ok(()) } diff --git a/src/cli.rs b/src/cli.rs index b651b19..9cfeb86 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,3 @@ -// src/cli.rs //! CLI argument parser for trait-winnower. #![deny(missing_docs)] @@ -15,11 +14,18 @@ use std::path::PathBuf; disable_help_subcommand = true )] pub struct Cli { - /// Increase verbosity (-v, -vv). - #[arg(short, long, action = clap::ArgAction::Count, global = true)] + /// Set verbosity level: -v=1, -v=2, -v=3 + #[arg( + short = 'v', + long = "verbose", + value_name = "LEVEL", + default_value_t = 0, + value_parser = clap::value_parser!(u8).range(0..=3), + global = true + )] pub verbose: u8, - /// Silence all output. + /// Silence all output (overrides -v). #[arg(short, long, global = true)] pub quiet: bool, @@ -52,5 +58,9 @@ 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 4954f2c..101fa50 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,29 @@ impl Default for Config { } impl Config { + /// Load `.trait-winnower.toml` from `dir` (or its parent if `dir` is a file). + /// If missing, return defaults. Ensures `include/exclude` are never empty. + pub fn load_or_default(dir: &Path) -> TraitError { + let base = if dir.is_file() { + dir.parent().unwrap_or(dir) + } else { + dir + }; + let file = base.join(".trait-winnower.toml"); + if file.exists() { + let s = fs::read_to_string(&file)?; + let mut cfg: Config = toml::from_str(&s)?; + if cfg.include.is_empty() { + cfg.include = Config::default().include; + } + if cfg.exclude.is_empty() { + cfg.exclude = Config::default().exclude; + } + Ok(cfg) + } else { + Ok(Config::default()) + } + } /// Write default configs to .trait-winnower.toml pub fn write_default_config_at(dir: &Path, force: bool) -> TraitError { let base = if dir.is_file() { diff --git a/src/discover.rs b/src/discover.rs index fbcd1e2..720b85d 100644 --- a/src/discover.rs +++ b/src/discover.rs @@ -1,50 +1,74 @@ -// src/error.rs -//! Source targets for trait-winnower. +// src/discover.rs +//! Discover files for analysis. #![deny(missing_docs)] use crate::error::TraitError; +use globset::{Glob, GlobSet, GlobSetBuilder}; use ignore::WalkBuilder; use std::path::{Path, PathBuf}; -/// Discover struct to keep -pub struct Discover(); +/// File discovery utilities. +pub struct Discover; impl Discover { - /// Find the files to operate on. - pub fn discover_rs_files(root: &Path) -> TraitError> { - let mut paths = Vec::new(); - let mut builder = WalkBuilder::new(root); + /// Find `.rs` files under `root`, applying `include` then subtracting `exclude` (exclude wins). + /// Glob matching uses root-relative paths; returned file paths are absolute. + pub fn discover_rs_files( + root: &Path, + include: &[String], + exclude: &[String], + ) -> TraitError> { + let inc = if include.is_empty() { + vec!["**/*".into()] + } else { + include.to_vec() + }; + let inc_set = Self::globset(&inc)?; + let exc_set = Self::globset(exclude)?; - builder - .hidden(false) + let mut walk = WalkBuilder::new(root); + walk.hidden(false) .ignore(true) .git_ignore(true) .git_exclude(true) .git_global(true) - .follow_links(false) - .max_depth(None); + .follow_links(false); - builder.add_ignore(".git"); - builder.add_ignore("target"); - builder.add_ignore("node_modules"); - builder.add_ignore("tests"); - - for res in builder.build() { - let dent = match res { + let mut out = Vec::new(); + for dent in walk.build() { + let dent = match dent { Ok(d) => d, Err(_) => continue, }; - if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) { continue; } + if dent.path().extension().and_then(|s| s.to_str()) != Some("rs") { + continue; + } + + let path = dent.path(); + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_str = rel.to_string_lossy().replace('\\', "/"); - if dent.path().extension().and_then(|s| s.to_str()) == Some("rs") { - paths.push(dent.into_path()); + if !inc_set.is_match(&rel_str) { + continue; } + if exc_set.is_match(&rel_str) { + continue; + } + + out.push(path.to_path_buf()); } + Ok(out) + } - Ok(paths) + fn globset(patterns: &[String]) -> TraitError { + let mut b = GlobSetBuilder::new(); + for p in patterns { + b.add(Glob::new(p)?); + } + Ok(b.build()?) } } diff --git a/src/info.rs b/src/info.rs new file mode 100644 index 0000000..97a299d --- /dev/null +++ b/src/info.rs @@ -0,0 +1,73 @@ +// src/print.rs +//! Print trait bounds. + +#![deny(missing_docs)] + +use crate::analysis::ItemKey; +use crate::analysis::ItemRef; +use quote::ToTokens; +use syn::File; +use syn::Item; + +/// Print trait bounds. +pub struct TraitInfo(); + +impl TraitInfo { + /// Print a single item. + pub fn show_item(it: &ItemKey) { + print!("{}", it); + println!(); + } + + /// Debug utility: print an `ItemRef` AST to stdout, nicely formatted. + pub fn debug_print_itemref(item: &ItemRef) { + match item { + ItemRef::Func(f) => { + let file = File { + shebang: None, + attrs: vec![], + items: vec![Item::Fn((**f).clone())], + }; + println!("{}", prettyplease::unparse(&file)); + } + ItemRef::Struct(s) => { + let file = File { + shebang: None, + attrs: vec![], + items: vec![Item::Struct((**s).clone())], + }; + println!("{}", prettyplease::unparse(&file)); + } + ItemRef::Enum(e) => { + let file = File { + shebang: None, + attrs: vec![], + items: vec![Item::Enum((**e).clone())], + }; + println!("{}", prettyplease::unparse(&file)); + } + ItemRef::Trait(t) => { + let file = File { + shebang: None, + attrs: vec![], + items: vec![Item::Trait((**t).clone())], + }; + println!("{}", prettyplease::unparse(&file)); + } + ItemRef::Impl(i) => { + let file = File { + shebang: None, + attrs: vec![], + items: vec![Item::Impl((**i).clone())], + }; + println!("{}", prettyplease::unparse(&file)); + } + ItemRef::ImplMethod { method, .. } => { + println!("{}", method.to_token_stream()); + } + ItemRef::TraitMethod { method, .. } => { + println!("{}", method.to_token_stream()); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index b8d9d1c..d0fd145 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,10 @@ #![deny(missing_docs)] +pub mod analysis; pub mod cli; pub mod config; pub mod discover; pub mod error; +pub mod info; pub mod target; diff --git a/src/target.rs b/src/target.rs index 77f488a..f06044d 100644 --- a/src/target.rs +++ b/src/target.rs @@ -1,4 +1,4 @@ -// src/error.rs +// src/target.rs //! Source targets for trait-winnower. #![deny(missing_docs)]