vers 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b753f78521c853cb7c511190af2d9722a20a1d5f503295da5b922ab020950c60
4
- data.tar.gz: 5a4d226e512a2f71b7634e946272c79d2558db811d8a734d258e2a48663648a3
3
+ metadata.gz: 6b7aa0b15219b0734ba33605567d7f6283c51b06afeb66d65f6b6893ada38b95
4
+ data.tar.gz: 79f6860358cba90b4d744af1af5c4198328f14a1bd555373018c3943b591c1ea
5
5
  SHA512:
6
- metadata.gz: ff9ae01cbf359c8b76bb10e7e2741c2d70d119039076b520dfa4c3b2e40c022c96b23b72b2251c2d00005d0ae0c81caacc125903506c97f807e7b980a8d07dc6
7
- data.tar.gz: 949060b4a56d8bc53b9ab42ec65eb98afd1fb50ba17108abd57fd98e9d5fa5abd299931b006a341ed813ac4df7d644222aa98205dd209ca30c7fa2f01df74e76
6
+ metadata.gz: 27d9b7fc130fc8a70f738114c5566d4cefbe092259c9a181d006322aecf6f2dc656474c99ee610b2ffc5fe58a9b84410c2b322813c213f63473d929e21286e4d
7
+ data.tar.gz: 58035172c00bbbd2d8e8e9a96aa22bbae5eff4f0de8c8e2dbfe4c5340cc4267c0e28c8766705d527a0b387a788dd12841bad6a31ebb399c4f4a0663975f641c0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-02-24
11
+
12
+ ### Added
13
+ - Scheme-specific version comparison for Maven and NuGet via `Version.compare_with_scheme` and `Vers.compare_with_scheme`
14
+ - `Vers::MavenVersion` module with Maven qualifier ordering (alpha < beta < milestone < rc < snapshot < release < sp), digit/letter transitions, sublist rules, qualifier aliases, and trailing zero normalization
15
+ - `Vers::NuGetVersion` module with 4-part numeric versions, case-insensitive prereleases, and build metadata stripping
16
+ - `scheme` parameter threaded through `Interval`, `VersionRange`, `Constraint`, and `Parser` so Maven and NuGet ranges use their own comparison rules in `contains?`, `intersect`, and other interval operations
17
+ - 1010 conformance test cases from the vers-spec test suite for Maven and NuGet version comparison
18
+
10
19
  ## [1.0.3] - 2026-01-09
11
20
 
12
21
  ### Added
@@ -117,7 +126,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
117
126
  - **No runtime dependencies** - pure Ruby implementation
118
127
  - **Minitest** for testing (development dependency only)
119
128
 
120
- [Unreleased]: https://github.com/andrew/vers/compare/v1.0.3...HEAD
129
+ [Unreleased]: https://github.com/andrew/vers/compare/v1.1.0...HEAD
130
+ [1.1.0]: https://github.com/andrew/vers/compare/v1.0.3...v1.1.0
121
131
  [1.0.3]: https://github.com/andrew/vers/compare/v1.0.2...v1.0.3
122
132
  [1.0.2]: https://github.com/andrew/vers/compare/v1.0.1...v1.0.2
123
133
  [1.0.1]: https://github.com/andrew/vers/compare/v1.0.0...v1.0.1
@@ -93,21 +93,21 @@ module Vers
93
93
  # Vers::Constraint.new(">=", "1.2.3").to_interval # => [1.2.3,+∞)
94
94
  # Vers::Constraint.new("=", "1.0.0").to_interval # => [1.0.0,1.0.0]
95
95
  #
96
- def to_interval
96
+ def to_interval(scheme: nil)
97
97
  case operator
98
98
  when "="
99
- Interval.exact(version)
99
+ Interval.exact(version, scheme: scheme)
100
100
  when "!="
101
101
  # != constraints need special handling in ranges - they create exclusions
102
102
  nil
103
103
  when ">"
104
- Interval.greater_than(version, inclusive: false)
104
+ Interval.greater_than(version, inclusive: false, scheme: scheme)
105
105
  when ">="
106
- Interval.greater_than(version, inclusive: true)
106
+ Interval.greater_than(version, inclusive: true, scheme: scheme)
107
107
  when "<"
108
- Interval.less_than(version, inclusive: false)
108
+ Interval.less_than(version, inclusive: false, scheme: scheme)
109
109
  when "<="
110
- Interval.less_than(version, inclusive: true)
110
+ Interval.less_than(version, inclusive: true, scheme: scheme)
111
111
  end
112
112
  end
113
113
 
data/lib/vers/interval.rb CHANGED
@@ -4,35 +4,36 @@ require_relative 'version'
4
4
 
5
5
  module Vers
6
6
  class Interval
7
- attr_reader :min, :max, :min_inclusive, :max_inclusive
7
+ attr_reader :min, :max, :min_inclusive, :max_inclusive, :scheme
8
8
 
9
- def initialize(min: nil, max: nil, min_inclusive: true, max_inclusive: true)
9
+ def initialize(min: nil, max: nil, min_inclusive: true, max_inclusive: true, scheme: nil)
10
10
  @min = min
11
11
  @max = max
12
12
  @min_inclusive = min_inclusive
13
13
  @max_inclusive = max_inclusive
14
+ @scheme = scheme
14
15
 
15
16
  validate_bounds!
16
17
  end
17
18
 
18
- def self.empty
19
- new(min: "1", max: "0", min_inclusive: true, max_inclusive: true)
19
+ def self.empty(scheme: nil)
20
+ new(min: "1", max: "0", min_inclusive: true, max_inclusive: true, scheme: scheme)
20
21
  end
21
22
 
22
- def self.unbounded
23
- new
23
+ def self.unbounded(scheme: nil)
24
+ new(scheme: scheme)
24
25
  end
