Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "test/vers-spec"]
path = test/vers-spec
url = https://github.com/package-url/vers-spec.git
86 changes: 71 additions & 15 deletions lib/vers/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,26 @@ def parse_native(range_string, scheme)
# @return [String] The vers URI string
#
def to_vers_string(version_range, scheme)
return "*" if version_range.unbounded?
return "vers:#{scheme}/*" if version_range.unbounded?
return "vers:#{scheme}/" if version_range.empty?

intervals = version_range.raw_constraints || version_range.intervals
constraints = []

# Detect != pattern: two intervals (-∞,V) ∪ (V,+∞)
if intervals.length == 2
a, b = intervals
if a.min.nil? && !a.max_inclusive && b.max.nil? && !b.min_inclusive && a.max == b.min
constraints << "!=#{a.max}"
constraints.sort_by! { |c| sort_key_for_constraint(c) }
return "vers:#{scheme}/#{constraints.join('|')}"
end
end

intervals.each do |interval|
if interval.min == interval.max && interval.min_inclusive && interval.max_inclusive
# Exact version
constraints << "=#{interval.min}"
constraints << interval.min.to_s
else
# Range constraints
if interval.min
Expand All @@ -134,7 +144,6 @@ def to_vers_string(version_range, scheme)
end

constraints.sort_by! { |c| sort_key_for_constraint(c) }
constraints.uniq!

"vers:#{scheme}/#{constraints.join('|')}"
end
Expand All @@ -144,7 +153,7 @@ def to_vers_string(version_range, scheme)
def sort_key_for_constraint(constraint)
version = constraint.sub(/\A[><=!]+/, '')
v = Version.cached_new(version)
[v.major || 0, v.minor || 0, v.patch || 0, constraint]
[v, constraint]
end

def parse_constraints(constraints_string, scheme)
Expand All @@ -164,8 +173,14 @@ def parse_constraints(constraints_string, scheme)
end
end

# Start with the union of all positive constraints
range = VersionRange.new(intervals, scheme: interval_scheme)
# Start with the union of all positive constraints, or unbounded if only exclusions
range = if intervals.any?
VersionRange.new(intervals, scheme: interval_scheme)
elsif exclusions.any?
VersionRange.unbounded
else
VersionRange.new([], scheme: interval_scheme)
end

# Apply exclusions
exclusions.each do |version|
Expand All @@ -185,7 +200,7 @@ def parse_npm_range(range_string)
# Handle || (OR) operator
if range_string.include?('||')
or_parts = range_string.split('||').map(&:strip)
ranges = or_parts.map { |part| parse_npm_single_range(part) }
ranges = or_parts.map { |part| parse_npm_range(part) }
return ranges.reduce { |acc, range| acc.union(range) }
end

Expand All @@ -206,7 +221,13 @@ def parse_npm_range(range_string)
end
end
ranges = merged.map { |part| parse_npm_single_range(part) }
ranges.reduce { |acc, range| acc.intersect(range) }
# If all parts are bare versions (no operators), treat as union
all_exact = merged.all? { |part| part.match?(/\A\d/) }
if all_exact
ranges.reduce { |acc, range| acc.union(range) }
else
ranges.reduce { |acc, range| acc.intersect(range) }
end
end

def parse_npm_single_range(range_string)
Expand Down Expand Up @@ -256,8 +277,25 @@ def parse_npm_single_range(range_string)
# Invalid patterns that should raise errors
raise ArgumentError, "Invalid NPM range format: #{range_string}"
else
# Check for operator + x-range (e.g. ">=2.2.x", ">=1.x")
if range_string.match(/\A[><=]+(\d+)\.[xX*]\z/)
major = $1.to_i
return VersionRange.new([
Interval.new(min: "#{major}.0.0", max: "#{major + 1}.0.0", min_inclusive: true, max_inclusive: false)
])
end
if range_string.match(/\A[><=]+(\d+)\.(\d+)\.[xX*]\z/)
major = $1.to_i
minor = $2.to_i
return VersionRange.new([
Interval.new(min: "#{major}.#{minor}.0", max: "#{major}.#{minor + 1}.0", min_inclusive: true, max_inclusive: false)
])
end
# Standard constraint
constraint = Constraint.parse(range_string)
# Normalize version to semver (npm always uses 3 segments)
normalized_version = Version.cached_new(constraint.version).to_s
constraint = Constraint.new(constraint.operator, normalized_version)
if constraint.exclusion?
VersionRange.unbounded.exclude(constraint.version)
else
Expand Down Expand Up @@ -289,9 +327,27 @@ def parse_caret_range(version)

def parse_tilde_range(version)
v = Version.cached_new(version)
upper_version = if v.minor

if v.prerelease
# ~0.8.0-pre := >=0.8.0-pre <0.8.0 OR >=0.8.0 <0.8.1
# Prereleases only match their own major.minor.patch
base = "#{v.major}.#{v.minor || 0}.#{v.patch || 0}"
next_patch = "#{v.major}.#{v.minor || 0}.#{(v.patch || 0) + 1}"
pre_range = VersionRange.new([
Interval.new(min: version, max: base, min_inclusive: true, max_inclusive: false)
])
release_range = VersionRange.new([
Interval.new(min: base, max: next_patch, min_inclusive: true, max_inclusive: false)
])
return pre_range.union(release_range)
end

