Skip to content
Merged
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
164 changes: 164 additions & 0 deletions crates/turborepo-env/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,85 @@ impl EnvironmentVariableMap {
}
}

/// Match the builtin passthrough list once against this environment
/// snapshot. Callers should reuse the resulting map across tasks, not
/// rematch per task.
pub fn builtin_pass_through_env(&self) -> Result<EnvironmentVariableMap, Error> {
// Windows regexes use Unicode case folding, not ASCII-only comparisons.
#[cfg(windows)]
{
let compiled = CompiledWildcards::compile(BUILTIN_PASS_THROUGH_ENV)?;
Ok(self.from_compiled_wildcards(&compiled))
}
#[cfg(not(windows))]
{
let mut output = EnvironmentVariableMap::default();
// The builtin list contains only literals and literal prefixes followed
// by one star. A test below guards this invariant.
for pattern in BUILTIN_PASS_THROUGH_ENV {
if let Some(prefix) = pattern.strip_suffix('*') {
for (name, value) in &self.0 {
if name
.strip_prefix(prefix)
.is_some_and(|suffix| !suffix.contains('\n'))
{
// Regex `.*` matches Unicode and CR, but not LF.
output.insert(name.clone(), value.clone());
}
}
} else if let Some((name, value)) = self.get_key_value(*pattern) {
output.insert(name.clone(), value.clone());
}
}
Ok(output)
}
}

// returns a WildcardMaps after processing wildcards against it.
fn wildcard_map_from_wildcards(
&self,
wildcard_patterns: &[impl AsRef<str>],
) -> Result<WildcardMaps, Error> {
#[cfg(not(windows))]
{
// Normalize only the leading include/exclude marker. Leave all other
// escaping and wildcard syntax to the existing regex implementation.
let literal_patterns = || {
wildcard_patterns.iter().map(|pattern| {
let pattern = pattern.as_ref();
if let Some(rest) = pattern.strip_prefix('!') {
(true, rest)
} else if pattern.starts_with("\\!") {
(false, &pattern[1..])
} else {
(false, pattern)
}
})
};
if literal_patterns().all(|(_, pattern)| !pattern.contains(['*', '\\'])) {
let mut output = WildcardMaps {
inclusions: EnvironmentVariableMap::default(),
exclusions: EnvironmentVariableMap::default(),
};
for (excluded, pattern) in literal_patterns() {
if let Some((name, value)) = self.get_key_value(pattern) {
let map = if excluded {
&mut output.exclusions
} else {
&mut output.inclusions
};
map.insert(name.clone(), value.clone());
}
}
return Ok(output);
}
}
self.wildcard_map_from_wildcards_regex(wildcard_patterns)
}

