verbatim 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/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/lib/verbatim/cursor.rb +65 -0
- data/lib/verbatim/errors.rb +29 -0
- data/lib/verbatim/parser.rb +112 -0
- data/lib/verbatim/schema.rb +325 -0
- data/lib/verbatim/schemas/calver.rb +59 -0
- data/lib/verbatim/schemas/semver.rb +86 -0
- data/lib/verbatim/schemas/semver_compare.rb +85 -0
- data/lib/verbatim/segment.rb +56 -0
- data/lib/verbatim/types.rb +341 -0
- data/lib/verbatim/version.rb +9 -0
- data/lib/verbatim.rb +18 -0
- metadata +99 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verbatim
|
|
4
|
+
# Base class for declarative version (or token) schemas. Subclasses declare {#segment}s and use {.parse}.
|
|
5
|
+
# Each {.segment} defines a public instance reader with the same name as the segment.
|
|
6
|
+
#
|
|
7
|
+
# @api public
|
|
8
|
+
#
|
|
9
|
+
class Schema
|
|
10
|
+
include Comparable
|
|
11
|
+
|
|
12
|
+
# Maximum number of characters allowed in the string passed to {.parse} (UTF-8 codepoint count).
|
|
13
|
+
#
|
|
14
|
+
MAX_INPUT_LEN = 128
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Subclasses must call +super+ and initialize {#segments}, {#default_delimiter}, and {#segment_names}.
|
|
18
|
+
#
|
|
19
|
+
# @param subclass [Class]
|
|
20
|
+
# @return [void]
|
|
21
|
+
#
|
|
22
|
+
def inherited(subclass)
|
|
23
|
+
super
|
|
24
|
+
|
|
25
|
+
subclass.instance_variable_set(:@segments, [])
|
|
26
|
+
subclass.instance_variable_set(:@default_delimiter, ".")
|
|
27
|
+
subclass.instance_variable_set(:@segment_names, [])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<Segment>] segments in declaration order
|
|
31
|
+
#
|
|
32
|
+
def segments
|
|
33
|
+
@segments ||= []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sets or returns the default delimiter between segments (when the next segment has no +lead+).
|
|
37
|
+
#
|
|
38
|
+
# @param value [String, nil] if +nil+, returns the current default without changing it
|
|
39
|
+
# @return [String] the active default delimiter
|
|
40
|
+
#
|
|
41
|
+
def delimiter(value = nil)
|
|
42
|
+
@default_delimiter ||= "."
|
|
43
|
+
return @default_delimiter if value.nil?
|
|
44
|
+
|
|
45
|
+
@default_delimiter = value.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
alias default_delimiter delimiter
|
|
49
|
+
|
|
50
|
+
# Declares a segment and defines an instance reader for +name+.
|
|
51
|
+
#
|
|
52
|
+
# @param name [Symbol]
|
|
53
|
+
# @param type [Symbol] registered in {Types.register}
|
|
54
|
+
# @param optional [Boolean]
|
|
55
|
+
# @param lead [String, nil]
|
|
56
|
+
# @param delimiter_after [Symbol, String] +:inherit+, +:none+, or explicit string
|
|
57
|
+
# @param options [Hash] forwarded to the type handler (e.g. +pad:+, +pattern:+)
|
|
58
|
+
# @return [void]
|
|
59
|
+
# @raise [ArgumentError] on duplicate segment +name+
|
|
60
|
+
#
|
|
61
|
+
def segment(name, type, optional: false, lead: nil, delimiter_after: :inherit, **options)
|
|
62
|
+
name = name.to_sym
|
|
63
|
+
@segment_names ||= []
|
|
64
|
+
@segments ||= []
|
|
65
|
+
@default_delimiter ||= "."
|
|
66
|
+
|
|
67
|
+
raise ArgumentError, "duplicate segment #{name.inspect}" if @segment_names.include?(name)
|
|
68
|
+
|
|
69
|
+
@segment_names << name
|
|
70
|
+
|
|
71
|
+
segments << Segment.new(
|
|
72
|
+
name: name,
|
|
73
|
+
type: type.to_sym,
|
|
74
|
+
optional: optional,
|
|
75
|
+
lead: lead&.to_s,
|
|
76
|
+
delimiter_after: delimiter_after,
|
|
77
|
+
options: options
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
define_reader(name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parses a string into a new instance of the schema class.
|
|
84
|
+
#
|
|
85
|
+
# @param string [String]
|
|
86
|
+
# @return [Schema] a frozen instance populated from +string+
|
|
87
|
+
# @raise [ParseError] on invalid input or if +string+ is longer than {#MAX_INPUT_LEN}
|
|
88
|
+
#
|
|
89
|
+
def parse(string)
|
|
90
|
+
if string.length > MAX_INPUT_LEN
|
|
91
|
+
raise ParseError.new(
|
|
92
|
+
"input exceeds maximum length of #{MAX_INPUT_LEN} characters (#{string.length} given)",
|
|
93
|
+
string: string,
|
|
94
|
+
index: MAX_INPUT_LEN,
|
|
95
|
+
segment: nil
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
allocate.tap do |instance|
|
|
100
|
+
instance.send(:initialize_values)
|
|
101
|
+
Parser.new(self, string).parse_into(instance)
|
|
102
|
+
instance.freeze
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Formats an instance of the schema class into a canonical string.
|
|
107
|
+
#
|
|
108
|
+
# @param instance [Schema] same class as this schema
|
|
109
|
+
# @return [String] canonical string for +instance+
|
|
110
|
+
# @raise [ArgumentError] if a required segment value is missing
|
|
111
|
+
#
|
|
112
|
+
def format(instance)
|
|
113
|
+
parts = []
|
|
114
|
+
|
|
115
|
+
segments.each_with_index do |segment, index|
|
|
116
|
+
value = instance.send(segment.name)
|
|
117
|
+
next if value.nil? && segment.optional?
|
|
118
|
+
|
|
119
|
+
raise ArgumentError, "missing required segment #{segment.name}" if value.nil?
|
|
120
|
+
|
|
121
|
+
if index.zero?
|
|
122
|
+
parts << Types.format(segment.type, value, segment)
|
|
123
|
+
elsif segment.lead?
|
|
124
|
+
parts << segment.lead.to_s << Types.format(segment.type, value, segment)
|
|
125
|
+
else
|
|
126
|
+
delim = delimiter_before_format(segments, index)
|
|
127
|
+
parts << delim if delim && !delim.empty?
|
|
128
|
+
parts << Types.format(segment.type, value, segment)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
parts.join
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# Defines a public instance reader for +name+.
|
|
138
|
+
#
|
|
139
|
+
# @param name [Symbol]
|
|
140
|
+
# @return [void]
|
|
141
|
+
#
|
|
142
|
+
def define_reader(name)
|
|
143
|
+
define_method(name) { @values[name] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns the delimiter before the given segment when formatting.
|
|
147
|
+
#
|
|
148
|
+
# @param segments [Array<Segment>]
|
|
149
|
+
# @param index [Integer] index of the segment being formatted
|
|
150
|
+
# @return [String, nil] delimiter before that segment when formatting
|
|
151
|
+
#
|
|
152
|
+
def delimiter_before_format(segments, index)
|
|
153
|
+
return if index.zero?
|
|
154
|
+
|
|
155
|
+
prev = segments[index - 1]
|
|
156
|
+
|
|
157
|
+
case prev.delimiter_after
|
|
158
|
+
when :inherit then delimiter(nil)
|
|
159
|
+
when :none then nil
|
|
160
|
+
else prev.delimiter_after.to_s
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param values [Hash] segment name => parsed value
|
|
166
|
+
# @return [void]
|
|
167
|
+
#
|
|
168
|
+
def initialize(**values)
|
|
169
|
+
initialize_values
|
|
170
|
+
|
|
171
|
+
values.each { |name, value| assign_segment(name.to_sym, value) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Assigns a value to a segment.
|
|
175
|
+
#
|
|
176
|
+
# @param name [Symbol, String]
|
|
177
|
+
# @param value [Object]
|
|
178
|
+
# @return [void]
|
|
179
|
+
# @raise [FrozenError] if +self+ is frozen
|
|
180
|
+
#
|
|
181
|
+
def assign_segment(name, value)
|
|
182
|
+
@values[name.to_sym] = value
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Returns the value of a segment.
|
|
186
|
+
#
|
|
187
|
+
# @param name [Symbol, String]
|
|
188
|
+
# @return [Object] segment value, or +nil+ if unset
|
|
189
|
+
#
|
|
190
|
+
def [](name)
|
|
191
|
+
@values[name.to_sym]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Returns a copy of the internal segment values.
|
|
195
|
+
#
|
|
196
|
+
# @return [Hash{Symbol => Object}] copy of internal segment values
|
|
197
|
+
#
|
|
198
|
+
def to_h
|
|
199
|
+
@values.dup
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Returns a new instance of the same schema with segment values merged over +self+.
|
|
203
|
+
#
|
|
204
|
+
# @param overrides [Hash] segment names (symbols or strings) => new values
|
|
205
|
+
# @return [Schema] unfrozen instance; does not mutate +self+
|
|
206
|
+
#
|
|
207
|
+
def with(**overrides)
|
|
208
|
+
base = self.class.segments.each_with_object({}) { |s, h| h[s.name] = @values[s.name] }
|
|
209
|
+
self.class.new(**base.merge(overrides.transform_keys(&:to_sym)))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Next sequential value for this schema. The base implementation raises; subclasses
|
|
213
|
+
# may override (e.g. {Schemas::CalVer}, {Schemas::SemVer}).
|
|
214
|
+
#
|
|
215
|
+
# @return [Schema]
|
|
216
|
+
# @raise [NotImplementedError] unless overridden
|
|
217
|
+
#
|
|
218
|
+
def succ
|
|
219
|
+
raise NotImplementedError, "#{self.class} does not define #succ; override it or use #with"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Previous sequential value for this schema. The base implementation raises; subclasses
|
|
223
|
+
# may override (e.g. {Schemas::CalVer}, {Schemas::SemVer}).
|
|
224
|
+
#
|
|
225
|
+
# @return [Schema]
|
|
226
|
+
# @raise [NotImplementedError] unless overridden
|
|
227
|
+
#
|
|
228
|
+
def pred
|
|
229
|
+
raise NotImplementedError, "#{self.class} does not define #pred; override it or use #with"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Returns the canonical string representation of the instance.
|
|
233
|
+
#
|
|
234
|
+
# @return [String] {.format}(+self+)
|
|
235
|
+
#
|
|
236
|
+
def to_s
|
|
237
|
+
self.class.format(self)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Returns a string representation of the instance.
|
|
241
|
+
#
|
|
242
|
+
# @return [String]
|
|
243
|
+
#
|
|
244
|
+
def inspect
|
|
245
|
+
attrs = self.class.segments.map { |segment| "#{segment.name}=#{@values[segment.name].inspect}" }.join(", ")
|
|
246
|
+
|
|
247
|
+
"#<#{self.class.name} #{attrs}>"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Compares two instances of the same class.
|
|
251
|
+
#
|
|
252
|
+
# @param other [Object]
|
|
253
|
+
# @return [Boolean]
|
|
254
|
+
#
|
|
255
|
+
def ==(other)
|
|
256
|
+
other.is_a?(self.class) && @values == other.instance_variable_get(:@values)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
alias eql? ==
|
|
260
|
+
|
|
261
|
+
# Returns a hash code for the instance.
|
|
262
|
+
#
|
|
263
|
+
# @return [Integer] hash consistent with {#eql?}
|
|
264
|
+
#
|
|
265
|
+
def hash
|
|
266
|
+
[self.class, @values].hash
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Compares two instances of the same class in segment declaration order;
|
|
270
|
+
# +nil+ optional values sort before non-+nil+. Subclasses (e.g. {Schemas::SemVer}) may override.
|
|
271
|
+
#
|
|
272
|
+
# @param other [Object]
|
|
273
|
+
# @return [Integer, nil] -1, 0, 1, or +nil+ if not comparable (different class or incomparable values)
|
|
274
|
+
#
|
|
275
|
+
def <=>(other)
|
|
276
|
+
return nil unless other.is_a?(self.class)
|
|
277
|
+
|
|
278
|
+
segs = self.class.segments
|
|
279
|
+
i = 0
|
|
280
|
+
while i < segs.length
|
|
281
|
+
segment = segs[i]
|
|
282
|
+
comparison = compare_values_for_sort(@values[segment.name], other.send(segment.name))
|
|
283
|
+
return nil if comparison.nil?
|
|
284
|
+
|
|
285
|
+
return comparison if comparison != 0
|
|
286
|
+
|
|
287
|
+
i += 1
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
0
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
# Compares two values for sort order.
|
|
296
|
+
#
|
|
297
|
+
# @param left [Object, nil]
|
|
298
|
+
# @param right [Object, nil]
|
|
299
|
+
# @return [Integer, nil]
|
|
300
|
+
#
|
|
301
|
+
def compare_values_for_sort(left, right)
|
|
302
|
+
if left.nil? && right.nil?
|
|
303
|
+
0
|
|
304
|
+
elsif left.nil?
|
|
305
|
+
-1
|
|
306
|
+
elsif right.nil?
|
|
307
|
+
1
|
|
308
|
+
else
|
|
309
|
+
comparison = left <=> right
|
|
310
|
+
|
|
311
|
+
return if comparison.nil?
|
|
312
|
+
|
|
313
|
+
comparison
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Initializes the internal segment values.
|
|
318
|
+
#
|
|
319
|
+
# @return [void]
|
|
320
|
+
#
|
|
321
|
+
def initialize_values
|
|
322
|
+
@values = {}
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../cursor"
|
|
7
|
+
require_relative "../segment"
|
|
8
|
+
require_relative "../types"
|
|
9
|
+
require_relative "../parser"
|
|
10
|
+
require_relative "../schema"
|
|
11
|
+
|
|
12
|
+
module Verbatim
|
|
13
|
+
module Schemas
|
|
14
|
+
# Calendar-style +YYYY.0M.0D+ version (+year+, zero-padded +month+ and +day+ in {#to_s}).
|
|
15
|
+
#
|
|
16
|
+
# Parsing accepts unpadded or padded numeric components (e.g. +2026.4.8+ or +2026.04.08+).
|
|
17
|
+
# This does not enforce real calendar dates (+2026.02.31+ parses); add your own validation if needed.
|
|
18
|
+
#
|
|
19
|
+
# @api public
|
|
20
|
+
#
|
|
21
|
+
class CalVer < Schema
|
|
22
|
+
delimiter "."
|
|
23
|
+
|
|
24
|
+
segment :year, :uint, pad: 4
|
|
25
|
+
segment :month, :uint, pad: 2, minimum: 1, maximum: 12
|
|
26
|
+
segment :day, :uint, pad: 2, minimum: 1, maximum: 31
|
|
27
|
+
|
|
28
|
+
# Next calendar day.
|
|
29
|
+
#
|
|
30
|
+
# @return [CalVer]
|
|
31
|
+
# @raise [ArgumentError] if the current date is invalid for +Date+ (e.g. Feb 30)
|
|
32
|
+
#
|
|
33
|
+
def succ
|
|
34
|
+
step_calendar(1)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Previous calendar day.
|
|
38
|
+
#
|
|
39
|
+
# @return [CalVer]
|
|
40
|
+
# @raise [ArgumentError] if the current date is invalid for +Date+ (e.g. Feb 30)
|
|
41
|
+
#
|
|
42
|
+
def pred
|
|
43
|
+
step_calendar(-1)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# @param delta [Integer] days to add (+1 / +-1+)
|
|
49
|
+
# @return [CalVer]
|
|
50
|
+
#
|
|
51
|
+
def step_calendar(delta)
|
|
52
|
+
d = Date.new(year, month, day) + delta
|
|
53
|
+
with(year: d.year, month: d.month, day: d.day)
|
|
54
|
+
rescue ArgumentError, Date::Error => e
|
|
55
|
+
raise ArgumentError, "invalid calendar date for CalVer#succ/#pred: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../cursor"
|
|
5
|
+
require_relative "../segment"
|
|
6
|
+
require_relative "../types"
|
|
7
|
+
require_relative "../parser"
|
|
8
|
+
require_relative "../schema"
|
|
9
|
+
require_relative "semver_compare"
|
|
10
|
+
|
|
11
|
+
module Verbatim
|
|
12
|
+
module Schemas
|
|
13
|
+
# Semantic Versioning 2.0.0 schema (+major.minor.patch+ with optional +-prerelease+ and ++build+).
|
|
14
|
+
#
|
|
15
|
+
# @api public
|
|
16
|
+
#
|
|
17
|
+
class SemVer < Schema
|
|
18
|
+
delimiter "."
|
|
19
|
+
|
|
20
|
+
segment :major, :uint, leading_zeros: false
|
|
21
|
+
segment :minor, :uint, leading_zeros: false
|
|
22
|
+
segment :patch, :uint, leading_zeros: false, delimiter_after: :none
|
|
23
|
+
segment :prerelease, :semver_ids,
|
|
24
|
+
optional: true,
|
|
25
|
+
lead: "-",
|
|
26
|
+
terminator: "+"
|
|
27
|
+
segment :build, :semver_ids,
|
|
28
|
+
optional: true,
|
|
29
|
+
lead: "+"
|
|
30
|
+
|
|
31
|
+
# Next release along the core line: increments +patch+ and clears prerelease and build.
|
|
32
|
+
#
|
|
33
|
+
# @return [SemVer]
|
|
34
|
+
#
|
|
35
|
+
def succ
|
|
36
|
+
with(patch: patch + 1, prerelease: nil, build: nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Previous plain release: decrements +patch+, or borrows from +minor+ or +major+ (lower fields zeroed).
|
|
40
|
+
# Only defined when +prerelease+ and +build+ are both absent; raises otherwise.
|
|
41
|
+
#
|
|
42
|
+
# @return [SemVer]
|
|
43
|
+
# @raise [ArgumentError] if prerelease or build is set, or if this is +0.0.0+
|
|
44
|
+
#
|
|
45
|
+
def pred
|
|
46
|
+
if prerelease || build
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"SemVer#pred is only defined for plain major.minor.patch (no prerelease or build)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if patch.positive?
|
|
52
|
+
with(patch: patch - 1)
|
|
53
|
+
elsif minor.positive?
|
|
54
|
+
with(minor: minor - 1, patch: 0)
|
|
55
|
+
elsif major.positive?
|
|
56
|
+
with(major: major - 1, minor: 0, patch: 0)
|
|
57
|
+
else
|
|
58
|
+
raise ArgumentError, "no predecessor for 0.0.0"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# SemVer 2.0.0 precedence: core numeric, then prerelease via {SemVerCompare}.
|
|
63
|
+
# Build metadata does not affect precedence; compared last for stable sorting only.
|
|
64
|
+
#
|
|
65
|
+
# @param other [Object]
|
|
66
|
+
# @return [Integer, nil] -1, 0, 1, or +nil+ if +other+ is not a {SemVer}
|
|
67
|
+
def <=>(other)
|
|
68
|
+
return nil unless other.is_a?(SemVer)
|
|
69
|
+
|
|
70
|
+
comparison = major <=> other.major
|
|
71
|
+
return comparison if comparison != 0
|
|
72
|
+
|
|
73
|
+
comparison = minor <=> other.minor
|
|
74
|
+
return comparison if comparison != 0
|
|
75
|
+
|
|
76
|
+
comparison = patch <=> other.patch
|
|
77
|
+
return comparison if comparison != 0
|
|
78
|
+
|
|
79
|
+
comparison = SemVerCompare.prerelease(prerelease, other.prerelease)
|
|
80
|
+
return comparison if comparison != 0
|
|
81
|
+
|
|
82
|
+
(build || "").to_s <=> (other.build || "").to_s
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verbatim
|
|
4
|
+
module Schemas
|
|
5
|
+
# SemVer 2.0.0 precedence for prerelease identifiers (+major+/+minor+/+patch+ compared separately in {SemVer#<=>}).
|
|
6
|
+
#
|
|
7
|
+
# @api public
|
|
8
|
+
#
|
|
9
|
+
module SemVerCompare
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Compares prerelease strings per SemVer 2.0.0.
|
|
13
|
+
# +nil+ means a release and sorts *after* any prerelease for the same core.
|
|
14
|
+
#
|
|
15
|
+
# @param left [String, nil]
|
|
16
|
+
# @param right [String, nil]
|
|
17
|
+
# @return [Integer] -1, 0, or 1
|
|
18
|
+
#
|
|
19
|
+
def prerelease(left, right)
|
|
20
|
+
case [left.nil?, right.nil?]
|
|
21
|
+
when [true, true] then 0
|
|
22
|
+
when [true, false] then 1
|
|
23
|
+
when [false, true] then -1
|
|
24
|
+
else
|
|
25
|
+
compare_prerelease_identifiers(left.split(".", -1), right.split(".", -1))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compares prerelease identifiers.
|
|
30
|
+
#
|
|
31
|
+
# @param ids_a [Array<String>]
|
|
32
|
+
# @param ids_b [Array<String>]
|
|
33
|
+
# @return [Integer] -1, 0, or 1
|
|
34
|
+
#
|
|
35
|
+
def compare_prerelease_identifiers(ids_a, ids_b)
|
|
36
|
+
i = 0
|
|
37
|
+
loop do
|
|
38
|
+
a_miss = i >= ids_a.length
|
|
39
|
+
b_miss = i >= ids_b.length
|
|
40
|
+
if a_miss && b_miss
|
|
41
|
+
return 0
|
|
42
|
+
elsif a_miss
|
|
43
|
+
return -1
|
|
44
|
+
elsif b_miss
|
|
45
|
+
return 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
comparison = compare_identifier(ids_a[i], ids_b[i])
|
|
49
|
+
return comparison if comparison != 0
|
|
50
|
+
|
|
51
|
+
i += 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Compares a single prerelease identifier.
|
|
56
|
+
#
|
|
57
|
+
# @param one [String]
|
|
58
|
+
# @param two [String]
|
|
59
|
+
# @return [Integer] -1, 0, or 1
|
|
60
|
+
#
|
|
61
|
+
def compare_identifier(one, two)
|
|
62
|
+
one_num = numeric_identifier?(one)
|
|
63
|
+
two_num = numeric_identifier?(two)
|
|
64
|
+
if one_num && two_num
|
|
65
|
+
Integer(one, 10) <=> Integer(two, 10)
|
|
66
|
+
elsif one_num
|
|
67
|
+
-1
|
|
68
|
+
elsif two_num
|
|
69
|
+
1
|
|
70
|
+
else
|
|
71
|
+
one <=> two
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Checks if a token is a numeric-only prerelease identifier.
|
|
76
|
+
#
|
|
77
|
+
# @param token [String]
|
|
78
|
+
# @return [Boolean] +true+ if +token+ is numeric-only per SemVer identifier rules
|
|
79
|
+
#
|
|
80
|
+
def numeric_identifier?(token)
|
|
81
|
+
token.match?(/\A[0-9]+\z/) && (token == "0" || token.match?(/\A[1-9]\d*\z/))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verbatim
|
|
4
|
+
# Immutable description of one field in a {Schema} (name, type, delimiters, options).
|
|
5
|
+
#
|
|
6
|
+
# @api public
|
|
7
|
+
#
|
|
8
|
+
class Segment
|
|
9
|
+
# @return [Symbol] segment field name
|
|
10
|
+
attr_reader :name
|
|
11
|
+
# @return [Symbol] registered type key (e.g. +:uint+)
|
|
12
|
+
attr_reader :type
|
|
13
|
+
# @return [Boolean] whether the segment may be omitted when its +lead+ is absent
|
|
14
|
+
attr_reader :optional
|
|
15
|
+
# @return [String, nil] literal prefix before the value (e.g. +-+), or +nil+
|
|
16
|
+
attr_reader :lead
|
|
17
|
+
# @return [Symbol, String] +:inherit+, +:none+, or an explicit delimiter string after this segment
|
|
18
|
+
attr_reader :delimiter_after
|
|
19
|
+
# @return [Hash] type-specific options (frozen)
|
|
20
|
+
attr_reader :options
|
|
21
|
+
|
|
22
|
+
# @param name [Symbol]
|
|
23
|
+
# @param type [Symbol]
|
|
24
|
+
# @param optional [Boolean]
|
|
25
|
+
# @param lead [String, nil]
|
|
26
|
+
# @param delimiter_after [Symbol, String] +:inherit+ (default), +:none+, or delimiter string
|
|
27
|
+
# @param options [Hash] extra options passed to the type handler
|
|
28
|
+
# @return [void]
|
|
29
|
+
#
|
|
30
|
+
def initialize(name:, type:, optional: false, lead: nil, delimiter_after: :inherit, options: {})
|
|
31
|
+
@name = name
|
|
32
|
+
@type = type
|
|
33
|
+
@optional = optional
|
|
34
|
+
@lead = lead
|
|
35
|
+
@delimiter_after = delimiter_after
|
|
36
|
+
@options = options.freeze
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Checks if the segment is optional.
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean] +true+ if {#optional} is +true+
|
|
43
|
+
#
|
|
44
|
+
def optional?
|
|
45
|
+
optional
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Checks if the segment has a lead.
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] +true+ if {#lead} is non-empty
|
|
51
|
+
#
|
|
52
|
+
def lead?
|
|
53
|
+
!lead.nil? && !lead.empty?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|