From 328c58775bdd1694cca35c933d2e96ca90366d1b Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Wed, 15 Jul 2026 18:11:33 +0900 Subject: [PATCH 01/33] Implement Proc#refined Add Proc#refined(mod1, ...), which returns a new Proc whose body runs with the given modules' refinements active. The receiver is unchanged and the captured closure environment is shared; only the refinement scope differs. In JRuby whether a call resolves refinements is decided at IR build time: CallInstr.create reads scope.maybeUsingRefinements() and, when true, builds a RefinedCachingCallSite that captures the static scope. So a refinement-aware copy of the receiver proc's IR is required (the analog of CRuby recursively deep-copying the block iseq); merely swapping an overlay at runtime is not enough. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Claude Fable 5 --- core/src/main/java/org/jruby/RubyModule.java | 23 +- core/src/main/java/org/jruby/RubyProc.java | 40 ++ .../main/java/org/jruby/TopSelfFactory.java | 3 + .../main/java/org/jruby/ir/IRClassBody.java | 5 + .../src/main/java/org/jruby/ir/IRClosure.java | 155 +++++- .../src/main/java/org/jruby/ir/IRManager.java | 12 + .../java/org/jruby/ir/IRMetaClassBody.java | 5 + core/src/main/java/org/jruby/ir/IRMethod.java | 18 + .../main/java/org/jruby/ir/IRModuleBody.java | 18 + core/src/main/java/org/jruby/ir/IRScope.java | 39 +- .../ir/instructions/ArrayDerefInstr.java | 5 + .../org/jruby/ir/instructions/CallInstr.java | 13 +- .../ir/instructions/DefineClassInstr.java | 2 +- .../instructions/DefineClassMethodInstr.java | 2 +- .../DefineInstanceMethodInstr.java | 2 +- .../ir/instructions/DefineMetaClassInstr.java | 3 +- .../ir/instructions/DefineModuleInstr.java | 3 +- .../OneFixnumArgNoBlockCallInstr.java | 8 - .../OneFloatArgNoBlockCallInstr.java | 9 - .../OneOperandArgBlockCallInstr.java | 10 - .../OneOperandArgNoBlockCallInstr.java | 9 - .../TwoOperandArgNoBlockCallInstr.java | 9 - .../ZeroOperandArgNoBlockCallInstr.java | 8 - .../org/jruby/ir/operands/SymbolProc.java | 10 + .../jruby/ir/operands/WrappedIRClosure.java | 5 + .../transformations/inlining/CloneInfo.java | 13 +- .../inlining/SimpleCloneInfo.java | 16 +- .../java/org/jruby/parser/StaticScope.java | 16 + .../jruby/runtime/MixedModeIRBlockBody.java | 7 + test/jruby/test_proc_refined.rb | 501 ++++++++++++++++++ 30 files changed, 896 insertions(+), 73 deletions(-) create mode 100644 test/jruby/test_proc_refined.rb diff --git a/core/src/main/java/org/jruby/RubyModule.java b/core/src/main/java/org/jruby/RubyModule.java index 82c30bc882c..1138d0efa91 100644 --- a/core/src/main/java/org/jruby/RubyModule.java +++ b/core/src/main/java/org/jruby/RubyModule.java @@ -1134,6 +1134,9 @@ public IRubyObject using(ThreadContext context, IRubyObject refinedModule) { if (context.getCurrentStaticScope().isWithinMethod()) { throw runtimeError(context, "Module#using is not permitted in methods"); } + if (context.getCurrentStaticScope().isWithinRefinementsProc()) { + throw runtimeError(context, "Module#using is not permitted in a proc with refinements"); + } // I pass the cref even though I don't need to so that the concept is simpler to read StaticScope staticScope = context.getCurrentStaticScope(); @@ -1164,7 +1167,12 @@ private static void usingModuleRecursive(ThreadContext context, RubyModule cref, RefinementStore refinementStore = module.refinementStore; if (refinementStore == null) return; // No refinements registered for this module - Map refinements = refinementStore.refinements; + // Snapshot under the map's own lock: Proc#refined activates refinements from arbitrary threads + // while Module#refine on another thread may be mutating this map. + Map refinements; + synchronized (refinementStore.refinements) { + refinements = new IdentityHashMap<>(refinementStore.refinements); + } for (Map.Entry entry: refinements.entrySet()) { usingRefinement(context, cref, entry.getKey(), entry.getValue()); } @@ -2938,9 +2946,21 @@ private Visibility getCurrentVisibilityForDefineMethod(ThreadContext context) { return context.getCurrentFrame().getSelf() == this ? context.getCurrentVisibility() : PUBLIC; } + private static void checkNotRefinementsProc(ThreadContext context, Block block) { + // A method defined from a Proc#refined proc would silently drop its refinements (a method is invoked + // against its method entry, not the proc's refinement scope), so reject it. + if (block.getBody() instanceof IRBlockBody body) { + IRClosure closure = body.getScope(); + if (closure != null && closure.isRefinementsClone()) { + throw argumentError(context, "can't define a method from a Proc with refinements"); + } + } + } + public IRubyObject defineMethodFromBlock(ThreadContext context, IRubyObject arg0, Block block, Visibility visibility) { RubySymbol name = checkID(context, arg0); if (!block.isGiven()) throw argumentError(context, "tried to create Proc object without a block"); + checkNotRefinementsProc(context, block); if ("initialize".equals(name.idString())) visibility = PRIVATE; @@ -2984,6 +3004,7 @@ public IRubyObject defineMethodFromCallable(ThreadContext context, IRubyObject a if (context.runtime.getProc().isInstance(arg1)) { // double-testing args.length here, but it avoids duplicating the proc-setup code in two places RubyProc proc = (RubyProc)arg1; + checkNotRefinementsProc(context, proc.getBlock()); newMethod = createProcMethod(context.runtime, name.idString(), visibility, proc.getBlock()); } else if (arg1 instanceof AbstractRubyMethod) { diff --git a/core/src/main/java/org/jruby/RubyProc.java b/core/src/main/java/org/jruby/RubyProc.java index db34caf2cd9..f8fdd0a7d16 100644 --- a/core/src/main/java/org/jruby/RubyProc.java +++ b/core/src/main/java/org/jruby/RubyProc.java @@ -39,6 +39,8 @@ import org.jruby.anno.JRubyMethod; import org.jruby.ast.util.ArgsUtil; +import org.jruby.ir.IRClosure; +import org.jruby.ir.interpreter.Interpreter; import org.jruby.ir.runtime.IRRuntimeHelpers; import org.jruby.java.codegen.BlockInterfaceGenerator; import org.jruby.javasupport.Java; @@ -58,6 +60,7 @@ import static org.jruby.api.Convert.asBoolean; import static org.jruby.api.Convert.asFixnum; +import static org.jruby.api.Convert.castAsModule; import static org.jruby.api.Create.*; import static org.jruby.api.Define.defineClass; import static org.jruby.api.Error.argumentError; @@ -418,6 +421,43 @@ public RubyProc to_proc() { return this; } + /** + * Return a new Proc whose body runs with the given modules' refinements active; the receiver is unchanged + * and the captured environment is shared. Heavy (it deep-copies the block's IR), so the result is cached. + */ + @JRubyMethod(name = "refined", rest = true) + public IRubyObject refined(ThreadContext context, IRubyObject[] args) { + if (args.length == 0) throw argumentError(context, "wrong number of arguments (given 0, expected 1+)"); + + BlockBody body = block.getBody(); + if (fromMethod || !(body instanceof IRBlockBody)) { + throw argumentError(context, "can't apply refinements to a Proc without a Ruby block"); + } + + // Chaining is not allowed: activate multiple modules by passing them all in a single call. + if (((IRBlockBody) body).getScope().isRefinementsClone()) { + throw argumentError(context, "can't apply refinements to a Proc that already has refinements"); + } + + RubyModule[] modules = new RubyModule[args.length]; + for (int i = 0; i < args.length; i++) { + modules[i] = castAsModule(context, args[i]); + } + + IRClosure refinedClosure = ((IRBlockBody) body).getScope().refinementsClone(context, modules); + // Share the captured environment (binding holds the dynamic scope); clone the binding wrapper only so that + // proc setup (file/line/dummy-scope) does not mutate the original proc's binding. + Binding newBinding = block.getBinding().clone(); + // A block from compiled top-level code has a null frame method name; the clone always runs interpreted, + // so give it the interpreter's top-level name or building a backtrace through it would crash. + if (newBinding.getMethod() == null) newBinding.setMethod(Interpreter.ROOT); + Block newBlock = new Block(refinedClosure.getBlockBody(), newBinding, block.type); + + // The real class, not the receiver's metaclass: the refined proc must not inherit the receiver's + // singleton state (as in CRuby). Proc subclasses are still preserved. + return newProc(context.runtime, getMetaClass().getRealClass(), newBlock, type, file, line); + } + @JRubyMethod public IRubyObject source_location(ThreadContext context) { if (file != null) return newArray(context, newString(context, file), asFixnum(context, line + 1 /*zero-based*/)); diff --git a/core/src/main/java/org/jruby/TopSelfFactory.java b/core/src/main/java/org/jruby/TopSelfFactory.java index 25bcf3a5bd8..b51630bc5dd 100644 --- a/core/src/main/java/org/jruby/TopSelfFactory.java +++ b/core/src/main/java/org/jruby/TopSelfFactory.java @@ -117,6 +117,9 @@ public IRubyObject call(ThreadContext context, IRubyObject self, RubyModule claz @Override public IRubyObject call(ThreadContext context, IRubyObject self, RubyModule clazz, String name, IRubyObject[] args) { Arity.checkArgumentCount(context, args, 1, 1); + if (context.getCurrentStaticScope().isWithinRefinementsProc()) { + throw context.runtime.newRuntimeError("main.using is not permitted in a proc with refinements"); + } RubyModule cref = context.getCurrentStaticScope().getOverlayModuleForWrite(context); // unclear what the equivalent check would be for us // rb_control_frame_t * prev_cfp = previous_frame(GET_THREAD()); diff --git a/core/src/main/java/org/jruby/ir/IRClassBody.java b/core/src/main/java/org/jruby/ir/IRClassBody.java index 03e7507dd59..e317d78c84b 100644 --- a/core/src/main/java/org/jruby/ir/IRClassBody.java +++ b/core/src/main/java/org/jruby/ir/IRClassBody.java @@ -9,6 +9,11 @@ public IRClassBody(IRManager manager, IRScope lexicalParent, ByteList name, int super(manager, lexicalParent, name, lineNumber, scope, executesOnce); } + @Override + protected IRModuleBody cloneInstance(IRScope lexicalParent, StaticScope scope) { + return new IRClassBody(getManager(), lexicalParent, getByteName(), getLine(), scope, executesOnce()); + } + @Override public IRScopeType getScopeType() { return IRScopeType.CLASS_BODY; diff --git a/core/src/main/java/org/jruby/ir/IRClosure.java b/core/src/main/java/org/jruby/ir/IRClosure.java index e7f5864a915..e2540170969 100644 --- a/core/src/main/java/org/jruby/ir/IRClosure.java +++ b/core/src/main/java/org/jruby/ir/IRClosure.java @@ -1,10 +1,13 @@ package org.jruby.ir; import java.util.ArrayList; +import java.util.Arrays; import java.util.EnumSet; import java.util.List; +import java.util.Map; import java.util.function.Supplier; +import org.jruby.RubyModule; import org.jruby.RubySymbol; import org.jruby.ast.DefNode; import org.jruby.ast.IterNode; @@ -26,6 +29,7 @@ import org.jruby.runtime.MixedModeIRBlockBody; import org.jruby.runtime.InterpretedIRBlockBody; import org.jruby.runtime.Signature; +import org.jruby.runtime.ThreadContext; import org.jruby.util.ByteList; // Closures are contexts/scopes for the purpose of IR building. They are self-contained and accumulate instructions @@ -74,7 +78,13 @@ protected IRClosure(IRManager manager, IRScope lexicalParent, int lineNumber, St /** Used by cloning code for inlining */ /* Inlining generates a new name and id and basic cloning will reuse the originals name */ protected IRClosure(IRClosure c, IRScope lexicalParent, int closureId, ByteList fullName) { - super(c, lexicalParent); + // Inlining clones share the source's StaticScope. + this(c, lexicalParent, closureId, fullName, c.getStaticScope()); + } + + /** Used by Proc#refined cloning, which needs each clone to own a fresh StaticScope. */ + protected IRClosure(IRClosure c, IRScope lexicalParent, int closureId, ByteList fullName, StaticScope staticScope) { + super(c, lexicalParent, staticScope); this.closureId = closureId; super.setByteName(fullName); @@ -341,24 +351,157 @@ public IRClosure cloneForInlining(CloneInfo ii) { IRClosure clonedClosure; IRScope lexicalParent = ii.getScope(); + // For Proc#refined we give the clone its own StaticScope whose enclosing scope points + // at the clone's lexical parent. + StaticScope refinementsScope = null; + if (ii.isRefinementsClone()) { + refinementsScope = getStaticScope().duplicate(); + refinementsScope.setEnclosingScope(lexicalParent.getStaticScope()); + } + if (ii instanceof SimpleCloneInfo && !((SimpleCloneInfo) ii).isEnsureBlockCloneMode()) { - clonedClosure = new IRClosure(this, lexicalParent, closureId, getByteName()); + clonedClosure = refinementsScope != null ? + new IRClosure(this, lexicalParent, closureId, getByteName(), refinementsScope) : + new IRClosure(this, lexicalParent, closureId, getByteName()); } else { int id = lexicalParent.getNextClosureId(); ByteList fullName = lexicalParent.getByteName(); fullName = fullName != null ? fullName.dup() : new ByteList(); fullName.append(CLOSURE_CLONE); fullName.append(java.lang.Integer.toString(id).getBytes()); - clonedClosure = new IRClosure(this, lexicalParent, id, fullName); + clonedClosure = refinementsScope != null ? + new IRClosure(this, lexicalParent, id, fullName, refinementsScope) : + new IRClosure(this, lexicalParent, id, fullName); + } + + if (refinementsScope != null) { + refinementsScope.setIRScope(clonedClosure); + clonedClosure.setIsMaybeUsingRefinements(); + clonedClosure.inRefinementsCloneTree = true; } - // WrappedIRClosure should always have a single unique IRClosure in them so we should - // not end up adding n copies of the same closure as distinct clones... - lexicalParent.addClosure(clonedClosure); + // the top clone of a Proc#refined tree is grafted under the source's real lexical parent. + boolean graftedTopRefinementsClone = ii.isRefinementsClone() && + !(lexicalParent instanceof IRClosure && ((IRClosure) lexicalParent).isInRefinementsCloneTree()); + if (graftedTopRefinementsClone) { + // Only the TOP clone is user-visible (the proc returned by Proc#refined); flag it so chaining and + // define_method are rejected on it, but not on the nested closures it lexically encloses. + clonedClosure.refinementsClone = true; + } else { + // WrappedIRClosure should always have a single unique IRClosure in them so we should + // not end up adding n copies of the same closure as distinct clones... + lexicalParent.addClosure(clonedClosure); + } return cloneForInlining(ii, clonedClosure); } + /** + * Single-slot memo of the most recent refinement-aware clone of a closure (see {@link #refinementsClone}). + */ + static final class RefinementsCache { + final RubyModule[] modules; + final IRClosure clone; + + RefinementsCache(RubyModule[] modules, IRClosure clone) { + this.modules = modules; + this.clone = clone; + } + + boolean matches(RubyModule[] other) { + return Arrays.equals(modules, other); + } + } + + /** + * True on every closure in a Proc#refined clone tree: the top clone and all closures it lexically + * encloses (which are deep-copied along with it). + */ + private boolean inRefinementsCloneTree; + + public boolean isInRefinementsCloneTree() { + return inRefinementsCloneTree; + } + + /** + * True only on the TOP clone of a Proc#refined tree -- the closure that directly backs the proc the user + * receives. Used to reject chaining Proc#refined and to reject defining a method from such a proc. + */ + private boolean refinementsClone; + + public boolean isRefinementsClone() { + return refinementsClone; + } + + /** + * Return a refinement-aware clone of this closure for the given modules, reusing the cached clone when the same + * modules (by identity and order) were used last time. A miss recomputes via {@link #cloneForRefinements} and + * overwrites the single slot, emitting a performance-category warning when an existing entry is evicted. + * Lock-free: a race only causes a redundant clone, which is harmless. + * + * @param context the current thread context + * @param modules the refinement modules to activate (freshly allocated array; stored without copying) + * @return a clone whose body runs with the given refinements active + */ + public IRClosure refinementsClone(ThreadContext context, RubyModule[] modules) { + Map cacheMap = getManager().getRefinementsCloneCache(); + RefinementsCache cache = cacheMap.get(this); + if (cache != null) { + if (cache.matches(modules)) { + // Proc#ruby2_keywords marks the scope, possibly after the clone was memoized; a stale + // clone must be rebuilt, not reused (as in CRuby). + if (cache.clone.isRuby2Keywords() == isRuby2Keywords()) return cache.clone; + context.runtime.getWarnings().warnPerformance( + "Proc#refined re-copies the block because the ruby2_keywords flag changed after the copy was memoized"); + } else { + context.runtime.getWarnings().warnPerformance( + "Proc#refined called with different modules for the same block disables memoization"); + } + } + + IRClosure clone = cloneForRefinements(context, modules); + // Build the clone's BlockBody now, before publishing it through the cache, so a thread that later + // reads the shared clone on a cache hit sees a fully-built body (via the put's happens-before) and never + // races another thread to create a second one in the lazy getBlockBody(). + clone.getBlockBody(); + cacheMap.put(this, new RefinementsCache(modules, clone)); + + return clone; + } + + /** + * Produce a refinement-aware deep copy of this closure tree for Proc#refined: the copy gets its own + * StaticScope with the given modules' refinements activated and its call sites re-created as refined. + * + * Private: callers go through {@link #refinementsClone}, which memoizes the clone. + * + * @param context the current thread context + * @param modules the refinement modules to activate + * @return a new IRClosure whose body runs with the given refinements active + */ + private IRClosure cloneForRefinements(ThreadContext context, RubyModule[] modules) { + // Cloning reads the startup InterpreterContext as its instruction source. + if (getInterpreterContext() == null) { + throw context.runtime.newArgumentError("cannot create a refinements-aware copy of this proc"); + } + + SimpleCloneInfo ii = new SimpleCloneInfo(getLexicalParent(), false, false, true); + IRClosure clone = cloneForInlining(ii); + clone.setArgumentDescriptors(getArgumentDescriptors()); + + // The constructor registered the clone in its lexical parent's child-scope list; detach the grafted + // top clone so the shared parent does not retain it. + clone.getLexicalParent().removeChildScope(clone); + + // Activate the requested refinements on the clone's own overlay module so refined call sites resolve them. + RubyModule overlay = clone.getStaticScope().getOverlayModuleForWrite(context); + for (RubyModule module : modules) { + RubyModule.usingModule(context, overlay, module); + } + + return clone; + } + public Signature getSignature() { return signature; } diff --git a/core/src/main/java/org/jruby/ir/IRManager.java b/core/src/main/java/org/jruby/ir/IRManager.java index 3469fe358a2..50f9d0b1b38 100644 --- a/core/src/main/java/org/jruby/ir/IRManager.java +++ b/core/src/main/java/org/jruby/ir/IRManager.java @@ -31,9 +31,12 @@ import org.jruby.ir.passes.CompilerPassListener; import org.jruby.ir.passes.CompilerPassScheduler; +import java.util.Collections; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; @@ -98,6 +101,11 @@ public class IRManager { private IRBuilderFactory builderFactory; private AtomicLong callSiteCounter = new AtomicLong(1); + // Proc#refined cache: source closure -> its most recent refinements clone (see IRClosure#refinementsClone) + // Entries live for the runtime's lifetime; weak keys would never clear because the value reaches its key. + private final Map refinementsCloneCache = + Collections.synchronizedMap(new IdentityHashMap<>()); + public IRManager(Ruby runtime, RubyInstanceConfig config) { this.runtime = runtime; this.config = config; @@ -122,6 +130,10 @@ public Ruby getRuntime() { return runtime; } + Map getRefinementsCloneCache() { + return refinementsCloneCache; + } + public Nil getNil() { return nil; } diff --git a/core/src/main/java/org/jruby/ir/IRMetaClassBody.java b/core/src/main/java/org/jruby/ir/IRMetaClassBody.java index 61d1fe70ff1..c101a599d68 100644 --- a/core/src/main/java/org/jruby/ir/IRMetaClassBody.java +++ b/core/src/main/java/org/jruby/ir/IRMetaClassBody.java @@ -9,6 +9,11 @@ public IRMetaClassBody(IRManager manager, IRScope lexicalParent, ByteList name, super(manager, lexicalParent, name, lineNumber, scope, false); } + @Override + protected IRModuleBody cloneInstance(IRScope lexicalParent, StaticScope scope) { + return new IRMetaClassBody(getManager(), lexicalParent, getByteName(), getLine(), scope); + } + @Override public IRScopeType getScopeType() { return IRScopeType.METACLASS_BODY; diff --git a/core/src/main/java/org/jruby/ir/IRMethod.java b/core/src/main/java/org/jruby/ir/IRMethod.java index 8815cb664ab..7dbb7f33e2f 100644 --- a/core/src/main/java/org/jruby/ir/IRMethod.java +++ b/core/src/main/java/org/jruby/ir/IRMethod.java @@ -40,6 +40,7 @@ import org.jruby.ir.operands.Label; import org.jruby.ir.operands.LocalVariable; import org.jruby.ir.representations.BasicBlock; +import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.runtime.ArgumentDescriptor; import org.jruby.runtime.CallType; @@ -80,6 +81,23 @@ public boolean hasBeenBuilt() { return defNode == null; } + /** + * Deep-copy this method for a Proc#refined clone: a {@code def} inside a refined proc must capture that + * proc's refinements (like a {@code def} inside a {@code using} scope), so the defined method keeps + * resolving the refined methods. For ordinary inlining the shared original is returned unchanged. + */ + public IRMethod cloneForInlining(CloneInfo ii) { + if (!ii.isRefinementsClone()) return this; + + // Cloning copies already-built instructions, so make sure ours are built first. + return cloneScopeForRefinements(ii.getScope(), lazilyAcquireInterpreterContext(), (parent, scope) -> { + IRMethod clone = new IRMethod(getManager(), parent, null, getByteName(), isInstanceMethod, getLine(), + scope, getCoverageMode()); + clone.setArgumentDescriptors(getArgumentDescriptors()); + return clone; + }); + } + public MethodData getMethodData() { List ivarNames; diff --git a/core/src/main/java/org/jruby/ir/IRModuleBody.java b/core/src/main/java/org/jruby/ir/IRModuleBody.java index 5ba4702fc0e..ac81b4265cd 100644 --- a/core/src/main/java/org/jruby/ir/IRModuleBody.java +++ b/core/src/main/java/org/jruby/ir/IRModuleBody.java @@ -1,5 +1,6 @@ package org.jruby.ir; +import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.util.ByteList; @@ -17,6 +18,23 @@ public IRModuleBody(IRManager manager, IRScope lexicalParent, ByteList name, int } } + /** + * Deep-copy this module/class body for a Proc#refined clone: a {@code class}/{@code module} (re)opened + * inside a refined proc must see that proc's refinements, in the body and in any {@code def} it contains. + * For ordinary inlining the shared original is returned unchanged. + */ + public IRModuleBody cloneForInlining(CloneInfo ii) { + if (!ii.isRefinementsClone()) return this; + + // Class/module bodies are built eagerly when their enclosing scope is built, so the IC is available here. + return cloneScopeForRefinements(ii.getScope(), builtInterpreterContext(), this::cloneInstance); + } + + /** Construct an empty copy of this body of the correct concrete type for a refinements clone. */ + protected IRModuleBody cloneInstance(IRScope lexicalParent, StaticScope scope) { + return new IRModuleBody(getManager(), lexicalParent, getByteName(), getLine(), scope, executesOnce); + } + @Override public int getNearestModuleReferencingScopeDepth() { return 0; diff --git a/core/src/main/java/org/jruby/ir/IRScope.java b/core/src/main/java/org/jruby/ir/IRScope.java index faf707128fe..41f51f0248d 100644 --- a/core/src/main/java/org/jruby/ir/IRScope.java +++ b/core/src/main/java/org/jruby/ir/IRScope.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; import java.util.function.Supplier; import org.jruby.runtime.DynamicScope; @@ -144,16 +145,20 @@ public DynamicScope getDynamicScope() { private boolean needsCodeCoverage; private boolean usesEval; - // Used by cloning code for inlining - protected IRScope(IRScope s, IRScope lexicalParent) { + /** + * Copy constructor used by cloning code (inlining shares the source's StaticScope; Proc#refined cloning + * supplies a fresh one so a refinements overlay can be attached without mutating the original). + */ + protected IRScope(IRScope s, IRScope lexicalParent, StaticScope staticScope) { this.lexicalParent = lexicalParent; this.manager = s.manager; this.lineNumber = s.lineNumber; - this.staticScope = s.staticScope; + this.staticScope = staticScope; this.nextClosureIndex = s.nextClosureIndex; this.interpreterContext = null; this.coverageMode = CoverageData.NONE; this.localVars = new HashMap<>(s.localVars); + this.ruby2Keywords = s.ruby2Keywords; this.scopeId = globalScopeCount.getAndIncrement(); setupLexicalContainment(); @@ -215,6 +220,10 @@ protected synchronized void addChildScope(IRScope scope) { lexicalChildren.add(scope); } + protected synchronized void removeChildScope(IRScope scope) { + lexicalChildren.remove(scope); + } + public synchronized List getLexicalScopes() { return lexicalChildren; } @@ -617,6 +626,30 @@ public InterpreterContext allocateInterpreterContext(Supplier> instr return interpreterContext; } + /** + * Shared body of the IRMethod/IRModuleBody Proc#refined clone: give the copy its own StaticScope reaching + * the enclosing clone's refinements overlay, flag it maybe-using-refinements, and re-create its instructions. + * The caller supplies the source InterpreterContext and a constructor for the empty copy. + */ + protected T cloneScopeForRefinements(IRScope lexicalParent, InterpreterContext sourceIC, + BiFunction constructor) { + StaticScope refinementsScope = getStaticScope().duplicate(); + refinementsScope.setEnclosingScope(lexicalParent.getStaticScope()); + + T clone = constructor.apply(lexicalParent, refinementsScope); + refinementsScope.setIRScope(clone); + clone.setIsMaybeUsingRefinements(); + + SimpleCloneInfo clonedII = new SimpleCloneInfo(clone, false, false, true); + List newInstrs = new ArrayList<>(sourceIC.getInstructions().length); + for (Instr i : sourceIC.getInstructions()) { + newInstrs.add(i.clone(clonedII)); + } + clone.allocateInterpreterContext(newInstrs, sourceIC.getTemporaryVariableCount(), sourceIC.getFlags()); + + return clone; + } + private Instr[] cloneInstrs() { SimpleCloneInfo cloneInfo = new SimpleCloneInfo(this, false); diff --git a/core/src/main/java/org/jruby/ir/instructions/ArrayDerefInstr.java b/core/src/main/java/org/jruby/ir/instructions/ArrayDerefInstr.java index 14f08aabf78..debbe8f71ee 100644 --- a/core/src/main/java/org/jruby/ir/instructions/ArrayDerefInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/ArrayDerefInstr.java @@ -45,6 +45,11 @@ public ArrayDerefInstr(IRScope scope, Variable result, Operand obj, FrozenString @Override public Instr clone(CloneInfo ii) { + // The optimized aref path bypasses refinements (dispatches through CachingCallSite.isBuiltin), so a + // clone into a refined scope falls back to a generic refined call, like the fixnum/float fast paths. + if (isPotentiallyRefined() || ii.getScope().maybeUsingRefinements()) { + return super.clone(ii); + } return new ArrayDerefInstr(ii.getScope(), (Variable) getResult().cloneForInlining(ii), getReceiver().cloneForInlining(ii), key, getFlags()); } diff --git a/core/src/main/java/org/jruby/ir/instructions/CallInstr.java b/core/src/main/java/org/jruby/ir/instructions/CallInstr.java index c9ee83169d6..75bae1149df 100644 --- a/core/src/main/java/org/jruby/ir/instructions/CallInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/CallInstr.java @@ -27,7 +27,14 @@ public class CallInstr extends CallBase implements ResultInstr { public static CallInstr create(IRScope scope, CallType callType, Variable result, RubySymbol name, Operand receiver, Operand[] args, Operand closure, int flags) { - boolean isPotentiallyRefined = scope.maybeUsingRefinements(); + return create(scope, callType, result, name, receiver, args, closure, flags, false); + } + + // clone() passes the source instruction's flag so a refined call stays refined even + // when it is cloned into a scope without the refinement flag. + private static CallInstr create(IRScope scope, CallType callType, Variable result, RubySymbol name, Operand receiver, + Operand[] args, Operand closure, int flags, boolean potentiallyRefined) { + boolean isPotentiallyRefined = potentiallyRefined || scope.maybeUsingRefinements(); if (!containsArgSplat(args)) { boolean hasClosure = closure != NullBlock.INSTANCE; @@ -121,9 +128,11 @@ public Instr discardResult() { return NoResultCallInstr.create(getCallType(), getName(), getReceiver(), getCallArgs(), getClosureArg(), isPotentiallyRefined()); }*/ + // Shared by the specialized subclasses: create() re-picks the specialization from the cloned operand + // shapes and the effective refinement flag, so a refinements clone drops the fixnum/float fast paths. @Override public Instr clone(CloneInfo ii) { - return new CallInstr(ii.getScope(), getOperation(), getCallType(), ii.getRenamedVariable(result), getName(), + return create(ii.getScope(), getCallType(), ii.getRenamedVariable(result), getName(), getReceiver().cloneForInlining(ii), cloneCallArgs(ii), getClosureArg().cloneForInlining(ii), getFlags(), isPotentiallyRefined() diff --git a/core/src/main/java/org/jruby/ir/instructions/DefineClassInstr.java b/core/src/main/java/org/jruby/ir/instructions/DefineClassInstr.java index a6a73be79eb..e83d4d7b72a 100644 --- a/core/src/main/java/org/jruby/ir/instructions/DefineClassInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/DefineClassInstr.java @@ -42,7 +42,7 @@ public Operand getSuperClass() { @Override public Instr clone(CloneInfo ii) { - return new DefineClassInstr(ii.getRenamedVariable(result), body, + return new DefineClassInstr(ii.getRenamedVariable(result), (IRClassBody) body.cloneForInlining(ii), getContainer().cloneForInlining(ii), getSuperClass().cloneForInlining(ii)); } diff --git a/core/src/main/java/org/jruby/ir/instructions/DefineClassMethodInstr.java b/core/src/main/java/org/jruby/ir/instructions/DefineClassMethodInstr.java index c8076c47757..a2c054aeca2 100644 --- a/core/src/main/java/org/jruby/ir/instructions/DefineClassMethodInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/DefineClassMethodInstr.java @@ -38,7 +38,7 @@ public String[] toStringNonOperandArgs() { @Override public Instr clone(CloneInfo ii) { - return new DefineClassMethodInstr(getContainer().cloneForInlining(ii), method); + return new DefineClassMethodInstr(getContainer().cloneForInlining(ii), method.cloneForInlining(ii)); } @Override diff --git a/core/src/main/java/org/jruby/ir/instructions/DefineInstanceMethodInstr.java b/core/src/main/java/org/jruby/ir/instructions/DefineInstanceMethodInstr.java index cd0377c59aa..1bb46be818c 100644 --- a/core/src/main/java/org/jruby/ir/instructions/DefineInstanceMethodInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/DefineInstanceMethodInstr.java @@ -40,7 +40,7 @@ public IRMethod getMethod() { @Override public Instr clone(CloneInfo ii) { - return new DefineInstanceMethodInstr(method); + return new DefineInstanceMethodInstr(method.cloneForInlining(ii)); } @Override diff --git a/core/src/main/java/org/jruby/ir/instructions/DefineMetaClassInstr.java b/core/src/main/java/org/jruby/ir/instructions/DefineMetaClassInstr.java index cff759e58f4..c8d14c8ee16 100644 --- a/core/src/main/java/org/jruby/ir/instructions/DefineMetaClassInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/DefineMetaClassInstr.java @@ -53,7 +53,8 @@ public String[] toStringNonOperandArgs() { @Override public Instr clone(CloneInfo ii) { - return new DefineMetaClassInstr(ii.getRenamedVariable(result), getObject().cloneForInlining(ii), metaClassBody); + return new DefineMetaClassInstr(ii.getRenamedVariable(result), getObject().cloneForInlining(ii), + metaClassBody.cloneForInlining(ii)); } @Override diff --git a/core/src/main/java/org/jruby/ir/instructions/DefineModuleInstr.java b/core/src/main/java/org/jruby/ir/instructions/DefineModuleInstr.java index a6a2e9c9691..33f3cf49336 100644 --- a/core/src/main/java/org/jruby/ir/instructions/DefineModuleInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/DefineModuleInstr.java @@ -43,7 +43,8 @@ public String[] toStringNonOperandArgs() { @Override public Instr clone(CloneInfo ii) { - return new DefineModuleInstr(ii.getRenamedVariable(result), getContainer().cloneForInlining(ii), body); + return new DefineModuleInstr(ii.getRenamedVariable(result), getContainer().cloneForInlining(ii), + body.cloneForInlining(ii)); } @Override diff --git a/core/src/main/java/org/jruby/ir/instructions/specialized/OneFixnumArgNoBlockCallInstr.java b/core/src/main/java/org/jruby/ir/instructions/specialized/OneFixnumArgNoBlockCallInstr.java index f14f7fa74a7..d1a0dcdc3a7 100644 --- a/core/src/main/java/org/jruby/ir/instructions/specialized/OneFixnumArgNoBlockCallInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/specialized/OneFixnumArgNoBlockCallInstr.java @@ -4,13 +4,11 @@ import org.jruby.ir.IRScope; import org.jruby.ir.Operation; import org.jruby.ir.instructions.CallInstr; -import org.jruby.ir.instructions.Instr; import org.jruby.ir.operands.Fixnum; import org.jruby.ir.operands.NullBlock; import org.jruby.ir.operands.Operand; import org.jruby.ir.operands.Variable; import org.jruby.ir.runtime.IRRuntimeHelpers; -import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.runtime.CallType; import org.jruby.runtime.DynamicScope; @@ -30,12 +28,6 @@ public OneFixnumArgNoBlockCallInstr(IRScope scope, CallType callType, Variable r this.fixNum = ((Fixnum) args[0]).value; } - @Override - public Instr clone(CloneInfo ii) { - return new OneFixnumArgNoBlockCallInstr(ii.getScope(), getCallType(), ii.getRenamedVariable(result), getName(), - getReceiver().cloneForInlining(ii), cloneCallArgs(ii), getFlags(), isPotentiallyRefined()); - } - public long getFixnumArg() { return fixNum; } diff --git a/core/src/main/java/org/jruby/ir/instructions/specialized/OneFloatArgNoBlockCallInstr.java b/core/src/main/java/org/jruby/ir/instructions/specialized/OneFloatArgNoBlockCallInstr.java index 7b37b1122c8..93a0767a686 100644 --- a/core/src/main/java/org/jruby/ir/instructions/specialized/OneFloatArgNoBlockCallInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/specialized/OneFloatArgNoBlockCallInstr.java @@ -4,13 +4,11 @@ import org.jruby.ir.IRScope; import org.jruby.ir.Operation; import org.jruby.ir.instructions.CallInstr; -import org.jruby.ir.instructions.Instr; import org.jruby.ir.operands.Float; import org.jruby.ir.operands.NullBlock; import org.jruby.ir.operands.Operand; import org.jruby.ir.operands.Variable; import org.jruby.ir.runtime.IRRuntimeHelpers; -import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.runtime.CallType; import org.jruby.runtime.DynamicScope; @@ -30,13 +28,6 @@ public OneFloatArgNoBlockCallInstr(IRScope scope, CallType callType, Variable re this.flote = ((Float) args[0]).value; } - @Override - public Instr clone(CloneInfo ii) { - return new OneFloatArgNoBlockCallInstr(ii.getScope(), getCallType(), ii.getRenamedVariable(result), getName(), - getReceiver().cloneForInlining(ii), cloneCallArgs(ii), getFlags(), isPotentiallyRefined() - ); - } - public double getFloatArg() { return flote; } diff --git a/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgBlockCallInstr.java b/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgBlockCallInstr.java index 9994dbc084e..3f13befef07 100644 --- a/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgBlockCallInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgBlockCallInstr.java @@ -4,11 +4,9 @@ import org.jruby.ir.IRScope; import org.jruby.ir.Operation; import org.jruby.ir.instructions.CallInstr; -import org.jruby.ir.instructions.Instr; import org.jruby.ir.operands.Operand; import org.jruby.ir.operands.Variable; import org.jruby.ir.runtime.IRRuntimeHelpers; -import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.runtime.Block; import org.jruby.runtime.CallType; @@ -24,14 +22,6 @@ public OneOperandArgBlockCallInstr(IRScope scope, CallType callType, Variable re super(scope, Operation.CALL_1OB, callType, result, name, receiver, args, closure, flags, isPotentiallyRefined); } - @Override - public Instr clone(CloneInfo ii) { - return new OneOperandArgBlockCallInstr(ii.getScope(), getCallType(), ii.getRenamedVariable(result), getName(), - getReceiver().cloneForInlining(ii), cloneCallArgs(ii), - getClosureArg().cloneForInlining(ii), getFlags(), isPotentiallyRefined() - ); - } - @Override public Object interpret(ThreadContext context, StaticScope currScope, DynamicScope dynamicScope, IRubyObject self, Object[] temp) { // NOTE: This logic shouod always match the CALL_10B logic in InterpreterEngine.processCall diff --git a/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgNoBlockCallInstr.java b/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgNoBlockCallInstr.java index 4b960bfe514..1789b97fef9 100644 --- a/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgNoBlockCallInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/specialized/OneOperandArgNoBlockCallInstr.java @@ -4,12 +4,10 @@ import org.jruby.ir.IRScope; import org.jruby.ir.Operation; import org.jruby.ir.instructions.CallInstr; -import org.jruby.ir.instructions.Instr; import org.jruby.ir.operands.NullBlock; import org.jruby.ir.operands.Operand; import org.jruby.ir.operands.Variable; import org.jruby.ir.runtime.IRRuntimeHelpers; -import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.runtime.CallType; import org.jruby.runtime.DynamicScope; @@ -30,13 +28,6 @@ public OneOperandArgNoBlockCallInstr(IRScope scope, Operation op, CallType callT super(scope, op, callType, result, name, receiver, args, NullBlock.INSTANCE, flags, isPotentiallyRefined); } - @Override - public Instr clone(CloneInfo ii) { - return new OneOperandArgNoBlockCallInstr(ii.getScope(), Operation.CALL_1O, getCallType(), - ii.getRenamedVariable(result), getName(), getReceiver().cloneForInlining(ii), cloneCallArgs(ii), - getFlags(), isPotentiallyRefined()); - } - @Override public Object interpret(ThreadContext context, StaticScope currScope, DynamicScope dynamicScope, IRubyObject self, Object[] temp) { IRubyObject object = (IRubyObject) getReceiver().retrieve(context, self, currScope, dynamicScope, temp); diff --git a/core/src/main/java/org/jruby/ir/instructions/specialized/TwoOperandArgNoBlockCallInstr.java b/core/src/main/java/org/jruby/ir/instructions/specialized/TwoOperandArgNoBlockCallInstr.java index c02a067f27a..2d72cf597d4 100644 --- a/core/src/main/java/org/jruby/ir/instructions/specialized/TwoOperandArgNoBlockCallInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/specialized/TwoOperandArgNoBlockCallInstr.java @@ -4,12 +4,10 @@ import org.jruby.ir.IRScope; import org.jruby.ir.Operation; import org.jruby.ir.instructions.CallInstr; -import org.jruby.ir.instructions.Instr; import org.jruby.ir.operands.NullBlock; import org.jruby.ir.operands.Operand; import org.jruby.ir.operands.Variable; import org.jruby.ir.runtime.IRRuntimeHelpers; -import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.runtime.CallType; import org.jruby.runtime.DynamicScope; @@ -23,13 +21,6 @@ public TwoOperandArgNoBlockCallInstr(IRScope scope, CallType callType, Variable super(scope, Operation.CALL_2O, callType, result, name, receiver, args, NullBlock.INSTANCE, flags, isPotentiallyRefined); } - @Override - public Instr clone(CloneInfo ii) { - return new TwoOperandArgNoBlockCallInstr(ii.getScope(), getCallType(), ii.getRenamedVariable(result), getName(), - getReceiver().cloneForInlining(ii), cloneCallArgs(ii), getFlags(), isPotentiallyRefined() - ); - } - public Operand getArg2() { return operands[2]; } diff --git a/core/src/main/java/org/jruby/ir/instructions/specialized/ZeroOperandArgNoBlockCallInstr.java b/core/src/main/java/org/jruby/ir/instructions/specialized/ZeroOperandArgNoBlockCallInstr.java index 7f370265bb6..e8cc45f7dd4 100644 --- a/core/src/main/java/org/jruby/ir/instructions/specialized/ZeroOperandArgNoBlockCallInstr.java +++ b/core/src/main/java/org/jruby/ir/instructions/specialized/ZeroOperandArgNoBlockCallInstr.java @@ -4,12 +4,10 @@ import org.jruby.ir.IRScope; import org.jruby.ir.Operation; import org.jruby.ir.instructions.CallInstr; -import org.jruby.ir.instructions.Instr; import org.jruby.ir.operands.NullBlock; import org.jruby.ir.operands.Operand; import org.jruby.ir.operands.Variable; import org.jruby.ir.runtime.IRRuntimeHelpers; -import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.parser.StaticScope; import org.jruby.runtime.CallType; import org.jruby.runtime.DynamicScope; @@ -30,12 +28,6 @@ public ZeroOperandArgNoBlockCallInstr(IRScope scope, CallType callType, Variable super(scope, Operation.CALL_0O, callType, result, name, receiver, args, NullBlock.INSTANCE, flags, isPotentiallyRefined); } - @Override - public Instr clone(CloneInfo ii) { - return new ZeroOperandArgNoBlockCallInstr(ii.getScope(), getOperation(), getCallType(), ii.getRenamedVariable(result), getName(), - getReceiver().cloneForInlining(ii), cloneCallArgs(ii), getFlags(), isPotentiallyRefined()); - } - @Override public Object interpret(ThreadContext context, StaticScope currScope, DynamicScope dynamicScope, IRubyObject self, Object[] temp) { IRubyObject object = (IRubyObject) getReceiver().retrieve(context, self, currScope, dynamicScope, temp); diff --git a/core/src/main/java/org/jruby/ir/operands/SymbolProc.java b/core/src/main/java/org/jruby/ir/operands/SymbolProc.java index 99685148bae..e90e8ec8831 100644 --- a/core/src/main/java/org/jruby/ir/operands/SymbolProc.java +++ b/core/src/main/java/org/jruby/ir/operands/SymbolProc.java @@ -5,6 +5,7 @@ import org.jruby.ir.persistence.IRReaderDecoder; import org.jruby.ir.persistence.IRWriterEncoder; import org.jruby.ir.runtime.IRRuntimeHelpers; +import org.jruby.ir.transformations.inlining.CloneInfo; import org.jruby.runtime.ThreadContext; /** @@ -31,6 +32,15 @@ public Object createCacheObject(ThreadContext context) { return IRRuntimeHelpers.newSymbolProc(context, getName()); } + @Override + public Operand cloneForInlining(CloneInfo ii) { + // A baked &:sym caches a *non-refined* symbol proc, so it would ignore the refinements activated on a + // Proc#refined clone. + if (ii.isRefinementsClone()) return new Symbol(name); + + return this; + } + @Override public int hashCode() { return 31 * super.hashCode() + name.hashCode(); diff --git a/core/src/main/java/org/jruby/ir/operands/WrappedIRClosure.java b/core/src/main/java/org/jruby/ir/operands/WrappedIRClosure.java index b3f9374047c..4c44ec3f2f1 100644 --- a/core/src/main/java/org/jruby/ir/operands/WrappedIRClosure.java +++ b/core/src/main/java/org/jruby/ir/operands/WrappedIRClosure.java @@ -61,6 +61,11 @@ public Operand getSimplifiedOperand(Map valueMap, boolean forc @Override public Operand cloneForInlining(CloneInfo info) { + // For Proc#refined we must deep-clone nested closures + if (info.isRefinementsClone()) { + return new WrappedIRClosure(info.getRenamedVariable(self), closure.cloneForInlining(info)); + } + // Making interp instrs so that if JIT hits IRClosure we will not concurrently modify the same IRScope. if (info instanceof SimpleCloneInfo && !((SimpleCloneInfo) info).isEnsureBlockCloneMode()) { // FIXME: It really bothers me we do not clone closure here but cloning like main clone case loses interpContext + other things. diff --git a/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java b/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java index 62dda170f49..1f1893cf50d 100644 --- a/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java +++ b/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java @@ -26,13 +26,24 @@ protected CloneInfo(IRScope scope) { public SimpleCloneInfo cloneForCloningClosure(IRClosure clonedClosure) { // If cloning for ensure block cloning we want to propagate that to child closure clones boolean ensureClone = this instanceof SimpleCloneInfo && ((SimpleCloneInfo) this).isEnsureBlockCloneMode(); - SimpleCloneInfo clone = new SimpleCloneInfo(clonedClosure, ensureClone); + // If cloning for Proc#refined we must propagate that so nested closures are also made refinement-aware + SimpleCloneInfo clone = new SimpleCloneInfo(clonedClosure, ensureClone, false, isRefinementsClone()); clone.variableRenameMap.putAll(variableRenameMap); return clone; } + /** + * Is this clone being performed to produce a refinement-aware copy (Proc#refined)? When true, call-like + * instructions are re-created as refined call sites bound to the clone's static scope. + * + * @return true if this is a refinements clone + */ + public boolean isRefinementsClone() { + return false; + } + /** * @return The IRScope this cloning operation is happening in (or is coming from). */ diff --git a/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java b/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java index e20ecc4a379..d0a20a86c44 100644 --- a/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java +++ b/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java @@ -10,22 +10,34 @@ */ public class SimpleCloneInfo extends CloneInfo { private final boolean isEnsureBlock; + private final boolean refinementsClone; private boolean cloneIPC; - public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock, boolean cloneIPC) { + public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock, boolean cloneIPC, boolean refinementsClone) { super(scope); this.isEnsureBlock = isEnsureBlock; + this.cloneIPC = cloneIPC; + this.refinementsClone = refinementsClone; + } + + public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock, boolean cloneIPC) { + this(scope, isEnsureBlock, cloneIPC, false); } public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock) { - this(scope, isEnsureBlock, false); + this(scope, isEnsureBlock, false, false); } public boolean isEnsureBlockCloneMode() { return this.isEnsureBlock; } + @Override + public boolean isRefinementsClone() { + return this.refinementsClone; + } + public boolean shouldCloneIPC() { return cloneIPC; } diff --git a/core/src/main/java/org/jruby/parser/StaticScope.java b/core/src/main/java/org/jruby/parser/StaticScope.java index 4ae833d2772..d2e7f617030 100644 --- a/core/src/main/java/org/jruby/parser/StaticScope.java +++ b/core/src/main/java/org/jruby/parser/StaticScope.java @@ -56,6 +56,7 @@ import org.jruby.ast.LocalVarNode; import org.jruby.ast.Node; import org.jruby.ast.VCallNode; +import org.jruby.ir.IRClosure; import org.jruby.ir.IRMethod; import org.jruby.ir.IRScope; import org.jruby.ir.IRScopeType; @@ -873,6 +874,21 @@ public boolean isWithinMethod() { return false; } + /** + * Determine if we are lexically within the body of a Proc#refined proc (its refinements clone tree). + * `using` is rejected there: it would diverge the refinement set shared by the memoized clone. Walk the + * enclosing-scope chain, since nested bodies/blocks/evals reach the clone through it. + * @return true if so + */ + public boolean isWithinRefinementsProc() { + for (StaticScope current = this; current != null; current = current.getEnclosingScope()) { + IRScope irScope = current.getIRScope(); + if (irScope instanceof IRClosure && ((IRClosure) irScope).isInRefinementsCloneTree()) return true; + } + + return false; + } + @Override public String toString() { // FIXME: Do we need to persist cref as well? diff --git a/core/src/main/java/org/jruby/runtime/MixedModeIRBlockBody.java b/core/src/main/java/org/jruby/runtime/MixedModeIRBlockBody.java index 6b3c15f15e5..32b5c4f39e9 100644 --- a/core/src/main/java/org/jruby/runtime/MixedModeIRBlockBody.java +++ b/core/src/main/java/org/jruby/runtime/MixedModeIRBlockBody.java @@ -179,6 +179,13 @@ private void promoteToFullBuild(ThreadContext context, boolean force) { ensureInstrsReady(); closure.getNearestTopLocalVariableScope().prepareForCompilation(); + // A Proc#refined clone is grafted under an already-built enclosing scope, so the recursive + // prepareForCompilation above can short-circuit before reaching it. Build the closure's own full IR + // directly in that case so the refinement-aware clone can still be JIT-compiled. + if (closure.getFullInterpreterContext() == null) { + closure.prepareForCompilation(); + } + FullInterpreterContext fic = closure.getFullInterpreterContext(); if (fic == null || !fic.hasExplicitCallProtocol()) { diff --git a/test/jruby/test_proc_refined.rb b/test/jruby/test_proc_refined.rb new file mode 100644 index 00000000000..b55221e9cd9 --- /dev/null +++ b/test/jruby/test_proc_refined.rb @@ -0,0 +1,501 @@ +require 'test/unit' +require 'test/jruby/test_helper' +require 'tempfile' +require 'jruby' + +class TestProcRefined < Test::Unit::TestCase + include TestHelper + module StringRefinement + refine String do + def shout + upcase + "!" + end + end + end + + module IntegerRefinement + refine Integer do + def double + self * 2 + end + end + end + + # A second String#shout refinement with a different result, used to tell which refinement set is active. + module StringRefinement2 + refine String do + def shout + downcase + "?" + end + end + end + + # Refines operators so the specialized call paths (opt_plus / opt_lt / opt_aref equivalents) are exercised. + module Operators + refine Integer do + def +(other) = "plus(#{self},#{other})" + def <(other) = "lt" + end + refine Array do + def [](i) = "at#{i}" + end + end + + # Refines Hash#[] to exercise the string-literal aref fast path (ArrayDerefInstr), which the builder emits + # only in unrefined scopes and which otherwise dispatches straight to the builtin. + module HashAref + refine Hash do + def [](k) = "aref(#{k})" + end + end + + class SuperBase + def greet = "base" + end + + # A refinement whose method calls super to reach the method it refines. + module SuperModule + refine SuperBase do + def greet = "ref-" + super + end + end + + # `using` inside a refined proc's body is rejected: the memoized clone shares refined call sites across + # every proc derived from the same (source, modules) key, which is only sound while all of them run under the + # same refinement set. A `using` mid-body would diverge that set and poison the shared clone. The refinement + # set is fixed at refined() time instead; pass every module in the single refined() call. (MRI parity: the + # same RuntimeError messages are raised by CRuby.) + def test_using_rejected_in_body + # main.using resolves to the top-level main object, so build these procs at the top level. + mod = "TestProcRefined::StringRefinement" + # main.using directly in the body + pr = eval("->(){ using #{mod} }", TOPLEVEL_BINDING).refined(StringRefinement) + assert_raise_with_message(RuntimeError, /main\.using is not permitted in a proc with refinements/) { pr.call } + # main.using in a block nested inside the refined proc + pr = eval("->(){ [1].each { using #{mod} } }", TOPLEVEL_BINDING).refined(StringRefinement) + assert_raise_with_message(RuntimeError, /main\.using is not permitted in a proc with refinements/) { pr.call } + # Module#using in a class/module body opened inside the refined proc (built at the top level so the enclosing + # scope chain does not lexically pass through a method, which would trip the "not permitted in methods" guard). + pr = eval("->(){ Class.new { using #{mod} } }", TOPLEVEL_BINDING).refined(StringRefinement) + assert_raise_with_message(RuntimeError, /Module#using is not permitted in a proc with refinements/) { pr.call } + # Module#using when the refined proc is run via module_eval (self is the module, but the block's scope is + # still the clone, so the walk finds it). + pr = eval("proc { using #{mod} }", TOPLEVEL_BINDING).refined(StringRefinement) + assert_raise_with_message(RuntimeError, /Module#using is not permitted in a proc with refinements/) { Module.new.module_eval(&pr) } + end + + # The refined proc is created from the receiver's real class: it must not inherit the receiver's + # singleton state (CRuby behaves the same), while Proc subclasses are preserved. + def test_singleton_state_not_inherited + pr = ->(s) { s.shout } + def pr.tag = :orig + refined = pr.refined(StringRefinement) + assert_equal(false, refined.respond_to?(:tag)) + assert_equal("HI!", refined.call("hi")) + sub = Class.new(Proc) + sub_refined = sub.new { |s| s.shout }.refined(StringRefinement) + assert_equal(sub, sub_refined.class) + end + + # Refinement activation (refined -> usingModuleRecursive) can run on any thread while another + # thread mutates the same module's refinement map via Module#refine; the activation must + # snapshot the map instead of iterating it unsynchronized (ConcurrentModificationException). + def test_concurrent_refined_and_refine + mod = Module.new { refine(String) { def shout = upcase } } + fillers = Array.new(2) { Module.new { refine(String) { def noop = self } } } + src = ->(s) { s.shout } + writer = Thread.new do + 300.times { mod.module_eval { refine(Class.new) { def x = 1 } } } + end + i = 0 + assert_nothing_raised do + while writer.alive? + # alternate the second module to defeat the single-slot memo and force re-activation + assert_equal("HI", src.refined(mod, fillers[i % 2]).call("hi")) + i += 1 + end + assert_equal("HI", src.refined(mod, fillers[0]).call("hi")) + end + ensure + writer.join + end + + # `refine` (which only defines methods on a refinement module, without activating them in the current scope) + # does not touch the active refinement set, so unlike `using` it is allowed inside a refined proc's body. + def test_refine_allowed_in_body + refined = ->(s) { + Module.new { refine(String) { def whisper = "w" } } + s.shout + }.refined(StringRefinement) + assert_nothing_raised { assert_equal("HI!", refined.call("hi")) } + end + + # class_eval gets its own copy of the refinement scope, so running a refined proc through it must not + # pollute the memoized clone that later derivations reuse. + def test_module_eval_does_not_leak_refinements + body = proc { "ok".shout }.refined(StringRefinement) + assert_equal("OK!", Class.new.class_eval(&body)) + again = proc { "ok".shout }.refined(StringRefinement) + assert_equal("OK!", Class.new.class_eval(&again)) + end + + # Each refined clone is grafted under the SOURCE proc's (shared, long-lived) lexical parent, but it must + # not be registered in that parent's nested-closure / lexical-child lists: doing so would retain every discarded + # clone forever (memory leak) and race with concurrent calls on those unsynchronized/AOT lists. Drive many + # cache-missing clones (distinct module each time) and assert the parent's lists do not grow. + def test_clones_not_retained_by_lexical_parent + prc = ->(s) { s.to_s } + closure = JRuby.reference(prc).get_block.get_body.get_scope + parent = closure.get_lexical_parent + before_closures = parent.get_closures.size + before_children = parent.get_lexical_scopes.size + 200.times do + mod = Module.new { refine(String) { def to_s; "x"; end } } + prc.refined(mod).call("hi") + end + assert_operator parent.get_closures.size - before_closures, :<=, 1, + "refined clones accumulated in the source's parent nestedClosures" + assert_operator parent.get_lexical_scopes.size - before_children, :<=, 1, + "refined clones accumulated in the source's parent lexicalChildren" + end + + # A /o (once) regexp literal interpolating a refined-method call is built under the refinement, and the clone's + # once cache is independent of the source proc's. + def test_once_regexp + refined = ->(s) { /\A#{s.shout}\z/o }.refined(StringRefinement) + r1 = refined.call("ab") + assert_equal('\AAB!\z', r1.source) + assert_same(r1, refined.call("zz")) # /o caches the first regexp on the clone's own once entry + # the original proc has no refinement, so building the regexp raises + assert_raise(NoMethodError) { ->(s) { /\A#{s.shout}\z/o }.call("ab") } + end + + # A proc created lexically INSIDE a refined proc is not itself "a proc that already has refinements": it + # only inherits the enclosing refinements lexically. refined (and define_method) must therefore be + # accepted on it, and the inner proc must see both the enclosing refinement and the one it adds. Only the proc + # handed back by refined is rejected for chaining. + def test_nested_proc_inside_refined_proc_is_not_a_chain + result = -> { + inner = ->(s, n) { [s.shout, n.double] } + inner.refined(StringRefinement).call("hi", 3) + }.refined(IntegerRefinement).call + # StringRefinement is added on the inner clone; IntegerRefinement is inherited from the enclosing refined proc. + assert_equal ["HI!", 6], result + + # define_method from such a nested proc is allowed (it is not the refined result itself). + assert_nothing_raised do + -> { Class.new { define_method(:m, ->(s) { s }) } }.refined(IntegerRefinement).call + end + end + + # An exception raised through a refined clone whose source block was created at top level (so the captured + # frame has no method name) must still build a backtrace -- the clone always runs interpreted, and a null frame name + # used to crash backtrace construction with a Java NullPointerException instead of surfacing the Ruby exception. + def test_exception_backtrace_through_top_level_clone + refined = -> { raise "boom" }.refined(StringRefinement) + err = assert_raise(RuntimeError) { refined.call } + assert_equal "boom", err.message + assert_match(/block in /, err.backtrace.first) + end + + # The refined clone must work when reached through the block-yield path (each(&proc)), not just #call. + def test_yield_path + forwarded = [] + rr = ->(s) { forwarded << s.shout }.refined(StringRefinement) + %w[p q].each(&rr) + assert_equal(["P!", "Q!"], forwarded) + end + + # The refinement must be carried when the clone is reached through the generic C invocation paths + # (Method#call, Fiber, Thread) rather than a direct optimized Proc#call. + def test_c_call_paths + refined = ->(s) { s.shout }.refined(StringRefinement) + assert_equal("A!", refined.send(:call, "a")) + assert_equal("B!", refined.method(:call).call("b")) + assert_equal("C!", Fiber.new(&refined).resume("c")) + assert_equal("D!", Thread.new("d", &refined).value) + end + + # Repeating the same (proc, modules) reuses the cached clone but stays correct; a different module set must + # not return the previously cached clone, and switching back is still correct. + def test_memoized + orig = ->(s) { s.shout } + assert_equal("HI!", orig.refined(StringRefinement).call("hi")) + assert_equal("HI!", orig.refined(StringRefinement).call("hi")) + assert_equal("hi?", orig.refined(StringRefinement2).call("hi")) + assert_equal("HI!", orig.refined(StringRefinement).call("hi")) + end + + # Run the block with performance warnings enabled and $stderr captured; return the captured output. + def capture_performance_warnings + require 'stringio' + old = Warning[:performance] + Warning[:performance] = true + stderr, $stderr = $stderr, StringIO.new + begin + yield + $stderr.string + ensure + $stderr = stderr + Warning[:performance] = old + end + end + + # A differing module set overwrites the single memo slot and emits a performance-category + # warning, as in CRuby. + def test_memo_different_modules_warning + orig = ->(s) { s.shout } + orig.refined(StringRefinement).call("hi") + out = capture_performance_warnings do + orig.refined(StringRefinement2).call("hi") + end + assert_match(/Proc#refined called with different modules for the same block disables memoization/, out) + end + + # Proc#ruby2_keywords marks the shared block scope, possibly after a clone was memoized. The stale + # memo is rebuilt (with a warning naming the cause) rather than reused or mutated: the new proc + # delegates keywords like its source, while procs built before the mark keep their creation-time + # behavior. Mirrors CRuby's test_refined_ruby2_keywords_memo. + def test_memo_ruby2_keywords + target = ->(a, k: nil) { [a, k] } + pr = proc { |*args| target.call(*args) } + q1 = pr.refined(StringRefinement) # memoize a clone before the mark + pr.ruby2_keywords + assert_equal([1, 2], pr.call(1, k: 2)) + out = capture_performance_warnings do + q2 = pr.refined(StringRefinement) + assert_equal([1, 2], q2.call(1, k: 2)) + # the clone made before the mark is not retroactively changed + assert_raise(ArgumentError) { q1.call(1, k: 2) } + # the rebuilt memo is hit from now on; no further warnings + assert_equal([1, 2], pr.refined(StringRefinement).call(1, k: 2)) + end + assert_match(/Proc#refined re-copies the block because the ruby2_keywords flag changed after the copy was memoized/, out) + assert_equal(1, out.scan(/ruby2_keywords flag changed/).size) + end + + # Procs sharing the same source block but capturing different closure environments hit the same cache slot + # (the environment is not part of the key), yet each clone must keep its own captured environment. + def test_memo_distinct_environments + factory = ->(tag) { ->(s) { "#{tag}:#{s.shout}" } } + p1 = factory.call("A") + p2 = factory.call("B") + r1 = p1.refined(StringRefinement) + r2 = p2.refined(StringRefinement) + assert_equal("A:X!", r1.call("x")) + assert_equal("B:Y!", r2.call("y")) + assert_equal("A:X!", r1.call("x")) + end + + # The clone must reproduce the begin/rescue/ensure control flow (copied exception regions, shared locals). + def test_rescue_ensure + refined = ->(s) { + r = nil + begin + r = s.shout + rescue NoMethodError + r = "rescued" + ensure + r = "#{r}." + end + r + }.refined(StringRefinement) + assert_equal("YO!.", refined.call("yo")) + end + + # Keyword and optional arguments must be reproduced on the clone. + def test_keyword_and_optional_args + refined = ->(a, b = "z", c:, d: "w") { + [a, b, c, d].map { |x| x.shout }.join + }.refined(StringRefinement) + assert_equal("A!Z!C!W!", refined.call("a", c: "c")) + assert_equal("A!B!C!D!", refined.call("a", "b", c: "c", d: "d")) + end + + # Literal when-clauses compile to a jump table that must survive the clone. + def test_case_when_literal + refined = ->(s) { + case s.shout + when "A!" then 1 + when "B!" then 2 + else 0 + end + }.refined(StringRefinement) + assert_equal(1, refined.call("a")) + assert_equal(2, refined.call("b")) + assert_equal(0, refined.call("c")) + end + + # A flip-flop keeps state keyed off the local scope; the clone must run its own flip state, and a second call + # starts from a fresh state. + def test_flip_flop + body = ->(arr) { + out = [] + arr.each { |i| out << i if (i == 2)..(i == 4) } + out + } + refined = body.refined(StringRefinement) + assert_equal([2, 3, 4], refined.call([1, 2, 3, 4, 5])) + assert_equal([2, 3, 4], refined.call([1, 2, 3, 4, 5])) + end + + # The clone reports the same source location, parameters and arity as the source proc. + def test_preserves_location_and_parameters + orig = ->(a, b = 1, *c, d:, **e, &f) { a } + refined = orig.refined(StringRefinement) + assert_equal(orig.source_location, refined.source_location) + assert_equal(orig.parameters, refined.parameters) + assert_equal(orig.arity, refined.arity) + end + + # Operator refinements must apply on the clone (specialized call paths) without leaking into the originals. + def test_operators + refined = ->(a, b) { [a + b, a < b] }.refined(Operators) + assert_equal(["plus(1,2)", "lt"], refined.call(1, 2)) + aref = ->(a) { a[0] }.refined(Operators) + assert_equal("at0", aref.call([9])) + assert_equal(3, ->(a, b) { a + b }.call(1, 2)) + end + + # A string-literal Hash aref compiles to the optimized ArrayDerefInstr, which bypasses refinements; the + # Proc#refined clone must fall back to a normal refined call so the refinement still applies (matching CRuby + # and a fresh build under `using`). + def test_optimized_aref + refined = ->(h) { h["x"] }.refined(HashAref) + assert_equal("aref(x)", refined.call({ "x" => 1 })) + # the original proc keeps the builtin fast path + assert_equal(1, ->(h) { h["x"] }.call({ "x" => 1 })) + end + + # A refined method may call super to reach the method it refines. + def test_super + refined = ->(o) { o.greet }.refined(SuperModule) + assert_equal("ref-base", refined.call(SuperBase.new)) + end + + # The clone shares the source proc's environment, so a recursive call through a captured local reaches the + # refined clone again and keeps the refinements. + def test_recursion_sees_refinements + fact = nil + fact = ->(s) { s.empty? ? "" : s[0].shout + fact.call(s[1..]) }.refined(StringRefinement) + assert_equal("A!B!C!", fact.call("abc")) + end + + # Many independently created refined clones must all survive GC and keep working. + def test_gc + procs = 100.times.map { ->(s) { s.shout }.refined(StringRefinement) } + JRuby.gc rescue GC.start + procs.each { |pr| assert_equal("A!", pr.call("a")) } + end + + # A Symbol#to_proc (&:shout) evaluated inside a refined clone resolves the method under the clone's refinements. + # A baked &:sym caches a non-refined symbol proc; the clone rewrites it to a plain symbol so the (now refined) + # call site converts it through Symbol#toRefinedProc with the clone's refinement scope. + def test_symbol_to_proc_sees_refinements + refined = ->(arr) { arr.map(&:shout) }.refined(StringRefinement) + assert_equal(["A!", "B!"], refined.call(["a", "b"])) + # the original proc has no refinement, so the symbol proc raises + assert_raise(NoMethodError) { ->(arr) { arr.map(&:shout) }.call(["a"]) } + end + + # A literal `def` inside the block captures the clone's refinements (like a def inside a `using` scope), so the + # refinement also applies when that method is called later. Covers both a singleton def and an instance def. + def test_def_in_block_sees_refinements + refined = ->(s) { + o = Object.new + def o.m = "hi".shout + [s.shout, o.m] + }.refined(StringRefinement) + assert_equal(["YO!", "HI!"], refined.call("yo")) + + inst = ->(s) { + k = Class.new { def m; "hi".shout; end } + [s.shout, k.new.m] + }.refined(StringRefinement) + assert_equal(["YO!", "HI!"], inst.call("yo")) + + # the original proc's def must NOT see refinements + assert_raise(NoMethodError) do + ->(s) { o = Object.new; def o.m = "hi".shout; o.m }.call("yo") + end + end + + # A class/module (re)opened with a `class`/`module` keyword inside a refined proc -- as opposed to via a + # class_eval/Class.new block -- compiles to its own class-body scope, which must also become refinement-aware + # so the body and any def inside it see the refinements (matching a body opened under a `using` scope). + # + # The `class`/`module` keyword is a syntax error inside a method body (even within a block there), so these + # procs are built at class-body scope (block bodies, which the keyword is legal in) rather than inside the + # test methods. + class ReopenTarget; end + module ReopenModule; end + + # reopen an existing class + REOPEN_CLASS = ->(s) { + class ReopenTarget + def m = "hi".shout + end + [s.shout, ReopenTarget.new.m] + } + # define a brand-new class with a superclass, plus a module method, plus class << self + REOPEN_VARIANTS = ->(s) { + class ReopenSub < ReopenTarget + def m = "hi".shout + end + module ReopenModule + def self.m = "hi".shout + end + class ReopenTarget + class << self + def n = "hi".shout + end + end + [s.shout, ReopenSub.new.m, ReopenModule.m, ReopenTarget.n] + } + # an otherwise identical proc whose class body must NOT see refinements when called without refined + REOPEN_UNREFINED = ->(s) { + class ReopenTarget + def unrefined = "hi".shout + end + ReopenTarget.new.unrefined + } + + def test_class_keyword_body_sees_refinements + refined = REOPEN_CLASS.refined(StringRefinement) + assert_equal(["YO!", "HI!"], refined.call("yo")) + + variants = REOPEN_VARIANTS.refined(StringRefinement) + assert_equal(["YO!", "HI!", "HI!", "HI!"], variants.call("yo")) + + # the original proc's class body must NOT see refinements + assert_raise(NoMethodError) { REOPEN_UNREFINED.call("yo") } + end + + # A refinement-aware clone is grafted under an already-built enclosing scope, so it needs its own full IR + # built before it can be JIT-compiled. Run in a subprocess with a low JIT threshold and synchronous (non + # background) compilation so both the original and the clone cross the threshold deterministically. We + # assert two things: the refinement still produces correct results after JIT (and the original is + # unaffected), and -- crucially for guarding the fix -- that the clone did not hit "JIT failed". Without + # the fix the clone cannot build its full IR and falls back to the interpreter, which is still correct, so + # the JIT log is what distinguishes a working fix from a silent regression. + def test_refinement_survives_jit + script = <<~'RUBY' + module R + refine(String) { def upcase; "REFINED"; end } + end + prc = ->(s) { s.upcase } + 1000.times { exit(1) unless prc.call("hi") == "HI" } # JIT the original (bare upcase) first + refined = prc.refined(R) + 1000.times { exit(2) unless refined.call("hi") == "REFINED" } # drive the clone past the threshold + exit(3) unless prc.call("hi") == "HI" # original must stay unaffected post-JIT + print "OK" + RUBY + Tempfile.create(['jit_refine', '.rb']) do |f| + f.write(script) + f.flush + out = jruby("-Xjit.threshold=10 -Xjit.background=false -Xjit.logging #{f.path} 2>&1") + assert_include out, "OK", "refinement-aware proc produced wrong results under JIT" + assert_not_include out, "JIT failed", "refinement-aware clone could not be JIT-compiled" + end + end +end From d68eeb4703428bb0c1f89cd2201f2f62d791fbd1 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Fri, 17 Jul 2026 14:17:57 -0700 Subject: [PATCH 02/33] Remove deprecated ObjectSpace#_id2ref Officially removed for Ruby 4.1. See https://bugs.ruby-lang.org/issues/22135 --- .../main/java/org/jruby/RubyObjectSpace.java | 41 ------------------- .../java/org/jruby/runtime/ObjectSpace.java | 21 +--------- .../runtime/ivars/VariableTableManager.java | 11 +---- .../java/org/jruby/test/TestObjectSpace.java | 19 --------- test/jruby/test_objectspace.rb | 32 --------------- 5 files changed, 2 insertions(+), 122 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyObjectSpace.java b/core/src/main/java/org/jruby/RubyObjectSpace.java index bb258bde528..3c326e93759 100644 --- a/core/src/main/java/org/jruby/RubyObjectSpace.java +++ b/core/src/main/java/org/jruby/RubyObjectSpace.java @@ -133,47 +133,6 @@ public static IRubyObject undefine_finalizer(ThreadContext context, IRubyObject return recv; } - @Deprecated(since = "10.0.0.0") - public static IRubyObject id2ref(IRubyObject recv, IRubyObject id) { - return id2ref(((RubyBasicObject) recv).getCurrentContext(), recv, id); - } - - @Deprecated(since = "10.1.0.0") - @JRubyMethod(name = "_id2ref", module = true, visibility = PRIVATE) - public static IRubyObject id2ref(ThreadContext context, IRubyObject recv, IRubyObject id) { - Warn.warnDeprecated(context, "ObjectSpace._id2ref is deprecated"); - - long longId = castAsFixnum(context, id).getValue(); - if (longId == 0) return context.fals; - if (longId == 20) return context.tru; - if (longId == 8) return context.nil; - if ((longId & 0b01) == 0b01) return asFixnum(context, (longId - 1) / 2); // fixnum - if ((longId & 0b11) == 0b10) { - // flonum - double d = 0.0; - if (longId != 0x8000000000000002L) { - long b63 = (longId >>> 63); - /* e: xx1... -> 011... */ - /* xx0... -> 100... */ - /* ^b63 */ - long longBits = Numeric.rotr((2 - b63) | (longId & ~0x03), 3); - d = Double.longBitsToDouble(longBits); - } - return asFloat(context, d); - } else { - if (context.runtime.isObjectSpaceEnabled()) { - IRubyObject object = context.runtime.getObjectSpace().id2ref(longId); - if (object == null) { - return context.nil; - } - return object; - } else { - warn(context, "ObjectSpace is disabled; _id2ref only supports immediates, pass -X+O to enable"); - throw rangeError(context, String.format("0x%016x is not id value", longId)); - } - } - } - public static IRubyObject each_objectInternal(final ThreadContext context, IRubyObject recv, IRubyObject[] args, final Block block) { final Ruby runtime = context.runtime; final RubyModule rubyClass; diff --git a/core/src/main/java/org/jruby/runtime/ObjectSpace.java b/core/src/main/java/org/jruby/runtime/ObjectSpace.java index 3e2db23e317..bc9f609ae34 100644 --- a/core/src/main/java/org/jruby/runtime/ObjectSpace.java +++ b/core/src/main/java/org/jruby/runtime/ObjectSpace.java @@ -47,7 +47,6 @@ import org.jruby.java.proxies.JavaProxy; import org.jruby.javasupport.JavaPackage; import org.jruby.runtime.builtin.IRubyObject; -import org.jruby.util.WeakIdentityHashMap; /** * FIXME: This version is faster than the previous, but both suffer from a @@ -64,7 +63,6 @@ public class ObjectSpace { private final ReferenceQueue deadIdentityReferences = new ReferenceQueue(); private final Map identities = new HashMap<>(64); - private final Map identitiesByObject = new WeakIdentityHashMap(64); private static final AtomicLong maxId = new AtomicLong(1000); private final ThreadLocal> currentObjectGroup = new ThreadLocal<>(); private Reference groupSweeperReference; @@ -73,31 +71,14 @@ public void registerObjectId(long id, IRubyObject object) { synchronized (identities) { cleanIdentities(); identities.put(id, new IdReference(object, id, deadIdentityReferences)); - identitiesByObject.put(object, id); } } - public static long calculateObjectId(Object object) { + public static long calculateObjectId() { // Fixnums get all the 0b01 id's, flonums get the 0b10 id's, so we use next ID * 4 return maxId.getAndIncrement() * 4; } - public long createAndRegisterObjectId(IRubyObject rubyObject) { - synchronized (identities) { - Long longId = identitiesByObject.get(rubyObject); - if (longId == null) { - longId = createId(rubyObject); - } - return longId.longValue(); - } - } - - private long createId(IRubyObject object) { - long id = calculateObjectId(object); - registerObjectId(id, object); - return id; - } - public IRubyObject id2ref(long id) { synchronized (identities) { cleanIdentities(); diff --git a/core/src/main/java/org/jruby/runtime/ivars/VariableTableManager.java b/core/src/main/java/org/jruby/runtime/ivars/VariableTableManager.java index dd0e1a0a23a..36e11ffaa00 100644 --- a/core/src/main/java/org/jruby/runtime/ivars/VariableTableManager.java +++ b/core/src/main/java/org/jruby/runtime/ivars/VariableTableManager.java @@ -32,13 +32,11 @@ import java.io.ObjectOutputStream; import java.lang.invoke.MethodHandle; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Function; -import org.jruby.Ruby; import org.jruby.RubyBasicObject; import org.jruby.RubyClass; import org.jruby.java.codegen.Reified; @@ -575,14 +573,7 @@ public VariableTableManager duplicateForData(RubyClass newRealClass) { * @return The generated object ID */ private long initObjectId(RubyBasicObject self, VariableAccessor objectIdAccessor) { - Ruby runtime = self.getRuntime(); - long id; - - if (runtime.isObjectSpaceEnabled()) { - id = runtime.getObjectSpace().createAndRegisterObjectId(self); - } else { - id = ObjectSpace.calculateObjectId(self); - } + long id = ObjectSpace.calculateObjectId(); // we use a direct path here to avoid frozen checks setObjectId(realClass, self, objectIdAccessor.getIndex(), id); diff --git a/core/src/test/java/org/jruby/test/TestObjectSpace.java b/core/src/test/java/org/jruby/test/TestObjectSpace.java index 4cf1915560b..e27b20f3069 100644 --- a/core/src/test/java/org/jruby/test/TestObjectSpace.java +++ b/core/src/test/java/org/jruby/test/TestObjectSpace.java @@ -38,7 +38,6 @@ import junit.framework.TestCase; import org.jruby.Ruby; -import org.jruby.RubyString; import org.jruby.api.Create; import org.jruby.runtime.ObjectSpace; import org.jruby.runtime.ThreadContext; @@ -46,7 +45,6 @@ import static org.jruby.api.Access.arrayClass; import static org.jruby.api.Access.stringClass; -import static org.jruby.api.Create.newArray; import static org.jruby.api.Create.newString; /** @@ -67,23 +65,6 @@ public void setUp() throws Exception { target = new ObjectSpace(); } - public void testIdentities() { - RubyString o1 = newString(context, "hey"); - RubyString o2 = newString(context, "ho"); - - long id1 = target.createAndRegisterObjectId(o1); - long id2 = target.createAndRegisterObjectId(o2); - - assertEquals("id of normal objects must be even", 0, id1 % 2); - assertEquals("id of normal objects must be even", 0, id2 % 2); - assertTrue("normal ids must be bigger than reserved values", id1 > 4); - assertTrue("normal ids must be bigger than reserved values", id2 > 4); - - assertSame(o1, target.id2ref(id1)); - assertSame(o2, target.id2ref(id2)); - assertNull(target.id2ref(4711)); - } - public void testObjectSpace() { IRubyObject o1 = Create.allocArray(context, 10); IRubyObject o2 = Create.allocArray(context, 20); diff --git a/test/jruby/test_objectspace.rb b/test/jruby/test_objectspace.rb index 579c543b2cd..2d2b79f0c32 100644 --- a/test/jruby/test_objectspace.rb +++ b/test/jruby/test_objectspace.rb @@ -12,38 +12,6 @@ def teardown JRuby.objectspace = @objectspace end - def test_id2ref - # Normal objects - o1 = "hey" - o2 = "ho" - id1 = o1.object_id - id2 = o2.object_id - assert_equal(o1, ObjectSpace._id2ref(id1)) - assert_equal(o2, ObjectSpace._id2ref(id2)) - - # Fixnums - o1 = 17 - o2 = 100001 - id1 = o1.object_id - id2 = o2.object_id - assert_equal(o1, ObjectSpace._id2ref(id1)) - assert_equal(o2, ObjectSpace._id2ref(id2)) - - assert_equal(1, 0.object_id) - assert_equal(3, 1.object_id) - assert_equal(201, 100.object_id) - assert_equal(-1, -1.object_id) - assert_equal(-19, -10.object_id) - - assert_equal(0, false.object_id) - assert_equal(20, true.object_id) - assert_equal(8, nil.object_id) - - assert_equal(false, ObjectSpace._id2ref(0)) - assert_equal(true, ObjectSpace._id2ref(20)) - assert_equal(nil, ObjectSpace._id2ref(8)) - end - def test_jruby_objectspace JRuby.objectspace = false assert_equal(false, JRuby.objectspace) From 87accb20e90951658cbf7c7305ac3aa7db6ea660 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Fri, 17 Jul 2026 14:41:51 -0700 Subject: [PATCH 03/33] Bump version to 4.1 for _id2ref spec guard --- core/pom.xml | 4 +- default.build.properties | 4 +- lib/pom.xml | 168 +++++++++++++++++++-------------------- 3 files changed, 88 insertions(+), 88 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index 2b75c20fcc0..798190a55f0 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -55,8 +55,8 @@ DO NOT MODIFY - GENERATED CODE ${build.dir}/test-results provided 2019c - 4.0.0 - 4.0 + 4.1.0 + 4.1 0 diff --git a/default.build.properties b/default.build.properties index 110dc39d37b..c2a5b7583e4 100644 --- a/default.build.properties +++ b/default.build.properties @@ -28,7 +28,7 @@ rake.args= install4j.executable=/Applications/install4j9/bin/install4jc # Ruby versions -version.ruby=4.0.0 -version.ruby.major=4.0 +version.ruby=4.1.0 +version.ruby.major=4.1 version.ruby.minor=0 diff --git a/lib/pom.xml b/lib/pom.xml index 1ca4123741b..af7be2685a4 100644 --- a/lib/pom.xml +++ b/lib/pom.xml @@ -1396,90 +1396,90 @@ DO NOT MODIFY - GENERATED CODE cache/rss-0.3.2* cache/syslog-0.4.0* cache/test-unit-3.7.5* - extensions/universal-java/4.0.0/rubygems-update-4.0.3/* - extensions/universal-java/4.0.0/bundler-4.0.3/* - extensions/universal-java/4.0.0/date-3.5.1/* - extensions/universal-java/4.0.0/delegate-0.6.1/* - extensions/universal-java/4.0.0/did_you_mean-2.0.0/* - extensions/universal-java/4.0.0/digest-3.2.1/* - extensions/universal-java/4.0.0/english-0.8.1/* - extensions/universal-java/4.0.0/erb-6.0.1.1/* - extensions/universal-java/4.0.0/error_highlight-0.7.1/* - extensions/universal-java/4.0.0/ffi-1.17.0/* - extensions/universal-java/4.0.0/fiddle-1.1.8/* - extensions/universal-java/4.0.0/fileutils-1.8.0/* - extensions/universal-java/4.0.0/find-0.2.0/* - extensions/universal-java/4.0.0/forwardable-1.4.0/* - extensions/universal-java/4.0.0/io-console-0.8.2/* - extensions/universal-java/4.0.0/io-wait-0.4.0/* - extensions/universal-java/4.0.0/ipaddr-1.2.8/* - extensions/universal-java/4.0.0/jar-dependencies-0.5.7/* - extensions/universal-java/4.0.0/jruby-readline-1.3.7/* - extensions/universal-java/4.0.0/jruby-openssl-0.16.1/* - extensions/universal-java/4.0.0/json-2.18.0/* - extensions/universal-java/4.0.0/net-http-0.9.1/* - extensions/universal-java/4.0.0/net-protocol-0.2.2/* - extensions/universal-java/4.0.0/open-uri-0.5.0/* - extensions/universal-java/4.0.0/open3-0.2.1/* - extensions/universal-java/4.0.0/optparse-0.8.1/* - extensions/universal-java/4.0.0/pp-0.6.3/* - extensions/universal-java/4.0.0/prettyprint-0.2.0/* - extensions/universal-java/4.0.0/psych-5.3.1/* - extensions/universal-java/4.0.0/rake-ant-1.0.6/* - extensions/universal-java/4.0.0/resolv-0.7.0/* - extensions/universal-java/4.0.0/ruby2_keywords-0.0.5/* - extensions/universal-java/4.0.0/securerandom-0.4.1/* - extensions/universal-java/4.0.0/shellwords-0.2.2/* - extensions/universal-java/4.0.0/singleton-0.3.0/* - extensions/universal-java/4.0.0/stringio-3.2.0/* - extensions/universal-java/4.0.0/strscan-3.1.6/* - extensions/universal-java/4.0.0/subspawn-0.1.1/* - extensions/universal-java/4.0.0/subspawn-posix-0.1.1/* - extensions/universal-java/4.0.0/ffi-binary-libfixposix-0.5.1.1/* - extensions/universal-java/4.0.0/ffi-bindings-libfixposix-0.5.1.0/* - extensions/universal-java/4.0.0/syntax_suggest-2.0.2/* - extensions/universal-java/4.0.0/tempfile-0.3.1/* - extensions/universal-java/4.0.0/time-0.4.2/* - extensions/universal-java/4.0.0/timeout-0.6.0/* - extensions/universal-java/4.0.0/tsort-0.2.0/* - extensions/universal-java/4.0.0/un-0.3.0/* - extensions/universal-java/4.0.0/uri-1.1.1/* - extensions/universal-java/4.0.0/weakref-0.1.4/* - extensions/universal-java/4.0.0/win32-registry-0.1.1/* - extensions/universal-java/4.0.0/yaml-0.4.0/* - extensions/universal-java/4.0.0/abbrev-0.1.2/* - extensions/universal-java/4.0.0/base64-0.3.0/* - extensions/universal-java/4.0.0/benchmark-0.5.0/* - extensions/universal-java/4.0.0/bigdecimal-4.0.1/* - extensions/universal-java/4.0.0/csv-3.3.5/* - extensions/universal-java/4.0.0/debug-0.2.1/* - extensions/universal-java/4.0.0/drb-2.2.3/* - extensions/universal-java/4.0.0/getoptlong-0.2.1/* - extensions/universal-java/4.0.0/irb-1.16.0/* - extensions/universal-java/4.0.0/logger-1.7.0/* - extensions/universal-java/4.0.0/matrix-0.4.3/* - extensions/universal-java/4.0.0/minitest-6.0.0/* - extensions/universal-java/4.0.0/mutex_m-0.3.0/* - extensions/universal-java/4.0.0/net-ftp-0.3.9/* - extensions/universal-java/4.0.0/net-imap-0.6.2/* - extensions/universal-java/4.0.0/net-pop-0.1.2/* - extensions/universal-java/4.0.0/net-smtp-0.5.1/* - extensions/universal-java/4.0.0/nkf-0.2.0/* - extensions/universal-java/4.0.0/observer-0.1.2/* - extensions/universal-java/4.0.0/ostruct-0.6.3/* - extensions/universal-java/4.0.0/power_assert-3.0.1/* - extensions/universal-java/4.0.0/prime-0.1.4/* - extensions/universal-java/4.0.0/pstore-0.2.0/* - extensions/universal-java/4.0.0/racc-1.8.1/* - extensions/universal-java/4.0.0/rake-${rake.version}/* - extensions/universal-java/4.0.0/rdoc-7.0.3/* - extensions/universal-java/4.0.0/reline-0.6.3/* - extensions/universal-java/4.0.0/resolv-replace-0.1.1/* - extensions/universal-java/4.0.0/rexml-3.4.4/* - extensions/universal-java/4.0.0/rinda-0.2.0/* - extensions/universal-java/4.0.0/rss-0.3.2/* - extensions/universal-java/4.0.0/syslog-0.4.0/* - extensions/universal-java/4.0.0/test-unit-3.7.5/* + extensions/universal-java/4.1.0/rubygems-update-4.0.3/* + extensions/universal-java/4.1.0/bundler-4.0.3/* + extensions/universal-java/4.1.0/date-3.5.1/* + extensions/universal-java/4.1.0/delegate-0.6.1/* + extensions/universal-java/4.1.0/did_you_mean-2.0.0/* + extensions/universal-java/4.1.0/digest-3.2.1/* + extensions/universal-java/4.1.0/english-0.8.1/* + extensions/universal-java/4.1.0/erb-6.0.1.1/* + extensions/universal-java/4.1.0/error_highlight-0.7.1/* + extensions/universal-java/4.1.0/ffi-1.17.0/* + extensions/universal-java/4.1.0/fiddle-1.1.8/* + extensions/universal-java/4.1.0/fileutils-1.8.0/* + extensions/universal-java/4.1.0/find-0.2.0/* + extensions/universal-java/4.1.0/forwardable-1.4.0/* + extensions/universal-java/4.1.0/io-console-0.8.2/* + extensions/universal-java/4.1.0/io-wait-0.4.0/* + extensions/universal-java/4.1.0/ipaddr-1.2.8/* + extensions/universal-java/4.1.0/jar-dependencies-0.5.7/* + extensions/universal-java/4.1.0/jruby-readline-1.3.7/* + extensions/universal-java/4.1.0/jruby-openssl-0.16.1/* + extensions/universal-java/4.1.0/json-2.18.0/* + extensions/universal-java/4.1.0/net-http-0.9.1/* + extensions/universal-java/4.1.0/net-protocol-0.2.2/* + extensions/universal-java/4.1.0/open-uri-0.5.0/* + extensions/universal-java/4.1.0/open3-0.2.1/* + extensions/universal-java/4.1.0/optparse-0.8.1/* + extensions/universal-java/4.1.0/pp-0.6.3/* + extensions/universal-java/4.1.0/prettyprint-0.2.0/* + extensions/universal-java/4.1.0/psych-5.3.1/* + extensions/universal-java/4.1.0/rake-ant-1.0.6/* + extensions/universal-java/4.1.0/resolv-0.7.0/* + extensions/universal-java/4.1.0/ruby2_keywords-0.0.5/* + extensions/universal-java/4.1.0/securerandom-0.4.1/* + extensions/universal-java/4.1.0/shellwords-0.2.2/* + extensions/universal-java/4.1.0/singleton-0.3.0/* + extensions/universal-java/4.1.0/stringio-3.2.0/* + extensions/universal-java/4.1.0/strscan-3.1.6/* + extensions/universal-java/4.1.0/subspawn-0.1.1/* + extensions/universal-java/4.1.0/subspawn-posix-0.1.1/* + extensions/universal-java/4.1.0/ffi-binary-libfixposix-0.5.1.1/* + extensions/universal-java/4.1.0/ffi-bindings-libfixposix-0.5.1.0/* + extensions/universal-java/4.1.0/syntax_suggest-2.0.2/* + extensions/universal-java/4.1.0/tempfile-0.3.1/* + extensions/universal-java/4.1.0/time-0.4.2/* + extensions/universal-java/4.1.0/timeout-0.6.0/* + extensions/universal-java/4.1.0/tsort-0.2.0/* + extensions/universal-java/4.1.0/un-0.3.0/* + extensions/universal-java/4.1.0/uri-1.1.1/* + extensions/universal-java/4.1.0/weakref-0.1.4/* + extensions/universal-java/4.1.0/win32-registry-0.1.1/* + extensions/universal-java/4.1.0/yaml-0.4.0/* + extensions/universal-java/4.1.0/abbrev-0.1.2/* + extensions/universal-java/4.1.0/base64-0.3.0/* + extensions/universal-java/4.1.0/benchmark-0.5.0/* + extensions/universal-java/4.1.0/bigdecimal-4.0.1/* + extensions/universal-java/4.1.0/csv-3.3.5/* + extensions/universal-java/4.1.0/debug-0.2.1/* + extensions/universal-java/4.1.0/drb-2.2.3/* + extensions/universal-java/4.1.0/getoptlong-0.2.1/* + extensions/universal-java/4.1.0/irb-1.16.0/* + extensions/universal-java/4.1.0/logger-1.7.0/* + extensions/universal-java/4.1.0/matrix-0.4.3/* + extensions/universal-java/4.1.0/minitest-6.0.0/* + extensions/universal-java/4.1.0/mutex_m-0.3.0/* + extensions/universal-java/4.1.0/net-ftp-0.3.9/* + extensions/universal-java/4.1.0/net-imap-0.6.2/* + extensions/universal-java/4.1.0/net-pop-0.1.2/* + extensions/universal-java/4.1.0/net-smtp-0.5.1/* + extensions/universal-java/4.1.0/nkf-0.2.0/* + extensions/universal-java/4.1.0/observer-0.1.2/* + extensions/universal-java/4.1.0/ostruct-0.6.3/* + extensions/universal-java/4.1.0/power_assert-3.0.1/* + extensions/universal-java/4.1.0/prime-0.1.4/* + extensions/universal-java/4.1.0/pstore-0.2.0/* + extensions/universal-java/4.1.0/racc-1.8.1/* + extensions/universal-java/4.1.0/rake-${rake.version}/* + extensions/universal-java/4.1.0/rdoc-7.0.3/* + extensions/universal-java/4.1.0/reline-0.6.3/* + extensions/universal-java/4.1.0/resolv-replace-0.1.1/* + extensions/universal-java/4.1.0/rexml-3.4.4/* + extensions/universal-java/4.1.0/rinda-0.2.0/* + extensions/universal-java/4.1.0/rss-0.3.2/* + extensions/universal-java/4.1.0/syslog-0.4.0/* + extensions/universal-java/4.1.0/test-unit-3.7.5/* From 5c05b1c471ca2b119523f20a44ad3e515d0e8fc2 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Fri, 17 Jul 2026 17:40:15 -0700 Subject: [PATCH 04/33] ObjectSpace#_id2ref is removed as of 4.1 --- spec/ruby/core/objectspace/_id2ref_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/ruby/core/objectspace/_id2ref_spec.rb b/spec/ruby/core/objectspace/_id2ref_spec.rb index a9fd526b7d0..19461b8729f 100644 --- a/spec/ruby/core/objectspace/_id2ref_spec.rb +++ b/spec/ruby/core/objectspace/_id2ref_spec.rb @@ -1,6 +1,6 @@ require_relative '../../spec_helper' -ruby_version_is "4.0" do +ruby_version_is "4.0"..."4.1" do describe "ObjectSpace._id2ref" do it "is deprecated" do id = nil.object_id From 6c7302458834c1bd4cb56d256091d7afeb2d7522 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Fri, 17 Jul 2026 17:40:37 -0700 Subject: [PATCH 05/33] Update test_objectspace from CRuby master --- test/mri/ruby/test_objectspace.rb | 86 ------------------------------- 1 file changed, 86 deletions(-) diff --git a/test/mri/ruby/test_objectspace.rb b/test/mri/ruby/test_objectspace.rb index a479547599a..033ad0f0de5 100644 --- a/test/mri/ruby/test_objectspace.rb +++ b/test/mri/ruby/test_objectspace.rb @@ -2,77 +2,6 @@ require 'test/unit' class TestObjectSpace < Test::Unit::TestCase - def self.deftest_id2ref(obj) - /:(\d+)/ =~ caller[0] - file = $` - line = $1.to_i - code = <<"End" - define_method("test_id2ref_#{line}") {\ - o = EnvUtil.suppress_warning { ObjectSpace._id2ref(obj.object_id) } - assert_same(obj, o, "didn't round trip: \#{obj.inspect}");\ - } -End - eval code, binding, file, line - end - - deftest_id2ref(-0x4000000000000001) - deftest_id2ref(-0x4000000000000000) - deftest_id2ref(-0x40000001) - deftest_id2ref(-0x40000000) - deftest_id2ref(-1) - deftest_id2ref(0) - deftest_id2ref(1) - deftest_id2ref(0x3fffffff) - deftest_id2ref(0x40000000) - deftest_id2ref(0x3fffffffffffffff) - deftest_id2ref(0x4000000000000000) - deftest_id2ref(:a) - deftest_id2ref(:abcdefghijilkjl) - deftest_id2ref(:==) - deftest_id2ref(Object.new) - deftest_id2ref(self) - deftest_id2ref(true) - deftest_id2ref(false) - deftest_id2ref(nil) - - def test_id2ref_liveness - assert_normal_exit <<-EOS - ids = [] - 10.times{ - 1_000.times{ - ids << 'hello'.object_id - } - objs = ids.map{|id| - begin - ObjectSpace._id2ref(id) - rescue RangeError - nil - end - } - GC.start - objs.each{|e| e.inspect} - } - EOS - end - - def test_id2ref_invalid_argument - msg = /no implicit conversion/ - assert_raise_with_message(TypeError, msg) { EnvUtil.suppress_warning { ObjectSpace._id2ref(nil) } } - assert_raise_with_message(TypeError, msg) { EnvUtil.suppress_warning { ObjectSpace._id2ref(false) } } - assert_raise_with_message(TypeError, msg) { EnvUtil.suppress_warning { ObjectSpace._id2ref(true) } } - assert_raise_with_message(TypeError, msg) { EnvUtil.suppress_warning { ObjectSpace._id2ref(:a) } } - assert_raise_with_message(TypeError, msg) { EnvUtil.suppress_warning { ObjectSpace._id2ref("0") } } - assert_raise_with_message(TypeError, msg) { EnvUtil.suppress_warning { ObjectSpace._id2ref(Object.new) } } - end - - def test_id2ref_invalid_symbol_id - # RB_STATIC_SYM_P checks for static symbols by checking that the bottom - # 8 bits of the object is equal to RUBY_SYMBOL_FLAG, so we need to make - # sure that the bottom 8 bits remain unchanged. - msg = /is not a symbol id value/ - assert_raise_with_message(RangeError, msg) { EnvUtil.suppress_warning { ObjectSpace._id2ref(:a.object_id + 256) } } - end - def test_count_objects h = {} ObjectSpace.count_objects(h) @@ -284,21 +213,6 @@ def test_each_object_recursive_key end; end - def test_id2ref_table_build - assert_separately([], <<-End) - 10.times do - Object.new.object_id - end - - GC.start(immediate_mark: false) - - obj = Object.new - EnvUtil.suppress_warning do - assert_equal obj, ObjectSpace._id2ref(obj.object_id) - end - End - end - def test_each_object_singleton_class assert_separately([], <<-End) class C From a7406fc4aee039f3f40954712d6213fc54e793e1 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Fri, 17 Jul 2026 18:23:07 -0700 Subject: [PATCH 06/33] Update test_settracefunc.rb from CRuby master --- test/mri/ruby/test_settracefunc.rb | 62 ++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/test/mri/ruby/test_settracefunc.rb b/test/mri/ruby/test_settracefunc.rb index 776534a2b54..95fc7f45536 100644 --- a/test/mri/ruby/test_settracefunc.rb +++ b/test/mri/ruby/test_settracefunc.rb @@ -26,6 +26,19 @@ def target_thread? Thread.current == @target_thread end + # Reject trace events from other code interrupting this thread, such as + # finalizers of objects left by other tests (e.g. Tempfile's), whose + # frames are pushed on top of the interrupted frame of this file. An + # event is ours iff the innermost frame, ignoring core methods written + # in Ruby ( such as Kernel#tap), belongs to this file. + # + # +locations+ must be the caller_locations captured directly in the + # trace handler; capturing it here would add this method's own frame. + def event_from_this_file?(locations) + innermost = locations.drop_while{|loc| loc.path.start_with?(" Date: Sat, 18 Jul 2026 10:18:37 +0900 Subject: [PATCH 07/33] Reduce Proc#refined tests to JRuby-specific ones Implementation-agnostic behavior is covered by spec/ruby/core/proc/refined_spec.rb (synced from ruby/spec) and by CRuby's test/ruby/test_proc.rb (synced as test/mri), so keep only the tests tied to JRuby internals: clone retention in the IR lexical parent, JIT compilation of the clone, concurrent refine/refined, backtrace construction through a top-level clone, and GC survival. Requested in https://github.com/jruby/jruby/pull/9486 Co-Authored-By: Claude Fable 5 --- test/jruby/test_proc_refined.rb | 398 -------------------------------- 1 file changed, 398 deletions(-) diff --git a/test/jruby/test_proc_refined.rb b/test/jruby/test_proc_refined.rb index b55221e9cd9..d5e0f1d7431 100644 --- a/test/jruby/test_proc_refined.rb +++ b/test/jruby/test_proc_refined.rb @@ -13,90 +13,6 @@ def shout end end - module IntegerRefinement - refine Integer do - def double - self * 2 - end - end - end - - # A second String#shout refinement with a different result, used to tell which refinement set is active. - module StringRefinement2 - refine String do - def shout - downcase + "?" - end - end - end - - # Refines operators so the specialized call paths (opt_plus / opt_lt / opt_aref equivalents) are exercised. - module Operators - refine Integer do - def +(other) = "plus(#{self},#{other})" - def <(other) = "lt" - end - refine Array do - def [](i) = "at#{i}" - end - end - - # Refines Hash#[] to exercise the string-literal aref fast path (ArrayDerefInstr), which the builder emits - # only in unrefined scopes and which otherwise dispatches straight to the builtin. - module HashAref - refine Hash do - def [](k) = "aref(#{k})" - end - end - - class SuperBase - def greet = "base" - end - - # A refinement whose method calls super to reach the method it refines. - module SuperModule - refine SuperBase do - def greet = "ref-" + super - end - end - - # `using` inside a refined proc's body is rejected: the memoized clone shares refined call sites across - # every proc derived from the same (source, modules) key, which is only sound while all of them run under the - # same refinement set. A `using` mid-body would diverge that set and poison the shared clone. The refinement - # set is fixed at refined() time instead; pass every module in the single refined() call. (MRI parity: the - # same RuntimeError messages are raised by CRuby.) - def test_using_rejected_in_body - # main.using resolves to the top-level main object, so build these procs at the top level. - mod = "TestProcRefined::StringRefinement" - # main.using directly in the body - pr = eval("->(){ using #{mod} }", TOPLEVEL_BINDING).refined(StringRefinement) - assert_raise_with_message(RuntimeError, /main\.using is not permitted in a proc with refinements/) { pr.call } - # main.using in a block nested inside the refined proc - pr = eval("->(){ [1].each { using #{mod} } }", TOPLEVEL_BINDING).refined(StringRefinement) - assert_raise_with_message(RuntimeError, /main\.using is not permitted in a proc with refinements/) { pr.call } - # Module#using in a class/module body opened inside the refined proc (built at the top level so the enclosing - # scope chain does not lexically pass through a method, which would trip the "not permitted in methods" guard). - pr = eval("->(){ Class.new { using #{mod} } }", TOPLEVEL_BINDING).refined(StringRefinement) - assert_raise_with_message(RuntimeError, /Module#using is not permitted in a proc with refinements/) { pr.call } - # Module#using when the refined proc is run via module_eval (self is the module, but the block's scope is - # still the clone, so the walk finds it). - pr = eval("proc { using #{mod} }", TOPLEVEL_BINDING).refined(StringRefinement) - assert_raise_with_message(RuntimeError, /Module#using is not permitted in a proc with refinements/) { Module.new.module_eval(&pr) } - end - - # The refined proc is created from the receiver's real class: it must not inherit the receiver's - # singleton state (CRuby behaves the same), while Proc subclasses are preserved. - def test_singleton_state_not_inherited - pr = ->(s) { s.shout } - def pr.tag = :orig - refined = pr.refined(StringRefinement) - assert_equal(false, refined.respond_to?(:tag)) - assert_equal("HI!", refined.call("hi")) - sub = Class.new(Proc) - sub_refined = sub.new { |s| s.shout }.refined(StringRefinement) - assert_equal(sub, sub_refined.class) - end - # Refinement activation (refined -> usingModuleRecursive) can run on any thread while another # thread mutates the same module's refinement map via Module#refine; the activation must # snapshot the map instead of iterating it unsynchronized (ConcurrentModificationException). @@ -120,25 +36,6 @@ def test_concurrent_refined_and_refine writer.join end - # `refine` (which only defines methods on a refinement module, without activating them in the current scope) - # does not touch the active refinement set, so unlike `using` it is allowed inside a refined proc's body. - def test_refine_allowed_in_body - refined = ->(s) { - Module.new { refine(String) { def whisper = "w" } } - s.shout - }.refined(StringRefinement) - assert_nothing_raised { assert_equal("HI!", refined.call("hi")) } - end - - # class_eval gets its own copy of the refinement scope, so running a refined proc through it must not - # pollute the memoized clone that later derivations reuse. - def test_module_eval_does_not_leak_refinements - body = proc { "ok".shout }.refined(StringRefinement) - assert_equal("OK!", Class.new.class_eval(&body)) - again = proc { "ok".shout }.refined(StringRefinement) - assert_equal("OK!", Class.new.class_eval(&again)) - end - # Each refined clone is grafted under the SOURCE proc's (shared, long-lived) lexical parent, but it must # not be registered in that parent's nested-closure / lexical-child lists: doing so would retain every discarded # clone forever (memory leak) and race with concurrent calls on those unsynchronized/AOT lists. Drive many @@ -159,35 +56,6 @@ def test_clones_not_retained_by_lexical_parent "refined clones accumulated in the source's parent lexicalChildren" end - # A /o (once) regexp literal interpolating a refined-method call is built under the refinement, and the clone's - # once cache is independent of the source proc's. - def test_once_regexp - refined = ->(s) { /\A#{s.shout}\z/o }.refined(StringRefinement) - r1 = refined.call("ab") - assert_equal('\AAB!\z', r1.source) - assert_same(r1, refined.call("zz")) # /o caches the first regexp on the clone's own once entry - # the original proc has no refinement, so building the regexp raises - assert_raise(NoMethodError) { ->(s) { /\A#{s.shout}\z/o }.call("ab") } - end - - # A proc created lexically INSIDE a refined proc is not itself "a proc that already has refinements": it - # only inherits the enclosing refinements lexically. refined (and define_method) must therefore be - # accepted on it, and the inner proc must see both the enclosing refinement and the one it adds. Only the proc - # handed back by refined is rejected for chaining. - def test_nested_proc_inside_refined_proc_is_not_a_chain - result = -> { - inner = ->(s, n) { [s.shout, n.double] } - inner.refined(StringRefinement).call("hi", 3) - }.refined(IntegerRefinement).call - # StringRefinement is added on the inner clone; IntegerRefinement is inherited from the enclosing refined proc. - assert_equal ["HI!", 6], result - - # define_method from such a nested proc is allowed (it is not the refined result itself). - assert_nothing_raised do - -> { Class.new { define_method(:m, ->(s) { s }) } }.refined(IntegerRefinement).call - end - end - # An exception raised through a refined clone whose source block was created at top level (so the captured # frame has no method name) must still build a backtrace -- the clone always runs interpreted, and a null frame name # used to crash backtrace construction with a Java NullPointerException instead of surfacing the Ruby exception. @@ -198,189 +66,6 @@ def test_exception_backtrace_through_top_level_clone assert_match(/block in /, err.backtrace.first) end - # The refined clone must work when reached through the block-yield path (each(&proc)), not just #call. - def test_yield_path - forwarded = [] - rr = ->(s) { forwarded << s.shout }.refined(StringRefinement) - %w[p q].each(&rr) - assert_equal(["P!", "Q!"], forwarded) - end - - # The refinement must be carried when the clone is reached through the generic C invocation paths - # (Method#call, Fiber, Thread) rather than a direct optimized Proc#call. - def test_c_call_paths - refined = ->(s) { s.shout }.refined(StringRefinement) - assert_equal("A!", refined.send(:call, "a")) - assert_equal("B!", refined.method(:call).call("b")) - assert_equal("C!", Fiber.new(&refined).resume("c")) - assert_equal("D!", Thread.new("d", &refined).value) - end - - # Repeating the same (proc, modules) reuses the cached clone but stays correct; a different module set must - # not return the previously cached clone, and switching back is still correct. - def test_memoized - orig = ->(s) { s.shout } - assert_equal("HI!", orig.refined(StringRefinement).call("hi")) - assert_equal("HI!", orig.refined(StringRefinement).call("hi")) - assert_equal("hi?", orig.refined(StringRefinement2).call("hi")) - assert_equal("HI!", orig.refined(StringRefinement).call("hi")) - end - - # Run the block with performance warnings enabled and $stderr captured; return the captured output. - def capture_performance_warnings - require 'stringio' - old = Warning[:performance] - Warning[:performance] = true - stderr, $stderr = $stderr, StringIO.new - begin - yield - $stderr.string - ensure - $stderr = stderr - Warning[:performance] = old - end - end - - # A differing module set overwrites the single memo slot and emits a performance-category - # warning, as in CRuby. - def test_memo_different_modules_warning - orig = ->(s) { s.shout } - orig.refined(StringRefinement).call("hi") - out = capture_performance_warnings do - orig.refined(StringRefinement2).call("hi") - end - assert_match(/Proc#refined called with different modules for the same block disables memoization/, out) - end - - # Proc#ruby2_keywords marks the shared block scope, possibly after a clone was memoized. The stale - # memo is rebuilt (with a warning naming the cause) rather than reused or mutated: the new proc - # delegates keywords like its source, while procs built before the mark keep their creation-time - # behavior. Mirrors CRuby's test_refined_ruby2_keywords_memo. - def test_memo_ruby2_keywords - target = ->(a, k: nil) { [a, k] } - pr = proc { |*args| target.call(*args) } - q1 = pr.refined(StringRefinement) # memoize a clone before the mark - pr.ruby2_keywords - assert_equal([1, 2], pr.call(1, k: 2)) - out = capture_performance_warnings do - q2 = pr.refined(StringRefinement) - assert_equal([1, 2], q2.call(1, k: 2)) - # the clone made before the mark is not retroactively changed - assert_raise(ArgumentError) { q1.call(1, k: 2) } - # the rebuilt memo is hit from now on; no further warnings - assert_equal([1, 2], pr.refined(StringRefinement).call(1, k: 2)) - end - assert_match(/Proc#refined re-copies the block because the ruby2_keywords flag changed after the copy was memoized/, out) - assert_equal(1, out.scan(/ruby2_keywords flag changed/).size) - end - - # Procs sharing the same source block but capturing different closure environments hit the same cache slot - # (the environment is not part of the key), yet each clone must keep its own captured environment. - def test_memo_distinct_environments - factory = ->(tag) { ->(s) { "#{tag}:#{s.shout}" } } - p1 = factory.call("A") - p2 = factory.call("B") - r1 = p1.refined(StringRefinement) - r2 = p2.refined(StringRefinement) - assert_equal("A:X!", r1.call("x")) - assert_equal("B:Y!", r2.call("y")) - assert_equal("A:X!", r1.call("x")) - end - - # The clone must reproduce the begin/rescue/ensure control flow (copied exception regions, shared locals). - def test_rescue_ensure - refined = ->(s) { - r = nil - begin - r = s.shout - rescue NoMethodError - r = "rescued" - ensure - r = "#{r}." - end - r - }.refined(StringRefinement) - assert_equal("YO!.", refined.call("yo")) - end - - # Keyword and optional arguments must be reproduced on the clone. - def test_keyword_and_optional_args - refined = ->(a, b = "z", c:, d: "w") { - [a, b, c, d].map { |x| x.shout }.join - }.refined(StringRefinement) - assert_equal("A!Z!C!W!", refined.call("a", c: "c")) - assert_equal("A!B!C!D!", refined.call("a", "b", c: "c", d: "d")) - end - - # Literal when-clauses compile to a jump table that must survive the clone. - def test_case_when_literal - refined = ->(s) { - case s.shout - when "A!" then 1 - when "B!" then 2 - else 0 - end - }.refined(StringRefinement) - assert_equal(1, refined.call("a")) - assert_equal(2, refined.call("b")) - assert_equal(0, refined.call("c")) - end - - # A flip-flop keeps state keyed off the local scope; the clone must run its own flip state, and a second call - # starts from a fresh state. - def test_flip_flop - body = ->(arr) { - out = [] - arr.each { |i| out << i if (i == 2)..(i == 4) } - out - } - refined = body.refined(StringRefinement) - assert_equal([2, 3, 4], refined.call([1, 2, 3, 4, 5])) - assert_equal([2, 3, 4], refined.call([1, 2, 3, 4, 5])) - end - - # The clone reports the same source location, parameters and arity as the source proc. - def test_preserves_location_and_parameters - orig = ->(a, b = 1, *c, d:, **e, &f) { a } - refined = orig.refined(StringRefinement) - assert_equal(orig.source_location, refined.source_location) - assert_equal(orig.parameters, refined.parameters) - assert_equal(orig.arity, refined.arity) - end - - # Operator refinements must apply on the clone (specialized call paths) without leaking into the originals. - def test_operators - refined = ->(a, b) { [a + b, a < b] }.refined(Operators) - assert_equal(["plus(1,2)", "lt"], refined.call(1, 2)) - aref = ->(a) { a[0] }.refined(Operators) - assert_equal("at0", aref.call([9])) - assert_equal(3, ->(a, b) { a + b }.call(1, 2)) - end - - # A string-literal Hash aref compiles to the optimized ArrayDerefInstr, which bypasses refinements; the - # Proc#refined clone must fall back to a normal refined call so the refinement still applies (matching CRuby - # and a fresh build under `using`). - def test_optimized_aref - refined = ->(h) { h["x"] }.refined(HashAref) - assert_equal("aref(x)", refined.call({ "x" => 1 })) - # the original proc keeps the builtin fast path - assert_equal(1, ->(h) { h["x"] }.call({ "x" => 1 })) - end - - # A refined method may call super to reach the method it refines. - def test_super - refined = ->(o) { o.greet }.refined(SuperModule) - assert_equal("ref-base", refined.call(SuperBase.new)) - end - - # The clone shares the source proc's environment, so a recursive call through a captured local reaches the - # refined clone again and keeps the refinements. - def test_recursion_sees_refinements - fact = nil - fact = ->(s) { s.empty? ? "" : s[0].shout + fact.call(s[1..]) }.refined(StringRefinement) - assert_equal("A!B!C!", fact.call("abc")) - end - # Many independently created refined clones must all survive GC and keep working. def test_gc procs = 100.times.map { ->(s) { s.shout }.refined(StringRefinement) } @@ -388,89 +73,6 @@ def test_gc procs.each { |pr| assert_equal("A!", pr.call("a")) } end - # A Symbol#to_proc (&:shout) evaluated inside a refined clone resolves the method under the clone's refinements. - # A baked &:sym caches a non-refined symbol proc; the clone rewrites it to a plain symbol so the (now refined) - # call site converts it through Symbol#toRefinedProc with the clone's refinement scope. - def test_symbol_to_proc_sees_refinements - refined = ->(arr) { arr.map(&:shout) }.refined(StringRefinement) - assert_equal(["A!", "B!"], refined.call(["a", "b"])) - # the original proc has no refinement, so the symbol proc raises - assert_raise(NoMethodError) { ->(arr) { arr.map(&:shout) }.call(["a"]) } - end - - # A literal `def` inside the block captures the clone's refinements (like a def inside a `using` scope), so the - # refinement also applies when that method is called later. Covers both a singleton def and an instance def. - def test_def_in_block_sees_refinements - refined = ->(s) { - o = Object.new - def o.m = "hi".shout - [s.shout, o.m] - }.refined(StringRefinement) - assert_equal(["YO!", "HI!"], refined.call("yo")) - - inst = ->(s) { - k = Class.new { def m; "hi".shout; end } - [s.shout, k.new.m] - }.refined(StringRefinement) - assert_equal(["YO!", "HI!"], inst.call("yo")) - - # the original proc's def must NOT see refinements - assert_raise(NoMethodError) do - ->(s) { o = Object.new; def o.m = "hi".shout; o.m }.call("yo") - end - end - - # A class/module (re)opened with a `class`/`module` keyword inside a refined proc -- as opposed to via a - # class_eval/Class.new block -- compiles to its own class-body scope, which must also become refinement-aware - # so the body and any def inside it see the refinements (matching a body opened under a `using` scope). - # - # The `class`/`module` keyword is a syntax error inside a method body (even within a block there), so these - # procs are built at class-body scope (block bodies, which the keyword is legal in) rather than inside the - # test methods. - class ReopenTarget; end - module ReopenModule; end - - # reopen an existing class - REOPEN_CLASS = ->(s) { - class ReopenTarget - def m = "hi".shout - end - [s.shout, ReopenTarget.new.m] - } - # define a brand-new class with a superclass, plus a module method, plus class << self - REOPEN_VARIANTS = ->(s) { - class ReopenSub < ReopenTarget - def m = "hi".shout - end - module ReopenModule - def self.m = "hi".shout - end - class ReopenTarget - class << self - def n = "hi".shout - end - end - [s.shout, ReopenSub.new.m, ReopenModule.m, ReopenTarget.n] - } - # an otherwise identical proc whose class body must NOT see refinements when called without refined - REOPEN_UNREFINED = ->(s) { - class ReopenTarget - def unrefined = "hi".shout - end - ReopenTarget.new.unrefined - } - - def test_class_keyword_body_sees_refinements - refined = REOPEN_CLASS.refined(StringRefinement) - assert_equal(["YO!", "HI!"], refined.call("yo")) - - variants = REOPEN_VARIANTS.refined(StringRefinement) - assert_equal(["YO!", "HI!", "HI!", "HI!"], variants.call("yo")) - - # the original proc's class body must NOT see refinements - assert_raise(NoMethodError) { REOPEN_UNREFINED.call("yo") } - end - # A refinement-aware clone is grafted under an already-built enclosing scope, so it needs its own full IR # built before it can be JIT-compiled. Run in a subprocess with a low JIT threshold and synchronous (non # background) compilation so both the original and the clone cross the threshold deterministically. We From ea9e71eeea5470411fdf63f7e01a96a279510e3b Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Tue, 28 Jul 2026 16:49:37 -0500 Subject: [PATCH 08/33] Deprecate ruby2_keywords at a Java level The ruby2_keywords method will be deprecated for removal in CRuby starting with 4.1, with removal currently planned in Ruby 4.5. We duplicate the documentation change here in preparation for that process. --- core/src/main/java/org/jruby/RubyHash.java | 12 ++++++++++++ core/src/main/java/org/jruby/RubyModule.java | 6 ++++++ core/src/main/java/org/jruby/RubyProc.java | 6 ++++++ core/src/main/java/org/jruby/TopSelfFactory.java | 2 ++ 4 files changed, 26 insertions(+) diff --git a/core/src/main/java/org/jruby/RubyHash.java b/core/src/main/java/org/jruby/RubyHash.java index 91ef311d8c5..9753378b5fb 100644 --- a/core/src/main/java/org/jruby/RubyHash.java +++ b/core/src/main/java/org/jruby/RubyHash.java @@ -1100,6 +1100,12 @@ public IRubyObject to_proc(ThreadContext context) { return getDelegate().to_proc(context); } + /** + * @deprecated This method will be removed in Ruby 4.5, one version after removal of the + * {@code ruby2_keywords} mechanism. See + * Feature #22205 for the schedule. + */ + @Deprecated(since = "ruby 4.1", forRemoval = true) @JRubyMethod(meta = true) public static IRubyObject ruby2_keywords_hash(ThreadContext context, IRubyObject _self, IRubyObject arg) { TypeConverter.checkType(context, arg, hashClass(context)); @@ -1110,6 +1116,12 @@ public static IRubyObject ruby2_keywords_hash(ThreadContext context, IRubyObject return hash; } + /** + * @deprecated This method will be removed in Ruby 4.5, one version after removal of the + * {@code ruby2_keywords} mechanism. See + * Feature #22205 for the schedule. + */ + @Deprecated(since = "ruby 4.1", forRemoval = true) @JRubyMethod(meta = true, name = "ruby2_keywords_hash?") public static IRubyObject ruby2_keywords_hash_p(ThreadContext context, IRubyObject _self, IRubyObject arg) { TypeConverter.checkType(context, arg, hashClass(context)); diff --git a/core/src/main/java/org/jruby/RubyModule.java b/core/src/main/java/org/jruby/RubyModule.java index 3321e87ab45..e208e517473 100644 --- a/core/src/main/java/org/jruby/RubyModule.java +++ b/core/src/main/java/org/jruby/RubyModule.java @@ -3444,6 +3444,12 @@ public void addWriteAttribute(ThreadContext context, String name) { addAccessor(context, newHardSymbol(context.runtime, name.intern()), PUBLIC, false, true); } + /** + * @deprecated Use {@code ...} argument forwarding or other delegation styles instead; they work correctly on + * Ruby 3.0 and later. This method will be removed in Ruby 4.4. See + * Feature #22205 for the schedule. + */ + @Deprecated(since = "ruby 4.1", forRemoval = true) @JRubyMethod(required = 1, rest = true, checkArity = false, visibility = PRIVATE) public IRubyObject ruby2_keywords(ThreadContext context, IRubyObject[] args) { Arity.checkArgumentCount(context, args, 1, -1); diff --git a/core/src/main/java/org/jruby/RubyProc.java b/core/src/main/java/org/jruby/RubyProc.java index f8fdd0a7d16..539a4e464e6 100644 --- a/core/src/main/java/org/jruby/RubyProc.java +++ b/core/src/main/java/org/jruby/RubyProc.java @@ -287,6 +287,12 @@ public IRubyObject to_s(ThreadContext context) { return string; } + /** + * @deprecated Use explicit delegation ({@code *args, **kwargs}) instead; it works correctly on Ruby 3.0 and + * later. This method will be removed in Ruby 4.4. See + * Feature #22205 for the schedule. + */ + @Deprecated(since = "ruby 4.1", forRemoval = true) @JRubyMethod public IRubyObject ruby2_keywords(ThreadContext context) { checkFrozen(); diff --git a/core/src/main/java/org/jruby/TopSelfFactory.java b/core/src/main/java/org/jruby/TopSelfFactory.java index b51630bc5dd..4e739d72653 100644 --- a/core/src/main/java/org/jruby/TopSelfFactory.java +++ b/core/src/main/java/org/jruby/TopSelfFactory.java @@ -91,8 +91,10 @@ public IRubyObject call(ThreadContext context, IRubyObject self, RubyModule claz } }); + // TODO: Remove ruby2_keywords when JRuby moves to Ruby 4.5 support. singletonClass.addMethod(context, "ruby2_keywords", new JavaMethod.JavaMethodN(singletonClass, Visibility.PRIVATE, "private") { @Override + @SuppressWarnings("removal") public IRubyObject call(ThreadContext context, IRubyObject self, RubyModule clazz, String name, IRubyObject[] args) { return Object.ruby2_keywords(context, args); } From 985d5bbb52e538e8b7ce8d8bb8c3407ac243d2b8 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Wed, 29 Jul 2026 16:04:51 +0900 Subject: [PATCH 09/33] Implement Proc#hash consistently with Proc#eql? Proc#hash was missing, so procs that are eql? (e.g. a proc and its dup) hashed differently. Co-Authored-By: Claude Fable 5 --- core/src/main/java/org/jruby/RubyProc.java | 5 +++++ core/src/main/java/org/jruby/RubySymbol.java | 12 ++++++++++++ core/src/main/java/org/jruby/runtime/Binding.java | 2 +- core/src/main/java/org/jruby/runtime/Block.java | 2 +- test/mri/excludes/TestProc.rb | 3 +-- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyProc.java b/core/src/main/java/org/jruby/RubyProc.java index 539a4e464e6..9e4dd25e34e 100644 --- a/core/src/main/java/org/jruby/RubyProc.java +++ b/core/src/main/java/org/jruby/RubyProc.java @@ -335,6 +335,11 @@ public IRubyObject op_equal(ThreadContext context, IRubyObject obj) { return asBoolean(context, getBlock().equals(other.block)); } + @JRubyMethod + public RubyFixnum hash(ThreadContext context) { + return asFixnum(context, block.hashCode()); + } + private static IRubyObject[] checkArityForLambda(ThreadContext context, Block.Type type, BlockBody blockBody, IRubyObject... args) { if (type == Block.Type.LAMBDA) { blockBody.getSignature().checkArity(context, args); diff --git a/core/src/main/java/org/jruby/RubySymbol.java b/core/src/main/java/org/jruby/RubySymbol.java index 325fde19197..37d37e79925 100644 --- a/core/src/main/java/org/jruby/RubySymbol.java +++ b/core/src/main/java/org/jruby/RubySymbol.java @@ -1588,6 +1588,18 @@ public SymbolProcBody(Ruby runtime, String id, RubySymbol symbol, StaticScope sc this.symbol = symbol; } + // Same-symbol procs are == and hash-equal even as distinct objects; a proc made under + // refinements (toRefinedProc) owns a non-dummy scope, which keeps it apart. + @Override + public boolean equals(Object other) { + return other instanceof SymbolProcBody that && id.equals(that.id) && getStaticScope() == that.getStaticScope(); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + private IRubyObject yieldInner(ThreadContext context, RubyArray array, Block blockArg) { if (array.isEmpty()) throw argumentError(context, "no receiver given"); diff --git a/core/src/main/java/org/jruby/runtime/Binding.java b/core/src/main/java/org/jruby/runtime/Binding.java index cb654e8104e..bdddca52b60 100644 --- a/core/src/main/java/org/jruby/runtime/Binding.java +++ b/core/src/main/java/org/jruby/runtime/Binding.java @@ -258,7 +258,7 @@ public boolean equals(Object other) { @Override public int hashCode() { - return (self == null ? 1 : self.hashCode()) * dynamicScope.hashCode(); + return (self == null ? 1 : self.hashCode()) * (dynamicScope == null ? 1 : dynamicScope.hashCode()); } // FIXME: This is because we clone the same explicit binding whenever we execute because both the captured Frame diff --git a/core/src/main/java/org/jruby/runtime/Block.java b/core/src/main/java/org/jruby/runtime/Block.java index 8702a18126a..de51413b27f 100644 --- a/core/src/main/java/org/jruby/runtime/Block.java +++ b/core/src/main/java/org/jruby/runtime/Block.java @@ -390,7 +390,7 @@ public boolean equals(Object other) { final Block that = (Block) other; - return this.binding.equals(that.binding) && this.body == that.body; + return this.binding.equals(that.binding) && this.body.equals(that.body); } @Override diff --git a/test/mri/excludes/TestProc.rb b/test/mri/excludes/TestProc.rb index c6352cae2ab..1f9d8c68ac0 100644 --- a/test/mri/excludes/TestProc.rb +++ b/test/mri/excludes/TestProc.rb @@ -3,8 +3,7 @@ exclude :test_binding_source_location, "fails in JIT mode on Linux CI on GHA" exclude :test_curry_with_trace, "fails in JIT mode on Linux CI on GHA" exclude :test_dup_clone, "needs investigation" -exclude :test_hash_does_not_change_after_compaction, "needs investigation" -exclude :test_hash_equal, "needs investigation" +exclude :test_hash_does_not_change_after_compaction, "GC.verify_compaction_references is not implemented" exclude :test_implicit_parameters_for_it_complex, "parser needs to treat `it` assignment as a new local variable (https://github.com/jruby/jruby/issues/9265)" exclude :test_lambda_warning_pass_block, "fails in JIT mode on Linux CI on GHA" exclude :test_parameters, "needs investigation" From 0b9fad0ee997aee89c5f562d91b33ee5461c7db4 Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Wed, 29 Jul 2026 13:39:46 +0900 Subject: [PATCH 10/33] Allow no-arg and chained Proc#refined and copy the block lazily Since prc.refined(*ms).refined(*ns) is behaviorally equivalent to prc.refined(*ms, *ns), a chained call re-clones from the original closure with the merged module list and the memoized copy is shared by these behaviorally equivalent Procs. The copy of the block's IR is deferred until the first call, so a Proc that is never called is no longer copied at all. [Feature #22213] Co-Authored-By: Claude Fable 5 --- core/src/main/java/org/jruby/RubyProc.java | 26 ++++-- .../src/main/java/org/jruby/ir/IRClosure.java | 87 ++++++++----------- .../java/org/jruby/ir/IRRefinedClosure.java | 75 ++++++++++++++++ core/src/main/java/org/jruby/ir/IRScope.java | 46 ++++++++-- .../transformations/inlining/CloneInfo.java | 8 ++ .../inlining/SimpleCloneInfo.java | 14 ++- .../java/org/jruby/runtime/IRBlockBody.java | 17 ++++ test/jruby/test_proc_refined.rb | 63 ++++++++++++++ 8 files changed, 267 insertions(+), 69 deletions(-) create mode 100644 core/src/main/java/org/jruby/ir/IRRefinedClosure.java diff --git a/core/src/main/java/org/jruby/RubyProc.java b/core/src/main/java/org/jruby/RubyProc.java index 9e4dd25e34e..ef568bfb506 100644 --- a/core/src/main/java/org/jruby/RubyProc.java +++ b/core/src/main/java/org/jruby/RubyProc.java @@ -40,6 +40,7 @@ import org.jruby.ast.util.ArgsUtil; import org.jruby.ir.IRClosure; +import org.jruby.ir.IRRefinedClosure; import org.jruby.ir.interpreter.Interpreter; import org.jruby.ir.runtime.IRRuntimeHelpers; import org.jruby.java.codegen.BlockInterfaceGenerator; @@ -434,28 +435,37 @@ public RubyProc to_proc() { /** * Return a new Proc whose body runs with the given modules' refinements active; the receiver is unchanged - * and the captured environment is shared. Heavy (it deep-copies the block's IR), so the result is cached. + * and the captured environment is shared. With no arguments, returns the receiver. Chained calls merge + * the module lists, later modules taking precedence. The block's IR is deep-copied, but only when the + * returned proc is first called, and the copy is cached. */ @JRubyMethod(name = "refined", rest = true) public IRubyObject refined(ThreadContext context, IRubyObject[] args) { - if (args.length == 0) throw argumentError(context, "wrong number of arguments (given 0, expected 1+)"); + if (args.length == 0) return this; BlockBody body = block.getBody(); if (fromMethod || !(body instanceof IRBlockBody)) { throw argumentError(context, "can't apply refinements to a Proc without a Ruby block"); } - // Chaining is not allowed: activate multiple modules by passing them all in a single call. - if (((IRBlockBody) body).getScope().isRefinementsClone()) { - throw argumentError(context, "can't apply refinements to a Proc that already has refinements"); - } - RubyModule[] modules = new RubyModule[args.length]; for (int i = 0; i < args.length; i++) { modules[i] = castAsModule(context, args[i]); } - IRClosure refinedClosure = ((IRBlockBody) body).getScope().refinementsClone(context, modules); + IRClosure source = ((IRBlockBody) body).getScope(); + // A chained call re-clones from the original closure with the merged list, sharing the memo slot + // with the equivalent single call. + if (source instanceof IRRefinedClosure refinedScope) { + RubyModule[] chained = refinedScope.getRefinementsModules(); + RubyModule[] merged = new RubyModule[chained.length + modules.length]; + System.arraycopy(chained, 0, merged, 0, chained.length); + System.arraycopy(modules, 0, merged, chained.length, modules.length); + modules = merged; + source = refinedScope.getRefinementsSource(); + } + + IRClosure refinedClosure = source.refinementsClone(context, modules); // Share the captured environment (binding holds the dynamic scope); clone the binding wrapper only so that // proc setup (file/line/dummy-scope) does not mutate the original proc's binding. Binding newBinding = block.getBinding().clone(); diff --git a/core/src/main/java/org/jruby/ir/IRClosure.java b/core/src/main/java/org/jruby/ir/IRClosure.java index e2540170969..8c5478b54c8 100644 --- a/core/src/main/java/org/jruby/ir/IRClosure.java +++ b/core/src/main/java/org/jruby/ir/IRClosure.java @@ -328,6 +328,12 @@ public LocalVariable getLocalVariable(RubySymbol name, int depth) { protected IRClosure cloneForInlining(CloneInfo ii, IRClosure clone) { SimpleCloneInfo clonedII = ii.cloneForCloningClosure(clone); + if (ii.isRefinementsClone()) { + clone.allocateInterpreterContext(new RefinementsCloneInstrs(clone, interpreterContext, clonedII), + interpreterContext.getTemporaryVariableCount(), interpreterContext.getFlags()); + return clone; + } + // if (getCFG() != null) { // clone.setCFG(getCFG().clone(clonedII, clone)); // } else { @@ -360,9 +366,13 @@ public IRClosure cloneForInlining(CloneInfo ii) { } if (ii instanceof SimpleCloneInfo && !((SimpleCloneInfo) ii).isEnsureBlockCloneMode()) { - clonedClosure = refinementsScope != null ? - new IRClosure(this, lexicalParent, closureId, getByteName(), refinementsScope) : - new IRClosure(this, lexicalParent, closureId, getByteName()); + if (ii.isRefinementsCloneRoot()) { + clonedClosure = new IRRefinedClosure(this, lexicalParent, closureId, getByteName(), refinementsScope); + } else { + clonedClosure = refinementsScope != null ? + new IRClosure(this, lexicalParent, closureId, getByteName(), refinementsScope) : + new IRClosure(this, lexicalParent, closureId, getByteName()); + } } else { int id = lexicalParent.getNextClosureId(); ByteList fullName = lexicalParent.getByteName(); @@ -380,14 +390,8 @@ public IRClosure cloneForInlining(CloneInfo ii) { clonedClosure.inRefinementsCloneTree = true; } - // the top clone of a Proc#refined tree is grafted under the source's real lexical parent. - boolean graftedTopRefinementsClone = ii.isRefinementsClone() && - !(lexicalParent instanceof IRClosure && ((IRClosure) lexicalParent).isInRefinementsCloneTree()); - if (graftedTopRefinementsClone) { - // Only the TOP clone is user-visible (the proc returned by Proc#refined); flag it so chaining and - // define_method are rejected on it, but not on the nested closures it lexically encloses. - clonedClosure.refinementsClone = true; - } else { + // A Proc#refined root is grafted under the source's real lexical parent, not registered there. + if (!ii.isRefinementsCloneRoot()) { // WrappedIRClosure should always have a single unique IRClosure in them so we should // not end up adding n copies of the same closure as distinct clones... lexicalParent.addClosure(clonedClosure); @@ -423,70 +427,47 @@ public boolean isInRefinementsCloneTree() { return inRefinementsCloneTree; } - /** - * True only on the TOP clone of a Proc#refined tree -- the closure that directly backs the proc the user - * receives. Used to reject chaining Proc#refined and to reject defining a method from such a proc. - */ - private boolean refinementsClone; - + /** True only on the {@link IRRefinedClosure} backing a Proc#refined proc. */ public boolean isRefinementsClone() { - return refinementsClone; + return false; } /** - * Return a refinement-aware clone of this closure for the given modules, reusing the cached clone when the same - * modules (by identity and order) were used last time. A miss recomputes via {@link #cloneForRefinements} and - * overwrites the single slot, emitting a performance-category warning when an existing entry is evicted. - * Lock-free: a race only causes a redundant clone, which is harmless. - * - * @param context the current thread context - * @param modules the refinement modules to activate (freshly allocated array; stored without copying) - * @return a clone whose body runs with the given refinements active + * Return a refinement-aware clone of this closure for the given modules, reusing the memoized clone + * when the same modules (by identity and order) were used last time. A miss builds a lazy shell; the + * memo is written when the shell materializes. Lock-free: a race only reclones. */ public IRClosure refinementsClone(ThreadContext context, RubyModule[] modules) { Map cacheMap = getManager().getRefinementsCloneCache(); RefinementsCache cache = cacheMap.get(this); - if (cache != null) { - if (cache.matches(modules)) { - // Proc#ruby2_keywords marks the scope, possibly after the clone was memoized; a stale - // clone must be rebuilt, not reused (as in CRuby). - if (cache.clone.isRuby2Keywords() == isRuby2Keywords()) return cache.clone; - context.runtime.getWarnings().warnPerformance( - "Proc#refined re-copies the block because the ruby2_keywords flag changed after the copy was memoized"); - } else { - context.runtime.getWarnings().warnPerformance( - "Proc#refined called with different modules for the same block disables memoization"); - } + if (cache != null && cache.matches(modules)) { + // Proc#ruby2_keywords marks the scope, possibly after the clone was memoized; a stale + // clone must be rebuilt, not reused (as in CRuby). + if (cache.clone.isRuby2Keywords() == isRuby2Keywords()) return cache.clone; + context.runtime.getWarnings().warnPerformance( + "Proc#refined re-copies the block because the ruby2_keywords flag changed after the copy was memoized"); } - IRClosure clone = cloneForRefinements(context, modules); - // Build the clone's BlockBody now, before publishing it through the cache, so a thread that later - // reads the shared clone on a cache hit sees a fully-built body (via the put's happens-before) and never - // races another thread to create a second one in the lazy getBlockBody(). + IRRefinedClosure clone = cloneForRefinements(context, modules); + clone.setRefinementsRecipe(this, modules); + // Build the BlockBody before the clone escapes so memo sharers never race in getBlockBody(). clone.getBlockBody(); - cacheMap.put(this, new RefinementsCache(modules, clone)); return clone; } /** - * Produce a refinement-aware deep copy of this closure tree for Proc#refined: the copy gets its own - * StaticScope with the given modules' refinements activated and its call sites re-created as refined. - * - * Private: callers go through {@link #refinementsClone}, which memoizes the clone. - * - * @param context the current thread context - * @param modules the refinement modules to activate - * @return a new IRClosure whose body runs with the given refinements active + * Produce the refinement-aware copy for Proc#refined: an {@link IRRefinedClosure} whose own StaticScope + * has the modules' refinements activated and whose instructions are cloned lazily on first use. */ - private IRClosure cloneForRefinements(ThreadContext context, RubyModule[] modules) { + private IRRefinedClosure cloneForRefinements(ThreadContext context, RubyModule[] modules) { // Cloning reads the startup InterpreterContext as its instruction source. if (getInterpreterContext() == null) { throw context.runtime.newArgumentError("cannot create a refinements-aware copy of this proc"); } - SimpleCloneInfo ii = new SimpleCloneInfo(getLexicalParent(), false, false, true); - IRClosure clone = cloneForInlining(ii); + SimpleCloneInfo ii = new SimpleCloneInfo(getLexicalParent(), false, false, true, true); + IRRefinedClosure clone = (IRRefinedClosure) cloneForInlining(ii); clone.setArgumentDescriptors(getArgumentDescriptors()); // The constructor registered the clone in its lexical parent's child-scope list; detach the grafted diff --git a/core/src/main/java/org/jruby/ir/IRRefinedClosure.java b/core/src/main/java/org/jruby/ir/IRRefinedClosure.java new file mode 100644 index 00000000000..d7efe0129aa --- /dev/null +++ b/core/src/main/java/org/jruby/ir/IRRefinedClosure.java @@ -0,0 +1,75 @@ +package org.jruby.ir; + +import java.util.Map; + +import org.jruby.RubyModule; +import org.jruby.parser.StaticScope; +import org.jruby.util.ByteList; + +/** + * The root of a Proc#refined clone tree -- the closure backing the returned proc. Carries the recipe + * (source closure + accumulated modules) so a chained call re-clones from the original with the merged + * list; the nested scopes it encloses remain ordinary IRClosure/IRFor/IRMethod clones. + */ +public class IRRefinedClosure extends IRClosure { + private IRClosure refinementsSource; + private RubyModule[] refinementsModules; + + IRRefinedClosure(IRClosure c, IRScope lexicalParent, int closureId, ByteList fullName, StaticScope staticScope) { + super(c, lexicalParent, closureId, fullName, staticScope); + } + + @Override + public boolean isRefinementsClone() { + return true; + } + + /** Set before the proc can escape, so materialization cannot precede it. */ + void setRefinementsRecipe(IRClosure source, RubyModule[] modules) { + this.refinementsSource = source; + this.refinementsModules = modules; + } + + public IRClosure getRefinementsSource() { + return refinementsSource; + } + + public RubyModule[] getRefinementsModules() { + return refinementsModules; + } + + // Module identity, not equals/hashCode: those dispatch to Ruby code, which user modules can override. + public boolean sameRecipe(IRRefinedClosure other) { + if (refinementsSource == null || refinementsSource != other.refinementsSource) return false; + RubyModule[] modules = refinementsModules, otherModules = other.refinementsModules; + if (modules.length != otherModules.length) return false; + for (int i = 0; i < modules.length; i++) { + if (modules[i] != otherModules[i]) return false; + } + return true; + } + + public int recipeHash() { + if (refinementsModules == null) return System.identityHashCode(this); + int hash = System.identityHashCode(refinementsSource); + for (RubyModule module : refinementsModules) { + hash = 31 * hash + System.identityHashCode(module); + } + return hash; + } + + /** + * Deferring the memo write to materialization means a never-called chain intermediate cannot evict + * a live entry. + */ + @Override + protected void publishRefinementsClone() { + Map cacheMap = getManager().getRefinementsCloneCache(); + RefinementsCache cache = cacheMap.get(refinementsSource); + if (cache != null && !cache.matches(refinementsModules)) { + getManager().getRuntime().getWarnings().warnPerformance( + "Proc#refined called with different modules for the same block disables memoization"); + } + cacheMap.put(refinementsSource, new RefinementsCache(refinementsModules, this)); + } +} diff --git a/core/src/main/java/org/jruby/ir/IRScope.java b/core/src/main/java/org/jruby/ir/IRScope.java index 41f51f0248d..76ed006928e 100644 --- a/core/src/main/java/org/jruby/ir/IRScope.java +++ b/core/src/main/java/org/jruby/ir/IRScope.java @@ -628,8 +628,8 @@ public InterpreterContext allocateInterpreterContext(Supplier> instr /** * Shared body of the IRMethod/IRModuleBody Proc#refined clone: give the copy its own StaticScope reaching - * the enclosing clone's refinements overlay, flag it maybe-using-refinements, and re-create its instructions. - * The caller supplies the source InterpreterContext and a constructor for the empty copy. + * the enclosing clone's refinements overlay, flag it maybe-using-refinements, and re-create its instructions + * lazily on first use. The caller supplies the source InterpreterContext and a constructor for the empty copy. */ protected T cloneScopeForRefinements(IRScope lexicalParent, InterpreterContext sourceIC, BiFunction constructor) { @@ -641,15 +641,47 @@ protected T cloneScopeForRefinements(IRScope lexicalParent, clone.setIsMaybeUsingRefinements(); SimpleCloneInfo clonedII = new SimpleCloneInfo(clone, false, false, true); - List newInstrs = new ArrayList<>(sourceIC.getInstructions().length); - for (Instr i : sourceIC.getInstructions()) { - newInstrs.add(i.clone(clonedII)); - } - clone.allocateInterpreterContext(newInstrs, sourceIC.getTemporaryVariableCount(), sourceIC.getFlags()); + clone.allocateInterpreterContext(new RefinementsCloneInstrs(clone, sourceIC, clonedII), + sourceIC.getTemporaryVariableCount(), sourceIC.getFlags()); return clone; } + /** + * Copies a Proc#refined clone's instructions on first use. Idempotent: InterpreterContext#getEngine can + * force it from two threads, and a duplicate copy would re-register nested shells in their parents. + */ + protected static final class RefinementsCloneInstrs implements Supplier> { + private final IRScope clone; + private final InterpreterContext sourceIC; + private final SimpleCloneInfo clonedII; + private List instrs; + + RefinementsCloneInstrs(IRScope clone, InterpreterContext sourceIC, SimpleCloneInfo clonedII) { + this.clone = clone; + this.sourceIC = sourceIC; + this.clonedII = clonedII; + } + + @Override + public synchronized List get() { + if (instrs == null) { + Instr[] source = sourceIC.getInstructions(); + List newInstrs = new ArrayList<>(source.length); + for (Instr i : source) { + newInstrs.add(i.clone(clonedII)); + } + instrs = newInstrs; + clone.publishRefinementsClone(); + } + return instrs; + } + } + + /** Hook: an IRRefinedClosure publishes itself to the memo when it materializes. */ + protected void publishRefinementsClone() { + } + private Instr[] cloneInstrs() { SimpleCloneInfo cloneInfo = new SimpleCloneInfo(this, false); diff --git a/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java b/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java index 1f1893cf50d..e4a528a8c74 100644 --- a/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java +++ b/core/src/main/java/org/jruby/ir/transformations/inlining/CloneInfo.java @@ -44,6 +44,14 @@ public boolean isRefinementsClone() { return false; } + /** + * Is this the root of a refinements clone request -- the closure Proc#refined was called on, which + * becomes an IRRefinedClosure -- rather than a nested scope cloned along with it? + */ + public boolean isRefinementsCloneRoot() { + return false; + } + /** * @return The IRScope this cloning operation is happening in (or is coming from). */ diff --git a/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java b/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java index d0a20a86c44..5f1982fb514 100644 --- a/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java +++ b/core/src/main/java/org/jruby/ir/transformations/inlining/SimpleCloneInfo.java @@ -11,14 +11,21 @@ public class SimpleCloneInfo extends CloneInfo { private final boolean isEnsureBlock; private final boolean refinementsClone; + private final boolean refinementsCloneRoot; private boolean cloneIPC; - public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock, boolean cloneIPC, boolean refinementsClone) { + public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock, boolean cloneIPC, boolean refinementsClone, + boolean refinementsCloneRoot) { super(scope); this.isEnsureBlock = isEnsureBlock; this.cloneIPC = cloneIPC; this.refinementsClone = refinementsClone; + this.refinementsCloneRoot = refinementsCloneRoot; + } + + public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock, boolean cloneIPC, boolean refinementsClone) { + this(scope, isEnsureBlock, cloneIPC, refinementsClone, false); } public SimpleCloneInfo(IRScope scope, boolean isEnsureBlock, boolean cloneIPC) { @@ -38,6 +45,11 @@ public boolean isRefinementsClone() { return this.refinementsClone; } + @Override + public boolean isRefinementsCloneRoot() { + return this.refinementsCloneRoot; + } + public boolean shouldCloneIPC() { return cloneIPC; } diff --git a/core/src/main/java/org/jruby/runtime/IRBlockBody.java b/core/src/main/java/org/jruby/runtime/IRBlockBody.java index 7986996294e..3f823c19fcf 100644 --- a/core/src/main/java/org/jruby/runtime/IRBlockBody.java +++ b/core/src/main/java/org/jruby/runtime/IRBlockBody.java @@ -2,6 +2,7 @@ import org.jruby.RubyArray; import org.jruby.ir.IRClosure; +import org.jruby.ir.IRRefinedClosure; import org.jruby.ir.IRScope; import org.jruby.ir.interpreter.InterpreterContext; import org.jruby.ir.runtime.IRRuntimeHelpers; @@ -195,6 +196,22 @@ public IRClosure getScope() { return (IRClosure) scope.getIRScope(); } + // Proc#refined clones with the same recipe are eql? and hash-equal even as distinct copies, + // as in CRuby; every other body compares by identity. + @Override + public boolean equals(Object other) { + if (this == other) return true; + + return other instanceof IRBlockBody body && + getScope() instanceof IRRefinedClosure scope && body.getScope() instanceof IRRefinedClosure otherScope && + scope.sameRecipe(otherScope); + } + + @Override + public int hashCode() { + return getScope() instanceof IRRefinedClosure refined ? refined.recipeHash() : super.hashCode(); + } + @Override public String getFile() { return fileName; diff --git a/test/jruby/test_proc_refined.rb b/test/jruby/test_proc_refined.rb index d5e0f1d7431..238f6e4284a 100644 --- a/test/jruby/test_proc_refined.rb +++ b/test/jruby/test_proc_refined.rb @@ -13,6 +13,69 @@ def shout end end + # Instructions of a refined clone are copied on the first call: nested closure shells only + # appear once the enclosing body materializes, so a never-called refined proc stays a cheap shell. + def test_clone_materializes_on_first_call + prc = ->(a) { a.map { |s| s.shout } } + refined = prc.refined(StringRefinement) + scope = JRuby.reference(refined).get_block.get_body.get_scope + assert_equal 0, scope.get_closures.size, "nested shells created before the first call" + assert_equal ["A!"], refined.call(["a"]) + assert_equal 1, scope.get_closures.size, "nested shell not created at materialization" + end + + # Concurrent first calls can force the lazy instruction copy from several threads at once; + # a duplicate build would register nested shells twice in the clone. + def test_concurrent_first_call_builds_once + 20.times do + refined = ->(s) { [s].map { |x| x.shout }.first }.refined(StringRefinement) + scope = JRuby.reference(refined).get_block.get_body.get_scope + latch = java.util.concurrent.CountDownLatch.new(1) + results = Queue.new + threads = Array.new(8) do + Thread.new do + latch.await + results << refined.call("a") + end + end + latch.count_down + threads.each(&:join) + 8.times { assert_equal "A!", results.pop } + assert_equal 1, scope.get_closures.size, "duplicate lazy build registered nested shells twice" + end + end + + # p.refined(a).refined(b) shares one memoized clone with p.refined(a, b); the memo is written at + # materialization, so the never-called chain intermediate does not evict the live entry. + def test_chain_shares_memoized_clone + quiet = Module.new { refine(String) { def shout; downcase; end } } + prc = ->(s) { s.shout } + combined = prc.refined(StringRefinement, quiet) + assert_equal "hi", combined.call("Hi") + chained = prc.refined(StringRefinement).refined(quiet) + assert_equal "hi", chained.call("Hi") + combined_scope = JRuby.reference(combined).get_block.get_body.get_scope + chained_scope = JRuby.reference(chained).get_block.get_body.get_scope + # not assert_equal: test-unit's diagnostics call encoding, which IRScope stubs with an exception + assert_same combined_scope, chained_scope, "chained call did not reuse the memoized clone" + end + + # A proc created inside a refined body lives in the clone tree; refined() on it must still produce + # a proper root clone: memoized once materialized, chainable, rejected by define_method. + def test_refined_on_proc_from_refined_body + quiet = Module.new { refine(String) { def shout; downcase; end } } + inner = -> { ->(s) { s.shout } }.refined(StringRefinement).call + r1 = inner.refined(quiet) + assert_equal "hi", r1.call("Hi") + r2 = inner.refined(quiet) + assert_same JRuby.reference(r1).get_block.get_body.get_scope, + JRuby.reference(r2).get_block.get_body.get_scope, + "repeat refined() on a clone-tree proc did not hit the memo" + # the chain re-clones from inner with the merged list, so the later module wins + assert_equal "HI!", r1.refined(StringRefinement).call("Hi") + assert_raise(ArgumentError) { Class.new { define_method(:m, r1) } } + end + # Refinement activation (refined -> usingModuleRecursive) can run on any thread while another # thread mutates the same module's refinement map via Module#refine; the activation must # snapshot the map instead of iterating it unsynchronized (ConcurrentModificationException). From 52400ad5693057643a3869a11a00155819d664ce Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Wed, 30 Sep 2026 21:54:05 -0300 Subject: [PATCH 11/33] Update clone/dup lexical scope tests for Ruby 4.1 (GH #9746) Ruby 4.1 no longer rewrites the lexical scope (cref) of methods copied by Module#clone and Module#dup; constants and class variables resolve through the original class [Feature #21981]. JRuby already behaves this way, so pull in the updated MRI tests from ruby/ruby@88095eb196 and remove the excludes for tests that now pass. --- test/mri/excludes/TestClass.rb | 1 - test/mri/excludes/TestModule.rb | 2 -- test/mri/excludes/TestVariable.rb | 1 - test/mri/ruby/test_class.rb | 5 +++-- test/mri/ruby/test_module.rb | 7 ++++--- test/mri/ruby/test_variable.rb | 13 +++++++++++-- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/test/mri/excludes/TestClass.rb b/test/mri/excludes/TestClass.rb index 934aef73bc6..5aade2f96c5 100644 --- a/test/mri/excludes/TestClass.rb +++ b/test/mri/excludes/TestClass.rb @@ -1,6 +1,5 @@ exclude :test_classext_memory_leak, "no working assert_no_memory_leak method" exclude :test_clone_singleton_class_exists, "work in progress" -exclude :test_constant_access_from_method_in_cloned_class, "work in progress" exclude :test_method_redefinition, "missing a warning" exclude :test_redefine_private_class, "needs investigation" exclude :test_redefinition_mismatch, "missing original method location in warning" diff --git a/test/mri/excludes/TestModule.rb b/test/mri/excludes/TestModule.rb index 48294eace57..358f804b2de 100644 --- a/test/mri/excludes/TestModule.rb +++ b/test/mri/excludes/TestModule.rb @@ -1,10 +1,8 @@ exclude :test_ancestry_of_duped_classes, "handle along with optimizations as described in https://github.com/jruby/jruby/issues/8717" exclude :test_bug17590, "work in progress" -exclude :test_class_variable_in_dup_class, "work in progress" exclude :test_complemented_method_entry_memory_leak, "no working assert_no_memory_leak method" exclude :test_const_defined?, "needs investigation" exclude :test_const_set_invalid_name, "UTF-16 invalid name is accepted as ok" -exclude :test_constant_access_from_method_in_cloned_module, "work in progress" exclude :test_define_module_under_private_constant, "we don't evaluate colon2-like class opening via colon2 logic, can't tell if private are ok" exclude :test_deprecate_constant, "work in progress" exclude :test_iclass_memory_leak, "no working assert_no_memory_leak method" diff --git a/test/mri/excludes/TestVariable.rb b/test/mri/excludes/TestVariable.rb index cfe2671167f..fc8589981b6 100644 --- a/test/mri/excludes/TestVariable.rb +++ b/test/mri/excludes/TestVariable.rb @@ -1,4 +1,3 @@ -exclude :test_cloned_allows_setting_cvar, "needs investigation" exclude :test_exivar_resize_with_compaction_stress, "depends on CRuby GC APIs" exclude :test_global_variables, "requires dynamic listing of backref numbers for available groups" exclude :test_setting_class_variable_on_module_through_inheritance, "work in progress" diff --git a/test/mri/ruby/test_class.rb b/test/mri/ruby/test_class.rb index 8f12e06685b..b328ec6db4e 100644 --- a/test/mri/ruby/test_class.rb +++ b/test/mri/ruby/test_class.rb @@ -435,6 +435,7 @@ class B; end end class CloneTest + TEST = :C0 def foo; TEST; end end @@ -448,8 +449,8 @@ class CloneTest2 end def test_constant_access_from_method_in_cloned_class - assert_equal :C1, CloneTest1.new.foo, '[Bug #15877]' - assert_equal :C2, CloneTest2.new.foo, '[Bug #15877]' + assert_equal :C0, CloneTest1.new.foo, 'originally [Bug #15877], but behaviour changed' + assert_equal :C0, CloneTest2.new.foo, 'originally [Bug #15877], but behaviour changed' end def test_invalid_superclass diff --git a/test/mri/ruby/test_module.rb b/test/mri/ruby/test_module.rb index 30a7c5d9bc1..8c67975b6b4 100644 --- a/test/mri/ruby/test_module.rb +++ b/test/mri/ruby/test_module.rb @@ -2822,7 +2822,7 @@ def a b = a.dup b.new.a = 'B' - assert_equal 'A', a.new.a, '[ruby-core:17019]' + assert_equal 'B', a.new.a, '[ruby-core:17019] behaviour changed: cvar resolves through original CREF' end Bug6891 = '[ruby-core:47241]' @@ -3265,6 +3265,7 @@ def self.m; C; end end module CloneTestM0 + TEST = :M0 def foo; TEST; end end @@ -3288,8 +3289,8 @@ def test_constant_access_from_method_in_cloned_module assert_equal 1, m::C, '[ruby-core:47834]' assert_equal 1, m.m, '[ruby-core:47834]' - assert_equal :M1, CloneTestC1.new.foo, '[Bug #15877]' - assert_equal :M2, CloneTestC2.new.foo, '[Bug #15877]' + assert_equal :M0, CloneTestC1.new.foo, 'originally [Bug #15877], but behaviour changed' + assert_equal :M0, CloneTestC2.new.foo, 'originally [Bug #15877], but behaviour changed' end def test_clone_freeze diff --git a/test/mri/ruby/test_variable.rb b/test/mri/ruby/test_variable.rb index 13b8a7905f2..c642742f490 100644 --- a/test/mri/ruby/test_variable.rb +++ b/test/mri/ruby/test_variable.rb @@ -50,6 +50,11 @@ def test_setting_class_variable_on_module_through_inheritance end Zeus = Gods.clone + class Zeus + def ruler5 + @@rule + end + end def test_cloned_allows_setting_cvar Zeus.class_variable_set(:@@rule, "Athena") @@ -58,8 +63,12 @@ def test_cloned_allows_setting_cvar zeus = Zeus.new.ruler0 assert_equal "Cronus", god - assert_equal "Athena", zeus - assert_not_equal god.object_id, zeus.object_id + assert_equal "Cronus", zeus + + assert_equal "Athena", Zeus.new.ruler5 + + assert_equal "Cronus", Gods.class_variable_get(:@@rule) + assert_equal "Athena", Zeus.class_variable_get(:@@rule) end def test_singleton_class_included_class_variable From 1cdb0413ecf8731b518f5919c2c86ef574d78b4a Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Wed, 30 Sep 2026 21:55:14 -0300 Subject: [PATCH 12/33] Remove exclude for passing TestModule#test_initialize_copy_empty --- test/mri/excludes/TestModule.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/test/mri/excludes/TestModule.rb b/test/mri/excludes/TestModule.rb index 358f804b2de..bc383aa1bc9 100644 --- a/test/mri/excludes/TestModule.rb +++ b/test/mri/excludes/TestModule.rb @@ -8,7 +8,6 @@ exclude :test_iclass_memory_leak, "no working assert_no_memory_leak method" exclude :test_include_with_prepend, "work in progress" exclude :test_initialize_copy, "work in progress" -exclude :test_initialize_copy_empty, "needs investigation" exclude :test_method_redefinition, "needs investigation" exclude :test_module_clone_memory_leak, "no working assert_no_memory_leak method" exclude :test_module_function_inside_method, "work in progress" From 4dc77e2e2dce82cfa695c3e7f307cabe043e716d Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Wed, 30 Sep 2026 16:37:39 -0500 Subject: [PATCH 13/33] Add Integer#bit_count Returns the population count of a non-negative Integer and raises ArgumentError for negative values, matching CRuby. See https://bugs.ruby-lang.org/issues/20163 Part of #9746 Co-Authored-By: Claude Opus 5.5 --- core/src/main/java/org/jruby/RubyBignum.java | 9 +++++++++ core/src/main/java/org/jruby/RubyFixnum.java | 10 ++++++++++ core/src/main/java/org/jruby/RubyInteger.java | 3 +++ 3 files changed, 22 insertions(+) diff --git a/core/src/main/java/org/jruby/RubyBignum.java b/core/src/main/java/org/jruby/RubyBignum.java index 1c30909bdd1..e555ead42b4 100644 --- a/core/src/main/java/org/jruby/RubyBignum.java +++ b/core/src/main/java/org/jruby/RubyBignum.java @@ -754,6 +754,15 @@ public IRubyObject bit_length(ThreadContext context) { return asFixnum(context, value.bitLength()); } + @Override + public IRubyObject bit_count(ThreadContext context) { + if (value.signum() < 0) { + throw argumentError(context, "bit_count is undefined for negative integers"); + } + + return asFixnum(context, value.bitCount()); + } + /** rb_big_xor * */ diff --git a/core/src/main/java/org/jruby/RubyFixnum.java b/core/src/main/java/org/jruby/RubyFixnum.java index 20da809ad47..965031ac535 100644 --- a/core/src/main/java/org/jruby/RubyFixnum.java +++ b/core/src/main/java/org/jruby/RubyFixnum.java @@ -1639,6 +1639,16 @@ public IRubyObject bit_length(ThreadContext context) { return asFixnum(context, 64 - Long.numberOfLeadingZeros(tmpValue)); } + @Override + public IRubyObject bit_count(ThreadContext context) { + long value = getValue(); + if (value < 0) { + throw argumentError(context, "bit_count is undefined for negative integers"); + } + + return asFixnum(context, Long.bitCount(value)); + } + @Deprecated(since = "10.0.3.0") @Override public IRubyObject id() { diff --git a/core/src/main/java/org/jruby/RubyInteger.java b/core/src/main/java/org/jruby/RubyInteger.java index 2adc2fda92b..ec2c551d255 100644 --- a/core/src/main/java/org/jruby/RubyInteger.java +++ b/core/src/main/java/org/jruby/RubyInteger.java @@ -1099,6 +1099,9 @@ public RubyInteger op_rshift(ThreadContext context, long other) { @JRubyMethod(name = "bit_length") public abstract IRubyObject bit_length(ThreadContext context); + @JRubyMethod(name = "bit_count") + public abstract IRubyObject bit_count(ThreadContext context); + @Deprecated(since = "10.0.0.0") boolean isOne() { return isOne(getCurrentContext()); From c30fd4ad41d0d78443e90f7e3d344bb21ef3463f Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Thu, 1 Oct 2026 19:18:21 -0300 Subject: [PATCH 14/33] Warn on module alias falling back to Object (Bug #22276) --- core/src/main/java/org/jruby/RubyModule.java | 12 +++++++++- test/mri/ruby/test_alias.rb | 25 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/jruby/RubyModule.java b/core/src/main/java/org/jruby/RubyModule.java index c694564a070..64cbb471865 100644 --- a/core/src/main/java/org/jruby/RubyModule.java +++ b/core/src/main/java/org/jruby/RubyModule.java @@ -2546,7 +2546,7 @@ public void putAlias(ThreadContext context, String id, CacheEntry entry, String } private CacheEntry searchForAliasMethod(ThreadContext context, String id) { - return deepMethodSearch(context, id); + return deepMethodSearch(context, id, true); } private void checkAliasFrameAccesses(ThreadContext context, String id, String newName, DynamicMethod method) { @@ -2825,6 +2825,10 @@ private void exportMethod(ThreadContext context, String name, Visibility visibil } private CacheEntry deepMethodSearch(ThreadContext context, String id) { + return deepMethodSearch(context, id, false); + } + + private CacheEntry deepMethodSearch(ThreadContext context, String id, boolean forAlias) { CacheEntry orig = searchWithCache(id); if (orig.method.isRefined()) { orig = resolveRefinedMethod(null, orig, id); @@ -2836,6 +2840,12 @@ private CacheEntry deepMethodSearch(ThreadContext context, String id) { RubySymbol name = asSymbol(context, id); throw nameError(context, undefinedMethodMessage(context.runtime, name, rubyName(context), isModule()), name); } + + // Bug #22276: modules aliasing methods found only via the Object fallback + if (forAlias) { + warnDeprecated(context, "the fallback to Object for alias of '" + id + "' in module '" + + rubyName(context) + "' is deprecated and will be removed in Ruby 4.3"); + } } return orig; diff --git a/test/mri/ruby/test_alias.rb b/test/mri/ruby/test_alias.rb index 539cd494887..0f5684bb784 100644 --- a/test/mri/ruby/test_alias.rb +++ b/test/mri/ruby/test_alias.rb @@ -208,6 +208,7 @@ def test_alias_in_module begin; bug = ARGV[0] + Warning[:deprecated] = false # the Object fallback warns since [Bug #22276] m = Module.new do alias orig_to_s to_s end @@ -217,6 +218,30 @@ def test_alias_in_module end; end + def test_alias_fallback_to_object_is_deprecated + bug22276 = '[ruby-core:126537] [Bug #22276]' + message = /the fallback to Object for alias of 'to_s' in module '.*' is deprecated and will be removed in Ruby 4\.3/ + + m = nil + assert_deprecated_warning(message) do + m = Module.new { alias orig_to_s to_s } + end + o = Object.new.extend(m) + assert_equal(o.to_s, o.orig_to_s, bug22276) + + assert_deprecated_warning(message) do + Module.new { alias_method :orig_to_s, :to_s } + end + + assert_warning('', bug22276) do + EnvUtil.deprecation_warning do + Module.new { def foo; end; alias bar foo } + Module.new { include Comparable; alias in_range? between? } + Class.new { alias to_str to_s } + end + end + end + class C0; def foo; end; end class C1 < C0; alias bar foo; end From 89cea1baf4ebbb3a3d3a90897002555422256fed Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Thu, 1 Oct 2026 19:30:21 -0300 Subject: [PATCH 15/33] Add Range#clamp (Feature #22175) --- core/src/main/java/org/jruby/RubyRange.java | 63 ++++++++++++++++++++ test/mri/ruby/test_range.rb | 66 +++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/core/src/main/java/org/jruby/RubyRange.java b/core/src/main/java/org/jruby/RubyRange.java index c0a5bf29803..523e76bb52b 100644 --- a/core/src/main/java/org/jruby/RubyRange.java +++ b/core/src/main/java/org/jruby/RubyRange.java @@ -1272,6 +1272,69 @@ public IRubyObject count(ThreadContext context, IRubyObject arg, Block block) { return RubyEnumerable.count(context, this, arg, block); } + @JRubyMethod + public IRubyObject clamp(ThreadContext context, IRubyObject range) { + RangeLike bounds = rangeValues(context, range); + if (bounds == null) throw typeError(context, range, "Range"); + + return clamp(context, bounds.begin, bounds.end, bounds.excl); + } + + @JRubyMethod + public IRubyObject clamp(ThreadContext context, IRubyObject min, IRubyObject max) { + return clamp(context, min, max, false); + } + + // MRI: range_clamp + private IRubyObject clamp(ThreadContext context, IRubyObject min, IRubyObject max, boolean excl) { + IRubyObject selfBeg = begin; + IRubyObject selfEnd = end; + boolean selfExcl = isExclusive; + int clampBeg = 0, clampEnd = 0; + + if (!min.isNil() && !max.isNil() && rangeCmp(context, min, max) > 0) { + throw argumentError(context, "min argument must be less than or equal to max argument"); + } + + if (!min.isNil()) { + if (selfBeg.isNil() || rangeCmp(context, selfBeg, min) < 0) { + clampBeg = -1; + selfBeg = min; + } + if (!selfEnd.isNil() && rangeCmp(context, selfEnd, min) < 0) { + clampEnd = -1; + selfEnd = min; + } + } + if (!max.isNil()) { + if (clampBeg == 0 && !selfBeg.isNil() && rangeCmp(context, selfBeg, max) > 0) { + clampBeg = 1; + selfBeg = max; + } + if (clampEnd == 0) { + int cmp = selfEnd.isNil() ? 1 : rangeCmp(context, selfEnd, max); + if (cmp > 0) { + clampEnd = 1; + selfEnd = max; + selfExcl = excl; + } else if (cmp == 0) { + selfExcl |= excl; + } + } + } + + // self is entirely outside the clamping bounds + if (clampBeg != 0 && clampBeg == clampEnd) selfExcl = true; + + return newRange(context, selfBeg, selfEnd, selfExcl); + } + + // MRI: r_cmp, raises ArgumentError if a and b are not comparable + private static int rangeCmp(ThreadContext context, IRubyObject a, IRubyObject b) { + JavaSites.RangeSites sites = sites(context); + return RubyComparable.cmpAndCmpint(context, sites.op_cmp, sites.op_gt, sites.op_lt, a, b); + } + @JRubyMethod public IRubyObject minmax(ThreadContext context, Block block) { if (block.isGiven()) return Helpers.invokeSuper(context, this, context.runtime.getRange(), "minmax", NULL_ARRAY, block); diff --git a/test/mri/ruby/test_range.rb b/test/mri/ruby/test_range.rb index ff17dca69ee..e104f4a80bb 100644 --- a/test/mri/ruby/test_range.rb +++ b/test/mri/ruby/test_range.rb @@ -1548,4 +1548,70 @@ def test_overlap? assert_not_operator((1...3), :overlap?, (3..4)) assert_not_operator((...3), :overlap?, (3..)) end + + def test_clamp + # Clamp begin and end to min and max. + assert_equal(3..7, (1..10).clamp(3, 7)) + assert_equal(3..7, (..10).clamp(3, 7)) + + # Clamp begin and end to a range. + assert_equal(3..7, (1..10).clamp(3..7)) + assert_equal(3..5, (1..5).clamp(3...7)) + assert_equal(3...7, (1..10).clamp(DuckRange.new(3, 7, true))) + + # Treat min and max like an inclusive range. + assert_equal((1...10).clamp(3..7), (1...10).clamp(3, 7)) + assert_equal((1...10).clamp(3..10), (1...10).clamp(3, 10)) + + # Exclude the end when the returned end is excluded by self or the + # argument range. + assert_equal(3...10, (1...10).clamp(3, 10)) + assert_equal(3...10, (1...10).clamp(3..10)) + assert_equal(3...10, (1..10).clamp(3...10)) + assert_equal(3...10, (..10).clamp(3...10)) + + # Include the end when the returned end is not an excluded end. + assert_equal(3..7, (1...10).clamp(3, 7)) + assert_equal(0..10, (0...).clamp(0, 10)) + assert_equal(3..5, (1..5).clamp(3...7)) + assert_equal(3..5, (..5).clamp(3...7)) + assert_equal(3..10, (1..10).clamp(3...)) + + # Handle beginless and endless argument ranges. + assert_equal(1..7, (1..10).clamp(..7)) + assert_equal(1...7, (1..10).clamp(...7)) + assert_equal(1..5, (1..5).clamp(...7)) + + assert_equal(3..10, (1..10).clamp(3..)) + assert_equal(3.., (1..).clamp(3..)) + assert_equal(3..., (1...).clamp(3...)) + + # Return an inclusive point range when begin and end are clamped to + # different equal bounds. + assert_equal(3..3, (1..10).clamp(3, 3)) + assert_equal(3..3, (1..10).clamp(3..3)) + + # Return an empty range when begin and end are clamped to the same side. + assert_equal(20...20, (1..10).clamp(20, 30)) + assert_equal(0...0, (1..10).clamp(-10, 0)) + assert_equal(20...20, (..10).clamp(20, 30)) + assert_equal(20...20, (1..10).clamp(20..30)) + assert_equal(0...0, (1..10).clamp(-10..0)) + assert_equal(20...20, (..10).clamp(20..30)) + assert_equal(3...3, (1..2).clamp(3, 3)) + assert_equal(3...3, (4..10).clamp(3, 3)) + assert_equal(0...0, (1..).clamp(nil, 0)) + assert_equal(0...0, (1..).clamp(..0)) + + # Return a Range instance, not an instance of a subclass. + subclass = Class.new(Range) + assert_instance_of(Range, subclass.new(1, 10).clamp(3, 7)) + + # Raise for invalid bounds or argument types. + assert_raise(ArgumentError) {(1..3).clamp(1, "z")} + assert_raise(ArgumentError) {(1..3).clamp("a", "z")} + assert_raise(ArgumentError) {(1..3).clamp(2, 1)} + assert_raise(ArgumentError) {(1..3).clamp(2..1)} + assert_raise(TypeError) {(1..3).clamp(1)} + end end From da3ba1430ab7b15e6219bf705fc324248795b74c Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Thu, 1 Oct 2026 19:37:05 -0300 Subject: [PATCH 16/33] Sync test_alias.rb with CRuby --- test/mri/ruby/test_alias.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/mri/ruby/test_alias.rb b/test/mri/ruby/test_alias.rb index 0f5684bb784..9b55c425ed8 100644 --- a/test/mri/ruby/test_alias.rb +++ b/test/mri/ruby/test_alias.rb @@ -208,9 +208,10 @@ def test_alias_in_module begin; bug = ARGV[0] - Warning[:deprecated] = false # the Object fallback warns since [Bug #22276] - m = Module.new do - alias orig_to_s to_s + m = EnvUtil.suppress_warning do + Module.new do + alias orig_to_s to_s + end end o = Object.new.extend(m) From 54eb3053a1cddefa29db7390c9316d1ce9fafb94 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Wed, 30 Sep 2026 17:06:39 -0500 Subject: [PATCH 17/33] Add MatchData#integer_at Converts the matched substring to an Integer like String#to_i, but returns nil when nothing could be parsed, matching CRuby's use of rb_int_parse_cstr. ConvertBytes gains byteListToInumOrNil for this. Also add the missing colon to the "undefined group name reference" IndexError raised by nameToBackrefError, matching CRuby. See https://bugs.ruby-lang.org/issues/21932 Part of #9746 Co-Authored-By: Claude Opus 5.5 --- .../main/java/org/jruby/RubyMatchData.java | 42 ++++++++++++++++++- .../java/org/jruby/util/ConvertBytes.java | 16 +++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/jruby/RubyMatchData.java b/core/src/main/java/org/jruby/RubyMatchData.java index edd22aa5d6b..238e066f717 100644 --- a/core/src/main/java/org/jruby/RubyMatchData.java +++ b/core/src/main/java/org/jruby/RubyMatchData.java @@ -54,6 +54,7 @@ import org.jruby.runtime.builtin.IRubyObject; import org.jruby.util.ByteList; import org.jruby.util.ByteListHolder; +import org.jruby.util.ConvertBytes; import org.jruby.util.RegexpOptions; import org.jruby.util.StringSupport; @@ -580,7 +581,7 @@ private int namevToBackrefNumber(ThreadContext context, IRubyObject name) { } private int nameToBackrefError(ThreadContext context, String name) { - throw indexError(context, "undefined group name reference " + name); + throw indexError(context, "undefined group name reference: " + name); } // MRI: match_ary_subseq @@ -669,6 +670,45 @@ public final IRubyObject at(ThreadContext context, final int nth) { return RubyRegexp.nth_match(context, nth, this); } + /** + * MRI: match_integer_at + */ + @JRubyMethod + public IRubyObject integer_at(ThreadContext context, IRubyObject idx) { + return integerAt(context, idx, null); + } + + @JRubyMethod + public IRubyObject integer_at(ThreadContext context, IRubyObject idx, IRubyObject base) { + return integerAt(context, idx, base); + } + + private IRubyObject integerAt(ThreadContext context, IRubyObject idx, IRubyObject baseArg) { + check(context); + + int nth; + if (idx instanceof RubyFixnum) { + nth = toInt(context, idx); + } else if ((nth = namevToBackrefNumber(context, idx)) < 0) { + nameToBackrefError(context, idx.asString().toString()); + } + + int base = baseArg == null ? 10 : toInt(context, baseArg); + if (base < 0 || base == 1 || base > 36) { + throw argumentError(context, "invalid radix " + base); + } + + int numRegs = regs == null ? 1 : regs.getNumRegs(); + if (nth >= numRegs) return context.nil; + if (nth < 0 && (nth += numRegs) <= 0) return context.nil; + + int start = regs == null ? begin : regs.getBeg(nth); + if (start < 0) return context.nil; + int end = regs == null ? this.end : regs.getEnd(nth); + + return ConvertBytes.byteListToInumOrNil(context.runtime, str.getByteList(), start, end, base); + } + /** match_size * */ diff --git a/core/src/main/java/org/jruby/util/ConvertBytes.java b/core/src/main/java/org/jruby/util/ConvertBytes.java index 7e84088377d..46ade44ece2 100644 --- a/core/src/main/java/org/jruby/util/ConvertBytes.java +++ b/core/src/main/java/org/jruby/util/ConvertBytes.java @@ -17,6 +17,7 @@ public class ConvertBytes { private byte[] data; private int base; private final boolean badcheck; + private boolean nilIfNoDigits; public ConvertBytes(Ruby runtime, ByteList str, int base, boolean badcheck) { this.runtime = runtime; @@ -366,6 +367,14 @@ public static RubyInteger byteListToInum(Ruby runtime, ByteList str, int off, in return (RubyInteger) new ConvertBytes(runtime, str, off, end, base, badcheck).byteListToInum(true); } + // MRI: rb_int_parse_cstr with endp, which ignores trailing garbage but returns nil + // if nothing could be parsed + public static IRubyObject byteListToInumOrNil(Ruby runtime, ByteList str, int off, int end, int base) { + ConvertBytes convert = new ConvertBytes(runtime, str, off, end, base, false); + convert.nilIfNoDigits = true; + return convert.byteListToInum(true); + } + private final static byte[] conv_digit = new byte[128]; private final static boolean[] digit = new boolean[128]; private final static boolean[] space = new boolean[128]; @@ -686,6 +695,8 @@ public IRubyObject byteListToInum(boolean exception) { return RubyFixnum.zero(runtime); } + final int start = beg; + ignoreLeadingWhitespace(); boolean sign = getSign(); @@ -716,6 +727,11 @@ public IRubyObject byteListToInum(boolean exception) { if (!exception) return runtime.getNil(); invalidString("Integer"); } + // MRI only returns 0 if whitespace, sign, prefix or zeros were consumed + // without reaching the end + if (nilIfNoDigits && (beg == start || beg == end)) { + return runtime.getNil(); + } return RubyFixnum.zero(runtime); } From 6095a2c82d3cb48a4dae44a6cd0351b60e95373b Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Sat, 3 Oct 2026 16:35:57 -0300 Subject: [PATCH 18/33] Remove arguments from to_set (Feature #21390) --- core/src/main/java/org/jruby/RubyRange.java | 6 +- .../java/org/jruby/ext/set/EnumerableExt.java | 13 +- .../main/java/org/jruby/ext/set/RubySet.java | 29 +--- core/src/main/ruby/jruby/kernel/enumerable.rb | 11 +- core/src/main/ruby/jruby/kernel/prelude.rb | 11 +- test/mri/excludes/TC_Set.rb | 4 + test/mri/ruby/test_set.rb | 151 +++++++++++++++++- 7 files changed, 162 insertions(+), 63 deletions(-) create mode 100644 test/mri/excludes/TC_Set.rb diff --git a/core/src/main/java/org/jruby/RubyRange.java b/core/src/main/java/org/jruby/RubyRange.java index 523e76bb52b..e37d119d1dd 100644 --- a/core/src/main/java/org/jruby/RubyRange.java +++ b/core/src/main/java/org/jruby/RubyRange.java @@ -1434,13 +1434,13 @@ public IRubyObject size(ThreadContext context) { return context.nil; } - @JRubyMethod(name = "to_set", rest = true, frame = true) - public IRubyObject to_set(ThreadContext context, IRubyObject[] args, Block block) { + @JRubyMethod(name = "to_set", frame = true) + public IRubyObject to_set(ThreadContext context, Block block) { if (isEndless) { throw context.runtime.newRangeError("cannot convert endless range to a set"); } - return invokeSuper(context, this, context.getFrameKlazz(), context.getFrameName(), args, block); + return invokeSuper(context, this, context.getFrameKlazz(), context.getFrameName(), NULL_ARRAY, block); } private boolean discreteObject(IRubyObject object) { diff --git a/core/src/main/java/org/jruby/ext/set/EnumerableExt.java b/core/src/main/java/org/jruby/ext/set/EnumerableExt.java index f0a46680b71..02786fa2bfe 100644 --- a/core/src/main/java/org/jruby/ext/set/EnumerableExt.java +++ b/core/src/main/java/org/jruby/ext/set/EnumerableExt.java @@ -29,7 +29,6 @@ package org.jruby.ext.set; import org.jruby.Ruby; -import org.jruby.RubyClass; import org.jruby.anno.JRubyMethod; import org.jruby.api.Access; import org.jruby.runtime.Block; @@ -43,21 +42,11 @@ */ public abstract class EnumerableExt { - //@JRubyMethod + @JRubyMethod public static IRubyObject to_set(final ThreadContext context, final IRubyObject self, final Block block) { RubySet set = new RubySet(context.runtime, Access.getClass(context, "Set"), false); set.initialize(context, self, block); return set; // return runtime.getClass("Set").newInstance(context, self, block); } - @JRubyMethod(rest = true) // to_set(klass = Set, *args, &block) - public static IRubyObject to_set(final ThreadContext context, final IRubyObject self, - final IRubyObject[] args, final Block block) { - - if ( args.length == 0 ) return to_set(context, self, block); - - final IRubyObject klass = args[0]; args[0] = self; - return ((RubyClass) klass).newInstance(context, args, block); - } - } diff --git a/core/src/main/java/org/jruby/ext/set/RubySet.java b/core/src/main/java/org/jruby/ext/set/RubySet.java index c6e7a63621b..0fabfbdcd13 100644 --- a/core/src/main/java/org/jruby/ext/set/RubySet.java +++ b/core/src/main/java/org/jruby/ext/set/RubySet.java @@ -36,13 +36,11 @@ import org.jruby.api.Access; import org.jruby.api.Create; import org.jruby.api.Error; -import org.jruby.api.Warn; import org.jruby.javasupport.JavaUtil; import org.jruby.runtime.*; import org.jruby.runtime.builtin.IRubyObject; import org.jruby.runtime.marshal.MarshalDumper; import org.jruby.runtime.marshal.MarshalLoader; -import org.jruby.util.ArraySupport; import org.jruby.util.io.RubyInputStream; import org.jruby.util.io.RubyOutputStream; @@ -442,7 +440,7 @@ public RubyArray to_a(final ThreadContext context) { return this.hash.keys(context); } - // Returns self if no arguments are given. + // Returns self if no block is given. @JRubyMethod public RubySet to_set(final ThreadContext context, final Block block) { if ( block.isGiven() ) { @@ -453,31 +451,6 @@ public RubySet to_set(final ThreadContext context, final Block block) { return this; } - // Otherwise, converts the set to another with klass.new(self, *args, &block). - @JRubyMethod(rest = true) - public RubySet to_set(final ThreadContext context, final IRubyObject[] args, final Block block) { - if ( args.length == 0 ) return to_set(context, block); - - Warn.warnDeprecated(context, "passing arguments to Set#to_set"); - - IRubyObject klass = args[0]; - final RubyClass Set = Access.getClass(context, "Set"); - - if (klass == Set && args.length == 1 && !block.isGiven()) return this; - - final IRubyObject[] rest; - if (klass instanceof RubyClass) { - rest = ArraySupport.newCopy(args, 1, args.length - 1); - } else { - klass = Set; - rest = args; - } - - RubySet set = new RubySet(context.runtime, (RubyClass) klass, false); - set.initialize(context, rest, block); - return set; - } - @JRubyMethod public IRubyObject compare_by_identity(ThreadContext context) { if (hash.isComparedByIdentity()) return this; diff --git a/core/src/main/ruby/jruby/kernel/enumerable.rb b/core/src/main/ruby/jruby/kernel/enumerable.rb index 358786a4402..e60b13b8ced 100644 --- a/core/src/main/ruby/jruby/kernel/enumerable.rb +++ b/core/src/main/ruby/jruby/kernel/enumerable.rb @@ -115,14 +115,7 @@ def enumerator_size end private :enumerator_size - # Passing arguments to this method is deprecated. - def to_set(*args, &block) - klass = if args.empty? - Set - else - warn "passing arguments to Enumerable#to_set is deprecated", uplevel: 1 - args.shift - end - klass.new(self, *args, &block) + def to_set(&block) + Set.new(self, &block) end end diff --git a/core/src/main/ruby/jruby/kernel/prelude.rb b/core/src/main/ruby/jruby/kernel/prelude.rb index 06949076c84..7b5a7668982 100644 --- a/core/src/main/ruby/jruby/kernel/prelude.rb +++ b/core/src/main/ruby/jruby/kernel/prelude.rb @@ -30,14 +30,7 @@ def pp(*objs) module Enumerable # Makes a set from the enumerable object with given arguments. - # Passing arguments to this method is deprecated. - def to_set(*args, &block) - klass = if args.empty? - Set - else - warn "passing arguments to Enumerable#to_set is deprecated", uplevel: 1 - args.shift - end - klass.new(self, *args, &block) + def to_set(&block) + Set.new(self, &block) end end diff --git a/test/mri/excludes/TC_Set.rb b/test/mri/excludes/TC_Set.rb new file mode 100644 index 00000000000..5ab4d4a587e --- /dev/null +++ b/test/mri/excludes/TC_Set.rb @@ -0,0 +1,4 @@ +exclude :test_compare_by_identity_compact, "no GC.verify_compaction_references method" +exclude :test_compare_by_identity_preservation, "Set operations do not preserve compare_by_identity yet" +exclude :test_iteration_mutation_guards, "Set does not raise when mutated during iteration yet" +exclude :test_xor, "Set#^ does not preserve compare_by_identity yet" diff --git a/test/mri/ruby/test_set.rb b/test/mri/ruby/test_set.rb index 70a61aa3b5d..c1135c86ce3 100644 --- a/test/mri/ruby/test_set.rb +++ b/test/mri/ruby/test_set.rb @@ -687,6 +687,18 @@ def test_and ret = set & [2,4,6] assert_not_same(set, ret) assert_equal(Set[2,4], ret) + + assert_separately([], "#{<<-"{#"}\n#{<<-'};'}") + {# + class << (obj = Object.new) + include Enumerable + attr_reader :block + def each(&block) = (@block = block) + end + assert_equal(Set[], Set[1, 2, 3] & obj) + GC.start + assert_nil(obj.block.call(1)) + }; end def test_xor @@ -700,6 +712,32 @@ def test_xor ret2 = set2 ^ [2,4,5,5] assert_instance_of(klass, ret2) assert_equal(klass[1,3,5], ret2) + + # Set ^ Set should not mutate either operand and should be symmetric + a = klass[1,2,3,4] + b = klass[2,4,5] + a_copy = a.dup + b_copy = b.dup + ret3 = a ^ b + ret4 = b ^ a + assert_equal(klass[1,3,5], ret3) + assert_equal(ret3, ret4) + assert_equal(a_copy, a) + assert_equal(b_copy, b) + assert_instance_of(klass, ret3) + assert_instance_of(klass, ret4) + + # compare_by_identity should keep distinct objects from enumerable RHS + iset = Set.new.compare_by_identity + x1 = +"x" + x2 = +"x" + ret5 = iset ^ [x1, x2] + assert_equal(2, ret5.size) + arr = ret5.to_a + assert_include(arr, x1) + assert_include(arr, x2) + assert_not_predicate(x1, :frozen?) + assert_not_predicate(x2, :frozen?) } end @@ -902,6 +940,25 @@ def test_compare_by_identity assert_equal(array.uniq.sort, set.sort) end + def test_compare_by_identity_compact + omit "compaction is not supported on this platform" unless GC.respond_to?(:compact) + + # [Bug #22064] + assert_separately([], "#{<<~"begin;"}\n#{<<~'end;'}") + begin; + set = Set.new.compare_by_identity + + o = Object.new + set.add(o) + + assert_include(set, o) + + GC.verify_compaction_references(expand_heap: true, toward: :empty) + + assert_include(set, o) + end; + end + def test_reset [Set, Class.new(Set)].each { |klass| a = [1, 2] @@ -939,7 +996,7 @@ def test_larger_sets set = set.dup 10_000.times do |i| - assert_includes set, i + assert_include set, i end end @@ -963,6 +1020,95 @@ def initialize(enum) assert_equal([2, 1], c[2].to_a) end + def test_iteration_mutation_guards + set = Set[1, 2] + assert_raise(RuntimeError) { set.each { set.add(3) } } + assert_raise(RuntimeError) { set.each { set.merge([3]) } } + assert_raise(RuntimeError) { set.each { set.compare_by_identity } } + assert_raise(RuntimeError) { set.each { set.reset } } + end + + def test_string_freezing_semantics + s = "foo" + set = Set.new + set << s + stored = set.to_a.first + assert_not_same(s, stored) + assert_predicate(stored, :frozen?) + + s2 = "bar" + iset = Set.new.compare_by_identity + iset << s2 + stored2 = iset.to_a.first + assert_same(s2, stored2) + assert_not_predicate(stored2, :frozen?) + + sf = "baz".freeze + set2 = Set.new + set2 << sf + assert_same(sf, set2.to_a.first) + end + + def test_compare_by_identity_hash_and_eq + a1, a2 = "a", "a" + set_default1 = Set[a1] + set_default2 = Set[a2] + assert_equal(set_default1, set_default2) + assert_equal(set_default1.hash, set_default2.hash) + + iset = Set.new.compare_by_identity + iset.merge([a1, a2]) + assert_not_equal(set_default1, iset) + refute_equal(set_default1.hash, iset.hash) + end + + def test_insertion_order_preserved + set = Set[3, 1, 2] + assert_equal([3, 1, 2], set.to_a) + + iset = Set.new.compare_by_identity + iset.merge([3, 1, 2]) + assert_equal([3, 1, 2], iset.to_a) + end + + def test_compare_by_identity_preservation + iset = Set.new.compare_by_identity + + # 1. Intersection (&) + assert_predicate(iset & [1, 2], :compare_by_identity?) + + # 2. Collect! / Map! + iset_collect = Set["a", "b"].compare_by_identity + iset_collect.collect! { |x| x } + assert_predicate(iset_collect, :compare_by_identity?) + + # 3. Flatten / Flatten! + iset_flat = Set[Set[1, 2]].compare_by_identity + assert_predicate(iset_flat.flatten, :compare_by_identity?) + iset_flat.flatten! + assert_predicate(iset_flat, :compare_by_identity?) + + # 4. Classify + iset_classify = Set["a", "b"].compare_by_identity + classified = iset_classify.classify { |x| x } + classified.each_value do |subset| + assert_predicate(subset, :compare_by_identity?) + end + + # 5. Divide (arity 1 and arity 2) + iset_divide1 = Set["a", "b"].compare_by_identity + divided1 = iset_divide1.divide { |x| x } + divided1.each do |subset| + assert_predicate(subset, :compare_by_identity?) + end + + iset_divide2 = Set["a", "b"].compare_by_identity + divided2 = iset_divide2.divide { |x, y| x == y } + divided2.each do |subset| + assert_predicate(subset, :compare_by_identity?) + end + end + end class TC_Enumerable < Test::Unit::TestCase @@ -978,7 +1124,8 @@ def test_to_set assert_equal([-10,-8,-6,-4,-2], set.sort) assert_same set, set.to_set - assert_not_same set, set.to_set { |o| o } + transformed = set.to_set { |o| o + 1 } + assert_equal([-9,-7,-5,-3,-1], transformed.sort) end class MyEnum From cfa8e9b9d2475403c3b4c650da685c571ea8972b Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Sun, 4 Oct 2026 10:24:06 -0300 Subject: [PATCH 19/33] Preserve compare_by_identity in Set operations --- .../main/java/org/jruby/ext/set/RubySet.java | 39 ++++++++++++++++--- spec/ruby/core/set/classify_spec.rb | 18 +++++++-- spec/ruby/core/set/divide_spec.rb | 18 +++++++-- spec/ruby/core/set/flatten_spec.rb | 33 ++++++++++++---- spec/ruby/core/set/intersection_spec.rb | 18 +++++++-- spec/ruby/core/set/map_spec.rb | 18 +++++++-- spec/tags/ruby/core/set/flatten_tags.txt | 1 - spec/tags/ruby/core/set/map_tags.txt | 1 - test/mri/excludes/TC_Set.rb | 2 - 9 files changed, 115 insertions(+), 33 deletions(-) delete mode 100644 spec/tags/ruby/core/set/flatten_tags.txt delete mode 100644 spec/tags/ruby/core/set/map_tags.txt diff --git a/core/src/main/java/org/jruby/ext/set/RubySet.java b/core/src/main/java/org/jruby/ext/set/RubySet.java index 0fabfbdcd13..b3e7cbd1c15 100644 --- a/core/src/main/java/org/jruby/ext/set/RubySet.java +++ b/core/src/main/java/org/jruby/ext/set/RubySet.java @@ -210,6 +210,22 @@ RubySet newSetFast(final Ruby runtime) { return newSet(runtime, getMetaClass()); } + /** + * Construct a new Set with the same class as this one, comparing by identity if this one does. + * + * @param context the current thread context + * @return a new Set + */ + private RubySet newSetPreservingIdentity(final ThreadContext context) { + return preserveIdentity(context, newSetFast(context.runtime)); + } + + // Must be called before any elements are added to set. + private RubySet preserveIdentity(final ThreadContext context, final RubySet set) { + if (hash.isComparedByIdentity()) set.hash.compare_by_identity(context); + return set; + } + /** * Construct a new Set. The Set class will be retrieved from the global namespace. * @@ -501,7 +517,7 @@ private void addFlattened(final ThreadContext context, final IdentityHashMap see // Returns a new set that is a copy of the set, flattening each containing set recursively. @JRubyMethod public RubySet flatten(final ThreadContext context) { - return newSetFast(context.runtime).flatten_merge(context, this); + return newSetPreservingIdentity(context).flatten_merge(context, this); } @JRubyMethod(name = "flatten!") @@ -839,17 +855,20 @@ public IRubyObject op_and(final ThreadContext context, IRubyObject enume) { final RubySet newSet = new RubySet(context.runtime, getMetaClass(), false); if (enume instanceof RubySet set) { newSet.allocHash(context, set.size()); + preserveIdentity(context, newSet); for ( IRubyObject obj : set.elementsOrdered() ) { if (containsImpl(obj)) newSet.addImpl(context, obj); } } else if (enume instanceof RubyArray ary) { newSet.allocHash(context, ary.size()); + preserveIdentity(context, newSet); for ( int i = 0; i < ary.size(); i++ ) { final IRubyObject obj = ary.eltInternal(i); if (containsImpl(obj)) newSet.addImpl(context, obj); } } else { newSet.allocHash(context); + preserveIdentity(context, newSet); // do_with_enum(enum) { |o| newSet.add(o) if include?(o) } doWithEnum(context, enume, new EachBody(context) { IRubyObject yieldImpl(ThreadContext context, IRubyObject obj) { @@ -873,8 +892,18 @@ public IRubyObject op_xor(final ThreadContext context, IRubyObject enume) { if (enume instanceof RubySet set) { otherSet = set; } else { - otherSet = new RubySet(context.runtime, getMetaClass(), false); - otherSet.initialize(context, enume, Block.NULL_BLOCK); // Set.new(enum) + // Set.new(enum), comparing by identity like self so distinct elements of enum stay distinct + final RubySet tmp = otherSet = newSetPreservingIdentity(context); + if (enume instanceof RubyArray ary) { + for ( int i = 0; i < ary.size(); i++ ) tmp.addImpl(context, ary.eltInternal(i)); + } else { + doWithEnum(context, enume, new EachBody(context) { + IRubyObject yieldImpl(ThreadContext context, IRubyObject obj) { + tmp.addImpl(context, obj); + return context.nil; + } + }); + } } for (IRubyObject o : otherSet.elementsOrdered()) { if (newSet.containsImpl(o)) { @@ -947,7 +976,7 @@ public IRubyObject classify(ThreadContext context, final Block block) { final IRubyObject key = block.yield(context, i); RubySet set = (RubySet) h.fastARef(key); if (set == null) { - set = newSetFast(context.runtime); + set = newSetPreservingIdentity(context); h.fastASet(key, set); } set.invokeAdd(context, i); @@ -1028,7 +1057,7 @@ IRubyObject setDivideArity2(ThreadContext context, Block block) { IRubyObject set = hash.op_aref(context, asFixnum(context, root)); if (set.isNil()) { - set = newSet(context.runtime, setClass); + set = preserveIdentity(context, newSet(context.runtime, setClass)); hash.op_aset(context, asFixnum(context, root), set); finalSet.add(context, set); } diff --git a/spec/ruby/core/set/classify_spec.rb b/spec/ruby/core/set/classify_spec.rb index 1cfacf3576c..a9521d3b528 100644 --- a/spec/ruby/core/set/classify_spec.rb +++ b/spec/ruby/core/set/classify_spec.rb @@ -24,9 +24,19 @@ classified.should == { 3 => Set["one", "two"], 4 => Set["four"], 5 => Set["three"] } end - it "does not retain compare_by_identity flag" do - set = Set["one", "two"].compare_by_identity - classified = set.classify { |x| x.length } - classified.values.each { |s| s.compare_by_identity?.should == false } + ruby_version_is "4.1" do + it "retains compare_by_identity flag" do + set = Set["one", "two"].compare_by_identity + classified = set.classify { |x| x.length } + classified.values.each { |s| s.compare_by_identity?.should == true } + end + end + + ruby_version_is ""..."4.0" do + it "does not retain compare_by_identity flag" do + set = Set["one", "two"].compare_by_identity + classified = set.classify { |x| x.length } + classified.values.each { |s| s.compare_by_identity?.should == false } + end end end diff --git a/spec/ruby/core/set/divide_spec.rb b/spec/ruby/core/set/divide_spec.rb index e00e4616e19..96880db9b4b 100644 --- a/spec/ruby/core/set/divide_spec.rb +++ b/spec/ruby/core/set/divide_spec.rb @@ -18,10 +18,20 @@ ret.each(&:even?).should == Set[Set[1, 3], Set[2, 4]] end - it "does not retain compare_by_identity flag" do - set = Set["one", "two"].compare_by_identity - res = set.divide { |x| x.length } - res.each { |s| s.compare_by_identity?.should == false } + ruby_version_is "4.1" do + it "retains compare_by_identity flag" do + set = Set["one", "two"].compare_by_identity + res = set.divide { |x| x.length } + res.each { |s| s.compare_by_identity?.should == true } + end + end + + ruby_version_is ""..."4.0" do + it "does not retain compare_by_identity flag" do + set = Set["one", "two"].compare_by_identity + res = set.divide { |x| x.length } + res.each { |s| s.compare_by_identity?.should == false } + end end end diff --git a/spec/ruby/core/set/flatten_spec.rb b/spec/ruby/core/set/flatten_spec.rb index 65c63a9b7f3..9fc05ea3731 100644 --- a/spec/ruby/core/set/flatten_spec.rb +++ b/spec/ruby/core/set/flatten_spec.rb @@ -16,17 +16,24 @@ -> { set.flatten }.should.raise(ArgumentError) end + ruby_version_is "4.1" do + it "retains compare_by_identity flag" do + set = Set[1, 2, Set[3, 4]].compare_by_identity + set.flatten.compare_by_identity?.should == true + end + end + ruby_version_is ""..."4.0" do context "when Set contains a Set-like object" do it "returns a copy of self with each included Set-like object flattened" do Set[SetSpecs::SetLike.new([1])].flatten.should == Set[1] end end - end - it "does not retain compare_by_identity flag" do - set = Set[1, 2, Set[3, 4]].compare_by_identity - set.flatten.compare_by_identity?.should == false + it "does not retain compare_by_identity flag" do + set = Set[1, 2, Set[3, 4]].compare_by_identity + set.flatten.compare_by_identity?.should == false + end end end @@ -52,10 +59,20 @@ -> { set.flatten! }.should.raise(ArgumentError) end - it "does not retain compare_by_identity flag when flattening elements" do - set = Set[1, 2, Set[3, 4]].compare_by_identity - set.flatten! - set.compare_by_identity?.should == false + ruby_version_is "4.1" do + it "retains compare_by_identity flag when flattening elements" do + set = Set[1, 2, Set[3, 4]].compare_by_identity + set.flatten! + set.compare_by_identity?.should == true + end + end + + ruby_version_is ""..."4.0" do + it "does not retain compare_by_identity flag when flattening elements" do + set = Set[1, 2, Set[3, 4]].compare_by_identity + set.flatten! + set.compare_by_identity?.should == false + end end it "retains compare_by_identity flag if no elements are flattened" do diff --git a/spec/ruby/core/set/intersection_spec.rb b/spec/ruby/core/set/intersection_spec.rb index 3551fe70189..2e1388e0cc3 100644 --- a/spec/ruby/core/set/intersection_spec.rb +++ b/spec/ruby/core/set/intersection_spec.rb @@ -21,9 +21,19 @@ -> { @set & Object.new }.should.raise(ArgumentError) end - it "does not retain compare_by_identity flag" do - @set.compare_by_identity - (@set & Set[:b, :c, :d, :e]).compare_by_identity?.should == false - (@set & [:b, :c, :d]).compare_by_identity?.should == false + ruby_version_is "4.1" do + it "retains compare_by_identity flag" do + @set.compare_by_identity + (@set & Set[:b, :c, :d, :e]).compare_by_identity?.should == true + (@set & [:b, :c, :d]).compare_by_identity?.should == true + end + end + + ruby_version_is ""..."4.0" do + it "does not retain compare_by_identity flag" do + @set.compare_by_identity + (@set & Set[:b, :c, :d, :e]).compare_by_identity?.should == false + (@set & [:b, :c, :d]).compare_by_identity?.should == false + end end end diff --git a/spec/ruby/core/set/map_spec.rb b/spec/ruby/core/set/map_spec.rb index 6f6959feebd..7121afc459b 100644 --- a/spec/ruby/core/set/map_spec.rb +++ b/spec/ruby/core/set/map_spec.rb @@ -20,9 +20,19 @@ @set.should == Set[2, 4, 6, 8, 10] end - it "does not retain compare_by_identity flag" do - @set.compare_by_identity - @set.map! { |x| x * 2 } - @set.compare_by_identity?.should == false + ruby_version_is "4.1" do + it "retains compare_by_identity flag" do + @set.compare_by_identity + @set.map! { |x| x * 2 } + @set.compare_by_identity?.should == true + end + end + + ruby_version_is ""..."4.0" do + it "does not retain compare_by_identity flag" do + @set.compare_by_identity + @set.map! { |x| x * 2 } + @set.compare_by_identity?.should == false + end end end diff --git a/spec/tags/ruby/core/set/flatten_tags.txt b/spec/tags/ruby/core/set/flatten_tags.txt deleted file mode 100644 index a70a8488266..00000000000 --- a/spec/tags/ruby/core/set/flatten_tags.txt +++ /dev/null @@ -1 +0,0 @@ -fails:Set#flatten! does not retain compare_by_identity flag when flattening elements diff --git a/spec/tags/ruby/core/set/map_tags.txt b/spec/tags/ruby/core/set/map_tags.txt deleted file mode 100644 index fcfe86f918a..00000000000 --- a/spec/tags/ruby/core/set/map_tags.txt +++ /dev/null @@ -1 +0,0 @@ -fails:Set#map! does not retain compare_by_identity flag diff --git a/test/mri/excludes/TC_Set.rb b/test/mri/excludes/TC_Set.rb index 5ab4d4a587e..0f74220d0aa 100644 --- a/test/mri/excludes/TC_Set.rb +++ b/test/mri/excludes/TC_Set.rb @@ -1,4 +1,2 @@ exclude :test_compare_by_identity_compact, "no GC.verify_compaction_references method" -exclude :test_compare_by_identity_preservation, "Set operations do not preserve compare_by_identity yet" exclude :test_iteration_mutation_guards, "Set does not raise when mutated during iteration yet" -exclude :test_xor, "Set#^ does not preserve compare_by_identity yet" From a66e1b13f4c8c20725db59c2e487867e76c5c4fc Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Tue, 6 Oct 2026 21:01:09 -0500 Subject: [PATCH 20/33] Delete thread variable when Thread#thread_variable_set is given nil Matches Ruby 4.1 (Feature #19333). Untag the passing ruby/spec example. Co-Authored-By: Claude Sonnet 5.5 --- core/src/main/java/org/jruby/RubyThread.java | 9 ++++++++- spec/tags/ruby/core/thread/thread_variable_set_tags.txt | 1 - 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyThread.java b/core/src/main/java/org/jruby/RubyThread.java index 9d294981313..aeb0894a51d 100644 --- a/core/src/main/java/org/jruby/RubyThread.java +++ b/core/src/main/java/org/jruby/RubyThread.java @@ -1184,11 +1184,18 @@ public IRubyObject thread_variable_get(ThreadContext context, IRubyObject key) { @JRubyMethod(name = "thread_variable_set") public IRubyObject thread_variable_set(ThreadContext context, IRubyObject key, IRubyObject value) { + // FIXME: FrozenError is raised in both, but the message differs (ruby/spec expects the CRuby one): + // CRuby: can't modify frozen thread locals + // JRuby: can't modify frozen Object: # checkFrozen(); key = RubySymbol.idSymbolFromObject(context, key); final Map locals = getThreadLocals(); synchronized (locals) { - locals.put(key, value); + if (value.isNil()) { + locals.remove(key); + } else { + locals.put(key, value); + } } return value; } diff --git a/spec/tags/ruby/core/thread/thread_variable_set_tags.txt b/spec/tags/ruby/core/thread/thread_variable_set_tags.txt index 47794ff655a..696eea91d76 100644 --- a/spec/tags/ruby/core/thread/thread_variable_set_tags.txt +++ b/spec/tags/ruby/core/thread/thread_variable_set_tags.txt @@ -1,5 +1,4 @@ fails:Thread#thread_variable_set converts a key that is neither String nor Symbol with #to_str -fails:Thread#thread_variable_set removes a key if the value is nil fails:Thread#thread_variable_set raises a FrozenError if the thread is frozen fails:Thread#thread_variable_set raises a TypeError if the key is neither Symbol nor String, nor responds to #to_str fails:Thread#thread_variable_set does not try to convert the key with #to_sym From 758fed4b830211a8e1fc9e4650f4b39c047db120 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Tue, 6 Oct 2026 21:59:11 -0500 Subject: [PATCH 21/33] Match CRuby's FrozenError message in Thread#thread_variable_set CRuby raises "can't modify frozen thread locals" for a frozen thread, as Thread#[]= already does here. Untag the ruby/spec example. Co-Authored-By: Claude Sonnet 5.5 --- core/src/main/java/org/jruby/RubyThread.java | 6 ++---- spec/tags/ruby/core/thread/thread_variable_set_tags.txt | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyThread.java b/core/src/main/java/org/jruby/RubyThread.java index aeb0894a51d..ff808e8e48d 100644 --- a/core/src/main/java/org/jruby/RubyThread.java +++ b/core/src/main/java/org/jruby/RubyThread.java @@ -1184,10 +1184,8 @@ public IRubyObject thread_variable_get(ThreadContext context, IRubyObject key) { @JRubyMethod(name = "thread_variable_set") public IRubyObject thread_variable_set(ThreadContext context, IRubyObject key, IRubyObject value) { - // FIXME: FrozenError is raised in both, but the message differs (ruby/spec expects the CRuby one): - // CRuby: can't modify frozen thread locals - // JRuby: can't modify frozen Object: # - checkFrozen(); + if (isFrozen()) throw frozenError(context, this, "can't modify frozen thread locals"); + key = RubySymbol.idSymbolFromObject(context, key); final Map locals = getThreadLocals(); synchronized (locals) { diff --git a/spec/tags/ruby/core/thread/thread_variable_set_tags.txt b/spec/tags/ruby/core/thread/thread_variable_set_tags.txt index 696eea91d76..3a94aeefc49 100644 --- a/spec/tags/ruby/core/thread/thread_variable_set_tags.txt +++ b/spec/tags/ruby/core/thread/thread_variable_set_tags.txt @@ -1,4 +1,3 @@ fails:Thread#thread_variable_set converts a key that is neither String nor Symbol with #to_str -fails:Thread#thread_variable_set raises a FrozenError if the thread is frozen fails:Thread#thread_variable_set raises a TypeError if the key is neither Symbol nor String, nor responds to #to_str fails:Thread#thread_variable_set does not try to convert the key with #to_sym From 232ec74a669fddd4d9bae86f3429faa474a1ac4b Mon Sep 17 00:00:00 2001 From: Evan Brooks Date: Thu, 8 Oct 2026 20:05:42 -0300 Subject: [PATCH 22/33] Add Module#descendants (Feature #9779) --- .../java/org/jruby/IncludedModuleWrapper.java | 17 ++ core/src/main/java/org/jruby/RubyModule.java | 49 ++++- test/mri/excludes/TestModule.rb | 2 + test/mri/ruby/test_module.rb | 174 +++++++++++++++--- 4 files changed, 215 insertions(+), 27 deletions(-) diff --git a/core/src/main/java/org/jruby/IncludedModuleWrapper.java b/core/src/main/java/org/jruby/IncludedModuleWrapper.java index 340e55c1bde..79c1bc62999 100644 --- a/core/src/main/java/org/jruby/IncludedModuleWrapper.java +++ b/core/src/main/java/org/jruby/IncludedModuleWrapper.java @@ -77,6 +77,23 @@ public RubyModule getDelegate() { return origin; } + /** + * The class or module whose include or prepend created this wrapper. This may itself be a wrapper when an + * include into a module is propagated to the hierarchies already including that module. Null for wrappers + * created for refinements. + * + * MRI: RCLASS_INCLUDER + */ + public RubyModule getIncluder() { + return includer; + } + + void setIncluder(RubyModule includer) { + this.includer = includer; + } + + private RubyModule includer; + @Override public boolean isIncluded() { return true; diff --git a/core/src/main/java/org/jruby/RubyModule.java b/core/src/main/java/org/jruby/RubyModule.java index 64cbb471865..b452c61eaec 100644 --- a/core/src/main/java/org/jruby/RubyModule.java +++ b/core/src/main/java/org/jruby/RubyModule.java @@ -3179,6 +3179,52 @@ public RubyArray ancestors(ThreadContext context) { return newArray(context, getAncestorList()); } + /** + * Returns the classes and modules that have this module in their ancestors, excluding this module itself, + * singleton classes, and refinements. The order is not defined. + * + * MRI: rb_mod_descendants + */ + @JRubyMethod(name = "descendants") + public RubyArray descendants(ThreadContext context) { + var descendants = newArray(context); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + visited.add(this); + + addDescendants(context, this, descendants, visited); + + return descendants; + } + + // MRI: module_descendants_add + private static void addDescendants(ThreadContext context, RubyModule module, RubyArray descendants, Set visited) { + RubyClass.BiConsumerIgnoresSecond visitor = entry -> { + RubyModule descendant = descendantFor(entry); + if (descendant == null || !visited.add(descendant)) return; + + descendants.append(context, descendant); + addDescendants(context, descendant, descendants, visited); + }; + + if (module instanceof RubyClass klass) klass.getSubclassesForRead().forEachClass(visitor); + module.getIncludingHierarchiesForRead().forEachClass(visitor); + } + + // MRI: module_descendants_recursive, resolves hierarchy entries to the class or module they belong to + private static RubyModule descendantFor(RubyClass entry) { + RubyModule descendant = entry; + + while (descendant instanceof IncludedModuleWrapper || descendant instanceof PrependedModule) { + descendant = descendant instanceof IncludedModuleWrapper wrapper ? + wrapper.getIncluder() : // an include, or one propagated through another wrapper + descendant.getOrigin(); // holder of a prepending class's own methods + } + + if (descendant == null || descendant.isIncluded() || descendant.isSingleton() || descendant.isRefinement()) return null; + + return descendant; + } + @Deprecated(since = "10.0.0.0") public RubyArray ancestors() { return ancestors(getCurrentContext()); @@ -4488,7 +4534,8 @@ private RubyModule proceedWithInclude(ThreadContext context, RubyModule insertAb // In the current logic, if we getService here we know that module is not an // IncludedModuleWrapper, so there's no need to fish out the delegate. But just // in case the logic should change later, let's do it anyway - RubyClass wrapper = new IncludedModuleWrapper(context.runtime, insertAbove.getSuperClass(), moduleToInclude, moduleToInclude.getMethodLocation()); + IncludedModuleWrapper wrapper = new IncludedModuleWrapper(context.runtime, insertAbove.getSuperClass(), moduleToInclude, moduleToInclude.getMethodLocation()); + wrapper.setIncluder(this); // if the insertion point is a class, update subclass lists if (insertAbove instanceof RubyClass insertAboveClass) { diff --git a/test/mri/excludes/TestModule.rb b/test/mri/excludes/TestModule.rb index bc383aa1bc9..6fd8cfd0887 100644 --- a/test/mri/excludes/TestModule.rb +++ b/test/mri/excludes/TestModule.rb @@ -1,3 +1,4 @@ +exclude :test_alias_prepended_module_warning, "missing deprecation warning for aliasing a method defined in a prepended module" exclude :test_ancestry_of_duped_classes, "handle along with optimizations as described in https://github.com/jruby/jruby/issues/8717" exclude :test_bug17590, "work in progress" exclude :test_complemented_method_entry_memory_leak, "no working assert_no_memory_leak method" @@ -13,6 +14,7 @@ exclude :test_module_function_inside_method, "work in progress" exclude :test_nested_defined, "needs investigation" exclude :test_prepend_constant_lookup, "work in progress" +exclude :test_prepend_super_in_alias, "missing deprecation warning for aliasing a method defined in a prepended module" exclude :test_prepend_works_with_duped_classes, "work in progress" exclude :test_private_constant_reopen, "needs investigation" exclude :test_private_constant_with_no_args, "needs investigation" diff --git a/test/mri/ruby/test_module.rb b/test/mri/ruby/test_module.rb index 8c67975b6b4..0b7f241325c 100644 --- a/test/mri/ruby/test_module.rb +++ b/test/mri/ruby/test_module.rb @@ -9,18 +9,18 @@ def _wrap_assertion yield end - def assert_method_defined?(klass, mid, message="") + def assert_method_defined?(klass, (mid, *args), message="") message = build_message(message, "#{klass}\##{mid} expected to be defined.") _wrap_assertion do - klass.method_defined?(mid) or + klass.method_defined?(mid, *args) or raise Test::Unit::AssertionFailedError, message, caller(3) end end - def assert_method_not_defined?(klass, mid, message="") + def assert_method_not_defined?(klass, (mid, *args), message="") message = build_message(message, "#{klass}\##{mid} expected to not be defined.") _wrap_assertion do - klass.method_defined?(mid) and + klass.method_defined?(mid, *args) and raise Test::Unit::AssertionFailedError, message, caller(3) end end @@ -221,6 +221,83 @@ def test_ancestors assert_equal([String, Comparable, Object, Kernel, BasicObject], String.ancestors - mixins) end + def test_descendants + a = Module.new + b = Module.new { include a } + c = Class.new { include b } + d = Class.new(c) + + order = [a, b, c, d] + assert_equal([b, c, d], a.descendants.sort_by {|m| order.index(m)}) + assert_equal([c, d], b.descendants.sort_by {|m| order.index(m)}) + assert_equal([d], c.descendants) + assert_equal([], d.descendants) + + # prepend + pre = Module.new + e = Class.new { prepend pre } + assert_include(pre.descendants, e) + + # diamond inclusion does not produce duplicates + f = Class.new { include a; include b } + descendants = a.descendants + assert_equal(descendants.uniq, descendants) + assert_include(descendants, f) + + # include into a module propagates to existing includers + g = Module.new + h = Module.new + i = Class.new { include h } + h.include(g) + assert_include(g.descendants, h) + assert_include(g.descendants, i) + + # singleton classes are excluded, even via Object#extend + obj = Object.new + obj.extend(a) + assert_not_include(a.descendants, obj.singleton_class) + + # duality with Module#ancestors, except that the receiver is excluded + all = [a, b, c, d, pre, e, f, g, h, i] + all.each do |x| + dx = x.descendants + assert_not_include(dx, x) + all.each do |y| + next if x == y + assert_equal(y.ancestors.include?(x), dx.include?(y), + "#{x.inspect} vs #{y.inspect}") + end + end + end + + def test_descendants_gc + a = Module.new + Class.new { include a } + Module.new { include a } + GC.start + descendants = a.descendants + assert_not_include(descendants, a) + # the anonymous descendants above may or may not have been collected + # already; what must hold is that no dead object is returned + descendants.each {|m| assert_kind_of(Module, m)} + end + + def test_descendants_refinement + assert_separately([], <<-"end;") + class Target; def t; end; end + module M; end + refinement = nil + Module.new do + refinement = refine(Target) do + def t2; end + end + end + assert_equal([], Target.descendants) + assert_not_include(M.descendants, Target) + assert_not_include(Target.descendants, refinement) + end; + end + CLASS_EVAL = 2 @@class_eval = 'b' @@ -583,7 +660,7 @@ def b; 2 end end def test_gc_prepend_chain - assert_separately([], <<-EOS) + assert_ruby_status([], <<-EOS) 10000.times { |i| m1 = Module.new do def foo; end @@ -813,40 +890,40 @@ def test_instance_methods def test_method_defined? [User, Class.new{include User}, Class.new{prepend User}].each do |klass| [[], [true]].each do |args| - assert !klass.method_defined?(:wombat, *args) - assert klass.method_defined?(:mixin, *args) - assert klass.method_defined?(:user, *args) - assert klass.method_defined?(:user2, *args) - assert !klass.method_defined?(:user3, *args) + assert_method_not_defined?(klass, [:wombat, *args]) + assert_method_defined?(klass, [:mixin, *args]) + assert_method_defined?(klass, [:user, *args]) + assert_method_defined?(klass, [:user2, *args]) + assert_method_not_defined?(klass, [:user3, *args]) - assert !klass.method_defined?("wombat", *args) - assert klass.method_defined?("mixin", *args) - assert klass.method_defined?("user", *args) - assert klass.method_defined?("user2", *args) - assert !klass.method_defined?("user3", *args) + assert_method_not_defined?(klass, ["wombat", *args]) + assert_method_defined?(klass, ["mixin", *args]) + assert_method_defined?(klass, ["user", *args]) + assert_method_defined?(klass, ["user2", *args]) + assert_method_not_defined?(klass, ["user3", *args]) end end end def test_method_defined_without_include_super - assert User.method_defined?(:user, false) - assert !User.method_defined?(:mixin, false) - assert Mixin.method_defined?(:mixin, false) + assert_method_defined?(User, [:user, false]) + assert_method_not_defined?(User, [:mixin, false]) + assert_method_defined?(Mixin, [:mixin, false]) User.const_set(:FOO, c = Class.new) c.prepend(User) - assert !c.method_defined?(:user, false) + assert_method_not_defined?(c, [:user, false]) c.define_method(:user){} - assert c.method_defined?(:user, false) + assert_method_defined?(c, [:user, false]) - assert !c.method_defined?(:mixin, false) + assert_method_not_defined?(c, [:mixin, false]) c.define_method(:mixin){} - assert c.method_defined?(:mixin, false) + assert_method_defined?(c, [:mixin, false]) - assert !c.method_defined?(:userx, false) + assert_method_not_defined?(c, [:userx, false]) c.define_method(:userx){} - assert c.method_defined?(:userx, false) + assert_method_defined?(c, [:userx, false]) # cleanup User.class_eval do @@ -1510,6 +1587,30 @@ def bar; 2; end INPUT end + def test_alias_prepended_module_warning + assert_in_out_err([], <<-INPUT, [], /aliasing C#foo defined in a prepended module M is deprecated/) + Warning[:deprecated] = true + module M + def foo = :foo + end + class C + prepend M + alias bar foo + end + INPUT + + assert_in_out_err([], <<-INPUT, [], /aliasing C#foo defined in a prepended module M is deprecated/) + Warning[:deprecated] = true + module M + def foo = :foo + end + class C + prepend M + alias_method :bar, :foo + end + INPUT + end + def test_mod_constants m = Module.new m.const_set(:Foo, :foo) @@ -2704,7 +2805,9 @@ def m; "A"; end def m; "B"+super; end alias m2 m prepend p - alias m3 m + end + assert_deprecated_warning(/aliasing .*#m defined in a prepended module .* is deprecated/) do + b.class_eval { alias m3 m } end assert_equal("BA", b.new.m2, bug7842) assert_equal("PBA", b.new.m3, bug7842) @@ -3073,7 +3176,7 @@ def test_return_value_of_define_singleton_method end def test_prepend_gc - assert_separately [], %{ + assert_ruby_status [], %{ module Foo end class Object @@ -3272,9 +3375,11 @@ def foo; TEST; end CloneTestM1 = CloneTestM0.clone CloneTestM2 = CloneTestM0.clone module CloneTestM1 + remove_const :TEST TEST = :M1 end module CloneTestM2 + remove_const :TEST TEST = :M2 end class CloneTestC1 @@ -3299,6 +3404,23 @@ def test_clone_freeze assert_not_predicate m.clone(freeze: false), :frozen? end + def test_dup_of_frozen_module_is_not_frozen + m = Module.new + m.instance_variable_set(:@a, 1) + m.freeze + + copy = m.dup + assert_not_predicate(copy, :frozen?) + copy.instance_variable_set(:@b, 2) + assert_equal([1, 2], [copy.instance_variable_get(:@a), copy.instance_variable_get(:@b)]) + end + + def test_singleton_class_of_frozen_module_is_frozen + m = Module.new.freeze + assert_predicate(m.singleton_class, :frozen?) + assert_raise(FrozenError) {m.singleton_class.instance_variable_set(:@a, 1)} + end + def test_module_name_in_singleton_method s = Object.new.singleton_class mod = s.const_set(:Foo, Module.new) From bf665f2f018f156c8a25dc63acf0ac229a636ca4 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Tue, 6 Oct 2026 21:10:28 -0500 Subject: [PATCH 23/33] Add optional include_all argument to Module#method_defined? Feature #22297 (Ruby 4.1). Sync the ruby/spec examples for it. Co-Authored-By: Claude Sonnet 5.5 --- core/src/main/java/org/jruby/RubyModule.java | 6 +++ spec/ruby/core/module/method_defined_spec.rb | 50 ++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/core/src/main/java/org/jruby/RubyModule.java b/core/src/main/java/org/jruby/RubyModule.java index 64cbb471865..47b16844b82 100644 --- a/core/src/main/java/org/jruby/RubyModule.java +++ b/core/src/main/java/org/jruby/RubyModule.java @@ -4084,6 +4084,12 @@ public RubyBoolean method_defined_p(ThreadContext context, IRubyObject symbol, I return asBoolean(context, visibility != UNDEFINED && visibility != PRIVATE); } + @JRubyMethod(name = "method_defined?") + public RubyBoolean method_defined_p(ThreadContext context, IRubyObject symbol, IRubyObject includeSuper, IRubyObject includeAll) { + Visibility visibility = checkMethodVisibility(context, symbol, includeSuper.isTrue()); + return asBoolean(context, visibility != UNDEFINED && (visibility != PRIVATE || includeAll.isTrue())); + } + @JRubyMethod(name = "public_method_defined?") public IRubyObject public_method_defined(ThreadContext context, IRubyObject symbol) { return asBoolean(context, checkMethodVisibility(context, symbol, true) == PUBLIC); diff --git a/spec/ruby/core/module/method_defined_spec.rb b/spec/ruby/core/module/method_defined_spec.rb index e6b4c7b8176..d4ec9f27b80 100644 --- a/spec/ruby/core/module/method_defined_spec.rb +++ b/spec/ruby/core/module/method_defined_spec.rb @@ -95,4 +95,54 @@ ModuleSpecs::Child.method_defined?(:private_super_module, false).should == false end end + + ruby_version_is "4.1" do + describe "when passed true as a third optional argument" do + it "returns true for private methods as well" do + # Include super + ModuleSpecs::Child.method_defined?(:public_child, true, true).should == true + ModuleSpecs::Child.method_defined?(:protected_child, true, true).should == true + ModuleSpecs::Child.method_defined?(:accessor_method, true, true).should == true + ModuleSpecs::Child.method_defined?(:private_child, true, true).should == true + ModuleSpecs::Child.method_defined?(:undefined, true, true).should == false + + # Defined in Parent + ModuleSpecs::Child.method_defined?(:public_parent, true, true).should == true + ModuleSpecs::Child.method_defined?(:protected_parent, true, true).should == true + ModuleSpecs::Child.method_defined?(:private_parent, true, true).should == true + + # Defined in Module + ModuleSpecs::Child.method_defined?(:public_module, true, true).should == true + ModuleSpecs::Child.method_defined?(:protected_module, true, true).should == true + ModuleSpecs::Child.method_defined?(:private_module, true, true).should == true + + # Defined in SuperModule + ModuleSpecs::Child.method_defined?(:public_super_module, true, true).should == true + ModuleSpecs::Child.method_defined?(:protected_super_module, true, true).should == true + ModuleSpecs::Child.method_defined?(:private_super_module, true, true).should == true + + # Ignore super + ModuleSpecs::Child.method_defined?(:public_child, false, true).should == true + ModuleSpecs::Child.method_defined?(:protected_child, false, true).should == true + ModuleSpecs::Child.method_defined?(:accessor_method, false, true).should == true + ModuleSpecs::Child.method_defined?(:private_child, false, true).should == true + ModuleSpecs::Child.method_defined?(:undefined, false, true).should == false + + # Defined in Parent + ModuleSpecs::Child.method_defined?(:public_parent, false, true).should == false + ModuleSpecs::Child.method_defined?(:protected_parent, false, true).should == false + ModuleSpecs::Child.method_defined?(:private_parent, false, true).should == false + + # Defined in Module + ModuleSpecs::Child.method_defined?(:public_module, false, true).should == false + ModuleSpecs::Child.method_defined?(:protected_module, false, true).should == false + ModuleSpecs::Child.method_defined?(:private_module, false, true).should == false + + # Defined in SuperModule + ModuleSpecs::Child.method_defined?(:public_super_module, false, true).should == false + ModuleSpecs::Child.method_defined?(:protected_super_module, false, true).should == false + ModuleSpecs::Child.method_defined?(:private_super_module, false, true).should == false + end + end + end end From d00e56799e0c7337e7cd143aa35c5ea8b94b94fc Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Tue, 6 Oct 2026 22:08:37 -0500 Subject: [PATCH 24/33] Add specs for method_defined? with a false or nil include_all argument Co-Authored-By: Claude Sonnet 5.5 --- spec/ruby/core/module/method_defined_spec.rb | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/spec/ruby/core/module/method_defined_spec.rb b/spec/ruby/core/module/method_defined_spec.rb index d4ec9f27b80..c1d62aeff45 100644 --- a/spec/ruby/core/module/method_defined_spec.rb +++ b/spec/ruby/core/module/method_defined_spec.rb @@ -144,5 +144,49 @@ ModuleSpecs::Child.method_defined?(:private_super_module, false, true).should == false end end + + describe "when passed false as a third optional argument" do + it "does not return true for private methods" do + # Include super + ModuleSpecs::Child.method_defined?(:public_child, true, false).should == true + ModuleSpecs::Child.method_defined?(:protected_child, true, false).should == true + ModuleSpecs::Child.method_defined?(:private_child, true, false).should == false + ModuleSpecs::Child.method_defined?(:public_parent, true, false).should == true + ModuleSpecs::Child.method_defined?(:private_parent, true, false).should == false + ModuleSpecs::Child.method_defined?(:public_module, true, false).should == true + ModuleSpecs::Child.method_defined?(:private_module, true, false).should == false + ModuleSpecs::Child.method_defined?(:private_super_module, true, false).should == false + ModuleSpecs::Child.method_defined?(:undefined, true, false).should == false + + # Ignore super + ModuleSpecs::Child.method_defined?(:public_child, false, false).should == true + ModuleSpecs::Child.method_defined?(:protected_child, false, false).should == true + ModuleSpecs::Child.method_defined?(:private_child, false, false).should == false + ModuleSpecs::Child.method_defined?(:public_parent, false, false).should == false + ModuleSpecs::Child.method_defined?(:private_parent, false, false).should == false + ModuleSpecs::Child.method_defined?(:private_module, false, false).should == false + ModuleSpecs::Child.method_defined?(:undefined, false, false).should == false + end + + it "returns the same result as when the third argument is omitted" do + [:public_child, :protected_child, :private_child, :public_parent, :private_parent, + :public_module, :private_module, :private_super_module, :undefined].each do |name| + [true, false].each do |include_super| + ModuleSpecs::Child.method_defined?(name, include_super, false).should == + ModuleSpecs::Child.method_defined?(name, include_super) + end + end + end + end + + describe "when passed nil as a third optional argument" do + it "does not return true for private methods" do + ModuleSpecs::Child.method_defined?(:private_child, true, nil).should == false + ModuleSpecs::Child.method_defined?(:private_child, false, nil).should == false + ModuleSpecs::Child.method_defined?(:private_parent, true, nil).should == false + ModuleSpecs::Child.method_defined?(:public_child, true, nil).should == true + ModuleSpecs::Child.method_defined?(:public_parent, false, nil).should == false + end + end end end From da5e1f4e6aaf64831215e657aca359bd4ac39f98 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Thu, 8 Oct 2026 19:44:49 -0500 Subject: [PATCH 25/33] Name method_defined? specs after the include_all argument The rdoc calls the third argument include_all, so use that name in the spec descriptions. Co-Authored-By: Claude Opus 5.5 --- spec/ruby/core/module/method_defined_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/ruby/core/module/method_defined_spec.rb b/spec/ruby/core/module/method_defined_spec.rb index c1d62aeff45..698ff682194 100644 --- a/spec/ruby/core/module/method_defined_spec.rb +++ b/spec/ruby/core/module/method_defined_spec.rb @@ -97,7 +97,7 @@ end ruby_version_is "4.1" do - describe "when passed true as a third optional argument" do + describe "when passed true as include_all" do it "returns true for private methods as well" do # Include super ModuleSpecs::Child.method_defined?(:public_child, true, true).should == true @@ -145,7 +145,7 @@ end end - describe "when passed false as a third optional argument" do + describe "when passed false as include_all" do it "does not return true for private methods" do # Include super ModuleSpecs::Child.method_defined?(:public_child, true, false).should == true @@ -179,7 +179,7 @@ end end - describe "when passed nil as a third optional argument" do + describe "when passed nil as include_all" do it "does not return true for private methods" do ModuleSpecs::Child.method_defined?(:private_child, true, nil).should == false ModuleSpecs::Child.method_defined?(:private_child, false, nil).should == false From 53d4c7006dd37d2dff9ffdee870306e513824630 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Wed, 14 Oct 2026 23:28:30 -0500 Subject: [PATCH 26/33] Accept a Hash of replacements in String#tr and #tr! (Feature #22238) Co-Authored-By: Claude Sonnet 5.5 --- core/src/main/java/org/jruby/RubyString.java | 109 +++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/core/src/main/java/org/jruby/RubyString.java b/core/src/main/java/org/jruby/RubyString.java index 6dd13c83b49..3c598c0f6c0 100644 --- a/core/src/main/java/org/jruby/RubyString.java +++ b/core/src/main/java/org/jruby/RubyString.java @@ -100,6 +100,7 @@ import java.nio.charset.Charset; import java.util.Arrays; +import java.util.HashMap; import java.util.Locale; import java.util.function.Function; @@ -6256,6 +6257,114 @@ public IRubyObject tr_bang(ThreadContext context, IRubyObject src, IRubyObject r return trTrans(context, src, repl, false); } + // MRI: rb_str_tr with a single pairs Hash + @JRubyMethod(name = "tr") + public IRubyObject tr(ThreadContext context, IRubyObject pairs) { + RubyString str = dupString(context, this); + str.trTransPairs(context, pairs); + return str; + } + + // MRI: rb_str_tr_bang with a single pairs Hash + @JRubyMethod(name = "tr!") + public IRubyObject tr_bang(ThreadContext context, IRubyObject pairs) { + return trTransPairs(context, pairs); + } + + // MRI: tr_trans_pairs + private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { + if (!(pairsArg instanceof RubyHash pairs)) { + throw typeError(context, str(context.runtime, "wrong argument type ", types(context.runtime, pairsArg.getMetaClass()), " (expected Hash)")); + } + + mustnotBroken(context); + modify(); + + if (value.getRealSize() == 0 || pairs.isEmpty()) return context.nil; + + final Encoding[] encHolder = { value.getEncoding() }; + final int[] crHolder = { scanForCodeRange() }; + final RubyString[] keys = new RubyString[pairs.size()]; + final RubyString[] repls = new RubyString[pairs.size()]; + final int[] count = { 0 }; + + // keys or values may be removed from the hash by to_str, so count what we actually collect + pairs.visitAll(context, (ctx, self, key, val, index) -> { + RubyString search = key.convertToString(); + RubyString repl = val.convertToString(); + + if (search.size() != 1 && search.strLength() != 1) throw argumentError(ctx, "keys must be of size 1"); + + encHolder[0] = checkMultiStr(encHolder[0], crHolder, search); + encHolder[0] = checkMultiStr(encHolder[0], crHolder, repl); + + if (count[0] < keys.length) { + keys[count[0]] = search; + repls[count[0]++] = repl; + } + }); + + final Encoding e1 = encHolder[0]; + final HashMap table = new HashMap<>(count[0] * 2); + for (int i = 0; i < count[0]; i++) { + ByteList k = keys[i].getByteList(); + table.put(StringSupport.codePoint(e1, k.unsafeBytes(), k.begin(), k.begin() + k.realSize()), repls[i]); + } + + final byte[] sbytes = value.unsafeBytes(); + int s = value.begin(); + final int send = s + value.realSize(); + final ByteList out = new ByteList(value.realSize()); + boolean modified = false; + + while (s < send) { + int clen = StringSupport.preciseLength(e1, sbytes, s, send); + if (clen <= 0) throw argumentError(context, "invalid byte sequence in " + e1); + + RubyString repl = table.get(StringSupport.codePoint(e1, sbytes, s, send)); + if (repl == null) { + out.append(sbytes, s, clen); + } else { + out.append(repl.getByteList()); + modified = true; + } + s += clen; + } + + if (!modified) return context.nil; + + value.setUnsafeBytes(out.unsafeBytes()); + value.setBegin(0); + value.setRealSize(out.realSize()); + setEncoding(e1); + clearCodeRange(); + return this; + } + + // MRI: rb_enc_check_multi_str + private static Encoding checkMultiStr(Encoding enc1, int[] cr, RubyString str2) { + if (str2.size() == 0) return enc1; + + Encoding enc2 = str2.getEncoding(); + int cr1 = cr[0]; + + if (enc1 == enc2) { + if (cr1 == CR_7BIT) cr[0] = str2.scanForCodeRange(); + return enc1; + } + + if (enc1.isAsciiCompatible() && enc2.isAsciiCompatible()) { + if (enc2 instanceof USASCIIEncoding || str2.scanForCodeRange() == CR_7BIT) return enc1; + + if (enc1 instanceof USASCIIEncoding || cr1 == CR_7BIT) { + cr[0] = str2.scanForCodeRange(); + return enc2; + } + } + + throw str2.getRuntime().newEncodingCompatibilityError("incompatible character encodings: " + enc1 + " and " + enc2); + } + private IRubyObject trTrans(ThreadContext context, IRubyObject src, IRubyObject repl, boolean sflag) { RubyString replStr = repl.convertToString(); ByteList replList = replStr.value; From d54c02664d3c89d5784cf618b4214e853bf09be5 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Wed, 14 Oct 2026 23:48:13 -0500 Subject: [PATCH 27/33] Tighten String#tr hash form: no Java exception on invalid key, MRI type error names Co-Authored-By: Claude Sonnet 5.5 --- core/src/main/java/org/jruby/RubyString.java | 39 +++++++++++--------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyString.java b/core/src/main/java/org/jruby/RubyString.java index 3c598c0f6c0..3b4f868c61b 100644 --- a/core/src/main/java/org/jruby/RubyString.java +++ b/core/src/main/java/org/jruby/RubyString.java @@ -129,6 +129,7 @@ import static org.jruby.api.Create.newString; import static org.jruby.api.Define.defineClass; import static org.jruby.api.Error.argumentError; +import static org.jruby.api.Error.encodingCompatibilityError; import static org.jruby.api.Error.indexError; import static org.jruby.api.Error.rangeError; import static org.jruby.api.Error.runtimeError; @@ -6274,7 +6275,12 @@ public IRubyObject tr_bang(ThreadContext context, IRubyObject pairs) { // MRI: tr_trans_pairs private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { if (!(pairsArg instanceof RubyHash pairs)) { - throw typeError(context, str(context.runtime, "wrong argument type ", types(context.runtime, pairsArg.getMetaClass()), " (expected Hash)")); + // MRI: special constants are named by value in Check_Type messages + String name = pairsArg.isNil() ? "nil" : + pairsArg == context.tru ? "true" : + pairsArg == context.fals ? "false" : + pairsArg.getMetaClass().getRealClass().getName(context); + throw typeError(context, "wrong argument type " + name + " (expected Hash)"); } mustnotBroken(context); @@ -6288,27 +6294,27 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { final RubyString[] repls = new RubyString[pairs.size()]; final int[] count = { 0 }; - // keys or values may be removed from the hash by to_str, so count what we actually collect + // to_str may remove entries from the hash, so count what we actually collect pairs.visitAll(context, (ctx, self, key, val, index) -> { RubyString search = key.convertToString(); RubyString repl = val.convertToString(); - if (search.size() != 1 && search.strLength() != 1) throw argumentError(ctx, "keys must be of size 1"); + if (search.size() != 1 && search.strLength() != 1) { + throw argumentError(ctx, "keys must be of size 1"); + } - encHolder[0] = checkMultiStr(encHolder[0], crHolder, search); - encHolder[0] = checkMultiStr(encHolder[0], crHolder, repl); + encHolder[0] = checkMultiStr(ctx, encHolder[0], crHolder, search); + encHolder[0] = checkMultiStr(ctx, encHolder[0], crHolder, repl); - if (count[0] < keys.length) { - keys[count[0]] = search; - repls[count[0]++] = repl; - } + keys[count[0]] = search; + repls[count[0]++] = repl; }); final Encoding e1 = encHolder[0]; final HashMap table = new HashMap<>(count[0] * 2); for (int i = 0; i < count[0]; i++) { ByteList k = keys[i].getByteList(); - table.put(StringSupport.codePoint(e1, k.unsafeBytes(), k.begin(), k.begin() + k.realSize()), repls[i]); + table.put(e1.mbcToCode(k.unsafeBytes(), k.begin(), k.begin() + k.realSize()), repls[i]); } final byte[] sbytes = value.unsafeBytes(); @@ -6318,10 +6324,9 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { boolean modified = false; while (s < send) { - int clen = StringSupport.preciseLength(e1, sbytes, s, send); - if (clen <= 0) throw argumentError(context, "invalid byte sequence in " + e1); + int clen = StringSupport.length(e1, sbytes, s, send); - RubyString repl = table.get(StringSupport.codePoint(e1, sbytes, s, send)); + RubyString repl = table.get(e1.mbcToCode(sbytes, s, send)); if (repl == null) { out.append(sbytes, s, clen); } else { @@ -6342,7 +6347,7 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { } // MRI: rb_enc_check_multi_str - private static Encoding checkMultiStr(Encoding enc1, int[] cr, RubyString str2) { + private static Encoding checkMultiStr(ThreadContext context, Encoding enc1, int[] cr, RubyString str2) { if (str2.size() == 0) return enc1; Encoding enc2 = str2.getEncoding(); @@ -6354,15 +6359,15 @@ private static Encoding checkMultiStr(Encoding enc1, int[] cr, RubyString str2) } if (enc1.isAsciiCompatible() && enc2.isAsciiCompatible()) { - if (enc2 instanceof USASCIIEncoding || str2.scanForCodeRange() == CR_7BIT) return enc1; + if (enc2 == USASCIIEncoding.INSTANCE || str2.scanForCodeRange() == CR_7BIT) return enc1; - if (enc1 instanceof USASCIIEncoding || cr1 == CR_7BIT) { + if (enc1 == USASCIIEncoding.INSTANCE || cr1 == CR_7BIT) { cr[0] = str2.scanForCodeRange(); return enc2; } } - throw str2.getRuntime().newEncodingCompatibilityError("incompatible character encodings: " + enc1 + " and " + enc2); + throw encodingCompatibilityError(context, "incompatible character encodings: " + enc1 + " and " + enc2); } private IRubyObject trTrans(ThreadContext context, IRubyObject src, IRubyObject repl, boolean sflag) { From d660c9b2d6cba0838f99c3a7d811746894696ed2 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Thu, 15 Oct 2026 17:02:05 -0500 Subject: [PATCH 28/33] Use direct lookup tables for String#tr hash form 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 --- core/src/main/java/org/jruby/RubyString.java | 95 ++++++++++++++------ 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyString.java b/core/src/main/java/org/jruby/RubyString.java index 3b4f868c61b..8cbc67a651f 100644 --- a/core/src/main/java/org/jruby/RubyString.java +++ b/core/src/main/java/org/jruby/RubyString.java @@ -53,6 +53,7 @@ import org.jcodings.specific.UTF32LEEncoding; import org.jcodings.specific.UTF8Encoding; import org.jcodings.unicode.UnicodeEncoding; +import org.jcodings.util.IntHash; import org.joni.Matcher; import org.joni.Option; import org.joni.Regex; @@ -100,7 +101,6 @@ import java.nio.charset.Charset; import java.util.Arrays; -import java.util.HashMap; import java.util.Locale; import java.util.function.Function; @@ -6288,11 +6288,8 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { if (value.getRealSize() == 0 || pairs.isEmpty()) return context.nil; - final Encoding[] encHolder = { value.getEncoding() }; - final int[] crHolder = { scanForCodeRange() }; - final RubyString[] keys = new RubyString[pairs.size()]; - final RubyString[] repls = new RubyString[pairs.size()]; - final int[] count = { 0 }; + // the negotiated encoding and code range, folded over every key and value + final TrPairsState state = new TrPairsState(value.getEncoding(), scanForCodeRange(), pairs.size()); // to_str may remove entries from the hash, so count what we actually collect pairs.visitAll(context, (ctx, self, key, val, index) -> { @@ -6303,40 +6300,65 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { throw argumentError(ctx, "keys must be of size 1"); } - encHolder[0] = checkMultiStr(ctx, encHolder[0], crHolder, search); - encHolder[0] = checkMultiStr(ctx, encHolder[0], crHolder, repl); + state.enc = checkMultiStr(ctx, state.enc, state, search); + state.enc = checkMultiStr(ctx, state.enc, state, repl); - keys[count[0]] = search; - repls[count[0]++] = repl; + state.keys[state.count] = search; + state.repls[state.count++] = repl; }); - final Encoding e1 = encHolder[0]; - final HashMap table = new HashMap<>(count[0] * 2); - for (int i = 0; i < count[0]; i++) { - ByteList k = keys[i].getByteList(); - table.put(e1.mbcToCode(k.unsafeBytes(), k.begin(), k.begin() + k.realSize()), repls[i]); + final Encoding e1 = state.enc; + + // like trTransHelper: a direct table for codepoints below StringSupport.TRANS_SIZE, an IntHash above + final RubyString[] low = new RubyString[StringSupport.TRANS_SIZE]; + IntHash high = null; + for (int i = 0; i < state.count; i++) { + ByteList k = state.keys[i].getByteList(); + int c = e1.mbcToCode(k.unsafeBytes(), k.begin(), k.begin() + k.realSize()); + if (c < 0) continue; // an invalid key can never match a character of a valid string + if (c < StringSupport.TRANS_SIZE) { + low[c] = state.repls[i]; + } else { + if (high == null) high = new IntHash<>(); + high.put(c, state.repls[i]); + } } final byte[] sbytes = value.unsafeBytes(); - int s = value.begin(); - final int send = s + value.realSize(); - final ByteList out = new ByteList(value.realSize()); - boolean modified = false; + final int begin = value.begin(); + final int send = begin + value.realSize(); + final boolean asciiCompatible = e1.isAsciiCompatible(); + ByteList out = null; + int checkpoint = begin; + int s = begin; while (s < send) { - int clen = StringSupport.length(e1, sbytes, s, send); + int clen; + RubyString repl; - RubyString repl = table.get(e1.mbcToCode(sbytes, s, send)); - if (repl == null) { - out.append(sbytes, s, clen); + int b = sbytes[s] & 0xff; + if (b < 0x80 && asciiCompatible) { + clen = 1; + repl = low[b]; } else { + clen = StringSupport.length(e1, sbytes, s, send); + int c = e1.mbcToCode(sbytes, s, send); + repl = c < 0 ? null : c < StringSupport.TRANS_SIZE ? low[c] : high == null ? null : high.get(c); + } + + if (repl != null) { + // copy the untouched run since the last replacement, then the replacement itself + if (out == null) out = new ByteList(value.realSize() + 16); + out.append(sbytes, checkpoint, s - checkpoint); out.append(repl.getByteList()); - modified = true; + checkpoint = s + clen; } s += clen; } - if (!modified) return context.nil; + if (out == null) return context.nil; + + out.append(sbytes, checkpoint, send - checkpoint); value.setUnsafeBytes(out.unsafeBytes()); value.setBegin(0); @@ -6346,15 +6368,30 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { return this; } + private static final class TrPairsState { + Encoding enc; + int cr; + final RubyString[] keys; + final RubyString[] repls; + int count; + + TrPairsState(Encoding enc, int cr, int capacity) { + this.enc = enc; + this.cr = cr; + this.keys = new RubyString[capacity]; + this.repls = new RubyString[capacity]; + } + } + // MRI: rb_enc_check_multi_str - private static Encoding checkMultiStr(ThreadContext context, Encoding enc1, int[] cr, RubyString str2) { + private static Encoding checkMultiStr(ThreadContext context, Encoding enc1, TrPairsState state, RubyString str2) { if (str2.size() == 0) return enc1; Encoding enc2 = str2.getEncoding(); - int cr1 = cr[0]; + int cr1 = state.cr; if (enc1 == enc2) { - if (cr1 == CR_7BIT) cr[0] = str2.scanForCodeRange(); + if (cr1 == CR_7BIT) state.cr = str2.scanForCodeRange(); return enc1; } @@ -6362,7 +6399,7 @@ private static Encoding checkMultiStr(ThreadContext context, Encoding enc1, int[ if (enc2 == USASCIIEncoding.INSTANCE || str2.scanForCodeRange() == CR_7BIT) return enc1; if (enc1 == USASCIIEncoding.INSTANCE || cr1 == CR_7BIT) { - cr[0] = str2.scanForCodeRange(); + state.cr = str2.scanForCodeRange(); return enc2; } } From 4d91f41195ee9fd1e16f9d1d02d726aa27f11220 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Thu, 15 Oct 2026 17:02:29 -0500 Subject: [PATCH 29/33] Add String#tr hash form specs for wide characters, receiver mutation and errors Co-Authored-By: Claude Sonnet 5.5 --- spec/ruby/core/string/tr_spec.rb | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/spec/ruby/core/string/tr_spec.rb b/spec/ruby/core/string/tr_spec.rb index 631a1e4964e..35464e61db8 100644 --- a/spec/ruby/core/string/tr_spec.rb +++ b/spec/ruby/core/string/tr_spec.rb @@ -169,6 +169,25 @@ ).should == "hello".encode(Encoding::UTF_16LE) end + it "replaces characters above U+00FF" do + "日本語".tr("本" => "ほん").should == "日ほん語" + end + + it "does not modify the receiver" do + str = "hello" + str.tr("l" => "L").should == "heLLo" + str.should == "hello" + end + + it "does not match an invalid key against characters of a valid string" do + "abc".tr("\xFF".dup.force_encoding(Encoding::UTF_8) => "x").should == "abc" + end + + it "raises TypeError if the only argument is not a Hash" do + -> { "hello".tr(1) }.should.raise(TypeError, "wrong argument type Integer (expected Hash)") + -> { "hello".tr(nil) }.should.raise(TypeError, "wrong argument type nil (expected Hash)") + end + it "raises ArgumentError if a key is more than one codepoint" do -> { "hello".tr("hel" => "") }.should.raise(ArgumentError) -> { "🤦🏼‍♂️".tr("🤦🏼‍♂️" => "") }.should.raise(ArgumentError) @@ -243,6 +262,24 @@ "hello".tr!("€" => "", "Ø" => "").should == nil end + it "replaces characters above U+00FF" do + "日本語".tr!("本" => "ほん").should == "日ほん語" + end + + it "modifies the receiver in place" do + str = "hello" + str.tr!("l" => "L").should.equal?(str) + str.should == "heLLo" + end + + it "raises FrozenError if the receiver is frozen" do + -> { "hello".freeze.tr!("l" => "L") }.should.raise(FrozenError) + end + + it "raises TypeError if the only argument is not a Hash" do + -> { "hello".tr!(1) }.should.raise(TypeError, "wrong argument type Integer (expected Hash)") + end + it "raises ArgumentError if a key is more than one codepoint" do -> { "hello".tr!("hel" => "") }.should.raise(ArgumentError) -> { "🤦🏼‍♂️".tr!("🤦🏼‍♂️" => "") }.should.raise(ArgumentError) From 6f4a2778ae3ec087f74d608b351d4ea8c8bb4f5e Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Thu, 15 Oct 2026 20:52:36 -0500 Subject: [PATCH 30/33] Document String#tr hash form implementation Co-Authored-By: Claude Sonnet 5.5 --- core/src/main/java/org/jruby/RubyString.java | 51 ++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyString.java b/core/src/main/java/org/jruby/RubyString.java index 8cbc67a651f..f4944cc4f01 100644 --- a/core/src/main/java/org/jruby/RubyString.java +++ b/core/src/main/java/org/jruby/RubyString.java @@ -6272,8 +6272,15 @@ public IRubyObject tr_bang(ThreadContext context, IRubyObject pairs) { return trTransPairs(context, pairs); } - // MRI: tr_trans_pairs + /** + * MRI: tr_trans_pairs + * + * Implements the single Hash form of tr/tr!: every character that is a key of pairs is replaced by the + * associated value string, in a single left-to-right pass (replacements are never rescanned). + * Returns this if anything was replaced and nil otherwise. + */ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { + // pairs is bound by the instanceof pattern and stays in scope below since the failing branch always throws if (!(pairsArg instanceof RubyHash pairs)) { // MRI: special constants are named by value in Check_Type messages String name = pairsArg.isNil() ? "nil" : @@ -6283,12 +6290,14 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { throw typeError(context, "wrong argument type " + name + " (expected Hash)"); } + // same order of checks as MRI: broken string, then frozen, then the nothing-to-do shortcut mustnotBroken(context); modify(); if (value.getRealSize() == 0 || pairs.isEmpty()) return context.nil; - // the negotiated encoding and code range, folded over every key and value + // phase 1: collect each key/value as a String and negotiate one encoding all of them (and self) can share. + // state.enc/state.cr start as self's and are folded over every key and value by checkMultiStr. final TrPairsState state = new TrPairsState(value.getEncoding(), scanForCodeRange(), pairs.size()); // to_str may remove entries from the hash, so count what we actually collect @@ -6296,6 +6305,7 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { RubyString search = key.convertToString(); RubyString repl = val.convertToString(); + // a key must be exactly one character: bytesize 1, or a single multibyte character if (search.size() != 1 && search.strLength() != 1) { throw argumentError(ctx, "keys must be of size 1"); } @@ -6307,9 +6317,15 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { state.repls[state.count++] = repl; }); + // MRI: e1, the encoding every key, value and self agree on; the result is tagged with it final Encoding e1 = state.enc; - // like trTransHelper: a direct table for codepoints below StringSupport.TRANS_SIZE, an IntHash above + // phase 2: index replacements by the codepoint of their key, like trTransHelper does: a direct table for + // codepoints below StringSupport.TRANS_SIZE and an IntHash above. + // + // Note low is allocated on every call. For a short receiver and just a few pairs that is likely the largest + // allocation here, so a small-pair fast path (e.g. up to ~4 pairs: compare each character against the key + // list directly and skip the tables) might be worthwhile if profiling shows it matters. final RubyString[] low = new RubyString[StringSupport.TRANS_SIZE]; IntHash high = null; for (int i = 0; i < state.count; i++) { @@ -6324,23 +6340,33 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { } } + // phase 3: scan self's bytes and translate. + // sbytes[begin, send) is the input; send only bounds reading it. The result goes to a separate, growable + // buffer (out) because replacements may be longer or shorter than the characters they replace. final byte[] sbytes = value.unsafeBytes(); final int begin = value.begin(); final int send = begin + value.realSize(); + // false for e.g. UTF-16/32, where an ASCII character is not a single byte final boolean asciiCompatible = e1.isAsciiCompatible(); + // out stays null until the first replacement so that an unchanged string returns nil without any copying ByteList out = null; + // everything in sbytes[checkpoint, s) is unchanged input that has not been copied to out yet int checkpoint = begin; + // s is the offset of the current character int s = begin; while (s < send) { int clen; RubyString repl; + // bytes are signed in Java; & 0xff gives the unsigned 0-255 value so the ASCII test and table index are right int b = sbytes[s] & 0xff; if (b < 0x80 && asciiCompatible) { + // ASCII fast path: a single byte whose value is its own codepoint, no decoding needed clen = 1; repl = low[b]; } else { + // otherwise decode: clen is the character's byte length, c its codepoint clen = StringSupport.length(e1, sbytes, s, send); int c = e1.mbcToCode(sbytes, s, send); repl = c < 0 ? null : c < StringSupport.TRANS_SIZE ? low[c] : high == null ? null : high.get(c); @@ -6358,6 +6384,7 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { if (out == null) return context.nil; + // copy the unchanged tail, then swap the new bytes in out.append(sbytes, checkpoint, send - checkpoint); value.setUnsafeBytes(out.unsafeBytes()); @@ -6368,11 +6395,18 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { return this; } + // Accumulator for trTransPairs: the pairs collected from the Hash plus the encoding negotiated so far. private static final class TrPairsState { + // the encoding all strings seen so far can share; starts as the receiver's Encoding enc; + // code range (7BIT/VALID) of the text seen so far; checkMultiStr uses it to tell whether the text is still + // pure ASCII and so may adopt another string's encoding. MRI passes this as an out-parameter (&cr). int cr; + // parallel arrays of the collected key and replacement strings final RubyString[] keys; final RubyString[] repls; + // number of pairs stored in keys/repls; to_str can remove entries from the Hash while it is being visited, + // so this can end up smaller than the arrays' length int count; TrPairsState(Encoding enc, int cr, int capacity) { @@ -6383,8 +6417,15 @@ private static final class TrPairsState { } } - // MRI: rb_enc_check_multi_str + /** + * MRI: rb_enc_check_multi_str + * + * Folds str2 (a key or value) into the encoding negotiated so far, returning the encoding to use from then on or + * raising Encoding::CompatibilityError if str2 cannot share one with the text seen so far (state.cr is updated + * when the encoding changes). + */ private static Encoding checkMultiStr(ThreadContext context, Encoding enc1, TrPairsState state, RubyString str2) { + // an empty string has no characters to conflict if (str2.size() == 0) return enc1; Encoding enc2 = str2.getEncoding(); @@ -6396,8 +6437,10 @@ private static Encoding checkMultiStr(ThreadContext context, Encoding enc1, TrPa } if (enc1.isAsciiCompatible() && enc2.isAsciiCompatible()) { + // str2 is pure ASCII so it is valid in enc1 as it is if (enc2 == USASCIIEncoding.INSTANCE || str2.scanForCodeRange() == CR_7BIT) return enc1; + // the text so far is pure ASCII so it can be reinterpreted in str2's encoding (e.g. US-ASCII + "é" is UTF-8) if (enc1 == USASCIIEncoding.INSTANCE || cr1 == CR_7BIT) { state.cr = str2.scanForCodeRange(); return enc2; From cf9af4ac960af52e7797ae722e79bc0b24019236 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Thu, 15 Oct 2026 22:46:52 -0500 Subject: [PATCH 31/33] Skip String#tr pairs deleted by a key's #to_str instead of raising 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 --- core/src/main/java/org/jruby/RubyString.java | 32 +++++++++++--------- spec/ruby/core/string/tr_spec.rb | 9 ++++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyString.java b/core/src/main/java/org/jruby/RubyString.java index f4944cc4f01..df817ee6f28 100644 --- a/core/src/main/java/org/jruby/RubyString.java +++ b/core/src/main/java/org/jruby/RubyString.java @@ -6300,22 +6300,26 @@ private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { // state.enc/state.cr start as self's and are folded over every key and value by checkMultiStr. final TrPairsState state = new TrPairsState(value.getEncoding(), scanForCodeRange(), pairs.size()); - // to_str may remove entries from the hash, so count what we actually collect - pairs.visitAll(context, (ctx, self, key, val, index) -> { - RubyString search = key.convertToString(); - RubyString repl = val.convertToString(); - - // a key must be exactly one character: bytesize 1, or a single multibyte character - if (search.size() != 1 && search.strLength() != 1) { - throw argumentError(ctx, "keys must be of size 1"); - } + // to_str may remove entries from the hash, so count what we actually collect. Use the visitAll overload that + // takes a state: like Hash#each it skips removed entries, while the lambda overload raises ConcurrencyError. + pairs.visitAll(context, new RubyHash.VisitorWithState() { + @Override + public void visit(ThreadContext ctx, RubyHash self, IRubyObject key, IRubyObject val, int index, TrPairsState state) { + RubyString search = key.convertToString(); + RubyString repl = val.convertToString(); + + // a key must be exactly one character: bytesize 1, or a single multibyte character + if (search.size() != 1 && search.strLength() != 1) { + throw argumentError(ctx, "keys must be of size 1"); + } - state.enc = checkMultiStr(ctx, state.enc, state, search); - state.enc = checkMultiStr(ctx, state.enc, state, repl); + state.enc = checkMultiStr(ctx, state.enc, state, search); + state.enc = checkMultiStr(ctx, state.enc, state, repl); - state.keys[state.count] = search; - state.repls[state.count++] = repl; - }); + state.keys[state.count] = search; + state.repls[state.count++] = repl; + } + }, state); // MRI: e1, the encoding every key, value and self agree on; the result is tagged with it final Encoding e1 = state.enc; diff --git a/spec/ruby/core/string/tr_spec.rb b/spec/ruby/core/string/tr_spec.rb index 35464e61db8..2cc193a3ca1 100644 --- a/spec/ruby/core/string/tr_spec.rb +++ b/spec/ruby/core/string/tr_spec.rb @@ -183,6 +183,15 @@ "abc".tr("\xFF".dup.force_encoding(Encoding::UTF_8) => "x").should == "abc" end + it "skips entries deleted from the Hash by a key's #to_str" do + pairs = {} + key = Object.new + key.define_singleton_method(:to_str) { pairs.delete("b"); "a" } + pairs[key] = "x" + pairs["b"] = "y" + "ab".tr(pairs).should == "xb" + end + it "raises TypeError if the only argument is not a Hash" do -> { "hello".tr(1) }.should.raise(TypeError, "wrong argument type Integer (expected Hash)") -> { "hello".tr(nil) }.should.raise(TypeError, "wrong argument type nil (expected Hash)") From b24d8b8262461424d4b1bc6e3d3179ec1f9f1aa4 Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Wed, 21 Oct 2026 00:16:50 -0500 Subject: [PATCH 32/33] Split String#tr hash form into a TrPairs helper Review feedback: break the large trTransPairs method into small methods. TrPairs collects the pairs, folds their encodings, builds the codepoint table and translates the receiver. Also use Byte.toUnsignedInt, TypeConverter.typeAsString for the TypeError message, and view() to install the result; only a frozen check is needed since the result is built in a new buffer. Co-Authored-By: Claude Opus 5.5 --- core/src/main/java/org/jruby/RubyString.java | 302 +++++++++---------- 1 file changed, 148 insertions(+), 154 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyString.java b/core/src/main/java/org/jruby/RubyString.java index df817ee6f28..a4ff5f95bff 100644 --- a/core/src/main/java/org/jruby/RubyString.java +++ b/core/src/main/java/org/jruby/RubyString.java @@ -100,7 +100,9 @@ import org.jruby.util.io.RubyInputStream; import java.nio.charset.Charset; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Locale; import java.util.function.Function; @@ -6258,7 +6260,7 @@ public IRubyObject tr_bang(ThreadContext context, IRubyObject src, IRubyObject r return trTrans(context, src, repl, false); } - // MRI: rb_str_tr with a single pairs Hash + // MRI: rb_str_tr, Hash form @JRubyMethod(name = "tr") public IRubyObject tr(ThreadContext context, IRubyObject pairs) { RubyString str = dupString(context, this); @@ -6266,7 +6268,7 @@ public IRubyObject tr(ThreadContext context, IRubyObject pairs) { return str; } - // MRI: rb_str_tr_bang with a single pairs Hash + // MRI: rb_str_tr_bang, Hash form @JRubyMethod(name = "tr!") public IRubyObject tr_bang(ThreadContext context, IRubyObject pairs) { return trTransPairs(context, pairs); @@ -6275,183 +6277,175 @@ public IRubyObject tr_bang(ThreadContext context, IRubyObject pairs) { /** * MRI: tr_trans_pairs * - * Implements the single Hash form of tr/tr!: every character that is a key of pairs is replaced by the - * associated value string, in a single left-to-right pass (replacements are never rescanned). - * Returns this if anything was replaced and nil otherwise. + * Replaces each character of self that is a key of the Hash with that key's value, in one left-to-right pass, + * so replacement text is never itself replaced. Returns self, or nil if nothing was replaced. */ - private IRubyObject trTransPairs(ThreadContext context, IRubyObject pairsArg) { - // pairs is bound by the instanceof pattern and stays in scope below since the failing branch always throws - if (!(pairsArg instanceof RubyHash pairs)) { - // MRI: special constants are named by value in Check_Type messages - String name = pairsArg.isNil() ? "nil" : - pairsArg == context.tru ? "true" : - pairsArg == context.fals ? "false" : - pairsArg.getMetaClass().getRealClass().getName(context); - throw typeError(context, "wrong argument type " + name + " (expected Hash)"); - } - - // same order of checks as MRI: broken string, then frozen, then the nothing-to-do shortcut + private IRubyObject trTransPairs(ThreadContext context, IRubyObject arg) { + if (!(arg instanceof RubyHash hash)) { + throw typeError(context, str(context.runtime, "wrong argument type ", TypeConverter.typeAsString(arg), " (expected Hash)")); + } + + // MRI's order: broken string, frozen, then the empty shortcut (which skips validating the keys). + // Only the frozen check is needed: the result is built in a new buffer, never written into the old bytes. mustnotBroken(context); - modify(); + modifyCheck(); + if (value.getRealSize() == 0 || hash.isEmpty()) return context.nil; - if (value.getRealSize() == 0 || pairs.isEmpty()) return context.nil; + ByteList translated = TrPairs.collect(context, hash, getEncoding(), scanForCodeRange()).translate(value); + if (translated == null) return context.nil; - // phase 1: collect each key/value as a String and negotiate one encoding all of them (and self) can share. - // state.enc/state.cr start as self's and are folded over every key and value by checkMultiStr. - final TrPairsState state = new TrPairsState(value.getEncoding(), scanForCodeRange(), pairs.size()); + view(translated); + clearCodeRange(); + return this; + } - // to_str may remove entries from the hash, so count what we actually collect. Use the visitAll overload that - // takes a state: like Hash#each it skips removed entries, while the lambda overload raises ConcurrencyError. - pairs.visitAll(context, new RubyHash.VisitorWithState() { + /** + * The pairs of a tr Hash. collect() converts every key and value to a String, works out the one encoding that + * self and all of them can share, and indexes the values by the codepoint of their key in that encoding. The + * index has to wait until every pair is seen because a later pair can still change the encoding. + */ + private static final class TrPairs { + private record Pair(RubyString key, RubyString value) {} + + // Visits the live Hash: a key's to_str may delete later entries, and MRI then skips them. This visitAll + // overload skips them too, like Hash#each; the stateless one raises ConcurrencyError instead. + private static final RubyHash.VisitorWithState ADD = new RubyHash.VisitorWithState<>() { @Override - public void visit(ThreadContext ctx, RubyHash self, IRubyObject key, IRubyObject val, int index, TrPairsState state) { - RubyString search = key.convertToString(); - RubyString repl = val.convertToString(); + public void visit(ThreadContext context, RubyHash self, IRubyObject key, IRubyObject value, int index, TrPairs pairs) { + pairs.add(context, key, value); + } + }; - // a key must be exactly one character: bytesize 1, or a single multibyte character - if (search.size() != 1 && search.strLength() != 1) { - throw argumentError(ctx, "keys must be of size 1"); - } + private final List pairs; + private Encoding encoding; + private int codeRange; // MRI's &cr: CR_7BIT while everything folded so far is ASCII and may change encoding - state.enc = checkMultiStr(ctx, state.enc, state, search); - state.enc = checkMultiStr(ctx, state.enc, state, repl); + // values by key codepoint, like trTransHelper: an array below TRANS_SIZE, an IntHash above it + private ByteList[] low; + private IntHash high; - state.keys[state.count] = search; - state.repls[state.count++] = repl; - } - }, state); - - // MRI: e1, the encoding every key, value and self agree on; the result is tagged with it - final Encoding e1 = state.enc; - - // phase 2: index replacements by the codepoint of their key, like trTransHelper does: a direct table for - // codepoints below StringSupport.TRANS_SIZE and an IntHash above. - // - // Note low is allocated on every call. For a short receiver and just a few pairs that is likely the largest - // allocation here, so a small-pair fast path (e.g. up to ~4 pairs: compare each character against the key - // list directly and skip the tables) might be worthwhile if profiling shows it matters. - final RubyString[] low = new RubyString[StringSupport.TRANS_SIZE]; - IntHash high = null; - for (int i = 0; i < state.count; i++) { - ByteList k = state.keys[i].getByteList(); - int c = e1.mbcToCode(k.unsafeBytes(), k.begin(), k.begin() + k.realSize()); - if (c < 0) continue; // an invalid key can never match a character of a valid string - if (c < StringSupport.TRANS_SIZE) { - low[c] = state.repls[i]; - } else { - if (high == null) high = new IntHash<>(); - high.put(c, state.repls[i]); - } + private TrPairs(Encoding encoding, int codeRange, int size) { + this.pairs = new ArrayList<>(size); + this.encoding = encoding; + this.codeRange = codeRange; } - // phase 3: scan self's bytes and translate. - // sbytes[begin, send) is the input; send only bounds reading it. The result goes to a separate, growable - // buffer (out) because replacements may be longer or shorter than the characters they replace. - final byte[] sbytes = value.unsafeBytes(); - final int begin = value.begin(); - final int send = begin + value.realSize(); - // false for e.g. UTF-16/32, where an ASCII character is not a single byte - final boolean asciiCompatible = e1.isAsciiCompatible(); - // out stays null until the first replacement so that an unchanged string returns nil without any copying - ByteList out = null; - // everything in sbytes[checkpoint, s) is unchanged input that has not been copied to out yet - int checkpoint = begin; - // s is the offset of the current character - int s = begin; - - while (s < send) { - int clen; - RubyString repl; - - // bytes are signed in Java; & 0xff gives the unsigned 0-255 value so the ASCII test and table index are right - int b = sbytes[s] & 0xff; - if (b < 0x80 && asciiCompatible) { - // ASCII fast path: a single byte whose value is its own codepoint, no decoding needed - clen = 1; - repl = low[b]; - } else { - // otherwise decode: clen is the character's byte length, c its codepoint - clen = StringSupport.length(e1, sbytes, s, send); - int c = e1.mbcToCode(sbytes, s, send); - repl = c < 0 ? null : c < StringSupport.TRANS_SIZE ? low[c] : high == null ? null : high.get(c); - } - - if (repl != null) { - // copy the untouched run since the last replacement, then the replacement itself - if (out == null) out = new ByteList(value.realSize() + 16); - out.append(sbytes, checkpoint, s - checkpoint); - out.append(repl.getByteList()); - checkpoint = s + clen; - } - s += clen; + static TrPairs collect(ThreadContext context, RubyHash hash, Encoding encoding, int codeRange) { + TrPairs pairs = new TrPairs(encoding, codeRange, hash.size()); + hash.visitAll(context, ADD, pairs); + pairs.buildTable(); + return pairs; } - if (out == null) return context.nil; + private void add(ThreadContext context, IRubyObject key, IRubyObject value) { + RubyString search = key.convertToString(); + RubyString replace = value.convertToString(); - // copy the unchanged tail, then swap the new bytes in - out.append(sbytes, checkpoint, send - checkpoint); + // one character: a single byte, or one (possibly multibyte) character in the key's own encoding + if (search.size() != 1 && search.strLength() != 1) { + throw argumentError(context, "keys must be of size 1"); + } - value.setUnsafeBytes(out.unsafeBytes()); - value.setBegin(0); - value.setRealSize(out.realSize()); - setEncoding(e1); - clearCodeRange(); - return this; - } + fold(context, search); + fold(context, replace); + pairs.add(new Pair(search, replace)); + } + + /** + * MRI: rb_enc_check_multi_str + * + * Folds one more string into the shared encoding, or raises Encoding::CompatibilityError. As with + * Encoding.compatible?, two different encodings can only be mixed when one side is pure ASCII. Unlike a + * pairwise check, codeRange remembers whether *everything* so far was ASCII: "ab".tr("a" => latin1_a_grave, + * "b" => utf8_e_acute) must raise even though each value is compatible with "ab" on its own. + */ + private void fold(ThreadContext context, RubyString str) { + if (str.size() == 0) return; + + Encoding strEncoding = str.getEncoding(); + if (encoding == strEncoding) { + if (codeRange == CR_7BIT) codeRange = str.scanForCodeRange(); + return; + } - // Accumulator for trTransPairs: the pairs collected from the Hash plus the encoding negotiated so far. - private static final class TrPairsState { - // the encoding all strings seen so far can share; starts as the receiver's - Encoding enc; - // code range (7BIT/VALID) of the text seen so far; checkMultiStr uses it to tell whether the text is still - // pure ASCII and so may adopt another string's encoding. MRI passes this as an out-parameter (&cr). - int cr; - // parallel arrays of the collected key and replacement strings - final RubyString[] keys; - final RubyString[] repls; - // number of pairs stored in keys/repls; to_str can remove entries from the Hash while it is being visited, - // so this can end up smaller than the arrays' length - int count; + if (encoding.isAsciiCompatible() && strEncoding.isAsciiCompatible()) { + // str is ASCII, so it reads the same in the current encoding + if (strEncoding == USASCIIEncoding.INSTANCE || str.scanForCodeRange() == CR_7BIT) return; - TrPairsState(Encoding enc, int cr, int capacity) { - this.enc = enc; - this.cr = cr; - this.keys = new RubyString[capacity]; - this.repls = new RubyString[capacity]; - } - } + // everything so far is ASCII, so it can all be read in str's encoding instead + if (encoding == USASCIIEncoding.INSTANCE || codeRange == CR_7BIT) { + encoding = strEncoding; + codeRange = str.scanForCodeRange(); + return; + } + } - /** - * MRI: rb_enc_check_multi_str - * - * Folds str2 (a key or value) into the encoding negotiated so far, returning the encoding to use from then on or - * raising Encoding::CompatibilityError if str2 cannot share one with the text seen so far (state.cr is updated - * when the encoding changes). - */ - private static Encoding checkMultiStr(ThreadContext context, Encoding enc1, TrPairsState state, RubyString str2) { - // an empty string has no characters to conflict - if (str2.size() == 0) return enc1; + throw encodingCompatibilityError(context, "incompatible character encodings: " + encoding + " and " + strEncoding); + } - Encoding enc2 = str2.getEncoding(); - int cr1 = state.cr; + private void buildTable() { + low = new ByteList[StringSupport.TRANS_SIZE]; + for (Pair pair : pairs) { + ByteList key = pair.key().getByteList(); + int c = encoding.mbcToCode(key.unsafeBytes(), key.begin(), key.begin() + key.realSize()); + if (c < 0) continue; // an invalid key can never match a character of the (valid) receiver - if (enc1 == enc2) { - if (cr1 == CR_7BIT) state.cr = str2.scanForCodeRange(); - return enc1; + if (c < low.length) { + low[c] = pair.value().getByteList(); + } else { + if (high == null) high = new IntHash<>(); + high.put(c, pair.value().getByteList()); // later keys win, as with MRI's hash_aset + } + } } - if (enc1.isAsciiCompatible() && enc2.isAsciiCompatible()) { - // str2 is pure ASCII so it is valid in enc1 as it is - if (enc2 == USASCIIEncoding.INSTANCE || str2.scanForCodeRange() == CR_7BIT) return enc1; + private ByteList lookup(int c) { + if (c < 0) return null; + if (c < low.length) return low[c]; + return high == null ? null : high.get(c); + } + + /** + * Returns a copy of source with every key character replaced, tagged with the shared encoding, or null if + * there was nothing to replace. source is read in the shared encoding. That only differs from its own + * encoding when source is pure ASCII, and ASCII reads the same in every encoding the fold allows. + */ + ByteList translate(ByteList source) { + byte[] bytes = source.unsafeBytes(); + int p = source.begin(); + int end = p + source.realSize(); + Encoding enc = encoding; + boolean asciiCompatible = enc.isAsciiCompatible(); // false for UTF-16/32, where ASCII is not one byte + ByteList[] low = this.low; // a local, so the JIT can keep it in a register through the loop + + // out is created at the first match, so a string with no matches returns null without copying anything. + // bytes[checkpoint, p) is unchanged input not yet copied to out. + ByteList out = null; + int checkpoint = p; + while (p < end) { + int b = Byte.toUnsignedInt(bytes[p]); + int len; + ByteList replace; + if (b < 0x80 && asciiCompatible) { // an ASCII byte is its own codepoint, so skip decoding + len = 1; + replace = low[b]; + } else { + len = StringSupport.length(enc, bytes, p, end); + replace = lookup(enc.mbcToCode(bytes, p, end)); + } - // the text so far is pure ASCII so it can be reinterpreted in str2's encoding (e.g. US-ASCII + "é" is UTF-8) - if (enc1 == USASCIIEncoding.INSTANCE || cr1 == CR_7BIT) { - state.cr = str2.scanForCodeRange(); - return enc2; + if (replace != null) { + if (out == null) out = new ByteList(source.realSize() + 16, enc); + out.append(bytes, checkpoint, p - checkpoint); + out.append(replace); + checkpoint = p + len; + } + p += len; } - } - throw encodingCompatibilityError(context, "incompatible character encodings: " + enc1 + " and " + enc2); + if (out != null) out.append(bytes, checkpoint, end - checkpoint); + return out; + } } private IRubyObject trTrans(ThreadContext context, IRubyObject src, IRubyObject repl, boolean sflag) { From 18206e0fc0e6638eb8e6f3bc5bffdb882c3dbcbb Mon Sep 17 00:00:00 2001 From: Zev Blut Date: Thu, 29 Oct 2026 17:10:08 -0500 Subject: [PATCH 33/33] Replace MRI comments with @CRuby in String#tr Hash form Follow-up to #9811. Converts the `// MRI:` references in the String#tr Hash form to @CRuby annotations. No code changes. Co-Authored-By: Claude Opus 5.5 --- core/src/main/java/org/jruby/RubyString.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/org/jruby/RubyString.java b/core/src/main/java/org/jruby/RubyString.java index 30e1ae970c7..628ae348243 100644 --- a/core/src/main/java/org/jruby/RubyString.java +++ b/core/src/main/java/org/jruby/RubyString.java @@ -6208,7 +6208,7 @@ public IRubyObject tr_bang(ThreadContext context, IRubyObject src, IRubyObject r return trTrans(context, src, repl, false); } - // MRI: rb_str_tr, Hash form + @CRuby(value = "rb_str_tr", note = "Hash form") @JRubyMethod(name = "tr") public IRubyObject tr(ThreadContext context, IRubyObject pairs) { RubyString str = dupString(context, this); @@ -6216,18 +6216,17 @@ public IRubyObject tr(ThreadContext context, IRubyObject pairs) { return str; } - // MRI: rb_str_tr_bang, Hash form + @CRuby(value = "rb_str_tr_bang", note = "Hash form") @JRubyMethod(name = "tr!") public IRubyObject tr_bang(ThreadContext context, IRubyObject pairs) { return trTransPairs(context, pairs); } /** - * MRI: tr_trans_pairs - * * Replaces each character of self that is a key of the Hash with that key's value, in one left-to-right pass, * so replacement text is never itself replaced. Returns self, or nil if nothing was replaced. */ + @CRuby("tr_trans_pairs") private IRubyObject trTransPairs(ThreadContext context, IRubyObject arg) { if (!(arg instanceof RubyHash hash)) { throw typeError(context, str(context.runtime, "wrong argument type ", TypeConverter.typeAsString(arg), " (expected Hash)")); @@ -6300,13 +6299,12 @@ private void add(ThreadContext context, IRubyObject key, IRubyObject value) { } /** - * MRI: rb_enc_check_multi_str - * * Folds one more string into the shared encoding, or raises Encoding::CompatibilityError. As with * Encoding.compatible?, two different encodings can only be mixed when one side is pure ASCII. Unlike a * pairwise check, codeRange remembers whether *everything* so far was ASCII: "ab".tr("a" => latin1_a_grave, * "b" => utf8_e_acute) must raise even though each value is compatible with "ab" on its own. */ + @CRuby(value = "rb_enc_check_multi_str", file = "encoding.c") private void fold(ThreadContext context, RubyString str) { if (str.size() == 0) return;