Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2204 (2023-08-13 01:46:08)

fix: Only show dataview suggestions when there is an open bracket preceding the cursor position.
<!--- Provide a general summary of your changes in the Title above -->

# Description

This PR updates the behavior of the Suggestor for the Dataview Task Format to only present suggestions when there is an open bracket pair (Either `[`, or `(`)

## Motivation and Context

The default suggestion strategy used by Tasks is to always present a list of suggestions that a user can use to autocomplete task fields as they are typing on a task line. This is _desirable_ for the Tasks Emoji Format, since it is often difficult/slow to manually produce the Emoji required to indicate the start of a task field.

However, this has little benefit for the Dataview Task Format; Dataview task fields can only appear within brackets `[]` or parens `()`. 

## How has this been tested?

This PR was implemented with a higher-order function that decorates a suggestion function (see function `onlySuggestIfBracketOpen`). Unit tests were added for the `onlySuggestIfBracketOpen` function.

## 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)
- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [ ] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## 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/Getting Started/Auto-Suggest.md b/docs/Getting Started/Auto-Suggest.md
index 6ef28522..20992dff 100644
--- a/docs/Getting Started/Auto-Suggest.md
+++ b/docs/Getting Started/Auto-Suggest.md
@@ -39,6 +39,9 @@ Here is a more detailed walk through of the creation of a new task, which can be
          - `+ [ ]`
      - and the global filter (if any)
 
+     Tasks also tries to display the auto-suggest menu based on context. For example, suggestions will only appear
+      within square brackets `[]` or parentheses `()` when using the [[Dataview Format#Bracketed inline fields|Dataview Task Format]].
+
 2. You can keep typing (to ignore the suggestions), or select one of the menu items in a variety of ways:
 
     - mouse-click on menu item
diff --git a/docs/Reference/Task Formats/Dataview Format.md b/docs/Reference/Task Formats/Dataview Format.md
index 97993bda..3d2f2a5e 100644
--- a/docs/Reference/Task Formats/Dataview Format.md
+++ b/docs/Reference/Task Formats/Dataview Format.md
@@ -126,6 +126,12 @@ For more information, see [[Priority]].
 
 For more information, see [[Recurring Tasks]].
 
+## Auto-Suggest
+
+The Dataview format fully supports Tasks' [[Auto-Suggest]] feature, but requires users to manually type out surrounding brackets (`[]` or `()`).  This works best with `Settings > Editor > Autopair Brackets` enabled.
+
+Since Tasks X.Y.Z, the Auto-Suggest menu _only_ appears between square brackets `[]` or parentheses `()`.
+
 ## Limitations of Dataview Format
 
 Essential reading:
diff --git a/src/Config/Settings.ts b/src/Config/Settings.ts
index bc71f2b2..c92fed1e 100644
--- a/src/Config/Settings.ts
+++ b/src/Config/Settings.ts
@@ -1,4 +1,8 @@
-import { DEFAULT_MAX_GENERIC_SUGGESTIONS, makeDefaultSuggestionBuilder } from '../Suggestor/Suggestor';
+import {
+    DEFAULT_MAX_GENERIC_SUGGESTIONS,
+    makeDefaultSuggestionBuilder,
+    onlySuggestIfBracketOpen,
+} from '../Suggestor/Suggestor';
 import { DEFAULT_SYMBOLS } from '../TaskSerializer/DefaultTaskSerializer';
 import { DATAVIEW_SYMBOLS } from '../TaskSerializer/DataviewTaskSerializer';
 import { StatusConfiguration } from '../StatusConfiguration';
@@ -43,7 +47,13 @@ export const TASK_FORMATS = {
     dataview: {
         displayName: 'Dataview',
         taskSerializer: new DataviewTaskSerializer(),
-        buildSuggestions: makeDefaultSuggestionBuilder(DATAVIEW_SYMBOLS, DEFAULT_MAX_GENERIC_SUGGESTIONS),
+        buildSuggestions: onlySuggestIfBracketOpen(
+            makeDefaultSuggestionBuilder(DATAVIEW_SYMBOLS, DEFAULT_MAX_GENERIC_SUGGESTIONS),
+            [
+                ['(', ')'],
+                ['[', ']'],
+            ],
+        ),
     },
 } as const;
 
diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts
index 3d26d2df..a608f6ec 100644
--- a/src/Suggestor/Suggestor.ts
+++ b/src/Suggestor/Suggestor.ts
@@ -345,3 +345,58 @@ export function matchByPosition(s: string, r: RegExp, position: number): RegExpM
         if (match?.index && match.index <= position && position <= match.index + match[0].length) return match;
     }
 }
