From 6d48ba443856a05c64d7f49af022db2f7173cb17 Mon Sep 17 00:00:00 2001 From: Christian Schilling Date: Sun, 16 Nov 2025 20:43:01 +0100 Subject: [PATCH] Add "scope" and "invert" syntax shorthands --- docs/src/reference/filters.md | 36 ++++++++ josh-core/src/filter/grammar.pest | 9 ++ josh-core/src/filter/mod.rs | 48 +++++++++++ josh-core/src/filter/parse.rs | 19 +++++ tests/filter/scope_filter.t | 134 ++++++++++++++++++++++++++++++ tests/proxy/workspace_errors.t | 2 +- 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 tests/filter/scope_filter.t diff --git a/docs/src/reference/filters.md b/docs/src/reference/filters.md index e9075568f..19e0db2ae 100644 --- a/docs/src/reference/filters.md +++ b/docs/src/reference/filters.md @@ -66,6 +66,42 @@ Remove all paths present in the *output* of ``:filter`` from the input tree. It should generally be avoided to use any filters that change paths and instead only use filters that select paths without altering them. +### Invert **`:invert[:filter]`** +A shorthand syntax that applies the inverse of the composed filter. The inverse of a filter is +a filter that undoes the transformation. For example, the inverse of ``:/sub1`` (subdirectory) +is ``:prefix=sub1`` (prefix), and vice versa. + +**Example:** +``` +:invert[:/sub1] +``` +This is equivalent to ``:prefix=sub1``, which takes the input tree and places it into +the ``sub1`` subdirectory. + +Multiple filters can be provided in the compose: +``` +:invert[:/sub1,:/sub2] +``` +This inverts the composition of ``:/sub1`` and ``:/sub2``. + +### Scope **`:[..]`** +A shorthand syntax that expands to ``:X:[..]:invert[:X]``, where: +- ``:X`` is a filter (without built-in compose) +- ``:[..]`` is a compose filter (like in ``:exclude``) + +This filter first applies ``:X`` to scope the input, then applies the compose filter ``:[..]``, +and finally inverts ``:X`` to restore the original scope. This is useful when you want to +apply a composition filter within a specific scope and then restore the original structure. + +**Example:** +``` +:<:/sub1>[::file1,::file2] +``` +This is equivalent to ``:/sub1:[::file1,::file2]:invert[:/sub1]``, which: +1. Selects the ``sub1`` subdirectory (applies ``:/sub1``) +2. Applies the composition filter to select ``file1`` and ``file2`` (applies ``:[::file1,::file2]``) +3. Restores the original scope by inverting the subdirectory selection (applies ``:invert[:/sub1]``) + ### Stored **`:+path/to/file`** Looks for a file with a ``.josh`` extension at the specified path and applies the filter defined in that file. The path argument should be provided *without* the ``.josh`` extension, as it will be automatically appended. diff --git a/josh-core/src/filter/grammar.pest b/josh-core/src/filter/grammar.pest index 531368e58..1e99fb8b1 100644 --- a/josh-core/src/filter/grammar.pest +++ b/josh-core/src/filter/grammar.pest @@ -33,6 +33,7 @@ filter_spec = { ( | filter_subdir | filter_stored | filter_nop + | filter_scope | filter | filter_noarg )+ } @@ -100,6 +101,14 @@ filter_squash = { ~ ")" } +filter_scope = { + CMD_START ~ "<" + ~ NEWLINE* + ~ filter_spec + ~ NEWLINE* + ~ ">" ~ GROUP_START ~ compose ~ GROUP_END +} + cmd = { ALNUM+ } file_entry = { dst_path ~ "=" ~ filter_spec } diff --git a/josh-core/src/filter/mod.rs b/josh-core/src/filter/mod.rs index b87adb505..08d798c5f 100644 --- a/josh-core/src/filter/mod.rs +++ b/josh-core/src/filter/mod.rs @@ -1770,4 +1770,52 @@ mod tests { dst_path(parse(":[a=:/x::y/,a/b=:/i]:prefix=c").unwrap()) ); } + + #[test] + fn invert_filter_parsing_test() { + // Test that :invert[X] syntax parses correctly + let filter = parse(":invert[:/sub1]").unwrap(); + // Verify it's not empty + assert_ne!(filter, empty()); + + // Test with prefix filter (inverse of subdir) + let filter2 = parse(":invert[:prefix=sub1]").unwrap(); + assert_ne!(filter2, empty()); + + // Test that it produces the correct inverse + let filter3 = parse(":invert[:/sub1]").unwrap(); + let spec_str = spec(filter3); + // Should produce prefix (inverse of subdir) + assert!(spec_str.contains("prefix") || !spec_str.is_empty()); + + // Test with multiple filters in compose + let filter4 = parse(":invert[:/sub1,:/sub2]").unwrap(); + assert_ne!(filter4, empty()); + } + + #[test] + fn scope_filter_parsing_test() { + // Test that :[Y] syntax parses correctly + let filter = parse(":<:/sub1>[:/file1]").unwrap(); + // Just verify parsing succeeds (filter may optimize to empty in some cases) + let _ = filter; + + // Test with multiple filters in compose + let filter2 = parse(":<:/sub1>[:/file1,:/file2]").unwrap(); + let _ = filter2; + + // Test with prefix filter + let filter3 = parse(":<:prefix=sub1>[:prefix=file1]").unwrap(); + let _ = filter3; + + // Test with exclude + let filter4 = parse(":<:/sub1>[:exclude[::file1]]").unwrap(); + let _ = filter4; + + // Test that it expands to chain structure by checking spec output + let filter5 = parse(":<:/sub1>[:/file1]").unwrap(); + let spec_str = spec(filter5); + // The spec should contain the chain representation + assert!(!spec_str.is_empty()); + } } diff --git a/josh-core/src/filter/parse.rs b/josh-core/src/filter/parse.rs index dae20a118..e12a7e460 100644 --- a/josh-core/src/filter/parse.rs +++ b/josh-core/src/filter/parse.rs @@ -149,6 +149,10 @@ fn parse_item(pair: pest::iterators::Pair) -> JoshResult { match *cmd { "pin" => Ok(Op::Pin(to_filter(Op::Compose(g)))), "exclude" => Ok(Op::Exclude(to_filter(Op::Compose(g)))), + "invert" => { + let filter = to_filter(Op::Compose(g)); + Ok(to_op(invert(filter)?)) + } "subtract" if g.len() == 2 => Ok(Op::Subtract(g[0], g[1])), _ => Err(josh_error(&format!("parse_item: no match {:?}", cmd))), } @@ -222,7 +226,22 @@ fn parse_item(pair: pest::iterators::Pair) -> JoshResult { Ok(Op::Squash(Some(ids))) } + Rule::filter_scope => { + let mut inner = pair.into_inner(); + let x_filter_spec = inner + .next() + .ok_or_else(|| josh_error("filter_scope: missing filter_spec"))?; + let y_compose = inner + .next() + .ok_or_else(|| josh_error("filter_scope: missing compose"))?; + let x = parse(x_filter_spec.as_str())?; + let y_filters = parse_group(y_compose.as_str())?; + let y = to_filter(Op::Compose(y_filters)); + + let inverted_x = invert(x)?; + Ok(Op::Chain(x, to_filter(Op::Chain(y, inverted_x)))) + } _ => Err(josh_error("parse_item: no match")), } } diff --git a/tests/filter/scope_filter.t b/tests/filter/scope_filter.t new file mode 100644 index 000000000..82aadabd4 --- /dev/null +++ b/tests/filter/scope_filter.t @@ -0,0 +1,134 @@ + $ export TESTTMP=${PWD} + + $ cd ${TESTTMP} + $ git init -q real_repo 1> /dev/null + $ cd real_repo + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ mkdir sub2 + $ echo contents2 > sub2/file2 + $ mkdir sub3 + $ echo contents3 > sub3/file3 + $ git add sub1 sub2 sub3 + $ git commit -m "add files" 1> /dev/null + +Test basic scope filter syntax :[Y] + $ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:/file1]') + $ josh-filter -p ${FILTER_HASH} + sub1 = :/sub1/file1 + $ git read-tree --reset -u ${FILTER_HASH} + $ tree + . + `-- chain + |-- 0 + | `-- subdir + `-- 1 + `-- chain + |-- 0 + | `-- subdir + `-- 1 + `-- prefix + + 7 directories, 3 files + $ cat sub1/file1 + cat: sub1/file1: No such file or directory + [1] + +Test scope filter with multiple filters in compose + $ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:/file1,:/sub2/file2]') + $ josh-filter -p ${FILTER_HASH} + sub1 = :/sub1:[ + :/file1 + :/sub2/file2 + ] + $ git read-tree --reset -u ${FILTER_HASH} + $ tree + . + `-- chain + |-- 0 + | `-- subdir + `-- 1 + `-- chain + |-- 0 + | `-- compose + | |-- 0 + | | `-- subdir + | `-- 1 + | `-- chain + | |-- 0 + | | `-- subdir + | `-- 1 + | `-- subdir + `-- 1 + `-- prefix + + 13 directories, 5 files + +Test scope filter with prefix filter + $ FILTER_HASH=$(josh-filter -i ':<:prefix=sub1>[:prefix=file1]') + $ josh-filter -p ${FILTER_HASH} + :empty + $ git read-tree --reset -u ${FILTER_HASH} + $ tree + . + `-- empty + + 1 directory, 1 file + +Test scope filter with subdir and exclude + $ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:exclude[::file1]]') + $ josh-filter -p ${FILTER_HASH} + sub1 = :/sub1:exclude[::file1] + $ git read-tree --reset -u ${FILTER_HASH} + $ tree + . + `-- chain + |-- 0 + | `-- subdir + `-- 1 + `-- chain + |-- 0 + | `-- exclude + | `-- file + `-- 1 + `-- prefix + + 8 directories, 3 files + +Test scope filter verifies it expands to chain(X, chain(Y, invert(X))) + $ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:/file1]') + $ josh-filter --print-filter ${FILTER_HASH} + error: unexpected argument found + [2] + +Test scope filter with nested filters + $ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:[:/file1,:/sub2/file2]]') + $ josh-filter -p ${FILTER_HASH} + sub1 = :/sub1:[ + :/file1 + :/sub2/file2 + ] + $ git read-tree --reset -u ${FILTER_HASH} + $ tree + . + `-- chain + |-- 0 + | `-- subdir + `-- 1 + `-- chain + |-- 0 + | `-- compose + | |-- 0 + | | `-- subdir + | `-- 1 + | `-- chain + | |-- 0 + | | `-- subdir + | `-- 1 + | `-- subdir + `-- 1 + `-- prefix + + 13 directories, 5 files + diff --git a/tests/proxy/workspace_errors.t b/tests/proxy/workspace_errors.t index 1ba6ab8d1..4e9c38617 100644 --- a/tests/proxy/workspace_errors.t +++ b/tests/proxy/workspace_errors.t @@ -106,7 +106,7 @@ Error in filter remote: 1 | a/b = :b/sub2 remote: | ^--- remote: | - remote: = expected EOI, filter_group, filter_subdir, filter_stored, filter_nop, filter_presub, filter, filter_noarg, filter_message, filter_rev, filter_from, filter_concat, filter_join, filter_replace, or filter_squash + remote: = expected EOI, filter_group, filter_subdir, filter_stored, filter_nop, filter_presub, filter, filter_noarg, filter_message, filter_rev, filter_from, filter_concat, filter_join, filter_replace, filter_squash, or filter_scope remote: remote: a/b = :b/sub2 remote: c = :/sub1