From 8cac99407c6fecaf38ccc655dbffca9ee79d52bf Mon Sep 17 00:00:00 2001 From: Daniel Bauer Date: Thu, 12 Dec 2024 00:24:57 +0100 Subject: [PATCH] feat: better regex matching for trigger --- src/trigger.test.ts | 148 +++++++++++++++++++++++++++++++++++++++----- src/trigger.ts | 98 +++++++++++++++++++++++++---- 2 files changed, 221 insertions(+), 25 deletions(-) diff --git a/src/trigger.test.ts b/src/trigger.test.ts index 021c3e9..ca3f641 100644 --- a/src/trigger.test.ts +++ b/src/trigger.test.ts @@ -1,19 +1,139 @@ import { getTriggerText } from "./trigger" -describe("test trigger", () => { - test("testing trigger regex on braces", () => { - expect(getTriggerText("test string () testing", 13)).toEqual(["", 13, 13]) - expect(getTriggerText("test string (person) testing", 16)).toEqual(["person", 13, 19]) - expect(getTriggerText("test string (person:) testing", 16)).toEqual(["person:", 13, 20]) - expect(getTriggerText("test string (person::) testing", 16)).toEqual(["person::", 13, 21]) - expect(getTriggerText("test string (person::Bob) testing", 16)).toEqual(["person::Bob", 13, 24]) - expect(getTriggerText("test string (person:: Bob) testing", 16)).toEqual(["person:: Bob", 13, 25]) - expect(getTriggerText("test string (person:: Bob) testing", 16)).toEqual(["person:: Bob", 13, 27]) +describe("trigger", () => { + describe("empty", () => { + test("empty parantheses", () => { + expect(getTriggerText("() testing", 1)).toEqual(["", 1, 1]) + }) + test("empty square brackets", () => { + expect(getTriggerText("[] testing", 1)).toEqual(["", 1, 1]) + }) + test("empty square brackets in text", () => { + expect(getTriggerText("test [] testing", 6)).toEqual(["", 6, 6]) + }) + // test("not match empty double square brackets", () => { + // expect(getTriggerText("[[]] testing", 2)).toEqual(null) + // }) + test("multiple empty brackets in text", () => { + expect(getTriggerText("test [] and [] testing", 13)).toEqual(["", 13, 13]) + }) }) - test("test multiple regex matches in trigger", () => { - expect(getTriggerText("test() string () testing", 5)).toEqual(["", 5, 5]) - expect(getTriggerText("test() string () testing", 15)).toEqual(["", 15, 15]) - expect(getTriggerText("test() string () testing", 17)).toEqual(null) + describe("text in parantheses", () => { + test("text behind", () => { + expect(getTriggerText("(capture) testing", 1)).toEqual(["capture", 1, 8]) + }) + test("text before", () => { + expect(getTriggerText("testing (capture)", 12)).toEqual(["capture", 9, 16]) + }) + test("text behind no whitespace", () => { + expect(getTriggerText("(capture)testing", 1)).toEqual(["capture", 1, 8]) + }) + test("text before no whitespace", () => { + expect(getTriggerText("test(capture)", 5)).toEqual(["capture", 5, 12]) + }) + test("text before and behind", () => { + expect(getTriggerText("test (capture) testing", 6)).toEqual(["capture", 6, 13]) + }) + test("text and colon", () => { + expect(getTriggerText("(capture:) testing", 1)).toEqual(["capture:", 1, 9]) + }) + test("text and double colon", () => { + expect(getTriggerText("(capture::) testing", 1)).toEqual(["capture::", 1, 10]) + }) + test("metadata field", () => { + expect(getTriggerText("(capture::Bob) testing", 1)).toEqual(["capture::Bob", 1, 13]) + }) + test("metadata field with whitespace", () => { + expect(getTriggerText("(capture:: Bob) testing", 1)).toEqual(["capture:: Bob", 1, 14]) + }) + test("metadata field with more white space", () => { + expect(getTriggerText("(capture:: Bob) testing", 1)).toEqual(["capture:: Bob", 1, 16]) + }) + test("text with markdown link", () => { + expect(getTriggerText("([[test]]) testing", 1)).toEqual(["[[test]]", 1, 9]) + }) + test("text with aliased markdown link", () => { + expect(getTriggerText("([[test|display]]) testing", 1)).toEqual(["[[test|display]]", 1, 17]) + }) + test("text with wiki link", () => { + expect(getTriggerText("([[test](https://example.com)]) testing", 1)).toEqual(["[[test](https://example.com)]", 1, 30]) + }) + test("text with wiki link local", () => { + expect(getTriggerText("([[test](test)]) testing", 1)).toEqual(["[[test](test)]", 1, 15]) + }) }) -}) \ No newline at end of file + + describe("text in square brackets", () => { + test("text behind", () => { + expect(getTriggerText("[capture] testing", 1)).toEqual(["capture", 1, 8]) + }) + test("text before", () => { + expect(getTriggerText("testing [capture]", 12)).toEqual(["capture", 9, 16]) + }) + test("text behind no whitespace", () => { + expect(getTriggerText("[capture]testing", 1)).toEqual(["capture", 1, 8]) + }) + test("text before no whitespace", () => { + expect(getTriggerText("test[capture]", 5)).toEqual(["capture", 5, 12]) + }) + test("text before and behind", () => { + expect(getTriggerText("test [capture] testing", 6)).toEqual(["capture", 6, 13]) + }) + test("text and colon", () => { + expect(getTriggerText("[capture:] testing", 1)).toEqual(["capture:", 1, 9]) + }) + test("text and double colon", () => { + expect(getTriggerText("[capture::] testing", 1)).toEqual(["capture::", 1, 10]) + }) + test("metadata field", () => { + expect(getTriggerText("[capture::Bob] testing", 1)).toEqual(["capture::Bob", 1, 13]) + }) + test("metadata field with whitespace", () => { + expect(getTriggerText("[capture:: Bob] testing", 1)).toEqual(["capture:: Bob", 1, 14]) + }) + test("metadata field with more white space", () => { + expect(getTriggerText("[capture:: Bob] testing", 1)).toEqual(["capture:: Bob", 1, 16]) + }) + test("text with markdown link", () => { + expect(getTriggerText("[[[test]]] testing", 1)).toEqual(["[[test]]", 1, 9]) + }) + test("text with aliased markdown link", () => { + expect(getTriggerText("[[[test|display]]] testing", 1)).toEqual(["[[test|display]]", 1, 17]) + }) + test("text with wiki link", () => { + expect(getTriggerText("[[[test](https://example.com)]] testing", 1)).toEqual(["[[test](https://example.com)]", 1, 30]) + }) + test("text with wiki link local", () => { + expect(getTriggerText("[[[test](test)]] testing", 1)).toEqual(["[[test](test)]", 1, 15]) + }) + }) + + describe("cursor position", () => { + test("cursor in first field", () => { + expect(getTriggerText("test (test) string (test2) testing", 9)).toEqual(["test", 6, 10]) + }) + test("cursor in second field", () => { + expect(getTriggerText("test (test) string (test2) testing", 21)).toEqual(["test2", 20, 25]) + }) + test("ignore cursor out of field", () => { + expect(getTriggerText("test (test) string (test2) testing", 2)).toEqual(null) + }) + }) + + describe("ignore plain markdown links", () => { + test("cursor in link text", () => { + expect(getTriggerText("test [test](https://example.com)", 8)).toEqual(null) + }) + test("cursor in link url", () => { + expect(getTriggerText("test [test](https://example.com)", 15)).toEqual(null) + }) + test("cursor in empty link text", () => { + expect(getTriggerText("test [](https://example.com)", 6)).toEqual(null) + }) + test("cursor in empty link url", () => { + expect(getTriggerText("test [test]()", 12)).toEqual(null) + }) + }) + +}) diff --git a/src/trigger.ts b/src/trigger.ts index 150f1d0..a0bacb7 100644 --- a/src/trigger.ts +++ b/src/trigger.ts @@ -1,17 +1,93 @@ -const triggerRegex = /\((.*?)\)/g +/** + * Matches single square bracket pair [] or parahtheses (), but not a double square bracket pair [[]] + * (? match.index && cursorPos <= match.index + match[1].length + 1 - - if (cursorInMatch) { - return [match[1], match.index+1, match.index + match[1].length + 1] - } + // Check for empty [] or (). + // If the user starts typing with no text in the field, this is the fastest way to find it. + let match = getEmptyTrigger(line, cursorPos) + if (match) { + return match + } + // If the user types inside an existing field, this is the function to find it. + match = getTriggerTextFromRegex(line, cursorPos, filledRegex) + if (match !== null) { + return match } return null +} -} \ No newline at end of file +/** + * Matches an empty [] or empty (), but not [[]] + * Has its own function since there is no capture group resulting in a different index calculation + */ +function getEmptyTrigger(line: string, cursorPos: number): [string, number, number] | null { + let matches = Array.from(line.matchAll(emptyRegex)) + for (const match of matches) { + if (match.index === undefined) { + continue + } + + if (cursorPos === match.index+1) { + return ["", match.index+1, match.index+1] + } + } + return null +} + +/** + * Given a regex with a capture group, a line of text, and the users cursor position, + * this function finds the match of the regex in the line that the user is currently inside. + * If the user is inside the capture group, returns [text, start, end] where text is the text inside the match, + * start is the position of the start of the match, and end is the position of the end of the match. + * Otherwise, returns null. + */ +function getTriggerTextFromRegex(line: string, cursorPos: number, regex: RegExp): [string, number, number] | null { + let matches = Array.from(line.matchAll(regex)) + for (const match of matches) { + if (match.index === undefined) { + continue + } + + const matchStart = match!.index + const matchEnd = matchStart + match[1].length + const cursorInMatch = cursorPos >= matchStart && cursorPos <= matchEnd + + if (cursorInMatch) { + return [match[1], matchStart, matchEnd] + } + } + return null +}