Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2374 (2023-10-27 12:24:07)

recfactor: SearchInfo now contains list of tasks being searched
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add to `SearchInfo` the tasks being searched.

## Motivation and Context

This will be needed by the work on implementing task dependencies.

## How has this been tested?

- Adding tests and updating existing ones
- Check some of the Tasks code blocks in the demo vault

## 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://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index 30f0a117..21d7a18a 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -13,6 +13,7 @@ import type { Grouper } from './Grouper';
 import type { Filter } from './Filter/Filter';
 import { QueryResult } from './QueryResult';
 import { scan } from './Scanner';
+import { SearchInfo } from './SearchInfo';
 
 export class Query implements IQuery {
     /** Note: source is the raw source, before expanding any placeholders */
@@ -232,9 +233,10 @@ Problem line: "${line}"`;
     }
 
     public applyQueryToTasks(tasks: Task[]): QueryResult {
+        const searchInfo = new SearchInfo(tasks);
         try {
             this.filters.forEach((filter) => {
-                tasks = tasks.filter(filter.filterFunction);
+                tasks = tasks.filter((task) => filter.filterFunction(task, searchInfo));
             });
 
             const { debugSettings } = getSettings();
diff --git a/src/Query/SearchInfo.ts b/src/Query/SearchInfo.ts
index 0fdbcdd0..7ec6f587 100644
--- a/src/Query/SearchInfo.ts
+++ b/src/Query/SearchInfo.ts
@@ -1,9 +1,18 @@
+import type { Task } from '../Task';
+
 /**
- * SearchInfo will soon contain selected data passed in from the {@link Query} being executed.
+ * SearchInfo contains selected data passed in from the {@link Query} being executed.
  *
- * This is the Parameter Object pattern: it is a container for information that will
- * be passed down through multiple levels of code, in order to be able to in future
- * pass through more data, without having to update the function signatures of all
- * the layers in between.
+ * This is the Parameter Object pattern: it is a container for information that needs
+ * to be passed down through multiple levels of code, without having to update
+ * the function signatures of all the layers in between.
  */
-export class SearchInfo {}
+export class SearchInfo {
+    /** The list of tasks being searched.
+     */
+    public readonly allTasks: Readonly<Task[]>;
+
+    public constructor(allTasks: Task[]) {
+        this.allTasks = [...allTasks];
+    }
+}


Test patch:
diff --git a/tests/CustomMatchers/CustomMatchersForFilters.test.ts b/tests/CustomMatchers/CustomMatchersForFilters.test.ts
index 04cf75cd..43135581 100644
--- a/tests/CustomMatchers/CustomMatchersForFilters.test.ts
+++ b/tests/CustomMatchers/CustomMatchersForFilters.test.ts
@@ -8,7 +8,8 @@ import { Explanation } from '../../src/Query/Explain/Explanation';
 describe('CustomMatchersForFilters', () => {
     it('should check filter with supplied SearchInfo', () => {
         // Arrange
-        const initialSearchInfo = new SearchInfo();
+        const task = new TaskBuilder().build();
+        const initialSearchInfo = new SearchInfo([task]);
         const checkSearchInfoPassedThrough = (_task: Task, searchInfo: SearchInfo) => {
             return Object.is(initialSearchInfo, searchInfo);
         };
@@ -17,6 +18,6 @@ describe('CustomMatchersForFilters', () => {
         );
 
         // Act, Assert
-        expect(filter).toMatchTaskWithSearchInfo(new TaskBuilder().build(), initialSearchInfo);
+        expect(filter).toMatchTaskWithSearchInfo(task, initialSearchInfo);
     });
 });
diff --git a/tests/CustomMatchers/CustomMatchersForFilters.ts b/tests/CustomMatchers/CustomMatchersForFilters.ts
index 42da8ae5..cf4c5f71 100644
--- a/tests/CustomMatchers/CustomMatchersForFilters.ts
+++ b/tests/CustomMatchers/CustomMatchersForFilters.ts
@@ -171,7 +171,7 @@ with filter: "${filter.instruction}"`,
 }
 
 export function toMatchTask(filter: FilterOrErrorMessage, task: Task) {
-    return toMatchTaskWithSearchInfo(filter, task, new SearchInfo());
+    return toMatchTaskWithSearchInfo(filter, task, new SearchInfo([task]));
 }
 
 export function toMatchTaskFromLine(filter: FilterOrErrorMessage, line: string) {
diff --git a/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts b/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
index 75006213..1a28721e 100644
--- a/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
+++ b/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
@@ -227,8 +227,9 @@ function verifyTransitionsAsMarkdownTable(statuses: Status[]) {
 
     function filterAllStatuses(filter: FilterOrErrorMessage) {
         const cells: string[] = [`Matches \`${filter!.instruction}\``];
+        const searchInfo = new SearchInfo(tasks);
         tasks.forEach((task) => {
-            const matchedText = filter!.filter?.filterFunction(task, new SearchInfo()) ? 'YES' : 'no';
+            const matchedText = filter!.filter?.filterFunction(task, searchInfo) ? 'YES' : 'no';
             cells.push(matchedText);
         });
         table.addRow(cells);
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 17339437..3e132a0b 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -11,6 +11,9 @@ import { fieldCreators } from '../src/Query/FilterParser';
 import type { Field } from '../src/Query/Filter/Field';
 import type { BooleanField } from '../src/Query/Filter/BooleanField';
 import { SearchInfo } from '../src/Query/SearchInfo';
+import { FilterOrErrorMessage } from '../src/Query/Filter/FilterOrErrorMessage';
+import { Explanation } from '../src/Query/Explain/Explanation';
+import { Filter } from '../src/Query/Filter/Filter';
 import { createTasksFromMarkdown, fromLine } from './TestHelpers';
 import { shouldSupportFiltering } from './TestingTools/FilterTestHelpers';
 import type { FilteringCase } from './TestingTools/FilterTestHelpers';
@@ -205,7 +208,7 @@ describe('Query parsing', () => {
             expect(query.filters.length).toEqual(1);
             expect(query.filters[0]).toBeDefined();
             // If the boolean query and its sub-query are parsed correctly, the expression should always be true
-            expect(query.filters[0].filterFunction(task, new SearchInfo())).toBeTruthy();
+            expect(query.filters[0].filterFunction(task, new SearchInfo([task]))).toBeTruthy();
         });
     });
 
@@ -615,8 +618,9 @@ describe('Query', () => {
 
             // Act
             let filteredTasks = [...tasks];
+            const searchInfo = new SearchInfo(tasks);
             query.filters.forEach((filter) => {
-                filteredTasks = filteredTasks.filter(filter.filterFunction);
+                filteredTasks = filteredTasks.filter((task) => filter.filterFunction(task, searchInfo));
             });
 
             // Assert
@@ -1026,6 +1030,29 @@ describe('Query', () => {
         });
     });
 
+    describe('SearchInfo', () => {
+        it('should pass SearchInfo through to filter functions', () => {
+            // Arrange
+            const same1 = new TaskBuilder().description('duplicate').build();
+            const same2 = new TaskBuilder().description('duplicate').build();
+            const different = new TaskBuilder().description('different').build();
+            const allTasks = [same1, same2, different];
+
+            const moreThanOneTaskHasThisDescription = (task: Task, searchInfo: SearchInfo) => {
+                return searchInfo.allTasks.filter((t) => t.description === task.description).length > 1;
+            };
+            const filter = FilterOrErrorMessage.fromFilter(
+                new Filter('stuff', moreThanOneTaskHasThisDescription, new Explanation('explanation of stuff')),
+            );
+
+            // Act, Assert
+            const searchInfo = new SearchInfo(allTasks);
+            expect(filter).toMatchTaskWithSearchInfo(same1, searchInfo);
+            expect(filter).toMatchTaskWithSearchInfo(same2, searchInfo);
+            expect(filter).not.toMatchTaskWithSearchInfo(different, searchInfo);
+        });
+    });
+
     describe('sorting', () => {
         const doneTask = new TaskBuilder().status(Status.DONE).build();
         const todoTask = new TaskBuilder().status(Status.TODO).build();
diff --git a/tests/Query/Filter/FunctionField.test.ts b/tests/Query/Filter/FunctionField.test.ts
index 5bfffbe4..b1902a45 100644
--- a/tests/Query/Filter/FunctionField.test.ts
+++ b/tests/Query/Filter/FunctionField.test.ts
@@ -44,7 +44,8 @@ describe('FunctionField - filtering', () => {
         // Assert
         expect(filter).toBeValid();
         const t = () => {
-            filter.filterFunction!(new TaskBuilder().build(), new SearchInfo());
+            const task = new TaskBuilder().build();
+            filter.filterFunction!(task, new SearchInfo([task]));
         };
         expect(t).toThrow(Error);
         expect(t).toThrowError('filtering function must return true or false. This returned "undefined".');
diff --git a/tests/Query/SearchInfo.test.ts b/tests/Query/SearchInfo.test.ts
new file mode 100644
index 00000000..b199dd1b
--- /dev/null
+++ b/tests/Query/SearchInfo.test.ts
@@ -0,0 +1,25 @@
+import { SearchInfo } from '../../src/Query/SearchInfo';
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
+
+describe('SearchInfo', () => {
+    it('should not be able to modify SearchInfo.allTasks directly', () => {
+        const tasks = [new TaskBuilder().build()];
+        const searchInfo = new SearchInfo(tasks);
+        expect(searchInfo.allTasks.length).toEqual(1);
+
+        // Success: Does not compile
+        // searchInfo.allTasks.push(new TaskBuilder().description('I should not be allowed').build());
+
+        // Success: Does not compile
+        // searchInfo.allTasks[0] = new TaskBuilder().description('cannot replace a task').build();
+    });
+
+    it('should not be able to modify SearchInfo.allTasks indirectly', () => {
+        const tasks = [new TaskBuilder().build()];
+        const searchInfo = new SearchInfo(tasks);
+
+        // Check that updating the original list of tasks does not change the tasks saved in searchInfo.allTasks
+        tasks.push(new TaskBuilder().description('I should not be added to searchInfo.allTasks').build());
+        expect(searchInfo.allTasks.length).toEqual(1);
+    });
+});
diff --git a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
index c41a2154..c1419666 100644
--- a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
+++ b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
@@ -91,8 +91,9 @@ export function verifyFunctionFieldFilterSamplesOnTasks(filters: QueryInstructio
 
         const filterFunction = filterOrErrorMessage.filterFunction!;
         const matchingTasks: string[] = [];
+        const searchInfo = new SearchInfo(tasks);
         for (const task of tasks) {
-            const matches = filterFunction(task, new SearchInfo());
+            const matches = filterFunction(task, searchInfo);
             if (matches) {
                 matchingTasks.push(task.toFileLineString());
             }
diff --git a/tests/TestingTools/FilterTestHelpers.ts b/tests/TestingTools/FilterTestHelpers.ts
index 88b53b28..8cbae689 100644
--- a/tests/TestingTools/FilterTestHelpers.ts
+++ b/tests/TestingTools/FilterTestHelpers.ts
@@ -28,7 +28,7 @@ export function testFilter(filter: FilterOrErrorMessage, taskBuilder: TaskBuilde
 export function testTaskFilter(filter: FilterOrErrorMessage, task: Task, expected: boolean) {
     expect(filter.filterFunction).toBeDefined();
     expect(filter.error).toBeUndefined();
-    expect(filter.filterFunction!(task, new SearchInfo())).toEqual(expected);
+    expect(filter.filterFunction!(task, new SearchInfo([task]))).toEqual(expected);
 }
 
 /**
@@ -50,8 +50,9 @@ export function testTaskFilterViaQuery(filter: string, task: Task, expected: boo
 
     // Act
     let filteredTasks = [...tasks];
+    const searchInfo = new SearchInfo(tasks);
     query.filters.forEach((filter) => {
-        filteredTasks = filteredTasks.filter(filter.filterFunction);
+        filteredTasks = filteredTasks.filter((task) => filter.filterFunction(task, searchInfo));
     });
     const matched = filteredTasks.length === 1;
 
@@ -84,8 +85,9 @@ export function shouldSupportFiltering(
 
     // Act
     let filteredTasks = [...tasks];
+    const searchInfo = new SearchInfo(tasks);
     query.filters.forEach((filter) => {
-        filteredTasks = filteredTasks.filter(filter.filterFunction);
+        filteredTasks = filteredTasks.filter((task) => filter.filterFunction(task, searchInfo));
     });
 
     // Assert