Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2711 (2024-03-16 21:57:02)

feat: 'explain' shows Continuations and Placeholders for filters
<!--- Provide a general summary of your changes in the Title above -->

# Description

The `explain` instruction now successfully explains Continuations and Placeholders for filter lines.


## Motivation and Context

See #2349 - for which this is a partial solution.

Still to do: explanations of [Continuations](https://publish.obsidian.md/tasks/Queries/Line+Continuations) and [Placeholders](https://publish.obsidian.md/tasks/Scripting/Placeholders) for `sort by function` and `group by function`. 

## How has this been tested?

<!--- 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. -->

- New tests
- Lots of exploratory testing

## Screenshots (if appropriate)

Changes in the documentation:

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/4840096/06358c39-db84-457f-a7d5-5a577054e4e9)



## 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)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)

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`.
- [x] My change requires a change to the documentation.
- [x] 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/docs/Queries/Explaining Queries.md b/docs/Queries/Explaining Queries.md
index da277fa6..bc8f0f24 100644
--- a/docs/Queries/Explaining Queries.md
+++ b/docs/Queries/Explaining Queries.md
@@ -149,6 +149,13 @@ the results begin with the following, on `2022-10-21`:
 ```text
 Explanation of this Tasks code block query:
 
+  (                                                                                       \
+      (description includes 1) AND (description includes 2) AND (description includes 3)  \
+  ) OR (                                                                                  \
+      (description includes 5) AND (description includes 6) AND (description includes 7)  \
+  )                                                                                       \
+  AND NOT (description includes 7)
+   =>
   ( (description includes 1) AND (description includes 2) AND (description includes 3) ) OR ( (description includes 5) AND (description includes 6) AND (description includes 7) ) AND NOT (description includes 7) =>
     OR (At least one of):
       AND (All of):
@@ -247,14 +254,19 @@ the results begin with the following:
 ```text
 Explanation of this Tasks code block query:
 
+  path includes {{query.file.path}} =>
   path includes some/sample/file path.md
 
+  root includes {{query.file.root}} =>
   root includes some/
 
+  folder includes {{query.file.folder}} =>
   folder includes some/sample/
 
+  filename includes {{query.file.filename}} =>
   filename includes file path.md
 
+  description includes Some Cryptic String {{! Inline comments are removed before search }} =>
   description includes Some Cryptic String 
 
   No grouping instructions supplied.
diff --git a/docs/Queries/Line Continuations.md b/docs/Queries/Line Continuations.md
index a808c41f..e9378d26 100644
--- a/docs/Queries/Line Continuations.md
+++ b/docs/Queries/Line Continuations.md
@@ -31,6 +31,9 @@ explain
 ```text
 Explanation of this Tasks code block query:
 
+  (priority is highest) OR       \
+      (priority is lowest)
+   =>
   (priority is highest) OR (priority is lowest) =>
     OR (At least one of):
       priority is highest
