From 9901ecff74baec87ba3d3e5f6674b78922f0613f Mon Sep 17 00:00:00 2001 From: git Date: Wed, 5 Aug 2026 07:39:57 +0000 Subject: [PATCH 01/25] [DOC] Update bundled gems list at 91cfd2c1efe9b8ae35f4e03cf15124 --- NEWS.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index a2a1da19441609..e5eca2441cd127 100644 --- a/NEWS.md +++ b/NEWS.md @@ -137,11 +137,11 @@ They are still available on rubygems.org and can be installed with ### The following default gems are updated. * RubyGems 4.1.0.dev - * 4.0.3 to [v4.0.4][RubyGems-v4.0.4], [v4.0.5][RubyGems-v4.0.5], [v4.0.6][RubyGems-v4.0.6], [v4.0.7][RubyGems-v4.0.7], [v4.0.8][RubyGems-v4.0.8], [v4.0.9][RubyGems-v4.0.9], [v4.0.10][RubyGems-v4.0.10], [v4.0.11][RubyGems-v4.0.11], [v4.0.12][RubyGems-v4.0.12], [v4.0.13][RubyGems-v4.0.13], [v4.0.14][RubyGems-v4.0.14], [v4.0.15][RubyGems-v4.0.15], [v4.0.16][RubyGems-v4.0.16], [v4.0.17][RubyGems-v4.0.17] + * 4.0.3 to [v4.0.4][RubyGems-v4.0.4], [v4.0.5][RubyGems-v4.0.5], [v4.0.6][RubyGems-v4.0.6], [v4.0.7][RubyGems-v4.0.7], [v4.0.8][RubyGems-v4.0.8], [v4.0.9][RubyGems-v4.0.9], [v4.0.10][RubyGems-v4.0.10], [v4.0.11][RubyGems-v4.0.11], [v4.0.12][RubyGems-v4.0.12], [v4.0.13][RubyGems-v4.0.13], [v4.0.14][RubyGems-v4.0.14], [v4.0.15][RubyGems-v4.0.15], [v4.0.16][RubyGems-v4.0.16], [v4.0.17][RubyGems-v4.0.17], [v4.0.18][RubyGems-v4.0.18] * bundler 4.1.0.dev * 4.0.3 to [v4.0.4][bundler-v4.0.4], [v4.0.5][bundler-v4.0.5], [v4.0.6][bundler-v4.0.6], [v4.0.7][bundler-v4.0.7], [v4.0.8][bundler-v4.0.8], [v4.0.9][bundler-v4.0.9], [v4.0.10][bundler-v4.0.10], [v4.0.11][bundler-v4.0.11], [v4.0.12][bundler-v4.0.12], [v4.0.13][bundler-v4.0.13], [v4.0.14][bundler-v4.0.14], [v4.0.15][bundler-v4.0.15], [v4.0.16][bundler-v4.0.16], [v4.0.17][bundler-v4.0.17] * erb 6.0.7 - * 6.0.1 to [v6.0.1.1][erb-v6.0.1.1], [v6.0.2][erb-v6.0.2], [v6.0.3][erb-v6.0.3], [v6.0.4][erb-v6.0.4], [v6.0.5][erb-v6.0.5], [v6.0.6][erb-v6.0.6] + * 6.0.1 to [v6.0.1.1][erb-v6.0.1.1], [v6.0.2][erb-v6.0.2], [v6.0.3][erb-v6.0.3], [v6.0.4][erb-v6.0.4], [v6.0.5][erb-v6.0.5], [v6.0.6][erb-v6.0.6], [v6.0.7][erb-v6.0.7] * error_highlight 0.7.2 * ipaddr 1.2.9 * 1.2.8 to [v1.2.9][ipaddr-v1.2.9] @@ -178,7 +178,7 @@ They are still available on rubygems.org and can be installed with * net-imap 0.6.6 * 0.6.2 to [v0.6.3][net-imap-v0.6.3], [v0.6.4][net-imap-v0.6.4], [v0.6.4.1][net-imap-v0.6.4.1], [v0.6.5][net-imap-v0.6.5], [v0.6.6][net-imap-v0.6.6] * rbs 4.0.3 - * 3.10.0 to [v3.10.1][rbs-v3.10.1], [v3.10.2][rbs-v3.10.2], [v3.10.3][rbs-v3.10.3], [v3.10.4][rbs-v3.10.4], [v4.0.0.dev.5][rbs-v4.0.0.dev.5], [v4.0.0][rbs-v4.0.0], [v4.0.2][rbs-v4.0.2], [v4.0.3][rbs-v4.0.3] + * 3.10.0 to [v3.10.1][rbs-v3.10.1], [v3.10.2][rbs-v3.10.2], [v3.10.3][rbs-v3.10.3], [v3.10.4][rbs-v3.10.4], [v4.0.0.dev.1][rbs-v4.0.0.dev.1], [v4.0.0.dev.2][rbs-v4.0.0.dev.2], [v4.0.0.dev.3][rbs-v4.0.0.dev.3], [v4.0.0.dev.4][rbs-v4.0.0.dev.4], [v4.0.0.dev.5][rbs-v4.0.0.dev.5], [v4.0.0][rbs-v4.0.0], [v4.0.1.dev.1][rbs-v4.0.1.dev.1], [v4.0.1.dev.2][rbs-v4.0.1.dev.2], [v4.0.1][rbs-v4.0.1], [v4.0.2][rbs-v4.0.2], [v4.0.3][rbs-v4.0.3] * typeprof 0.32.0 * mutex_m 0.3.0 * bigdecimal 4.1.2 @@ -316,6 +316,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [RubyGems-v4.0.15]: https://github.com/rubygems/rubygems/releases/tag/v4.0.15 [RubyGems-v4.0.16]: https://github.com/rubygems/rubygems/releases/tag/v4.0.16 [RubyGems-v4.0.17]: https://github.com/rubygems/rubygems/releases/tag/v4.0.17 +[RubyGems-v4.0.18]: https://github.com/rubygems/rubygems/releases/tag/v4.0.18 [bundler-v4.0.4]: https://github.com/rubygems/rubygems/releases/tag/bundler-v4.0.4 [bundler-v4.0.5]: https://github.com/rubygems/rubygems/releases/tag/bundler-v4.0.5 [bundler-v4.0.6]: https://github.com/rubygems/rubygems/releases/tag/bundler-v4.0.6 @@ -336,6 +337,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [erb-v6.0.4]: https://github.com/ruby/erb/releases/tag/v6.0.4 [erb-v6.0.5]: https://github.com/ruby/erb/releases/tag/v6.0.5 [erb-v6.0.6]: https://github.com/ruby/erb/releases/tag/v6.0.6 +[erb-v6.0.7]: https://github.com/ruby/erb/releases/tag/v6.0.7 [ipaddr-v1.2.9]: https://github.com/ruby/ipaddr/releases/tag/v1.2.9 [json-v2.18.1]: https://github.com/ruby/json/releases/tag/v2.18.1 [json-v2.19.0]: https://github.com/ruby/json/releases/tag/v2.19.0 @@ -379,8 +381,15 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [rbs-v3.10.2]: https://github.com/ruby/rbs/releases/tag/v3.10.2 [rbs-v3.10.3]: https://github.com/ruby/rbs/releases/tag/v3.10.3 [rbs-v3.10.4]: https://github.com/ruby/rbs/releases/tag/v3.10.4 +[rbs-v4.0.0.dev.1]: https://github.com/ruby/rbs/releases/tag/v4.0.0.dev.1 +[rbs-v4.0.0.dev.2]: https://github.com/ruby/rbs/releases/tag/v4.0.0.dev.2 +[rbs-v4.0.0.dev.3]: https://github.com/ruby/rbs/releases/tag/v4.0.0.dev.3 +[rbs-v4.0.0.dev.4]: https://github.com/ruby/rbs/releases/tag/v4.0.0.dev.4 [rbs-v4.0.0.dev.5]: https://github.com/ruby/rbs/releases/tag/v4.0.0.dev.5 [rbs-v4.0.0]: https://github.com/ruby/rbs/releases/tag/v4.0.0 +[rbs-v4.0.1.dev.1]: https://github.com/ruby/rbs/releases/tag/v4.0.1.dev.1 +[rbs-v4.0.1.dev.2]: https://github.com/ruby/rbs/releases/tag/v4.0.1.dev.2 +[rbs-v4.0.1]: https://github.com/ruby/rbs/releases/tag/v4.0.1 [rbs-v4.0.2]: https://github.com/ruby/rbs/releases/tag/v4.0.2 [rbs-v4.0.3]: https://github.com/ruby/rbs/releases/tag/v4.0.3 [bigdecimal-v4.1.0]: https://github.com/ruby/bigdecimal/releases/tag/v4.1.0 From 9ac3cceaf29643e28da42c855f1307bcf6b43439 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 14:31:03 +0900 Subject: [PATCH 02/25] [ruby/rubygems] Move quality checks from RSpec suite to a lint rake task spec/quality_spec.rb only ran in the two full-suite Ubuntu jobs, so failures surfaced late and nowhere else. Port it to tool/quality_check.rb as plain Ruby, run it via `rake quality:check` in the ubuntu-lint check_misc job, and drop it from the spec suite, intentionally removing it from the ruby/ruby vendored suite where repo hygiene checks are not needed. The `ruby -w` warnings check now runs only on the check_misc CRuby (3.4) instead of also on 3.2. https://github.com/ruby/rubygems/commit/2c59e7b281 Co-Authored-By: Claude Fable 5 --- spec/bundler/quality_spec.rb | 261 --------------------------------- spec/bundler/support/shards.rb | 1 - 2 files changed, 262 deletions(-) delete mode 100644 spec/bundler/quality_spec.rb diff --git a/spec/bundler/quality_spec.rb b/spec/bundler/quality_spec.rb deleted file mode 100644 index 16b7f18788076c..00000000000000 --- a/spec/bundler/quality_spec.rb +++ /dev/null @@ -1,261 +0,0 @@ -# frozen_string_literal: true - -require "set" - -RSpec.describe "The library itself" do - def check_for_git_merge_conflicts(filename) - merge_conflicts_regex = / - <<<<<<<| - =======| - >>>>>>> - /x - - failing_lines = [] - each_line(filename) do |line, number| - failing_lines << number + 1 if line&.match?(merge_conflicts_regex) - end - - return if failing_lines.empty? - "#{filename} has unresolved git merge conflicts on lines #{failing_lines.join(", ")}" - end - - def check_for_tab_characters(filename) - # Because Go uses hard tabs - return if filename.end_with?(".go.tt") - - failing_lines = [] - each_line(filename) do |line, number| - failing_lines << number + 1 if line.include?("\t") - end - - return if failing_lines.empty? - "#{filename} has tab characters on lines #{failing_lines.join(", ")}" - end - - def check_for_extra_spaces(filename) - failing_lines = [] - each_line(filename) do |line, number| - next if /^\s+#.*\s+\n$/.match?(line) - failing_lines << number + 1 if /\s+\n$/.match?(line) - end - - return if failing_lines.empty? - "#{filename} has spaces on the EOL on lines #{failing_lines.join(", ")}" - end - - def check_for_extraneous_quotes(filename) - failing_lines = [] - each_line(filename) do |line, number| - failing_lines << number + 1 if /\u{2019}/.match?(line) - end - - return if failing_lines.empty? - "#{filename} has an extraneous quote on lines #{failing_lines.join(", ")}" - end - - def check_for_expendable_words(filename) - failing_line_message = [] - useless_words = %w[ - actually - basically - clearly - just - obviously - really - simply - ] - pattern = /\b#{Regexp.union(useless_words)}\b/i - - each_line(filename) do |line, number| - next unless word_found = pattern.match(line) - failing_line_message << "#{filename}:#{number.succ} has '#{word_found}'. Avoid using these kinds of weak modifiers." - end - - failing_line_message unless failing_line_message.empty? - end - - def check_for_specific_pronouns(filename) - failing_line_message = [] - specific_pronouns = /\b(he|she|his|hers|him|her|himself|herself)\b/i - - each_line(filename) do |line, number| - next unless word_found = specific_pronouns.match(line) - failing_line_message << "#{filename}:#{number.succ} has '#{word_found}'. Use more generic pronouns in documentation." - end - - failing_line_message unless failing_line_message.empty? - end - - it "has no malformed whitespace" do - exempt = /\.gitmodules|fixtures|vendor|LICENSE|vcr_cassettes|rbreadline\.diff|index\.txt$/ - error_messages = [] - tracked_files.each do |filename| - next if filename&.match?(exempt) - error_messages << check_for_tab_characters(filename) - error_messages << check_for_extra_spaces(filename) - end - expect(error_messages.compact).to be_well_formed - end - - it "has no extraneous quotes" do - exempt = /vendor|vcr_cassettes|LICENSE|rbreadline\.diff/ - error_messages = [] - tracked_files.each do |filename| - next if filename&.match?(exempt) - error_messages << check_for_extraneous_quotes(filename) - end - expect(error_messages.compact).to be_well_formed - end - - it "does not include any unresolved merge conflicts" do - error_messages = [] - exempt = %r{lock/lockfile_spec|quality_spec|vcr_cassettes|\.ronn|lockfile_parser} - tracked_files.each do |filename| - next if filename&.match?(exempt) - error_messages << check_for_git_merge_conflicts(filename) - end - expect(error_messages.compact).to be_well_formed - end - - it "maintains language quality of the documentation" do - error_messages = [] - man_tracked_files.each do |filename| - error_messages << check_for_expendable_words(filename) - error_messages << check_for_specific_pronouns(filename) - end - expect(error_messages.compact).to be_well_formed - end - - it "maintains language quality of sentences used in source code" do - error_messages = [] - exempt = /vendor|vcr_cassettes|CODE_OF_CONDUCT/ - lib_tracked_files.each do |filename| - next if filename&.match?(exempt) - error_messages << check_for_expendable_words(filename) - error_messages << check_for_specific_pronouns(filename) - end - expect(error_messages.compact).to be_well_formed - end - - it "documents all used settings" do - exemptions = %w[ - gem.changelog - gem.ci - gem.coc - gem.linter - gem.mit - gem.bundle - gem.rubocop - gem.test - git.allow_insecure - inline - trust-policy - ] - - all_settings = Hash.new {|h, k| h[k] = [] } - documented_settings = [] - - Bundler::Settings::BOOL_KEYS.each {|k| all_settings[k] << "in Bundler::Settings::BOOL_KEYS" } - Bundler::Settings::NUMBER_KEYS.each {|k| all_settings[k] << "in Bundler::Settings::NUMBER_KEYS" } - Bundler::Settings::ARRAY_KEYS.each {|k| all_settings[k] << "in Bundler::Settings::ARRAY_KEYS" } - Bundler::Settings::STRING_KEYS.each {|k| all_settings[k] << "in Bundler::Settings::STRING_KEYS" } - - key_pattern = /([a-z\._-]+)/i - lib_tracked_files.each do |filename| - each_line(filename) do |line, number| - line.scan(/Bundler\.settings\[:#{key_pattern}\]/).flatten.each {|s| all_settings[s] << "referenced at `#{filename}:#{number.succ}`" } - end - end - settings_section = File.read(source_root.join("lib/bundler/man/bundle-config.1.ronn")).split(/^## /).find {|section| section.start_with?("LIST OF AVAILABLE KEYS") } - documented_settings = settings_section.scan(/^\* `#{key_pattern}`/).flatten - - documented_settings.each do |s| - all_settings.delete(s) - expect(exemptions.delete(s)).to be_nil, "setting #{s} was exempted but was actually documented" - end - - exemptions.each do |s| - expect(all_settings.delete(s)).to be_truthy, "setting #{s} was exempted but unused" - end - error_messages = all_settings.map do |setting, refs| - "The `#{setting}` setting is undocumented\n\t- #{refs.join("\n\t- ")}\n" - end - - expect(error_messages.sort).to be_well_formed - - expect(documented_settings).to be_sorted - end - - it "can still be built" do - with_built_bundler do |gem_path| - expect(File.exist?(gem_path)).to be true - end - end - - it "ships the correct set of files" do - git_list = tracked_files.reject {|f| f.start_with?("spec/") } - - gem_list = loaded_gemspec.files - gem_list.map! {|f| f.sub(%r{\Aexe/}, "libexec/") } if ruby_core? - - expect(git_list).to match_array(gem_list) - end - - it "does not contain any warnings" do - exclusions = %w[ - lib/bundler/capistrano.rb - lib/bundler/deployment.rb - lib/bundler/gem_tasks.rb - lib/bundler/vlad.rb - ] - files_to_require = lib_tracked_files.grep(/\.rb$/) - exclusions - files_to_require.reject! {|f| f.start_with?("lib/bundler/vendor") } - files_to_require.map! {|f| File.expand_path(f, source_root) } - files_to_require.sort! - sys_exec("ruby -w") do |input, _, _| - files_to_require.each do |f| - input.puts "require '#{f}'" - end - end - - warnings = stdboth.split("\n") - # ignore warnings around deprecated Object#=~ method in RubyGems - warnings.reject! {|w| w =~ %r{rubygems\/version.rb.*deprecated\ Object#=~} } - - expect(warnings).to be_well_formed - end - - it "does not use require internally, but require_relative" do - exempt = %r{templates/|\.5|\.1|vendor/} - all_bad_requires = [] - lib_tracked_files.each do |filename| - next if filename&.match?(exempt) - each_line(filename) do |line, number| - line.scan(/^ *require "bundler/).each { all_bad_requires << "#{filename}:#{number.succ}" } - end - end - - expect(all_bad_requires).to be_empty, "#{all_bad_requires.size} internal requires that should use `require_relative`: #{all_bad_requires}" - end - - # We don't want our artifice code to activate bundler, but it needs to use the - # namespaced implementation of `Net::HTTP`. So we duplicate the file in - # bundler that loads that. - it "keeps vendored_net_http spec code in sync with the lib implementation" do - lib_implementation_path = File.join(source_lib_dir, "bundler", "vendored_net_http.rb") - expect(File.exist?(lib_implementation_path)).to be_truthy - lib_code = File.read(lib_implementation_path) - - spec_implementation_path = File.join(spec_dir, "support", "vendored_net_http.rb") - expect(File.exist?(spec_implementation_path)).to be_truthy - spec_code = File.read(spec_implementation_path) - - expect(lib_code).to eq(spec_code) - end - - private - - def each_line(filename, &block) - File.readlines(File.expand_path(filename, source_root), encoding: "UTF-8").each_with_index(&block) - end -end diff --git a/spec/bundler/support/shards.rb b/spec/bundler/support/shards.rb index e8e21ac842ad78..0a2c8413e224f2 100644 --- a/spec/bundler/support/shards.rb +++ b/spec/bundler/support/shards.rb @@ -175,7 +175,6 @@ module Shards "spec/commands/licenses_spec.rb", "spec/install/gemfile/lockfile_spec.rb", "spec/bundler/fetcher/dependency_spec.rb", - "spec/quality_spec.rb", "spec/bundler/remote_specification_spec.rb", "spec/install/process_lock_spec.rb", "spec/install/binstubs_spec.rb", From 28dc734a9d988604e1b4967e26cc76cdef03d86b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 14:48:35 +0900 Subject: [PATCH 03/25] [ruby/rubygems] Remove spec/quality_es_spec.rb It guarded the language quality of the Spanish man page translations, but no .es.ronn files are left in the repo, so it scanned English files for Spanish words and could never fail. Also remove the be_well_formed and be_sorted matchers, which have no remaining users. https://github.com/ruby/rubygems/commit/43a4188999 Co-Authored-By: Claude Fable 5 --- spec/bundler/quality_es_spec.rb | 61 -------------------------------- spec/bundler/support/matchers.rb | 22 ------------ spec/bundler/support/shards.rb | 1 - 3 files changed, 84 deletions(-) delete mode 100644 spec/bundler/quality_es_spec.rb diff --git a/spec/bundler/quality_es_spec.rb b/spec/bundler/quality_es_spec.rb deleted file mode 100644 index e68674c03094b7..00000000000000 --- a/spec/bundler/quality_es_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "La biblioteca si misma" do - def check_for_expendable_words(filename) - failing_line_message = [] - useless_words = %w[ - básicamente - claramente - sólo - solamente - obvio - obviamente - fácil - fácilmente - sencillamente - simplemente - ] - pattern = /\b#{Regexp.union(useless_words)}\b/i - - File.readlines(File.expand_path(filename, source_root)).each_with_index do |line, number| - next unless word_found = pattern.match(line) - failing_line_message << "#{filename}:#{number.succ} contiene '#{word_found}'. Esta palabra tiene un significado subjetivo y es mejor obviarla en textos técnicos." - end - - failing_line_message unless failing_line_message.empty? - end - - def check_for_specific_pronouns(filename) - failing_line_message = [] - specific_pronouns = /\b(él|ella|ellos|ellas)\b/i - - File.readlines(File.expand_path(filename, source_root)).each_with_index do |line, number| - next unless word_found = specific_pronouns.match(line) - failing_line_message << "#{filename}:#{number.succ} contiene '#{word_found}'. Use pronombres más genéricos en la documentación." - end - - failing_line_message unless failing_line_message.empty? - end - - it "mantiene la calidad de lenguaje de la documentación" do - included = /ronn/ - error_messages = [] - man_tracked_files.each do |filename| - next unless filename&.match?(included) - error_messages << check_for_expendable_words(filename) - error_messages << check_for_specific_pronouns(filename) - end - expect(error_messages.compact).to be_well_formed - end - - it "mantiene la calidad de lenguaje de oraciones usadas en el código fuente" do - error_messages = [] - exempt = /vendor/ - lib_tracked_files.each do |filename| - next if filename&.match?(exempt) - error_messages << check_for_expendable_words(filename) - error_messages << check_for_specific_pronouns(filename) - end - expect(error_messages.compact).to be_well_formed - end -end diff --git a/spec/bundler/support/matchers.rb b/spec/bundler/support/matchers.rb index efd56b44f68ffa..77b7d27e56e336 100644 --- a/spec/bundler/support/matchers.rb +++ b/spec/bundler/support/matchers.rb @@ -75,28 +75,6 @@ def self.define_compound_matcher(matcher, preconditions, &declarations) end end - RSpec::Matchers.define :be_sorted do - diffable - attr_reader :expected - match do |actual| - expected = block_arg ? actual.sort_by(&block_arg) : actual.sort - actual.==(expected).tap do - # HACK: since rspec won't show a diff when everything is a string - differ = RSpec::Support::Differ.new - @actual = differ.send(:object_to_string, actual) - @expected = differ.send(:object_to_string, expected) - end - end - end - - RSpec::Matchers.define :be_well_formed do - match(&:empty?) - - failure_message do |actual| - actual.join("\n") - end - end - define_compound_matcher :read_as, [exist] do |file_contents| diffable diff --git a/spec/bundler/support/shards.rb b/spec/bundler/support/shards.rb index 0a2c8413e224f2..4cc96860b6348d 100644 --- a/spec/bundler/support/shards.rb +++ b/spec/bundler/support/shards.rb @@ -95,7 +95,6 @@ module Shards "spec/bundler/retry_spec.rb", "spec/bundler/installer/spec_installation_spec.rb", "spec/bundler/spec_set_spec.rb", - "spec/quality_es_spec.rb", "spec/bundler/index_spec.rb", "spec/other/cli_man_pages_spec.rb", ], From 0946835f0a709ea0f22458a89bd5405e28af6828 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 13:15:31 +0900 Subject: [PATCH 04/25] [ruby/rubygems] Move world-writable cache coverage to the RubyGems test suite The e2e example from https://github.com/ruby/rubygems/commit/3b9c572b43 guarded compact index cache writes under a permissive umask. The client now lives in RubyGems, so assert the CacheFile invariant directly: a fresh cache file keeps the explicit 0644 mode instead of picking up the umask. https://github.com/ruby/rubygems/commit/dbd2465993 Co-Authored-By: Claude Fable 5 --- spec/bundler/install/gems/compact_index_spec.rb | 8 -------- .../test_gem_compact_index_client_cache_file.rb | 11 +++++++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/spec/bundler/install/gems/compact_index_spec.rb b/spec/bundler/install/gems/compact_index_spec.rb index efc955b8432ecd..e0fe8518c84dab 100644 --- a/spec/bundler/install/gems/compact_index_spec.rb +++ b/spec/bundler/install/gems/compact_index_spec.rb @@ -964,14 +964,6 @@ def start end end - it "works when cache dir is world-writable" do - install_gemfile <<-G, artifice: "compact_index" - File.umask(0000) - source "#{source_uri}" - gem "myrack" - G - end - it "doesn't explode when the API dependencies are wrong" do install_gemfile <<-G, artifice: "compact_index_wrong_dependencies", env: { "DEBUG" => "true" }, raise_on_error: false source "#{source_uri}" diff --git a/test/rubygems/test_gem_compact_index_client_cache_file.rb b/test/rubygems/test_gem_compact_index_client_cache_file.rb index f2e2633646b4a0..c770a4bb105097 100644 --- a/test/rubygems/test_gem_compact_index_client_cache_file.rb +++ b/test/rubygems/test_gem_compact_index_client_cache_file.rb @@ -139,6 +139,17 @@ def test_commit_after_close_raises end end + def test_write_with_permissive_umask_uses_default_mode + original_umask = File.umask(0o000) + + CacheFile.write(@path, "data") + + assert_equal "data", @path.read + assert_equal 0, @path.stat.mode & 0o022, "expected a fresh cache file to not be world-writable under a permissive umask" + ensure + File.umask(original_umask) + end + def test_write_preserves_permissions @path.binwrite "old" @path.chmod 0o400 From 5ede6e31346258dd38513bab7e4e1df1eb751b5d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 13:16:32 +0900 Subject: [PATCH 05/25] [ruby/rubygems] Retry without Range when compact index returns 416 Bundler's downloader already recovers from a stale over-long cache by dropping the Range header and refetching the whole file, but Gem::CompactIndexClient::HTTPFetcher raised FetchError instead, so the gem CLI could not self-heal. Align the behavior. https://github.com/ruby/rubygems/commit/0d65a77e3b Co-Authored-By: Claude Fable 5 --- .../compact_index_client/http_fetcher.rb | 5 +++ ...t_gem_compact_index_client_http_fetcher.rb | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/rubygems/compact_index_client/http_fetcher.rb b/lib/rubygems/compact_index_client/http_fetcher.rb index cb7b485ee49791..be63807b7098b5 100644 --- a/lib/rubygems/compact_index_client/http_fetcher.rb +++ b/lib/rubygems/compact_index_client/http_fetcher.rb @@ -41,6 +41,11 @@ def fetch(uri, headers, redirects_remaining) raise Gem::RemoteFetcher::FetchError.new("redirecting but no redirect location was given", uri) unless location fetch(uri + location, headers, redirects_remaining - 1) + when Gem::Net::HTTPRangeNotSatisfiable + raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) unless headers.key?("Range") + + # The local cache is longer than the remote file, refetch it whole. + fetch(uri, headers.except("Range"), redirects_remaining) else raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) end diff --git a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb index c563482dad8916..bb8c7f8727651c 100644 --- a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb +++ b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb @@ -34,6 +34,12 @@ def initialize end end + class FakeRangeNotSatisfiable < Gem::Net::HTTPRangeNotSatisfiable + def initialize + super("1.1", "416", "Range Not Satisfiable") + end + end + class FakeRemoteFetcher attr_reader :requests @@ -116,6 +122,35 @@ def test_call_raises_after_too_many_redirects assert_match(/too many redirects/, error.message) end + def test_call_retries_without_range_on_range_not_satisfiable + requests = [] + remote = Object.new + remote.define_singleton_method(:request) do |uri, request_class, &block| + request = request_class.new(uri) + block&.call(request) + requests << request + request["Range"] ? FakeRangeNotSatisfiable.new : FakeResponse.new("full data") + end + + fetcher = Gem::CompactIndexClient::HTTPFetcher.new("https://index.example", remote) + response = fetcher.call("versions", "Range" => "bytes=100-", "If-None-Match" => '"abc"') + + assert_equal "full data", response.body + assert_equal 2, requests.size + assert_nil requests.last["Range"] + assert_equal '"abc"', requests.last["If-None-Match"] + end + + def test_call_raises_on_range_not_satisfiable_without_range + fetcher, _remote = fetcher_for("https://index.example/versions" => FakeRangeNotSatisfiable.new) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + assert_match(/bad response Range Not Satisfiable 416/, error.message) + end + def test_call_raises_fetch_error_on_failure_response fetcher, _remote = fetcher_for("https://index.example/versions" => FakeNotFound.new) From 653716c1d26804326dbbeb87ab098adc5b84d191 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 13:17:23 +0900 Subject: [PATCH 06/25] [ruby/rubygems] Drop the redundant range-not-satisfiable e2e example Both halves of what it exercised are unit-covered now: the downloader spec covers dropping the Range header on 416, and the RubyGems updater tests cover replacing the cache from a full response. Remove the example and its dedicated artifice server. https://github.com/ruby/rubygems/commit/56f09e3ebc Co-Authored-By: Claude Fable 5 --- .../install/gems/compact_index_spec.rb | 35 ------------------- .../compact_index_range_not_satisfiable.rb | 34 ------------------ 2 files changed, 69 deletions(-) delete mode 100644 spec/bundler/support/artifice/compact_index_range_not_satisfiable.rb diff --git a/spec/bundler/install/gems/compact_index_spec.rb b/spec/bundler/install/gems/compact_index_spec.rb index e0fe8518c84dab..41d18280f24cfb 100644 --- a/spec/bundler/install/gems/compact_index_spec.rb +++ b/spec/bundler/install/gems/compact_index_spec.rb @@ -845,41 +845,6 @@ def start expect(the_bundle).to include_gems "myrack 1.0.0" end - it "performs full update of compact index info cache if range is not satisfiable" do - gemfile <<-G - source "#{source_uri}" - gem 'myrack', '0.9.1' - G - - bundle :install, artifice: "compact_index" - - cache_path = compact_index_cache_path.join("localgemserver.test.80.dd34752a738ee965a2a4298dc16db6c5") - - # We must remove the etag so that we don't ignore the range and get a 304 Not Modified. - myrack_info_etag_path = File.join(cache_path, "info-etags", "myrack-92f3313ce5721296f14445c3a6b9c073") - File.unlink(myrack_info_etag_path) if File.exist?(myrack_info_etag_path) - - myrack_info_path = File.join(cache_path, "info", "myrack") - expected_myrack_info_content = File.read(myrack_info_path) - - # Modify the cache files to make the range not satisfiable - File.open(myrack_info_path, "a") {|f| f << "0.9.2 |checksum:c55b525b421fd833a93171ad3d7f04528ca8e87d99ac273f8933038942a5888c" } - - # Update the Gemfile so the next install does its normal things - gemfile <<-G - source "#{source_uri}" - gem 'myrack', '1.0.0' - G - - # The cache files now being longer means the requested range is going to be not satisfiable - # Bundler must end up requesting the whole file to fix things up. - bundle :install, artifice: "compact_index_range_not_satisfiable" - - resulting_myrack_info_content = File.read(myrack_info_path) - - expect(resulting_myrack_info_content).to eq(expected_myrack_info_content) - end - it "fails gracefully when the source URI has an invalid scheme" do install_gemfile <<-G, raise_on_error: false source "htps://rubygems.org" diff --git a/spec/bundler/support/artifice/compact_index_range_not_satisfiable.rb b/spec/bundler/support/artifice/compact_index_range_not_satisfiable.rb deleted file mode 100644 index 8a7c4b79b0dcbe..00000000000000 --- a/spec/bundler/support/artifice/compact_index_range_not_satisfiable.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require_relative "helpers/compact_index" - -class CompactIndexRangeNotSatisfiable < CompactIndexAPI - get "/versions" do - if env["HTTP_RANGE"] - status 416 - else - etag_response do - file = tmp("versions.list") - FileUtils.rm_f(file) - file = CompactIndex::VersionsFile.new(file.to_s) - file.create(gems) - file.contents - end - end - end - - get "/info/:name" do - if env["HTTP_RANGE"] - status 416 - else - etag_response do - gem = gems.find {|g| g.name == params[:name] } - CompactIndex.info(gem ? gem.versions : []) - end - end - end -end - -require_relative "helpers/artifice" - -Artifice.activate_with(CompactIndexRangeNotSatisfiable) From 58e40b587024a7b6e61e70585dd4d2bb7247d475 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 13:06:08 +0900 Subject: [PATCH 07/25] [ruby/rubygems] Summarize cooldown-skipped versions at the end of install, update, and lock When cooldown silently resolves an older version, users comparing environments with different cooldown settings can't tell why they got different versions. Collect the versions the resolver excluded, and after a successful resolution print the newest skipped version per gem once the command finishes. https://github.com/ruby/rubygems/commit/ce938110f7 Co-Authored-By: Claude Fable 5 --- lib/bundler/cli/common.rb | 11 ++++ lib/bundler/cli/install.rb | 1 + lib/bundler/cli/lock.rb | 2 + lib/bundler/cli/update.rb | 1 + lib/bundler/definition.rb | 8 +++ lib/bundler/resolver.rb | 55 ++++++++++++++++++- spec/bundler/install/cooldown_spec.rb | 78 +++++++++++++++++++++++++++ 7 files changed, 155 insertions(+), 1 deletion(-) diff --git a/lib/bundler/cli/common.rb b/lib/bundler/cli/common.rb index b44fbc30964157..070cc095bdef5e 100644 --- a/lib/bundler/cli/common.rb +++ b/lib/bundler/cli/common.rb @@ -20,6 +20,17 @@ def self.print_post_install_message(name, msg) Bundler.ui.info msg end + def self.output_cooldown_skipped_summary(definition = Bundler.definition) + skipped = definition.cooldown_skipped + return if skipped.empty? + + Bundler.ui.info "The following gem versions were skipped by the cooldown setting:" + skipped.each do |entry| + days = entry[:available_in_days] + Bundler.ui.info " * #{entry[:name]} #{entry[:version]} (available in #{days} #{days == 1 ? "day" : "days"}), resolved #{entry[:resolved]} instead" + end + end + def self.output_fund_metadata_summary return if Bundler.settings["ignore_funding_requests"] definition = Bundler.definition diff --git a/lib/bundler/cli/install.rb b/lib/bundler/cli/install.rb index cf21afbb06e062..aec64adb2c496f 100644 --- a/lib/bundler/cli/install.rb +++ b/lib/bundler/cli/install.rb @@ -64,6 +64,7 @@ def run end Bundler::CLI::Common.output_post_install_messages installer.post_install_messages + Bundler::CLI::Common.output_cooldown_skipped_summary(definition) if CLI::Common.clean_after_install? require_relative "clean" diff --git a/lib/bundler/cli/lock.rb b/lib/bundler/cli/lock.rb index f6732c59254a09..eeea5ae1d51bd0 100644 --- a/lib/bundler/cli/lock.rb +++ b/lib/bundler/cli/lock.rb @@ -77,6 +77,8 @@ def run puts "Writing lockfile to #{file}" definition.write_lock(file, false) end + + Bundler::CLI::Common.output_cooldown_skipped_summary(definition) end Bundler.ui.output_stream = previous_output_stream diff --git a/lib/bundler/cli/update.rb b/lib/bundler/cli/update.rb index 33f9ac87406a54..c4eb6b07a0f334 100644 --- a/lib/bundler/cli/update.rb +++ b/lib/bundler/cli/update.rb @@ -126,6 +126,7 @@ def run Bundler.ui.confirm "Bundle updated!" Bundler::CLI::Common.output_without_groups_message(:update) Bundler::CLI::Common.output_post_install_messages installer.post_install_messages + Bundler::CLI::Common.output_cooldown_skipped_summary Bundler::CLI::Common.output_fund_metadata_summary end diff --git a/lib/bundler/definition.rb b/lib/bundler/definition.rb index ac37929386592c..b997b7e4767bd5 100644 --- a/lib/bundler/definition.rb +++ b/lib/bundler/definition.rb @@ -372,6 +372,12 @@ def spec_git_paths sources.git_sources.filter_map {|s| File.realpath(s.path) if File.exist?(s.path) } end + # Versions excluded by cooldown during the last resolution, one entry per + # gem with the newest skipped version. Empty when no resolution ran. + def cooldown_skipped + @cooldown_skipped || [] + end + def groups dependencies.flat_map(&:groups).uniq end @@ -783,6 +789,8 @@ def start_resolution result = SpecSet.new(resolver.start) + @cooldown_skipped = resolver.cooldown_skipped + @resolved_bundler_version = result.find {|spec| spec.name == "bundler" }&.version @new_platforms.each do |platform| diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index 972b33863f04aa..270ad90d161f1f 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -14,11 +14,15 @@ class Resolver require_relative "resolver/root" require_relative "resolver/strategy" + attr_reader :cooldown_skipped + def initialize(base, gem_version_promoter, most_specific_locked_platform = nil) @source_requirements = base.source_requirements @base = base @gem_version_promoter = gem_version_promoter @most_specific_locked_platform = most_specific_locked_platform + @cooldown_skipped = [] + @cooldown_skipped_specs = {} end def start @@ -36,6 +40,8 @@ def setup_solver root = Resolver::Root.new(name_for_explicit_dependency_source) root_version = Resolver::Candidate.new(0) + @cooldown_skipped_specs = {} + @all_specs = Hash.new do |specs, name| source = source_for(name) matches = source.specs.search(name) @@ -83,7 +89,9 @@ def solve_versions(root:, logger:) result = solver.solve resolved_specs = result.flat_map {|package, version| version.to_specs(package, @most_specific_locked_platform) } Override.attach(resolved_specs, @base.overrides) - SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs) + spec_set = SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs) + @cooldown_skipped = cooldown_skipped_summary(spec_set) + spec_set rescue Gem::PubGrub::SolveFailure => e incompatibility = e.incompatibility @@ -447,6 +455,7 @@ def cooldown_excluded_versions(specs) specs.each do |spec| next unless cooldown_excluded?(spec) excluded[[spec.name, spec.version]] = true + (@cooldown_skipped_specs ||= {})[[spec.name, spec.version]] ||= spec end excluded end @@ -489,6 +498,50 @@ def cooldown_now @cooldown_now ||= Time.now end + # Reports, per gem, the newest version that cooldown kept out of a + # successful resolution. A skipped version is only worth reporting when it + # is newer than the version actually resolved and satisfies every + # requirement the final resolution places on that gem, so we don't claim a + # version the resolver could never have picked anyway. + def cooldown_skipped_summary(spec_set) + return [] if @cooldown_skipped_specs.empty? + + requirements = Hash.new {|h, name| h[name] = [] } + @requirements.each {|dep| requirements[dep.name] << dep.requirement } + spec_set.each do |spec| + spec.dependencies.each {|dep| requirements[dep.name] << dep.requirement } + end + + resolved_versions = {} + spec_set.each do |spec| + version = resolved_versions[spec.name] + resolved_versions[spec.name] = spec.version if version.nil? || spec.version > version + end + + newest_skipped = {} + @cooldown_skipped_specs.each do |(name, version), spec| + resolved = resolved_versions[name] + next unless resolved && version > resolved + next unless requirements[name].all? {|req| req.satisfied_by?(version) } + newest = newest_skipped[name] + newest_skipped[name] = spec if newest.nil? || version > newest.version + end + + newest_skipped.values.sort_by(&:name).map do |spec| + { + name: spec.name, + version: spec.version, + resolved: resolved_versions[spec.name], + available_in_days: remaining_cooldown_days(spec), + } + end + end + + def remaining_cooldown_days(spec) + remaining = (spec.remote.effective_cooldown * 86_400) - (cooldown_now - spec.created_at) + [(remaining / 86_400.0).ceil, 1].max + end + def filter_remote_specs(specs, package) if package.prefer_local? local_specs = specs.select {|s| s.is_a?(StubSpecification) } diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index 5825f91b5789bd..7cf6dde932b254 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -188,6 +188,84 @@ expect(the_bundle).to include_gems("ripe_gem 2.0.0") end + it "summarizes skipped versions at the end of bundle install" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 7", artifice: "compact_index_cooldown" + + expect(out).to include("The following gem versions were skipped by the cooldown setting:") + expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead") + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "summarizes skipped versions at the end of bundle update" do + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update ripe_gem", artifice: "compact_index_cooldown" + + expect(out).to include("The following gem versions were skipped by the cooldown setting:") + expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead") + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "does not print a skip summary when cooldown is disabled" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 0", artifice: "compact_index_cooldown" + + expect(out).not_to include("skipped by the cooldown setting") + end + + it "does not print a skip summary for versions the Gemfile requirement rejects anyway" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem", "~> 1.0" + G + + bundle "install --cooldown 7", artifice: "compact_index_cooldown" + + expect(out).not_to include("skipped by the cooldown setting") + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "does not print a skip summary when installing from an up-to-date lockfile" do + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + bundle "install", artifice: "compact_index_cooldown" + expect(out).to include("skipped by the cooldown setting") + + bundle "install", artifice: "compact_index_cooldown" + expect(out).not_to include("skipped by the cooldown setting") + end + it "applies cooldown declared per-source in the Gemfile" do gemfile <<-G source "https://gem.repo3", cooldown: 7 From e20b1ca6978c6d12b918201088c5b46a0cee377d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 13:42:12 +0900 Subject: [PATCH 08/25] [ruby/rubygems] Fix weak modifier flagged by quality_spec https://github.com/ruby/rubygems/commit/528ee98f2c Co-Authored-By: Claude Fable 5 --- lib/bundler/resolver.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index 270ad90d161f1f..510ae09712c295 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -500,9 +500,9 @@ def cooldown_now # Reports, per gem, the newest version that cooldown kept out of a # successful resolution. A skipped version is only worth reporting when it - # is newer than the version actually resolved and satisfies every - # requirement the final resolution places on that gem, so we don't claim a - # version the resolver could never have picked anyway. + # is newer than the resolved version and satisfies every requirement the + # final resolution places on that gem, so we don't claim a version the + # resolver could never have picked anyway. def cooldown_skipped_summary(spec_set) return [] if @cooldown_skipped_specs.empty? From ecc6edae550059c6414af728f0ada809d7547b97 Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Fri, 17 Jul 2026 01:35:50 +0900 Subject: [PATCH 09/25] win32: a backslash before whitespace escaped the closing quote Inside double quotes, the escape was cancelled by almost any character but not by whitespace, so `"AA\ " BB` came out as a single argument. [Bug #22201] --- test/ruby/test_process.rb | 14 ++++++++++++++ win32/win32.c | 1 + 2 files changed, 15 insertions(+) diff --git a/test/ruby/test_process.rb b/test/ruby/test_process.rb index 7708f0d4225590..7b1ff61b569eaf 100644 --- a/test/ruby/test_process.rb +++ b/test/ruby/test_process.rb @@ -1251,6 +1251,20 @@ def test_spawn_trailing_backslash end end + def test_argv_backslash_before_space + return unless windows? + [ + ["AA\\ ", "BB"], + ["AA\\ ", "BB"], + ["AA\\\\ ", "BB"], + ["AA ", "BB"], # control: no backslash + ["AA\\", "BB"], # control: no space after the backslash + ].each do |args| + out = IO.popen([EnvUtil.rubybin, "-e", "STDOUT.binmode; print Marshal.dump(ARGV)", *args], "rb", &:read) + assert_equal(args, Marshal.load(out), "[Bug #22201] argv did not round-trip: #{args.inspect}") + end + end + def test_exec_wordsplit with_tmpchdir {|d| File.write("script", <<-'End') diff --git a/win32/win32.c b/win32/win32.c index aaf54a76a7e4d4..f3cda7bf804718 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -1746,6 +1746,7 @@ w32_cmdvector(const WCHAR *cmd, char ***vec, UINT cp, rb_encoding *enc) *ptr = 0; done = 1; } + slashes = 0; break; case L'*': From 23e969c789ee185442998176e8d4904be950cc1a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 18:41:54 +0900 Subject: [PATCH 10/25] [DOC] Use __LINE__ in the second Module#module_eval lineno example The example passing lineno 10 evaluated __FILE__, which prints the filename instead of 10. --- vm_eval.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vm_eval.c b/vm_eval.c index 945c21f568fe7e..f1c8ba88b00b35 100644 --- a/vm_eval.c +++ b/vm_eval.c @@ -2442,7 +2442,7 @@ rb_obj_instance_exec(int argc, const VALUE *argv, VALUE self) * class Foo; end * * Foo.module_eval("puts __LINE__") # => 1 - * Foo.module_eval("puts __FILE__", nil, 10) # => 10 + * Foo.module_eval("puts __LINE__", nil, 10) # => 10 * * When a block is given, evaluates the block in the context * of +self+: From d4ce236636a96c98772179bf6656837bb3ca3666 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 18:41:57 +0900 Subject: [PATCH 11/25] [DOC] Use IO::Buffer.for in the IO::Buffer#transfer example IO::Buffer.new takes a size as its first argument, so passing a string raises TypeError. Adjust the sample output to the flags the buffer created by IO::Buffer.for actually shows. --- io_buffer.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/io_buffer.c b/io_buffer.c index e7a03fcb786b14..ae00daa7e641da 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -1819,15 +1819,15 @@ io_buffer_slice(int argc, VALUE *argv, VALUE self) * Transfers ownership of the underlying memory to a new buffer, causing the * current buffer to become uninitialized. * - * buffer = IO::Buffer.new('test') + * buffer = IO::Buffer.for('test') * other = buffer.transfer * other * # => - * # # + * # # * # 0x00000000 74 65 73 74 test * buffer * # => - * # # + * # # * buffer.null? * # => true */ From 2d7947d082023aab8cee5ad065e7ded63dedeab1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 18:42:01 +0900 Subject: [PATCH 12/25] [DOC] Use trace_object_allocations_start in the dump_all example ObjectSpace.trace_object_allocations raises LocalJumpError when called without a block. --- ext/objspace/lib/objspace.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/objspace/lib/objspace.rb b/ext/objspace/lib/objspace.rb index 47873f51126796..0bd5670b5e700c 100644 --- a/ext/objspace/lib/objspace.rb +++ b/ext/objspace/lib/objspace.rb @@ -70,7 +70,7 @@ def dump(obj, output: :string) # If _shapes_ is +false+, no shapes are dumped. # # To only dump objects allocated past a certain point you can combine _since_ and _shapes_: - # ObjectSpace.trace_object_allocations + # ObjectSpace.trace_object_allocations_start # GC.start # gc_generation = GC.count # shape_generation = RubyVM.stat(:next_shape_id) From 2e5542578b608ee2f5399526992b4488b7cc2b46 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 18:42:05 +0900 Subject: [PATCH 13/25] [DOC] Add require 'date' to the SimpleDelegator example The example calls Date.new, which raises NameError without the require. --- lib/delegate.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/delegate.rb b/lib/delegate.rb index 0ff9797bdb2a61..e1f29925e782e9 100644 --- a/lib/delegate.rb +++ b/lib/delegate.rb @@ -252,6 +252,8 @@ def self.public_api # :nodoc: # and even to change the object being delegated to at a later time with # #__setobj__. # +# require 'date' +# # class User # def born_on # Date.new(1989, 9, 10) From d1a30a10b1ccff4247b6948fb79d6c229272e314 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 18:42:12 +0900 Subject: [PATCH 14/25] [DOC] Fix SyntaxError in the shareable_proc define_method example The do block attaches to define_method while &Ractor.shareable_proc also passes a block argument, so the example fails to parse. Pass the shareable proc as a positional argument instead. --- doc/language/ractor.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/language/ractor.md b/doc/language/ractor.md index 1592656217678f..059d9df76827b7 100644 --- a/doc/language/ractor.md +++ b/doc/language/ractor.md @@ -533,9 +533,7 @@ In order to dynamically define a method with `Module#define_method` that can be ```ruby class A - define_method :testing, &Ractor.shareable_proc do - p self - end + define_method(:testing, Ractor.shareable_proc { p self }) end Ractor.new do a = A.new From d2b75b5cb7d2598594013b4f027f17291089dd3f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 5 Aug 2026 18:32:50 +0900 Subject: [PATCH 15/25] [ruby/rubygems] Document SPDX license handling for license= and licenses= RubyGems validates each entry as a single SPDX identifier; compound expressions such as `MIT OR Apache-2.0` are rejected with a warning at `gem build`. Document that, the `+`/`WITH` syntax, and the array form as the only supported way to declare a multi-licensed gem. Also replace the deprecated `GPL-2.0` example with `GPL-2.0-only`. https://github.com/rubygems/guides/issues/433 https://github.com/ruby/rubygems/commit/eb398892ab Co-Authored-By: Claude Fable 5 --- lib/rubygems/specification.rb | 42 ++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index 50eecedb77276d..adee800051fc07 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -326,21 +326,30 @@ def authors=(value) ## # The license for this gem. # - # The license must be no more than 64 characters. - # - # This should just be the name of your license. The full text of the license - # should be inside of the gem (at the top level) when you build it. - # - # The simplest way is to specify the standard SPDX ID - # https://spdx.org/licenses/ for the license. + # The license must be no more than 64 characters, and should be a single + # SPDX license identifier from https://spdx.org/licenses/. # Ideally, you should pick one that is OSI (Open Source Initiative) # https://opensource.org/licenses/ approved. # # The most commonly used OSI-approved licenses are MIT and Apache-2.0. # GitHub also provides a license picker at https://choosealicense.com/. # - # You can also use a custom license file along with your gemspec and specify - # a LicenseRef-, where idstring is the name of the file containing + # The full text of the license should be inside of the gem (at the top + # level) when you build it. + # + # RubyGems validates the license against the SPDX license list when you + # run gem build and warns about unknown or deprecated identifiers. + # An identifier may carry a trailing + (this version or any later + # version) and a license exception joined with WITH, for example + # Apache-2.0 WITH LLVM-exception. + # + # Compound SPDX license expressions such as MIT OR Apache-2.0 are + # not currently supported. RubyGems treats the whole string as a single + # identifier and warns that it is invalid. For a gem available under more + # than one license, set each license as a separate entry with #licenses=. + # + # For a license that has no SPDX identifier, use Nonstandard, or + # LicenseRef- where idstring is the name of the file containing # the license text. # # You should specify a license for your gem so that people know how they are @@ -348,8 +357,6 @@ def authors=(value) # specifying a license means all rights are reserved; others have no right # to use the code for any purpose. # - # You can set multiple licenses with #licenses= - # # Usage: # spec.license = 'MIT' @@ -360,15 +367,20 @@ def license=(o) ## # The license(s) for the library. # - # Each license must be a short name, no more than 64 characters. + # Each entry must be a single SPDX license identifier, no more than 64 + # characters. Entries are validated independently, so a compound expression + # such as MIT OR Apache-2.0 is not valid as an entry. Listing the + # identifiers as separate array elements is currently the only way RubyGems + # supports declaring a dual- or multi-licensed gem. # - # This should just be the name of your license. The full - # text of the license should be inside of the gem when you build it. + # Note that the array itself does not state how the licenses combine. + # Include the full text of each license in the gem and describe the exact + # terms there. # # See #license= for more discussion # # Usage: - # spec.licenses = ['MIT', 'GPL-2.0'] + # spec.licenses = ['MIT', 'GPL-2.0-only'] def licenses=(licenses) @licenses = Array licenses From f3ec056c9f14e8e65a1e4617252f96c72b71d149 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 4 Aug 2026 16:02:46 -0400 Subject: [PATCH 16/25] [ruby/prism] Split up lparen token This more closely matches parse.y, and simplifies a bit of the lex state as seen from the outside. https://github.com/ruby/prism/commit/06dff548e8 --- lib/prism/lex_compat.rb | 1 + lib/prism/translation/parser/lexer.rb | 18 ++----------- prism/config.yml | 2 ++ prism/prism.c | 38 ++++++++++++++++++--------- prism/templates/src/tokens.c.erb | 2 ++ test/prism/ruby/parser_test.rb | 1 - 6 files changed, 33 insertions(+), 29 deletions(-) diff --git a/lib/prism/lex_compat.rb b/lib/prism/lex_compat.rb index 4d92842bda7515..dd7b7a4b04dbbd 100644 --- a/lib/prism/lex_compat.rb +++ b/lib/prism/lex_compat.rb @@ -191,6 +191,7 @@ def deconstruct_keys(keys) # :nodoc: NEWLINE: :on_nl, NUMBERED_REFERENCE: :on_backref, PARENTHESIS_LEFT: :on_lparen, + PARENTHESIS_LEFT_GROUPING: :on_lparen, PARENTHESIS_LEFT_PARENTHESES: :on_lparen, PARENTHESIS_RIGHT: :on_rparen, PERCENT: :on_op, diff --git a/lib/prism/translation/parser/lexer.rb b/lib/prism/translation/parser/lexer.rb index 0b2f4b9da76cb1..870d6716406d47 100644 --- a/lib/prism/translation/parser/lexer.rb +++ b/lib/prism/translation/parser/lexer.rb @@ -141,6 +141,7 @@ class Lexer # :nodoc: NEWLINE: :tNL, NUMBERED_REFERENCE: :tNTH_REF, PARENTHESIS_LEFT: :tLPAREN2, + PARENTHESIS_LEFT_GROUPING: :tLPAREN, PARENTHESIS_LEFT_PARENTHESES: :tLPAREN_ARG, PARENTHESIS_RIGHT: :tRPAREN, PERCENT: :tPERCENT, @@ -193,19 +194,6 @@ class Lexer # :nodoc: EXPR_BEG = 0x1 EXPR_LABEL = 0x400 - # The `PARENTHESIS_LEFT` token in Prism is classified as either - # `tLPAREN` or `tLPAREN2` in the Parser gem. The following token types - # are listed as those classified as `tLPAREN`. - LPAREN_CONVERSION_TOKEN_TYPES = Set.new([ - :kAND, :kBEGIN, :kBREAK, :kCASE, :kDO_COND, :kDO_LAMBDA, :kDO, :kELSE, - :kELSIF, :kENSURE, :kFOR, :kIF_MOD, :kIF, :kIN, :kNEXT, :kOR, - :kRESCUE_MOD, :kRESCUE, :kRETURN, :kTHEN, :kUNLESS_MOD, :kUNLESS, - :kUNTIL_MOD, :kUNTIL, :kWHEN, :kWHILE_MOD, :kWHILE, - :tAMPER, :tANDOP, :tBANG, :tCARET, :tCOMMA, :tDIVIDE, :tDOT2, :tDOT3, - :tEQL, :tLCURLY, :tLPAREN_ARG, :tLPAREN, :tLPAREN2, :tLSHFT, :tNL, - :tOP_ASGN, :tOROP, :tPIPE, :tSEMI, :tSTRING_DBEG, :tUMINUS, :tUPLUS - ]) - # Types of tokens that are allowed to continue a method call with comments in-between. # For these, the parser gem doesn't emit a newline token after the last comment. COMMENT_CONTINUATION_TYPES = Set.new([:COMMENT, :AMPERSAND_DOT, :DOT]) @@ -214,7 +202,7 @@ class Lexer # :nodoc: # Heredocs are complex and require us to keep track of a bit of info to refer to later HeredocData = Struct.new(:identifier, :common_whitespace, keyword_init: true) - private_constant :TYPES, :EXPR_BEG, :EXPR_LABEL, :LPAREN_CONVERSION_TOKEN_TYPES, :HeredocData + private_constant :TYPES, :EXPR_BEG, :EXPR_LABEL, :HeredocData # The Parser::Source::Buffer that the tokens were lexed from. attr_reader :source_buffer @@ -326,8 +314,6 @@ def to_a value.chomp!(":") when :tLCURLY type = :tLBRACE if state == EXPR_BEG | EXPR_LABEL - when :tLPAREN2 - type = :tLPAREN if tokens.empty? || LPAREN_CONVERSION_TOKEN_TYPES.include?(tokens.dig(-1, 0)) when :tNTH_REF value = parse_integer(value.delete_prefix("$")) when :tOP_ASGN diff --git a/prism/config.yml b/prism/config.yml index 21502ea8ca9e91..b700234fa5dff6 100644 --- a/prism/config.yml +++ b/prism/config.yml @@ -584,6 +584,8 @@ tokens: comment: "a numbered reference to a capture group in the previous regular expression match" - name: PARENTHESIS_LEFT comment: "(" + - name: PARENTHESIS_LEFT_GROUPING + comment: "( scanned at the beginning of an expression" - name: PARENTHESIS_LEFT_PARENTHESES comment: "( for a parentheses node" - name: PERCENT diff --git a/prism/prism.c b/prism/prism.c index 87bb03738fdf95..c8d0b3b5b20242 100644 --- a/prism/prism.c +++ b/prism/prism.c @@ -10493,9 +10493,15 @@ parser_lex(pm_parser_t *parser) { // ( case '(': { + /* A parenthesis scanned at the beginning of an expression + * groups the expression it wraps, while one scanned in + * argument position with a preceding space wraps a command + * argument. Everything else opens an argument list. */ pm_token_type_t type = PM_TOKEN_PARENTHESIS_LEFT; - if (space_seen && (lex_state_arg_p(parser) || parser->lex_state == (PM_LEX_STATE_END | PM_LEX_STATE_LABEL))) { + if (lex_state_beg_p(parser)) { + type = PM_TOKEN_PARENTHESIS_LEFT_GROUPING; + } else if (space_seen && (lex_state_arg_p(parser) || parser->lex_state == (PM_LEX_STATE_END | PM_LEX_STATE_LABEL))) { type = PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES; } @@ -12751,6 +12757,14 @@ match4(const pm_parser_t *parser, pm_token_type_t type1, pm_token_type_t type2, return match1(parser, type1) || match1(parser, type2) || match1(parser, type3) || match1(parser, type4); } +/** + * Returns true if the current token is any of the five given types. + */ +static PRISM_INLINE bool +match5(const pm_parser_t *parser, pm_token_type_t type1, pm_token_type_t type2, pm_token_type_t type3, pm_token_type_t type4, pm_token_type_t type5) { + return match1(parser, type1) || match1(parser, type2) || match1(parser, type3) || match1(parser, type4) || match1(parser, type5); +} + /** * Returns true if the current token is any of the six given types. */ @@ -13571,7 +13585,7 @@ parse_targets(pm_parser_t *parser, pm_node_t *first_target, pm_binding_power_t b pm_node_t *splat = UP(pm_splat_node_create(parser, &star_operator, name)); pm_multi_target_node_targets_append(parser, result, splat); has_rest = true; - } else if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { + } else if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { context_push(parser, PM_CONTEXT_MULTI_TARGET); pm_node_t *target = parse_expression(parser, binding_power, PM_PARSE_ACCEPTS_DO_BLOCK, PM_ERR_EXPECT_EXPRESSION_AFTER_COMMA, (uint16_t) (depth + 1)); target = parse_target(parser, target, true, false); @@ -14335,7 +14349,7 @@ parse_arguments(pm_parser_t *parser, pm_arguments_t *arguments, bool accepts_for */ static pm_multi_target_node_t * parse_required_destructured_parameter(pm_parser_t *parser) { - expect1(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_ERR_EXPECT_LPAREN_REQ_PARAMETER); + expect1(parser, PM_TOKEN_PARENTHESIS_LEFT_GROUPING, PM_ERR_EXPECT_LPAREN_REQ_PARAMETER); pm_multi_target_node_t *node = pm_multi_target_node_create(parser); pm_multi_target_node_opening_set(parser, node, &parser->previous); @@ -14354,7 +14368,7 @@ parse_required_destructured_parameter(pm_parser_t *parser) { break; } - if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { + if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { param = UP(parse_required_destructured_parameter(parser)); } else if (accept1(parser, PM_TOKEN_USTAR)) { pm_token_t star = parser->previous; @@ -14416,7 +14430,7 @@ static pm_parameters_order_t parameters_ordering[PM_TOKEN_MAXIMUM] = { [PM_TOKEN_AMPERSAND] = PM_PARAMETERS_ORDER_NOTHING_AFTER, [PM_TOKEN_UDOT_DOT_DOT] = PM_PARAMETERS_ORDER_NOTHING_AFTER, [PM_TOKEN_IDENTIFIER] = PM_PARAMETERS_ORDER_NAMED, - [PM_TOKEN_PARENTHESIS_LEFT] = PM_PARAMETERS_ORDER_NAMED, + [PM_TOKEN_PARENTHESIS_LEFT_GROUPING] = PM_PARAMETERS_ORDER_NAMED, [PM_TOKEN_EQUAL] = PM_PARAMETERS_ORDER_OPTIONAL, [PM_TOKEN_LABEL] = PM_PARAMETERS_ORDER_KEYWORDS, [PM_TOKEN_USTAR] = PM_PARAMETERS_ORDER_AFTER_OPTIONAL, @@ -14523,7 +14537,7 @@ parse_parameters( bool parsing = true; switch (parser->current.type) { - case PM_TOKEN_PARENTHESIS_LEFT: { + case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: { update_parameter_state(parser, &parser->current, &order); pm_node_t *param = UP(parse_required_destructured_parameter(parser)); @@ -15522,7 +15536,7 @@ parse_arguments_list(pm_parser_t *parser, pm_arguments_t *arguments, bool full_a * it, so pop the delimiter frame, push the command-args frame, and then * restore the delimiter frame on top (the delimiter's closing token * will pop it back off during argument parsing). */ - bool lookahead_delimiter = match4(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES, PM_TOKEN_BRACKET_LEFT, PM_TOKEN_BRACKET_LEFT_ARRAY); + bool lookahead_delimiter = match5(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_GROUPING, PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES, PM_TOKEN_BRACKET_LEFT, PM_TOKEN_BRACKET_LEFT_ARRAY); if (lookahead_delimiter) pm_accepts_block_stack_pop(parser); pm_accepts_block_stack_push(parser, false); if (lookahead_delimiter) pm_accepts_block_stack_push(parser, true); @@ -17501,7 +17515,7 @@ parse_pattern_primitive(pm_parser_t *parser, pm_constant_id_list_t *captures, pm return UP(pm_pinned_variable_node_create(parser, &operator, variable)); } - case PM_TOKEN_PARENTHESIS_LEFT: { + case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: { bool previous_pattern_matching_newlines = parser->pattern_matching_newlines; parser->pattern_matching_newlines = false; @@ -17604,7 +17618,7 @@ parse_pattern_primitives(pm_parser_t *parser, pm_constant_id_list_t *captures, p break; } - case PM_TOKEN_PARENTHESIS_LEFT: + case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: case PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES: { pm_token_t operator = parser->previous; pm_token_t opening = parser->current; @@ -19506,7 +19520,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u return UP(array); } - case PM_TOKEN_PARENTHESIS_LEFT: + case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: case PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES: return parse_parentheses(parser, binding_power, flags, depth); case PM_TOKEN_BRACE_LEFT: { @@ -20129,7 +20143,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u context_push(parser, PM_CONTEXT_DEFINED); bool newline = accept1(parser, PM_TOKEN_NEWLINE); - if (accept1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { + if (accept2(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { lparen = parser->previous; if (newline && accept1(parser, PM_TOKEN_PARENTHESIS_RIGHT)) { @@ -20298,7 +20312,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u accept1(parser, PM_TOKEN_NEWLINE); - if (accept1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { + if (accept2(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { pm_token_t lparen = parser->previous; if (accept1(parser, PM_TOKEN_PARENTHESIS_RIGHT)) { diff --git a/prism/templates/src/tokens.c.erb b/prism/templates/src/tokens.c.erb index 472c82ea690939..fe8cfd58683c23 100644 --- a/prism/templates/src/tokens.c.erb +++ b/prism/templates/src/tokens.c.erb @@ -275,6 +275,8 @@ pm_token_str(pm_token_type_t token_type) { return "numbered reference"; case PM_TOKEN_PARENTHESIS_LEFT: return "'('"; + case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: + return "'('"; case PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES: return "'('"; case PM_TOKEN_PARENTHESIS_RIGHT: diff --git a/test/prism/ruby/parser_test.rb b/test/prism/ruby/parser_test.rb index 856ecedc1de39d..74605c2a6899e4 100644 --- a/test/prism/ruby/parser_test.rb +++ b/test/prism/ruby/parser_test.rb @@ -111,7 +111,6 @@ class ParserTest < TestCase skip_tokens = [ "dash_heredocs.txt", "embdoc_no_newline_at_end.txt", - "methods.txt", "seattlerb/bug169.txt", "seattlerb/case_in.txt", "seattlerb/difficult4__leading_dots2.txt", From 5e36630fc012a305ea23aacd1c14cc8f9ad194f1 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 4 Aug 2026 22:11:20 -0400 Subject: [PATCH 17/25] [ruby/prism] Split up lbrace token This also more closely matches parse.y, and makes it easier to delineate which brace belongs to which type. https://github.com/ruby/prism/commit/43ed8c1f80 --- lib/prism/lex_compat.rb | 2 ++ lib/prism/translation/parser/lexer.rb | 18 +++---------- prism/config.yml | 4 +++ prism/prism.c | 37 +++++++++++++++++--------- prism/templates/src/tokens.c.erb | 4 +++ test/prism/errors/command_calls_25.txt | 3 ++- test/prism/ruby/parser_test.rb | 9 +------ 7 files changed, 41 insertions(+), 36 deletions(-) diff --git a/lib/prism/lex_compat.rb b/lib/prism/lex_compat.rb index dd7b7a4b04dbbd..ce9eed94fb8002 100644 --- a/lib/prism/lex_compat.rb +++ b/lib/prism/lex_compat.rb @@ -78,6 +78,8 @@ def deconstruct_keys(keys) # :nodoc: BANG_EQUAL: :on_op, BANG_TILDE: :on_op, BRACE_LEFT: :on_lbrace, + BRACE_LEFT_ARGUMENT: :on_lbrace, + BRACE_LEFT_HASH: :on_lbrace, BRACE_RIGHT: :on_rbrace, BRACKET_LEFT: :on_lbracket, BRACKET_LEFT_ARRAY: :on_lbracket, diff --git a/lib/prism/translation/parser/lexer.rb b/lib/prism/translation/parser/lexer.rb index 870d6716406d47..2cb78c3ce67795 100644 --- a/lib/prism/translation/parser/lexer.rb +++ b/lib/prism/translation/parser/lexer.rb @@ -33,6 +33,8 @@ class Lexer # :nodoc: BANG_EQUAL: :tNEQ, BANG_TILDE: :tNMATCH, BRACE_LEFT: :tLCURLY, + BRACE_LEFT_ARGUMENT: :tLBRACE_ARG, + BRACE_LEFT_HASH: :tLBRACE, BRACE_RIGHT: :tRCURLY, BRACKET_LEFT: :tLBRACK2, BRACKET_LEFT_ARRAY: :tLBRACK, @@ -184,16 +186,6 @@ class Lexer # :nodoc: WORDS_SEP: :tSPACE } - # These constants represent flags in our lex state. We really, really - # don't want to be using them and we really, really don't want to be - # exposing them as part of our public API. Unfortunately, we don't have - # another way of matching the exact tokens that the parser gem expects - # without them. We should find another way to do this, but in the - # meantime we'll hide them from the documentation and mark them as - # private constants. - EXPR_BEG = 0x1 - EXPR_LABEL = 0x400 - # Types of tokens that are allowed to continue a method call with comments in-between. # For these, the parser gem doesn't emit a newline token after the last comment. COMMENT_CONTINUATION_TYPES = Set.new([:COMMENT, :AMPERSAND_DOT, :DOT]) @@ -202,7 +194,7 @@ class Lexer # :nodoc: # Heredocs are complex and require us to keep track of a bit of info to refer to later HeredocData = Struct.new(:identifier, :common_whitespace, keyword_init: true) - private_constant :TYPES, :EXPR_BEG, :EXPR_LABEL, :HeredocData + private_constant :TYPES, :HeredocData # The Parser::Source::Buffer that the tokens were lexed from. attr_reader :source_buffer @@ -241,7 +233,7 @@ def to_a comment_newline_location = nil while index < length - token, state = lexed[index] + token, _ = lexed[index] index += 1 next if TYPES_ALWAYS_SKIP.include?(token.type) @@ -312,8 +304,6 @@ def to_a value.chomp!(":") when :tLABEL_END value.chomp!(":") - when :tLCURLY - type = :tLBRACE if state == EXPR_BEG | EXPR_LABEL when :tNTH_REF value = parse_integer(value.delete_prefix("$")) when :tOP_ASGN diff --git a/prism/config.yml b/prism/config.yml index b700234fa5dff6..249ecbe09fba33 100644 --- a/prism/config.yml +++ b/prism/config.yml @@ -388,6 +388,10 @@ tokens: comment: "!~" - name: BRACE_LEFT comment: "{" + - name: BRACE_LEFT_ARGUMENT + comment: "{ for a block following a parenthesized argument" + - name: BRACE_LEFT_HASH + comment: "{ for a hash literal" - name: BRACKET_LEFT comment: "[" - name: BRACKET_LEFT_ARRAY diff --git a/prism/prism.c b/prism/prism.c index c8d0b3b5b20242..96d391030640c1 100644 --- a/prism/prism.c +++ b/prism/prism.c @@ -10560,24 +10560,28 @@ parser_lex(pm_parser_t *parser) { pm_token_type_t type = PM_TOKEN_BRACE_LEFT; if (parser->enclosure_nesting == parser->lambda_enclosure_nesting) { - // This { begins a lambda + /* This { begins a lambda */ parser->command_start = true; lex_state_set(parser, PM_LEX_STATE_BEG); type = PM_TOKEN_LAMBDA_BEGIN; } else if (lex_state_p(parser, PM_LEX_STATE_LABELED)) { - // This { begins a hash literal + /* This { begins a hash literal */ lex_state_set(parser, PM_LEX_STATE_BEG | PM_LEX_STATE_LABEL); + type = PM_TOKEN_BRACE_LEFT_HASH; } else if (lex_state_p(parser, PM_LEX_STATE_ARG_ANY | PM_LEX_STATE_END | PM_LEX_STATE_ENDFN)) { - // This { begins a block + /* This { begins a block */ parser->command_start = true; lex_state_set(parser, PM_LEX_STATE_BEG); } else if (lex_state_p(parser, PM_LEX_STATE_ENDARG)) { - // This { begins a block on a command + /* This { begins a block following a parenthesized + * command argument */ parser->command_start = true; lex_state_set(parser, PM_LEX_STATE_BEG); + type = PM_TOKEN_BRACE_LEFT_ARGUMENT; } else { - // This { begins a hash literal + /* This { begins a hash literal */ lex_state_set(parser, PM_LEX_STATE_BEG | PM_LEX_STATE_LABEL); + type = PM_TOKEN_BRACE_LEFT_HASH; } parser->enclosure_nesting++; @@ -13796,7 +13800,7 @@ parse_assocs(pm_parser_t *parser, pm_static_literals_t *literals, pm_node_t *nod pm_token_t operator = parser->previous; pm_node_t *value = NULL; - if (match1(parser, PM_TOKEN_BRACE_LEFT)) { + if (match1(parser, PM_TOKEN_BRACE_LEFT_HASH)) { // If we're about to parse a nested hash that is being // pushed into this hash directly with **, then we want the // inner hash to share the static literals with the outer @@ -15414,7 +15418,7 @@ parse_block(pm_parser_t *parser, uint16_t depth) { * managed by the lexer. A `do`/`end` block is delimited by keywords, so we * push the frame here (covering the block parameters and body) and pop it * before consuming `end`, mirroring parse.y's `do_body` rule. */ - bool do_block = opening.type != PM_TOKEN_BRACE_LEFT; + bool do_block = opening.type != PM_TOKEN_BRACE_LEFT && opening.type != PM_TOKEN_BRACE_LEFT_ARGUMENT; if (do_block) pm_accepts_block_stack_push(parser, true); pm_parser_scope_push(parser, false); @@ -15439,7 +15443,7 @@ parse_block(pm_parser_t *parser, uint16_t depth) { accept1(parser, PM_TOKEN_NEWLINE); pm_node_t *statements = NULL; - if (opening.type == PM_TOKEN_BRACE_LEFT) { + if (!do_block) { if (!match1(parser, PM_TOKEN_BRACE_RIGHT)) { statements = UP(parse_statements(parser, PM_CONTEXT_BLOCK_BRACES, (uint16_t) (depth + 1))); } @@ -15558,7 +15562,7 @@ parse_arguments_list(pm_parser_t *parser, pm_arguments_t *arguments, bool full_a * it, pop the command-args frame beneath it, and restore the block * frame so the block's `}` still pops it. This mirrors the `tLBRACE_ARG` * lookahead handling in parse.y's `command_args` rule. */ - bool lookahead_brace = match1(parser, PM_TOKEN_BRACE_LEFT); + bool lookahead_brace = match2(parser, PM_TOKEN_BRACE_LEFT, PM_TOKEN_BRACE_LEFT_ARGUMENT); if (lookahead_brace) pm_accepts_block_stack_pop(parser); pm_accepts_block_stack_pop(parser); if (lookahead_brace) pm_accepts_block_stack_push(parser, true); @@ -15570,7 +15574,7 @@ parse_arguments_list(pm_parser_t *parser, pm_arguments_t *arguments, bool full_a if (full_arguments) { pm_block_node_t *block = NULL; - if (accept1(parser, PM_TOKEN_BRACE_LEFT)) { + if (accept2(parser, PM_TOKEN_BRACE_LEFT, PM_TOKEN_BRACE_LEFT_ARGUMENT)) { found |= true; block = parse_block(parser, (uint16_t) (depth + 1)); pm_arguments_validate_block(parser, arguments, block); @@ -17378,7 +17382,7 @@ parse_pattern_primitive(pm_parser_t *parser, pm_constant_id_list_t *captures, pm pm_array_pattern_node_requireds_append(parser->arena, node, inner); return UP(node); } - case PM_TOKEN_BRACE_LEFT: { + case PM_TOKEN_BRACE_LEFT_HASH: { bool previous_pattern_matching_newlines = parser->pattern_matching_newlines; parser->pattern_matching_newlines = false; @@ -17599,7 +17603,7 @@ parse_pattern_primitives(pm_parser_t *parser, pm_constant_id_list_t *captures, p switch (parser->current.type) { case PM_TOKEN_IDENTIFIER: case PM_TOKEN_BRACKET_LEFT_ARRAY: - case PM_TOKEN_BRACE_LEFT: + case PM_TOKEN_BRACE_LEFT_HASH: case PM_TOKEN_CARET: case PM_TOKEN_CONSTANT: case PM_TOKEN_UCOLON_COLON: @@ -19200,6 +19204,13 @@ parse_parentheses(pm_parser_t *parser, pm_binding_power_t binding_power, uint8_t /* If this is the end of the file or we match a right parenthesis, then we * have an empty parentheses node, and we can immediately return. */ if (match2(parser, PM_TOKEN_PARENTHESIS_RIGHT, PM_TOKEN_EOF)) { + /* A command argument group sets EXPR_ENDARG before its ')' is + * consumed, even when the group is empty, so that a following '{' is + * scanned as a block brace. */ + if (match1(parser, PM_TOKEN_PARENTHESIS_RIGHT) && opening.type == PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES) { + lex_state_set(parser, PM_LEX_STATE_ENDARG); + } + expect1(parser, PM_TOKEN_PARENTHESIS_RIGHT, PM_ERR_EXPECT_RPAREN); pop_block_exits(parser, previous_block_exits); return UP(pm_parentheses_node_create(parser, &opening, NULL, &parser->previous, paren_flags)); @@ -19523,7 +19534,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: case PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES: return parse_parentheses(parser, binding_power, flags, depth); - case PM_TOKEN_BRACE_LEFT: { + case PM_TOKEN_BRACE_LEFT_HASH: { // If we were passed a current_hash_keys via the parser, then that // means we're already parsing a hash and we want to share the set // of hash keys with this inner hash we're about to parse for the diff --git a/prism/templates/src/tokens.c.erb b/prism/templates/src/tokens.c.erb index fe8cfd58683c23..92600590c0f552 100644 --- a/prism/templates/src/tokens.c.erb +++ b/prism/templates/src/tokens.c.erb @@ -53,6 +53,10 @@ pm_token_str(pm_token_type_t token_type) { return "'!~'"; case PM_TOKEN_BRACE_LEFT: return "'{'"; + case PM_TOKEN_BRACE_LEFT_ARGUMENT: + return "'{'"; + case PM_TOKEN_BRACE_LEFT_HASH: + return "'{'"; case PM_TOKEN_BRACE_RIGHT: return "'}'"; case PM_TOKEN_BRACKET_LEFT: diff --git a/test/prism/errors/command_calls_25.txt b/test/prism/errors/command_calls_25.txt index cf04508f87d8a7..c8769c538ab75f 100644 --- a/test/prism/errors/command_calls_25.txt +++ b/test/prism/errors/command_calls_25.txt @@ -3,6 +3,7 @@ ^ expected a `do` keyword or a `{` to open the lambda block ^ unexpected ')', expecting end-of-input ^ unexpected ')', ignoring it - ^ unexpected end-of-input, assuming it is closing the parent top level context + ^ unexpected '{', ignoring it + ^ unexpected '}', ignoring it ^~ expected a lambda block beginning with `do` to end with `end` diff --git a/test/prism/ruby/parser_test.rb b/test/prism/ruby/parser_test.rb index 74605c2a6899e4..e44bc20d4dea0b 100644 --- a/test/prism/ruby/parser_test.rb +++ b/test/prism/ruby/parser_test.rb @@ -111,11 +111,8 @@ class ParserTest < TestCase skip_tokens = [ "dash_heredocs.txt", "embdoc_no_newline_at_end.txt", - "seattlerb/bug169.txt", "seattlerb/case_in.txt", "seattlerb/difficult4__leading_dots2.txt", - "seattlerb/difficult6__7.txt", - "seattlerb/difficult6__8.txt", "seattlerb/heredoc_unicode.txt", "seattlerb/parse_line_heredoc.txt", "seattlerb/pct_w_heredoc_interp_nested.txt", @@ -128,14 +125,10 @@ class ParserTest < TestCase "whitequark/beginless_irange_after_newline.txt", "whitequark/forward_arg_with_open_args.txt", "whitequark/kwarg_no_paren.txt", - "whitequark/lbrace_arg_after_command_args.txt", "whitequark/multiple_pattern_matches.txt", "whitequark/newline_in_hash_argument.txt", "whitequark/pattern_matching_hash.txt", - "whitequark/ruby_bug_14690.txt", - "whitequark/ruby_bug_9669.txt", - "whitequark/space_args_arg_block.txt", - "whitequark/space_args_block.txt" + "whitequark/ruby_bug_9669.txt" ] Fixture.each_for_version(except: skip_syntax_error, version: "3.3") do |fixture| From 0017dea39538b75f54a64364e3878f8421378812 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 4 Aug 2026 22:32:44 -0400 Subject: [PATCH 18/25] [ruby/prism] Split up backtick token Now we fully match parse.y, and it is clearer to distinguish betewen backticks that are used for method names and backticks that begin xstring literals. https://github.com/ruby/prism/commit/a33c3ff7fe --- lib/prism/lex_compat.rb | 1 + lib/prism/translation/parser/lexer.rb | 9 +++------ prism/config.yml | 4 +++- prism/prism.c | 6 +++--- prism/templates/src/tokens.c.erb | 2 ++ 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/prism/lex_compat.rb b/lib/prism/lex_compat.rb index ce9eed94fb8002..749f11173a42aa 100644 --- a/lib/prism/lex_compat.rb +++ b/lib/prism/lex_compat.rb @@ -236,6 +236,7 @@ def deconstruct_keys(keys) # :nodoc: USTAR: :on_op, USTAR_STAR: :on_op, WORDS_SEP: :on_words_sep, + XSTRING_BEGIN: :on_backtick, "__END__": :on___end__ }.freeze diff --git a/lib/prism/translation/parser/lexer.rb b/lib/prism/translation/parser/lexer.rb index 2cb78c3ce67795..c26f48bcfbfda7 100644 --- a/lib/prism/translation/parser/lexer.rb +++ b/lib/prism/translation/parser/lexer.rb @@ -28,7 +28,7 @@ class Lexer # :nodoc: AMPERSAND_DOT: :tANDDOT, AMPERSAND_EQUAL: :tOP_ASGN, BACK_REFERENCE: :tBACK_REF, - BACKTICK: :tXSTRING_BEG, + BACKTICK: :tBACK_REF2, BANG: :tBANG, BANG_EQUAL: :tNEQ, BANG_TILDE: :tNMATCH, @@ -183,7 +183,8 @@ class Lexer # :nodoc: UPLUS: :tUPLUS, USTAR: :tSTAR, USTAR_STAR: :tDSTAR, - WORDS_SEP: :tSPACE + WORDS_SEP: :tSPACE, + XSTRING_BEGIN: :tXSTRING_BEG } # Types of tokens that are allowed to continue a method call with comments in-between. @@ -482,10 +483,6 @@ def to_a type = :tIDENTIFIER end when :tXSTRING_BEG - if (next_token = lexed[index]&.first) && !%i[STRING_CONTENT STRING_END EMBEXPR_BEGIN].include?(next_token.type) - # self.`() - type = :tBACK_REF2 - end quote_stack.push(value) when :tSYMBOLS_BEG, :tQSYMBOLS_BEG, :tWORDS_BEG, :tQWORDS_BEG if (next_token = lexed[index]&.first) && next_token.type == :WORDS_SEP diff --git a/prism/config.yml b/prism/config.yml index 249ecbe09fba33..cc5eb7e099c228 100644 --- a/prism/config.yml +++ b/prism/config.yml @@ -377,7 +377,7 @@ tokens: - name: AMPERSAND_EQUAL comment: "&=" - name: BACKTICK - comment: "`" + comment: "` as a method name" - name: BACK_REFERENCE comment: "a back reference" - name: BANG @@ -664,6 +664,8 @@ tokens: comment: "unary **" - name: WORDS_SEP comment: "a separator between words in a list" + - name: XSTRING_BEGIN + comment: "the beginning of an execution string" - name: __END__ comment: "marker for the point in the file at which the parser should stop" flags: diff --git a/prism/prism.c b/prism/prism.c index 96d391030640c1..bd16a3f2822db4 100644 --- a/prism/prism.c +++ b/prism/prism.c @@ -10898,7 +10898,7 @@ parser_lex(pm_parser_t *parser) { } lex_mode_push_string(parser, true, false, '\0', '`'); - LEX(PM_TOKEN_BACKTICK); + LEX(PM_TOKEN_XSTRING_BEGIN); } // single-quoted string literal @@ -16036,7 +16036,7 @@ parse_conditional(pm_parser_t *parser, pm_context_t context, size_t opening_newl #define PM_CASE_PRIMITIVE PM_TOKEN_INTEGER: case PM_TOKEN_INTEGER_IMAGINARY: case PM_TOKEN_INTEGER_RATIONAL: \ case PM_TOKEN_INTEGER_RATIONAL_IMAGINARY: case PM_TOKEN_FLOAT: case PM_TOKEN_FLOAT_IMAGINARY: \ case PM_TOKEN_FLOAT_RATIONAL: case PM_TOKEN_FLOAT_RATIONAL_IMAGINARY: case PM_TOKEN_SYMBOL_BEGIN: \ - case PM_TOKEN_REGEXP_BEGIN: case PM_TOKEN_BACKTICK: case PM_TOKEN_PERCENT_LOWER_X: case PM_TOKEN_PERCENT_LOWER_I: \ + case PM_TOKEN_REGEXP_BEGIN: case PM_TOKEN_XSTRING_BEGIN: case PM_TOKEN_PERCENT_LOWER_X: case PM_TOKEN_PERCENT_LOWER_I: \ case PM_TOKEN_PERCENT_LOWER_W: case PM_TOKEN_PERCENT_UPPER_I: case PM_TOKEN_PERCENT_UPPER_W: \ case PM_TOKEN_STRING_BEGIN: case PM_TOKEN_KEYWORD_NIL: case PM_TOKEN_KEYWORD_SELF: case PM_TOKEN_KEYWORD_TRUE: \ case PM_TOKEN_KEYWORD_FALSE: case PM_TOKEN_KEYWORD___FILE__: case PM_TOKEN_KEYWORD___LINE__: \ @@ -20644,7 +20644,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u pm_interpolated_regular_expression_node_closing_set(parser, interpolated, &closing); return UP(interpolated); } - case PM_TOKEN_BACKTICK: + case PM_TOKEN_XSTRING_BEGIN: case PM_TOKEN_PERCENT_LOWER_X: { parser_lex(parser); pm_token_t opening = parser->previous; diff --git a/prism/templates/src/tokens.c.erb b/prism/templates/src/tokens.c.erb index 92600590c0f552..fb71afe217f687 100644 --- a/prism/templates/src/tokens.c.erb +++ b/prism/templates/src/tokens.c.erb @@ -361,6 +361,8 @@ pm_token_str(pm_token_type_t token_type) { return "**"; case PM_TOKEN_WORDS_SEP: return "string separator"; + case PM_TOKEN_XSTRING_BEGIN: + return "backtick string literal"; case PM_TOKEN___END__: return "'__END__'"; case PM_TOKEN_MAXIMUM: From 1e7d46bc0d2b1fae5cbca8b54bfcb10ee9a46e23 Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Tue, 4 Aug 2026 21:57:18 +0100 Subject: [PATCH 19/25] Add Ractor tests for the monitor list. This commit adds tests to ensure that the monitor list is handled correctly in advance or rlgc merging. Because the monitor list can be mutated by multiple Ractors it's possible to engineer a situation where the list contains dangling pointers. This test causes a SEGV under rlgc. --- bootstraptest/test_ractor.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bootstraptest/test_ractor.rb b/bootstraptest/test_ractor.rb index 64ce8a91165b3c..0207773e8843cf 100644 --- a/bootstraptest/test_ractor.rb +++ b/bootstraptest/test_ractor.rb @@ -2420,6 +2420,24 @@ def initialize(a) port.receive == :aborted } +assert_equal 'ok', %q{ + long = Ractor.new { Ractor.receive } + Ractor.new(long) { |t| t.monitor(Ractor::Port.new) }.value + GC.start + 'ok' +} + +assert_equal 'ok', %q{ + long = Ractor.new { Ractor.receive } + 20.times do + Ractor.new(long) { |t| t.monitor(Ractor::Port.new) }.value + GC.start + end + long.send(:bye) + long.join + 'ok' +} + ## Ractor#join # Ractor#join returns self when the Ractor is terminated. From 1211355e5139e68cff85fdae4f2af8313b23d92b Mon Sep 17 00:00:00 2001 From: Burdette Lamar Date: Wed, 5 Aug 2026 07:35:16 -0500 Subject: [PATCH 20/25] [DOC] Revise "Access Time" section of timestamps doc- #18196 --- doc/file/timestamps.md | 54 +++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/doc/file/timestamps.md b/doc/file/timestamps.md index cd8b064e837571..24f63452d13a3f 100644 --- a/doc/file/timestamps.md +++ b/doc/file/timestamps.md @@ -9,12 +9,12 @@ the returned times may vary among filesystems, even on the same machine. These timestamps methods are: -| Name | Meaning | Changes | -|:--------------------------------:|----------------------------------------|-----------------------| -| [`birthtime`](#birth-time) | Create time. | Never. | -| [`mtime`](#modification-time) | Modification time. | When written. | -| [`atime`](#access-time) | Access time. | When read or written. | -| [`ctime`](#metadata-change-time) | Metadata-change time (or create time). | See below. | +| Name | Meaning | Changes | +|:--------------------------------:|----------------------------------------|---------------| +| [`birthtime`](#birth-time) | Create time. | Never. | +| [`mtime`](#modification-time) | Modification time. | When written. | +| [`atime`](#access-time) | Access time. | When read. | +| [`ctime`](#metadata-change-time) | Metadata-change time (or create time). | See below. | A method raises an exception if the filesystem does not support the corresponding timestamp. @@ -61,11 +61,8 @@ The modification time (along with the access time) may also be updated explicitl ## Access \Time -The access time for an entry is the time of the most recent read of or write to -the content of the entry, as reported by the underlying filesystem. - -Depending on a filesystem's settings, reading an entry may cause the access time -to be updated immediately, later, or never. +The access time for an entry is the time of the most recent read for the entry, +as reported by the underlying filesystem. Each of these methods returns the access time for an entry as a Time object: @@ -81,6 +78,41 @@ The access time (along with the modification time) may also be updated explicitl - Pathname#lutime. - Pathname#utime. +Depending on a filesystem's settings, reading an entry may cause the access time +to be updated immediately, later, or never; +thus in the tables below, some entries say "Filesystem-dependent." + +### File + +The access time for a file is commonly the most recent time the file was read, +or if never read, the time it was created: + +| Operation | Updates Access \Time | +|:------------------:|:--------------------:| +| Create | Yes | +| Read | Filesystem-dependent | +| Write | No | +| Rename | No | +| Move | No | +| Change permissions | No | +| Change ownership | No | + + +### Directory + +The access time for a directory is commonly the most recent time its entries were read, +or if never read, the time it was created: + +| Operation | Updates Access \Time | +|:------------------:|:--------------------:| +| Create | Yes | +| Read entries | Filesystem-dependent | +| Write entries | No | +| Rename | No | +| Move | No | +| Change permissions | No | +| Change ownership | No | + ## Metadata-Change \Time The metadata-change time for an entry is the time the entry last read. From 56acaf32b28227e46be75372ad74ac7ebd8b3a9c Mon Sep 17 00:00:00 2001 From: Jarek Prokop <33065078+jackorp@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:08:57 +0200 Subject: [PATCH 21/25] YJIT: Ensure GC ran before executing the test proc in YJIT tests. (GH-18197) [Bug #22228] --- test/ruby/test_yjit.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/ruby/test_yjit.rb b/test/ruby/test_yjit.rb index 9b9a59c397f8e5..ad0ccbfae1310d 100644 --- a/test/ruby/test_yjit.rb +++ b/test/ruby/test_yjit.rb @@ -2020,6 +2020,13 @@ def assert_compiles( no_send_fallbacks: false, verify_ctx: false ) + # [Bug #22228] Run a major GC to collect objects registered during + # initialization that would otherwise cause unexpected YJIT side + # exits when GC.stress is enabled. + run_gc = <<~RUBY + GC.start + RUBY + reset_stats = <<~RUBY RubyVM::YJIT.runtime_stats RubyVM::YJIT.reset_stats! @@ -2048,6 +2055,7 @@ def collect_insns(iseq) _test_proc = -> { #{test_script} } + #{run_gc} #{reset_stats} result = _test_proc.call #{write_results} From 70874cc592b34d455a6f9f48a78a173c896eab0f Mon Sep 17 00:00:00 2001 From: Luke Gruber Date: Mon, 3 Aug 2026 09:35:08 -0400 Subject: [PATCH 22/25] ZJIT: fix gen_new_hash to use proper shape_id in flags If the shape capacity isn't set on an object, one thing that fails is moving it across Ractors. The new object created in the dst Ractor needs to be big enough to embed the contents if the src was embedded. --- hash.c | 13 ++++++++++--- zjit.h | 2 +- zjit/src/codegen.rs | 12 ++++++------ zjit/src/codegen_tests.rs | 28 ++++++++++++++++++++++++++++ zjit/src/cruby_bindings.inc.rs | 2 +- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/hash.c b/hash.c index 90d1c1ffd3f50b..c898327870bc79 100644 --- a/hash.c +++ b/hash.c @@ -32,6 +32,7 @@ #include "internal/class.h" #include "internal/cont.h" #include "internal/error.h" +#include "internal/gc.h" #include "internal/hash.h" #include "internal/object.h" #include "internal/proc.h" @@ -44,6 +45,7 @@ #include "ruby/st.h" #include "ruby/util.h" #include "ruby_assert.h" +#include "shape.h" #include "symbol.h" #include "ruby/thread_native.h" #include "ruby/ractor.h" @@ -1463,9 +1465,14 @@ hash_alloc(VALUE klass) #if USE_ZJIT size_t -rb_zjit_hash_new_size(void) -{ - return hash_slot_size(sizeof(st_table) > sizeof(ar_table)); +rb_zjit_hash_new_size(VALUE *flags_out) +{ + size_t size = hash_slot_size(sizeof(st_table) > sizeof(ar_table)); + // mimic rb_newobj() + shape_id_t shape_id = rb_shape_transition_slot_size(ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_OTHER, + rb_gc_size_slot_size(size)); + *flags_out = T_HASH | ((VALUE)shape_id << SHAPE_FLAG_SHIFT); + return size; } #endif diff --git a/zjit.h b/zjit.h index a99a105e06c286..9c1506630c5eee 100644 --- a/zjit.h +++ b/zjit.h @@ -93,7 +93,7 @@ void rb_zjit_invalidate_root_box(void); void rb_zjit_jit_frame_update_references(zjit_jit_frame_t *jit_frame); void rb_zjit_materialize_frames(const rb_execution_context_t *ec, rb_control_frame_t *cfp); void rb_zjit_materialize_frames_for_longjmp(const rb_execution_context_t *ec, rb_control_frame_t *cfp); -size_t rb_zjit_hash_new_size(void); +size_t rb_zjit_hash_new_size(VALUE *flags_out); bool rb_zjit_class_allocate_instance_fastpath(VALUE klass, size_t *size_out, shape_id_t *shape_id_out); bool rb_zjit_str_resurrect_fastpath(VALUE str, bool chilled, size_t *size_out, VALUE *flags_out, long *len_out, size_t *byte_size_out); bool rb_zjit_array_dup_can_fastpath(VALUE ary, size_t *alloc_size_out, VALUE *flags_out, long *len_out); diff --git a/zjit/src/codegen.rs b/zjit/src/codegen.rs index 27428f9d63a9aa..dace5d4b5dec5e 100644 --- a/zjit/src/codegen.rs +++ b/zjit/src/codegen.rs @@ -2475,11 +2475,11 @@ fn gen_new_hash( if elements.is_empty() { gen_prepare_leaf_call_with_gc(asm, state); - let alloc_size = unsafe { rb_zjit_hash_new_size() }; - let flags = RUBY_T_HASH as u64; + let mut flags = VALUE(0); + let alloc_size = unsafe { rb_zjit_hash_new_size(&mut flags) }; let klass = unsafe { rb_cHash }; - gc_fastpath::gc_fastpath_new_obj(jit, asm, alloc_size, flags, klass, + gc_fastpath::gc_fastpath_new_obj(jit, asm, alloc_size, flags.into(), klass, |asm, hash| { asm.store(Opnd::mem(VALUE_BITS, hash, RUBY_OFFSET_RHASH_IFNONE), Qnil.into()); }, @@ -2494,11 +2494,11 @@ fn gen_new_hash( let num_pairs = elements.len() / 2; let hash = if num_pairs <= RUBY_RHASH_AR_TABLE_MAX_SIZE as usize { - let alloc_size = unsafe { rb_zjit_hash_new_size() }; - let flags = RUBY_T_HASH as u64; + let mut flags = VALUE(0); + let alloc_size = unsafe { rb_zjit_hash_new_size(&mut flags) }; let klass = unsafe { rb_cHash }; - gc_fastpath::gc_fastpath_new_obj(jit, asm, alloc_size, flags, klass, + gc_fastpath::gc_fastpath_new_obj(jit, asm, alloc_size, flags.into(), klass, |asm, hash| { asm.store(Opnd::mem(VALUE_BITS, hash, RUBY_OFFSET_RHASH_IFNONE), Qnil.into()); }, diff --git a/zjit/src/codegen_tests.rs b/zjit/src/codegen_tests.rs index 6b7325d8147592..b2c9bad57f93e5 100644 --- a/zjit/src/codegen_tests.rs +++ b/zjit/src/codegen_tests.rs @@ -3351,6 +3351,34 @@ fn test_new_hash_dynamic_sym_keys_gc_stress() { "#), @r#"[Hash, 2, [3], [3]]"#); } +// The NewHash inline-alloc fast path must bake the slot-size shape_id into the +// object flags. Without it, a cross-ractor move sizes the destination object +// from a zero shape_id, so the moved hash is allocated too small and its keys +// are corrupted. +#[test] +fn test_new_hash_sym_keys_ractor_move() { + eval(" + def create_hash + { an_object: Array.new, hi: true, bonjour: true } + end + "); + assert_contains_opcode("create_hash", YARVINSN_newhash); + assert_snapshot!(inspect(" + r = Ractor.new do + h = receive + 30.times { |i| h[i] = true } + h.keys.delete_if { |k| Integer === k } + end + + create_hash + create_hash + + h = create_hash + r.send(h, move: true) + r.value + "), @"[:an_object, :hi, :bonjour]"); +} + #[test] fn test_object_alloc_gc_stress() { eval(" diff --git a/zjit/src/cruby_bindings.inc.rs b/zjit/src/cruby_bindings.inc.rs index 9e34613a338810..da77593f7b9619 100644 --- a/zjit/src/cruby_bindings.inc.rs +++ b/zjit/src/cruby_bindings.inc.rs @@ -2272,7 +2272,7 @@ unsafe extern "C" { pub fn rb_iseq_label(iseq: *const rb_iseq_t) -> VALUE; pub fn rb_iseq_defined_string(type_: defined_type) -> VALUE; pub fn rb_zjit_profile_enable(iseq: *const rb_iseq_t); - pub fn rb_zjit_hash_new_size() -> usize; + pub fn rb_zjit_hash_new_size(flags_out: *mut VALUE) -> usize; pub fn rb_zjit_class_allocate_instance_fastpath( klass: VALUE, size_out: *mut usize, From f688f1cb2c5b25a419cd8a205d0ee278c932d344 Mon Sep 17 00:00:00 2001 From: Luke Gruber Date: Mon, 3 Aug 2026 10:43:49 -0400 Subject: [PATCH 23/25] [JIT] Only call JIT single ractor mode invalidation once per process Previously, JIT single-ractor mode invalidation happened on every `Ractor.new` call. This invalidation takes the VM lock + VM barrier so slows down these calls. We only need to invalidate compiled code that makes these single-ractor assumptions on the transition from single-ractor to multi-ractor mode. The JITs never make single-ractor assumptions when multi-ractor mode has been enabled. If a process in multi-ractor mode forks, the forked process is set back to single-ractor mode. The JITs can therefore make single-ractor assumptions in this new process, and the invalidation can occur again when the first non-main Ractor is created in that process. --- ractor.c | 5 +++-- yjit.h | 4 ++-- yjit/src/invariants.rs | 7 ++++--- zjit.h | 4 ++-- zjit/src/invariants.rs | 7 ++++--- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/ractor.c b/ractor.c index 556d663ba13db0..15132415bf9c4b 100644 --- a/ractor.c +++ b/ractor.c @@ -415,6 +415,9 @@ cancel_single_ractor_mode(void) RUBY_DEBUG_LOG("enable multi-ractor mode"); ruby_single_main_ractor = NULL; + rb_yjit_invalidate_single_ractor(); + rb_zjit_invalidate_single_ractor(); + rb_funcall(rb_cRactor, rb_intern("_activated"), 0); } @@ -601,8 +604,6 @@ ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VAL r->verbose = cr->verbose; r->debug = cr->debug; - rb_yjit_before_ractor_spawn(); - rb_zjit_before_ractor_spawn(); rb_thread_create_ractor(r, args, block); RB_GC_GUARD(rv); diff --git a/yjit.h b/yjit.h index 45a9f74c8692d6..481ee8b9b6350d 100644 --- a/yjit.h +++ b/yjit.h @@ -43,7 +43,7 @@ void rb_yjit_constant_state_changed(ID id); void rb_yjit_iseq_mark(void *payload); void rb_yjit_iseq_update_references(const rb_iseq_t *iseq); void rb_yjit_iseq_free(const rb_iseq_t *iseq); -void rb_yjit_before_ractor_spawn(void); +void rb_yjit_invalidate_single_ractor(void); void rb_yjit_constant_ic_update(const rb_iseq_t *const iseq, IC ic, unsigned insn_idx); void rb_yjit_tracing_invalidate_all(void); void rb_yjit_show_usage(int help, int highlight, unsigned int width, int columns); @@ -71,7 +71,7 @@ static inline void rb_yjit_constant_state_changed(ID id) {} static inline void rb_yjit_iseq_mark(void *payload) {} static inline void rb_yjit_iseq_update_references(const rb_iseq_t *iseq) {} static inline void rb_yjit_iseq_free(const rb_iseq_t *iseq) {} -static inline void rb_yjit_before_ractor_spawn(void) {} +static inline void rb_yjit_invalidate_single_ractor(void) {} static inline void rb_yjit_constant_ic_update(const rb_iseq_t *const iseq, IC ic, unsigned insn_idx) {} static inline void rb_yjit_tracing_invalidate_all(void) {} static inline void rb_yjit_lazy_push_frame(const VALUE *pc) {} diff --git a/yjit/src/invariants.rs b/yjit/src/invariants.rs index f3bb91ddc18f2e..71e22206797b3c 100644 --- a/yjit/src/invariants.rs +++ b/yjit/src/invariants.rs @@ -303,10 +303,11 @@ pub extern "C" fn rb_yjit_cme_invalidate(callee_cme: *const rb_callable_method_e }); } -/// Callback for when Ruby is about to spawn a ractor. In that case we need to -/// invalidate every block that is assuming single ractor mode. +/// Invalidate every block that assumes single-ractor mode. Called when Ruby +/// transitions from single-ractor to multi-ractor mode (i.e. a second ractor +/// is spawned). #[no_mangle] -pub extern "C" fn rb_yjit_before_ractor_spawn() { +pub extern "C" fn rb_yjit_invalidate_single_ractor() { // If YJIT isn't enabled, do nothing if !yjit_enabled_p() { return; diff --git a/zjit.h b/zjit.h index 9c1506630c5eee..cabef7ef2b81b5 100644 --- a/zjit.h +++ b/zjit.h @@ -86,7 +86,7 @@ void rb_zjit_iseq_update_references(void *payload); void rb_zjit_mark_all_writable(void); void rb_zjit_mark_all_executable(void); void rb_zjit_iseq_free(const rb_iseq_t *iseq); -void rb_zjit_before_ractor_spawn(void); +void rb_zjit_invalidate_single_ractor(void); void rb_zjit_tracing_invalidate_all(void); void rb_zjit_invalidate_no_singleton_class(VALUE klass); void rb_zjit_invalidate_root_box(void); @@ -133,7 +133,7 @@ static inline void rb_zjit_bop_redefined(int redefined_flag, enum ruby_basic_ope static inline void rb_zjit_cme_invalidate(const rb_callable_method_entry_t *cme) {} static inline void rb_zjit_invalidate_no_ep_escape(const rb_iseq_t *iseq) {} static inline void rb_zjit_constant_state_changed(ID id) {} -static inline void rb_zjit_before_ractor_spawn(void) {} +static inline void rb_zjit_invalidate_single_ractor(void) {} static inline void rb_zjit_tracing_invalidate_all(void) {} static inline void rb_zjit_invalidate_no_singleton_class(VALUE klass) {} static inline void rb_zjit_invalidate_root_box(void) {} diff --git a/zjit/src/invariants.rs b/zjit/src/invariants.rs index 22cd3d2c73e852..6a4ad3ff5a2687 100644 --- a/zjit/src/invariants.rs +++ b/zjit/src/invariants.rs @@ -398,10 +398,11 @@ pub fn track_single_ractor_assumption( )); } -/// Callback for when Ruby is about to spawn a ractor. In that case we need to -/// invalidate every block that is assuming single ractor mode. +/// Invalidate every block that assumes single-ractor mode. Called when Ruby +/// transitions from single-ractor to multi-ractor mode (i.e. a second ractor +/// is spawned). #[unsafe(no_mangle)] -pub extern "C" fn rb_zjit_before_ractor_spawn() { +pub extern "C" fn rb_zjit_invalidate_single_ractor() { // If ZJIT isn't enabled, do nothing if !zjit_enabled_p() { return; From 895084e65bee280bddc36dc31f19ec3a0e80587e Mon Sep 17 00:00:00 2001 From: XrXr Date: Wed, 5 Aug 2026 12:23:32 -0400 Subject: [PATCH 24/25] Revert recent PRISM changes to fix irb bundled_gems CI This reverts commit 0017dea39538b75f54a64364e3878f8421378812, 5e36630fc012a305ea23aacd1c14cc8f9ad194f1, and f3ec056c9f14e8e65a1e4617252f96c72b71d149. We are trying to bump to latest IRB, which has "Accomodate new prism tokens", but that runs into another CI failure. While we figure out a path forward at , let's get back to a green CI. --- lib/prism/lex_compat.rb | 4 -- lib/prism/translation/parser/lexer.rb | 43 +++++++++++--- prism/config.yml | 10 +--- prism/prism.c | 81 +++++++++----------------- prism/templates/src/tokens.c.erb | 8 --- test/prism/errors/command_calls_25.txt | 3 +- test/prism/ruby/parser_test.rb | 10 +++- 7 files changed, 74 insertions(+), 85 deletions(-) diff --git a/lib/prism/lex_compat.rb b/lib/prism/lex_compat.rb index 749f11173a42aa..4d92842bda7515 100644 --- a/lib/prism/lex_compat.rb +++ b/lib/prism/lex_compat.rb @@ -78,8 +78,6 @@ def deconstruct_keys(keys) # :nodoc: BANG_EQUAL: :on_op, BANG_TILDE: :on_op, BRACE_LEFT: :on_lbrace, - BRACE_LEFT_ARGUMENT: :on_lbrace, - BRACE_LEFT_HASH: :on_lbrace, BRACE_RIGHT: :on_rbrace, BRACKET_LEFT: :on_lbracket, BRACKET_LEFT_ARRAY: :on_lbracket, @@ -193,7 +191,6 @@ def deconstruct_keys(keys) # :nodoc: NEWLINE: :on_nl, NUMBERED_REFERENCE: :on_backref, PARENTHESIS_LEFT: :on_lparen, - PARENTHESIS_LEFT_GROUPING: :on_lparen, PARENTHESIS_LEFT_PARENTHESES: :on_lparen, PARENTHESIS_RIGHT: :on_rparen, PERCENT: :on_op, @@ -236,7 +233,6 @@ def deconstruct_keys(keys) # :nodoc: USTAR: :on_op, USTAR_STAR: :on_op, WORDS_SEP: :on_words_sep, - XSTRING_BEGIN: :on_backtick, "__END__": :on___end__ }.freeze diff --git a/lib/prism/translation/parser/lexer.rb b/lib/prism/translation/parser/lexer.rb index c26f48bcfbfda7..0b2f4b9da76cb1 100644 --- a/lib/prism/translation/parser/lexer.rb +++ b/lib/prism/translation/parser/lexer.rb @@ -28,13 +28,11 @@ class Lexer # :nodoc: AMPERSAND_DOT: :tANDDOT, AMPERSAND_EQUAL: :tOP_ASGN, BACK_REFERENCE: :tBACK_REF, - BACKTICK: :tBACK_REF2, + BACKTICK: :tXSTRING_BEG, BANG: :tBANG, BANG_EQUAL: :tNEQ, BANG_TILDE: :tNMATCH, BRACE_LEFT: :tLCURLY, - BRACE_LEFT_ARGUMENT: :tLBRACE_ARG, - BRACE_LEFT_HASH: :tLBRACE, BRACE_RIGHT: :tRCURLY, BRACKET_LEFT: :tLBRACK2, BRACKET_LEFT_ARRAY: :tLBRACK, @@ -143,7 +141,6 @@ class Lexer # :nodoc: NEWLINE: :tNL, NUMBERED_REFERENCE: :tNTH_REF, PARENTHESIS_LEFT: :tLPAREN2, - PARENTHESIS_LEFT_GROUPING: :tLPAREN, PARENTHESIS_LEFT_PARENTHESES: :tLPAREN_ARG, PARENTHESIS_RIGHT: :tRPAREN, PERCENT: :tPERCENT, @@ -183,10 +180,32 @@ class Lexer # :nodoc: UPLUS: :tUPLUS, USTAR: :tSTAR, USTAR_STAR: :tDSTAR, - WORDS_SEP: :tSPACE, - XSTRING_BEGIN: :tXSTRING_BEG + WORDS_SEP: :tSPACE } + # These constants represent flags in our lex state. We really, really + # don't want to be using them and we really, really don't want to be + # exposing them as part of our public API. Unfortunately, we don't have + # another way of matching the exact tokens that the parser gem expects + # without them. We should find another way to do this, but in the + # meantime we'll hide them from the documentation and mark them as + # private constants. + EXPR_BEG = 0x1 + EXPR_LABEL = 0x400 + + # The `PARENTHESIS_LEFT` token in Prism is classified as either + # `tLPAREN` or `tLPAREN2` in the Parser gem. The following token types + # are listed as those classified as `tLPAREN`. + LPAREN_CONVERSION_TOKEN_TYPES = Set.new([ + :kAND, :kBEGIN, :kBREAK, :kCASE, :kDO_COND, :kDO_LAMBDA, :kDO, :kELSE, + :kELSIF, :kENSURE, :kFOR, :kIF_MOD, :kIF, :kIN, :kNEXT, :kOR, + :kRESCUE_MOD, :kRESCUE, :kRETURN, :kTHEN, :kUNLESS_MOD, :kUNLESS, + :kUNTIL_MOD, :kUNTIL, :kWHEN, :kWHILE_MOD, :kWHILE, + :tAMPER, :tANDOP, :tBANG, :tCARET, :tCOMMA, :tDIVIDE, :tDOT2, :tDOT3, + :tEQL, :tLCURLY, :tLPAREN_ARG, :tLPAREN, :tLPAREN2, :tLSHFT, :tNL, + :tOP_ASGN, :tOROP, :tPIPE, :tSEMI, :tSTRING_DBEG, :tUMINUS, :tUPLUS + ]) + # Types of tokens that are allowed to continue a method call with comments in-between. # For these, the parser gem doesn't emit a newline token after the last comment. COMMENT_CONTINUATION_TYPES = Set.new([:COMMENT, :AMPERSAND_DOT, :DOT]) @@ -195,7 +214,7 @@ class Lexer # :nodoc: # Heredocs are complex and require us to keep track of a bit of info to refer to later HeredocData = Struct.new(:identifier, :common_whitespace, keyword_init: true) - private_constant :TYPES, :HeredocData + private_constant :TYPES, :EXPR_BEG, :EXPR_LABEL, :LPAREN_CONVERSION_TOKEN_TYPES, :HeredocData # The Parser::Source::Buffer that the tokens were lexed from. attr_reader :source_buffer @@ -234,7 +253,7 @@ def to_a comment_newline_location = nil while index < length - token, _ = lexed[index] + token, state = lexed[index] index += 1 next if TYPES_ALWAYS_SKIP.include?(token.type) @@ -305,6 +324,10 @@ def to_a value.chomp!(":") when :tLABEL_END value.chomp!(":") + when :tLCURLY + type = :tLBRACE if state == EXPR_BEG | EXPR_LABEL + when :tLPAREN2 + type = :tLPAREN if tokens.empty? || LPAREN_CONVERSION_TOKEN_TYPES.include?(tokens.dig(-1, 0)) when :tNTH_REF value = parse_integer(value.delete_prefix("$")) when :tOP_ASGN @@ -483,6 +506,10 @@ def to_a type = :tIDENTIFIER end when :tXSTRING_BEG + if (next_token = lexed[index]&.first) && !%i[STRING_CONTENT STRING_END EMBEXPR_BEGIN].include?(next_token.type) + # self.`() + type = :tBACK_REF2 + end quote_stack.push(value) when :tSYMBOLS_BEG, :tQSYMBOLS_BEG, :tWORDS_BEG, :tQWORDS_BEG if (next_token = lexed[index]&.first) && next_token.type == :WORDS_SEP diff --git a/prism/config.yml b/prism/config.yml index cc5eb7e099c228..21502ea8ca9e91 100644 --- a/prism/config.yml +++ b/prism/config.yml @@ -377,7 +377,7 @@ tokens: - name: AMPERSAND_EQUAL comment: "&=" - name: BACKTICK - comment: "` as a method name" + comment: "`" - name: BACK_REFERENCE comment: "a back reference" - name: BANG @@ -388,10 +388,6 @@ tokens: comment: "!~" - name: BRACE_LEFT comment: "{" - - name: BRACE_LEFT_ARGUMENT - comment: "{ for a block following a parenthesized argument" - - name: BRACE_LEFT_HASH - comment: "{ for a hash literal" - name: BRACKET_LEFT comment: "[" - name: BRACKET_LEFT_ARRAY @@ -588,8 +584,6 @@ tokens: comment: "a numbered reference to a capture group in the previous regular expression match" - name: PARENTHESIS_LEFT comment: "(" - - name: PARENTHESIS_LEFT_GROUPING - comment: "( scanned at the beginning of an expression" - name: PARENTHESIS_LEFT_PARENTHESES comment: "( for a parentheses node" - name: PERCENT @@ -664,8 +658,6 @@ tokens: comment: "unary **" - name: WORDS_SEP comment: "a separator between words in a list" - - name: XSTRING_BEGIN - comment: "the beginning of an execution string" - name: __END__ comment: "marker for the point in the file at which the parser should stop" flags: diff --git a/prism/prism.c b/prism/prism.c index bd16a3f2822db4..87bb03738fdf95 100644 --- a/prism/prism.c +++ b/prism/prism.c @@ -10493,15 +10493,9 @@ parser_lex(pm_parser_t *parser) { // ( case '(': { - /* A parenthesis scanned at the beginning of an expression - * groups the expression it wraps, while one scanned in - * argument position with a preceding space wraps a command - * argument. Everything else opens an argument list. */ pm_token_type_t type = PM_TOKEN_PARENTHESIS_LEFT; - if (lex_state_beg_p(parser)) { - type = PM_TOKEN_PARENTHESIS_LEFT_GROUPING; - } else if (space_seen && (lex_state_arg_p(parser) || parser->lex_state == (PM_LEX_STATE_END | PM_LEX_STATE_LABEL))) { + if (space_seen && (lex_state_arg_p(parser) || parser->lex_state == (PM_LEX_STATE_END | PM_LEX_STATE_LABEL))) { type = PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES; } @@ -10560,28 +10554,24 @@ parser_lex(pm_parser_t *parser) { pm_token_type_t type = PM_TOKEN_BRACE_LEFT; if (parser->enclosure_nesting == parser->lambda_enclosure_nesting) { - /* This { begins a lambda */ + // This { begins a lambda parser->command_start = true; lex_state_set(parser, PM_LEX_STATE_BEG); type = PM_TOKEN_LAMBDA_BEGIN; } else if (lex_state_p(parser, PM_LEX_STATE_LABELED)) { - /* This { begins a hash literal */ + // This { begins a hash literal lex_state_set(parser, PM_LEX_STATE_BEG | PM_LEX_STATE_LABEL); - type = PM_TOKEN_BRACE_LEFT_HASH; } else if (lex_state_p(parser, PM_LEX_STATE_ARG_ANY | PM_LEX_STATE_END | PM_LEX_STATE_ENDFN)) { - /* This { begins a block */ + // This { begins a block parser->command_start = true; lex_state_set(parser, PM_LEX_STATE_BEG); } else if (lex_state_p(parser, PM_LEX_STATE_ENDARG)) { - /* This { begins a block following a parenthesized - * command argument */ + // This { begins a block on a command parser->command_start = true; lex_state_set(parser, PM_LEX_STATE_BEG); - type = PM_TOKEN_BRACE_LEFT_ARGUMENT; } else { - /* This { begins a hash literal */ + // This { begins a hash literal lex_state_set(parser, PM_LEX_STATE_BEG | PM_LEX_STATE_LABEL); - type = PM_TOKEN_BRACE_LEFT_HASH; } parser->enclosure_nesting++; @@ -10898,7 +10888,7 @@ parser_lex(pm_parser_t *parser) { } lex_mode_push_string(parser, true, false, '\0', '`'); - LEX(PM_TOKEN_XSTRING_BEGIN); + LEX(PM_TOKEN_BACKTICK); } // single-quoted string literal @@ -12761,14 +12751,6 @@ match4(const pm_parser_t *parser, pm_token_type_t type1, pm_token_type_t type2, return match1(parser, type1) || match1(parser, type2) || match1(parser, type3) || match1(parser, type4); } -/** - * Returns true if the current token is any of the five given types. - */ -static PRISM_INLINE bool -match5(const pm_parser_t *parser, pm_token_type_t type1, pm_token_type_t type2, pm_token_type_t type3, pm_token_type_t type4, pm_token_type_t type5) { - return match1(parser, type1) || match1(parser, type2) || match1(parser, type3) || match1(parser, type4) || match1(parser, type5); -} - /** * Returns true if the current token is any of the six given types. */ @@ -13589,7 +13571,7 @@ parse_targets(pm_parser_t *parser, pm_node_t *first_target, pm_binding_power_t b pm_node_t *splat = UP(pm_splat_node_create(parser, &star_operator, name)); pm_multi_target_node_targets_append(parser, result, splat); has_rest = true; - } else if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { + } else if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { context_push(parser, PM_CONTEXT_MULTI_TARGET); pm_node_t *target = parse_expression(parser, binding_power, PM_PARSE_ACCEPTS_DO_BLOCK, PM_ERR_EXPECT_EXPRESSION_AFTER_COMMA, (uint16_t) (depth + 1)); target = parse_target(parser, target, true, false); @@ -13800,7 +13782,7 @@ parse_assocs(pm_parser_t *parser, pm_static_literals_t *literals, pm_node_t *nod pm_token_t operator = parser->previous; pm_node_t *value = NULL; - if (match1(parser, PM_TOKEN_BRACE_LEFT_HASH)) { + if (match1(parser, PM_TOKEN_BRACE_LEFT)) { // If we're about to parse a nested hash that is being // pushed into this hash directly with **, then we want the // inner hash to share the static literals with the outer @@ -14353,7 +14335,7 @@ parse_arguments(pm_parser_t *parser, pm_arguments_t *arguments, bool accepts_for */ static pm_multi_target_node_t * parse_required_destructured_parameter(pm_parser_t *parser) { - expect1(parser, PM_TOKEN_PARENTHESIS_LEFT_GROUPING, PM_ERR_EXPECT_LPAREN_REQ_PARAMETER); + expect1(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_ERR_EXPECT_LPAREN_REQ_PARAMETER); pm_multi_target_node_t *node = pm_multi_target_node_create(parser); pm_multi_target_node_opening_set(parser, node, &parser->previous); @@ -14372,7 +14354,7 @@ parse_required_destructured_parameter(pm_parser_t *parser) { break; } - if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { + if (match1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { param = UP(parse_required_destructured_parameter(parser)); } else if (accept1(parser, PM_TOKEN_USTAR)) { pm_token_t star = parser->previous; @@ -14434,7 +14416,7 @@ static pm_parameters_order_t parameters_ordering[PM_TOKEN_MAXIMUM] = { [PM_TOKEN_AMPERSAND] = PM_PARAMETERS_ORDER_NOTHING_AFTER, [PM_TOKEN_UDOT_DOT_DOT] = PM_PARAMETERS_ORDER_NOTHING_AFTER, [PM_TOKEN_IDENTIFIER] = PM_PARAMETERS_ORDER_NAMED, - [PM_TOKEN_PARENTHESIS_LEFT_GROUPING] = PM_PARAMETERS_ORDER_NAMED, + [PM_TOKEN_PARENTHESIS_LEFT] = PM_PARAMETERS_ORDER_NAMED, [PM_TOKEN_EQUAL] = PM_PARAMETERS_ORDER_OPTIONAL, [PM_TOKEN_LABEL] = PM_PARAMETERS_ORDER_KEYWORDS, [PM_TOKEN_USTAR] = PM_PARAMETERS_ORDER_AFTER_OPTIONAL, @@ -14541,7 +14523,7 @@ parse_parameters( bool parsing = true; switch (parser->current.type) { - case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: { + case PM_TOKEN_PARENTHESIS_LEFT: { update_parameter_state(parser, &parser->current, &order); pm_node_t *param = UP(parse_required_destructured_parameter(parser)); @@ -15418,7 +15400,7 @@ parse_block(pm_parser_t *parser, uint16_t depth) { * managed by the lexer. A `do`/`end` block is delimited by keywords, so we * push the frame here (covering the block parameters and body) and pop it * before consuming `end`, mirroring parse.y's `do_body` rule. */ - bool do_block = opening.type != PM_TOKEN_BRACE_LEFT && opening.type != PM_TOKEN_BRACE_LEFT_ARGUMENT; + bool do_block = opening.type != PM_TOKEN_BRACE_LEFT; if (do_block) pm_accepts_block_stack_push(parser, true); pm_parser_scope_push(parser, false); @@ -15443,7 +15425,7 @@ parse_block(pm_parser_t *parser, uint16_t depth) { accept1(parser, PM_TOKEN_NEWLINE); pm_node_t *statements = NULL; - if (!do_block) { + if (opening.type == PM_TOKEN_BRACE_LEFT) { if (!match1(parser, PM_TOKEN_BRACE_RIGHT)) { statements = UP(parse_statements(parser, PM_CONTEXT_BLOCK_BRACES, (uint16_t) (depth + 1))); } @@ -15540,7 +15522,7 @@ parse_arguments_list(pm_parser_t *parser, pm_arguments_t *arguments, bool full_a * it, so pop the delimiter frame, push the command-args frame, and then * restore the delimiter frame on top (the delimiter's closing token * will pop it back off during argument parsing). */ - bool lookahead_delimiter = match5(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_GROUPING, PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES, PM_TOKEN_BRACKET_LEFT, PM_TOKEN_BRACKET_LEFT_ARRAY); + bool lookahead_delimiter = match4(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES, PM_TOKEN_BRACKET_LEFT, PM_TOKEN_BRACKET_LEFT_ARRAY); if (lookahead_delimiter) pm_accepts_block_stack_pop(parser); pm_accepts_block_stack_push(parser, false); if (lookahead_delimiter) pm_accepts_block_stack_push(parser, true); @@ -15562,7 +15544,7 @@ parse_arguments_list(pm_parser_t *parser, pm_arguments_t *arguments, bool full_a * it, pop the command-args frame beneath it, and restore the block * frame so the block's `}` still pops it. This mirrors the `tLBRACE_ARG` * lookahead handling in parse.y's `command_args` rule. */ - bool lookahead_brace = match2(parser, PM_TOKEN_BRACE_LEFT, PM_TOKEN_BRACE_LEFT_ARGUMENT); + bool lookahead_brace = match1(parser, PM_TOKEN_BRACE_LEFT); if (lookahead_brace) pm_accepts_block_stack_pop(parser); pm_accepts_block_stack_pop(parser); if (lookahead_brace) pm_accepts_block_stack_push(parser, true); @@ -15574,7 +15556,7 @@ parse_arguments_list(pm_parser_t *parser, pm_arguments_t *arguments, bool full_a if (full_arguments) { pm_block_node_t *block = NULL; - if (accept2(parser, PM_TOKEN_BRACE_LEFT, PM_TOKEN_BRACE_LEFT_ARGUMENT)) { + if (accept1(parser, PM_TOKEN_BRACE_LEFT)) { found |= true; block = parse_block(parser, (uint16_t) (depth + 1)); pm_arguments_validate_block(parser, arguments, block); @@ -16036,7 +16018,7 @@ parse_conditional(pm_parser_t *parser, pm_context_t context, size_t opening_newl #define PM_CASE_PRIMITIVE PM_TOKEN_INTEGER: case PM_TOKEN_INTEGER_IMAGINARY: case PM_TOKEN_INTEGER_RATIONAL: \ case PM_TOKEN_INTEGER_RATIONAL_IMAGINARY: case PM_TOKEN_FLOAT: case PM_TOKEN_FLOAT_IMAGINARY: \ case PM_TOKEN_FLOAT_RATIONAL: case PM_TOKEN_FLOAT_RATIONAL_IMAGINARY: case PM_TOKEN_SYMBOL_BEGIN: \ - case PM_TOKEN_REGEXP_BEGIN: case PM_TOKEN_XSTRING_BEGIN: case PM_TOKEN_PERCENT_LOWER_X: case PM_TOKEN_PERCENT_LOWER_I: \ + case PM_TOKEN_REGEXP_BEGIN: case PM_TOKEN_BACKTICK: case PM_TOKEN_PERCENT_LOWER_X: case PM_TOKEN_PERCENT_LOWER_I: \ case PM_TOKEN_PERCENT_LOWER_W: case PM_TOKEN_PERCENT_UPPER_I: case PM_TOKEN_PERCENT_UPPER_W: \ case PM_TOKEN_STRING_BEGIN: case PM_TOKEN_KEYWORD_NIL: case PM_TOKEN_KEYWORD_SELF: case PM_TOKEN_KEYWORD_TRUE: \ case PM_TOKEN_KEYWORD_FALSE: case PM_TOKEN_KEYWORD___FILE__: case PM_TOKEN_KEYWORD___LINE__: \ @@ -17382,7 +17364,7 @@ parse_pattern_primitive(pm_parser_t *parser, pm_constant_id_list_t *captures, pm pm_array_pattern_node_requireds_append(parser->arena, node, inner); return UP(node); } - case PM_TOKEN_BRACE_LEFT_HASH: { + case PM_TOKEN_BRACE_LEFT: { bool previous_pattern_matching_newlines = parser->pattern_matching_newlines; parser->pattern_matching_newlines = false; @@ -17519,7 +17501,7 @@ parse_pattern_primitive(pm_parser_t *parser, pm_constant_id_list_t *captures, pm return UP(pm_pinned_variable_node_create(parser, &operator, variable)); } - case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: { + case PM_TOKEN_PARENTHESIS_LEFT: { bool previous_pattern_matching_newlines = parser->pattern_matching_newlines; parser->pattern_matching_newlines = false; @@ -17603,7 +17585,7 @@ parse_pattern_primitives(pm_parser_t *parser, pm_constant_id_list_t *captures, p switch (parser->current.type) { case PM_TOKEN_IDENTIFIER: case PM_TOKEN_BRACKET_LEFT_ARRAY: - case PM_TOKEN_BRACE_LEFT_HASH: + case PM_TOKEN_BRACE_LEFT: case PM_TOKEN_CARET: case PM_TOKEN_CONSTANT: case PM_TOKEN_UCOLON_COLON: @@ -17622,7 +17604,7 @@ parse_pattern_primitives(pm_parser_t *parser, pm_constant_id_list_t *captures, p break; } - case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: + case PM_TOKEN_PARENTHESIS_LEFT: case PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES: { pm_token_t operator = parser->previous; pm_token_t opening = parser->current; @@ -19204,13 +19186,6 @@ parse_parentheses(pm_parser_t *parser, pm_binding_power_t binding_power, uint8_t /* If this is the end of the file or we match a right parenthesis, then we * have an empty parentheses node, and we can immediately return. */ if (match2(parser, PM_TOKEN_PARENTHESIS_RIGHT, PM_TOKEN_EOF)) { - /* A command argument group sets EXPR_ENDARG before its ')' is - * consumed, even when the group is empty, so that a following '{' is - * scanned as a block brace. */ - if (match1(parser, PM_TOKEN_PARENTHESIS_RIGHT) && opening.type == PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES) { - lex_state_set(parser, PM_LEX_STATE_ENDARG); - } - expect1(parser, PM_TOKEN_PARENTHESIS_RIGHT, PM_ERR_EXPECT_RPAREN); pop_block_exits(parser, previous_block_exits); return UP(pm_parentheses_node_create(parser, &opening, NULL, &parser->previous, paren_flags)); @@ -19531,10 +19506,10 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u return UP(array); } - case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: + case PM_TOKEN_PARENTHESIS_LEFT: case PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES: return parse_parentheses(parser, binding_power, flags, depth); - case PM_TOKEN_BRACE_LEFT_HASH: { + case PM_TOKEN_BRACE_LEFT: { // If we were passed a current_hash_keys via the parser, then that // means we're already parsing a hash and we want to share the set // of hash keys with this inner hash we're about to parse for the @@ -20154,7 +20129,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u context_push(parser, PM_CONTEXT_DEFINED); bool newline = accept1(parser, PM_TOKEN_NEWLINE); - if (accept2(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { + if (accept1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { lparen = parser->previous; if (newline && accept1(parser, PM_TOKEN_PARENTHESIS_RIGHT)) { @@ -20323,7 +20298,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u accept1(parser, PM_TOKEN_NEWLINE); - if (accept2(parser, PM_TOKEN_PARENTHESIS_LEFT, PM_TOKEN_PARENTHESIS_LEFT_GROUPING)) { + if (accept1(parser, PM_TOKEN_PARENTHESIS_LEFT)) { pm_token_t lparen = parser->previous; if (accept1(parser, PM_TOKEN_PARENTHESIS_RIGHT)) { @@ -20644,7 +20619,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, u pm_interpolated_regular_expression_node_closing_set(parser, interpolated, &closing); return UP(interpolated); } - case PM_TOKEN_XSTRING_BEGIN: + case PM_TOKEN_BACKTICK: case PM_TOKEN_PERCENT_LOWER_X: { parser_lex(parser); pm_token_t opening = parser->previous; diff --git a/prism/templates/src/tokens.c.erb b/prism/templates/src/tokens.c.erb index fb71afe217f687..472c82ea690939 100644 --- a/prism/templates/src/tokens.c.erb +++ b/prism/templates/src/tokens.c.erb @@ -53,10 +53,6 @@ pm_token_str(pm_token_type_t token_type) { return "'!~'"; case PM_TOKEN_BRACE_LEFT: return "'{'"; - case PM_TOKEN_BRACE_LEFT_ARGUMENT: - return "'{'"; - case PM_TOKEN_BRACE_LEFT_HASH: - return "'{'"; case PM_TOKEN_BRACE_RIGHT: return "'}'"; case PM_TOKEN_BRACKET_LEFT: @@ -279,8 +275,6 @@ pm_token_str(pm_token_type_t token_type) { return "numbered reference"; case PM_TOKEN_PARENTHESIS_LEFT: return "'('"; - case PM_TOKEN_PARENTHESIS_LEFT_GROUPING: - return "'('"; case PM_TOKEN_PARENTHESIS_LEFT_PARENTHESES: return "'('"; case PM_TOKEN_PARENTHESIS_RIGHT: @@ -361,8 +355,6 @@ pm_token_str(pm_token_type_t token_type) { return "**"; case PM_TOKEN_WORDS_SEP: return "string separator"; - case PM_TOKEN_XSTRING_BEGIN: - return "backtick string literal"; case PM_TOKEN___END__: return "'__END__'"; case PM_TOKEN_MAXIMUM: diff --git a/test/prism/errors/command_calls_25.txt b/test/prism/errors/command_calls_25.txt index c8769c538ab75f..cf04508f87d8a7 100644 --- a/test/prism/errors/command_calls_25.txt +++ b/test/prism/errors/command_calls_25.txt @@ -3,7 +3,6 @@ ^ expected a `do` keyword or a `{` to open the lambda block ^ unexpected ')', expecting end-of-input ^ unexpected ')', ignoring it - ^ unexpected '{', ignoring it - ^ unexpected '}', ignoring it + ^ unexpected end-of-input, assuming it is closing the parent top level context ^~ expected a lambda block beginning with `do` to end with `end` diff --git a/test/prism/ruby/parser_test.rb b/test/prism/ruby/parser_test.rb index e44bc20d4dea0b..856ecedc1de39d 100644 --- a/test/prism/ruby/parser_test.rb +++ b/test/prism/ruby/parser_test.rb @@ -111,8 +111,12 @@ class ParserTest < TestCase skip_tokens = [ "dash_heredocs.txt", "embdoc_no_newline_at_end.txt", + "methods.txt", + "seattlerb/bug169.txt", "seattlerb/case_in.txt", "seattlerb/difficult4__leading_dots2.txt", + "seattlerb/difficult6__7.txt", + "seattlerb/difficult6__8.txt", "seattlerb/heredoc_unicode.txt", "seattlerb/parse_line_heredoc.txt", "seattlerb/pct_w_heredoc_interp_nested.txt", @@ -125,10 +129,14 @@ class ParserTest < TestCase "whitequark/beginless_irange_after_newline.txt", "whitequark/forward_arg_with_open_args.txt", "whitequark/kwarg_no_paren.txt", + "whitequark/lbrace_arg_after_command_args.txt", "whitequark/multiple_pattern_matches.txt", "whitequark/newline_in_hash_argument.txt", "whitequark/pattern_matching_hash.txt", - "whitequark/ruby_bug_9669.txt" + "whitequark/ruby_bug_14690.txt", + "whitequark/ruby_bug_9669.txt", + "whitequark/space_args_arg_block.txt", + "whitequark/space_args_block.txt" ] Fixture.each_for_version(except: skip_syntax_error, version: "3.3") do |fixture| From 87b5ecefdfb217b31f2823e1ed964ce500716e5f Mon Sep 17 00:00:00 2001 From: ndossche <7771979+ndossche@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:17:57 +0200 Subject: [PATCH 25/25] [ruby/openssl] rand: fix error check of RAND_load_file() This function returns -1 on error, or the number of bytes read on success. Preserve current behaviour and also error out on error. Discovered by an experimental static analyzer I work on. https://github.com/ruby/openssl/commit/b36a61597e --- ext/openssl/ossl_rand.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/openssl/ossl_rand.c b/ext/openssl/ossl_rand.c index 753f8b25f77fa5..a5db62a3610b6a 100644 --- a/ext/openssl/ossl_rand.c +++ b/ext/openssl/ossl_rand.c @@ -67,7 +67,7 @@ ossl_rand_add(VALUE self, VALUE str, VALUE entropy) static VALUE ossl_rand_load_file(VALUE self, VALUE filename) { - if(!RAND_load_file(StringValueCStr(filename), -1)) { + if (RAND_load_file(StringValueCStr(filename), -1) < 0) { ossl_raise(eRandomError, NULL); } return Qtrue;