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
1 change: 1 addition & 0 deletions lib/software_version.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'software_version/version'
require 'software_version/rpm'

module SoftwareVersion
end
Expand Down
101 changes: 101 additions & 0 deletions lib/software_version/rpm.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
require 'software_version/version'

# Root namespace of the gem.
module SoftwareVersion
# Version comparing like RPM (rpmvercmp, as used by rpm and dnf/yum).
#
# It differs from the generic Version on a few points:
# - every non-alphanumeric character other than '~' and '^' is a mute
# separator, so `module+el8+2468` aligns with `module+el8.1.0+3366` the way
# dnf compares them, while the generic tokenizer keeps '+' as an ordered
# token and misaligns them;
# - the string is first split into `[epoch:]version[-release]` on the last
# dash, and each part is compared fully before the next one;
# - words carry no semantic (no pre-version like alpha/rc, no dropped
# rev/update) and are compared as raw case-sensitive strings;
# - the number of segments matters: 1.0 > 1, unlike the generic 1.0 = 1.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

C'est ce que fait RPM mais pas sur de si on veut vraiment garder ce comportement la.

class Rpm < Version
def <=>(other)
other = SoftwareVersion.Rpm(other)
cmp = epoch <=> other.epoch
cmp = compare_tokens(version_tokens, other.version_tokens) if cmp.zero?
cmp = compare_tokens(release_tokens, other.release_tokens) if cmp.zero?
cmp
end

def epoch
evr[0]
end

protected

# Expose the version and release tokens, mostly for #version_parts. The
# EOV of the version part keeps the first number sequence from leaking
# into the release.
def tokens
@tokens ||= version_tokens + release_tokens
end

def version_tokens
@version_tokens ||= parse(evr[1])
end

def release_tokens
@release_tokens ||= parse(evr[2])
end

private

# Split the raw string into [epoch, version, release] like rpm does: the
# epoch is the leading digits before a ':' and defaults to 0, the release
# is everything after the last '-' and defaults to "".
def evr
@evr ||= begin
rest = @v.to_s
epoch = 0
if (match = rest.match(/\A(\d+):(.*)\z/m))
epoch = match[1].to_i
rest = match[2]
end
dash = rest.rindex('-')
dash ? [epoch, rest[0...dash], rest[(dash + 1)..]] : [epoch, rest, '']
end
end

# rpm keeps only numbers, words, '~' and '^'; every other character is a
# separator without semantic value. Words are left raw: rpmvercmp has no
# special words and compares them as case-sensitive ASCII strings.
#
# A trailing '^' keeps the generic "highest version possible" meaning

@jinxka jinxka Jul 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Comportement différent de rpmvercmp mais très utile chez nous.
Les versions de RPM ne se terminent pas naturellement par ^ donc pas de risque de casse.

# instead of rpmvercmp's ("1.0^" > "1.0" but < "1.0.1"), so that ranges
# like `>= 1.0, <= 1.0^` keep matching every 1.0 subversion. Real rpm
# versions never end with '^', so this exception costs no fidelity.
def parse(version_string)
semantic_tokens = []
literal_tokens = lex(version_string)
literal_tokens << nil # Sentinel for each_cons.
literal_tokens.each_cons(2) do |current, ahead|
case current[0]
when Token::DOT, Token::UNDERSCORE, Token::COLON,
Token::DASH, Token::PLUS
# Mute separators.
when Token::CARET
semantic_tokens << (ahead[0] == Token::EOV ? [Token::MAX, current[1]] : current)
else
semantic_tokens << current
end
end
semantic_tokens
end
end

# Convert the argument to an Rpm version. An existing Rpm is reused as-is;
# any other Version or String is (re)wrapped so it compares with rpm/dnf
# semantics.
def Rpm(version)
return version if version.is_a?(Rpm)

Rpm.new(version.is_a?(Version) ? version.v : version.to_s)
end
module_function :Rpm
end
26 changes: 19 additions & 7 deletions lib/software_version/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ def initialize(raw_version)
end

def <=>(other)
# Defer to rpm/dnf semantics whenever the other operand is an Rpm version,
# so the comparison stays symmetric regardless of operand order.
return -(other <=> self) if other.is_a?(Rpm)

other_tokens = other.is_a?(Version) ? other.tokens : parse(other.to_s)
tokens.zip(other_tokens) do |left, right|
cmp = left[0] <=> right[0]
cmp = left[1] <=> right[1] if cmp == 0
return cmp if cmp != 0
end
0
compare_tokens(tokens, other_tokens)
end

def to_s
Expand Down Expand Up @@ -91,6 +90,17 @@ def tokens
@tokens ||= parse(@v.to_s)
end

# Compare two Token arrays pairwise. Types are compared first, values
# break the tie. Returns -1, 0 or 1.
def compare_tokens(left_tokens, right_tokens)
left_tokens.zip(right_tokens) do |left, right|
cmp = left[0] <=> right[0]
cmp = left[1] <=> right[1] if cmp == 0
return cmp if cmp != 0
end
0
end

private

# Associate characters to their token types. Multiple characters of the
Expand Down Expand Up @@ -128,7 +138,6 @@ def lex(version_string)
case chunk_type
when nil then return
when Token::NUMBER then tokens << [Token::NUMBER, chunk_value.to_i]
when Token::WORD then tokens << [Token::WORD, chunk_value.downcase]
else tokens << [chunk_type, chunk_value]
end
chunk_type = nil
Expand Down Expand Up @@ -179,7 +188,10 @@ def parse(version_string)
when Token::CARET
semantic_tokens << (ahead[0] == Token::EOV ? [Token::MAX, current[1]] : current)

# Words are case-insensitive for the generic semantic. The lexer keeps
# the original case because subclasses may be case-sensitive.
when Token::WORD
current = [Token::WORD, current[1].downcase]
case current[1]
# Some special words are just fancy ways of making a subversion.
# Semantically, they are nothing more than dots, so 1u1 = 1.1. Some
Expand Down
Loading