diff --git a/lib/bitclust/search_index_generator.rb b/lib/bitclust/search_index_generator.rb index 0c5f004c..60540c67 100644 --- a/lib/bitclust/search_index_generator.rb +++ b/lib/bitclust/search_index_generator.rb @@ -92,11 +92,25 @@ def to_js(db, fdb = nil) # sorts after "3.4"), and entry order is the first appearance while # scanning versions in ascending order — independent of the caller's # argument order, so the generated file diffs stay stable. + # + # A module function's full_name is spelled ".#" by pre-4.0 databases and + # "?." by 4.0+ ones (bitclust#250), which would split it into two rows — + # confusing on a page that lists every version side by side. Fold the + # display to the current "?." spelling here, so both spellings collapse + # into one row with a unified versions list (name/type/path/match_name + # are spelling-independent already). Only entries carrying a match_name + # (dot-qualified methods) are folded: a prose heading whose *text* + # happens to contain ".#" must keep matching its page verbatim. The + # single-version in-page search keeps each version's own spelling — + # build_index is not affected. See the report in bitclust#279. def self.merge(version_indexes) merged = {} #: Hash[Array[String?], merged_entry] sorted = version_indexes.sort_by { |version, _| Gem::Version.new(version) } sorted.each do |version, index| index.each do |e| + if e[:match_name] && e[:full_name].include?('.#') + e = e.merge(full_name: e[:full_name].gsub('.#', '?.')) + end key = e.values_at(:name, :full_name, :type, :path) entry = (merged[key] ||= e.merge(versions: [])) entry[:versions] << version diff --git a/test/js/test_search.mjs b/test/js/test_search.mjs index 13225ba4..a947b1c9 100644 --- a/test/js/test_search.mjs +++ b/test/js/test_search.mjs @@ -166,6 +166,30 @@ const index = [ 'search("each") still finds the ordinary method entry') } +// --- wire up search_page.js (the cross-version /ja/search/ page) --------- +// Like setupSearchBox, but for search_page.js: fresh (unwrapped) ranker +// globals first, then the page script's own globals (a merged index whose +// entries carry `versions`, the version list, and the path prefix), plus +// the window shims search_page.js touches (location/history/focus). +function setupSearchPage(index) { + loadRankerScripts() + const input = makeElement('input') + input.focus = function() {} + const result = makeElement('ul') + result.parentNode = makeElement('div') + globalThis.document = makeDocument({ 'search-field': input, 'search-results': result }) + globalThis.search_data = { index } + globalThis.search_versions = ['3.4', '4.0'] + globalThis.search_version_base = '../' + globalThis.window = { + location: { search: '', pathname: '/ja/search/' }, + history: { replaceState() {} }, + } + ;(0, eval)(std.loadFile(jsdir + 'search_page.js')) + document.dispatch('DOMContentLoaded') + return { input, result } +} + // End-to-end through search_init.js's DOM wiring: typing "defined?" and // dispatching keyup renders a result item carrying the new search-type- // heading badge, labeled via the formatType map added in this change. @@ -237,9 +261,8 @@ const index = [ // End-to-end through the DOM, same as the "defined?" test above: typing a // dot-qualified query renders a result whose *displayed* title is the // original "File.open" -- proving the match_name substitution never reaches -// the page (highlightMatch degrades to no inline marks for this query -// shape, a known/documented limitation -- see the #279 report -- but the -// text itself must still be exactly right and HTML-escaped normally). +// the page -- highlighted whole by the highlightMatch wrap (see the +// dedicated section below) and HTML-escaped normally. { const { input, result } = setupSearchBox(qualifiedIndex) input.value = 'File.open' @@ -250,6 +273,88 @@ const index = [ 'the rendered result displays the literal "File.open" full_name') assert(!html.includes('File::open'), 'the rendered result never shows the "::" match_name form') assert(html.includes('method/-file/s/open.html'), 'the result links to the right path') + assert(html.includes('File.open'), + 'the qualified query is highlighted as one contiguous run (bitclust#279 comment)') +} + +// --- bitclust#279 (comment): highlightMatch on qualified queries --------- +// The vendored highlightMatch() compares q.normalized (with "." already +// rewritten to "::", e.g. "file::open") against the literal-dot full_name +// ("File.open"), so a dot-qualified query never earned an highlight -- +// at best the fuzzy fallback gave up at the first ":". search_init.js wraps +// it: whenever the vendored code found nothing to mark, retry with the +// user's original query text, treating ".#" and "?." as the same +// module-function spelling. Both marks are two characters long, so indexes +// into the folded strings map 1:1 onto the displayed text. +{ + const q = parseQuery('File.open') + assert(highlightMatch('File.open', q) === '\u0001File.open\u0002', + 'highlightMatch marks the whole contiguous match for "File.open"') +} +{ + const q = parseQuery('file.o') + assert(highlightMatch('File.open', q) === '\u0001File.o\u0002pen', + 'a partial qualified query highlights just its prefix, case-insensitively') +} +{ + const q = parseQuery('Kernel?.open') + assert(highlightMatch('Kernel.#open', q) === '\u0001Kernel.#open\u0002', + 'a "?."-spelled query highlights a ".#"-spelled entry (cross-notation)') + assert(highlightMatch('Kernel?.open', q) === '\u0001Kernel?.open\u0002', + 'a "?."-spelled query highlights a "?."-spelled entry') +} +{ + const q = parseQuery('Kernel.#open') + assert(highlightMatch('Kernel?.open', q) === '\u0001Kernel?.open\u0002', + 'a ".#"-spelled query highlights a "?."-spelled entry (cross-notation)') +} +// Vendored behavior is preserved wherever it already worked: unqualified +// queries keep the vendored contiguous highlight... +{ + const q = parseQuery('open') + assert(highlightMatch('File.open', q) === 'File.\u0001open\u0002', + 'unqualified queries keep the vendored contiguous highlight') +} +// ...fuzzy scattered highlights pass through untouched... +{ + const q = parseQuery('fopen') + const r = highlightMatch('File.open', q) + assert(r.indexOf('\u0001') !== -1 && r.replace(/[\u0001\u0002]/g, '') === 'File.open', + 'fuzzy highlights from the vendored code pass through unchanged') +} +// ...and a query that matches nothing still returns the text verbatim. +{ + const q = parseQuery('Dir.open') + assert(highlightMatch('File.open', q) === 'File.open', + 'a non-matching qualified query leaves the text unhighlighted') +} + +// --- bitclust#279 (comment): the cross-version page (search_page.js) ----- +// Same wraps as search_init.js, exercised end-to-end. The merged index only +// carries the "?." spelling (SearchIndexGenerator.merge folds ".#" away); +// an old-notation query must still find that entry, and the rendered row +// shows the "?."-spelled title highlighted, linking to the newest version +// with the full version list underneath. +{ + const mergedIndex = [ + { name: 'open', full_name: 'Kernel?.open', match_name: 'Kernel::#open', + type: 'class_method', path: 'method/-kernel/m/open.html', + versions: ['3.4', '4.0'] }, + { name: 'open', full_name: 'File.open', match_name: 'File::open', + type: 'class_method', path: 'method/-file/s/open.html', + versions: ['3.4', '4.0'] }, + ] + const { input, result } = setupSearchPage(mergedIndex) + input.value = 'Kernel.#open' + input.dispatch('keyup', { key: 'n' }) + assert(result.children.length === 1, + 'a ".#"-spelled query finds the "?."-only merged entry on the cross-version page') + const html = result.children.length ? result.children[0].innerHTML : '' + assert(html.includes('Kernel?.open'), + '...rendered with the whole "?."-spelled title highlighted') + assert(html.includes('../4.0/method/-kernel/m/open.html'), + '...linking to the newest version') + assert(html.includes('>3.4<'), '...with the older version listed too') } // --- bitclust#279: defensive fallback ------------------------------------ @@ -266,8 +371,10 @@ const index = [ { parseQuery = undefined computeScore = undefined - assert(typeof parseQuery !== 'function' && typeof computeScore !== 'function', - '(fallback setup) parseQuery/computeScore are hidden, simulating a future closure-based ranker') + highlightMatch = undefined + assert(typeof parseQuery !== 'function' && typeof computeScore !== 'function' && + typeof highlightMatch !== 'function', + '(fallback setup) parseQuery/computeScore/highlightMatch are hidden, simulating a future closure-based ranker') // A closure-hiding stand-in ranker: same public shape as SearchRanker, but // its matching logic never touches globalThis. diff --git a/test/test_search_index_generator.rb b/test/test_search_index_generator.rb index 62333a52..aa81f84a 100644 --- a/test/test_search_index_generator.rb +++ b/test/test_search_index_generator.rb @@ -416,6 +416,44 @@ def test_merge_orders_entries_by_first_appearance_in_version_order assert_equal %w[B A], merged.map { |e| e[:full_name] } end + def test_merge_folds_module_function_display_to_question_dot + # bitclust#279 コメント対応: 統合ページ(/ja/search/)で同じ module + # function が "Kernel.#open"(4.0 より前の版)と "Kernel?.open" + # (4.0 以降)の2行に分かれて出るのはわかりにくい。表示は現行表記の + # "?." に畳んで1エントリに合流させる(name/type/path/match_name は + # 表記に依らず同一なので、full_name さえ畳めばキーが一致する)。 + # 単一版ページの表示はその版の表記のまま(build_index 側は触らない)。 + pre40 = { name: 'open', full_name: 'Kernel.#open', type: 'class_method', + path: 'method/-kernel/m/open.html', match_name: 'Kernel::#open' } + post40 = { name: 'open', full_name: 'Kernel?.open', type: 'class_method', + path: 'method/-kernel/m/open.html', match_name: 'Kernel::#open' } + merged = BitClust::SearchIndexGenerator.merge( + [['3.4', [pre40]], ['4.0', [post40]]]) + assert_equal 1, merged.size + assert_equal 'Kernel?.open', merged[0][:full_name] + assert_equal %w[3.4 4.0], merged[0][:versions] + assert_equal 'Kernel::#open', merged[0][:match_name] + end + + def test_merge_folds_display_even_when_only_old_notation_exists + # 4.0 以降に存在しないまま消えたメソッドでも、統合ページの表示は + # "?." に揃える(znz さんの依頼は「?. だけ出てくるように」) + pre40 = { name: 'gone', full_name: 'Kernel.#gone', type: 'class_method', + path: 'method/-kernel/m/gone.html', match_name: 'Kernel::#gone' } + merged = BitClust::SearchIndexGenerator.merge([['3.4', [pre40]]]) + assert_equal 'Kernel?.gone', merged[0][:full_name] + end + + def test_merge_keeps_dot_hash_in_entries_without_match_name + # ".#" を字面に含んでいても match_name を持たないエントリ(散文 + # 見出しなど)は表示を畳まない — 見出しはページ本文の字面と一致 + # していることに意味がある + heading = { name: '.#', full_name: '.# (プログラム・文・式)', type: 'heading', + path: 'doc/symref.html#dot-hash' } + merged = BitClust::SearchIndexGenerator.merge([['3.4', [heading]]]) + assert_equal '.# (プログラム・文・式)', merged[0][:full_name] + end + def test_merged_js_format js = BitClust::SearchIndexGenerator.merged_js([['3.4', [entry]]]) assert_match(/\Avar search_data = \{/, js) diff --git a/test/test_searchpage_command.rb b/test/test_searchpage_command.rb index 7f855d14..20f5bc60 100644 --- a/test/test_searchpage_command.rb +++ b/test/test_searchpage_command.rb @@ -86,6 +86,35 @@ def test_merged_index_versions assert_equal %w[3.4 4.1], klass['versions'] end + def test_merged_index_shows_module_functions_as_question_dot_only + # bitclust#279 コメント対応: 版によって表示が ".#"(4.0 より前)と + # "?."(4.0 以降)に割れる module function は、統合ページでは "?." + # 表記の1エントリに合流させる(両表記が併記されるとわかりにくい) + db33 = build_db('3.3', <<~'RD') + description + + = module Kernel + == Module Functions + --- at_exit{ ... } -> Proc + aaa + RD + db40 = build_db('4.0', <<~'RD') + description + + = module Kernel + == Module Functions + --- at_exit{ ... } -> Proc + aaa + RD + run_command(["--outputdir=#{@out}", db40, db33]) + js = File.read("#{@out}/js/search_data.js") + data = JSON.parse(js.sub(/\Avar search_data = /, '').sub(/;\z/, '')) + entries = data['index'].select { |e| e['name'] == 'at_exit' } + assert_equal ['Kernel?.at_exit'], entries.map { |e| e['full_name'] } + assert_equal %w[3.3 4.0], entries[0]['versions'] + assert_not_match(/Kernel\.#/, js) + end + def test_index_html_embeds_versions_and_ui run_command(["--outputdir=#{@out}", @db41, @db34]) html = File.read("#{@out}/index.html") diff --git a/theme/default/js/search_init.js b/theme/default/js/search_init.js index ce84ff53..de33bd7a 100644 --- a/theme/default/js/search_init.js +++ b/theme/default/js/search_init.js @@ -67,6 +67,35 @@ }; } + // bitclust#279 (continued): highlightMatch() compares q.normalized (with + // "." already rewritten to "::" and "?." folded to ".#" by the wrap + // above) against the displayed full_name, which keeps its literal + // "."/".#"/"?." — so the dot-qualified queries fixed above matched their + // entry but never earned an highlight (at best the vendored fuzzy + // fallback gave up at the first ":"). Whenever the vendored code found + // nothing to mark, retry a contiguous match with the user's *original* + // query text (q.original, restored by the parseQuery wrap), treating + // ".#" and "?." as the same module-function spelling. Both marks are two + // characters long, so indexes into the folded strings map 1:1 onto the + // displayed text and the markers can be spliced straight into it. + if (typeof highlightMatch === 'function') { + var alikiHighlightMatch = highlightMatch; + highlightMatch = function(text, q) { + var marked = alikiHighlightMatch(text, q); + if (marked !== text) return marked; // the vendored code managed + if (!text || !q || !q.original) return marked; + var canonText = text.toLowerCase().replace(/\.#/g, '?.'); + var canonQuery = q.original.toLowerCase().replace(/\.#/g, '?.'); + if (!canonQuery) return marked; + var start = canonText.indexOf(canonQuery); + if (start === -1) return marked; + var end = start + canonQuery.length; + return text.substring(0, start) + + '\u0001' + text.substring(start, end) + '\u0002' + + text.substring(end); + }; + } + function createSearchInstance(input, result) { if (!input || !result) return null; diff --git a/theme/default/js/search_page.js b/theme/default/js/search_page.js index e5ba9e8a..f52bcd84 100644 --- a/theme/default/js/search_page.js +++ b/theme/default/js/search_page.js @@ -57,6 +57,33 @@ }; } + // Same highlightMatch wrap as search_init.js: when the vendored code + // found nothing to mark (dot-qualified queries never contiguous-match a + // literal-dot full_name once "." has been rewritten to "::"), retry with + // the user's original query text, treating ".#" and "?." as the same + // module-function spelling. On this page the merged index only carries + // the "?." spelling (SearchIndexGenerator.merge folds ".#" away), so + // this is what lets a query typed in the pre-4.0 ".#" notation still + // light up the "?."-spelled row it matched. Both marks are two + // characters, so indexes into the folded strings map 1:1 onto the text. + if (typeof highlightMatch === 'function') { + var alikiHighlightMatch = highlightMatch; + highlightMatch = function(text, q) { + var marked = alikiHighlightMatch(text, q); + if (marked !== text) return marked; // the vendored code managed + if (!text || !q || !q.original) return marked; + var canonText = text.toLowerCase().replace(/\.#/g, '?.'); + var canonQuery = q.original.toLowerCase().replace(/\.#/g, '?.'); + if (!canonQuery) return marked; + var start = canonText.indexOf(canonQuery); + if (start === -1) return marked; + var end = start + canonQuery.length; + return text.substring(0, start) + + '\u0001' + text.substring(start, end) + '\u0002' + + text.substring(end); + }; + } + function versionHref(version, path) { return search_version_base + version + '/' + path; }