25
26
 
26
- def self.exact(version)
27
- new(min: version, max: version, min_inclusive: true, max_inclusive: true)
27
+ def self.exact(version, scheme: nil)
28
+ new(min: version, max: version, min_inclusive: true, max_inclusive: true, scheme: scheme)
28
29
  end
29
30
 
30
- def self.greater_than(version, inclusive: false)
31
- new(min: version, min_inclusive: inclusive)
31
+ def self.greater_than(version, inclusive: false, scheme: nil)
32
+ new(min: version, min_inclusive: inclusive, scheme: scheme)
32
33
  end
33
34
 
34
- def self.less_than(version, inclusive: false)
35
- new(max: version, max_inclusive: inclusive)
35
+ def self.less_than(version, inclusive: false, scheme: nil)
36
+ new(max: version, max_inclusive: inclusive, scheme: scheme)
36
37
  end
37
38
 
38
39
  def empty?
@@ -59,7 +60,8 @@ module Vers
59
60
  end
60
61
 
61
62
  def intersect(other)
62
- return self.class.empty if empty? || other.empty?
63
+ merged_scheme = @scheme || other.scheme
64
+ return self.class.empty(scheme: merged_scheme) if empty? || other.empty?
63
65
 
64
66
  new_min = nil
65
67
  new_min_inclusive = true
@@ -110,7 +112,8 @@ module Vers
110
112
  min: new_min,
111
113
  max: new_max,
112
114
  min_inclusive: new_min_inclusive,
113
- max_inclusive: new_max_inclusive
115
+ max_inclusive: new_max_inclusive,
116
+ scheme: merged_scheme
114
117
  )
115
118
  end
116
119
 
@@ -120,6 +123,7 @@ module Vers
120
123
 
121
124
  return nil unless overlaps?(other) || adjacent?(other)
122
125
 
126
+ merged_scheme = @scheme || other.scheme
123
127
  new_min = nil
124
128
  new_min_inclusive = true
125
129
  new_max = nil
@@ -169,7 +173,8 @@ module Vers
169
173
  min: new_min,
170
174
  max: new_max,
171
175
  min_inclusive: new_min_inclusive,
172
- max_inclusive: new_max_inclusive
176
+ max_inclusive: new_max_inclusive,
177
+ scheme: merged_scheme
173
178
  )
174
179
  end
175
180
 
@@ -221,9 +226,12 @@ module Vers
221
226
  return 0 if a == b
222
227
  return -1 if a.nil?
223
228
  return 1 if b.nil?
224
-
225
- # Use the Version class for comparison
226
- Version.compare(a, b)
229
+
230
+ if @scheme
231
+ Version.compare_with_scheme(a, b, @scheme)
232
+ else
233
+ Version.compare(a, b)
234
+ end
227
235
  end
228
236
  end
229
237
  end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vers
