vers 1.3.0 → 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,20 +17,33 @@ 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
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
+ # Maximum accepted length for a range string at parse/parse_native
36
+ # entry points. Range strings concatenate multiple constraints so this
37
+ # is set higher than Version::MAX_LENGTH while still bounding
38
+ # split/regex work to a few KB.
39
+ MAX_INPUT_LENGTH = 2048
40
+
41
+ # Maximum number of |-separated or ||-separated constraints in a
42
+ # single range. The exclusion loop in parse_constraints does
43
+ # O(n^2 log n) work as each != splits an interval and reconstructs the
44
+ # range; capping n keeps the worst case under a few thousand interval
45
+ # operations.
46
+ MAX_CONSTRAINTS = 64
34
47
 
35
48
  ##
36
49
  # Parses a vers URI string into a VersionRange
@@ -43,21 +56,39 @@ module Vers
43
56
  #
44
57
  # parser = Vers::Parser.new
45
58
  # parser.parse("vers:npm/>=1.2.3|<2.0.0")
46
- # parser.parse("vers:gem/~>1.0")
47
- # parser.parse("vers:pypi/==1.2.3")
59
+ # parser.parse_native("~>1.0", "gem")
60
+ # parser.parse_native("==1.2.3", "pypi")
48
61
  #
49
- def parse(vers_string)
50
- if vers_string == "*"
51
- return VersionRange.unbounded
62
+ def parse(vers_string, require_canonical_order: false)
63
+ validate_input_length!(vers_string)
64
+
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"
52
71
  end
53
72
 
54
- match = vers_string.match(VERS_URI_REGEX)
55
- 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
56
78
 
57
- scheme = match[1]
58
- 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
88
+
89
+ validate_vers_constraints!(constraints_string, scheme, require_canonical_order)
59
90
 
60
- parse_constraints(constraints_string, scheme)
91
+ parse_constraints(constraints_string, scheme, decode_versions: true)
61
92
  end
62
93
 
63
94
  ##
@@ -75,31 +106,45 @@ module Vers
75
106
  # parser.parse_native(">=1.0,<2.0", "pypi")
76
107
  #
77
108
  def parse_native(range_string, scheme)
78
- case scheme
109
+ validate_input_length!(range_string)
110
+
111
+ canonical_scheme = Scheme.canonical(scheme)
112
+ range = case canonical_scheme
79
113
  when "npm"
80
- parse_npm_range(range_string)
81
- when "gem", "rubygems"
114
+ parse_npm_range(range_string, scheme: "npm")
115
+ when "gem"
82
116
  parse_gem_range(range_string)
83
117
  when "pypi"
84
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)
85
129
  when "maven"
86
130
  parse_maven_range(range_string)
87
131
  when "cargo"
88
- parse_npm_range(range_string)
132
+ parse_npm_range(range_string, scheme: "cargo")
89
133
  when "nuget"
90
134
  parse_nuget_range(range_string)
91
- when "hex", "elixir"
135
+ when "hex"
92
136
  parse_hex_range(range_string)
93
- when "go", "golang"
137
+ when "go"
94
138
  parse_go_range(range_string)
95
- when "deb", "debian"
139
+ when "deb"
96
140
  parse_debian_range(range_string)
97
141
  when "rpm"
98
142
  parse_rpm_range(range_string)
99
143
  else
100
144
  # Fall back to generic constraint parsing
101
- parse_constraints(range_string, scheme)
145
+ parse_constraints(range_string, canonical_scheme)
102
146
  end
147
+ range.with_scheme(canonical_scheme)
103
148
  end
104
149
 
105
150
  ##
@@ -110,61 +155,234 @@ module Vers
110
155
  # @return [String] The vers URI string
111
156
  #
112
157
  def to_vers_string(version_range, scheme)
113
- return "vers:#{scheme}/*" if version_range.unbounded?
114
- 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
115
162
 
116
- 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)
117
172
  constraints = []
118
173
 
119
174
  # Detect != pattern: two intervals (-∞,V) ∪ (V,+∞)
120
175
  if intervals.length == 2
121
176
  a, b = intervals
122
177
  if a.min.nil? && !a.max_inclusive && b.max.nil? && !b.min_inclusive && a.max == b.min
123
- constraints << "!=#{a.max}"
124
- 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)
125
181
  return "vers:#{scheme}/#{constraints.join('|')}"
126
182
  end
127
183
  end
128
184
 
129
185
  intervals.each do |interval|
186
+ next if interval.unbounded?
187
+
130
188
  if interval.min == interval.max && interval.min_inclusive && interval.max_inclusive
