Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1907 (2023-04-23 11:58:51)

fix: Allow filename filters to contain parentheses
Fixed:
https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1852

<!--- Provide a general summary of your changes in the Title above -->

# Description

This makes sure that `BooleanField` is always last in `FilterParser`.
If a field is added after `BooleanField`, it shows a TypeScript compilation error.

## Motivation and Context


Fixes https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1852

## How has this been tested?

I tried the query described in the bug and it works as expected.
I also tried adding this to the tests, however this also caused the tests to catch #1500, which this PR does not fix, so I had to leave these tests out for now :(

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **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 **for users**)
- [ ] **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))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [ ] **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://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [ ] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) 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://publish.obsidian.md/tasks-contributing)
- [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/Queries/Filters.md b/docs/Queries/Filters.md
index 32eb1ce4..8fdf8755 100644
--- a/docs/Queries/Filters.md
+++ b/docs/Queries/Filters.md
@@ -530,6 +530,32 @@ Note that the path includes the `.md` extension.
 > [!released]
 `regex matches` and `regex does not match` were introduced in Tasks 1.12.0.
 
+### Root
+
+> [!released]
+> Introduced in Tasks X.Y.Z.
+
+The `root` is the top-level folder of the file that contains the task, that is, the first directory in the path, which will be `/` for files in root of the vault.
+
+- `root (includes|does not include) <root>`
+  - Matches case-insensitive (disregards capitalization).
+- `root (regex matches|regex does not match) /<JavaScript-style Regex>/`
+  - Does regular expression match (case-sensitive by default).
+  - Essential reading: [[Regular Expressions|Regular Expression Searches]].
+
+### Folder
+
+> [!released]
+> Introduced in Tasks X.Y.Z.
+
+This is the `folder` to the file that contains the task, which will be `/` for files in root of the vault.
+
+- `folder (includes|does not include) <folder>`
+  - Matches case-insensitive (disregards capitalization).
+- `folder (regex matches|regex does not match) /<JavaScript-style Regex>/`
+  - Does regular expression match (case-sensitive by default).
+  - Essential reading: [[Regular Expressions|Regular Expression Searches]].
+
 ### File Name
 
 > [!released]
diff --git a/docs/Quick Reference/Quick Reference.md b/docs/Quick Reference/Quick Reference.md
index eb23d150..3766e0aa 100644
--- a/docs/Quick Reference/Quick Reference.md
+++ b/docs/Quick Reference/Quick Reference.md
@@ -22,8 +22,8 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `priority is (above, below, not)? (low, none, medium, high)`                                                                                                                                                                                                                                    | `sort by priority`                          | `group by priority`    | `hide priority`        |
 |                                                                                                                                                                                                                                                                                                 | `sort by urgency`                           |                        | `show urgency`         |
 | `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             | `sort by path`                              | `group by path`        |                        |
-|                                                                                                                                                                                                                                                                                                 |                                             | `group by root`        |                        |
-|                                                                                                                                                                                                                                                                                                 |                                             | `group by folder`      |                        |
+| `root (includes, does not include) <root>`<br>`root (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             |                                             | `group by root`        |                        |
+| `folder (includes, does not include) <folder>`<br>`folder (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             |                                             | `group by folder`      |                        |
 | `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`                                                                                                                                                                                 | `sort by filename`                          | `group by filename`    |                        |
 | `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                                                                                                                     | `sort by heading`                           | `group by heading`     |                        |
 |                                                                                                                                                                                                                                                                                                 |                                             | `group by backlink`    | `hide backlink`        |
diff --git a/src/Query/Filter/BacklinkField.ts b/src/Query/Filter/BacklinkField.ts
new file mode 100644
index 00000000..1b0c0b5b
--- /dev/null
+++ b/src/Query/Filter/BacklinkField.ts
@@ -0,0 +1,66 @@
+import { Group } from '../Group';
+import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
+import { TextField } from './TextField';
+import { HeadingField } from './HeadingField';
+import { FilterOrErrorMessage } from './Filter';
+
+export class BacklinkField extends TextField {
+    public fieldName(): string {
+        return 'backlink';
+    }
+
+    public value(task: Task): string {
+        const linkText = task.getLinkText({ isFilenameUnique: true });
+        if (linkText === null) {
+            return 'Unknown Location';
+        }
+
+        let filenameComponent = 'Unknown Location';
+
+        if (task.filename !== null) {
+            filenameComponent = task.filename;
+        }
+
+        if (task.precedingHeader === null || task.precedingHeader.length === 0) {
+            return filenameComponent;
+        }
+
+        // Markdown characters in the heading must NOT be escaped.
+        const headingGrouper = new HeadingField().createGrouper().grouper;
+        const headingComponent = headingGrouper(task)[0];
+
+        if (filenameComponent === headingComponent) {
+            return filenameComponent;
+        } else {
+            return `${filenameComponent} > ${headingComponent}`;
+        }
+    }
+
+    createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
+        return FilterOrErrorMessage.fromError(line, 'backlink field does not support filtering');
+    }
+
+    canCreateFilterForLine(_line: string): boolean {
+        return false;
+    }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            const filename = task.filename;
+            if (filename !== null) {
+                const backlink = this.value(task);
+                if (filename !== backlink) {
+                    // In case backlink is 'file_name > heading', the filename only shall be escaped
+                    return [Group.escapeMarkdownCharacters(filename) + backlink.substring(filename.length)];
+                }
+            }
+
+            return [Group.escapeMarkdownCharacters(this.value(task))];
+        };
+    }
+}
diff --git a/src/Query/Filter/FolderField.ts b/src/Query/Filter/FolderField.ts
new file mode 100644
index 00000000..c74f6d6d
--- /dev/null
+++ b/src/Query/Filter/FolderField.ts
@@ -0,0 +1,30 @@
+import { Group } from '../Group';
+import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
+import { TextField } from './TextField';
+
+export class FolderField extends TextField {
+    public fieldName(): string {
+        return 'folder';
+    }
+
+    public value(task: Task): string {
+        const path = task.path;
+        const fileNameWithExtension = task.filename + '.md';
+        const folder = path.substring(0, path.lastIndexOf(fileNameWithExtension));
+        if (folder === '') {
+            return '/';
+        }
+        return folder;
+    }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            return [Group.escapeMarkdownCharacters(this.value(task))];
+        };
+    }
+}
diff --git a/src/Query/Filter/RootField.ts b/src/Query/Filter/RootField.ts
new file mode 100644
index 00000000..1b475047
--- /dev/null
+++ b/src/Query/Filter/RootField.ts
@@ -0,0 +1,34 @@
+import { Group } from '../Group';
+import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
+import { TextField } from './TextField';
+
+export class RootField extends TextField {
+    public fieldName(): string {
+        return 'root';
+    }
+
+    public value(task: Task): string {
+        let path = task.path.replace(/\\/g, '/');
+
+        if (path.charAt(0) === '/') {
+            path = path.substring(1);
+        }
+
+        const separatorIndex = path.indexOf('/');
+        if (separatorIndex == -1) {
+            return '/';
+        }
+        return path.substring(0, separatorIndex + 1);
+    }
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public grouper(): GrouperFunction {
+        return (task: Task) => {
+            return [Group.escapeMarkdownCharacters(this.value(task))];
+        };
+    }
+}
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index 21839504..dc112482 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -1,3 +1,4 @@
+import type { Field } from './Filter/Field';
 import { DescriptionField } from './Filter/DescriptionField';
 import { CreatedDateField } from './Filter/CreatedDateField';
 import { DoneDateField } from './Filter/DoneDateField';
