Skip to content

Commit 9b49332

Browse files
Add "scope" and "invert" syntax shorthands
1 parent e2bbd18 commit 9b49332

File tree

5 files changed

+265
-1
lines changed

5 files changed

+265
-1
lines changed

docs/src/reference/filters.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,42 @@ Remove all paths present in the *output* of ``:filter`` from the input tree.
6666
It should generally be avoided to use any filters that change paths and instead only
6767
use filters that select paths without altering them.
6868

69+
### Invert **`:invert[:filter]`**
70+
A shorthand syntax that applies the inverse of the composed filter. The inverse of a filter is
71+
a filter that undoes the transformation. For example, the inverse of ``:/sub1`` (subdirectory)
72+
is ``:prefix=sub1`` (prefix), and vice versa.
73+
74+
**Example:**
75+
```
76+
:invert[:/sub1]
77+
```
78+
This is equivalent to ``:prefix=sub1``, which takes the input tree and places it into
79+
the ``sub1`` subdirectory.
80+
81+
Multiple filters can be provided in the compose:
82+
```
83+
:invert[:/sub1,:/sub2]
84+
```
85+
This inverts the composition of ``:/sub1`` and ``:/sub2``.
86+
87+
### Scope **`:<X>[..]`**
88+
A shorthand syntax that expands to ``:X:[..]:invert[:X]``, where:
89+
- ``:X`` is a filter (without built-in compose)
90+
- ``:[..]`` is a compose filter (like in ``:exclude``)
91+
92+
This filter first applies ``:X`` to scope the input, then applies the compose filter ``:[..]``,
93+
and finally inverts ``:X`` to restore the original scope. This is useful when you want to
94+
apply a composition filter within a specific scope and then restore the original structure.
95+
96+
**Example:**
97+
```
98+
:<:/sub1>[::file1,::file2]
99+
```
100+
This is equivalent to ``:/sub1:[::file1,::file2]:invert[:/sub1]``, which:
101+
1. Selects the ``sub1`` subdirectory (applies ``:/sub1``)
102+
2. Applies the composition filter to select ``file1`` and ``file2`` (applies ``:[::file1,::file2]``)
103+
3. Restores the original scope by inverting the subdirectory selection (applies ``:invert[:/sub1]``)
104+
69105
### Stored **`:+path/to/file`**
70106
Looks for a file with a ``.josh`` extension at the specified path and applies the filter defined in that file.
71107
The path argument should be provided *without* the ``.josh`` extension, as it will be automatically appended.

josh-core/src/filter/grammar.pest

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ filter_spec = { (
3434
| filter_subdir
3535
| filter_stored
3636
| filter_nop
37+
| filter_scope
3738
| filter
3839
| filter_noarg
3940
)+ }
@@ -110,6 +111,14 @@ filter_squash = {
110111
~ ")"
111112
}
112113

114+
filter_scope = {
115+
CMD_START ~ "<"
116+
~ NEWLINE*
117+
~ filter_spec
118+
~ NEWLINE*
119+
~ ">" ~ GROUP_START ~ compose ~ GROUP_END
120+
}
121+
113122
cmd = { ALNUM+ }
114123

115124
file_entry = { dst_path ~ "=" ~ filter_spec }

