Created
April 5, 2019 19:09
-
-
Save straight-shoota/d8eec4a346018b0a51c1730b2a5c2bf8 to your computer and use it in GitHub Desktop.
Crystal SoftwareVersion
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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