Skip to content

Accept a Hash of replacements in String#tr and #tr! (Feature #22238) - #9811

Draft
zev wants to merge 6 commits into
jruby:ruby-4.1from
zev:string-tr-hash
Draft

zev wants to merge 6 commits into
jruby:ruby-4.1from
zev:string-tr-hash

Conversation

@zev

@zev zev commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Part of #9746.

Adds the single-Hash form of String#tr and String#tr! from Ruby 4.1 (Feature #22238):

"hello".tr("e" => "EH! ", "o" => "OH!", "l" => "")  #=> "hEH! OH!"

Keys must be a single character; values may be any string (including empty, which deletes). Replacement is a single pass, so replaced text is never rescanned. tr! returns nil when nothing changed.

Implementation

RubyString#trTransPairs, modelled on CRuby's tr_trans_pairs:

  • Same order of checks as CRuby: Check_Type(Hash), broken string, frozen, then empty receiver/hash returns nil.
  • checkMultiStr is a port of rb_enc_check_multi_str. It folds each key and value into one negotiated encoding or raises Encoding::CompatibilityError; the result is tagged with it.
  • Lookup uses a 256-entry array plus a lazily created IntHash, as StringSupport.trTransHelper does. ASCII bytes in ASCII-compatible encodings skip decoding.
  • Unchanged runs are only copied once the first replacement is found, so a no-op returns nil without copying.

The four commits are kept separate so the port, the review fixes, the table rewrite and the specs can be diffed individually; happy to squash.

Not done / differences from CRuby

  • No SIMD search. The JVM version is a plain scan.
  • When the receiver's encoding differs from the negotiated one, CRuby re-encodes unmatched characters in non-UTF-8/ASCII/binary encodings. Here they are copied as raw bytes, which is byte-identical because that case only arises for 7-bit receivers. No dedicated spec for it.
  • The lookup table is allocated per call; a small-pair fast path could avoid that for short strings. Left as a comment, not measured.
  • @CRuby annotation not used since org.jruby.anno.CRuby is not on ruby-4.1 yet; // MRI: comments follow the file's current convention.

Testing

  • The hash form examples already in spec/ruby/core/string/tr_spec.rb (ruby_version_is "4.1") pass.
  • Added specs for characters above U+00FF, receiver mutation, an invalid key, FrozenError and the TypeError messages. They also pass on CRuby 4.1.0-dev (44/44).
  • tr_spec.rb/tr_s_spec.rb: only the existing tagged "c1-c1" examples fail.
  • A JRuby-vs-CRuby comparison of multibyte, UTF-16, binary, invalid-key and tr! cases produced identical output.
  • Not run: the full spec:ruby:fast baseline diff.

🤖 Generated with Claude Code

zev and others added 6 commits October 15, 2026 20:52
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…pe error names

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mirror StringSupport.trTransHelper: a 256-entry array for low codepoints and
an IntHash above that, decode only non-ASCII characters, and copy unchanged
runs only once a replacement is found so a no-op returns nil without copying.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…and errors

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The lambda overload of RubyHash#visitAll counts entries up front and
raises ConcurrencyError when a key's #to_str deletes another entry.
Use the state-passing overload, which skips removed entries like
Hash#each and MRI's rb_hash_foreach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zev

zev commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Pushed cf9af4a to fix a crash found while comparing against CRuby 4.1.

If a key's #to_str deletes another entry from the Hash, tr raised ConcurrencyError. CRuby skips the deleted entry:

pairs = {}
key = Object.new
key.define_singleton_method(:to_str) { pairs.delete("b"); "a" }
pairs[key] = "x"
pairs["b"] = "y"
"ab".tr(pairs) # CRuby: "xb", JRuby before: ConcurrencyError

The lambda overload of RubyHash#visitAll counts entries up front and raises when one disappears. The state-passing overload, which Hash#each uses, skips removed entries instead, so the visitor now uses that. The visitor body is unchanged apart from indentation. Added a spec for this; it passes on CRuby 4.1 and JRuby. tr_spec.rb/tr_s_spec.rb still only fail the existing tagged "c1-c1" examples.

Not changed: CRuby raises can't add a new key into hash during iteration if #to_str adds a key, while JRuby allows it. The guard for that is private to RubyHashLinkedBuckets.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant