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,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'constraint'
4
+ require_relative 'version_range'
5
+
6
+ module Vers
7
+ ##
8
+ # Parses vers URI strings and package manager specific version ranges
9
+ #
10
+ # This class handles parsing of vers URI format (e.g., "vers:npm/>=1.2.3|<2.0.0")
11
+ # and provides extensible support for different package ecosystem syntaxes.
12
+ #
13
+ # == Examples
14
+ #
15
+ # parser = Vers::Parser.new
16
+ # range = parser.parse("vers:npm/>=1.2.3|<2.0.0")
17
+ # range.contains?("1.5.0") # => true
18
+ #
19
+ class Parser
20
+ # Regex for parsing vers URI format
21
+ VERS_URI_REGEX = /\Avers:([^\/]+)\/(.+)\z/
22
+
23
+ ##
24
+ # Parses a vers URI string into a VersionRange
25
+ #
26
+ # @param vers_string [String] The vers URI string to parse
27
+ # @return [VersionRange] The parsed version range
28
+ # @raise [ArgumentError] if the vers string is invalid
29
+ #
30
+ # == Examples
31
+ #
32
+ # parser = Vers::Parser.new
33
+ # parser.parse("vers:npm/>=1.2.3|<2.0.0")
34
+ # parser.parse("vers:gem/~>1.0")
35
+ # parser.parse("vers:pypi/==1.2.3")
36
+ #
37
+ def parse(vers_string)
38
+ if vers_string == "*"
39
+ return VersionRange.unbounded
40
+ end
41
+
42
+ match = vers_string.match(VERS_URI_REGEX)
43
+ raise ArgumentError, "Invalid vers URI format: #{vers_string}" unless match
44
+
45
+ scheme = match[1]
46
+ constraints_string = match[2]
47
+
48
+ parse_constraints(constraints_string, scheme)
49
+ end
50
+
51
+ ##
52
+ # Parses a native package manager version range into a VersionRange
53
+ #
54
+ # @param range_string [String] The native version range string
55
+ # @param scheme [String] The package manager scheme (npm, gem, pypi, etc.)
56
+ # @return [VersionRange] The parsed version range
57
+ #
58
+ # == Examples
59
+ #
60
+ # parser = Vers::Parser.new
61
+ # parser.parse_native("^1.2.3", "npm")
62
+ # parser.parse_native("~> 1.0", "gem")
63
+ # parser.parse_native(">=1.0,<2.0", "pypi")
64
+ #
65
+ def parse_native(range_string, scheme)
66
+ case scheme
67
+ when "npm"
68
+ parse_npm_range(range_string)
69
+ when "gem", "rubygems"
70
+ parse_gem_range(range_string)
71
+ when "pypi"
72
+ parse_pypi_range(range_string)
73
+ when "maven"
74
+ parse_maven_range(range_string)
75
+ when "nuget"
76
+ parse_nuget_range(range_string)
77
+ when "deb", "debian"
78
+ parse_debian_range(range_string)
79
+ when "rpm"
80
+ parse_rpm_range(range_string)
81
+ else
82
+ # Fall back to generic constraint parsing
83
+ parse_constraints(range_string, scheme)
84
+ end
85
+ end
86
+
87
+ ##
88
+ # Converts a VersionRange back to a vers URI string
89
+ #
90
+ # @param version_range [VersionRange] The version range to convert
91
+ # @param scheme [String] The package manager scheme
92
+ # @return [String] The vers URI string
93
+ #
94
+ def to_vers_string(version_range, scheme)
95
+ return "*" if version_range.unbounded?
96
+ return "vers:#{scheme}/" if version_range.empty?
97
+
98
+ constraints = []
99
+
100
+ version_range.intervals.each do |interval|
101
+ if interval.min == interval.max && interval.min_inclusive && interval.max_inclusive
102
+ # Exact version
103
+ constraints << "=#{interval.min}"
104
+ else
105
+ # Range constraints
106
+ if interval.min
107
+ operator = interval.min_inclusive ? ">=" : ">"
108
+ constraints << "#{operator}#{interval.min}"
109
+ end
110
+
111
+ if interval.max
112
+ operator = interval.max_inclusive ? "<=" : "<"
113
+ constraints << "#{operator}#{interval.max}"
114
+ end
115
+ end
116
+ end
117
+
118
+ "vers:#{scheme}/#{constraints.join('|')}"
119
+ end
120
+
121
+ private
122
+
123
+ def parse_constraints(constraints_string, scheme)
124
+ constraint_strings = constraints_string.split('|')
125
+ intervals = []
126
+ exclusions = []
127
+
128
+ constraint_strings.each do |constraint_string|
129
+ constraint = Constraint.parse(constraint_string.strip)
130
+
131
+ if constraint.exclusion?
132
+ exclusions << constraint.version
133
+ else
134
+ interval = constraint.to_interval
135
+ intervals << interval if interval
136
+ end
137
+ end
138
+
139
+ # Start with the union of all positive constraints
140
+ range = VersionRange.new(intervals)
141
+
142
+ # Apply exclusions
143
+ exclusions.each do |version|
144
+ range = range.exclude(version)
145
+ end
146
+
147
+ range
148
+ end
149
+
150
+ # NPM range parsing (^, ~, -, ||, etc.)
151
+ def parse_npm_range(range_string)
152
+ # Handle empty string as unbounded
153
+ if range_string.nil? || range_string.strip.empty?
154
+ return VersionRange.unbounded
155
+ end
156
+
157
+ # Handle || (OR) operator
158
+ if range_string.include?('||')
159
+ or_parts = range_string.split('||').map(&:strip)
160
+ ranges = or_parts.map { |part| parse_npm_single_range(part) }
161
+ return ranges.reduce { |acc, range| acc.union(range) }
162
+ end
163
+
164
+ # Handle hyphen ranges first (before space splitting)
165
+ if range_string.match(/^(.+?)\s+-\s+(.+)$/)
166
+ return parse_npm_single_range(range_string)
167
+ end
168
+
169
+ # Handle space-separated AND constraints
170
+ and_parts = range_string.split(/\s+/).reject(&:empty?)
171
+ ranges = and_parts.map { |part| parse_npm_single_range(part) }
172
+ ranges.reduce { |acc, range| acc.intersect(range) }
173
+ end
174
+
175
+ def parse_npm_single_range(range_string)
176
+ case range_string
177
+ when /^\^(.+)$/
178
+ # Caret range: ^1.2.3 := >=1.2.3 <2.0.0
179
+ version = Regexp.last_match(1)
180
+ parse_caret_range(version)
181
+ when /^~(.+)$/
182
+ # Tilde range: ~1.2.3 := >=1.2.3 <1.3.0
183
+ version = Regexp.last_match(1)
184
+ parse_tilde_range(version)
185
+ when /^(.+?)\s+-\s+(.+)$/
186
+ # Hyphen range: 1.2.3 - 2.3.4 := >=1.2.3 <=2.3.4
187
+ from_version = Regexp.last_match(1).strip
188
+ to_version = Regexp.last_match(2).strip
189
+ VersionRange.new([
190
+ Interval.new(min: from_version, max: to_version, min_inclusive: true, max_inclusive: true)
191
+ ])
192
+ when "*", "x", "X"
193
+ VersionRange.unbounded
194
+ when /^(\d+)\.x$/
195
+ # X-range like "1.x" := >=1.0.0 <2.0.0
196
+ major = Regexp.last_match(1).to_i
197
+ VersionRange.new([
198
+ Interval.new(min: "#{major}.0.0", max: "#{major + 1}.0.0", min_inclusive: true, max_inclusive: false)
199
+ ])
200
+ when /^(\d+)\.(\d+)\.x$/
201
+ # X-range like "1.2.x" := >=1.2.0 <1.3.0
202
+ major = Regexp.last_match(1).to_i
203
+ minor = Regexp.last_match(2).to_i
204
+ VersionRange.new([
205
+ Interval.new(min: "#{major}.#{minor}.0", max: "#{major}.#{minor + 1}.0", min_inclusive: true, max_inclusive: false)
206
+ ])
207
+ when /^(blerg|git\+|https?:\/\/)/
208
+ # Invalid patterns that should raise errors
209
+ raise ArgumentError, "Invalid NPM range format: #{range_string}"
210
+ else
211
+ # Standard constraint
212
+ constraint = Constraint.parse(range_string)
213
+ if constraint.exclusion?
214
+ VersionRange.unbounded.exclude(constraint.version)
215
+ else
216
+ VersionRange.new([constraint.to_interval])
217
+ end
218
+ end
219
+ end
220
+
221
+ def parse_caret_range(version)
222
+ v = Version.new(version)
223
+ if v.major > 0
224
+ # ^1.2.3 := >=1.2.3 <2.0.0
225
+ upper_version = "#{v.major + 1}.0.0"
226
+ elsif v.minor && v.minor > 0
227
+ # ^0.2.3 := >=0.2.3 <0.3.0
228
+ upper_version = "0.#{v.minor + 1}.0"
229
+ else
230
+ # ^0.0.3 := >=0.0.3 <0.0.4
231
+ upper_version = "0.0.#{(v.patch || 0) + 1}"
232
+ end
233
+
234
+ VersionRange.new([
235
+ Interval.new(min: version, max: upper_version, min_inclusive: true, max_inclusive: false)
236
+ ])
237
+ end
238
+
239
+ def parse_tilde_range(version)
240
+ v = Version.new(version)
241
+ if v.minor
242
+ # ~1.2.3 := >=1.2.3 <1.3.0
243
+ upper_version = "#{v.major}.#{v.minor + 1}.0"
244
+ else
245
+ # ~1 := >=1.0.0 <2.0.0
246
+ upper_version = "#{v.major + 1}.0.0"
247
+ end
248
+
249
+ VersionRange.new([
250
+ Interval.new(min: version, max: upper_version, min_inclusive: true, max_inclusive: false)
251
+ ])
252
+ end
253
+
254
+ # Gem range parsing (~>, >=, etc.)
255
+ def parse_gem_range(range_string)
256
+ if range_string.match(/^~>\s*(.+)$/)
257
+ # Pessimistic operator: ~> 1.2.3
258
+ version = Regexp.last_match(1).strip
259
+ parse_pessimistic_range(version)
260
+ else
261
+ # Standard constraints separated by commas
262
+ constraints = range_string.split(',').map(&:strip)
263
+ parse_constraints(constraints.join('|'), 'gem')
264
+ end
265
+ end
266
+
267
+ def parse_pessimistic_range(version)
268
+ v = Version.new(version)
269
+ if v.patch
270
+ # ~> 1.2.3 := >= 1.2.3, < 1.3.0
271
+ upper_version = "#{v.major}.#{v.minor + 1}.0"
272
+ elsif v.minor
273
+ # ~> 1.2 := >= 1.2.0, < 2.0.0
274
+ upper_version = "#{v.major + 1}.0.0"
275
+ else
276
+ # ~> 1 := >= 1.0.0, < 2.0.0
277
+ upper_version = "#{v.major + 1}.0.0"
278
+ end
279
+
280
+ VersionRange.new([
281
+ Interval.new(min: version, max: upper_version, min_inclusive: true, max_inclusive: false)
282
+ ])
283
+ end
284
+
285
+ # Python/PyPI range parsing
286
+ def parse_pypi_range(range_string)
287
+ # Handle comma-separated constraints
288
+ constraints = range_string.split(',').map(&:strip)
289
+ parse_constraints(constraints.join('|'), 'pypi')
290
+ end
291
+
292
+ # Maven range parsing
293
+ def parse_maven_range(range_string)
294
+ # Validate bracket notation first
295
+ if range_string.match(/^[\[\(].+[\]\)]$/)
296
+ # Check for malformed single version ranges
297
+ if range_string.match(/^\([^,]+\]$/) || range_string.match(/^\[[^,]+\)$/)
298
+ raise ArgumentError, "Malformed Maven range: mismatched brackets in '#{range_string}'"
299
+ end
300
+ end
301
+
302
+ case range_string
303
+ when /^\[([^,]+),([^,]+)\]$/
304
+ # [1.0,2.0] := >=1.0 <=2.0
305
+ min_version = Regexp.last_match(1).strip
306
+ max_version = Regexp.last_match(2).strip
307
+ VersionRange.new([
308
+ Interval.new(min: min_version, max: max_version, min_inclusive: true, max_inclusive: true)
309
+ ])
310
+ when /^\(([^,]+),([^,]+)\)$/
311
+ # (1.0,2.0) := >1.0 <2.0
312
+ min_version = Regexp.last_match(1).strip
313
+ max_version = Regexp.last_match(2).strip
314
+ VersionRange.new([
315
+ Interval.new(min: min_version, max: max_version, min_inclusive: false, max_inclusive: false)
316
+ ])
317
+ when /^\[([^,]+),([^,]+)\)$/
318
+ # [1.0,2.0) := >=1.0 <2.0
319
+ min_version = Regexp.last_match(1).strip
320
+ max_version = Regexp.last_match(2).strip
321
+ VersionRange.new([
322
+ Interval.new(min: min_version, max: max_version, min_inclusive: true, max_inclusive: false)
323
+ ])
324
+ when /^\(([^,]+),([^,]+)\]$/
325
+ # (1.0,2.0] := >1.0 <=2.0
326
+ min_version = Regexp.last_match(1).strip
327
+ max_version = Regexp.last_match(2).strip
328
+ VersionRange.new([
329
+ Interval.new(min: min_version, max: max_version, min_inclusive: false, max_inclusive: true)
330
+ ])
331
+ when /^\[([^,]+)\]$/
332
+ # [1.0] := exactly 1.0
333
+ version = Regexp.last_match(1).strip
334
+ VersionRange.exact(version)
335
+ when /^\[([^,]+),\)$/
336
+ # [1.0,) := >=1.0
337
+ min_version = Regexp.last_match(1).strip
338
+ VersionRange.new([
339
+ Interval.new(min: min_version, min_inclusive: true)
340
+ ])
341
+ when /^\(([^,]+),\)$/
342
+ # (1.0,) := >1.0
343
+ min_version = Regexp.last_match(1).strip
344
+ VersionRange.new([
345
+ Interval.new(min: min_version, min_inclusive: false)
346
+ ])
347
+ when /^\(,([^,]+)\]$/
348
+ # (,1.0] := <=1.0
349
+ max_version = Regexp.last_match(1).strip
350
+ VersionRange.new([
351
+ Interval.new(max: max_version, max_inclusive: true)
352
+ ])
353
+ when /^\(,([^,]+)\)$/
354
+ # (,1.0) := <1.0
355
+ max_version = Regexp.last_match(1).strip
356
+ VersionRange.new([
357
+ Interval.new(max: max_version, max_inclusive: false)
358
+ ])
359
+ when /^[0-9]/
360
+ # Simple version number without brackets - in Maven, this is minimum version
361
+ if range_string.match(/^[0-9]+(\.[0-9]+)*(-[a-zA-Z0-9.-]+)?$/)
362
+ VersionRange.new([
363
+ Interval.new(min: range_string, min_inclusive: true)
364
+ ])
365
+ else
366
+ parse_constraints(range_string, 'maven')
367
+ end
368
+ when /^(.+),(.+)$/
369
+ # Handle union ranges like "(,1.0],[1.2,)"
370
+ parts = range_string.split(',')
371
+ if parts.length > 2
372
+ # Complex union - parse each part recursively
373
+ ranges = []
374
+ # Split and preserve bracket information
375
+ # Find all individual ranges by splitting on comma between brackets
376
+ individual_ranges = []
377
+ remaining = range_string.strip
378
+
379
+ while remaining.length > 0
380
+ # Find the next complete bracket range
381
+ if match = remaining.match(/^[\[\(][^\[\]\(\)]*[\]\)]/)
382
+ individual_ranges << match[0].strip
383
+ remaining = remaining[match.end(0)..-1].strip
384
+ # Skip over comma and whitespace
385
+ remaining = remaining.sub(/^\s*,\s*/, '')
386
+ else
387
+ break
388
+ end
389
+ end
390
+
391
+ if individual_ranges.length > 1
392
+ individual_ranges.each do |range_part|
393
+ begin
394
+ parsed_range = parse_maven_range(range_part)
395
+ ranges << parsed_range
396
+ rescue
397
+ # If parsing fails, skip this part
398
+ end
399
+ end
400
+
401
+ if ranges.any?
402
+ return ranges.reduce { |acc, range| acc.union(range) }
403
+ end
404
+ end
405
+ end
406
+
407
+ # Fall back to standard constraint parsing
408
+ parse_constraints(range_string, 'maven')
409
+ else
410
+ # Fall back to standard constraint parsing
411
+ parse_constraints(range_string, 'maven')
412
+ end
413
+ end
414
+
415
+ # NuGet range parsing (similar to Maven but with some differences)
416
+ def parse_nuget_range(range_string)
417
+ # NuGet uses the same bracket notation as Maven
418
+ # But simple version strings like "1.0" are minimum versions, not exact
419
+ case range_string
420
+ when /^[\[\(].+[\]\)]$/
421
+ # Use Maven parsing for bracket notation
422
+ parse_maven_range(range_string)
423
+ when /^[0-9]/
424
+ # Simple version number - treat as minimum version for NuGet
425
+ VersionRange.new([
426
+ Interval.new(min: range_string, min_inclusive: true)
427
+ ])
428
+ else
429
+ # Fall back to standard constraint parsing
430
+ parse_constraints(range_string, 'nuget')
431
+ end
432
+ end
433
+
434
+ # Debian range parsing
435
+ def parse_debian_range(range_string)
436
+ # Debian uses operators like >=, <=, =, >>, <<
437
+ range_string = range_string.gsub('>>', '>').gsub('<<', '<')
438
+ parse_constraints(range_string, 'deb')
439
+ end
440
+
441
+ # RPM range parsing
442
+ def parse_rpm_range(range_string)
443
+ # RPM uses similar operators to Debian
444
+ parse_constraints(range_string, 'rpm')
445
+ end
446
+ end
447
+ end