Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions lib/rubygems/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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..)
Copy link
Contributor Author

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 make 2.."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.

# "matches"
# else
# "no match" # => "no match"
# end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would match "x.y" when x >= 3 && y >= 2, not when Gem::Version.new("x.y") >= Gem::Version.new("3.2").

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 readability

We 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
end

2. Partial matching without positional coupling

While 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
end

3. Real world patterns

The 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"
end

4. Precedent in Ruby Core

Classes like Date and Time are also arguably positional in nature, and have more of an arbitrary precision element, but they also support hash-like patterns. I believe they are complementary for different use-cases, though again with time I'd not want to type out the array syntax to get what I wanted.

5. Edge cases

What should we do when the segments start to become a bit more erratic such as your case of 3.2.0.rc.2? Then it becomes fuzzier if it's a purely positional concept or if there are implied names for each given segment:

case version
in [major, minor, build, pre, _] when pre.is_a?(String)
 # Handle prerelease
in major: 3.., minor: 2..
 # Handle stable versions
end

Compromise?

Given all of that I would still be inclined to support both, but update the documentation to:

  1. Note that hash keys (major/minor/patch) are conventional names, and not enforced semantics
  2. Show examples of pre-release / alpha / edge case versions
  3. Recommend array patterns for versions with 3+ segments

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.

Copy link
Member

Choose a reason for hiding this comment

The 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 === matching with range ("3.2"..) when I don't have subpatterns to match against segments or need to extract them into variables, though.

major, minor, build = segments
{ major:, minor:, build: }
end

##
# A recommended version for use with a ~> Requirement.

Expand Down
59 changes: 59 additions & 0 deletions test/rubygems/test_gem_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

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..)
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 2.."matches" because of the newline.

"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)
Expand Down