Repository: obsidian-tasks-group/obsidian-tasks (ts)

Issue:
refactor: Replace the duplication of '\s\t>' regular expressions with single source of truth
Reviewing #953, I see 4 regular expressions needed to be changed in order to add support for callouts and block quotes:

![image](https://user-images.githubusercontent.com/4840096/183080050-3737035e-f23f-44f6-b96a-8d84fefec47e.png)

This is an unnecessary duplication of core logic, and a pit-fall for maintainers, as it is easy to not change all locations.

I would like there to be a single source of truth for the identification of the start of a task line.



Pull request:
refactor: Remove regexes from Commands files to Task where they can be tested
<!--- Provide a general summary of your changes in the Title above -->

# Description
As proposed in [this comment](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/982#issuecomment-1207286916), this change removes all regexes from files in `src/Commands/` which are hard to test because of reliance on the Obsidian API. Relevant regexes are copied to `Task.ts` as discussed in the linked comment, and comments are added/updated to try to make them clear. (There was one regex in ToggleDone that I judged not worth keeping since the comments around it were not correct about Obsidian's behavior. Updated the comments instead.)

## Motivation and Context
Better maintainability.
Fixes #982 

## How has this been tested?
Passes automated tests, and smoke tested on Obsidian stable. This PR does not currently add new automated tests for the regexes, but I would be happy to do that, given some guidance on what to add!

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [ ] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


Patch:
diff --git a/docs/installation/index.md b/docs/installation/index.md
index e5faeb80..38824c15 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -12,6 +12,6 @@ Follow the steps below to install Tasks.
 1. Search for "Tasks" in Obsidian's community plugins browser
 2. Enable the plugin in your Obsidian settings (find "Tasks" under "Community plugins").
 3. Check the settings. It makes sense to set the global filter early on (if you want one).
-4. Replace the "Toggle checklist status" hotkey with "Tasks: Toggle Done".
-    - I recommend you remove the original toggle hotkey and set the "Tasks" toggle to `Ctrl + Enter` (or `Cmd + Enter` on a mac).
+4. Replace the "Toggle Checkbox Status" hotkey with "Tasks: Toggle Done".
+    - I recommend you remove the original toggle checkbox hotkey and set the "Tasks" toggle to `Ctrl + Enter` (or `Cmd + Enter` on a mac).
 5. Optional: bind a hotkey to the command `Tasks: Create or edit` to have quick access to the Tasks modal.
diff --git a/docs/queries/filters.md b/docs/queries/filters.md
index 6d1584d1..e630dad2 100644
--- a/docs/queries/filters.md
+++ b/docs/queries/filters.md
@@ -134,6 +134,10 @@ As well as the date-related searches above, these filters search other propertie
 - `description (includes|does not include) <string>`
   - Matches case-insensitive (disregards capitalization).
   - Disregards the global filter when matching.
+- `description (regex matches|regex does not match) <JavaScript-style Regex>`
+  - Matches based on [JavaScript's RegExp implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)
+  - Supports [JavaScript RegExp Flags](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#advanced_searching_with_flags)
+  - **_Use with extreme care; this is a tool for software developers or people willing to spend a lot of time reading complicated documentation_**
 
 ### Priority
 
diff --git a/package.json b/package.json
index 20618bc9..83a95129 100644
--- a/package.json
+++ b/package.json
@@ -26,20 +26,20 @@
     "@arkweid/lefthook": "^0.7.6",
     "@codemirror/view": "^6.2.0",
     "@tsconfig/svelte": "^3.0.0",
-    "@types/jest": "^28.1.6",
-    "@typescript-eslint/eslint-plugin": "^5.33.0",
-    "@typescript-eslint/parser": "^5.33.0",
+    "@types/jest": "^28.1.7",
+    "@typescript-eslint/eslint-plugin": "^5.33.1",
+    "@typescript-eslint/parser": "^5.33.1",
     "async-mutex": "^0.3.1",
     "builtin-modules": "^3.3.0",
     "esbuild": "0.14.54",
     "esbuild-svelte": "^0.7.1",
-    "eslint": "^8.21.0",
+    "eslint": "^8.22.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.22.1",
     "eslint-plugin-prettier": "^4.2.1",
     "jest": "^28.1.3",
     "jest-environment-jsdom": "^28.1.3",
-    "markdownlint-cli2": "^0.5.0",
+    "markdownlint-cli2": "^0.5.1",
     "moment": "^2.29.4",
     "obsidian": "0.14.6",
     "prettier": "^2.2.1",
@@ -47,7 +47,7 @@
     "svelte": "^3.48.0",
     "svelte-check": "^2.7.2",
     "svelte-preprocess": "^4.10.1",
-    "ts-jest": "^28.0.7",
+    "ts-jest": "^28.0.8",
     "tslib": "^2.3.1",
     "typescript": "^4.4.4"
   },
diff --git a/src/Cache.ts b/src/Cache.ts
index be05b6d6..c779d8d9 100644
--- a/src/Cache.ts
+++ b/src/Cache.ts
@@ -1,6 +1,6 @@
 import { MetadataCache, TAbstractFile, TFile, Vault } from 'obsidian';
 import type { CachedMetadata, EventRef } from 'obsidian';
-import type { ListItemCache, SectionCache } from 'obsidian';
+import type { HeadingCache, ListItemCache, SectionCache } from 'obsidian';
 import { Mutex } from 'async-mutex';
 
 import { Task } from './Task';
@@ -201,24 +201,25 @@ export class Cache {
             return;
         }
 
-        let listItems = fileCache.listItems;
-        if (listItems === undefined) {
-            // When there is no list items cache, there are no tasks.
-            // Still continue to notify watchers of removal.
-            listItems = [];
-        }
-
         const oldTasks = this.tasks.filter((task: Task) => {
             return task.path === file.path;
         });
 
-        const fileContent = await this.vault.cachedRead(file);
-        const newTasks = Cache.getTasksFromFileContent(
-            fileContent,
-            listItems,
-            fileCache,
-            file,
-        );
+        const listItems = fileCache.listItems;
+        // When there is no list items cache, there are no tasks.
+        // Still continue to notify watchers of removal.
+
+        let newTasks: Task[] = [];
+        if (listItems !== undefined) {
+            // Only read the file and process for tasks if there are list items.
+            const fileContent = await this.vault.cachedRead(file);
+            newTasks = Cache.getTasksFromFileContent(
+                fileContent,
+                listItems,
+                fileCache,
+                file,
+            );
+        }
 
         // If there are no changes in any of the tasks, there's
         // nothing to do, so just return.
@@ -272,10 +273,10 @@ export class Cache {
                 ) {
                     // We went past the current section (or this is the first task).
                     // Find the section that is relevant for this task and the following of the same section.
-                    currentSection = Cache.getSection({
-                        lineNumberTask: listItem.position.start.line,
-                        sections: fileCache.sections,
-                    });
+                    currentSection = Cache.getSection(
+                        listItem.position.start.line,
+                        fileCache.sections,
+                    );
                     sectionIndex = 0;
                 }
 
@@ -290,11 +291,10 @@ export class Cache {
                     path: file.path,
                     sectionStart: currentSection.position.start.line,
                     sectionIndex,
-                    precedingHeader: Cache.getPrecedingHeader({
-                        lineNumberTask: listItem.position.start.line,
-                        sections: fileCache.sections,
-                        fileLines,
-                    }),
+                    precedingHeader: Cache.getPrecedingHeader(
+                        listItem.position.start.line,
+                        fileCache.headings,
+                    ),
                 });
 
                 if (task !== null) {
@@ -307,13 +307,10 @@ export class Cache {
         return tasks;
     }
 
-    private static getSection({
-        lineNumberTask,
-        sections,
-    }: {
-        lineNumberTask: number;
-        sections: SectionCache[] | undefined;
-    }): SectionCache | null {
+    private static getSection(
+        lineNumberTask: number,
+        sections: SectionCache[] | undefined,
+    ): SectionCache | null {
         if (sections === undefined) {
             return null;
         }
@@ -330,44 +327,22 @@ export class Cache {
         return null;
     }
 
-    private static getPrecedingHeader({
-        lineNumberTask,
-        sections,
-        fileLines,
-    }: {
-        lineNumberTask: number;
-        sections: SectionCache[] | undefined;
-        fileLines: string[];
-    }): string | null {
-        if (sections === undefined) {
+    private static getPrecedingHeader(
+        lineNumberTask: number,
+        headings: HeadingCache[] | undefined,
+    ): string | null {
+        if (headings === undefined) {
             return null;
         }
 
-        let precedingHeaderSection: SectionCache | undefined;
-        for (const section of sections) {
-            if (section.type === 'heading') {
-                if (section.position.start.line > lineNumberTask) {
-                    // Break out of the loop as the last header was the preceding one.
-                    break;
-                }
-                precedingHeaderSection = section;
-            }
-        }
-        if (precedingHeaderSection === undefined) {
-            return null;
-        }
+        let precedingHeader: string | null = null;
 
-        const lineNumberPrecedingHeader =
-            precedingHeaderSection.position.start.line;
-
-        const linePrecedingHeader = fileLines[lineNumberPrecedingHeader];
-
-        const headerRegex = /^#+ +(.*)/u;
-        const headerMatch = linePrecedingHeader.match(headerRegex);
-        if (headerMatch === null) {
-            return null;
-        } else {
-            return headerMatch[1];
+        for (const heading of headings) {
+            if (heading.position.start.line > lineNumberTask) {
+                return precedingHeader;
+            }
+            precedingHeader = heading.heading;
         }
+        return precedingHeader;
     }
 }
diff --git a/src/Commands/CreateOrEdit.ts b/src/Commands/CreateOrEdit.ts
index bae133fc..e7c5e647 100644
--- a/src/Commands/CreateOrEdit.ts
+++ b/src/Commands/CreateOrEdit.ts
@@ -58,8 +58,7 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
 
     // If we are not on a line of a task, we take what we have.
     // The non-task line can still be a checklist, for example if it is lacking the global filter.
-    const nonTaskRegex: RegExp = /^([\s\t>]*)[-*]? *(\[(.)\])? *(.*)/u;
-    const nonTaskMatch = line.match(nonTaskRegex);
+    const nonTaskMatch = line.match(Task.nonTaskRegex);
     if (nonTaskMatch === null) {
         // Should never happen; everything in the regex is optional.
         console.error('Tasks: Cannot create task on line:', line);
diff --git a/src/Commands/ToggleDone.ts b/src/Commands/ToggleDone.ts
index 30545799..0080c848 100644
--- a/src/Commands/ToggleDone.ts
+++ b/src/Commands/ToggleDone.ts
@@ -34,8 +34,8 @@ export const toggleDone = (checking: boolean, editor: Editor, view: View) => {
     const toggledLine = toggleLine({ line, path });
     editor.setLine(lineNumber, toggledLine);
 
-    // The cursor is moved to the end of the line by default.
-    // If there is text on the line, put the cursor back where it was on the line.
+    // The cursor is moved to either the beginning of the line (usually) or the end of the line (if it was already at the end) by default.
+    // Put the cursor back where it was on the line (if there is "text" on the line -- preserves behavior but buggy, will fix in future PR).
     if (/[^ [\]*-]/.test(toggledLine)) {
         editor.setCursor({
             line: cursorPosition.line,
@@ -72,13 +72,12 @@ const toggleLine = ({ line, path }: { line: string; path: string }): string => {
             // 1. a list item
             // 2. a simple text line
 
-            const listItemRegex = /^([\s\t>]*)([-*])/;
-            if (listItemRegex.test(line)) {
+            if (Task.listItemRegex.test(line)) {
                 // Let's convert the list item to a checklist item.
-                toggledLine = line.replace(listItemRegex, '$1$2 [ ]');
+                toggledLine = line.replace(Task.listItemRegex, '$1$2 [ ]');
             } else {
                 // Let's convert the line to a list item.
-                toggledLine = line.replace(/^([\s\t>]*)/, '$1- ');
+                toggledLine = line.replace(Task.indentationRegex, '$1- ');
             }
         }
     }
diff --git a/src/Query/Filter/DescriptionField.ts b/src/Query/Filter/DescriptionField.ts
index fa9c5577..d31bb931 100644
--- a/src/Query/Filter/DescriptionField.ts
+++ b/src/Query/Filter/DescriptionField.ts
@@ -9,17 +9,10 @@ import { TextField } from './TextField';
  * with the global filter (if any) removed.
  */
 export class DescriptionField extends TextField {
-    private static readonly descriptionRegexp =
-        /^description (includes|does not include) (.*)/;
-
     protected fieldName(): string {
         return 'description';
     }
 
-    protected filterRegexp(): RegExp {
-        return DescriptionField.descriptionRegexp;
-    }
-
     /**
      * Return the task's description, with any global tag removed
      * @param task
diff --git a/src/Query/Filter/HeadingField.ts b/src/Query/Filter/HeadingField.ts
index d1aa6a52..403e1a8a 100644
--- a/src/Query/Filter/HeadingField.ts
+++ b/src/Query/Filter/HeadingField.ts
@@ -5,13 +5,6 @@ import { TextField } from './TextField';
  *
  */
 export class HeadingField extends TextField {
-    private static readonly headingRegexp =
-        /^heading (includes|does not include) (.*)/;
-
-    protected filterRegexp(): RegExp {
-        return HeadingField.headingRegexp;
-    }
-
     protected fieldName(): string {
         return 'heading';
     }
diff --git a/src/Query/Filter/PathField.ts b/src/Query/Filter/PathField.ts
index e2ac4b33..c19294e3 100644
--- a/src/Query/Filter/PathField.ts
+++ b/src/Query/Filter/PathField.ts
@@ -8,13 +8,6 @@ import { TextField } from './TextField';
  *
  */
 export class PathField extends TextField {
-    private static readonly pathRegexp =
-        /^path (includes|does not include) (.*)/;
-
-    protected filterRegexp(): RegExp {
-        return PathField.pathRegexp;
-    }
-
     protected fieldName(): string {
         return 'path';
     }
diff --git a/src/Query/Filter/TextField.ts b/src/Query/Filter/TextField.ts
index 497f2725..ddbde515 100644
--- a/src/Query/Filter/TextField.ts
+++ b/src/Query/Filter/TextField.ts
@@ -8,23 +8,42 @@ import { FilterOrErrorMessage } from './Filter';
  * value, such as the description or file path.
  */
 export abstract class TextField extends Field {
+    private maybeNegate(match: boolean, filterMethod: String) {
+        return filterMethod.match(/not/) ? !match : match;
+    }
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
         const result = new FilterOrErrorMessage();
         const match = Field.getMatch(this.filterRegexp(), line);
         if (match !== null) {
             const filterMethod = match[1];
-            if (filterMethod === 'includes') {
-                result.filter = (task: Task) =>
-                    TextField.stringIncludesCaseInsensitive(
-                        this.value(task),
-                        match[2],
-                    );
-            } else if (match[1] === 'does not include') {
+            if (['includes', 'does not include'].includes(filterMethod)) {
                 result.filter = (task: Task) =>
-                    !TextField.stringIncludesCaseInsensitive(
-                        this.value(task),
-                        match[2],
+                    this.maybeNegate(
+                        TextField.stringIncludesCaseInsensitive(
+                            this.value(task),
+                            match[2],
+                        ),
+                        filterMethod,
                     );
+            } else if (
+                ['regex matches', 'regex does not match'].includes(filterMethod)
+            ) {
+                // Courtesy of https://stackoverflow.com/questions/17843691/javascript-regex-to-match-a-regex
+                const regexPattern =
+                    /\/((?![*+?])(?:[^\r\n[/\\]|\\.|\[(?:[^\r\n\]\\]|\\.)*])+)\/((?:g(?:im?|mi?)?|i(?:gm?|mg?)?|m(?:gi?|ig?)?)?)/;
+                const query = match[2].match(regexPattern);
+
+                if (query !== null) {
+                    result.filter = (task: Task) =>
+                        this.maybeNegate(
+                            this.value(task).match(
+                                new RegExp(query[1], query[2]),
+                            ) !== null,
+                            filterMethod,
+                        );
+                } else {
+                    result.error = `cannot parse regex (${this.fieldName()}); check your leading and trailing slashes for your query`;
+                }
             } else {
                 result.error = `do not understand query filter (${this.fieldName()})`;
             }
@@ -43,7 +62,11 @@ export abstract class TextField extends Field {
             .includes(needle.toLocaleLowerCase());
     }
 
-    protected abstract filterRegexp(): RegExp | null;
+    protected filterRegexp(): RegExp {
+        return new RegExp(
+            `^${this.fieldName()} (includes|does not include|regex matches|regex does not match) (.*)`,
+        );
+    }
 
     protected abstract fieldName(): string;
 
diff --git a/src/Task.ts b/src/Task.ts
index 9cfee567..079b8da9 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -84,11 +84,46 @@ export class Task {
 
     public static readonly dateFormat = 'YYYY-MM-DD';
 
+    // Matches indentation before a list marker (including > for potentially nested blockquotes or Obsidian callouts)
+    public static readonly indentationRegex = /^([\s\t>]*)/;
+
+    // Matches (but does not save) - or * list markers.
+    public static readonly listMarkerRegex = /[-*]/;
+
+    // Matches a checkbox and saves the status character inside
+    public static readonly checkboxRegex = /\[(.)\]/u;
+
+    // Matches the rest of the task after the checkbox.
+    public static readonly afterCheckboxRegex = / *(.*)/u;
+
     // Main regex for parsing a line. It matches the following:
-    // - Indentation (including > for potentially nested blockquotes or Obsidian callouts)
+    // - Indentation
     // - Status character
     // - Rest of task after checkbox markdown
-    public static readonly taskRegex = /^([\s\t>]*)[-*] +\[(.)\] *(.*)/u;
+    public static readonly taskRegex = new RegExp(
+        this.indentationRegex.source +
+            this.listMarkerRegex.source +
+            ' +' +
+            this.checkboxRegex.source +
+            this.afterCheckboxRegex.source,
+        'u',
+    );
+
+    // Used with the "Create or Edit Task" command to parse indentation and status if present
+    public static readonly nonTaskRegex = new RegExp(
+        this.indentationRegex.source +
+            this.listMarkerRegex.source +
+            '? *(' +
+            this.checkboxRegex.source +
+            ')?' +
+            this.afterCheckboxRegex.source,
+        'u',
+    );
+
+    // Used with "Toggle Done" command to detect a list item that can get a checkbox added to it.
+    public static readonly listItemRegex = new RegExp(
+        this.indentationRegex.source + '(' + this.listMarkerRegex.source + ')',
+    );
 
     // Match on block link at end.
     public static readonly blockLinkRegex = / \^[a-zA-Z0-9-]+$/u;
diff --git a/yarn.lock b/yarn.lock
index 8eb0f8e4..649e5312 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -941,12 +941,12 @@
   dependencies:
     "@types/istanbul-lib-report" "*"
 
-"@types/jest@^28.1.6":
-  version "28.1.6"
-  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-28.1.6.tgz#d6a9cdd38967d2d746861fb5be6b120e38284dd4"
-  integrity sha512-0RbGAFMfcBJKOmqRazM8L98uokwuwD5F8rHrv/ZMbrZBwVOWZUyPG6VFNscjYr/vjM3Vu4fRrCPbOs42AfemaQ==
+"@types/jest@^28.1.7":
+  version "28.1.7"
+  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-28.1.7.tgz#a680c5d05b69634c2d54a63cb106d7fb1adaba16"
+  integrity sha512-acDN4VHD40V24tgu0iC44jchXavRNVFXQ/E6Z5XNsswgoSO/4NgsXoEYmPUGookKldlZQyIpmrEXsHI9cA3ZTA==
   dependencies:
-    jest-matcher-utils "^28.0.0"
+    expect "^28.0.0"
     pretty-format "^28.0.0"
 
 "@types/jsdom@^16.2.4":
@@ -1024,14 +1024,14 @@
   dependencies:
     "@types/yargs-parser" "*"
 
-"@typescript-eslint/eslint-plugin@^5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.0.tgz#059798888720ec52ffa96c5f868e31a8f70fa3ec"
-  integrity sha512-jHvZNSW2WZ31OPJ3enhLrEKvAZNyAFWZ6rx9tUwaessTc4sx9KmgMNhVcqVAl1ETnT5rU5fpXTLmY9YvC1DCNg==
+"@typescript-eslint/eslint-plugin@^5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz#c0a480d05211660221eda963cc844732fe9b1714"
+  integrity sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ==
   dependencies:
-    "@typescript-eslint/scope-manager" "5.33.0"
-    "@typescript-eslint/type-utils" "5.33.0"
-    "@typescript-eslint/utils" "5.33.0"
+    "@typescript-eslint/scope-manager" "5.33.1"
+    "@typescript-eslint/type-utils" "5.33.1"
+    "@typescript-eslint/utils" "5.33.1"
     debug "^4.3.4"
     functional-red-black-tree "^1.0.1"
     ignore "^5.2.0"
@@ -1039,69 +1039,69 @@
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/parser@^5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.33.0.tgz#26ec3235b74f0667414613727cb98f9b69dc5383"
-  integrity sha512-cgM5cJrWmrDV2KpvlcSkelTBASAs1mgqq+IUGKJvFxWrapHpaRy5EXPQz9YaKF3nZ8KY18ILTiVpUtbIac86/w==
+"@typescript-eslint/parser@^5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.33.1.tgz#e4b253105b4d2a4362cfaa4e184e2d226c440ff3"
+  integrity sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA==
   dependencies:
-    "@typescript-eslint/scope-manager" "5.33.0"
-    "@typescript-eslint/types" "5.33.0"
-    "@typescript-eslint/typescript-estree" "5.33.0"
+    "@typescript-eslint/scope-manager" "5.33.1"
+    "@typescript-eslint/types" "5.33.1"
+    "@typescript-eslint/typescript-estree" "5.33.1"
     debug "^4.3.4"
 
-"@typescript-eslint/scope-manager@5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.33.0.tgz#509d7fa540a2c58f66bdcfcf278a3fa79002e18d"
-  integrity sha512-/Jta8yMNpXYpRDl8EwF/M8It2A9sFJTubDo0ATZefGXmOqlaBffEw0ZbkbQ7TNDK6q55NPHFshGBPAZvZkE8Pw==
+"@typescript-eslint/scope-manager@5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz#8d31553e1b874210018ca069b3d192c6d23bc493"
+  integrity sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA==
   dependencies:
-    "@typescript-eslint/types" "5.33.0"
-    "@typescript-eslint/visitor-keys" "5.33.0"
+    "@typescript-eslint/types" "5.33.1"
+    "@typescript-eslint/visitor-keys" "5.33.1"
 
-"@typescript-eslint/type-utils@5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.33.0.tgz#92ad1fba973c078d23767ce2d8d5a601baaa9338"
-  integrity sha512-2zB8uEn7hEH2pBeyk3NpzX1p3lF9dKrEbnXq1F7YkpZ6hlyqb2yZujqgRGqXgRBTHWIUG3NGx/WeZk224UKlIA==
+"@typescript-eslint/type-utils@5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz#1a14e94650a0ae39f6e3b77478baff002cec4367"
+  integrity sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g==
   dependencies:
-    "@typescript-eslint/utils" "5.33.0"
+    "@typescript-eslint/utils" "5.33.1"
     debug "^4.3.4"
     tsutils "^3.21.0"
 
-"@typescript-eslint/types@5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.33.0.tgz#d41c584831805554b063791338b0220b613a275b"
-  integrity sha512-nIMt96JngB4MYFYXpZ/3ZNU4GWPNdBbcB5w2rDOCpXOVUkhtNlG2mmm8uXhubhidRZdwMaMBap7Uk8SZMU/ppw==
+"@typescript-eslint/types@5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.33.1.tgz#3faef41793d527a519e19ab2747c12d6f3741ff7"
+  integrity sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ==
 
-"@typescript-eslint/typescript-estree@5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.0.tgz#02d9c9ade6f4897c09e3508c27de53ad6bfa54cf"
-  integrity sha512-tqq3MRLlggkJKJUrzM6wltk8NckKyyorCSGMq4eVkyL5sDYzJJcMgZATqmF8fLdsWrW7OjjIZ1m9v81vKcaqwQ==
+"@typescript-eslint/typescript-estree@5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz#a573bd360790afdcba80844e962d8b2031984f34"
+  integrity sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA==
   dependencies:
-    "@typescript-eslint/types" "5.33.0"
-    "@typescript-eslint/visitor-keys" "5.33.0"
+    "@typescript-eslint/types" "5.33.1"
+    "@typescript-eslint/visitor-keys" "5.33.1"
     debug "^4.3.4"
     globby "^11.1.0"
     is-glob "^4.0.3"
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/utils@5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.33.0.tgz#46797461ce3146e21c095d79518cc0f8ec574038"
-  integrity sha512-JxOAnXt9oZjXLIiXb5ZIcZXiwVHCkqZgof0O8KPgz7C7y0HS42gi75PdPlqh1Tf109M0fyUw45Ao6JLo7S5AHw==
+"@typescript-eslint/utils@5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.33.1.tgz#171725f924fe1fe82bb776522bb85bc034e88575"
+  integrity sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ==
   dependencies:
     "@types/json-schema" "^7.0.9"
-    "@typescript-eslint/scope-manager" "5.33.0"
-    "@typescript-eslint/types" "5.33.0"
-    "@typescript-eslint/typescript-estree" "5.33.0"
+    "@typescript-eslint/scope-manager" "5.33.1"
+    "@typescript-eslint/types" "5.33.1"
+    "@typescript-eslint/typescript-estree" "5.33.1"
     eslint-scope "^5.1.1"
     eslint-utils "^3.0.0"
 
-"@typescript-eslint/visitor-keys@5.33.0":
-  version "5.33.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.0.tgz#fbcbb074e460c11046e067bc3384b5d66b555484"
-  integrity sha512-/XsqCzD4t+Y9p5wd9HZiptuGKBlaZO5showwqODii5C0nZawxWLF+Q6k5wYHBrQv96h6GYKyqqMHCSTqta8Kiw==
+"@typescript-eslint/visitor-keys@5.33.1":
+  version "5.33.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz#0155c7571c8cd08956580b880aea327d5c34a18b"
+  integrity sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg==
   dependencies:
-    "@typescript-eslint/types" "5.33.0"
+    "@typescript-eslint/types" "5.33.1"
     eslint-visitor-keys "^3.3.0"
 
 abab@^2.0.5, abab@^2.0.6:
@@ -2000,10 +2000,10 @@ eslint-visitor-keys@^3.3.0:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
   integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
 
-eslint@^8.21.0:
-  version "8.21.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.21.0.tgz#1940a68d7e0573cef6f50037addee295ff9be9ef"
-  integrity sha512-/XJ1+Qurf1T9G2M5IHrsjp+xrGT73RZf23xA1z5wB1ZzzEAWSZKvRwhWxTFp1rvkvCfwcvAUNAP31bhKTTGfDA==
+eslint@^8.22.0:
+  version "8.22.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.22.0.tgz#78fcb044196dfa7eef30a9d65944f6f980402c48"
+  integrity sha512-ci4t0sz6vSRKdmkOGmprBo6fmI4PrphDFMy5JEq/fNS0gQkJM3rLmrqcp8ipMcdobH3KtUP40KniAE9W19S4wA==
   dependencies:
     "@eslint/eslintrc" "^1.3.0"
     "@humanwhocodes/config-array" "^0.10.4"
@@ -2113,7 +2113,7 @@ exit@^0.1.2:
   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
   integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=
 
-expect@^28.1.3:
+expect@^28.0.0, expect@^28.1.3:
   version "28.1.3"
   resolved "https://registry.yarnpkg.com/expect/-/expect-28.1.3.tgz#90a7c1a124f1824133dd4533cce2d2bdcb6603ec"
   integrity sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g==
@@ -2867,7 +2867,7 @@ jest-leak-detector@^28.1.3:
     jest-get-type "^28.0.2"
     pretty-format "^28.1.3"
 
-jest-matcher-utils@^28.0.0, jest-matcher-utils@^28.1.3:
+jest-matcher-utils@^28.1.3:
   version "28.1.3"
   resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz#5a77f1c129dd5ba3b4d7fc20728806c78893146e"
   integrity sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==
@@ -3278,22 +3278,22 @@ markdownlint-cli2-formatter-default@0.0.3:
   resolved "https://registry.yarnpkg.com/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.3.tgz#5aecd6e576ad18801b76e58bbbaf0e916c583ab8"
   integrity sha512-QEAJitT5eqX1SNboOD+SO/LNBpu4P4je8JlR02ug2cLQAqmIhh8IJnSK7AcaHBHhNADqdGydnPpQOpsNcEEqCw==
 
-markdownlint-cli2@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/markdownlint-cli2/-/markdownlint-cli2-0.5.0.tgz#4d2c3545d2ab4e8b1d38dcb81e207602061eba32"
-  integrity sha512-lUirs0NEuIOIwfWCjVqBBKB5VXD+OceyviHuyjwOUAj/DWm5Pf8iJ23nMo/0ySH6svQJ9ddC+Dsq/X5Hd+yvnA==
+markdownlint-cli2@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/markdownlint-cli2/-/markdownlint-cli2-0.5.1.tgz#b55b89301f422231a0fc6265794b28cf4da95a82"
+  integrity sha512-f3Nb1GF/c8YSrV/FntsCWzpa5mLFJRlO+wzEgv+lkNQjU6MZflUwc2FbyEDPTo6oVhP2VyUOkK0GkFgfuktl1w==
   dependencies:
     globby "13.1.2"
-    markdownlint "0.26.1"
+    markdownlint "0.26.2"
     markdownlint-cli2-formatter-default "0.0.3"
     micromatch "4.0.5"
     strip-json-comments "5.0.0"
     yaml "2.1.1"
 
-markdownlint@0.26.1:
-  version "0.26.1"
-  resolved "https://registry.yarnpkg.com/markdownlint/-/markdownlint-0.26.1.tgz#0f6ea2d0fc08c4f41c6e001b467736405327a392"
-  integrity sha512-8sLz1ktz5s4E0IDum2H9aiWLQU7RA5Eket9HUW5IRwfFnW2RD2ZyqYePW+z71tMc7lrFZc1+yPmlN9lirbJnlg==
+markdownlint@0.26.2:
+  version "0.26.2"
+  resolved "https://registry.yarnpkg.com/markdownlint/-/markdownlint-0.26.2.tgz#11d3d03e7f0dd3c2e239753ee8fd064a861d9237"
+  integrity sha512-2Am42YX2Ex5SQhRq35HxYWDfz1NLEOZWWN25nqd2h3AHRKsGRE+Qg1gt1++exW792eXTrR4jCNHfShfWk9Nz8w==
   dependencies:
     markdown-it "13.0.1"
 
@@ -4127,10 +4127,10 @@ tr46@^3.0.0:
   dependencies:
     punycode "^2.1.1"
 
-ts-jest@^28.0.7:
-  version "28.0.7"
-  resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-28.0.7.tgz#e18757a9e44693da9980a79127e5df5a98b37ac6"
-  integrity sha512-wWXCSmTwBVmdvWrOpYhal79bDpioDy4rTT+0vyUnE3ZzM7LOAAGG9NXwzkEL/a516rQEgnMmS/WKP9jBPCVJyA==
+ts-jest@^28.0.8:
+  version "28.0.8"
+  resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-28.0.8.tgz#cd204b8e7a2f78da32cf6c95c9a6165c5b99cc73"
+  integrity sha512-5FaG0lXmRPzApix8oFG8RKjAz4ehtm8yMKOTy5HX3fY6W8kmvOrmcY0hKDElW52FJov+clhUbrKAqofnj4mXTg==
   dependencies:
     bs-logger "0.x"
     fast-json-stable-stringify "2.x"


Test patch:
diff --git a/tests/CustomMatchers/CustomMatchersForFilters.ts b/tests/CustomMatchers/CustomMatchersForFilters.ts
new file mode 100644
index 00000000..9f39b059
--- /dev/null
+++ b/tests/CustomMatchers/CustomMatchersForFilters.ts
@@ -0,0 +1,141 @@
+import type { FilterOrErrorMessage } from '../../src/Query/Filter/Filter';
+import { fromLine } from '../TestHelpers';
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
+
+/**
+ @summary
+ This file contains Jest custom matchers, for idiomatic testing of filtering
+ via Field classes.
+
+ @description
+ These matchers are a more idiomatic way of testing custom objects via
+ the Jest test framework than the helper functions in tests/TestingTools/
+ and various testing helpers in individual x.test.ts files.
+ <br>
+
+ When they fail, they show the line number at the call site, rather
+ than some line buried down in the helper function, in other words, a much
+ more useful call stack/traceback.
+ <br>
+
+ They can also generate much more informative error messages describing
+ the failure.
+ <br>
+
+ Example usage (shown for  {@link toMatchTaskFromLine}, but other matchers are available:
+ <br>
+
+ @example
+
+ // Setup:
+ import { toMatchTaskFromLine } from '<relative-path>/CustomMatchersForFilters';
+
+ expect.extend({
+    toMatchTaskFromLine,
+});
+
+ // Inside it() and describe() blocks:
+ it('works negating regexes', () => {
+        // Arrange
+        const filter = new DescriptionField().createFilterOrErrorMessage(
+            'description regex does not match /^task/',
+        );
+
+        // Assert
+        expect(filter).toMatchTaskFromLine(
+            '- [ ] this does not start with the pattern',
+        );
+        expect(filter).not.toMatchTaskFromLine(
+            '- [ ] task does start with the pattern',
+        );
+    });
+
+ */
+
+declare global {
+    namespace jest {
+        interface Matchers<R> {
+            toBeValid(): R;
+            toMatchTaskFromLine(line: string): R;
+            toMatchTaskWithPath(path: string): R;
+        }
+
+        interface Expect {
+            toBeValid(): any;
+            toMatchTaskFromLine(line: string): any;
+            toMatchTaskWithPath(path: string): any;
+        }
+
+        interface InverseAsymmetricMatchers {
+            toBeValid(): any;
+            toMatchTaskFromLine(line: string): any;
+            toMatchTaskWithPath(path: string): any;
+        }
+    }
+}
+
+export function toBeValid(filter: FilterOrErrorMessage) {
+    if (filter.filter === undefined) {
+        return {
+            message: () =>
+                'unexpected null filter: check your instruction matches your filter class',
+            pass: false,
+        };
+    }
+
+    if (filter.error !== undefined) {
+        return {
+            message: () =>
+                'unexpected error message in filter: check your instruction matches your filter class',
+            pass: false,
+        };
+    }
+
+    return {
+        message: () => 'filter is unexpectedly valid',
+        pass: true,
+    };
+}
+
+export function toMatchTaskFromLine(
+    filter: FilterOrErrorMessage,
+    line: string,
+) {
+    const task = fromLine({
+        line: line,
+    });
+
+    const matches = filter.filter!(task);
+    if (!matches) {
+        return {
+            message: () => `unexpected failure to match task: ${line}`,
+            pass: false,
+        };
+    }
+
+    return {
+        message: () => `filter should not have matched task: ${line}`,
+        pass: true,
+    };
+}
+
+export function toMatchTaskWithPath(
+    filter: FilterOrErrorMessage,
+    path: string,
+) {
+    const builder = new TaskBuilder();
+    const task = builder.path(path).build();
+
+    const matches = filter.filter!(task);
+    if (!matches) {
+        return {
+            message: () => `unexpected failure to match task: ${path}`,
+            pass: false,
+        };
+    }
+
+    return {
+        message: () => `filter should not have matched task: ${path}`,
+        pass: true,
+    };
+}
diff --git a/tests/Query/Filter/DescriptionField.test.ts b/tests/Query/Filter/DescriptionField.test.ts
index a2986895..feefc2cd 100644
--- a/tests/Query/Filter/DescriptionField.test.ts
+++ b/tests/Query/Filter/DescriptionField.test.ts
@@ -3,6 +3,7 @@ import { getSettings, updateSettings } from '../../../src/config/Settings';
 import { testTaskFilter } from '../../TestingTools/FilterTestHelpers';
 import { fromLine } from '../../TestHelpers';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
+import { toMatchTaskFromLine } from '../../CustomMatchers/CustomMatchersForFilters';
 
 function testDescriptionFilter(
     filter: FilterOrErrorMessage,
@@ -15,6 +16,10 @@ function testDescriptionFilter(
     testTaskFilter(filter, task, expected);
 }
 
+expect.extend({
+    toMatchTaskFromLine,
+});
+
 describe('description', () => {
     it('ignores the global filter when filtering', () => {
         // Arrange
@@ -63,4 +68,34 @@ describe('description', () => {
         // Cleanup
         updateSettings(originalSettings);
     });
+
+    it('works with regex', () => {
+        // Arrange
+        const filter = new DescriptionField().createFilterOrErrorMessage(
+            'description regex matches /^task/',
+        );
+
+        // Assert
+        expect(filter).not.toMatchTaskFromLine(
+            '- [ ] this does not start with the pattern',
+        );
+        expect(filter).toMatchTaskFromLine(
+            '- [ ] task does start with the pattern',
+        );
+    });
+
+    it('works negating regexes', () => {
+        // Arrange
+        const filter = new DescriptionField().createFilterOrErrorMessage(
+            'description regex does not match /^task/',
+        );
+
+        // Assert
+        expect(filter).toMatchTaskFromLine(
+            '- [ ] this does not start with the pattern',
+        );
+        expect(filter).not.toMatchTaskFromLine(
+            '- [ ] task does start with the pattern',
+        );
+    });
 });
diff --git a/tests/Query/Filter/PathField.test.ts b/tests/Query/Filter/PathField.test.ts
index 8734a88a..5a37b976 100644
--- a/tests/Query/Filter/PathField.test.ts
+++ b/tests/Query/Filter/PathField.test.ts
@@ -2,6 +2,10 @@ import { PathField } from '../../../src/Query/Filter/PathField';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
+import {
+    toBeValid,
+    toMatchTaskWithPath,
+} from '../../CustomMatchers/CustomMatchersForFilters';
 
 function testTaskFilterForTaskWithPath(
     filter: FilterOrErrorMessage,
@@ -12,6 +16,14 @@ function testTaskFilterForTaskWithPath(
     testFilter(filter, builder.path(path), expected);
 }
 
+expect.extend({
+    toMatchTaskWithPath,
+});
+
+expect.extend({
+    toBeValid,
+});
+
 describe('path', () => {
     it('by path (includes)', () => {
         // Arrange
@@ -37,4 +49,49 @@ describe('path', () => {
         testTaskFilterForTaskWithPath(filter, '/some/path/file.md', false);
         testTaskFilterForTaskWithPath(filter, '/other/path/file.md', true);
     });
+
+    it('by path (regex matches)', () => {
+        // Arrange
+        const filter = new PathField().createFilterOrErrorMessage(
+            'path regex matches /w.bble/',
+        );
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).toMatchTaskWithPath('/some/path/wibble.md');
+        expect(filter).toMatchTaskWithPath('/some/path/wobble.md');
+        expect(filter).not.toMatchTaskWithPath('');
+        expect(filter).not.toMatchTaskWithPath('/some/path/WobblE.md'); // confirm case-sensitive
+        expect(filter).not.toMatchTaskWithPath('/other/path/file.md');
+    });
+
+    it('by path (regex matches) with flags', () => {
+        // Arrange
+        const filter = new PathField().createFilterOrErrorMessage(
+            'path regex matches /w.bble/i',
+        );
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).toMatchTaskWithPath('/some/path/wibble.md');
+        expect(filter).toMatchTaskWithPath('/some/path/wobble.md');
+        expect(filter).not.toMatchTaskWithPath('');
+        expect(filter).toMatchTaskWithPath('/some/path/WobblE.md'); // confirm case-insensitive (flag)
+        expect(filter).not.toMatchTaskWithPath('/other/path/file.md');
+    });
+
+    it('by path (regex does not match)', () => {
+        // Arrange
+        const filter = new PathField().createFilterOrErrorMessage(
+            'path regex does not match /w.bble/',
+        );
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).not.toMatchTaskWithPath('/some/path/wibble.md');
+        expect(filter).not.toMatchTaskWithPath('/some/path/wobble.md');
+        expect(filter).toMatchTaskWithPath('');
+        expect(filter).toMatchTaskWithPath('/some/path/WobblE.md'); // confirm case-sensitive
+        expect(filter).toMatchTaskWithPath('/other/path/file.md');
+    });
 });