Skip to content

Commit e397477

Browse files
Evan Lightclaremacraeelight
authored
feat: Add regular expression support to text filters (#790)
* initial commit (blank message) * Integrating @claremacrae's fixes * Added PathField test courtesy of @claremacrae * Basic regex matching using PathField; need to support slash delimiters and regex params later * Regex now requires leading and trailing slashes; still no support for regex flags * test: Confirm regex matches is case-sensitive Co-Authored-By: Evan Light <10112+elight@users.noreply.github.com> * test: Add custom matcher for FilterOrErrorMessage Initially testing the PathField. Will be moved to a separate file later. Co-Authored-By: Evan Light <10112+elight@users.noreply.github.com> * refactor: Inline HeadingField.headingRegexp This is in preparation for creating it programmatically. * refactor: Make HeadingField.filterRegexp() programmatically * refactor: Move HeadingField.filterRegexp() to TextField * refactor: DescriptionField now uses TextField.filterRegexp() * test: Re-order expects for glance-ability * Handles regex flags (and linter won't STFU about it) * Refactor to DRY filtering * Test positive case with description field * Test negative regex match with description field * Linter fixes because I turned off my git hooks locally * Another 'fix' by the linter; I don't love its formatting here but I'll just go with it. * Add the link to the appropriate SO post for the regexp * Updated documentation * refactor: Fix '33:43 error Unnecessary escape character: \[ no-useless-escape' Authored-by: @claremacrae WebStorm says the behaviour is unchanged. * refactor: Fix 'Warning:(34, 75) Redundant character escape '\]' in RegExp' Authored-by: @claremacrae * refactor: Formatting changes made by 'yarn lint' Authored-by: @claremacrae * test: Remove console.log call from tests Authored-by: @claremacrae * fix: 'path regex does not match' works and is tested Authored-by: @claremacrae Co-authored-by: Clare Macrae <github@cfmacrae.fastmail.co.uk> Co-authored-by: Evan Light <10112+elight@users.noreply.github.com>
1 parent d48284e commit e397477

File tree

7 files changed

+226
-32
lines changed

7 files changed

+226
-32
lines changed

