Add helper functions for suggestion box

This adds some helper functions that will be used for a suggestion box
in the future. There is one helper function for highlighting part of a
text case-insensitively. Another helper function will try to find
followup options based on a list of tokens.

The tests for these functions use access paths as input, but these
functions are not intended to be specific to access paths. They can be
used for any list of options/string.
This commit is contained in:
Koen Vlaswinkel
2024-01-11 12:09:10 +01:00
parent 77f84c6ca9
commit 7f730d24b0
4 changed files with 303 additions and 0 deletions

View File

@@ -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);
},
);
});

View File

@@ -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);
});
});

View File

@@ -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 !== "");
}

View File

@@ -0,0 +1,44 @@
type Option<T extends Option<T>> = {
label: string;
followup?: T[];
};
function findNestedMatchingOptions<T extends Option<T>>(
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<T extends Option<T>>(
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()),
);
}