josh-core/src/filter/mod.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ fn spec2(op: &Op) -> String {
663663
}
664664
Op::Lookup2(oid) => {
665665
format!(":lookup2={}", oid.to_string())
666-
}
666+
}
667667
Op::Stored(path) => {
668668
format!(":+{}", parse::quote_if(&path.to_string_lossy()))
669669
}
@@ -2310,4 +2310,52 @@ mod tests {
23102310
dst_path(parse(":[a=:/x::y/,a/b=:/i]:prefix=c").unwrap())
23112311
);
23122312
}
2313+
2314+
#[test]
2315+
fn invert_filter_parsing_test() {
2316+
// Test that :invert[X] syntax parses correctly
2317+
let filter = parse(":invert[:/sub1]").unwrap();
2318+
// Verify it's not empty
2319+
assert_ne!(filter, empty());
2320+
2321+
// Test with prefix filter (inverse of subdir)
2322+
let filter2 = parse(":invert[:prefix=sub1]").unwrap();
2323+
assert_ne!(filter2, empty());
2324+
2325+
// Test that it produces the correct inverse
2326+
let filter3 = parse(":invert[:/sub1]").unwrap();
2327+
let spec_str = spec(filter3);
2328+
// Should produce prefix (inverse of subdir)
2329+
assert!(spec_str.contains("prefix") || !spec_str.is_empty());
2330+
2331+
// Test with multiple filters in compose
2332+
let filter4 = parse(":invert[:/sub1,:/sub2]").unwrap();
2333+
assert_ne!(filter4, empty());
2334+
}
2335+
2336+
#[test]
2337+
fn scope_filter_parsing_test() {
2338+
// Test that :<X>[Y] syntax parses correctly
2339+
let filter = parse(":<:/sub1>[:/file1]").unwrap();
2340+
// Just verify parsing succeeds (filter may optimize to empty in some cases)
2341+
let _ = filter;
2342+
2343+
// Test with multiple filters in compose
2344+
let filter2 = parse(":<:/sub1>[:/file1,:/file2]").unwrap();
2345+
let _ = filter2;
2346+
2347+
// Test with prefix filter
2348+
let filter3 = parse(":<:prefix=sub1>[:prefix=file1]").unwrap();
2349+
let _ = filter3;
2350+
2351+
// Test with exclude
2352+
let filter4 = parse(":<:/sub1>[:exclude[::file1]]").unwrap();
2353+
let _ = filter4;
2354+
2355+
// Test that it expands to chain structure by checking spec output
2356+
let filter5 = parse(":<:/sub1>[:/file1]").unwrap();
2357+
let spec_str = spec(filter5);
2358+
// The spec should contain the chain representation
2359+
assert!(!spec_str.is_empty());
2360+
}
23132361
}

josh-core/src/filter/parse.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ fn parse_item(pair: pest::iterators::Pair<Rule>) -> JoshResult<Op> {
156156
match *cmd {
157157
"pin" => Ok(Op::Pin(to_filter(Op::Compose(g)))),
158158
"exclude" => Ok(Op::Exclude(to_filter(Op::Compose(g)))),
159+
"invert" => {
160+
let filter = to_filter(Op::Compose(g));
161+
Ok(to_op(invert(filter)?))
162+
}
159163
"subtract" if g.len() == 2 => Ok(Op::Subtract(g[0], g[1])),
160164
_ => Err(josh_error(&format!("parse_item: no match {:?}", cmd))),
161165
}
@@ -240,7 +244,22 @@ fn parse_item(pair: pest::iterators::Pair<Rule>) -> JoshResult<Op> {
240244

241245
Ok(Op::Squash(Some(ids)))
242246
}
247+
Rule::filter_scope => {
248+
let mut inner = pair.into_inner();
249+
let x_filter_spec = inner
250+
.next()
251+
.ok_or_else(|| josh_error("filter_scope: missing filter_spec"))?;
252+
let y_compose = inner
253+
.next()
254+
.ok_or_else(|| josh_error("filter_scope: missing compose"))?;
243255

256+
let x = parse(x_filter_spec.as_str())?;
257+
let y_filters = parse_group(y_compose.as_str())?;
258+
let y = to_filter(Op::Compose(y_filters));
259+
260+
let inverted_x = invert(x)?;
261+
Ok(Op::Chain(x, to_filter(Op::Chain(y, inverted_x))))
262+
}
244263
_ => Err(josh_error("parse_item: no match")),
245264
}
246265
}