131
189
  # Exact version
132
- constraints << interval.min.to_s
190
+ constraints << encode_vers_version(normalize_vers_version(interval.min.to_s, scheme))
133
191
  else
134
192
  # Range constraints
135
193
  if interval.min
136
194
  operator = interval.min_inclusive ? ">=" : ">"
137
- constraints << "#{operator}#{interval.min}"
195
+ version = encode_vers_version(normalize_vers_version(interval.min, scheme))
196
+ constraints << "#{operator}#{version}"
138
197
  end
139
198
 
140
199
  if interval.max
141
200
  operator = interval.max_inclusive ? "<=" : "<"
142
- constraints << "#{operator}#{interval.max}"
201
+ version = encode_vers_version(normalize_vers_version(interval.max, scheme))
202
+ constraints << "#{operator}#{version}"
143
203
  end
144
204
  end
145
205
  end
146
206
 
147
- 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)
148
213
 
149
214
  "vers:#{scheme}/#{constraints.join('|')}"
150
215
  end
151
216
 
152
217
  private
153
218
 
154
- def sort_key_for_constraint(constraint)
155
- version = constraint.sub(OPERATOR_PREFIX_REGEX, '')
156
- v = Version.cached_new(version)
157
- [v, constraint]
219
+ def validate_input_length!(input)
220
+ return if input.nil?
221
+ return if input.length <= MAX_INPUT_LENGTH
222
+ raise ArgumentError, "Range string too long (#{input.length} > #{MAX_INPUT_LENGTH})"
223
+ end
224
+
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?
341
+ end
342
+
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)
158
365
  end
159
366
 
160
- def parse_constraints(constraints_string, scheme)
161
- constraint_strings = constraints_string.split(/[|,]/)
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
+
371
+ # Limit constraint count to bound the O(n^2 log n) exclusion loop
372
+ # below: each != splits an interval and reconstructs the range.
373
+ constraint_strings = constraints_string.split(/[|,]/, MAX_CONSTRAINTS + 1)
374
+ if constraint_strings.length > MAX_CONSTRAINTS
375
+ raise ArgumentError, "Too many constraints (> #{MAX_CONSTRAINTS})"
376
+ end
162
377
  intervals = []
163
378
  exclusions = []
164
- interval_scheme = %w[maven nuget].include?(scheme) ? scheme : nil
379
+ interval_scheme = canonical_scheme
165
380
 
166
381
  constraint_strings.each do |constraint_string|
167
382
  constraint = Constraint.parse(constraint_string.strip)
383
+ if decode_versions
384
+ constraint = Constraint.new(constraint.operator, decode_vers_version(constraint.version))
385
+ end
168
386
 
169
387
  if constraint.exclusion?
170
388
  exclusions << constraint.version
@@ -174,186 +392,622 @@ module Vers
174
392
  end
175
393
  end
176
394
 
395
+ grouped_intervals = intersect_consecutive_intervals(intervals, interval_scheme)
396
+
177
397
  # Start with the union of all positive constraints, or unbounded if only exclusions
178
- range = if intervals.any?
179
- 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
+ )
180
405
  elsif exclusions.any?
181
- VersionRange.unbounded
406
+ VersionRange.new(
407
+ [Interval.unbounded(scheme: interval_scheme)],
408
+ raw_constraints: [],
409
+ scheme: interval_scheme,
410
+ exclusions: exclusions
411
+ )
182
412
  else
183
413
  VersionRange.new([], scheme: interval_scheme)
184
414
  end
185
415
 
186
- # Apply exclusions
187
- exclusions.each do |version|
188
- 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
189
441
  end
190
442
 
191
- range
443
+ grouped
192
444
  end
193
445
 
194
- # NPM range parsing (^, ~, -, ||, etc.)
195
- def parse_npm_range(range_string)
196
- # Handle empty string as unbounded
197
- if range_string.nil? || range_string.strip.empty?
198
- return VersionRange.unbounded
199
- end
200
-
201
- # Handle || (OR) operator
202
- if range_string.include?('||')
203
- or_parts = range_string.split('||').map(&:strip)
204
- ranges = or_parts.map { |part| parse_npm_range(part) }
205
- return ranges.reduce { |acc, range| acc.union(range) }
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])
206
465
  end
207
466
 
208
- # Handle hyphen ranges first (before space splitting)
209
- if range_string.match(/^(.+?)\s+-\s+(.+)$/)
210
- return parse_npm_single_range(range_string)
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
211
505
  end