@@ -76,6 +79,7 @@ explain
 ```text
 Explanation of this Tasks code block query:
 
+  description includes \\ =>
   description includes \
 
   No grouping instructions supplied.
diff --git a/docs/Scripting/Placeholders.md b/docs/Scripting/Placeholders.md
index 90d2887d..51754c77 100644
--- a/docs/Scripting/Placeholders.md
+++ b/docs/Scripting/Placeholders.md
@@ -43,14 +43,19 @@ the results begin with the following, which demonstrates how each value inside `
 ```text
 Explanation of this Tasks code block query:
 
+  path includes {{query.file.path}} =>
   path includes some/sample/file path.md
 
+  root includes {{query.file.root}} =>
   root includes some/
 
+  folder includes {{query.file.folder}} =>
   folder includes some/sample/
 
+  filename includes {{query.file.filename}} =>
   filename includes file path.md
 
+  description includes Some Cryptic String {{! Inline comments are removed before search }} =>
   description includes Some Cryptic String 
 
   No grouping instructions supplied.
diff --git a/src/Query/Filter/Filter.ts b/src/Query/Filter/Filter.ts
index 870a8289..61dfe0ad 100644
--- a/src/Query/Filter/Filter.ts
+++ b/src/Query/Filter/Filter.ts
@@ -57,10 +57,11 @@ export class Filter {
     public explainFilterIndented(indent: string) {
         const explanation = this.explanation;
         const unindentedExplanation = explanation.asString();
+        const explainedStatement = this._statement.explainStatement(indent);
         if (unindentedExplanation === this.instruction) {
-            return `${indent}${this.instruction}\n`;
+            return `${explainedStatement}\n`;
         } else {
-            return `${indent}${this.instruction} =>\n${explanation.asString(indent + '  ')}\n`;
+            return `${explainedStatement} =>\n${explanation.asString(indent + '  ')}\n`;
         }
     }
 }
diff --git a/src/Query/Statement.ts b/src/Query/Statement.ts
index 346ce112..027861b8 100644
--- a/src/Query/Statement.ts
+++ b/src/Query/Statement.ts
@@ -52,4 +52,33 @@ export class Statement {
     public get anyPlaceholdersExpanded(): string {
         return this._anyPlaceholdersExpanded;
     }
+
+    public explainStatement(indent: string) {
+        function appendLineIfDifferent(previousLine: string, nextLine: string) {
+            if (nextLine !== previousLine) {
+                result += ` =>
+${indent}${nextLine}`;
+            }
+        }
+
+        // The raw instruction is stored with any surrounding whitespace from the query line.
+        // However, this looks messy in explanations, and leads to complications when comparing
+        // whether the different fields in this class are identical.
+        // So for simplicity, we trim the raw instruction in 'explain' output.
+        const rawInstructionTrimmed = this._rawInstruction.trim();
+
+        const raw = rawInstructionTrimmed.split('\n').join('\n' + indent);
+        let result = `${indent}${raw}`;
+
+        // If the raw instruction has more than one line, append text so that the
+        // subsequent '=>' string will appear at the start of the next line, for clarity:
+        if (this._rawInstruction.includes('\n')) {
+            result += '\n' + indent;
+        }
+
+        appendLineIfDifferent(rawInstructionTrimmed, this._anyContinuationLinesRemoved);
+        appendLineIfDifferent(this._anyContinuationLinesRemoved, this._anyPlaceholdersExpanded);
+
+        return result;
+    }
 }


Test patch:
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text
index 126f066e..41cd1adf 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text
@@ -1,5 +1,6 @@
 Explanation of this Tasks code block query:
 
+  description includes \\ =>
   description includes \
 
   No grouping instructions supplied.
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text
index 0d5440cf..4eed308e 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text
@@ -1,5 +1,8 @@
 Explanation of this Tasks code block query:
 
+  (priority is highest) OR       \
+      (priority is lowest)
+   =>
   (priority is highest) OR (priority is lowest) =>
     OR (At least one of):
       priority is highest
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text
index 010d0e9f..65d969d9 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text
@@ -1,5 +1,12 @@
 Explanation of this Tasks code block query:
 
+  (                                                                                       \
+      (description includes 1) AND (description includes 2) AND (description includes 3)  \
+  ) OR (                                                                                  \
+      (description includes 5) AND (description includes 6) AND (description includes 7)  \
+  )                                                                                       \
+  AND NOT (description includes 7)
+   =>
   ( (description includes 1) AND (description includes 2) AND (description includes 3) ) OR ( (description includes 5) AND (description includes 6) AND (description includes 7) ) AND NOT (description includes 7) =>
     OR (At least one of):
       AND (All of):
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
index 5565aa0d..efb69fe9 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
@@ -1,13 +1,18 @@
 Explanation of this Tasks code block query:
 
+  path includes {{query.file.path}} =>
   path includes some/sample/file path.md
 
+  root includes {{query.file.root}} =>
   root includes some/
 
+  folder includes {{query.file.folder}} =>
   folder includes some/sample/
 
+  filename includes {{query.file.filename}} =>
   filename includes file path.md
 
+  description includes Some Cryptic String {{! Inline comments are removed before search }} =>
   description includes Some Cryptic String 
 
   No grouping instructions supplied.
diff --git a/tests/Query/Explain/Explainer.test.ts b/tests/Query/Explain/Explainer.test.ts
index d7a4921d..5fd30adf 100644
--- a/tests/Query/Explain/Explainer.test.ts
+++ b/tests/Query/Explain/Explainer.test.ts
@@ -12,6 +12,17 @@ import { DebugSettings } from '../../../src/Config/DebugSettings';
 
 window.moment = moment;
 
+/**
+ * Join all the lines with continuation character, then create query
+ * @param lines
+ */
+function makeQueryFromContinuationLines(lines: string[]) {
+    const source = lines.join('\\\n');
+    const query = new Query(source, 'sample.md');
+    expect(query.error).toBeUndefined();
+    return query;
+}
+
 afterEach(() => {
     GlobalFilter.getInstance().reset();
     resetSettings();
@@ -137,6 +148,27 @@ describe('explain filters', () => {
             "
         `);
     });
