versionian 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.adoc +874 -0
  6. data/Rakefile +12 -0
  7. data/docs/Gemfile +8 -0
  8. data/docs/_guides/custom-schemes.adoc +110 -0
  9. data/docs/_guides/index.adoc +12 -0
  10. data/docs/_pages/component-types.adoc +151 -0
  11. data/docs/_pages/declarative-schemes.adoc +260 -0
  12. data/docs/_pages/index.adoc +15 -0
  13. data/docs/_pages/range-matching.adoc +102 -0
  14. data/docs/_pages/schemes.adoc +68 -0
  15. data/docs/_references/api.adoc +251 -0
  16. data/docs/_references/index.adoc +13 -0
  17. data/docs/_references/schemes.adoc +410 -0
  18. data/docs/_tutorials/getting-started.adoc +119 -0
  19. data/docs/_tutorials/index.adoc +11 -0
  20. data/docs/index.adoc +287 -0
  21. data/lib/versionian/component_definition.rb +71 -0
  22. data/lib/versionian/component_types/base.rb +19 -0
  23. data/lib/versionian/component_types/date_part.rb +41 -0
  24. data/lib/versionian/component_types/enum.rb +32 -0
  25. data/lib/versionian/component_types/float.rb +23 -0
  26. data/lib/versionian/component_types/hash.rb +21 -0
  27. data/lib/versionian/component_types/integer.rb +23 -0
  28. data/lib/versionian/component_types/postfix.rb +51 -0
  29. data/lib/versionian/component_types/prerelease.rb +70 -0
  30. data/lib/versionian/component_types/registry.rb +35 -0
  31. data/lib/versionian/component_types/string.rb +21 -0
  32. data/lib/versionian/errors/invalid_scheme_error.rb +7 -0
  33. data/lib/versionian/errors/invalid_version_error.rb +7 -0
  34. data/lib/versionian/errors/parse_error.rb +7 -0
  35. data/lib/versionian/parsers/declarative.rb +214 -0
  36. data/lib/versionian/scheme_loader.rb +102 -0
  37. data/lib/versionian/scheme_registry.rb +34 -0
  38. data/lib/versionian/schemes/calver.rb +94 -0
  39. data/lib/versionian/schemes/composite.rb +82 -0
  40. data/lib/versionian/schemes/declarative.rb +138 -0
  41. data/lib/versionian/schemes/pattern.rb +236 -0
  42. data/lib/versionian/schemes/semantic.rb +136 -0
  43. data/lib/versionian/schemes/solover.rb +121 -0
  44. data/lib/versionian/schemes/wendtver.rb +141 -0
  45. data/lib/versionian/version.rb +6 -0
  46. data/lib/versionian/version_component.rb +28 -0
  47. data/lib/versionian/version_identifier.rb +121 -0
  48. data/lib/versionian/version_range.rb +61 -0
  49. data/lib/versionian/version_scheme.rb +68 -0
  50. data/lib/versionian.rb +64 -0
  51. data/lib/versius.rb +5 -0
  52. data/sig/versius.rbs +4 -0
  53. metadata +157 -0
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versionian
4
+ module Errors
5
+ class InvalidSchemeError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versionian
4
+ module Errors
5
+ class InvalidVersionError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versionian
4
+ module Errors
5
+ class ParseError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versionian
4
+ module Parsers
5
+ # Parses version strings based on declarative segment definitions.
6
+ # Uses a state machine approach instead of regex for implementation independence.
7
+ class Declarative
8
+ attr_reader :segment_definitions
9
+
10
+ # @param segment_definitions [Array<ComponentDefinition>] Ordered segment definitions
11
+ def initialize(segment_definitions)
12
+ @segment_definitions = segment_definitions
13
+ validate_definitions!
14
+ end
15
+
16
+ # Parse a version string into component values.
17
+ #
18
+ # @param version_string [String] The version string to parse
19
+ # @return [Hash] Component values keyed by component name
20
+ # @raise [Errors::ParseError] If the version string doesn't match the schema
21
+ def parse(version_string)
22
+ raise Errors::ParseError, "Version string cannot be empty" if version_string.nil? || version_string.empty?
23
+
24
+ position = 0
25
+ results = {}
26
+
27
+ @segment_definitions.each_with_index do |segment, index|
28
+ # For optional segments with prefix, check if the prefix is present
29
+ # If the prefix was used as a boundary marker by the previous segment,
30
+ # we need to handle this case specially
31
+ if segment.optional && segment.prefix
32
+ if position >= version_string.length
33
+ results[segment.name] = nil
34
+ next
35
+ end
36
+
37
+ # Check if the defined prefix is present at the current position
38
+ prefix_at_position = version_string[position, segment.prefix.length] == segment.prefix
39
+
40
+ # Check if the prefix was just before our current position
41
+ # (meaning it was used as a boundary marker)
42
+ prefix_before_position = position >= segment.prefix.length &&
43
+ version_string[position - segment.prefix.length, segment.prefix.length] == segment.prefix
44
+
45
+ # For include_prefix_in_value, check if we're at a prefix-like character
46
+ prefix_like_char = segment.include_prefix_in_value &&
47
+ ["+", "-"].include?(version_string[position, 1])
48
+
49
+ prefix_present = prefix_at_position || prefix_before_position || prefix_like_char
50
+
51
+ unless prefix_present
52
+ results[segment.name] = nil
53
+ next
54
+ end
55
+
56
+ # If prefix was before our position, we're already past it, so don't consume again
57
+ # If prefix is at our position, consume it (unless include_prefix_in_value)
58
+ if prefix_at_position
59
+ unless segment.include_prefix_in_value
60
+ position += segment.prefix.length
61
+ end
62
+ end
63
+ end
64
+
65
+ # Find where this segment ends (look ahead to next segment's markers)
66
+ end_pos = find_segment_end_position(version_string, position, index)
67
+
68
+ # Extract raw value
69
+ raw_value = version_string[position...end_pos]
70
+
71
+ if raw_value.nil? || raw_value.empty?
72
+ if segment.optional
73
+ results[segment.name] = nil
74
+ next
75
+ else
76
+ raise Errors::ParseError,
77
+ "Required segment '#{segment.name}' is missing or empty"
78
+ end
79
+ end
80
+
81
+ # Parse the value
82
+ component_type = ComponentTypes.resolve(segment.type)
83
+ parsed_value = component_type.parse(raw_value, segment)
84
+ results[segment.name] = parsed_value
85
+
86
+ # Advance position to end of this segment's value
87
+ position = end_pos
88
+
89
+ # After processing this segment, consume the separator for the next segment
90
+ # The separator is defined on the CURRENT segment and comes BEFORE the next segment
91
+ if segment.separator && !segment.separator.empty?
92
+ if position < version_string.length
93
+ if version_string[position, segment.separator.length] == segment.separator
94
+ position += segment.separator.length
95
+ end
96
+ # If separator not found at position, the next segment will handle it
97
+ end
98
+ end
99
+ end
100
+
101
+ # Check that we consumed the entire string
102
+ if position < version_string.length
103
+ remaining = version_string[position..-1]
104
+ raise Errors::ParseError,
105
+ "Unexpected trailing content after parsing: '#{remaining}'"
106
+ end
107
+
108
+ results
109
+ end
110
+
111
+ # Check if a version string matches this schema.
112
+ #
113
+ # @param version_string [String]
114
+ # @return [Boolean]
115
+ def match?(version_string)
116
+ parse(version_string)
117
+ true
118
+ rescue Errors::ParseError, Errors::InvalidVersionError
119
+ false
120
+ end
121
+
122
+ private
123
+
124
+ # Find the end position of this segment in the version string.
125
+ #
126
+ # The end position is determined by finding the earliest occurrence of:
127
+ # - This segment's own separator (if any) - the separator comes AFTER the value
128
+ # - The next segment's prefix (if any) - prefix comes BEFORE the next value
129
+ # - The next segment's separator (if this segment has no separator)
130
+ # - End of string (fallback)
131
+ #
132
+ # @param string [String] The version string
133
+ # @param position [Integer] Current position in the string
134
+ # @param segment_index [Integer] Index of current segment
135
+ # @return [Integer] End position of this segment
136
+ def find_segment_end_position(string, position, segment_index)
137
+ candidates = [string.length]
138
+ current_segment = @segment_definitions[segment_index]
139
+
140
+ # Check for current segment's own separator (comes after this segment's value)
141
+ if current_segment.separator && !current_segment.separator.empty?
142
+ sep_pos = string.index(current_segment.separator, position)
143
+ candidates << sep_pos if sep_pos && sep_pos >= position
144
+ end
145
+
146
+ # Look for next segment's markers (separators and prefixes)
147
+ ((segment_index + 1)...@segment_definitions.length).each do |next_idx|
148
+ next_segment = @segment_definitions[next_idx]
149
+
150
+ # Check for next segment's separator (always valid as a boundary)
151
+ if next_segment.separator && !next_segment.separator.empty?
152
+ sep_pos = string.index(next_segment.separator, position)
153
+ candidates << sep_pos if sep_pos && sep_pos >= position
154
+ end
155
+
156
+ # Check for next segment's prefix (always valid as a boundary)
157
+ if next_segment.prefix && !next_segment.prefix.empty?
158
+ # Check if the prefix exists in the remaining string (from position onwards)
159
+ remaining = string[position..]
160
+ found_prefix = false
161
+
162
+ if remaining
163
+ prefix_index_in_remaining = remaining.index(next_segment.prefix)
164
+ if prefix_index_in_remaining
165
+ # Calculate the actual position in the full string
166
+ actual_prefix_pos = position + prefix_index_in_remaining
167
+ candidates << actual_prefix_pos
168
+ found_prefix = true
169
+ end
170
+ end
171
+
172
+ # For include_prefix_in_value segments, also check for alternative prefixes
173
+ if next_segment.include_prefix_in_value
174
+ ["+", "-"].each do |alt_prefix|
175
+ if alt_prefix != next_segment.prefix
176
+ alt_index_in_remaining = remaining&.index(alt_prefix)
177
+ if alt_index_in_remaining
178
+ candidates << (position + alt_index_in_remaining)
179
+ found_prefix = true
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ # Only stop looking if we found a prefix in the remaining string
186
+ break if found_prefix
187
+ end
188
+ end
189
+
190
+ candidates.compact.min
191
+ end
192
+
193
+ def validate_definitions!
194
+ @segment_definitions.each do |segment|
195
+ raise Errors::InvalidSchemeError, "segment name required" unless segment.name
196
+ raise Errors::InvalidSchemeError, "segment type required" unless segment.type
197
+ end
198
+
199
+ # Validate that optional segments (after the first) have a prefix
200
+ @segment_definitions.each_with_index do |segment, index|
201
+ next unless segment.optional
202
+ next if index.zero? # First segment doesn't need separator before it
203
+
204
+ # Optional segment must have prefix (to identify its presence)
205
+ # Separator alone is not enough because optional segments are optional
206
+ if segment.prefix.nil?
207
+ raise Errors::InvalidSchemeError,
208
+ "Optional segment '#{segment.name}' must have prefix or separator"
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "psych"
5
+
6
+ module Versionian
7
+ module Schemes
8
+ autoload :Pattern, "versionian/schemes/pattern"
9
+ autoload :CalVer, "versionian/schemes/calver"
10
+ autoload :Composite, "versionian/schemes/composite"
11
+ end
12
+
13
+ class SchemeLoader
14
+ ALLOWED_CLASSES = [Symbol, Integer, String, Array, Hash, TrueClass, FalseClass, NilClass].freeze
15
+
16
+ class << self
17
+ def from_yaml_file(path)
18
+ yaml = File.read(path)
19
+ from_yaml_string(yaml)
20
+ end
21
+
22
+ def from_yaml_string(yaml_string)
23
+ data = Psych.safe_load(yaml_string, permitted_classes: ALLOWED_CLASSES, aliases: true)
24
+ from_hash(data)
25
+ rescue Psych::SyntaxError => e
26
+ raise Errors::InvalidSchemeError, "Invalid YAML: #{e.message}"
27
+ end
28
+
29
+ def from_hash(data)
30
+ case data["type"]&.to_sym
31
+ when :declarative, "declarative"
32
+ Schemes::Declarative.new(
33
+ name: data["name"]&.to_sym,
34
+ component_definitions: parse_component_definitions(data["components"]),
35
+ description: data["description"]
36
+ )
37
+ when :pattern, "pattern"
38
+ Schemes::Pattern.new(
39
+ name: data["name"]&.to_sym,
40
+ pattern: data["pattern"],
41
+ component_definitions: parse_component_definitions(data["components"]),
42
+ format_template: data["format_template"],
43
+ description: data["description"]
44
+ )
45
+ when :calver, "calver"
46
+ Schemes::CalVer.new(
47
+ format: data["format"] || "YYYY.MM.DD",
48
+ name: data["name"]&.to_sym || :calver,
49
+ description: data["description"]
50
+ )
51
+ when :composite, "composite"
52
+ parse_composite_scheme(data)
53
+ else
54
+ raise Errors::InvalidSchemeError, "Unknown scheme type: #{data["type"]}"
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def parse_composite_scheme(data)
61
+ sub_schemes = (data["schemes"] || []).map do |scheme_data|
62
+ from_hash(scheme_data)
63
+ end
64
+
65
+ fallback = from_hash(data["fallback_scheme"]) if data["fallback_scheme"]
66
+
67
+ Schemes::Composite.new(
68
+ name: data["name"]&.to_sym,
69
+ schemes: sub_schemes,
70
+ fallback_scheme: fallback,
71
+ description: data["description"]
72
+ )
73
+ end
74
+
75
+ def parse_component_definitions(components_data)
76
+ return [] unless components_data
77
+
78
+ components_data.map do |comp|
79
+ {
80
+ name: comp["name"]&.to_sym,
81
+ type: comp["type"]&.to_sym,
82
+ subtype: comp["subtype"],
83
+ values: (comp["values"] || []).map(&:to_sym),
84
+ order: (comp["order"] || []).map(&:to_sym),
85
+ weight: comp["weight"],
86
+ optional: comp["optional"],
87
+ ignore_in_comparison: comp["ignore_in_comparison"],
88
+ default: comp["default"],
89
+ compare_as: comp["compare_as"],
90
+ separator: comp["separator"],
91
+ prefix: comp["prefix"],
92
+ suffix: comp["suffix"],
93
+ min_count: comp["min_count"] || 1,
94
+ max_count: comp["max_count"] || 1,
95
+ validate: comp["validate"] || {},
96
+ include_prefix_in_value: comp["include_prefix_in_value"]
97
+ }.compact
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Versionian
6
+ class SchemeRegistry
7
+ include Singleton
8
+
9
+ def initialize
10
+ @schemes = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def register(name, scheme)
15
+ @mutex.synchronize do
16
+ @schemes[name] = scheme
17
+ end
18
+ end
19
+
20
+ def get(name)
21
+ @schemes[name] || raise(Errors::InvalidSchemeError, "Unknown scheme: #{name}")
22
+ end
23
+
24
+ def registered
25
+ @schemes.keys
26
+ end
27
+
28
+ def detect_from(version_string)
29
+ @schemes.values.find do |scheme|
30
+ scheme.valid?(version_string)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versionian
4
+ module Schemes
5
+ class CalVer < VersionScheme
6
+ # Format patterns with explicit format templates for rendering
7
+ FORMAT_PATTERNS = {
8
+ # Date-based formats with standard separators
9
+ "YYYY.MM.DD" => {
10
+ pattern: '^(\d{4})\.(\d{2})\.(\d{2})$',
11
+ format: "{year}.{month}.{day}"
12
+ },
13
+ "YYYY.MM" => {
14
+ pattern: '^(\d{4})\.(\d{2})$',
15
+ format: "{year}.{month}"
16
+ },
17
+ "YYYY.WW" => {
18
+ pattern: '^(\d{4})\.(\d{2})$',
19
+ format: "{year}.{week}"
20
+ },
21
+ "YY.MM.DD" => {
22
+ pattern: '^(\d{2})\.(\d{2})\.(\d{2})$',
23
+ format: "{year}.{month}.{day}"
24
+ },
25
+ "YY.MM" => {
26
+ pattern: '^(\d{2})\.(\d{2})$',
27
+ format: "{year}.{month}"
28
+ },
29
+ "YY.WW" => {
30
+ pattern: '^(\d{2})\.(\d{2})$',
31
+ format: "{year}.{week}"
32
+ }
33
+ }.freeze
34
+
35
+ attr_reader :format, :pattern_scheme
36
+
37
+ def initialize(format: "YYYY.MM.DD", name: :calver, description: nil)
38
+ # Normalize format - 0M and 0W are just formatting, use MM and WW
39
+ normalized_format = format.gsub("0M", "MM").gsub("0W", "WW")
40
+
41
+ config = FORMAT_PATTERNS[normalized_format] || FORMAT_PATTERNS["YYYY.MM.DD"]
42
+ desc = description || "Calendar versioning (#{normalized_format})"
43
+
44
+ component_definitions = build_component_definitions(normalized_format)
45
+
46
+ super(name: name, description: desc)
47
+ @format = normalized_format
48
+ @pattern_scheme = Pattern.new(
49
+ name: name,
50
+ pattern: config[:pattern],
51
+ component_definitions: component_definitions,
52
+ format_template: config[:format],
53
+ description: desc
54
+ )
55
+ end
56
+
57
+ def parse(version_string)
58
+ @pattern_scheme.parse(version_string)
59
+ end
60
+
61
+ def compare_arrays(a, b)
62
+ @pattern_scheme.compare_arrays(a, b)
63
+ end
64
+
65
+ def matches_range?(version_string, range)
66
+ @pattern_scheme.matches_range?(version_string, range)
67
+ end
68
+
69
+ def render(version)
70
+ @pattern_scheme.render(version)
71
+ end
72
+
73
+ private
74
+
75
+ def build_component_definitions(format)
76
+ definitions = []
77
+
78
+ # Year component (2 or 4 digits)
79
+ definitions << { name: :year, type: :date_part, subtype: :year }
80
+
81
+ # Month component
82
+ definitions << { name: :month, type: :date_part, subtype: :month } if format.include?("MM")
83
+
84
+ # Day component
85
+ definitions << { name: :day, type: :date_part, subtype: :day } if format.include?("DD")
86
+
87
+ # Week component
88
+ definitions << { name: :week, type: :date_part, subtype: :week } if format.include?("WW")
89
+
90
+ definitions
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versionian
4
+ module Schemes
5
+ class Composite < VersionScheme
6
+ attr_reader :schemes, :fallback_scheme
7
+
8
+ def initialize(schemes:, fallback_scheme:, name:, description: nil)
9
+ super(name: name, description: description)
10
+ @schemes = schemes
11
+ @fallback_scheme = fallback_scheme # Default if no pattern matches
12
+ end
13
+
14
+ def parse(version_string)
15
+ # Try each scheme in order
16
+ @schemes.each do |scheme|
17
+ next unless scheme.valid?(version_string)
18
+
19
+ version = scheme.parse(version_string)
20
+ # Wrap with composite metadata for cross-format comparison
21
+ return wrap_with_comparable_array(version)
22
+ end
23
+
24
+ # Fallback to default scheme
25
+ @fallback_scheme.parse(version_string)
26
+ end
27
+
28
+ def compare_arrays(a, b)
29
+ # Direct array comparison (already normalized by wrap_with_comparable_array)
30
+ a <=> b
31
+ end
32
+
33
+ def matches_range?(version_string, range)
34
+ version = parse(version_string)
35
+ comparable = version.comparable_array
36
+
37
+ case range.type
38
+ when :equals
39
+ comparable == parse(range.boundary).comparable_array
40
+ when :before
41
+ comparable < parse(range.boundary).comparable_array
42
+ when :after
43
+ comparable >= parse(range.boundary).comparable_array
44
+ when :between
45
+ comparable >= parse(range.from).comparable_array &&
46
+ comparable <= parse(range.to).comparable_array
47
+ end
48
+ end
49
+
50
+ def render(version)
51
+ version.raw_string
52
+ end
53
+
54
+ private
55
+
56
+ def wrap_with_comparable_array(version)
57
+ # Extract comparable array and normalize length
58
+ base_array = version.comparable_array
59
+
60
+ # Check if any component has compare_as directive
61
+ compare_as_component = version.components.find { |c| c.definition&.compare_as }
62
+
63
+ if compare_as_component && compare_as_component.definition.compare_as == "lowest"
64
+ # Mark as lower priority than base versions
65
+ # Prefix with a sentinel value that sorts lower than any normal component
66
+ # For x.y.z-git{hash}: [-1, 1, 2, 3, hash_value]
67
+ # This sorts lower than [0, 1, 2, 3, 0] (standard x.y.z)
68
+ [-1] + base_array
69
+ elsif compare_as_component && compare_as_component.definition.compare_as == "highest"
70
+ # Mark as higher priority than base versions
71
+ [1] + base_array
72
+ else
73
+ # Standard comparison
74
+ # Pad with zeros to normalize length
75
+ max_length = 5
76
+ padded = [0] + base_array + [0] * (max_length - base_array.length)
77
+ padded.first(max_length + 1)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../parsers/declarative'
4
+ require_relative '../version_scheme'
5
+
6
+ module Versionian
7
+ module Schemes
8
+ # Declarative version scheme that uses segment definitions instead of regex patterns.
9
+ # This provides implementation independence and better performance than regex-based schemes.
10
+ class Declarative < VersionScheme
11
+ attr_reader :component_definitions, :parser
12
+
13
+ # Initialize a new declarative scheme.
14
+ #
15
+ # @param name [Symbol] The name of the scheme
16
+ # @param component_definitions [Array<ComponentDefinition, Hash>] Ordered segment definitions
17
+ # @param description [String] Optional description of the scheme
18
+ def initialize(name:, component_definitions:, description: nil)
19
+ super(name: name, description: description)
20
+ # Convert hash definitions to ComponentDefinition objects
21
+ @component_definitions = component_definitions.map do |defn|
22
+ defn.is_a?(Hash) ? ComponentDefinition.from_hash(defn) : defn
23
+ end
24
+ @parser = Parsers::Declarative.new(@component_definitions)
25
+ end
26
+
27
+ # Parse a version string into component values.
28
+ #
29
+ # @param version_string [String] The version string to parse
30
+ # @return [Hash] Component values keyed by component name
31
+ # @raise [Errors::ParseError] If the version string doesn't match the schema
32
+ def parse(version_string)
33
+ component_values = @parser.parse(version_string)
34
+ build_version_object(version_string, component_values)
35
+ end
36
+
37
+ # Check if a version string matches this scheme.
38
+ #
39
+ # @param version_string [String]
40
+ # @return [Boolean]
41
+ def valid?(version_string)
42
+ @parser.match?(version_string)
43
+ end
44
+
45
+ # Check if this scheme supports a given version string.
46
+ #
47
+ # @param version_string [String]
48
+ # @return [Boolean]
49
+ def supports?(version_string)
50
+ valid?(version_string)
51
+ end
52
+
53
+ # Compare two version strings.
54
+ #
55
+ # @param a [String] First version string
56
+ # @param b [String] Second version string
57
+ # @return [Integer] -1, 0, or 1
58
+ def compare(a, b)
59
+ version_a = parse(a)
60
+ version_b = parse(b)
61
+ version_a <=> version_b
62
+ end
63
+
64
+ # Check if a version string matches a range.
65
+ #
66
+ # @param version_string [String] The version string to check
67
+ # @param range [VersionRange] The range to check against
68
+ # @return [Boolean]
69
+ def matches_range?(version_string, range)
70
+ version = parse(version_string)
71
+ range.includes?(version)
72
+ end
73
+
74
+ # Render a version object back to a string.
75
+ #
76
+ # @param version [Version] The version object to render
77
+ # @return [String] The rendered version string
78
+ def render(version)
79
+ version.raw_string
80
+ end
81
+
82
+ private
83
+
84
+ # Build a Version object from component values.
85
+ #
86
+ # @param raw_string [String] The original version string
87
+ # @param component_values [Hash] Parsed component values
88
+ # @return [VersionIdentifier] The version object
89
+ def build_version_object(raw_string, component_values)
90
+ # Build comparable array from component values
91
+ comparable_array = build_comparable_array(component_values)
92
+
93
+ # Build component objects
94
+ components = component_values.map do |name, value|
95
+ definition = @component_definitions.find { |d| d.name == name }
96
+ build_component_object(name, value, definition)
97
+ end
98
+
99
+ VersionIdentifier.new(
100
+ raw_string: raw_string,
101
+ scheme: self,
102
+ components: components,
103
+ comparable_array: comparable_array
104
+ )
105
+ end
106
+
107
+ # Build a comparable array from component values.
108
+ #
109
+ # @param component_values [Hash] Parsed component values
110
+ # @return [Array] Comparable array
111
+ def build_comparable_array(component_values)
112
+ # Build array based on component definitions order
113
+ @component_definitions.map do |definition|
114
+ value = component_values[definition.name]
115
+ next if value.nil? # Skip optional components that weren't parsed
116
+
117
+ component_type = ComponentTypes.resolve(definition.type)
118
+ component_type.to_comparable(value, definition)
119
+ end.compact
120
+ end
121
+
122
+ # Build a component object from a parsed value.
123
+ #
124
+ # @param name [Symbol] Component name
125
+ # @param value [Object] Parsed value
126
+ # @param definition [ComponentDefinition] Component definition
127
+ # @return [VersionComponent] The component object
128
+ def build_component_object(name, value, definition)
129
+ VersionComponent.new(
130
+ name: name,
131
+ type: definition.type,
132
+ value: value,
133
+ weight: definition.weight || 1
134
+ )
135
+ end
136
+ end
137
+ end
138
+ end