Improve tokenization of version strings

Tokens like "b4", "beta1", "p195", &c. are now treated as atoms rather
than being broken down even further. Additionally, we enable support for
padding in the middle of versions strings, so we can successfully
compare something like "2.1-p195" with "2.1.0-p194" by inferring that
"2.1" is really "2.1.0".

This fixes the comparison "9.9.3-P1" > "9.9.3" which previously has not
been handled correctly.
This commit is contained in:
Jack Nagel 2013-06-05 21:52:48 -05:00
parent 3e5ac7e55c
commit 28acfbba51
2 changed files with 199 additions and 80 deletions

View File

@ -8,10 +8,20 @@ class VersionComparisonTests < Test::Unit::TestCase
assert_operator version('0.1'), :==, version('0.1.0') assert_operator version('0.1'), :==, version('0.1.0')
assert_operator version('0.1'), :<, version('0.2') assert_operator version('0.1'), :<, version('0.2')
assert_operator version('1.2.3'), :>, version('1.2.2') assert_operator version('1.2.3'), :>, version('1.2.2')
assert_operator version('1.2.3-p34'), :>, version('1.2.3-p33')
assert_operator version('1.2.4'), :<, version('1.2.4.1') assert_operator version('1.2.4'), :<, version('1.2.4.1')
end
def test_patchlevel
assert_operator version('1.2.3-p34'), :>, version('1.2.3-p33')
assert_operator version('1.2.3-p33'), :<, version('1.2.3-p34')
end
def test_HEAD
assert_operator version('HEAD'), :>, version('1.2.3') assert_operator version('HEAD'), :>, version('1.2.3')
assert_operator version('1.2.3'), :<, version('HEAD') assert_operator version('1.2.3'), :<, version('HEAD')
end
def test_alpha_beta_rc
assert_operator version('3.2.0b4'), :<, version('3.2.0') assert_operator version('3.2.0b4'), :<, version('3.2.0')
assert_operator version('1.0beta6'), :<, version('1.0b7') assert_operator version('1.0beta6'), :<, version('1.0b7')
assert_operator version('1.0b6'), :<, version('1.0beta7') assert_operator version('1.0b6'), :<, version('1.0beta7')
@ -19,13 +29,18 @@ class VersionComparisonTests < Test::Unit::TestCase
assert_operator version('1.1beta2'), :<, version('1.1rc1') assert_operator version('1.1beta2'), :<, version('1.1rc1')
assert_operator version('1.0.0beta7'), :<, version('1.0.0') assert_operator version('1.0.0beta7'), :<, version('1.0.0')
assert_operator version('3.2.1'), :>, version('3.2beta4') assert_operator version('3.2.1'), :>, version('3.2beta4')
assert_nil version('1.0') <=> 'foo'
end end
def test_version_queries def test_comparing_unevenly_padded_versions
assert Version.new("1.1alpha1").alpha? assert_operator version('2.1.0-p194'), :<, version('2.1-p195')
assert Version.new("1.0beta2").beta? assert_operator version('2.1-p195'), :>, version('2.1.0-p194')
assert Version.new("1.0rc-1").rc? assert_operator version('2.1-p194'), :<, version('2.1.0-p195')
assert_operator version('2.1.0-p195'), :>, version('2.1-p194')
assert_operator version('2-p194'), :<, version('2.1-p195')
end
def test_comparison_returns_nil_for_non_version
assert_nil version('1.0') <=> 'foo'
end end
def test_compare_patchlevel_to_non_patchlevel def test_compare_patchlevel_to_non_patchlevel

View File

