Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2255 (2023-09-08 07:10:14)

refactor: extract data attribute names dictionary from `getComponentDataAttribute()`
<!--- Provide a general summary of your changes in the Title above -->

# Description

Refactor - remove code duplication and extract hard coded values to a dictionary. Continuation of #2235.

## Motivation and Context

Prepare for #2198 and maybe for an abstraction for `LayoutOptions`.

## How has this been tested?

Unit testing.

## Types of changes

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)

## Checklist

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

- [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/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 3035f09a..eaa06e42 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -39,7 +39,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 49a4f8cf..ec27f2a7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,7 +8,7 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
     - name: Use Node.js
diff --git a/.github/workflows/update-docs-markdown.yml b/.github/workflows/update-docs-markdown.yml
index acd7af63..b5b57fb4 100644
--- a/.github/workflows/update-docs-markdown.yml
+++ b/.github/workflows/update-docs-markdown.yml
@@ -12,7 +12,7 @@ jobs:
   docs:
     runs-on: windows-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install modules
         run: yarn
diff --git a/.github/workflows/verify-on-windows.yml b/.github/workflows/verify-on-windows.yml
index e915347e..2f7b22a9 100644
--- a/.github/workflows/verify-on-windows.yml
+++ b/.github/workflows/verify-on-windows.yml
@@ -16,7 +16,7 @@ jobs:
     runs-on: windows-latest
     steps:
       # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install modules
         run: yarn
diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index badddea9..6cafce34 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install modules
         run: yarn
diff --git a/.gitignore b/.gitignore
index b4657886..983ff351 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ yarn-error.log
 /main.js
 *.js.map
 .log
+/generated/
 
 # obsidian
 /data.json
diff --git a/docs/How To/How to get tasks in current file.md b/docs/How To/How to get tasks in current file.md
index 18468455..10a6966f 100644
--- a/docs/How To/How to get tasks in current file.md
+++ b/docs/How To/How to get tasks in current file.md
@@ -4,7 +4,7 @@ publish: true
 
 # How to get all tasks in the current file
 
-<span class="related-pages">#plugin/dataview</span>
+<span class="related-pages">#feature/scripting #plugin/dataview</span>
 
 ## Motivation and assumptions
 
@@ -13,37 +13,45 @@ for example to make sure no task gets accidentally missed.
 
 This page documents ways of setting this up.
 
-Assumptions:
+## Using pure Tasks blocks - with placeholders
 
-- We assume that you know how to install and enable the [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin.
+> [!released]
+> Placeholders were introduced in Tasks 4.7.0.
 
-## Using pure Tasks blocks - fragile and error-prone
+We want to search for tasks in the file with the same `path` that the query is in.
 
-Tasks does not provide an automated way to include the location of the `tasks` block in a query.
+Tasks now provides an automated way to include the location of the `tasks` block in a query.
 
-It is possible to use the `path` instruction, but unfortunately you have to insert the path to the file yourself:
+We can use the `path` instruction with the placeholder text `{{query.file.path}}` which will be replaced with the path of the file containing the current query, like this:
 
     ## Summary of Tasks within this note
 
     ```tasks
     not done
-    path includes [insert current note's name or full path]
+    path includes path includes {{query.file.path}}
     ```
 
-For example:
+The following placeholders are available:
 
-    ## Summary of Tasks within this note
+```text
+{{query.file.path}}
+{{query.file.root}}
+{{query.file.folder}}
+{{query.file.filename}}
+```
 
-    ```tasks
-    not done
-    path includes Obsidian/tasks/tasks user support/03 Done - tasks user support/1.11.0 release
-    ```
+They can be used with any text filter, not just `path`, `file`, `folder`, `filename`. For example, they might be useful with `description` and `heading` filters.
 
-> [!warning]
-> Using `path includes` to search for a particular file name or folder is error-prone, as if you rename the file,
-you have to remember to manually update the location in the tasks block, and this is very error-prone.
+For more information, see:
 
-## Using Dataview to generate Tasks blocks - safe and convenient
+- [[Placeholders]]
+- [[Query Properties]]
+
+## Using Dataview to generate Tasks blocks - the old way
+
+<label class="ob-comment" title="" style=""> Assumptions <input type="checkbox"> <span style=""> Move this to a separate note<br> - but retain the original heading<br> - and link to it from here </span></label>:
+
+- We assume that you know how to install and enable the [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin.
 
 There is a nice property that the [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin can write out code blocks that are then processed by other plugins.
 
diff --git a/docs/Introduction.md b/docs/Introduction.md
index 20edd111..a0bc2d70 100644
--- a/docs/Introduction.md
+++ b/docs/Introduction.md
@@ -6,6 +6,9 @@ publish: true
 
 ## What's New?
 
+- X.Y.Z: 🔥 Add [[Query Properties#Values for Query File Properties|query file properties]] `query.file.pathWithoutExtension` and `query.file.filenameWithoutExtension`
+- X.Y.Z: 🔥 Add [[Task Properties#Values for File Properties|task file properties]] `task.file.pathWithoutExtension` and `task.file.filenameWithoutExtension`
+- 4.7.0: 🔥 Use [[Query Properties]] and [[Placeholders]] to filter and group with the query's file path, root, folder and name.
 - 4.6.0: 🔥 Add `on or before` and `on or after` to [[Filters#Date search options|date search options]]
 - 4.6.0: 🔥 Add `in or before` and `in or after` to [[Filters#Date range options|date range search search options]]
 - 4.5.0: 🔥 Support task in list items starting with [[Getting Started#Finding tasks in your vault|`+` signs]]
diff --git a/docs/Queries/Explaining Queries.md b/docs/Queries/Explaining Queries.md
index af8f3229..4e001a1d 100644
--- a/docs/Queries/Explaining Queries.md
+++ b/docs/Queries/Explaining Queries.md
@@ -190,6 +190,39 @@ due next week =>
 ```
 <!-- endSnippet -->
 
+### Placeholder values are expanded
+
+> [!released]
+> Placeholders were introduced in Tasks 4.7.0.
+
+For example, when the following query with [[Query Properties]] in [[Placeholders|placeholders]] is placed in a tasks query block in the file `some/sample/file path.md`:
+
+<!-- snippet: DocsSamplesForExplain.test.explain_placeholders.approved.query.text -->
+```text
+explain
+path includes {{query.file.path}}
+root includes {{query.file.root}}
+folder includes {{query.file.folder}}
+filename includes {{query.file.filename}}
+```
+<!-- endSnippet -->
+
+the results begin with the following:
+
+<!-- snippet: DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text -->
+```text
+Explanation of this Tasks code block query:
+
+path includes some/sample/file path.md
+
+root includes some/
+
+folder includes some/sample/
+
+filename includes file path.md
+```
+<!-- endSnippet -->
+
 ## Styling explain results
 
 ### Default style
diff --git a/docs/Queries/Filters.md b/docs/Queries/Filters.md
index 5329a994..2d05f3a3 100644
--- a/docs/Queries/Filters.md
+++ b/docs/Queries/Filters.md
@@ -975,15 +975,22 @@ Note that the path includes the `.md` extension.
 
 - `path (includes|does not include) <path>`
   - Matches case-insensitive (disregards capitalization).
+  - Use `{{query.file.path}}` or `{{query.file.pathWithoutExtension}}` as a placeholder for the path of the file containing the current query.
+    - For example, `path equals {{query.file.path}}`
+    - Useful reading: [[Query Properties]] and [[Placeholders]]
 - `path (regex matches|regex does not match) /<JavaScript-style Regex>/`
   - Does regular expression match (case-sensitive by default).
   - Essential reading: [[Regular Expressions|Regular Expression Searches]].
 
 > [!released]
-`regex matches` and `regex does not match` were introduced in Tasks 1.12.0.
+>
+> - `regex matches` and `regex does not match` were introduced in Tasks 1.12.0.
+> - Placeholders were released in Tasks 4.7.0.
 
 Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file path** is now possible, using `task.file.path`.
 
+In Tasks X.Y.Z `task.file.pathWithoutExtension` was added.
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.path_docs.approved.md -->
 
 - ```filter by function task.file.path.includes('tasks releases/4.1.0 Release.md')```
@@ -1000,12 +1007,17 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file path** is now p
 ### Root
 
 > [!released]
-> Introduced in Tasks 3.4.0.
+>
+> - Introduced in Tasks 3.4.0.
+> - Placeholders were released in Tasks 4.7.0.
 
 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 the root of the vault.
 
 - `root (includes|does not include) <root>`
   - Matches case-insensitive (disregards capitalization).
+  - Use `{{query.file.root}}` as a placeholder for the root of the file containing the current query.
+    - For example, `root includes {{query.file.root}}`
+    - Useful reading: [[Query Properties]] and [[Placeholders]]
 - `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]].
@@ -1026,12 +1038,17 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by root folder** is now
 ### Folder
 
 > [!released]
-> Introduced in Tasks 3.4.0.
+>
+> - Introduced in Tasks 3.4.0.
+> - Placeholders were released in Tasks 4.7.0.
 
 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).
+  - Use `{{query.file.folder}}` as a placeholder for the folder of the file containing the current query.
+    - For example, `folder includes {{query.file.folder}}`, which will match tasks in the folder containing the query **and all sub-folders**.
+    - Useful reading: [[Query Properties]] and [[Placeholders]]
 - `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]].
@@ -1044,7 +1061,12 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by folder** is now poss
   - Find tasks in files in any file in the given folder **only**, and not any sub-folders.
   - The equality test, `===`, requires that the trailing slash (`/`) be included.
 - ```filter by function task.file.folder.includes("Work/Projects/")```
-  - Find tasks in files in any folder **and any sub-folders**.
+  - Find tasks in files in a specific folder **and any sub-folders**.
+- ```filter by function task.file.folder.includes( '{{query.file.folder}}' )```
+  - Find tasks in files in the folder that contains the query **and any sub-folders**.
+  - Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
+- ```filter by function task.file.folder === '{{query.file.folder}}'```
+  - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
 - ```filter by function task.file.folder.includes("Work/Projects")```
   - By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as:
     - `Work/Projects 2023/`
@@ -1055,18 +1077,25 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by folder** is now poss
 ### File Name
 
 > [!released]
-Introduced in Tasks 1.13.0.
+>
+> - Introduced in Tasks 3.4.0.
+> - Placeholders were released in Tasks 4.7.0.
 
 Note that the file name includes the `.md` extension.
 
 - `filename (includes|does not include) <filename>`
   - Matches case-insensitive (disregards capitalization).
+  - Use `{{query.file.filename}}`  or `{{query.file.filenameWithoutExtension}}` as a placeholder for the file name of the file containing the current query.
+    - For example, `filename includes {{query.file.filename}}`
+    - Useful reading: [[Query Properties]] and [[Placeholders]]
 - `filename (regex matches|regex does not match) /<JavaScript-style Regex>/`
   - Does regular expression match (case-sensitive by default).
   - Essential reading: [[Regular Expressions|Regular Expression Searches]].
 
 Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file name** is now possible, using `task.file.filename`.
 
+In Tasks X.Y.Z `task.file.filenameWithoutExtension` was added.
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.filename_docs.approved.md -->
 
 - ```filter by function task.file.filename === "4.1.0 Release.md"```
diff --git a/docs/Queries/Grouping.md b/docs/Queries/Grouping.md
index 506b0ec1..6901151f 100644
--- a/docs/Queries/Grouping.md
+++ b/docs/Queries/Grouping.md
@@ -522,9 +522,22 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by file path** is now p
 
 - ```group by function task.file.path```
   - Like 'group by path' but includes the file extension.
+- ```group by function task.file.path.replace('{{query.file.folder}}', '')```
+  - Group by the task's file path, but remove the query's folder from the group.
+  - For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths.
+  - Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
+  - This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line.
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
+Since Tasks 4.7.0, the query's file path can be used in custom groups.
+
+- It must be quoted:
+  - `'{{query.file.path}}'` or
+  - `'{{query.file.pathWithoutExtension}}'` (since Tasks X.Y.Z)
+- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped.
+- Useful reading: [[Query Properties]] and [[Placeholders]].
+
 ### Root
 
 - `group by root` (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)
@@ -541,6 +554,12 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by root folder** is now
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
+Since Tasks 4.7.0, the query's file root can be used in custom groups.
+
+- It must be quoted: `'{{query.file.root}}'`
+- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped.
+- Useful reading: [[Query Properties]] and [[Placeholders]].
+
 ### Folder
 
 - `group by folder` (the folder to the file that contains the task, which always ends in `/` and will be exactly `/` for files in root of the vault)
@@ -561,6 +580,12 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by folder** is now poss
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
+Since Tasks 4.7.0, the query's folder can be used in custom groups.
+
+- It must be quoted: `'{{query.file.folder}}'`
+- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped.
+- Useful reading: [[Query Properties]] and [[Placeholders]].
+
 ### File Name
 
 - `group by filename` (the link to the file that contains the task, without the `.md` extension)
@@ -572,14 +597,22 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by file name** is now p
 
 - ```group by function task.file.filename```
   - Like 'group by filename' but does not link to the file.
-- ```group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
+- ```group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
   - Like 'group by backlink' but links to the heading in the file.
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
+Since Tasks 4.7.0, the query's file name can be used in custom groups.
+
+- It must be quoted:
+  - `'{{query.file.filename}}'` or
+  - `'{{query.file.filenameWithoutExtension}}'` (since Tasks X.Y.Z)
+- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped.
+- Useful reading: [[Query Properties]] and [[Placeholders]].
+
 ### Backlink
 
-- `group by backlink` (the text that would be shown in the task's [[Backlinks|backlink]], combining the task's file name and heading, but with no link added)
+- `group by backlink` (the text that would be shown in the task's [[Backlinks|backlink]], combining the task's file name and heading, with a link)
 
 ### Heading
 
diff --git a/docs/Quick Reference.md b/docs/Quick Reference.md
index c6c19c58..ae32ccb8 100644
--- a/docs/Quick Reference.md
+++ b/docs/Quick Reference.md
@@ -8,57 +8,57 @@ aliases:
 
 This table summarizes the filters and other options available inside a `tasks` block.
 
-| [[Filters]]                                                                                                                                                                                                                                                                                     | [[Sorting\|Sort]]                           | [[Grouping\|Group]]    | [[Layout\|Display]]    | [[About Scripting\|Scripting]]                      |
-| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------- | ---------------------- | --------------------------------------------------- |
-| **[[Filters#Filters for Dates in Tasks\|Status]]**                                                                                                                                                                                                                                              |                                             |                        |                        |                                                     |
-| `done`<br>`not done`                                                                                                                                                                                                                                                                            | `sort by status`                            | `group by status`      |                        | `task.isDone`                                       |
-| `status.name (includes, does not include) <string>`<br>`status.name (regex matches, regex does not match) /regex/i`                                                                                                                                                                             | `sort by status.name`                       | `group by status.name` |                        | `task.status.name`                                  |
-| `status.type (is, is not) (TODO, DONE, IN_PROGRESS, CANCELLED, NON_TASK)`                                                                                                                                                                                                                       | `sort by status.type`                       | `group by status.type` |                        | `task.status.type`                                  |
-|                                                                                                                                                                                                                                                                                                 |                                             |                        |                        | `task.status.symbol`                                |
-|                                                                                                                                                                                                                                                                                                 |                                             |                        |                        | `task.status.nextSymbol`                            |
-| **[[Filters#Filters for Dates in Tasks\|Dates]]**                                                                                                                                                                                                                                               |                                             |                        |                        |                                                     |
+| [[Filters]]                                                                                                                                                                                                                                                                                                                                           | [[Sorting\|Sort]]                           | [[Grouping\|Group]]    | [[Layout\|Display]]    | [[About Scripting\|Scripting]]                      |
+| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------- | ---------------------- | --------------------------------------------------- |
+| **[[Filters#Filters for Dates in Tasks\|Status]]**                                                                                                                                                                                                                                                                                                    |                                             |                        |                        |                                                     |
+| `done`<br>`not done`                                                                                                                                                                                                                                                                                                                                  | `sort by status`                            | `group by status`      |                        | `task.isDone`                                       |
+| `status.name (includes, does not include) <string>`<br>`status.name (regex matches, regex does not match) /regex/i`                                                                                                                                                                                                                                   | `sort by status.name`                       | `group by status.name` |                        | `task.status.name`                                  |
+| `status.type (is, is not) (TODO, DONE, IN_PROGRESS, CANCELLED, NON_TASK)`                                                                                                                                                                                                                                                                             | `sort by status.type`                       | `group by status.type` |                        | `task.status.type`                                  |
+|                                                                                                                                                                                                                                                                                                                                                       |                                             |                        |                        | `task.status.symbol`                                |
+|                                                                                                                                                                                                                                                                                                                                                       |                                             |                        |                        | `task.status.nextSymbol`                            |
+| **[[Filters#Filters for Dates in Tasks\|Dates]]**                                                                                                                                                                                                                                                                                                     |                                             |                        |                        |                                                     |
 | `done (on, before, after, on or before, on or after) <date>`<br>`done (in, before, after, in or before, in or after) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has done date`<br>`no  done date`<br>`done date is invalid`                          | `sort by done`                              | `group by done`        | `hide done date`       | `task.done`                                         |
 | `created (on, before, after, on or before, on or after) <date>`<br>`created (in, before, after, in or before, in or after) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has created date`<br>`no  created date`<br>`created date is invalid`           | `sort by created`                           | `group by created`     | `hide created date`    | `task.created`                                      |
 | `starts (on, before, after, on or before, on or after) <date>`<br>`starts (in, before, after, in or before, in or after) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has start date`<br>`no  start date`<br>`start date is invalid`                   | `sort by start`                             | `group by start`       | `hide start date`      | `task.start`                                        |
 | `scheduled (on, before, after, on or before, on or after) <date>`<br>`scheduled (in, before, after, in or before, in or after) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has scheduled date`<br>`no  scheduled date`<br>`scheduled date is invalid` | `sort by scheduled`                         | `group by scheduled`   | `hide scheduled date`  | `task.scheduled`                                    |
 | `due (on, before, after, on or before, on or after) <date>`<br>`due (in, before, after, in or before, in or after) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has due date`<br>`no  due date`<br>`due date is invalid`                               | `sort by due`                               | `group by due`         | `hide due date`        | `task.due`                                          |
 | `happens (on, before, after, on or before, on or after) <date>`<br>`happens (in, before, after, in or before, in or after) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has happens date`<br>`no  happens date`                                        | `sort by happens`                           | `group by happens`     |                        | `task.happens`                                      |
-| **[[Filters#Recurrence\|Recurrence]]**                                                                                                                                                                                                                                                          |                                             |                        |                        |                                                     |
-| `is recurring`<br>`is not recurring`                                                                                                                                                                                                                                                            | `sort by recurring`                         | `group by recurring`   |                        | `task.isRecurring`                                  |
-| `recurrence (includes, does not include) <string>`<br>`recurrence (regex matches, regex does not match) /regex/i`                                                                                                                                                                               |                                             | `group by recurrence`  | `hide recurrence rule` | `task.recurrenceRule`                               |
-| **[[Filters#Priority\|Priority]]** and **[[Urgency\|urgency]]**                                                                                                                                                                                                                                 |                                             |                        |                        |                                                     |
-| `priority is (above, below, not)? (lowest, low, none, medium, high, highest)`                                                                                                                                                                                                                   | `sort by priority`                          | `group by priority`    | `hide priority`        | `task.priorityName`<br>`task.priorityNumber`        |
-|                                                                                                                                                                                                                                                                                                 | `sort by urgency`                           | `group by urgency`     | `show urgency`         | `task.urgency`                                      |
-| **[[Filters#Filters for File Properties\|File properties]]**                                                                                                                                                                                                                                    |                                             |                        |                        |                                                     |
-| `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             | `sort by path`                              | `group by path`        |                        | `task.file.path`                                    |
-| `root (includes, does not include) <root>`<br>`root (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             |                                             | `group by root`        |                        | `task.file.root`                                    |
-| `folder (includes, does not include) <folder>`<br>`folder (regex matches, regex does not match) /regex/i`                                                                                                                                                                                       |                                             | `group by folder`      |                        | `task.file.folder`                                  |
-| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`                                                                                                                                                                                 | `sort by filename`                          | `group by filename`    |                        | `task.file.filename`                                |
-| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                                                                                                                     | `sort by heading`                           | `group by heading`     |                        | `task.hasHeading`<br>`task.heading`                 |
-|                                                                                                                                                                                                                                                                                                 |                                             | `group by backlink`    | `hide backlink`        |                                                     |
-| **[[Filters#Description\|Description]]**, **[[Filters#Tags\|Tags]]** and other odds and ends                                                                                                                                                                                                    |                                             |                        |                        |                                                     |
-| `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i`                                                                                                                                                                             | `sort by description`                       |                        |                        | `task.description`<br>`task.descriptionWithoutTags` |
-| `has tags`<br>`no tags`<br>`tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i`                                                                  | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`        | `hide tags`            | `task.tags`                                         |
-|                                                                                                                                                                                                                                                                                                 |                                             |                        |                        | `task.originalMarkdown`                             |
-| **[[About Scripting\|Scripting]]**                                                                                                                                                                                                                                                              |                                             |                        |                        |                                                     |
-| `filter by function`                                                                                                                                                                                                                                                                            |                                             | `group by function`    |                        |                                                     |
-| **[[Combining Filters]]**                                                                                                                                                                                                                                                                       |                                             |                        |                        |                                                     |
-| `(filter 1) AND (filter 2)`                                                                                                                                                                                                                                                                     |                                             |                        |                        |                                                     |
-| `(filter 1) OR (filter 2)`                                                                                                                                                                                                                                                                      |                                             |                        |                        |                                                     |
-| `NOT (filter 1)`                                                                                                                                                                                                                                                                                |                                             |                        |                        |                                                     |
-| `(filter 1) XOR (filter 2)`                                                                                                                                                                                                                                                                     |                                             |                        |                        |                                                     |
-| `(filter 1) AND NOT (filter 2)`                                                                                                                                                                                                                                                                 |                                             |                        |                        |                                                     |
-| `(filter 1) OR NOT (filter 2)`                                                                                                                                                                                                                                                                  |                                             |                        |                        |                                                     |
-| `(filter 1) AND ((filter 2) OR (filter 3))`                                                                                                                                                                                                                                                     |                                             |                        |                        |                                                     |
-| **Other Filter Options**                                                                                                                                                                                                                                                                        |                                             |                        |                        |                                                     |
-| `exclude sub-items`                                                                                                                                                                                                                                                                             |                                             |                        |                        |                                                     |
-| `limit to <number> tasks`<br>`limit <number>`                                                                                                                                                                                                                                                   |                                             |                        |                        |                                                     |
-| `limit groups to <number> tasks`<br>`limit groups <number>`                                                                                                                                                                                                                                     |                                             |                        |                        |                                                     |
-| **Other Layout Options**                                                                                                                                                                                                                                                                        |                                             |                        |                        |                                                     |
-| `hide edit button`                                                                                                                                                                                                                                                                              |                                             |                        |                        |                                                     |
-| `hide task count`                                                                                                                                                                                                                                                                               |                                             |                        |                        |                                                     |
-| `short mode`                                                                                                                                                                                                                                                                                    |                                             |                        |                        |                                                     |
-| **Other Instructions**                                                                                                                                                                                                                                                                          |                                             |                        |                        |                                                     |
-| `ignore global query`                                                                                                                                                                                                                                                                           |                                             |                        |                        |                                                     |
-| `explain`                                                                                                                                                                                                                                                                                       |                                             |                        |                        |                                                     |
-| `# comment`                                                                                                                                                                                                                                                                                     |                                             |                        |                        |                                                     |
+| **[[Filters#Recurrence\|Recurrence]]**                                                                                                                                                                                                                                                                                                                |                                             |                        |                        |                                                     |
+| `is recurring`<br>`is not recurring`                                                                                                                                                                                                                                                                                                                  | `sort by recurring`                         | `group by recurring`   |                        | `task.isRecurring`                                  |
+| `recurrence (includes, does not include) <string>`<br>`recurrence (regex matches, regex does not match) /regex/i`                                                                                                                                                                                                                                     |                                             | `group by recurrence`  | `hide recurrence rule` | `task.recurrenceRule`                               |
+| **[[Filters#Priority\|Priority]]** and **[[Urgency\|urgency]]**                                                                                                                                                                                                                                                                                       |                                             |                        |                        |                                                     |
+| `priority is (above, below, not)? (lowest, low, none, medium, high, highest)`                                                                                                                                                                                                                                                                         | `sort by priority`                          | `group by priority`    | `hide priority`        | `task.priorityName`<br>`task.priorityNumber`        |
+|                                                                                                                                                                                                                                                                                                                                                       | `sort by urgency`                           | `group by urgency`     | `show urgency`         | `task.urgency`                                      |
+| **[[Filters#Filters for File Properties\|File properties]]**                                                                                                                                                                                                                                                                                          |                                             |                        |                        |                                                     |
+| `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`<br>`path includes {{query.file.path}}`                                                                                                                                                                                                            | `sort by path`                              | `group by path`        |                        | `task.file.path`                                    |
+| `root (includes, does not include) <root>`<br>`root (regex matches, regex does not match) /regex/i`<br>`root includes {{query.file.root}}`                                                                                                                                                                                                            |                                             | `group by root`        |                        | `task.file.root`                                    |
+| `folder (includes, does not include) <folder>`<br>`folder (regex matches, regex does not match) /regex/i`<br>`folder includes {{query.file.folder}}`                                                                                                                                                                                                  |                                             | `group by folder`      |                        | `task.file.folder`                                  |
+| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`<br>`filename includes {{query.file.filename}}`                                                                                                                                                                                        | `sort by filename`                          | `group by filename`    |                        | `task.file.filename`                                |
+| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                                                                                                                                                                           | `sort by heading`                           | `group by heading`     |                        | `task.hasHeading`<br>`task.heading`                 |
+|                                                                                                                                                                                                                                                                                                                                                       |                                             | `group by backlink`    | `hide backlink`        |                                                     |
+| **[[Filters#Description\|Description]]**, **[[Filters#Tags\|Tags]]** and other odds and ends                                                                                                                                                                                                                                                          |                                             |                        |                        |                                                     |
+| `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i`                                                                                                                                                                                                                                   | `sort by description`                       |                        |                        | `task.description`<br>`task.descriptionWithoutTags` |
+| `has tags`<br>`no tags`<br>`tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i`                                                                                                                        | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`        | `hide tags`            | `task.tags`                                         |
+|                                                                                                                                                                                                                                                                                                                                                       |                                             |                        |                        | `task.originalMarkdown`                             |
+| **[[About Scripting\|Scripting]]**                                                                                                                                                                                                                                                                                                                    |                                             |                        |                        |                                                     |
+| `filter by function`                                                                                                                                                                                                                                                                                                                                  |                                             | `group by function`    |                        |                                                     |
+| **[[Combining Filters]]**                                                                                                                                                                                                                                                                                                                             |                                             |                        |                        |                                                     |
+| `(filter 1) AND (filter 2)`                                                                                                                                                                                                                                                                                                                           |                                             |                        |                        |                                                     |
+| `(filter 1) OR (filter 2)`                                                                                                                                                                                                                                                                                                                            |                                             |                        |                        |                                                     |
+| `NOT (filter 1)`                                                                                                                                                                                                                                                                                                                                      |                                             |                        |                        |                                                     |
+| `(filter 1) XOR (filter 2)`                                                                                                                                                                                                                                                                                                                           |                                             |                        |                        |                                                     |
+| `(filter 1) AND NOT (filter 2)`                                                                                                                                                                                                                                                                                                                       |                                             |                        |                        |                                                     |
+| `(filter 1) OR NOT (filter 2)`                                                                                                                                                                                                                                                                                                                        |                                             |                        |                        |                                                     |
+| `(filter 1) AND ((filter 2) OR (filter 3))`                                                                                                                                                                                                                                                                                                           |                                             |                        |                        |                                                     |
+| **Other Filter Options**                                                                                                                                                                                                                                                                                                                              |                                             |                        |                        |                                                     |
+| `exclude sub-items`                                                                                                                                                                                                                                                                                                                                   |                                             |                        |                        |                                                     |
+| `limit to <number> tasks`<br>`limit <number>`                                                                                                                                                                                                                                                                                                         |                                             |                        |                        |                                                     |
+| `limit groups to <number> tasks`<br>`limit groups <number>`                                                                                                                                                                                                                                                                                           |                                             |                        |                        |                                                     |
+| **Other Layout Options**                                                                                                                                                                                                                                                                                                                              |                                             |                        |                        |                                                     |
+| `hide edit button`                                                                                                                                                                                                                                                                                                                                    |                                             |                        |                        |                                                     |
+| `hide task count`                                                                                                                                                                                                                                                                                                                                     |                                             |                        |                        |                                                     |
+| `short mode`                                                                                                                                                                                                                                                                                                                                          |                                             |                        |                        |                                                     |
+| **Other Instructions**                                                                                                                                                                                                                                                                                                                                |                                             |                        |                        |                                                     |
+| `ignore global query`                                                                                                                                                                                                                                                                                                                                 |                                             |                        |                        |                                                     |
+| `explain`                                                                                                                                                                                                                                                                                                                                             |                                             |                        |                        |                                                     |
+| `# comment`                                                                                                                                                                                                                                                                                                                                           |                                             |                        |                        |                                                     |
diff --git a/docs/Scripting/About Scripting.md b/docs/Scripting/About Scripting.md
index d0417569..0d374185 100644
--- a/docs/Scripting/About Scripting.md
+++ b/docs/Scripting/About Scripting.md
@@ -15,6 +15,10 @@ We are using the word 'scripting' in a very loose sense here:
 - For now, it refers only to writing JavaScript expressions in Tasks query blocks.
 - It is intended to evolve in to something broader over time.
 
+## Placeholder capabilities
+
+- [[Placeholders]] - use placeholder text in native Tasks queries, such as  `{{query.file.path}}` to refer to some properties of the file containing the query.
+
 ## Scripting capabilities
 
 - [[Custom Filters]] - write short JavaScript expressions to create task search filters.
@@ -26,4 +30,5 @@ We are using the word 'scripting' in a very loose sense here:
 
 - [[Task Properties]] - all the available task properties, such as `task.description`,  `task.file.path`.
   - Note: The properties are also listed in [[Quick Reference]].
+- [[Query Properties]] - all the available task properties, such as  `query.file.path`,  `query.file.path` - available for use via [[Placeholders]].
 - [[Expressions]] - some background about how JavaScript expressions work, for use in Tasks code blocks.
diff --git a/docs/Scripting/Custom Filters.md b/docs/Scripting/Custom Filters.md
index 4311c928..5e09e7e9 100644
--- a/docs/Scripting/Custom Filters.md
+++ b/docs/Scripting/Custom Filters.md
@@ -38,6 +38,15 @@ The Reference section [[Task Properties]] shows all the task properties availabl
 
 The available task properties are also shown in the [[Quick Reference]] table.
 
+### Available Query Properties
+
+The Reference section [[Query Properties]] shows all the query properties available for use via [[Placeholders]] in custom filters.
+
+Any placeholders in custom filters must be surrounded by quotes.
+
+> [!released]
+> Query properties and placeholders were introduced in Tasks 4.7.0.
+
 ### Expressions
 
 The instructions look like this:
@@ -136,7 +145,12 @@ For users who are comfortable with JavaScript, these more complicated examples m
   - Find tasks in files in any file in the given folder **only**, and not any sub-folders.
   - The equality test, `===`, requires that the trailing slash (`/`) be included.
 - ```filter by function task.file.folder.includes("Work/Projects/")```
-  - Find tasks in files in any folder **and any sub-folders**.
+  - Find tasks in files in a specific folder **and any sub-folders**.
+- ```filter by function task.file.folder.includes( '{{query.file.folder}}' )```
+  - Find tasks in files in the folder that contains the query **and any sub-folders**.
+  - Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
+- ```filter by function task.file.folder === '{{query.file.folder}}'```
+  - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
 - ```filter by function task.file.folder.includes("Work/Projects")```
   - By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as:
     - `Work/Projects 2023/`
diff --git a/docs/Scripting/Custom Grouping.md b/docs/Scripting/Custom Grouping.md
index f84f1a87..57ced5f5 100644
--- a/docs/Scripting/Custom Grouping.md
+++ b/docs/Scripting/Custom Grouping.md
@@ -37,6 +37,15 @@ The Reference section [[Task Properties]] shows all the task properties availabl
 
 The available task properties are also shown in the [[Quick Reference]] table.
 
+### Available Query Properties
+
+The Reference section [[Query Properties]] shows all the query properties available for use via [[Placeholders]] in custom grouping.
+
+Any placeholders in custom groups must be surrounded by quotes.
+
+> [!released]
+> Query properties and placeholders were introduced in Tasks 4.7.0.
+
 ### Expressions
 
 The instructions look like this:
diff --git a/docs/Scripting/Placeholders.md b/docs/Scripting/Placeholders.md
new file mode 100644
index 00000000..6111f15f
--- /dev/null
+++ b/docs/Scripting/Placeholders.md
@@ -0,0 +1,124 @@
+---
+publish: true
+---
+
+# Placeholders
+
+<span class="related-pages">#feature/scripting</span>
+
+> [!released]
+> Placeholders were introduced in Tasks 4.7.0.
+
+## Summary
+
+- Tasks provides a placeholder facility to enable filters to access the location of the query file.
+- Any known property inside a pair of `{{` and `}}` strings is expanded to a value obtained from the query file's path.
+- For example,:
+  - `{{query.file.path}}` might get expanded to
+  - `some/sample/actions on my hobby.md` - for any Tasks queries inside that file.
+- The available values for use in placeholders are listed in [[Query Properties]].
+
+## Checking placeholder values
+
+The [[Explaining Queries|explain]] instruction shows how any placeholders in the query are interpreted. This can be used to understand how placeholders are expanded generally.
+
+For example, when the following query with [[Query Properties]] in [[Placeholders|placeholders]] is placed in a tasks query block in the file `some/sample/file path.md`:
+
+<!-- snippet: DocsSamplesForExplain.test.explain_placeholders.approved.query.text -->
+```text
+explain
+path includes {{query.file.path}}
+root includes {{query.file.root}}
+folder includes {{query.file.folder}}
+filename includes {{query.file.filename}}
+```
+<!-- endSnippet -->
+
+the results begin with the following, which demonstrates how each value inside `{{...}}` was expanded:
+
+<!-- snippet: DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text -->
+```text
+Explanation of this Tasks code block query:
+
+path includes some/sample/file path.md
+
+root includes some/
+
+folder includes some/sample/
+
+filename includes file path.md
+```
+<!-- endSnippet -->
+
+## Error checking: invalid variables
+
+If there are any unknown properties in the placeholders, a clear message is written.
+
+For example, the following shows that the names of query properties are case-sensitive:
+
+<!-- snippet: DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text -->
+```text
+# query.file.fileName is invalid, because of the capital N.
+# query.file.filename is the correct property name.
+filename includes {{query.file.fileName}}
+```
+<!-- endSnippet -->
+
+... generates this output:
+
+```text
+Tasks query: There was an error expanding one or more placeholders.
+
+The error message was:
+    Unknown property: query.file.fileName
+
+The problem is in:
+    filename includes {{query.file.fileName}}
+```
+
+%% ---------------------------------------------------------------------------
+IF THIS TEXT CHANGES, IT MEANS THE HARD-CODED OUTPUT ABOVE NEEDS TO BE UPDATED:
+
+<!-- snippet: DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text -->
+```text
+Explanation of this Tasks code block query:
+
+Query has an error:
+There was an error expanding one or more placeholders.
+
+The error message was:
+    Unknown property: query.file.fileName
+
+The problem is in:
+    filename includes {{query.file.fileName}}
+```
+<!-- endSnippet -->
+--------------------------------------------------------------------------- %%
+
+## Things to be aware of
+
+- The symbols are case-sensitive:
+  - `query.file.fileName` is not recognised
+- When placeholders are used in custom filters and groups, they must be surrounded by quotes.
+  - For example: `'{{query.file.folder}}'`
+
+## Known Limitations
+
+- It complains about any unrecognised placeholders in comments, even though comments are then ignored.
+- Explanations:
+  - `explain` instructions only show the expanded text.
+  - It would be nice to also show the original variable name, and then the expanded text.
+- Use in regular expressions is allowed
+  - but due to [[Regular Expressions#Special characters|characters with special meanings]] in regular expressions, it is not recommended to use them.
+- When you rename a file containing a tasks query block with variable names in, the query block is not automatically updated:
+  - the workaround is to close and re-open the file containing the query.
+
+## Missing Features
+
+- Searching by today's date or time
+- Getting date strings from file names
+
+## Technical Details
+
+- The templating library used is [mustache.js](https://www.npmjs.com/package/mustache).
+- Error-checking to detect use of unknown variables is implemented via [mustache-validator](https://www.npmjs.com/package/mustache-validator).
diff --git a/docs/Scripting/Query Properties.md b/docs/Scripting/Query Properties.md
new file mode 100644
index 00000000..af7830cc
--- /dev/null
+++ b/docs/Scripting/Query Properties.md
@@ -0,0 +1,45 @@
+---
+publish: true
+---
+
+# Query Properties
+
+<span class="related-pages">#feature/scripting</span>
+
+> [!released]
+> Query Properties were introduced in Tasks 4.7.0.
+
+## Introduction
+
+In a growing number of locations, Tasks allows programmatic/scripting access to properties of the file containing the search query:
+
+- [[Placeholders]]
+
+This page documents all the available pieces of information in Queries that you can access.
+
+> [!warning]
+>
+> - These properties can currently only be used in [[Placeholders]].
+> - Placeholders can be in [[Custom Filters]] and [[Custom Grouping]], but must be surrounded by quotes. For example: `'{{query.file.folder}}'`.
+> - In a future release, we will allow expressions such as `query.file.folder` to be used directly in custom filters and groups.
+
+## Values for Query File Properties
+
+<!-- placeholder to force blank line before included text --><!-- include: QueryProperties.test.query_file_properties.approved.md -->
+
+| Field | Type | Example |
+| ----- | ----- | ----- |
+| `query.file.path` | `string` | `'root/sub-folder/file containing query.md'` |
+| `query.file.pathWithoutExtension` | `string` | `'root/sub-folder/file containing query'` |
+| `query.file.root` | `string` | `'root/'` |
+| `query.file.folder` | `string` | `'root/sub-folder/'` |
+| `query.file.filename` | `string` | `'file containing query.md'` |
+| `query.file.filenameWithoutExtension` | `string` | `'file containing query'` |
+
+<!-- placeholder to force blank line after included text --><!-- endInclude -->
+
+1. `query.file` is a `TasksFile` object.
+1. You can see the current [TasksFile source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksFile.ts), to explore its capabilities.
+1. The presence of `.md` filename extensions is chosen to match the existing conventions in the Tasks filter instructions [[Filters#File Path|path]] and [[Filters#File Name|filename]].
+1. `query.file.pathWithoutExtension` was added in Tasks X.Y.Z.
+1. `query.file.filenameWithoutExtension` was added in Tasks X.Y.Z.
diff --git a/docs/Scripting/Task Properties.md b/docs/Scripting/Task Properties.md
index 3fe2a3ac..478776ae 100644
--- a/docs/Scripting/Task Properties.md
+++ b/docs/Scripting/Task Properties.md
@@ -97,9 +97,11 @@ For more information, including adding your own customised statuses, see [[Statu
 | Field | Type 1 | Example 1 | Type 2 | Example 2 |
 | ----- | ----- | ----- | ----- | ----- |
 | `task.file.path` | `string` | `'some/folder/fileName.md'` | `string` | `''` |
+| `task.file.pathWithoutExtension` | `string` | `'some/folder/fileName'` | `string` | `''` |
 | `task.file.root` | `string` | `'some/'` | `string` | `'/'` |
 | `task.file.folder` | `string` | `'some/folder/'` | `string` | `'/'` |
 | `task.file.filename` | `string` | `'fileName.md'` | `string` | `''` |
+| `task.file.filenameWithoutExtension` | `string` | `'fileName'` | `string` | `''` |
 | `task.hasHeading` | `boolean` | `true` | `boolean` | `false` |
 | `task.heading` | `string` | `'My Header'` | `null` | `null` |
 
@@ -108,3 +110,5 @@ For more information, including adding your own customised statuses, see [[Statu
 1. `task.file` is a `TasksFile` object.
 1. You can see the current [TasksFile source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksFile.ts), to explore its capabilities.
 1. The presence of `.md` filename extensions is chosen to match the existing conventions in the Tasks filter instructions [[Filters#File Path|path]] and [[Filters#File Name|filename]].
+1. `task.file.pathWithoutExtension` was added in Tasks X.Y.Z.
+1. `task.file.filenameWithoutExtension` was added in Tasks X.Y.Z.
diff --git a/manifest.json b/manifest.json
index 3125317e..8569d755 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "obsidian-tasks-plugin",
   "name": "Tasks",
-  "version": "4.6.1",
+  "version": "4.7.1",
   "minAppVersion": "1.1.1",
   "description": "Task management for Obsidian",
   "author": "Martin Schenck and Clare Macrae",
diff --git a/package.json b/package.json
index 037290a0..43fe10e7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "obsidian-tasks",
-  "version": "4.6.1",
+  "version": "4.7.1",
   "description": "Task management for the Obsidian knowledge base",
   "main": "main.js",
   "scripts": {
@@ -29,6 +29,7 @@
     "@testing-library/svelte": "^3.2.2",
     "@tsconfig/svelte": "^3.0.0",
     "@types/jest": "^29.5.2",
+    "@types/mustache": "^4.2.2",
     "@typescript-eslint/eslint-plugin": "^5.59.8",
     "@typescript-eslint/parser": "^5.59.8",
     "approvals": "^6.2.1",
@@ -51,14 +52,16 @@
     "svelte-check": "^3.4.6",
     "svelte-jester": "^2.3.2",
     "svelte-preprocess": "^5.0.4",
-    "ts-jest": "^29.1.0",
+    "ts-jest": "^29.1.1",
     "tslib": "^2.5.2",
     "typescript": "^5.0.4"
   },
   "dependencies": {
-    "boon-js": "^2.0.3",
+    "boon-js": "^2.0.4",
     "chrono-node": "2.3.9",
     "eventemitter2": "^6.4.5",
-    "rrule": "^2.7.1"
+    "mustache": "^4.2.0",
+    "mustache-validator": "^0.2.0",
+    "rrule": "^2.7.2"
   }
 }
diff --git a/resources/sample_vaults/Tasks-Demo/Filters/Search for tasks in file or folder containing the Query.md b/resources/sample_vaults/Tasks-Demo/Filters/Search for tasks in file or folder containing the Query.md
new file mode 100644
index 00000000..0c13c572
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Filters/Search for tasks in file or folder containing the Query.md
@@ -0,0 +1,20 @@
+# Search for tasks in file or folder containing the Query
+
+See [How to get all tasks in the current file](https://publish.obsidian.md/tasks/How+To/How+to+get+tasks+in+current+file).
+
+## Sample Tasks
+
+- [ ] #task Task 1
+- [ ] #task Task 2
+- [ ] #task Task 3
+- [ ] #task Task 4
+
+## Search
+
+```tasks
+explain
+path includes {{query.file.path}}
+root includes {{query.file.root}}
+folder includes {{query.file.folder}}
+filename includes {{query.file.filename}}
+```
diff --git a/src/Commands/CreateOrEditTaskParser.ts b/src/Commands/CreateOrEditTaskParser.ts
index f4f93d41..d56e513f 100644
--- a/src/Commands/CreateOrEditTaskParser.ts
+++ b/src/Commands/CreateOrEditTaskParser.ts
@@ -4,6 +4,37 @@ import { DateFallback } from '../DateFallback';
 import { StatusRegistry } from '../StatusRegistry';
 import { TaskLocation } from '../TaskLocation';
 import { getSettings } from '../Config/Settings';
+import { GlobalFilter } from '../Config/GlobalFilter';
+
+function getDefaultCreatedDate() {
+    const { setCreatedDate } = getSettings();
+    return setCreatedDate ? window.moment() : null;
+}
+
+function shouldUpdateCreatedDateForTask(task: Task) {
+    const { setCreatedDate } = getSettings();
+
+    if (!setCreatedDate) {
+        // Auto-adding of Created Date is disabled in settings.
+        return false;
+    }
+
+    if (task.createdDate !== null) {
+        // The task already had a created date, so don't change it.
+        return false;
+    }
+
+    // If the description was empty, treat it as new and add a creation date.
+    const descriptionIsEmpty = task.description === '';
+
+    // If the global filter will be added when the task is saved, treat it as new and add a creation date.
+    // See issue #2112.
+    const globalFilterEnabled = !GlobalFilter.getInstance().isEmpty();
+    const taskDoesNotContainGlobalFilter = !GlobalFilter.getInstance().includedIn(task.description);
+    const needsGlobalFilterToBeAdded = globalFilterEnabled && taskDoesNotContainGlobalFilter;
+
+    return descriptionIsEmpty || needsGlobalFilterToBeAdded;
+}
 
 /**
  * Read any markdown line and treat it as a task, for the purposes of
@@ -27,15 +58,16 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta
         DateFallback.fromPath(path), // set the scheduled date from the filename, so it can be displayed in the dialog
     );
 
+    const createdDate = getDefaultCreatedDate();
+
     if (task !== null) {
+        if (shouldUpdateCreatedDateForTask(task)) {
+            return new Task({ ...task, createdDate });
+        }
         return task;
     }
 
-    const { setCreatedDate } = getSettings();
-    const createdDate = setCreatedDate ? window.moment() : null;
-
     // 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 nonTaskMatch = line.match(TaskRegularExpressions.nonTaskRegex);
     if (nonTaskMatch === null) {
         // Should never happen; everything in the regex is optional.
diff --git a/src/Config/GlobalFilter.ts b/src/Config/GlobalFilter.ts
index 9158235a..a24b3ac3 100644
--- a/src/Config/GlobalFilter.ts
+++ b/src/Config/GlobalFilter.ts
@@ -1,61 +1,99 @@
 import * as RegExpTools from '../lib/RegExpTools';
-import { getSettings, updateSettings } from './Settings';
 
+/**
+ * GlobalFilter has its own data, independent of {@link Settings.globalFilter} value in {@link Settings}.
+ *
+ * See https://publish.obsidian.md/tasks/Getting+Started/Global+Filter
+ *
+ * Limitations:
+ * - All methods are static, so it is a collection of multiple static things
+ *     - This is in contrast to {@link GlobalQuery} what has just the one static method, {@link GlobalQuery.getInstance}.
+ *     - These static methods will be made non-static in a future change.
+ */
 export class GlobalFilter {
+    private static instance: GlobalFilter;
+
     static empty = '';
+    private _globalFilter = '';
+    private _removeGlobalFilter = false;
+
+    /**
+     * Provides access to the single global instance of GlobalFilter.
+     * This should eventually only be used in the plugin code.
+     */
+    public static getInstance(): GlobalFilter {
+        if (!GlobalFilter.instance) {
+            GlobalFilter.instance = new GlobalFilter();
+        }
+
+        return GlobalFilter.instance;
+    }
 
-    static get(): string {
-        const { globalFilter } = getSettings();
-        return globalFilter;
+    public get(): string {
+        return this._globalFilter;
     }
 
-    static set(value: string) {
-        updateSettings({ globalFilter: value });
+    public set(value: string) {
+        this._globalFilter = value;
     }
 
-    static reset() {
-        updateSettings({ globalFilter: GlobalFilter.empty });
+    public reset() {
+        this.set(GlobalFilter.empty);
     }
 
-    static isEmpty(): boolean {
-        return GlobalFilter.get() === GlobalFilter.empty;
+    public isEmpty(): boolean {
+        return this.get() === GlobalFilter.empty;
     }
 
-    static equals(tag: string): boolean {
-        return GlobalFilter.get() === tag;
+    public equals(tag: string): boolean {
+        return this.get() === tag;
     }
 
-    static includedIn(description: string): boolean {
-        const globalFilter = GlobalFilter.get();
+    public includedIn(description: string): boolean {
+        const globalFilter = this.get();
         return description.includes(globalFilter);
     }
 
-    static prependTo(description: string): string {
-        return GlobalFilter.get() + ' ' + description;
+    public prependTo(description: string): string {
+        return this.get() + ' ' + description;
     }
 
-    static removeAsWordFromDependingOnSettings(description: string): string {
-        const { removeGlobalFilter } = getSettings();
+    public removeAsWordFromDependingOnSettings(description: string): string {
+        const removeGlobalFilter = this.getRemoveGlobalFilter();
         if (removeGlobalFilter) {
-            return GlobalFilter.removeAsWordFrom(description);
+            return this.removeAsWordFrom(description);
         }
 
         return description;
     }
 
+    /**
+     * @see setRemoveGlobalFilter
+     */
+    public getRemoveGlobalFilter() {
+        return this._removeGlobalFilter;
+    }
+
+    /**
+     * @see getRemoveGlobalFilter
+     */
+    public setRemoveGlobalFilter(removeGlobalFilter: boolean) {
+        this._removeGlobalFilter = removeGlobalFilter;
+    }
+
     /**
      * Search for the global filter for the purpose of removing it from the description, but do so only
      * if it is a separate word (preceding the beginning of line or a space and followed by the end of line
      * or a space), because we don't want to cut-off nested tags like #task/subtag.
      * If the global filter exists as part of a nested tag, we keep it untouched.
      */
-    static removeAsWordFrom(description: string): string {
-        if (GlobalFilter.isEmpty()) {
+    public removeAsWordFrom(description: string): string {
+        if (this.isEmpty()) {
             return description;
         }
 
         // This matches the global filter (after escaping it) only when it's a complete word
-        const theRegExp = RegExp('(^|\\s)' + RegExpTools.escapeRegExp(GlobalFilter.get()) + '($|\\s)', 'ug');
+        const theRegExp = RegExp('(^|\\s)' + RegExpTools.escapeRegExp(this.get()) + '($|\\s)', 'ug');
 
         if (description.search(theRegExp) > -1) {
             description = description.replace(theRegExp, '$1$2').replace('  ', ' ').trim();
@@ -64,8 +102,8 @@ export class GlobalFilter {
         return description;
     }
 
-    static removeAsSubstringFrom(description: string): string {
-        const globalFilter = GlobalFilter.get();
+    public removeAsSubstringFrom(description: string): string {
+        const globalFilter = this.get();
         return description.replace(globalFilter, '').trim();
     }
 }
diff --git a/src/Config/GlobalQuery.ts b/src/Config/GlobalQuery.ts
new file mode 100644
index 00000000..9b6dc3f9
--- /dev/null
+++ b/src/Config/GlobalQuery.ts
@@ -0,0 +1,62 @@
+import { Query } from '../Query/Query';
+
+/**
+ * Global Query set in the {@link SettingsTab} and associated services.
+ *
+ * There are two ways of using this class.
+ * - In 'production' code, that is in the actual plugin code that is released,
+ *   call {@link GlobalQuery.getInstance()} to obtain the single global instance.
+ * - Tests of Global Query capabilities do not need to modify the global instance:
+ *   They should use `new GlobalQuery()`, which makes simpler, more readable
+ *   tests that can be run in parallel.
+ *
+ * @export
+ * @class GlobalQuery
+ */
+export class GlobalQuery {
+    private static instance: GlobalQuery;
+
+    static empty = '';
+    private _source;
+
+    /**
+     * Creates an instance of Global Query object.
+     *
+     * Code in the plugin should use {@link getInstance} to access the Global Query services.
+     */
+    constructor(source: string = GlobalQuery.empty) {
+        this._source = source;
+    }
+
+    /**
+     * Provides access to the single global instance of the Global Query.
+     * This should be used in the plugin code.
+     */
+    public static getInstance(): GlobalQuery {
+        if (!GlobalQuery.instance) {
+            GlobalQuery.instance = new GlobalQuery();
+        }
+
+        return GlobalQuery.instance;
+    }
+
+    public set(source: string) {
+        this._source = source;
+    }
+
+    /**
+     * Returns {@link Query} object with the Global Query
+     * @param path
+     */
+    public query(path: string | undefined = undefined): Query {
+        return new Query({ source: this._source }, path);
+    }
+
+    /**
+     * Returns true if the Global Query contains characters except line breaks and spaces.
+     *
+     */
+    public hasInstructions() {
+        return this._source.trim() !== GlobalQuery.empty;
+    }
+}
diff --git a/src/Config/Settings.ts b/src/Config/Settings.ts
index c92fed1e..178aaff1 100644
--- a/src/Config/Settings.ts
+++ b/src/Config/Settings.ts
@@ -14,7 +14,6 @@ import { DebugSettings } from './DebugSettings';
 import { StatusSettings } from './StatusSettings';
 import { Feature } from './Feature';
 import type { FeatureFlag } from './Feature';
-import { GlobalFilter } from './GlobalFilter';
 
 interface SettingsMap {
     [key: string]: string | boolean;
@@ -91,7 +90,7 @@ export interface Settings {
 
 const defaultSettings: Settings = {
     globalQuery: '',
-    globalFilter: GlobalFilter.empty,
+    globalFilter: '',
     removeGlobalFilter: false,
     taskFormat: 'tasksPluginEmoji',
     setCreatedDate: false,
@@ -212,12 +211,3 @@ export const toggleFeature = (internalName: string, enabled: boolean): FeatureFl
 export function getUserSelectedTaskFormat(): TaskFormat {
     return TASK_FORMATS[getSettings().taskFormat];
 }
-
-/**
- * Retrieves the source of the global {@link Query}
- *
- * @exports
- */
-export function getGlobalQuerySource(): { source: string } {
-    return { source: getSettings().globalQuery };
-}
diff --git a/src/Config/SettingsTab.ts b/src/Config/SettingsTab.ts
index 6427384e..c9e91765 100644
--- a/src/Config/SettingsTab.ts
+++ b/src/Config/SettingsTab.ts
@@ -12,6 +12,7 @@ import { StatusSettings } from './StatusSettings';
 import settingsJson from './settingsConfiguration.json';
 
 import { CustomStatusModal } from './CustomStatusModal';
+import { GlobalQuery } from './GlobalQuery';
 
 export class SettingsTab extends PluginSettingTab {
     // If the UI needs a more complex setting you can create a
@@ -100,9 +101,10 @@ export class SettingsTab extends PluginSettingTab {
                 // but wasn't able to figure out how to make the text box
                 // wide enough for the whole string to be visible.
                 text.setPlaceholder('e.g. #task or TODO')
-                    .setValue(GlobalFilter.get())
+                    .setValue(GlobalFilter.getInstance().get())
                     .onChange(async (value) => {
-                        GlobalFilter.set(value);
+                        updateSettings({ globalFilter: value });
+                        GlobalFilter.getInstance().set(value);
                         await this.plugin.saveSettings();
                     });
             });
@@ -117,7 +119,7 @@ export class SettingsTab extends PluginSettingTab {
 
                 toggle.setValue(settings.removeGlobalFilter).onChange(async (value) => {
                     updateSettings({ removeGlobalFilter: value });
-
+                    GlobalFilter.getInstance().setRemoveGlobalFilter(value);
                     await this.plugin.saveSettings();
                 });
             });
@@ -143,7 +145,7 @@ export class SettingsTab extends PluginSettingTab {
                         .setValue(settings.globalQuery)
                         .onChange(async (value) => {
                             updateSettings({ globalQuery: value });
-
+                            GlobalQuery.getInstance().set(value);
                             await this.plugin.saveSettings();
                         });
                 }),
diff --git a/src/File.ts b/src/File.ts
index 56c301bc..914cec04 100644
--- a/src/File.ts
+++ b/src/File.ts
@@ -345,7 +345,7 @@ function tryFindingLineNumberFromTaskSectionInfo(
         }
 
         const line = fileLines[listItemLineNumber];
-        if (GlobalFilter.includedIn(line)) {
+        if (GlobalFilter.getInstance().includedIn(line)) {
             if (sectionIndex === originalTask.taskLocation.sectionIndex) {
                 if (line === originalTask.originalMarkdown) {
                     taskLineNumber = listItemLineNumber;
diff --git a/src/InlineRenderer.ts b/src/InlineRenderer.ts
index 188c31e7..6405293f 100644
--- a/src/InlineRenderer.ts
+++ b/src/InlineRenderer.ts
@@ -2,6 +2,7 @@ import type { MarkdownPostProcessorContext, Plugin } from 'obsidian';
 import { MarkdownRenderChild } from 'obsidian';
 import { GlobalFilter } from './Config/GlobalFilter';
 import { Task } from './Task';
+import { taskToLi } from './TaskLineRenderer';
 import { TaskLocation } from './TaskLocation';
 
 export class InlineRenderer {
@@ -46,7 +47,7 @@ export class InlineRenderer {
                 return false;
             }
 
-            return GlobalFilter.includedIn(firstLineText);
+            return GlobalFilter.getInstance().includedIn(firstLineText);
         });
         if (renderedElements.length === 0) {
             // No tasks means nothing to do.
@@ -99,7 +100,7 @@ export class InlineRenderer {
 
             const dataLine: string = renderedElement.getAttr('data-line') ?? '0';
             const listIndex: number = Number.parseInt(dataLine, 10);
-            const taskElement = await task.toLi({
+            const taskElement = await taskToLi(task, {
                 parentUlElement: element,
                 listIndex,
                 obsidianComponent: childComponent,
diff --git a/src/Query/Filter/BacklinkField.ts b/src/Query/Filter/BacklinkField.ts
index b9af03cf..410d3557 100644
--- a/src/Query/Filter/BacklinkField.ts
+++ b/src/Query/Filter/BacklinkField.ts
@@ -1,7 +1,7 @@
 import type { Task } from '../../Task';
 import type { GrouperFunction } from '../Grouper';
-import { TextField } from './TextField';
 import { FilterOrErrorMessage } from './FilterOrErrorMessage';
+import { TextField } from './TextField';
 
 export class BacklinkField extends TextField {
     public fieldName(): string {
@@ -35,15 +35,13 @@ export class BacklinkField extends TextField {
                 return ['Unknown Location'];
             }
 
-            // Always escape the filename, to prevent any underscores being interpreted as markdown:
-            let result = TextField.escapeMarkdownCharacters(filename);
-
-            // Only append the heading if it differs from the un-escaped fileanme:
-            if (task.precedingHeader && task.precedingHeader !== filename) {
-                // We don't escape the heading, as any markdown characters in it really are markdown styling:
-                result += ' > ' + task.precedingHeader;
+            const header = task.precedingHeader;
+            if (header === null) {
+                return ['[[' + filename + ']]'];
             }
-            return [result];
+
+            // Always append the header, to ensure we navigate to the correct section of the file:
+            return [`[[${filename}#${header}|${filename} > ${header}]]`];
         };
     }
 }
diff --git a/src/Query/Filter/DescriptionField.ts b/src/Query/Filter/DescriptionField.ts
index 85f78655..1753cc63 100644
--- a/src/Query/Filter/DescriptionField.ts
+++ b/src/Query/Filter/DescriptionField.ts
@@ -25,7 +25,7 @@ export class DescriptionField extends TextField {
         // Remove global filter from description match if present.
         // This is necessary to match only on the content of the task, not
         // the global filter.
-        return GlobalFilter.removeAsSubstringFrom(task.description);
+        return GlobalFilter.getInstance().removeAsSubstringFrom(task.description);
     }
 
     public supportsSorting(): boolean {
@@ -55,7 +55,7 @@ export class DescriptionField extends TextField {
      * Properly reads links [[like this|one]] (note pipe).
      */
     public static cleanDescription(description: string): string {
-        description = GlobalFilter.removeAsSubstringFrom(description);
+        description = GlobalFilter.getInstance().removeAsSubstringFrom(description);
 
         const startsWithLinkRegex = /^\[\[?([^\]]*)]]?/;
         const linkRegexMatch = description.match(startsWithLinkRegex);
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index 3e747864..8d2ac45f 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -1,3 +1,5 @@
+import { expandPlaceholders } from '../Scripting/ExpandPlaceholders';
+import { makeQueryContext } from '../Scripting/QueryContext';
 import { LayoutOptions } from '../TaskLayout';
 import type { Task } from '../Task';
 import type { IQuery } from '../IQuery';
@@ -12,7 +14,9 @@ import type { Filter } from './Filter/Filter';
 import { QueryResult } from './QueryResult';
 
 export class Query implements IQuery {
-    public source: string;
+    /** Note: source is the raw source, before expanding any placeholders */
+    public readonly source: string;
+    public readonly filePath: string | undefined;
 
     private _limit: number | undefined = undefined;
     private _taskGroupLimit: number | undefined = undefined;
@@ -33,12 +37,20 @@ export class Query implements IQuery {
 
     private readonly commentRegexp = /^#.*/;
 
-    constructor({ source }: { source: string }) {
+    constructor({ source }: { source: string }, path: string | undefined = undefined) {
         this.source = source;
+        this.filePath = path;
+
         source
             .split('\n')
-            .map((line: string) => line.trim())
-            .forEach((line: string) => {
+            .map((rawLine: string) => rawLine.trim())
+            .forEach((rawLine: string) => {
+                const line = this.expandPlaceholders(rawLine, path);
+                if (this.error !== undefined) {
+                    // There was an error expanding placeholders.
+                    return;
+                }
+
                 switch (true) {
                     case line === '':
                         break;
@@ -72,6 +84,37 @@ export class Query implements IQuery {
             });
     }
 
+    private expandPlaceholders(source: string, path: string | undefined) {
+        if (source.includes('{{') && source.includes('}}')) {
+            if (this.filePath === undefined) {
+                this._error = `The query looks like it contains a placeholder, with "{{" and "}}"
+but no file path has been supplied, so cannot expand placeholder values.
+The query is:
+${source}`;
+                return source;
+            }
+        }
+
+        // TODO Do not complain about any placeholder errors in comment lines
+        // TODO Show the original and expanded text in explanations
+        // TODO Give user error info if they try and put a string in a regex search
+        let expandedSource: string = source;
+        if (path) {
+            const queryContext = makeQueryContext(path);
+            try {
+                expandedSource = expandPlaceholders(source, queryContext);
+            } catch (error) {
+                if (error instanceof Error) {
+                    this._error = error.message;
+                } else {
+                    this._error = 'Internal error. expandPlaceholders() threw something other than Error.';
+                }
+                return source;
+            }
+        }
+        return expandedSource;
+    }
+
     /**
      *
      * Appends {@link q2} to this query.
@@ -95,7 +138,7 @@ export class Query implements IQuery {
     public append(q2: Query): Query {
         if (this.source === '') return q2;
         if (q2.source === '') return this;
-        return new Query({ source: `${this.source}\n${q2.source}` });
+        return new Query({ source: `${this.source}\n${q2.source}` }, this.filePath);
     }
 
     /**
@@ -107,6 +150,12 @@ export class Query implements IQuery {
     public explainQuery(): string {
         let result = '';
 
+        if (this.error !== undefined) {
+            result += 'Query has an error:\n';
+            result += this.error + '\n';
+            return result;
+        }
+
         const numberOfFilters = this.filters.length;
         if (numberOfFilters === 0) {
             result += 'No filters supplied. All tasks will match the query.';
diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts
index dfcc6551..b635f708 100644
--- a/src/QueryRenderer.ts
+++ b/src/QueryRenderer.ts
@@ -1,10 +1,13 @@
 import { App, Keymap, MarkdownRenderChild, MarkdownRenderer, Plugin, TFile } from 'obsidian';
 import type { EventRef, MarkdownPostProcessorContext } from 'obsidian';
+import { GlobalFilter } from './Config/GlobalFilter';
+import { GlobalQuery } from './Config/GlobalQuery';
 
 import type { IQuery } from './IQuery';
 import { State } from './Cache';
 import { getTaskLineAndFile, replaceTaskWithTasks } from './File';
 import type { GroupDisplayHeading } from './Query/GroupDisplayHeading';
+import { taskToLi } from './TaskLineRenderer';
 import { TaskModal } from './TaskModal';
 import type { TasksEvents } from './TasksEvents';
 import type { Task } from './Task';
@@ -89,12 +92,12 @@ class QueryRenderChild extends MarkdownRenderChild {
         // added later.
         switch (this.containerEl.className) {
             case 'block-language-tasks':
-                this.query = getQueryForQueryRenderer(this.source);
+                this.query = getQueryForQueryRenderer(this.source, GlobalQuery.getInstance(), this.filePath);
                 this.queryType = 'tasks';
                 break;
 
             default:
-                this.query = getQueryForQueryRenderer(this.source);
+                this.query = getQueryForQueryRenderer(this.source, GlobalQuery.getInstance(), this.filePath);
                 this.queryType = 'tasks';
                 break;
         }
@@ -135,7 +138,7 @@ class QueryRenderChild extends MarkdownRenderChild {
         const millisecondsToMidnight = midnight.getTime() - now.getTime();
 
         this.queryReloadTimeout = setTimeout(() => {
-            this.query = getQueryForQueryRenderer(this.source);
+            this.query = getQueryForQueryRenderer(this.source, GlobalQuery.getInstance(), this.filePath);
             // Process the current cache state:
             this.events.triggerRequestCacheUpdate(this.render.bind(this));
             this.reloadQueryAtMidnight();
@@ -198,7 +201,12 @@ class QueryRenderChild extends MarkdownRenderChild {
 
     // Use the 'explain' instruction to enable this
     private createExplanation(content: HTMLDivElement) {
-        const explanationAsString = explainResults(this.source);
+        const explanationAsString = explainResults(
+            this.source,
+            GlobalFilter.getInstance(),
+            GlobalQuery.getInstance(),
+            this.filePath,
+        );
 
         const explanationsBlock = content.createEl('pre');
         explanationsBlock.addClasses(['plugin-tasks-query-explanation']);
@@ -212,20 +220,17 @@ class QueryRenderChild extends MarkdownRenderChild {
     }: {
         tasks: Task[];
         content: HTMLDivElement;
-    }): Promise<{ taskList: HTMLUListElement; tasksCount: number }> {
-        const tasksCount = tasks.length;
-
+    }): Promise<HTMLUListElement> {
         const layout = new TaskLayout(this.query.layoutOptions);
         const taskList = content.createEl('ul');
         taskList.addClasses(['contains-task-list', 'plugin-tasks-query-result']);
         taskList.addClasses(layout.taskListHiddenClasses);
         const groupingAttribute = this.getGroupingAttribute();
         if (groupingAttribute && groupingAttribute.length > 0) taskList.dataset.taskGroupBy = groupingAttribute;
-        for (let i = 0; i < tasksCount; i++) {
-            const task = tasks[i];
+        for (const [i, task] of tasks.entries()) {
             const isFilenameUnique = this.isFilenameUnique({ task });
 
-            const listItem = await task.toLi({
+            const listItem = await taskToLi(task, {
                 parentUlElement: taskList,
                 listIndex: i,
                 layoutOptions: this.query.layoutOptions,
@@ -238,8 +243,6 @@ class QueryRenderChild extends MarkdownRenderChild {
             const footnotes = listItem.querySelectorAll('[data-footnote-id]');
             footnotes.forEach((footnote) => footnote.remove());
 
-            const shortMode = this.query.layoutOptions.shortMode;
-
             const extrasSpan = listItem.createSpan('task-extras');
 
             if (!this.query.layoutOptions.hideUrgency) {
@@ -247,6 +250,7 @@ class QueryRenderChild extends MarkdownRenderChild {
             }
 
             if (!this.query.layoutOptions.hideBacklinks) {
+                const shortMode = this.query.layoutOptions.shortMode;
                 this.addBacklinks(extrasSpan, task, shortMode, isFilenameUnique);
             }
 
@@ -257,7 +261,7 @@ class QueryRenderChild extends MarkdownRenderChild {
             taskList.appendChild(listItem);
         }
 
-        return { taskList, tasksCount };
+        return taskList;
     }
 
     private addEditButton(listItem: HTMLElement, task: Task) {
@@ -295,7 +299,7 @@ class QueryRenderChild extends MarkdownRenderChild {
             // will be empty, and no headings will be added.
             this.addGroupHeadings(content, group.groupHeadings);
 
-            const { taskList } = await this.createTasksList({
+            const taskList = await this.createTasksList({
                 tasks: group.tasks,
                 content: content,
             });
diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts
new file mode 100644
index 00000000..e93806e6
--- /dev/null
+++ b/src/Scripting/ExpandPlaceholders.ts
@@ -0,0 +1,45 @@
+import Mustache from 'mustache';
+import proxyData from 'mustache-validator';
+
+// https://github.com/janl/mustache.js
+
+/**
+ * Expand any placeholder strings - {{....}} - in the given template, and return the result.
+ *
+ * The template implementation is currently provided by: [mustache.js](https://github.com/janl/mustache.js).
+ *
+ * @param template - A template string, typically with placeholders such as {{query.task.folder}}
+ * @param view - The property values
+ *
+ * @throws Error
+ *
+ *      By using mustache-validator's proxyData, we ensure that any accesses of property names that are
+ *      not in the view, we ensure that errors are detected immediately.
+ *      The first unknown placeholder is included in Error.message.
+ */
+export function expandPlaceholders(template: string, view: any): string {
+    // Turn off HTML escaping of things like '/' in file paths:
+    // https://github.com/janl/mustache.js#variables
+    Mustache.escape = function (text) {
+        return text;
+    };
+
+    try {
+        return Mustache.render(template, proxyData(view));
+    } catch (error) {
+        let message = '';
+        if (error instanceof Error) {
+            message = `There was an error expanding one or more placeholders.
+
+The error message was:
+    ${error.message.replace(/ > /g, '.').replace('Missing Mustache data property', 'Unknown property')}`;
+        } else {
+            message = 'Unknown error expanding placeholders.';
+        }
+        message += `
+
+The problem is in:
+    ${template}`;
+        throw Error(message);
+    }
+}
diff --git a/src/Scripting/QueryContext.ts b/src/Scripting/QueryContext.ts
new file mode 100644
index 00000000..d0ee9867
--- /dev/null
+++ b/src/Scripting/QueryContext.ts
@@ -0,0 +1,30 @@
+import { TasksFile } from './TasksFile';
+
+/**
+ * This interface is part of the implementation of placeholders.
+ * Use {@link makeQueryContext} to make a QueryContext.
+ *
+ * QueryContext is a 'view' to pass in to {@link expandPlaceholders}.
+ *
+ * It provides the following:
+ * `queryContext.query.file` - where query.file is a {@link TasksFile} object.
+ * So it supplies `query.file.path`, `query.file.folder`.
+ */
+export interface QueryContext {
+    query: {
+        file: TasksFile;
+    };
+}
+
+/**
+ * Create a {@link QueryContext} to represent a query in note at the give path.
+ * @param path
+ */
+export function makeQueryContext(path: string): QueryContext {
+    const tasksFile = new TasksFile(path);
+    return {
+        query: {
+            file: tasksFile,
+        },
+    };
+}
diff --git a/src/Scripting/TasksFile.ts b/src/Scripting/TasksFile.ts
index 3c597bc0..44e540b1 100644
--- a/src/Scripting/TasksFile.ts
+++ b/src/Scripting/TasksFile.ts
@@ -15,6 +15,17 @@ export class TasksFile {
         return this._path;
     }
 
+    /**
+     * Return the path to the file, with the filename extension removed.
+     */
+    get pathWithoutExtension(): string {
+        return this.withoutExtension(this.path);
+    }
+
+    private withoutExtension(value: string) {
+        return value.replace(/\.md$/, '');
+    }
+
     /**
      * Return the root to the file.
      */
@@ -54,4 +65,8 @@ export class TasksFile {
             return '';
         }
     }
+
+    get filenameWithoutExtension(): string {
+        return this.withoutExtension(this.filename);
+    }
 }
diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts
index b24d7936..006ca84e 100644
--- a/src/Suggestor/Suggestor.ts
+++ b/src/Suggestor/Suggestor.ts
@@ -414,7 +414,7 @@ export function onlySuggestIfBracketOpen(fn: SuggestionBuilder, brackets: [strin
  *                          See 'ch' in https://docs.obsidian.md/Reference/TypeScript+API/EditorPosition/EditorPosition
  */
 export function canSuggestForLine(line: string, cursorPosition: number) {
-    return GlobalFilter.includedIn(line) && cursorIsInTaskLineDescription(line, cursorPosition);
+    return GlobalFilter.getInstance().includedIn(line) && cursorIsInTaskLineDescription(line, cursorPosition);
 }
 
 /**
diff --git a/src/Task.ts b/src/Task.ts
index bc59eadc..9e875530 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -6,8 +6,6 @@ import { GlobalFilter } from './Config/GlobalFilter';
 import { StatusRegistry } from './StatusRegistry';
 import type { Status } from './Status';
 import { Urgency } from './Urgency';
-import { renderTaskLine } from './TaskLineRenderer';
-import type { TaskLineRenderDetails } from './TaskLineRenderer';
 import { DateFallback } from './DateFallback';
 import { compareByDate } from './lib/DateTools';
 import { TasksDate } from './Scripting/TasksDate';
@@ -239,7 +237,7 @@ export class Task {
 
         // return if the line does not have the global filter. Do this before
         // any other processing to improve performance.
-        if (!GlobalFilter.includedIn(taskComponents.body)) {
+        if (!GlobalFilter.getInstance().includedIn(taskComponents.body)) {
             return null;
         }
 
@@ -286,7 +284,7 @@ export class Task {
         taskInfo.tags = taskInfo.tags.map((tag) => tag.trim());
 
         // Remove the Global Filter if it is there
-        taskInfo.tags = taskInfo.tags.filter((tag) => !GlobalFilter.equals(tag));
+        taskInfo.tags = taskInfo.tags.filter((tag) => !GlobalFilter.getInstance().equals(tag));
 
         return new Task({
             ...taskComponents,
@@ -329,16 +327,6 @@ export class Task {
         }
         return { indentation, listMarker, status, body, blockLink };
     }
-
-    /**
-     * Create an HTML rendered List Item element (LI) for the current task.
-     * @note Output is based on the {@link DefaultTaskSerializer}'s format, with default (emoji) symbols
-     * @param renderDetails
-     */
-    public async toLi(renderDetails: TaskLineRenderDetails): Promise<HTMLLIElement> {
-        return renderTaskLine(this, renderDetails);
-    }
-
     /**
      * Flatten the task as a string that includes all its components.
      *
diff --git a/src/TaskLineRenderer.ts b/src/TaskLineRenderer.ts
index 42767c06..62463ee6 100644
--- a/src/TaskLineRenderer.ts
+++ b/src/TaskLineRenderer.ts
@@ -44,16 +44,6 @@ export type TextRenderer = (
     obsidianComponent: Component | null, // null is allowed here only for tests
 ) => Promise<void>;
 
-async function obsidianMarkdownRenderer(
-    text: string,
-    element: HTMLSpanElement,
-    path: string,
-    obsidianComponent: Component | null,
-) {
-    if (!obsidianComponent) throw new Error('Must call the Obsidian renderer with an Obsidian Component object');
-    await MarkdownRenderer.renderMarkdown(text, element, path, obsidianComponent);
-}
-
 /**
  * Renders a given Task object into an HTML List Item (LI) element, using the given renderDetails
  * configuration and a supplied TextRenderer (typically the Obsidian Markdown renderer, but for testing
@@ -64,9 +54,8 @@ async function obsidianMarkdownRenderer(
 export async function renderTaskLine(
     task: Task,
     renderDetails: TaskLineRenderDetails,
-    textRenderer: TextRenderer | null = null,
+    textRenderer: TextRenderer,
 ): Promise<HTMLLIElement> {
-    if (!textRenderer) textRenderer = obsidianMarkdownRenderer;
     const li: HTMLLIElement = document.createElement('li');
     renderDetails.parentUlElement.appendChild(li);
 
@@ -125,6 +114,8 @@ export async function renderTaskLine(
     return li;
 }
 
+export type AttributesDictionary = { [key: string]: string };
+
 async function taskToHtml(
     task: Task,
     renderDetails: TaskLineRenderDetails,
@@ -139,7 +130,7 @@ async function taskToHtml(
         let componentString = emojiSerializer.componentToString(task, taskLayout, component);
         if (componentString) {
             if (component === 'description') {
-                componentString = GlobalFilter.removeAsWordFromDependingOnSettings(componentString);
+                componentString = GlobalFilter.getInstance().removeAsWordFromDependingOnSettings(componentString);
             }
             // Create the text span that will hold the rendered component
             const span = document.createElement('span');
@@ -165,18 +156,18 @@ async function taskToHtml(
                 const componentClass = getTaskComponentClass(component, task);
                 span.classList.add(...componentClass);
 
-                // Add the attributes to the component ('priority-medium', 'due-past-1d' etc)
-                const dataAttributes = getTaskDataAttributes(component, task);
-                for (const key in dataAttributes) span.dataset[key] = dataAttributes[key];
-                allAttributes = { ...allAttributes, ...dataAttributes };
+                // Add the component's attribute ('priority-medium', 'due-past-1d' etc.)
+                const componentDataAttribute = getComponentDataAttribute(component, task);
+                for (const key in componentDataAttribute) span.dataset[key] = componentDataAttribute[key];
+                allAttributes = { ...allAttributes, ...componentDataAttribute };
             }
         }
     }
 
     // Now build classes for the hidden task components without rendering them
     for (const component of taskLayout.hiddenTaskLayoutComponents) {
-        const dataAttributes = getTaskDataAttributes(component, task);
-        allAttributes = { ...allAttributes, ...dataAttributes };
+        const hiddenComponentDataAttribute = getComponentDataAttribute(component, task);
+        allAttributes = { ...allAttributes, ...hiddenComponentDataAttribute };
     }
 
     // If a task has no priority field set, its priority will not be rendered as part of the loop above and
@@ -185,8 +176,8 @@ async function taskToHtml(
     // So if the priority was not rendered, force it through the pipe of getting the component data for the
     // priority field.
     if (allAttributes.taskPriority === undefined) {
-        const dataAttributes = getTaskDataAttributes('priority', task);
-        allAttributes = { ...allAttributes, ...dataAttributes };
+        const priorityDataAttribute = getComponentDataAttribute('priority', task);
+        allAttributes = { ...allAttributes, ...priorityDataAttribute };
     }
 
     return allAttributes;
@@ -242,8 +233,6 @@ async function renderComponentText(
     }
 }
 
-export type AttributesDictionary = { [key: string]: string };
-
 /**
  * The CSS class that describes what the component is, e.g. a due date or a priority, and is a value from LayoutClasses.
  */
@@ -275,52 +264,55 @@ function getTaskComponentClass(component: TaskLayoutComponent, task: Task) {
 }
 
 /**
- * The dataAttributes describe the content of the component, e.g. `data-task-priority="medium"`, `data-task-due="past-1d"` etc.
+ * The data attribute describes the content of the component, e.g. `data-task-priority="medium"`, `data-task-due="past-1d"` etc.
  */
-function getTaskDataAttributes(component: TaskLayoutComponent, task: Task) {
-    const dataAttributes: AttributesDictionary = {};
-
-    function addDateDataAttributes(date: moment.Moment | null, attributeName: string) {
-        if (date) {
-            const dateValue = dateToAttribute(date);
-            if (dateValue) {
-                dataAttributes[attributeName] = dateValue;
-            }
-        }
-    }
+function getComponentDataAttribute(component: TaskLayoutComponent, task: Task) {
+    const dataAttribute: AttributesDictionary = {};
+
+    // If a TaskLayoutComponent needs a data attribute in the task's <span>, add the data attribute name
+    // to this dictionary: key is the component, value is the data attribute name.
+    // Otherwise, just leave an empty string ('') as the value.
+    // Also add the new component to the switch-case below in this function. This is where
+    // the data attribute value shall be calculated and set in the returned dictionary.
+    const DataAttributeNames: { [c in TaskLayoutComponent]: string } = {
+        createdDate: 'taskCreated',
+        dueDate: 'taskDue',
+        startDate: 'taskStart',
+        scheduledDate: 'taskScheduled',
+        doneDate: 'taskDone',
+        priority: 'taskPriority',
+        description: '',
+        recurrenceRule: '',
+        blockLink: '',
+    };
 
-    // Update data attributes
     switch (component) {
         case 'description':
-        case 'recurrenceRule': {
+        case 'recurrenceRule':
+        case 'blockLink':
             break;
-        }
         case 'priority': {
-            dataAttributes['taskPriority'] = PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase();
-            break;
-        }
-        case 'createdDate': {
-            addDateDataAttributes(task.createdDate, 'taskCreated');
-            break;
-        }
-        case 'dueDate': {
-            addDateDataAttributes(task.dueDate, 'taskDue');
-            break;
-        }
-        case 'startDate': {
-            addDateDataAttributes(task.startDate, 'taskStart');
-            break;
-        }
-        case 'scheduledDate': {
-            addDateDataAttributes(task.scheduledDate, 'taskScheduled');
+            const attributeName = DataAttributeNames[component];
+            dataAttribute[attributeName] = PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase();
             break;
         }
+        case 'createdDate':
+        case 'dueDate':
+        case 'startDate':
+        case 'scheduledDate':
         case 'doneDate': {
-            addDateDataAttributes(task.doneDate, 'taskDone');
+            const date = task[component];
+            if (date) {
+                const attributeValue = dateToAttribute(date);
+                if (attributeValue) {
+                    const attributeName = DataAttributeNames[component];
+                    dataAttribute[attributeName] = attributeValue;
+                }
+            }
             break;
         }
     }
-    return dataAttributes;
+    return dataAttribute;
 }
 
 /*
@@ -436,3 +428,23 @@ function toTooltipDate({ signifier, date }: { signifier: string; date: Moment })
         window.moment().startOf('day'),
     )})`;
 }
+
+/**
+ * Create an HTML rendered List Item element (LI) for a task.
+ * @note Output is based on the {@link DefaultTaskSerializer}'s format, with default (emoji) symbols
+ * @param task
+ * @param renderDetails
+ */
+export function taskToLi(task: Task, renderDetails: TaskLineRenderDetails): Promise<HTMLLIElement> {
+    async function obsidianMarkdownRenderer(
+        text: string,
+        element: HTMLSpanElement,
+        path: string,
+        obsidianComponent: Component | null,
+    ) {
+        if (!obsidianComponent) throw new Error('Must call the Obsidian renderer with an Obsidian Component object');
+        await MarkdownRenderer.renderMarkdown(text, element, path, obsidianComponent);
+    }
+
+    return renderTaskLine(task, renderDetails, obsidianMarkdownRenderer);
+}
diff --git a/src/lib/QueryRendererHelper.ts b/src/lib/QueryRendererHelper.ts
index dc746c9e..3d0e1c81 100644
--- a/src/lib/QueryRendererHelper.ts
+++ b/src/lib/QueryRendererHelper.ts
@@ -1,6 +1,5 @@
-import type { IQuery } from '../IQuery';
-import { getGlobalQuerySource } from '../Config/Settings';
-import { GlobalFilter } from '../Config/GlobalFilter';
+import type { GlobalFilter } from '../Config/GlobalFilter';
+import type { GlobalQuery } from '../Config/GlobalQuery';
 import { Query } from '../Query/Query';
 
 /**
@@ -20,22 +19,28 @@ import { Query } from '../Query/Query';
  *     * Explains the query described by {@link source}
  *
  * @param {string} source The source of the task block to explain
+ * @param {GlobalFilter} globalFilter The global filter. In `src/`, generally pass in {@link GlobalFilter.getInstance}
+ * @param {GlobalQuery} globalQuery The global query. In `src/`, generally pass in {@link GlobalQuery.getInstance}
+ * @param {string} path The location of the task block, if known
  * @returns {string}
  */
-export function explainResults(source: string): string {
+export function explainResults(
+    source: string,
+    globalFilter: GlobalFilter,
+    globalQuery: GlobalQuery,
+    path: string | undefined = undefined,
+): string {
     let result = '';
 
-    if (!GlobalFilter.isEmpty()) {
-        result += `Only tasks containing the global filter '${GlobalFilter.get()}'.\n\n`;
+    if (!globalFilter.isEmpty()) {
+        result += `Only tasks containing the global filter '${globalFilter.get()}'.\n\n`;
     }
 
-    const tasksBlockQuery = new Query({ source });
+    const tasksBlockQuery = new Query({ source }, path);
 
     if (!tasksBlockQuery.ignoreGlobalQuery) {
-        const globalQuery: IQuery = new Query(getGlobalQuerySource());
-
-        if (globalQuery.source.trim() !== '') {
-            result += `Explanation of the global query:\n\n${globalQuery.explainQuery()}\n`;
+        if (globalQuery.hasInstructions()) {
+            result += `Explanation of the global query:\n\n${globalQuery.query(path).explainQuery()}\n`;
         }
     }
 
@@ -50,15 +55,16 @@ export function explainResults(source: string): string {
  * This query is the result of joining the global query with the query in the task block
  *
  * @param {string} source The query source from the task block
+ * @param globalQuery
+ * @param {string | undefined} path The path to the file containing the query, if available.
  * @returns {Query} The query to execute
  */
-export function getQueryForQueryRenderer(source: string): Query {
-    const globalQuery = new Query(getGlobalQuerySource());
-    const tasksBlockQuery = new Query({ source });
+export function getQueryForQueryRenderer(source: string, globalQuery: GlobalQuery, path: string | undefined): Query {
+    const tasksBlockQuery = new Query({ source }, path);
 
     if (tasksBlockQuery.ignoreGlobalQuery) {
         return tasksBlockQuery;
     }
 
-    return globalQuery.append(tasksBlockQuery);
+    return globalQuery.query(path).append(tasksBlockQuery);
 }
diff --git a/src/main.ts b/src/main.ts
index c55f403e..abef4640 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,6 +2,7 @@ import { Plugin } from 'obsidian';
 
 import { Cache } from './Cache';
 import { Commands } from './Commands';
+import { GlobalQuery } from './Config/GlobalQuery';
 import { TasksEvents } from './TasksEvents';
 import { initializeFile } from './File';
 import { InlineRenderer } from './InlineRenderer';
@@ -15,6 +16,7 @@ import { EditorSuggestor } from './Suggestor/EditorSuggestorPopup';
 import { StatusSettings } from './Config/StatusSettings';
 import type { Task } from './Task';
 import { tasksApiV1 } from './Api';
+import { GlobalFilter } from './Config/GlobalFilter';
 
 export default class TasksPlugin extends Plugin {
     private cache: Cache | undefined;
@@ -68,6 +70,9 @@ export default class TasksPlugin extends Plugin {
     async loadSettings() {
         const newSettings = await this.loadData();
         updateSettings(newSettings);
+        GlobalFilter.getInstance().set(newSettings.globalFilter);
+        GlobalFilter.getInstance().setRemoveGlobalFilter(newSettings.removeGlobalFilter);
+        GlobalQuery.getInstance().set(newSettings.globalQuery);
         await this.loadTaskStatuses();
     }
 
diff --git a/src/ui/EditTask.svelte b/src/ui/EditTask.svelte
index 63c22d4c..a72ef8f3 100644
--- a/src/ui/EditTask.svelte
+++ b/src/ui/EditTask.svelte
@@ -231,11 +231,11 @@
     onMount(() => {
         const { provideAccessKeys } = getSettings();
         withAccessKeys = provideAccessKeys;
-        const description = GlobalFilter.removeAsWordFrom(task.description);
+        const description = GlobalFilter.getInstance().removeAsWordFrom(task.description);
         // If we're displaying to the user the description without the global filter (i.e. it was removed in the method
         // above), or if the description did not include a global filter in the first place, we'll add the global filter
         // when saving the task.
-        if (description != task.description || !GlobalFilter.includedIn(task.description)) {
+        if (description != task.description || !GlobalFilter.getInstance().includedIn(task.description)) {
             addGlobalFilterOnSave = true;
         }
         let priority: typeof editableTask.priority = 'none';
@@ -299,7 +299,7 @@
     const _onSubmit = () => {
         let description = editableTask.description.trim();
         if (addGlobalFilterOnSave) {
-            description = GlobalFilter.prependTo(description);
+            description = GlobalFilter.getInstance().prependTo(description);
         }
 
         const startDate = parseTypedDateForSaving(editableTask.startDate);
diff --git a/tsconfig.json b/tsconfig.json
index 4bb0cd4e..36a0351c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,7 +17,7 @@
     "moduleResolution": "node",
     "importHelpers": true,
     "ignoreDeprecations": "5.0",
-    "outDir": "",
+    "outDir": "generated",
     "paths": {
       "src": [
         "src/*",
diff --git a/versions.json b/versions.json
index 3b2c7f8e..0cbcbbf4 100644
--- a/versions.json
+++ b/versions.json
@@ -75,5 +75,7 @@
   "4.4.0": "1.1.1",
   "4.5.0": "1.1.1",
   "4.6.0": "1.1.1",
-  "4.6.1": "1.1.1"
+  "4.6.1": "1.1.1",
+  "4.7.0": "1.1.1",
+  "4.7.1": "1.1.1"
 }
diff --git a/yarn.lock b/yarn.lock
index 06c0c7cb..e1c3017c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3,9 +3,9 @@
 
 
 "@adobe/css-tools@^4.0.1":
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.2.0.tgz#e1a84fca468f4b337816fcb7f0964beb620ba855"
-  integrity sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.1.tgz#abfccb8ca78075a2b6187345c26243c1a0842f28"
+  integrity sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==
 
 "@ampproject/remapping@^2.1.0":
   version "2.2.0"
@@ -1240,6 +1240,11 @@
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
+"@types/mustache@^4.2.2":
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/@types/mustache/-/mustache-4.2.2.tgz#825bf5c214c3ab84d0b23fef2c8eb898f3ff8717"
+  integrity sha512-MUSpfpW0yZbTgjekDbH0shMYBUD+X/uJJJMm9LXN1d5yjl5lCY1vN/eWKD6D1tOtjA6206K0zcIPnUaFMurdNA==
+
 "@types/node@*":
   version "18.0.3"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.3.tgz#463fc47f13ec0688a33aec75d078a0541a447199"
@@ -1685,10 +1690,10 @@ binary-extensions@^2.0.0:
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
-boon-js@^2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/boon-js/-/boon-js-2.0.3.tgz#8ebfaf219908b9d9259d050ac9de079ebbd7d4b7"
-  integrity sha512-xcLS25RHhH/Bl+toajfOnTzynzWOJSRYG0m2W/8RePZ2xqPPblYmKgd/nZWbA4U7PQ2ohq49YxIWhsUJWvGwXQ==
+boon-js@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/boon-js/-/boon-js-2.0.4.tgz#5e8feda3363d9e72ca98597e8f6374fb03be23e2"
+  integrity sha512-STAfDwFteYLGyVfzGMaBwWpTjPnaGDT2K5GqwPlgnTgHAnvPIpJuRspuqMcsQznwtRywm9v991Qz3DRGGw1qrg==
 
 brace-expansion@^1.1.7:
   version "1.1.11"
@@ -3985,19 +3990,7 @@ jest-snapshot@^29.5.0:
     pretty-format "^29.5.0"
     semver "^7.3.5"
 
-jest-util@^29.0.0, jest-util@^29.3.1:
-  version "29.3.1"
-  resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.3.1.tgz#1dda51e378bbcb7e3bc9d8ab651445591ed373e1"
-  integrity sha512-7YOVZaiX7RJLv76ZfHt4nbNEzzTRiMW/IiOG7ZOKmTXmoGBxUDefgMAxQubu6WPVqP5zSzAdZG0FfLcC7HOIFQ==
-  dependencies:
-    "@jest/types" "^29.3.1"
-    "@types/node" "*"
-    chalk "^4.0.0"
-    ci-info "^3.2.0"
-    graceful-fs "^4.2.9"
-    picomatch "^2.2.3"
-
-jest-util@^29.5.0:
+jest-util@^29.0.0, jest-util@^29.3.1, jest-util@^29.5.0:
   version "29.5.0"
   resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.5.0.tgz#24a4d3d92fc39ce90425311b23c27a6e0ef16b8f"
   integrity sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==
@@ -4454,6 +4447,16 @@ ms@^2.1.1:
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
+mustache-validator@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/mustache-validator/-/mustache-validator-0.2.0.tgz#b9ef770263fe560429a7f90cb5866331f4274cb1"
+  integrity sha512-L4RIboVYTXHFHNWv5Sac3Ru63SUMgDRSY1G7YapeasgMf7IqpYIO/h8f+/5GJJIwcCcfyQXDFffD+VO6/F5f+Q==
+
+mustache@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
+  integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
+
 natives@^1.1.6:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.6.tgz#a603b4a498ab77173612b9ea1acdec4d980f00bb"
@@ -4929,10 +4932,10 @@ rimraf@~2.6.2:
   dependencies:
     glob "^7.1.3"
 
-rrule@^2.7.1:
-  version "2.7.1"
-  resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.7.1.tgz#2511e1aed0e96fb5acf831304cde97d2cb65d99e"
-  integrity sha512-4p20u/1U7WqR3Nb1hOUrm0u1nSI7sO93ZUVZEZ5HeF6Gr5OlJuyhwEGRvUHq8ZfrPsq5gfa5b9dqnUs/kPqpIw==
+rrule@^2.7.2:
+  version "2.7.2"
+  resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.7.2.tgz#cb899cb21bbe6c71b587107956604173b69f9143"
+  integrity sha512-NkBsEEB6FIZOZ3T8frvEBOB243dm46SPufpDckY/Ap/YH24V1zLeMmDY8OA10lk452NdrF621+ynDThE7FQU2A==
   dependencies:
     tslib "^2.4.0"
 
@@ -4986,18 +4989,18 @@ saxes@^6.0.0:
   dependencies:
     xmlchars "^2.2.0"
 
-semver@7.x, semver@^7.3.5, semver@^7.3.7:
+semver@^6.0.0, semver@^6.3.0:
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
+
+semver@^7.3.5, semver@^7.3.7, semver@^7.5.3:
   version "7.5.4"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
   integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
   dependencies:
     lru-cache "^6.0.0"
 
-semver@^6.0.0, semver@^6.3.0:
-  version "6.3.1"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
-  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-
 shebang-command@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -5356,10 +5359,10 @@ tr46@^3.0.0:
   dependencies:
     punycode "^2.1.1"
 
-ts-jest@^29.0.3, ts-jest@^29.1.0:
-  version "29.1.0"
-  resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.0.tgz#4a9db4104a49b76d2b368ea775b6c9535c603891"
-  integrity sha512-ZhNr7Z4PcYa+JjMl62ir+zPiNJfXJN6E8hSLnaUKhOgqcn8vb3e537cpkd0FuAfRK3sR1LSqM1MOhliXNgOFPA==
+ts-jest@^29.0.3, ts-jest@^29.1.1:
+  version "29.1.1"
+  resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.1.tgz#f58fe62c63caf7bfcc5cc6472082f79180f0815b"
+  integrity sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==
   dependencies:
     bs-logger "0.x"
     fast-json-stable-stringify "2.x"
@@ -5367,7 +5370,7 @@ ts-jest@^29.0.3, ts-jest@^29.1.0:
     json5 "^2.2.3"
     lodash.memoize "4.x"
     make-error "1.x"
-    semver "7.x"
+    semver "^7.5.3"
     yargs-parser "^21.0.1"
 
 tsconfig-paths@^3.14.1:
@@ -5385,12 +5388,7 @@ tslib@^1.8.1:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tslib@^2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
-  integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
-
-tslib@^2.5.2:
+tslib@^2.4.0, tslib@^2.5.2:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338"
   integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==
@@ -5587,9 +5585,9 @@ which@^2.0.1:
     isexe "^2.0.0"
 
 word-wrap@^1.2.3, word-wrap@~1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
-  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
+  integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
 
 wrap-ansi@^7.0.0:
   version "7.0.0"


Test patch:
diff --git a/tests/Commands/CreateOrEditTaskParser.test.ts b/tests/Commands/CreateOrEditTaskParser.test.ts
index f9c3352c..332bdf57 100644
--- a/tests/Commands/CreateOrEditTaskParser.test.ts
+++ b/tests/Commands/CreateOrEditTaskParser.test.ts
@@ -2,6 +2,7 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
+import { resetSettings, updateSettings } from '../../src/Config/Settings';
 import { Priority } from '../../src/Task';
 import { taskFromLine } from '../../src/Commands/CreateOrEditTaskParser';
 import { GlobalFilter } from '../../src/Config/GlobalFilter';
@@ -10,7 +11,7 @@ window.moment = moment;
 
 describe('CreateOrEditTaskParser - testing edited task if line is saved unchanged', () => {
     afterEach(() => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it.each([
@@ -62,7 +63,7 @@ describe('CreateOrEditTaskParser - testing edited task if line is saved unchange
     ])(
         'line loaded into "Create or edit task" command: "%s"',
         (line: string, expectedResult: string, globalFilter: string) => {
-            GlobalFilter.set(globalFilter);
+            GlobalFilter.getInstance().set(globalFilter);
             const path = 'a/b/c.md';
             const task = taskFromLine({ line, path });
             expect(task.toFileLineString()).toStrictEqual(expectedResult);
@@ -73,11 +74,11 @@ describe('CreateOrEditTaskParser - testing edited task if line is saved unchange
 
 describe('CreateOrEditTaskParser - task recognition', () => {
     afterEach(() => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('should recognize task details without global filter', () => {
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
         const taskLine =
             '- [ ] without global filter but with all the info ⏬ 🔁 every 2 days ➕ 2022-03-10 🛫 2022-01-31 ⏳ 2023-06-13 📅 2024-12-10 ✅ 2023-06-22';
         const path = 'a/b/c.md';
@@ -96,3 +97,72 @@ describe('CreateOrEditTaskParser - task recognition', () => {
         expect(task.doneDate).toEqualMoment(moment('2023-06-22'));
     });
 });
+
+describe('CreateOrEditTaskParser - created date', () => {
+    beforeEach(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date('2023-09-17'));
+    });
+
+    afterEach(() => {
+        jest.useRealTimers();
+        resetSettings();
+        GlobalFilter.getInstance().reset();
+    });
+
+    it.each([
+        [
+            // bullet point only
+            '- ',
+            '- [ ]  ➕ 2023-09-17',
+            '2023-09-17',
+        ],
+        [
+            // bullet point and a checkbox
+            '- [ ] ',
+            '- [ ]  ➕ 2023-09-17',
+            '2023-09-17',
+        ],
+        [
+            // with an existing created date
+            '- [ ] without global filter and with ➕ 2023-01-20',
+            '- [ ] without global filter and with ➕ 2023-01-20',
+            '2023-01-20',
+        ],
+    ])(
+        'line loaded into "Create or edit task" command: "%s"',
+        (line: string, expectedTaskLine: string, expectedCreatedDate: string) => {
+            updateSettings({ setCreatedDate: true });
+            const path = 'a/b/c.md';
+
+            const task = taskFromLine({ line, path });
+
+            expect(task.toFileLineString()).toStrictEqual(expectedTaskLine);
+            expect(task.createdDate).toEqualMoment(moment(expectedCreatedDate));
+        },
+    );
+
+    it('should not add created date to a task line with description', () => {
+        updateSettings({ setCreatedDate: true });
+        const path = 'a/b/c.md';
+        const line = '- [ ] hope created date will not be added';
+
+        const task = taskFromLine({ line, path });
+
+        expect(task.toFileLineString()).toStrictEqual('- [ ] hope created date will not be added');
+        expect(task.createdDate).toEqual(null);
+    });
+
+    it('should add created date if adding the global filter', () => {
+        updateSettings({ setCreatedDate: true });
+        GlobalFilter.getInstance().set('#task');
+        const path = 'a/b/c.md';
+        const line = '- [ ] did not have the global filter';
+
+        const task = taskFromLine({ line, path });
+
+        // The global filter doesn't get added until the Modal rewrites the line
+        expect(task.toFileLineString()).toStrictEqual('- [ ] did not have the global filter ➕ 2023-09-17');
+        expect(task.createdDate).toEqualMoment(moment('2023-09-17'));
+    });
+});
diff --git a/tests/Commands/ToggleDone.test.ts b/tests/Commands/ToggleDone.test.ts
index 1d762cf4..b9deff09 100644
--- a/tests/Commands/ToggleDone.test.ts
+++ b/tests/Commands/ToggleDone.test.ts
@@ -78,7 +78,7 @@ function testToggleLineForOutOfRangeCursorPositions(
 
 describe('ToggleDone', () => {
     afterEach(() => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     const todaySpy = jest.spyOn(Date, 'now').mockReturnValue(moment('2022-09-04').valueOf());
@@ -93,7 +93,7 @@ describe('ToggleDone', () => {
         testToggleLine('|', '- |');
         testToggleLine('foo|bar', '- foobar|');
 
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         testToggleLine('|', '- |');
         testToggleLine('foo|bar', '- foobar|');
@@ -104,7 +104,7 @@ describe('ToggleDone', () => {
         testToggleLine('- |', '- [ ] |');
         testToggleLine('- |foobar', '- [ ] foobar|');
 
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         testToggleLine('|- ', '- [ ] |');
         testToggleLine('- |', '- [ ] |');
@@ -118,7 +118,7 @@ describe('ToggleDone', () => {
         // Issue #449 - cursor jumped 13 characters to the right on completion
         testToggleLine('- [ ] I have a |proper description', '- [x] I have a |proper description ✅ 2022-09-04');
 
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         testToggleLine('|- [ ] ', '|- [x] ');
         testToggleLine('- [ ] |', '- [x] |');
@@ -134,7 +134,7 @@ describe('ToggleDone', () => {
         // Issue #449 - cursor jumped 13 characters to the left on un-completion
         testToggleLine('- [x] I have a proper description| ✅ 2022-09-04', '- [ ] I have a proper description|');
 
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         // Done date is not removed if task does not match global filter
         testToggleLine('|- [x]  ✅ 2022-09-04', '|- [ ] ✅ 2022-09-04');
@@ -162,7 +162,7 @@ describe('ToggleDone', () => {
 - [x] I am a recurring task| 🔁 every day 📅 2022-09-04 ✅ 2022-09-04`,
         );
 
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         // Tasks do not recur, and no done-date added, if not matching global filter
         testToggleLine(
@@ -180,7 +180,7 @@ describe('ToggleDone', () => {
 
     describe('should honour next status character', () => {
         afterEach(() => {
-            GlobalFilter.reset();
+            GlobalFilter.getInstance().reset();
         });
 
         // Arrange
@@ -201,7 +201,7 @@ describe('ToggleDone', () => {
         });
 
         it('when there is a global filter and task with global filter is toggled', () => {
-            GlobalFilter.set('#task');
+            GlobalFilter.getInstance().set('#task');
 
             const line1 = '- [C] #task this is a task starting at Con';
 
@@ -214,7 +214,7 @@ describe('ToggleDone', () => {
         });
 
         it('when there is a global filter and task without global filter is toggled', () => {
-            GlobalFilter.set('#task');
+            GlobalFilter.getInstance().set('#task');
 
             const line1 = '- [P] this is a task starting at Pro, not matching the global filter';
 
diff --git a/tests/Config/GlobalQuery.test.ts b/tests/Config/GlobalQuery.test.ts
new file mode 100644
index 00000000..435e7624
--- /dev/null
+++ b/tests/Config/GlobalQuery.test.ts
@@ -0,0 +1,43 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { GlobalQuery } from '../../src/Config/GlobalQuery';
+
+describe('GlobalQuery tests', () => {
+    it('getInstance() should return the same object', () => {
+        const globalQuery1 = GlobalQuery.getInstance();
+        const globalQuery2 = GlobalQuery.getInstance();
+
+        expect(Object.is(globalQuery1, globalQuery2)).toEqual(true);
+    });
+
+    it('should have no instructions by default', () => {
+        const globalQuery = new GlobalQuery();
+
+        expect(globalQuery.hasInstructions()).toEqual(false);
+        expect(globalQuery.query().source).toEqual('');
+    });
+
+    it('should set the query', () => {
+        const globalQuery = new GlobalQuery('description includes this should be the source of the query');
+
+        expect(globalQuery.query().source).toEqual('description includes this should be the source of the query');
+    });
+
+    it('should update the query', () => {
+        const globalQuery = new GlobalQuery('# this should be overridden');
+        globalQuery.set('# this should be the new global query');
+
+        expect(globalQuery.query().source).toEqual('# this should be the new global query');
+    });
+
+    it.each(['', ' ', '\n', '\n     \n    ', '  \n    \n'])(
+        'should have no instructions if only line breaks and spaces were set in the query',
+        (globalQuerySource) => {
+            const globalQuery = new GlobalQuery(globalQuerySource);
+
+            expect(globalQuery.hasInstructions()).toEqual(false);
+        },
+    );
+});
diff --git a/tests/EditTask.test.Exhaustive_editing_Edit_and_save_All_inputs.approved.txt b/tests/EditTask.test.Exhaustive_editing_Edit_and_save_All_inputs.approved.txt
new file mode 100644
index 00000000..18062dd7
--- /dev/null
+++ b/tests/EditTask.test.Exhaustive_editing_Edit_and_save_All_inputs.approved.txt
@@ -0,0 +1,162 @@
+KEY: (globalFilter, set created date)
+
+
+('', false)
+    '' =>
+    '- [ ] simulate user typing text in to empty description field'
+
+('', false)
+    'plain text, not a list item' =>
+    '- [ ] plain text, not a list item'
+
+('', false)
+    '-' =>
+    '- [ ] simulate user typing text in to empty description field'
+
+('', false)
+    '- ' =>
+    '- [ ] simulate user typing text in to empty description field'
+
+('', false)
+    '- [ ]' =>
+    '- [ ] simulate user typing text in to empty description field'
+
+('', false)
+    '- [ ] ' =>
+    '- [ ] simulate user typing text in to empty description field'
+
+('', false)
+    '- list item, but no checkbox' =>
+    '- [ ] list item, but no checkbox'
+
+('', false)
+    '- [ ] checkbox with initial description' =>
+    '- [ ] checkbox with initial description'
+
+('', false)
+    '- [ ] checkbox with initial description and created date ➕ 2023-01-01' =>
+    '- [ ] checkbox with initial description and created date ➕ 2023-01-01'
+
+('', false)
+    '- [ ] #task checkbox with global filter string and initial description' =>
+    '- [ ] #task checkbox with global filter string and initial description'
+
+('', true)
+    '' =>
+    '- [ ] simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('', true)
+    'plain text, not a list item' =>
+    '- [ ] plain text, not a list item ➕ 2023-07-18'
+
+('', true)
+    '-' =>
+    '- [ ] simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('', true)
+    '- ' =>
+    '- [ ] simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('', true)
+    '- [ ]' =>
+    '- [ ] simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('', true)
+    '- [ ] ' =>
+    '- [ ] simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('', true)
+    '- list item, but no checkbox' =>
+    '- [ ] list item, but no checkbox ➕ 2023-07-18'
+
+('', true)
+    '- [ ] checkbox with initial description' =>
+    '- [ ] checkbox with initial description'
+
+('', true)
+    '- [ ] checkbox with initial description and created date ➕ 2023-01-01' =>
+    '- [ ] checkbox with initial description and created date ➕ 2023-01-01'
+
+('', true)
+    '- [ ] #task checkbox with global filter string and initial description' =>
+    '- [ ] #task checkbox with global filter string and initial description'
+
+('#task', false)
+    '' =>
+    '- [ ] #task simulate user typing text in to empty description field'
+
+('#task', false)
+    'plain text, not a list item' =>
+    '- [ ] #task plain text, not a list item'
+
+('#task', false)
+    '-' =>
+    '- [ ] #task simulate user typing text in to empty description field'
+
+('#task', false)
+    '- ' =>
+    '- [ ] #task simulate user typing text in to empty description field'
+
+('#task', false)
+    '- [ ]' =>
+    '- [ ] #task simulate user typing text in to empty description field'
+
+('#task', false)
+    '- [ ] ' =>
+    '- [ ] #task simulate user typing text in to empty description field'
+
+('#task', false)
+    '- list item, but no checkbox' =>
+    '- [ ] #task list item, but no checkbox'
+
+('#task', false)
+    '- [ ] checkbox with initial description' =>
+    '- [ ] #task checkbox with initial description'
+
+('#task', false)
+    '- [ ] checkbox with initial description and created date ➕ 2023-01-01' =>
+    '- [ ] #task checkbox with initial description and created date ➕ 2023-01-01'
+
+('#task', false)
+    '- [ ] #task checkbox with global filter string and initial description' =>
+    '- [ ] #task checkbox with global filter string and initial description'
+
+('#task', true)
+    '' =>
+    '- [ ] #task simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('#task', true)
+    'plain text, not a list item' =>
+    '- [ ] #task plain text, not a list item ➕ 2023-07-18'
+
+('#task', true)
+    '-' =>
+    '- [ ] #task simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('#task', true)
+    '- ' =>
+    '- [ ] #task simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('#task', true)
+    '- [ ]' =>
+    '- [ ] #task simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('#task', true)
+    '- [ ] ' =>
+    '- [ ] #task simulate user typing text in to empty description field ➕ 2023-07-18'
+
+('#task', true)
+    '- list item, but no checkbox' =>
+    '- [ ] #task list item, but no checkbox ➕ 2023-07-18'
+
+('#task', true)
+    '- [ ] checkbox with initial description' =>
+    '- [ ] #task checkbox with initial description ➕ 2023-07-18'
+
+('#task', true)
+    '- [ ] checkbox with initial description and created date ➕ 2023-01-01' =>
+    '- [ ] #task checkbox with initial description and created date ➕ 2023-01-01'
+
+('#task', true)
+    '- [ ] #task checkbox with global filter string and initial description' =>
+    '- [ ] #task checkbox with global filter string and initial description'
diff --git a/tests/EditTask.test.ts b/tests/EditTask.test.ts
index ffbd295a..7f0f9b74 100644
--- a/tests/EditTask.test.ts
+++ b/tests/EditTask.test.ts
@@ -10,6 +10,8 @@ import EditTask from '../src/ui/EditTask.svelte';
 import { Status } from '../src/Status';
 import { DateFallback } from '../src/DateFallback';
 import { GlobalFilter } from '../src/Config/GlobalFilter';
+import { resetSettings, updateSettings } from '../src/Config/Settings';
+import { verifyAllCombinations3Async } from './TestingTools/CombinationApprovalsAsync';
 
 window.moment = moment;
 const statusOptions: Status[] = [Status.DONE, Status.TODO];
@@ -69,9 +71,36 @@ function convertDescriptionToTaskLine(taskDescription: string): string {
     return `- [ ] ${taskDescription}`;
 }
 
+/**
+ * Simulate the behaviour of:
+ *   - clicking on a line in Obsidian,
+ *   - opening the Edit task modal,
+ *   - optionally editing the description,
+ *   - and clicking Apply.
+ * @param line
+ * @param newDescription - the new value for the description field.
+ *                         If `undefined`, the description won't be edited, unless text is needed to enable the Apply button.
+ * @returns The edited task line.
+ */
+async function editTaskLine(line: string, newDescription: string | undefined) {
+    const task = taskFromLine({ line: line, path: '' });
+    const { waitForClose, onSubmit } = constructSerialisingOnSubmit(task);
+    const { result, container } = renderAndCheckModal(task, onSubmit);
+
+    const description = getAndCheckRenderedDescriptionElement(container);
+    const submit = getAndCheckApplyButton(result);
+
+    let adjustedNewDescription = newDescription ? newDescription : description!.value;
+    if (!adjustedNewDescription) {
+        adjustedNewDescription = 'simulate user typing text in to empty description field';
+    }
+
+    return await editDescriptionAndSubmit(description, adjustedNewDescription, submit, waitForClose);
+}
+
 describe('Task rendering', () => {
     afterEach(() => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     function testDescriptionRender(taskDescription: string, expectedDescription: string) {
@@ -89,29 +118,29 @@ describe('Task rendering', () => {
     });
 
     it('should display task description without non-tag Global Filter)', () => {
-        GlobalFilter.set('filter');
+        GlobalFilter.getInstance().set('filter');
         testDescriptionRender('filter important thing', 'important thing');
     });
 
     it('should display task description with complex non-tag Global Filter)', () => {
-        GlobalFilter.set('filter');
+        GlobalFilter.getInstance().set('filter');
         // This behavior is inconsistent with Obsidian's tag definition which includes nested tags
         testDescriptionRender('filter/important thing', 'filter/important thing');
     });
 
     it('should display task description without tag-like Global Filter', () => {
-        GlobalFilter.set('#todo');
+        GlobalFilter.getInstance().set('#todo');
         testDescriptionRender('#todo another plan', 'another plan');
     });
 
     it('should display task description with complex tag-like Global Filter', () => {
-        GlobalFilter.set('#todo');
+        GlobalFilter.getInstance().set('#todo');
         // This behavior is inconsistent with Obsidian's tag definition which includes nested tags
         testDescriptionRender('#todo/important another plan', '#todo/important another plan');
     });
 
     it('should display task description with emoji removed, when global filter is in initial line', () => {
-        GlobalFilter.set('#todo');
+        GlobalFilter.getInstance().set('#todo');
         testDescriptionRender(
             '#todo with global filter and with scheduled date ⏳ 2023-06-13',
             'with global filter and with scheduled date',
@@ -119,7 +148,7 @@ describe('Task rendering', () => {
     });
 
     it('should display task description with emoji removed, even if the global filter is missing from initial line (bug 2037)', () => {
-        GlobalFilter.set('#todo');
+        GlobalFilter.getInstance().set('#todo');
         // When written, this was a demonstration of the behaviour logged in
         // https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2037
         testDescriptionRender(
@@ -131,18 +160,12 @@ describe('Task rendering', () => {
 
 describe('Task editing', () => {
     afterEach(() => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     async function testDescriptionEdit(taskDescription: string, newDescription: string, expectedDescription: string) {
-        const task = taskFromLine({ line: convertDescriptionToTaskLine(taskDescription), path: '' });
-        const { waitForClose, onSubmit } = constructSerialisingOnSubmit(task);
-        const { result, container } = renderAndCheckModal(task, onSubmit);
-
-        const description = getAndCheckRenderedDescriptionElement(container);
-        const submit = getAndCheckApplyButton(result);
-
-        const editedTask = await editDescriptionAndSubmit(description, newDescription, submit, waitForClose);
+        const line = convertDescriptionToTaskLine(taskDescription);
+        const editedTask = await editTaskLine(line, newDescription);
         expect(editedTask).toEqual(`- [ ] ${expectedDescription}`);
     }
 
@@ -158,7 +181,7 @@ describe('Task editing', () => {
     it('should not change the description if the task was not edited and keep Global Filter', async () => {
         const globalFilter = '#remember';
         const description = 'simple task';
-        GlobalFilter.set(globalFilter);
+        GlobalFilter.getInstance().set(globalFilter);
         await testDescriptionEdit(`${globalFilter} ${description}`, description, `${globalFilter} ${description}`);
     });
 
@@ -166,7 +189,7 @@ describe('Task editing', () => {
         const globalFilter = '#remember';
         const oldDescription = 'simple task';
         const newDescription = 'new plan';
-        GlobalFilter.set(globalFilter);
+        GlobalFilter.getInstance().set(globalFilter);
         await testDescriptionEdit(
             `${globalFilter} ${oldDescription}`,
             newDescription,
@@ -174,3 +197,72 @@ describe('Task editing', () => {
         );
     });
 });
+
+/**
+ * @summary This tests behaviour under a wide variety of scenarios, such as multiple different user settings, and input lines.
+ *
+ * As the number of combinations of settings values increases, it becomes harder and harder
+ * to write sufficient tests manually, and to find corner cases in exploratory testing.
+ */
+describe('Exhaustive editing', () => {
+    beforeEach(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date('2023-07-18'));
+    });
+
+    afterEach(() => {
+        GlobalFilter.getInstance().reset();
+        resetSettings();
+        jest.useRealTimers();
+    });
+
+    /**
+     * Test outcome of simply editing and saving a task line, under many conditions.
+     * Written as our previous test coverage was not good enough to detect the following:
+     *   - https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2112
+     *   - Since Tasks 4.0.1, using 'Create or edit task' on a line with a checkbox
+     *     but no global filter no longer adds the Created date.
+     */
+    describe('Edit and save', () => {
+        const name = 'All inputs';
+        const title = 'KEY: (globalFilter, set created date)\n';
+        const globalFilterValues = ['', '#task'];
+        const setCreatedDateValues = [false, true];
+        const initialTaskLineValues = [
+            '',
+            'plain text, not a list item',
+            '-',
+            '- ',
+            '- [ ]',
+            '- [ ] ',
+            '- list item, but no checkbox',
+            '- [ ] checkbox with initial description',
+            '- [ ] checkbox with initial description and created date ➕ 2023-01-01',
+            '- [ ] #task checkbox with global filter string and initial description',
+        ];
+
+        // For explanation of this call, see:
+        // https://publish.obsidian.md/tasks-contributing/Testing/Approval+Tests#Verify+the+results+of+multiple+input+values
+        verifyAllCombinations3Async<string, boolean, string>(
+            name,
+            title,
+            async (globalFilter, setCreatedDate, initialTaskLine) => {
+                GlobalFilter.getInstance().set(globalFilter as string);
+
+                // @ts-expect-error: TS2322: Type 'T2' is not assignable to type 'boolean | undefined'.
+                updateSettings({ setCreatedDate });
+
+                // @ts-expect-error: TS2345: Argument of type 'T3' is not assignable to parameter of type 'string'.
+                const editedTaskLine = await editTaskLine(initialTaskLine, undefined);
+
+                return `
+('${globalFilter}', ${setCreatedDate})
+    '${initialTaskLine}' =>
+    '${editedTaskLine}'`;
+            },
+            globalFilterValues,
+            setCreatedDateValues,
+            initialTaskLineValues,
+        );
+    });
+});
diff --git a/tests/GlobalFilter.test.ts b/tests/GlobalFilter.test.ts
index 5b8838dd..af988e43 100644
--- a/tests/GlobalFilter.test.ts
+++ b/tests/GlobalFilter.test.ts
@@ -1,61 +1,109 @@
-import { resetSettings, updateSettings } from '../src/Config/Settings';
 import { GlobalFilter } from '../src/Config/GlobalFilter';
 
 describe('Global Filter tests', () => {
-    afterEach(() => {
-        GlobalFilter.reset();
+    it('getInstance() should return the same object', () => {
+        const globalFilter1 = GlobalFilter.getInstance();
+        const globalFilter2 = GlobalFilter.getInstance();
+
+        expect(Object.is(globalFilter1, globalFilter2)).toEqual(true);
     });
 
     it('Should provide Global Filter with the default value with get()', () => {
-        expect(GlobalFilter.get()).toEqual('');
+        const globalFilter = new GlobalFilter();
+        expect(globalFilter.get()).toEqual('');
     });
 
     it('Should set new Global Filter', () => {
         // Arrange
         const testValue = 'newGlobalFilter';
-        GlobalFilter.set(testValue);
+        const globalFilter = new GlobalFilter();
+
+        // Act
+        globalFilter.set(testValue);
 
         // Assert
-        expect(GlobalFilter.get()).toEqual(testValue);
+        expect(globalFilter.get()).toEqual(testValue);
+    });
+
+    it('Should allow independent values for independent instances', () => {
+        // Arrange
+        const globalFilter1 = new GlobalFilter();
+        const globalFilter2 = new GlobalFilter();
+
+        // Act
+        globalFilter1.set('1');
+        globalFilter2.set('2');
+
+        // Assert
+        expect(globalFilter1.get()).toEqual('1');
+        expect(globalFilter2.get()).toEqual('2');
     });
 
     it('Should reset the Global Filter', () => {
         // Arrange
         const testValue = '#important';
-        GlobalFilter.set(testValue);
-        GlobalFilter.reset();
+        const globalFilter = new GlobalFilter();
+        globalFilter.set(testValue);
+
+        // Act
+        globalFilter.reset();
 
         // Assert
-        expect(GlobalFilter.get()).toEqual('');
+        expect(globalFilter.get()).toEqual('');
     });
 
     it('Should indicate empty Global Filter by default', () => {
         // Assert
-        expect(GlobalFilter.isEmpty()).toEqual(true);
+        const globalFilter = new GlobalFilter();
+        expect(globalFilter.isEmpty()).toEqual(true);
     });
 
     it('Should indicate non-empty Global Filter after setting one', () => {
         // Arrange
-        GlobalFilter.set('newGlobalFilter');
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('newGlobalFilter');
 
         // Assert
-        expect(GlobalFilter.isEmpty()).toEqual(false);
+        expect(globalFilter.isEmpty()).toEqual(false);
     });
 
     it('Should match a string with Global Filter', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('#task');
+
+        // Assert
+        expect(globalFilter.includedIn('Important #task inside')).toEqual(true);
+    });
+
+    it('Should check equality correctly', () => {
+        // Arrange
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('#task');
 
         // Assert
-        expect(GlobalFilter.includedIn('Important #task inside')).toEqual(true);
+        expect(globalFilter.equals('#task')).toEqual(true);
+        expect(globalFilter.equals('#tasks')).toEqual(false);
+        expect(globalFilter.equals('#TODO')).toEqual(false);
     });
 
     it('Should not match a string without Global Filter', () => {
         // Arrange
-        GlobalFilter.set('testValue');
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('testValue');
 
         // Assert
-        expect(GlobalFilter.includedIn('Without Global Filter')).toEqual(false);
+        expect(globalFilter.includedIn('Without Global Filter')).toEqual(false);
+    });
+
+    it('Should control whether to remove the global filter from displayed tasks', () => {
+        const globalFilter = new GlobalFilter();
+
+        globalFilter.setRemoveGlobalFilter(false);
+        expect(globalFilter.getRemoveGlobalFilter()).toEqual(false);
+
+        globalFilter.setRemoveGlobalFilter(true);
+        expect(globalFilter.getRemoveGlobalFilter()).toEqual(true);
     });
 
     it('Should remove Global Filter from the beginning of a string', () => {
@@ -63,10 +111,11 @@ describe('Global Filter tests', () => {
         const testValue = '#end';
         const testStringBefore = 'Important thing to do #end';
         const testStringAfter = 'Important thing to do';
-        GlobalFilter.set(testValue);
+        const globalFilter = new GlobalFilter();
+        globalFilter.set(testValue);
 
         // Assert
-        expect(GlobalFilter.removeAsSubstringFrom(testStringBefore)).toEqual(testStringAfter);
+        expect(globalFilter.removeAsSubstringFrom(testStringBefore)).toEqual(testStringAfter);
     });
 
     it('Should remove Global Filter from the end of a string', () => {
@@ -74,10 +123,11 @@ describe('Global Filter tests', () => {
         const testValue = '#beginning';
         const testStringBefore = '#beginning another important thing';
         const testStringAfter = 'another important thing';
-        GlobalFilter.set(testValue);
+        const globalFilter = new GlobalFilter();
+        globalFilter.set(testValue);
 
         // Assert
-        expect(GlobalFilter.removeAsSubstringFrom(testStringBefore)).toEqual(testStringAfter);
+        expect(globalFilter.removeAsSubstringFrom(testStringBefore)).toEqual(testStringAfter);
     });
 
     it('Should remove Global Filter in the middle of a string', () => {
@@ -86,37 +136,35 @@ describe('Global Filter tests', () => {
         const testStringBefore = 'With the GF in the #middle of the string';
         // Note the 2 spaces where the 'newGlobalFilter' was
         const testStringAfter = 'With the GF in the  of the string';
-        GlobalFilter.set(testValue);
+        const globalFilter = new GlobalFilter();
+        globalFilter.set(testValue);
 
         // Assert
-        expect(GlobalFilter.removeAsSubstringFrom(testStringBefore)).toEqual(testStringAfter);
+        expect(globalFilter.removeAsSubstringFrom(testStringBefore)).toEqual(testStringAfter);
     });
 });
 
 describe('Global Filter tests with Remove Global Filter Setting', () => {
-    afterEach(() => {
-        GlobalFilter.reset();
-        resetSettings();
-    });
-
     it('Should remove Global Filter from a string when Setting is set to false', () => {
         // Arrange
-        GlobalFilter.set('todo');
-        updateSettings({ removeGlobalFilter: false });
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('todo');
+        globalFilter.setRemoveGlobalFilter(false);
 
         // Assert
-        expect(GlobalFilter.removeAsWordFromDependingOnSettings('This is absolutely todo')).toEqual(
+        expect(globalFilter.removeAsWordFromDependingOnSettings('This is absolutely todo')).toEqual(
             'This is absolutely todo',
         );
     });
 
     it('Should remove Global Filter from a string when Setting is set to true', () => {
         // Arrange
-        GlobalFilter.set('todo');
-        updateSettings({ removeGlobalFilter: true });
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('todo');
+        globalFilter.setRemoveGlobalFilter(true);
 
         // Assert
-        expect(GlobalFilter.removeAsWordFromDependingOnSettings('This is absolutely todo')).toEqual(
+        expect(globalFilter.removeAsWordFromDependingOnSettings('This is absolutely todo')).toEqual(
             'This is absolutely',
         );
     });
@@ -133,153 +181,147 @@ describe('Global Filter tests with Remove Global Filter Setting', () => {
         ['TODO', 'at the end TODO/maybe'],
     ])(
         'should not remove Global Filter (%s) if it is in a substring for a task "- [ ] %s"',
-        async (globalFilter: string, description: string) => {
-            updateSettings({ removeGlobalFilter: true });
-            GlobalFilter.set(globalFilter);
+        async (globalFilterValue: string, description: string) => {
+            const globalFilter = new GlobalFilter();
+            globalFilter.setRemoveGlobalFilter(true);
+            globalFilter.set(globalFilterValue);
 
-            expect(GlobalFilter.removeAsWordFrom(description)).toEqual(description);
+            expect(globalFilter.removeAsWordFrom(description)).toEqual(description);
         },
     );
 });
 
 describe('check removal of the global filter', () => {
-    afterEach(() => {
-        GlobalFilter.reset();
-    });
-
     type GlobalFilterRemovalExpectation = {
-        globalFilter: string;
+        globalFilterValue: string;
         inputDescription: string;
         expectedDescription: string;
     };
 
     test.each<GlobalFilterRemovalExpectation>([
         {
-            globalFilter: '#task',
+            globalFilterValue: '#task',
             inputDescription: '#task this is a very simple task',
             expectedDescription: 'this is a very simple task',
         },
         {
-            globalFilter: '',
+            globalFilterValue: '',
             inputDescription: '#task this is a very simple task',
             expectedDescription: '#task this is a very simple task',
         },
         {
-            globalFilter: '🞋',
+            globalFilterValue: '🞋',
             inputDescription: 'task with emoji 🞋 global filter',
             expectedDescription: 'task with emoji global filter',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: 'task with #t global filter in the middle',
             expectedDescription: 'task with global filter in the middle',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: 'task with global filter in the end #t',
             expectedDescription: 'task with global filter in the end',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: 'task #t with #t several global #t filters #t',
             // Trailing spaces present after the first removal
             expectedDescription: 'task with  several global  filters',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: 'task with global filter in the end and some spaces  #t  ',
             expectedDescription: 'task with global filter in the end and some spaces',
         },
         {
-            globalFilter: '#complex/global/filter',
+            globalFilterValue: '#complex/global/filter',
             inputDescription: 'task with #complex/global/filter in the middle',
             expectedDescription: 'task with in the middle',
         },
         {
-            globalFilter: '#task',
+            globalFilterValue: '#task',
             inputDescription: 'task with an extension of the global filter #task/with/extension',
             expectedDescription: 'task with an extension of the global filter #task/with/extension',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: 'task with #t multiple global filters #t',
             expectedDescription: 'task with multiple global filters',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: '#t', // confirm behaviour when the description is empty
             expectedDescription: '',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: '#t #t',
             // Wrong behaviour - the expected should be empty
             expectedDescription: '#t',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: '#t #t #t',
             // Wrong behaviour - the expected should be empty
             expectedDescription: '#t',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: '#t #t #t #t',
             // Wrong behaviour - the expected should be empty
             expectedDescription: '#t #t',
         },
         {
-            globalFilter: '#t',
+            globalFilterValue: '#t',
             inputDescription: '#t #t #t #t #t',
             // Wrong behaviour - the expected should be empty
             expectedDescription: '#t #t',
         },
         {
-            globalFilter: 'some',
+            globalFilterValue: 'some',
             inputDescription: 'some', // confirm behaviour when the description is empty
             expectedDescription: '',
         },
         {
-            globalFilter: 'some',
+            globalFilterValue: 'some',
             inputDescription: 'some some',
             // Wrong behaviour - the expected should be empty
             expectedDescription: 'some',
         },
         {
-            globalFilter: 'some',
+            globalFilterValue: 'some',
             inputDescription: 'some some some',
             // Wrong behaviour - the expected should be empty
             expectedDescription: 'some',
         },
         {
-            globalFilter: 'some',
+            globalFilterValue: 'some',
             inputDescription: 'some some some some',
             // Wrong behaviour - the expected should be empty
             expectedDescription: 'some some',
         },
         {
-            globalFilter: 'some',
+            globalFilterValue: 'some',
             inputDescription: 'some some some some some',
             // Wrong behaviour - the expected should be empty
             expectedDescription: 'some some',
         },
     ])(
         'should parse "$inputDescription" and extract "$expectedDescription"',
-        ({ globalFilter, inputDescription, expectedDescription }) => {
+        ({ globalFilterValue, inputDescription, expectedDescription }) => {
             // Arrange
-            GlobalFilter.set(globalFilter);
+            const globalFilter = new GlobalFilter();
+            globalFilter.set(globalFilterValue);
 
             // Assert
-            expect(GlobalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
+            expect(globalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
         },
     );
 });
 
 describe('check removal of the global filter exhaustively', () => {
-    afterEach(() => {
-        GlobalFilter.reset();
-    });
-
     test.each<string>([
         '#t',
         // The characters listed below are the ones that are - or were - escaped by
@@ -314,44 +356,43 @@ describe('check removal of the global filter exhaustively', () => {
         // Failed attempt at creating a failing test for when / was not escaped
         '///',
         '\\',
-    ])('should parse global filter "%s" edge cases correctly', (globalFilter) => {
+    ])('should parse global filter "%s" edge cases correctly', (globalFilterValue) => {
         // Arrange
-        GlobalFilter.set(globalFilter);
+        const globalFilter = new GlobalFilter();
+        globalFilter.set(globalFilterValue);
 
         // global filter removed at beginning, middle and end
-        let inputDescription = `${globalFilter} 1 ${globalFilter} 2 ${globalFilter}`;
+        let inputDescription = `${globalFilterValue} 1 ${globalFilterValue} 2 ${globalFilterValue}`;
         let expectedDescription = '1 2';
-        expect(GlobalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
+        expect(globalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
 
         // global filter not removed if non-empty non-tag characters before or after it
-        inputDescription = `${globalFilter}x 1 x${globalFilter} ${globalFilter}x 2 x${globalFilter}`;
-        expectedDescription = `${globalFilter}x 1 x${globalFilter} ${globalFilter}x 2 x${globalFilter}`;
-        expect(GlobalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
+        inputDescription = `${globalFilterValue}x 1 x${globalFilterValue} ${globalFilterValue}x 2 x${globalFilterValue}`;
+        expectedDescription = `${globalFilterValue}x 1 x${globalFilterValue} ${globalFilterValue}x 2 x${globalFilterValue}`;
+        expect(globalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
 
         // global filter not removed if non-empty sub-tag characters after it.
         // Include at least one occurrence of global filter, so we don't pass by luck.
-        inputDescription = `${globalFilter}/x 1 x${globalFilter} ${globalFilter}/x 2 ${globalFilter} ${globalFilter}/x`;
-        expectedDescription = `${globalFilter}/x 1 x${globalFilter} ${globalFilter}/x 2 ${globalFilter}/x`;
-        expect(GlobalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
+        inputDescription = `${globalFilterValue}/x 1 x${globalFilterValue} ${globalFilterValue}/x 2 ${globalFilterValue} ${globalFilterValue}/x`;
+        expectedDescription = `${globalFilterValue}/x 1 x${globalFilterValue} ${globalFilterValue}/x 2 ${globalFilterValue}/x`;
+        expect(globalFilter.removeAsWordFrom(inputDescription)).toEqual(expectedDescription);
     });
 });
 
 describe('GlobalFilter.prepend() tests', () => {
-    afterEach(() => {
-        GlobalFilter.reset();
-    });
-
     it('Should prepend Global Filter', () => {
-        const globalFilter = 'awesome';
+        const globalFilterValue = 'awesome';
         const description = 'blossom';
 
-        GlobalFilter.set(globalFilter);
-        expect(GlobalFilter.prependTo(description)).toEqual(`${globalFilter} ${description}`);
+        const globalFilter = new GlobalFilter();
+        globalFilter.set(globalFilterValue);
+        expect(globalFilter.prependTo(description)).toEqual(`${globalFilterValue} ${description}`);
     });
 
     it('Should prepend not prepend empty Global Filter', () => {
         // Note that an empty space is currently prepended in case the Global Filter is empty
         // Not fixing this for now in a refactoring PR
-        expect(GlobalFilter.prependTo('description')).toEqual(' description');
+        const globalFilter = new GlobalFilter();
+        expect(globalFilter.prependTo('description')).toEqual(' description');
     });
 });
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 8d2c6afd..2f1f3fcc 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -506,6 +506,62 @@ Problem line: "${source}"`);
 Problem line: "${source}"`);
         });
     });
+
+    describe('parsing placeholders', () => {
+        it('should expand placeholder values in filters, but not source', () => {
+            // Arrange
+            const rawQuery = 'path includes {{query.file.path}}';
+            const path = 'a/b/path with space.md';
+
+            // Act
+            const query = new Query({ source: rawQuery }, path);
+
+            // Assert
+            expect(query.source).toEqual(rawQuery); // Interesting that query.source still has the placeholder text
+            expect(query.filters.length).toEqual(1);
+            expect(query.filters[0].instruction).toEqual('path includes a/b/path with space.md');
+        });
+
+        it('should report error if placeholders used without query location', () => {
+            // Arrange
+            const input = 'path includes {{query.file.path}}';
+
+            // Act
+            const query = new Query({ source: input });
+
+            // Assert
+            expect(query).not.toBeValid();
+            expect(query.error).toEqual(
+                'The query looks like it contains a placeholder, with "{{" and "}}"\n' +
+                    'but no file path has been supplied, so cannot expand placeholder values.\n' +
+                    'The query is:\n' +
+                    'path includes {{query.file.path}}',
+            );
+            expect(query.filters.length).toEqual(0);
+        });
+
+        it('should report error if non-existent placeholder used', () => {
+            // Arrange
+            const input = 'path includes {{query.file.noSuchProperty}}';
+            const path = 'a/b/path with space.md';
+
+            // Act
+            const query = new Query({ source: input }, path);
+
+            // Assert
+            expect(query).not.toBeValid();
+            expect(query.error).toEqual(
+                'There was an error expanding one or more placeholders.\n' +
+                    '\n' +
+                    'The error message was:\n' +
+                    '    Unknown property: query.file.noSuchProperty\n' +
+                    '\n' +
+                    'The problem is in:\n' +
+                    '    path includes {{query.file.noSuchProperty}}',
+            );
+            expect(query.filters.length).toEqual(0);
+        });
+    });
 });
 
 describe('Query', () => {
@@ -999,7 +1055,7 @@ describe('Query', () => {
 
     describe('explanations', () => {
         afterEach(() => {
-            GlobalFilter.reset();
+            GlobalFilter.getInstance().reset();
         });
 
         it('should explain 0 filters', () => {
@@ -1031,6 +1087,17 @@ due 2012-01-23 =>
             expect(query.explainQuery()).toEqual(expectedDisplayText);
         });
 
+        it('should include any error message in the explanation', () => {
+            const input = 'i am a nonsense query';
+            const query = new Query({ source: input });
+
+            const expectedDisplayText = `Query has an error:
+do not understand query
+Problem line: "i am a nonsense query"
+`;
+            expect(query.explainQuery()).toEqual(expectedDisplayText);
+        });
+
         it('should explain limit 5', () => {
             const input = 'limit 5';
             const query = new Query({ source: input });
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
new file mode 100644
index 00000000..6ebedc10
--- /dev/null
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
@@ -0,0 +1,9 @@
+Explanation of this Tasks code block query:
+
+path includes some/sample/file path.md
+
+root includes some/
+
+folder includes some/sample/
+
+filename includes file path.md
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.query.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.query.text
new file mode 100644
index 00000000..e3f3cb83
--- /dev/null
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.query.text
@@ -0,0 +1,6 @@
+
+explain
+path includes {{query.file.path}}
+root includes {{query.file.root}}
+folder includes {{query.file.folder}}
+filename includes {{query.file.filename}}
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text
new file mode 100644
index 00000000..e582014b
--- /dev/null
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text
@@ -0,0 +1,10 @@
+Explanation of this Tasks code block query:
+
+Query has an error:
+There was an error expanding one or more placeholders.
+
+The error message was:
+    Unknown property: query.file.fileName
+
+The problem is in:
+    filename includes {{query.file.fileName}}
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text
new file mode 100644
index 00000000..0041a996
--- /dev/null
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text
@@ -0,0 +1,4 @@
+
+# query.file.fileName is invalid, because of the capital N.
+# query.file.filename is the correct property name.
+filename includes {{query.file.fileName}}
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.ts b/tests/Query/Explain/DocsSamplesForExplain.test.ts
index b360b607..c27a72f2 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.ts
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.ts
@@ -2,8 +2,10 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
+import { GlobalFilter } from '../../../src/Config/GlobalFilter';
+import { GlobalQuery } from '../../../src/Config/GlobalQuery';
 import { verifyQuery, verifyTaskBlockExplanation } from '../../TestingTools/ApprovalTestHelpers';
-import { resetSettings, updateSettings } from '../../../src/Config/Settings';
+import { resetSettings } from '../../../src/Config/Settings';
 window.moment = moment;
 
 function checkExplainPresentAndVerify(blockQuery: string) {
@@ -33,7 +35,7 @@ explain`;
 
         // Act, Assert
         checkExplainPresentAndVerify(instructions);
-        verifyTaskBlockExplanation(instructions);
+        verifyTaskBlockExplanation(instructions, new GlobalFilter(), new GlobalQuery());
     });
 
     it('boolean combinations', () => {
@@ -45,7 +47,7 @@ not done
 
         // Act, Assert
         checkExplainPresentAndVerify(instructions);
-        verifyTaskBlockExplanation(instructions);
+        verifyTaskBlockExplanation(instructions, new GlobalFilter(), new GlobalQuery());
     });
 
     it('nested boolean combinations', () => {
@@ -56,7 +58,7 @@ explain
 
         // Act, Assert
         checkExplainPresentAndVerify(instructions);
-        verifyTaskBlockExplanation(instructions);
+        verifyTaskBlockExplanation(instructions, new GlobalFilter(), new GlobalQuery());
     });
 
     it('regular expression', () => {
@@ -67,20 +69,19 @@ path regex matches /^Root/Sub-Folder/Sample File\\.md/i`;
 
         // Act, Assert
         checkExplainPresentAndVerify(instructions);
-        verifyTaskBlockExplanation(instructions);
+        verifyTaskBlockExplanation(instructions, new GlobalFilter(), new GlobalQuery());
     });
 
-    const globalQuery = `limit 50
+    const globalQueryLine = `limit 50
 heading includes tasks`;
     it('example global query', () => {
         // Act, Assert
-        verifyQuery(globalQuery);
+        verifyQuery(globalQueryLine);
     });
 
     it('explains task block with global query active', () => {
         // Arrange
-        updateSettings({ globalQuery });
-
+        const globalQuery = new GlobalQuery(globalQueryLine);
         const blockQuery = `
 not done
 due next week
@@ -88,6 +89,32 @@ explain`;
 
         // Act, Assert
         checkExplainPresentAndVerify(blockQuery);
-        verifyTaskBlockExplanation(blockQuery);
+        verifyTaskBlockExplanation(blockQuery, new GlobalFilter(), globalQuery);
+    });
+
+    it('placeholders', () => {
+        // Arrange
+        const instructions: string = `
+explain
+path includes {{query.file.path}}
+root includes {{query.file.root}}
+folder includes {{query.file.folder}}
+filename includes {{query.file.filename}}`;
+
+        // Act, Assert
+        checkExplainPresentAndVerify(instructions);
+        verifyTaskBlockExplanation(instructions, new GlobalFilter(), new GlobalQuery());
+    });
+
+    it('placeholders error', () => {
+        // Arrange
+        const instructions: string = `
+# query.file.fileName is invalid, because of the capital N.
+# query.file.filename is the correct property name.
+filename includes {{query.file.fileName}}`;
+
+        // Act, Assert
+        verifyQuery(instructions); // This does not have an explain, so does not call checkExplainPresentAndVerify()
+        verifyTaskBlockExplanation(instructions, new GlobalFilter(), new GlobalQuery());
     });
 });
diff --git a/tests/Query/Filter/BacklinkField.test.ts b/tests/Query/Filter/BacklinkField.test.ts
index f5abb5c9..60da0682 100644
--- a/tests/Query/Filter/BacklinkField.test.ts
+++ b/tests/Query/Filter/BacklinkField.test.ts
@@ -38,22 +38,22 @@ describe('grouping by backlink', () => {
         ['', 'heading', ['Unknown Location']],
 
         // no heading supplied
-        ['a/b/c.md', null, ['c']],
+        ['a/b/c.md', null, ['[[c]]']],
 
         // File and heading, nominal case
-        ['a/b/c.md', 'heading', ['c > heading']],
+        ['a/b/c.md', 'heading', ['[[c#heading|c > heading]]']],
 
-        // If file name and heading are identical, avoid duplication ('c > c')
-        ['a/b/c.md', 'c', ['c']],
+        // If file name and heading are identical, allow duplication ('c > c'), in order to link to correct section
+        ['a/b/c.md', 'c', ['[[c#c|c > c]]']],
 
-        // If file name and heading are identical, avoid duplication, even if there are underscores in the file name
-        ['a_b_c.md', 'a_b_c', ['a\\_b\\_c']],
+        // If file name and heading are identical, allow duplication, even if there are underscores in the file name
+        ['a_b_c.md', 'a_b_c', ['[[a_b_c#a_b_c|a_b_c > a_b_c]]']],
 
-        // Underscores in file name component are escaped
-        ['a/b/_c_.md', null, ['\\_c\\_']],
+        // Underscores in filename component are not escaped
+        ['a/b/_c_.md', null, ['[[_c_]]']],
 
-        // But underscores in the heading component are not
-        ['a/b/_c_.md', 'heading _italic text_', ['\\_c\\_ > heading _italic text_']],
+        // Underscores in the heading component are not escaped either
+        ['a/b/_c_.md', 'heading _italic text_', ['[[_c_#heading _italic text_|_c_ > heading _italic text_]]']],
     ])(
         'path "%s" and heading "%s" should have groups: %s',
         (path: string, heading: string | null, groups: string[]) => {
@@ -73,11 +73,13 @@ describe('grouping by backlink', () => {
 
         // Assert
         expect({ grouper, tasks }).groupHeadingsToBe([
-            '\\_c\\_',
-            '\\_c\\_ > heading _italic text_',
-            'a\\_b\\_c',
-            'c',
-            'c > heading',
+            '[[_c_]]',
+            '[[_c_#heading _italic text_|_c_ > heading _italic text_]]',
+            '[[a_b_c#a_b_c|a_b_c > a_b_c]]',
+            '[[b]]',
+            '[[c]]',
+            '[[c#c|c > c]]',
+            '[[c#heading|c > heading]]',
             'Unknown Location',
         ]);
     });
diff --git a/tests/Query/Filter/DescriptionField.test.ts b/tests/Query/Filter/DescriptionField.test.ts
index e03faa90..271d9aad 100644
--- a/tests/Query/Filter/DescriptionField.test.ts
+++ b/tests/Query/Filter/DescriptionField.test.ts
@@ -43,7 +43,7 @@ describe('description should strip signifiers, some duplicate spaces and trailin
 
     it('with tag as global filter - all tags included', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         const task = fromLine({
             line: '- [ ] #task Initial  description  ⏫  #tag1 ✅ 2022-08-12 #tag2/sub-tag ',
@@ -54,12 +54,12 @@ describe('description should strip signifiers, some duplicate spaces and trailin
         expect(field.value(task)).toStrictEqual('Initial  description #tag1 #tag2/sub-tag');
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('with non-tag as global filter - all tags included', () => {
         // Arrange
-        GlobalFilter.set('global-filter');
+        GlobalFilter.getInstance().set('global-filter');
 
         const task = fromLine({
             line: '- [ ] global-filter Initial  description  ⏫  #tag1 ✅ 2022-08-12 #tag2/sub-tag ',
@@ -70,14 +70,14 @@ describe('description should strip signifiers, some duplicate spaces and trailin
         expect(field.value(task)).toStrictEqual('Initial  description #tag1 #tag2/sub-tag');
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 });
 
 describe('description', () => {
     it('ignores the global filter when filtering', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
         const filter = new DescriptionField().createFilterOrErrorMessage('description includes task');
 
         // Act, Assert
@@ -90,12 +90,12 @@ describe('description', () => {
         testDescriptionFilter(filter, '- [ ] #task this does: task', true);
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('works without a global filter', () => {
         // Arrange
-        GlobalFilter.set('');
+        GlobalFilter.getInstance().set('');
         const filter = new DescriptionField().createFilterOrErrorMessage('description includes task');
 
         // Act, Assert
@@ -106,7 +106,7 @@ describe('description', () => {
         testDescriptionFilter(filter, '- [ ] #task this does: task', true);
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('works with regex', () => {
diff --git a/tests/Query/Filter/FilenameField.test.ts b/tests/Query/Filter/FilenameField.test.ts
index d29a5997..783239db 100644
--- a/tests/Query/Filter/FilenameField.test.ts
+++ b/tests/Query/Filter/FilenameField.test.ts
@@ -126,6 +126,6 @@ describe('grouping by filename', () => {
         const grouper = new FilenameField().createNormalGrouper();
 
         // Assert
-        expect({ grouper, tasks }).groupHeadingsToBe(['[[_c_]]', '[[a_b_c]]', '[[c]]', 'Unknown Location']);
+        expect({ grouper, tasks }).groupHeadingsToBe(['[[_c_]]', '[[a_b_c]]', '[[b]]', '[[c]]', 'Unknown Location']);
     });
 });
diff --git a/tests/Query/Filter/FolderField.test.ts b/tests/Query/Filter/FolderField.test.ts
index 3d126516..8f168561 100644
--- a/tests/Query/Filter/FolderField.test.ts
+++ b/tests/Query/Filter/FolderField.test.ts
@@ -55,6 +55,6 @@ describe('grouping by folder', () => {
         const grouper = new FolderField().createNormalGrouper();
 
         // Assert
-        expect({ grouper, tasks }).groupHeadingsToBe(['/', 'a/b/', 'a/d/', 'e/d/']);
+        expect({ grouper, tasks }).groupHeadingsToBe(['/', 'a/', 'a/b/', 'a/d/', 'e/d/']);
     });
 });
diff --git a/tests/Query/Filter/PathField.test.ts b/tests/Query/Filter/PathField.test.ts
index 7725f882..f53513dc 100644
--- a/tests/Query/Filter/PathField.test.ts
+++ b/tests/Query/Filter/PathField.test.ts
@@ -157,6 +157,7 @@ describe('grouping by path', () => {
         // Assert
         expect({ grouper, tasks }).groupHeadingsToBe([
             // Why there is no path for empty path?
+            'a/b',
             'a/b/\\_c\\_',
             'a/b/c',
             'a/d/c',
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index 94454f2e..ccc48f3d 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -38,7 +38,7 @@ describe('tag presence & absence', () => {
     });
 
     it('should filter together with the global filter ("has tags")', () => {
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         // Arrange
         const filter = new TagsField().createFilterOrErrorMessage('has tags');
@@ -51,11 +51,11 @@ describe('tag presence & absence', () => {
         expect(filter).toMatchTaskFromLine('- [ ] #task stuff #one #two ');
         expect(filter).not.toMatchTaskFromLine('- [ ] #task global filter is not a tag');
 
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('should filter together with the global filter ("no tags")', () => {
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         // Arrange
         const filter = new TagsField().createFilterOrErrorMessage('no tags');
@@ -68,7 +68,7 @@ describe('tag presence & absence', () => {
         expect(filter).not.toMatchTaskFromLine('- [ ] #task stuff #one #two ');
         expect(filter).toMatchTaskFromLine('- [ ] #task global filter is not a tag');
 
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 });
 
@@ -221,7 +221,7 @@ describe('tag/tags', () => {
             'should filter tag with globalFilter %s',
             (_, { tasks: allTaskLines, filters, expectedResult }) => {
                 // Arrange
-                GlobalFilter.set('#task');
+                GlobalFilter.getInstance().set('#task');
 
                 // Run on the plural version of the filter first.
                 shouldSupportFiltering(filters, allTaskLines, expectedResult);
@@ -241,7 +241,7 @@ describe('tag/tags', () => {
                 shouldSupportFiltering(filters, allTaskLines, expectedResult);
 
                 // Cleanup
-                GlobalFilter.reset();
+                GlobalFilter.getInstance().reset();
             },
         );
 
@@ -276,14 +276,14 @@ describe('tag/tags', () => {
 
         it('should ignore the tag which is the global filter', () => {
             // Arrange
-            GlobalFilter.set('#task');
+            GlobalFilter.getInstance().set('#task');
             const filters: Array<string> = ['tags include task'];
 
             // Act, Assert
             shouldSupportFiltering(filters, defaultTasksWithTags, []);
 
             // Cleanup
-            GlobalFilter.reset();
+            GlobalFilter.getInstance().reset();
         });
     });
 
@@ -472,7 +472,7 @@ describe('Sort by tags', () => {
 
     it('should sort correctly by tag defaulting to first with global filter', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         const t1 = fromLine({ line: '- [ ] #task a #aaa #jjj' });
         const t2 = fromLine({ line: '- [ ] #task a #aaaa #aaaa' });
@@ -499,12 +499,12 @@ describe('Sort by tags', () => {
         ).toEqual(expectedOrder);
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('should sort correctly reversed by tag defaulting to first with global filter', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         const t1 = fromLine({ line: '- [ ] #task a #aaa #jjj' });
         const t2 = fromLine({ line: '- [ ] #task a #aaaa #aaaa' });
@@ -531,12 +531,12 @@ describe('Sort by tags', () => {
         ).toEqual(expectedOrder);
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('should sort correctly by second tag with global filter', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         const t1 = fromLine({ line: '- [ ] #task a #fff #aaa' });
         const t2 = fromLine({ line: '- [ ] #task a #aaaa #aaaa' });
@@ -558,12 +558,12 @@ describe('Sort by tags', () => {
         expect(result).toEqual(expectedOrder);
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('should sort correctly reversed by second tag with global filter', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         const t1 = fromLine({ line: '- [ ] #task a #fff #aaa' });
         const t2 = fromLine({ line: '- [ ] #task a #aaaa #aaaa' });
@@ -585,7 +585,7 @@ describe('Sort by tags', () => {
         expect(result).toEqual(expectedOrder);
 
         // Cleanup
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     // Issue #1407 - Multiple 'sort by tag' lines ignored all but last one
diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts
new file mode 100644
index 00000000..d0573fd1
--- /dev/null
+++ b/tests/Scripting/ExpandPlaceholders.test.ts
@@ -0,0 +1,67 @@
+import { expandPlaceholders } from '../../src/Scripting/ExpandPlaceholders';
+import { makeQueryContext } from '../../src/Scripting/QueryContext';
+
+describe('ExpandTemplate', () => {
+    const path = 'a/b/path with space.md';
+
+    it('hard-coded call', () => {
+        const view = {
+            title: 'Joe',
+            calc: () => 2 + 4,
+        };
+
+        const output = expandPlaceholders('{{ title }} spends {{ calc }}', view);
+        expect(output).toMatchInlineSnapshot('"Joe spends 6"');
+    });
+
+    it('fake query - with file path', () => {
+        const rawString = `path includes {{query.file.path}}
+filename includes {{query.file.filename}}`;
+
+        const queryContext = makeQueryContext(path);
+        expect(expandPlaceholders(rawString, queryContext)).toMatchInlineSnapshot(`
+            "path includes a/b/path with space.md
+            filename includes path with space.md"
+        `);
+    });
+
+    it('should return the input string if no {{ in line', function () {
+        const queryContext = makeQueryContext(path);
+        const line = 'no braces here';
+
+        const result = expandPlaceholders(line, queryContext);
+
+        // This test revealed that Mustache itself returns the input string if no {{ present.
+        expect(Object.is(line, result)).toEqual(true);
+    });
+
+    it('should throw an error if unknown template field used', () => {
+        const view = {
+            title: 'Joe',
+        };
+
+        const source = '{{ title }} spends {{ unknownField }}';
+        expect(() => expandPlaceholders(source, view)).toThrow(`There was an error expanding one or more placeholders.
+
+The error message was:
+    Unknown property: unknownField
+
+The problem is in:
+    {{ title }} spends {{ unknownField }}`);
+    });
+
+    it('should throw an error if unknown template nested field used', () => {
+        const queryContext = makeQueryContext('stuff.md');
+        const source = '{{ query.file.nonsense }}';
+
+        expect(() => expandPlaceholders(source, queryContext)).toThrow(
+            `There was an error expanding one or more placeholders.
+
+The error message was:
+    Unknown property: query.file.nonsense
+
+The problem is in:
+    {{ query.file.nonsense }}`,
+        );
+    });
+});
diff --git a/tests/Scripting/QueryContext.test.ts b/tests/Scripting/QueryContext.test.ts
new file mode 100644
index 00000000..b46a6068
--- /dev/null
+++ b/tests/Scripting/QueryContext.test.ts
@@ -0,0 +1,38 @@
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
+import { FilenameField } from '../../src/Query/Filter/FilenameField';
+import { FolderField } from '../../src/Query/Filter/FolderField';
+import { PathField } from '../../src/Query/Filter/PathField';
+import { RootField } from '../../src/Query/Filter/RootField';
+import { makeQueryContext } from '../../src/Scripting/QueryContext';
+
+describe('QueryContext', () => {
+    describe('values should all match their corresponding filters', () => {
+        const path = 'a/b/c.md';
+        const task = new TaskBuilder().path(path).build();
+        const queryContext = makeQueryContext(path);
+
+        it('root', () => {
+            const instruction = `root includes ${queryContext.query.file.root}`;
+            const filter = new RootField().createFilterOrErrorMessage(instruction);
+            expect(filter).toMatchTask(task);
+        });
+
+        it('path', () => {
+            const instruction = `path includes ${queryContext.query.file.path}`;
+            const filter = new PathField().createFilterOrErrorMessage(instruction);
+            expect(filter).toMatchTask(task);
+        });
+
+        it('folder', () => {
+            const instruction = `folder includes ${queryContext.query.file.folder}`;
+            const filter = new FolderField().createFilterOrErrorMessage(instruction);
+            expect(filter).toMatchTask(task);
+        });
+
+        it('filename', () => {
+            const instruction = `filename includes ${queryContext.query.file.filename}`;
+            const filter = new FilenameField().createFilterOrErrorMessage(instruction);
+            expect(filter).toMatchTask(task);
+        });
+    });
+});
diff --git a/tests/Scripting/QueryProperties.test.query_file_properties.approved.md b/tests/Scripting/QueryProperties.test.query_file_properties.approved.md
new file mode 100644
index 00000000..46fae447
--- /dev/null
+++ b/tests/Scripting/QueryProperties.test.query_file_properties.approved.md
@@ -0,0 +1,13 @@
+<!-- placeholder to force blank line before included text -->
+
+| Field | Type | Example |
+| ----- | ----- | ----- |
+| `query.file.path` | `string` | `'root/sub-folder/file containing query.md'` |
+| `query.file.pathWithoutExtension` | `string` | `'root/sub-folder/file containing query'` |
+| `query.file.root` | `string` | `'root/'` |
+| `query.file.folder` | `string` | `'root/sub-folder/'` |
+| `query.file.filename` | `string` | `'file containing query.md'` |
+| `query.file.filenameWithoutExtension` | `string` | `'file containing query'` |
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/QueryProperties.test.ts b/tests/Scripting/QueryProperties.test.ts
new file mode 100644
index 00000000..92cfc11e
--- /dev/null
+++ b/tests/Scripting/QueryProperties.test.ts
@@ -0,0 +1,34 @@
+import { expandPlaceholders } from '../../src/Scripting/ExpandPlaceholders';
+import { makeQueryContext } from '../../src/Scripting/QueryContext';
+
+import { MarkdownTable } from '../TestingTools/VerifyMarkdownTable';
+import { addBackticks, determineExpressionType, formatToRepresentType } from './ScriptingTestHelpers';
+
+describe('query', () => {
+    function verifyFieldDataForReferenceDocs(fields: string[]) {
+        const markdownTable = new MarkdownTable(['Field', 'Type', 'Example']);
+        const path = 'root/sub-folder/file containing query.md';
+        const queryContext = makeQueryContext(path);
+        for (const field of fields) {
+            const value1 = expandPlaceholders('{{' + field + '}}', queryContext);
+            const cells = [
+                addBackticks(field),
+                addBackticks(determineExpressionType(value1)),
+                addBackticks(formatToRepresentType(value1)),
+            ];
+            markdownTable.addRow(cells);
+        }
+        markdownTable.verifyForDocs();
+    }
+
+    it('file properties', () => {
+        verifyFieldDataForReferenceDocs([
+            'query.file.path',
+            'query.file.pathWithoutExtension',
+            'query.file.root',
+            'query.file.folder',
+            'query.file.filename',
+            'query.file.filenameWithoutExtension',
+        ]);
+    });
+});
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md
index 7517c02e..a7188984 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md
@@ -4,7 +4,12 @@
     - Find tasks in files in any file in the given folder **only**, and not any sub-folders.
     - The equality test, `===`, requires that the trailing slash (`/`) be included.
 - ```filter by function task.file.folder.includes("Work/Projects/")```
-    - Find tasks in files in any folder **and any sub-folders**.
+    - Find tasks in files in a specific folder **and any sub-folders**.
+- ```filter by function task.file.folder.includes( '{{query.file.folder}}' )```
+    - Find tasks in files in the folder that contains the query **and any sub-folders**.
+    - Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
+- ```filter by function task.file.folder === '{{query.file.folder}}'```
+    - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
 - ```filter by function task.file.folder.includes("Work/Projects")```
     - By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as:
         - `Work/Projects 2023/`
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt
index bbc1b877..ea0916b9 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt
@@ -11,13 +11,33 @@ The equality test, `===`, requires that the trailing slash (`/`) be included.
 
 
 filter by function task.file.folder.includes("Work/Projects/")
-Find tasks in files in any folder **and any sub-folders**.
+Find tasks in files in a specific folder **and any sub-folders**.
 =>
 - [ ] In Work/Projects/general projects stuff.md
 - [ ] In Work/Projects/Detail/detailed.md
 ====================================================================================
 
 
+filter by function task.file.folder.includes( '{{query.file.folder}}' )
+Find tasks in files in the folder that contains the query **and any sub-folders**.
+Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
+=>
+- [ ] xyz in a/b.md
+- [ ] xyz in a/b/c.md
+- [ ] xyz in a/d/c.md
+- [ ] xyz in a/b/c.md
+- [ ] xyz in a/b/_c_.md
+- [ ] xyz in a/b/_c_.md
+====================================================================================
+
+
+filter by function task.file.folder === '{{query.file.folder}}'
+Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
+=>
+- [ ] xyz in a/b.md
+====================================================================================
+
+
 filter by function task.file.folder.includes("Work/Projects")
 By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as:
     `Work/Projects 2023/`
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
index ffeb4100..427e9f26 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
@@ -232,7 +232,16 @@ describe('file properties', () => {
                 ],
                 [
                     'filter by function task.file.folder.includes("Work/Projects/")',
-                    'Find tasks in files in any folder **and any sub-folders**',
+                    'Find tasks in files in a specific folder **and any sub-folders**',
+                ],
+                [
+                    "filter by function task.file.folder.includes( '{{query.file.folder}}' )",
+                    'Find tasks in files in the folder that contains the query **and any sub-folders**',
+                    'Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.',
+                ],
+                [
+                    "filter by function task.file.folder === '{{query.file.folder}}'",
+                    'Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**)',
                 ],
                 [
                     'filter by function task.file.folder.includes("Work/Projects")',
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md
index 09d2ba31..d5f6b918 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md
@@ -2,7 +2,7 @@
 
 - ```group by function task.file.filename```
     - Like 'group by filename' but does not link to the file.
-- ```group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
+- ```group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
     - Like 'group by backlink' but links to the heading in the file.
 
 
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt
index 0d43a87b..56faa75f 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt
@@ -7,17 +7,19 @@ Like 'group by filename' but does not link to the file.
 =>
 _c_.md
 a_b_c.md
+b.md
 c.md
 ====================================================================================
 
 
-group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'
+group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'
 Like 'group by backlink' but links to the heading in the file.
 =>
 [[_c_]]
 [[_c_#heading _italic text_]]
 [[#heading]]
 [[a_b_c#a_b_c]]
+[[b]]
 [[c]]
 [[c#c]]
 [[c#heading]]
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt
index 580946a8..f3f5f79e 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt
@@ -6,6 +6,7 @@ group by function task.file.folder
 Same as 'group by folder'.
 =>
 /
+a/
 a/b/
 a/d/
 e/d/
@@ -21,6 +22,7 @@ Here's how it works:
     Then the trailing slash is added back, to ensure we do not get an empty string for files in the top level of the vault.
 =>
 /
+a/
 b/
 d/
 ====================================================================================
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md
index 579ee751..39bb1a81 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md
@@ -2,6 +2,11 @@
 
 - ```group by function task.file.path```
     - Like 'group by path' but includes the file extension.
+- ```group by function task.file.path.replace('{{query.file.folder}}', '')```
+    - Group by the task's file path, but remove the query's folder from the group.
+    - For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths.
+    - Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
+    - This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line.
 
 
 <!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt
index 3d6273a5..435747ff 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt
@@ -6,9 +6,25 @@ group by function task.file.path
 Like 'group by path' but includes the file extension.
 =>
 a_b_c.md
+a/b.md
 a/b/_c_.md
 a/b/c.md
 a/d/c.md
 e/d/c.md
 ====================================================================================
 
+
+group by function task.file.path.replace('{{query.file.folder}}', '')
+Group by the task's file path, but remove the query's folder from the group.
+For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths.
+Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
+This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line.
+=>
+a_b_c.md
+b.md
+b/_c_.md
+b/c.md
+d/c.md
+e/d/c.md
+====================================================================================
+
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
index 699cbc98..5e3674ea 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
@@ -191,6 +191,13 @@ describe('file properties', () => {
             [
                 // comment to force line break
                 ['group by function task.file.path', "Like 'group by path' but includes the file extension"],
+                [
+                    "group by function task.file.path.replace('{{query.file.folder}}', '')",
+                    "Group by the task's file path, but remove the query's folder from the group.",
+                    "For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths.",
+                    'Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.',
+                    "This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line.",
+                ],
             ],
             SampleTasks.withAllRootsPathsHeadings(),
         ],
@@ -227,7 +234,7 @@ describe('file properties', () => {
             [
                 ['group by function task.file.filename', "Like 'group by filename' but does not link to the file"],
                 [
-                    "group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'",
+                    "group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'",
                     "Like 'group by backlink' but links to the heading in the file",
                 ],
             ],
diff --git a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
index c4bac506..bc0cffa6 100644
--- a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
+++ b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
@@ -3,6 +3,8 @@ import { FunctionField } from '../../../src/Query/Filter/FunctionField';
 import type { Task } from '../../../src/Task';
 import { groupHeadingsForTask } from '../../CustomMatchers/CustomMatchersForGrouping';
 import { verifyMarkdownForDocs } from '../../TestingTools/VerifyMarkdownTable';
+import { expandPlaceholders } from '../../../src/Scripting/ExpandPlaceholders';
+import { makeQueryContext } from '../../../src/Scripting/QueryContext';
 
 /** For example, 'task.due' */
 type TaskPropertyName = string;
@@ -63,7 +65,8 @@ export function verifyFunctionFieldFilterSamplesOnTasks(filters: QueryInstructio
         const instruction = filter[0];
         const comment = filter.slice(1);
 
-        const filterOrErrorMessage = new FunctionField().createFilterOrErrorMessage(instruction);
+        const expandedInstruction = expandPlaceholders(instruction, makeQueryContext('a/b.md'));
+        const filterOrErrorMessage = new FunctionField().createFilterOrErrorMessage(expandedInstruction);
         expect(filterOrErrorMessage).toBeValid();
 
         const filterFunction = filterOrErrorMessage.filterFunction!;
@@ -104,7 +107,11 @@ export function verifyFunctionFieldGrouperSamplesOnTasks(
     verifyAll('Results of custom groupers', customGroups, (group) => {
         const instruction = group[0];
         const comment = group.slice(1);
-        const grouper = new FunctionField().createGrouperFromLine(instruction);
+
+        const expandedInstruction = expandPlaceholders(instruction, makeQueryContext('a/b.md'));
+        const grouper = new FunctionField().createGrouperFromLine(expandedInstruction);
+        expect(grouper).not.toBeNull();
+
         const headings = groupHeadingsForTask(grouper!, tasks);
         return formatQueryAndResultsForApproving(instruction, comment, headings);
     });
diff --git a/tests/Scripting/TaskProperties.test.task_file_properties.approved.md b/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
index de861ee4..2a54f7ad 100644
--- a/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
+++ b/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
@@ -3,9 +3,11 @@
 | Field | Type 1 | Example 1 | Type 2 | Example 2 |
 | ----- | ----- | ----- | ----- | ----- |
 | `task.file.path` | `string` | `'some/folder/fileName.md'` | `string` | `''` |
+| `task.file.pathWithoutExtension` | `string` | `'some/folder/fileName'` | `string` | `''` |
 | `task.file.root` | `string` | `'some/'` | `string` | `'/'` |
 | `task.file.folder` | `string` | `'some/folder/'` | `string` | `'/'` |
 | `task.file.filename` | `string` | `'fileName.md'` | `string` | `''` |
+| `task.file.filenameWithoutExtension` | `string` | `'fileName'` | `string` | `''` |
 | `task.hasHeading` | `boolean` | `true` | `boolean` | `false` |
 | `task.heading` | `string` | `'My Header'` | `null` | `null` |
 
diff --git a/tests/Scripting/TaskProperties.test.ts b/tests/Scripting/TaskProperties.test.ts
index a25d3157..e4f0e839 100644
--- a/tests/Scripting/TaskProperties.test.ts
+++ b/tests/Scripting/TaskProperties.test.ts
@@ -84,9 +84,11 @@ describe('task', () => {
     it('file properties', () => {
         verifyFieldDataForReferenceDocs([
             'task.file.path',
+            'task.file.pathWithoutExtension',
             'task.file.root',
             'task.file.folder',
             'task.file.filename',
+            'task.file.filenameWithoutExtension',
             'task.hasHeading',
             'task.heading',
         ]);
diff --git a/tests/Scripting/TasksFile.test.ts b/tests/Scripting/TasksFile.test.ts
index 53ba8dfa..4fb8cf8d 100644
--- a/tests/Scripting/TasksFile.test.ts
+++ b/tests/Scripting/TasksFile.test.ts
@@ -7,6 +7,13 @@ describe('TasksFile', () => {
         expect(file.path).toEqual(path);
     });
 
+    it('should provide access to path without extension', () => {
+        const path = 'a/b/c/d.md';
+        const file = new TasksFile(path);
+        expect(file.pathWithoutExtension).toEqual('a/b/c/d');
+        expect(new TasksFile('/root/folder.mds/file.md').pathWithoutExtension).toStrictEqual('/root/folder.mds/file');
+    });
+
     it('should provide access to the root', () => {
         expect(new TasksFile('').root).toStrictEqual('/');
         expect(new TasksFile('outside/inside/A.md').root).toStrictEqual('outside/');
@@ -25,4 +32,17 @@ describe('TasksFile', () => {
         expect(new TasksFile('file in root.md').filename).toEqual('file in root.md');
         expect(new TasksFile('directory name/file in sub-directory.md').filename).toEqual('file in sub-directory.md');
     });
+
+    it('should provide access to the filename without extension', () => {
+        expect(new TasksFile('').filenameWithoutExtension).toEqual('');
+        expect(new TasksFile('file in root.md').filenameWithoutExtension).toEqual('file in root');
+        expect(new TasksFile('directory name/file in sub-directory.md').filenameWithoutExtension).toEqual(
+            'file in sub-directory',
+        );
+        // Check it only replaces the last .md
+        expect(new TasksFile('1.md.only-replace.2.md').filenameWithoutExtension).toEqual('1.md.only-replace.2');
+
+        // Check it escapes the '.' in the file extension
+        expect(new TasksFile('1.md.only-replace.2,md').filenameWithoutExtension).toEqual('1.md.only-replace.2,md');
+    });
 });
diff --git a/tests/Suggestor/Suggestor.test.ts b/tests/Suggestor/Suggestor.test.ts
index eb5647f7..02656660 100644
--- a/tests/Suggestor/Suggestor.test.ts
+++ b/tests/Suggestor/Suggestor.test.ts
@@ -230,7 +230,7 @@ describe('onlySuggestIfBracketOpen', () => {
 
 describe('canSuggestForLine', () => {
     afterEach(() => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     function canSuggestForLineWithCursor(line: string) {
@@ -238,22 +238,22 @@ describe('canSuggestForLine', () => {
     }
 
     it('should not suggest if there is no checkbox', () => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
         expect(canSuggestForLineWithCursor('- not a task line|')).toEqual(false);
     });
 
-    it('should suggest if there is no global filter', () => {
-        GlobalFilter.reset();
+    it('should suggest if there is no global filter and cursor is in the description', () => {
+        GlobalFilter.getInstance().reset();
         expect(canSuggestForLineWithCursor('- [ ] global filter is not set|')).toEqual(true);
     });
 
     it('should suggest if global filter missing from line', () => {
-        GlobalFilter.set('#todo');
+        GlobalFilter.getInstance().set('#todo');
         expect(canSuggestForLineWithCursor('- [ ] #todo has global filter|')).toEqual(true);
     });
 
     it('should not suggest if global filter missing from line', () => {
-        GlobalFilter.set('#todo');
+        GlobalFilter.getInstance().set('#todo');
         expect(canSuggestForLineWithCursor('- [ ] no global filter|')).toEqual(false);
     });
 
diff --git a/tests/Task.test.ts b/tests/Task.test.ts
index 45059eb6..04f45e89 100644
--- a/tests/Task.test.ts
+++ b/tests/Task.test.ts
@@ -406,7 +406,7 @@ describe('parsing tags', () => {
         ({ markdownTask, expectedDescription, extractedTags, globalFilter }) => {
             // Arrange
             if (globalFilter != '') {
-                GlobalFilter.set(globalFilter);
+                GlobalFilter.getInstance().set(globalFilter);
             }
 
             // Act
@@ -423,7 +423,7 @@ describe('parsing tags', () => {
 
             // Cleanup
             if (globalFilter != '') {
-                GlobalFilter.reset();
+                GlobalFilter.getInstance().reset();
             }
         },
     );
@@ -431,12 +431,12 @@ describe('parsing tags', () => {
 
 describe('task parsing VS global filter', () => {
     afterEach(() => {
-        GlobalFilter.reset();
+        GlobalFilter.getInstance().reset();
     });
 
     it('returns null when task does not have global filter', () => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
         const line = '- [x] this is a done task 🗓 2021-09-12 ✅ 2021-06-20';
 
         // Act
@@ -455,7 +455,7 @@ describe('task parsing VS global filter', () => {
         '- [ #task ] this is a task 🗓 2021-09-12',
     ])('should not parse task with global filter outside of the description: "%s"', (line: string) => {
         // Arrange
-        GlobalFilter.set('#task');
+        GlobalFilter.getInstance().set('#task');
 
         // Act
         const task = fromLine({
@@ -468,7 +468,7 @@ describe('task parsing VS global filter', () => {
 
     it('should not consider task status when searching for global filter', () => {
         // Arrange
-        GlobalFilter.set('@');
+        GlobalFilter.getInstance().set('@');
 
         // Act
         const task = fromLine({
@@ -673,7 +673,7 @@ describe('to string', () => {
     it('retains the global filter', () => {
         // Arrange
         const line = '- [ ] This is a task with #t as a global filter and also #t/some tags';
-        GlobalFilter.set('#t');
+        GlobalFilter.getInstance().set('#t');
 
         // Act
         const task: Task = fromLine({
@@ -684,6 +684,7 @@ describe('to string', () => {
         const expectedLine = 'This is a task with #t as a global filter and also #t/some tags';
         expect(task.toString()).toStrictEqual(expectedLine);
         resetSettings();
+        GlobalFilter.getInstance().reset();
     });
 });
 
@@ -1158,6 +1159,7 @@ describe('created dates on recurring task', () => {
     afterEach(() => {
         jest.useRealTimers();
         resetSettings();
+        GlobalFilter.getInstance().reset();
     });
 
     it('should not set created date with disabled setting', () => {
@@ -1214,11 +1216,13 @@ describe('order of recurring tasks', () => {
         jest.useFakeTimers();
         jest.setSystemTime(new Date(2023, 5 - 1, 16));
         resetSettings();
+        GlobalFilter.getInstance().reset();
     });
 
     afterAll(() => {
         jest.useRealTimers();
         resetSettings();
+        GlobalFilter.getInstance().reset();
     });
 
     it('should put new task before old, by default', () => {
diff --git a/tests/TaskLineRenderer.test.ts b/tests/TaskLineRenderer.test.ts
index 4cf398f9..5d2bba1d 100644
--- a/tests/TaskLineRenderer.test.ts
+++ b/tests/TaskLineRenderer.test.ts
@@ -67,6 +67,8 @@ function getOtherLayoutComponents(parentElement: HTMLElement): string[] {
 describe('task line rendering', () => {
     afterEach(() => {
         resetSettings();
+        GlobalFilter.getInstance().reset();
+        GlobalFilter.getInstance().setRemoveGlobalFilter(false);
     });
 
     it('creates the correct span structure for a basic task', async () => {
@@ -118,8 +120,8 @@ describe('task line rendering', () => {
     };
 
     it('should render Global Filter when the Remove Global Filter is off', async () => {
-        updateSettings({ removeGlobalFilter: false });
-        GlobalFilter.set('#global');
+        GlobalFilter.getInstance().setRemoveGlobalFilter(false);
+        GlobalFilter.getInstance().set('#global');
 
         const taskLine = '- [ ] This is a simple task with a #global filter';
         const descriptionWithFilter = await getDescriptionTest(taskLine);
@@ -128,8 +130,8 @@ describe('task line rendering', () => {
     });
 
     it('should not render Global Filter when the Remove Global Filter is on', async () => {
-        updateSettings({ removeGlobalFilter: true });
-        GlobalFilter.set('#global');
+        GlobalFilter.getInstance().setRemoveGlobalFilter(true);
+        GlobalFilter.getInstance().set('#global');
 
         const taskLine = '- [ ] #global/subtag-shall-stay This is a simple task with a #global filter';
         const descriptionWithoutFilter = await getDescriptionTest(taskLine);
diff --git a/tests/TestHelpers.ts b/tests/TestHelpers.ts
index cd48b4fc..ddf8c831 100644
--- a/tests/TestHelpers.ts
+++ b/tests/TestHelpers.ts
@@ -112,6 +112,7 @@ export class SampleTasks {
             ['', 'heading'],
 
             // no heading supplied
+            ['a/b.md', null],
             ['a/b/c.md', null],
 
             // File and heading, nominal case
diff --git a/tests/TestingTools/ApprovalTestHelpers.ts b/tests/TestingTools/ApprovalTestHelpers.ts
index 7509284f..ec7a94d9 100644
--- a/tests/TestingTools/ApprovalTestHelpers.ts
+++ b/tests/TestingTools/ApprovalTestHelpers.ts
@@ -1,5 +1,7 @@
 import { Options } from 'approvals/lib/Core/Options';
 import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
+import type { GlobalFilter } from '../../src/Config/GlobalFilter';
+import type { GlobalQuery } from '../../src/Config/GlobalQuery';
 import { Query } from '../../src/Query/Query';
 import { explainResults } from '../../src/lib/QueryRendererHelper';
 
@@ -70,10 +72,17 @@ export function verifyQueryExplanation(instructions: string, options?: Options):
  * See {@link verifyQueryExplanation} to just explain the query.
  *
  * @param instructions
- * @param options
+ * @param globalFilter
+ * @param globalQuery
+ * @param options?
  */
-export function verifyTaskBlockExplanation(instructions: string, options?: Options): void {
-    const explanation = explainResults(instructions);
+export function verifyTaskBlockExplanation(
+    instructions: string,
+    globalFilter: GlobalFilter,
+    globalQuery: GlobalQuery,
+    options?: Options,
+): void {
+    const explanation = explainResults(instructions, globalFilter, globalQuery, 'some/sample/file path.md');
 
     options = options || new Options();
     options = options.forFile().withFileExtention('explanation.text');
diff --git a/tests/TestingTools/CombinationApprovalsAsync.ts b/tests/TestingTools/CombinationApprovalsAsync.ts
index bc3c4d9e..6b72b114 100644
--- a/tests/TestingTools/CombinationApprovalsAsync.ts
+++ b/tests/TestingTools/CombinationApprovalsAsync.ts
@@ -1,8 +1,20 @@
 import { it } from '@jest/globals';
 import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
 import { EMPTY } from 'approvals/lib/Utilities/Printers';
+import { formatToRepresentType } from '../Scripting/ScriptingTestHelpers';
 import { runCombinations9 } from './RunCombinations';
 
+/**
+ * Add quotes around any string values.
+ *
+ * If the code in this file is ever published up-stream to Approvals.NodeJS, we can always copy
+ * formatToRepresentType() over too.
+ * @param x
+ */
+export function fmt(x: any): string {
+    return formatToRepresentType(x);
+}
+
 export function verifyAllCombinations2Async<T1, T2>(
     name: string,
     title: string,
@@ -13,7 +25,7 @@ export function verifyAllCombinations2Async<T1, T2>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, _t3, _t4, _t5, _t6, _t7, _t8, _t9) => {
-            it(`${name} ${t1} ${t2}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)}`, async () => {
                 output += (await func(t1, t2)) + '\n';
             });
         },
@@ -44,7 +56,7 @@ export function verifyAllCombinations3Async<T1, T2, T3>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, t3, _t4, _t5, _t6, _t7, _t8, _t9) => {
-            it(`${name} ${t1} ${t2} ${t3}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)} ${fmt(t3)}`, async () => {
                 output += (await func(t1, t2, t3)) + '\n';
             });
         },
@@ -76,7 +88,7 @@ export function verifyAllCombinations4Async<T1, T2, T3, T4>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, t3, t4, _t5, _t6, _t7, _t8, _t9) => {
-            it(`${name} ${t1} ${t2} ${t3} ${t4}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)} ${fmt(t3)} ${fmt(t4)}`, async () => {
                 output += (await func(t1, t2, t3, t4)) + '\n';
             });
         },
@@ -109,7 +121,7 @@ export function verifyAllCombinations5Async<T1, T2, T3, T4, T5>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, t3, t4, t5, _t6, _t7, _t8, _t9) => {
-            it(`${name} ${t1} ${t2} ${t3} ${t4} ${t5}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)} ${fmt(t3)} ${fmt(t4)} ${fmt(t5)}`, async () => {
                 output += (await func(t1, t2, t3, t4, t5)) + '\n';
             });
         },
@@ -143,7 +155,7 @@ export function verifyAllCombinations6Async<T1, T2, T3, T4, T5, T6>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, t3, t4, t5, t6, _t7, _t8, _t9) => {
-            it(`${name} ${t1} ${t2} ${t3} ${t4} ${t5} ${t6}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)} ${fmt(t3)} ${fmt(t4)} ${fmt(t5)} ${fmt(t6)}`, async () => {
                 output += (await func(t1, t2, t3, t4, t5, t6)) + '\n';
             });
         },
@@ -178,7 +190,7 @@ export function verifyAllCombinations7Async<T1, T2, T3, T4, T5, T6, T7>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, t3, t4, t5, t6, t7, _t8, _t9) => {
-            it(`${name} ${t1} ${t2} ${t3} ${t4} ${t5} ${t6} ${t7}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)} ${fmt(t3)} ${fmt(t4)} ${fmt(t5)} ${fmt(t6)} ${fmt(t7)}`, async () => {
                 output += (await func(t1, t2, t3, t4, t5, t6, t7)) + '\n';
             });
         },
@@ -223,7 +235,9 @@ export function verifyAllCombinations8Async<T1, T2, T3, T4, T5, T6, T7, T8>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, t3, t4, t5, t6, t7, t8, _t9) => {
-            it(`${name} ${t1} ${t2} ${t3} ${t4} ${t5} ${t6} ${t7} ${t8}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)} ${fmt(t3)} ${fmt(t4)} ${fmt(t5)} ${fmt(t6)} ${fmt(t7)} ${fmt(
+                t8,
+            )}`, async () => {
                 output += (await func(t1, t2, t3, t4, t5, t6, t7, t8)) + '\n';
             });
         },
@@ -270,7 +284,9 @@ export function verifyAllCombinations9Async<T1, T2, T3, T4, T5, T6, T7, T8, T9>(
     let output = title + '\n';
     runCombinations9(
         (t1, t2, t3, t4, t5, t6, t7, t8, t9) => {
-            it(`${name} ${t1} ${t2} ${t3} ${t4} ${t5} ${t6} ${t7} ${t8} ${t9}`, async () => {
+            it(`${name} ${fmt(t1)} ${fmt(t2)} ${fmt(t3)} ${fmt(t4)} ${fmt(t5)} ${fmt(t6)} ${fmt(t7)} ${fmt(t8)} ${fmt(
+                t9,
+            )}`, async () => {
                 output += (await func(t1, t2, t3, t4, t5, t6, t7, t8, t9)) + '\n';
             });
         },
diff --git a/tests/lib/QueryRendererHelper.test.ts b/tests/lib/QueryRendererHelper.test.ts
index 81a57df6..59ae6b51 100644
--- a/tests/lib/QueryRendererHelper.test.ts
+++ b/tests/lib/QueryRendererHelper.test.ts
@@ -3,18 +3,13 @@
  */
 import moment from 'moment';
 import { Query } from '../../src/Query/Query';
-import { resetSettings, updateSettings } from '../../src/Config/Settings';
 import { explainResults, getQueryForQueryRenderer } from '../../src/lib/QueryRendererHelper';
 import { GlobalFilter } from '../../src/Config/GlobalFilter';
+import { GlobalQuery } from '../../src/Config/GlobalQuery';
 
 window.moment = moment;
 
 describe('explain', () => {
-    afterEach(() => {
-        resetSettings();
-        GlobalFilter.reset();
-    });
-
     it('should explain a task', () => {
         const source = '';
         const query = new Query({ source });
@@ -23,11 +18,12 @@ describe('explain', () => {
 
 No filters supplied. All tasks will match the query.`;
 
-        expect(explainResults(query.source)).toEqual(expectedDisplayText);
+        expect(explainResults(query.source, new GlobalFilter(), new GlobalQuery())).toEqual(expectedDisplayText);
     });
 
     it('should explain a task with global filter active', () => {
-        GlobalFilter.set('#task');
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('#task');
 
         const source = '';
         const query = new Query({ source });
@@ -37,11 +33,11 @@ No filters supplied. All tasks will match the query.`;
 Explanation of this Tasks code block query:
 
 No filters supplied. All tasks will match the query.`;
-        expect(explainResults(query.source)).toEqual(expectedDisplayText);
+        expect(explainResults(query.source, globalFilter, new GlobalQuery())).toEqual(expectedDisplayText);
     });
 
     it('should explain a task with global query active', () => {
-        updateSettings({ globalQuery: 'description includes hello' });
+        const globalQuery = new GlobalQuery('description includes hello');
 
         const source = '';
         const query = new Query({ source });
@@ -54,12 +50,13 @@ Explanation of this Tasks code block query:
 
 No filters supplied. All tasks will match the query.`;
 
-        expect(explainResults(query.source)).toEqual(expectedDisplayText);
+        expect(explainResults(query.source, new GlobalFilter(), globalQuery)).toEqual(expectedDisplayText);
     });
 
     it('should explain a task with global query and global filter active', () => {
-        updateSettings({ globalQuery: 'description includes hello' });
-        GlobalFilter.set('#task');
+        const globalQuery = new GlobalQuery('description includes hello');
+        const globalFilter = new GlobalFilter();
+        globalFilter.set('#task');
 
         const source = '';
         const query = new Query({ source });
@@ -74,11 +71,11 @@ Explanation of this Tasks code block query:
 
 No filters supplied. All tasks will match the query.`;
 
-        expect(explainResults(query.source)).toEqual(expectedDisplayText);
+        expect(explainResults(query.source, globalFilter, globalQuery)).toEqual(expectedDisplayText);
     });
 
     it('should explain a task with global query set but ignored without the global query', () => {
-        updateSettings({ globalQuery: 'description includes hello' });
+        const globalQuery = new GlobalQuery('description includes hello');
 
         const source = 'ignore global query';
         const query = new Query({ source });
@@ -87,7 +84,7 @@ No filters supplied. All tasks will match the query.`;
 
 No filters supplied. All tasks will match the query.`;
 
-        expect(explainResults(query.source)).toEqual(expectedDisplayText);
+        expect(explainResults(query.source, new GlobalFilter(), globalQuery)).toEqual(expectedDisplayText);
     });
 });
 
@@ -97,21 +94,35 @@ No filters supplied. All tasks will match the query.`;
  *       the right query.
  */
 describe('query used for QueryRenderer', () => {
-    afterEach(() => {
-        resetSettings();
-    });
-
     it('should be the result of combining the global query and the actual query', () => {
+        // Arrange
         const querySource = 'description includes world';
         const globalQuerySource = 'description includes hello';
-        updateSettings({ globalQuery: globalQuerySource });
-        expect(getQueryForQueryRenderer(querySource).source).toEqual(`${globalQuerySource}\n${querySource}`);
+        const filePath = 'a/b/c.md';
+
+        // Act
+        const globalQuery = new GlobalQuery(globalQuerySource);
+        const query = getQueryForQueryRenderer(querySource, globalQuery, filePath);
+
+        // Assert
+        expect(query.source).toEqual(`${globalQuerySource}\n${querySource}`);
+        expect(query.filePath).toEqual(filePath);
     });
 
     it('should ignore the global query if "ignore global query" is set', () => {
-        updateSettings({ globalQuery: 'path includes from_global_query' });
-        expect(getQueryForQueryRenderer('description includes from_block_query\nignore global query').source).toEqual(
+        // Arrange
+        const globalQuery = new GlobalQuery('path includes from_global_query');
+        const filePath = 'a/b/c.md';
+
+        // Act
+        const query = getQueryForQueryRenderer(
             'description includes from_block_query\nignore global query',
+            globalQuery,
+            filePath,
         );
+
+        // Assert
+        expect(query.source).toEqual('description includes from_block_query\nignore global query');
+        expect(query.filePath).toEqual(filePath);
     });
 });