Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions assets/javascripts/app/searcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export class Searcher extends Events {
static EVENT_REGEXP = /\ event$/;
static DOT_REGEXP = /\.+/g;
static WHITESPACE_REGEXP = /\s/g;
static SYMBOL_ONLY_REGEXP = /^[^\w\s.]+$/;

static EMPTY_STRING = "";
static ELLIPSIS = "...";
Expand All @@ -196,15 +197,29 @@ export class Searcher extends Events {
* @returns {string}
*/
static normalizeString(string) {
return string
string = string
.toLowerCase()
.replace(Searcher.ELLIPSIS, Searcher.EMPTY_STRING)
.replace(Searcher.EVENT_REGEXP, Searcher.EMPTY_STRING)
.replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
.replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING);

const normalized = string
.replace(Searcher.SEPARATORS_REGEXP, SEPARATOR)
.replace(Searcher.DOT_REGEXP, SEPARATOR)
.replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
.replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING);

// A separator can also be an entire symbolic name, e.g. Clojure's ->.
// Keep it searchable without changing separators in compound names.
if (normalized === SEPARATOR) {
const symbol = string
.replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
.trim();
if (Searcher.SYMBOL_ONLY_REGEXP.test(symbol)) {
return symbol;
}
}
return normalized;
}

/**
Expand Down
85 changes: 85 additions & 0 deletions test/assets/searcher_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// @ts-check

import assert from "node:assert/strict";
import test from "node:test";

import {
Searcher,
SynchronousSearcher,
} from "../../assets/javascripts/app/searcher.js";
import { Entry } from "../../assets/javascripts/models/entry.js";

/**
* @param {string} query
* @param {string[]} names
*/
const search = (query, names) => {
const entries = names.map((name) => new Entry({ name }));
const searcher = new SynchronousSearcher();
/** @type {Entry[]} */
let results = [];
searcher.on("results", (found) => (results = /** @type {Entry[]} */ (found)));
searcher.find(entries, "text", query);
return results.map((entry) => entry.name);
};

test("standalone symbolic names survive separator normalization", () => {
// These symbols also act as separators in compound names.
for (const name of ["->", "#", "::", ":-"]) {
assert.deepEqual(search(name, ["unrelated", name]), [name]);
assert.deepEqual(search(` ${name} `, [name]), [name]);
assert.deepEqual(search(`${name}()`, [name]), [name]);
assert.deepEqual(search(`${name} (macro)`, [name]), [name]);
}
});

test("Clojure operators keep exact matches first", () => {
const names = ["some->>", "cond->>", "->>", "->", "-'", "-", "+'", "+"];
assert.deepEqual(search("->", names), ["->"]);
assert.deepEqual(search("->>", names), ["->>", "some->>", "cond->>"]);
assert.deepEqual(search("-", names), ["-", "->", "-'"]);
assert.deepEqual(search("+", names), ["+", "+'"]);
});

test("member separators keep equivalent queries and suffix ranking", () => {
const names = [
"Other#method", "Class->method", "Class::method", "Class#method.extra",
];
const expected = ["Class->method", "Class::method", "Class#method.extra"];
for (const query of ["Class->method", "Class::method", "Class.method"]) {
assert.deepEqual(search(query, names), expected);
}
assert.deepEqual(search("method", names), [
"Other#method", "Class->method", "Class::method", "Class#method.extra",
]);
assert.deepEqual(search("Class->", names), expected);
});

test("ordinary normalization retains namespaces, decoration and separators", () => {
const equivalent = [
"Class#method", "Class::method", "Class:-method", "Class->method",
"Class$method", "Class-method", "Class:method", "Class / method",
"Class - method", "Class & method", "Class: method", "Class method",
"Class..method()", "Class.method (method)", "Class.method event",
];
for (const name of equivalent) {
assert.equal(Searcher.normalizeString(name), "class.method");
}
assert.deepEqual(search("clojure.core", ["clojure.core", "clojure.core.protocols"]), [
"clojure.core", "clojure.core.protocols",
]);
assert.equal(Searcher.normalizeQuery("Class-"), "class.");
assert.equal(Searcher.normalizeQuery("Class:"), "class.");
});

test("empty and dot-only queries stay suppressed", () => {
for (const query of ["", " ", ".", "..", "..."]) {
assert.deepEqual(search(query, [".", "..", "Class.method"]), []);
}
});

test("fuzzy matching and exact matches keep their ordering", () => {
assert.deepEqual(search("map", ["my-map", "map", "make-apple-pie"]), [
"map", "my-map", "make-apple-pie",
]);
});