docs/queries/filters.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ As well as the date-related searches above, these filters search other propertie
134134
- `description (includes|does not include) <string>`
135135
- Matches case-insensitive (disregards capitalization).
136136
- Disregards the global filter when matching.
137+
- `description (regex matches|regex does not match) <JavaScript-style Regex>`
138+
- Matches based on [JavaScript's RegExp implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)
139+
- Supports [JavaScript RegExp Flags](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#advanced_searching_with_flags)
140+
- **_Use with extreme care; this is a tool for software developers or people willing to spend a lot of time reading complicated documentation_**
137141

138142
### Priority
139143

src/Query/Filter/DescriptionField.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,10 @@ import { TextField } from './TextField';
99
* with the global filter (if any) removed.
1010
*/
1111
export class DescriptionField extends TextField {
12-
private static readonly descriptionRegexp =
13-
/^description (includes|does not include) (.*)/;
14-
1512
protected fieldName(): string {
1613
return 'description';
1714
}
1815

19-
protected filterRegexp(): RegExp {
20-
return DescriptionField.descriptionRegexp;
21-
}
22-
2316
/**
2417
* Return the task's description, with any global tag removed
2518
* @param task

src/Query/Filter/HeadingField.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,6 @@ import { TextField } from './TextField';
55
*
66
*/
77
export class HeadingField extends TextField {
8-
private static readonly headingRegexp =
9-
/^heading (includes|does not include) (.*)/;
10-
11-
protected filterRegexp(): RegExp {
12-
return HeadingField.headingRegexp;
13-
}
14-
158
protected fieldName(): string {
169
return 'heading';
1710
}

src/Query/Filter/PathField.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,6 @@ import { TextField } from './TextField';
88
*
99
*/
1010
export class PathField extends TextField {
11-
private static readonly pathRegexp =
12-
/^path (includes|does not include) (.*)/;
13-
14-
protected filterRegexp(): RegExp {
15-
return PathField.pathRegexp;
16-
}
17-
1811
protected fieldName(): string {
1912
return 'path';
2013
}

src/Query/Filter/TextField.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,42 @@ import { FilterOrErrorMessage } from './Filter';
88
* value, such as the description or file path.
99
*/
1010
export abstract class TextField extends Field {
11+
private maybeNegate(match: boolean, filterMethod: String) {
12+
return filterMethod.match(/not/) ? !match : match;
13+
}
1114
public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
1215
const result = new FilterOrErrorMessage();
1316
const match = Field.getMatch(this.filterRegexp(), line);
1417
if (match !== null) {
1518
const filterMethod = match[1];
16-
if (filterMethod === 'includes') {
17-
result.filter = (task: Task) =>
18-
TextField.stringIncludesCaseInsensitive(
19-
this.value(task),
20-
match[2],
21-
);
22-
} else if (match[1] === 'does not include') {
19+
if (['includes', 'does not include'].includes(filterMethod)) {
2320
result.filter = (task: Task) =>
24-
!TextField.stringIncludesCaseInsensitive(
25-
this.value(task),
26-
match[2],
21+
this.maybeNegate(
22+
TextField.stringIncludesCaseInsensitive(
23+
this.value(task),
24+
match[2],
25+
),
26+
filterMethod,
2727
);
28+
} else if (
29+
['regex matches', 'regex does not match'].includes(filterMethod)
30+
) {
31+
// Courtesy of https://stackoverflow.com/questions/17843691/javascript-regex-to-match-a-regex
32+
const regexPattern =
33+
/\/((?![*+?])(?:[^\r\n[/\\]|\\.|\[(?:[^\r\n\]\\]|\\.)*])+)\/((?:g(?:im?|mi?)?|i(?:gm?|mg?)?|m(?:gi?|ig?)?)?)/;
34+
const query = match[2].match(regexPattern);
35+
36+
if (query !== null) {
37+
result.filter = (task: Task) =>
38+
this.maybeNegate(
39+
this.value(task).match(
40+
new RegExp(query[1], query[2]),
41+
) !== null,
42+
filterMethod,
43+
);
44+
} else {
45+
result.error = `cannot parse regex (${this.fieldName()}); check your leading and trailing slashes for your query`;
46+
}
2847
} else {
2948
result.error = `do not understand query filter (${this.fieldName()})`;
3049
}
@@ -43,7 +62,11 @@ export abstract class TextField extends Field {
4362
.includes(needle.toLocaleLowerCase());
4463
}
4564

46-
protected abstract filterRegexp(): RegExp | null;
65+
protected filterRegexp(): RegExp {
66+
return new RegExp(
67+
`^${this.fieldName()} (includes|does not include|regex matches|regex does not match) (.*)`,
68+
);
69+
}
4770

4871
protected abstract fieldName(): string;
4972

tests/Query/Filter/DescriptionField.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,48 @@ function testDescriptionFilter(
1515
testTaskFilter(filter, task, expected);
1616
}
1717

18+
declare global {
19+
namespace jest {
20+
interface Matchers<R> {
21+
toMatchTaskWithDescription(description: string): R;
22+
}
23+
24+
interface Expect {
25+
toMatchTaskWithDescription(description: string): any;
26+
}
27+
28+
interface InverseAsymmetricMatchers {
29+
toMatchTaskWithDescription(description: string): any;
30+
}
31+
}
32+
}
33+
34+
export function toMatchTaskWithDescription(
35+
filter: FilterOrErrorMessage,
36+
description: string,
37+
) {
38+
const task = fromLine({
39+
line: description,
40+
});
41+
42+
const matches = filter.filter!(task);
43+
if (!matches) {
44+
return {
45+
message: () => `unexpected failure to match task: ${description}`,
46+
pass: false,
47+
};
48+
}
49+
50+
return {
51+
message: () => `filter should not have matched task: ${description}`,
52+
pass: true,
53+
};
54+
}
55+
56+
expect.extend({
57+
toMatchTaskWithDescription,
58+
});
59+
1860
describe('description', () => {
1961
it('ignores the global filter when filtering', () => {
2062
// Arrange
@@ -63,4 +105,34 @@ describe('description', () => {
63105
// Cleanup
64106
updateSettings(originalSettings);
65107
});
108+
109+
it('works with regex', () => {
110+
// Arrange
111+
const filter = new DescriptionField().createFilterOrErrorMessage(
112+
'description regex matches /^task/',
113+
);
114+
115+
// Assert
116+
expect(filter).not.toMatchTaskWithDescription(
117+
'- [ ] this does not start with the pattern',
118+
);
119+
expect(filter).toMatchTaskWithDescription(
120+
'- [ ] task does start with the pattern',
121+
);
122+
});
123+
124+
it('works negating regexes', () => {
125+
// Arrange
126+
const filter = new DescriptionField().createFilterOrErrorMessage(
127+
'description regex does not match /^task/',
128+
);
129+
130+
// Assert
131+
expect(filter).toMatchTaskWithDescription(
132+
'- [ ] this does not start with the pattern',
133+
);
134+
expect(filter).not.toMatchTaskWithDescription(
135+
'- [ ] task does start with the pattern',
136+
);
137+
});
66138
});

tests/Query/Filter/PathField.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,77 @@ function testTaskFilterForTaskWithPath(
1212
testFilter(filter, builder.path(path), expected);
1313
}
1414

15+
declare global {
16+
namespace jest {
17+
interface Matchers<R> {
18+
toMatchTaskWithPath(path: string): R;
19+
toBeValid(): R;
20+
}
21+
22+
interface Expect {
23+
toMatchTaskWithPath(path: string): any;
24+
toBeValid(): any;
25+
}
26+
27+
interface InverseAsymmetricMatchers {
28+
toMatchTaskWithPath(path: string): any;
29+
toBeValid(): any;
30+
}
31+
}
32+
}
33+
34+
export function toBeValid(filter: FilterOrErrorMessage) {
35+
if (filter.filter === undefined) {
36+
return {
37+
message: () =>
38+
'unexpected null filter: check your instruction matches your filter class',
39+
pass: false,
40+
};
41+
}
42+
43+
if (filter.error !== undefined) {
44+
return {
45+
message: () =>
46+
'unexpected error message in filter: check your instruction matches your filter class',
47+
pass: false,
48+
};
49+
}
50+
51+
return {
52+
message: () => 'filter is unexpectedly valid',
53+
pass: true,
54+
};
55+
}
56+
57+
export function toMatchTaskWithPath(
58+
filter: FilterOrErrorMessage,
59+
path: string,
60+
) {
61+
const builder = new TaskBuilder();
62+
const task = builder.path(path).build();
63+
64+
const matches = filter.filter!(task);
65+
if (!matches) {
66+
return {
67+
message: () => `unexpected failure to match task: ${path}`,
68+
pass: false,
69+
};
70+
}
71+
72+
return {
73+
message: () => `filter should not have matched task: ${path}`,
74+
pass: true,
75+
};
76+
}
77+
78+
expect.extend({
79+
toMatchTaskWithPath,
80+
});
81+
82+
expect.extend({
83+
toBeValid,
84+
});
85+
1586
describe('path', () => {
1687
it('by path (includes)', () => {
1788
// Arrange
@@ -37,4 +108,49 @@ describe('path', () => {
37108
testTaskFilterForTaskWithPath(filter, '/some/path/file.md', false);
38109
testTaskFilterForTaskWithPath(filter, '/other/path/file.md', true);
39110
});
111+
112+
it('by path (regex matches)', () => {
113+
// Arrange
114+
const filter = new PathField().createFilterOrErrorMessage(
115+
'path regex matches /w.bble/',
116+
);
117+
118+
// Assert
119+
expect(filter).toBeValid();
120+
expect(filter).toMatchTaskWithPath('/some/path/wibble.md');
121+
expect(filter).toMatchTaskWithPath('/some/path/wobble.md');
122+
expect(filter).not.toMatchTaskWithPath('');
123+
expect(filter).not.toMatchTaskWithPath('/some/path/WobblE.md'); // confirm case-sensitive
124+
expect(filter).not.toMatchTaskWithPath('/other/path/file.md');
125+
});
126+
127+
it('by path (regex matches) with flags', () => {
128+
// Arrange
129+
const filter = new PathField().createFilterOrErrorMessage(
130+
'path regex matches /w.bble/i',
131+
);
132+
133+
// Assert
134+
expect(filter).toBeValid();
135+
expect(filter).toMatchTaskWithPath('/some/path/wibble.md');
136+
expect(filter).toMatchTaskWithPath('/some/path/wobble.md');
137+
expect(filter).not.toMatchTaskWithPath('');
138+
expect(filter).toMatchTaskWithPath('/some/path/WobblE.md'); // confirm case-insensitive (flag)
139+
expect(filter).not.toMatchTaskWithPath('/other/path/file.md');
140+
});
141+
142+
it('by path (regex does not match)', () => {
143+
// Arrange
144+
const filter = new PathField().createFilterOrErrorMessage(
145+
'path regex does not match /w.bble/',
146+
);
147+
148+
// Assert
149+
expect(filter).toBeValid();
150+
expect(filter).not.toMatchTaskWithPath('/some/path/wibble.md');
151+
expect(filter).not.toMatchTaskWithPath('/some/path/wobble.md');
152+
expect(filter).toMatchTaskWithPath('');
153+
expect(filter).toMatchTaskWithPath('/some/path/WobblE.md'); // confirm case-sensitive
154+
expect(filter).toMatchTaskWithPath('/other/path/file.md');
155+
});
40156
});

0 commit comments

Comments
 (0)