diff --git a/packages/nx/src/native/cache/expand_outputs.rs b/packages/nx/src/native/cache/expand_outputs.rs index 7eaa2a87ea4..6d2c6328691 100644 --- a/packages/nx/src/native/cache/expand_outputs.rs +++ b/packages/nx/src/native/cache/expand_outputs.rs @@ -507,6 +507,35 @@ mod test { ); } + #[test] + fn should_get_files_for_scoped_output_glob() { + let temp = TempDir::new().unwrap(); + temp.child("packages/@acme/producer/dist/index.d.mts") + .touch() + .unwrap(); + temp.child("packages/@acme/producer/dist/nested/example.d.ts") + .touch() + .unwrap(); + temp.child("packages/@acme/producer/dist/nested/example.js") + .touch() + .unwrap(); + + let entries = vec!["packages/@acme/producer/dist/**/*.{d.ts,d.cts,d.mts}".to_string()]; + let mut hashed_outputs = get_files_for_outputs(temp.path(), entries.clone()).unwrap(); + let mut expanded_outputs = expand_outputs(temp.display().to_string(), entries).unwrap(); + hashed_outputs.sort(); + expanded_outputs.sort(); + + assert_eq!( + hashed_outputs, + vec![ + "packages/@acme/producer/dist/index.d.mts", + "packages/@acme/producer/dist/nested/example.d.ts", + ] + ); + assert_eq!(hashed_outputs, expanded_outputs); + } + #[test] #[cfg(unix)] fn should_expand_outputs_with_symlinks_and_globs() { diff --git a/packages/nx/src/native/glob/glob_group.rs b/packages/nx/src/native/glob/glob_group.rs index 79430f98a0c..a1300a021ce 100644 --- a/packages/nx/src/native/glob/glob_group.rs +++ b/packages/nx/src/native/glob/glob_group.rs @@ -17,6 +17,8 @@ pub enum GlobGroup<'a> { NegatedFileName(Cow<'a, str>), // !(a|b|c)* NegatedWildcard(Cow<'a, str>), + // ?, +, or @ without a following parenthesized group + UngroupedSpecialChar(char), NonSpecialGroup(Cow<'a, str>), NonSpecial(Cow<'a, str>), } @@ -50,6 +52,7 @@ impl<'a> Display for GlobGroup<'a> { write!(f, "{}*", s) } } + GlobGroup::UngroupedSpecialChar(character) => write!(f, "{}", character), GlobGroup::NonSpecial(s) => write!(f, "{}", s), } } diff --git a/packages/nx/src/native/glob/glob_parser.rs b/packages/nx/src/native/glob/glob_parser.rs index ef69d0cad66..1249b584f37 100644 --- a/packages/nx/src/native/glob/glob_parser.rs +++ b/packages/nx/src/native/glob/glob_parser.rs @@ -8,6 +8,12 @@ use nom::sequence::{preceded, terminated}; use nom::{Finish, IResult}; use std::borrow::Cow; +#[derive(Clone, Copy)] +enum UngroupedSpecialCharBehavior { + Discard, + Preserve, +} + /// Consumes special characters if they are not part of a group, otherwise returns an error /// Example: /// - ?snap -> snap @@ -26,6 +32,15 @@ fn special_char_with_no_group(input: &str) -> IResult<&str, &str, VerboseError<& })(input) } +fn ungrouped_special_char(input: &str) -> IResult<&str, GlobGroup<'_>, VerboseError<&str>> { + context( + "ungrouped_special_char", + map(alt((tag("?"), tag("+"), tag("@"))), |character: &str| { + GlobGroup::UngroupedSpecialChar(character.chars().next().unwrap()) + }), + )(input) +} + fn simple_group(input: &str) -> IResult<&str, GlobGroup<'_>, VerboseError<&str>> { context( "simple_group", @@ -135,17 +150,25 @@ fn separated_group_items(input: &str) -> IResult<&str, Cow<'_, str>, VerboseErro )(input) } -fn parse_segment(input: &str) -> IResult<&str, Vec>, VerboseError<&str>> { +fn parse_segment( + input: &str, + ungrouped_special_char_behavior: UngroupedSpecialCharBehavior, +) -> IResult<&str, Vec>, VerboseError<&str>> { context( "parse_segment", many_till( context("glob_group", |input| { // check if the special character is part of a group - let group_input = match special_char_with_no_group(input) { - // if there was no (, then we know that the special character is not part of a group, we can return this input - Ok((no_group_input, _)) => no_group_input, - // otherwise, there was a ( after the special character, so we need to parse the original input - Err(_) => input, + let group_input = match ungrouped_special_char_behavior { + UngroupedSpecialCharBehavior::Discard => { + match special_char_with_no_group(input) { + // if there was no (, then we know that the special character is not part of a group, we can return this input + Ok((no_group_input, _)) => no_group_input, + // otherwise, there was a ( after the special character, so we need to parse the original input + Err(_) => input, + } + } + UngroupedSpecialCharBehavior::Preserve => input, }; alt(( simple_group, @@ -157,6 +180,7 @@ fn parse_segment(input: &str) -> IResult<&str, Vec>, VerboseError< negated_wildcard, negated_group, brace_group_with_empty_item, + ungrouped_special_char, non_special_character, ))(group_input) }), @@ -166,8 +190,16 @@ fn parse_segment(input: &str) -> IResult<&str, Vec>, VerboseError< .map(|(i, (groups, _))| (i, groups)) } -fn separated_segments(input: &str) -> IResult<&str, Vec>>, VerboseError<&str>> { - separated_list0(tag("/"), map_parser(take_till(|c| c == '/'), parse_segment))(input) +fn separated_segments( + input: &str, + ungrouped_special_char_behavior: UngroupedSpecialCharBehavior, +) -> IResult<&str, Vec>>, VerboseError<&str>> { + separated_list0( + tag("/"), + map_parser(take_till(|c| c == '/'), move |segment| { + parse_segment(segment, ungrouped_special_char_behavior) + }), + )(input) } // match on !test/, but not !(test)/ @@ -183,9 +215,12 @@ fn negated_glob(input: &str) -> (&str, bool) { } } -pub fn parse_glob(input: &str) -> anyhow::Result<(bool, Vec>>)> { +fn parse_glob_with_behavior( + input: &str, + ungrouped_special_char_behavior: UngroupedSpecialCharBehavior, +) -> anyhow::Result<(bool, Vec>>)> { let (input, negated) = negated_glob(input); - let result = separated_segments(input).finish(); + let result = separated_segments(input, ungrouped_special_char_behavior).finish(); if let Ok((_, result)) = result { Ok((negated, result)) } else { @@ -196,6 +231,18 @@ pub fn parse_glob(input: &str) -> anyhow::Result<(bool, Vec>>) } } +pub fn parse_glob(input: &str) -> anyhow::Result<(bool, Vec>>)> { + parse_glob_with_behavior(input, UngroupedSpecialCharBehavior::Discard) +} + +/// Retains ungrouped special characters so literal path segments such as +/// `packages/@acme` can be reconstructed after parsing. +pub fn parse_glob_preserving_ungrouped_special_chars( + input: &str, +) -> anyhow::Result<(bool, Vec>>)> { + parse_glob_with_behavior(input, UngroupedSpecialCharBehavior::Preserve) +} + #[cfg(test)] mod test { use crate::native::glob::glob_group::GlobGroup; @@ -357,6 +404,28 @@ mod test { ) ); } + + #[test] + fn should_preserve_ungrouped_special_chars_when_requested() { + let result = super::parse_glob_preserving_ungrouped_special_chars("@scope/a+b").unwrap(); + assert_eq!( + result, + ( + false, + vec![ + vec![ + GlobGroup::UngroupedSpecialChar('@'), + GlobGroup::NonSpecial("scope".into()) + ], + vec![ + GlobGroup::NonSpecial("a".into()), + GlobGroup::UngroupedSpecialChar('+'), + GlobGroup::NonSpecial("b".into()) + ] + ] + ) + ); + } #[test] fn should_parse_globs_with_braces() { let result = parse_glob("**/*.spec.ts{,.snap}").unwrap(); diff --git a/packages/nx/src/native/glob/glob_transform.rs b/packages/nx/src/native/glob/glob_transform.rs index ad9f2de3df5..0741f970fa3 100644 --- a/packages/nx/src/native/glob/glob_transform.rs +++ b/packages/nx/src/native/glob/glob_transform.rs @@ -1,6 +1,6 @@ use super::contains_glob_pattern; use crate::native::glob::glob_group::GlobGroup; -use crate::native::glob::glob_parser::parse_glob; +use crate::native::glob::glob_parser::{parse_glob, parse_glob_preserving_ungrouped_special_chars}; use itertools::Either::{Left, Right}; use itertools::Itertools; use std::collections::HashSet; @@ -111,6 +111,10 @@ fn build_segment( | GlobGroup::NonSpecialGroup(_) => { build_segment(&built_glob, &group[1..], is_last_segment, is_negative) } + // Skip ungrouped special characters when building converted patterns. + GlobGroup::UngroupedSpecialChar(_) => { + build_segment(existing, &group[1..], is_last_segment, is_negative) + } } } else if is_negative { vec![GlobType::Negative(existing.to_string())] @@ -119,21 +123,37 @@ fn build_segment( } } +fn static_segment(group: &[GlobGroup]) -> Option { + let mut segment = String::new(); + for glob_part in group { + match glob_part { + GlobGroup::NonSpecial(value) if !contains_glob_pattern(value) => { + segment.push_str(value) + } + GlobGroup::UngroupedSpecialChar(character @ ('@' | '+')) => segment.push(*character), + _ => return None, + } + } + Some(segment) +} + pub fn partition_glob(glob: &str) -> anyhow::Result<(String, Vec)> { - let (negated, groups) = parse_glob(glob)?; + // Preserve ungrouped special characters so scoped directories such as + // `packages/@acme` remain part of the static root. + let (negated, groups) = parse_glob_preserving_ungrouped_special_chars(glob)?; // Partition glob into leading directories and patterns that should be matched let mut has_patterns = false; let (leading_dir_segments, pattern_segments): (Vec, _) = groups .into_iter() .filter(|group| !group.is_empty()) - .partition_map(|group| match &group[0] { - GlobGroup::NonSpecial(value) if !contains_glob_pattern(&value) && !has_patterns => { - Left(value.to_string()) - } - _ => { - has_patterns = true; - Right(group) + .partition_map(|group| { + if !has_patterns { + if let Some(segment) = static_segment(&group) { + return Left(segment); + } } + has_patterns = true; + Right(group) }); Ok(( @@ -298,6 +318,24 @@ mod test { assert_eq!(globs, [] as [String; 0]); } + #[test] + fn should_partition_literal_extglob_prefixes() { + let (leading_dirs, globs) = + super::partition_glob("packages/@acme/producer/dist/**/*.d.ts").unwrap(); + assert_eq!(leading_dirs, "packages/@acme/producer/dist"); + assert_eq!(globs, ["**/*.d.ts"]); + + let (leading_dirs, globs) = + super::partition_glob("packages/a+b/producer/dist/**/*.d.ts").unwrap(); + assert_eq!(leading_dirs, "packages/a+b/producer/dist"); + assert_eq!(globs, ["**/*.d.ts"]); + + let (leading_dirs, globs) = + super::partition_glob("packages/@(acme|other)/dist/**/*.d.ts").unwrap(); + assert_eq!(leading_dirs, "packages"); + assert_eq!(globs, ["{acme,other}/dist/**/*.d.ts"]); + } + #[test] fn should_handle_test_optional_s_pattern() { let globs = convert_glob("**/__test?(s)__/**/*").unwrap(); diff --git a/packages/nx/src/native/tasks/hashers/hash_task_output.rs b/packages/nx/src/native/tasks/hashers/hash_task_output.rs index 0ec9f7b6124..82061599a4a 100644 --- a/packages/nx/src/native/tasks/hashers/hash_task_output.rs +++ b/packages/nx/src/native/tasks/hashers/hash_task_output.rs @@ -103,3 +103,43 @@ pub fn hash_task_output( files, }) } + +#[cfg(test)] +mod tests { + use super::*; + use assert_fs::TempDir; + use assert_fs::prelude::*; + + #[test] + fn should_hash_scoped_output_files() { + let temp = TempDir::new().unwrap(); + let declaration = temp.child("packages/@acme/producer/dist/index.d.ts"); + declaration + .write_str("export declare const value: 1;\n") + .unwrap(); + let outputs = vec!["packages/@acme/producer/dist/**/*.d.ts".to_string()]; + + let first = hash_task_output( + temp.path().to_str().unwrap(), + "**/*.d.ts", + &outputs, + &DashMap::new(), + ) + .unwrap(); + + declaration + .write_str("export declare const value: 2;\n") + .unwrap(); + let second = hash_task_output( + temp.path().to_str().unwrap(), + "**/*.d.ts", + &outputs, + &DashMap::new(), + ) + .unwrap(); + + assert_eq!(first.files, ["packages/@acme/producer/dist/index.d.ts"]); + assert_eq!(first.files, second.files); + assert_ne!(first.hash, second.hash); + } +}