212
506
 
213
- # Handle space-separated AND constraints
214
- and_parts = range_string.split(/\s+/).reject(&:empty?)
215
- # Re-join bare operators with their version
216
- merged = []
217
- and_parts.each do |part|
218
- if merged.last&.match?(/\A(>=|<=|!=|[<>=~^])\z/)
219
- merged[-1] = "#{merged.last}#{part}"
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}"
220
533
  else
221
- merged << part
534
+ prefix << segment
222
535
  end
223
536
  end
224
- ranges = merged.map { |part| parse_npm_single_range(part) }
225
- # If all parts are bare versions (no operators), treat as union
226
- all_exact = merged.all? { |part| part.match?(/\A\d/) }
227
- if all_exact
228
- ranges.reduce { |acc, range| acc.union(range) }
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")
229
556
  else
230
- ranges.reduce { |acc, range| acc.intersect(range) }
231
- end
232
- end
233
-
234
- def parse_npm_single_range(range_string)
235
- cache_key = "npm:#{range_string}"
236
- return @@parser_cache[cache_key] if @@parser_cache.key?(cache_key)
237
-
238
- if @@parser_cache.size >= @@cache_size_limit
239
- keys = @@parser_cache.keys
240
- keys.first(keys.size / 2).each { |k| @@parser_cache.delete(k) }
241
- end
242
-
243
- result = case range_string
244
- when NPM_CARET_REGEX
245
- # Caret range: ^1.2.3 := >=1.2.3 <2.0.0
246
- version = $1
247
- parse_caret_range(version)
248
- when NPM_TILDE_REGEX
249
- # Tilde range: ~1.2.3 := >=1.2.3 <1.3.0
250
- version = $1
251
- parse_tilde_range(version)
252
- when NPM_HYPHEN_REGEX
253
- # Hyphen range: 1.2.3 - 2.3.4 := >=1.2.3 <=2.3.4
254
- from_version = $1.strip
255
- to_version = $2.strip
256
- VersionRange.new([
257
- Interval.new(min: from_version, max: to_version, min_inclusive: true, max_inclusive: true)
258
- ])
259
- when "*", "x", "X"
260
- VersionRange.unbounded
261
- when NPM_X_RANGE_MAJOR_REGEX
262
- # X-range like "1.x" := >=1.0.0 <2.0.0
263
- major = $1.to_i
264
- VersionRange.new([
265
- Interval.new(min: "#{major}.0.0", max: "#{major + 1}.0.0", min_inclusive: true, max_inclusive: false)
266
- ])
267
- when NPM_X_RANGE_MINOR_REGEX
268
- # X-range like "1.2.x" := >=1.2.0 <1.3.0
269
- major = $1.to_i
270
- minor = $2.to_i
271
- VersionRange.new([
272
- Interval.new(min: "#{major}.#{minor}.0", max: "#{major}.#{minor + 1}.0", min_inclusive: true, max_inclusive: false)
273
- ])
274
- when /^(blerg|git\+|https?:\/\/)/
275
- # Invalid patterns that should raise errors
276
- raise ArgumentError, "Invalid NPM range format: #{range_string}"
277
- else
278
- # Check for operator + x-range (e.g. ">=2.2.x", ">=1.x")
279
- if range_string.match(/\A[><=]+(\d+)\.[xX*]\z/)
280
- major = $1.to_i
281
- return VersionRange.new([
282
- Interval.new(min: "#{major}.0.0", max: "#{major + 1}.0.0", min_inclusive: true, max_inclusive: false)
283
- ])
284
- end
285
- if range_string.match(/\A[><=]+(\d+)\.(\d+)\.[xX*]\z/)
286
- major = $1.to_i
287
- minor = $2.to_i
288
- return VersionRange.new([
289
- Interval.new(min: "#{major}.#{minor}.0", max: "#{major}.#{minor + 1}.0", min_inclusive: true, max_inclusive: false)
290
- ])
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")
291
631
  end