@ -1,46 +1,148 @@
class VersionElement
include Comparable
def initialize elem
elem = elem.to_s.downcase
@elem = case elem
when /\d+/ then elem.to_i
when 'a', 'alpha' then 'alpha'
when 'b', 'beta' then 'beta'
else elem
end
end
ZERO = VersionElement.new(0)
def <=>(other)
return unless other.is_a? VersionElement
return -1 if string? and other.numeric?
return 1 if numeric? and other.string?
return elem <=> other.elem
end
def to_s
@elem.to_s
end
protected
attr_reader :elem
def string?
elem.is_a? String
end
def numeric?
elem.is_a? Numeric
end
end
class Version class Version
include Comparable include Comparable
def initialize val, detected=false class Token
include Comparable
attr_reader :value
def initialize(value)
@value = value
end
def inspect
"#<#{self.class} #{value.inspect}>"
end
end
class NullToken < Token
def initialize(value=nil)
super
end
def <=>(other)
case other
when NumericToken
other.value == 0 ? 0 : -1
when AlphaToken, BetaToken, RCToken
1
else
-1
end
end
def inspect
"#<#{self.class}>"
end
end
NULL_TOKEN = NullToken.new
class StringToken < Token
PATTERN = /[a-z]+[0-9]+/i
def initialize(value)
@value = value.to_s
end
def <=>(other)
case other
when StringToken
value <=> other.value
when NumericToken, NullToken
-Integer(other <=> self)
end
end
end
class NumericToken < Token
PATTERN = /[0-9]+/i
def initialize(value)
@value = value.to_i
end
def <=>(other)
case other
when NumericToken
value <=> other.value
when StringToken
1
when NullToken
-Integer(other <=> self)
end
end
end
class CompositeToken < StringToken
def rev
value[/([0-9]+)/, 1]
end
end
class AlphaToken < CompositeToken
PATTERN = /a(?:lpha)?[0-9]+/i
def <=>(other)
case other
when AlphaToken
rev <=> other.rev
else
super
end
end
end
class BetaToken < CompositeToken
PATTERN = /b(?:eta)?[0-9]+/i
def <=>(other)
case other
when BetaToken
rev <=> other.rev
when AlphaToken
1
when RCToken, PatchToken
-1
else
super
end
end
end
class RCToken < CompositeToken
PATTERN = /rc[0-9]+/i
def <=>(other)
case other
when RCToken
rev <=> other.rev
when AlphaToken, BetaToken
1
when PatchToken
-1
else
super
end
end
end
class PatchToken < CompositeToken
PATTERN = /p[0-9]+/i
def <=>(other)
case other
when PatchToken
rev <=> other.rev
when AlphaToken, BetaToken, RCToken
1
else
super
end
end
end
def initialize(val, detected=false)
@version = val.to_s @version = val.to_s
@detected_from_url = detected @detected_from_url = detected
end end
@ -53,40 +155,14 @@ class Version
@version == 'HEAD' @version == 'HEAD'
end end
def devel?
alpha? or beta? or rc?
end
def alpha?
to_a.any? { |e| e.to_s == 'alpha' }
end
def beta?
to_a.any? { |e| e.to_s == 'beta' }
end
def rc?
to_a.any? { |e| e.to_s == 'rc' }
end
def <=>(other) def <=>(other)
# Return nil if objects aren't comparable return unless Version === other
return unless other.is_a? Version return 0 if head? && other.head?
# Versions are equal if both are HEAD return 1 if head? && !other.head?
return 0 if head? and other.head? return -1 if !head? && other.head?
# HEAD is greater than any numerical version
return 1 if head? and not other.head?
return -1 if not head? and other.head?
stuple, otuple = to_a, other.to_a max = [tokens.length, other.tokens.length].max
slen, olen = stuple.length, otuple.length pad_to(max) <=> other.pad_to(max)
max = [slen, olen].max
stuple.fill(VersionElement::ZERO, slen, max - slen)
otuple.fill(VersionElement::ZERO, olen, max - olen)
stuple <=> otuple
end end
def to_s def to_s
@ -96,8 +172,36 @@ class Version
protected protected
def to_a def pad_to(length)
@array ||= @version.scan(/\d+|[a-zA-Z]+/).map! { |e| VersionElement.new(e) } nums, rest = tokens.partition { |t| NumericToken === t }
nums.concat([NULL_TOKEN]*(length - tokens.length)).concat(rest)
end
def tokens
@tokens ||= tokenize
end
alias_method :to_a, :tokens
def tokenize
@version.scan(
Regexp.union(
AlphaToken::PATTERN,
BetaToken::PATTERN,
RCToken::PATTERN,
PatchToken::PATTERN,
NumericToken::PATTERN,
StringToken::PATTERN
)
).map! do |token|
case token
when /\A#{AlphaToken::PATTERN}\z/o then AlphaToken
when /\A#{BetaToken::PATTERN}\z/o then BetaToken
when /\A#{RCToken::PATTERN}\z/o then RCToken
when /\A#{PatchToken::PATTERN}\z/o then PatchToken
when /\A#{NumericToken::PATTERN}\z/o then NumericToken
when /\A#{StringToken::PATTERN}\z/o then StringToken
end.new(token)
end
end end
def self.parse spec def self.parse spec