Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1109 (2022-09-03 12:53:26)

feat: Add regular expression search for tag and tags
<!--- Provide a general summary of your changes in the Title above -->

# Description

Adds new instructions:

`tags (regex matches|regex does not match) /<JavaScript-style Regex>/`
`tag (regex matches|regex does not match) /<JavaScript-style Regex>/`

Updated relevant documentation pages.

Noted that tag searches now support regex, in relevant page in sample vault.

## Motivation and Context

Fixes #1040
Enables exact tag searches, as requested in #931

## How has this been tested?

- By adding new tests
- By running existing tests

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
expected)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content)
- [x] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change requires a change to the documentation.
- [x] I have [updated the documentation](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/docs/queries/filters.md b/docs/queries/filters.md
index 32596231..96e6602e 100644
--- a/docs/queries/filters.md
+++ b/docs/queries/filters.md
@@ -191,17 +191,31 @@ For example:
 
 > Introduced in Tasks 1.6.0.
 
-- `tags (include|do not include) <tag>` (Alternative grammar `tag (includes|does not include) <tag>` matching description syntax)
+- `tags (include|do not include) <tag>` _or_
+- `tag (includes|does not include) <tag>`
   - Matches case-insensitive (disregards capitalization).
   - Disregards the global filter when matching.
   - The `#` is optional on the tag so `#home` and `home` will work to match `#home`.
   - If the `#` is given, it must be present, so searching for `#home` will match `#home` but not `#location/home`.
   - The match is partial so `tags include foo` will match `#foo/bar` and `#foo-bar`.
+- `tags (regex matches|regex does not match) /<JavaScript-style Regex>/` _or_
+- `tag (regex matches|regex does not match) /<JavaScript-style Regex>/`
+  - Does regular expression match (case-sensitive by default).
+  - Essential reading: [Regular Expression Searches]({{ site.baseurl }}{% link queries/regular-expressions.md %}).
+  - This enables tag searches that avoid sub-tags, by putting a `$` character at the end of the regular expression. See examples below.
+  - If searching for sub-tags, remember to escape the slashes in regular expressions: `\/`
+
+> `regex matches` and `regex does not match` were introduced in Tasks 1.13.0.
 
 #### Tag Query Examples
 
 - `tags include #todo`
 - `tags do not include #todo`
+- `tag regex matches /#t$/`
+  - Searches for a single-character tag `#t`, with no sub-tags, because `$` matches the end of the tag text.
+- `tag regex matches /#book$/i`
+  - The trailing `i` means case-insensitive.
+  - Searches for tags such as `#book`,  `#Book`, `#BOOK` and the `$` prevents matching of `#books`,  `#book/literature`, etc.
 
 ## Filters for File Properties
 
diff --git a/docs/queries/regular-expressions.md b/docs/queries/regular-expressions.md
index 99071fdc..424f6468 100644
--- a/docs/queries/regular-expressions.md
+++ b/docs/queries/regular-expressions.md
@@ -195,8 +195,6 @@ description regex matches /[012][0-9]:[0-5][0-9]/
 
 ### Finding sub-tags
 
-Currently `tag` and `tags` searches do not yet support regular expressions. Therefore, for precise searching of tags, use  `description` instead.
-
 Suppose you wanted to search for tags of this form: `#tag/subtag3/subsubtag5`, where the `3` and the `5` are allowed to be any single digit.
 
 - We can use either `[0-9]` or `\d` to match a single digit.
@@ -205,34 +203,17 @@ Suppose you wanted to search for tags of this form: `#tag/subtag3/subsubtag5`, w
 Escaping the `/` leads us to this instruction, which we have made case-insensitive to find capitalised tags too:
 
 ```text
-description regex matches /#tag\/subtag[0-9]\/subsubtag[0-9]/i
+tags regex matches /#tag\/subtag[0-9]\/subsubtag[0-9]/i
 ```
 
 ### Finding short tags
 
-Currently `tag` and `tags` searches do not yet support regular expressions. Therefore, for precise searching of tags, use  `description` instead.
-
 Suppose you wanted to search for tasks with a very short tag in: `#t`, and to not match tags like `#task` and `#t/subtag`.
 
-The most general query is:
-
 ```text
-(description regex matches /#t\s/i) OR (description regex matches /#t$/i)
+tag regex matches /#t$/i
 ```
 
-We have made it case-insensitive to find capitalised tags too.
-
-The Boolean `OR` allows us to search for two different patterns, for a thorough search:
-
-- `description regex matches /#t\s/i`
-  - Matches `#t` or `#T`, followed by any white-space character. This might be a literal space, or it might be a tab, for example.
-  - It won't be a newline character though, as task descriptions are always exactly one line, with no newline character at the end.
-  - For example, this will match:
-    - `- [ ] #t Do stuff`
-    - `- [ ] Do #t stuff`
-  - But it will not match:
-    - `- [ ] Do stuff #t`
-- `description regex matches /#t$/i`
-  - Matches `#t` or `#T` at the very end of the task line (after all signifiers and trailing white space have been removed)
-  - For example, this will match:
-    - `- [ ] Do stuff #t`
+The `$` prevents there being any more characters in the tag after the search pattern.
+
+The `i` makes the search case-insensitive.
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index 8f3f49d4..363f17c1 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -14,35 +14,35 @@ has_toc: false
 
 This table summarizes the filters and other options available inside a `tasks` block.
 
-| [Filters][1]                                                                                                        | [Sort][2]                                   | [Group][3]            | [Display][4]           |
-| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | --------------------- | ---------------------- |
-| `done`<br>`not done`                                                                                                | `sort by status`                            | `group by status`     |                        |
-| `done (before, after, on) <date>`<br>`has done date`<br>`no  done date`                                             | `sort by done`                              | `group by done`       | `hide done date`       |
-| `starts (before, after, on) <date>`<br>`has start date`<br>`no  start date`                                         | `sort by start`                             | `group by start`      | `hide start date`      |
-| `scheduled (before, after, on) <date>`<br>`has scheduled date`<br>`no  scheduled date`                              | `sort by scheduled`                         | `group by scheduled`  | `hide scheduled date`  |
-| `due (before, after, on) <date>`<br>`has due date`<br>`no  due date`                                                | `sort by due`                               | `group by due`        | `hide due date`        |
-| `happens (before, after, on) <date>`<br>`has happens date`<br>`no  happens date`                                    |                                             | `group by happens`    |                        |
-| `is recurring`<br>`is not recurring`                                                                                |                                             | `group by recurring`  |                        |
-|                                                                                                                     |                                             | `group by recurrence` | `hide recurrence rule` |
-| `priority is (above, below)? (low, none, medium, high)`                                                             | `sort by priority`                          | `group by priority`   | `hide priority`        |
-|                                                                                                                     | `sort by urgency`                           |                       |                        |
-| `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`                 | `sort by path`                              | `group by path`       |                        |
-|                                                                                                                     |                                             | `group by root`       |                        |
-|                                                                                                                     |                                             | `group by folder`     |                        |
-| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`     |                                             | `group by filename`   |                        |
-| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`         |                                             | `group by heading`    |                        |
-|                                                                                                                     |                                             | `group by backlink`   | `hide backlink`        |
-| `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i` | `sort by description`                       |                       |                        |
-| `tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`                                  | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`       |                        |
-| `(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))`                                                                         |                                             |                       |                        |
-| `exclude sub-items`                                                                                                 |                                             |                       |                        |
-| `limit to <number> tasks`<br>`limit <number>`                                                                       |                                             |                       |                        |
+| [Filters][1]                                                                                                                                                                                        | [Sort][2]                                   | [Group][3]            | [Display][4]           |
+| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | --------------------- | ---------------------- |
+| `done`<br>`not done`                                                                                                                                                                                | `sort by status`                            | `group by status`     |                        |
+| `done (before, after, on) <date>`<br>`has done date`<br>`no  done date`                                                                                                                             | `sort by done`                              | `group by done`       | `hide done date`       |
+| `starts (before, after, on) <date>`<br>`has start date`<br>`no  start date`                                                                                                                         | `sort by start`                             | `group by start`      | `hide start date`      |
+| `scheduled (before, after, on) <date>`<br>`has scheduled date`<br>`no  scheduled date`                                                                                                              | `sort by scheduled`                         | `group by scheduled`  | `hide scheduled date`  |
+| `due (before, after, on) <date>`<br>`has due date`<br>`no  due date`                                                                                                                                | `sort by due`                               | `group by due`        | `hide due date`        |
+| `happens (before, after, on) <date>`<br>`has happens date`<br>`no  happens date`                                                                                                                    |                                             | `group by happens`    |                        |
+| `is recurring`<br>`is not recurring`                                                                                                                                                                |                                             | `group by recurring`  |                        |
+|                                                                                                                                                                                                     |                                             | `group by recurrence` | `hide recurrence rule` |
+| `priority is (above, below)? (low, none, medium, high)`                                                                                                                                             | `sort by priority`                          | `group by priority`   | `hide priority`        |
+|                                                                                                                                                                                                     | `sort by urgency`                           |                       |                        |
+| `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`                                                                                                 | `sort by path`                              | `group by path`       |                        |
+|                                                                                                                                                                                                     |                                             | `group by root`       |                        |
+|                                                                                                                                                                                                     |                                             | `group by folder`     |                        |
+| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`                                                                                     |                                             | `group by filename`   |                        |
+| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                         |                                             | `group by heading`    |                        |
+|                                                                                                                                                                                                     |                                             | `group by backlink`   | `hide backlink`        |
+| `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i`                                                                                 | `sort by description`                       |                       |                        |
+| `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`       |                        |
+| `(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))`                                                                                                                                                         |                                             |                       |                        |
+| `exclude sub-items`                                                                                                                                                                                 |                                             |                       |                        |
+| `limit to <number> tasks`<br>`limit <number>`                                                                                                                                                       |                                             |                       |                        |
 
 Other layout options:
 