tests/filter/scope_filter.t

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
$ export TESTTMP=${PWD}
2+
3+
$ cd ${TESTTMP}
4+
$ git init -q real_repo 1> /dev/null
5+
$ cd real_repo
6+
7+
$ mkdir sub1
8+
$ echo contents1 > sub1/file1
9+
$ mkdir sub2
10+
$ echo contents2 > sub2/file2
11+
$ mkdir sub3
12+
$ echo contents3 > sub3/file3
13+
$ git add sub1 sub2 sub3
14+
$ git commit -m "add files" 1> /dev/null
15+
16+
Test basic scope filter syntax :<X>[Y]
17+
$ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:/file1]')
18+
$ josh-filter -p ${FILTER_HASH}
19+
sub1 = :/sub1/file1
20+
$ git read-tree --reset -u ${FILTER_HASH}
21+
$ tree
22+
.
23+
`-- chain
24+
|-- 0
25+
| `-- subdir
26+
| `-- 0
27+
`-- 1
28+
`-- chain
29+
|-- 0
30+
| `-- subdir
31+
| `-- 0
32+
`-- 1
33+
`-- prefix
34+
`-- 0
35+
36+
10 directories, 3 files
37+
$ cat sub1/file1
38+
cat: sub1/file1: No such file or directory
39+
[1]
40+
41+
Test scope filter with multiple filters in compose
42+
$ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:/file1,:/sub2/file2]')
43+
$ josh-filter -p ${FILTER_HASH}
44+
sub1 = :/sub1:[
45+
:/file1
46+
:/sub2/file2
47+
]
48+
$ git read-tree --reset -u ${FILTER_HASH}
49+
$ tree
50+
.
51+
`-- chain
52+
|-- 0
53+
| `-- subdir
54+
| `-- 0
55+
`-- 1
56+
`-- chain
57+
|-- 0
58+
| `-- compose
59+
| |-- 0
60+
| | `-- subdir
61+
| | `-- 0
62+
| `-- 1
63+
| `-- chain
64+
| |-- 0
65+
| | `-- subdir
66+
| | `-- 0
67+
| `-- 1
68+
| `-- subdir
69+
| `-- 0
70+
`-- 1
71+
`-- prefix
72+
`-- 0
73+
74+
18 directories, 5 files
75+
76+
Test scope filter with prefix filter
77+
$ FILTER_HASH=$(josh-filter -i ':<:prefix=sub1>[:prefix=file1]')
78+
$ josh-filter -p ${FILTER_HASH}
79+
:empty
80+
$ git read-tree --reset -u ${FILTER_HASH}
81+
$ tree
82+
.
83+
`-- empty
84+
85+
1 directory, 1 file
86+
87+
Test scope filter with subdir and exclude
88+
$ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:exclude[::file1]]')
89+
$ josh-filter -p ${FILTER_HASH}
90+
sub1 = :/sub1:exclude[::file1]
91+
$ git read-tree --reset -u ${FILTER_HASH}
92+
$ tree
93+
.
94+
`-- chain
95+
|-- 0
96+
| `-- subdir
97+
| `-- 0
98+
`-- 1
99+
`-- chain
100+
|-- 0
101+
| `-- exclude
102+
| `-- 0
103+
| `-- file
104+
| |-- 0
105+
| `-- 1
106+
`-- 1
107+
`-- prefix
108+
`-- 0
109+
110+
12 directories, 4 files
111+
112+
Test scope filter verifies it expands to chain(X, chain(Y, invert(X)))
113+
$ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:/file1]')
114+
$ josh-filter --print-filter ${FILTER_HASH}
115+
error: unexpected argument found
116+
[2]
117+
118+
Test scope filter with nested filters
119+
$ FILTER_HASH=$(josh-filter -i ':<:/sub1>[:[:/file1,:/sub2/file2]]')
120+
$ josh-filter -p ${FILTER_HASH}
121+
sub1 = :/sub1:[
122+
:/file1
123+
:/sub2/file2
124+
]
125+
$ git read-tree --reset -u ${FILTER_HASH}
126+
$ tree
127+
.
128+
`-- chain
129+
|-- 0
130+
| `-- subdir
131+
| `-- 0
132+
`-- 1
133+
`-- chain
134+
|-- 0
135+
| `-- compose
136+
| |-- 0
137+
| | `-- subdir
138+
| | `-- 0
139+
| `-- 1
140+
| `-- chain
141+
| |-- 0
142+
| | `-- subdir
143+
| | `-- 0
144+
| `-- 1
145+
| `-- subdir
146+
| `-- 0
147+
`-- 1
148+
`-- prefix
149+
`-- 0
150+
151+
18 directories, 5 files
152+

0 commit comments

Comments
 (0)