+
+/**
+ * Checks whether _any_ of the bracket pairs in {@link brackets} is open at the end of the string {@link line}
+ *
+ * @example
+ *     isAnyBracketOpen("(hello world",   [['(', ')']]);             // true
+ *     isAnyBracketOpen("[hello world",   [['[', ']']]);             // true
+ *     isAnyBracketOpen("[hello world",   [['(', ')'], ['[', ']']]); // true
+ *     isAnyBracketOpen("([hello world)", [['(', ')'], ['[', ']']])  // true
+ *     isAnyBracketOpen("))))(",          [['(', ')']])              // true
+ *     isAnyBracketOpen("(hello world)",  [['(', ')']]);             // false
+ *     isAnyBracketOpen("(hello world)",  []);                       // false
+ *
+ * @param line - The line of text to scan
+ * @param brackets - A list of tuples that defines bracket pairs.
+ */
+function isAnyBracketOpen(line: string, brackets: [opening_bracket: string, closing_bracket: string][]): boolean {
+    if (brackets.length === 0) {
+        return false;
+    }
+
+    // Maps an opening bracket to the number of open brackets of that type
+    const numOpeningBrackets = Object.fromEntries(brackets.map(([open, _]) => [open, 0]));
+    // Maps a closing bracket to an opening bracket
+    const openingOf = Object.fromEntries(brackets.map(([open, close]) => [close, open]));
+
+    for (const c of line) {
+        if (c in numOpeningBrackets) {
+            numOpeningBrackets[c]++;
+        } else if (c in openingOf) {
+            numOpeningBrackets[openingOf[c]] = Math.max(0, numOpeningBrackets[openingOf[c]] - 1);
+        }
+    }
+
+    return Object.values(numOpeningBrackets).some((n) => n > 0);
+}
+
+/**
+ * Given a SuggestionBuilder {@link fn}, returns a new SuggestionBuilder with identical behavior to {@link fn} except
+ *     it only return suggestions if there is an open bracket at the given cursor position.
+ *
+ * @param fn - A suggestion builder to wrap
+ * @param brackets - A list of tuples that defines bracket pairs.
+ * @returns A {@link SuggestionBuilder} that returns:
+ *   * `[]` if there is no open brackets at the the given
+ *   * {@link fn}`(line, cursorPos, settings)` otherwise
+ */
+export function onlySuggestIfBracketOpen(fn: SuggestionBuilder, brackets: [string, string][]): SuggestionBuilder {
+    return (line, cursorPos, settings): SuggestInfo[] => {
+        if (!isAnyBracketOpen(line.slice(0, cursorPos), brackets)) {
+            return [];
+        }
+        return fn(line, cursorPos, settings);
+    };
+}


Test patch:
diff --git a/tests/Suggestor/Suggestor.test.ts b/tests/Suggestor/Suggestor.test.ts
index a23dd714..477ae203 100644
--- a/tests/Suggestor/Suggestor.test.ts
+++ b/tests/Suggestor/Suggestor.test.ts
@@ -4,8 +4,8 @@
 import moment from 'moment';
 import * as chrono from 'chrono-node';
 import { getSettings } from '../../src/Config/Settings';
-import type { SuggestInfo } from '../../src/Suggestor';
-import { makeDefaultSuggestionBuilder } from '../../src/Suggestor/Suggestor';
+import type { SuggestInfo, SuggestionBuilder } from '../../src/Suggestor';
+import { makeDefaultSuggestionBuilder, onlySuggestIfBracketOpen } from '../../src/Suggestor/Suggestor';
 import { DEFAULT_SYMBOLS } from '../../src/TaskSerializer/DefaultTaskSerializer';
 import { DATAVIEW_SYMBOLS } from '../../src/TaskSerializer/DataviewTaskSerializer';
 import { MarkdownTable } from '../TestingTools/VerifyMarkdownTable';
@@ -150,3 +150,75 @@ describe.each([
         markdownTable.verify();
     });
 });
+
+describe('onlySuggestIfBracketOpen', () => {
+    const buildSuggestions: SuggestionBuilder = onlySuggestIfBracketOpen(
+        // A dummy SuggestionBuilder. Just used to validate that results are returned, results aren't meaningful
+        () => [{} as unknown as SuggestInfo],
+        [
+            ['(', ')'],
+            ['[', ']'],
+        ],
+    );
+
+    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('|', '');
+        // 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);
+        expect(suggestions).not.toEqual(emptySuggestion);
+
+        suggestions = buildSuggestions(...cursorPosition('[hello world|'), settings);
+        expect(suggestions).not.toEqual(emptySuggestion);
+    });
+
+    it('should suggest if cursor at end of line with an nested open pairs', () => {
+        const settings = getSettings();
+        let suggestions = buildSuggestions(...cursorPosition('(((hello world))|'), settings);
+        expect(suggestions).not.toEqual(emptySuggestion);
+
+        suggestions = buildSuggestions(...cursorPosition('[[[hello world]]|'), settings);
+        expect(suggestions).not.toEqual(emptySuggestion);
+    });
+
+    it('should suggest if cursor in middle of closed pair', () => {
+        const settings = getSettings();
+        let suggestions = buildSuggestions(...cursorPosition('(hello world|)'), settings);
+        expect(suggestions).not.toEqual(emptySuggestion);
+
+        suggestions = buildSuggestions(...cursorPosition('[hello world|]'), settings);
+        expect(suggestions).not.toEqual(emptySuggestion);
+    });
+
+    it('should suggest if there is an opening bracket after many closing brackets', () => {
+        const suggestions = buildSuggestions(...cursorPosition(']]]]]]](hello|'), getSettings());
+        expect(suggestions).not.toEqual(emptySuggestion);
+    });
+
+    it('should not suggest on an empty line', () => {
+        const suggestions = buildSuggestions(...cursorPosition('|'), getSettings());
+        expect(suggestions).toEqual(emptySuggestion);
+    });
+
+    it("should not suggest if there's no open bracket at cursor position", () => {
+        const suggestions = buildSuggestions(...cursorPosition('(hello world)|'), getSettings());
+        expect(suggestions).toEqual(emptySuggestion);
+    });
+});