Repository: obsidian-tasks-group/obsidian-tasks (ts)

Issue:
Unable to find section, when title has tag inside
## Expected Behavior
<!--- Tell us what should happen -->
find right section

## Current Behavior
<!--- Tell us what happens instead of the expected behavior -->
![image](https://user-images.githubusercontent.com/5095449/168467118-44c499a3-8aba-4c38-902f-544f1d178f35.png)

## Steps to Reproduce
<!-- Which exact steps can I take to reproduce the issue? -->
create the task under header which has tag inside

## Context (Environment)
* Obsidian version: v0.14.6
* Tasks version: v1.5.1
* [x] I have tried it with all other plugins disabled and the error still occurs

## Possible Solution
<!--- Not obligatory, but suggest a fix/reason for the bug, if you have an idea -->
1. may be obsidian update the recognition
![image](https://user-images.githubusercontent.com/5095449/168467260-ef42ed0a-c24a-4ad8-a8e1-ca8bd8cec655.png)
2. or use the standard link
![image](https://user-images.githubusercontent.com/5095449/168467355-ca955c17-78c9-4fac-ba16-8d62baba1a79.png)






Pull request description:
fix: Unable to find section, when title has tag inside (#654)
## Description
Remove tags from backlink's `href` and `data-href` to resolve #654. As discussed in the issue, Obsidian expects links to sections with tags to have `#` symbols removed, this change fixes rendered task's links.

## How has this been tested?
Bug fix tested locally using the Tasks-Demo vault.
Tag added to heading in ACME note and backlink tested for tasks rendered in the Tasks note.

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] Refactor (non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## Checklist:
- [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 accordingly.
- [ ] My change has adequate Unit Test coverage.

Patch:
diff --git a/src/Query.ts b/src/Query.ts
index 95fdde99..5580021d 100644
--- a/src/Query.ts
+++ b/src/Query.ts
@@ -3,7 +3,7 @@ import type { TaskGroups } from './Query/TaskGroups';
 
 import { LayoutOptions } from './LayoutOptions';
 import { Sort } from './Sort';
-import { Status, Task } from './Task';
+import type { Task } from './Task';
 import type { IQuery } from './IQuery';
 
 import type { Field } from './Query/Filter/Field';
@@ -16,6 +16,8 @@ import { PriorityField } from './Query/Filter/PriorityField';
 import { ScheduledDateField } from './Query/Filter/ScheduledDateField';
 import { StartDateField } from './Query/Filter/StartDateField';
 import { HappensDateField } from './Query/Filter/HappensDateField';
+import { RecurringField } from './Query/Filter/RecurringField';
+import { StatusField } from './Query/Filter/StatusField';
 import { TagsField } from './Query/Filter/TagsField';
 
 export type SortingProperty =
@@ -58,18 +60,6 @@ export class Query implements IQuery {
     private _sorting: Sorting[] = [];
     private _grouping: Grouping[] = [];
 
-    private readonly noStartString = 'no start date';
-    private readonly hasStartString = 'has start date';
-
-    private readonly noScheduledString = 'no scheduled date';
-    private readonly hasScheduledString = 'has scheduled date';
-
-    private readonly noDueString = 'no due date';
-    private readonly hasDueString = 'has due date';
-
-    private readonly doneString = 'done';
-    private readonly notDoneString = 'not done';
-
     // If a tag is specified the user can also add a number to specify
     // which one to sort by if there is more than one.
     private readonly sortByRegexp =
@@ -82,9 +72,6 @@ export class Query implements IQuery {
         /^hide (task count|backlink|priority|start date|scheduled date|done date|due date|recurrence rule|edit button)/;
     private readonly shortModeRegexp = /^short/;
 
-    private readonly recurringString = 'is recurring';
-    private readonly notRecurringString = 'is not recurring';
-
     private readonly limitRegexp = /^limit (to )?(\d+)( tasks?)?/;
     private readonly excludeSubItemsString = 'exclude sub-items';
 
@@ -99,50 +86,16 @@ export class Query implements IQuery {
                 switch (true) {
                     case line === '':
                         break;
-                    case line === this.doneString:
-                        this._filters.push(
-                            (task) => task.status === Status.Done,
-                        );
-                        break;
-                    case line === this.notDoneString:
-                        this._filters.push(
-                            (task) => task.status !== Status.Done,
-                        );
-                        break;
-                    case line === this.recurringString:
-                        this._filters.push((task) => task.recurrence !== null);
-                        break;
-                    case line === this.notRecurringString:
-                        this._filters.push((task) => task.recurrence === null);
-                        break;
                     case line === this.excludeSubItemsString:
                         this._filters.push((task) => task.indentation === '');
                         break;
-                    case line === this.noStartString:
-                        this._filters.push((task) => task.startDate === null);
-                        break;
-                    case line === this.noScheduledString:
-                        this._filters.push(
-                            (task) => task.scheduledDate === null,
-                        );
-                        break;
-                    case line === this.noDueString:
-                        this._filters.push((task) => task.dueDate === null);
-                        break;
-                    case line === this.hasStartString:
-                        this._filters.push((task) => task.startDate !== null);
-                        break;
-                    case line === this.hasScheduledString:
-                        this._filters.push(
-                            (task) => task.scheduledDate !== null,
-                        );
-                        break;
-                    case line === this.hasDueString:
-                        this._filters.push((task) => task.dueDate !== null);
-                        break;
                     case this.shortModeRegexp.test(line):
                         this._layoutOptions.shortMode = true;
                         break;
+                    case this.parseFilter(line, new StatusField()):
+                        break;
+                    case this.parseFilter(line, new RecurringField()):
+                        break;
                     case this.parseFilter(line, new PriorityField()):
                         break;
                     case this.parseFilter(line, new HappensDateField()):
diff --git a/src/Query/Filter/DateField.ts b/src/Query/Filter/DateField.ts
index c161f891..e4f37ee7 100644
--- a/src/Query/Filter/DateField.ts
+++ b/src/Query/Filter/DateField.ts
@@ -10,9 +10,33 @@ import { FilterOrErrorMessage } from './Filter';
  * value, such as the done date.
  */
 export abstract class DateField extends Field {
+    private readonly instructionForFieldPresence = `has ${this.fieldName()} date`;
+    private readonly instructionForFieldAbsence = `no ${this.fieldName()} date`;
+
+    public canCreateFilterForLine(line: string): boolean {
+        if (line === this.instructionForFieldPresence) {
+            return true;
+        }
+        if (line === this.instructionForFieldAbsence) {
+            return true;
+        }
+        return super.canCreateFilterForLine(line);
+    }
+
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
         const result = new FilterOrErrorMessage();
-        const match = line.match(this.filterRegexp());
+
+        if (line === this.instructionForFieldPresence) {
+            result.filter = (task: Task) => this.date(task) !== null;
+            return result;
+        }
+
+        if (line === this.instructionForFieldAbsence) {
+            result.filter = (task: Task) => this.date(task) === null;
+            return result;
+        }
+
+        const match = Field.getMatch(this.filterRegexp(), line);
         if (match !== null) {
             const filterDate = DateParser.parseDate(match[2]);
             if (!filterDate.isValid()) {
diff --git a/src/Query/Filter/DoneDateField.ts b/src/Query/Filter/DoneDateField.ts
index 54a10a62..9999641f 100644
--- a/src/Query/Filter/DoneDateField.ts
+++ b/src/Query/Filter/DoneDateField.ts
@@ -1,41 +1,12 @@
 import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateField } from './DateField';
-import { FilterOrErrorMessage } from './Filter';
 
 /**
  * Support the 'done' search instruction.
  */
 export class DoneDateField extends DateField {
     private static readonly doneRegexp = /^done (before|after|on)? ?(.*)/;
-    private static readonly instructionForFieldPresence = 'has done date';
-    private static readonly instructionForFieldAbsence = 'no done date';
-
-    public canCreateFilterForLine(line: string): boolean {
-        if (line === DoneDateField.instructionForFieldPresence) {
-            return true;
-        }
-        if (line === DoneDateField.instructionForFieldAbsence) {
-            return true;
-        }
-        return super.canCreateFilterForLine(line);
-    }
-
-    public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
-        if (line === DoneDateField.instructionForFieldPresence) {
-            const result = new FilterOrErrorMessage();
-            result.filter = (task: Task) => this.date(task) !== null;
-            return result;
-        }
-
-        if (line === DoneDateField.instructionForFieldAbsence) {
-            const result = new FilterOrErrorMessage();
-            result.filter = (task: Task) => this.date(task) === null;
-            return result;
-        }
-
-        return super.createFilterOrErrorMessage(line);
-    }
 
     protected filterRegexp(): RegExp {
         return DoneDateField.doneRegexp;
diff --git a/src/Query/Filter/Field.ts b/src/Query/Filter/Field.ts
index 17d76974..03f9415e 100644
--- a/src/Query/Filter/Field.ts
+++ b/src/Query/Filter/Field.ts
@@ -22,7 +22,7 @@ export abstract class Field {
      * @param line - A line from a ```tasks``` block.
      */
     public canCreateFilterForLine(line: string): boolean {
-        return this.filterRegexp().test(line);
+        return Field.lineMatchesFilter(this.filterRegexp(), line);
     }
 
     /**
@@ -34,12 +34,49 @@ export abstract class Field {
         line: string,
     ): FilterOrErrorMessage;
 
+    /**
+     * Does the given line match the given filter?
+     * @param filter - A RegExp regular expression, that specifies one query instruction.
+     *                 Or null, if the field does not support regexp-based filtering.
+     * @param line - A line from a tasks code block query.
+     * @protected
+     */
+    protected static lineMatchesFilter(
+        filter: RegExp | null,
+        line: string,
+    ): boolean {
+        if (filter) {
+            return filter.test(line);
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Return the match for the given filter, or null if it does not match
+     * @param filterRegexp - A RegExp regular expression, that specifies one query instruction.
+     *                       Or null, if the field does not support regexp-based filtering.
+     * @param line - A line from a tasks code block query.
+     * @protected
+     */
+    protected static getMatch(
+        filterRegexp: RegExp | null,
+        line: string,
+    ): RegExpMatchArray | null {
+        if (filterRegexp) {
+            return line.match(filterRegexp);
+        } else {
+            return null;
+        }
+    }
+
     /**
      * Return a regular expression that will match a correctly-formed
      * instruction line for filtering Tasks by inspecting the value of this field.
+     * Or null, if this field does not have a regex-based instruction.
      * @protected
      */
-    protected abstract filterRegexp(): RegExp;
+    protected abstract filterRegexp(): RegExp | null;
 
     /**
      * Return the name of this field, to be used in error messages.
diff --git a/src/Query/Filter/HappensDateField.ts b/src/Query/Filter/HappensDateField.ts
index 6a3924ff..264432d9 100644
--- a/src/Query/Filter/HappensDateField.ts
+++ b/src/Query/Filter/HappensDateField.ts
@@ -40,7 +40,7 @@ export class HappensDateField extends Field {
             return result;
         }
 
-        const happensMatch = line.match(this.filterRegexp());
+        const happensMatch = Field.getMatch(this.filterRegexp(), line);
         if (happensMatch !== null) {
             const filterDate = DateParser.parseDate(happensMatch[2]);
             if (!filterDate.isValid()) {
diff --git a/src/Query/Filter/PriorityField.ts b/src/Query/Filter/PriorityField.ts
index 3037b066..b584cd98 100644
--- a/src/Query/Filter/PriorityField.ts
+++ b/src/Query/Filter/PriorityField.ts
@@ -8,7 +8,7 @@ export class PriorityField extends Field {
 
     createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
         const result = new FilterOrErrorMessage();
-        const priorityMatch = line.match(this.filterRegexp());
+        const priorityMatch = Field.getMatch(this.filterRegexp(), line);
         if (priorityMatch !== null) {
             const filterPriorityString = priorityMatch[3];
             let filterPriority: Priority | null = null;
diff --git a/src/Query/Filter/RecurringField.ts b/src/Query/Filter/RecurringField.ts
new file mode 100644
index 00000000..9efecb1d
--- /dev/null
+++ b/src/Query/Filter/RecurringField.ts
@@ -0,0 +1,40 @@
+import { Field } from './Field';
+import { FilterOrErrorMessage } from './Filter';
+
+export class RecurringField extends Field {
+    private readonly instructionForFieldPresence = 'is recurring';
+    private readonly instructionForFieldAbsence = 'is not recurring';
+
+    public canCreateFilterForLine(line: string): boolean {
+        return (
+            line === this.instructionForFieldAbsence ||
+            line === this.instructionForFieldPresence
+        );
+    }
+
+    public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
+        const result = new FilterOrErrorMessage();
+
+        if (line === this.instructionForFieldPresence) {
+            result.filter = (task) => task.recurrence !== null;
+            return result;
+        }
+
+        if (line === this.instructionForFieldAbsence) {
+            result.filter = (task) => task.recurrence === null;
+            return result;
+        }
+
+        result.error = `do not understand filter: ${line}`;
+
+        return result;
+    }
+
+    protected fieldName(): string {
+        return 'recurring';
+    }
+
+    protected filterRegexp(): RegExp | null {
+        return null;
+    }
+}
diff --git a/src/Query/Filter/StatusField.ts b/src/Query/Filter/StatusField.ts
new file mode 100644
index 00000000..9ce30d54
--- /dev/null
+++ b/src/Query/Filter/StatusField.ts
@@ -0,0 +1,41 @@
+import { Status } from '../../Task';
+import { Field } from './Field';
+import { FilterOrErrorMessage } from './Filter';
+
+export class StatusField extends Field {
+    private readonly instructionForFieldPresence = 'done';
+    private readonly instructionForFieldAbsence = 'not done';
+
+    public canCreateFilterForLine(line: string): boolean {
+        return (
+            line === this.instructionForFieldAbsence ||
+            line === this.instructionForFieldPresence
+        );
+    }
+
+    public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
+        const result = new FilterOrErrorMessage();
+
+        if (line === this.instructionForFieldPresence) {
+            result.filter = (task) => task.status === Status.Done;
+            return result;
+        }
+
+        if (line === this.instructionForFieldAbsence) {
+            result.filter = (task) => task.status !== Status.Done;
+            return result;
+        }
+
+        result.error = `do not understand filter: ${line}`;
+
+        return result;
+    }
+
+    protected fieldName(): string {
+        return 'status';
+    }
+
+    protected filterRegexp(): RegExp | null {
+        return null;
+    }
+}
diff --git a/src/Query/Filter/TagsField.ts b/src/Query/Filter/TagsField.ts
index 23dd26b2..87243365 100644
--- a/src/Query/Filter/TagsField.ts
+++ b/src/Query/Filter/TagsField.ts
@@ -14,7 +14,7 @@ export class TagsField extends Field {
 
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
         const result = new FilterOrErrorMessage();
-        const tagMatch = line.match(this.filterRegexp());
+        const tagMatch = Field.getMatch(this.filterRegexp(), line);
         if (tagMatch !== null) {
             const filterMethod = tagMatch[2];
 
diff --git a/src/Query/Filter/TextField.ts b/src/Query/Filter/TextField.ts
index b12925b5..497f2725 100644
--- a/src/Query/Filter/TextField.ts
+++ b/src/Query/Filter/TextField.ts
@@ -10,7 +10,7 @@ import { FilterOrErrorMessage } from './Filter';
 export abstract class TextField extends Field {
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
         const result = new FilterOrErrorMessage();
-        const match = line.match(this.filterRegexp());
+        const match = Field.getMatch(this.filterRegexp(), line);
         if (match !== null) {
             const filterMethod = match[1];
             if (filterMethod === 'includes') {
@@ -43,7 +43,7 @@ export abstract class TextField extends Field {
             .includes(needle.toLocaleLowerCase());
     }
 
-    protected abstract filterRegexp(): RegExp;
+    protected abstract filterRegexp(): RegExp | null;
 
     protected abstract fieldName(): string;
 
diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts
index 3e760691..d34d5d70 100644
--- a/src/QueryRenderer.ts
+++ b/src/QueryRenderer.ts
@@ -297,10 +297,12 @@ class QueryRenderChild extends MarkdownRenderChild {
         }
 
         if (task.precedingHeader !== null) {
-            link.href = link.href + '#' + task.precedingHeader;
+            link.href = link.href + '#' + task.precedingHeader.replace('#', '');
             link.setAttribute(
                 'data-href',
-                link.getAttribute('data-href') + '#' + task.precedingHeader,
+                link.getAttribute('data-href') +
+                    '#' +
+                    task.precedingHeader.replace('#', ''),
             );
         }
 


Test patch:
diff --git a/resources/sample_vaults/Tasks-Demo/Manual Testing/654 - Unable to find section, when title has tag inside.md b/resources/sample_vaults/Tasks-Demo/Manual Testing/654 - Unable to find section, when title has tag inside.md
new file mode 100644
index 00000000..fbf786bd
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Manual Testing/654 - Unable to find section, when title has tag inside.md
@@ -0,0 +1,25 @@
+# 654 - Unable to find section, when title has tag inside
+
+- [Unable to find section, when title has tag inside · Issue #654 · obsidian-tasks-group/obsidian-tasks · GitHub](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/654)
+
+## Section with tag in #example-tag
+
+- [ ] #task Task in section with tag in heading name
+
+## Searches
+
+### The Bug
+
+The bug is that ==clicking on the `[file name] > [section name]`  text below does not jump to the section containing the task.==
+
+```tasks
+path includes 654
+sort by description
+```
+
+### Correct behaviour
+
+Clicking on `Section with tag in #example-tag` in the task results block above should:
+
+1. scroll to that section
+2. highlight the section
diff --git a/resources/sample_vaults/Tasks-Demo/Manual Testing/README - Manual Testing.md b/resources/sample_vaults/Tasks-Demo/Manual Testing/README - Manual Testing.md
new file mode 100644
index 00000000..eeb2aedd
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Manual Testing/README - Manual Testing.md
@@ -0,0 +1,16 @@
+# README - Manual Testing
+
+## Purpose
+
+This folder contains files that are useful for:
+
+- verifying the existence of reported bugs
+- manual testing of proposed fixes for the bugs
+
+## File naming
+
+Where files are for testing individual issues, PRs or discussions, please name the file like this:
+
+`[issue number] - [brief summary]`
+
+Thank you.
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 9a511412..d5e37581 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -56,7 +56,6 @@ describe('Query parsing', () => {
             'no scheduled date',
             'no start date',
             'not done',
-            'not done',
             'path does not include some/path',
             'path includes some/path',
             'priority is above none',
diff --git a/tests/Query/Filter/RecurringField.test.ts b/tests/Query/Filter/RecurringField.test.ts
new file mode 100644
index 00000000..011687a1
--- /dev/null
+++ b/tests/Query/Filter/RecurringField.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { RecurringField } from '../../../src/Query/Filter/RecurringField';
+import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
+import { testTaskFilter } from '../../TestingTools/FilterTestHelpers';
+import { fromLine } from '../../TestHelpers';
+
+window.moment = moment;
+
+function testRecurringFilter(
+    filter: FilterOrErrorMessage,
+    line: string,
+    expected: boolean,
+) {
+    const task = fromLine({ line });
+    testTaskFilter(filter, task, expected);
+}
+
+describe('recurring', () => {
+    const non_recurring = '- [ ] non-recurring task';
+    const recurring = '- [ ] recurring 🔁 every day 📅 2022-06-17';
+    // Invalid recurrence rules are discarded, and treated as non-recurring
+    const invalid = '- [ ] recurring 🔁 invalid rule 📅 2022-06-17';
+
+    it('is recurring', () => {
+        // Arrange
+        const filter = new RecurringField().createFilterOrErrorMessage(
+            'is recurring',
+        );
+
+        // Assert
+        testRecurringFilter(filter, non_recurring, false);
+        testRecurringFilter(filter, recurring, true);
+        testRecurringFilter(filter, invalid, false);
+    });
+
+    it('is not recurring', () => {
+        // Arrange
+        const filter = new RecurringField().createFilterOrErrorMessage(
+            'is not recurring',
+        );
+
+        // Assert
+        testRecurringFilter(filter, non_recurring, true);
+        testRecurringFilter(filter, recurring, false);
+        testRecurringFilter(filter, invalid, true);
+    });
+});
diff --git a/tests/Query/Filter/StatusField.test.ts b/tests/Query/Filter/StatusField.test.ts
new file mode 100644
index 00000000..a4af3b4a
--- /dev/null
+++ b/tests/Query/Filter/StatusField.test.ts
@@ -0,0 +1,34 @@
+import { StatusField } from '../../../src/Query/Filter/StatusField';
+import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import { testFilter } from '../../TestingTools/FilterTestHelpers';
+import { Status } from '../../../src/Task';
+
+function testStatusFilter(
+    filter: FilterOrErrorMessage,
+    status: Status,
+    expected: boolean,
+) {
+    const builder = new TaskBuilder();
+    testFilter(filter, builder.status(status), expected);
+}
+
+describe('status', () => {
+    it('done', () => {
+        // Arrange
+        const filter = new StatusField().createFilterOrErrorMessage('done');
+
+        // Assert
+        testStatusFilter(filter, Status.Todo, false);
+        testStatusFilter(filter, Status.Done, true);
+    });
+
+    it('not done', () => {
+        // Arrange
+        const filter = new StatusField().createFilterOrErrorMessage('not done');
+
+        // Assert
+        testStatusFilter(filter, Status.Todo, true);
+        testStatusFilter(filter, Status.Done, false);
+    });
+});