Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2244 (2023-09-01 21:10:51)

fix: Prevent auto-suggest appearing before the end of the checkbox
<!--- Provide a general summary of your changes in the Title above -->

# Description

I have found that Auto Suggest has a tendency to pop up in situations where it will never be valid, leading to task lines like:

```text
📅 - [ ] 
-📅  [ ] 
- [ 📅 ] 
```

This change means that Auto Suggest will now only pop up when the cursor is beyond one space after the `]` character.

If you imagine that the `|` in the following is the location of the cursor, the auto-suggest menu will no longer pop up in these situations:

```
- [ |] 
- [ ]| 
1. [ ]|
    - [ ]|
```

It will still pop up in these situations:

```
- [ ] |
1. [ ] |
    - [ ] |
```

## Motivation and Context

To make Auto Suggest a bit more specific.

## How has this been tested?

- By adding tests.
- Manual testing 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: -->

Changes visible to users:

- [x] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)

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/Suggestor/EditorSuggestorPopup.ts b/src/Suggestor/EditorSuggestorPopup.ts
index 914b6b1f..c5853ee1 100644
--- a/src/Suggestor/EditorSuggestorPopup.ts
+++ b/src/Suggestor/EditorSuggestorPopup.ts
@@ -19,7 +19,7 @@ export class EditorSuggestor extends EditorSuggest<SuggestInfoWithContext> {
     onTrigger(cursor: EditorPosition, editor: Editor, _file: TFile): EditorSuggestTriggerInfo | null {
         if (!this.settings.autoSuggestInEditor) return null;
         const line = editor.getLine(cursor.line);
-        if (canSuggestForLine(line)) {
+        if (canSuggestForLine(line, cursor.ch)) {
             return {
                 start: { line: cursor.line, ch: 0 },
                 end: {
diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts
index 8f53c6bd..b24d7936 100644
--- a/src/Suggestor/Suggestor.ts
+++ b/src/Suggestor/Suggestor.ts
@@ -3,8 +3,7 @@ import { DateParser } from '../Query/DateParser';
 import { doAutocomplete } from '../DateAbbreviations';
 import { Recurrence } from '../Recurrence';
 import type { DefaultTaskSerializerSymbols } from '../TaskSerializer/DefaultTaskSerializer';
-import * as task from '../Task';
-import { TaskRegularExpressions } from '../Task';
+import { Task, TaskRegularExpressions } from '../Task';
 import { GlobalFilter } from '../Config/GlobalFilter';
 import type { SuggestInfo, SuggestionBuilder } from '.';
 
@@ -411,7 +410,35 @@ export function onlySuggestIfBracketOpen(fn: SuggestionBuilder, brackets: [strin
  *  - Is the global filter (if set) in the line?
  *  - Is the line a task line (with a checkbox)
  * @param line
+ * @param cursorPosition - the cursor position, when 0 is presumed to mean 'at the start of the line'.
+ *                          See 'ch' in https://docs.obsidian.md/Reference/TypeScript+API/EditorPosition/EditorPosition
  */
-export function canSuggestForLine(line: string) {
-    return GlobalFilter.includedIn(line) && line.match(task.TaskRegularExpressions.taskRegex) !== null;
+export function canSuggestForLine(line: string, cursorPosition: number) {
+    return GlobalFilter.includedIn(line) && cursorIsInTaskLineDescription(line, cursorPosition);
+}
+
+/**
+ * Return true if:
+ * - line is a task line, that is, it is a list item with a checkbox.
+ * - the cursor is in a task line's description.
+ *
+ * Here, description includes any task signifiers, as well as the vanilla description.
+ * @param line
+ * @param cursorPosition
+ */
+function cursorIsInTaskLineDescription(line: string, cursorPosition: number) {
+    if (line.length === 0) {
+        return false;
+    }
+
+    const components = Task.extractTaskComponents(line);
+    if (!components) {
+        // It is not a task line, that is, it is not a list item with a checkbox:
+        return false;
+    }
+
+    // Reconstruct the contents of the line, up to the space after the closing ']' in the checkbox:
+    const beforeDescription = components.indentation + components.listMarker + ' [' + components.status.symbol + '] ';
+
+    return cursorPosition >= beforeDescription.length;
 }
diff --git a/src/Task.ts b/src/Task.ts
index 0e4c57f8..bc59eadc 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -297,7 +297,12 @@ export class Task {
         });
     }
 
-    private static extractTaskComponents(line: string): TaskComponents | null {
+    /**
+     * Extract the component parts of the task line.
+     * @param line
+     * @returns a {@link TaskComponents} object containing the component parts of the task line
+     */
+    static extractTaskComponents(line: string): TaskComponents | null {
         // Check the line to see if it is a markdown task.
         const regexMatch = line.match(TaskRegularExpressions.taskRegex);
         if (regexMatch === null) {


Test patch:
diff --git a/tests/Suggestor/Suggestor.test.ts b/tests/Suggestor/Suggestor.test.ts
index a589dea3..eb5647f7 100644
--- a/tests/Suggestor/Suggestor.test.ts
+++ b/tests/Suggestor/Suggestor.test.ts
@@ -28,6 +28,23 @@ afterAll(() => {
     chronoSpy.mockRestore();
 });
 
+/**
+ * Given a string with **exactly** one vertical bar (`|`), returns the string with the vertical bar removed
+ * and the index of the bar.
+ *
+ * This is used as a helper when writing tests, since it provides a less error prone
+ * and more readable way to represent a cursor's position, denoted by the vertical bar, and the string.
+ *
+ * @param line - A string that contains exactly one vertical bar (`|`)
+ * @returns A tuple of the line without the vertical bar, and the index of the vertical bar.
+ */
+function cursorPosition(line: string): [lineWithoutCursor: string, cursorIndex: number] {
+    const line_without_cursor = line.replace(/\|/g, '');
+    // Check that the cursor marker appears exactly once in each input string:
+    expect(line_without_cursor.length).toEqual(line.length - 1);
+    return [line_without_cursor, line.indexOf('|')];
+}
+
 const MAX_GENERIC_SUGGESTIONS_FOR_TESTS = 50;
 
 describe.each([
@@ -168,23 +185,6 @@ describe('onlySuggestIfBracketOpen', () => {
 
     const emptySuggestion = [] as unknown;
 
-    /**
-     * Given a string with **exactly** one vertical bar (`|`), returns the string with the vertical bar removed
-     * and the index of the bar.
-     *
-     * This is used as a helper when writing tests, since it provides a less error prone
-     * and more readable way to represent a cursor's position, denoted by the vertical bar, and the string.
-     *
-     * @param line - A string that contains exactly one vertical bar (`|`)
-     * @returns A tuple of the line without the vertical bar, and the index of the vertical bar.
-     */
-    function cursorPosition(line: string): [lineWithoutCursor: string, cursorIndex: number] {
-        const line_without_cursor = line.replace(/\|/g, '');
-        // Check that the cursor marker appears exactly once in each input string:
-        expect(line_without_cursor.length).toEqual(line.length - 1);
-        return [line_without_cursor, line.indexOf('|')];
-    }
-
     it('should suggest if cursor at end of line with an open pair', () => {
         const settings = getSettings();
         let suggestions = buildSuggestions(...cursorPosition('(hello world|'), settings);
@@ -233,18 +233,50 @@ describe('canSuggestForLine', () => {
         GlobalFilter.reset();
     });
 
+    function canSuggestForLineWithCursor(line: string) {
+        return canSuggestForLine(...cursorPosition(line));
+    }
+
+    it('should not suggest if there is no checkbox', () => {
+        GlobalFilter.reset();
+        expect(canSuggestForLineWithCursor('- not a task line|')).toEqual(false);
+    });
+
     it('should suggest if there is no global filter', () => {
         GlobalFilter.reset();
-        expect(canSuggestForLine('- [ ] global filter is not set')).toEqual(true);
+        expect(canSuggestForLineWithCursor('- [ ] global filter is not set|')).toEqual(true);
     });
 
     it('should suggest if global filter missing from line', () => {
         GlobalFilter.set('#todo');
-        expect(canSuggestForLine('- [ ] #todo has global filter')).toEqual(true);
+        expect(canSuggestForLineWithCursor('- [ ] #todo has global filter|')).toEqual(true);
     });
 
     it('should not suggest if global filter missing from line', () => {
         GlobalFilter.set('#todo');
-        expect(canSuggestForLine('- [ ] no global filter')).toEqual(false);
+        expect(canSuggestForLineWithCursor('- [ ] no global filter|')).toEqual(false);
+    });
+
+    it('should not suggest when cursor is in empty line', () => {
+        expect(canSuggestForLineWithCursor('|')).toEqual(false);
+    });
+
+    it('should not suggest when cursor is in the checkbox', () => {
+        expect(canSuggestForLineWithCursor('- [ |] ')).toEqual(false);
+        expect(canSuggestForLineWithCursor('- [ ]| ')).toEqual(false);
+    });
+
+    it('should suggest when the cursor is at least one character past the checkbox', () => {
+        expect(canSuggestForLineWithCursor('- [ ] |')).toEqual(true);
+    });
+
+    it('should suggest correctly when task is in a numbered list', () => {
+        expect(canSuggestForLineWithCursor('1. [ ]|')).toEqual(false);
+        expect(canSuggestForLineWithCursor('1. [ ] |')).toEqual(true);
+    });
+
+    it('should suggest correctly when task is indented', () => {
+        expect(canSuggestForLineWithCursor('    - [ ]|')).toEqual(false);
+        expect(canSuggestForLineWithCursor('    - [ ] |')).toEqual(true);
     });
 });