fn wildcard_map_from_wildcards_regex(
&self,
wildcard_patterns: &[impl AsRef<str>],
) -> Result<WildcardMaps, Error> {
let mut output = WildcardMaps {
inclusions: EnvironmentVariableMap::default(),
Expand Down Expand Up @@ -782,6 +857,95 @@ mod tests {
}
}

#[test]
fn test_builtin_pass_through_fast_path_shape() {
for pattern in BUILTIN_PASS_THROUGH_ENV {
let literal = pattern.strip_suffix('*').unwrap_or(pattern);
assert!(!literal.contains(['*', '\\']));
assert!(!literal.starts_with('!'));
}
}

#[test]
fn test_builtin_pass_through_matches_regex() {
let mut env = EnvironmentVariableMap::default();
for pattern in BUILTIN_PASS_THROUGH_ENV {
let base = pattern.strip_suffix('*').unwrap_or(pattern);
for suffix in [
"", "suffix", "é東京", "\r", "\n", "a\nb", "\r\n", "\u{2028}", "*",
] {
let name = format!("{base}{suffix}");
env.insert(name.clone(), format!("value:{name}"));
}
env.insert(base.to_lowercase(), "lowercase".into());
env.insert(format!("BEFORE_{base}"), "not a prefix match".into());
}
// Unicode simple case folding on Windows must remain regex-based.
env.insert("DOCKER_HOST".into(), "kelvin".into());
env.insert("ſHELL".into(), "long s".into());
env.insert("ProgramFiles(x86)".into(), "literal parentheses".into());
let compiled = CompiledWildcards::compile(BUILTIN_PASS_THROUGH_ENV).unwrap();
assert_eq!(
env.builtin_pass_through_env().unwrap(),
env.from_compiled_wildcards(&compiled)
);
}

#[test_case(&[] ; "empty list")]
#[test_case(&["FOO", "!FOOD", "MISSING", "FOO"] ; "literals and independent exclusion")]
#[test_case(&["FOO", "!FOO"] ; "exclusion wins")]
#[test_case(&["!FOO"] ; "only exclusion")]
#[test_case(&["", "!"] ; "empty names")]
#[test_case(&["\\!BANG", "!!BANG"] ; "literal bang and exclusion")]
#[test_case(&["ProgramFiles(x86)", "A.B+$^[]{}?", "é東京", "A\nB", "A\rB"] ; "literal regex syntax and unicode")]
#[test_case(&["SHELL", "DOCKER_HOST"] ; "windows unicode folding")]
#[test_case(&["FOO*", "!FOOD", "BAR"] ; "wildcard fallback")]
#[test_case(&["*", "!FOO*"] ; "all wildcard fallback")]
#[test_case(&["FOO\\*", "\\\\*", "A\\B", "\\!BANG*"] ; "escape fallback")]
#[test_case(&["F**O*", "*é*", "!A*B"] ; "arbitrary wildcard fallback")]
fn test_wildcard_fast_path_matches_regex(patterns: &[&str]) {
let env = EnvironmentVariableMap::from(
[
"",
"FOO",
"FOOD",
"FOOBAR",
"BAR",
"!BANG",
"!BANG_MORE",
"FOO*",
"\\*",
"A\\B",
"ProgramFiles(x86)",
"A.B+$^[]{}?",
"é東京",
"A\nB",
"A\rB",
"FOO\n",
"FOO\r",
"foo",
"SHELL",
"shell",
"ſHELL",
"DOCKER_HOST",
"DOCKER_HOST",
]
.into_iter()
.enumerate()
.map(|(i, name)| (name.to_owned(), i.to_string()))
.collect::<HashMap<_, _>>(),
);
let actual = env
.wildcard_map_from_wildcards_unresolved(patterns)
.unwrap();
// Keep the old implementation as an independent oracle, including its
// unresolved exclusions (which can remove framework-inferred values).
let expected = env.wildcard_map_from_wildcards_regex(patterns).unwrap();
assert_eq!(actual.inclusions, expected.inclusions);
assert_eq!(actual.exclusions, expected.exclusions);
assert_eq!(env.from_wildcards(patterns).unwrap(), expected.resolve());
}

#[test]
fn test_builtin_pass_through_env_compiles() {
CompiledWildcards::compile(BUILTIN_PASS_THROUGH_ENV)
Expand Down
72 changes: 72 additions & 0 deletions crates/turborepo-globwalk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ pub fn fix_glob_pattern(pattern: &str) -> Cow<'_, str> {
}
};

// Most patterns already use standalone, non-consecutive globstars. None
// of the rewrites below can match those patterns (or patterns without a
// globstar), so avoid initializing their Unicode regexes on the common
// startup path. Ambiguous forms still use the original rewrites.
let mut previous_globstar = false;
let needs_rewrite = p0.split('/').any(|component| {
let globstar = component == "**";
let needs_rewrite =
(globstar && previous_globstar) || (!globstar && component.contains("**"));
previous_globstar = globstar;
needs_rewrite
});
if !needs_rewrite {
return p0;
}

// Chain regex replacements, taking advantage of Cow<str>:
// - If no match, replace() returns Cow::Borrowed pointing to the input
// - If match, replace() returns Cow::Owned with the replacement
Expand Down Expand Up @@ -1321,6 +1337,62 @@ mod test {
assert_eq!(output.as_ref(), expected);
}

#[test]
fn glob_normalization_fast_path_matches_original_rewrites() {
let collapse = regex::Regex::new(r"\*\*(?:/\*\*)+").unwrap();
let suffix = regex::Regex::new(r"\*\*(?P<suffix>[^*/]+)").unwrap();
let prefix = regex::Regex::new(r"(?P<prefix>[^*/]+)\*\*").unwrap();
let check = |input: &str| {
#[cfg(not(windows))]
let normalized = std::borrow::Cow::Borrowed(input);
#[cfg(windows)]
let normalized = {
use path_slash::PathExt;
let converted = std::path::Path::new(input).to_slash().unwrap();
if (input.ends_with('/') || input.ends_with('\\')) && !converted.ends_with('/') {
std::borrow::Cow::Owned(format!("{converted}/"))
} else {
converted
}
};
let first = collapse.replace(&normalized, "**");
let second = suffix.replace(&first, "**/*$suffix");
let expected = prefix.replace(&second, "$prefix*/**");
assert_eq!(fix_glob_pattern(input), expected, "{input:?}");
};
for input in [
"",
"packages/*",
"**/node_modules/**",
"**//**",
"***x",
"x***",
"**/**/**",
"***/*/**",
"é**猫",
"**\n**",
r"packages\**\src\*",
"**{a,b}/[xy]/**",
"a**/b**/**c",
"packages/**/",
] {
check(input);
}
// Exhaustive short inputs include overlapping globstars and Unicode
// boundaries, where a too-permissive fast path could skip a rewrite.
let alphabet = ["*", "/", "a", "猫"];
for len in 0..=7u32 {
for mut code in 0..4usize.pow(len) {
let mut input = String::new();
for _ in 0..len {
input.push_str(alphabet[code % 4]);
code /= 4;
}
check(&input);
}
}
}

