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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/bitclust/search_index_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 112 additions & 5 deletions test/js/test_search.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <em> 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'
Expand All @@ -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('<em>File.open</em>'),
'the qualified query is highlighted as one contiguous <em> 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 <em> 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('<em>Kernel?.open</em>'),
'...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 ------------------------------------
Expand All @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions test/test_search_index_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions test/test_searchpage_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
29 changes: 29 additions & 0 deletions theme/default/js/search_init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em> 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;

Expand Down
27 changes: 27 additions & 0 deletions theme/default/js/search_page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading