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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.adoc +874 -0
- data/Rakefile +12 -0
- data/docs/Gemfile +8 -0
- data/docs/_guides/custom-schemes.adoc +110 -0
- data/docs/_guides/index.adoc +12 -0
- data/docs/_pages/component-types.adoc +151 -0
- data/docs/_pages/declarative-schemes.adoc +260 -0
- data/docs/_pages/index.adoc +15 -0
- data/docs/_pages/range-matching.adoc +102 -0
- data/docs/_pages/schemes.adoc +68 -0
- data/docs/_references/api.adoc +251 -0
- data/docs/_references/index.adoc +13 -0
- data/docs/_references/schemes.adoc +410 -0
- data/docs/_tutorials/getting-started.adoc +119 -0
- data/docs/_tutorials/index.adoc +11 -0
- data/docs/index.adoc +287 -0
- data/lib/versionian/component_definition.rb +71 -0
- data/lib/versionian/component_types/base.rb +19 -0
- data/lib/versionian/component_types/date_part.rb +41 -0
- data/lib/versionian/component_types/enum.rb +32 -0
- data/lib/versionian/component_types/float.rb +23 -0
- data/lib/versionian/component_types/hash.rb +21 -0
- data/lib/versionian/component_types/integer.rb +23 -0
- data/lib/versionian/component_types/postfix.rb +51 -0
- data/lib/versionian/component_types/prerelease.rb +70 -0
- data/lib/versionian/component_types/registry.rb +35 -0
- data/lib/versionian/component_types/string.rb +21 -0
- data/lib/versionian/errors/invalid_scheme_error.rb +7 -0
- data/lib/versionian/errors/invalid_version_error.rb +7 -0
- data/lib/versionian/errors/parse_error.rb +7 -0
- data/lib/versionian/parsers/declarative.rb +214 -0
- data/lib/versionian/scheme_loader.rb +102 -0
- data/lib/versionian/scheme_registry.rb +34 -0
- data/lib/versionian/schemes/calver.rb +94 -0
- data/lib/versionian/schemes/composite.rb +82 -0
- data/lib/versionian/schemes/declarative.rb +138 -0
- data/lib/versionian/schemes/pattern.rb +236 -0
- data/lib/versionian/schemes/semantic.rb +136 -0
- data/lib/versionian/schemes/solover.rb +121 -0
- data/lib/versionian/schemes/wendtver.rb +141 -0
- data/lib/versionian/version.rb +6 -0
- data/lib/versionian/version_component.rb +28 -0
- data/lib/versionian/version_identifier.rb +121 -0
- data/lib/versionian/version_range.rb +61 -0
- data/lib/versionian/version_scheme.rb +68 -0
- data/lib/versionian.rb +64 -0
- data/lib/versius.rb +5 -0
- data/sig/versius.rbs +4 -0
- metadata +157 -0
|
@@ -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
|