-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Add pattern matching support to Gem::Version #9060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
653101a
2a4c560
423a743
8676f19
3bc3e37
17cdd2a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -336,6 +336,65 @@ def segments # :nodoc: | |
| _segments.dup | ||
| end | ||
|
|
||
| ## | ||
| # Deconstructs the version into an array for pattern matching. | ||
| # Returns the version segments as an array. | ||
| # | ||
| # Gem::Version.new("3.2.1").deconstruct #=> [3, 2, 1] | ||
| # Gem::Version.new("3.2.0.rc.2").deconstruct #=> [3, 2, 0, "rc", 2] | ||
| # | ||
| # This enables array pattern matching on version segments: | ||
| # | ||
| # case Gem::Version.new("3.2.1") | ||
| # in [major, minor, build] | ||
| # # major => 3, minor => 2, build => 1 | ||
| # end | ||
| # | ||
| # case Gem::Version.new("3.2.0.rc.2") | ||
| # in [major, minor, build, pre, *] | ||
| # # Matches prerelease versions | ||
| # end | ||
| alias_method :deconstruct, :segments | ||
|
|
||
| ## | ||
| # Deconstructs the version into a hash for pattern matching. | ||
| # Returns a hash with keys +:major+, +:minor+, and +:build+. | ||
| # | ||
| # Note: RubyGems does not enforce a specific versioning scheme, and the | ||
| # names "major", "minor", and "build" are conventional. | ||
| # | ||
| # Gem::Version.new("3.2.1").deconstruct_keys(nil) #=> { major: 3, minor: 2, build: 1 } | ||
| # Gem::Version.new("3.2").deconstruct_keys(nil) #=> { major: 3, minor: 2, build: nil } | ||
| # Gem::Version.new("3").deconstruct_keys(nil) #=> { major: 3, minor: nil, build: nil } | ||
| # | ||
| # This enables hash pattern matching: | ||
| # | ||
| # case Gem::Version.new("3.2.1") | ||
| # in major: 3, minor: 2, build: 1 | ||
| # # Matches exactly 3.2.1 | ||
| # end | ||
| # | ||
| # Important: Hash pattern matching checks each segment independently, which | ||
| # differs from version comparison semantics: | ||
| # | ||
| # # This matches "3.2" but NOT "4.0" (since 0 < 2) | ||
| # case Gem::Version.new("4.0") | ||
| # in major: (3..), minor: (2..) | ||
| # "matches" | ||
| # else | ||
| # "no match" # => "no match" | ||
| # end | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would match "x.y" when
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call, that is indeed a flaw of the hash-like patterns, so that comment would indeed be incorrect.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Patched the docs to be more accurate there. |
||
| # | ||
| # # However, version comparison shows 4.0 > 3.2 | ||
| # Gem::Version.new("4.0") >= Gem::Version.new("3.2") #=> true | ||
| # | ||
| # For version comparison logic, use standard comparison operators or array | ||
| # patterns instead of hash patterns with ranges. | ||
| def deconstruct_keys(keys) | ||
colby-swandale marked this conversation as resolved.
Show resolved
Hide resolved
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was under the impression RubyGems doesn't assume any particular versioning scheme beyond being dot-separated. For example, "RubyGems Rational Versioning" in the class-level docs refers the third number as "build" rather than "patch". RubyGems also has released a version "3.2.0.rc.2". I think only array patterns should be supported.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a fair argument, and I get where you're coming from here, but allow me a case for why it may still make sense. 1. Hash patterns provide greater readabilityWe can likely agree that major and minor are known. Patch vs build vs whatever other term? That's a fair point, but even with those two the intent is clearer with keywords: # Hash pattern - clear intent
case version
in major: 3.., minor: 2..
use_modern_api
end
# Array pattern - requires mental mapping
case version
in [3.., 2.., _]
use_modern_api
end2. Partial matching without positional couplingWhile versions are indeed well-structured it's easier to match without placeholders: # Check major version without caring about minor/patch
case version
in major: 3..
# Works regardless of how many segments exist
end
# Array pattern requires placeholder for unused positions
case version
in [3.., _, _] # or [3.., *rest] - less clear
end3. Real world patternsThe most common use-cases tend to be around just checking major and minor version: case Gem::Version.new(RUBYVERSION)
in major: 3.., minor: 2.. then gem "activesupport", "~> 8.0"
in major: 3.., minor: 1.. then gem "activesupport", "~> 7.0"
else gem "activesupport", "~> 6.0"
end4. Precedent in Ruby CoreClasses like 5. Edge casesWhat should we do when the segments start to become a bit more erratic such as your case of case version
in [major, minor, build, pre, _] when pre.is_a?(String)
# Handle prerelease
in major: 3.., minor: 2..
# Handle stable versions
endCompromise?Given all of that I would still be inclined to support both, but update the documentation to:
The hash pattern doesn't prevent flexibility, it provides a convenience mechanism for what's probably 90%+ of cases where people want to match against things, with array like patterns remaining available for more complex versioning schemes. That said, it's your all's repo, so decision is yours there.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (FWIW, I'm not a RubyGems maintainer and won't be deciding whether this gets merged.) I'd expect array patterns and hash patterns can express roughly the same amount of information. Personally, I don't agree with the 1. and 2., as array patterns are shorter and, to me, clear enough. I'd probably use |
||
| major, minor, build = segments | ||
| { major:, minor:, build: } | ||
| end | ||
|
|
||
| ## | ||
| # A recommended version for use with a ~> Requirement. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -226,6 +226,65 @@ def test_frozen_version | |
| assert_version_equal v("2"), v.bump | ||
| end | ||
|
|
||
| def test_deconstruct | ||
| version = v("3.2.1") | ||
| major, minor, patch = version.deconstruct | ||
| assert_equal 3, major | ||
| assert_equal 2, minor | ||
| assert_equal 1, patch | ||
| end | ||
|
|
||
| def test_deconstruct_keys | ||
| version = v("3.2.1") | ||
| assert_equal({ major: 3, minor: 2, build: 1 }, version.deconstruct_keys(nil)) | ||
| end | ||
|
|
||
colby-swandale marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| def test_deconstruct_keys_two_segments | ||
| version = v("3.2") | ||
| assert_equal({ major: 3, minor: 2, build: nil }, version.deconstruct_keys(nil)) | ||
| end | ||
|
|
||
| def test_deconstruct_keys_one_segment | ||
| version = v("3") | ||
| assert_equal({ major: 3, minor: nil, build: nil }, version.deconstruct_keys(nil)) | ||
| end | ||
|
|
||
| def test_pattern_matching_array | ||
| case v("3.2.1") | ||
| in [major, minor, build] | ||
| assert_equal 3, major | ||
| assert_equal 2, minor | ||
| assert_equal 1, build | ||
| else | ||
| flunk "Array pattern did not match" | ||
| end | ||
| end | ||
|
|
||
| def test_pattern_matching_hash | ||
| result = | ||
| case v("3.2.1") | ||
| in major: 3.., minor: 2.. then "matched" | ||
| else "no match" | ||
| end | ||
| assert_equal "matched", result | ||
| end | ||
|
|
||
| def test_pattern_matching_hash_vs_comparison | ||
| # Hash pattern checks each segment independently | ||
| version = v("4.0") | ||
| result = | ||
| case version | ||
| in major: (3..), minor: (2..) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above comment on parens. TL;DR: without them it tries to do |
||
| "matched" | ||
| else | ||
| "no match" | ||
| end | ||
| assert_equal "no match", result | ||
|
|
||
| # But version comparison shows 4.0 > 3.2 | ||
| assert_operator v("4.0"), :>=, v("3.2") | ||
| end | ||
|
|
||
| # Asserts that +version+ is a prerelease. | ||
|
|
||
| def assert_prerelease(version) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why the parens? Because if you exclude them the
2..will ignore the newline and try and make2.."matches"which is... not ideal. I'm half between whether that's a syntax bug or by design, but in the interim probably patch this to match.