4
+ module MavenVersion
5
+ MavenComponent = Struct.new(:is_numeric, :numeric, :qualifier, :is_null, :after_dash, keyword_init: true) do
6
+ def initialize(is_numeric: false, numeric: 0, qualifier: "", is_null: false, after_dash: false)
7
+ super
8
+ end
9
+ end
10
+
11
+ QUALIFIER_ORDER = {
12
+ "alpha" => 1,
13
+ "beta" => 2,
14
+ "milestone" => 3,
15
+ "rc" => 4,
16
+ "snapshot" => 5,
17
+ "" => 6,
18
+ "sp" => 7
19
+ }.freeze
20
+
21
+ UNKNOWN_QUALIFIER_ORDER = 8
22
+
23
+ module_function
24
+
25
+ def compare(a, b)
26
+ return 0 if a == b
27
+
28
+ parts_a = parse_maven_version(a)
29
+ parts_b = parse_maven_version(b)
30
+
31
+ max_len = [parts_a.length, parts_b.length].max
32
+
33
+ max_len.times do |i|
34
+ comp_a = i < parts_a.length ? parts_a[i] : MavenComponent.new(is_null: true)
35
+ comp_b = i < parts_b.length ? parts_b[i] : MavenComponent.new(is_null: true)
36
+
37
+ cmp = compare_components(comp_a, comp_b)
38
+ return cmp unless cmp == 0
39
+ end
40
+
41
+ 0
42
+ end
43
+
44
+ def parse_maven_version(s)
45
+ s = s.downcase
46
+
47
+ parts, after_dash_flags = split_with_separators(s)
48
+
49
+ result = []
50
+ parts.each_with_index do |part, i|
51
+ next if part.empty?
52
+
53
+ next_is_digit = if i + 1 < parts.length
54
+ parts[i + 1].match?(/\A\d+\z/)
55
+ else
56
+ false
57
+ end
58
+
59
+ normalized = normalize_qualifier(part, next_is_digit)
60
+ next if normalized.empty?
61
+
62
+ after_dash = i < after_dash_flags.length ? after_dash_flags[i] : false
63
+
64
+ if normalized.match?(/\A\d+\z/)
65
+ result << MavenComponent.new(is_numeric: true, numeric: normalized.to_i, after_dash: after_dash)
66
+ else
67
+ result << MavenComponent.new(qualifier: normalized, after_dash: after_dash)
68
+ end
69
+ end
70
+
71
+ normalize_components(result)
72
+ end
73
+
74
+ def split_with_separators(s)
75
+ parts = []
76
+ after_dash = []
77
+ current = +""
78
+ last_was_digit = false
79
+ first_char = true
80
+ current_after_dash = false
81
+
82
+ s.each_char do |c|
83
+ if c == "." || c == "-"
84
+ if current.length > 0
85
+ parts << current
86
+ after_dash << current_after_dash
87
+ current = +""
88
+ end
89
+ current_after_dash = (c == "-")
90
+ first_char = true
91
+ next
92
+ end
93
+
94
+ is_digit = c >= "0" && c <= "9"
95
+
96
+ if !first_char && is_digit != last_was_digit && current.length > 0
97
+ parts << current
98
+ after_dash << current_after_dash
99
+ current = +""
100
+ current_after_dash = true
101
+ end
102
+
103
+ current << c
104
+ last_was_digit = is_digit
105
+ first_char = false
106
+ end
107
+
108
+ if current.length > 0
109
+ parts << current
110
+ after_dash << current_after_dash
111
+ end
112
+
113
+ [parts, after_dash]
114
+ end
115
+
116
+ def normalize_qualifier(q, next_is_digit)
117
+ if next_is_digit && q.length == 1
118
+ case q
119
+ when "a" then return "alpha"
120
+ when "b" then return "beta"
121
+ when "m" then return "milestone"
122
+ end
123
+ end
124
+
125
+ case q
126
+ when "cr" then "rc"
127
+ when "ga", "final", "release" then ""
128
+ else q
129
+ end
130
+ end
131
+
132
+ def normalize_components(components)
133
+ return components if components.empty?
134
+
135
+ first_sublist_idx = components.index { |c| c.after_dash }
136
+
137
+ if first_sublist_idx && first_sublist_idx > 0
138
+ base_end = first_sublist_idx
139
+ while base_end > 1 && components[base_end - 1].is_numeric && components[base_end - 1].numeric == 0
140
+ base_end -= 1
141
+ end
142
+ if base_end < first_sublist_idx
143
+ components = components[0...base_end] + components[first_sublist_idx..]
144
+ end
145
+ elsif first_sublist_idx.nil?
146
+ while components.length > 0 && components.last.is_numeric && components.last.numeric == 0
147
+ components.pop
148
+ end
149
+ end
150
+
151
+ components
152
+ end
153
+
154
+ def compare_components(a, b)
155
+ return 0 if a.is_null && b.is_null
156
+
157
+ if a.is_null
158
+ return compare_to_null(b) * -1
159
+ end
160
+ if b.is_null
161
+ return compare_to_null(a)
162
+ end
163
+
164
+ if a.after_dash != b.after_dash
165
+ if a.after_dash
166
+ return b.is_numeric ? -1 : 1
167
+ else
168
+ return a.is_numeric ? 1 : -1
169
+ end
170
+ end
171
+
172
+ if a.is_numeric && b.is_numeric
173
+ return a.numeric <=> b.numeric
174
+ end
175
+
176
+ if a.is_numeric && !b.is_numeric
177
+ return 1
178
+ end
179
+ if !a.is_numeric && b.is_numeric
180
+ return -1
181
+ end
182
+
183
+ order_a = qualifier_order(a.qualifier)
184
+ order_b = qualifier_order(b.qualifier)
185
+
186
+ if order_a != order_b
187
+ return order_a <=> order_b
188
+ end
189
+
190
+ known_a = QUALIFIER_ORDER.key?(a.qualifier)
191
+ known_b = QUALIFIER_ORDER.key?(b.qualifier)
192
+
193
+ if !known_a && !known_b
194
+ return a.qualifier <=> b.qualifier
195
+ end
196
+
197
+ 0
198
+ end
199
+
200
+ def compare_to_null(comp)
201
+ if comp.is_numeric
202
+ if comp.numeric == 0
203
+ 0
204
+ else
205
+ 1
206
+ end
207
+ else
208
+ order_comp = qualifier_order(comp.qualifier)
209
+ order_null = QUALIFIER_ORDER[""]
210
+ order_comp <=> order_null
211
+ end
212
+ end
213
+
214
+ def qualifier_order(q)
215
+ QUALIFIER_ORDER.fetch(q, UNKNOWN_QUALIFIER_ORDER)
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vers
4
+ module NuGetVersion
5
+ module_function
6
+
7
+ def compare(a, b)
8
+ return 0 if a == b
9
+
10
+ parts_a = parse_nuget(a)
11
+ parts_b = parse_nuget(b)
12
+
13
+ 4.times do |i|
14
+ cmp = parts_a[:numeric][i] <=> parts_b[:numeric][i]
15
+ return cmp unless cmp == 0
16
+ end
17
+
18
+ pre_a = parts_a[:prerelease]
19
+ pre_b = parts_b[:prerelease]
20
+
21
+ return 1 if pre_a.empty? && !pre_b.empty?
22
+ return -1 if !pre_a.empty? && pre_b.empty?
23
+ return 0 if pre_a.empty? && pre_b.empty?
24
+
25
+ compare_prerelease(pre_a, pre_b)
26
+ end
27
+
28
+ def parse_nuget(s)
29
+ s = s.dup
30
+
31
+ if (idx = s.index("+"))
32
+ s = s[0...idx]
33
+ end
34
+
35
+ prerelease = ""
36
+ if (idx = s.index("-"))
37
+ prerelease = s[(idx + 1)..]
38
+ s = s[0...idx]
39
+ end
40
+
41
+ numeric = [0, 0, 0, 0]
42
+ parts = s.split(".")
43
+ parts.each_with_index do |part, i|
44
+ break if i >= 4
45
+ numeric[i] = part.to_i
46
+ end
47
+
48
+ { numeric: numeric, prerelease: prerelease }
49
+ end
50
+
51
+ def compare_prerelease(a, b)
52
+ parts_a = a.downcase.split(".")
53
+ parts_b = b.downcase.split(".")
54
+
55
+ max_len = [parts_a.length, parts_b.length].max
56
+
57
+ max_len.times do |i|
58
+ part_a = i < parts_a.length ? parts_a[i] : nil
59
+ part_b = i < parts_b.length ? parts_b[i] : nil
60
+
61
+ return -1 if part_a.nil?
62
+ return 1 if part_b.nil?
63
+
64
+ num_a = part_a.match?(/\A\d+\z/) ? part_a.to_i : nil
65
+ num_b = part_b.match?(/\A\d+\z/) ? part_b.to_i : nil
66
+
67
+ if num_a && num_b
68
+ cmp = num_a <=> num_b
69
+ return cmp unless cmp == 0
70
+ else
71
+ cmp = part_a <=> part_b
72
+ return cmp unless cmp == 0
73
+ end
74
+ end
75
+
76
+ 0
77
+ end
78
+ end
79
+ end
data/lib/vers/parser.rb CHANGED
@@ -83,8 +83,14 @@ module Vers
83
83
  parse_pypi_range(range_string)
