Skip to content

Commit 7f730d2

Browse files
committed
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.
1 parent 77f84c6 commit 7f730d2

File tree

4 files changed

+303
-0
lines changed

4 files changed

+303
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { createHighlights } from "../highlight";
2+
3+
describe("createHighlights", () => {
4+
it.each([
5+
{
6+
text: "Argument[foo].Element.Field[@test]",
7+
search: "Argument[foo]",
8+
snippets: [
9+
{ text: "Argument[foo]", highlight: true },
10+
{
11+
text: ".Element.Field[@test]",
12+
highlight: false,
13+
},
14+
],
15+
},
16+
{
17+
text: "Field[@test]",
18+
search: "test",
19+
snippets: [
20+
{ text: "Field[@", highlight: false },
21+
{
22+
text: "test",
23+
highlight: true,
24+
},
25+
{
26+
text: "]",
27+
highlight: false,
28+
},
29+
],
30+
},
31+
{
32+
text: "Field[@test]",
33+
search: "TEST",
34+
snippets: [
35+
{ text: "Field[@", highlight: false },
36+
{
37+
text: "test",
38+
highlight: true,
39+
},
40+
{
41+
text: "]",
42+
highlight: false,
43+
},
44+
],
45+
},
46+
{
47+
text: "Field[@test]",
48+
search: "[@TEST",
49+
snippets: [
50+
{ text: "Field", highlight: false },
51+
{
52+
text: "[@test",
53+
highlight: true,
54+
},
55+
{
56+
text: "]",
57+
highlight: false,
58+
},
59+
],
60+
},
61+
{
62+
text: "Field[@test]",
63+
search: "",
64+
snippets: [{ text: "Field[@test]", highlight: false }],
65+
},
66+
])(
67+
`creates highlights for $text with $search`,
68+
({ text, search, snippets }) => {
69+
expect(createHighlights(text, search)).toEqual(snippets);
70+
},
71+
);
72+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { findMatchingOptions } from "../options";
2+
3+
type TestOption = {
4+
label: string;
5+
value: string;
6+
followup?: TestOption[];
7+
};
8+
9+
const suggestedOptions: TestOption[] = [
10+
{
11+
label: "Argument[self]",
12+
value: "Argument[self]",
13+
},
14+
{
15+
label: "Argument[0]",
16+
value: "Argument[0]",
17+
followup: [
18+
{
19+
label: "Element[0]",
20+
value: "Argument[0].Element[0]",
21+
},
22+
{
23+
label: "Element[1]",
24+
value: "Argument[0].Element[1]",
25+
},
26+
],
27+
},
28+
{
29+
label: "Argument[1]",
30+
value: "Argument[1]",
31+
},
32+
{
33+
label: "Argument[text_rep:]",
34+
value: "Argument[text_rep:]",
35+
},
36+
{
37+
label: "Argument[block]",
38+
value: "Argument[block]",
39+
followup: [
40+
{
41+
label: "Parameter[0]",
42+
value: "Argument[block].Parameter[0]",
43+
followup: [
44+
{
45+
label: "Element[:query]",
46+
value: "Argument[block].Parameter[0].Element[:query]",
47+
},
48+
{
49+
label: "Element[:parameters]",
50+
value: "Argument[block].Parameter[0].Element[:parameters]",
51+
},
52+
],
53+
},
54+
{
55+
label: "Parameter[1]",
56+
value: "Argument[block].Parameter[1]",
57+
followup: [
58+
{
59+
label: "Field[@query]",
60+
value: "Argument[block].Parameter[1].Field[@query]",
61+
},
62+
],
63+
},
64+
],
65+
},
66+
{
67+
label: "ReturnValue",
68+
value: "ReturnValue",
69+
},
70+
];
71+
72+
describe("findMatchingOptions", () => {
73+
it.each([
74+
{
75+
// Argument[block].
76+
tokens: ["Argument[block]", ""],
77+
options: ["Argument[block].Parameter[0]", "Argument[block].Parameter[1]"],
78+
},
79+
{
80+
// Argument[block].Parameter[0]
81+
tokens: ["Argument[block]", "Parameter[0]"],
82+
options: ["Argument[block].Parameter[0]"],
83+
},
84+
{
85+
// Argument[block].Parameter[0].
86+
tokens: ["Argument[block]", "Parameter[0]", ""],
87+
options: [
88+
"Argument[block].Parameter[0].Element[:query]",
89+
"Argument[block].Parameter[0].Element[:parameters]",
90+
],
91+
},
92+
{
93+
// ""
94+
tokens: [""],
95+
options: [
96+
"Argument[self]",
97+
"Argument[0]",
98+
"Argument[1]",
99+
"Argument[text_rep:]",
100+
"Argument[block]",
101+
"ReturnValue",
102+
],
103+
},
104+
{
105+
// ""
106+
tokens: [],
107+
options: [
108+
"Argument[self]",
109+
"Argument[0]",
110+
"Argument[1]",
111+
"Argument[text_rep:]",
112+
"Argument[block]",
113+
"ReturnValue",
114+
],
115+
},
116+
{
117+
// block
118+
tokens: ["block"],
119+
options: ["Argument[block]"],
120+
},
121+
{
122+
// l
123+
tokens: ["l"],
124+
options: ["Argument[self]", "Argument[block]", "ReturnValue"],
125+
},
126+
{
127+
// L
128+
tokens: ["L"],
129+
options: ["Argument[self]", "Argument[block]", "ReturnValue"],
130+
},
131+
])(`creates options for $value`, ({ tokens, options }) => {
132+
expect(
133+
findMatchingOptions(suggestedOptions, tokens).map(
134+
(option) => option.value,
135+
),
136+
).toEqual(options);
137+
});
138+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
type Snippet = {
2+
text: string;
3+
highlight: boolean;
4+
};
5+
6+
/**
7+
* Highlight creates a list of snippets that can be used to render a highlighted
8+
* string. This highlight is case-insensitive.
9+
*
10+
* @param text The text in which to create highlights
11+
* @param search The string that will be highlighted in the text.
12+
* @returns A list of snippets that can be used to render a highlighted string.
13+
*/
14+
export function createHighlights(text: string, search: string): Snippet[] {
15+
if (search === "") {
16+
return [{ text, highlight: false }];
17+
}
18+
19+
const searchLower = search.toLowerCase();
20+
const textLower = text.toLowerCase();
21+
22+
const highlights: Snippet[] = [];
23+
24+
let index = 0;
25+
for (;;) {
26+
const searchIndex = textLower.indexOf(searchLower, index);
27+
if (searchIndex === -1) {
28+
break;
29+
}
30+
31+
highlights.push({
32+
text: text.substring(index, searchIndex),
33+
highlight: false,
34+
});
35+
highlights.push({
36+
text: text.substring(searchIndex, searchIndex + search.length),
37+
highlight: true,
38+
});
39+
40+
index = searchIndex + search.length;
41+
}
42+
43+
highlights.push({
44+
text: text.substring(index),
45+
highlight: false,
46+
});
47+
48+
return highlights.filter((highlight) => highlight.text !== "");
49+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
type Option<T extends Option<T>> = {
2+
label: string;
3+
followup?: T[];
4+
};
5+
6+
function findNestedMatchingOptions<T extends Option<T>>(
7+
parts: string[],
8+
options: T[],
9+
): T[] {
10+
const part = parts[0];
11+
const rest = parts.slice(1);
12+
13+
if (!part) {
14+
return options;
15+
}
16+
17+
const matchingOption = options.find((item) => item.label === part);
18+
if (!matchingOption) {
19+
return [];
20+
}
21+
22+
if (rest.length === 0) {
23+
return matchingOption.followup ?? [];
24+
}
25+
26+
return findNestedMatchingOptions(rest, matchingOption.followup ?? []);
27+
}
28+
29+
export function findMatchingOptions<T extends Option<T>>(
30+
options: T[],
31+
tokens: string[],
32+
): T[] {
33+
if (tokens.length === 0) {
34+
return options;
35+
}
36+
const prefixTokens = tokens.slice(0, tokens.length - 1);
37+
const lastToken = tokens[tokens.length - 1];
38+
39+
const matchingOptions = findNestedMatchingOptions(prefixTokens, options);
40+
41+
return matchingOptions.filter((item) =>
42+
item.label.toLowerCase().includes(lastToken.toLowerCase()),
43+
);
44+
}

0 commit comments

Comments
 (0)