+
+    describe('white space around instruction', () => {
+        it('should not duplicate query line with extra space - 1-line explanation', () => {
+            const source = '            has done date    ';
+            const query = new Query(source);
+            expect(explainer.explainFilters(query)).toMatchInlineSnapshot(`
+                "has done date
+                "
+            `);
+        });
+
+        it('should not duplicate query line with extra space - multi-line explanation', () => {
+            const source = '            description regex matches /(buy|order)/i    ';
+            const query = new Query(source);
+            expect(explainer.explainFilters(query)).toMatchInlineSnapshot(`
+                "description regex matches /(buy|order)/i =>
+                  using regex:            '(buy|order)' with flag 'i'
+                "
+            `);
+        });
+    });
 });
 
 describe('explain groupers', () => {
@@ -150,6 +182,28 @@ describe('explain groupers', () => {
             "
         `);
     });
+
+    it('should explain a multi-line "group by function"', () => {
+        const lines = [
+            'group by function                                   ',
+            '    const date = task.due;                          ',
+            '    if (!date.moment) {                             ',
+            '        return "Undated";                           ',
+            '    }                                               ',
+            '    if (date.moment.day() === 0) {                  ',
+            '        {{! Put the Sunday group last: }}           ',
+            '        return date.format("[%%][8][%%]dddd");      ',
+            '    }                                               ',
+            '    return date.format("[%%]d[%%]dddd");',
+        ];
+        const query = makeQueryFromContinuationLines(lines);
+
+        // TODO Make this also show the original instruction, including continuations. See #2349.
+        expect(explainer.explainGroups(query)).toMatchInlineSnapshot(`
+            "group by function const date = task.due; if (!date.moment) { return "Undated"; } if (date.moment.day() === 0) {  return date.format("[%%][8][%%]dddd"); } return date.format("[%%]d[%%]dddd");
+            "
+        `);
+    });
 });
 
 describe('explain sorters', () => {
@@ -162,6 +216,24 @@ describe('explain sorters', () => {
             "
         `);
     });
+
+    it('should explain a multi-line "sort by function"', () => {
+        const lines = [
+            'sort by function                                                   ',
+            '    const priorities = [..."🟥🟧🟨🟩🟦"];                        ',
+            '    for (let i = 0; i < priorities.length; i++) {                  ',
+            '        if (task.description.includes(priorities[i])) return i;    ',
+            '    }                                                              ',
+            '    return 999;',
+        ];
+        const query = makeQueryFromContinuationLines(lines);
+
+        // TODO Make this also show the original instruction, including continuations. See #2349.
+        expect(explainer.explainSorters(query)).toMatchInlineSnapshot(`
+            "sort by function const priorities = [..."🟥🟧🟨🟩🟦"]; for (let i = 0; i < priorities.length; i++) { if (task.description.includes(priorities[i])) return i; } return 999;
+            "
+        `);
+    });
 });
 
 describe('explain limits', () => {
diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts
index f0509560..65e4876c 100644
--- a/tests/Query/Query.test.ts
+++ b/tests/Query/Query.test.ts
@@ -1497,7 +1497,11 @@ with \ backslash)`;
             const query = new Query(source);
             const queryUpperCase = new Query(source);
             expect(query.explainQuery()).toMatchInlineSnapshot(`
-                "(description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
+                "(description includes line 1) OR        \\
+                  (description includes line 1 continued\\
+                with \\ backslash)
+                 =>
+                (description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
                   OR (At least one of):
                     description includes line 1
                     description includes line 1 continued with \\ backslash
@@ -1508,7 +1512,11 @@ with \ backslash)`;
                 "
             `);
             expect(queryUpperCase.explainQuery()).toMatchInlineSnapshot(`
-                "(description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
+                "(description includes line 1) OR        \\
+                  (description includes line 1 continued\\
+                with \\ backslash)
+                 =>
+                (description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
                   OR (At least one of):
                     description includes line 1
                     description includes line 1 continued with \\ backslash
diff --git a/tests/Query/Statement.test.ts b/tests/Query/Statement.test.ts
index 5565a92a..aa804c19 100644
--- a/tests/Query/Statement.test.ts
+++ b/tests/Query/Statement.test.ts
@@ -10,4 +10,28 @@ world`;
         expect(instruction.anyContinuationLinesRemoved).toEqual('hello world');
         expect(instruction.anyPlaceholdersExpanded).toEqual('hello world');
     });
+
+    describe('explaining statements', () => {
+        it('should explain simplest statement', () => {
+            const instruction = 'hello world';
+            const statement = new Statement(instruction, instruction);
+            expect(statement.explainStatement('')).toEqual(instruction);
+            expect(statement.explainStatement('  ')).toEqual('  hello world');
+        });
+
+        it('should explain statement with extra white space', () => {
+            const instruction = '   hello world   ';
+            const statement = new Statement(instruction, instruction);
+            expect(statement.explainStatement('')).toEqual('hello world');
+            expect(statement.explainStatement('  ')).toEqual('  hello world');
+        });
+
+        it('should show unexpanded and expanded placeholders, if they differ', () => {
+            const instruction = '{{fake placeholder}}';
+            const statement = new Statement(instruction, instruction);
+            statement.recordExpandedPlaceholders('expanded placeholder');
+            expect(statement.explainStatement('  ')).toEqual(`  {{fake placeholder}} =>
+  expanded placeholder`);
+        });
+    });
 });