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,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versionian
|
|
4
|
+
module Schemes
|
|
5
|
+
class Pattern < VersionScheme
|
|
6
|
+
attr_reader :pattern, :pattern_regex
|
|
7
|
+
|
|
8
|
+
# Format template uses placeholders like {major}, {minor}, etc.
|
|
9
|
+
# Example: "{major}.{minor}.{patch}" or "{year}.{month}-{build}"
|
|
10
|
+
|
|
11
|
+
def initialize(name:, pattern:, component_definitions:, format_template: nil, description: nil)
|
|
12
|
+
# Process component_definitions first (convert hashes to ComponentDefinition objects)
|
|
13
|
+
processed_definitions = component_definitions.map do |d|
|
|
14
|
+
d.is_a?(Hash) ? ComponentDefinition.from_hash(d) : ComponentDefinition.new(**d)
|
|
15
|
+
end
|
|
16
|
+
validate_component_definitions!(processed_definitions)
|
|
17
|
+
|
|
18
|
+
# Derive or use provided format_template
|
|
19
|
+
template = format_template || derive_format_template(pattern)
|
|
20
|
+
|
|
21
|
+
# Call super with all parameters
|
|
22
|
+
super(
|
|
23
|
+
name: name,
|
|
24
|
+
description: description,
|
|
25
|
+
format_template: template,
|
|
26
|
+
component_definitions: processed_definitions
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@pattern = pattern
|
|
30
|
+
@pattern_regex = compile_and_validate_pattern(pattern)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_component_definitions!(definitions = @component_definitions)
|
|
34
|
+
raise Errors::InvalidSchemeError, "No component definitions provided" if definitions.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse(version_string)
|
|
38
|
+
validate_version_string(version_string)
|
|
39
|
+
|
|
40
|
+
match = version_string.match(@pattern_regex)
|
|
41
|
+
raise Errors::ParseError, "Version '#{version_string}' does not match pattern #{@pattern}" unless match
|
|
42
|
+
|
|
43
|
+
components = extract_components(match)
|
|
44
|
+
validate_component_count(match.captures.length)
|
|
45
|
+
|
|
46
|
+
comparable_array = build_comparable_array(components)
|
|
47
|
+
|
|
48
|
+
VersionIdentifier.new(
|
|
49
|
+
raw_string: version_string,
|
|
50
|
+
scheme: self,
|
|
51
|
+
components: components,
|
|
52
|
+
comparable_array: comparable_array
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def compare_arrays(a, b)
|
|
57
|
+
# Lexicographic comparison
|
|
58
|
+
max_len = [a.length, b.length].max
|
|
59
|
+
max_len.times do |i|
|
|
60
|
+
a_val = a[i]
|
|
61
|
+
b_val = b[i]
|
|
62
|
+
|
|
63
|
+
# Handle nil values (optional components)
|
|
64
|
+
a_val = 0 if a_val.nil?
|
|
65
|
+
b_val = 0 if b_val.nil?
|
|
66
|
+
|
|
67
|
+
cmp = a_val <=> b_val
|
|
68
|
+
return cmp if cmp != 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
a.length <=> b.length
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def matches_range?(version_string, range)
|
|
75
|
+
version = parse(version_string)
|
|
76
|
+
comparable = version.comparable_array
|
|
77
|
+
|
|
78
|
+
case range.type
|
|
79
|
+
when :equals
|
|
80
|
+
comparable == parse(range.boundary).comparable_array
|
|
81
|
+
when :before
|
|
82
|
+
comparable < parse(range.boundary).comparable_array
|
|
83
|
+
when :after
|
|
84
|
+
comparable >= parse(range.boundary).comparable_array
|
|
85
|
+
when :between
|
|
86
|
+
comparable >= parse(range.from).comparable_array &&
|
|
87
|
+
comparable <= parse(range.to).comparable_array
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render(version)
|
|
92
|
+
# If no format template, return raw_string
|
|
93
|
+
return version.raw_string unless @format_template
|
|
94
|
+
|
|
95
|
+
# Reconstruct version from components using format template
|
|
96
|
+
# Format template supports optional segments with [] syntax
|
|
97
|
+
# Example: "{major}.{minor}.{patch}[.{patchlevel}]"
|
|
98
|
+
# "{major}.{minor}.{patch}[-{stage}-{iteration}]"
|
|
99
|
+
result = @format_template.dup
|
|
100
|
+
|
|
101
|
+
# First, handle optional segments marked with []
|
|
102
|
+
# Process from innermost to outermost to handle nested optionals
|
|
103
|
+
loop do
|
|
104
|
+
# Find innermost optional segment
|
|
105
|
+
match = result.match(/\[([^\[\]]+)\]/)
|
|
106
|
+
break unless match
|
|
107
|
+
|
|
108
|
+
optional_segment = match[1]
|
|
109
|
+
placeholder = match[0]
|
|
110
|
+
|
|
111
|
+
# Check if any component placeholders in this segment have values
|
|
112
|
+
has_value = false
|
|
113
|
+
optional_segment.scan(/\{(\w+)\}/) do |component_name|
|
|
114
|
+
component = version.component(component_name.first.to_sym)
|
|
115
|
+
has_value = true if component
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Replace with content if has value, empty string otherwise
|
|
119
|
+
replacement = has_value ? optional_segment : ""
|
|
120
|
+
result = result.sub(placeholder, replacement)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Then replace component values for all placeholders
|
|
124
|
+
@component_definitions.each do |comp_def|
|
|
125
|
+
component = version.component(comp_def.name)
|
|
126
|
+
type = ComponentTypes.resolve(comp_def.type)
|
|
127
|
+
|
|
128
|
+
# Build placeholder string, converting symbol name to string
|
|
129
|
+
placeholder = "{#{comp_def.name.to_s}}"
|
|
130
|
+
|
|
131
|
+
if component
|
|
132
|
+
formatted_value = type.format(component.value)
|
|
133
|
+
result = result.gsub(placeholder, formatted_value)
|
|
134
|
+
else
|
|
135
|
+
# Remove empty placeholder
|
|
136
|
+
result = result.gsub(placeholder, "")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# puts "After component replacement: #{result.inspect}"
|
|
141
|
+
|
|
142
|
+
# Clean up any remaining empty optional segment markers
|
|
143
|
+
# e.g., "[-{stage}-{iteration}]" where both stage and iteration are nil becomes "[-]"
|
|
144
|
+
result = result.gsub(/\[-\]/, "").gsub(/\[-/, "-").gsub(/-\]/, "-")
|
|
145
|
+
|
|
146
|
+
# Clean up any double separators or leading/trailing separators
|
|
147
|
+
result = result.gsub(/\.+/, ".").gsub(/^-/, "").gsub(/-$/, "").gsub(/^\.+/, "").gsub(/\.+$/, "")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def compile_and_validate_pattern(pattern_str)
|
|
153
|
+
# Basic ReDoS protection
|
|
154
|
+
# Check for dangerous patterns that can cause catastrophic backtracking
|
|
155
|
+
if pattern_str.include?("\\d+*") || pattern_str.include?('\d+*')
|
|
156
|
+
raise Errors::InvalidSchemeError, "Pattern may cause catastrophic backtracking"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if pattern_str =~ /\(\([^(]*\*\)[^)]*\*\)/ || pattern_str =~ /\(\([^(]*\+\)[^)]*\+\)/
|
|
160
|
+
raise Errors::InvalidSchemeError, "Pattern may cause catastrophic backtracking"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
raise Errors::InvalidSchemeError, "Pattern has too many nested quantifiers" if pattern_str.scan(/\{/).length > 5
|
|
164
|
+
|
|
165
|
+
Regexp.new(pattern_str)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def derive_format_template(pattern_str)
|
|
169
|
+
# Derive format template from pattern by replacing capture groups with placeholders
|
|
170
|
+
# Pattern: ^(\d+)\.(\d+)\.(\d+)$ -> Format: {name1}.{name2}.{name3}
|
|
171
|
+
|
|
172
|
+
# Find all capture groups and their positions
|
|
173
|
+
pattern_str.gsub(/^\^|\$$/, "") # Remove anchors
|
|
174
|
+
|
|
175
|
+
# Replace each capture group with a placeholder
|
|
176
|
+
# We need to track which capture group corresponds to which component
|
|
177
|
+
# Since @component_definitions might not be set yet, we defer to a simpler approach
|
|
178
|
+
|
|
179
|
+
# For now, just store the pattern and use raw_string for rendering
|
|
180
|
+
# Users can provide explicit format_template if they want custom rendering
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def extract_components(match)
|
|
185
|
+
@component_definitions.each_with_index.map do |comp_def, index|
|
|
186
|
+
raw_value = match[index + 1] # Skip full match
|
|
187
|
+
|
|
188
|
+
# Handle optional components
|
|
189
|
+
if raw_value.nil?
|
|
190
|
+
next nil if comp_def.optional
|
|
191
|
+
|
|
192
|
+
# Skip optional components that didn't match
|
|
193
|
+
|
|
194
|
+
raise Errors::ParseError, "Required component '#{comp_def.name}' is missing"
|
|
195
|
+
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
type = ComponentTypes.resolve(comp_def.type)
|
|
199
|
+
parsed_value = type.parse(raw_value, comp_def)
|
|
200
|
+
|
|
201
|
+
VersionComponent.new(
|
|
202
|
+
name: comp_def.name,
|
|
203
|
+
type: comp_def.type,
|
|
204
|
+
value: parsed_value,
|
|
205
|
+
weight: comp_def.weight || 1,
|
|
206
|
+
values: comp_def.values,
|
|
207
|
+
order: comp_def.order || [],
|
|
208
|
+
definition: comp_def
|
|
209
|
+
)
|
|
210
|
+
end.compact # Remove nils from optional components
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def build_comparable_array(components)
|
|
214
|
+
components.map do |component|
|
|
215
|
+
next if component.definition&.ignore_in_comparison
|
|
216
|
+
|
|
217
|
+
type = ComponentTypes.resolve(component.type)
|
|
218
|
+
type.to_comparable(component.value, component)
|
|
219
|
+
end.compact
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_component_count(capture_count)
|
|
223
|
+
# For patterns with optional components, we validate during extraction
|
|
224
|
+
# The capture_count here is total groups, but optional ones may be nil
|
|
225
|
+
# This is validated in extract_components where we check required components
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def validate_version_string(version_string)
|
|
229
|
+
return unless version_string.nil? || version_string.strip.empty?
|
|
230
|
+
|
|
231
|
+
raise Errors::InvalidVersionError,
|
|
232
|
+
"Version string cannot be empty"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
|
|
5
|
+
module Versionian
|
|
6
|
+
module Schemes
|
|
7
|
+
# Autoload all scheme classes
|
|
8
|
+
autoload :Pattern, "versionian/schemes/pattern"
|
|
9
|
+
autoload :Declarative, "versionian/schemes/declarative"
|
|
10
|
+
autoload :CalVer, "versionian/schemes/calver"
|
|
11
|
+
autoload :Composite, "versionian/schemes/composite"
|
|
12
|
+
autoload :SoloVer, "versionian/schemes/solover"
|
|
13
|
+
autoload :WendtVer, "versionian/schemes/wendtver"
|
|
14
|
+
|
|
15
|
+
class Semantic < VersionScheme
|
|
16
|
+
# SemVer pattern: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
|
|
17
|
+
# Must be exactly X.Y or X.Y.Z format where X, Y, Z are non-negative integers
|
|
18
|
+
# CalVer formats like YYYY.MM.DD should not match (year is 4 digits)
|
|
19
|
+
SEMVER_PATTERN = /\A\d{1,5}\.\d{1,5}(?:\.\d{1,5})?(?:-[\w.]+)?(?:\+[\w.]+)?\z/
|
|
20
|
+
|
|
21
|
+
def initialize(name: :semantic, description: "Semantic versioning (semver.org)")
|
|
22
|
+
component_definitions = [
|
|
23
|
+
{ name: :major, type: :integer },
|
|
24
|
+
{ name: :minor, type: :integer },
|
|
25
|
+
{ name: :patch, type: :integer, optional: true }
|
|
26
|
+
].map { |d| d.is_a?(Hash) ? ComponentDefinition.from_hash(d) : ComponentDefinition.new(**d) }
|
|
27
|
+
|
|
28
|
+
super(
|
|
29
|
+
name: name,
|
|
30
|
+
description: description,
|
|
31
|
+
component_definitions: component_definitions
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse(version_string)
|
|
36
|
+
validate_version_string(version_string)
|
|
37
|
+
|
|
38
|
+
# Strip build metadata for Gem::Version compatibility
|
|
39
|
+
version_without_build = version_string.gsub(/\+.*$/, "")
|
|
40
|
+
gem_version = ::Gem::Version.new(version_without_build)
|
|
41
|
+
components = build_components(version_string, gem_version)
|
|
42
|
+
comparable_array = [gem_version] # Gem::Version is directly comparable
|
|
43
|
+
|
|
44
|
+
VersionIdentifier.new(
|
|
45
|
+
raw_string: version_string,
|
|
46
|
+
scheme: self,
|
|
47
|
+
components: components,
|
|
48
|
+
comparable_array: comparable_array
|
|
49
|
+
)
|
|
50
|
+
rescue ArgumentError => e
|
|
51
|
+
raise Errors::InvalidVersionError, "Invalid semantic version '#{version_string}': #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def compare_arrays(a, b)
|
|
55
|
+
# Gem::Version handles comparison
|
|
56
|
+
a <=> b
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def matches_range?(version_string, range)
|
|
60
|
+
version = parse(version_string)
|
|
61
|
+
gem_version = version.comparable_array.first
|
|
62
|
+
|
|
63
|
+
case range.type
|
|
64
|
+
when :equals
|
|
65
|
+
gem_version == ::Gem::Version.new(range.boundary.gsub(/\+.*$/, ""))
|
|
66
|
+
when :before
|
|
67
|
+
gem_version < ::Gem::Version.new(range.boundary.gsub(/\+.*$/, ""))
|
|
68
|
+
when :after
|
|
69
|
+
gem_version >= ::Gem::Version.new(range.boundary.gsub(/\+.*$/, ""))
|
|
70
|
+
when :between
|
|
71
|
+
gem_version >= ::Gem::Version.new(range.from.gsub(/\+.*$/, "")) &&
|
|
72
|
+
gem_version <= ::Gem::Version.new(range.to.gsub(/\+.*$/, ""))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def valid?(version_string)
|
|
77
|
+
return false if version_string.nil? || version_string.strip.empty?
|
|
78
|
+
return false unless version_string =~ SEMVER_PATTERN
|
|
79
|
+
|
|
80
|
+
# Check if this looks like a CalVer date (first component is a 4-digit year)
|
|
81
|
+
# Exclude versions like 2024.01.15 that look like dates
|
|
82
|
+
first_component = version_string.split(/[-.+]/).first
|
|
83
|
+
if first_component =~ /^\d{4}$/ && first_component.to_i.between?(1900, 2100)
|
|
84
|
+
# This looks like a year, not a semantic version
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Try to parse it
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def build_components(version_string, gem_version)
|
|
95
|
+
# Extract components from version string
|
|
96
|
+
parts = version_string.split(/[+-]/).first.split(".")
|
|
97
|
+
components = []
|
|
98
|
+
|
|
99
|
+
components << VersionComponent.new(
|
|
100
|
+
name: :major,
|
|
101
|
+
type: :integer,
|
|
102
|
+
value: gem_version.segments[0] || 0,
|
|
103
|
+
weight: 1,
|
|
104
|
+
definition: @component_definitions[0]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
components << VersionComponent.new(
|
|
108
|
+
name: :minor,
|
|
109
|
+
type: :integer,
|
|
110
|
+
value: gem_version.segments[1] || 0,
|
|
111
|
+
weight: 1,
|
|
112
|
+
definition: @component_definitions[1]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if gem_version.segments.length >= 3
|
|
116
|
+
components << VersionComponent.new(
|
|
117
|
+
name: :patch,
|
|
118
|
+
type: :integer,
|
|
119
|
+
value: gem_version.segments[2],
|
|
120
|
+
weight: 1,
|
|
121
|
+
definition: @component_definitions[2]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
components
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_version_string(version_string)
|
|
129
|
+
return unless version_string.nil? || version_string.strip.empty?
|
|
130
|
+
|
|
131
|
+
raise Errors::InvalidVersionError,
|
|
132
|
+
"Version string cannot be empty"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versionian
|
|
4
|
+
module Schemes
|
|
5
|
+
class SoloVer < VersionScheme
|
|
6
|
+
SOLO_PATTERN = /^(\d+)(([+-])([a-zA-Z0-9]+))?$/
|
|
7
|
+
|
|
8
|
+
def initialize(name: :solover, description: "SoloVer single number with optional postfix")
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parse(version_string)
|
|
13
|
+
validate_version_string(version_string)
|
|
14
|
+
|
|
15
|
+
match = version_string.match(SOLO_PATTERN)
|
|
16
|
+
raise Errors::ParseError, "Invalid SoloVer format '#{version_string}'" unless match
|
|
17
|
+
|
|
18
|
+
number = match[1].to_i
|
|
19
|
+
postfix = match[2]
|
|
20
|
+
prefix = match[3]
|
|
21
|
+
identifier = match[4]
|
|
22
|
+
|
|
23
|
+
components = [
|
|
24
|
+
VersionComponent.new(
|
|
25
|
+
name: :number,
|
|
26
|
+
type: :integer,
|
|
27
|
+
value: number,
|
|
28
|
+
weight: 1
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
comparable_array = [number]
|
|
33
|
+
|
|
34
|
+
if postfix
|
|
35
|
+
components << VersionComponent.new(
|
|
36
|
+
name: :postfix,
|
|
37
|
+
type: :postfix,
|
|
38
|
+
value: { prefix: prefix, identifier: identifier },
|
|
39
|
+
weight: 1
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Store prefix order and identifier for comparison
|
|
43
|
+
prefix_order = case prefix
|
|
44
|
+
when "+" then 1
|
|
45
|
+
when "-" then 2
|
|
46
|
+
else 0
|
|
47
|
+
end
|
|
48
|
+
comparable_array << prefix_order
|
|
49
|
+
comparable_array << identifier
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
VersionIdentifier.new(
|
|
53
|
+
raw_string: version_string,
|
|
54
|
+
scheme: self,
|
|
55
|
+
components: components,
|
|
56
|
+
comparable_array: comparable_array
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def compare_arrays(a, b)
|
|
61
|
+
# Compare number first
|
|
62
|
+
number_cmp = a[0] <=> b[0]
|
|
63
|
+
return number_cmp if number_cmp != 0
|
|
64
|
+
|
|
65
|
+
# Check if both have postfixes or both don't
|
|
66
|
+
a_has_postfix = a.length > 1
|
|
67
|
+
b_has_postfix = b.length > 1
|
|
68
|
+
|
|
69
|
+
if !a_has_postfix && !b_has_postfix
|
|
70
|
+
return 0
|
|
71
|
+
elsif !a_has_postfix
|
|
72
|
+
return -1 # No postfix < +postfix
|
|
73
|
+
elsif !b_has_postfix
|
|
74
|
+
return 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Both have postfixes, compare by prefix
|
|
78
|
+
prefix_cmp = a[1] <=> b[1]
|
|
79
|
+
return prefix_cmp if prefix_cmp != 0
|
|
80
|
+
|
|
81
|
+
# Same prefix, compare identifier lexicographically
|
|
82
|
+
a[2] <=> b[2]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def matches_range?(version_string, range)
|
|
86
|
+
version_a = parse(version_string)
|
|
87
|
+
version_b = parse(range.boundary) if range.boundary
|
|
88
|
+
version_from = parse(range.from) if range.from
|
|
89
|
+
version_to = parse(range.to) if range.to
|
|
90
|
+
|
|
91
|
+
case range.type
|
|
92
|
+
when :equals
|
|
93
|
+
compare_arrays(version_a.comparable_array, version_b.comparable_array).zero?
|
|
94
|
+
when :before
|
|
95
|
+
compare_arrays(version_a.comparable_array, version_b.comparable_array).negative?
|
|
96
|
+
when :after
|
|
97
|
+
compare_arrays(version_a.comparable_array, version_b.comparable_array) >= 0
|
|
98
|
+
when :between
|
|
99
|
+
compare_arrays(version_a.comparable_array, version_from.comparable_array) >= 0 &&
|
|
100
|
+
compare_arrays(version_a.comparable_array, version_to.comparable_array) <= 0
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render(version)
|
|
105
|
+
postfix_component = version.component(:postfix)
|
|
106
|
+
if postfix_component&.value
|
|
107
|
+
"#{version.component(:number).value}#{postfix_component.value[:prefix]}#{postfix_component.value[:identifier]}"
|
|
108
|
+
else
|
|
109
|
+
version.component(:number).value.to_s
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def validate_version_string(version_string)
|
|
116
|
+
raise Errors::InvalidVersionError, "Version string cannot be nil" if version_string.nil?
|
|
117
|
+
raise Errors::InvalidVersionError, "Version string cannot be empty" if version_string.strip.empty?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versionian
|
|
4
|
+
module Schemes
|
|
5
|
+
class WendtVer < VersionScheme
|
|
6
|
+
WENDT_PATTERN = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
|
|
7
|
+
|
|
8
|
+
# WendtVer uses auto-incrementing components with carryover
|
|
9
|
+
# Format: major.minor.patch.build
|
|
10
|
+
# When build reaches 999, it resets to 0 and increments patch
|
|
11
|
+
# When patch reaches 99, it resets to 0 and increments minor
|
|
12
|
+
# When minor reaches 99, it resets to 0 and increments major
|
|
13
|
+
|
|
14
|
+
MAX_VALUES = { build: 999, patch: 99, minor: 99, major: Float::INFINITY }.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(name: :wendtver, description: "WendtVer auto-incrementing with carryover")
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parse(version_string)
|
|
21
|
+
validate_version_string(version_string)
|
|
22
|
+
|
|
23
|
+
match = version_string.match(WENDT_PATTERN)
|
|
24
|
+
raise Errors::ParseError, "Invalid WendtVer format '#{version_string}'" unless match
|
|
25
|
+
|
|
26
|
+
major = match[1].to_i
|
|
27
|
+
minor = match[2].to_i
|
|
28
|
+
patch = match[3].to_i
|
|
29
|
+
build = match[4].to_i
|
|
30
|
+
|
|
31
|
+
validate_ranges(major, minor, patch, build)
|
|
32
|
+
|
|
33
|
+
components = [
|
|
34
|
+
VersionComponent.new(name: :major, type: :integer, value: major, weight: 1),
|
|
35
|
+
VersionComponent.new(name: :minor, type: :integer, value: minor, weight: 1),
|
|
36
|
+
VersionComponent.new(name: :patch, type: :integer, value: patch, weight: 1),
|
|
37
|
+
VersionComponent.new(name: :build, type: :integer, value: build, weight: 1)
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
comparable_array = [major, minor, patch, build]
|
|
41
|
+
|
|
42
|
+
VersionIdentifier.new(
|
|
43
|
+
raw_string: version_string,
|
|
44
|
+
scheme: self,
|
|
45
|
+
components: components,
|
|
46
|
+
comparable_array: comparable_array
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def compare_arrays(a, b)
|
|
51
|
+
# Standard lexicographic comparison
|
|
52
|
+
a <=> b
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def matches_range?(version_string, range)
|
|
56
|
+
version_a = parse(version_string)
|
|
57
|
+
version_b = parse(range.boundary) if range.boundary
|
|
58
|
+
version_from = parse(range.from) if range.from
|
|
59
|
+
version_to = parse(range.to) if range.to
|
|
60
|
+
|
|
61
|
+
case range.type
|
|
62
|
+
when :equals
|
|
63
|
+
compare_arrays(version_a.comparable_array, version_b.comparable_array).zero?
|
|
64
|
+
when :before
|
|
65
|
+
compare_arrays(version_a.comparable_array, version_b.comparable_array).negative?
|
|
66
|
+
when :after
|
|
67
|
+
compare_arrays(version_a.comparable_array, version_b.comparable_array) >= 0
|
|
68
|
+
when :between
|
|
69
|
+
compare_arrays(version_a.comparable_array, version_from.comparable_array) >= 0 &&
|
|
70
|
+
compare_arrays(version_a.comparable_array, version_to.comparable_array) <= 0
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render(version)
|
|
75
|
+
[version.component(:major).value,
|
|
76
|
+
version.component(:minor).value,
|
|
77
|
+
version.component(:patch).value,
|
|
78
|
+
version.component(:build).value].join(".")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Increment a version by one position
|
|
82
|
+
def increment(version_string, position = :build)
|
|
83
|
+
version = parse(version_string)
|
|
84
|
+
major = version.component(:major).value
|
|
85
|
+
minor = version.component(:minor).value
|
|
86
|
+
patch = version.component(:patch).value
|
|
87
|
+
build = version.component(:build).value
|
|
88
|
+
|
|
89
|
+
case position
|
|
90
|
+
when :build
|
|
91
|
+
build += 1
|
|
92
|
+
if build > MAX_VALUES[:build]
|
|
93
|
+
build = 0
|
|
94
|
+
patch += 1
|
|
95
|
+
if patch > MAX_VALUES[:patch]
|
|
96
|
+
patch = 0
|
|
97
|
+
minor += 1
|
|
98
|
+
if minor > MAX_VALUES[:minor]
|
|
99
|
+
minor = 0
|
|
100
|
+
major += 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
when :patch
|
|
105
|
+
patch += 1
|
|
106
|
+
if patch > MAX_VALUES[:patch]
|
|
107
|
+
patch = 0
|
|
108
|
+
minor += 1
|
|
109
|
+
if minor > MAX_VALUES[:minor]
|
|
110
|
+
minor = 0
|
|
111
|
+
major += 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
when :minor
|
|
115
|
+
minor += 1
|
|
116
|
+
if minor > MAX_VALUES[:minor]
|
|
117
|
+
minor = 0
|
|
118
|
+
major += 1
|
|
119
|
+
end
|
|
120
|
+
when :major
|
|
121
|
+
major += 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
"#{major}.#{minor}.#{patch}.#{build}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def validate_version_string(version_string)
|
|
130
|
+
raise Errors::InvalidVersionError, "Version string cannot be nil" if version_string.nil?
|
|
131
|
+
raise Errors::InvalidVersionError, "Version string cannot be empty" if version_string.strip.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_ranges(_major, minor, patch, build)
|
|
135
|
+
raise Errors::ParseError, "Minor must be 0-99" unless minor.between?(0, 99)
|
|
136
|
+
raise Errors::ParseError, "Patch must be 0-99" unless patch.between?(0, 99)
|
|
137
|
+
raise Errors::ParseError, "Build must be 0-999" unless build.between?(0, 999)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versionian
|
|
4
|
+
class VersionComponent
|
|
5
|
+
attr_reader :name, :type, :value, :weight, :values, :order, :definition
|
|
6
|
+
|
|
7
|
+
def initialize(name:, type:, value:, weight: 1, values: [], order: [], definition: nil)
|
|
8
|
+
@name = name
|
|
9
|
+
@type = type
|
|
10
|
+
@value = value
|
|
11
|
+
@weight = weight
|
|
12
|
+
@values = values
|
|
13
|
+
@order = order
|
|
14
|
+
@definition = definition
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_comparable
|
|
19
|
+
type_class = ComponentTypes.resolve(@type)
|
|
20
|
+
type_class.to_comparable(@value, self)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
type_class = ComponentTypes.resolve(@type)
|
|
25
|
+
type_class.format(@value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|