Skip to content

Instantly share code, notes, and snippets.

@straight-shoota
Created April 5, 2019 19:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save straight-shoota/d8eec4a346018b0a51c1730b2a5c2bf8 to your computer and use it in GitHub Desktop.
Save straight-shoota/d8eec4a346018b0a51c1730b2a5c2bf8 to your computer and use it in GitHub Desktop.
Crystal SoftwareVersion
# The `SoftwareVersion` type represents a version number.
#
# An instance can be created from a version string which consists of a series of
# segments separated by periods. Each segment contains one ore more alpanumerical
# ASCII characters. The first segment is expected to contain only digits.
#
# There may be one instance of a dash (`-`) which denotes the version as a
# pre-release. It is otherwise equivalent to a period.
#
# Optional version metadata may be attached and is separated by a plus character (`+`).
# All content following a `+` is considered metadata.
#
# This format is described by the regular expression:
# `/[0-9]+(?>\.[0-9a-zA-Z]+)*(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-.]+)?/`
#
# This implementation is compatible to popular versioning schemes such as
# [`SemVer`](https://semver.org/) and [`CalVer`](https://calver.org/) but
# doesn't enforce any particular one.
#
# It behaves mostly equivalent to [`Gem::Version`](http://docs.seattlerb.org/rubygems/Gem/Version.html) from `rubygems`.
#
# ## Sort order
# This wrapper type is mostly important for properly sorting version numbers,
# because generic lexical sorting doesn't work: For instance, `3.10` is supposed
# to be greater than `3.2`.
#
# Every set of consecutive digits anywhere in the string are interpreted as a
# decimal number and numerically sorted. Letters are lexically sorted.
# Periods (and dash) delimit numbers but don't affect sort order by themselves.
# Thus `1.0a` is considered equal to `1.0.a`.
#
# ## Pre-release
# If a version number contains a letter (`a-z`) then that version is considered
# a pre-release. Pre-releases sort lower than the rest of the version prior to
# the first letter (or dash). For instance `1.0-b` compares lower than `1.0` but
# greater than `1.0-a`.
struct SoftwareVersion
include Comparable(self)
# :nodoc:
VERSION_PATTERN = /[0-9]+(?>\.[0-9a-zA-Z]+)*(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-.]+)?/
# :nodoc:
ANCHORED_VERSION_PATTERN = /\A\s*(#{VERSION_PATTERN})\s*\z/
@string : String
# Returns `true` if *string* is a valid version format.
def self.valid?(string : String) : Bool
!ANCHORED_VERSION_PATTERN.match(string).nil?
end
# Constructs a `Version` from *string*.
protected def initialize(@string : String)
end
# Parses an instance from a string.
#
# A version string is a series of digits or ASCII letters separated by dots.
#
# Returns `nil` if *string* describes an invalid version (see `.valid?`).
def self.parse?(string : String) : self?
# If string is an empty string convert it to 0
string = "0" if string =~ /\A\s*\Z/
return unless valid?(string)
new(string.strip)
end
# Parses an instance from a string.
#
# A version string is a series of digits or ASCII letters separated by dots.
#
# Raises `ArgumentError` if *string* describes an invalid version (see `.valid?`).
def self.parse(string : String) : self
parse?(string) || raise ArgumentError.new("Malformed version string #{string.inspect}")
end
# Constructs a `Version` from the string representation of *version* number.
def self.new(version : Number)
new(version.to_s)
end
# Appends the string representation of this version to *io*.
def to_s(io : IO)
io << @string
end
# Returns the string representation of this version.
def to_s : String
@string
end
# Returns `true` if this version is a pre-release version.
#
# A version is considered pre-release if it contains an ASCII letter or `-`.
#
# ```
# SoftwareVersion.new("1.0.0").prerelease? # => false
# SoftwareVersion.new("1.0.0-dev").prerelease? # => true
# SoftwareVersion.new("1.0.0-1").prerelease? # => true
# SoftwareVersion.new("1.0.0a1").prerelease? # => true
# ```
def prerelease? : Bool
@string.each_char do |char|
if char.ascii_letter? || char == '-'
return true
elsif char == '+'
# the following chars are metadata
return false
end
end
false
end
# Returns the metadata attached to this version or `nil` if no metadata available.
#
# ```
# SoftwareVersion.new("1.0.0").metadata # => nil
# SoftwareVersion.new("1.0.0-rc1").metadata # => nil
# SoftwareVersion.new("1.0.0+build1").metadata # => "build1"
# SoftwareVersion.new("1.0.0-rc1+build1").metadata # => "build1"
# ```
def metadata : String?
if index = @string.byte_index('+'.ord)
@string.byte_slice(index + 1, @string.bytesize - index - 1)
end
end
# Returns version representing the release version associated with this version.
#
# If this version is a pre-release (see `#prerelease?`) a new instance will be created
# with the same version string before the first ASCII letter or `-`.
#
# Version metadata (see `#metadata`) will be stripped.
#
# ```
# SoftwareVersion.new("1.0.0").release # => SoftwareVersion.new("1.0.0")
# SoftwareVersion.new("1.0.0-dev").release # => SoftwareVersion.new("1.0.0")
# SoftwareVersion.new("1.0.0-1").release # => SoftwareVersion.new("1.0.0")
# SoftwareVersion.new("1.0.0a1").release # => SoftwareVersion.new("1.0.0")
# SoftwareVersion.new("1.0.0+b1").release # => SoftwareVersion.new("1.0.0")
# SoftwareVersion.new("1.0.0-rc1+b1").release # => SoftwareVersion.new("1.0.0")
# ```
def release : self
@string.each_char_with_index do |char, index|
if char.ascii_letter? || char == '-' || char == '+'
return self.class.new(@string.byte_slice(0, index))
end
end
self
end
# Compares this version with *other* returning -1, 0, or 1 if the
# other version is larger, the same, or smaller than this one.
def <=>(other : self)
lstring = @string
rstring = other.@string
lindex = 0
rindex = 0
while true
lchar = lstring.byte_at?(lindex).try &.chr
rchar = rstring.byte_at?(rindex).try &.chr
# Both strings have been entirely consumed, they're identical
return 0 if lchar.nil? && rchar.nil?
ldelimiter = {'.', '-'}.includes?(lchar)
rdelimiter = {'.', '-'}.includes?(rchar)
# Skip delimiters
lindex += 1 if ldelimiter
rindex += 1 if rdelimiter
next if ldelimiter || rdelimiter
# If one string is consumed, the other is either ranked higher (char is a digit)
# or lower (char is letter, making it a pre-release tag).
if lchar.nil?
return rchar.not_nil!.ascii_letter? ? 1 : -1
elsif rchar.nil?
return lchar.ascii_letter? ? -1 : 1
end
# Try to consume consequitive digits into a number
lnumber, new_lindex = consume_number(lstring, lindex)
rnumber, new_rindex = consume_number(rstring, rindex)
# Proceed depending on where a number was found on each string
case {new_lindex, new_rindex}
when {lindex, rindex}
# Both strings have a letter at current position.
# They are compared (lexical) and the algorithm only continues if they
# are equal.
ret = lchar <=> rchar
return ret unless ret == 0
lindex += 1
rindex += 1
when {_, rindex}
# Left hand side has a number, right hand side a letter (and thus a pre-release tag)
return -1
when {lindex, _}
# Right hand side has a number, left hand side a letter (and thus a pre-release tag)
return 1
else
# Both strings have numbers at current position.
# They are compared (numerical) and the algorithm only continues if they
# are equal.
ret = lnumber <=> rnumber
return ret unless ret == 0
# Move to the next position in both strings
lindex = new_lindex
rindex = new_rindex
end
end
end
# Helper method to read a sequence of digits from *string* starting at
# position *index* into an integer number.
# It returns the consumed number and index position.
private def consume_number(string : String, index : Int32)
number = 0
while (byte = string.byte_at?(index)) && byte.chr.ascii_number?
number *= 10
number += byte
index += 1
end
{number, index}
end
def self.compare(a : String, b : String)
new(a) <=> new(b)
end
def matches_pessimistic_version_constraint?(constraint : String)
constraint = self.class.new(constraint).release.to_s
if last_period_index = constraint.rindex('.')
constraint_lead = constraint.[0...last_period_index]
else
constraint_lead = constraint
end
last_period_index = constraint_lead.bytesize
# Compare the leading part of the constraint up until the last period.
# If it doesn't match, the constraint is not fulfilled.
return false unless @string.starts_with?(constraint_lead)
# The character following the constraint lead can't be a number, otherwise
# `0.10` would match `0.1` because it starts with the same three characters
next_char = @string.byte_at?(last_period_index).try &.chr
return true unless next_char
return false if next_char.ascii_number?
# We've established that constraint is met up until the second-to-last
# segment.
# Now we only need to ensure that the last segment is actually bigger than
# the constraint so that `0.1` doesn't match `~> 0.2`.
# self >= constraint
constraint_number, _ = consume_number(constraint, last_period_index + 1)
own_number, _ = consume_number(@string, last_period_index + 1)
own_number >= constraint_number
end
# Custom hash implementation which produces the same hash for `a` and `b` when `a <=> b == 0`
def hash(hasher)
string = @string
index = 0
while byte = string.byte_at?(index)
if {'.'.ord, '-'.ord}.includes?(byte)
index += 1
next
end
number, new_index = consume_number(string, index)
if new_index != index
hasher.int(number)
index = new_index
else
hasher.int(byte)
end
index += 1
end
hasher
end
end
require "spec"
require "./software_version"
private def v(string)
SoftwareVersion.parse string
end
private def v(version : SoftwareVersion)
version
end
# Assert that two versions are equal. Handles strings or
# SoftwareVersion instances.
private def assert_version_equal(expected, actual)
v(actual).should eq v(expected)
v(actual).hash.should eq(v(expected).hash), "since #{actual} == #{expected}, they must have the same hash"
end
# Refute the assumption that the two versions are equal.
private def refute_version_equal(unexpected, actual)
v(actual).should_not eq v(unexpected)
v(actual).hash.should_not eq(v(unexpected).hash), "since #{actual} != #{unexpected}, they must not have the same hash"
end
describe SoftwareVersion do
it ".valid?" do
SoftwareVersion.valid?("5.1").should be_true
SoftwareVersion.valid?("an invalid version").should be_false
end
it "equals" do
assert_version_equal "1.2", "1.2"
assert_version_equal "1.2.b1", "1.2.b.1"
assert_version_equal "1.0+1234", "1.0+1234"
refute_version_equal "1.2", "1.2.0"
refute_version_equal "1.2", "1.3"
end
it ".new" do
assert_version_equal "1", SoftwareVersion.new(1)
assert_version_equal "1.0", SoftwareVersion.new(1.0)
end
describe ".parse" do
it "parses metadata" do
SoftwareVersion.parse("1.0+1234")
end
it "valid values for 1.0" do
{"1.0", "1.0 ", " 1.0 ", "1.0\n", "\n1.0\n", "1.0"}.each do |good|
assert_version_equal "1.0", good
end
end
it "invalid values" do
invalid_versions = %w[
junk
1.0\n2.0
1..2
1.2\ 3.4
]
# DON'T TOUCH THIS WITHOUT CHECKING CVE-2013-4287
invalid_versions << "2.3422222.222.222222222.22222.ads0as.dasd0.ddd2222.2.qd3e."
invalid_versions.each do |invalid|
expect_raises ArgumentError, "Malformed version string #{invalid.inspect}" do
SoftwareVersion.parse invalid
end
SoftwareVersion.parse?(invalid).should be_nil
end
end
it "empty version" do
["", " ", " "].each do |empty|
SoftwareVersion.parse(empty).to_s.should eq "0"
end
end
end
it "#<=>" do
# This spec has changed from Gems::Version where both where considered equal
v("1.0").should be < v("1.0.0")
v("1.0").should be > v("1.0.a")
v("1.8.2").should be > v("0.0.0")
v("1.8.2").should be > v("1.8.2.a")
v("1.8.2.b").should be > v("1.8.2.a")
v("1.8.2.a10").should be > v("1.8.2.a9")
v("1.0.0+build1").should be > v("1.0.0")
v("1.0.0+build2").should be > v("1.0.0+build1")
v("1.2.b1").should eq v("1.2.b.1")
v("").should eq v("0")
v("1.2.b1").should eq v("1.2.b1")
v("1.0a").should eq v("1.0.a")
v("1.0a").should eq v("1.0-a")
v("1.0.0+build1").should eq v("1.0.0+build1")
v("1.8.2.a").should be < v("1.8.2")
v("1.2").should be < v("1.3")
v("0.2.0").should be < v("0.2.0.1")
v("1.2.rc1").should be < v("1.2.rc2")
end
it "sort" do
list = ["0.1.0", "0.2.0", "5.333.1", "5.2.1", "0.2", "0.2.0.1", "5.8", "0.0.0.11"].map { |v| v(v) }
list.sort.map(&.to_s).should eq ["0.0.0.11", "0.1.0", "0.2", "0.2.0", "0.2.0.1", "5.2.1", "5.8", "5.333.1"]
end
it "#prerelease?" do
v("1.2.0.a").prerelease?.should be_true
v("1.0a").prerelease?.should be_true
v("2.9.b").prerelease?.should be_true
v("22.1.50.0.d").prerelease?.should be_true
v("1.2.d.42").prerelease?.should be_true
v("1.A").prerelease?.should be_true
v("1-1").prerelease?.should be_true
v("1-a").prerelease?.should be_true
v("1.0").prerelease?.should be_false
v("1.0.0.1").prerelease?.should be_false
v("1.0+20190405").prerelease?.should be_false
v("1.0+build1").prerelease?.should be_false
v("1.2.0").prerelease?.should be_false
v("2.9").prerelease?.should be_false
v("22.1.50.0").prerelease?.should be_false
end
it "#release" do
# Assert that *release* is the correct non-prerelease *version*.
v("1.0").release.should eq v("1.0")
v("1.2.0.a").release.should eq v("1.2.0")
v("1.1.rc10").release.should eq v("1.1")
v("1.9.3.alpha.5").release.should eq v("1.9.3")
v("1.9.3").release.should eq v("1.9.3")
v("0.4.0").release.should eq v("0.4.0")
# Return release without metadata
v("1.0+12345").release.should eq v("1.0")
v("1.0+build1").release.should eq v("1.0")
v("1.0-rc1+build1").release.should eq v("1.0")
v("1.0a+build1").release.should eq v("1.0")
end
it "#metadata" do
v("1.0+12345").metadata.should eq "12345"
v("1.0+build1").metadata.should eq "build1"
v("1.0-rc1+build1").metadata.should eq "build1"
v("1.0a+build1").metadata.should eq "build1"
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment