diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/highlight.test.ts b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/highlight.test.ts new file mode 100644 index 00000000000..283dea4167c --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/highlight.test.ts @@ -0,0 +1,72 @@ +import { createHighlights } from "../highlight"; + +describe("createHighlights", () => { + it.each([ + { + text: "Argument[foo].Element.Field[@test]", + search: "Argument[foo]", + snippets: [ + { text: "Argument[foo]", highlight: true }, + { + text: ".Element.Field[@test]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "test", + snippets: [ + { text: "Field[@", highlight: false }, + { + text: "test", + highlight: true, + }, + { + text: "]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "TEST", + snippets: [ + { text: "Field[@", highlight: false }, + { + text: "test", + highlight: true, + }, + { + text: "]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "[@TEST", + snippets: [ + { text: "Field", highlight: false }, + { + text: "[@test", + highlight: true, + }, + { + text: "]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "", + snippets: [{ text: "Field[@test]", highlight: false }], + }, + ])( + `creates highlights for $text with $search`, + ({ text, search, snippets }) => { + expect(createHighlights(text, search)).toEqual(snippets); + }, + ); +}); diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/options.test.ts b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/options.test.ts new file mode 100644 index 00000000000..e04bd29c982 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/options.test.ts @@ -0,0 +1,138 @@ +import { findMatchingOptions } from "../options"; + +type TestOption = { + label: string; + value: string; + followup?: TestOption[]; +}; + +const suggestedOptions: TestOption[] = [ + { + label: "Argument[self]", + value: "Argument[self]", + }, + { + label: "Argument[0]", + value: "Argument[0]", + followup: [ + { + label: "Element[0]", + value: "Argument[0].Element[0]", + }, + { + label: "Element[1]", + value: "Argument[0].Element[1]", + }, + ], + }, + { + label: "Argument[1]", + value: "Argument[1]", + }, + { + label: "Argument[text_rep:]", + value: "Argument[text_rep:]", + }, + { + label: "Argument[block]", + value: "Argument[block]", + followup: [ + { + label: "Parameter[0]", + value: "Argument[block].Parameter[0]", + followup: [ + { + label: "Element[:query]", + value: "Argument[block].Parameter[0].Element[:query]", + }, + { + label: "Element[:parameters]", + value: "Argument[block].Parameter[0].Element[:parameters]", + }, + ], + }, + { + label: "Parameter[1]", + value: "Argument[block].Parameter[1]", + followup: [ + { + label: "Field[@query]", + value: "Argument[block].Parameter[1].Field[@query]", + }, + ], + }, + ], + }, + { + label: "ReturnValue", + value: "ReturnValue", + }, +]; + +describe("findMatchingOptions", () => { + it.each([ + { + // Argument[block]. + tokens: ["Argument[block]", ""], + options: ["Argument[block].Parameter[0]", "Argument[block].Parameter[1]"], + }, + { + // Argument[block].Parameter[0] + tokens: ["Argument[block]", "Parameter[0]"], + options: ["Argument[block].Parameter[0]"], + }, + { + // Argument[block].Parameter[0]. + tokens: ["Argument[block]", "Parameter[0]", ""], + options: [ + "Argument[block].Parameter[0].Element[:query]", + "Argument[block].Parameter[0].Element[:parameters]", + ], + }, + { + // "" + tokens: [""], + options: [ + "Argument[self]", + "Argument[0]", + "Argument[1]", + "Argument[text_rep:]", + "Argument[block]", + "ReturnValue", + ], + }, + { + // "" + tokens: [], + options: [ + "Argument[self]", + "Argument[0]", + "Argument[1]", + "Argument[text_rep:]", + "Argument[block]", + "ReturnValue", + ], + }, + { + // block + tokens: ["block"], + options: ["Argument[block]"], + }, + { + // l + tokens: ["l"], + options: ["Argument[self]", "Argument[block]", "ReturnValue"], + }, + { + // L + tokens: ["L"], + options: ["Argument[self]", "Argument[block]", "ReturnValue"], + }, + ])(`creates options for $value`, ({ tokens, options }) => { + expect( + findMatchingOptions(suggestedOptions, tokens).map( + (option) => option.value, + ), + ).toEqual(options); + }); +}); diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/highlight.ts b/extensions/ql-vscode/src/view/common/SuggestBox/highlight.ts new file mode 100644 index 00000000000..2167405065e --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/highlight.ts @@ -0,0 +1,49 @@ +type Snippet = { + text: string; + highlight: boolean; +}; + +/** + * Highlight creates a list of snippets that can be used to render a highlighted + * string. This highlight is case-insensitive. + * + * @param text The text in which to create highlights + * @param search The string that will be highlighted in the text. + * @returns A list of snippets that can be used to render a highlighted string. + */ +export function createHighlights(text: string, search: string): Snippet[] { + if (search === "") { + return [{ text, highlight: false }]; + } + + const searchLower = search.toLowerCase(); + const textLower = text.toLowerCase(); + + const highlights: Snippet[] = []; + + let index = 0; + for (;;) { + const searchIndex = textLower.indexOf(searchLower, index); + if (searchIndex === -1) { + break; + } + + highlights.push({ + text: text.substring(index, searchIndex), + highlight: false, + }); + highlights.push({ + text: text.substring(searchIndex, searchIndex + search.length), + highlight: true, + }); + + index = searchIndex + search.length; + } + + highlights.push({ + text: text.substring(index), + highlight: false, + }); + + return highlights.filter((highlight) => highlight.text !== ""); +} diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/options.ts b/extensions/ql-vscode/src/view/common/SuggestBox/options.ts new file mode 100644 index 00000000000..2f15b45cfe7 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/options.ts @@ -0,0 +1,44 @@ +type Option> = { + label: string; + followup?: T[]; +}; + +function findNestedMatchingOptions>( + parts: string[], + options: T[], +): T[] { + const part = parts[0]; + const rest = parts.slice(1); + + if (!part) { + return options; + } + + const matchingOption = options.find((item) => item.label === part); + if (!matchingOption) { + return []; + } + + if (rest.length === 0) { + return matchingOption.followup ?? []; + } + + return findNestedMatchingOptions(rest, matchingOption.followup ?? []); +} + +export function findMatchingOptions>( + options: T[], + tokens: string[], +): T[] { + if (tokens.length === 0) { + return options; + } + const prefixTokens = tokens.slice(0, tokens.length - 1); + const lastToken = tokens[tokens.length - 1]; + + const matchingOptions = findNestedMatchingOptions(prefixTokens, options); + + return matchingOptions.filter((item) => + item.label.toLowerCase().includes(lastToken.toLowerCase()), + ); +}