84
84
  when "maven"
85
85
  parse_maven_range(range_string)
86
+ when "cargo"
87
+ parse_npm_range(range_string)
86
88
  when "nuget"
87
89
  parse_nuget_range(range_string)
90
+ when "hex", "elixir"
91
+ parse_hex_range(range_string)
92
+ when "go", "golang"
93
+ parse_go_range(range_string)
88
94
  when "deb", "debian"
89
95
  parse_debian_range(range_string)
90
96
  when "rpm"
@@ -106,9 +112,10 @@ module Vers
106
112
  return "*" if version_range.unbounded?
107
113
  return "vers:#{scheme}/" if version_range.empty?
108
114
 
115
+ intervals = version_range.raw_constraints || version_range.intervals
109
116
  constraints = []
110
-
111
- version_range.intervals.each do |interval|
117
+
118
+ intervals.each do |interval|
112
119
  if interval.min == interval.max && interval.min_inclusive && interval.max_inclusive
113
120
  # Exact version
114
121
  constraints << "=#{interval.min}"
@@ -118,7 +125,7 @@ module Vers
118
125
  operator = interval.min_inclusive ? ">=" : ">"
119
126
  constraints << "#{operator}#{interval.min}"
120
127
  end
121
-
128
+
122
129
  if interval.max
123
130
  operator = interval.max_inclusive ? "<=" : "<"
124
131
  constraints << "#{operator}#{interval.max}"
@@ -126,30 +133,40 @@ module Vers
126
133
  end
127
134
  end
128
135
 
136
+ constraints.sort_by! { |c| sort_key_for_constraint(c) }
137
+ constraints.uniq!
138
+
129
139
  "vers:#{scheme}/#{constraints.join('|')}"
130
140
  end
131
141
 
132
142
  private
133
143
 
144
+ def sort_key_for_constraint(constraint)
145
+ version = constraint.sub(/\A[><=!]+/, '')
146
+ v = Version.cached_new(version)
147
+ [v.major || 0, v.minor || 0, v.patch || 0, constraint]
148
+ end
149
+
134
150
  def parse_constraints(constraints_string, scheme)
135
- constraint_strings = constraints_string.split('|')
151
+ constraint_strings = constraints_string.split(/[|,]/)
136
152
  intervals = []
137
153
  exclusions = []
154
+ interval_scheme = %w[maven nuget].include?(scheme) ? scheme : nil
138
155
 
139
156
  constraint_strings.each do |constraint_string|
140
157
  constraint = Constraint.parse(constraint_string.strip)
141
-
158
+
142
159
  if constraint.exclusion?
143
160
  exclusions << constraint.version
144
161
  else
145
- interval = constraint.to_interval
162
+ interval = constraint.to_interval(scheme: interval_scheme)
146
163
  intervals << interval if interval
147
164
  end
148
165
  end
149
166
 
150
167
  # Start with the union of all positive constraints
151
- range = VersionRange.new(intervals)
152
-
168
+ range = VersionRange.new(intervals, scheme: interval_scheme)
169
+
153
170
  # Apply exclusions
154
171
  exclusions.each do |version|
155
172
  range = range.exclude(version)
@@ -179,7 +196,16 @@ module Vers
179
196
 
180
197
  # Handle space-separated AND constraints
181
198
  and_parts = range_string.split(/\s+/).reject(&:empty?)
182
- ranges = and_parts.map { |part| parse_npm_single_range(part) }
199
+ # Re-join bare operators with their version
200
+ merged = []
201
+ and_parts.each do |part|
202
+ if merged.last&.match?(/\A(>=|<=|!=|[<>=~^])\z/)
203
+ merged[-1] = "#{merged.last}#{part}"
204
+ else
205
+ merged << part
206
+ end
207
+ end
208
+ ranges = merged.map { |part| parse_npm_single_range(part) }
183
209
  ranges.reduce { |acc, range| acc.intersect(range) }
184
210
  end
185
211
 
@@ -323,70 +349,70 @@ module Vers
323
349
  raise ArgumentError, "Malformed Maven range: mismatched brackets in '#{range_string}'"
324
350
  end
325
351
  end
326
-
352
+
327
353
  case range_string
328
354
  when /^\[([^,]+),([^,]+)\]$/
329
355
  # [1.0,2.0] := >=1.0 <=2.0
330
356
  min_version = Regexp.last_match(1).strip
331
357
  max_version = Regexp.last_match(2).strip
332
358
  VersionRange.new([
333
- Interval.new(min: min_version, max: max_version, min_inclusive: true, max_inclusive: true)
334
- ])
359
+ Interval.new(min: min_version, max: max_version, min_inclusive: true, max_inclusive: true, scheme: "maven")
360
+ ], scheme: "maven")
335
361
  when /^\(([^,]+),([^,]+)\)$/
336
362
  # (1.0,2.0) := >1.0 <2.0
337
363
  min_version = Regexp.last_match(1).strip
338
364
  max_version = Regexp.last_match(2).strip