@@ -23,8 +24,16 @@ import type { FilterOrErrorMessage } from './Filter/Filter';
 import type { Sorter } from './Sorter';
 import type { Grouper } from './Grouper';
 import { MultiTextField } from './Filter/MultiTextField';
+import { FolderField } from './Filter/FolderField';
+import { RootField } from './Filter/RootField';
+import { BacklinkField } from './Filter/BacklinkField';
 
-const fieldCreators = [
+// When parsing a query the fields are tested one by one according to this order.
+// Since BooleanField is a meta-field, which needs to aggregate a few fields together, it is intended to
+// be kept last.
+// When adding new fields keep this order in mind, putting fields that are more specific before fields that
+// may contain them, and keep BooleanField last.
+const fieldCreators: EndsWith<BooleanField> = [
     () => new StatusNameField(), // status.name is before status, to avoid ambiguity
     () => new StatusTypeField(), // status.type is before status, to avoid ambiguity
     () => new StatusField(),
@@ -37,16 +46,22 @@ const fieldCreators = [
     () => new DueDateField(),
     () => new DoneDateField(),
     () => new PathField(),
+    () => new FolderField(),
+    () => new RootField(),
+    () => new BacklinkField(),
     () => new DescriptionField(),
     () => new TagsField(),
     () => new HeadingField(),
     () => new ExcludeSubItemsField(),
-    () => new BooleanField(),
     () => new FilenameField(),
     () => new UrgencyField(),
     () => new RecurrenceField(),
+    () => new BooleanField(), // --- Please make sure to keep BooleanField last (see comment above) ---
 ];
 
+// This type helps verify that BooleanField is kept last
+type EndsWith<End, T extends Field = Field> = [...Array<() => T>, () => End];
+
 export function parseFilter(filterString: string): FilterOrErrorMessage | null {
     for (const creator of fieldCreators) {
         const field = creator();
diff --git a/src/Query/Group.ts b/src/Query/Group.ts
index 9f869c3d..289efcb0 100644
--- a/src/Query/Group.ts
+++ b/src/Query/Group.ts
@@ -1,17 +1,11 @@
 import type { Task } from '../Task';
 import { TaskGroups } from './TaskGroups';
-import { Grouper } from './Grouper';
-import type { GrouperFunction, GroupingProperty } from './Grouper';
-import { HeadingField } from './Filter/HeadingField';
+import type { Grouper } from './Grouper';
 
 /**
  * Implementation of the 'group by' instruction.
  */
 export class Group {
-    public static fromGroupingProperty(property: GroupingProperty): Grouper {
-        return new Grouper(property, Group.grouperForProperty(property));
-    }
-
     /**
      * Group a list of tasks, according to one or more task properties
      * @param grouping 0 or more Grouping values, one per 'group by' line
@@ -33,65 +27,8 @@ export class Group {
         return grouping.grouper(task);
     }
 
-    public static grouperForProperty(property: GroupingProperty): GrouperFunction {
-        return Group.groupers[property];
-    }
-
-    private static groupers: Record<GroupingProperty, GrouperFunction> = {
-        backlink: Group.groupByBacklink,
-        folder: Group.groupByFolder,
-        root: Group.groupByRoot,
-    };
-
     public static escapeMarkdownCharacters(filename: string) {
         // https://wilsonmar.github.io/markdown-text-for-github-from-html/#special-characters
         return filename.replace(/\\/g, '\\\\').replace(/_/g, '\\_');
     }
-
-    private static groupByFolder(task: Task): string[] {
-        const path = task.path;
-        const fileNameWithExtension = task.filename + '.md';
-        const folder = path.substring(0, path.lastIndexOf(fileNameWithExtension));
-        if (folder === '') {
-            return ['/'];
-        }
-        return [Group.escapeMarkdownCharacters(folder)];
-    }
-
-    private static groupByRoot(task: Task): string[] {
-        const path = task.path.replace(/\\/g, '/');
-        const separatorIndex = path.indexOf('/');
-        if (separatorIndex == -1) {
-            return ['/'];
-        }
-        return [Group.escapeMarkdownCharacters(path.substring(0, separatorIndex + 1))];
-    }
-
-    private static groupByBacklink(task: Task): string[] {
-        const linkText = task.getLinkText({ isFilenameUnique: true });
-        if (linkText === null) {
-            return ['Unknown Location'];
-        }
-
-        let filenameComponent = 'Unknown Location';
-
-        if (task.filename !== null) {
-            // Markdown characters in the file name must be escaped.
-            filenameComponent = Group.escapeMarkdownCharacters(task.filename);
-        }
-
-        if (task.precedingHeader === null || task.precedingHeader.length === 0) {
-            return [filenameComponent];
-        }
-
-        // Markdown characters in the heading must NOT be escaped.
-        const headingGrouper = new HeadingField().createGrouper().grouper;
-        const headingComponent = headingGrouper(task)[0];
-
-        if (filenameComponent === headingComponent) {
-            return [filenameComponent];
-        } else {
-            return [`${filenameComponent} > ${headingComponent}`];
-        }
-    }
 }
diff --git a/src/Query/Grouper.ts b/src/Query/Grouper.ts
index 53b52044..89eb69ef 100644
--- a/src/Query/Grouper.ts
+++ b/src/Query/Grouper.ts
@@ -5,8 +5,6 @@ import type { Task } from '../Task';
  */
 export type GrouperFunction = (task: Task) => string[];
 
-export type GroupingProperty = 'backlink' | 'folder' | 'root';
-
 export class Grouper {
     public readonly property: string;
     public readonly grouper: GrouperFunction;
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index 3a9d192d..3bb3b1cc 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -9,7 +9,6 @@ import type { TaskGroups } from './TaskGroups';
 import * as FilterParser from './FilterParser';
 import { Group } from './Group';
 import type { Grouper } from './Grouper';
-import type { GroupingProperty } from './Grouper';
 import type { Filter } from './Filter/Filter';
 
 export class Query implements IQuery {
@@ -22,8 +21,6 @@ export class Query implements IQuery {
     private _sorting: Sorter[] = [];
     private _grouping: Grouper[] = [];
 
-    private readonly groupByRegexp = /^group by (backlink|folder|root)/;
-
     private readonly hideOptionsRegexp =
         /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|urgency)/;
     private readonly shortModeRegexp = /^short/;
@@ -53,10 +50,7 @@ export class Query implements IQuery {
                         break;
                     case this.parseSortBy({ line }):
                         break;
-                    case this.parseGroupBy2({ line }):
-                        break;
-                    case this.groupByRegexp.test(line):
-                        this.parseGroupBy({ line });
+                    case this.parseGroupBy({ line }):
                         break;
                     case this.hideOptionsRegexp.test(line):
                         this.parseHideOptions({ line });
@@ -221,35 +215,13 @@ export class Query implements IQuery {
     }
 
     /**
-     * Old-style parsing of `group by` lines, for grouping that is implemented with static
-     * methods in {@link Group}, that are looked up from a {@link GroupingProperty}.
-     *
-     * These will be gradually migrated to the grouping method in {@link Field}
-     * classes, after which this method will be deleted.
-     *
-     * @param line
-     * @private
-     * @see parseGroupBy2
-     */
-    private parseGroupBy({ line }: { line: string }): void {
-        const fieldMatch = line.match(this.groupByRegexp);
-        if (fieldMatch !== null) {
-            this._grouping.push(Group.fromGroupingProperty(fieldMatch[1] as GroupingProperty));
-        } else {
-            this._error = 'do not understand query grouping';
-        }
-    }
-
-    /**
-     * New-style parsing of `group by` lines, for grouping that is implemented in the {@link Field}
+     * Parsing of `group by` lines, for grouping that is implemented in the {@link Field}
      * classes.
      *
-     * Once the original {@link parseGroupBy} has been removed, rename this to parseGroupBy()
      * @param line
      * @private
-     * @see parseGroupBy
      */
-    private parseGroupBy2({ line }: { line: string }): boolean {
+    private parseGroupBy({ line }: { line: string }): boolean {
         const groupingMaybe = FilterParser.parseGrouper(line);
         if (groupingMaybe) {
             this._grouping.push(groupingMaybe);


Test patch:
diff --git a/tests/Group.test.ts b/tests/Group.test.ts
index 827c43ba..86a7e33a 100644
--- a/tests/Group.test.ts
+++ b/tests/Group.test.ts
@@ -5,19 +5,14 @@ import moment from 'moment';
 import { FilenameField } from '../src/Query/Filter/FilenameField';
 import { Group } from '../src/Query/Group';
 import type { Grouper } from '../src/Query/Grouper';
-import type { GroupingProperty } from '../src/Query/Grouper';
 import type { Task } from '../src/Task';
 import { PathField } from '../src/Query/Filter/PathField';
 import { TagsField } from '../src/Query/Filter/TagsField';
+import { FolderField } from '../src/Query/Filter/FolderField';
 import { fromLine } from './TestHelpers';
 
 window.moment = moment;
 
-function checkGroupNamesOfTask(task: Task, property: GroupingProperty, expectedGroupNames: string[]) {
-    const group = Group.getGroupNamesForTask(Group.fromGroupingProperty(property), task);
-    expect(group).toEqual(expectedGroupNames);
-}
-
 describe('Grouping tasks', () => {
     it('groups correctly by path', () => {
         // Arrange
@@ -185,7 +180,7 @@ describe('Grouping tasks', () => {
         });
         const tasks = [t1, t2, t3];
 
-        const grouping: Grouper[] = [Group.fromGroupingProperty('folder'), new FilenameField().createGrouper()];
+        const grouping: Grouper[] = [new FolderField().createGrouper(), new FilenameField().createGrouper()];
 
         // Act
         const groups = Group.by(grouping, tasks);
@@ -218,118 +213,3 @@ describe('Grouping tasks', () => {
         `);
     });
 });
-
-describe('Group names', () => {
-    type GroupNameCase = {
-        groupBy: GroupingProperty;
-        taskLine: string;
-        expectedGroupNames: string[];
-        path?: string;
-        precedingHeading?: string | null;
-    };
-
-    const groupNameCases: Array<GroupNameCase> = [
-        // Maintenance Note: tests are in alphabetical order of 'groupBy' name
-
-        // -----------------------------------------------------------
-        // group by backlink
-        {
-            groupBy: 'backlink',
-            taskLine: '- [ ] xxx', // no location supplied
-            expectedGroupNames: ['Unknown Location'],
-            precedingHeading: 'heading',
-        },
-        {
-            groupBy: 'backlink',
-            taskLine: '- [ ] xxx', // no heading supplied
-            expectedGroupNames: ['c'],
-            path: 'a/b/c.md',
-        },
-        {
-            groupBy: 'backlink',
-            taskLine: '- [ ] xxx',
-            expectedGroupNames: ['c > heading'],
-            path: 'a/b/c.md',
-            precedingHeading: 'heading',
-        },
-        {
-            groupBy: 'backlink',
-            taskLine: '- [ ] xxx',
-            expectedGroupNames: ['c'], // If file name and heading are identical, avoid duplication ('c > c')
-            path: 'a/b/c.md',
-            precedingHeading: 'c',
-        },
-        {
-            groupBy: 'backlink',
-            taskLine: '- [ ] xxx',
-            // underscores in file name component are escaped
-            // but underscores in the heading component are not
-            expectedGroupNames: ['\\_c\\_ > heading _italic text_'],
-            path: 'a/b/_c_.md',
-            precedingHeading: 'heading _italic text_',
-        },
-
-        // -----------------------------------------------------------
-        // group by folder
-        {
-            groupBy: 'folder',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['a/b/'],
-            path: 'a/b/c.md',
-        },
-        {
-            groupBy: 'folder',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['a/\\_b\\_/'], // underscores in folder names are escaped
-            path: 'a/_b_/c.md',
-        },
-        {
-            // file in root of vault:
-            groupBy: 'folder',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['/'],
-            path: 'a.md',
-        },
-
-        // -----------------------------------------------------------
-        // group by root
-        {
-            groupBy: 'root',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['a/'],
-            path: 'a/b/c.md',
-        },
-        {
-            groupBy: 'root',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['\\_g\\_/'], // underscores in root folder names are escaped
-            path: '_g_/h/i.md',
-        },
-        {
-            groupBy: 'root',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['a/'],
-            path: 'a\\b\\c.md', // Windows path
-        },
-        {
-            // file in root of vault:
-            groupBy: 'root',
-            taskLine: '- [ ] a',
-            expectedGroupNames: ['/'],
-            path: 'a.md',
-        },
-        // -----------------------------------------------------------
-    ];
-
-    test.concurrent.each<GroupNameCase>(groupNameCases)(
-        'assigns correct group name (%j)',
-        ({ groupBy, taskLine, path, expectedGroupNames, precedingHeading }) => {
-            const task = fromLine({
-                line: taskLine,
-                path: path ? path : '',
-                precedingHeader: precedingHeading,
-            });
-            checkGroupNamesOfTask(task, groupBy, expectedGroupNames);
-        },
-    );
-});
diff --git a/tests/Query/Filter/BacklinkField.test.ts b/tests/Query/Filter/BacklinkField.test.ts
new file mode 100644
index 00000000..21352946
--- /dev/null
+++ b/tests/Query/Filter/BacklinkField.test.ts
@@ -0,0 +1,69 @@
+import { BacklinkField } from '../../../src/Query/Filter/BacklinkField';
+import { fromLine } from '../../TestHelpers';
+
+describe('backlink', () => {
+    it('should provide the backlink', () => {
+        const field = new BacklinkField();
+
+        expect(field.value(fromLine({ line: '- [ ] do' }))).toStrictEqual('Unknown Location');
+        expect(field.value(fromLine({ line: '- [ ] do', path: 'folder/file.md' }))).toStrictEqual('file');
+        expect(field.value(fromLine({ line: '- [ ] do', path: 'a_b/_c_d_/_fi_le_.md' }))).toStrictEqual('_fi_le_');
+        expect(field.value(fromLine({ line: '- [ ] do', path: 'file.md', precedingHeader: 'topic' }))).toStrictEqual(
+            'file > topic',
+        );
+        expect(
+            field.value(fromLine({ line: '- [ ] do', path: 'fi_le.md', precedingHeader: 'topic _ita_' })),
+        ).toStrictEqual('fi_le > topic _ita_');
+    });
+});
+
+describe('backlink', () => {
+    it('should not support filtering', () => {
+        // Arrange
+        const field = new BacklinkField();
+
+        // Assert
+        expect(field.createFilterOrErrorMessage('backlink includes heading > filename')).not.toBeValid();
+        expect(field.canCreateFilterForLine('backlink includes heading > filename')).toEqual(false);
+    });
+});
+
+describe('grouping by backlink', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new BacklinkField();
+        expect(field.supportsGrouping()).toEqual(true);
+
+        const fieldGrouper = field.createGrouper();
+        expect(fieldGrouper.property).toEqual('backlink');
+    });
+
+    it.each([
+        // no location supplied
+        [undefined, 'heading', ['Unknown Location']],
+
+        // no heading supplied
+        ['a/b/c.md', undefined, ['c']],
+
+        // File and heading, nominal case
+        ['a/b/c.md', 'heading', ['c > heading']],
+
+        // If file name and heading are identical, avoid duplication ('c > c')
+        ['a/b/c.md', 'c', ['c']],
+
+        // Underscores in file name component are escaped
+        ['a/b/_c_.md', undefined, ['\\_c\\_']],
+
+        // But underscores in the heading component are not
+        ['a/b/_c_.md', 'heading _italic text_', ['\\_c\\_ > heading _italic text_']],
+    ])(
+        'task "%s" with path "%s" should have groups: %s',
+        (path: string | undefined, heading: string | undefined, groups: string[]) => {
+            // Arrange
+            const grouper = new BacklinkField().createGrouper().grouper;
+            const t = '- [ ] xyz';
+
+            // Assert
+            expect(grouper(fromLine({ line: t, path: path, precedingHeader: heading }))).toEqual(groups);
+        },
+    );
+});
diff --git a/tests/Query/Filter/FolderField.test.ts b/tests/Query/Filter/FolderField.test.ts
new file mode 100644
index 00000000..1ebdcd20
--- /dev/null
+++ b/tests/Query/Filter/FolderField.test.ts
@@ -0,0 +1,55 @@
+import { FolderField } from '../../../src/Query/Filter/FolderField';
+import { fromLine } from '../../TestHelpers';
+
+describe('folder', () => {
+    it('should provide access to the folder', () => {
+        const field = new FolderField();
+
+        expect(field.value(fromLine({ line: '- [ ] do' }))).toStrictEqual('/');
+        expect(field.value(fromLine({ line: '- [ ] do', path: 'outside/inside/file.md' }))).toStrictEqual(
+            'outside/inside/',
+        );
+        expect(field.value(fromLine({ line: '- [ ] do', path: 'a_b/_c_d_/file.md' }))).toStrictEqual('a_b/_c_d_/');
+    });
+});
+
+describe('folder', () => {
+    // Note: We don't need to check all behaviours that are implemented in the base class.
+    // These are minimal tests to confirm that the filters are correctly wired up,
+    // to guard against possible future coding errors.
+
+    it('filter by folder (includes)', () => {
+        // Arrange
+        const filter = new FolderField().createFilterOrErrorMessage('folder includes search_text');
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).not.toMatchTaskWithPath('');
+        expect(filter).toMatchTaskWithPath('/some/SeArch_Text/some file name.md');
+        expect(filter).not.toMatchTaskWithPath('/other/folder/search_text.md'); // Ignores text in file names
+    });
+});
+
+describe('grouping by folder', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new FolderField();
+        expect(field.supportsGrouping()).toEqual(true);
+
+        const fieldGrouper = field.createGrouper();
+        expect(fieldGrouper.property).toEqual('folder');
+    });
+
+    it.each([
+        ['- [ ] a', 'a/b/c.md', ['a/b/']],
+        // underscores in folder names are escaped
+        ['- [ ] a', 'a/_b_/c.md', ['a/\\_b\\_/']],
+        // file in root of vault:
+        ['- [ ] a', 'a.md', ['/']],
+    ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
+        // Arrange
+        const grouper = new FolderField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
+    });
+});
diff --git a/tests/Query/Filter/RootField.test.ts b/tests/Query/Filter/RootField.test.ts
new file mode 100644
index 00000000..78754e95
--- /dev/null
+++ b/tests/Query/Filter/RootField.test.ts
@@ -0,0 +1,58 @@
+import { RootField } from '../../../src/Query/Filter/RootField';
+import { fromLine } from '../../TestHelpers';
+
+describe('root', () => {
+    it('should provide access to root folder name with trailing slash', () => {
+        const field = new RootField();
+
+        const line = '- [ ] do';
+        expect(field.value(fromLine({ line: line }))).toStrictEqual('/');
+        expect(field.value(fromLine({ line: line, path: 'outside/inside/A.md' }))).toStrictEqual('outside/');
+        expect(field.value(fromLine({ line: line, path: 'a_b/_c_d_/B.md' }))).toStrictEqual('a_b/');
+        expect(field.value(fromLine({ line: line, path: '/root/SeArch_Text/search_text.md' }))).toStrictEqual('root/');
+    });
+});
+
+describe('root', () => {
+    // Note: We don't need to check all behaviours that are implemented in the base class.
+    // These are minimal tests to confirm that the filters are correctly wired up,
+    // to guard against possible future coding errors.
+
+    it('filter by root (includes)', () => {
+        // Arrange
+        const filter = new RootField().createFilterOrErrorMessage('root includes search_text');
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).not.toMatchTaskWithPath('');
+        expect(filter).toMatchTaskWithPath('/SeArch_Text/some folder name/some file name.md');
+        expect(filter).not.toMatchTaskWithPath('/some root folder/SeArch_Text/search_text.md'); // Ignores text in child folder names
+        expect(filter).not.toMatchTaskWithPath('/some root folder/folder/search_text.md'); // Ignores text in file names
+    });
+});
+
+describe('grouping by root', () => {
+    it('supports grouping methods correctly', () => {
+        const field = new RootField();
+        expect(field.supportsGrouping()).toEqual(true);
+
+        const fieldGrouper = field.createGrouper();
+        expect(fieldGrouper.property).toEqual('root');
+    });
+
+    it.each([
+        ['- [ ] a', 'a/b/c.md', ['a/']],
+        // underscores in root folder names are escaped
+        ['- [ ] a', '_g_/h/i.md', ['\\_g\\_/']],
+        // Windows path
+        ['- [ ] a', 'a\\b\\c.md', ['a/']],
+        // file in root of vault:
+        ['- [ ] a', 'a.md', ['/']],
+    ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
+        // Arrange
+        const grouper = new RootField().createGrouper().grouper;
+
+        // Assert
+        expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
+    });
+});