diff --git a/lib/rdoc/code_object/class_module.rb b/lib/rdoc/code_object/class_module.rb
index f6b0abb2f5..a4c5fec8c8 100644
--- a/lib/rdoc/code_object/class_module.rb
+++ b/lib/rdoc/code_object/class_module.rb
@@ -689,6 +689,9 @@ def remove_things(my_things, other_files) # :nodoc:
##
# Search record used by RDoc::Generator::JsonIndex
+ #
+ # TODO: Remove this method after dropping the darkfish theme and JsonIndex generator.
+ # Use #search_snippet instead for getting documentation snippets.
def search_record
[
@@ -702,6 +705,16 @@ def search_record
]
end
+ ##
+ # Returns an HTML snippet of the first comment for search results.
+
+ def search_snippet
+ first_comment = @comment_location.first&.first
+ return '' unless first_comment && !first_comment.empty?
+
+ snippet(first_comment)
+ end
+
##
# Sets the store for this class or module and its contained code objects.
diff --git a/lib/rdoc/code_object/constant.rb b/lib/rdoc/code_object/constant.rb
index d5f54edb67..8823b0bd67 100644
--- a/lib/rdoc/code_object/constant.rb
+++ b/lib/rdoc/code_object/constant.rb
@@ -154,6 +154,15 @@ def path
"#{@parent.path}##{@name}"
end
+ ##
+ # Returns an HTML snippet of the comment for search results.
+
+ def search_snippet
+ return '' if comment.empty?
+
+ snippet(comment)
+ end
+
def pretty_print(q) # :nodoc:
q.group 2, "[#{self.class.name} #{full_name}", "]" do
unless comment.empty? then
diff --git a/lib/rdoc/code_object/method_attr.rb b/lib/rdoc/code_object/method_attr.rb
index 16779fa918..3169640982 100644
--- a/lib/rdoc/code_object/method_attr.rb
+++ b/lib/rdoc/code_object/method_attr.rb
@@ -375,6 +375,9 @@ def pretty_print(q) # :nodoc:
##
# Used by RDoc::Generator::JsonIndex to create a record for the search
# engine.
+ #
+ # TODO: Remove this method after dropping the darkfish theme and JsonIndex generator.
+ # Use #search_snippet instead for getting documentation snippets.
def search_record
[
@@ -384,10 +387,19 @@ def search_record
@parent.full_name,
path,
params,
- snippet(@comment),
+ search_snippet,
]
end
+ ##
+ # Returns an HTML snippet of the comment for search results.
+
+ def search_snippet
+ return '' if @comment.empty?
+
+ snippet(@comment)
+ end
+
def to_s # :nodoc:
if @is_alias_for
"#{self.class.name}: #{full_name} -> #{is_alias_for}"
diff --git a/lib/rdoc/code_object/top_level.rb b/lib/rdoc/code_object/top_level.rb
index c1c003130e..553f45884c 100644
--- a/lib/rdoc/code_object/top_level.rb
+++ b/lib/rdoc/code_object/top_level.rb
@@ -237,6 +237,9 @@ def pretty_print(q) # :nodoc:
##
# Search record used by RDoc::Generator::JsonIndex
+ #
+ # TODO: Remove this method after dropping the darkfish theme and JsonIndex generator.
+ # Use #search_snippet instead for getting documentation snippets.
def search_record
return unless @parser < RDoc::Parser::Text
@@ -248,10 +251,19 @@ def search_record
'',
path,
'',
- snippet(@comment),
+ search_snippet,
]
end
+ ##
+ # Returns an HTML snippet of the comment for search results.
+
+ def search_snippet
+ return '' if @comment.empty?
+
+ snippet(@comment)
+ end
+
##
# Is this TopLevel from a text file instead of a source code file?
diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb
index faa310451c..fc62d55f22 100644
--- a/lib/rdoc/generator/aliki.rb
+++ b/lib/rdoc/generator/aliki.rb
@@ -15,6 +15,30 @@ def initialize(store, options)
@template_dir = Pathname.new(aliki_template_dir)
end
+ ##
+ # Generate documentation. Overrides Darkfish to use Aliki's own search index
+ # instead of the JsonIndex generator.
+
+ def generate
+ setup
+
+ write_style_sheet
+ generate_index
+ generate_class_files
+ generate_file_files
+ generate_table_of_contents
+ write_search_index
+
+ copy_static
+
+ rescue => e
+ debug_msg "%s: %s\n %s" % [
+ e.class.name, e.message, e.backtrace.join("\n ")
+ ]
+
+ raise
+ end
+
##
# Copy only the static assets required by the Aliki theme. Unlike Darkfish we
# don't ship embedded fonts or image sprites, so limit the asset list to keep
@@ -39,4 +63,104 @@ def write_style_sheet
install_rdoc_static_file @template_dir + path, dst, options
end
end
+
+ ##
+ # Build a search index array for Aliki's searcher.
+
+ def build_search_index
+ setup
+
+ index = []
+
+ @classes.each do |klass|
+ next unless klass.display?
+
+ index << build_class_module_entry(klass)
+
+ klass.constants.each do |const|
+ next unless const.display?
+
+ index << build_constant_entry(const, klass)
+ end
+ end
+
+ @methods.each do |method|
+ next unless method.display?
+
+ index << build_method_entry(method)
+ end
+
+ index
+ end
+
+ ##
+ # Write the search index as a JavaScript file
+ # Format: var search_data = { index: [...] }
+ #
+ # We still write to a .js instead of a .json because loading a JSON file triggers CORS check in browsers.
+ # And if we simply inspect the generated pages using file://, which is often the case due to lack of the server mode,
+ # the JSON file will be blocked by the browser.
+
+ def write_search_index
+ debug_msg "Writing Aliki search index"
+
+ index = build_search_index
+
+ FileUtils.mkdir_p 'js' unless @dry_run
+
+ search_index_path = 'js/search_data.js'
+ return if @dry_run
+
+ data = { index: index }
+ File.write search_index_path, "var search_data = #{JSON.generate(data)};"
+ end
+
+ private
+
+ def build_class_module_entry(klass)
+ type = case klass
+ when RDoc::NormalClass then 'class'
+ when RDoc::NormalModule then 'module'
+ else 'class'
+ end
+
+ entry = {
+ name: klass.name,
+ full_name: klass.full_name,
+ type: type,
+ path: klass.path
+ }
+
+ snippet = klass.search_snippet
+ entry[:snippet] = snippet unless snippet.empty?
+ entry
+ end
+
+ def build_method_entry(method)
+ type = method.singleton ? 'class_method' : 'instance_method'
+
+ entry = {
+ name: method.name,
+ full_name: method.full_name,
+ type: type,
+ path: method.path
+ }
+
+ snippet = method.search_snippet
+ entry[:snippet] = snippet unless snippet.empty?
+ entry
+ end
+
+ def build_constant_entry(const, parent)
+ entry = {
+ name: const.name,
+ full_name: "#{parent.full_name}::#{const.name}",
+ type: 'constant',
+ path: parent.path
+ }
+
+ snippet = const.search_snippet
+ entry[:snippet] = snippet unless snippet.empty?
+ entry
+ end
end
diff --git a/lib/rdoc/generator/template/aliki/_head.rhtml b/lib/rdoc/generator/template/aliki/_head.rhtml
index d7392f3487..1733bd0174 100644
--- a/lib/rdoc/generator/template/aliki/_head.rhtml
+++ b/lib/rdoc/generator/template/aliki/_head.rhtml
@@ -116,22 +116,22 @@
>
diff --git a/lib/rdoc/generator/template/aliki/css/rdoc.css b/lib/rdoc/generator/template/aliki/css/rdoc.css
index dc8afbccc4..82f7f5e8c5 100644
--- a/lib/rdoc/generator/template/aliki/css/rdoc.css
+++ b/lib/rdoc/generator/template/aliki/css/rdoc.css
@@ -85,6 +85,16 @@
--color-th-background: var(--color-neutral-100);
--color-td-background: var(--color-neutral-50);
+ /* Search Type Badge Colors */
+ --color-search-type-class-bg: #e6f0ff;
+ --color-search-type-class-text: #0050a0;
+ --color-search-type-module-bg: #e6ffe6;
+ --color-search-type-module-text: #060;
+ --color-search-type-constant-bg: #fff0e6;
+ --color-search-type-constant-text: #995200;
+ --color-search-type-method-bg: #f0e6ff;
+ --color-search-type-method-text: #5200a0;
+
/* RGBA Colors (theme-agnostic) */
--color-overlay: rgb(0 0 0 / 50%);
--color-emphasis-bg: rgb(255 111 97 / 10%);
@@ -218,6 +228,16 @@
--color-th-background: var(--color-background-tertiary);
--color-td-background: var(--color-background-secondary);
+ /* Search Type Badge Colors - Dark Theme */
+ --color-search-type-class-bg: #1e3a5f;
+ --color-search-type-class-text: #93c5fd;
+ --color-search-type-module-bg: #14532d;
+ --color-search-type-module-text: #86efac;
+ --color-search-type-constant-bg: #451a03;
+ --color-search-type-constant-text: #fcd34d;
+ --color-search-type-method-bg: #3b0764;
+ --color-search-type-method-text: #d8b4fe;
+
/* Dark theme shadows (slightly more subtle) */
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 40%), 0 1px 2px -1px rgb(0 0 0 / 40%);
--shadow-md: 0 2px 8px rgb(0 0 0 / 40%);
@@ -1834,6 +1854,39 @@ footer.site-footer .footer-bottom:first-child {
font-weight: bold;
}
+#search-results .search-type {
+ display: inline-block;
+ margin-left: var(--space-2);
+ padding: 0 var(--space-2);
+ font-size: var(--font-size-xs);
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+ vertical-align: middle;
+ background: var(--color-background-tertiary);
+ color: var(--color-text-secondary);
+}
+
+#search-results .search-type-class {
+ background: var(--color-search-type-class-bg);
+ color: var(--color-search-type-class-text);
+}
+
+#search-results .search-type-module {
+ background: var(--color-search-type-module-bg);
+ color: var(--color-search-type-module-text);
+}
+
+#search-results .search-type-constant {
+ background: var(--color-search-type-constant-bg);
+ color: var(--color-search-type-constant-text);
+}
+
+#search-results .search-type-instance-method,
+#search-results .search-type-class-method {
+ background: var(--color-search-type-method-bg);
+ color: var(--color-search-type-method-text);
+}
+
#search-results li em {
background-color: var(--color-search-highlight-bg);
font-style: normal;
diff --git a/lib/rdoc/generator/template/aliki/js/aliki.js b/lib/rdoc/generator/template/aliki/js/aliki.js
index 6f0dce8951..631bd585cb 100644
--- a/lib/rdoc/generator/template/aliki/js/aliki.js
+++ b/lib/rdoc/generator/template/aliki/js/aliki.js
@@ -28,7 +28,7 @@ function createSearchInstance(input, result) {
result.classList.remove("initially-hidden");
- const search = new Search(search_data, input, result);
+ const search = new SearchController(search_data, input, result);
search.renderItem = function(result) {
const li = document.createElement('li');
@@ -40,8 +40,12 @@ function createSearchInstance(input, result) {
html += `${result.params}`;
html += '';
- if (result.namespace)
- html += `
${this.hlt(result.namespace)}`;
+ // Add type indicator
+ if (result.type) {
+ const typeLabel = this.formatType(result.type);
+ const typeClass = result.type.replace(/_/g, '-');
+ html += `${typeLabel}`;
+ }
if (result.snippet)
html += `
${result.snippet}
`;
@@ -51,6 +55,17 @@ function createSearchInstance(input, result) {
return li;
}
+ search.formatType = function(type) {
+ const typeLabels = {
+ 'class': 'class',
+ 'module': 'module',
+ 'constant': 'const',
+ 'instance_method': 'method',
+ 'class_method': 'method'
+ };
+ return typeLabels[type] || type;
+ }
+
search.select = function(result) {
let href = result.firstChild.firstChild.href;
const query = this.input.value;
diff --git a/lib/rdoc/generator/template/aliki/js/search.js b/lib/rdoc/generator/template/aliki/js/search_controller.js
similarity index 91%
rename from lib/rdoc/generator/template/aliki/js/search.js
rename to lib/rdoc/generator/template/aliki/js/search_controller.js
index 68e1f77ff8..02078e0a65 100644
--- a/lib/rdoc/generator/template/aliki/js/search.js
+++ b/lib/rdoc/generator/template/aliki/js/search_controller.js
@@ -1,15 +1,15 @@
-Search = function(data, input, result) {
+SearchController = function(data, input, result) {
this.data = data;
this.input = input;
this.result = result;
this.current = null;
this.view = this.result.parentNode;
- this.searcher = new Searcher(data.index);
+ this.ranker = new SearchRanker(data.index);
this.init();
}
-Search.prototype = Object.assign({}, Navigation, new function() {
+SearchController.prototype = Object.assign({}, SearchNavigation, new function() {
var suid = 1;
this.init = function() {
@@ -25,7 +25,7 @@ Search.prototype = Object.assign({}, Navigation, new function() {
this.input.addEventListener('keyup', observer);
this.input.addEventListener('click', observer); // mac's clear field
- this.searcher.ready(function(results, isLast) {
+ this.ranker.ready(function(results, isLast) {
_this.addResults(results, isLast);
})
@@ -36,7 +36,7 @@ Search.prototype = Object.assign({}, Navigation, new function() {
this.search = function(value, selectFirstMatch) {
this.selectFirstMatch = selectFirstMatch;
- value = value.trim().toLowerCase();
+ value = value.trim();
if (value) {
this.setNavigationActive(true);
} else {
@@ -53,7 +53,7 @@ Search.prototype = Object.assign({}, Navigation, new function() {
this.result.setAttribute('aria-busy', 'true');
this.result.setAttribute('aria-expanded', 'true');
this.firstRun = true;
- this.searcher.find(value);
+ this.ranker.find(value);
}
}
diff --git a/lib/rdoc/generator/template/aliki/js/search_navigation.js b/lib/rdoc/generator/template/aliki/js/search_navigation.js
new file mode 100644
index 0000000000..0c6d50f27d
--- /dev/null
+++ b/lib/rdoc/generator/template/aliki/js/search_navigation.js
@@ -0,0 +1,105 @@
+/*
+ * SearchNavigation allows movement using the arrow keys through the search results.
+ *
+ * When using this library you will need to set scrollIntoView to the
+ * appropriate function for your layout. Use scrollInWindow if the container
+ * is not scrollable and scrollInElement if the container is a separate
+ * scrolling region.
+ */
+SearchNavigation = new function() {
+ this.initNavigation = function() {
+ var _this = this;
+
+ document.addEventListener('keydown', function(e) {
+ _this.onkeydown(e);
+ });
+
+ this.navigationActive = true;
+ }
+
+ this.setNavigationActive = function(state) {
+ this.navigationActive = state;
+ }
+
+ this.onkeydown = function(e) {
+ if (!this.navigationActive) return;
+ switch(e.key) {
+ case 'ArrowLeft':
+ if (this.moveLeft()) e.preventDefault();
+ break;
+ case 'ArrowUp':
+ if (e.key == 'ArrowUp' || e.ctrlKey) {
+ if (this.moveUp()) e.preventDefault();
+ }
+ break;
+ case 'ArrowRight':
+ if (this.moveRight()) e.preventDefault();
+ break;
+ case 'ArrowDown':
+ if (e.key == 'ArrowDown' || e.ctrlKey) {
+ if (this.moveDown()) e.preventDefault();
+ }
+ break;
+ case 'Enter':
+ if (this.current) e.preventDefault();
+ this.select(this.current);
+ break;
+ }
+ if (e.ctrlKey && e.shiftKey) this.select(this.current);
+ }
+
+ this.moveRight = function() {
+ }
+
+ this.moveLeft = function() {
+ }
+
+ this.move = function(isDown) {
+ }
+
+ this.moveUp = function() {
+ return this.move(false);
+ }
+
+ this.moveDown = function() {
+ return this.move(true);
+ }
+
+ /*
+ * Scrolls to the given element in the scrollable element view.
+ */
+ this.scrollInElement = function(element, view) {
+ var offset, viewHeight, viewScroll, height;
+ offset = element.offsetTop;
+ height = element.offsetHeight;
+ viewHeight = view.offsetHeight;
+ viewScroll = view.scrollTop;
+
+ if (offset - viewScroll + height > viewHeight) {
+ view.scrollTop = offset - viewHeight + height;
+ }
+ if (offset < viewScroll) {
+ view.scrollTop = offset;
+ }
+ }
+
+ /*
+ * Scrolls to the given element in the window. The second argument is
+ * ignored
+ */
+ this.scrollInWindow = function(element, ignored) {
+ var offset, viewHeight, viewScroll, height;
+ offset = element.offsetTop;
+ height = element.offsetHeight;
+ viewHeight = window.innerHeight;
+ viewScroll = window.scrollY;
+
+ if (offset - viewScroll + height > viewHeight) {
+ window.scrollTo(window.scrollX, offset - viewHeight + height);
+ }
+ if (offset < viewScroll) {
+ window.scrollTo(window.scrollX, offset);
+ }
+ }
+}
+
diff --git a/lib/rdoc/generator/template/aliki/js/search_ranker.js b/lib/rdoc/generator/template/aliki/js/search_ranker.js
new file mode 100644
index 0000000000..efccefd58a
--- /dev/null
+++ b/lib/rdoc/generator/template/aliki/js/search_ranker.js
@@ -0,0 +1,239 @@
+/**
+ * Aliki Search Implementation
+ *
+ * Search algorithm with the following priorities:
+ * 1. Exact full_name match always wins (for namespace/method queries)
+ * 2. Exact name match gets high priority
+ * 3. Match types:
+ * - Namespace queries (::) and method queries (# or .) match against full_name
+ * - Regular queries match against unqualified name
+ * - Prefix (10000) > substring (5000) > fuzzy (1000)
+ * 4. First character determines type priority:
+ * - Starts with lowercase: methods first
+ * - Starts with uppercase: classes/modules/constants first
+ * 5. Within same type priority:
+ * - Unqualified match > qualified match
+ * - Shorter name > longer name
+ * 6. Class methods > instance methods
+ * 7. Result limit: 30
+ * 8. Minimum query length: 1 character
+ */
+
+var MAX_RESULTS = 30;
+var MIN_QUERY_LENGTH = 1;
+
+/*
+ * Scoring constants - organized in tiers where each tier dominates lower tiers.
+ * This ensures match type always beats type priority, etc.
+ *
+ * Tier 0: Exact matches (immediate return)
+ * Tier 1: Match type (prefix > substring > fuzzy)
+ * Tier 2: Exact name bonus
+ * Tier 3: Type priority (method vs class based on query case)
+ * Tier 4: Minor bonuses (top-level, class method, name length)
+ */
+var SCORE_EXACT_FULL_NAME = 1000000; // Tier 0: Query exactly matches full_name
+var SCORE_MATCH_PREFIX = 10000; // Tier 1: Query is prefix of name
+var SCORE_MATCH_SUBSTRING = 5000; // Tier 1: Query is substring of name
+var SCORE_MATCH_FUZZY = 1000; // Tier 1: Query chars appear in order
+var SCORE_EXACT_NAME = 500; // Tier 2: Name exactly equals query
+var SCORE_TYPE_PRIORITY = 100; // Tier 3: Preferred type (method/class)
+var SCORE_TOP_LEVEL = 50; // Tier 4: Top-level over namespaced
+var SCORE_CLASS_METHOD = 10; // Tier 4: Class method over instance method
+
+/**
+ * Check if all characters in query appear in order in target
+ * e.g., "addalias" fuzzy matches "add_foo_alias"
+ */
+function fuzzyMatch(target, query) {
+ var ti = 0;
+ for (var qi = 0; qi < query.length; qi++) {
+ ti = target.indexOf(query[qi], ti);
+ if (ti === -1) return false;
+ ti++;
+ }
+ return true;
+}
+
+/**
+ * Parse and normalize a search query
+ * @param {string} query - The raw search query
+ * @returns {Object} Parsed query with normalized form and flags
+ */
+function parseQuery(query) {
+ // Lowercase for case-insensitive matching (so "hash" finds both Hash class and #hash methods)
+ var normalized = query.toLowerCase();
+ var isNamespaceQuery = query.includes('::');
+ var isMethodQuery = query.includes('#') || query.includes('.');
+
+ // Normalize . to :: (RDoc uses :: for class methods in full_name)
+ if (query.includes('.')) {
+ normalized = normalized.replace(/\./g, '::');
+ }
+
+ return {
+ original: query,
+ normalized: normalized,
+ isNamespaceQuery: isNamespaceQuery,
+ isMethodQuery: isMethodQuery,
+ // Namespace and method queries match against full_name instead of name
+ matchesFullName: isNamespaceQuery || isMethodQuery,
+ // If query starts with lowercase, prioritize methods; otherwise prioritize classes/modules/constants
+ prioritizeMethod: !/^[A-Z]/.test(query)
+ };
+}
+
+/**
+ * Main search function
+ * @param {string} query - The search query
+ * @param {Array} index - The search index to search in
+ * @returns {Array} Array of matching entries, sorted by relevance
+ */
+function search(query, index) {
+ if (!query || query.length < MIN_QUERY_LENGTH) {
+ return [];
+ }
+
+ var q = parseQuery(query);
+ var results = [];
+
+ for (var i = 0; i < index.length; i++) {
+ var entry = index[i];
+ var score = computeScore(entry, q);
+
+ if (score !== null) {
+ results.push({ entry: entry, score: score });
+ }
+ }
+
+ results.sort(function(a, b) {
+ return b.score - a.score;
+ });
+
+ return results.slice(0, MAX_RESULTS).map(function(r) {
+ return r.entry;
+ });
+}
+
+/**
+ * Compute the relevance score for an entry
+ * @param {Object} entry - The search index entry
+ * @param {Object} q - Parsed query from parseQuery()
+ * @returns {number|null} Score or null if no match
+ */
+function computeScore(entry, q) {
+ var name = entry.name;
+ var fullName = entry.full_name;
+ var type = entry.type;
+
+ var nameLower = name.toLowerCase();
+ var fullNameLower = fullName.toLowerCase();
+
+ // Exact full_name match (e.g., "Array#filter" matches Array#filter)
+ if (q.matchesFullName && fullNameLower === q.normalized) {
+ return SCORE_EXACT_FULL_NAME;
+ }
+
+ var matchScore = 0;
+ var target = q.matchesFullName ? fullNameLower : nameLower;
+
+ if (target.startsWith(q.normalized)) {
+ matchScore = SCORE_MATCH_PREFIX; // Prefix (e.g., "Arr" matches "Array")
+ } else if (target.includes(q.normalized)) {
+ matchScore = SCORE_MATCH_SUBSTRING; // Substring (e.g., "ray" matches "Array")
+ } else if (fuzzyMatch(target, q.normalized)) {
+ matchScore = SCORE_MATCH_FUZZY; // Fuzzy (e.g., "addalias" matches "add_foo_alias")
+ } else {
+ return null;
+ }
+
+ var score = matchScore;
+ var isMethod = (type === 'instance_method' || type === 'class_method');
+
+ if (q.prioritizeMethod ? isMethod : !isMethod) {
+ score += SCORE_TYPE_PRIORITY;
+ }
+
+ if (type === 'class_method') score += SCORE_CLASS_METHOD;
+ if (name === fullName) score += SCORE_TOP_LEVEL; // Top-level (Hash) > namespaced (Foo::Hash)
+ if (nameLower === q.normalized) score += SCORE_EXACT_NAME; // Exact name match
+ score -= name.length;
+
+ return score;
+}
+
+/**
+ * SearchRanker class for compatibility with the Search UI
+ * Provides ready() and find() interface
+ */
+function SearchRanker(index) {
+ this.index = index;
+ this.handlers = [];
+}
+
+SearchRanker.prototype.ready = function(fn) {
+ this.handlers.push(fn);
+};
+
+SearchRanker.prototype.find = function(query) {
+ var q = parseQuery(query);
+ var rawResults = search(query, this.index);
+ var results = rawResults.map(function(entry) {
+ return formatResult(entry, q);
+ });
+
+ var _this = this;
+ this.handlers.forEach(function(fn) {
+ fn.call(_this, results, true);
+ });
+};
+
+/**
+ * Format a search result entry for display
+ */
+function formatResult(entry, q) {
+ var result = {
+ title: highlightMatch(entry.full_name, q),
+ path: entry.path,
+ type: entry.type
+ };
+
+ if (entry.snippet) {
+ result.snippet = entry.snippet;
+ }
+
+ return result;
+}
+
+/**
+ * Add highlight markers (\u0001 and \u0002) to matching portions of text
+ * @param {string} text - The text to highlight
+ * @param {Object} q - Parsed query from parseQuery()
+ */
+function highlightMatch(text, q) {
+ if (!text || !q) return text;
+
+ var textLower = text.toLowerCase();
+ var query = q.normalized;
+
+ // Try contiguous match first (prefix or substring)
+ var matchIndex = textLower.indexOf(query);
+ if (matchIndex !== -1) {
+ return text.substring(0, matchIndex) +
+ '\u0001' + text.substring(matchIndex, matchIndex + query.length) + '\u0002' +
+ text.substring(matchIndex + query.length);
+ }
+
+ // Fall back to fuzzy highlight (highlight each matched character)
+ var result = '';
+ var ti = 0;
+ for (var qi = 0; qi < query.length; qi++) {
+ var charIndex = textLower.indexOf(query[qi], ti);
+ if (charIndex === -1) return text;
+ result += text.substring(ti, charIndex);
+ result += '\u0001' + text[charIndex] + '\u0002';
+ ti = charIndex + 1;
+ }
+ result += text.substring(ti);
+ return result;
+}
diff --git a/test/rdoc/generator/aliki/search_index_test.rb b/test/rdoc/generator/aliki/search_index_test.rb
new file mode 100644
index 0000000000..9c6e1ef72e
--- /dev/null
+++ b/test/rdoc/generator/aliki/search_index_test.rb
@@ -0,0 +1,216 @@
+# frozen_string_literal: true
+
+require_relative '../../support/test_case'
+
+class RDocGeneratorAlikiSearchIndexTest < RDoc::TestCase
+ def setup
+ super
+
+ @tmpdir = Dir.mktmpdir "test_rdoc_generator_aliki_search_index_#{$$}_"
+ FileUtils.mkdir_p @tmpdir
+
+ @options = RDoc::Options.new
+ @options.files = []
+ @options.setup_generator 'aliki'
+ @options.template_dir = ''
+ @options.op_dir = @tmpdir
+ @options.option_parser = OptionParser.new
+ @options.finish
+
+ @g = RDoc::Generator::Aliki.new @store, @options
+
+ @rdoc.options = @options
+ @rdoc.generator = @g
+
+ @top_level = @store.add_file 'file.rb'
+ @top_level.parser = RDoc::Parser::Ruby
+
+ Dir.chdir @tmpdir
+ end
+
+ def teardown
+ super
+
+ Dir.chdir @pwd
+ FileUtils.rm_rf @tmpdir
+ end
+
+ def test_build_search_index_returns_array
+ index = @g.build_search_index
+
+ assert_kind_of Array, index
+ end
+
+ def test_build_search_index_includes_classes
+ @klass = @top_level.add_class RDoc::NormalClass, 'MyClass'
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ class_entry = index.find { |e| e[:name] == 'MyClass' }
+ assert_not_nil class_entry, "Expected to find MyClass in index"
+ assert_equal 'MyClass', class_entry[:full_name]
+ assert_equal 'class', class_entry[:type]
+ assert_equal 'MyClass.html', class_entry[:path]
+ end
+
+ def test_build_search_index_includes_modules
+ @mod = @top_level.add_module RDoc::NormalModule, 'MyModule'
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ mod_entry = index.find { |e| e[:name] == 'MyModule' }
+ assert_not_nil mod_entry, "Expected to find MyModule in index"
+ assert_equal 'MyModule', mod_entry[:full_name]
+ assert_equal 'module', mod_entry[:type]
+ assert_equal 'MyModule.html', mod_entry[:path]
+ end
+
+ def test_build_search_index_includes_nested_class
+ @outer = @top_level.add_class RDoc::NormalClass, 'Outer'
+ @inner = @outer.add_class RDoc::NormalClass, 'Inner'
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ inner_entry = index.find { |e| e[:full_name] == 'Outer::Inner' }
+ assert_not_nil inner_entry, "Expected to find Outer::Inner in index"
+ assert_equal 'Inner', inner_entry[:name]
+ assert_equal 'Outer::Inner', inner_entry[:full_name]
+ assert_equal 'class', inner_entry[:type]
+ assert_equal 'Outer/Inner.html', inner_entry[:path]
+ end
+
+ def test_build_search_index_includes_instance_methods
+ @klass = @top_level.add_class RDoc::NormalClass, 'MyClass'
+ @meth = RDoc::AnyMethod.new nil, 'my_method'
+ @meth.singleton = false
+ @klass.add_method @meth
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ meth_entry = index.find { |e| e[:name] == 'my_method' && e[:type] == 'instance_method' }
+ assert_not_nil meth_entry, "Expected to find instance method my_method in index"
+ assert_equal 'MyClass#my_method', meth_entry[:full_name]
+ assert_equal 'instance_method', meth_entry[:type]
+ assert_match(/MyClass\.html#method-i-my_method/, meth_entry[:path])
+ end
+
+ def test_build_search_index_includes_class_methods
+ @klass = @top_level.add_class RDoc::NormalClass, 'MyClass'
+ @meth = RDoc::AnyMethod.new nil, 'my_class_method'
+ @meth.singleton = true
+ @klass.add_method @meth
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ meth_entry = index.find { |e| e[:name] == 'my_class_method' && e[:type] == 'class_method' }
+ assert_not_nil meth_entry, "Expected to find class method my_class_method in index"
+ assert_equal 'MyClass::my_class_method', meth_entry[:full_name]
+ assert_equal 'class_method', meth_entry[:type]
+ assert_match(/MyClass\.html#method-c-my_class_method/, meth_entry[:path])
+ end
+
+ def test_build_search_index_includes_constants
+ @klass = @top_level.add_class RDoc::NormalClass, 'MyClass'
+ @const = RDoc::Constant.new 'MY_CONSTANT', 'value', 'A constant'
+ @klass.add_constant @const
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ const_entry = index.find { |e| e[:name] == 'MY_CONSTANT' && e[:type] == 'constant' }
+ assert_not_nil const_entry, "Expected to find constant MY_CONSTANT in index"
+ assert_equal 'MyClass::MY_CONSTANT', const_entry[:full_name]
+ assert_equal 'constant', const_entry[:type]
+ end
+
+ def test_build_search_index_excludes_nodoc
+ @klass = @top_level.add_class RDoc::NormalClass, 'DocumentedClass'
+ @nodoc_klass = @top_level.add_class RDoc::NormalClass, 'NodocClass'
+ @nodoc_klass.document_self = false
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ documented = index.find { |e| e[:name] == 'DocumentedClass' }
+ nodoc = index.find { |e| e[:name] == 'NodocClass' }
+
+ assert_not_nil documented, "Expected to find DocumentedClass in index"
+ assert_nil nodoc, "Expected NodocClass to be excluded from index"
+ end
+
+ def test_build_search_index_excludes_ignored
+ @klass = @top_level.add_class RDoc::NormalClass, 'VisibleClass'
+ @ignored = @top_level.add_class RDoc::NormalClass, 'IgnoredClass'
+ @ignored.ignore
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ visible = index.find { |e| e[:name] == 'VisibleClass' }
+ ignored = index.find { |e| e[:name] == 'IgnoredClass' }
+
+ assert_not_nil visible, "Expected to find VisibleClass in index"
+ assert_nil ignored, "Expected IgnoredClass to be excluded from index"
+ end
+
+ def test_build_search_index_includes_special_method_names
+ @klass = @top_level.add_class RDoc::NormalClass, 'MyClass'
+
+ @bracket_method = RDoc::AnyMethod.new nil, '[]'
+ @klass.add_method @bracket_method
+
+ @shovel_method = RDoc::AnyMethod.new nil, '<<'
+ @klass.add_method @shovel_method
+
+ @equals_method = RDoc::AnyMethod.new nil, '=='
+ @klass.add_method @equals_method
+
+ @store.complete :private
+
+ index = @g.build_search_index
+
+ bracket = index.find { |e| e[:name] == '[]' }
+ shovel = index.find { |e| e[:name] == '<<' }
+ equals = index.find { |e| e[:name] == '==' }
+
+ assert_not_nil bracket, "Expected to find [] method in index"
+ assert_not_nil shovel, "Expected to find << method in index"
+ assert_not_nil equals, "Expected to find == method in index"
+ end
+
+ def test_write_search_index_creates_js_file
+ @klass = @top_level.add_class RDoc::NormalClass, 'TestClass'
+ @store.complete :private
+
+ @g.write_search_index
+
+ search_data_path = File.join(@tmpdir, 'js', 'search_data.js')
+ assert_file search_data_path
+
+ js_content = File.read(search_data_path)
+ assert_match(/^var search_data = /, js_content)
+
+ # Extract JSON from JS
+ json_str = js_content.sub(/^var search_data = /, '').chomp(';')
+ data = JSON.parse(json_str, symbolize_names: true)
+
+ assert_kind_of Hash, data
+ assert_kind_of Array, data[:index]
+ assert data[:index].any? { |e| e[:name] == 'TestClass' }
+ end
+
+ def test_build_search_index_entry_structure
+ @klass = @top_level.add_class RDoc::NormalClass, 'MyClass'
+ @store.complete :private
+
+ index = @g.build_search_index
+ entry = index.find { |e| e[:name] == 'MyClass' }
+
+ assert_equal %i[name full_name type path].sort, entry.keys.sort
+ end
+end
diff --git a/test/rdoc/generator/aliki/search_test.rb b/test/rdoc/generator/aliki/search_test.rb
new file mode 100644
index 0000000000..5f9d72bb39
--- /dev/null
+++ b/test/rdoc/generator/aliki/search_test.rb
@@ -0,0 +1,555 @@
+# 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/aliki_test.rb b/test/rdoc/generator/aliki_test.rb
index 8d3ba737c4..44bac32407 100644
--- a/test/rdoc/generator/aliki_test.rb
+++ b/test/rdoc/generator/aliki_test.rb
@@ -63,7 +63,9 @@ def test_write_style_sheet_copies_css_and_js_only
# Aliki should have these assets
assert_file 'css/rdoc.css'
assert_file 'js/aliki.js'
- assert_file 'js/search.js'
+ assert_file 'js/search_controller.js'
+ assert_file 'js/search_navigation.js'
+ assert_file 'js/search_ranker.js'
assert_file 'js/theme-toggle.js'
assert_file 'js/c_highlighter.js'
@@ -83,7 +85,10 @@ def test_asset_version_query_strings
# JS files should have version query strings
assert_match %r{js/aliki\.js\?v=#{Regexp.escape(RDoc::VERSION)}}, content
- assert_match %r{js/search\.js\?v=#{Regexp.escape(RDoc::VERSION)}}, content
+ assert_match %r{js/search_controller\.js\?v=#{Regexp.escape(RDoc::VERSION)}}, content
+ assert_match %r{js/search_navigation\.js\?v=#{Regexp.escape(RDoc::VERSION)}}, content
+ assert_match %r{js/search_ranker\.js\?v=#{Regexp.escape(RDoc::VERSION)}}, content
+ assert_match %r{js/search_data\.js\?v=#{Regexp.escape(RDoc::VERSION)}}, content
assert_match %r{js/theme-toggle\.js\?v=#{Regexp.escape(RDoc::VERSION)}}, content
end
@@ -202,7 +207,7 @@ def test_generate
assert_file 'Klass/Inner.html'
# Aliki assets
- assert_file 'js/search_index.js'
+ assert_file 'js/search_data.js'
assert_file 'css/rdoc.css'
assert_file 'js/aliki.js'