From 9831f97f453f0ff6b9b7e69e1be2c8d59c2f6960 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Wed, 4 Nov 2026 13:39:27 +0100 Subject: [PATCH 1/6] Optimize seach index creation --- lib/rdoc/generator/aliki.rb | 79 ++++++++----- .../rdoc/generator/aliki/search_index_test.rb | 111 ++++++++++++++++++ 2 files changed, 159 insertions(+), 31 deletions(-) diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index 5157283b3a..9de9c4a8ab 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'uri' +require 'tempfile' module RDoc module Generator @@ -74,29 +75,7 @@ def write_style_sheet # 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 + enum_for(:each_search_index_entry).to_a end ## @@ -110,15 +89,31 @@ def build_search_index 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 + if @dry_run + build_search_index + return + end - data = { index: index } - File.write search_index_path, "var search_data = #{JSON.generate(data)};" + entries = if method(:build_search_index).owner == Aliki + enum_for(:each_search_index_entry) + else + build_search_index + end + + FileUtils.mkdir_p 'js' + # Finish serialization before opening the destination, preserving an existing index on failure. + Tempfile.create('rdoc-search', 'js') do |file| + file.write 'var search_data = {"index":[' + separator = '' + entries.each do |entry| + file.write separator + file.write JSON.generate(entry) + separator = ',' + end + file.write ']};' + file.rewind + IO.copy_stream file, 'js/search_data.js' + end end ## @@ -153,6 +148,28 @@ def resolve_url(rel_prefix, url) private + def each_search_index_entry + setup + + @classes.each do |klass| + next unless klass.display? + + yield build_class_module_entry(klass) + + klass.constants.each do |const| + next unless const.display? + + yield build_constant_entry(const, klass) + end + end + + @methods.each do |method| + next unless method.display? + + yield build_method_entry(method) + end + end + def template_encoding ::Encoding::UTF_8 end diff --git a/test/rdoc/generator/aliki/search_index_test.rb b/test/rdoc/generator/aliki/search_index_test.rb index bb6087da0f..1064482442 100644 --- a/test/rdoc/generator/aliki/search_index_test.rb +++ b/test/rdoc/generator/aliki/search_index_test.rb @@ -213,4 +213,115 @@ def test_build_search_index_entry_structure assert_equal %i[name full_name type path].sort, entry.keys.sort end + + def test_write_search_index_matches_array_serialization + klass = @top_level.add_class RDoc::NormalClass, 'Outer' + klass.add_comment "Quotes \" and \\, café 日本語, & links.\n", @top_level + klass.add_class RDoc::NormalClass, 'Inner' + klass.add_constant RDoc::Constant.new('VALUE', '1', 'A constant') + klass.add_method RDoc::AnyMethod.new('[]') + method = RDoc::AnyMethod.new('build') + method.singleton = true + klass.add_method method + @store.complete :private + expected = "var search_data = #{JSON.generate(index: @g.build_search_index)};" + + @g.write_search_index + + assert_equal expected, File.read('js/search_data.js') + assert_equal ['search_data.js'], Dir.children('js') + end + + def test_write_search_index_empty + @g.write_search_index + + assert_equal 'var search_data = {"index":[]};', File.read('js/search_data.js') + end + + def test_write_search_index_serializes_before_building_next_entry + @top_level.add_class RDoc::NormalClass, 'First' + @top_level.add_class RDoc::NormalClass, 'Second' + @store.complete :private + built = [] + serialized = [] + @g.define_singleton_method(:build_class_module_entry) do |klass| + raise 'Previous entries have not been serialized' unless built == serialized + + built << klass.name + entry = super(klass) + snippet = Object.new + snippet.define_singleton_method(:to_json) do |*| + serialized << klass.name + JSON.generate('A snippet') + end + entry[:snippet] = snippet + entry + end + + @g.write_search_index + + assert_equal ['First', 'Second'], serialized + assert_equal built, serialized + end + + def test_write_search_index_preserves_existing_file_on_serialization_error + @top_level.add_class RDoc::NormalClass, 'First' + @top_level.add_class RDoc::NormalClass, 'Second' + @store.complete :private + @g.define_singleton_method(:build_class_module_entry) do |klass| + entry = super(klass) + entry[:snippet] = "\xFF" if klass.name == 'Second' + entry + end + FileUtils.mkdir_p 'js' + File.write 'js/search_data.js', 'previous index' + + assert_raise JSON::GeneratorError do + @g.write_search_index + end + + assert_equal 'previous index', File.read('js/search_data.js') + assert_equal ['search_data.js'], Dir.children('js') + end + + def test_write_search_index_preserves_permissions + omit 'assumes UNIX permission model' if /mswin|mingw/ =~ RUBY_PLATFORM + + FileUtils.mkdir_p 'js' + File.write 'js/search_data.js', 'previous index' + File.chmod 0640, 'js/search_data.js' + + @g.write_search_index + + assert_equal 0640, File.stat('js/search_data.js').mode & 0777 + end + + def test_write_search_index_uses_default_permissions + omit 'assumes UNIX permission model' if /mswin|mingw/ =~ RUBY_PLATFORM + + old_umask = File.umask 0027 + + @g.write_search_index + + assert_equal 0640, File.stat('js/search_data.js').mode & 0777 + ensure + File.umask old_umask if old_umask + end + + def test_write_search_index_dry_run + @g.dry_run = true + + @g.write_search_index + + assert_equal [], Dir.children(@tmpdir) + end + + def test_write_search_index_uses_overridden_builder + entries = [{ name: 'Custom entry' }] + @g.define_singleton_method(:build_search_index) { entries } + + @g.write_search_index + + assert_equal "var search_data = #{JSON.generate(index: entries)};", File.read('js/search_data.js') + end end From 93ae3aca3c6052b69ceb949ad8005d32578c1be8 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Wed, 4 Nov 2026 14:42:03 +0100 Subject: [PATCH 2/6] Honor redefined Aliki search index builders --- lib/rdoc/generator/aliki.rb | 5 ++++- test/rdoc/generator/aliki/search_index_test.rb | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index 9de9c4a8ab..7a08193940 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -78,6 +78,9 @@ def build_search_index enum_for(:each_search_index_entry).to_a end + DEFAULT_SEARCH_INDEX_BUILDER = instance_method :build_search_index + private_constant :DEFAULT_SEARCH_INDEX_BUILDER + ## # Write the search index as a JavaScript file # Format: var search_data = { index: [...] } @@ -94,7 +97,7 @@ def write_search_index return end - entries = if method(:build_search_index).owner == Aliki + entries = if method(:build_search_index).unbind == DEFAULT_SEARCH_INDEX_BUILDER enum_for(:each_search_index_entry) else build_search_index diff --git a/test/rdoc/generator/aliki/search_index_test.rb b/test/rdoc/generator/aliki/search_index_test.rb index 1064482442..266628686c 100644 --- a/test/rdoc/generator/aliki/search_index_test.rb +++ b/test/rdoc/generator/aliki/search_index_test.rb @@ -324,4 +324,19 @@ def test_write_search_index_uses_overridden_builder assert_equal "var search_data = #{JSON.generate(index: entries)};", File.read('js/search_data.js') end + + def test_write_search_index_uses_redefined_builder + generator = RDoc::Generator::Aliki + original = generator.instance_method :build_search_index + entries = [{ name: 'Custom entry' }] + generator.remove_method :build_search_index + generator.define_method(:build_search_index) { entries } + + @g.write_search_index + + assert_equal "var search_data = #{JSON.generate(index: entries)};", File.read('js/search_data.js') + ensure + generator.remove_method :build_search_index + generator.define_method :build_search_index, original + end end From d7700cba195c0e73cd9b422f3d8056dd35740caa Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Wed, 4 Nov 2026 14:51:43 +0100 Subject: [PATCH 3/6] Publish Aliki search indexes atomically --- lib/rdoc/generator/aliki.rb | 16 +++-- .../rdoc/generator/aliki/search_index_test.rb | 65 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index 7a08193940..4715df36e8 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -104,8 +104,15 @@ def write_search_index end FileUtils.mkdir_p 'js' - # Finish serialization before opening the destination, preserving an existing index on failure. - Tempfile.create('rdoc-search', 'js') do |file| + # Follow symlinks, but replace the target inode rather than updating other hard links. + search_index_path = File.realdirpath 'js/search_data.js' + mode = begin + File.stat(search_index_path).mode & 0777 + rescue Errno::ENOENT + 0666 & ~File.umask + end + + Tempfile.create('rdoc-search', File.dirname(search_index_path)) do |file| file.write 'var search_data = {"index":[' separator = '' entries.each do |entry| @@ -114,8 +121,9 @@ def write_search_index separator = ',' end file.write ']};' - file.rewind - IO.copy_stream file, 'js/search_data.js' + file.chmod mode + file.close + File.rename file.path, search_index_path end end diff --git a/test/rdoc/generator/aliki/search_index_test.rb b/test/rdoc/generator/aliki/search_index_test.rb index 266628686c..438377406b 100644 --- a/test/rdoc/generator/aliki/search_index_test.rb +++ b/test/rdoc/generator/aliki/search_index_test.rb @@ -284,6 +284,71 @@ def test_write_search_index_preserves_existing_file_on_serialization_error assert_equal ['search_data.js'], Dir.children('js') end + def test_write_search_index_replaces_existing_file + omit 'open files cannot always be replaced on Windows' if /mswin|mingw/ =~ RUBY_PLATFORM + + FileUtils.mkdir_p 'js' + File.write 'js/search_data.js', 'previous index' + + File.open 'js/search_data.js' do |previous| + @g.write_search_index + + assert_equal 'previous index', previous.read + end + + assert_equal 'var search_data = {"index":[]};', File.read('js/search_data.js') + assert_equal ['search_data.js'], Dir.children('js') + end + + def test_write_search_index_preserves_existing_file_on_publication_error + original = File.method :rename + FileUtils.mkdir_p 'js' + File.write 'js/search_data.js', 'previous index' + File.singleton_class.remove_method :rename + File.define_singleton_method(:rename) { |*| raise Errno::ENOSPC } + + assert_raise Errno::ENOSPC do + @g.write_search_index + end + + assert_equal 'previous index', File.read('js/search_data.js') + assert_equal ['search_data.js'], Dir.children('js') + ensure + File.singleton_class.remove_method :rename + File.define_singleton_method :rename, original + end + + def test_write_search_index_preserves_symlink + FileUtils.mkdir_p %w[js target] + File.write 'target/search_data.js', 'previous index' + begin + File.symlink '../target/search_data.js', 'js/search_data.js' + rescue NotImplementedError, Errno::EACCES, Errno::EPERM + omit 'symlinks are not supported' + end + + @g.write_search_index + + assert File.symlink?('js/search_data.js') + assert_equal 'var search_data = {"index":[]};', File.read('target/search_data.js') + assert_equal ['search_data.js'], Dir.children('target') + end + + def test_write_search_index_follows_dangling_symlink + FileUtils.mkdir_p %w[js target] + begin + File.symlink '../target/search_data.js', 'js/search_data.js' + rescue NotImplementedError, Errno::EACCES, Errno::EPERM + omit 'symlinks are not supported' + end + + @g.write_search_index + + assert File.symlink?('js/search_data.js') + assert_equal 'var search_data = {"index":[]};', File.read('target/search_data.js') + assert_equal ['search_data.js'], Dir.children('target') + end + def test_write_search_index_preserves_permissions omit 'assumes UNIX permission model' if /mswin|mingw/ =~ RUBY_PLATFORM From 71a00f6bc0589df8e37b0afb749e6f7eaf91881e Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Wed, 4 Nov 2026 15:04:45 +0100 Subject: [PATCH 4/6] Simplify Aliki search index streaming --- lib/rdoc/generator/aliki.rb | 3 +-- test/rdoc/generator/aliki/search_index_test.rb | 8 +++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index 4715df36e8..43b64bedbd 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -116,8 +116,7 @@ def write_search_index file.write 'var search_data = {"index":[' separator = '' entries.each do |entry| - file.write separator - file.write JSON.generate(entry) + file.write separator, JSON.generate(entry) separator = ',' end file.write ']};' diff --git a/test/rdoc/generator/aliki/search_index_test.rb b/test/rdoc/generator/aliki/search_index_test.rb index 438377406b..c39dd8df25 100644 --- a/test/rdoc/generator/aliki/search_index_test.rb +++ b/test/rdoc/generator/aliki/search_index_test.rb @@ -248,13 +248,11 @@ def test_write_search_index_serializes_before_building_next_entry raise 'Previous entries have not been serialized' unless built == serialized built << klass.name - entry = super(klass) - snippet = Object.new - snippet.define_singleton_method(:to_json) do |*| + entry = Object.new + entry.define_singleton_method(:to_json) do |*| serialized << klass.name - JSON.generate('A snippet') + JSON.generate(name: klass.name) end - entry[:snippet] = snippet entry end From 6203b5153eb01744bb6783a40f57e67bc131c87f Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Wed, 4 Nov 2026 15:28:49 +0100 Subject: [PATCH 5/6] Preserve ownership when publishing Aliki search indexes --- lib/rdoc/generator/aliki.rb | 12 +++-- .../rdoc/generator/aliki/search_index_test.rb | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index 43b64bedbd..30536312ab 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -106,11 +106,12 @@ def write_search_index FileUtils.mkdir_p 'js' # Follow symlinks, but replace the target inode rather than updating other hard links. search_index_path = File.realdirpath 'js/search_data.js' - mode = begin - File.stat(search_index_path).mode & 0777 + stat = begin + File.stat search_index_path rescue Errno::ENOENT - 0666 & ~File.umask + nil end + mode = stat ? stat.mode & 0777 : 0666 & ~File.umask Tempfile.create('rdoc-search', File.dirname(search_index_path)) do |file| file.write 'var search_data = {"index":[' @@ -120,6 +121,11 @@ def write_search_index separator = ',' end file.write ']};' + if stat + tempfile_stat = file.stat + file.chown stat.uid, stat.gid if stat.uid != tempfile_stat.uid || stat.gid != tempfile_stat.gid + end + # shortcut: extended ACLs are not copied; add platform-specific support if indexes need ACL-based access. file.chmod mode file.close File.rename file.path, search_index_path diff --git a/test/rdoc/generator/aliki/search_index_test.rb b/test/rdoc/generator/aliki/search_index_test.rb index c39dd8df25..fa3e585eeb 100644 --- a/test/rdoc/generator/aliki/search_index_test.rb +++ b/test/rdoc/generator/aliki/search_index_test.rb @@ -359,6 +359,52 @@ def test_write_search_index_preserves_permissions assert_equal 0640, File.stat('js/search_data.js').mode & 0777 end + def test_write_search_index_preserves_ownership + omit 'assumes UNIX ownership model' if /mswin|mingw/ =~ RUBY_PLATFORM + + FileUtils.mkdir_p 'js' + File.write 'js/search_data.js', 'previous index' + previous_stat = File.stat 'js/search_data.js' + group = Process.groups.find { |gid| gid != previous_stat.gid } + omit 'requires a secondary group' unless group + File.chown nil, group, 'js/search_data.js' + File.chmod 0640, 'js/search_data.js' + + @g.write_search_index + + stat = File.stat 'js/search_data.js' + assert_equal previous_stat.uid, stat.uid + assert_equal group, stat.gid + assert_equal 0640, stat.mode & 0777 + end + + def test_write_search_index_preserves_existing_file_on_ownership_error + original = Tempfile.method :create + FileUtils.mkdir_p 'js' + File.write 'js/search_data.js', 'previous index' + different_owner = File.stat 'js/search_data.js' + uid = different_owner.uid + 1 + different_owner.define_singleton_method(:uid) { uid } + Tempfile.singleton_class.remove_method :create + Tempfile.define_singleton_method(:create) do |*args, &block| + original.call(*args) do |file| + file.define_singleton_method(:stat) { different_owner } + file.define_singleton_method(:chown) { |*| raise Errno::EPERM } + block.call(file) + end + end + + assert_raise Errno::EPERM do + @g.write_search_index + end + + assert_equal 'previous index', File.read('js/search_data.js') + assert_equal ['search_data.js'], Dir.children('js') + ensure + Tempfile.singleton_class.remove_method :create + Tempfile.define_singleton_method :create, original + end + def test_write_search_index_uses_default_permissions omit 'assumes UNIX permission model' if /mswin|mingw/ =~ RUBY_PLATFORM From 0b965fd300c4f7b1e985d6b5d2a9410729be77cb Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Thu, 5 Nov 2026 01:41:12 +0100 Subject: [PATCH 6/6] Remove Aliki search index builder fallback --- lib/rdoc/generator/aliki.rb | 13 ++-------- .../rdoc/generator/aliki/search_index_test.rb | 24 ++++++------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index 30536312ab..753987892d 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -78,9 +78,6 @@ def build_search_index enum_for(:each_search_index_entry).to_a end - DEFAULT_SEARCH_INDEX_BUILDER = instance_method :build_search_index - private_constant :DEFAULT_SEARCH_INDEX_BUILDER - ## # Write the search index as a JavaScript file # Format: var search_data = { index: [...] } @@ -93,16 +90,10 @@ def write_search_index debug_msg "Writing Aliki search index" if @dry_run - build_search_index + each_search_index_entry {} return end - entries = if method(:build_search_index).unbind == DEFAULT_SEARCH_INDEX_BUILDER - enum_for(:each_search_index_entry) - else - build_search_index - end - FileUtils.mkdir_p 'js' # Follow symlinks, but replace the target inode rather than updating other hard links. search_index_path = File.realdirpath 'js/search_data.js' @@ -116,7 +107,7 @@ def write_search_index Tempfile.create('rdoc-search', File.dirname(search_index_path)) do |file| file.write 'var search_data = {"index":[' separator = '' - entries.each do |entry| + each_search_index_entry do |entry| file.write separator, JSON.generate(entry) separator = ',' end diff --git a/test/rdoc/generator/aliki/search_index_test.rb b/test/rdoc/generator/aliki/search_index_test.rb index fa3e585eeb..c32d3f3779 100644 --- a/test/rdoc/generator/aliki/search_index_test.rb +++ b/test/rdoc/generator/aliki/search_index_test.rb @@ -419,33 +419,23 @@ def test_write_search_index_uses_default_permissions def test_write_search_index_dry_run @g.dry_run = true + @g.define_singleton_method(:build_search_index) { raise 'must not build an array' } @g.write_search_index assert_equal [], Dir.children(@tmpdir) end - def test_write_search_index_uses_overridden_builder + def test_write_search_index_uses_shared_entry_iterator entries = [{ name: 'Custom entry' }] - @g.define_singleton_method(:build_search_index) { entries } - - @g.write_search_index - - assert_equal "var search_data = #{JSON.generate(index: entries)};", File.read('js/search_data.js') - end - - def test_write_search_index_uses_redefined_builder - generator = RDoc::Generator::Aliki - original = generator.instance_method :build_search_index - entries = [{ name: 'Custom entry' }] - generator.remove_method :build_search_index - generator.define_method(:build_search_index) { entries } + @g.define_singleton_method(:each_search_index_entry) do |&block| + entries.each(&block) + end + assert_equal entries, @g.build_search_index + @g.define_singleton_method(:build_search_index) { raise 'must not build an array' } @g.write_search_index assert_equal "var search_data = #{JSON.generate(index: entries)};", File.read('js/search_data.js') - ensure - generator.remove_method :build_search_index - generator.define_method :build_search_index, original end end