Skip to content

Commit e8849a6

Browse files
Add "scope" and "invert" syntax shorthands
1 parent 46f4a94 commit e8849a6

File tree

5 files changed

+264
-0
lines changed

5 files changed

+264
-0
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
@@ -33,6 +33,7 @@ filter_spec = { (
3333
| filter_subdir
3434
| filter_stored
3535
| filter_nop
36+
| filter_scope
3637
| filter
3738
| filter_noarg
3839
)+ }
@@ -100,6 +101,14 @@ filter_squash = {
100101
~ ")"
101102
}
102103

104+
filter_scope = {
105+
CMD_START ~ "<"
106+
~ NEWLINE*
107+
~ filter_spec
108+
~ NEWLINE*
109+
~ ">" ~ GROUP_START ~ compose ~ GROUP_END
110+
}
111+
103112
cmd = { ALNUM+ }
104113

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

josh-core/src/filter/mod.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1770,4 +1770,52 @@ mod tests {
17701770
dst_path(parse(":[a=:/x::y/,a/b=:/i]:prefix=c").unwrap())
17711771
);
17721772
}
1773+
1774+
#[test]
1775+
fn invert_filter_parsing_test() {
1776+
// Test that :invert[X] syntax parses correctly
1777+
let filter = parse(":invert[:/sub1]").unwrap();
1778+
// Verify it's not empty
1779+
assert_ne!(filter, empty());
1780+
1781+
// Test with prefix filter (inverse of subdir)
1782+
let filter2 = parse(":invert[:prefix=sub1]").unwrap();
1783+
assert_ne!(filter2, empty());
1784+
1785+
// Test that it produces the correct inverse
1786+
let filter3 = parse(":invert[:/sub1]").unwrap();
1787+
let spec_str = spec(filter3);
1788+
// Should produce prefix (inverse of subdir)
1789+
assert!(spec_str.contains("prefix") || !spec_str.is_empty());
1790+
1791+
// Test with multiple filters in compose
1792+
let filter4 = parse(":invert[:/sub1,:/sub2]").unwrap();
1793+
assert_ne!(filter4, empty());
1794+
}
1795+
1796+
#[test]
1797+
fn scope_filter_parsing_test() {
1798+
// Test that :<X>[Y] syntax parses correctly
1799+
let filter = parse(":<:/sub1>[:/file1]").unwrap();
1800+
// Just verify parsing succeeds (filter may optimize to empty in some cases)
1801+
let _ = filter;
1802+
1803+
// Test with multiple filters in compose
1804+
let filter2 = parse(":<:/sub1>[:/file1,:/file2]").unwrap();
1805+
let _ = filter2;
1806+
1807+
// Test with prefix filter
1808+
let filter3 = parse(":<:prefix=sub1>[:prefix=file1]").unwrap();
1809+
let _ = filter3;
1810+
1811+
// Test with exclude
1812+
let filter4 = parse(":<:/sub1>[:exclude[::file1]]").unwrap();
1813+
let _ = filter4;
1814+
1815+
// Test that it expands to chain structure by checking spec output
1816+
let filter5 = parse(":<:/sub1>[:/file1]").unwrap();
1817+
let spec_str = spec(filter5);
1818+
// The spec should contain the chain representation
1819+
assert!(!spec_str.is_empty());
1820+
}
17731821
}

josh-core/src/filter/parse.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ fn parse_item(pair: pest::iterators::Pair<Rule>) -> JoshResult<Op> {
149149
match *cmd {
150150
"pin" => Ok(Op::Pin(to_filter(Op::Compose(g)))),
151151
"exclude" => Ok(Op::Exclude(to_filter(Op::Compose(g)))),
152+
"invert" => {
153+
let filter = to_filter(Op::Compose(g));
154+
Ok(to_op(invert(filter)?))
155+
}
152156
"subtract" if g.len() == 2 => Ok(Op::Subtract(g[0], g[1])),
153157
_ => Err(josh_error(&format!("parse_item: no match {:?}", cmd))),
154158
}
@@ -222,7 +226,22 @@ fn parse_item(pair: pest::iterators::Pair<Rule>) -> JoshResult<Op> {
222226

223227
Ok(Op::Squash(Some(ids)))
224228
}
229+
Rule::filter_scope => {
230+
let mut inner = pair.into_inner();
231+
let x_filter_spec = inner
232+
.next()
233+
.ok_or_else(|| josh_error("filter_scope: missing filter_spec"))?;
234+
let y_compose = inner
235+
.next()
236+
.ok_or_else(|| josh_error("filter_scope: missing compose"))?;
225237

238+
let x = parse(x_filter_spec.as_str())?;
239+
let y_filters = parse_group(y_compose.as_str())?;
240+
let y = to_filter(Op::Compose(y_filters));
241+
242+
let inverted_x = invert(x)?;
243+
Ok(Op::Chain(x, to_filter(Op::Chain(y, inverted_x))))
244+
}
226245
_ => Err(josh_error("parse_item: no match")),
227246
}
228247
}

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)