339
365
  VersionRange.new([
340
- Interval.new(min: min_version, max: max_version, min_inclusive: false, max_inclusive: false)
341
- ])
366
+ Interval.new(min: min_version, max: max_version, min_inclusive: false, max_inclusive: false, scheme: "maven")
367
+ ], scheme: "maven")
342
368
  when /^\[([^,]+),([^,]+)\)$/
343
369
  # [1.0,2.0) := >=1.0 <2.0
344
370
  min_version = Regexp.last_match(1).strip
345
371
  max_version = Regexp.last_match(2).strip
346
372
  VersionRange.new([
347
- Interval.new(min: min_version, max: max_version, min_inclusive: true, max_inclusive: false)
348
- ])
373
+ Interval.new(min: min_version, max: max_version, min_inclusive: true, max_inclusive: false, scheme: "maven")
374
+ ], scheme: "maven")
349
375
  when /^\(([^,]+),([^,]+)\]$/
350
376
  # (1.0,2.0] := >1.0 <=2.0
351
377
  min_version = Regexp.last_match(1).strip
352
378
  max_version = Regexp.last_match(2).strip
353
379
  VersionRange.new([
354
- Interval.new(min: min_version, max: max_version, min_inclusive: false, max_inclusive: true)
355
- ])
380
+ Interval.new(min: min_version, max: max_version, min_inclusive: false, max_inclusive: true, scheme: "maven")
381
+ ], scheme: "maven")
356
382
  when /^\[([^,]+)\]$/
357
383
  # [1.0] := exactly 1.0
358
384
  version = Regexp.last_match(1).strip
359
- VersionRange.exact(version)
385
+ VersionRange.exact(version, scheme: "maven")
360
386
  when /^\[([^,]+),\)$/
361
387
  # [1.0,) := >=1.0
362
388
  min_version = Regexp.last_match(1).strip
363
389
  VersionRange.new([
364
- Interval.new(min: min_version, min_inclusive: true)
365
- ])
390
+ Interval.new(min: min_version, min_inclusive: true, scheme: "maven")
391
+ ], scheme: "maven")
366
392
  when /^\(([^,]+),\)$/
367
393
  # (1.0,) := >1.0
368
394
  min_version = Regexp.last_match(1).strip
369
395
  VersionRange.new([
370
- Interval.new(min: min_version, min_inclusive: false)
371
- ])
396
+ Interval.new(min: min_version, min_inclusive: false, scheme: "maven")
397
+ ], scheme: "maven")
372
398
  when /^\(,([^,]+)\]$/
373
399
  # (,1.0] := <=1.0
374
400
  max_version = Regexp.last_match(1).strip
375
401
  VersionRange.new([
376
- Interval.new(max: max_version, max_inclusive: true)
377
- ])
402
+ Interval.new(max: max_version, max_inclusive: true, scheme: "maven")
403
+ ], scheme: "maven")
378
404
  when /^\(,([^,]+)\)$/
379
405
  # (,1.0) := <1.0
380
406
  max_version = Regexp.last_match(1).strip
381
407
  VersionRange.new([
382
- Interval.new(max: max_version, max_inclusive: false)
383
- ])
408
+ Interval.new(max: max_version, max_inclusive: false, scheme: "maven")
409
+ ], scheme: "maven")
384
410
  when /^[0-9]/
385
411
  # Simple version number without brackets - in Maven, this is minimum version
386
412
  if range_string.match(/^[0-9]+(\.[0-9]+)*(-[a-zA-Z0-9.-]+)?$/)
387
413
  VersionRange.new([
388
- Interval.new(min: range_string, min_inclusive: true)
389
- ])
414
+ Interval.new(min: range_string, min_inclusive: true, scheme: "maven")
415
+ ], scheme: "maven")
390
416
  else
391
417
  parse_constraints(range_string, 'maven')
392
418
  end
@@ -400,7 +426,7 @@ module Vers
400
426
  # Find all individual ranges by splitting on comma between brackets
401
427
  individual_ranges = []
402
428
  remaining = range_string.strip
403
-
429
+
404
430
  while remaining.length > 0
405
431
  # Find the next complete bracket range
406
432
  if match = remaining.match(/^[\[\(][^\[\]\(\)]*[\]\)]/)
@@ -412,7 +438,7 @@ module Vers
412
438
  break
413
439
  end
414
440
  end
415
-
441
+
416
442
  if individual_ranges.length > 1
417
443
  individual_ranges.each do |range_part|
418
444
  begin
@@ -422,13 +448,13 @@ module Vers
422
448
  # If parsing fails, skip this part
423
449
  end
424
450
  end
425
-
451
+
426
452
  if ranges.any?
427
453
  return ranges.reduce { |acc, range| acc.union(range) }
428
454
  end
429
455
  end
430
456
  end
431
-
457
+
432
458
  # Fall back to standard constraint parsing
433
459
  parse_constraints(range_string, 'maven')
434
460
  else
@@ -443,19 +469,147 @@ module Vers
443
469
  # But simple version strings like "1.0" are minimum versions, not exact
444
470
  case range_string
445
471
  when /^[\[\(].+[\]\)]$/
446
- # Use Maven parsing for bracket notation
447
- parse_maven_range(range_string)
472
+ # Parse bracket notation like Maven but with nuget scheme
473
+ range = parse_nuget_bracket_range(range_string)
474
+ range
448
475
  when /^[0-9]/
449
476
  # Simple version number - treat as minimum version for NuGet
450
477
  VersionRange.new([
451
- Interval.new(min: range_string, min_inclusive: true)
452
- ])
478
+ Interval.new(min: range_string, min_inclusive: true, scheme: "nuget")
479
+ ], scheme: "nuget")
453
480
  else
454
481
  # Fall back to standard constraint parsing
