From 21868ab61f67ebfea4af62a45e3f4e019a1f73ac Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Tue, 16 Dec 2025 15:58:27 +0000 Subject: [PATCH 1/3] Rebuild Aliki's searching mechanism --- lib/rdoc/code_object/class_module.rb | 13 + lib/rdoc/code_object/constant.rb | 9 + lib/rdoc/code_object/method_attr.rb | 14 +- lib/rdoc/code_object/top_level.rb | 14 +- lib/rdoc/generator/aliki.rb | 124 ++++ lib/rdoc/generator/template/aliki/_head.rhtml | 8 +- .../generator/template/aliki/css/rdoc.css | 53 ++ lib/rdoc/generator/template/aliki/js/aliki.js | 21 +- .../js/{search.js => search_controller.js} | 12 +- .../template/aliki/js/search_navigation.js | 105 ++++ .../template/aliki/js/search_ranker.js | 228 ++++++++ .../rdoc/generator/aliki/search_index_test.rb | 216 +++++++ test/rdoc/generator/aliki/search_test.rb | 538 ++++++++++++++++++ test/rdoc/generator/aliki_test.rb | 11 +- 14 files changed, 1348 insertions(+), 18 deletions(-) rename lib/rdoc/generator/template/aliki/js/{search.js => search_controller.js} (91%) create mode 100644 lib/rdoc/generator/template/aliki/js/search_navigation.js create mode 100644 lib/rdoc/generator/template/aliki/js/search_ranker.js create mode 100644 test/rdoc/generator/aliki/search_index_test.rb create mode 100644 test/rdoc/generator/aliki/search_test.rb 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..f369d5d288 --- /dev/null +++ b/lib/rdoc/generator/template/aliki/js/search_ranker.js @@ -0,0 +1,228 @@ +/** + * 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 matches rank higher than substring matches + * 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; + +/** + * 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 1000000; + } + + var matchScore = 0; + + if (q.matchesFullName) { + // For namespace queries like "Foo::Bar" or method queries like "Array#filter", + // match against full_name + if (fullNameLower.startsWith(q.normalized)) { + matchScore = 1000; // Prefix match on full_name + } else if (fullNameLower.includes(q.normalized)) { + matchScore = 100; // Substring match on full_name + } else { + return null; + } + } else { + // For regular queries, match against unqualified name + if (nameLower.startsWith(q.normalized)) { + matchScore = 1000; + } else if (nameLower.includes(q.normalized)) { + matchScore = 100; + } else { + return null; + } + } + + var score = matchScore; + var isMethod = (type === 'instance_method' || type === 'class_method'); + + if (q.prioritizeMethod) { + if (isMethod) score += 10000; + } else { + if (!isMethod) score += 10000; + } + + // Class method > instance method + if (type === 'class_method') { + score += 500; + } + + // Top-level (Hash) > namespaced (Foo::Hash) + if (name === fullName) { + score += 5000; + } + + // Exact name match (e.g., "Hash" matches Hash over Hashable) + if (nameLower === q.normalized) { + score += 50000; + } + + // Shorter name is better (subtract name length) + 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 queryLen = q.normalized.length; + var result = ''; + var lastIndex = 0; + var matchIndex = textLower.indexOf(q.normalized); + + while (matchIndex !== -1) { + // Add text before match + result += text.substring(lastIndex, matchIndex); + // Add highlighted match + result += '\u0001' + text.substring(matchIndex, matchIndex + queryLen) + '\u0002'; + lastIndex = matchIndex + queryLen; + // Find next match + matchIndex = textLower.indexOf(q.normalized, lastIndex); + } + + // Add remaining text after last match + result += text.substring(lastIndex); + + 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..47592f96bc --- /dev/null +++ b/test/rdoc/generator/aliki/search_test.rb @@ -0,0 +1,538 @@ +# 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 + + # 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' From d20d70bfebd10d0c5ee491efe9fcc8634cb2a571 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Wed, 17 Dec 2025 22:14:37 +0000 Subject: [PATCH 2/3] Support fuzzy search (lowest priority) --- .../template/aliki/js/search_ranker.js | 63 ++++++++++++------- test/rdoc/generator/aliki/search_test.rb | 17 +++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/lib/rdoc/generator/template/aliki/js/search_ranker.js b/lib/rdoc/generator/template/aliki/js/search_ranker.js index f369d5d288..bef186404c 100644 --- a/lib/rdoc/generator/template/aliki/js/search_ranker.js +++ b/lib/rdoc/generator/template/aliki/js/search_ranker.js @@ -7,7 +7,7 @@ * 3. Match types: * - Namespace queries (::) and method queries (# or .) match against full_name * - Regular queries match against unqualified name - * - Prefix matches rank higher than substring matches + * - Prefix match (1000) > substring match (100) > fuzzy match (10) * 4. First character determines type priority: * - Starts with lowercase: methods first * - Starts with uppercase: classes/modules/constants first @@ -22,6 +22,20 @@ var MAX_RESULTS = 30; var MIN_QUERY_LENGTH = 1; +/** + * 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 @@ -107,18 +121,22 @@ function computeScore(entry, q) { // For namespace queries like "Foo::Bar" or method queries like "Array#filter", // match against full_name if (fullNameLower.startsWith(q.normalized)) { - matchScore = 1000; // Prefix match on full_name + matchScore = 1000; // Prefix (e.g., "Arr" matches "Array") } else if (fullNameLower.includes(q.normalized)) { - matchScore = 100; // Substring match on full_name + matchScore = 100; // Substring (e.g., "ray" matches "Array") + } else if (fuzzyMatch(fullNameLower, q.normalized)) { + matchScore = 10; // Fuzzy (e.g., "addalias" matches "add_foo_alias") } else { return null; } } else { // For regular queries, match against unqualified name if (nameLower.startsWith(q.normalized)) { - matchScore = 1000; + matchScore = 1000; // Prefix } else if (nameLower.includes(q.normalized)) { - matchScore = 100; + matchScore = 100; // Substring + } else if (fuzzyMatch(nameLower, q.normalized)) { + matchScore = 10; // Fuzzy } else { return null; } @@ -206,23 +224,26 @@ function highlightMatch(text, q) { if (!text || !q) return text; var textLower = text.toLowerCase(); - var queryLen = q.normalized.length; - var result = ''; - var lastIndex = 0; - var matchIndex = textLower.indexOf(q.normalized); - - while (matchIndex !== -1) { - // Add text before match - result += text.substring(lastIndex, matchIndex); - // Add highlighted match - result += '\u0001' + text.substring(matchIndex, matchIndex + queryLen) + '\u0002'; - lastIndex = matchIndex + queryLen; - // Find next match - matchIndex = textLower.indexOf(q.normalized, lastIndex); + 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); } - // Add remaining text after last match - result += text.substring(lastIndex); - + // 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_test.rb b/test/rdoc/generator/aliki/search_test.rb index 47592f96bc..5f9d72bb39 100644 --- a/test/rdoc/generator/aliki/search_test.rb +++ b/test/rdoc/generator/aliki/search_test.rb @@ -81,6 +81,23 @@ def test_substring_match_finds_suffix_matches 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( From b255555392223d1057c89a7bd99b979343c1b49a Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 18 Dec 2025 11:23:38 +0000 Subject: [PATCH 3/3] Adjust scoring system --- .../template/aliki/js/search_ranker.js | 80 ++++++++----------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/lib/rdoc/generator/template/aliki/js/search_ranker.js b/lib/rdoc/generator/template/aliki/js/search_ranker.js index bef186404c..efccefd58a 100644 --- a/lib/rdoc/generator/template/aliki/js/search_ranker.js +++ b/lib/rdoc/generator/template/aliki/js/search_ranker.js @@ -7,7 +7,7 @@ * 3. Match types: * - Namespace queries (::) and method queries (# or .) match against full_name * - Regular queries match against unqualified name - * - Prefix match (1000) > substring match (100) > fuzzy match (10) + * - Prefix (10000) > substring (5000) > fuzzy (1000) * 4. First character determines type priority: * - Starts with lowercase: methods first * - Starts with uppercase: classes/modules/constants first @@ -22,6 +22,25 @@ 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" @@ -112,61 +131,32 @@ function computeScore(entry, q) { // Exact full_name match (e.g., "Array#filter" matches Array#filter) if (q.matchesFullName && fullNameLower === q.normalized) { - return 1000000; + return SCORE_EXACT_FULL_NAME; } var matchScore = 0; - - if (q.matchesFullName) { - // For namespace queries like "Foo::Bar" or method queries like "Array#filter", - // match against full_name - if (fullNameLower.startsWith(q.normalized)) { - matchScore = 1000; // Prefix (e.g., "Arr" matches "Array") - } else if (fullNameLower.includes(q.normalized)) { - matchScore = 100; // Substring (e.g., "ray" matches "Array") - } else if (fuzzyMatch(fullNameLower, q.normalized)) { - matchScore = 10; // Fuzzy (e.g., "addalias" matches "add_foo_alias") - } else { - return null; - } + 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 { - // For regular queries, match against unqualified name - if (nameLower.startsWith(q.normalized)) { - matchScore = 1000; // Prefix - } else if (nameLower.includes(q.normalized)) { - matchScore = 100; // Substring - } else if (fuzzyMatch(nameLower, q.normalized)) { - matchScore = 10; // Fuzzy - } else { - return null; - } + return null; } var score = matchScore; var isMethod = (type === 'instance_method' || type === 'class_method'); - if (q.prioritizeMethod) { - if (isMethod) score += 10000; - } else { - if (!isMethod) score += 10000; - } - - // Class method > instance method - if (type === 'class_method') { - score += 500; - } - - // Top-level (Hash) > namespaced (Foo::Hash) - if (name === fullName) { - score += 5000; - } - - // Exact name match (e.g., "Hash" matches Hash over Hashable) - if (nameLower === q.normalized) { - score += 50000; + if (q.prioritizeMethod ? isMethod : !isMethod) { + score += SCORE_TYPE_PRIORITY; } - // Shorter name is better (subtract name length) + 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;