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.
@@ -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