455
482
  parse_constraints(range_string, 'nuget')
456
483
  end
457
484
  end
458
485
 
486
+ def parse_nuget_bracket_range(range_string)
487
+ case range_string
488
+ when /^\[([^,]+),([^,]+)\]$/
489
+ min_v = Regexp.last_match(1).strip
490
+ max_v = Regexp.last_match(2).strip
491
+ VersionRange.new([
492
+ Interval.new(min: min_v, max: max_v, min_inclusive: true, max_inclusive: true, scheme: "nuget")
493
+ ], scheme: "nuget")
494
+ when /^\(([^,]+),([^,]+)\)$/
495
+ min_v = Regexp.last_match(1).strip
496
+ max_v = Regexp.last_match(2).strip
497
+ VersionRange.new([
498
+ Interval.new(min: min_v, max: max_v, min_inclusive: false, max_inclusive: false, scheme: "nuget")
499
+ ], scheme: "nuget")
500
+ when /^\[([^,]+),([^,]+)\)$/
501
+ min_v = Regexp.last_match(1).strip
502
+ max_v = Regexp.last_match(2).strip
503
+ VersionRange.new([
504
+ Interval.new(min: min_v, max: max_v, min_inclusive: true, max_inclusive: false, scheme: "nuget")
505
+ ], scheme: "nuget")
506
+ when /^\(([^,]+),([^,]+)\]$/
507
+ min_v = Regexp.last_match(1).strip
508
+ max_v = Regexp.last_match(2).strip
509
+ VersionRange.new([
510
+ Interval.new(min: min_v, max: max_v, min_inclusive: false, max_inclusive: true, scheme: "nuget")
511
+ ], scheme: "nuget")
512
+ when /^\[([^,]+)\]$/
513
+ version = Regexp.last_match(1).strip
514
+ VersionRange.exact(version, scheme: "nuget")
515
+ when /^\[([^,]+),\)$/
516
+ min_v = Regexp.last_match(1).strip
517
+ VersionRange.new([
518
+ Interval.new(min: min_v, min_inclusive: true, scheme: "nuget")
519
+ ], scheme: "nuget")
520
+ when /^\(([^,]+),\)$/
521
+ min_v = Regexp.last_match(1).strip
522
+ VersionRange.new([
523
+ Interval.new(min: min_v, min_inclusive: false, scheme: "nuget")
524
+ ], scheme: "nuget")
525
+ when /^\(,([^,]+)\]$/
526
+ max_v = Regexp.last_match(1).strip
527
+ VersionRange.new([
528
+ Interval.new(max: max_v, max_inclusive: true, scheme: "nuget")
529
+ ], scheme: "nuget")
530
+ when /^\(,([^,]+)\)$/
531
+ max_v = Regexp.last_match(1).strip
532
+ VersionRange.new([
533
+ Interval.new(max: max_v, max_inclusive: false, scheme: "nuget")
534
+ ], scheme: "nuget")
535
+ else
536
+ parse_constraints(range_string, 'nuget')
537
+ end
538
+ end
539
+
540
+ # Hex/Elixir range parsing
541
+ def parse_hex_range(range_string)
542
+ # Handle "or" disjunction first
543
+ if range_string.include?(" or ")
544
+ or_parts = range_string.split(" or ").map(&:strip)
545
+ ranges = or_parts.map { |part| parse_hex_single_range(part) }
546
+ return ranges.reduce { |acc, range| acc.union(range) }
547
+ end
548
+
549
+ parse_hex_single_range(range_string)
550
+ end
551
+
552
+ def parse_hex_single_range(range_string)
553
+ # Handle "and" conjunction
554
+ if range_string.include?(" and ")
555
+ and_parts = range_string.split(" and ").map(&:strip)
556
+ ranges = and_parts.map { |part| parse_hex_constraint(part) }
557
+ return ranges.reduce { |acc, range| acc.intersect(range) }
558
+ end
559
+
560
+ parse_hex_constraint(range_string)
561
+ end
562
+
563
+ def parse_hex_constraint(constraint_string)
564
+ if constraint_string.match(/^~>\s*(.+)$/)
565
+ parse_pessimistic_range(Regexp.last_match(1).strip)
566
+ else
567
+ # Normalize == to = for our internal constraint parsing
568
+ normalized = constraint_string.gsub("==", "=")
569
+ constraint = Constraint.parse(normalized.strip)
570
+ if constraint.exclusion?
571
+ VersionRange.unbounded.exclude(constraint.version)
572
+ else
573
+ VersionRange.new([constraint.to_interval])
574
+ end
575
+ end
576
+ end
577
+
578
+ # Go module range parsing (comma-separated AND constraints, v-prefix preserved)
579
+ def parse_go_range(range_string)
580
+ return VersionRange.unbounded if range_string.nil? || range_string.strip.empty?
581
+
582
+ unless range_string.include?(',')
583
+ return parse_constraints(range_string, 'go')
584
+ end
585
+
586
+ parts = range_string.split(',').map(&:strip)
587
+ constraint_intervals = []
588
+ exclusions = []
589
+
590
+ parts.each do |part|
591
+ constraint = Constraint.parse(part)
592
+ if constraint.exclusion?
593
+ exclusions << constraint.version
594
+ else
595
+ interval = constraint.to_interval
596
+ constraint_intervals << interval if interval
597
+ end
598
+ end
599
+
600
+ if constraint_intervals.any?
601
+ range = VersionRange.new([constraint_intervals.first])
602
+ constraint_intervals[1..].each do |interval|
603
+ range = range.intersect(VersionRange.new([interval]))
604
+ end
605
+ else
606
+ range = VersionRange.unbounded
607
+ end
608
+
609
+ exclusions.each { |version| range = range.exclude(version) }
610
+ range
611
+ end
612
+
459
613
  # Debian range parsing
