Skip to content

Commit 05c1b9e

Browse files
authored
perf(quote search): optimize highlighting search matches (@nadalaba) (monkeytypegame#6944)
- use a simpler and 6x faster `highlightMatches` method. - add tests.
1 parent a33b464 commit 05c1b9e

File tree

3 files changed

+173
-18
lines changed

3 files changed

+173
-18
lines changed

frontend/__tests__/utils/strings.spec.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,156 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import * as Strings from "../../src/ts/utils/strings";
33

44
describe("string utils", () => {
5+
describe("highlightMatches", () => {
6+
const shouldHighlight = [
7+
{
8+
description: "word at the beginning",
9+
text: "Start here.",
10+
matches: ["Start"],
11+
expected: '<span class="highlight">Start</span> here.',
12+
},
13+
{
14+
description: "word at the end",
15+
text: "reach the end",
16+
matches: ["end"],
17+
expected: 'reach the <span class="highlight">end</span>',
18+
},
19+
{
20+
description: "mutliple matches",
21+
text: "one two three",
22+
matches: ["one", "three"],
23+
expected:
24+
'<span class="highlight">one</span> two <span class="highlight">three</span>',
25+
},
26+
{
27+
description: "repeated matches",
28+
text: "one two two",
29+
matches: ["two"],
30+
expected:
31+
'one <span class="highlight">two</span> <span class="highlight">two</span>',
32+
},
33+
{
34+
description: "longest possible match",
35+
text: "abc ab",
36+
matches: ["ab", "abc"],
37+
expected:
38+
'<span class="highlight">abc</span> <span class="highlight">ab</span>',
39+
},
40+
{
41+
description: "if wrapped in parenthesis",
42+
text: "(test)",
43+
matches: ["test"],
44+
expected: '(<span class="highlight">test</span>)',
45+
},
46+
{
47+
description: "if wrapped in commas",
48+
text: ",test,",
49+
matches: ["test"],
50+
expected: ',<span class="highlight">test</span>,',
51+
},
52+
{
53+
description: "if wrapped in underscores",
54+
text: "_test_",
55+
matches: ["test"],
56+
expected: '_<span class="highlight">test</span>_',
57+
},
58+
{
59+
description: "words in russian",
60+
text: "Привет, мир!",
61+
matches: ["Привет", "мир"],
62+
expected:
63+
'<span class="highlight">Привет</span>, <span class="highlight">мир</span>!',
64+
},
65+
{
66+
description: "words with chinese punctuation",
67+
text: "你好,世界!",
68+
matches: ["你好", "世界"],
69+
expected:
70+
'<span class="highlight">你好</span>,<span class="highlight">世界</span>!',
71+
},
72+
{
73+
description: "words with arabic punctuation",
74+
text: "؟مرحبا، بكم؛",
75+
matches: ["مرحبا", "بكم"],
76+
expected:
77+
'؟<span class="highlight">مرحبا</span>، <span class="highlight">بكم</span>؛',
78+
},
79+
{
80+
description: "standalone numbers",
81+
text: "My number is 1234.",
82+
matches: ["1234"],
83+
expected: 'My number is <span class="highlight">1234</span>.',
84+
},
85+
];
86+
const shouldNotHighlight = [
87+
{
88+
description: "a match within a longer word",
89+
text: "together",
90+
matches: ["get"],
91+
},
92+
{
93+
description: "a match with leading letters",
94+
text: "welcome",
95+
matches: ["come"],
96+
},
97+
{
98+
description: "a match with trailing letters",
99+
text: "comets",
100+
matches: ["come"],
101+
},
102+
{
103+
description: "japanese matches within longer words",
104+
text: "こんにちは世界",
105+
matches: ["こんにちは"],
106+
},
107+
{
108+
description: "numbers within words",
109+
text: "abc1234def",
110+
matches: ["1234"],
111+
},
112+
];
113+
const returnOriginal = [
114+
{
115+
description: "if matches is an empty array",
116+
text: "Nothing to match.",
117+
matches: [],
118+
},
119+
{
120+
description: "if matches has an empty string only",
121+
text: "Nothing to match.",
122+
matches: [""],
123+
},
124+
{
125+
description: "if no matches found in text",
126+
text: "Hello world.",
127+
matches: ["absent"],
128+
},
129+
{
130+
description: "if text is empty",
131+
text: "",
132+
matches: ["anything"],
133+
},
134+
];
135+
it.each(shouldHighlight)(
136+
"should highlight $description",
137+
({ text, matches, expected }) => {
138+
expect(Strings.highlightMatches(text, matches)).toBe(expected);
139+
}
140+
);
141+
it.each(shouldNotHighlight)(
142+
"should not highlight $description",
143+
({ text, matches }) => {
144+
expect(Strings.highlightMatches(text, matches)).toBe(text);
145+
}
146+
);
147+
it.each(returnOriginal)(
148+
"should return original text $description",
149+
({ text, matches }) => {
150+
expect(Strings.highlightMatches(text, matches)).toBe(text);
151+
}
152+
);
153+
});
154+
5155
describe("splitIntoCharacters", () => {
6156
it("splits regular characters", () => {
7157
expect(Strings.splitIntoCharacters("abc")).toEqual(["a", "b", "c"]);

frontend/src/ts/modals/quote-search.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
SearchService,
1111
TextExtractor,
1212
} from "../utils/search-service";
13-
import { splitByAndKeep } from "../utils/strings";
1413
import QuotesController, { Quote } from "../controllers/quotes-controller";
1514
import { isAuthenticated } from "../firebase";
1615
import { debounce } from "throttle-debounce";
@@ -21,6 +20,7 @@ import * as TestState from "../test/test-state";
2120
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
2221
import * as TestLogic from "../test/test-logic";
2322
import { createErrorMessage } from "../utils/misc";
23+
import { highlightMatches } from "../utils/strings";
2424

2525
const searchServiceCache: Record<string, SearchService<Quote>> = {};
2626

@@ -43,23 +43,6 @@ function getSearchService<T>(
4343
return newSearchService;
4444
}
4545

46-
function highlightMatches(text: string, matchedText: string[]): string {
47-
if (matchedText.length === 0) {
48-
return text;
49-
}
50-
const words = splitByAndKeep(text, `.,"/#!$%^&*;:{}=-_\`~() `.split(""));
51-
52-
const normalizedWords = words.map((word) => {
53-
const shouldHighlight =
54-
matchedText.find((match) => {
55-
return word.startsWith(match);
56-
}) !== undefined;
57-
return shouldHighlight ? `<span class="highlight">${word}</span>` : word;
58-
});
59-
60-
return normalizedWords.join("");
61-
}
62-
6346
function applyQuoteLengthFilter(quotes: Quote[]): Quote[] {
6447
if (!modal.isOpen()) return [];
6548
const quoteLengthFilterValue = $(

frontend/src/ts/utils/strings.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,28 @@ export function splitByAndKeep(text: string, delimiters: string[]): string[] {
9696
return splitString;
9797
}
9898

99+
/**
100+
* Highlights all occurrences of specified words within a given text.
101+
* Each match is wrapped in a <span class="highlight"> element.
102+
* Matches are ignored if they appear as part of a larger word
103+
* not included in the matches array.
104+
* @param text The full text in which to highlight words.
105+
* @param matches An array of words to highlight.
106+
* @return The full text with all matching words highlighted.
107+
*/
108+
export function highlightMatches(text: string, matches: string[]): string {
109+
matches = matches.filter((match) => match !== "");
110+
if (matches.length === 0) return text;
111+
112+
// matches that don't have a letter before or after them
113+
const pattern = new RegExp(
114+
`(?<!\\p{L})(?:${matches.join("|")})(?!\\p{L})`,
115+
"gu"
116+
);
117+
118+
return text.replace(pattern, '<span class="highlight">$&</span>');
119+
}
120+
99121
/**
100122
* Returns a display string for the given language, optionally removing the size indicator.
101123
* @param language The language string.

0 commit comments

Comments
 (0)