diff --git a/resources/sample_vaults/Tasks-Demo/Filters/Regular Expression Searches.md b/resources/sample_vaults/Tasks-Demo/Filters/Regular Expression Searches.md
index 56e7d5ad..0ecd4638 100644
--- a/resources/sample_vaults/Tasks-Demo/Filters/Regular Expression Searches.md
+++ b/resources/sample_vaults/Tasks-Demo/Filters/Regular Expression Searches.md
@@ -239,7 +239,7 @@ Intentionally without `#task` as not intended to be picked up by example searche
 
 - [ ] Work out how to prevent `path regex matches /a/b/c/d/` from confusingly only searching `path regex matches /a/`.
   - Logged in [#1037](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1037)
-- [ ] Add regex support to `tag`/`tags` filter.
+- [x] Add regex support to `tag`/`tags` filter.
   - Logged in [#1040](https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/1040)
 - [ ] Specific error message if there are no `/` at beginning and end of query - I'm finding it really easy to forget to include these.
   - Logged in [#1038](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1038)
diff --git a/src/Query/Filter/TagsField.ts b/src/Query/Filter/TagsField.ts
index d94977e3..ee6f207e 100644
--- a/src/Query/Filter/TagsField.ts
+++ b/src/Query/Filter/TagsField.ts
@@ -1,5 +1,7 @@
 import type { Task } from '../../Task';
 import { SubstringMatcher } from '../Matchers/SubstringMatcher';
+import { RegexMatcher } from '../Matchers/RegexMatcher';
+import type { IStringMatcher } from '../Matchers/IStringMatcher';
 import { Field } from './Field';
 import { FilterOrErrorMessage } from './Filter';
 import { TextField } from './TextField';
@@ -12,7 +14,7 @@ import { TextField } from './TextField';
 export class TagsField extends Field {
     // Handles both ways of referencing the tags query.
     private static readonly tagRegexp =
-        /^(tag|tags) (includes|does not include|include|do not include) (.*)/;
+        /^(tag|tags) (includes|does not include|include|do not include|regex matches|regex does not match) (.*)/;
 
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
         const match = Field.getMatch(this.filterRegexp(), line);
@@ -23,20 +25,32 @@ export class TagsField extends Field {
         }
         const filterMethod = match[2];
         const search = match[3];
-
+        let matcher: IStringMatcher | null = null;
         if (filterMethod.includes('include')) {
-            const matcher = new SubstringMatcher(search);
-            return FilterOrErrorMessage.fromFilter((task: Task) => {
-                return TextField.maybeNegate(
-                    matcher.matchesAnyOf(task.tags),
-                    filterMethod,
+            matcher = new SubstringMatcher(search);
+        } else if (filterMethod.includes('regex')) {
+            matcher = RegexMatcher.validateAndConstruct(search);
+            if (matcher === null) {
+                return FilterOrErrorMessage.fromError(
+                    `cannot parse regex (${this.fieldName()}); check your leading and trailing slashes for your query`,
                 );
-            });
-        } else {
+            }
+        }
+
+        if (matcher === null) {
+            // It's likely this can now never be reached.
+            // Retained for safety, for now.
             return FilterOrErrorMessage.fromError(
                 `do not understand query filter (${this.fieldName()})`,
             );
         }
+
+        return FilterOrErrorMessage.fromFilter((task: Task) => {
+            return TextField.maybeNegate(
+                matcher!.matchesAnyOf(task.tags),
+                filterMethod,
+            );
+        });
     }
 
     /**


Test patch:
diff --git a/tests/Query/Filter/DescriptionField.test.ts b/tests/Query/Filter/DescriptionField.test.ts
index 879316bb..aee35f5a 100644
--- a/tests/Query/Filter/DescriptionField.test.ts
+++ b/tests/Query/Filter/DescriptionField.test.ts
@@ -187,6 +187,9 @@ describe('search description for time stamps', () => {
 });
 
 describe('search description for short tags, excluding sub-tags', () => {
+    // Note that the following were written before tag searches supported regex.
+    // They would now be written using:
+    //  tag regex matches /#t$/i
     it('should search for a short tag anywhere in the line except the end', () => {
         // Arrange
         // \s is a whitespace character.
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index 6941a629..8a0332c6 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -232,4 +232,45 @@ describe('tag/tags', () => {
             resetSettings();
         });
     });
+
+    it('tag/tags - regex matches short tag', () => {
+        // Arrange
+        const filter = new TagsField().createFilterOrErrorMessage(
+            String.raw`tag regex matches /#t$/i`, // case-INsensitive
+        );
+
+        // Act, Assert
+        expect(filter).toMatchTaskFromLine('- [ ] #t Do stuff');
+        expect(filter).toMatchTaskFromLine('- [ ] Do #t stuff');
+        expect(filter).toMatchTaskFromLine('- [ ] Do stuff #t');
+
+        // Confirm that tags with sub-tags are not found:
+        expect(filter).not.toMatchTaskFromLine('- [ ] #t/b Do stuff');
+        expect(filter).not.toMatchTaskFromLine('- [ ] Do #t/b stuff');
+        expect(filter).not.toMatchTaskFromLine('- [ ] Do stuff #t/b');
+    });
+
+    it('tag/tags - regex searching for sub-tags', () => {
+        // Arrange
+        const filter = new TagsField().createFilterOrErrorMessage(
+            String.raw`tags regex matches /#tag\/subtag[0-9]\/subsubtag[0-9]/i`, // case-INsensitive
+        );
+
+        // Act, Assert
+        expect(filter).toMatchTaskFromLine('- [ ] a #tag/subtag3/subsubtag5');
+        expect(filter).toMatchTaskFromLine('- [ ] b #tag/subtag3/Subsubtag9');
+    });
+
+    it('tag/tags - regex does not match', () => {
+        // Arrange
+        const filter = new TagsField().createFilterOrErrorMessage(
+            String.raw`tags regex does not match /#HOME/`, // case-sensitive
+        );
+
+        // Act, Assert
+        expect(filter).toMatchTaskFromLine('- [ ] stuff #work');
+        expect(filter).toMatchTaskFromLine('- [ ] stuff #home');
+        expect(filter).not.toMatchTaskFromLine('- [ ] stuff #HOME');
+        expect(filter).not.toMatchTaskFromLine('- [ ] stuff #work #HOME'); // searches multiple tags
+    });
 });