460
614
  def parse_debian_range(range_string)
461
615
  # Debian uses operators like >=, <=, =, >>, <<
data/lib/vers/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vers
4
- VERSION = "1.0.3"
4
+ VERSION = "1.1.0"
5
5
 
6
6
  ##
7
7
  # Handles version comparison and normalization across different package ecosystems.
@@ -64,10 +64,29 @@ module Vers
64
64
  # Use cached versions for better performance
65
65
  version_a = cached_new(a)
66
66
  version_b = cached_new(b)
67
-
67
+
68
68
  version_a <=> version_b
69
69
  end
70
70
 
71
+ ##
72
+ # Compares two version strings using scheme-specific rules
73
+ #
74
+ # @param a [String] First version string
75
+ # @param b [String] Second version string
76
+ # @param scheme [String, nil] Package manager scheme (maven, nuget, or nil for generic)
77
+ # @return [Integer] -1 if a < b, 0 if a == b, 1 if a > b
78
+ #
79
+ def self.compare_with_scheme(a, b, scheme)
80
+ case scheme
81
+ when "maven"
82
+ MavenVersion.compare(a, b)
83
+ when "nuget"
84
+ NuGetVersion.compare(a, b)
85
+ else
86
+ compare(a, b)
87
+ end
88
+ end
89
+
71
90
  ##
72
91
  # Normalizes a version string to a consistent format
73
92
  #
@@ -295,6 +314,9 @@ module Vers
295
314
  private
296
315
 
297
316
  def parse_version
317
+ # Strip leading v/V prefix (e.g. "v1.0.0" -> "1.0.0")
318
+ @original = @original.sub(/\Av/i, '')
319
+
298
320
  # Handle simple numeric versions (optimized case)
299
321
  if @original.match(/^\d+$/)
300
322
  @major = @original.to_i
@@ -5,31 +5,38 @@ require_relative 'version'
5
5
 
6
6
  module Vers
7
7
  class VersionRange
8
- attr_reader :intervals
9
-
10
- def initialize(intervals = [])
11
- @intervals = intervals.compact.reject(&:empty?).sort_by { |i| [i.min || '', i.max || ''] }
8
+ attr_reader :intervals, :raw_constraints, :scheme
9
+
10
+ def initialize(intervals = [], raw_constraints: nil, scheme: nil)
11
+ @scheme = scheme
12
+ @intervals = intervals.compact.reject(&:empty?)
13
+ if @scheme
14
+ @intervals.sort! { |a, b| compare_interval_bounds(a, b) }
15
+ else
16
+ @intervals.sort_by! { |i| [i.min || '', i.max || ''] }
17
+ end
18
+ @raw_constraints = raw_constraints
12
19
  merge_overlapping_intervals!
13
20
  end
14
21
 
15
- def self.empty
16
- new([])
22
+ def self.empty(scheme: nil)
23
+ new([], scheme: scheme)
17
24
  end
18
25
 
19
- def self.unbounded
20
- new([Interval.unbounded])
26
+ def self.unbounded(scheme: nil)
27
+ new([Interval.unbounded(scheme: scheme)], scheme: scheme)
21
28
  end
22
29
 
23
- def self.exact(version)
24
- new([Interval.exact(version)])
30
+ def self.exact(version, scheme: nil)
31
+ new([Interval.exact(version, scheme: scheme)], scheme: scheme)
25
32
  end
26
33
 
27
- def self.greater_than(version, inclusive: false)
28
- new([Interval.greater_than(version, inclusive: inclusive)])
34
+ def self.greater_than(version, inclusive: false, scheme: nil)
35
+ new([Interval.greater_than(version, inclusive: inclusive, scheme: scheme)], scheme: scheme)
29
36
  end
30
37
 
31
- def self.less_than(version, inclusive: false)
32
- new([Interval.less_than(version, inclusive: inclusive)])
38
+ def self.less_than(version, inclusive: false, scheme: nil)
39
+ new([Interval.less_than(version, inclusive: inclusive, scheme: scheme)], scheme: scheme)
33
40
  end
34
41
 
35
42
  def empty?
@@ -45,38 +52,47 @@ module Vers
45
52
  end
46
53
 
47
54
  def intersect(other)
55
+ merged_scheme = @scheme || other.scheme
48
56
  result_intervals = []
49
-
57
+
50
58
  intervals.each do |interval1|
51
59
  other.intervals.each do |interval2|
52
60
  intersection = interval1.intersect(interval2)
53
61
  result_intervals << intersection unless intersection.empty?
54
62
  end
55
63
  end
56
-
57
- self.class.new(result_intervals)
64
+
65
+ combined_raw = (raw_constraints || intervals) + (other.raw_constraints || other.intervals)
66
+ self.class.new(result_intervals, raw_constraints: combined_raw, scheme: merged_scheme)
58
67
  end
59
68
 
60
69
  def union(other)
61
- self.class.new(intervals + other.intervals)
70
+ merged_scheme = @scheme || other.scheme
71
+ combined_raw = (raw_constraints || intervals) + (other.raw_constraints || other.intervals)
72
+ self.class.new(intervals + other.intervals, raw_constraints: combined_raw, scheme: merged_scheme)
62
73
  end
63
74
 
64
75
  def complement
65
- return self.class.unbounded if empty?
66
- return self.class.empty if unbounded?
76
+ return self.class.unbounded(scheme: @scheme) if empty?
77
+ return self.class.empty(scheme: @scheme) if unbounded?
67
78
 
68
79
  result_intervals = []
