Skip to content

Commit

Permalink
Add semver 2 versioning in dependabot common
Browse files Browse the repository at this point in the history
Why:
To be used as a standard for ecosystems that do not have a versioning
standard.
  • Loading branch information
amazimbe committed Aug 19, 2024
1 parent 02f7d23 commit 61d29eb
Show file tree
Hide file tree
Showing 2 changed files with 324 additions and 0 deletions.
134 changes: 134 additions & 0 deletions common/lib/dependabot/sem_version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# typed: strong
# frozen_string_literal: true

require "sorbet-runtime"

# See https://semver.org/spec/v2.0.0.html for semver 2 details
#
module Dependabot
class SemVersion
extend T::Sig
extend T::Helpers
include Comparable

SEMVER_REGEX = /^
(0|[1-9]\d*)\. # major
(0|[1-9]\d*)\. # minor
(0|[1-9]\d*) # patch
(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))? # pre release
(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))? # build metadata
$/x

sig { returns(String) }
attr_accessor :major

sig { returns(String) }
attr_accessor :minor

sig { returns(String) }
attr_accessor :patch

sig { returns(T.nilable(String)) }
attr_accessor :build

sig { returns(T.nilable(String)) }
attr_accessor :prerelease

sig { params(version: String).void }
def initialize(version)
tokens = parse(version)
@major = T.let(T.must(tokens[:major]), String)
@minor = T.let(T.must(tokens[:minor]), String)
@patch = T.let(T.must(tokens[:patch]), String)
@build = T.let(tokens[:build], T.nilable(String))
@prerelease = T.let(tokens[:prerelease], T.nilable(String))
end

sig { returns(T::Boolean) }
def prerelease?
!!prerelease
end

sig { returns(String) }
def to_s
value = [major, minor, patch].join(".")
value += "-#{prerelease}" if prerelease
value += "+#{build}" if build
value
end

sig { returns(String) }
def inspect
"#<#{self.class} #{self}>"
end

sig { params(other: ::Dependabot::SemVersion).returns(T::Boolean) }
def eql?(other)
other.is_a?(self.class) && to_s == other.to_s
end

sig { params(other: ::Dependabot::SemVersion).returns(Integer) }
def <=>(other)
maj = major.to_i <=> other.major.to_i
return maj unless maj.zero?

min = minor.to_i <=> other.minor.to_i
return min unless min.zero?

pat = patch.to_i <=> other.patch.to_i
return pat unless pat.zero?

pre = compare_prereleases(prerelease, other.prerelease)
return pre unless pre.zero?

0
end

sig { params(version: T.nilable(String)).returns(T::Boolean) }
def self.correct?(version)
return false if version.nil?

version.match?(SEMVER_REGEX)
end

private

sig { params(version: String).returns(T::Hash[Symbol, T.nilable(String)]) }
def parse(version)
match = version.match(SEMVER_REGEX)
raise ArgumentError, "Malformed version number string #{version}" unless match

major, minor, patch, prerelease, build = match.captures
raise ArgumentError, "Malformed version number string #{version}" if minor.empty? || patch.empty?

{ major: major, minor: minor, patch: patch, prerelease: prerelease, build: build }
end

sig { params(prerelease1: T.nilable(String), prerelease2: T.nilable(String)).returns(Integer) }
def compare_prereleases(prerelease1, prerelease2) # rubocop:disable Metrics/PerceivedComplexity
return 0 if prerelease1.nil? && prerelease2.nil?
return -1 if prerelease2.nil?
return 1 if prerelease1.nil?

prerelease1_tokens = prerelease1.split(".")
prerelease2_tokens = prerelease2.split(".")

prerelease1_tokens.zip(prerelease2_tokens) do |t1, t2|
return 1 if t2.nil? # t2 can be nil, in which case it loses

# If they're both ints, convert to such
# If one's an int and the other isn't, the string version of the int gets correctly compared
if t1 =~ /^\d+$/ && t2 =~ /^\d+$/
t1 = t1.to_i
t2 = t2.to_i
end

comp = t1 <=> t2
return comp unless comp.zero?
end

# If we got this far, either they're equal (same length) or they won
prerelease1_tokens.length == prerelease2_tokens.length ? 0 : -1
end
end
end
190 changes: 190 additions & 0 deletions common/spec/dependabot/sem_version_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# typed: true
# frozen_string_literal: true

require "spec_helper"
require "dependabot/sem_version"

RSpec.describe Dependabot::SemVersion do
subject(:version) { described_class.new(version_string) }

let(:valid_versions) do
%w( 0.0.4 1.2.3 10.20.30 1.1.2-prerelease+meta 1.1.2+meta 1.1.2+meta-valid 1.0.0-alpha
1.0.0-beta 1.0.0-alpha.beta 1.0.0-alpha.beta.1 1.0.0-alpha.1 1.0.0-alpha0.valid
1.0.0-alpha.0valid 1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay
1.0.0-rc.1+build.1 2.0.0-rc.1+build.123 1.2.3-beta 10.2.3-DEV-SNAPSHOT
1.2.3-SNAPSHOT-123 2.0.0+build.1848 2.0.1-alpha.1227 1.0.0-alpha+beta
1.2.3----RC-SNAPSHOT.12.9.1--.12+788 1.2.3----R-S.12.9.1--.12+meta
1.2.3----RC-SNAPSHOT.12.9.1--.12 1.0.0+0.build.1-rc.10000aaa-kk-0.1
9999999.999999999.99999999 1.0.0-0A.is.legal)
end

