-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add semver 2 versioning in dependabot common
Why: To be used as a standard for ecosystems that do not have a versioning standard.
- Loading branch information
Showing
2 changed files
with
324 additions
and
0 deletions.
There are no files selected for viewing
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
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 |
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
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 |