Skip to content

Use explicit keyword arguments instead of ruby2_keywords - #2484

Closed
SeanLF wants to merge 3 commits into
rack:mainfrom
SeanLF:replace-ruby2-keywords
Closed

SeanLF wants to merge 3 commits into
rack:mainfrom
SeanLF:replace-ruby2-keywords

Conversation

@SeanLF

@SeanLF SeanLF commented Jul 27, 2026

Copy link
Copy Markdown

Draft, stacked on #2482. The first commit here is that PR; the diff reduces to a single commit once it merges.

Rack calls ruby2_keywords in four places, all behind respond_to? guards. Ruby proposes deprecating and removing it (Feature #22205, currently Open, no target version). Because of the guards, removal would not raise. It would silently skip, and middleware and delegators taking keyword arguments would start receiving a positional Hash.

with ruby2_keywords:   args=[:positional]                kwargs={answer: 42}
without:               args=[:positional, {answer: 42}]  kwargs={}

This forwards keywords explicitly on Ruby 3.0+ in Builder#use, BodyProxy#method_missing, MockResponse::Cookie#method_missing and UploadedFile#method_missing. Argument forwarding is unchanged on every supported Ruby.

Why the definitions are version-gated

Adding **kwargs outright regresses Ruby 2.4 to 2.7, where a trailing positional Hash is not distinguished from keywords:

call current naive **kwargs
use M, opts where opts == {} {} ArgumentError (given 1, expected 2)
use M, {"a" => 1, :b => 2} {"a"=>1, :b=>2} ArgumentError

An empty options Hash is dropped entirely; a Hash mixing Symbol and non-Symbol keys is split in two. So the pre-3.0 branch keeps ruby2_keywords, which exists on every Ruby that branch can run on. Follows the if/else version-gate style of Utils#clock_time and Utils#escape_html.

If Rack ever drops Ruby < 3.0

Every gate collapses and each site becomes a single definition with no duplication and no :nocov:. Not proposing that here; #1601 settled the policy and this patch deliberately keeps 2.4 working.

Verification

Compared the values actually received by middleware and delegated objects against unpatched main on 2.4, 2.6, 2.7, 3.0 and 3.4: byte-identical for keyword arguments, positional Hash, empty Hash, mixed-key Hash, no-argument middleware and splat middleware. Same for BodyProxy (including close-on-raise, to_str, and method(:x).call), for UploadedFile, and for consumers that subclass Builder or prepend a module over use. Builder#map flush ordering verified unchanged across four map/use interleavings.

Three tests added. They fail against a naive **kwargs version (2 errors on 2.6) and against the ruby2_keywords-removed scenario.

Notes

  • Builder#use.parameters gains [:keyrest, :kwargs] on 3.0+. Only observable API change.
  • Builder#use now delegates to a private add_middleware, a pure extraction of the existing @map/@use logic, so the two version branches stay one line each.
  • Subclasses overriding use with a bare splat already lose keywords on Ruby 3.0+. Pre-existing, unchanged here.
  • No documentation changes: SPEC.rdoc covers the server/application protocol, not the Builder DSL, and the call syntax is unchanged.

No urgency, since #22205 is still Open. Happy to hold this until it is accepted, or to split it per file if that reviews more easily.

SeanLF and others added 2 commits July 27, 2026 13:59
Rack::Multipart::UploadedFile forwards missing methods to the wrapped
tempfile, but the delegator was never flagged with ruby2_keywords. On Ruby
3.0+, where keywords are separated from a trailing Hash, the keywords are
rebuilt as a positional Hash before reaching the tempfile:

  uploaded_file.readlines(chomp: true)
  # => TypeError: no implicit conversion of Hash into Integer
  uploaded_file.gets(chomp: true)
  # => TypeError: no implicit conversion of Hash into Integer

Ruby 2.4 through 2.6 are unaffected, since they do not distinguish a
trailing Hash from keywords, which is why this went unnoticed.

Flag the delegator, matching BodyProxy#method_missing and
MockResponse::Cookie#method_missing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… 3.0+

Ruby proposes deprecating and removing ruby2_keywords (Feature #22205).
Rack's calls are guarded by respond_to?, so removal would not raise: it
would silently skip, and middleware and delegators taking keyword arguments
would start receiving a positional Hash instead.

Forward keywords explicitly on Ruby 3.0+ at all four sites: Builder#use,
BodyProxy#method_missing, MockResponse::Cookie#method_missing and
UploadedFile#method_missing.

The definitions are version-gated rather than replaced outright. Before
Ruby 3.0 a trailing positional Hash is not distinguished from keywords, so
a **kwargs parameter captures an options Hash and mangles it: an empty Hash
is dropped entirely, and a Hash mixing Symbol and non-Symbol keys is split
in two. The pre-3.0 branch keeps ruby2_keywords, which exists on every Ruby
that branch can run on.

Argument forwarding is byte-identical to the current implementation on
2.4, 2.6, 2.7, 3.0 and 3.4.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`RUBY_VERSION >= '3.0'` is a string comparison, so it is false on Ruby 10:
"10.0.0" sorts before "3.0". The gate would then take the pre-3.0 branch,
where `respond_to?(:ruby2_keywords, true)` is also false once Ruby removes
the method, so the flag is silently skipped and keyword forwarding breaks
again.

That is the exact failure this change exists to prevent, so compare the
major version numerically here. The string form is kept elsewhere in Rack,
where a far-future Ruby only skips an optional method definition.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@jeremyevans

Copy link
Copy Markdown
Contributor

Thank you for the pull request.

I think if Ruby decides to deprecate ruby2_keywords, we should drop support for Ruby < 3.0 (maybe 2.7 if ... will work), as opposed to duplicating method definitions and keeping support. However, I'm not sure how the other committers feel about that.

@SeanLF

SeanLF commented Jul 27, 2026

Copy link
Copy Markdown
Author

Yes, ... works on 2.7, including for Builder#use.

It's the same code at either floor, so 2.7 vs 3.0 stays a policy call rather than a technical one. Explicit *args, **kwargs would force 3.0: on 2.7 it still mangles a Hash mixing Symbol and non-Symbol keys.

Cleaner diff too: no ruby2_keywords, no version gates, no duplicated definitions. Happy to convert this PR to it, or to park it until #22205 progresses.

Verification

Dots uses ..., Kw uses explicit keywords, Flag is the current approach. Builder#use stores its arguments in a proc rather than forwarding immediately, so that shape is what's exercised. Pos takes a required positional options Hash, which is where they diverge.

for v in 2.7 3.0 3.4; do printf "ruby:%-5s " "$v"; docker run --rm ruby:$v ruby -e '
class Dots; def use(m, ...); @p = proc { |app| m.new(app, ...) }; end; def to_app(a); @p[a]; end; end
class Kw;   def use(m, *a, **k, &b); @p = proc { |app| m.new(app, *a, **k, &b) }; end; def to_app(a); @p[a]; end; end
class Flag; def use(m, *a, &b); @p = proc { |app| m.new(app, *a, &b) }; end; ruby2_keywords(:use); def to_app(a); @p[a]; end; end
class Pos; def initialize(app, options); $out << options; end; end
o = {}
[Dots, Kw, Flag].each { |c| $out = []
  [->(b){ b.use(Pos, o) }, ->(b){ b.use(Pos, {"s" => 1, sym: 2}) }, ->(b){ b.use(Pos, {a: 1}) }].each do |fn|
    b = c.new; begin; fn.call(b); b.to_app(:app); rescue ArgumentError; $out << :ArgumentError; end
  end
  puts "#{c}: #{$out.inspect}" }
'; echo; done
ruby:2.7   Dots: [{}, {"s"=>1, :sym=>2}, {:a=>1}]
           Kw:   [{}, :ArgumentError, {:a=>1}]
           Flag: [{}, {"s"=>1, :sym=>2}, {:a=>1}]
ruby:3.0   all three identical

2.4-2.6 raise SyntaxError on .... JRuby 9.4 and TruffleRuby both match.

Also checked against unpatched main: BodyProxy delegation including close-on-raise and method(:x).call, UploadedFile delegation, Builder#map flush ordering, and consumers that subclass Builder or prepend a module over use. All identical.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants