Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1170 (2022-09-21 18:44:21)

fix: Support single filters surrounded by parentheses
<!--- Provide a general summary of your changes in the Title above -->

# Description


- This makes `( any valid filter )` be equivalent to `any valid filter`
- Also fixed a few limitations of TaskBuilder which I discovered whilst writing a failing test for this issue.

## Motivation and Context

This fixes #975.

See the description in #975 for why this helps.


## How has this been tested?

- Tested the modified regex in https://regex101.com before adding it to the code
- By running existing tests
- By adding a failing test for the problem
- And by strengthening the existing tests for BooleanFilter to make sure that it tests `canCreateFilterForLine()` as well as `createFilterOrErrorMessage()`
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)

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] 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/BooleanField.ts b/src/Query/Filter/BooleanField.ts
index d2592f0c..7f82fc76 100644
--- a/src/Query/Filter/BooleanField.ts
+++ b/src/Query/Filter/BooleanField.ts
@@ -22,7 +22,9 @@ import type { Filter } from './Filter';
  * the expression into a single boolean entity.
  */
 export class BooleanField extends Field {
-    private readonly basicBooleanRegexp = /.*(AND|OR|XOR|NOT)\s*[("].*/g;
+    // First pattern in this matches conventional (filter1) OR (filter2) and similar
+    // Second pattern matches (filter1) - that is, ensures that a single filter is treated as valid
+    private readonly basicBooleanRegexp = /(.*(AND|OR|XOR|NOT)\s*[("].*|\(.+\))/g;
     private readonly supportedOperators = ['AND', 'OR', 'XOR', 'NOT'];
     private subFields: Record<string, Filter> = {};
 


Test patch:
diff --git a/tests/Query/Filter/BooleanField.test.ts b/tests/Query/Filter/BooleanField.test.ts
index c8aad4a6..cb1a00ba 100644
--- a/tests/Query/Filter/BooleanField.test.ts
+++ b/tests/Query/Filter/BooleanField.test.ts
@@ -14,12 +14,19 @@ function testWithDescription(filter: FilterOrErrorMessage, description: string,
     testFilter(filter, builder.description(description), expected);
 }
 
+function createValidFilter(instruction: string) {
+    // First make sure that BooleanField recognises the instruction as valid
+    expect(new BooleanField().canCreateFilterForLine(instruction)).toEqual(true);
+
+    return new BooleanField().createFilterOrErrorMessage(instruction);
+}
+
 describe('boolean query', () => {
     // These tests are intended to be really simple, so it is easy to reason about the correct behaviour of the code.
     describe('basic operators', () => {
         it('AND', () => {
             // Arrange
-            const filter = new BooleanField().createFilterOrErrorMessage(
+            const filter = createValidFilter(
                 '"description includes d1" AND "description includes d2"', // Use "..." instead of (), for completeness
             );
 
@@ -32,9 +39,7 @@ describe('boolean query', () => {
 
         it('OR', () => {
             // Arrange
-            const filter = new BooleanField().createFilterOrErrorMessage(
-                '(description includes d1) OR (description includes d2)',
-            );
+            const filter = createValidFilter('(description includes d1) OR (description includes d2)');
 
             // Act, Assert
             testWithDescription(filter, 'neither', false);
@@ -45,9 +50,7 @@ describe('boolean query', () => {
 
         it('XOR', () => {
             // Arrange
-            const filter = new BooleanField().createFilterOrErrorMessage(
-                '(description includes d1) XOR (description includes d2)',
-            );
+            const filter = createValidFilter('(description includes d1) XOR (description includes d2)');
 
             // Act, Assert
             testWithDescription(filter, 'neither', false);
@@ -58,7 +61,7 @@ describe('boolean query', () => {
 
         it('NOT', () => {
             // Arrange
-            const filter = new BooleanField().createFilterOrErrorMessage('NOT (description includes d1)');
+            const filter = createValidFilter('NOT (description includes d1)');
 
             // Act, Assert
             testWithDescription(filter, 'nothing', true);
@@ -67,9 +70,7 @@ describe('boolean query', () => {
 
         it('AND NOT', () => {
             // Arrange
-            const filter = new BooleanField().createFilterOrErrorMessage(
-                '(description includes d1) AND NOT (description includes d2)',
-            );
+            const filter = createValidFilter('(description includes d1) AND NOT (description includes d2)');
 
             // Act, Assert
             testWithDescription(filter, 'neither', false);
@@ -80,9 +81,7 @@ describe('boolean query', () => {
 
         it('OR NOT', () => {
             // Arrange
-            const filter = new BooleanField().createFilterOrErrorMessage(
-                '(description includes d1) OR NOT (description includes d2)',
-            );
+            const filter = createValidFilter('(description includes d1) OR NOT (description includes d2)');
 
             // Act, Assert
             testWithDescription(filter, 'neither', true);
@@ -92,7 +91,7 @@ describe('boolean query', () => {
         });
 
         it('input components dirty with whitespaces', () => {
-            const filter = new BooleanField().createFilterOrErrorMessage(
+            const filter = createValidFilter(
                 ' (description includes #context/location1) OR (description includes #context/location2 ) OR (  description includes #context/location3 ) OR   (  description includes #context/location4 )',
             );
 
@@ -102,6 +101,16 @@ describe('boolean query', () => {
             testWithDescription(filter, 'xxx #context/location3', true);
             testWithDescription(filter, 'xxx #context/location4', true);
         });
+
+        it('should work with single filter in parentheses - via BooleanField', () => {
+            // This tests the fix for
+            //  https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/975
+            //  Support single filters surround by parentheses
+            const filter = createValidFilter('(description includes #context/location1)');
+
+            testWithDescription(filter, 'xxx #context/location1', true);
+            testWithDescription(filter, 'xxx #context/location2', false);
+        });
     });
 
     describe('error cases - to show error messages', () => {
diff --git a/tests/TestingTools/TaskBuilder.test.ts b/tests/TestingTools/TaskBuilder.test.ts
new file mode 100644
index 00000000..3d0576b8
--- /dev/null
+++ b/tests/TestingTools/TaskBuilder.test.ts
@@ -0,0 +1,11 @@
+import { TaskBuilder } from './TaskBuilder';
+
+export {};
+
+describe('TaskBuilder', () => {
+    it('should add the tags to the description', () => {
+        const builder = new TaskBuilder().description('hello').tags(['#tag1', '#tag2']);
+        const task = builder.build();
+        expect(task.toFileLineString()).toStrictEqual('- [ ] hello #tag1 #tag2');
+    });
+});
diff --git a/tests/TestingTools/TaskBuilder.ts b/tests/TestingTools/TaskBuilder.ts
index 6721d69f..dbbf8ca6 100644
--- a/tests/TestingTools/TaskBuilder.ts
+++ b/tests/TestingTools/TaskBuilder.ts
@@ -25,7 +25,7 @@ export class TaskBuilder {
     private _sectionStart: number = 0;
     private _sectionIndex: number = 0;
 
-    private _originalStatusCharacter: string = '';
+    private _originalStatusCharacter: string = ' ';
     private _precedingHeader: string | null = null;
     private _tags: string[] = [];
     private _priority: Priority = Priority.None;
@@ -51,9 +51,13 @@ export class TaskBuilder {
      *      .build();
      */
     public build(): Task {
+        let description = this._description;
+        if (this._tags.length > 0) {
+            description += ' ' + this._tags.join(' ');
+        }
         return new Task({
             status: this._status,
-            description: this._description,
+            description: description,
             path: this._path,
             indentation: this._indentation,
             sectionStart: this._sectionStart,
@@ -76,6 +80,12 @@ export class TaskBuilder {
         return this;
     }
 
+    /**
+     * Set the description.
+     *
+     * This is not parsed for tags. Tags should be added via the separate {@link tags} method.
+     * @param description - description for the task, without tags
+     */
     public description(description: string): TaskBuilder {
         this._description = description;
         return this;