#[test]
#[cfg(not(windows))]
fn test_fix_glob_pattern_returns_borrowed_when_no_change() {
Expand Down
109 changes: 74 additions & 35 deletions crates/turborepo-repository/src/inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,42 +75,40 @@ impl RepoState {
/// returns: Result<RepoState, Error>
#[tracing::instrument(skip_all)]
pub fn infer(reference_dir: &AbsoluteSystemPath) -> Result<Self, Error> {
reference_dir
.ancestors()
.filter_map(|path| {
PackageJson::load(&path.join_component("package.json"))
.ok()
.map(|package_json| {
// FIXME: We should save this package manager that we detected
let package_manager =
PackageManager::read_or_detect_package_manager(&package_json, path);
let workspace_globs = package_manager
.as_ref()
.ok()
.and_then(|mgr| mgr.get_workspace_globs(path).ok());
let candidates = reference_dir.ancestors().filter_map(|path| {
PackageJson::load(&path.join_component("package.json"))
.ok()
.map(|package_json| {
let package_manager =
PackageManager::read_or_detect_package_manager(&package_json, path);
let workspace_globs = package_manager
.as_ref()
.ok()
.and_then(|mgr| mgr.get_workspace_globs(path).ok());

InferInfo {
path: path.to_owned(),
workspace_globs,
package_manager,
package_json,
}
})
})
.reduce(|current, candidate| {
if current.repo_mode() == RepoMode::MultiPackage {
// We already have a multi-package root, go with that
current
} else if candidate.is_workspace_root_of(&current.path) {
// The next candidate is a multipackage root, and it contains current so it's
// our root.
candidate
} else {
// keep the current single package, it's the closest in
current
}
})
.map(|root| root.into())
InferInfo {
path: path.to_owned(),
workspace_globs,
package_manager,
package_json,
}
})
});
let mut root: Option<InferInfo> = None;
for candidate in candidates {
let selected = match root {
Some(current) if !candidate.is_workspace_root_of(&current.path) => current,
_ => candidate,
};
// Once a multi-package root is selected, no ancestor can replace
// it. Stop here rather than loading manifests and compiling globs
// for outer repositories whose results would be discarded.
if selected.repo_mode() == RepoMode::MultiPackage {
return Ok(selected.into());
}
root = Some(selected);
}
root.map(Into::into)
.ok_or_else(|| Error::NotFound(reference_dir.to_owned()))
}
}
Expand All @@ -131,6 +129,47 @@ mod test {
(tmp_dir, dir)
}

#[test]
fn nested_workspace_keeps_nearest_selected_root() {
let (_tmp, root) = tmp_dir();
root.join_component("package.json")
.create_with_contents(
r#"{"name":"outer","packageManager":"npm@10.0.0","workspaces":["**"]}"#,
)
.unwrap();
let inner = root.join_component("inner");
inner.create_dir_all().unwrap();
inner
.join_component("package.json")
.create_with_contents(
r#"{"name":"inner","packageManager":"npm@10.0.0","workspaces":["packages/*"]}"#,
)
.unwrap();
let member = inner.join_components(&["packages", "app"]);
member.create_dir_all().unwrap();
member
.join_component("package.json")
.create_with_contents(r#"{"name":"app"}"#)
.unwrap();
let src = member.join_component("src");
src.create_dir_all().unwrap();
for invocation in [&inner, &member, &src] {
let inferred = RepoState::infer(invocation).unwrap();
assert_eq!(inferred.root, inner);
assert_eq!(inferred.mode, RepoMode::MultiPackage);
}

// Merely encountering a workspace isn't enough to stop: a standalone
// package excluded from the inner workspace may belong to the outer one.
let standalone = inner.join_component("standalone");
standalone.create_dir_all().unwrap();
standalone
.join_component("package.json")
.create_with_contents(r#"{"name":"standalone"}"#)
.unwrap();
assert_eq!(RepoState::infer(&standalone).unwrap().root, root);
}

#[test]
fn test_repo_state_infer() {
// Directory layout:
Expand Down
Loading
Loading