292
- # Standard constraint
293
- constraint = Constraint.parse(range_string)
294
- # Normalize version to semver (npm always uses 3 segments)
295
- normalized_version = Version.cached_new(constraint.version).to_s
296
- constraint = Constraint.new(constraint.operator, normalized_version)
297
- if constraint.exclusion?
298
- VersionRange.unbounded.exclude(constraint.version)
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")
807
+ end
808
+
809
+ # NPM range parsing (^, ~, -, ||, etc.)
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)
816
+ if or_parts.length > MAX_CONSTRAINTS
817
+ raise ArgumentError, "Too many || clauses (> #{MAX_CONSTRAINTS})"
818
+ end
819
+ ranges = or_parts.map { |part| parse_npm_range(part, scheme: scheme) }
820
+ return ranges.reduce { |combined, range| combined.union(range) }
821
+ end
822
+
823
+ if constraint.include?(" - ")
824
+ lower, upper = constraint.split(" - ", 2).map(&:strip)
825
+ return parse_npm_hyphen_range(lower, upper, scheme: scheme)
826
+ end
827
+
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
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}"
859
+ end
860
+
861
+ if parsed.exclusion?
862
+ VersionRange.unbounded(scheme: scheme).exclude(parsed.version)
863
+ else
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)
299
934
  else
300
- VersionRange.new([constraint.to_interval])
935
+ raise ArgumentError, "Invalid operator for NPM partial range: #{operator}"
301
936
  end
302
- end
303
-
304
- @@parser_cache[cache_key] = result
305
- result
937
+
938
+ VersionRange.new([interval], raw_constraints: [interval], scheme: scheme)
306
939
  end
307
940
 
308
- def parse_caret_range(version)
309
- v = Version.cached_new(version)
310
- upper_version = if v.major > 0
311
- # ^1.2.3 := >=1.2.3 <2.0.0
312
- "#{v.major + 1}.0.0"
313
- elsif v.minor && v.minor > 0
314
- # ^0.2.3 := >=0.2.3 <0.3.0
315
- "0.#{v.minor + 1}.0"
316
- else
317
- # ^0.0.3 := >=0.0.3 <0.0.4
318
- "0.0.#{(v.patch || 0) + 1}"
319
- 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)
320
944
 
321
- VersionRange.new([
322
- Interval.new(min: version, max: upper_version, min_inclusive: true, max_inclusive: false)
323
- ])
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(".")]
324
967
  end
325
968
 
326
- def parse_tilde_range(version)
327
- 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
328
973
 
329
- if v.prerelease
330
- # ~0.8.0-pre := >=0.8.0-pre <0.8.0 OR >=0.8.0 <0.8.1
331
- # Prereleases only match their own major.minor.patch
332
- base = "#{v.major}.#{v.minor || 0}.#{v.patch || 0}"
333
- next_patch = "#{v.major}.#{v.minor || 0}.#{(v.patch || 0) + 1}"
334
- pre_range = VersionRange.new([
335
- Interval.new(min: version, max: base, min_inclusive: true, max_inclusive: false)
336
- ])
337
- release_range = VersionRange.new([
338
- Interval.new(min: base, max: next_patch, min_inclusive: true, max_inclusive: false)
339
- ])
340
- 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
341
981
  end
982
+ partial
983
+ end
342
984
 
343
- upper_version = if v.patch
344
- # ~1.2.3 := >=1.2.3 <1.3.0
345
- "#{v.major}.#{v.minor + 1}.0"
346
- elsif v.minor
347
- # ~1.2 := >=1.2.0 <1.3.0
348
- "#{v.major}.#{v.minor + 1}.0"
349
- else
350
- # ~1 := >=1.0.0 <2.0.0
351
- "#{v.major + 1}.0.0"
352
- end
985
+ def extract_npm_operator(constraint)
986
+ operator = constraint[/\A(?:>=|<=|!=|>|<|=)/].to_s
987
+ [operator, constraint.delete_prefix(operator).strip]
988
+ end
353
989
 
354
- VersionRange.new([
355
- Interval.new(min: version, max: upper_version, min_inclusive: true, max_inclusive: false)
356
- ])
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)
357
1011
  end
358
1012
 
359
1013
  # Gem range parsing (~>, >=, etc.)
@@ -389,9 +1043,77 @@ module Vers
389
1043
 
390
1044
  # Python/PyPI range parsing
391
1045
  def parse_pypi_range(range_string)
392
- # Handle comma-separated constraints
393
- constraints = range_string.split(',').map(&:strip)
394
- 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")
395
1117
  end
396
1118
 
397
1119
  # Maven range parsing
@@ -498,7 +1220,7 @@ module Vers
498
1220
  begin
499
1221
  parsed_range = parse_maven_range(range_part)
500
1222
  ranges << parsed_range
501
- rescue
1223
+ rescue ArgumentError
502
1224
  # If parsing fails, skip this part
503
1225
  end
504
1226
  end
@@ -677,4 +1399,4 @@ module Vers
677
1399
  parse_constraints(range_string, 'rpm')
678
1400
  end
679
1401
  end
680
- end
1402
+ end