From 3f7deaeabab21f1079b4f2468d78a3450e74e73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20M=C3=BCller?= Date: Sun, 7 Apr 2019 13:21:47 +0200 Subject: [PATCH 1/2] Replace SemanticVersion with SoftwareVersion --- spec/compiler/compiler_spec.cr | 2 +- spec/std/semantic_version_spec.cr | 45 ---- spec/std/software_version_spec.cr | 168 +++++++++++++ spec/std/yaml/builder_spec.cr | 2 +- spec/std/yaml/serialization_spec.cr | 4 +- src/compiler/crystal/macros/methods.cr | 6 +- src/docs_main.cr | 2 +- src/semantic_version.cr | 210 +--------------- src/software_version.cr | 330 +++++++++++++++++++++++++ src/yaml.cr | 6 +- 10 files changed, 514 insertions(+), 261 deletions(-) delete mode 100644 spec/std/semantic_version_spec.cr create mode 100644 spec/std/software_version_spec.cr create mode 100644 src/software_version.cr diff --git a/spec/compiler/compiler_spec.cr b/spec/compiler/compiler_spec.cr index 8527eb3562d2..525bc51c3c0e 100644 --- a/spec/compiler/compiler_spec.cr +++ b/spec/compiler/compiler_spec.cr @@ -3,7 +3,7 @@ require "./spec_helper" describe "Compiler" do it "has a valid version" do - SemanticVersion.parse(Crystal::Config.version) + SoftwareVersion.parse(Crystal::Config.version) end it "compiles a file" do diff --git a/spec/std/semantic_version_spec.cr b/spec/std/semantic_version_spec.cr deleted file mode 100644 index 34750d74d935..000000000000 --- a/spec/std/semantic_version_spec.cr +++ /dev/null @@ -1,45 +0,0 @@ -require "spec" -require "semantic_version" - -describe SemanticVersion do - it "compares <" do - sversions = %w( - 1.2.3-2 - 1.2.3-10 - 1.2.3-alpha - 1.2.3-alpha.2 - 1.2.3-alpha.10 - 1.2.3-beta - 1.2.3 - 1.2.4-alpha - 1.2.4-beta - 1.2.4 - ) - versions = sversions.map { |s| SemanticVersion.parse(s) }.to_a - - versions.each_with_index do |v, i| - v.to_s.should eq(sversions[i]) - end - - versions.each_cons(2) do |pair| - pair[0].should be < pair[1] - end - end - - it "compares build equivalence" do - sversions = [ - "1.2.3+1", - "1.2.3+999", - "1.2.3+a", - ] - versions = sversions.map { |s| SemanticVersion.parse(s) }.to_a - - versions.each_with_index do |v, i| - v.to_s.should eq(sversions[i]) - end - - versions.each_cons(2) do |pair| - pair[0].should eq(pair[1]) - end - end -end diff --git a/spec/std/software_version_spec.cr b/spec/std/software_version_spec.cr new file mode 100644 index 000000000000..f80020a2824b --- /dev/null +++ b/spec/std/software_version_spec.cr @@ -0,0 +1,168 @@ +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 with number" do + assert_version_equal "1", SoftwareVersion.new(1) + assert_version_equal "1.0", SoftwareVersion.new(1.0) + end + + it ".new with segments" do + SoftwareVersion.new(1).to_s.should eq "1" + SoftwareVersion.new(1, 0).to_s.should eq "1.0" + SoftwareVersion.new(1, 0, 0).to_s.should eq "1.0.0" + SoftwareVersion.new(1, 0, 0, prerelease: "rc1").to_s.should eq "1.0.0-rc1" + SoftwareVersion.new(1, 0, 0, prerelease: "rc1", metadata: "build8").to_s.should eq "1.0.0-rc1+build8" + SoftwareVersion.new(1, prerelease: "rc1").to_s.should eq "1-rc1" + SoftwareVersion.new(1, prerelease: "rc1", metadata: "build8").to_s.should eq "1-rc1+build8" + 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 + v("1.0+b").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 diff --git a/spec/std/yaml/builder_spec.cr b/spec/std/yaml/builder_spec.cr index 5ca3082d547b..00f09cce03dc 100644 --- a/spec/std/yaml/builder_spec.cr +++ b/spec/std/yaml/builder_spec.cr @@ -6,7 +6,7 @@ private def assert_built(expected, expect_document_end = false) # Earlier libyaml releases still write the document end marker and this is hard to fix on Crystal's side. # So we just ignore it and adopt the specs accordingly to coincide with the used libyaml version. if expect_document_end - if YAML.libyaml_version < SemanticVersion.new(0, 2, 1) + if YAML.libyaml_version < SoftwareVersion.new(0, 2, 1) expected += "...\n" end end diff --git a/spec/std/yaml/serialization_spec.cr b/spec/std/yaml/serialization_spec.cr index 96d7b5bbf05e..f26359f01ca2 100644 --- a/spec/std/yaml/serialization_spec.cr +++ b/spec/std/yaml/serialization_spec.cr @@ -15,7 +15,7 @@ alias YamlRec = Int32 | Array(YamlRec) | Hash(YamlRec, YamlRec) # Earlier libyaml releases still write the document end marker and this is hard to fix on Crystal's side. # So we just ignore it and adopt the specs accordingly to coincide with the used libyaml version. private def assert_yaml_document_end(actual, expected) - if YAML.libyaml_version < SemanticVersion.new(0, 2, 1) + if YAML.libyaml_version < SoftwareVersion.new(0, 2, 1) expected += "...\n" end @@ -333,7 +333,7 @@ describe "YAML serialization" do it "does for bytes" do yaml = "hello".to_slice.to_yaml - if YAML.libyaml_version < SemanticVersion.new(0, 2, 2) + if YAML.libyaml_version < SoftwareVersion.new(0, 2, 2) yaml.should eq("--- !!binary 'aGVsbG8=\n\n'\n") else yaml.should eq("--- !!binary 'aGVsbG8=\n\n '\n") diff --git a/src/compiler/crystal/macros/methods.cr b/src/compiler/crystal/macros/methods.cr index fe8519f9322c..b22b6dc09e16 100644 --- a/src/compiler/crystal/macros/methods.cr +++ b/src/compiler/crystal/macros/methods.cr @@ -1,6 +1,6 @@ require "../semantic/ast" require "./macros" -require "semantic_version" +require "software_version" module Crystal class MacroInterpreter @@ -88,13 +88,13 @@ module Crystal second_string = second.to_string("second argument to 'compare_versions'") first_version = begin - SemanticVersion.parse(first_string) + SoftwareVersion.parse(first_string) rescue ex first_arg.raise ex.message end second_version = begin - SemanticVersion.parse(second_string) + SoftwareVersion.parse(second_string) rescue ex second_arg.raise ex.message end diff --git a/src/docs_main.cr b/src/docs_main.cr index fc1316930508..e44851106c45 100644 --- a/src/docs_main.cr +++ b/src/docs_main.cr @@ -51,7 +51,7 @@ require "./partial_comparable" require "./path" require "./random/**" require "./readline" -require "./semantic_version" +require "./software_version" require "./signal" require "./string_pool" require "./string_scanner" diff --git a/src/semantic_version.cr b/src/semantic_version.cr index c9f68d6386ae..9565fbf6eee3 100644 --- a/src/semantic_version.cr +++ b/src/semantic_version.cr @@ -1,205 +1,5 @@ -# Conforms to Semantic Versioning 2.0.0 -# -# See [https://semver.org/](https://semver.org/) for more information. -struct SemanticVersion - include Comparable(self) - - # The major version of this semantic version - getter major : Int32 - - # The minor version of this semantic version - getter minor : Int32 - - # The patch version of this semantic version - getter patch : Int32 - - # The build metadata of this semantic version - getter build : String? - - # The pre-release version of this semantic version - getter prerelease : Prerelease - - # Parses a `SemanticVersion` from the given semantic version string - # - # ``` - # require "semantic_version" - # - # semver = SemanticVersion.parse("2.61.4") - # semver # => # - # ``` - # - # Raises `ArgumentError` if *str* is not a semantic version. - def self.parse(str : String) : self - if m = str.match /^(\d+)\.(\d+)\.(\d+)(-([\w\.]+))?(\+(\w+))??$/ - major = m[1].to_i - minor = m[2].to_i - patch = m[3].to_i - prerelease = m[5]? - build = m[7]? - new major, minor, patch, prerelease, build - else - raise ArgumentError.new("Not a semantic version: #{str.inspect}") - end - end - - # Creates a new `SemanticVersion` instance with the given major, minor, and patch versions - # and optionally build and pre-release version - # - # Raises `ArgumentError` if *prerelease* is invalid pre-release version - def initialize(@major : Int, @minor : Int, @patch : Int, prerelease : String | Prerelease | Nil = nil, @build : String? = nil) - @prerelease = case prerelease - when Prerelease - prerelease - when String - Prerelease.parse prerelease - when nil - Prerelease.new - else - raise ArgumentError.new("Invalid prerelease #{prerelease.inspect}") - end - end - - # Returns the string representation of this semantic version - # - # ``` - # require "semantic_version" - # - # semver = SemanticVersion.parse("0.27.1") - # semver.to_s # => "0.27.1" - # ``` - def to_s(io : IO) : Nil - io << major << '.' << minor << '.' << patch - unless prerelease.identifiers.empty? - io << '-' - prerelease.to_s io - end - if build - io << '+' << build - end - end - - # The comparison operator. - # - # Returns `-1`, `0` or `1` depending on whether `self`'s version is lower than *other*'s, - # equal to *other*'s version or greater than *other*'s version. - # - # ``` - # require "semantic_version" - # - # semver1 = SemanticVersion.new(1, 0, 0) - # semver2 = SemanticVersion.new(2, 0, 0) - # - # semver1 <=> semver2 # => -1 - # semver2 <=> semver2 # => 0 - # semver2 <=> semver1 # => 1 - # ``` - def <=>(other : self) : Int32 - r = major <=> other.major - return r unless r.zero? - r = minor <=> other.minor - return r unless r.zero? - r = patch <=> other.patch - return r unless r.zero? - - pre1 = prerelease - pre2 = other.prerelease - - prerelease <=> other.prerelease - end - - # Contains the pre-release version related to this semantic version - struct Prerelease - # Parses a `Prerelease` from the given pre-release version string - # - # ``` - # require "semantic_version" - # - # prerelease = SemanticVersion::Prerelease.parse("rc.1.3") - # prerelease # => SemanticVersion::Prerelease(@identifiers=["rc", 1, 3]) - # ``` - def self.parse(str : String) : self - identifiers = [] of String | Int32 - str.split('.').each do |val| - if number = val.to_i32? - identifiers << number - else - identifiers << val - end - end - Prerelease.new identifiers - end - - # Array of identifiers that make up the pre-release metadata - getter identifiers : Array(String | Int32) - - # Creates a new `Prerelease` instance with supplied array of identifiers - def initialize(@identifiers : Array(String | Int32) = [] of String | Int32) - end - - # Returns the string representation of this semantic version's pre-release metadata - # - # ``` - # require "semantic_version" - # - # semver = SemanticVersion.parse("0.27.1-rc.1") - # semver.prerelease.to_s # => "rc.1" - # ``` - def to_s(io : IO) : Nil - identifiers.join('.', io) - end - - # The comparison operator. - # - # Returns `-1`, `0` or `1` depending on whether `self`'s pre-release is lower than *other*'s, - # equal to *other*'s pre-release or greater than *other*'s pre-release. - # - # ``` - # require "semantic_version" - # - # prerelease1 = SemanticVersion::Prerelease.new(["rc", 1]) - # prerelease2 = SemanticVersion::Prerelease.new(["rc", 1, 2]) - # - # prerelease1 <=> prerelease2 # => -1 - # prerelease1 <=> prerelease1 # => 0 - # prerelease2 <=> prerelease1 # => 1 - # ``` - def <=>(other : self) : Int32 - if identifiers.empty? - if other.identifiers.empty? - return 0 - else - return 1 - end - elsif other.identifiers.empty? - return -1 - end - - identifiers.each_with_index do |item, i| - return 1 if i >= other.identifiers.size # larger = higher precedence - - oitem = other.identifiers[i] - r = compare item, oitem - return r if r != 0 - end - - return -1 if identifiers.size != other.identifiers.size # larger = higher precedence - 0 - end - - private def compare(x : Int32, y : String) - -1 - end - - private def compare(x : String, y : Int32) - 1 - end - - private def compare(x : Int32, y : Int32) - x <=> y - end - - private def compare(x : String, y : String) - x <=> y - end - end -end +# DEPRECATED: `SemanticVersion` has been deprecated. Use `SoftwareVersion` instead. +{% if compare_versions(Crystal::VERSION, "0.28.0-0") >= 0 %} +@[Deprecated("Use SoftwareVersion instead.")] +{% end %} +alias SemanticVersion = SoftwareVersion diff --git a/src/software_version.cr b/src/software_version.cr new file mode 100644 index 000000000000..3dcd2824aae6 --- /dev/null +++ b/src/software_version.cr @@ -0,0 +1,330 @@ +# 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 alphanumerical +# ASCII characters. The first segment is expected to contain only digits. +# +# If the string contains a dash (`-`) or a letter, it is considered a +# pre-release. +# +# 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-releases are sorted lower than the corresponding release version which +# includes the characters up to the first dash or letter. 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/ + + @version : String + + # Returns `true` if *string* is a valid version format. + def self.valid?(string : String) : Bool + !ANCHORED_VERSION_PATTERN.match(string).nil? + end + + # Constructs an instance from *string*. + protected def initialize(@version : 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. + 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. + 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) : self + new(version.to_s) + end + + # Constructs an instance from arguments. + # + # ``` + # require "software_version" + # + # SoftwareVersion.new(1, 0).to_s # => "1.0" + # SoftwareVersion.new(1, 0, 0, prerelease: "rc1", metadata: "build8").to_s # => "1.0.0-rc1+build8" + # ``` + def self.new(major : Int, minor : Int? = nil, patch : Int? = nil, *, prerelease : String? = nil, metadata : String? = nil) + string = String.build do |io| + io << major + io << '.' << minor if minor + io << '.' << patch if patch + io << '-' << prerelease if prerelease + io << '+' << metadata if metadata + end + new string + end + + # Appends the string representation of this version to *io*. + def to_s(io : IO) : Nil + @version.to_s(io) + end + + # Returns the string representation of this version. + def to_s : String + @version + end + + # Returns `true` if this version is a pre-release version. + # + # A version is considered pre-release if it contains an letter or a dash (`-`). + # + # ``` + # require "software_version" + # + # SoftwareVersion.parse("1.0.0").prerelease? # => false + # SoftwareVersion.parse("1.0.0-dev").prerelease? # => true + # SoftwareVersion.parse("1.0.0-1").prerelease? # => true + # SoftwareVersion.parse("1.0.0a1").prerelease? # => true + # ``` + def prerelease? : Bool + @version.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. + # + # ``` + # require "software_version" + # + # SoftwareVersion.parse("1.0.0").metadata # => nil + # SoftwareVersion.parse("1.0.0-rc1").metadata # => nil + # SoftwareVersion.parse("1.0.0+build1").metadata # => "build1" + # SoftwareVersion.parse("1.0.0-rc1+build1").metadata # => "build1" + # ``` + def metadata : String? + if index = @version.byte_index('+'.ord) + @version.byte_slice(index + 1, @version.bytesize - index - 1) + end + end + + # Returns a `SoftwareVersion` representing the corresponding release version of + # `self`. + # + # If this version is a pre-release a new instance will be created + # with the same version string before the first letter or dash. + # + # Version metadata will be stripped. + # + # ``` + # require "software_version" + # + # SoftwareVersion.parse("1.0").release # => SoftwareVersion.parse("1.0") + # SoftwareVersion.parse("1.0-dev").release # => SoftwareVersion.parse("1.0") + # SoftwareVersion.parse("1.0-1").release # => SoftwareVersion.parse("1.0") + # SoftwareVersion.parse("1.0a1").release # => SoftwareVersion.parse("1.0") + # SoftwareVersion.parse("1.0+b1").release # => SoftwareVersion.parse("1.0") + # SoftwareVersion.parse("1.0-rc1+b1").release # => SoftwareVersion.parse("1.0") + # ``` + def release : self + @version.each_char_with_index do |char, index| + if char.ascii_letter? || char == '-' || char == '+' + return self.class.new(@version.byte_slice(0, index)) + end + end + + self + end + + # Compares this version with *other* returning `-1`, `0`, or `1` if the + # other version is lower, equal or greater than `self`. + def <=>(other : self) : Int + lstring = @version + rstring = other.@version + 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 + + # Implements the pessimistic version constraint `~>`. + # + # A version matches *constraint* if is equal to the constraint comparing up + # to the constraint's second-to-last segment and the following segment is + # greater than the constraint's. + # + # ``` + # require "software_version" + # + # SoftwareVersion.parse("1.0.0").matches_pessimistic_version_constraint?("1.0.0") # => true + # SoftwareVersion.parse("1.0.1").matches_pessimistic_version_constraint?("1.0.0") # => true + # SoftwareVersion.parse("1.1.0").matches_pessimistic_version_constraint?("1.0.0") # => false + # SoftwareVersion.parse("1.1.0").matches_pessimistic_version_constraint?("1.0") # => true + # SoftwareVersion.parse("2.0.0").matches_pessimistic_version_constraint?("1.0") # => false + # ``` + def matches_pessimistic_version_constraint?(constraint : String) : Bool + constraint = self.class.parse(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 @version.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 = @version.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(@version, 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 = @version + 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 diff --git a/src/yaml.cr b/src/yaml.cr index b238b5b52a17..4d3b2093b3be 100644 --- a/src/yaml.cr +++ b/src/yaml.cr @@ -2,7 +2,7 @@ require "./yaml/*" require "./yaml/schema/*" require "./yaml/schema/core/*" require "./yaml/nodes/*" -require "semantic_version" +require "software_version" require "base64" @@ -160,9 +160,9 @@ module YAML end # Returns the used version of `libyaml`. - def self.libyaml_version : SemanticVersion + def self.libyaml_version : SoftwareVersion LibYAML.yaml_get_version(out major, out minor, out patch) - SemanticVersion.new(major, minor, patch) + SoftwareVersion.new(major, minor, patch) end end From eb89dd7596117e0132de22ee69ae7ad8f1e8b19d Mon Sep 17 00:00:00 2001 From: r00ster Date: Sun, 7 Apr 2019 13:27:13 +0200 Subject: [PATCH 2/2] Apply suggestions from code review Thanks @r00ster91 Co-Authored-By: straight-shoota --- src/software_version.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/software_version.cr b/src/software_version.cr index 3dcd2824aae6..c8e9e9ecbbf3 100644 --- a/src/software_version.cr +++ b/src/software_version.cr @@ -13,7 +13,7 @@ # 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 +# This implementation is compatible with popular versioning schemes such as # [`SemVer`](https://semver.org/) and [`CalVer`](https://calver.org/) but # doesn't enforce any particular one. # @@ -59,7 +59,7 @@ struct SoftwareVersion # Returns `nil` if *string* describes an invalid version. def self.parse?(string : String) : self? # If string is an empty string convert it to 0 - string = "0" if string =~ /\A\s*\Z/ + string = "0" if string.blank? return unless valid?(string) @@ -111,7 +111,7 @@ struct SoftwareVersion # Returns `true` if this version is a pre-release version. # - # A version is considered pre-release if it contains an letter or a dash (`-`). + # A version is considered pre-release if it contains a letter or a dash (`-`). # # ``` # require "software_version" @@ -178,8 +178,8 @@ struct SoftwareVersion self end - # Compares this version with *other* returning `-1`, `0`, or `1` if the - # other version is lower, equal or greater than `self`. + # Compares this version with *other* returning `-1`, `0`, or `1` depending on whether + # *other*'s version is lower, equal or greater than `self`. def <=>(other : self) : Int lstring = @version rstring = other.@version @@ -209,7 +209,7 @@ struct SoftwareVersion return lchar.ascii_letter? ? -1 : 1 end - # Try to consume consequitive digits into a number + # Try to consume consecutive digits into a number lnumber, new_lindex = consume_number(lstring, lindex) rnumber, new_rindex = consume_number(rstring, rindex)