Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1056 (2022-08-20 22:25:32)

refactor: Tags search now implemented with SubstringMatcher
<!--- Provide a general summary of your changes in the Title above -->

# Description

- `IStringMatcher` is now an abstract class
- Added `IStringMatcher.matchesAnyOf(stringsToSearch: string[])`
- TagsField is now implemented using `SubstringMatcher`

## Motivation and Context

This will make it really easy to add regex searching in a later step

## How has this been tested?

- Running existing tests
- Adding new tests

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

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)
- [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`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [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/src/Query/Filter/TagsField.ts b/src/Query/Filter/TagsField.ts
index df294bde..c44f5f37 100644
--- a/src/Query/Filter/TagsField.ts
+++ b/src/Query/Filter/TagsField.ts
@@ -1,4 +1,5 @@
 import type { Task } from '../../Task';
+import { SubstringMatcher } from '../Matchers/SubstringMatcher';
 import { Field } from './Field';
 import { FilterOrErrorMessage } from './Filter';
 import { TextField } from './TextField';
@@ -22,19 +23,13 @@ export class TagsField extends Field {
             // Search is done sans the hash. If it is provided then strip it off.
             const search = tagMatch[3].replace(/^#/, '');
 
-            if (filterMethod === 'include' || filterMethod === 'includes') {
+            if (filterMethod.includes('include')) {
+                const matcher = new SubstringMatcher(search);
                 result.filter = (task: Task) =>
-                    task.tags.find((tag) =>
-                        TextField.stringIncludesCaseInsensitive(tag, search),
-                    ) !== undefined;
-            } else if (
-                tagMatch[2] === 'do not include' ||
-                tagMatch[2] === 'does not include'
-            ) {
-                result.filter = (task: Task) =>
-                    task.tags.find((tag) =>
-                        TextField.stringIncludesCaseInsensitive(tag, search),
-                    ) == undefined;
+                    TextField.maybeNegate(
+                        matcher.matchesAnyOf(task.tags),
+                        filterMethod,
+                    );
             } else {
                 result.error = 'do not understand query filter (tag/tags)';
             }
diff --git a/src/Query/Filter/TextField.ts b/src/Query/Filter/TextField.ts
index 1036887b..0e1d756d 100644
--- a/src/Query/Filter/TextField.ts
+++ b/src/Query/Filter/TextField.ts
@@ -68,7 +68,7 @@ export abstract class TextField extends Field {
      */
     protected abstract value(task: Task): string;
 
-    private static maybeNegate(match: boolean, filterMethod: String) {
+    public static maybeNegate(match: boolean, filterMethod: String) {
         return filterMethod.match(/not/) ? !match : match;
     }
 }
diff --git a/src/Query/Matchers/IStringMatcher.ts b/src/Query/Matchers/IStringMatcher.ts
index 7b861454..b837af27 100644
--- a/src/Query/Matchers/IStringMatcher.ts
+++ b/src/Query/Matchers/IStringMatcher.ts
@@ -4,10 +4,18 @@
  * This is used to hide away the details of various text searches, such as the
  * simple inclusion of a sub-string, or the more complex regular expression searches.
  */
-export interface IStringMatcher {
+export abstract class IStringMatcher {
     /**
      * Return whether the given string matches this condition.
      * @param stringToSearch
      */
-    matches(stringToSearch: string): boolean;
+    public abstract matches(stringToSearch: string): boolean;
+
+    /**
+     * Return whether any of the given strings matches this condition.
+     * @param stringsToSearch
+     */
+    public matchesAnyOf(stringsToSearch: string[]) {
+        return stringsToSearch.some((s) => this.matches(s));
+    }
 }
diff --git a/src/Query/Matchers/RegexMatcher.ts b/src/Query/Matchers/RegexMatcher.ts
index 612f2387..2b61aaf5 100644
--- a/src/Query/Matchers/RegexMatcher.ts
+++ b/src/Query/Matchers/RegexMatcher.ts
@@ -1,9 +1,9 @@
-import type { IStringMatcher } from './IStringMatcher';
+import { IStringMatcher } from './IStringMatcher';
 
 /**
  * Regular-expression-based implementation of IStringMatcher.
  */
-export class RegexMatcher implements IStringMatcher {
+export class RegexMatcher extends IStringMatcher {
     private readonly regex: RegExp;
 
     /**
@@ -12,6 +12,7 @@ export class RegexMatcher implements IStringMatcher {
      * @param regex {RegExp} - A valid regular expression
      */
     public constructor(regex: RegExp) {
+        super();
         this.regex = regex;
     }
 
diff --git a/src/Query/Matchers/SubstringMatcher.ts b/src/Query/Matchers/SubstringMatcher.ts
index a24ce654..3d3c62fb 100644
--- a/src/Query/Matchers/SubstringMatcher.ts
+++ b/src/Query/Matchers/SubstringMatcher.ts
@@ -1,11 +1,11 @@
-import type { IStringMatcher } from './IStringMatcher';
+import { IStringMatcher } from './IStringMatcher';
 
 /**
  * Substring-based implementation of IStringMatcher.
  *
  * This does a case-insensitive search for the given string.
  */
-export class SubstringMatcher implements IStringMatcher {
+export class SubstringMatcher extends IStringMatcher {
     private readonly stringToFind: string;
 
     /**
@@ -15,6 +15,7 @@ export class SubstringMatcher implements IStringMatcher {
      *                                Searches will be case-insensitive.
      */
     public constructor(stringToFind: string) {
+        super();
         this.stringToFind = stringToFind;
     }
 


Test patch:
diff --git a/tests/Query/Matchers/SubstringMatcher.test.ts b/tests/Query/Matchers/SubstringMatcher.test.ts
index 7e488df6..d35cf6f0 100644
--- a/tests/Query/Matchers/SubstringMatcher.test.ts
+++ b/tests/Query/Matchers/SubstringMatcher.test.ts
@@ -7,4 +7,14 @@ describe('SubstringMatcher', () => {
         expect(matcher.matches('prefix find me suffix')).toStrictEqual(true);
         expect(matcher.matches('FIND ME')).toStrictEqual(true);
     });
+
+    it('should match any values in array of text', () => {
+        const matcher = new SubstringMatcher('find me');
+        expect(
+            matcher.matchesAnyOf(['wibble', 'stuff', 'FIND ME']),
+        ).toStrictEqual(true);
+        expect(
+            matcher.matchesAnyOf(['wibble', 'stuff', 'LOSE ME']),
+        ).not.toStrictEqual(true);
+    });
 });