69
-
70
- sorted_intervals = intervals.sort_by { |i| i.min || '' }
71
-
80
+
81
+ sorted_intervals = if @scheme
82
+ intervals.sort { |a, b| compare_interval_bounds(a, b) }
83
+ else
84
+ intervals.sort_by { |i| i.min || '' }
85
+ end
86
+
72
87
  first_interval = sorted_intervals.first
73
88
  if first_interval.min
74
89
  result_intervals << Interval.new(
75
90
  max: first_interval.min,
76
- max_inclusive: !first_interval.min_inclusive
91
+ max_inclusive: !first_interval.min_inclusive,
92
+ scheme: @scheme
77
93
  )
78
94
  end
79
-
95
+
80
96
  sorted_intervals.each_cons(2) do |curr, next_interval|
81
97
  if curr.max && next_interval.min
82
98
  comparison = version_compare(curr.max, next_interval.min)
@@ -85,53 +101,57 @@ module Vers
85
101
  min: curr.max,
86
102
  max: next_interval.min,
87
103
  min_inclusive: !curr.max_inclusive,
88
- max_inclusive: !next_interval.min_inclusive
104
+ max_inclusive: !next_interval.min_inclusive,
105
+ scheme: @scheme
89
106
  )
90
107
  end
91
108
  end
92
109
  end
93
-
110
+
94
111
  last_interval = sorted_intervals.last
95
112
  if last_interval.max
96
113
  result_intervals << Interval.new(
97
114
  min: last_interval.max,
98
- min_inclusive: !last_interval.max_inclusive
115
+ min_inclusive: !last_interval.max_inclusive,
116
+ scheme: @scheme
99
117
  )
100
118
  end
101
-
102
- self.class.new(result_intervals)
119
+
120
+ self.class.new(result_intervals, scheme: @scheme)
103
121
  end
104
122
 
105
123
  def exclude(version)
106
124
  return self if !contains?(version)
107
-
125
+
108
126
  result_intervals = []
109
-
127
+
110
128
  intervals.each do |interval|
111
129
  if interval.contains?(version)
112
- if interval.min && version_compare(interval.min, version) < 0
130
+ if interval.min.nil? || version_compare(interval.min, version) < 0
113
131
  result_intervals << Interval.new(
114
132
  min: interval.min,
115
133
  max: version,
116
134
  min_inclusive: interval.min_inclusive,
117
- max_inclusive: false
135
+ max_inclusive: false,
136
+ scheme: @scheme
118
137
  )
119
138
  end
120
-
121
- if interval.max && version_compare(version, interval.max) < 0
139
+
140
+ if interval.max.nil? || version_compare(version, interval.max) < 0
122
141
  result_intervals << Interval.new(
123
142
  min: version,
124
143
  max: interval.max,
125
144
  min_inclusive: false,
126
- max_inclusive: interval.max_inclusive
145
+ max_inclusive: interval.max_inclusive,
146
+ scheme: @scheme
127
147
  )
128
148
  end
129
149
  else
130
150
  result_intervals << interval
131
151
  end
132
152
  end
133
-
134
- self.class.new(result_intervals)
153
+
154
+ self.class.new(result_intervals, raw_constraints: raw_constraints, scheme: @scheme)
135
155
  end
136
156
 
137
157
  def to_s
@@ -165,9 +185,39 @@ module Vers
165
185
  return 0 if a == b
166
186
  return -1 if a.nil?
167
187
  return 1 if b.nil?
168
-
169
- # Use the Version class for comparison
170
- Version.compare(a, b)
188
+
189
+ if @scheme
190
+ Version.compare_with_scheme(a, b, @scheme)
191
+ else
192
+ Version.compare(a, b)
193
+ end
194
+ end
195
+
196
+ def compare_interval_bounds(a, b)
197
+ min_a = a.min
198
+ min_b = b.min
199
+ min_cmp = if min_a.nil? && min_b.nil?
200
+ 0
201
+ elsif min_a.nil?
202
+ -1
203
+ elsif min_b.nil?
204
+ 1
205
+ else
206
+ version_compare(min_a, min_b)
207
+ end
208
+ return min_cmp unless min_cmp == 0
209
+
210
+ max_a = a.max
211
+ max_b = b.max
212
+ if max_a.nil? && max_b.nil?
213
+ 0
214
+ elsif max_a.nil?
215
+ 1
216
+ elsif max_b.nil?
217
+ -1
218
+ else
219
+ version_compare(max_a, max_b)
220
+ end
171
221
  end
172
222
  end
173
223
  end
data/lib/vers.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "vers/version"
4
+ require_relative "vers/maven_version"
5
+ require_relative "vers/nuget_version"
4
6
  require_relative "vers/interval"
5
7
  require_relative "vers/version_range"
6
8
  require_relative "vers/constraint"
@@ -143,6 +145,18 @@ module Vers
143
145
  Version.compare(a, b)
144
146
  end
145
147
 
148
+ ##
149
+ # Compares two version strings using scheme-specific rules
150
+ #
151
+ # @param a [String] First version string
152
+ # @param b [String] Second version string
153
+ # @param scheme [String, nil] Package manager scheme (maven, nuget, or nil for generic)
154
+ # @return [Integer] -1 if a < b, 0 if a == b, 1 if a > b
155
+ #
156
+ def self.compare_with_scheme(a, b, scheme)
157
+ Version.compare_with_scheme(a, b, scheme)
158
+ end
159
+
146
160
  ##
147
161
  # Normalizes a version string to a consistent format
148
162
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vers
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -30,6 +30,8 @@ files:
30
30
  - lib/vers.rb
31
31
  - lib/vers/constraint.rb
32
32
  - lib/vers/interval.rb
33
+ - lib/vers/maven_version.rb
34
+ - lib/vers/nuget_version.rb
33
35
  - lib/vers/parser.rb
34
36
  - lib/vers/version.rb
35
37
  - lib/vers/version_range.rb