From 5a27aaadab940b3c6d33d1db90f402c69dd8d360 Mon Sep 17 00:00:00 2001 From: git Date: Thu, 3 Sep 2026 06:58:26 +0000 Subject: [PATCH 1/9] [DOC] Update bundled gems list at 2fb0f3b490d91feef7811efe3343e7 --- NEWS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index d0a4de6c836051..d36344025d544c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -176,7 +176,7 @@ 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], [v4.0.18][RubyGems-v4.0.18], [v4.0.19][RubyGems-v4.0.19] + * 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], [v4.0.19][RubyGems-v4.0.19], [v4.0.20][RubyGems-v4.0.20] * 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 @@ -394,6 +394,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [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 [RubyGems-v4.0.19]: https://github.com/rubygems/rubygems/releases/tag/v4.0.19 +[RubyGems-v4.0.20]: https://github.com/rubygems/rubygems/releases/tag/v4.0.20 [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 From 5985f8bb1848cb29f27da20e3d3f67ecb4ca5d24 Mon Sep 17 00:00:00 2001 From: Shizuo Fujita Date: Thu, 3 Sep 2026 14:53:08 +0900 Subject: [PATCH 2/9] [ruby/json] Fix SEGV when as_json returns a non-String for an invalid symbol In strict mode, a Symbol whose name is not UTF-8 compatible was passed to generate_json_string, which handed the as_json result to raw_generate_json_string without checking its type. A non-String result was then read as a String, segfaulting the process. Route strict symbols through the T_STRING case of generate_json_general so they reuse its as_json handling. That re-dispatches the result by type, and its as_json_called flag stops a symbol returned by as_json from recursing. https://github.com/ruby/json/commit/2d73e6bdfe Co-Authored-By: Claude Opus 5 (1M context) --- ext/json/generator/generator.c | 26 +++++++++++++++----------- test/json/json_coder_test.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/ext/json/generator/generator.c b/ext/json/generator/generator.c index 38607cf4eb0b6d..d0d04875122472 100644 --- a/ext/json/generator/generator.c +++ b/ext/json/generator/generator.c @@ -1124,15 +1124,6 @@ static void generate_json_fallback(FBuffer *buffer, struct generate_json_data *d } } -static inline void generate_json_symbol(FBuffer *buffer, struct generate_json_data *data, VALUE obj) -{ - if (data->state->strict) { - generate_json_string(buffer, data, rb_sym2str(obj)); - } else { - generate_json_fallback(buffer, data, obj); - } -} - static void generate_json_null(FBuffer *buffer, struct generate_json_data *data, VALUE obj) { fbuffer_append(buffer, "null", 4); @@ -1218,7 +1209,13 @@ static inline void generate_json_general(FBuffer *buffer, struct generate_json_d } else if (RB_FLONUM_P(obj)) { generate_json_float(buffer, data, obj); } else if (RB_STATIC_SYM_P(obj)) { - generate_json_symbol(buffer, data, obj); + if (data->state->strict) { + obj = rb_sym2str(obj); + JSON_ASSERT(RBASIC_CLASS(obj) == rb_cString); + goto generate_string; + } + + generate_json_fallback(buffer, data, obj); } else { goto general; } @@ -1239,6 +1236,7 @@ static inline void generate_json_general(FBuffer *buffer, struct generate_json_d case T_STRING: if (fallback && klass != rb_cString) goto general; + generate_string: if (RB_LIKELY(valid_json_string_p(obj))) { raw_generate_json_string(buffer, data, obj); } else if (as_json_called) { @@ -1250,7 +1248,13 @@ static inline void generate_json_general(FBuffer *buffer, struct generate_json_d } break; case T_SYMBOL: - generate_json_symbol(buffer, data, obj); + if (data->state->strict) { + obj = rb_sym2str(obj); + JSON_ASSERT(RBASIC_CLASS(obj) == rb_cString); + goto generate_string; + } + + generate_json_fallback(buffer, data, obj); break; case T_FLOAT: if (fallback && klass != rb_cFloat) goto general; diff --git a/test/json/json_coder_test.rb b/test/json/json_coder_test.rb index a8477dd7be89b1..7be1dfb3f301be 100755 --- a/test/json/json_coder_test.rb +++ b/test/json/json_coder_test.rb @@ -137,6 +137,33 @@ def test_json_coder_string_invalid_encoding assert_equal 2, calls end + def test_json_coder_symbol_invalid_encoding + symbol = "\xFF".b.to_sym + + calls = 0 + coder = JSON::Coder.new do |object, is_key| + calls += 1 + object.bytes + end + + assert_equal "[[255]]", coder.dump([symbol]) + assert_equal 1, calls + + coder = JSON::Coder.new { |object, is_key| nil } + assert_equal "[null]", coder.dump([symbol]) + + coder = JSON::Coder.new { |object, is_key| Object.new } + assert_raise(JSON::GeneratorError) { coder.dump([symbol]) } + + if RUBY_ENGINE == "ruby" + coder = JSON::Coder.new { |object, is_key| symbol } + error = assert_raise JSON::GeneratorError do + coder.dump([symbol]) + end + assert_equal "source sequence is illegal/malformed utf-8", error.message + end + end + def test_depth coder = JSON::Coder.new(object_nl: "\n", array_nl: "\n", space: " ", indent: " ", depth: 1) assert_equal %({\n "foo": 42\n }), coder.dump(foo: 42) From 453a69e00f2a00f432989dd27ab5cc949ba3fa2a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 3 Sep 2026 15:54:57 +0900 Subject: [PATCH 3/9] [ruby/rubygems] Ship one README and one license file at the top level README-bundler.md duplicated the supporting, conduct, contributing and getting-help sections of README.md, and no prose document linked to it. LICENSE-bundler.md carried the same MIT text as MIT.txt, differing only in wrap width and quote style. Move Bundler's description and usage example into README.md, credit Bundler's copyright holders in MIT.txt, and ship MIT.txt in the bundler gem in place of the two removed files. https://github.com/ruby/rubygems/commit/292aae49b4 Co-Authored-By: Claude Opus 5 --- lib/bundler/bundler.gemspec | 2 +- spec/bundler/support/path.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bundler/bundler.gemspec b/lib/bundler/bundler.gemspec index 346d156693e018..812cf74bc5333e 100644 --- a/lib/bundler/bundler.gemspec +++ b/lib/bundler/bundler.gemspec @@ -55,7 +55,7 @@ Gem::Specification.new do |s| # These live next to the gemspec when Bundler ships as a gem, but not when # it is synced into Ruby core, where the gemspec moves under lib/bundler. - s.files += %w[CHANGELOG-bundler.md LICENSE-bundler.md README-bundler.md].select {|f| File.file?(f) } + s.files += %w[CHANGELOG-bundler.md MIT.txt].select {|f| File.file?(f) } s.bindir = "exe" s.executables = %w[bundle bundler] s.require_paths = ["lib"] diff --git a/spec/bundler/support/path.rb b/spec/bundler/support/path.rb index 5c107758518078..5df6de58d6cfe6 100644 --- a/spec/bundler/support/path.rb +++ b/spec/bundler/support/path.rb @@ -359,7 +359,7 @@ def git_ls_files(glob) end def tracked_files_glob - ruby_core? ? "libexec/bundle* lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* lib/rubygems/credential_store* spec/bundler man/bundle*" : "exe/bundle exe/bundler lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* lib/rubygems/credential_store* bundler.gemspec CHANGELOG-bundler.md LICENSE-bundler.md README-bundler.md" + ruby_core? ? "libexec/bundle* lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* lib/rubygems/credential_store* spec/bundler man/bundle*" : "exe/bundle exe/bundler lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* lib/rubygems/credential_store* bundler.gemspec CHANGELOG-bundler.md MIT.txt" end def lib_tracked_files_glob From 2ce8a2dc200ef88210aba9d995b865bc936852b5 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Tue, 25 Aug 2026 12:46:14 +0900 Subject: [PATCH 4/9] [ruby/rubygems] Scrub off the invalid sequence Since the base `string` is a valid encoding, there should be no other invalid sequences in `sliced`. https://github.com/ruby/rubygems/commit/c240193553 --- lib/rubygems/util/atomic_file_writer.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/rubygems/util/atomic_file_writer.rb b/lib/rubygems/util/atomic_file_writer.rb index be9bf76e83b87a..c3be873c9eba28 100644 --- a/lib/rubygems/util/atomic_file_writer.rb +++ b/lib/rubygems/util/atomic_file_writer.rb @@ -99,9 +99,7 @@ def self.open(file_name) # still be cut mid-character. def self.byteslice_at_char_boundary(string, max_bytesize) sliced = string.byteslice(0, max_bytesize) - if string.valid_encoding? - sliced = sliced.byteslice(0, sliced.bytesize - 1) until sliced.valid_encoding? - end + sliced.scrub!("") if string.valid_encoding? sliced end private_class_method :byteslice_at_char_boundary From e5b152bca26b51ea7e43e5e4939ab2a0a5956aca Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Tue, 25 Aug 2026 12:55:46 +0900 Subject: [PATCH 5/9] [ruby/rubygems] Use FD-based operations Operations on an already open file are faster and safer than those based on the path name. https://github.com/ruby/rubygems/commit/f449acaa49 --- lib/rubygems/util/atomic_file_writer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/rubygems/util/atomic_file_writer.rb b/lib/rubygems/util/atomic_file_writer.rb index c3be873c9eba28..6f75e38d51f8d2 100644 --- a/lib/rubygems/util/atomic_file_writer.rb +++ b/lib/rubygems/util/atomic_file_writer.rb @@ -53,9 +53,9 @@ def self.open(file_name) if old_stat # Set correct permissions on new file begin - File.chown(old_stat.uid, old_stat.gid, tmp_path) + temp_file.chown(old_stat.uid, old_stat.gid) # This operation will affect filesystem ACL's - File.chmod(old_stat.mode, tmp_path) + temp_file.chmod(old_stat.mode) rescue Errno::EPERM, Errno::EACCES # Changing file ownership failed, moving on. end From b9c0939226f2908bb3e224c328a7002580df5710 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 3 Sep 2026 17:05:43 +0900 Subject: [PATCH 6/9] [ruby/rubygems] Keep trimming the tail instead of String#scrub! A UTF-8 replacement makes String#scrub! raise Encoding::CompatibilityError on an ASCII-incompatible receiver such as UTF-16LE, where the loop trimmed the tail fine. It is also slower than the loop for the common case of an ASCII name that needs no truncation. https://github.com/ruby/rubygems/commit/5e1eb084d6 Co-Authored-By: Claude Opus 5 --- lib/rubygems/util/atomic_file_writer.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/util/atomic_file_writer.rb b/lib/rubygems/util/atomic_file_writer.rb index 6f75e38d51f8d2..a8eadcb6498bde 100644 --- a/lib/rubygems/util/atomic_file_writer.rb +++ b/lib/rubygems/util/atomic_file_writer.rb @@ -99,7 +99,9 @@ def self.open(file_name) # still be cut mid-character. def self.byteslice_at_char_boundary(string, max_bytesize) sliced = string.byteslice(0, max_bytesize) - sliced.scrub!("") if string.valid_encoding? + if string.valid_encoding? + sliced = sliced.byteslice(0, sliced.bytesize - 1) until sliced.valid_encoding? + end sliced end private_class_method :byteslice_at_char_boundary From 522f6ea22744b8ae92a50cee26785ddd5b64b7e7 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 1 Sep 2026 09:42:26 +0200 Subject: [PATCH 7/9] String#tr: take a Hash for multi-character replacements [Feature #22238] --- benchmark/string_tr.yml | 21 +++ encoding.c | 41 +++++ internal/encoding.h | 1 + spec/ruby/core/string/tr_spec.rb | 88 ++++++++++ string.c | 289 ++++++++++++++++++++++++++++++- 5 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 benchmark/string_tr.yml diff --git a/benchmark/string_tr.yml b/benchmark/string_tr.yml new file mode 100644 index 00000000000000..e0f582c240c17b --- /dev/null +++ b/benchmark/string_tr.yml @@ -0,0 +1,21 @@ +prelude: | + # frozen_string_literal: true + + require 'cgi/escape' + + STR = ((("a" * 31) + "<") * 1000).freeze + + ESCAPED_CHARS = { + ">" => '\u003e', + "<" => '\u003c', + "&" => '\u0026', + }.freeze + ESCAPE_PATTERN = Regexp.union(ESCAPED_CHARS.keys) + +benchmark: + tr_escape: | + STR.tr(ESCAPED_CHARS) + gsub_escape: | + STR.gsub(ESCAPE_PATTERN, ESCAPED_CHARS) + cgi_escape: | + CGI.escape_html(STR) diff --git a/encoding.c b/encoding.c index a49f3b6b3adc0f..30cee45fd2b3e8 100644 --- a/encoding.c +++ b/encoding.c @@ -1198,6 +1198,47 @@ enc_compatible_latter(VALUE str1, VALUE str2, int idx1, int idx2) return 0; } +rb_encoding * +rb_enc_check_multi_str(rb_encoding *enc1, int *cr, VALUE str2) +{ + RUBY_ASSERT(*cr != ENC_CODERANGE_UNKNOWN); + + if (RSTRING_LEN(str2) == 0) { + return enc1; + } + + int idx2 = enc_get_index_str(str2); + rb_encoding *enc2 = rb_enc_from_index(idx2); + + int cr1 = *cr; + + if (enc1 == enc2) { + if (cr1 == ENC_CODERANGE_7BIT) { + *cr = rb_enc_str_coderange(str2); + } + return enc1; + } + + if (!rb_enc_asciicompat(enc1) || !rb_enc_asciicompat(enc2)) { + rb_raise(rb_eEncCompatError, "incompatible character encodings: %s and %s", + rb_enc_inspect_name(enc1), + rb_enc_inspect_name(enc2)); + } + + if (enc2 == global_enc_us_ascii || rb_enc_str_asciionly_p(str2)) { + return enc1; + } + + if (enc1 == global_enc_us_ascii || cr1 == ENC_CODERANGE_7BIT) { + *cr = rb_enc_str_coderange(str2); + return enc2; + } + + rb_raise(rb_eEncCompatError, "incompatible character encodings: %s and %s", + rb_enc_inspect_name(enc1), + rb_enc_inspect_name(enc2)); +} + static rb_encoding* enc_compatible_str(VALUE str1, VALUE str2) { diff --git a/internal/encoding.h b/internal/encoding.h index 38bf8fc9da77ef..09c125804f3fc8 100644 --- a/internal/encoding.h +++ b/internal/encoding.h @@ -30,6 +30,7 @@ void rb_enc_set_base(const char *name, const char *orig); int rb_enc_set_dummy(int index); void rb_enc_raw_set(VALUE obj, rb_encoding *enc); int rb_enc_registered(const char *name); +rb_encoding *rb_enc_check_multi_str(rb_encoding *enc1, int *cr, VALUE str2); PUREFUNC(int rb_data_is_encoding(VALUE obj)); diff --git a/spec/ruby/core/string/tr_spec.rb b/spec/ruby/core/string/tr_spec.rb index cb57c3851eec5e..98551d4ab83b3e 100644 --- a/spec/ruby/core/string/tr_spec.rb +++ b/spec/ruby/core/string/tr_spec.rb @@ -93,6 +93,50 @@ str.tr(a, b).should == "椎名深夏" end + it "raises Encoding::CompatibilityError when from_string or to_string parameters have an incompatible encoding" do + -> { "fée".tr("é".encode(Encoding::ISO_8859_1), "e") }.should.raise(Encoding::CompatibilityError) + -> { "fée".tr("e", "é".encode(Encoding::ISO_8859_1)) }.should.raise(Encoding::CompatibilityError) + end + + ruby_version_is "4.1" do + describe "hash form" do + it "returns a new strings with characters from keys replaced by the associated strings" do + "hello".tr("e" => "EH! ", "o" => "OH!", "l" => "").should == "hEH! OH!" + "h€llø".tr("€" => "e", "ø" => "o").should == "hello" + end + + it "returns a string in the combined encoding" do + str = "hello".encode(Encoding::US_ASCII).tr("e" => "é") + str.should == "héllo" + str.encoding.should == Encoding::UTF_8 + end + + it "works with multi-byte encodings" do + "hello".encode(Encoding::UTF_16LE).tr( + "e".encode(Encoding::UTF_16LE) => "EH! ".encode(Encoding::UTF_16LE), + "o".encode(Encoding::UTF_16LE) => "OH!".encode(Encoding::UTF_16LE), + "l".encode(Encoding::UTF_16LE) => "".encode(Encoding::UTF_16LE), + ).should == "hEH! OH!".encode(Encoding::UTF_16LE) + + "h€llø".encode(Encoding::UTF_16LE).tr( + "€".encode(Encoding::UTF_16LE) => "e".encode(Encoding::UTF_16LE), + "ø".encode(Encoding::UTF_16LE) => "o".encode(Encoding::UTF_16LE), + ).should == "hello".encode(Encoding::UTF_16LE) + end + + it "raises ArgumentError if a key is more than one codepoint" do + -> { "hello".tr("hel" => "") }.should.raise(ArgumentError) + -> { "🤦🏼‍♂️".tr("🤦🏼‍♂️" => "") }.should.raise(ArgumentError) + end + + it "raises Encoding::CompatibilityError when either keys or values have an incompatible encoding" do + -> { "fée".tr("é".encode(Encoding::ISO_8859_1) => "e") }.should.raise(Encoding::CompatibilityError) + -> { "fée".tr("e" => "é".encode(Encoding::ISO_8859_1)) }.should.raise(Encoding::CompatibilityError) + + -> { "ab".encode(Encoding::US_ASCII).tr("a" => "à".encode(Encoding::ISO_8859_1), "b" => "é") }.should.raise(Encoding::CompatibilityError) + end + end + end end describe "String#tr!" do @@ -123,4 +167,48 @@ -> { s.tr!("R", "S") }.should.raise(FrozenError) -> { s.tr!("", "") }.should.raise(FrozenError) end + + ruby_version_is "4.1" do + describe "hash form" do + it "returns a new strings with characters from keys replaced by the associated strings" do + "hello".tr!("e" => "EH! ", "o" => "OH!", "l" => "").should == "hEH! OH!" + "h€llø".tr!("€" => "e", "ø" => "o").should == "hello" + end + + it "returns a string in the combined encoding" do + str = "hello".encode(Encoding::US_ASCII).tr!("e" => "é") + str.should == "héllo" + str.encoding.should == Encoding::UTF_8 + end + + it "works with multi-byte encodings" do + "hello".encode(Encoding::UTF_16LE).tr!( + "e".encode(Encoding::UTF_16LE) => "EH! ".encode(Encoding::UTF_16LE), + "o".encode(Encoding::UTF_16LE) => "OH!".encode(Encoding::UTF_16LE), + "l".encode(Encoding::UTF_16LE) => "".encode(Encoding::UTF_16LE), + ).should == "hEH! OH!".encode(Encoding::UTF_16LE) + + "h€llø".encode(Encoding::UTF_16LE).tr!( + "€".encode(Encoding::UTF_16LE) => "e".encode(Encoding::UTF_16LE), + "ø".encode(Encoding::UTF_16LE) => "o".encode(Encoding::UTF_16LE), + ).should == "hello".encode(Encoding::UTF_16LE) + end + + it "returns nil if the string wasn't modified" do + "hello".tr!("€" => "", "Ø" => "").should == nil + end + + it "raises ArgumentError if a key is more than one codepoint" do + -> { "hello".tr!("hel" => "") }.should.raise(ArgumentError) + -> { "🤦🏼‍♂️".tr!("🤦🏼‍♂️" => "") }.should.raise(ArgumentError) + end + + it "raises Encoding::CompatibilityError when either keys or values have an incompatible encoding" do + -> { "fée".tr!("é".encode(Encoding::ISO_8859_1) => "e") }.should.raise(Encoding::CompatibilityError) + -> { "fée".tr!("e" => "é".encode(Encoding::ISO_8859_1)) }.should.raise(Encoding::CompatibilityError) + + -> { "ab".encode(Encoding::US_ASCII).tr!("a" => "à".encode(Encoding::ISO_8859_1), "b" => "é") }.should.raise(Encoding::CompatibilityError) + end + end + end end diff --git a/string.c b/string.c index e4503a96bf1f2d..11534c0ca1157a 100644 --- a/string.c +++ b/string.c @@ -9186,10 +9186,274 @@ tr_trans(VALUE str, VALUE src, VALUE repl, int sflag) return Qnil; } +struct tr_buffer { + unsigned char *buf; + unsigned char *ptr; + size_t capa; + size_t initial_capa; +}; + +static inline void +tr_buffer_init(struct tr_buffer *buffer, size_t initial_capa) +{ + if (initial_capa < 32) { + initial_capa = 32; + } + *buffer = (struct tr_buffer){ .initial_capa = initial_capa }; +} + +static inline void +tr_buffer_ensure_capa(struct tr_buffer *buffer, size_t extra_capa) +{ + size_t offset = buffer->ptr - buffer->buf; + size_t required_capa = offset + extra_capa; + if (UNLIKELY(buffer->capa < required_capa)) { + size_t new_capa = buffer->capa ? buffer->capa : buffer->initial_capa; + while (new_capa < required_capa) { + new_capa *= 1.2; + } + buffer->buf = SIZED_REALLOC_N(buffer->buf, unsigned char, new_capa, buffer->capa); + buffer->ptr = buffer->buf + offset; + buffer->capa = new_capa; + } +} + +static inline void +tr_buffer_append(struct tr_buffer *buffer, unsigned char *ptr, size_t len) +{ + if (len) { + tr_buffer_ensure_capa(buffer, len); + memcpy(buffer->ptr, ptr, len); + buffer->ptr += len; + } +} + +static inline void +tr_buffer_append_str(struct tr_buffer *buffer, VALUE str) +{ + tr_buffer_append(buffer, (unsigned char *)RSTRING_PTR(str), RSTRING_LEN(str)); +} + +static inline void +tr_buffer_mbcput(struct tr_buffer *buffer, int codepoint, rb_encoding *enc) +{ + tr_buffer_ensure_capa(buffer, 4); + buffer->ptr += rb_enc_mbcput(codepoint, buffer->ptr, enc); +} + +static inline void +tr_buffer_free(struct tr_buffer *buffer) +{ + if (buffer->buf) { + SIZED_FREE_N(buffer->buf, buffer->capa); + } +} + +struct tr_pair { + VALUE search; + VALUE replace; +}; + +struct tr_trans_pairs_coerce_args { + struct tr_pair *pairs; + size_t index; + rb_encoding *enc; + int cr; +}; + +static int +tr_trans_pairs_coerce_i(st_data_t key, st_data_t value, st_data_t _args) +{ + struct tr_trans_pairs_coerce_args *args = (struct tr_trans_pairs_coerce_args *)_args; + struct tr_pair *pair = &args->pairs[args->index]; + args->index++; + + VALUE search = (VALUE)key; + VALUE replace = (VALUE)value; + StringValue(search); + StringValue(replace); + + if (RSTRING_LEN(search) != 1 && str_strlen(search, NULL) != 1) { + rb_raise(rb_eArgError, "keys must be of size 1"); // TODO: better error message + } + + args->enc = rb_enc_check_multi_str(args->enc, &args->cr, search); + args->enc = rb_enc_check_multi_str(args->enc, &args->cr, replace); + + pair->search = search; + pair->replace = replace; + return ST_CONTINUE; +} + +static VALUE +tr_trans_pairs(VALUE str, VALUE pairs_val) +{ + Check_Type(pairs_val, T_HASH); + size_t pairs_count = RHASH_SIZE(pairs_val); + rb_str_modify(str); + + if (RSTRING_LEN(str) == 0 || !RSTRING_PTR(str) || pairs_count == 0) return Qnil; + + VALUE pairs_handle; + struct tr_pair *pairs = ALLOCV_N(struct tr_pair, pairs_handle, pairs_count); + + int cr = rb_enc_str_coderange(str); + rb_encoding *enc = rb_str_enc_get(str); + + struct tr_trans_pairs_coerce_args coerce_args = { + .pairs = pairs, + .enc = enc, + .cr = cr, + }; + rb_hash_foreach(pairs_val, tr_trans_pairs_coerce_i, (VALUE)&coerce_args); + rb_encoding *e1 = coerce_args.enc; + + VALUE hash = 0; + + unsigned char *sstart = (unsigned char *)RSTRING_PTR(str); + unsigned char *s = sstart; + unsigned char *send = sstart + RSTRING_LEN(str); + int termlen = rb_enc_mbminlen(e1); + + struct tr_buffer buffer; + tr_buffer_init(&buffer, send - s); + bool modify = false; + + if (RB_LIKELY(rb_str_encindex_fastpath(rb_enc_to_index(e1)))) { + VALUE trans_table[256] = { 0 }; + + for (size_t index = 0; index < pairs_count; index++) { + struct tr_pair *pair = &pairs[index]; + + char *ptr = RSTRING_PTR(pair->search); + unsigned int codepoint = rb_enc_mbc_to_codepoint(ptr, RSTRING_END(pair->search), e1); + + if (rb_enc_codelen(codepoint, e1) == 1) { + trans_table[(unsigned char)*ptr] = pair->replace; + } + else { + trans_table[(unsigned char)*ptr] = Qundef; + if (!hash) { + hash = rb_obj_hide(rb_hash_new_capa(pairs_count)); + } + rb_hash_aset(hash, UINT2NUM(codepoint), pair->replace); + } + } + + unsigned char *checkpoint = s; + while (s < send) { + VALUE repl = trans_table[*s]; + + int clen = 1; + + if (UNLIKELY(repl == Qundef)) { + unsigned int c = rb_enc_mbc_to_codepoint((char *)s, (char *)send, e1); + clen = rb_enc_codelen(c, e1); + repl = rb_hash_lookup2(hash, UINT2NUM(c), 0); + } + + if (LIKELY(repl == 0)) { + s += clen; + continue; + } + + modify = true; + + if (checkpoint < s) { + tr_buffer_append(&buffer, checkpoint, s - checkpoint); + } + tr_buffer_append_str(&buffer, repl); + s += clen; + checkpoint = s; + + if (cr == ENC_CODERANGE_7BIT && rb_enc_str_coderange(repl) != ENC_CODERANGE_7BIT) { + cr == ENC_CODERANGE_VALID; + } + } + + if (modify && checkpoint < s) { + tr_buffer_append(&buffer, checkpoint, s - checkpoint); + } + } + else { + hash = rb_obj_hide(rb_hash_new_capa(pairs_count)); + + for (size_t index = 0; index < pairs_count; index++) { + struct tr_pair *pair = &pairs[index]; + + unsigned int codepoint = rb_enc_mbc_to_codepoint(RSTRING_PTR(pair->search), RSTRING_END(pair->search), e1); + rb_hash_aset(hash, UINT2NUM(codepoint), pair->replace); + } + + while (s < send) { + bool may_modify = false; + + int r = rb_enc_precise_mbclen((char *)s, (char *)send, e1); + if (!MBCLEN_CHARFOUND_P(r)) { + tr_buffer_free(&buffer); + rb_raise(rb_eArgError, "invalid byte sequence in %s", rb_enc_name(e1)); + } + int clen = MBCLEN_CHARFOUND_LEN(r); + unsigned int c = rb_enc_mbc_to_codepoint((char *)s, (char *)send, e1); + unsigned int c0 = c; + + long tlen = enc == e1 ? clen : rb_enc_codelen(c, e1); + + VALUE replacement = rb_hash_lookup(hash, UINT2NUM(c)); + if (NIL_P(replacement)) { + tlen = enc == e1 ? clen : rb_enc_codelen(c, enc); + c = c0; + if (enc != e1) may_modify = true; + } + else { + tlen = RSTRING_LEN(replacement); + modify = true; + } + + if (NIL_P(replacement)) { + tr_buffer_mbcput(&buffer, c, enc); + } + else { + tr_buffer_append_str(&buffer, replacement); + } + + if (may_modify && memcmp(s, buffer.ptr - tlen, tlen) != 0) { + modify = true; + } + + if (cr == ENC_CODERANGE_7BIT && !rb_isascii(c)) { + cr = ENC_CODERANGE_VALID; + } + + s += clen; + } + } + + if (!STR_EMBED_P(str)) { + SIZED_FREE_N(STR_HEAP_PTR(str), STR_HEAP_SIZE(str)); + } + tr_buffer_ensure_capa(&buffer, termlen); + TERM_FILL((char *)buffer.ptr, termlen); + RSTRING(str)->as.heap.ptr = (char *)buffer.buf; + STR_SET_LEN(str, buffer.ptr - buffer.buf); + STR_SET_NOEMBED(str); + RSTRING(str)->as.heap.aux.capa = buffer.capa - termlen; + + RB_GC_GUARD(hash); + + if (modify) { + if (cr != ENC_CODERANGE_BROKEN) + ENC_CODERANGE_SET(str, cr); + rb_enc_associate(str, e1); + return str; + } + return Qnil; +} /* * call-seq: * tr!(selector, replacements) -> self or nil + * tr!(pairs) -> self or nil * * Like String#tr, except: * @@ -9200,8 +9464,16 @@ tr_trans(VALUE str, VALUE src, VALUE repl, int sflag) */ static VALUE -rb_str_tr_bang(VALUE str, VALUE src, VALUE repl) +rb_str_tr_bang(int argc, VALUE *argv, VALUE str) { + rb_check_arity(argc, 1, 2); + + if (argc == 1) { + VALUE pairs = argv[0]; + return tr_trans_pairs(str, pairs); + } + + VALUE src = argv[0], repl = argv[1]; return tr_trans(str, src, repl, 0); } @@ -9244,9 +9516,18 @@ rb_str_tr_bang(VALUE str, VALUE src, VALUE repl) */ static VALUE -rb_str_tr(VALUE str, VALUE src, VALUE repl) +rb_str_tr(int argc, VALUE *argv, VALUE str) { + rb_check_arity(argc, 1, 2); + str = str_duplicate(rb_cString, str); + + if (argc == 1) { + VALUE pairs = argv[0]; + return tr_trans_pairs(str, pairs); + } + + VALUE src = argv[0], repl = argv[1]; tr_trans(str, src, repl, 0); return str; } @@ -13535,13 +13816,13 @@ Init_String(void) rb_define_method(rb_cString, "delete_prefix!", rb_str_delete_prefix_bang, 1); rb_define_method(rb_cString, "delete_suffix!", rb_str_delete_suffix_bang, 1); - rb_define_method(rb_cString, "tr", rb_str_tr, 2); + rb_define_method(rb_cString, "tr", rb_str_tr, -1); rb_define_method(rb_cString, "tr_s", rb_str_tr_s, 2); rb_define_method(rb_cString, "delete", rb_str_delete, -1); rb_define_method(rb_cString, "squeeze", rb_str_squeeze, -1); rb_define_method(rb_cString, "count", rb_str_count, -1); - rb_define_method(rb_cString, "tr!", rb_str_tr_bang, 2); + rb_define_method(rb_cString, "tr!", rb_str_tr_bang, -1); rb_define_method(rb_cString, "tr_s!", rb_str_tr_s_bang, 2); rb_define_method(rb_cString, "delete!", rb_str_delete_bang, -1); rb_define_method(rb_cString, "squeeze!", rb_str_squeeze_bang, -1); From 4b5d6e98e1241207553df7b4f8f8b52dbbc21c61 Mon Sep 17 00:00:00 2001 From: Kevin Menard Date: Thu, 27 Aug 2026 14:22:41 -0400 Subject: [PATCH 8/9] ZJIT: Add a test for an empty inline frame pair that survives elision --- zjit/src/hir/opt_tests.rs | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index f8a8fd6d0f7a14..063a130496feb3 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -2146,6 +2146,49 @@ mod hir_opt_tests { "); } + #[test] + fn test_eliminate_empty_inline_frames_with_dead_snapshot() { + eval(" + def add(a, b) = a + b + def test = add(1, 2) + add(3, 4) + test + "); + + // `add` is inlined at both call sites, giving two `PushInlineFrame`/`PopInlineFrame` + // pairs. The first pair keeps `add`'s `PatchPoint` and `CheckInterrupts`, which the + // deduplication passes leave on the earliest copy, so it has real work between it. The + // second pair's body is optimized away entirely, yet the pair still isn't eliminated, + // because it encloses the `Snapshot`s that body left behind. `Snapshot`s aren't printed + // out, so that pair looks empty below even though the pass doesn't treat it as empty. + assert_snapshot!(hir_string("test"), @" + fn test@:3: + bb1(): + EntryPoint interpreter + v1:BasicObject = LoadSelf + Jump bb3(v1) + bb2(): + EntryPoint JIT(0) + v4:BasicObject = LoadArg :self@0 + Jump bb3(v4) + bb3(v6:BasicObject): + v11:Fixnum[1] = Const Value(1) + v13:Fixnum[2] = Const Value(2) + PatchPoint MethodRedefined(Object@0x1000, add@0x1008, cme:0x1010) + v32:ObjectSubclass[class_exact*:Object@VALUE(0x1000)] = GuardType v6, ObjectSubclass[class_exact*:Object@VALUE(0x1000)] recompile + PushInlineFrame :add, v32 (0x1038), num_args=2 + PatchPoint MethodRedefined(Integer@0x1058, +@0x1060, cme:0x1068) + v88:Fixnum[3] = Const Value(3) + CheckInterrupts + PopInlineFrame + v18:Fixnum[3] = Const Value(3) + v20:Fixnum[4] = Const Value(4) + PushInlineFrame :add, v32 (0x1038), num_args=2 + PopInlineFrame + v90:Fixnum[10] = Const Value(10) + Return v90 + "); + } + #[test] fn test_call_with_correct_and_too_many_args_for_method() { eval(" From e6d3c65cd0cbfaf1e3a941c3978ac4d70b472bcc Mon Sep 17 00:00:00 2001 From: Kevin Menard Date: Thu, 27 Aug 2026 14:23:05 -0400 Subject: [PATCH 9/9] ZJIT: A dead Snapshot no longer prevents empty inline frame elision --- zjit/src/hir.rs | 9 +++++++++ zjit/src/hir/opt_tests.rs | 10 ++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/zjit/src/hir.rs b/zjit/src/hir.rs index b567c8d2794c84..f66bddd4eb4332 100644 --- a/zjit/src/hir.rs +++ b/zjit/src/hir.rs @@ -7085,9 +7085,18 @@ impl Function { /// * It must not reference a FrameState `Snapshot` operand: a side exit /// materializes the enclosing inlined frame, and effects don't model /// deopt for otherwise pure instructions like `FixnumAdd`. + /// (`Snapshot` instructions themselves are exempt: they are inert data + /// that cannot side-exit, so they should not prevent elision.) /// * `LoadSP` reads the frame-dependent SP register despite having empty /// effects, so it's excluded explicitly. fn can_elide_enclosing_frame(&self, insn: &Insn) -> bool { + // A `Snapshot` only matters as the deopt state of an instruction + // that can side-exit. It shouldn't prevent elision on its own. + // Side-exiting instructions will block elision when they're scanned. + // TODO (nirvdrum 2026-09-02) Replace this specific instruction check with a check of the instruction's effects. + if matches!(insn, Insn::Snapshot { .. }) { + return true; + } // TODO: Model LoadSP as reading from the control frame and drop this // special case. if matches!(insn, Insn::LoadSP) { diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index 063a130496feb3..62ab8c6bd66096 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -2156,10 +2156,8 @@ mod hir_opt_tests { // `add` is inlined at both call sites, giving two `PushInlineFrame`/`PopInlineFrame` // pairs. The first pair keeps `add`'s `PatchPoint` and `CheckInterrupts`, which the - // deduplication passes leave on the earliest copy, so it has real work between it. The - // second pair's body is optimized away entirely, yet the pair still isn't eliminated, - // because it encloses the `Snapshot`s that body left behind. `Snapshot`s aren't printed - // out, so that pair looks empty below even though the pass doesn't treat it as empty. + // deduplication passes leave on the earliest copy, so it has real work between it and + // survives. The second pair and its body are optimized away entirely. assert_snapshot!(hir_string("test"), @" fn test@:3: bb1(): @@ -2180,10 +2178,6 @@ mod hir_opt_tests { v88:Fixnum[3] = Const Value(3) CheckInterrupts PopInlineFrame - v18:Fixnum[3] = Const Value(3) - v20:Fixnum[4] = Const Value(4) - PushInlineFrame :add, v32 (0x1038), num_args=2 - PopInlineFrame v90:Fixnum[10] = Const Value(10) Return v90 ");