diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 5a66242f43..0b80dce053 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -35,3 +35,5 @@ jobs:
run: npm ci
- name: Run stylelint
run: npm run lint:css
+ - name: Run JS tests
+ run: npm run test:js
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0f1b8593ee..1bbe01fba7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -52,6 +52,17 @@ bundle exec rake verify_generated
- **Test Location:** `test/` directory
- **Test Helper:** `test/lib/helper.rb`
+### JavaScript Tests
+
+The template JavaScript is tested with Node's built-in test runner. It needs Node 20 or later and no npm packages.
+
+```bash
+npm run test:js
+```
+
+- **Test Location:** `test/js/` directory
+- **Test Helper:** `test/js/helper.js`
+
## Linting
### RuboCop (Ruby Code)
diff --git a/Gemfile b/Gemfile
index 4b2be8f5d5..31d0c3008e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -16,7 +16,3 @@ if ENV['PRISM_VERSION'] == 'head'
elsif ENV['PRISM_VERSION']
gem 'prism', ENV['PRISM_VERSION']
end
-
-platforms :ruby do
- gem 'mini_racer' # For testing the searcher.js file
-end
diff --git a/package.json b/package.json
index a1c219ec5a..22515bd046 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,8 @@
"name": "rdoc",
"private": true,
"scripts": {
- "lint:css": "stylelint 'lib/rdoc/generator/template/**/*.css'"
+ "lint:css": "stylelint 'lib/rdoc/generator/template/**/*.css'",
+ "test:js": "node --test test/js/*/*.test.js"
},
"devDependencies": {
"stylelint": "^16.25.0",
diff --git a/test/js/aliki/bash_highlighter.test.js b/test/js/aliki/bash_highlighter.test.js
new file mode 100644
index 0000000000..2c98df387a
--- /dev/null
+++ b/test/js/aliki/bash_highlighter.test.js
@@ -0,0 +1,162 @@
+'use strict';
+
+const { test } = require('node:test');
+const { assert, assertIncludes, refuteIncludes, loadHighlighter } = require('../helper');
+
+const highlight = loadHighlighter('aliki/js/bash_highlighter.js');
+
+test('prompts', () => {
+ // $ followed by space or end of line is a prompt
+ for (const [input, expected] of [
+ ['$ bundle exec rake', '$'],
+ [' $ npm install', '$'],
+ ['$', '$'],
+ ]) {
+ assertIncludes(highlight(input), expected, `Failed for: ${input}`);
+ }
+
+ // $VAR is a variable, not a prompt
+ refuteIncludes(highlight('$HOME/bin'), '');
+});
+
+test('comments', () => {
+ for (const [input, expected] of [
+ ['# This is a comment', ''],
+ ['bundle exec rake # Run tests', ''],
+ ]) {
+ assertIncludes(highlight(input), expected, `Failed for: ${input}`);
+ }
+});
+
+test('options', () => {
+ for (const [input, expected] of [
+ ['ls -l', '-l'],
+ ['npm install --save-dev', '--save-dev'],
+ ['git commit --message=fix', '--message=fix'],
+ ['ls -la --color=auto', ['-la', '--color=auto']],
+ ]) {
+ for (const exp of [expected].flat()) {
+ assertIncludes(highlight(input), exp, `Failed for: ${input}`);
+ }
+ }
+});
+
+test('options with quoted values', () => {
+ // Option with quoted value should stop at the quote
+ let result = highlight('git commit --message="initial commit"');
+ assertIncludes(result, '--message=');
+ assertIncludes(result, '"initial commit"');
+
+ // Single-quoted value
+ result = highlight("git commit --message='initial commit'");
+ assertIncludes(result, '--message=');
+ assertIncludes(result, ''initial commit'');
+});
+
+test('strings', () => {
+ for (const [input, expected] of [
+ ['echo "hello world"', '"hello world"'],
+ ["echo 'hello world'", ''hello world''],
+ ['echo "hello \\"world\\""', '"hello \\"world\\""'],
+ ['npx @herb-tools/linter "**/*.rhtml"', '"**/*.rhtml"'],
+ ]) {
+ assertIncludes(highlight(input), expected, `Failed for: ${input}`);
+ }
+});
+
+test('commands', () => {
+ const result = highlight('bundle exec rake');
+ assertIncludes(result, 'bundle');
+ // Only the first word is highlighted as command
+ refuteIncludes(result, 'exec');
+ refuteIncludes(result, 'rake');
+});
+
+test('path commands', () => {
+ for (const [input, expected] of [
+ ['./configure --prefix=/usr/local', './configure'],
+ ['../configure --enable-gcov', '../configure'],
+ ['./autogen.sh', './autogen.sh'],
+ ['~/.rubies/ruby-master/bin/ruby -e "puts 1"', '~/.rubies/ruby-master/bin/ruby'],
+ ]) {
+ assertIncludes(highlight(input), expected, `Failed for: ${input}`);
+ }
+});
+
+test('absolute path commands', () => {
+ for (const [input, expected] of [
+ ['/bin/sh -c "echo hello"', '/bin/sh'],
+ ['/usr/bin/env ruby', '/usr/bin/env'],
+ ['/opt/homebrew/bin/ruby -v', '/opt/homebrew/bin/ruby'],
+ ]) {
+ assertIncludes(highlight(input), expected, `Failed for: ${input}`);
+ }
+});
+
+test('environment variables', () => {
+ for (const [input, expected] of [
+ ['COVERAGE=true make test', ['COVERAGE=', 'make']],
+ ['CC=clang CXX=clang++ make', ['CC=', 'CXX=', 'make']],
+ ['RUBY_TEST_TIMEOUT_SCALE=5 make check', ['RUBY_TEST_TIMEOUT_SCALE=', 'make']],
+ ]) {
+ for (const exp of [expected].flat()) {
+ assertIncludes(highlight(input), exp, `Failed for: ${input}`);
+ }
+ }
+});
+
+test('hyphens in words not options', () => {
+ // Hyphen in @herb-tools/linter should NOT be treated as option
+ let result = highlight('npx @herb-tools/linter');
+ assertIncludes(result, 'npx');
+ refuteIncludes(result, '-tools/linter');
+ assertIncludes(result, '@herb-tools/linter');
+
+ // Command with hyphen gets highlighted as command, not option
+ result = highlight('some-command arg');
+ assertIncludes(result, 'some-command');
+ refuteIncludes(result, '');
+});
+
+test('complex commands', () => {
+ // Typical shell command with prompt
+ let result = highlight('$ bundle exec rubocop -A');
+ assertIncludes(result, '$');
+ assertIncludes(result, 'bundle');
+ assertIncludes(result, '-A');
+
+ // Complex git command
+ result = highlight('$ git commit -m "Fix bug" --no-verify');
+ assertIncludes(result, '$');
+ assertIncludes(result, 'git');
+ assertIncludes(result, '-m');
+ assertIncludes(result, '"Fix bug"');
+ assertIncludes(result, '--no-verify');
+});
+
+test('multiline with comments', () => {
+ const code = `# Generate documentation (creates _site directory)
+bundle exec rake rdoc
+
+# Force regenerate documentation
+bundle exec rake rerdoc
+`;
+
+ const result = highlight(code);
+ assertIncludes(result, '');
+ assertIncludes(result, '');
+});
+
+test('empty and whitespace', () => {
+ assert.equal(highlight(''), '');
+ assert.equal(highlight(' \n\t \n '), ' \n\t \n ');
+});
+
+test('html escaping', () => {
+ let result = highlight('echo ""');
+ assertIncludes(result, '<script>');
+ assertIncludes(result, '</script>');
+
+ result = highlight('echo "a && b"');
+ assertIncludes(result, '&&');
+});
diff --git a/test/js/aliki/c_highlighter.test.js b/test/js/aliki/c_highlighter.test.js
new file mode 100644
index 0000000000..5323bae8b8
--- /dev/null
+++ b/test/js/aliki/c_highlighter.test.js
@@ -0,0 +1,284 @@
+'use strict';
+
+const { test } = require('node:test');
+const { assert, assertIncludes, refuteIncludes, loadHighlighter } = require('../helper');
+
+const highlight = loadHighlighter('aliki/js/c_highlighter.js');
+
+test('keywords', () => {
+ const result = highlight('int main() { return 0; }');
+
+ assertIncludes(result, 'int');
+ assertIncludes(result, 'return');
+});
+
+test('identifiers', () => {
+ const result = highlight('int x = 5; char *name;');
+
+ assertIncludes(result, 'x');
+ assertIncludes(result, 'name');
+});
+
+test('operators', () => {
+ const result = highlight('a == b && c != d');
+
+ assertIncludes(result, '==');
+ assertIncludes(result, '&&');
+ assertIncludes(result, '!=');
+});
+
+test('single char operators', () => {
+ const result = highlight('a + b - c * d / e');
+
+ assertIncludes(result, '+');
+ assertIncludes(result, '-');
+ assertIncludes(result, '*');
+ assertIncludes(result, '/');
+});
+
+test('preprocessor directives', () => {
+ const result = highlight('#include \n#define MAX 100');
+
+ assertIncludes(result, '#include <stdio.h>');
+ assertIncludes(result, '#define MAX 100');
+});
+
+test('preprocessor with line continuation', () => {
+ const result = highlight('#define LONG_MACRO \\\n value');
+
+ // Preprocessor should capture everything including the line continuation
+ assertIncludes(result, '#define LONG_MACRO \\');
+ assertIncludes(result, ' value');
+});
+
+test('single line comment', () => {
+ const result = highlight('// This is a comment');
+
+ assertIncludes(result, '');
+});
+
+test('multi line comment', () => {
+ const result = highlight('/* Multi\\nline\\ncomment */');
+
+ assertIncludes(result, '');
+});
+
+test('string literals', () => {
+ const result = highlight('"hello world"');
+
+ assertIncludes(result, '"hello world"');
+});
+
+test('string with escapes', () => {
+ const result = highlight('"hello \\"world\\""');
+
+ assertIncludes(result, '"hello \\"world\\""');
+});
+
+test('character literals', () => {
+ const result = highlight("'a'");
+
+ assertIncludes(result, ''a'');
+});
+
+test('character literals with escapes', () => {
+ const result = highlight("'\\n' '\\\\' '\\''");
+
+ assertIncludes(result, ''\\n'');
+ assertIncludes(result, ''\\\\'');
+ assertIncludes(result, ''\\''');
+});
+
+test('decimal numbers', () => {
+ const result = highlight('42 3.14 2.5e10');
+
+ assertIncludes(result, '42');
+ assertIncludes(result, '3.14');
+ assertIncludes(result, '2.5e10');
+});
+
+test('hexadecimal numbers', () => {
+ const result = highlight('0xFF 0xDEADBEEF');
+
+ assertIncludes(result, '0xFF');
+ assertIncludes(result, '0xDEADBEEF');
+});
+
+test('octal numbers', () => {
+ const result = highlight('0755');
+
+ assertIncludes(result, '0755');
+});
+
+test('number suffixes', () => {
+ const result = highlight('42u 3.14f 100L');
+
+ assertIncludes(result, '42u');
+ assertIncludes(result, '3.14f');
+ assertIncludes(result, '100L');
+});
+
+test('html escaping', () => {
+ const result = highlight('if (x < 5 && y > 10) {}');
+
+ assertIncludes(result, '<');
+ assertIncludes(result, '>');
+ assertIncludes(result, '&&');
+});
+
+test('complex c code', () => {
+ const code = `#include
+
+/* Main function */
+int main() {
+ char *str = "Hello, World!";
+ int x = 0xFF;
+
+ // Print the string
+ if (x > 0) {
+ printf("%s\\n", str);
+ }
+ return 0;
+}
+`;
+
+ const result = highlight(code);
+
+ // Verify key components are highlighted
+ assertIncludes(result, '#include <stdio.h>');
+ assertIncludes(result, '');
+ assertIncludes(result, 'int');
+ assertIncludes(result, 'char');
+ assertIncludes(result, 'return');
+ assertIncludes(result, 'main');
+ assertIncludes(result, 'printf');
+ assertIncludes(result, '"Hello, World!"');
+ assertIncludes(result, '0xFF');
+ assertIncludes(result, '0');
+ assertIncludes(result, '');
+});
+
+test('empty code', () => {
+ const result = highlight('');
+
+ assert.equal(result, '');
+});
+
+test('only whitespace', () => {
+ const result = highlight(' \n\t \n ');
+
+ assert.equal(result, ' \n\t \n ');
+});
+
+test('c11 keywords', () => {
+ const result = highlight('_Atomic _Bool _Complex _Thread_local');
+
+ assertIncludes(result, '_Atomic');
+ assertIncludes(result, '_Bool');
+ assertIncludes(result, '_Complex');
+ assertIncludes(result, '_Thread_local');
+});
+
+test('preprocessor only at line start', () => {
+ // # in the middle of code should not be treated as preprocessor
+ const result = highlight('int x = 5 # 3;');
+
+ refuteIncludes(result, '');
+ // The # should be treated as regular text
+ assertIncludes(result, '#');
+});
+
+test('typical function definition', () => {
+ const code = `static VALUE
+rb_ary_all_p(int argc, VALUE *argv, VALUE ary)
+{
+ long i;
+
+ for (i = 0; i < RARRAY_LEN(ary); i++) {
+ if (!RTEST(RARRAY_AREF(ary, i))) {
+ return Qfalse;
+ }
+ }
+ return Qtrue;
+}
+`;
+
+ const result = highlight(code);
+
+ // Verify it highlights correctly without errors
+ assertIncludes(result, 'static');
+ assertIncludes(result, 'VALUE');
+ assertIncludes(result, 'rb_ary_all_p');
+ assertIncludes(result, 'int');
+ assertIncludes(result, 'long');
+ assertIncludes(result, 'for');
+ assertIncludes(result, 'return');
+ assertIncludes(result, 'Qtrue');
+ assertIncludes(result, 'Qfalse');
+ assertIncludes(result, 'RARRAY_LEN');
+ assertIncludes(result, 'RTEST');
+ assertIncludes(result, 'RARRAY_AREF');
+});
+
+test('macros all caps', () => {
+ const result = highlight('RARRAY_LEN(ary) ARY_EMBED_P(x) FL_UNSET_EMBED');
+
+ assertIncludes(result, 'RARRAY_LEN');
+ assertIncludes(result, 'ARY_EMBED_P');
+ assertIncludes(result, 'FL_UNSET_EMBED');
+});
+
+test('types common ruby types', () => {
+ const result = highlight('VALUE obj; ID name; size_t len;');
+
+ assertIncludes(result, 'VALUE');
+ assertIncludes(result, 'ID');
+ assertIncludes(result, 'size_t');
+});
+
+test('types with t suffix', () => {
+ const result = highlight('uint32_t count; ssize_t result; ptrdiff_t diff;');
+
+ assertIncludes(result, 'uint32_t');
+ assertIncludes(result, 'ssize_t');
+ assertIncludes(result, 'ptrdiff_t');
+});
+
+test('function names', () => {
+ const result = highlight('rb_ary_replace(copy, orig); ary_memcpy(dest, src);');
+
+ assertIncludes(result, 'rb_ary_replace');
+ assertIncludes(result, 'ary_memcpy');
+});
+
+test('function definition', () => {
+ const result = highlight('VALUE rb_ary_new(void) {');
+
+ assertIncludes(result, 'VALUE');
+ assertIncludes(result, 'rb_ary_new');
+ assertIncludes(result, 'void');
+});
+
+test('variable names', () => {
+ const result = highlight('int count = 5; char *ptr = NULL;');
+
+ assertIncludes(result, 'count');
+ assertIncludes(result, 'ptr');
+ assertIncludes(result, 'NULL');
+});
+
+test('mixed identifiers in expression', () => {
+ const result = highlight('if (ARY_EMBED_P(ary) && len > 0)');
+
+ assertIncludes(result, 'ARY_EMBED_P');
+ assertIncludes(result, 'ary');
+ assertIncludes(result, 'len');
+});
+
+test('macro vs function distinction', () => {
+ // Macros are ALL_CAPS, functions are not
+ const result = highlight('RARRAY_LEN(ary) vs ary_length(ary)');
+
+ assertIncludes(result, 'RARRAY_LEN');
+ assertIncludes(result, 'ary_length');
+});
diff --git a/test/js/aliki/search.test.js b/test/js/aliki/search.test.js
new file mode 100644
index 0000000000..6c619bf105
--- /dev/null
+++ b/test/js/aliki/search.test.js
@@ -0,0 +1,497 @@
+'use strict';
+
+const { describe, test } = require('node:test');
+const { assert, assertIncludes, refuteIncludes, loadScripts } = require('../helper');
+
+function runSearch({ query, data }) {
+ const context = loadScripts(['aliki/js/search_ranker.js']);
+ // Copy the results out of the script context so deepEqual compares plain arrays.
+ return structuredClone(context.search(query, data));
+}
+
+// Goes through SearchController to catch bugs like the query being
+// lowercased before it reaches the ranker.
+function runSearchThroughController({ query, data }) {
+ const context = loadScripts(
+ ['aliki/js/search_navigation.js', 'aliki/js/search_ranker.js', 'aliki/js/search_controller.js'],
+ { document: { addEventListener() {} } }
+ );
+ const input = {
+ value: '',
+ addEventListener() {},
+ setAttribute() {},
+ select() {}
+ };
+ const result = {
+ innerHTML: '',
+ parentNode: { scrollTop: 0, offsetHeight: 100 },
+ childElementCount: 0,
+ firstChild: null,
+ setAttribute() {},
+ appendChild(item) {
+ this.childElementCount++;
+ if (!this.firstChild) this.firstChild = item;
+ }
+ };
+ const controller = new context.SearchController({ index: data }, input, result);
+
+ // Capture the ranked entries before SearchController formats them.
+ let rawResults = [];
+ const originalFind = controller.ranker.find.bind(controller.ranker);
+ controller.ranker.find = function(q) {
+ rawResults = context.search(q, this.index);
+ originalFind(q);
+ };
+ controller.renderItem = () => ({ classList: { add() {} }, setAttribute() {} });
+
+ controller.search(query);
+ return rawResults;
+}
+
+describe('search', () => {
+ // Minimum query length requirement (1 character)
+ test('minimum query length works for single char', () => {
+ const results = runSearch({
+ query: 'H',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ });
+
+ test('minimum query length works for two chars', () => {
+ const results = runSearch({
+ query: 'Ha',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].name, 'Hash');
+ });
+
+ // Prefix matching ranks higher than substring matching
+ test('prefix match ranks higher than substring match', () => {
+ const results = runSearch({
+ query: 'Ha',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'Aha', full_name: 'Aha', type: 'class', path: 'Aha.html' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ assert.equal(results[0].name, 'Hash', 'Prefix match should rank first');
+ assert.equal(results[1].name, 'Aha', 'Substring match should rank second');
+ });
+
+ // Substring matching support
+ test('substring match finds suffix matches', () => {
+ const results = runSearch({
+ query: 'ter',
+ data: [
+ { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].name, 'filter');
+ });
+
+ // Fuzzy matching support (characters in order)
+ test('fuzzy match finds non contiguous matches', () => {
+ const results = runSearch({
+ query: 'addalias',
+ data: [
+ { name: 'add_foo_alias', full_name: 'RDoc::Context#add_foo_alias', type: 'instance_method', path: 'x' },
+ { name: 'add_alias', full_name: 'RDoc::Context#add_alias', type: 'instance_method', path: 'x' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'x' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ // Both are fuzzy matches; shorter name wins
+ assert.equal(results[0].name, 'add_alias');
+ assert.equal(results[1].name, 'add_foo_alias');
+ });
+
+ // Case-based type priority: uppercase query prioritizes classes/modules
+ test('uppercase query prioritizes class over method', () => {
+ const results = runSearch({
+ query: 'Hash',
+ data: [
+ { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ assert.equal(results[0].name, 'Hash');
+ assert.equal(results[0].type, 'class');
+ assert.equal(results[1].name, 'hash');
+ });
+
+ // Case-based type priority: lowercase query prioritizes methods
+ test('lowercase query prioritizes method over class', () => {
+ const results = runSearch({
+ query: 'hash',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ assert.equal(results[0].name, 'hash');
+ assert.equal(results[0].type, 'instance_method');
+ assert.equal(results[1].name, 'Hash');
+ });
+
+ // Unqualified match > qualified match
+ test('unqualified match prioritized over qualified', () => {
+ const results = runSearch({
+ query: 'Foo',
+ data: [
+ { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' },
+ { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ assert.equal(results[0].full_name, 'Foo');
+ assert.equal(results[1].full_name, 'Bar::Foo');
+ });
+
+ // Shorter name > longer name
+ test('shorter name prioritized', () => {
+ const results = runSearch({
+ query: 'Hash',
+ data: [
+ { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' },
+ ],
+ });
+
+ assert.equal(results.length, 3);
+ assert.equal(results[0].name, 'Hash');
+ assert.equal(results[1].name, 'Hashable');
+ assert.equal(results[2].name, 'HashWithIndifferentAccess');
+ });
+
+ // Class method > instance method
+ test('class method prioritized over instance method', () => {
+ const results = runSearch({
+ query: 'hash',
+ data: [
+ { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
+ { name: 'hash', full_name: 'Digest::Base::hash', type: 'class_method', path: 'Digest/Base.html#hash' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ assert.equal(results[0].type, 'class_method');
+ assert.equal(results[1].type, 'instance_method');
+ });
+
+ // Exact full match wins
+ test('exact full name match wins', () => {
+ const results = runSearch({
+ query: 'Bar::Foo',
+ data: [
+ { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' },
+ { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' },
+ { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].full_name, 'Bar::Foo');
+ });
+
+ // Namespace query matches within namespace
+ test('namespace query matches namespace', () => {
+ const results = runSearch({
+ query: 'Foo::B',
+ data: [
+ { name: 'Bar', full_name: 'Bar', type: 'class', path: 'Bar.html' },
+ { name: 'Bar', full_name: 'Foo::Bar', type: 'class', path: 'Foo/Bar.html' },
+ { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ const names = results.map((r) => r.full_name);
+ assertIncludes(names, 'Foo::Bar');
+ assertIncludes(names, 'Foo::Baz');
+ });
+
+ // Method query with # matches against full_name
+ test('instance method query matches full name', () => {
+ const results = runSearch({
+ query: 'Array#filter',
+ data: [
+ { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
+ { name: 'filter', full_name: 'Enumerable#filter', type: 'instance_method', path: 'Enumerable.html#filter' },
+ { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].full_name, 'Array#filter');
+ });
+
+ // Method query with . matches against full_name (class methods)
+ // Note: RDoc uses :: for class methods in full_name, but users may type . (Ruby convention)
+ // The search normalizes . to :: so "Array.try_convert" matches "Array::try_convert"
+ test('class method query matches full name', () => {
+ const results = runSearch({
+ query: 'Array.try_convert',
+ data: [
+ { name: 'try_convert', full_name: 'Array::try_convert', type: 'class_method', path: 'Array.html#try_convert' },
+ { name: 'try_convert', full_name: 'Hash::try_convert', type: 'class_method', path: 'Hash.html#try_convert' },
+ { name: 'try_convert', full_name: 'String::try_convert', type: 'class_method', path: 'String.html#try_convert' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].full_name, 'Array::try_convert');
+ });
+
+ // Method query prefix matching against full_name
+ test('method query prefix matching', () => {
+ const results = runSearch({
+ query: 'Array#fi',
+ data: [
+ { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
+ { name: 'find', full_name: 'Array#find', type: 'instance_method', path: 'Array.html#find' },
+ { name: 'first', full_name: 'Array#first', type: 'instance_method', path: 'Array.html#first' },
+ { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' },
+ ],
+ });
+
+ assert.equal(results.length, 3);
+ const fullNames = results.map((r) => r.full_name);
+ assertIncludes(fullNames, 'Array#filter');
+ assertIncludes(fullNames, 'Array#find');
+ assertIncludes(fullNames, 'Array#first');
+ refuteIncludes(fullNames, 'Hash#filter');
+ });
+
+ // Method query substring matching against full_name
+ test('method query substring matching', () => {
+ const results = runSearch({
+ query: '#filter',
+ data: [
+ { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
+ { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' },
+ { name: 'filter_map', full_name: 'Array#filter_map', type: 'instance_method', path: 'Array.html#filter_map' },
+ ],
+ });
+
+ assert.equal(results.length, 3);
+ // All entries contain #filter in their full_name
+ for (const r of results) {
+ assert.match(r.full_name, /#filter/);
+ }
+ });
+
+ // Special characters
+ test('special characters searchable', () => {
+ const results = runSearch({
+ query: '<<',
+ data: [
+ { name: '<<', full_name: 'Array#<<', type: 'instance_method', path: 'Array.html#<<' },
+ { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].name, '<<');
+ });
+
+ test('bracket method searchable', () => {
+ const results = runSearch({
+ query: '[]',
+ data: [
+ { name: '[]', full_name: 'Hash#[]', type: 'instance_method', path: 'Hash.html#[]' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].name, '[]');
+ });
+
+ // Result limit
+ test('result limit 30', () => {
+ const data = Array.from({ length: 50 }, (_, i) => ({ name: `Test${i}`, full_name: `Test${i}`, type: 'class', path: `Test${i}.html` }));
+
+ const results = runSearch({ query: 'Test', data: data });
+
+ assert.equal(results.length, 30);
+ });
+
+ // Empty query
+ test('empty query returns empty', () => {
+ const results = runSearch({
+ query: '',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ ],
+ });
+
+ assert.deepEqual(results, []);
+ });
+
+ // No matches
+ test('no matches returns empty', () => {
+ const results = runSearch({
+ query: 'xyz',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' },
+ ],
+ });
+
+ assert.deepEqual(results, []);
+ });
+
+ // Case insensitive matching
+ test('case insensitive matching', () => {
+ const results = runSearch({
+ query: 'HASH',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ ],
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].name, 'Hash');
+ });
+
+ // Constant search
+ test('constant search', () => {
+ const results = runSearch({
+ query: 'VER',
+ data: [
+ { name: 'VERSION', full_name: 'RDoc::VERSION', type: 'constant', path: 'RDoc.html' },
+ { name: 'Verifier', full_name: 'Verifier', type: 'class', path: 'Verifier.html' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ // Verifier is unqualified (name == full_name), VERSION is qualified (RDoc::VERSION)
+ // Unqualified wins over qualified, then shorter wins within same qualification
+ assert.equal(results[0].name, 'Verifier');
+ assert.equal(results[1].name, 'VERSION');
+ });
+
+ // Exact name match should win over prefix match
+ test('exact name match beats prefix match', () => {
+ const results = runSearch({
+ query: 'RDoc',
+ data: [
+ { name: 'rdoc_version', full_name: 'RDoc::RubygemsHook#rdoc_version', type: 'instance_method', path: 'RDoc/RubygemsHook.html#rdoc_version' },
+ { name: 'RDoc', full_name: 'RDoc', type: 'module', path: 'RDoc.html' },
+ { name: 'RDoc', full_name: 'RDoc::RDoc', type: 'class', path: 'RDoc/RDoc.html' },
+ ],
+ });
+
+ assert.equal(results.length, 3);
+ // Exact name matches should come first
+ assert.equal(results[0].full_name, 'RDoc', 'Expected top-level RDoc module first');
+ assert.equal(results[1].full_name, 'RDoc::RDoc', 'Expected RDoc::RDoc class second');
+ assert.equal(results[2].full_name, 'RDoc::RubygemsHook#rdoc_version', 'Expected rdoc_version method last');
+ });
+
+ // Hash class should rank higher than #hash methods when searching "Hash"
+ // This replicates the bug where methods appear before the class
+ test('hash class ranks higher than hash methods', () => {
+ const results = runSearch({
+ query: 'Hash',
+ data: [
+ { name: 'hash', full_name: 'Gem::Resolver::IndexSpecification#hash', type: 'instance_method', path: 'Gem/Resolver/IndexSpecification.html#hash' },
+ { name: 'hash', full_name: 'URI::Generic#hash', type: 'instance_method', path: 'URI/Generic.html#hash' },
+ { name: 'hash', full_name: 'Struct#hash', type: 'instance_method', path: 'Struct.html#hash' },
+ { name: 'hash', full_name: 'Regexp#hash', type: 'instance_method', path: 'Regexp.html#hash' },
+ { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
+ { name: 'hash', full_name: 'Time#hash', type: 'instance_method', path: 'Time.html#hash' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'Hash', full_name: 'Gem::SafeMarshal::Elements::Hash', type: 'class', path: 'Gem/SafeMarshal/Elements/Hash.html' },
+ ],
+ });
+
+ assert.equal(results.length, 8);
+ // Hash class should come first (uppercase query + exact name match + unqualified)
+ assert.equal(results[0].full_name, 'Hash', 'Expected Hash class first');
+ // Nested Hash class second (uppercase query + exact name match, but qualified)
+ assert.equal(results[1].full_name, 'Gem::SafeMarshal::Elements::Hash', 'Expected nested Hash class second');
+ // Methods should come after classes
+ assert.equal(results[2].type, 'instance_method', 'Expected methods after classes');
+ });
+
+ // Combined priority test matching user's expectation
+ // User query: "Ha"
+ // Expected order: Hash, Hashable, HashWithIndifferentAccess, Foo::Hash, #hash
+ test('combined priority matching user expectation', () => {
+ const results = runSearch({
+ query: 'Ha',
+ data: [
+ { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
+ { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' },
+ { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' },
+ { name: 'Hash', full_name: 'Foo::Hash', type: 'class', path: 'Foo/Hash.html' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ ],
+ });
+
+ assert.equal(results.length, 5);
+ // Order: class/module first, unqualified before qualified, shorter before longer, methods last
+ assert.equal(results[0].full_name, 'Hash', 'Expected top-level Hash first');
+ assert.equal(results[1].full_name, 'Hashable', 'Expected Hashable second (shorter unqualified)');
+ assert.equal(results[2].full_name, 'HashWithIndifferentAccess', 'Expected HashWithIndifferentAccess third');
+ assert.equal(results[3].full_name, 'Foo::Hash', 'Expected Foo::Hash fourth (qualified)');
+ assert.equal(results[4].full_name, 'Object#hash', 'Expected method last');
+ });
+});
+
+describe('SearchController', () => {
+ // This test catches the bug where SearchController.search() was lowercasing
+ // the query before passing it to the ranker, breaking case-based type priority
+ test('uppercase query preserves case for type priority', () => {
+ const results = runSearchThroughController({
+ query: 'Hash',
+ data: [
+ { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ // With uppercase "Hash" query, class should come first due to type priority
+ assert.equal(results[0].full_name, 'Hash', 'Expected Hash class first with uppercase query');
+ assert.equal(results[1].full_name, 'Object#hash', 'Expected hash method second');
+ });
+
+ test('lowercase query prioritizes methods', () => {
+ const results = runSearchThroughController({
+ query: 'hash',
+ data: [
+ { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
+ { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
+ ],
+ });
+
+ assert.equal(results.length, 2);
+ // With lowercase "hash" query, method should come first due to type priority
+ assert.equal(results[0].full_name, 'Object#hash', 'Expected hash method first with lowercase query');
+ assert.equal(results[1].full_name, 'Hash', 'Expected Hash class second');
+ });
+});
diff --git a/test/js/helper.js b/test/js/helper.js
new file mode 100644
index 0000000000..d12f56ef31
--- /dev/null
+++ b/test/js/helper.js
@@ -0,0 +1,66 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { inspect } = require('node:util');
+const vm = require('node:vm');
+
+const TEMPLATE_DIR = path.join(__dirname, '../../lib/rdoc/generator/template');
+
+// The template scripts are classic browser scripts that share globals.
+// Load them into one fresh context, in the order a page loads them.
+function loadScripts(files, globals = {}) {
+ const context = vm.createContext(globals);
+ for (const file of files) {
+ const source = fs.readFileSync(path.join(TEMPLATE_DIR, file), 'utf8');
+ vm.runInContext(source, context, { filename: file });
+ }
+ return context;
+}
+
+// Load a highlighter script with a fake document and return a function that
+// highlights one code block the way the page does on DOMContentLoaded.
+function loadHighlighter(file) {
+ let onLoad = null;
+ let blocks = [];
+ const document = {
+ readyState: 'loading',
+ addEventListener(type, listener) {
+ if (type === 'DOMContentLoaded') onLoad = listener;
+ },
+ querySelectorAll() {
+ return blocks;
+ }
+ };
+ loadScripts([file], { document });
+
+ return function highlight(code) {
+ const attributes = {};
+ const block = {
+ textContent: code,
+ innerHTML: code,
+ getAttribute: (name) => attributes[name],
+ setAttribute: (name, value) => { attributes[name] = value; }
+ };
+ blocks = [block];
+ onLoad();
+ return block.innerHTML;
+ };
+}
+
+function assertIncludes(collection, item, message) {
+ assert.ok(
+ collection.includes(item),
+ message ?? `Expected ${inspect(collection)} to include ${inspect(item)}`
+ );
+}
+
+function refuteIncludes(collection, item, message) {
+ assert.ok(
+ !collection.includes(item),
+ message ?? `Expected ${inspect(collection)} not to include ${inspect(item)}`
+ );
+}
+
+module.exports = { assert, assertIncludes, refuteIncludes, loadHighlighter, loadScripts };
diff --git a/test/js/json_index/searcher.test.js b/test/js/json_index/searcher.test.js
new file mode 100644
index 0000000000..aedb77b5fb
--- /dev/null
+++ b/test/js/json_index/searcher.test.js
@@ -0,0 +1,303 @@
+'use strict';
+
+const { test } = require('node:test');
+const { assert, loadScripts } = require('../helper');
+
+function stripHighlights(str) {
+ return str.replace(/[\u0001\u0002]/g, '');
+}
+
+// The data sets here are small enough that Searcher#find delivers every
+// result before it returns, so the test needs no timers.
+function runSearch({ query, data }) {
+ const context = loadScripts(['json_index/js/searcher.js']);
+ const searcher = new context.Searcher(data);
+ let results = [];
+ searcher.ready((res) => {
+ results = results.concat(res);
+ });
+ searcher.find(query);
+ return results;
+}
+
+test('exact match prioritized', () => {
+ const results = runSearch({
+ query: 'string',
+ data: {
+ searchIndex: ['string', 'string', 'strings'],
+ longSearchIndex: ['gem::safemarshal::elements::string', 'string', 'strings'],
+ info: [
+ ['String', 'Gem::SafeMarshal::Elements', 'Gem/SafeMarshal/Elements/String.html', '', 'Nested String class', '', 'class'],
+ ['String', '', 'String.html', '', 'Top-level String class', '', 'class'],
+ ['Strings', '', 'Strings.html', '', 'Strings class', '', 'class'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 3);
+ // Top-level String should come first despite being second in the array
+ assert.equal(stripHighlights(results[0].title), 'String');
+ assert.equal(results[0].namespace, '', 'Top-level String should be prioritized over nested String');
+ assert.equal(results[0].path, 'String.html');
+
+ // Nested String should come second
+ assert.equal(stripHighlights(results[1].title), 'String');
+ assert.equal(stripHighlights(results[1].namespace), 'Gem::SafeMarshal::Elements');
+});
+
+test('exact method match', () => {
+ const results = runSearch({
+ query: 'attribute',
+ data: {
+ searchIndex: ['attributemanager', 'attributes', 'attribute()'],
+ longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::markup::attributes', 'rdoc::markup::attributemanager#attribute()'],
+ info: [
+ ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'AttributeManager class', '', 'class'],
+ ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'],
+ ['attribute', 'RDoc::Markup::AttributeManager', 'RDoc/Markup/AttributeManager.html#method-i-attribute', '()', 'Attribute method', '', 'method'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 3);
+ // attribute() method should come first despite being last in the array
+ assert.equal(stripHighlights(results[0].title), 'attribute');
+ assert.equal(stripHighlights(results[0].namespace), 'RDoc::Markup::AttributeManager');
+});
+
+test('capitalized query prioritizes exact class', () => {
+ const results = runSearch({
+ query: 'String',
+ data: {
+ searchIndex: ['string', 'string()'],
+ longSearchIndex: ['string', 'object#string()'],
+ info: [
+ ['String', '', 'String.html', '', 'String class', '', 'class'],
+ ['string', 'Object', 'Object.html#method-i-string', '()', 'String method', '', 'method'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 2);
+ // Capitalized query: exact class (Pass 0) beats exact method (Pass 1)
+ assert.equal(stripHighlights(results[0].title), 'String');
+ assert.equal(results[0].namespace, '');
+ assert.equal(results[0].path, 'String.html');
+
+ // Method comes second
+ assert.equal(stripHighlights(results[1].title), 'string');
+ assert.equal(stripHighlights(results[1].namespace), 'Object');
+});
+
+test('lowercase query prioritizes method', () => {
+ const results = runSearch({
+ query: 'options',
+ data: {
+ searchIndex: ['options', 'options()'],
+ longSearchIndex: ['rdoc::options', 'rdoc::codeobject#options()'],
+ info: [
+ ['Options', 'RDoc', 'RDoc/Options.html', '', 'Options class', '', 'class'],
+ ['options', 'RDoc::CodeObject', 'RDoc/CodeObject.html#method-i-options', '()', 'Options method', '', 'method'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 2);
+ // Lowercase query should prioritize method over class
+ assert.equal(stripHighlights(results[0].title), 'options');
+ assert.equal(stripHighlights(results[0].namespace), 'RDoc::CodeObject');
+ assert.equal(results[0].path, 'RDoc/CodeObject.html#method-i-options');
+
+ // Class comes second
+ assert.equal(stripHighlights(results[1].title), 'Options');
+ assert.equal(stripHighlights(results[1].namespace), 'RDoc');
+});
+
+test('beginning match', () => {
+ const results = runSearch({
+ query: 'attr',
+ data: {
+ searchIndex: ['attribute()', 'attributemanager', 'generator'],
+ longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::markup::attributemanager', 'rdoc::generator'],
+ info: [
+ ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'],
+ ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'],
+ ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 2);
+ assert.equal(stripHighlights(results[0].title), 'attribute');
+ assert.equal(stripHighlights(results[1].title), 'AttributeManager');
+});
+
+test('long index match', () => {
+ const results = runSearch({
+ query: 'rdoc::markup',
+ data: {
+ searchIndex: ['attributes', 'parser'],
+ longSearchIndex: ['rdoc::markup::attributes', 'rdoc::parser'],
+ info: [
+ ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'],
+ ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(stripHighlights(results[0].title), 'Attributes');
+ assert.equal(stripHighlights(results[0].namespace), 'RDoc::Markup');
+});
+
+test('contains match', () => {
+ const results = runSearch({
+ query: 'manager',
+ data: {
+ searchIndex: ['attributemanager', 'parser'],
+ longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::parser'],
+ info: [
+ ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'],
+ ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(stripHighlights(results[0].title), 'AttributeManager');
+});
+
+test('regexp match', () => {
+ const results = runSearch({
+ query: 'atrbt',
+ data: {
+ searchIndex: ['attribute()', 'generator'],
+ longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::generator'],
+ info: [
+ ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'],
+ ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(stripHighlights(results[0].title), 'attribute');
+});
+
+test('empty query', () => {
+ const results = runSearch({
+ query: '',
+ data: {
+ searchIndex: ['string'],
+ longSearchIndex: ['string'],
+ info: [['String', '', 'String.html', '', 'String class', '', 'class']],
+ },
+ });
+
+ assert.equal(results.length, 0);
+});
+
+test('no matches', () => {
+ const results = runSearch({
+ query: 'nonexistent',
+ data: {
+ searchIndex: ['string', 'attribute()'],
+ longSearchIndex: ['string', 'rdoc#attribute()'],
+ info: [
+ ['String', '', 'String.html', '', 'String class', '', 'class'],
+ ['attribute', 'RDoc', 'RDoc.html#attribute', '()', 'Attribute method', '', 'method'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 0);
+});
+
+test('multiple exact matches', () => {
+ const results = runSearch({
+ query: 'test',
+ data: {
+ searchIndex: ['test', 'test', 'testing'],
+ longSearchIndex: ['test', 'rdoc::test', 'testing'],
+ info: [
+ ['Test', '', 'Test.html', '', 'Top-level Test', '', 'class'],
+ ['Test', 'RDoc', 'RDoc/Test.html', '', 'RDoc Test', '', 'class'],
+ ['Testing', '', 'Testing.html', '', 'Testing class', '', 'class'],
+ ],
+ },
+ });
+
+ assert.equal(results.length, 3);
+ // First result should be the exact match with both indexes matching
+ assert.equal(stripHighlights(results[0].title), 'Test');
+ assert.equal(results[0].namespace, '');
+});
+
+// Test case insensitive search
+test('case insensitive', () => {
+ const results = runSearch({
+ query: 'STRING',
+ data: {
+ searchIndex: ['string'],
+ longSearchIndex: ['string'],
+ info: [['String', '', 'String.html', '', 'String class', '', 'class']],
+ },
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(stripHighlights(results[0].title), 'String');
+});
+
+test('multi word query', () => {
+ const results = runSearch({
+ query: 'rdoc markup',
+ data: {
+ searchIndex: ['attributemanager'],
+ longSearchIndex: ['rdoc::markup::attributemanager'],
+ info: [['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager', '', 'class']],
+ },
+ });
+
+ assert.equal(results.length, 1);
+ assert.equal(results[0].title, 'AttributeManager');
+});
+
+test('highlighting', () => {
+ const results = runSearch({
+ query: 'string',
+ data: {
+ searchIndex: ['string'],
+ longSearchIndex: ['string'],
+ info: [['String', '', 'String.html', '', 'String class', '', 'class']],
+ },
+ });
+
+ assert.equal(results.length, 1);
+ // Check that highlighting markers (unicode \u0001 and \u0002) are present
+ assert.match(results[0].title, /[\u0001\u0002]/);
+});
+
+test('max results limit', () => {
+ // Create 150 entries (more than MAX_RESULTS = 100)
+ const searchIndex = [];
+ const longSearchIndex = [];
+ const info = [];
+
+ for (let i = 0; i < 150; i++) {
+ searchIndex.push(`test${i}`);
+ longSearchIndex.push(`test${i}`);
+ info.push([`Test${i}`, '', `Test${i}.html`, '', `Test class ${i}`, '', 'class']);
+ }
+
+ const results = runSearch({
+ query: 'test',
+ data: {
+ searchIndex: searchIndex,
+ longSearchIndex: longSearchIndex,
+ info: info,
+ },
+ });
+
+ // Should return at most 100 results
+ assert.ok(results.length <= 100);
+});
diff --git a/test/rdoc/generator/aliki/highlight_bash_test.rb b/test/rdoc/generator/aliki/highlight_bash_test.rb
deleted file mode 100644
index 3e5de5a96b..0000000000
--- a/test/rdoc/generator/aliki/highlight_bash_test.rb
+++ /dev/null
@@ -1,203 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../helper'
-
-return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/
-
-begin
- require 'mini_racer'
-rescue LoadError
- return
-end
-
-class RDocGeneratorAlikiHighlightBashTest < Test::Unit::TestCase
- HIGHLIGHT_BASH_JS_PATH = File.expand_path(
- '../../../../lib/rdoc/generator/template/aliki/js/bash_highlighter.js',
- __dir__
- )
-
- HIGHLIGHT_BASH_JS = begin
- highlight_bash_js = File.read(HIGHLIGHT_BASH_JS_PATH)
-
- # We need to modify the JS slightly to make it work in the context of a test.
- highlight_bash_js.gsub(
- /\(function\(\) \{[\s\S]*'use strict';/,
- "// Test wrapper\n"
- ).gsub(
- /if \(document\.readyState[\s\S]*\}\)\(\);/,
- "// Removed DOM initialization for testing"
- )
- end.freeze
-
- def setup
- @context = MiniRacer::Context.new
- @context.eval(HIGHLIGHT_BASH_JS)
- end
-
- def teardown
- @context.dispose
- end
-
- def test_prompts
- # $ followed by space or end of line is a prompt
- [
- ['$ bundle exec rake', '$'],
- [' $ npm install', '$'],
- ['$', '$'],
- ].each do |input, expected|
- assert_includes highlight(input), expected, "Failed for: #{input}"
- end
-
- # $VAR is a variable, not a prompt
- refute_includes highlight('$HOME/bin'), ''
- end
-
- def test_comments
- [
- ['# This is a comment', ''],
- ['bundle exec rake # Run tests', ''],
- ].each do |input, expected|
- assert_includes highlight(input), expected, "Failed for: #{input}"
- end
- end
-
- def test_options
- [
- ['ls -l', '-l'],
- ['npm install --save-dev', '--save-dev'],
- ['git commit --message=fix', '--message=fix'],
- ['ls -la --color=auto', ['-la', '--color=auto']],
- ].each do |input, expected|
- Array(expected).each do |exp|
- assert_includes highlight(input), exp, "Failed for: #{input}"
- end
- end
- end
-
- def test_options_with_quoted_values
- # Option with quoted value should stop at the quote
- result = highlight('git commit --message="initial commit"')
- assert_includes result, '--message='
- assert_includes result, '"initial commit"'
-
- # Single-quoted value
- result = highlight("git commit --message='initial commit'")
- assert_includes result, '--message='
- assert_includes result, "'initial commit'"
- end
-
- def test_strings
- [
- ['echo "hello world"', '"hello world"'],
- ["echo 'hello world'", "'hello world'"],
- ['echo "hello \"world\""', '"hello \"world\""'],
- ['npx @herb-tools/linter "**/*.rhtml"', '"**/*.rhtml"'],
- ].each do |input, expected|
- assert_includes highlight(input), expected, "Failed for: #{input}"
- end
- end
-
- def test_commands
- result = highlight('bundle exec rake')
- assert_includes result, 'bundle'
- # Only the first word is highlighted as command
- refute_includes result, 'exec'
- refute_includes result, 'rake'
- end
-
- def test_path_commands
- [
- ['./configure --prefix=/usr/local', './configure'],
- ['../configure --enable-gcov', '../configure'],
- ['./autogen.sh', './autogen.sh'],
- ['~/.rubies/ruby-master/bin/ruby -e "puts 1"', '~/.rubies/ruby-master/bin/ruby'],
- ].each do |input, expected|
- assert_includes highlight(input), expected, "Failed for: #{input}"
- end
- end
-
- def test_absolute_path_commands
- [
- ['/bin/sh -c "echo hello"', '/bin/sh'],
- ['/usr/bin/env ruby', '/usr/bin/env'],
- ['/opt/homebrew/bin/ruby -v', '/opt/homebrew/bin/ruby'],
- ].each do |input, expected|
- assert_includes highlight(input), expected, "Failed for: #{input}"
- end
- end
-
- def test_environment_variables
- [
- ['COVERAGE=true make test', ['COVERAGE=', 'make']],
- ['CC=clang CXX=clang++ make', ['CC=', 'CXX=', 'make']],
- ['RUBY_TEST_TIMEOUT_SCALE=5 make check', ['RUBY_TEST_TIMEOUT_SCALE=', 'make']],
- ].each do |input, expected|
- Array(expected).each do |exp|
- assert_includes highlight(input), exp, "Failed for: #{input}"
- end
- end
- end
-
- def test_hyphens_in_words_not_options
- # Hyphen in @herb-tools/linter should NOT be treated as option
- result = highlight('npx @herb-tools/linter')
- assert_includes result, 'npx'
- refute_includes result, '-tools/linter'
- assert_includes result, '@herb-tools/linter'
-
- # Command with hyphen gets highlighted as command, not option
- result = highlight('some-command arg')
- assert_includes result, 'some-command'
- refute_includes result, ''
- end
-
- def test_complex_commands
- # Typical shell command with prompt
- result = highlight('$ bundle exec rubocop -A')
- assert_includes result, '$'
- assert_includes result, 'bundle'
- assert_includes result, '-A'
-
- # Complex git command
- result = highlight('$ git commit -m "Fix bug" --no-verify')
- assert_includes result, '$'
- assert_includes result, 'git'
- assert_includes result, '-m'
- assert_includes result, '"Fix bug"'
- assert_includes result, '--no-verify'
- end
-
- def test_multiline_with_comments
- code = <<~SHELL
- # Generate documentation (creates _site directory)
- bundle exec rake rdoc
-
- # Force regenerate documentation
- bundle exec rake rerdoc
- SHELL
-
- result = highlight(code)
- assert_includes result, ''
- assert_includes result, ''
- end
-
- def test_empty_and_whitespace
- assert_equal '', highlight('')
- assert_equal " \n\t \n ", highlight(" \n\t \n ")
- end
-
- def test_html_escaping
- result = highlight('echo ""')
- assert_includes result, '<script>'
- assert_includes result, '</script>'
-
- result = highlight('echo "a && b"')
- assert_includes result, '&&'
- end
-
- private
-
- def highlight(code)
- @context.eval("highlightShell(#{code.to_json})")
- end
-end
diff --git a/test/rdoc/generator/aliki/highlight_c_test.rb b/test/rdoc/generator/aliki/highlight_c_test.rb
deleted file mode 100644
index 4d7a1089db..0000000000
--- a/test/rdoc/generator/aliki/highlight_c_test.rb
+++ /dev/null
@@ -1,326 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../helper'
-
-return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/
-
-begin
- require 'mini_racer'
-rescue LoadError
- return
-end
-
-class RDocGeneratorAlikiHighlightCTest < Test::Unit::TestCase
- HIGHLIGHT_C_JS_PATH = File.expand_path(
- '../../../../lib/rdoc/generator/template/aliki/js/c_highlighter.js',
- __dir__
- )
-
- HIGHLIGHT_C_JS = begin
- highlight_c_js = File.read(HIGHLIGHT_C_JS_PATH)
-
- # We need to modify the JS slightly to make it work in the context of a test.
- highlight_c_js.gsub(
- /\(function\(\) \{[\s\S]*'use strict';/,
- "// Test wrapper\n"
- ).gsub(
- /if \(document\.readyState[\s\S]*\}\)\(\);/,
- "// Removed DOM initialization for testing"
- )
- end.freeze
-
- def setup
- @context = MiniRacer::Context.new
- @context.eval(HIGHLIGHT_C_JS)
- end
-
- def teardown
- @context.dispose
- end
-
- def test_keywords
- result = highlight('int main() { return 0; }')
-
- assert_includes result, 'int'
- assert_includes result, 'return'
- end
-
- def test_identifiers
- result = highlight('int x = 5; char *name;')
-
- assert_includes result, 'x'
- assert_includes result, 'name'
- end
-
- def test_operators
- result = highlight('a == b && c != d')
-
- assert_includes result, '=='
- assert_includes result, '&&'
- assert_includes result, '!='
- end
-
- def test_single_char_operators
- result = highlight('a + b - c * d / e')
-
- assert_includes result, '+'
- assert_includes result, '-'
- assert_includes result, '*'
- assert_includes result, '/'
- end
-
- def test_preprocessor_directives
- result = highlight("#include \n#define MAX 100")
-
- assert_includes result, '#include <stdio.h>'
- assert_includes result, '#define MAX 100'
- end
-
- def test_preprocessor_with_line_continuation
- result = highlight("#define LONG_MACRO \\\n value")
-
- # Preprocessor should capture everything including the line continuation
- assert_includes result, '#define LONG_MACRO \\'
- assert_includes result, ' value'
- end
-
- def test_single_line_comment
- result = highlight('// This is a comment')
-
- assert_includes result, ''
- end
-
- def test_multi_line_comment
- result = highlight('/* Multi\nline\ncomment */')
-
- assert_includes result, ''
- end
-
- def test_string_literals
- result = highlight('"hello world"')
-
- assert_includes result, '"hello world"'
- end
-
- def test_string_with_escapes
- result = highlight('"hello \"world\""')
-
- assert_includes result, '"hello \"world\""'
- end
-
- def test_character_literals
- result = highlight("'a'")
-
- assert_includes result, "'a'"
- end
-
- def test_character_literals_with_escapes
- result = highlight("'\\n' '\\\\' '\\''")
-
- assert_includes result, "'\\n'"
- assert_includes result, "'\\\\'"
- assert_includes result, "'\\''"
- end
-
- def test_decimal_numbers
- result = highlight('42 3.14 2.5e10')
-
- assert_includes result, '42'
- assert_includes result, '3.14'
- assert_includes result, '2.5e10'
- end
-
- def test_hexadecimal_numbers
- result = highlight('0xFF 0xDEADBEEF')
-
- assert_includes result, '0xFF'
- assert_includes result, '0xDEADBEEF'
- end
-
- def test_octal_numbers
- result = highlight('0755')
-
- assert_includes result, '0755'
- end
-
- def test_number_suffixes
- result = highlight('42u 3.14f 100L')
-
- assert_includes result, '42u'
- assert_includes result, '3.14f'
- assert_includes result, '100L'
- end
-
- def test_html_escaping
- result = highlight('if (x < 5 && y > 10) {}')
-
- assert_includes result, '<'
- assert_includes result, '>'
- assert_includes result, '&&'
- end
-
- def test_complex_c_code
- code = <<~C
- #include
-
- /* Main function */
- int main() {
- char *str = "Hello, World!";
- int x = 0xFF;
-
- // Print the string
- if (x > 0) {
- printf("%s\\n", str);
- }
- return 0;
- }
- C
-
- result = highlight(code)
-
- # Verify key components are highlighted
- assert_includes result, '#include <stdio.h>'
- assert_includes result, ''
- assert_includes result, 'int'
- assert_includes result, 'char'
- assert_includes result, 'return'
- assert_includes result, 'main'
- assert_includes result, 'printf'
- assert_includes result, '"Hello, World!"'
- assert_includes result, '0xFF'
- assert_includes result, '0'
- assert_includes result, ''
- end
-
- def test_empty_code
- result = highlight('')
-
- assert_equal '', result
- end
-
- def test_only_whitespace
- result = highlight(" \n\t \n ")
-
- assert_equal " \n\t \n ", result
- end
-
- def test_c11_keywords
- result = highlight('_Atomic _Bool _Complex _Thread_local')
-
- assert_includes result, '_Atomic'
- assert_includes result, '_Bool'
- assert_includes result, '_Complex'
- assert_includes result, '_Thread_local'
- end
-
- def test_preprocessor_only_at_line_start
- # # in the middle of code should not be treated as preprocessor
- result = highlight('int x = 5 # 3;')
-
- refute_includes result, ''
- # The # should be treated as regular text
- assert_includes result, '#'
- end
-
- def test_typical_function_definition
- code = <<~C
- static VALUE
- rb_ary_all_p(int argc, VALUE *argv, VALUE ary)
- {
- long i;
-
- for (i = 0; i < RARRAY_LEN(ary); i++) {
- if (!RTEST(RARRAY_AREF(ary, i))) {
- return Qfalse;
- }
- }
- return Qtrue;
- }
- C
-
- result = highlight(code)
-
- # Verify it highlights correctly without errors
- assert_includes result, 'static'
- assert_includes result, 'VALUE'
- assert_includes result, 'rb_ary_all_p'
- assert_includes result, 'int'
- assert_includes result, 'long'
- assert_includes result, 'for'
- assert_includes result, 'return'
- assert_includes result, 'Qtrue'
- assert_includes result, 'Qfalse'
- assert_includes result, 'RARRAY_LEN'
- assert_includes result, 'RTEST'
- assert_includes result, 'RARRAY_AREF'
- end
-
- def test_macros_all_caps
- result = highlight('RARRAY_LEN(ary) ARY_EMBED_P(x) FL_UNSET_EMBED')
-
- assert_includes result, 'RARRAY_LEN'
- assert_includes result, 'ARY_EMBED_P'
- assert_includes result, 'FL_UNSET_EMBED'
- end
-
- def test_types_common_ruby_types
- result = highlight('VALUE obj; ID name; size_t len;')
-
- assert_includes result, 'VALUE'
- assert_includes result, 'ID'
- assert_includes result, 'size_t'
- end
-
- def test_types_with_t_suffix
- result = highlight('uint32_t count; ssize_t result; ptrdiff_t diff;')
-
- assert_includes result, 'uint32_t'
- assert_includes result, 'ssize_t'
- assert_includes result, 'ptrdiff_t'
- end
-
- def test_function_names
- result = highlight('rb_ary_replace(copy, orig); ary_memcpy(dest, src);')
-
- assert_includes result, 'rb_ary_replace'
- assert_includes result, 'ary_memcpy'
- end
-
- def test_function_definition
- result = highlight('VALUE rb_ary_new(void) {')
-
- assert_includes result, 'VALUE'
- assert_includes result, 'rb_ary_new'
- assert_includes result, 'void'
- end
-
- def test_variable_names
- result = highlight('int count = 5; char *ptr = NULL;')
-
- assert_includes result, 'count'
- assert_includes result, 'ptr'
- assert_includes result, 'NULL'
- end
-
- def test_mixed_identifiers_in_expression
- result = highlight('if (ARY_EMBED_P(ary) && len > 0)')
-
- assert_includes result, 'ARY_EMBED_P'
- assert_includes result, 'ary'
- assert_includes result, 'len'
- end
-
- def test_macro_vs_function_distinction
- # Macros are ALL_CAPS, functions are not
- result = highlight('RARRAY_LEN(ary) vs ary_length(ary)')
-
- assert_includes result, 'RARRAY_LEN'
- assert_includes result, 'ary_length'
- end
-
- private
-
- def highlight(code)
- @context.eval("highlightC(#{code.to_json})")
- end
-end
diff --git a/test/rdoc/generator/aliki/search_test.rb b/test/rdoc/generator/aliki/search_test.rb
deleted file mode 100644
index 5f9d72bb39..0000000000
--- a/test/rdoc/generator/aliki/search_test.rb
+++ /dev/null
@@ -1,555 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../support/test_case'
-
-return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/
-
-begin
- require 'mini_racer'
-rescue LoadError
- return
-end
-
-class RDocGeneratorAlikiSearchRankerTest < Test::Unit::TestCase
- def setup
- @context = MiniRacer::Context.new
-
- search_ranker_js_path = File.expand_path(
- '../../../../lib/rdoc/generator/template/aliki/js/search_ranker.js',
- __dir__
- )
- search_ranker_js = File.read(search_ranker_js_path)
- @context.eval(search_ranker_js)
- end
-
- def teardown
- @context.dispose
- end
-
- # Minimum query length requirement (1 character)
- def test_minimum_query_length_works_for_single_char
- results = run_search(
- query: 'H',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' }
- ]
- )
-
- assert_equal 2, results.length
- end
-
- def test_minimum_query_length_works_for_two_chars
- results = run_search(
- query: 'Ha',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal 'Hash', results[0]['name']
- end
-
- # Prefix matching ranks higher than substring matching
- def test_prefix_match_ranks_higher_than_substring_match
- results = run_search(
- query: 'Ha',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'Aha', full_name: 'Aha', type: 'class', path: 'Aha.html' }
- ]
- )
-
- assert_equal 2, results.length
- assert_equal 'Hash', results[0]['name'], "Prefix match should rank first"
- assert_equal 'Aha', results[1]['name'], "Substring match should rank second"
- end
-
- # Substring matching support
- def test_substring_match_finds_suffix_matches
- results = run_search(
- query: 'ter',
- data: [
- { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal 'filter', results[0]['name']
- end
-
- # Fuzzy matching support (characters in order)
- def test_fuzzy_match_finds_non_contiguous_matches
- results = run_search(
- query: 'addalias',
- data: [
- { name: 'add_foo_alias', full_name: 'RDoc::Context#add_foo_alias', type: 'instance_method', path: 'x' },
- { name: 'add_alias', full_name: 'RDoc::Context#add_alias', type: 'instance_method', path: 'x' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'x' }
- ]
- )
-
- assert_equal 2, results.length
- # Both are fuzzy matches; shorter name wins
- assert_equal 'add_alias', results[0]['name']
- assert_equal 'add_foo_alias', results[1]['name']
- end
-
- # Case-based type priority: uppercase query prioritizes classes/modules
- def test_uppercase_query_prioritizes_class_over_method
- results = run_search(
- query: 'Hash',
- data: [
- { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }
- ]
- )
-
- assert_equal 2, results.length
- assert_equal 'Hash', results[0]['name']
- assert_equal 'class', results[0]['type']
- assert_equal 'hash', results[1]['name']
- end
-
- # Case-based type priority: lowercase query prioritizes methods
- def test_lowercase_query_prioritizes_method_over_class
- results = run_search(
- query: 'hash',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }
- ]
- )
-
- assert_equal 2, results.length
- assert_equal 'hash', results[0]['name']
- assert_equal 'instance_method', results[0]['type']
- assert_equal 'Hash', results[1]['name']
- end
-
- # Unqualified match > qualified match
- def test_unqualified_match_prioritized_over_qualified
- results = run_search(
- query: 'Foo',
- data: [
- { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' },
- { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' }
- ]
- )
-
- assert_equal 2, results.length
- assert_equal 'Foo', results[0]['full_name']
- assert_equal 'Bar::Foo', results[1]['full_name']
- end
-
- # Shorter name > longer name
- def test_shorter_name_prioritized
- results = run_search(
- query: 'Hash',
- data: [
- { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' }
- ]
- )
-
- assert_equal 3, results.length
- assert_equal 'Hash', results[0]['name']
- assert_equal 'Hashable', results[1]['name']
- assert_equal 'HashWithIndifferentAccess', results[2]['name']
- end
-
- # Class method > instance method
- def test_class_method_prioritized_over_instance_method
- results = run_search(
- query: 'hash',
- data: [
- { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
- { name: 'hash', full_name: 'Digest::Base::hash', type: 'class_method', path: 'Digest/Base.html#hash' }
- ]
- )
-
- assert_equal 2, results.length
- assert_equal 'class_method', results[0]['type']
- assert_equal 'instance_method', results[1]['type']
- end
-
- # Exact full match wins
- def test_exact_full_name_match_wins
- results = run_search(
- query: 'Bar::Foo',
- data: [
- { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' },
- { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' },
- { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal 'Bar::Foo', results[0]['full_name']
- end
-
- # Namespace query matches within namespace
- def test_namespace_query_matches_namespace
- results = run_search(
- query: 'Foo::B',
- data: [
- { name: 'Bar', full_name: 'Bar', type: 'class', path: 'Bar.html' },
- { name: 'Bar', full_name: 'Foo::Bar', type: 'class', path: 'Foo/Bar.html' },
- { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' }
- ]
- )
-
- assert_equal 2, results.length
- names = results.map { |r| r['full_name'] }
- assert_includes names, 'Foo::Bar'
- assert_includes names, 'Foo::Baz'
- end
-
- # Method query with # matches against full_name
- def test_instance_method_query_matches_full_name
- results = run_search(
- query: 'Array#filter',
- data: [
- { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
- { name: 'filter', full_name: 'Enumerable#filter', type: 'instance_method', path: 'Enumerable.html#filter' },
- { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal 'Array#filter', results[0]['full_name']
- end
-
- # Method query with . matches against full_name (class methods)
- # Note: RDoc uses :: for class methods in full_name, but users may type . (Ruby convention)
- # The search normalizes . to :: so "Array.try_convert" matches "Array::try_convert"
- def test_class_method_query_matches_full_name
- results = run_search(
- query: 'Array.try_convert',
- data: [
- { name: 'try_convert', full_name: 'Array::try_convert', type: 'class_method', path: 'Array.html#try_convert' },
- { name: 'try_convert', full_name: 'Hash::try_convert', type: 'class_method', path: 'Hash.html#try_convert' },
- { name: 'try_convert', full_name: 'String::try_convert', type: 'class_method', path: 'String.html#try_convert' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal 'Array::try_convert', results[0]['full_name']
- end
-
- # Method query prefix matching against full_name
- def test_method_query_prefix_matching
- results = run_search(
- query: 'Array#fi',
- data: [
- { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
- { name: 'find', full_name: 'Array#find', type: 'instance_method', path: 'Array.html#find' },
- { name: 'first', full_name: 'Array#first', type: 'instance_method', path: 'Array.html#first' },
- { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' }
- ]
- )
-
- assert_equal 3, results.length
- full_names = results.map { |r| r['full_name'] }
- assert_includes full_names, 'Array#filter'
- assert_includes full_names, 'Array#find'
- assert_includes full_names, 'Array#first'
- refute_includes full_names, 'Hash#filter'
- end
-
- # Method query substring matching against full_name
- def test_method_query_substring_matching
- results = run_search(
- query: '#filter',
- data: [
- { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' },
- { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' },
- { name: 'filter_map', full_name: 'Array#filter_map', type: 'instance_method', path: 'Array.html#filter_map' }
- ]
- )
-
- assert_equal 3, results.length
- # All entries contain #filter in their full_name
- results.each do |r|
- assert_match(/#filter/, r['full_name'])
- end
- end
-
- # Special characters
- def test_special_characters_searchable
- results = run_search(
- query: '<<',
- data: [
- { name: '<<', full_name: 'Array#<<', type: 'instance_method', path: 'Array.html#<<' },
- { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal '<<', results[0]['name']
- end
-
- def test_bracket_method_searchable
- results = run_search(
- query: '[]',
- data: [
- { name: '[]', full_name: 'Hash#[]', type: 'instance_method', path: 'Hash.html#[]' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal '[]', results[0]['name']
- end
-
- # Result limit
- def test_result_limit_30
- data = 50.times.map do |i|
- { name: "Test#{i}", full_name: "Test#{i}", type: 'class', path: "Test#{i}.html" }
- end
-
- results = run_search(query: 'Test', data: data)
-
- assert_equal 30, results.length
- end
-
- # Empty query
- def test_empty_query_returns_empty
- results = run_search(
- query: '',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }
- ]
- )
-
- assert_equal [], results
- end
-
- # No matches
- def test_no_matches_returns_empty
- results = run_search(
- query: 'xyz',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' }
- ]
- )
-
- assert_equal [], results
- end
-
- # Case insensitive matching
- def test_case_insensitive_matching
- results = run_search(
- query: 'HASH',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }
- ]
- )
-
- assert_equal 1, results.length
- assert_equal 'Hash', results[0]['name']
- end
-
- # Constant search
- def test_constant_search
- results = run_search(
- query: 'VER',
- data: [
- { name: 'VERSION', full_name: 'RDoc::VERSION', type: 'constant', path: 'RDoc.html' },
- { name: 'Verifier', full_name: 'Verifier', type: 'class', path: 'Verifier.html' }
- ]
- )
-
- assert_equal 2, results.length
- # Verifier is unqualified (name == full_name), VERSION is qualified (RDoc::VERSION)
- # Unqualified wins over qualified, then shorter wins within same qualification
- assert_equal 'Verifier', results[0]['name']
- assert_equal 'VERSION', results[1]['name']
- end
-
- # Exact name match should win over prefix match
- def test_exact_name_match_beats_prefix_match
- results = run_search(
- query: 'RDoc',
- data: [
- { name: 'rdoc_version', full_name: 'RDoc::RubygemsHook#rdoc_version', type: 'instance_method', path: 'RDoc/RubygemsHook.html#rdoc_version' },
- { name: 'RDoc', full_name: 'RDoc', type: 'module', path: 'RDoc.html' },
- { name: 'RDoc', full_name: 'RDoc::RDoc', type: 'class', path: 'RDoc/RDoc.html' }
- ]
- )
-
- assert_equal 3, results.length
- # Exact name matches should come first
- assert_equal 'RDoc', results[0]['full_name'], "Expected top-level RDoc module first"
- assert_equal 'RDoc::RDoc', results[1]['full_name'], "Expected RDoc::RDoc class second"
- assert_equal 'RDoc::RubygemsHook#rdoc_version', results[2]['full_name'], "Expected rdoc_version method last"
- end
-
- # Hash class should rank higher than #hash methods when searching "Hash"
- # This replicates the bug where methods appear before the class
- def test_hash_class_ranks_higher_than_hash_methods
- results = run_search(
- query: 'Hash',
- data: [
- { name: 'hash', full_name: 'Gem::Resolver::IndexSpecification#hash', type: 'instance_method', path: 'Gem/Resolver/IndexSpecification.html#hash' },
- { name: 'hash', full_name: 'URI::Generic#hash', type: 'instance_method', path: 'URI/Generic.html#hash' },
- { name: 'hash', full_name: 'Struct#hash', type: 'instance_method', path: 'Struct.html#hash' },
- { name: 'hash', full_name: 'Regexp#hash', type: 'instance_method', path: 'Regexp.html#hash' },
- { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
- { name: 'hash', full_name: 'Time#hash', type: 'instance_method', path: 'Time.html#hash' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'Hash', full_name: 'Gem::SafeMarshal::Elements::Hash', type: 'class', path: 'Gem/SafeMarshal/Elements/Hash.html' }
- ]
- )
-
- assert_equal 8, results.length
- # Hash class should come first (uppercase query + exact name match + unqualified)
- assert_equal 'Hash', results[0]['full_name'], "Expected Hash class first"
- # Nested Hash class second (uppercase query + exact name match, but qualified)
- assert_equal 'Gem::SafeMarshal::Elements::Hash', results[1]['full_name'], "Expected nested Hash class second"
- # Methods should come after classes
- assert_equal 'instance_method', results[2]['type'], "Expected methods after classes"
- end
-
- # Combined priority test matching user's expectation
- # User query: "Ha"
- # Expected order: Hash, Hashable, HashWithIndifferentAccess, Foo::Hash, #hash
- def test_combined_priority_matching_user_expectation
- results = run_search(
- query: 'Ha',
- data: [
- { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
- { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' },
- { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' },
- { name: 'Hash', full_name: 'Foo::Hash', type: 'class', path: 'Foo/Hash.html' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }
- ]
- )
-
- assert_equal 5, results.length
- # Order: class/module first, unqualified before qualified, shorter before longer, methods last
- assert_equal 'Hash', results[0]['full_name'], "Expected top-level Hash first"
- assert_equal 'Hashable', results[1]['full_name'], "Expected Hashable second (shorter unqualified)"
- assert_equal 'HashWithIndifferentAccess', results[2]['full_name'], "Expected HashWithIndifferentAccess third"
- assert_equal 'Foo::Hash', results[3]['full_name'], "Expected Foo::Hash fourth (qualified)"
- assert_equal 'Object#hash', results[4]['full_name'], "Expected method last"
- end
-
- private
-
- def run_search(query:, data:)
- @context.eval("search(#{query.to_json}, #{data.to_json})")
- end
-end
-
-# Integration test that goes through SearchController to catch bugs like
-# the query being lowercased before reaching the ranker
-class RDocGeneratorAlikiSearchControllerTest < Test::Unit::TestCase
- def setup
- @context = MiniRacer::Context.new
-
- # Mock DOM elements and document BEFORE loading JS files
- @context.eval(<<~JS)
- var document = {
- addEventListener: function() {}
- };
- var mockInput = {
- value: '',
- addEventListener: function() {},
- setAttribute: function() {},
- select: function() {}
- };
- var mockResult = {
- innerHTML: '',
- parentNode: { scrollTop: 0, offsetHeight: 100 },
- childElementCount: 0,
- firstChild: null,
- setAttribute: function() {},
- appendChild: function(item) {
- this.childElementCount++;
- if (!this.firstChild) this.firstChild = item;
- }
- };
- JS
-
- # Load all search-related JS files in order
- js_dir = File.expand_path('../../../../lib/rdoc/generator/template/aliki/js', __dir__)
-
- @context.eval(File.read(File.join(js_dir, 'search_navigation.js')))
- @context.eval(File.read(File.join(js_dir, 'search_ranker.js')))
- @context.eval(File.read(File.join(js_dir, 'search_controller.js')))
- end
-
- def teardown
- @context.dispose
- end
-
- # This test catches the bug where SearchController.search() was lowercasing
- # the query before passing it to the ranker, breaking case-based type priority
- def test_uppercase_query_preserves_case_for_type_priority
- results = run_search_through_controller(
- query: 'Hash',
- data: [
- { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' },
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }
- ]
- )
-
- assert_equal 2, results.length
- # With uppercase "Hash" query, class should come first due to type priority
- assert_equal 'Hash', results[0]['full_name'], "Expected Hash class first with uppercase query"
- assert_equal 'Object#hash', results[1]['full_name'], "Expected hash method second"
- end
-
- def test_lowercase_query_prioritizes_methods
- results = run_search_through_controller(
- query: 'hash',
- data: [
- { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' },
- { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }
- ]
- )
-
- assert_equal 2, results.length
- # With lowercase "hash" query, method should come first due to type priority
- assert_equal 'Object#hash', results[0]['full_name'], "Expected hash method first with lowercase query"
- assert_equal 'Hash', results[1]['full_name'], "Expected Hash class second"
- end
-
- private
-
- def run_search_through_controller(query:, data:)
- # Set up search data
- @context.eval("var search_data = { index: #{data.to_json} };")
-
- # Create SearchController and intercept ranker to capture raw results
- @context.eval(<<~JS)
- var capturedRawResults = [];
- var controller = new SearchController(search_data, mockInput, mockResult);
-
- // Override ranker.find to capture raw results before formatting
- var originalFind = controller.ranker.find.bind(controller.ranker);
- controller.ranker.find = function(query) {
- var rawResults = search(query, this.index);
- capturedRawResults = rawResults;
- // Call original to continue normal flow
- originalFind(query);
- };
-
- controller.renderItem = function(result) {
- return { classList: { add: function() {} }, setAttribute: function() {} };
- };
- JS
-
- # Simulate search
- @context.eval("controller.search(#{query.to_json})")
-
- # Return captured raw results (entries with full_name, type, etc.)
- @context.eval("capturedRawResults")
- end
-end
diff --git a/test/rdoc/generator/json_index/searcher_test.rb b/test/rdoc/generator/json_index/searcher_test.rb
deleted file mode 100644
index d5f0635ef7..0000000000
--- a/test/rdoc/generator/json_index/searcher_test.rb
+++ /dev/null
@@ -1,336 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../helper'
-
-return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/
-
-begin
- require 'mini_racer'
-rescue LoadError
- return
-end
-
-# This test is a simpler setup for testing the searcher.js file without pulling all the JS dependencies.
-# If there are more JS functionalities to test in the future, we can move to use JS test frameworks.
-class RDocGeneratorJsonIndexSearcherTest < Test::Unit::TestCase
- def setup
- @context = MiniRacer::Context.new
-
- searcher_js_path = File.expand_path(
- '../../../../lib/rdoc/generator/template/json_index/js/searcher.js',
- __dir__
- )
- searcher_js = File.read(searcher_js_path)
- @context.eval(searcher_js)
- end
-
- def teardown
- @context.dispose
- end
-
- def test_exact_match_prioritized
- results = run_search(
- query: 'string',
- data: {
- searchIndex: ['string', 'string', 'strings'],
- longSearchIndex: ['gem::safemarshal::elements::string', 'string', 'strings'],
- info: [
- ['String', 'Gem::SafeMarshal::Elements', 'Gem/SafeMarshal/Elements/String.html', '', 'Nested String class', '', 'class'],
- ['String', '', 'String.html', '', 'Top-level String class', '', 'class'],
- ['Strings', '', 'Strings.html', '', 'Strings class', '', 'class']
- ]
- }
- )
-
- assert_equal 3, results.length
- # Top-level String should come first despite being second in the array
- assert_equal 'String', strip_highlights(results[0]['title'])
- assert_equal '', results[0]['namespace'], 'Top-level String should be prioritized over nested String'
- assert_equal 'String.html', results[0]['path']
-
- # Nested String should come second
- assert_equal 'String', strip_highlights(results[1]['title'])
- assert_equal 'Gem::SafeMarshal::Elements', strip_highlights(results[1]['namespace'])
- end
-
- def test_exact_method_match
- results = run_search(
- query: 'attribute',
- data: {
- searchIndex: ['attributemanager', 'attributes', 'attribute()'],
- longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::markup::attributes', 'rdoc::markup::attributemanager#attribute()'],
- info: [
- ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'AttributeManager class', '', 'class'],
- ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'],
- ['attribute', 'RDoc::Markup::AttributeManager', 'RDoc/Markup/AttributeManager.html#method-i-attribute', '()', 'Attribute method', '', 'method']
- ]
- }
- )
-
- assert_equal 3, results.length
- # attribute() method should come first despite being last in the array
- assert_equal 'attribute', strip_highlights(results[0]['title'])
- assert_equal 'RDoc::Markup::AttributeManager', strip_highlights(results[0]['namespace'])
- end
-
- def test_capitalized_query_prioritizes_exact_class
- results = run_search(
- query: 'String',
- data: {
- searchIndex: ['string', 'string()'],
- longSearchIndex: ['string', 'object#string()'],
- info: [
- ['String', '', 'String.html', '', 'String class', '', 'class'],
- ['string', 'Object', 'Object.html#method-i-string', '()', 'String method', '', 'method']
- ]
- }
- )
-
- assert_equal 2, results.length
- # Capitalized query: exact class (Pass 0) beats exact method (Pass 1)
- assert_equal 'String', strip_highlights(results[0]['title'])
- assert_equal '', results[0]['namespace']
- assert_equal 'String.html', results[0]['path']
-
- # Method comes second
- assert_equal 'string', strip_highlights(results[1]['title'])
- assert_equal 'Object', strip_highlights(results[1]['namespace'])
- end
-
- def test_lowercase_query_prioritizes_method
- results = run_search(
- query: 'options',
- data: {
- searchIndex: ['options', 'options()'],
- longSearchIndex: ['rdoc::options', 'rdoc::codeobject#options()'],
- info: [
- ['Options', 'RDoc', 'RDoc/Options.html', '', 'Options class', '', 'class'],
- ['options', 'RDoc::CodeObject', 'RDoc/CodeObject.html#method-i-options', '()', 'Options method', '', 'method']
- ]
- }
- )
-
- assert_equal 2, results.length
- # Lowercase query should prioritize method over class
- assert_equal 'options', strip_highlights(results[0]['title'])
- assert_equal 'RDoc::CodeObject', strip_highlights(results[0]['namespace'])
- assert_equal 'RDoc/CodeObject.html#method-i-options', results[0]['path']
-
- # Class comes second
- assert_equal 'Options', strip_highlights(results[1]['title'])
- assert_equal 'RDoc', strip_highlights(results[1]['namespace'])
- end
-
- def test_beginning_match
- results = run_search(
- query: 'attr',
- data: {
- searchIndex: ['attribute()', 'attributemanager', 'generator'],
- longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::markup::attributemanager', 'rdoc::generator'],
- info: [
- ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'],
- ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'],
- ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class']
- ]
- }
- )
-
- assert_equal 2, results.length
- assert_equal 'attribute', strip_highlights(results[0]['title'])
- assert_equal 'AttributeManager', strip_highlights(results[1]['title'])
- end
-
- def test_long_index_match
- results = run_search(
- query: 'rdoc::markup',
- data: {
- searchIndex: ['attributes', 'parser'],
- longSearchIndex: ['rdoc::markup::attributes', 'rdoc::parser'],
- info: [
- ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'],
- ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class']
- ]
- }
- )
-
- assert_equal 1, results.length
- assert_equal 'Attributes', strip_highlights(results[0]['title'])
- assert_equal 'RDoc::Markup', strip_highlights(results[0]['namespace'])
- end
-
- def test_contains_match
- results = run_search(
- query: 'manager',
- data: {
- searchIndex: ['attributemanager', 'parser'],
- longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::parser'],
- info: [
- ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'],
- ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class']
- ]
- }
- )
-
- assert_equal 1, results.length
- assert_equal 'AttributeManager', strip_highlights(results[0]['title'])
- end
-
- def test_regexp_match
- results = run_search(
- query: 'atrbt',
- data: {
- searchIndex: ['attribute()', 'generator'],
- longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::generator'],
- info: [
- ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'],
- ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class']
- ]
- }
- )
-
- assert_equal 1, results.length
- assert_equal 'attribute', strip_highlights(results[0]['title'])
- end
-
- def test_empty_query
- results = run_search(
- query: '',
- data: {
- searchIndex: ['string'],
- longSearchIndex: ['string'],
- info: [['String', '', 'String.html', '', 'String class', '', 'class']]
- }
- )
-
- assert_equal 0, results.length
- end
-
- def test_no_matches
- results = run_search(
- query: 'nonexistent',
- data: {
- searchIndex: ['string', 'attribute()'],
- longSearchIndex: ['string', 'rdoc#attribute()'],
- info: [
- ['String', '', 'String.html', '', 'String class', '', 'class'],
- ['attribute', 'RDoc', 'RDoc.html#attribute', '()', 'Attribute method', '', 'method']
- ]
- }
- )
-
- assert_equal 0, results.length
- end
-
- def test_multiple_exact_matches
- results = run_search(
- query: 'test',
- data: {
- searchIndex: ['test', 'test', 'testing'],
- longSearchIndex: ['test', 'rdoc::test', 'testing'],
- info: [
- ['Test', '', 'Test.html', '', 'Top-level Test', '', 'class'],
- ['Test', 'RDoc', 'RDoc/Test.html', '', 'RDoc Test', '', 'class'],
- ['Testing', '', 'Testing.html', '', 'Testing class', '', 'class']
- ]
- }
- )
-
- assert_equal 3, results.length
- # First result should be the exact match with both indexes matching
- assert_equal 'Test', strip_highlights(results[0]['title'])
- assert_equal '', results[0]['namespace']
- end
-
- # Test case insensitive search
- def test_case_insensitive
- results = run_search(
- query: 'STRING',
- data: {
- searchIndex: ['string'],
- longSearchIndex: ['string'],
- info: [['String', '', 'String.html', '', 'String class', '', 'class']]
- }
- )
-
- assert_equal 1, results.length
- assert_equal 'String', strip_highlights(results[0]['title'])
- end
-
- def test_multi_word_query
- results = run_search(
- query: 'rdoc markup',
- data: {
- searchIndex: ['attributemanager'],
- longSearchIndex: ['rdoc::markup::attributemanager'],
- info: [['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager', '', 'class']]
- }
- )
-
- assert_equal 1, results.length
- assert_equal 'AttributeManager', results[0]['title']
- end
-
- def test_highlighting
- results = run_search(
- query: 'string',
- data: {
- searchIndex: ['string'],
- longSearchIndex: ['string'],
- info: [['String', '', 'String.html', '', 'String class', '', 'class']]
- }
- )
-
- assert_equal 1, results.length
- # Check that highlighting markers (unicode \u0001 and \u0002) are present
- assert_match(/[\u0001\u0002]/, results[0]['title'])
- end
-
- def test_max_results_limit
- # Create 150 entries (more than MAX_RESULTS = 100)
- search_index = []
- long_search_index = []
- info = []
-
- 150.times do |i|
- search_index << "test#{i}"
- long_search_index << "test#{i}"
- info << ["Test#{i}", '', "Test#{i}.html", '', "Test class #{i}", '', 'class']
- end
-
- results = run_search(
- query: 'test',
- data: {
- searchIndex: search_index,
- longSearchIndex: long_search_index,
- info: info
- }
- )
-
- # Should return at most 100 results
- assert_operator results.length, :<=, 100
- end
-
- private
-
- def run_search(query:, data:)
- @context.eval("var testResults = [];")
- @context.eval(<<~JS)
- var data = #{data.to_json};
- var searcher = new Searcher(data);
- searcher.ready(function(res, isLast) {
- testResults = testResults.concat(res);
- });
- searcher.find(#{query.to_json});
- JS
-
- # Give some time for async operations
- sleep 0.01
-
- @context.eval('testResults')
- end
-
- # Helper to strip highlighting markers from a string
- def strip_highlights(str)
- str.gsub(/[\u0001\u0002]/, '')
- end
-end