upper_version = if v.patch
# ~1.2.3 := >=1.2.3 <1.3.0
"#{v.major}.#{v.minor + 1}.0"
elsif v.minor
# ~1.2 := >=1.2.0 <1.3.0
"#{v.major}.#{v.minor + 1}.0"
else
# ~1 := >=1.0.0 <2.0.0
"#{v.major + 1}.0.0"
Expand All @@ -318,14 +374,14 @@ def parse_gem_range(range_string)
def parse_pessimistic_range(version)
v = Version.cached_new(version)
upper_version = if v.patch
# ~> 1.2.3 := >= 1.2.3, < 1.3.0
"#{v.major}.#{v.minor + 1}.0"
# ~> 1.2.3 := >= 1.2.3, < 1.3
"#{v.major}.#{v.minor + 1}"
elsif v.minor
# ~> 1.2 := >= 1.2.0, < 2.0.0
"#{v.major + 1}.0.0"
# ~> 1.2 := >= 1.2.0, < 2
"#{v.major + 1}"
else
# ~> 1 := >= 1.0.0, < 2.0.0
"#{v.major + 1}.0.0"
# ~> 1 := >= 1.0.0, < 2
"#{v.major + 1}"
end

VersionRange.new([
Expand Down
6 changes: 3 additions & 3 deletions test/test_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -388,13 +388,13 @@ def test_to_vers_string_basic
def test_to_vers_string_exact
range = Vers::VersionRange.exact("1.2.3")
vers_string = @parser.to_vers_string(range, "gem")
assert_equal "vers:gem/=1.2.3", vers_string
assert_equal "vers:gem/1.2.3", vers_string
end

def test_to_vers_string_unbounded
range = Vers::VersionRange.unbounded
vers_string = @parser.to_vers_string(range, "pypi")
assert_equal "*", vers_string
assert_equal "vers:pypi/*", vers_string
end

def test_to_vers_string_empty
Expand Down Expand Up @@ -597,7 +597,7 @@ def test_round_trip_npm_space_separated
def test_round_trip_gem_pessimistic
range = @parser.parse_native("~> 1.2.3", "gem")
vers = @parser.to_vers_string(range, "gem")
assert_equal "vers:gem/>=1.2.3|<1.3.0", vers
assert_equal "vers:gem/>=1.2.3|<1.3", vers
end

def test_round_trip_reparse_equivalence
Expand Down
88 changes: 88 additions & 0 deletions test/test_vers_spec_suite.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

require 'test_helper'
require 'json'

class TestVersSpecSuite < Minitest::Test
SPEC_DIR = File.join(File.dirname(__FILE__), 'vers-spec', 'tests')
SUPPORTED_SCHEMES = %w[gem maven npm nuget pypi].freeze

def test_version_comparison
each_test_file("_version_cmp_test.json") do |test_case|
scheme = test_case.dig('input', 'input_scheme')
next unless SUPPORTED_SCHEMES.include?(scheme)

versions = test_case.dig('input', 'versions')
expected = test_case['expected_output']

if test_case['test_type'] == 'equality'
result = Vers.compare_with_scheme(versions[0], versions[1], scheme)
if expected == true
assert_equal 0, result, "#{test_case['description']}: #{versions[0]} should equal #{versions[1]}"
else
refute_equal 0, result, "#{test_case['description']}: #{versions[0]} should not equal #{versions[1]}"
end
else
sorted = versions.sort { |a, b| Vers.compare_with_scheme(a, b, scheme) }
# NuGet prerelease tags are case-insensitive; normalize for comparison
if scheme == 'nuget'
sorted = sorted.map { |v| normalize_nuget_version(v) }
end
assert_equal expected, sorted, "#{test_case['description']}: sorting #{versions.inspect}"
end
end
end

def test_range_from_native
each_test_file("_range_from_native_test.json") do |test_case|
scheme = test_case.dig('input', 'scheme')
next unless SUPPORTED_SCHEMES.include?(scheme)

native_range = test_case.dig('input', 'native_range')
expected_vers = test_case['expected_output']

range = Vers.parse_native(native_range, scheme)
generated = Vers.to_vers_string(range, scheme)

assert_equal expected_vers, generated,
"#{test_case['description']}: native '#{native_range}'"
end
end

def test_range_containment
each_test_file("_range_containment_test.json") do |test_case|
vers_string = test_case.dig('input', 'vers')
version = test_case.dig('input', 'version')
expected = test_case['expected_output']

scheme = vers_string.match(/\Avers:([^\/]+)\//)[1] rescue nil
next unless scheme && SUPPORTED_SCHEMES.include?(scheme)

range = Vers.parse(vers_string)
result = range.contains?(version)
assert_equal expected, result,
"#{test_case['description']}: #{vers_string} contains? #{version}"
end
end

def each_test_file(suffix)
Dir.glob(File.join(SPEC_DIR, "*#{suffix}")).each do |file|
data = JSON.parse(File.read(file))
data['tests'].each { |test_case| yield test_case }
end
end

def normalize_nuget_version(version)
if version.include?('-')
base, rest = version.split('-', 2)
if rest.include?('+')
pre, build = rest.split('+', 2)
"#{base}-#{pre.downcase}+#{build}"
else
"#{base}-#{rest.downcase}"
end
else
version
end
end
end
1 change: 1 addition & 0 deletions test/vers-spec
Submodule vers-spec added at 87e8dc