Skip to content

Commit 18faa12

Browse files
committed
Fix search for standalone symbolic entry names
1 parent fde566c commit 18faa12

2 files changed

Lines changed: 102 additions & 2 deletions

File tree

‎assets/javascripts/app/searcher.js‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export class Searcher extends Events {
183183
static EVENT_REGEXP = /\ event$/;
184184
static DOT_REGEXP = /\.+/g;
185185
static WHITESPACE_REGEXP = /\s/g;
186+
static SYMBOL_ONLY_REGEXP = /^[^\w\s.]+$/;
186187

187188
static EMPTY_STRING = "";
188189
static ELLIPSIS = "...";
@@ -196,15 +197,29 @@ export class Searcher extends Events {
196197
* @returns {string}
197198
*/
198199
static normalizeString(string) {
199-
return string
200+
string = string
200201
.toLowerCase()
201202
.replace(Searcher.ELLIPSIS, Searcher.EMPTY_STRING)
202203
.replace(Searcher.EVENT_REGEXP, Searcher.EMPTY_STRING)
203-
.replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
204+
.replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING);
205+
206+
const normalized = string
204207
.replace(Searcher.SEPARATORS_REGEXP, SEPARATOR)
205208
.replace(Searcher.DOT_REGEXP, SEPARATOR)
206209
.replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
207210
.replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING);
211+
212+
// A separator can also be an entire symbolic name, e.g. Clojure's ->.
213+
// Keep it searchable without changing separators in compound names.
214+
if (normalized === SEPARATOR) {
215+
const symbol = string
216+
.replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
217+
.trim();
218+
if (Searcher.SYMBOL_ONLY_REGEXP.test(symbol)) {
219+
return symbol;
220+
}
221+
}
222+
return normalized;
208223
}
209224

210225
/**

‎test/assets/searcher_test.js‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @ts-check
2+
3+
import assert from "node:assert/strict";
4+
import test from "node:test";
5+
6+
import {
7+
Searcher,
8+
SynchronousSearcher,
9+
} from "../../assets/javascripts/app/searcher.js";
10+
import { Entry } from "../../assets/javascripts/models/entry.js";
11+
12+
/**
13+
* @param {string} query
14+
* @param {string[]} names
15+
*/
16+
const search = (query, names) => {
17+
const entries = names.map((name) => new Entry({ name }));
18+
const searcher = new SynchronousSearcher();
19+
/** @type {Entry[]} */
20+
let results = [];
21+
searcher.on("results", (found) => (results = /** @type {Entry[]} */ (found)));
22+
searcher.find(entries, "text", query);
23+
return results.map((entry) => entry.name);
24+
};
25+
26+
test("standalone symbolic names survive separator normalization", () => {
27+
// These symbols also act as separators in compound names.
28+
for (const name of ["->", "#", "::", ":-"]) {
29+
assert.deepEqual(search(name, ["unrelated", name]), [name]);
30+
assert.deepEqual(search(` ${name} `, [name]), [name]);
31+
assert.deepEqual(search(`${name}()`, [name]), [name]);
32+
assert.deepEqual(search(`${name} (macro)`, [name]), [name]);
33+
}
34+
});
35+
36+
test("Clojure operators keep exact matches first", () => {
37+
const names = ["some->>", "cond->>", "->>", "->", "-'", "-", "+'", "+"];
38+
assert.deepEqual(search("->", names), ["->"]);
39+
assert.deepEqual(search("->>", names), ["->>", "some->>", "cond->>"]);
40+
assert.deepEqual(search("-", names), ["-", "->", "-'"]);
41+
assert.deepEqual(search("+", names), ["+", "+'"]);
42+
});
43+
44+
test("member separators keep equivalent queries and suffix ranking", () => {
45+
const names = [
46+
"Other#method", "Class->method", "Class::method", "Class#method.extra",
47+
];
48+
const expected = ["Class->method", "Class::method", "Class#method.extra"];
49+
for (const query of ["Class->method", "Class::method", "Class.method"]) {
50+
assert.deepEqual(search(query, names), expected);
51+
}
52+
assert.deepEqual(search("method", names), [
53+
"Other#method", "Class->method", "Class::method", "Class#method.extra",
54+
]);
55+
assert.deepEqual(search("Class->", names), expected);
56+
});
57+
58+
test("ordinary normalization retains namespaces, decoration and separators", () => {
59+
const equivalent = [
60+
"Class#method", "Class::method", "Class:-method", "Class->method",
61+
"Class$method", "Class-method", "Class:method", "Class / method",
62+
"Class - method", "Class & method", "Class: method", "Class method",
63+
"Class..method()", "Class.method (method)", "Class.method event",
64+
];
65+
for (const name of equivalent) {
66+
assert.equal(Searcher.normalizeString(name), "class.method");
67+
}
68+
assert.deepEqual(search("clojure.core", ["clojure.core", "clojure.core.protocols"]), [
69+
"clojure.core", "clojure.core.protocols",
70+
]);
71+
assert.equal(Searcher.normalizeQuery("Class-"), "class.");
72+
assert.equal(Searcher.normalizeQuery("Class:"), "class.");
73+
});
74+
75+
test("empty and dot-only queries stay suppressed", () => {
76+
for (const query of ["", " ", ".", "..", "..."]) {
77+
assert.deepEqual(search(query, [".", "..", "Class.method"]), []);
78+
}
79+
});
80+
81+
test("fuzzy matching and exact matches keep their ordering", () => {
82+
assert.deepEqual(search("map", ["my-map", "map", "make-apple-pie"]), [
83+
"map", "my-map", "make-apple-pie",
84+
]);
85+
});

0 commit comments

Comments
 (0)