diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ccb8bb226..aba5487f8 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2719,7 +2719,7 @@ fn cargo_lockless_other_dependencies( .into_iter() .flat_map(|a| a.iter().filter_map(toml_edit::Value::as_str)); for member in members { - let named = if member.contains(['*', '?']) { + let named = if crate::utils::cargo_workspace::is_member_pattern(member) { format!("the workspace members pattern `{member}`") } else if crate::utils::cargo_workspace::normalize_rel(dir, member) .is_some_and(|d| known.contains(format!("{d}/Cargo.toml").as_str())) @@ -15631,6 +15631,15 @@ mod tests { detail.contains("the workspace members pattern `crates/*`"), "{detail}" ); + // A `[..]` class is a pattern too (#1833), not a literal member. + let detail = refused(one( + "[workspace]\nmembers = [\"crates/[ab]\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + )); + assert!( + detail.contains("the workspace members pattern `crates/[ab]`"), + "{detail}" + ); } /// The lockless shapes that stay redirectable: the patched crate alone, diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs index 6209a38fc..db99948a0 100644 --- a/crates/socket-patch-core/src/utils/cargo_workspace.rs +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use toml_edit::{DocumentMut, Item, Table}; +use crate::utils::workspace_globs::segment_glob_matches; use crate::vendor::lock_inventory::view::{MemoryProject, ProjectView}; use crate::vendor::parse_memo::ParseMemo; @@ -349,10 +350,12 @@ pub(crate) fn normalize_rel(base: &str, rel: &str) -> Option { /// directory. const CARGO_SKIP: &[&str] = &["target"]; -/// Expand a cargo `members` / `exclude` glob (`*`, `?`, `**`) to the -/// repo-relative directories it names. A wildcard segment never matches a -/// dot-directory (unless the segment itself starts with `.`, as `.*` does) -/// or a name in `skip`; a literal segment names any real directory. +/// Expand a cargo `members` / `exclude` glob (`*`, `?`, `[..]` classes, +/// `**`) to the repo-relative directories it names. A wildcard segment +/// never matches a dot-directory (unless the segment itself starts with +/// `.`, as `.*` does) or a name in `skip`; a literal segment names any +/// real directory. A segment matches as `workspace_globs` reads one, so a +/// `[` that never closes is a literal character. pub(crate) fn expand_glob(tree: &dyn DirTree, pattern: &str, skip: &[&str]) -> Vec { expand_glob_bounded(tree, pattern, skip).0 } @@ -377,6 +380,30 @@ pub(crate) fn expand_glob_bounded( (out, truncated) } +/// Whether a `members` entry is a glob rather than a literal path: some +/// segment holds `*`, `?` or a `[..]` class that closes. A `[` that never +/// closes is not a class. +pub(crate) fn is_member_pattern(pattern: &str) -> bool { + pattern.split('/').any(segment_is_pattern) +} + +fn segment_is_pattern(seg: &str) -> bool { + seg.contains(['*', '?']) + || seg.match_indices('[').any(|(at, _)| { + let body = &seg[at + 1..]; + let body = body.strip_prefix('!').unwrap_or(body); + body.char_indices().skip(1).any(|(_, c)| c == ']') + }) +} + +/// `seg` as [`segment_glob_matches`] should read it for cargo. Cargo's +/// `glob` crate negates a class only on `!`, so a `^` opening a class is a +/// member; the shared matcher also negates on `^`, so a NUL member (which +/// no directory name holds) goes ahead of it. +fn cargo_segment(seg: &str) -> Vec { + seg.replace("[^", "[\0^").chars().collect() +} + fn expand_from( tree: &dyn DirTree, at: String, @@ -391,7 +418,7 @@ fn expand_from( out.push(at); return; }; - if !seg.contains(['*', '?']) { + if !segment_is_pattern(seg) { let next = join_rel(&at, seg); if tree.is_real_dir(&next) { expand_from(tree, next, tail, skip, out); @@ -415,26 +442,15 @@ fn expand_from( } return; } + let pattern = cargo_segment(seg); for child in children { - if wildcard_match(seg.as_bytes(), child.as_bytes()) { + let name: Vec = child.chars().collect(); + if segment_glob_matches(&pattern, &name) { expand_from(tree, join_rel(&at, &child), tail, skip, out); } } } -fn wildcard_match(pattern: &[u8], name: &[u8]) -> bool { - match (pattern.first(), name.first()) { - (None, None) => true, - (Some(b'*'), _) => { - wildcard_match(&pattern[1..], name) - || (!name.is_empty() && wildcard_match(pattern, &name[1..])) - } - (Some(b'?'), Some(_)) => wildcard_match(&pattern[1..], &name[1..]), - (Some(p), Some(n)) if p == n => wildcard_match(&pattern[1..], &name[1..]), - _ => false, - } -} - #[cfg(test)] mod tests { use super::*; @@ -614,13 +630,68 @@ mod tests { assert_eq!(member_manifests(root), vec!["crates/a/Cargo.toml"]); } + /// #1833: cargo expands `members` with the `glob` crate, so `[ab]`, + /// `[a-b]` and `[!x]` classes name the directories they match, as `*` + /// and `?` do. The crate negates only on `!`: `[^x]` is the class of + /// `^` and `x`. #[test] - fn wildcard_matching() { - assert!(wildcard_match(b"a*c", b"abbc")); - assert!(wildcard_match(b"*", b"")); - assert!(wildcard_match(b"a?c", b"abc")); - assert!(!wildcard_match(b"a?c", b"ac")); - assert!(!wildcard_match(b"a*d", b"abc")); + fn member_globs_read_character_classes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for name in ["alpha", "beta", "gamma"] { + write(root, &format!("crates/{name}/Cargo.toml"), &pkg(name)); + write(root, &format!("tools/{name}/Cargo.toml"), &pkg(name)); + write(root, &format!("libs/{name}/Cargo.toml"), &pkg(name)); + } + write(root, "carets/xtra/Cargo.toml", &pkg("xtra")); + write(root, "carets/^hat/Cargo.toml", &pkg("hat")); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/[ab]*\", \"tools/[a-b]?*\", \"libs/[!a]*\", \ + \"carets/[^x]*\"]\n", + ); + assert_eq!( + member_manifests(root), + vec![ + "carets/^hat/Cargo.toml", + "carets/xtra/Cargo.toml", + "crates/alpha/Cargo.toml", + "crates/beta/Cargo.toml", + "libs/beta/Cargo.toml", + "libs/gamma/Cargo.toml", + "tools/alpha/Cargo.toml", + "tools/beta/Cargo.toml", + ] + ); + } + + /// A `[[]` class matches a literal `[`. + #[test] + fn a_bracket_class_matches_a_literal_bracket() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "x[1/Cargo.toml", &pkg("x")); + write(root, "Cargo.toml", "[workspace]\nmembers = [\"x[[]?\"]\n"); + assert_eq!(member_manifests(root), vec!["x[1/Cargo.toml"]); + } + + #[test] + fn member_patterns_are_wildcards_or_closed_classes() { + for pattern in [ + "crates/*", + "a?", + "crates/[ab]", + "x[!a]y", + "x[^a]", + "x[^]", + "[]]", + ] { + assert!(is_member_pattern(pattern), "{pattern}"); + } + for pattern in ["crates/a", "odd[dir", "x[]", "x[!]", "a]b["] { + assert!(!is_member_pattern(pattern), "{pattern}"); + } } /// V-7: the memoized member walk returns exactly what the per-call