Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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,
Expand Down
121 changes: 96 additions & 25 deletions crates/socket-patch-core/src/utils/cargo_workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -349,10 +350,12 @@ pub(crate) fn normalize_rel(base: &str, rel: &str) -> Option<String> {
/// 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<String> {
expand_glob_bounded(tree, pattern, skip).0
}
Expand All @@ -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<char> {
seg.replace("[^", "[\0^").chars().collect()
}

fn expand_from(
tree: &dyn DirTree,
at: String,
Expand All @@ -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);
Expand All @@ -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<char> = 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::*;
Expand Down Expand Up @@ -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
Expand Down
Loading