let(:invalid_versions) do
%w(1 1.2 1.2.3-0123 1.2.3-0123.0123 1.1.2+.123 +invalid -invalid -invalid+invalid -invalid.01 alpha alpha.beta
alpha.beta.1 alpha.1 alpha+beta alpha_beta alpha. alpha.. beta 1.0.0-alpha_beta -alpha. 1.0.0-alpha..
1.0.0-alpha..1 1.0.0-alpha...1 1.0.0-alpha....1 1.0.0-alpha.....1 1.0.0-alpha......1 1.0.0-alpha.......1
01.1.1 1.01.1 1.1.01 1.2.3.DEV 1.2-SNAPSHOT 1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788 1.2-RC-SNAPSHOT
-1.0.3-gamma+b7718 +justmeta 9.8.7+meta+meta 9.8.7-whatever+meta+meta
999.9999.99999----RC-SNAPSHOT.12.09.1----..12)
end

describe "#initialize" do
context "when the version is invalid" do
let(:version_string) { "1" }
let(:error_message) { "Malformed version number string #{version_string}" }

it "raises an error" do
expect { version }.to raise_error(ArgumentError, error_message)
end
end
end

describe "to_s" do
it "returns the correct value" do
valid_versions.each do |version|
expect(described_class.new(version).to_s).to eq(version)
end
end
end

describe "#inspect" do
subject { described_class.new(version_string).inspect }

let(:version_string) { "1.0.0+build1" }

it { is_expected.to eq("#<#{described_class} #{version_string}>") }
end

describe "#eql?" do
let(:first) { described_class.new("1.2.3-rc.1+build1") }
let(:second) { described_class.new("1.2.3-rc.1+build1") }

it "returns true for equal semver values" do
expect(first).to eql(second)
end
end

describe "#<=>" do
it "sorts version strings semantically" do
versions = []

versions << described_class.new("1.0.0-alpha")
versions << described_class.new("1.0.0-alpha.1")
versions << described_class.new("1.0.0-alpha.1.beta.gamma")
versions << described_class.new("1.0.0-alpha.beta")
versions << described_class.new("1.0.0-alpha.beta.1")
versions << described_class.new("1.0.0-beta")
versions << described_class.new("1.0.0-beta.2")
versions << described_class.new("1.0.0-beta.11")
versions << described_class.new("1.0.0-rc.1")
versions << described_class.new("1.0.0")
expect(versions.shuffle.sort).to eq(versions)
end

context "when comparing numerical prereleases" do
let(:first) { described_class.new("1.0.0-rc.2") }
let(:second) { described_class.new("1.0.0-rc.10") }

it "compares numerically" do
expect(first <=> second).to eq(-1)
expect(second <=> first).to eq(1)
end
end

context "when comparing alphanumerical prereleases" do
let(:first) { described_class.new("1.0.0-alpha10") }
let(:second) { described_class.new("1.0.0-alpha2") }

it "compares lexicographically" do
expect(first <=> second).to eq(-1)
expect(second <=> first).to eq(1)
end
end

context "when comparing versions that contain build data" do
let(:first) { described_class.new("1.0.0+build-123") }
let(:second) { described_class.new("1.0.0+build-456") }

it "ignores build metadata" do
expect(first <=> second).to eq(0)
end
end
end

describe "#prerelease?" do
subject { version.prerelease? }

context "with an alpha" do
let(:version_string) { "1.0.0-alpha" }

it { is_expected.to be(true) }
end

context "with a capitalised alpha" do
let(:version_string) { "1.0.0-Alpha" }

it { is_expected.to be(true) }
end

context "with a dev token" do
let(:version_string) { "1.2.1-dev-65" }

it { is_expected.to be(true) }
end

context "with a 'pre' pre-release separated with a -" do
let(:version_string) { "2.10.0-pre0" }

it { is_expected.to be(true) }
end

context "with a release" do
let(:version_string) { "1.0.0" }

it { is_expected.to be(false) }
end

context "with a + separated alphanumeric build identifier" do
let(:version_string) { "1.0.0+build1" }

it { is_expected.to be(false) }
end

context "with an 'alpha' separated by a -" do
let(:version_string) { "1.0.0-alpha+001" }

it { is_expected.to be(true) }
end
end

describe ".correct?" do
subject { described_class.correct?(version_string) }

context "with a nil version" do
let(:version_string) { nil }

it { is_expected.to be(false) }
end

context "with an empty version" do
let(:version_string) { "" }

it { is_expected.to be(false) }
end

context "with valid semver2 strings" do
it "returns true" do
valid_versions.each do |version|
expect(described_class.correct?(version)).to be(true)
end
end
end

context "with invalid semver2 strings" do
it "returns false" do
invalid_versions.each do |version|
expect(described_class.correct?(version)).to be(false)
end
end
end
end
end

0 comments on commit 61d29eb

Please sign in to comment.