vers 1.3.1 → 2.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.
data/lib/vers/parser.rb CHANGED
@@ -17,21 +17,21 @@ module Vers
17
17
  # range.contains?("1.5.0") # => true
18
18
  #
19
19
  class Parser
20
- # Regex for parsing vers URI format
21
- VERS_URI_REGEX = /\Avers:([^\/]+)\/(.+)\z/
22
-
23
- # Pre-compiled regex patterns for common npm patterns
24
- NPM_CARET_REGEX = /\A\^(.+)\z/
25
- NPM_TILDE_REGEX = /\A~(.+)\z/
26
- NPM_HYPHEN_REGEX = /\A(.+?)\s+-\s+(.+)\z/
27
- NPM_X_RANGE_MAJOR_REGEX = /\A(\d+)\.x\z/
28
- NPM_X_RANGE_MINOR_REGEX = /\A(\d+)\.(\d+)\.x\z/
20
+ NGINX_RANGE_REGEX = /\A\d+(?:\.\d+)+-\d+(?:\.\d+)+\z/
29
21
  OPERATOR_PREFIX_REGEX = /\A[><=!]+/
30
-
31
- # Cache for parsed ranges to improve performance
32
- @@parser_cache = {}
33
- @@cache_size_limit = 500
34
-
22
+ PUB_VERSION_PREFIX_REGEX = /\A[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?/
23
+ SEMVER_OUTPUT_SCHEMES = %w[npm cargo nuget composer pub].freeze
24
+ VERS_META_ENCODINGS = {
25
+ "%" => "%25",
26
+ "|" => "%7C",
27
+ ">" => "%3E",
28
+ "<" => "%3C",
29
+ "=" => "%3D",
30
+ "!" => "%21",
31
+ "/" => "%2F",
32
+ "*" => "%2A",
33
+ " " => "%20"
34
+ }.freeze
35
35
  # Maximum accepted length for a range string at parse/parse_native
36
36
  # entry points. Range strings concatenate multiple constraints so this
37
37
  # is set higher than Version::MAX_LENGTH while still bounding
@@ -56,23 +56,39 @@ module Vers
56
56
  #
57
57
  # parser = Vers::Parser.new
58
58
  # parser.parse("vers:npm/>=1.2.3|<2.0.0")
59
- # parser.parse("vers:gem/~>1.0")
60
- # parser.parse("vers:pypi/==1.2.3")
59
+ # parser.parse_native("~>1.0", "gem")
60
+ # parser.parse_native("==1.2.3", "pypi")
61
61
  #
62
- def parse(vers_string)
62
+ def parse(vers_string, require_canonical_order: false)
63
63
  validate_input_length!(vers_string)
64
64
 
65
- if vers_string == "*"
66
- return VersionRange.unbounded
65
+ return VersionRange.unbounded if vers_string == "*"
66
+ unless vers_string.is_a?(String) && vers_string.start_with?("vers:")
67
+ raise ArgumentError, "Invalid vers URI format: #{vers_string}"
68
+ end
69
+ if vers_string.match?(/[ \t\r\n]/)
70
+ raise ArgumentError, "non-canonical VERS: whitespace is not permitted"
67
71
  end
68
72
 
69
- match = vers_string.match(VERS_URI_REGEX)
70
- raise ArgumentError, "Invalid vers URI format: #{vers_string}" unless match
73
+ remainder = vers_string.delete_prefix("vers:")
74
+ slash = remainder.index("/")
75
+ unless slash && slash.positive?
76
+ raise ArgumentError, "Invalid vers URI format: #{vers_string}"
77
+ end
71
78
 
72
- scheme = match[1]
73
- constraints_string = match[2]
79
+ raw_scheme = remainder[...slash]
80
+ if raw_scheme != raw_scheme.downcase
81
+ raise ArgumentError, "non-canonical VERS: type must be lowercase"
82
+ end
83
+ scheme = Scheme.canonical(raw_scheme)
84
+ constraints_string = remainder[(slash + 1)..]
85
+ if constraints_string.empty? || constraints_string == "*"
86
+ return VersionRange.unbounded(scheme: scheme)
87
+ end
74
88
 
75
- parse_constraints(constraints_string, scheme)
89
+ validate_vers_constraints!(constraints_string, scheme, require_canonical_order)
90
+
91
+ parse_constraints(constraints_string, scheme, decode_versions: true)
76
92
  end
77
93
 
78
94
  ##
@@ -92,31 +108,43 @@ module Vers
92
108
  def parse_native(range_string, scheme)
93
109
  validate_input_length!(range_string)
94
110
 
95
- case scheme
111
+ canonical_scheme = Scheme.canonical(scheme)
112
+ range = case canonical_scheme
96
113
  when "npm"
97
- parse_npm_range(range_string)
98
- when "gem", "rubygems"
114
+ parse_npm_range(range_string, scheme: "npm")
115
+ when "gem"
99
116
  parse_gem_range(range_string)
100
117
  when "pypi"
101
118
  parse_pypi_range(range_string)
119
+ when "composer"
120
+ parse_composer_range(range_string)
121
+ when "pub"
122
+ parse_pub_range(range_string)
123
+ when "conan"
124
+ parse_conan_range(range_string)
125
+ when "nginx"
126
+ parse_nginx_range(range_string)
127
+ when "openssl"
128
+ parse_openssl_range(range_string)
102
129
  when "maven"
103
130
  parse_maven_range(range_string)
104
131
  when "cargo"
105
- parse_npm_range(range_string)
132
+ parse_npm_range(range_string, scheme: "cargo")
106
133
  when "nuget"
107
134
  parse_nuget_range(range_string)
108
- when "hex", "elixir"
135
+ when "hex"
109
136
  parse_hex_range(range_string)
110
- when "go", "golang"
137
+ when "go"
111
138
  parse_go_range(range_string)
112
- when "deb", "debian"
139
+ when "deb"
113
140
  parse_debian_range(range_string)
114
141
  when "rpm"
115
142
  parse_rpm_range(range_string)
116
143
  else
117
144
  # Fall back to generic constraint parsing
118
- parse_constraints(range_string, scheme)
145
+ parse_constraints(range_string, canonical_scheme)
119
146
  end
147
+ range.with_scheme(canonical_scheme)
120
148
  end
121
149
 
122
150
  ##
@@ -127,41 +155,61 @@ module Vers
127
155
  # @return [String] The vers URI string
128
156
  #
129
157
  def to_vers_string(version_range, scheme)
130
- return "vers:#{scheme}/*" if version_range.unbounded?
131
- return "vers:#{scheme}/" if version_range.empty?
158
+ canonical_scheme = Scheme.canonical(scheme)
159
+ if version_range.scheme && canonical_scheme != version_range.scheme
160
+ raise ArgumentError, "Cannot serialize a #{version_range.scheme} range as #{canonical_scheme}"
161
+ end
132
162
 
133
- intervals = version_range.raw_constraints || version_range.intervals
163
+ scheme = canonical_scheme
164
+ if version_range.unbounded? && (!version_range.raw_constraints || version_range.raw_constraints.empty?)
165
+ return "vers:#{scheme}/*"
166
+ end
167
+ if version_range.empty? && (!version_range.raw_constraints || version_range.raw_constraints.empty?)
168
+ return "vers:#{scheme}/"
169
+ end
170
+
171
+ intervals = serialization_intervals(version_range, scheme)
134
172
  constraints = []
135
173
 
136
174
  # Detect != pattern: two intervals (-∞,V) ∪ (V,+∞)
137
175
  if intervals.length == 2
138
176
  a, b = intervals
139
177
  if a.min.nil? && !a.max_inclusive && b.max.nil? && !b.min_inclusive && a.max == b.min
140
- constraints << "!=#{a.max}"
141
- constraints.sort_by! { |c| sort_key_for_constraint(c) }
178
+ version = encode_vers_version(normalize_vers_version(a.max, scheme))
179
+ constraints << "!=#{version}"
180
+ sort_constraints!(constraints, scheme)
142
181
  return "vers:#{scheme}/#{constraints.join('|')}"
143
182
  end
144
183
  end
145
184
 
146
185
  intervals.each do |interval|
186
+ next if interval.unbounded?
187
+
147
188
  if interval.min == interval.max && interval.min_inclusive && interval.max_inclusive
148
189
  # Exact version
149
- constraints << interval.min.to_s
190
+ constraints << encode_vers_version(normalize_vers_version(interval.min.to_s, scheme))
150
191
  else
151
192
  # Range constraints
152
193
  if interval.min
153
194
  operator = interval.min_inclusive ? ">=" : ">"
154
- constraints << "#{operator}#{interval.min}"
195
+ version = encode_vers_version(normalize_vers_version(interval.min, scheme))
196
+ constraints << "#{operator}#{version}"
155
197
  end
156
198
 
157
199
  if interval.max
158
200
  operator = interval.max_inclusive ? "<=" : "<"
159
- constraints << "#{operator}#{interval.max}"
201
+ version = encode_vers_version(normalize_vers_version(interval.max, scheme))
202
+ constraints << "#{operator}#{version}"
160
203
  end
161
204
  end
162
205
  end
163
206
 
164
- constraints.sort_by! { |c| sort_key_for_constraint(c) }
207
+ version_range.exclusions.each do |version|
208
+ normalized = normalize_vers_version(version, scheme)
209
+ constraints << "!=#{encode_vers_version(normalized)}"
210
+ end
211
+
212
+ sort_constraints!(constraints, scheme)
165
213
 
166
214
  "vers:#{scheme}/#{constraints.join('|')}"
167
215
  end
@@ -174,13 +222,152 @@ module Vers
174
222
  raise ArgumentError, "Range string too long (#{input.length} > #{MAX_INPUT_LENGTH})"
175
223
  end
176
224
 
177
- def sort_key_for_constraint(constraint)
178
- version = constraint.sub(OPERATOR_PREFIX_REGEX, '')
179
- v = Version.cached_new(version)
180
- [v, constraint]
225
+ def validate_vers_constraints!(constraints, scheme, require_canonical_order)
226
+ if constraints.start_with?("|")
227
+ raise ArgumentError, "non-canonical VERS: leading pipe is not permitted"
228
+ end
229
+ if constraints.end_with?("|")
230
+ raise ArgumentError, "non-canonical VERS: trailing pipe is not permitted"
231
+ end
232
+ if constraints.include?("||")
233
+ raise ArgumentError, "non-canonical VERS: consecutive pipes are not permitted"
234
+ end
235
+
236
+ previous = nil
237
+ previous_raw = nil
238
+ seen_versions = []
239
+ constraints.split("|").each do |raw|
240
+ constraint = Constraint.parse(raw)
241
+ validate_vers_version!(constraint.version, scheme)
242
+ decoded_version = decode_vers_version(constraint.version)
243
+
244
+ if require_canonical_order
245
+ if seen_versions.any? { |version| Version.compare_with_scheme(version, decoded_version, scheme).zero? }
246
+ raise ArgumentError, "non-canonical VERS: duplicate versions are not permitted"
247
+ end
248
+ if previous
249
+ order = Version.compare_with_scheme(previous, decoded_version, scheme)
250
+ if order.positive? || (order.zero? && previous_raw > raw)
251
+ raise ArgumentError, "non-canonical VERS: constraints are not sorted by version"
252
+ end
253
+ end
254
+ seen_versions << decoded_version
255
+ end
256
+
257
+ previous = decoded_version
258
+ previous_raw = raw
259
+ end
260
+ end
261
+
262
+ def validate_vers_version!(version, scheme)
263
+ index = 0
264
+ while index < version.bytesize
265
+ unless version.getbyte(index) == 37
266
+ index += 1
267
+ next
268
+ end
269
+
270
+ first = version.getbyte(index + 1)
271
+ second = version.getbyte(index + 2)
272
+ unless ascii_hex?(first) && ascii_hex?(second)
273
+ raise ArgumentError, "non-canonical VERS: invalid percent-encoding in version"
274
+ end
275
+ if lowercase_ascii_hex?(first) || lowercase_ascii_hex?(second)
276
+ raise ArgumentError, "non-canonical VERS: percent-encoding in version is not canonical"
277
+ end
278
+
279
+ index += 3
280
+ end
281
+
282
+ if version.match?(/[><=!*\/]/)
283
+ raise ArgumentError, "non-canonical VERS: reserved characters in version must be percent-encoded"
284
+ end
285
+
286
+ return unless scheme == "datetime"
287
+ if version.include?("%3A")
288
+ raise ArgumentError, "non-canonical VERS: datetime time colons must be unencoded"
289
+ end
290
+
291
+ decoded = decode_vers_version(version)
292
+ if (decoded.length > 10 && decoded[10] == "t") || decoded.end_with?("z")
293
+ raise ArgumentError, "non-canonical VERS: datetime must use uppercase T and Z"
294
+ end
295
+ end
296
+
297
+ def ascii_hex?(byte)
298
+ byte && ((48..57).cover?(byte) || (65..70).cover?(byte) || lowercase_ascii_hex?(byte))
299
+ end
300
+
301
+ def lowercase_ascii_hex?(byte)
302
+ byte && (97..102).cover?(byte)
303
+ end
304
+
305
+ def sort_constraints!(constraints, scheme)
306
+ constraints.sort! do |left, right|
307
+ left_version = decode_vers_version(left.sub(OPERATOR_PREFIX_REGEX, ""))
308
+ right_version = decode_vers_version(right.sub(OPERATOR_PREFIX_REGEX, ""))
309
+ comparison = Version.compare_with_scheme(left_version, right_version, scheme)
310
+ comparison.zero? ? left <=> right : comparison
311
+ end
312
+ end
313
+
314
+ def serialization_intervals(version_range, scheme)
315
+ raw_constraints = version_range.raw_constraints
316
+ return version_range.intervals unless raw_constraints
317
+ return raw_constraints unless %w[npm cargo].include?(scheme)
318
+ return raw_constraints if version_range.empty?
319
+
320
+ grouped = intersect_consecutive_intervals(raw_constraints, scheme)
321
+ raw_range = VersionRange.new(grouped, scheme: scheme)
322
+ equivalent_intervals?(raw_range.intervals, version_range.intervals, scheme) ? raw_constraints : version_range.intervals
323
+ end
324
+
325
+ def equivalent_intervals?(left, right, scheme)
326
+ return false unless left.length == right.length
327
+
328
+ left.zip(right).all? do |left_interval, right_interval|
329
+ left_interval.min_inclusive == right_interval.min_inclusive &&
330
+ left_interval.max_inclusive == right_interval.max_inclusive &&
331
+ equivalent_bound?(left_interval.min, right_interval.min, scheme) &&
332
+ equivalent_bound?(left_interval.max, right_interval.max, scheme)
333
+ end
334
+ end
335
+
336
+ def equivalent_bound?(left, right, scheme)
337
+ return true if left.nil? && right.nil?
338
+ return false if left.nil? || right.nil?
339
+
340
+ Version.compare_for_range(left, right, scheme).zero?
181
341
  end
182
342
 
183
- def parse_constraints(constraints_string, scheme)
343
+ def normalize_vers_version(version, scheme)
344
+ canonical_scheme = Scheme.canonical(scheme)
345
+ if %w[npm cargo].include?(canonical_scheme) && SemverVersion.valid?(version)
346
+ return SemverVersion.normalize(version)
347
+ end
348
+
349
+ return version if version.include?("-")
350
+ return version unless SEMVER_OUTPUT_SCHEMES.include?(canonical_scheme)
351
+ return version unless SemverVersion.valid?(version)
352
+
353
+ case version.count(".")
354
+ when 0
355
+ "#{version}.0.0"
356
+ when 1
357
+ "#{version}.0"
358
+ else
359
+ version
360
+ end
361
+ end
362
+
363
+ def encode_vers_version(version)
364
+ version.gsub(/[\%|><=!\/* ]/, VERS_META_ENCODINGS)
365
+ end
366
+
367
+ def parse_constraints(constraints_string, scheme, decode_versions: false)
368
+ canonical_scheme = Scheme.canonical(scheme)
369
+ return VersionRange.unbounded(scheme: canonical_scheme) if constraints_string == "*"
370
+
184
371
  # Limit constraint count to bound the O(n^2 log n) exclusion loop
185
372
  # below: each != splits an interval and reconstructs the range.
186
373
  constraint_strings = constraints_string.split(/[|,]/, MAX_CONSTRAINTS + 1)
@@ -189,10 +376,13 @@ module Vers
189
376
  end
190
377
  intervals = []
191
378
  exclusions = []
192
- interval_scheme = %w[maven nuget].include?(scheme) ? scheme : nil
379
+ interval_scheme = canonical_scheme
193
380
 
194
381
  constraint_strings.each do |constraint_string|
195
382
  constraint = Constraint.parse(constraint_string.strip)
383
+ if decode_versions
384
+ constraint = Constraint.new(constraint.operator, decode_vers_version(constraint.version))
385
+ end
196
386
 
197
387
  if constraint.exclusion?
198
388
  exclusions << constraint.version
@@ -202,189 +392,622 @@ module Vers
202
392
  end
203
393
  end
204
394
 
395
+ grouped_intervals = intersect_consecutive_intervals(intervals, interval_scheme)
396
+
205
397
  # Start with the union of all positive constraints, or unbounded if only exclusions
206
- range = if intervals.any?
207
- VersionRange.new(intervals, scheme: interval_scheme)
398
+ range = if grouped_intervals.any?
399
+ VersionRange.new(
400
+ grouped_intervals,
401
+ raw_constraints: intervals,
402
+ scheme: interval_scheme,
403
+ exclusions: exclusions
404
+ )
208
405
  elsif exclusions.any?
209
- VersionRange.unbounded
406
+ VersionRange.new(
407
+ [Interval.unbounded(scheme: interval_scheme)],
408
+ raw_constraints: [],
409
+ scheme: interval_scheme,
410
+ exclusions: exclusions
411
+ )
210
412
  else
211
413
  VersionRange.new([], scheme: interval_scheme)
212
414
  end
213
415
 
214
- # Apply exclusions
215
- exclusions.each do |version|
216
- range = range.exclude(version)
416
+ range
417
+ end
418
+
419
+ def intersect_consecutive_intervals(intervals, scheme)
420
+ grouped = []
421
+ index = 0
422
+
423
+ while index < intervals.length
424
+ current = intervals.fetch(index)
425
+ following = intervals[index + 1]
426
+ opposite_bounds = following &&
427
+ ((current.min && !current.max && following.max && !following.min) ||
428
+ (current.max && !current.min && following.min && !following.max))
429
+
430
+ if opposite_bounds
431
+ intersection = current.intersect(following)
432
+ unless intersection.empty?
433
+ grouped << intersection
434
+ index += 2
435
+ next
436
+ end
437
+ end
438
+
439
+ grouped << current.with_scheme(scheme)
440
+ index += 1
217
441
  end
218
442
 
219
- range
443
+ grouped
444
+ end
445
+
446
+ def decode_vers_version(version)
447
+ version.gsub(/%([0-9A-Fa-f]{2})/) { [$1.to_i(16)].pack("C") }
448
+ end
449
+
450
+ def parse_composer_range(range_string)
451
+ constraint = range_string.to_s.strip
452
+ return VersionRange.unbounded(scheme: "composer") if %w[* x X @dev].include?(constraint)
453
+
454
+ parts = constraint.split(/\s*\|\|?\s*/)
455
+ raise ArgumentError, "Invalid Composer range: #{range_string}" if parts.any?(&:empty?)
456
+
457
+ ranges = parts.map { |part| parse_composer_conjunction(part) }
458
+ ranges.reduce { |combined, range| combined.union(range) }
459
+ end
460
+
461
+ def parse_composer_conjunction(constraint)
462
+ value = constraint.strip.sub(/\s+as\s+.+\z/i, "")
463
+ if (match = /\A(\S+)\s+-\s+(\S+)\z/.match(value))
464
+ return parse_composer_hyphen_range(match[1], match[2])
465
+ end
466
+
467
+ parts = value.tr(",", " ").split
468
+ ranges = parts.map { |part| parse_composer_constraint(part) }
469
+ ranges.reduce { |combined, range| combined.intersect(range) }
470
+ end
471
+
472
+ def parse_composer_constraint(constraint)
473
+ return VersionRange.unbounded(scheme: "composer") if constraint == "@dev"
474
+
475
+ value, stability = constraint.split("@", 2)
476
+ return VersionRange.unbounded(scheme: "composer") if value.empty?
477
+ return parse_composer_wildcard(value) if composer_wildcard?(value)
478
+ return parse_composer_caret(value.delete_prefix("^")) if value.start_with?("^")
479
+ return parse_composer_tilde(value.delete_prefix("~")) if value.start_with?("~")
480
+ return composer_exact_range(value) if ComposerVersion.branch?(value)
481
+
482
+ operator = value[/\A(?:==|<>|!=|>=|<=|>|<|=)/] || "="
483
+ version = value.delete_prefix(operator)
484
+ operator = "=" if operator == "=="
485
+ operator = "!=" if operator == "<>"
486
+ implicit = operator == "=" || operator == "!=" ? nil : "dev"
487
+ normalized = ComposerVersion.normalize(version, implicit_stability: stability || implicit)
488
+
489
+ return composer_exclusion_range(normalized) if operator == "!="
490
+ return composer_exact_range(normalized) if operator == "="
491
+
492
+ composer_interval_range(Constraint.new(operator, normalized).to_interval(scheme: "composer"))
493
+ end
494
+
495
+ def parse_composer_caret(version)
496
+ parts = ComposerVersion.release_parts(version)
497
+ raise ArgumentError, "Invalid Composer caret version: #{version}" unless parts
498
+
499
+ bump = 0
500
+ if VersionComparison.compare_numbers(parts[0], "0").zero? && parts.length > 1
501
+ bump = 1
502
+ if VersionComparison.compare_numbers(parts[1], "0").zero? && parts.length > 2
503
+ bump = 2
504
+ end
505
+ end
506
+
507
+ lower = ComposerVersion.normalize(version, implicit_stability: "dev")
508
+ upper = increment_composer_release(parts, bump)
509
+ composer_interval_range(Interval.new(min: lower, max: upper, min_inclusive: true, max_inclusive: false, scheme: "composer"))
510
+ end
511
+
512
+ def parse_composer_tilde(version)
513
+ parts = ComposerVersion.release_parts(version)
514
+ raise ArgumentError, "Invalid Composer tilde version: #{version}" unless parts
515
+
516
+ bump = parts.length > 2 ? parts.length - 2 : 0
517
+ lower = ComposerVersion.normalize(version, implicit_stability: "dev")
518
+ upper = increment_composer_release(parts, bump)
519
+ composer_interval_range(Interval.new(min: lower, max: upper, min_inclusive: true, max_inclusive: false, scheme: "composer"))
520
+ end
521
+
522
+ def parse_composer_wildcard(value)
523
+ return VersionRange.unbounded(scheme: "composer") if %w[* x X].include?(value)
524
+
525
+ segments = value.sub(/\Av(?=\d)/i, "").split(".")
526
+ prefix = []
527
+ wildcard = false
528
+ segments.each do |segment|
529
+ if segment == "*" || segment.casecmp?("x")
530
+ wildcard = true
531
+ elsif wildcard || !VersionComparison.numeric?(segment)
532
+ raise ArgumentError, "Invalid Composer wildcard: #{value}"
533
+ else
534
+ prefix << segment
535
+ end
536
+ end
537
+ raise ArgumentError, "Invalid Composer wildcard: #{value}" unless wildcard
538
+ return VersionRange.unbounded(scheme: "composer") if prefix.empty?
539
+
540
+ lower_parts = prefix.map { |part| VersionComparison.normalize_number(part) }
541
+ lower_parts << "0" while lower_parts.length < 3
542
+ lower = "#{lower_parts.join(".")}-dev"
543
+ upper = increment_composer_release(prefix, prefix.length - 1)
544
+ composer_interval_range(Interval.new(min: lower, max: upper, min_inclusive: true, max_inclusive: false, scheme: "composer"))
545
+ end
546
+
547
+ def parse_composer_hyphen_range(lower, upper)
548
+ lower_parts = ComposerVersion.release_parts(lower)
549
+ upper_parts = ComposerVersion.release_parts(upper)
550
+ raise ArgumentError, "Invalid Composer hyphen range: #{lower} - #{upper}" unless lower_parts && upper_parts
551
+
552
+ minimum = ComposerVersion.normalize(lower, implicit_stability: "dev")
553
+ if upper_parts.length < 3 && !ComposerVersion.explicit_stability?(upper)
554
+ maximum = increment_composer_release(upper_parts, upper_parts.length - 1)
555
+ interval = Interval.new(min: minimum, max: maximum, min_inclusive: true, max_inclusive: false, scheme: "composer")
556
+ else
557
+ maximum = ComposerVersion.normalize(upper)
558
+ interval = Interval.new(min: minimum, max: maximum, min_inclusive: true, max_inclusive: true, scheme: "composer")
559
+ end
560
+ composer_interval_range(interval)
561
+ end
562
+
563
+ def increment_composer_release(parts, index)
564
+ length = [parts.length, 3].max
565
+ incremented = Array.new(length, "0")
566
+ parts.each_with_index { |part, part_index| incremented[part_index] = VersionComparison.normalize_number(part) }
567
+ incremented[index] = (incremented[index].to_i + 1).to_s
568
+ ((index + 1)...length).each { |part_index| incremented[part_index] = "0" }
569
+ "#{incremented.join(".")}-dev"
570
+ end
571
+
572
+ def composer_wildcard?(value)
573
+ value.split(".").any? { |part| part == "*" || part.casecmp?("x") }
574
+ end
575
+
576
+ def composer_interval_range(interval)
577
+ VersionRange.new([interval], raw_constraints: [interval], scheme: "composer")
578
+ end
579
+
580
+ def composer_exact_range(version)
581
+ interval = Interval.exact(ComposerVersion.branch?(version) ? version : ComposerVersion.normalize(version), scheme: "composer")
582
+ composer_interval_range(interval)
583
+ end
584
+
585
+ def composer_exclusion_range(version)
586
+ VersionRange.new(
587
+ [Interval.unbounded(scheme: "composer")],
588
+ raw_constraints: [],
589
+ scheme: "composer",
590
+ exclusions: [version]
591
+ )
592
+ end
593
+
594
+ def parse_pub_range(range_string)
595
+ constraint = range_string.to_s.strip
596
+ return VersionRange.unbounded(scheme: "pub") if constraint == "any"
597
+ raise ArgumentError, "Empty Pub range" if constraint.empty?
598
+ raise ArgumentError, "Unsupported Pub range: #{range_string}" if constraint.match?(/[,|*]/) || constraint.include?("!=")
599
+ return parse_pub_caret(constraint.delete_prefix("^")) if constraint.start_with?("^")
600
+
601
+ ranges = tokenize_pub_constraints(constraint).map { |part| parse_pub_constraint(part) }
602
+ adjust_pub_upper_bounds(ranges.reduce { |combined, range| combined.intersect(range) })
603
+ end
604
+
605
+ def tokenize_pub_constraints(constraint)
606
+ remaining = constraint.strip
607
+ tokens = []
608
+
609
+ until remaining.empty?
610
+ operator = remaining[/\A(?:>=|<=|>|<)/].to_s
611
+ remaining = remaining.delete_prefix(operator).lstrip
612
+ match = PUB_VERSION_PREFIX_REGEX.match(remaining)
613
+ raise ArgumentError, "Invalid Pub range: #{constraint}" unless match
614
+
615
+ tokens << "#{operator}#{match[0]}"
616
+ remaining = remaining[match[0].length..].to_s.strip
617
+ end
618
+
619
+ tokens
620
+ end
621
+
622
+ def parse_pub_constraint(constraint)
623
+ operator = constraint[/\A(?:>=|<=|>|<)/].to_s
624
+ version = constraint.delete_prefix(operator)
625
+ raise ArgumentError, "Invalid Pub version: #{version}" unless PubVersion.valid?(version)
626
+
627
+ interval = if operator.empty?
628
+ Interval.exact(version, scheme: "pub")
629
+ else
630
+ Constraint.new(operator, version).to_interval(scheme: "pub")
631
+ end
632
+ VersionRange.new([interval], raw_constraints: [interval], scheme: "pub")
633
+ end
634
+
635
+ def parse_pub_caret(version)
636
+ parsed = PubVersion.parse(version)
637
+ raise ArgumentError, "Invalid Pub caret version: #{version}" unless parsed
638
+
639
+ upper = if VersionComparison.compare_numbers(parsed.core[0], "0").zero?
640
+ "0.#{parsed.core[1].to_i + 1}.0-0"
641
+ else
642
+ "#{parsed.core[0].to_i + 1}.0.0-0"
643
+ end
644
+ interval = Interval.new(min: version, max: upper, min_inclusive: true, max_inclusive: false, scheme: "pub")
645
+ VersionRange.new([interval], raw_constraints: [interval], scheme: "pub")
646
+ end
647
+
648
+ def adjust_pub_upper_bounds(range)
649
+ replacements = {}
650
+ intervals = range.intervals.map do |interval|
651
+ next interval unless pub_upper_needs_first_prerelease?(interval)
652
+
653
+ replacements[interval.max] = "#{interval.max}-0"
654
+ Interval.new(
655
+ min: interval.min,
656
+ max: replacements.fetch(interval.max),
657
+ min_inclusive: interval.min_inclusive,
658
+ max_inclusive: interval.max_inclusive,
659
+ scheme: "pub"
660
+ )
661
+ end
662
+ raw_constraints = (range.raw_constraints || range.intervals).map do |interval|
663
+ replacement = !interval.max_inclusive && replacements[interval.max]
664
+ next interval unless replacement
665
+
666
+ Interval.new(
667
+ min: interval.min,
668
+ max: replacement,
669
+ min_inclusive: interval.min_inclusive,
670
+ max_inclusive: interval.max_inclusive,
671
+ scheme: "pub"
672
+ )
673
+ end
674
+ VersionRange.new(intervals, raw_constraints: raw_constraints, scheme: "pub", exclusions: range.exclusions)
675
+ end
676
+
677
+ def pub_upper_needs_first_prerelease?(interval)
678
+ return false unless interval.max && !interval.max_inclusive && PubVersion.valid?(interval.max)
679
+
680
+ maximum = PubVersion.parse(interval.max)
681
+ return false unless maximum.prerelease.empty? && maximum.build.empty?
682
+ return true unless interval.min && PubVersion.valid?(interval.min)
683
+
684
+ minimum = PubVersion.parse(interval.min)
685
+ return true if minimum.prerelease.empty?
686
+
687
+ !minimum.core.zip(maximum.core).all? do |minimum_part, maximum_part|
688
+ VersionComparison.compare_numbers(minimum_part, maximum_part).zero?
689
+ end
690
+ end
691
+
692
+ def parse_conan_range(range_string)
693
+ constraint = range_string.to_s.strip.split(",", 2).first.to_s.strip
694
+ return VersionRange.empty(scheme: "conan") if constraint.empty?
695
+
696
+ if %w[* *-].include?(constraint)
697
+ interval = Interval.greater_than("0.0.0", inclusive: true, scheme: "conan")
698
+ return VersionRange.new([interval], raw_constraints: [interval], scheme: "conan")
699
+ end
700
+
701
+ if constraint.include?("||")
702
+ parts = constraint.split("||").map(&:strip)
703
+ raise ArgumentError, "Invalid Conan range: #{range_string}" if parts.any?(&:empty?)
704
+
705
+ return parts.map { |part| parse_conan_range(part) }.reduce { |combined, range| combined.union(range) }
706
+ end
707
+
708
+ if constraint.match?(/\s/)
709
+ parts = constraint.split
710
+ return parts.map { |part| parse_conan_range(part) }.reduce { |combined, range| combined.intersect(range) }
711
+ end
712
+
713
+ constraint = constraint.delete_suffix("-")
714
+ if constraint.start_with?("~", "^")
715
+ operator = constraint[0]
716
+ version = constraint[1..]
717
+ upper = conan_upper_bound(version, operator)
718
+ interval = Interval.new(
719
+ min: version,
720
+ max: upper,
721
+ min_inclusive: true,
722
+ max_inclusive: false,
723
+ scheme: "conan"
724
+ )
725
+ return VersionRange.new([interval], raw_constraints: [interval], scheme: "conan")
726
+ end
727
+
728
+ parse_constraints(constraint, "conan")
729
+ end
730
+
731
+ def conan_upper_bound(version, operator)
732
+ parts = version.split(".")
733
+ unless parts.any? && parts.all? { |part| VersionComparison.numeric?(part) }
734
+ raise ArgumentError, "Invalid Conan compatible version: #{version}"
735
+ end
736
+
737
+ index = 0
738
+ if operator == "~" && parts.length > 1
739
+ index = 1
740
+ elsif operator == "^"
741
+ index += 1 while index < parts.length - 1 && VersionComparison.compare_numbers(parts[index], "0").zero?
742
+ end
743
+
744
+ upper = parts.first(index + 1).map { |part| VersionComparison.normalize_number(part) }
745
+ upper[index] = (upper[index].to_i + 1).to_s
746
+ "#{upper.join(".")}-"
747
+ end
748
+
749
+ def parse_openssl_range(range_string)
750
+ parts = range_string.to_s.split(",", -1).map(&:strip)
751
+ intervals = parts.map do |version|
752
+ unless OpenSSLVersion.valid?(version)
753
+ raise ArgumentError, "Invalid OpenSSL version: #{version}"
754
+ end
755
+
756
+ Interval.exact(version, scheme: "openssl")
757
+ end
758
+ VersionRange.new(intervals, raw_constraints: intervals, scheme: "openssl")
759
+ end
760
+
761
+ def parse_nginx_range(range_string)
762
+ constraint = range_string.to_s.strip
763
+ if constraint.include?(",")
764
+ parts = constraint.split(",", -1).map(&:strip)
765
+ raise ArgumentError, "Invalid Nginx range: #{range_string}" if parts.any?(&:empty?)
766
+
767
+ return parts.map { |part| parse_nginx_range(part) }.reduce { |combined, range| combined.union(range) }
768
+ end
769
+
770
+ if constraint.end_with?("+")
771
+ version = constraint.delete_suffix("+")
772
+ unless SemverVersion.valid?(version)
773
+ raise ArgumentError, "Invalid Nginx range: #{range_string}"
774
+ end
775
+
776
+ parts = version.split(".")
777
+ maximum = if parts.fetch(1).to_i.even?
778
+ "#{parts.fetch(0)}.#{parts.fetch(1).to_i + 1}.0"
779
+ end
780
+ interval = Interval.new(
781
+ min: version,
782
+ max: maximum,
783
+ min_inclusive: true,
784
+ max_inclusive: false,
785
+ scheme: "nginx"
786
+ )
787
+ return VersionRange.new([interval], raw_constraints: [interval], scheme: "nginx")
788
+ end
789
+
790
+ if NGINX_RANGE_REGEX.match?(constraint)
791
+ minimum, maximum = constraint.split("-", 2)
792
+ unless SemverVersion.valid?(minimum) && SemverVersion.valid?(maximum)
793
+ raise ArgumentError, "Invalid Nginx range: #{range_string}"
794
+ end
795
+
796
+ interval = Interval.new(
797
+ min: minimum,
798
+ max: maximum,
799
+ min_inclusive: true,
800
+ max_inclusive: true,
801
+ scheme: "nginx"
802
+ )
803
+ return VersionRange.new([interval], raw_constraints: [interval], scheme: "nginx")
804
+ end
805
+
806
+ parse_constraints(constraint, "nginx")
220
807
  end
221
808
 
222
809
  # NPM range parsing (^, ~, -, ||, etc.)
223
- def parse_npm_range(range_string)
224
- # Handle empty string as unbounded
225
- if range_string.nil? || range_string.strip.empty?
226
- return VersionRange.unbounded
227
- end
228
-
229
- # Handle || (OR) operator
230
- if range_string.include?('||')
231
- or_parts = range_string.split('||', MAX_CONSTRAINTS + 1).map(&:strip)
810
+ def parse_npm_range(range_string, scheme: "npm")
811
+ constraint = range_string.to_s.strip
812
+ return VersionRange.unbounded(scheme: scheme) if constraint.empty? || %w[* x X].include?(constraint)
813
+
814
+ if constraint.include?("||")
815
+ or_parts = constraint.split("||", MAX_CONSTRAINTS + 1).map(&:strip)
232
816
  if or_parts.length > MAX_CONSTRAINTS
233
817
  raise ArgumentError, "Too many || clauses (> #{MAX_CONSTRAINTS})"
234
818
  end
235
- ranges = or_parts.map { |part| parse_npm_range(part) }
236
- return ranges.reduce { |acc, range| acc.union(range) }
819
+ ranges = or_parts.map { |part| parse_npm_range(part, scheme: scheme) }
820
+ return ranges.reduce { |combined, range| combined.union(range) }
237
821
  end
238
822
 
239
- # Handle hyphen ranges first (before space splitting)
240
- if range_string.match(/^(.+?)\s+-\s+(.+)$/)
241
- return parse_npm_single_range(range_string)
823
+ if constraint.include?(" - ")
824
+ lower, upper = constraint.split(" - ", 2).map(&:strip)
825
+ return parse_npm_hyphen_range(lower, upper, scheme: scheme)
242
826
  end
243
827
 
244
- # Handle space-separated AND constraints
245
- and_parts = range_string.split(/\s+/).reject(&:empty?)
246
- # Re-join bare operators with their version
247
- merged = []
248
- and_parts.each do |part|
249
- if merged.last&.match?(/\A(>=|<=|!=|[<>=~^])\z/)
250
- merged[-1] = "#{merged.last}#{part}"
251
- else
252
- merged << part
828
+ if constraint.match?(/[ \t\r\n]/)
829
+ tokens = constraint.split
830
+ merged = []
831
+ tokens.each do |token|
832
+ if merged.last&.match?(/\A(?:>=|<=|!=|>|<|=|~>|~|\^)\z/)
833
+ merged[-1] = "#{merged.last}#{token}"
834
+ else
835
+ merged << token
836
+ end
253
837
  end
838
+ ranges = merged.map { |part| parse_npm_single_range(part, scheme: scheme) }
839
+ return ranges.reduce { |combined, range| combined.intersect(range) }
840
+ end
841
+
842
+ parse_npm_single_range(constraint, scheme: scheme)
843
+ end
844
+
845
+ def parse_npm_single_range(range_string, scheme: "npm")
846
+ constraint = range_string.to_s.strip
847
+ return parse_caret_range(constraint.delete_prefix("^").strip, scheme: scheme) if constraint.start_with?("^")
848
+ return parse_tilde_range(constraint.delete_prefix("~>").strip, scheme: scheme) if constraint.start_with?("~>")
849
+ return parse_tilde_range(constraint.delete_prefix("~").strip, scheme: scheme) if constraint.start_with?("~")
850
+
851
+ operator, version = extract_npm_operator(constraint)
852
+ if constraint.end_with?(".x", ".X", ".*") || partial_npm_version?(version)
853
+ return parse_npm_partial_range(version, operator, scheme: scheme)
854
+ end
855
+
856
+ parsed = Constraint.parse(constraint)
857
+ unless NpmVersion.valid?(parsed.version)
858
+ raise ArgumentError, "Invalid NPM range format: #{range_string}"
254
859
  end
255
- ranges = merged.map { |part| parse_npm_single_range(part) }
256
- # If all parts are bare versions (no operators), treat as union
257
- all_exact = merged.all? { |part| part.match?(/\A\d/) }
258
- if all_exact
259
- ranges.reduce { |acc, range| acc.union(range) }
860
+
861
+ if parsed.exclusion?
862
+ VersionRange.unbounded(scheme: scheme).exclude(parsed.version)
260
863
  else
261
- ranges.reduce { |acc, range| acc.intersect(range) }
262
- end
263
- end
264
-
265
- def parse_npm_single_range(range_string)
266
- cache_key = "npm:#{range_string}"
267
- return @@parser_cache[cache_key] if @@parser_cache.key?(cache_key)
268
-
269
- if @@parser_cache.size >= @@cache_size_limit
270
- keys = @@parser_cache.keys
271
- keys.first(keys.size / 2).each { |k| @@parser_cache.delete(k) }
272
- end
273
-
274
- result = case range_string
275
- when NPM_CARET_REGEX
276
- # Caret range: ^1.2.3 := >=1.2.3 <2.0.0
277
- version = $1
278
- parse_caret_range(version)
279
- when NPM_TILDE_REGEX
280
- # Tilde range: ~1.2.3 := >=1.2.3 <1.3.0
281
- version = $1
282
- parse_tilde_range(version)
283
- when NPM_HYPHEN_REGEX
284
- # Hyphen range: 1.2.3 - 2.3.4 := >=1.2.3 <=2.3.4
285
- from_version = $1.strip
286
- to_version = $2.strip
287
- VersionRange.new([
288
- Interval.new(min: from_version, max: to_version, min_inclusive: true, max_inclusive: true)
289
- ])
290
- when "*", "x", "X"
291
- VersionRange.unbounded
292
- when NPM_X_RANGE_MAJOR_REGEX
293
- # X-range like "1.x" := >=1.0.0 <2.0.0
294
- major = $1.to_i
295
- VersionRange.new([
296
- Interval.new(min: "#{major}.0.0", max: "#{major + 1}.0.0", min_inclusive: true, max_inclusive: false)
297
- ])
298
- when NPM_X_RANGE_MINOR_REGEX
299
- # X-range like "1.2.x" := >=1.2.0 <1.3.0
300
- major = $1.to_i
301
- minor = $2.to_i
302
- VersionRange.new([
303
- Interval.new(min: "#{major}.#{minor}.0", max: "#{major}.#{minor + 1}.0", min_inclusive: true, max_inclusive: false)
304
- ])
305
- when /^(blerg|git\+|https?:\/\/)/
306
- # Invalid patterns that should raise errors
307
- raise ArgumentError, "Invalid NPM range format: #{range_string}"
308
- else
309
- # Check for operator + x-range (e.g. ">=2.2.x", ">=1.x")
310
- if range_string.match(/\A[><=]+(\d+)\.[xX*]\z/)
311
- major = $1.to_i
312
- return VersionRange.new([
313
- Interval.new(min: "#{major}.0.0", max: "#{major + 1}.0.0", min_inclusive: true, max_inclusive: false)
314
- ])
315
- end
316
- if range_string.match(/\A[><=]+(\d+)\.(\d+)\.[xX*]\z/)
317
- major = $1.to_i
318
- minor = $2.to_i
319
- return VersionRange.new([
320
- Interval.new(min: "#{major}.#{minor}.0", max: "#{major}.#{minor + 1}.0", min_inclusive: true, max_inclusive: false)
321
- ])
322
- end
323
- # Standard constraint
324
- constraint = Constraint.parse(range_string)
325
- # Normalize version to semver (npm always uses 3 segments)
326
- normalized_version = Version.cached_new(constraint.version).to_s
327
- constraint = Constraint.new(constraint.operator, normalized_version)
328
- if constraint.exclusion?
329
- VersionRange.unbounded.exclude(constraint.version)
864
+ interval = parsed.to_interval(scheme: scheme)
865
+ VersionRange.new([interval], scheme: scheme)
866
+ end
867
+ end
868
+
869
+ def parse_caret_range(version, scheme: "npm")
870
+ return VersionRange.unbounded(scheme: scheme) if version.empty? || %w[* x X].include?(version)
871
+ return parse_npm_partial_range(version, "", scheme: scheme) if version.end_with?(".x", ".X", ".*")
872
+
873
+ parsed = SemverVersion.parse(version)
874
+ raise ArgumentError, "Invalid NPM caret version: #{version}" unless parsed && NpmVersion.valid?(version)
875
+
876
+ base = version.split("+", 2).first.split("-", 2).first.delete_prefix("v").delete_prefix("V")
877
+ segments = base.count(".") + 1
878
+ major, minor, patch = parsed.core.map(&:to_i)
879
+ upper = if segments == 1 || major.positive?
880
+ "#{major + 1}.0.0"
881
+ elsif segments == 2 || minor.positive?
882
+ "0.#{minor + 1}.0"
883
+ else
884
+ "0.0.#{patch + 1}"
885
+ end
886
+ interval = Interval.new(min: version, max: upper, min_inclusive: true, max_inclusive: false, scheme: scheme)
887
+ VersionRange.new([interval], scheme: scheme)
888
+ end
889
+
890
+ def parse_tilde_range(version, scheme: "npm")
891
+ return VersionRange.unbounded(scheme: scheme) if version.empty? || %w[* x X].include?(version)
892
+ return parse_npm_partial_range(version, "", scheme: scheme) if version.end_with?(".x", ".X", ".*")
893
+
894
+ parsed = SemverVersion.parse(version)
895
+ raise ArgumentError, "Invalid NPM tilde version: #{version}" unless parsed && NpmVersion.valid?(version)
896
+
897
+ base = version.split("+", 2).first.split("-", 2).first.delete_prefix("v").delete_prefix("V")
898
+ segments = base.count(".") + 1
899
+ major, minor, patch = parsed.core.map(&:to_i)
900
+ upper = if segments >= 2
901
+ "#{major}.#{minor + 1}.0"
902
+ else
903
+ "#{major + 1}.0.0"
904
+ end
905
+ interval = Interval.new(min: version, max: upper, min_inclusive: true, max_inclusive: false, scheme: scheme)
906
+ raw_constraints = if parsed.prerelease.empty?
907
+ nil
908
+ else
909
+ base_version = "#{major}.#{minor}.#{patch}"
910
+ next_patch = "#{major}.#{minor}.#{patch + 1}"
911
+ [
912
+ Interval.new(min: version, max: base_version, min_inclusive: true, max_inclusive: false, scheme: scheme),
913
+ Interval.new(min: base_version, max: next_patch, min_inclusive: true, max_inclusive: false, scheme: scheme)
914
+ ]
915
+ end
916
+ VersionRange.new([interval], raw_constraints: raw_constraints, scheme: scheme)
917
+ end
918
+
919
+ def parse_npm_partial_range(version, operator, scheme: "npm")
920
+ lower, upper = npm_partial_bounds(version)
921
+ return VersionRange.unbounded(scheme: scheme) unless lower
922
+
923
+ interval = case operator
924
+ when "", "="
925
+ Interval.new(min: lower, max: upper, min_inclusive: true, max_inclusive: false, scheme: scheme)
926
+ when ">="
927
+ Interval.greater_than(lower, inclusive: true, scheme: scheme)
928
+ when ">"
929
+ Interval.greater_than(upper, inclusive: true, scheme: scheme)
930
+ when "<="
931
+ Interval.less_than(upper, scheme: scheme)
932
+ when "<"
933
+ Interval.less_than(lower, scheme: scheme)
330
934
  else
331
- VersionRange.new([constraint.to_interval])
935
+ raise ArgumentError, "Invalid operator for NPM partial range: #{operator}"
332
936
  end
333
- end
334
-
335
- @@parser_cache[cache_key] = result
336
- result
937
+
938
+ VersionRange.new([interval], raw_constraints: [interval], scheme: scheme)
337
939
  end
338
940
 
339
- def parse_caret_range(version)
340
- v = Version.cached_new(version)
341
- upper_version = if v.major > 0
342
- # ^1.2.3 := >=1.2.3 <2.0.0
343
- "#{v.major + 1}.0.0"
344
- elsif v.minor && v.minor > 0
345
- # ^0.2.3 := >=0.2.3 <0.3.0
346
- "0.#{v.minor + 1}.0"
347
- else
348
- # ^0.0.3 := >=0.0.3 <0.0.4
349
- "0.0.#{(v.patch || 0) + 1}"
350
- end
941
+ def npm_partial_bounds(version)
942
+ constraint = version.to_s.strip
943
+ return [nil, nil] if constraint.empty? || %w[* x X].include?(constraint)
351
944
 
352
- VersionRange.new([
353
- Interval.new(min: version, max: upper_version, min_inclusive: true, max_inclusive: false)
354
- ])
945
+ segments = constraint.delete_prefix("v").delete_prefix("V").split(".", -1)
946
+ raise ArgumentError, "Invalid NPM partial version: #{version}" if segments.length > 3
947
+
948
+ parts = []
949
+ wildcard = false
950
+ segments.each do |segment|
951
+ if %w[* x X].include?(segment)
952
+ wildcard = true
953
+ elsif wildcard || !VersionComparison.numeric?(segment)
954
+ raise ArgumentError, "Invalid NPM partial version: #{version}"
955
+ else
956
+ parts << segment.to_i
957
+ end
958
+ end
959
+ return [nil, nil] if parts.empty?
960
+
961
+ lower_parts = parts.dup
962
+ lower_parts << 0 while lower_parts.length < 3
963
+ upper_parts = lower_parts.dup
964
+ upper_parts[parts.length - 1] += 1
965
+ (parts.length...upper_parts.length).each { |index| upper_parts[index] = 0 }
966
+ [lower_parts.join("."), upper_parts.join(".")]
355
967
  end
356
968
 
357
- def parse_tilde_range(version)
358
- v = Version.cached_new(version)
969
+ def partial_npm_version?(version)
970
+ constraint = version.to_s.strip.delete_prefix("v").delete_prefix("V")
971
+ segments = constraint.split(".", -1)
972
+ return false if segments.length > 3
359
973
 
360
- if v.prerelease
361
- # ~0.8.0-pre := >=0.8.0-pre <0.8.0 OR >=0.8.0 <0.8.1
362
- # Prereleases only match their own major.minor.patch
363
- base = "#{v.major}.#{v.minor || 0}.#{v.patch || 0}"
364
- next_patch = "#{v.major}.#{v.minor || 0}.#{(v.patch || 0) + 1}"
365
- pre_range = VersionRange.new([
366
- Interval.new(min: version, max: base, min_inclusive: true, max_inclusive: false)
367
- ])
368
- release_range = VersionRange.new([
369
- Interval.new(min: base, max: next_patch, min_inclusive: true, max_inclusive: false)
370
- ])
371
- return pre_range.union(release_range)
974
+ partial = segments.length < 3
975
+ segments.each do |segment|
976
+ if %w[* x X].include?(segment)
977
+ partial = true
978
+ elsif !VersionComparison.numeric?(segment)
979
+ return false
980
+ end
372
981
  end
982
+ partial
983
+ end
373
984
 
374
- upper_version = if v.patch
375
- # ~1.2.3 := >=1.2.3 <1.3.0
376
- "#{v.major}.#{v.minor + 1}.0"
377
- elsif v.minor
378
- # ~1.2 := >=1.2.0 <1.3.0
379
- "#{v.major}.#{v.minor + 1}.0"
380
- else
381
- # ~1 := >=1.0.0 <2.0.0
382
- "#{v.major + 1}.0.0"
383
- end
985
+ def extract_npm_operator(constraint)
986
+ operator = constraint[/\A(?:>=|<=|!=|>|<|=)/].to_s
987
+ [operator, constraint.delete_prefix(operator).strip]
988
+ end
384
989
 
385
- VersionRange.new([
386
- Interval.new(min: version, max: upper_version, min_inclusive: true, max_inclusive: false)
387
- ])
990
+ def parse_npm_hyphen_range(lower, upper, scheme: "npm")
991
+ minimum = partial_npm_version?(lower) ? npm_partial_bounds(lower).fetch(0) : lower
992
+ if partial_npm_version?(upper)
993
+ maximum = npm_partial_bounds(upper).fetch(1)
994
+ maximum_inclusive = false
995
+ else
996
+ maximum = upper
997
+ maximum_inclusive = true
998
+ end
999
+ unless NpmVersion.valid?(minimum) && NpmVersion.valid?(maximum)
1000
+ raise ArgumentError, "Invalid NPM hyphen range: #{lower} - #{upper}"
1001
+ end
1002
+
1003
+ interval = Interval.new(
1004
+ min: minimum,
1005
+ max: maximum,
1006
+ min_inclusive: true,
1007
+ max_inclusive: maximum_inclusive,
1008
+ scheme: scheme
1009
+ )
1010
+ VersionRange.new([interval], scheme: scheme)
388
1011
  end
389
1012
 
390
1013
  # Gem range parsing (~>, >=, etc.)
@@ -420,9 +1043,77 @@ module Vers
420
1043
 
421
1044
  # Python/PyPI range parsing
422
1045
  def parse_pypi_range(range_string)
423
- # Handle comma-separated constraints
424
- constraints = range_string.split(',').map(&:strip)
425
- parse_constraints(constraints.join('|'), 'pypi')
1046
+ constraint = range_string.to_s.strip
1047
+ raise ArgumentError, "Empty PyPI range" if constraint.empty?
1048
+
1049
+ if constraint.include?(",")
1050
+ ranges = constraint.split(",", -1).map do |part|
1051
+ raise ArgumentError, "Empty PyPI constraint" if part.strip.empty?
1052
+
1053
+ parse_pypi_range(part)
1054
+ end
1055
+ return ranges.reduce { |combined, range| combined.intersect(range) }
1056
+ end
1057
+
1058
+ return parse_pypi_compatible_range(constraint.delete_prefix("~=").strip) if constraint.start_with?("~=")
1059
+ if constraint.start_with?("===")
1060
+ raise ArgumentError, "PyPI arbitrary equality constraints are not supported: #{constraint}"
1061
+ end
1062
+ if constraint.start_with?("==") && constraint.delete_prefix("==").strip.end_with?(".*")
1063
+ return parse_pypi_prefix_range(constraint.delete_prefix("==").strip, exclude: false)
1064
+ end
1065
+ if constraint.start_with?("!=") && constraint.delete_prefix("!=").strip.end_with?(".*")
1066
+ return parse_pypi_prefix_range(constraint.delete_prefix("!=").strip, exclude: true)
1067
+ end
1068
+
1069
+ constraint = "=#{constraint.delete_prefix("==").strip}" if constraint.start_with?("==")
1070
+ parse_constraints(constraint, "pypi")
1071
+ end
1072
+
1073
+ def parse_pypi_compatible_range(version)
1074
+ parsed = PyPIVersion.parse(version)
1075
+ unless parsed && parsed.release.length >= 2
1076
+ raise ArgumentError, "Invalid PyPI compatible release: #{version}"
1077
+ end
1078
+
1079
+ upper_release = parsed.release[0...-1].map { |part| VersionComparison.normalize_number(part) }
1080
+ upper_release[-1] = (upper_release.fetch(-1).to_i + 1).to_s
1081
+ upper = upper_release.join(".")
1082
+ unless VersionComparison.compare_numbers(parsed.epoch, "0").zero?
1083
+ upper = "#{VersionComparison.normalize_number(parsed.epoch)}!#{upper}"
1084
+ end
1085
+
1086
+ interval = Interval.new(min: version, max: upper, min_inclusive: true, max_inclusive: false, scheme: "pypi")
1087
+ VersionRange.new([interval], raw_constraints: [interval], scheme: "pypi")
1088
+ end
1089
+
1090
+ def parse_pypi_prefix_range(version, exclude:)
1091
+ prefix = version.delete_suffix(".*")
1092
+ parsed = PyPIVersion.parse(prefix)
1093
+ invalid = !parsed || !parsed.pre_tag.nil? || !parsed.post_number.nil? ||
1094
+ !parsed.dev_number.nil? || parsed.local.any?
1095
+ raise ArgumentError, "Invalid PyPI prefix constraint: #{version}" if invalid
1096
+
1097
+ release = parsed.release.map { |part| VersionComparison.normalize_number(part) }
1098
+ upper_release = release.dup
1099
+ upper_release[-1] = (upper_release.fetch(-1).to_i + 1).to_s
1100
+ epoch = if VersionComparison.compare_numbers(parsed.epoch, "0").zero?
1101
+ ""
1102
+ else
1103
+ "#{VersionComparison.normalize_number(parsed.epoch)}!"
1104
+ end
1105
+ lower = "#{epoch}#{release.join(".")}.dev0"
1106
+ upper = "#{epoch}#{upper_release.join(".")}.dev0"
1107
+
1108
+ intervals = if exclude
1109
+ [
1110
+ Interval.less_than(lower, scheme: "pypi"),
1111
+ Interval.greater_than(upper, inclusive: true, scheme: "pypi")
1112
+ ]
1113
+ else
1114
+ [Interval.new(min: lower, max: upper, min_inclusive: true, max_inclusive: false, scheme: "pypi")]
1115
+ end
1116
+ VersionRange.new(intervals, raw_constraints: intervals, scheme: "pypi")
426
1117
  end
427
1118
 
428
1119
  # Maven range parsing
@@ -708,4 +1399,4 @@ module Vers
708
1399
  parse_constraints(range_string, 'rpm')
709
1400
  end
710
1401
  end
711
- end
1402
+ end