vers 1.0.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.
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vers
4
+ VERSION = "1.0.0"
5
+
6
+ ##
7
+ # Handles version comparison and normalization across different package ecosystems.
8
+ #
9
+ # This class provides version comparison functionality that can handle different
10
+ # versioning schemes used by various package managers (npm, gem, pypi, etc.).
11
+ #
12
+ # == Examples
13
+ #
14
+ # Vers::Version.compare("1.2.3", "1.2.4") # => -1
15
+ # Vers::Version.compare("2.0.0", "1.9.9") # => 1
16
+ # Vers::Version.compare("1.0.0", "1.0.0") # => 0
17
+ #
18
+ class Version
19
+ # Regex for parsing semantic version components including build metadata
20
+ SEMANTIC_VERSION_REGEX = /\A(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([^+]+))?(?:\+(.+))?\z/
21
+
22
+ attr_reader :major, :minor, :patch, :prerelease, :build
23
+
24
+ ##
25
+ # Creates a new Version object
26
+ #
27
+ # @param version_string [String] The version string to parse
28
+ #
29
+ def initialize(version_string)
30
+ @original = version_string.to_s
31
+ parse_version
32
+ end
33
+
34
+ ##
35
+ # Compares two version strings
36
+ #
37
+ # @param a [String] First version string
38
+ # @param b [String] Second version string
39
+ # @return [Integer] -1 if a < b, 0 if a == b, 1 if a > b
40
+ #
41
+ def self.compare(a, b)
42
+ return 0 if a == b
43
+ return -1 if a.nil?
44
+ return 1 if b.nil?
45
+
46
+ version_a = new(a)
47
+ version_b = new(b)
48
+
49
+ version_a <=> version_b
50
+ end
51
+
52
+ ##
53
+ # Normalizes a version string to a consistent format
54
+ #
55
+ # @param version_string [String] The version string to normalize
56
+ # @return [String] The normalized version string
57
+ #
58
+ def self.normalize(version_string)
59
+ new(version_string).to_s
60
+ end
61
+
62
+ ##
63
+ # Checks if a version string is valid
64
+ #
65
+ # @param version_string [String] The version string to validate
66
+ # @return [Boolean] true if the version is valid
67
+ #
68
+ def self.valid?(version_string)
69
+ new(version_string)
70
+ true
71
+ rescue ArgumentError
72
+ false
73
+ end
74
+
75
+ ##
76
+ # Version comparison operator
77
+ #
78
+ # @param other [Version] The other version to compare to
79
+ # @return [Integer] -1, 0, or 1
80
+ #
81
+ def <=>(other)
82
+ return 0 if @original == other.to_s
83
+
84
+ # Compare major.minor.patch numerically
85
+ major_cmp = (major || 0) <=> (other.major || 0)
86
+ return major_cmp unless major_cmp == 0
87
+
88
+ minor_cmp = (minor || 0) <=> (other.minor || 0)
89
+ return minor_cmp unless minor_cmp == 0
90
+
91
+ patch_cmp = (patch || 0) <=> (other.patch || 0)
92
+ return patch_cmp unless patch_cmp == 0
93
+
94
+ # Handle prerelease comparison
95
+ return 1 if prerelease.nil? && !other.prerelease.nil?
96
+ return -1 if !prerelease.nil? && other.prerelease.nil?
97
+ return 0 if prerelease.nil? && other.prerelease.nil?
98
+
99
+ compare_prerelease(prerelease, other.prerelease)
100
+ end
101
+
102
+ ##
103
+ # String representation of the version
104
+ #
105
+ # @return [String] The normalized version string
106
+ #
107
+ def to_s
108
+ version = "#{major || 0}"
109
+ version += ".#{minor || 0}"
110
+ version += ".#{patch || 0}"
111
+ version += "-#{prerelease}" if prerelease
112
+ version
113
+ end
114
+
115
+ def ==(other)
116
+ other.is_a?(Version) && self <=> other == 0
117
+ end
118
+
119
+ def <(other)
120
+ (self <=> other) < 0
121
+ end
122
+
123
+ def <=(other)
124
+ (self <=> other) <= 0
125
+ end
126
+
127
+ def >(other)
128
+ (self <=> other) > 0
129
+ end
130
+
131
+ def >=(other)
132
+ (self <=> other) >= 0
133
+ end
134
+
135
+ def hash
136
+ [@original].hash
137
+ end
138
+
139
+ ##
140
+ # Increments the specified component of the version
141
+ #
142
+ # @param component [Symbol] The component to increment (:major, :minor, :patch)
143
+ # @return [Version] A new Version object with the incremented component
144
+ #
145
+ # == Examples
146
+ #
147
+ # version = Vers::Version.new("1.2.3")
148
+ # version.increment(:major) # => #<Vers::Version "2.0.0">
149
+ # version.increment(:minor) # => #<Vers::Version "1.3.0">
150
+ # version.increment(:patch) # => #<Vers::Version "1.2.4">
151
+ #
152
+ def increment(component)
153
+ case component
154
+ when :major
155
+ self.class.new("#{major + 1}.0.0")
156
+ when :minor
157
+ self.class.new("#{major}.#{(minor || 0) + 1}.0")
158
+ when :patch
159
+ self.class.new("#{major}.#{minor || 0}.#{(patch || 0) + 1}")
160
+ else
161
+ raise ArgumentError, "Invalid component: #{component}. Must be :major, :minor, or :patch"
162
+ end
163
+ end
164
+
165
+ ##
166
+ # Increments the major version component
167
+ #
168
+ # @return [Version] A new Version object with incremented major version
169
+ #
170
+ def increment_major
171
+ increment(:major)
172
+ end
173
+
174
+ ##
175
+ # Increments the minor version component
176
+ #
177
+ # @return [Version] A new Version object with incremented minor version
178
+ #
179
+ def increment_minor
180
+ increment(:minor)
181
+ end
182
+
183
+ ##
184
+ # Increments the patch version component
185
+ #
186
+ # @return [Version] A new Version object with incremented patch version
187
+ #
188
+ def increment_patch
189
+ increment(:patch)
190
+ end
191
+
192
+ ##
193
+ # Checks if this version satisfies a constraint using pessimistic operator logic
194
+ #
195
+ # @param constraint [String] The constraint string (e.g., "~> 1.2")
196
+ # @return [Boolean] true if this version satisfies the constraint
197
+ #
198
+ # == Examples
199
+ #
200
+ # version = Vers::Version.new("1.2.5")
201
+ # version.satisfies?("~> 1.2") # => true (>= 1.2.0, < 1.3.0)
202
+ # version.satisfies?("~> 1.2.3") # => true (>= 1.2.3, < 1.3.0)
203
+ # version.satisfies?("~> 1.3") # => false
204
+ #
205
+ def satisfies?(constraint)
206
+ if constraint.start_with?("~>")
207
+ # Pessimistic constraint
208
+ base_version = constraint.sub(/^~>\s*/, "").strip
209
+ base = self.class.new(base_version)
210
+
211
+ # Must be >= base version
212
+ return false if self < base
213
+
214
+ # Must be < next significant version
215
+ if base.patch && base.patch > 0
216
+ # ~> 1.2.3 means >= 1.2.3, < 1.3.0
217
+ upper_bound = self.class.new("#{base.major}.#{(base.minor || 0) + 1}.0")
218
+ elsif base.minor
219
+ # ~> 1.2 means >= 1.2.0, < 1.3.0
220
+ upper_bound = self.class.new("#{base.major}.#{(base.minor || 0) + 1}.0")
221
+ else
222
+ # ~> 1 means >= 1.0.0, < 2.0.0
223
+ upper_bound = self.class.new("#{base.major + 1}.0.0")
224
+ end
225
+
226
+ self < upper_bound
227
+ else
228
+ # For other constraints, delegate to constraint parsing
229
+ # This would require the Constraint class, so for now return true
230
+ true
231
+ end
232
+ end
233
+
234
+ ##
235
+ # Checks if this is a stable release (no prerelease components)
236
+ #
237
+ # @return [Boolean] true if this is a stable release
238
+ #
239
+ def stable?
240
+ prerelease.nil?
241
+ end
242
+
243
+ ##
244
+ # Checks if this is a prerelease version
245
+ #
246
+ # @return [Boolean] true if this is a prerelease version
247
+ #
248
+ def prerelease?
249
+ !prerelease.nil?
250
+ end
251
+
252
+ ##
253
+ # Gets the semantic version components as a hash
254
+ #
255
+ # @return [Hash] Hash with :major, :minor, :patch, :prerelease, :build keys
256
+ #
257
+ def to_h
258
+ {
259
+ major: major,
260
+ minor: minor,
261
+ patch: patch,
262
+ prerelease: prerelease,
263
+ build: build
264
+ }
265
+ end
266
+
267
+ ##
268
+ # Creates a new Version with the same major.minor but patch set to 0
269
+ #
270
+ # @return [Version] A new Version object with patch reset to 0
271
+ #
272
+ def base
273
+ self.class.new("#{major}.#{minor || 0}.0")
274
+ end
275
+
276
+ private
277
+
278
+ def parse_version
279
+ # Handle simple numeric versions
280
+ if @original.match(/^\d+$/)
281
+ @major = @original.to_i
282
+ return
283
+ end
284
+
285
+ # Try semantic version parsing first
286
+ if match = @original.match(SEMANTIC_VERSION_REGEX)
287
+ @major = match[1]&.to_i
288
+ @minor = match[2]&.to_i
289
+ @patch = match[3]&.to_i
290
+ @prerelease = match[4]
291
+ @build = match[5]
292
+ return
293
+ end
294
+
295
+ # Fall back to splitting on dots/dashes
296
+ parts = @original.split(/[.-]/)
297
+
298
+ if parts.empty?
299
+ raise ArgumentError, "Invalid version format: #{@original}"
300
+ end
301
+
302
+ @major = parts[0]&.to_i
303
+ @minor = parts[1]&.to_i if parts[1]
304
+ @patch = parts[2]&.to_i if parts[2]
305
+
306
+ # Everything after patch is considered prerelease
307
+ if parts.length > 3
308
+ @prerelease = parts[3..-1].join('.')
309
+ end
310
+ end
311
+
312
+ def compare_prerelease(pre_a, pre_b)
313
+ parts_a = pre_a.split('.')
314
+ parts_b = pre_b.split('.')
315
+
316
+ max_length = [parts_a.length, parts_b.length].max
317
+
318
+ 0.upto(max_length - 1) do |i|
319
+ part_a = parts_a[i]
320
+ part_b = parts_b[i]
321
+
322
+ return -1 if part_a.nil?
323
+ return 1 if part_b.nil?
324
+
325
+ # Try numeric comparison first
326
+ if part_a.match(/^\d+$/) && part_b.match(/^\d+$/)
327
+ numeric_cmp = part_a.to_i <=> part_b.to_i
328
+ return numeric_cmp unless numeric_cmp == 0
329
+ else
330
+ string_cmp = part_a <=> part_b
331
+ return string_cmp unless string_cmp == 0
332
+ end
333
+ end
334
+
335
+ 0
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'interval'
4
+ require_relative 'version'
5
+
6
+ module Vers
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 || ''] }
12
+ merge_overlapping_intervals!
13
+ end
14
+
15
+ def self.empty
16
+ new([])
17
+ end
18
+
19
+ def self.unbounded
20
+ new([Interval.unbounded])
21
+ end
22
+
23
+ def self.exact(version)
24
+ new([Interval.exact(version)])
25
+ end
26
+
27
+ def self.greater_than(version, inclusive: false)
28
+ new([Interval.greater_than(version, inclusive: inclusive)])
29
+ end
30
+
31
+ def self.less_than(version, inclusive: false)
32
+ new([Interval.less_than(version, inclusive: inclusive)])
33
+ end
34
+
35
+ def empty?
36
+ intervals.empty?
37
+ end
38
+
39
+ def unbounded?
40
+ intervals.length == 1 && intervals.first.unbounded?
41
+ end
42
+
43
+ def contains?(version)
44
+ intervals.any? { |interval| interval.contains?(version) }
45
+ end
46
+
47
+ def intersect(other)
48
+ result_intervals = []
49
+
50
+ intervals.each do |interval1|
51
+ other.intervals.each do |interval2|
52
+ intersection = interval1.intersect(interval2)
53
+ result_intervals << intersection unless intersection.empty?
54
+ end
55
+ end
56
+
57
+ self.class.new(result_intervals)
58
+ end
59
+
60
+ def union(other)
61
+ self.class.new(intervals + other.intervals)
62
+ end
63
+
64
+ def complement
65
+ return self.class.unbounded if empty?
66
+ return self.class.empty if unbounded?
67
+
68
+ result_intervals = []
69
+
70
+ sorted_intervals = intervals.sort_by { |i| i.min || '' }
71
+
72
+ first_interval = sorted_intervals.first
73
+ if first_interval.min
74
+ result_intervals << Interval.new(
75
+ max: first_interval.min,
76
+ max_inclusive: !first_interval.min_inclusive
77
+ )
78
+ end
79
+
80
+ sorted_intervals.each_cons(2) do |curr, next_interval|
81
+ if curr.max && next_interval.min
82
+ comparison = version_compare(curr.max, next_interval.min)
83
+ if comparison < 0 || (comparison == 0 && (!curr.max_inclusive || !next_interval.min_inclusive))
84
+ result_intervals << Interval.new(
85
+ min: curr.max,
86
+ max: next_interval.min,
87
+ min_inclusive: !curr.max_inclusive,
88
+ max_inclusive: !next_interval.min_inclusive
89
+ )
90
+ end
91
+ end
92
+ end
93
+
94
+ last_interval = sorted_intervals.last
95
+ if last_interval.max
96
+ result_intervals << Interval.new(
97
+ min: last_interval.max,
98
+ min_inclusive: !last_interval.max_inclusive
99
+ )
100
+ end
101
+
102
+ self.class.new(result_intervals)
103
+ end
104
+
105
+ def exclude(version)
106
+ return self if !contains?(version)
107
+
108
+ result_intervals = []
109
+
110
+ intervals.each do |interval|
111
+ if interval.contains?(version)
112
+ if interval.min && version_compare(interval.min, version) < 0
113
+ result_intervals << Interval.new(
114
+ min: interval.min,
115
+ max: version,
116
+ min_inclusive: interval.min_inclusive,
117
+ max_inclusive: false
118
+ )
119
+ end
120
+
121
+ if interval.max && version_compare(version, interval.max) < 0
122
+ result_intervals << Interval.new(
123
+ min: version,
124
+ max: interval.max,
125
+ min_inclusive: false,
126
+ max_inclusive: interval.max_inclusive
127
+ )
128
+ end
129
+ else
130
+ result_intervals << interval
131
+ end
132
+ end
133
+
134
+ self.class.new(result_intervals)
135
+ end
136
+
137
+ def to_s
138
+ return "∅" if empty?
139
+ return intervals.map(&:to_s).join(" ∪ ")
140
+ end
141
+
142
+ private
143
+
144
+ def merge_overlapping_intervals!
145
+ return if intervals.length <= 1
146
+
147
+ merged = []
148
+ current = intervals.first
149
+
150
+ intervals[1..-1].each do |interval|
151
+ union_result = current.union(interval)
152
+ if union_result
153
+ current = union_result
154
+ else
155
+ merged << current
156
+ current = interval
157
+ end
158
+ end
159
+
160
+ merged << current
161
+ @intervals = merged
162
+ end
163
+
164
+ def version_compare(a, b)
165
+ return 0 if a == b
166
+ return -1 if a.nil?
167
+ return 1 if b.nil?
168
+
169
+ # Use the Version class for comparison
170
+ Version.compare(a, b)
171
+ end
172
+ end
173
+ end
data/lib/vers.rb ADDED
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vers/version"
4
+ require_relative "vers/interval"
5
+ require_relative "vers/version_range"
6
+ require_relative "vers/constraint"
7
+ require_relative "vers/parser"
8
+
9
+ ##
10
+ # Vers - A Ruby gem for parsing, comparing and sorting versions according to the VERS spec
11
+ #
12
+ # This gem provides tools for working with version ranges across different package managers,
13
+ # using a mathematical interval model internally and supporting the vers specification from
14
+ # the Package URL (PURL) project.
15
+ #
16
+ # == Features
17
+ #
18
+ # * Parse version ranges from multiple package ecosystems (npm, gem, pypi, maven, etc.)
19
+ # * Convert between native version range syntax and universal vers URI format
20
+ # * Mathematical interval-based operations (union, intersection, complement)
21
+ # * Version comparison and containment checking
22
+ # * Extensible architecture for adding new package manager support
23
+ #
24
+ # == Quick Start
25
+ #
26
+ # require 'vers'
27
+ #
28
+ # # Parse a vers URI
29
+ # range = Vers.parse("vers:npm/>=1.2.3|<2.0.0")
30
+ # range.contains?("1.5.0") # => true
31
+ # range.contains?("2.1.0") # => false
32
+ #
33
+ # # Parse native package manager syntax
34
+ # npm_range = Vers.parse_native("^1.2.3", "npm")
35
+ # gem_range = Vers.parse_native("~> 1.0", "gem")
36
+ #
37
+ # # Check version containment
38
+ # Vers.satisfies?("1.5.0", ">=1.0.0,<2.0.0") # => true
39
+ #
40
+ # # Compare versions
41
+ # Vers.compare("1.2.3", "1.2.4") # => -1
42
+ #
43
+ # == Mathematical Model
44
+ #
45
+ # Internally, all version ranges are represented as mathematical intervals,
46
+ # similar to those used in mathematics (e.g., [1.0.0, 2.0.0) represents
47
+ # versions from 1.0.0 inclusive to 2.0.0 exclusive).
48
+ #
49
+ # This allows for precise set operations like union, intersection, and
50
+ # complement, regardless of the original package manager syntax.
51
+ #
52
+ # @see https://github.com/package-url/purl-spec/blob/main/VERSION-RANGE-SPEC.rst
53
+ # @author Andrew Nesbitt
54
+ #
55
+ module Vers
56
+ class Error < StandardError; end
57
+
58
+ # Default parser instance for convenience methods
59
+ @@parser = Parser.new
60
+
61
+ ##
62
+ # Parses a vers URI string into a VersionRange
63
+ #
64
+ # @param vers_string [String] The vers URI string (e.g., "vers:npm/>=1.2.3|<2.0.0")
65
+ # @return [VersionRange] The parsed version range
66
+ # @raise [ArgumentError] if the vers string is invalid
67
+ #
68
+ # == Examples
69
+ #
70
+ # Vers.parse("vers:npm/>=1.2.3|<2.0.0")
71
+ # Vers.parse("vers:gem/~>1.0")
72
+ # Vers.parse("*") # unbounded range
73
+ #
74
+ def self.parse(vers_string)
75
+ @@parser.parse(vers_string)
76
+ end
77
+
78
+ ##
79
+ # Parses a native package manager version range into a VersionRange
80
+ #
81
+ # @param range_string [String] The native version range string
82
+ # @param scheme [String] The package manager scheme (npm, gem, pypi, etc.)
83
+ # @return [VersionRange] The parsed version range
84
+ #
85
+ # == Examples
86
+ #
87
+ # Vers.parse_native("^1.2.3", "npm") # npm caret range
88
+ # Vers.parse_native("~> 1.0", "gem") # gem pessimistic operator
89
+ # Vers.parse_native(">=1.0,<2.0", "pypi") # python constraints
90
+ #
91
+ def self.parse_native(range_string, scheme)
92
+ @@parser.parse_native(range_string, scheme)
93
+ end
94
+
95
+ ##
96
+ # Converts a VersionRange to a vers URI string
97
+ #
98
+ # @param version_range [VersionRange] The version range to convert
99
+ # @param scheme [String] The package manager scheme
100
+ # @return [String] The vers URI string
101
+ #
102
+ def self.to_vers_string(version_range, scheme)
103
+ @@parser.to_vers_string(version_range, scheme)
104
+ end
105
+
106
+ ##
107
+ # Checks if a version satisfies a version range constraint
108
+ #
109
+ # @param version [String] The version to check
110
+ # @param constraint [String] The version constraint (vers URI or native format)
111
+ # @param scheme [String, nil] The package manager scheme (if not using vers URI)
112
+ # @return [Boolean] true if the version satisfies the constraint
113
+ #
114
+ # == Examples
115
+ #
116
+ # Vers.satisfies?("1.5.0", "vers:npm/>=1.0.0|<2.0.0") # => true
117
+ # Vers.satisfies?("1.5.0", "^1.2.3", "npm") # => true
118
+ #
119
+ def self.satisfies?(version, constraint, scheme = nil)
120
+ range = if scheme
121
+ parse_native(constraint, scheme)
122
+ else
123
+ parse(constraint)
124
+ end
125
+
126
+ range.contains?(version)
127
+ end
128
+
129
+ ##
130
+ # Compares two version strings
131
+ #
132
+ # @param a [String] First version string
133
+ # @param b [String] Second version string
134
+ # @return [Integer] -1 if a < b, 0 if a == b, 1 if a > b
135
+ #
136
+ # == Examples
137
+ #
138
+ # Vers.compare("1.2.3", "1.2.4") # => -1
139
+ # Vers.compare("2.0.0", "1.9.9") # => 1
140
+ # Vers.compare("1.0.0", "1.0.0") # => 0
141
+ #
142
+ def self.compare(a, b)
143
+ Version.compare(a, b)
144
+ end
145
+
146
+ ##
147
+ # Normalizes a version string to a consistent format
148
+ #
149
+ # @param version_string [String] The version string to normalize
150
+ # @return [String] The normalized version string
151
+ #
152
+ def self.normalize(version_string)
153
+ Version.normalize(version_string)
154
+ end
155
+
156
+ ##
157
+ # Checks if a version string is valid
158
+ #
159
+ # @param version_string [String] The version string to validate
160
+ # @return [Boolean] true if the version is valid
161
+ #
162
+ def self.valid?(version_string)
163
+ Version.valid?(version_string)
164
+ end
165
+
166
+ ##
167
+ # Creates an exact version range
168
+ #
169
+ # @param version [String] The exact version
170
+ # @return [VersionRange] A range containing only the specified version
171
+ #
172
+ def self.exact(version)
173
+ VersionRange.exact(version)
174
+ end
175
+
176
+ ##
177
+ # Creates a greater-than version range
178
+ #
179
+ # @param version [String] The minimum version
180
+ # @param inclusive [Boolean] Whether to include the minimum version
181
+ # @return [VersionRange] A range for versions greater than (or equal to) the specified version
182
+ #
183
+ def self.greater_than(version, inclusive: false)
184
+ VersionRange.greater_than(version, inclusive: inclusive)
185
+ end
186
+
187
+ ##
188
+ # Creates a less-than version range
189
+ #
190
+ # @param version [String] The maximum version
191
+ # @param inclusive [Boolean] Whether to include the maximum version
192
+ # @return [VersionRange] A range for versions less than (or equal to) the specified version
193
+ #
194
+ def self.less_than(version, inclusive: false)
195
+ VersionRange.less_than(version, inclusive: inclusive)
196
+ end
197
+
198
+ ##
199
+ # Creates an unbounded version range (matches all versions)
200
+ #
201
+ # @return [VersionRange] An unbounded range
202
+ #
203
+ def self.unbounded
204
+ VersionRange.unbounded
205
+ end
206
+
207
+ ##
208
+ # Creates an empty version range (matches no versions)
209
+ #
210
+ # @return [VersionRange] An empty range
211
+ #
212
+ def self.empty
213
+ VersionRange.empty
214
+ end
215
+ end