diff --git a/Cargo.lock b/Cargo.lock index 142801c9d..96368f943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1416,15 +1416,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -2227,18 +2218,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime", - "toml_parser", - "winnow 1.0.4", -] - [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -2383,7 +2362,6 @@ dependencies = [ name = "usage-derive" version = "5.1.0" dependencies = [ - "proc-macro-crate", "proc-macro2", "quote", "syn 3.0.3", @@ -2817,9 +2795,6 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen" diff --git a/derive/Cargo.toml b/derive/Cargo.toml index cad5f848f..f8939a151 100644 --- a/derive/Cargo.toml +++ b/derive/Cargo.toml @@ -19,11 +19,13 @@ release = true [dependencies] proc-macro2 = "1" -proc-macro-crate = "3" quote = "1" # syn 2 rather than 3, which is already in the tree via clap_derive: nothing here # needs the newer API, and matching what is there avoids a second copy. syn = { version = "3", features = ["full"] } +# No `proc-macro-crate`: it pulls `toml_edit` (and indexmap/winnow/…) into every +# adopter's compile just to read a Cargo.toml rename. The derive reads the few +# dependency forms usage documents itself — see `crate_name.rs`. # No dependency on usage-argv, not even for tests. This crate emits tokens and # links nothing, and dev-depending on the runtime it emits code for creates a cycle diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index e8d17e646..50c87a310 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -13,9 +13,9 @@ //! do not collide with anything, and so `cargo expand` shows them together. use proc_macro2::TokenStream; -use proc_macro_crate::{crate_name, FoundCrate}; use quote::{format_ident, quote}; +use crate::crate_name::{crate_name, FoundCrate}; use crate::model::{ rendered_path, Cli, ConditionalDefault, DoubleDash, Field, Kind, Shape, Subcommands, ValueEnum, }; @@ -26,16 +26,19 @@ use crate::model::{ /// deliberately enable a different feature set there. Otherwise the `usage-rs` facade provides /// the runtime as `usage::argv`, keeping derives, tables, and their versions behind one /// dependency. +/// +/// Resolved by reading the adopter's `Cargo.toml` directly rather than via `proc-macro-crate`, +/// so the derive does not drag `toml_edit` into every compile. fn runtime_path() -> TokenStream { match crate_name("usage-argv") { Ok(FoundCrate::Name(name)) => { - let runtime = format_ident!("{}", name.replace('-', "_")); + let runtime = format_ident!("{name}"); quote!(::#runtime) } _ => match crate_name("usage-rs") { Ok(FoundCrate::Itself) => quote!(::usage_rs::argv), Ok(FoundCrate::Name(name)) => { - let facade = format_ident!("{}", name.replace('-', "_")); + let facade = format_ident!("{name}"); quote!(::#facade::argv) } // Preserve the old useful compiler error when neither dependency was declared. @@ -52,13 +55,13 @@ fn runtime_path() -> TokenStream { fn derive_path() -> TokenStream { match crate_name("usage-derive") { Ok(FoundCrate::Name(name)) => { - let derive = format_ident!("{}", name.replace('-', "_")); + let derive = format_ident!("{name}"); quote!(::#derive) } _ => match crate_name("usage-rs") { Ok(FoundCrate::Itself) => quote!(::usage_rs), Ok(FoundCrate::Name(name)) => { - let facade = format_ident!("{}", name.replace('-', "_")); + let facade = format_ident!("{name}"); quote!(::#facade) } _ => quote!(::usage_derive), diff --git a/derive/src/crate_name.rs b/derive/src/crate_name.rs new file mode 100644 index 000000000..93674308a --- /dev/null +++ b/derive/src/crate_name.rs @@ -0,0 +1,600 @@ +//! Resolve how the adopter depended on usage's runtime and derive crates. +//! +//! Replaces `proc-macro-crate` so the derive does not pull `toml_edit` and friends into +//! every adopter's compile. Only the dependency forms usage actually documents are +//! recognised: a bare key, a `{ package = "…", … }` rename, a multi-line `{ … }` table, +//! a `[dependencies.foo]` header, and workspace inheritance. That covers the facade alias, +//! direct `usage-argv` / `usage-derive`, and the external fixtures. + +use std::fs; +use std::path::PathBuf; + +/// How the searched package appears in the adopter's crate graph. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FoundCrate { + /// The adopter *is* that package (e.g. a derive inside `usage-rs` itself). + Itself, + /// The package is a dependency under this rustc crate name (hyphens already folded). + Name(String), +} + +/// Look up `package` in the crate currently being compiled. +pub fn crate_name(package: &str) -> Result { + let dir = std::env::var_os("CARGO_MANIFEST_DIR").ok_or(())?; + let dir = PathBuf::from(dir); + let manifest = dir.join("Cargo.toml"); + let text = fs::read_to_string(manifest).map_err(|_| ())?; + if let Ok(found) = find_in_manifest(&text, package) { + return Ok(found); + } + + let inherited = workspace_dependency_keys(&text); + if inherited.is_empty() { + return Err(()); + } + for ancestor in dir.ancestors() { + let Ok(workspace) = fs::read_to_string(ancestor.join("Cargo.toml")) else { + continue; + }; + for key in &inherited { + if workspace_dependency_resolves(&workspace, key, package) { + return Ok(FoundCrate::Name(key.replace('-', "_"))); + } + } + } + Err(()) +} + +fn find_in_manifest(text: &str, package: &str) -> Result { + let pkg_name = package_name(text).ok_or(())?; + if pkg_name == package { + return Ok(FoundCrate::Itself); + } + + let mut in_dependencies = false; + // Open multi-line dependency table: key is the rustc rename, package field may follow. + let mut open: Option = None; + + for raw in text.lines() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + + if let Some(header) = section_header(line) { + if let Some(found) = finish_open(&mut open, package) { + return Ok(found); + } + if let Some(key) = dependency_table_key(header) { + // `[dependencies.usage-argv]` or `[dependencies.usage]` — body may set package. + in_dependencies = true; + open = Some(OpenTable { + key: key.to_string(), + package: None, + }); + continue; + } + in_dependencies = is_dependencies_section(header); + continue; + } + + if !in_dependencies { + continue; + } + + if open.is_some() { + // The body of a `{ … }` dependency, possibly ending on this line. Read a `package` + // field from the part before any `}`, then close the table when the `}` arrives. + let (fields, closed) = match line.split_once('}') { + Some((before, _)) => (before, true), + None => (line, false), + }; + if let Some(pkg) = package_from_fields(fields) { + if let Some(t) = open.as_mut() { + t.package = Some(pkg); + } + } + if closed { + if let Some(found) = finish_open(&mut open, package) { + return Ok(found); + } + } + continue; + } + + if let Some((key, value)) = inline_dependency(line) { + if let Some(found) = match_dep(&key, value.as_deref(), package) { + return Ok(found); + } + continue; + } + + if let Some((key, package)) = multiline_table_start(line) { + open = Some(OpenTable { key, package }); + } + } + + finish_open(&mut open, package).ok_or(()) +} + +struct OpenTable { + key: String, + package: Option, +} + +fn finish_open(open: &mut Option, wanted: &str) -> Option { + let table = open.take()?; + match_dep(&table.key, table.package.as_deref(), wanted) +} + +fn match_dep(key: &str, package_field: Option<&str>, wanted: &str) -> Option { + let resolved = package_field.unwrap_or(key); + if resolved != wanted { + return None; + } + Some(FoundCrate::Name(key.replace('-', "_"))) +} + +fn package_name(text: &str) -> Option { + let mut in_package = false; + for raw in text.lines() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if let Some(header) = section_header(line) { + in_package = header == "package"; + continue; + } + if in_package { + if let Some(name) = string_assignment(line, "name") { + return Some(name); + } + } + } + None +} + +fn section_header(line: &str) -> Option<&str> { + let line = line.trim(); + if !line.starts_with('[') || !line.ends_with(']') { + return None; + } + Some(line[1..line.len() - 1].trim()) +} + +fn is_dependencies_section(header: &str) -> bool { + matches!( + header, + "dependencies" | "dev-dependencies" | "build-dependencies" + ) || header.starts_with("target.") + && (header.ends_with(".dependencies") + || header.ends_with(".dev-dependencies") + || header.ends_with(".build-dependencies")) +} + +fn dependency_table_key(header: &str) -> Option<&str> { + for prefix in ["dependencies.", "dev-dependencies.", "build-dependencies."] { + if let Some(key) = header.strip_prefix(prefix) { + if is_ident_key(key) { + return Some(key); + } + } + } + None +} + +fn workspace_dependency_table_key(header: &str) -> Option<&str> { + header + .strip_prefix("workspace.dependencies.") + .filter(|key| is_ident_key(key)) +} + +fn workspace_dependency_keys(text: &str) -> Vec { + let mut keys = Vec::new(); + let mut in_dependencies = false; + let mut table: Option<(String, bool)> = None; + + for raw in text.lines() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if let Some(header) = section_header(line) { + if let Some((key, true)) = table.take() { + keys.push(key); + } + if let Some(key) = dependency_table_key(header) { + in_dependencies = true; + table = Some((key.to_string(), false)); + } else { + in_dependencies = is_dependencies_section(header); + } + continue; + } + if !in_dependencies { + continue; + } + if let Some((_, inherited)) = table.as_mut() { + if bool_assignment(line, "workspace") == Some(true) { + *inherited = true; + } + continue; + } + let Some((key, value)) = split_assignment(line) else { + continue; + }; + if let Some(key) = key.strip_suffix(".workspace") { + if is_ident_key(key) && parse_bool(value) == Some(true) { + keys.push(key.to_string()); + } + } else if is_ident_key(key) && inline_table_bool(value, "workspace") == Some(true) { + keys.push(key.to_string()); + } + } + if let Some((key, true)) = table { + keys.push(key); + } + keys +} + +fn workspace_dependency_resolves(text: &str, key: &str, package: &str) -> bool { + let mut in_dependencies = false; + let mut open: Option = None; + + for raw in text.lines() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if let Some(header) = section_header(line) { + if let Some(table) = open.take() { + if table.key == key + && match_dep(&table.key, table.package.as_deref(), package).is_some() + { + return true; + } + } + if let Some(table_key) = workspace_dependency_table_key(header) { + in_dependencies = true; + open = Some(OpenTable { + key: table_key.to_string(), + package: None, + }); + } else { + in_dependencies = header == "workspace.dependencies"; + } + continue; + } + if !in_dependencies { + continue; + } + if let Some(table) = open.as_mut() { + if let Some(pkg) = string_assignment(line, "package") { + table.package = Some(pkg); + } + continue; + } + if let Some((candidate, value)) = inline_dependency(line) { + if candidate == key && match_dep(&candidate, value.as_deref(), package).is_some() { + return true; + } + continue; + } + if let Some((candidate, package_field)) = multiline_table_start(line) { + open = Some(OpenTable { + key: candidate, + package: package_field, + }); + } + } + + open.is_some_and(|table| { + table.key == key && match_dep(&table.key, table.package.as_deref(), package).is_some() + }) +} + +fn inline_dependency(line: &str) -> Option<(String, Option)> { + let (key, rest) = split_assignment(line)?; + if !is_ident_key(key) { + return None; + } + let rest = rest.trim(); + if rest.starts_with('{') && rest.contains('}') { + let package = inline_table_package(rest); + return Some((key.to_string(), package)); + } + if is_string_literal(rest) || rest.chars().all(|c| c.is_ascii_digit() || c == '.') { + return Some((key.to_string(), None)); + } + None +} + +fn multiline_table_start(line: &str) -> Option<(String, Option)> { + let (key, rest) = split_assignment(line)?; + if !is_ident_key(key) { + return None; + } + let rest = rest.trim(); + if rest == "{" || (rest.starts_with('{') && !rest.contains('}')) { + // Fields may already sit on the opening line, e.g. `usage = { package = "usage-rs",`. + let body = rest.strip_prefix('{').unwrap_or(rest); + let package = package_from_fields(body); + return Some((key.to_string(), package)); + } + None +} + +/// Find `package = "…"` among comma-separated `key = value` fields, tolerating a trailing comma. +fn package_from_fields(body: &str) -> Option { + for part in body.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some(pkg) = string_assignment(part, "package") { + return Some(pkg); + } + } + None +} + +fn inline_table_package(table: &str) -> Option { + let mut body = table.trim(); + body = body.strip_prefix('{')?.trim(); + body = body.strip_suffix('}')?.trim(); + package_from_fields(body) +} + +fn string_assignment(line: &str, field: &str) -> Option { + let (key, rest) = split_assignment(line)?; + if key != field { + return None; + } + parse_string(rest.trim()) +} + +fn split_assignment(line: &str) -> Option<(&str, &str)> { + let eq = line.find('=')?; + let key = line[..eq].trim(); + let rest = line[eq + 1..].trim(); + Some((key, rest)) +} + +fn parse_string(value: &str) -> Option { + // A field inside an inline table carries its separator: `package = "usage-rs",`. Drop a + // single trailing comma before matching the quotes. + let value = value.trim().trim_end_matches(',').trim(); + if let Some(v) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) { + return Some(v.to_string()); + } + if let Some(v) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) { + return Some(v.to_string()); + } + None +} + +fn is_string_literal(value: &str) -> bool { + parse_string(value).is_some() +} + +fn bool_assignment(line: &str, field: &str) -> Option { + let (key, value) = split_assignment(line)?; + (key == field).then(|| parse_bool(value)).flatten() +} + +fn inline_table_bool(table: &str, field: &str) -> Option { + let mut body = table.trim().strip_prefix('{')?.trim(); + body = body.strip_suffix('}')?.trim(); + body.split(',') + .find_map(|part| bool_assignment(part.trim(), field)) +} + +fn parse_bool(value: &str) -> Option { + match value.trim().trim_end_matches(',').trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +fn is_ident_key(key: &str) -> bool { + !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn strip_comment(line: &str) -> &str { + // Dependency lines usage writes never put `#` inside a string; keep this dumb on purpose. + line.split('#').next().unwrap_or(line) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_a_renamed_facade() { + let manifest = r#" +[package] +name = "app" + +[dependencies] +usage = { package = "usage-rs", version = "5" } +"#; + assert_eq!( + find_in_manifest(manifest, "usage-rs").unwrap(), + FoundCrate::Name("usage".into()) + ); + } + + #[test] + fn finds_direct_argv() { + let manifest = r#" +[package] +name = "app" +[dependencies] +usage-argv = { path = "../argv", features = ["spec"] } +"#; + assert_eq!( + find_in_manifest(manifest, "usage-argv").unwrap(), + FoundCrate::Name("usage_argv".into()) + ); + } + + #[test] + fn itself_when_expanding_inside_the_package() { + let manifest = r#" +[package] +name = "usage-rs" +[dependencies] +usage-argv = { path = "../argv" } +"#; + assert_eq!( + find_in_manifest(manifest, "usage-rs").unwrap(), + FoundCrate::Itself + ); + } + + #[test] + fn finds_multiline_rename() { + let manifest = r#" +[package] +name = "app" +[dependencies] +usage = { + package = "usage-rs" + version = "5" +} +"#; + assert_eq!( + find_in_manifest(manifest, "usage-rs").unwrap(), + FoundCrate::Name("usage".into()) + ); + } + + #[test] + fn finds_multiline_rename_with_trailing_commas() { + // Cargo's TOML 1.1 multi-line inline table: each field carries a trailing comma. + let manifest = r#" +[package] +name = "app" +[dependencies] +usage = { + package = "usage-rs", + version = "5", +} +"#; + assert_eq!( + find_in_manifest(manifest, "usage-rs").unwrap(), + FoundCrate::Name("usage".into()) + ); + } + + #[test] + fn finds_package_on_the_opening_brace_line() { + let manifest = r#" +[package] +name = "app" +[dependencies] +usage = { package = "usage-rs", + version = "5" } +"#; + assert_eq!( + find_in_manifest(manifest, "usage-rs").unwrap(), + FoundCrate::Name("usage".into()) + ); + } + + #[test] + fn finds_package_sharing_the_closing_brace_line() { + let manifest = r#" +[package] +name = "app" +[dependencies] +usage = { + version = "5", + package = "usage-rs" } +"#; + assert_eq!( + find_in_manifest(manifest, "usage-rs").unwrap(), + FoundCrate::Name("usage".into()) + ); + } + + #[test] + fn finds_named_dependency_table() { + let manifest = r#" +[package] +name = "app" +[dependencies.usage-argv] +path = "../argv" +"#; + assert_eq!( + find_in_manifest(manifest, "usage-argv").unwrap(), + FoundCrate::Name("usage_argv".into()) + ); + } + + #[test] + fn finds_renamed_named_dependency_table() { + let manifest = r#" +[package] +name = "app" +[dependencies.usage] +package = "usage-rs" +version = "5" +"#; + assert_eq!( + find_in_manifest(manifest, "usage-rs").unwrap(), + FoundCrate::Name("usage".into()) + ); + } + + #[test] + fn prefers_nothing_when_absent() { + let manifest = r#" +[package] +name = "app" +[dependencies] +serde = "1" +"#; + assert!(find_in_manifest(manifest, "usage-rs").is_err()); + } + + #[test] + fn finds_workspace_inheritance_forms() { + for member in [ + r#" +[package] +name = "app" +[dependencies] +usage = { workspace = true } +"#, + r#" +[package] +name = "app" +[dependencies] +usage.workspace = true +"#, + r#" +[package] +name = "app" +[dependencies.usage] +workspace = true +"#, + ] { + assert_eq!(workspace_dependency_keys(member), ["usage"]); + } + + let workspace = r#" +[workspace] +[workspace.dependencies] +usage = { package = "usage-rs", version = "5" } +"#; + assert!(workspace_dependency_resolves( + workspace, "usage", "usage-rs" + )); + } +} diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 97240b08b..f75fe4b66 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -308,6 +308,7 @@ use proc_macro::TokenStream; use syn::{parse_macro_input, DeriveInput}; mod codegen; +mod crate_name; mod model; /// Compile a struct into a parser and a spec. See the [crate docs](crate). diff --git a/usage-rs/tests/external.rs b/usage-rs/tests/external.rs index d76eaca05..392c04e6a 100644 --- a/usage-rs/tests/external.rs +++ b/usage-rs/tests/external.rs @@ -33,3 +33,8 @@ fn documented_cargo_alias_is_the_only_dependency() { fn direct_dependencies_win_in_a_mixed_configuration() { run_fixture("mixed-dependencies"); } + +#[test] +fn workspace_inherited_facade_is_resolved() { + run_fixture("workspace-inheritance"); +} diff --git a/usage-rs/tests/fixtures/workspace-inheritance/Cargo.toml b/usage-rs/tests/fixtures/workspace-inheritance/Cargo.toml new file mode 100644 index 000000000..781eb28fb --- /dev/null +++ b/usage-rs/tests/fixtures/workspace-inheritance/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "usage-rs-workspace-inheritance-fixture" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +usage.workspace = true + +[workspace] + +[workspace.dependencies] +usage = { package = "usage-rs", path = "../../.." } diff --git a/usage-rs/tests/fixtures/workspace-inheritance/src/main.rs b/usage-rs/tests/fixtures/workspace-inheritance/src/main.rs new file mode 100644 index 000000000..601973261 --- /dev/null +++ b/usage-rs/tests/fixtures/workspace-inheritance/src/main.rs @@ -0,0 +1,22 @@ +use std::ffi::OsStr; + +use usage::{Cli, ValueEnum}; + +#[derive(ValueEnum)] +enum Shell { + Bash, + Zsh, +} + +#[derive(Cli)] +#[usage(bin = "ex")] +struct Ex { + #[usage(long, value_enum)] + shell: Shell, +} + +fn main() { + let cli = Ex::parse_from(&[OsStr::new("--shell"), OsStr::new("zsh")]) + .expect("workspace-inherited facade should resolve"); + assert!(matches!(cli.shell, Shell::Zsh)); +}