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,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verbatim
4
+ # Registry and dispatch for segment type handlers (+:uint+, +:int+, custom types).
5
+ #
6
+ # @api public
7
+ #
8
+ module Types
9
+ class << self
10
+ # Parses a value from a string according to a segment type.
11
+ #
12
+ # @param type [Symbol]
13
+ # @param cursor [Cursor]
14
+ # @param segment [Segment]
15
+ # @param parse_ctx [Object] opaque context (currently {Parser}); reserved for handlers
16
+ # @return [Object] parsed value
17
+ # @raise [ArgumentError] if +type+ is unknown
18
+ # @raise [ParseError] if the handler rejects input
19
+ #
20
+ def parse(type, cursor, segment, parse_ctx)
21
+ handler = registry.fetch(type) { raise ArgumentError, "unknown segment type: #{type.inspect}" }
22
+ handler.parse(cursor, segment, parse_ctx)
23
+ end
24
+
25
+ # Formats a value into a string according to a segment type.
26
+ #
27
+ # @param type [Symbol]
28
+ # @param value [Object]
29
+ # @param segment [Segment]
30
+ # @return [String] fragment for {#Schema#to_s}
31
+ # @raise [ArgumentError] if +type+ is unknown or +value+ is invalid for the type
32
+ #
33
+ def format(type, value, segment)
34
+ handler = registry.fetch(type) { raise ArgumentError, "unknown segment type: #{type.inspect}" }
35
+ handler.format(value, segment)
36
+ end
37
+
38
+ # Registers a new type handler.
39
+
40
+ # @param type [Symbol]
41
+ # @param handler [Object] object responding to +parse(cursor, segment, parse_ctx)+ and +format(value, segment)+
42
+ # @return [void]
43
+ #
44
+ def register(type, handler)
45
+ registry[type] = handler
46
+ end
47
+
48
+ private
49
+
50
+ # Returns the registry of type handlers.
51
+ #
52
+ # @return [Hash{Symbol => Object}] registry of type handlers
53
+ #
54
+ def registry
55
+ @registry ||= {}
56
+ end
57
+ end
58
+
59
+ # Internal helpers and built-in type handler implementations.
60
+
61
+ module Handlers
62
+ module_function
63
+
64
+ # Raises a parse error.
65
+
66
+ # @param cursor [Cursor]
67
+ # @param segment [Segment, nil]
68
+ # @param message [String]
69
+ # @return [void]
70
+ # @raise [ParseError]
71
+
72
+ def fail_parse!(cursor, segment, message)
73
+ raise ParseError.new(
74
+ message,
75
+ string: cursor.string,
76
+ index: cursor.pos,
77
+ segment: segment&.name
78
+ )
79
+ end
80
+
81
+ # Unsigned decimal integer segment type (+:uint+).
82
+ #
83
+ # @api public
84
+ #
85
+ class Uint
86
+ # Parses a value from a string according to a segment type.
87
+ #
88
+ # @param cursor [Cursor]
89
+ # @param segment [Segment]
90
+ # @param _parse_ctx [Object]
91
+ # @return [Integer]
92
+ # @raise [ParseError]
93
+ #
94
+ def parse(cursor, segment, _parse_ctx)
95
+ start = cursor.pos
96
+ Handlers.fail_parse!(cursor, segment, "expected digit") if cursor.eos? || cursor.peek !~ /\d/
97
+
98
+ cursor.advance(1) while !cursor.eos? && cursor.peek =~ /\d/
99
+ raw = cursor.string[start, cursor.pos - start]
100
+ if segment.options[:leading_zeros] == false && raw.length > 1 && raw.start_with?("0")
101
+ Handlers.fail_parse!(cursor, segment, "leading zeros not allowed")
102
+ end
103
+ value = Integer(raw, 10)
104
+ validate_uint_range!(cursor, segment, value)
105
+
106
+ value
107
+ end
108
+
109
+ # Formats a value into a string according to a segment type.
110
+ #
111
+ # @param value [Integer]
112
+ # @param segment [Segment]
113
+ # @return [String]
114
+ # @raise [ArgumentError]
115
+ #
116
+ def format(value, segment)
117
+ raise ArgumentError, "uint value must be Integer, got #{value.class}" unless value.is_a?(Integer)
118
+
119
+ pad = segment.options[:pad]
120
+ if pad
121
+ raise ArgumentError, "options[:pad] must be a positive Integer" unless pad.is_a?(Integer) && pad.positive?
122
+
123
+ Kernel.format("%0*d", pad, value)
124
+ else
125
+ value.to_s
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ # Validates the range of a uint value.
132
+ #
133
+ # @param cursor [Cursor]
134
+ # @param segment [Segment]
135
+ # @param value [Integer]
136
+ # @return [void]
137
+ # @raise [ParseError]
138
+ #
139
+ def validate_uint_range!(cursor, segment, value)
140
+ if (min = segment.options[:minimum]) && value < min
141
+ Handlers.fail_parse!(cursor, segment, "value #{value} is below minimum #{min}")
142
+ end
143
+ return unless (max = segment.options[:maximum]) && value > max
144
+
145
+ Handlers.fail_parse!(cursor, segment, "value #{value} is above maximum #{max}")
146
+ end
147
+ end
148
+
149
+ # Signed decimal integer (+optional leading +-+); +:int+ type.
150
+ #
151
+ # @api public
152
+ #
153
+ class Int
154
+ # Parses a value from a string according to a segment type.
155
+ #
156
+ # @param cursor [Cursor]
157
+ # @param segment [Segment]
158
+ # @param _parse_ctx [Object]
159
+ # @return [Integer]
160
+ # @raise [ParseError]
161
+ #
162
+ def parse(cursor, segment, _parse_ctx)
163
+ start = cursor.pos
164
+ cursor.advance(1) if !cursor.eos? && cursor.peek == "-"
165
+ Handlers.fail_parse!(cursor, segment, "expected digit") if cursor.eos? || cursor.peek !~ /\d/
166
+
167
+ cursor.advance(1) while !cursor.eos? && cursor.peek =~ /\d/
168
+ raw = cursor.string[start, cursor.pos - start]
169
+ Integer(raw, 10)
170
+ end
171
+
172
+ # Formats a value into a string according to a segment type.
173
+ #
174
+ # @param value [Integer]
175
+ # @param _segment [Segment]
176
+ # @return [String]
177
+ # @raise [ArgumentError]
178
+ #
179
+ def format(value, _segment)
180
+ raise ArgumentError, "int value must be Integer, got #{value.class}" unless value.is_a?(Integer)
181
+
182
+ value.to_s
183
+ end
184
+ end
185
+
186
+ # Non-empty run of +[0-9A-Za-z-]+; +:token+ type.
187
+ #
188
+ # @api public
189
+ #
190
+ class Token
191
+ # Parses a value from a string according to a segment type.
192
+ #
193
+ # @param cursor [Cursor]
194
+ # @param segment [Segment]
195
+ # @param _parse_ctx [Object]
196
+ # @return [String]
197
+ # @raise [ParseError]
198
+ #
199
+ def parse(cursor, segment, _parse_ctx)
200
+ start = cursor.pos
201
+ if cursor.eos? || cursor.peek !~ /[0-9A-Za-z-]/
202
+ Handlers.fail_parse!(cursor, segment,
203
+ "expected token character")
204
+ end
205
+
206
+ cursor.advance(1) while !cursor.eos? && cursor.peek =~ /[0-9A-Za-z-]/
207
+ cursor.string[start, cursor.pos - start]
208
+ end
209
+
210
+ # Formats a value into a string according to a segment type.
211
+ #
212
+ # @param value [Object] coerced with +#to_s+
213
+ # @param _segment [Segment]
214
+ # @return [String]
215
+ # @raise [ArgumentError] if the string is empty
216
+ #
217
+ def format(value, _segment)
218
+ string = value.to_s
219
+
220
+ raise ArgumentError, "token must be non-empty" if string.empty?
221
+
222
+ string
223
+ end
224
+ end
225
+
226
+ # Match anchored regexp; +:string+ type (+options[:pattern]+ required).
227
+ #
228
+ # @api public
229
+ #
230
+ class StringType
231
+ # Parses a value from a string according to a segment type.
232
+ #
233
+ # @param cursor [Cursor]
234
+ # @param segment [Segment]
235
+ # @param _parse_ctx [Object]
236
+ # @return [String]
237
+ # @raise [ArgumentError] if +pattern+ is missing
238
+ # @raise [ParseError] if the remainder does not match
239
+ #
240
+ def parse(cursor, segment, _parse_ctx)
241
+ pattern = segment.options[:pattern]
242
+ raise ArgumentError, ":string requires options[:pattern] Regexp" unless pattern.is_a?(Regexp)
243
+
244
+ rest = cursor.remainder
245
+ anchored = Regexp.new("\\A(?:#{pattern.source})", pattern.options)
246
+ m = anchored.match(rest)
247
+ Handlers.fail_parse!(cursor, segment, "did not match pattern") unless m
248
+
249
+ cursor.advance(m.end(0))
250
+ m[0]
251
+ end
252
+
253
+ # Formats a value into a string according to a segment type.
254
+ #
255
+ # @param value [Object]
256
+ # @param _segment [Segment]
257
+ # @return [String]
258
+ #
259
+ def format(value, _segment)
260
+ value.to_s
261
+ end
262
+ end
263
+
264
+ # Dot-separated SemVer identifier string; +:semver_ids+ type.
265
+ #
266
+ # @api public
267
+ #
268
+ class SemverIdentifiers
269
+ # Checks if a token is a valid SemVer 2.0 identifier.
270
+ #
271
+ # @param token [String]
272
+ # @return [Boolean] +true+ if +token+ is a valid SemVer 2.0 identifier
273
+ #
274
+ def self.valid_ident?(token)
275
+ return false if token.empty?
276
+
277
+ if token.match?(/\A[0-9]+\z/)
278
+ token == "0" || token.match?(/\A[1-9]\d*\z/)
279
+ else
280
+ token.match?(/\A[0-9A-Za-z-]+\z/)
281
+ end
282
+ end
283
+
284
+ # Parses a value from a string according to a segment type.
285
+ #
286
+ # @param cursor [Cursor]
287
+ # @param segment [Segment]
288
+ # @param _parse_ctx [Object]
289
+ # @return [String] dot-separated identifiers (no leading +lead+)
290
+ # @raise [ParseError]
291
+ #
292
+ def parse(cursor, segment, _parse_ctx)
293
+ terminator = segment.options[:terminator]
294
+ rest = cursor.remainder
295
+ stop_idx = if terminator
296
+ idx = rest.index(terminator)
297
+ idx.nil? ? rest.length : idx
298
+ else
299
+ rest.length
300
+ end
301
+ chunk = rest[0, stop_idx]
302
+ Handlers.fail_parse!(cursor, segment, "expected semver identifiers") if chunk.empty?
303
+
304
+ chunk.split(".", -1).each do |p|
305
+ unless self.class.valid_ident?(p)
306
+ Handlers.fail_parse!(cursor, segment,
307
+ "invalid semver identifier #{p.inspect}")
308
+ end
309
+ end
310
+ cursor.advance(chunk.length)
311
+
312
+ chunk
313
+ end
314
+
315
+ # Formats a value into a string according to a segment type.
316
+ #
317
+ # @param value [Object]
318
+ # @param _segment [Segment]
319
+ # @return [String]
320
+ # @raise [ArgumentError] if empty or any identifier is invalid
321
+ #
322
+ def format(value, _segment)
323
+ string = value.to_s
324
+ raise ArgumentError, "semver identifiers must be non-empty" if string.empty?
325
+
326
+ string.split(".", -1).each do |p|
327
+ raise ArgumentError, "invalid semver identifier #{p.inspect}" unless self.class.valid_ident?(p)
328
+ end
329
+
330
+ string
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
336
+
337
+ Verbatim::Types.register(:uint, Verbatim::Types::Handlers::Uint.new)
338
+ Verbatim::Types.register(:int, Verbatim::Types::Handlers::Int.new)
339
+ Verbatim::Types.register(:token, Verbatim::Types::Handlers::Token.new)
340
+ Verbatim::Types.register(:string, Verbatim::Types::Handlers::StringType.new)
341
+ Verbatim::Types.register(:semver_ids, Verbatim::Types::Handlers::SemverIdentifiers.new)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verbatim
4
+ # Gem version string (semantic versioning of the library itself).
5
+ #
6
+ # @return [String]
7
+ #
8
+ VERSION = "0.1.0"
9
+ end
data/lib/verbatim.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verbatim/version"
4
+ require_relative "verbatim/errors"
5
+ require_relative "verbatim/cursor"
6
+ require_relative "verbatim/segment"
7
+ require_relative "verbatim/types"
8
+ require_relative "verbatim/parser"
9
+ require_relative "verbatim/schema"
10
+ require_relative "verbatim/schemas/semver"
11
+ require_relative "verbatim/schemas/calver"
12
+
13
+ # Root namespace for the Verbatim version-schema library.
14
+ #
15
+ # @api public
16
+ #
17
+ module Verbatim
18
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: verbatim
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dustin Dugal
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.12'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.12'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.60'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.60'
54
+ description: 'Define version string layouts with a class DSL, parse with .parse, round-trip
55
+ with #to_s.'
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - LICENSE
62
+ - README.md
63
+ - lib/verbatim.rb
64
+ - lib/verbatim/cursor.rb
65
+ - lib/verbatim/errors.rb
66
+ - lib/verbatim/parser.rb
67
+ - lib/verbatim/schema.rb
68
+ - lib/verbatim/schemas/calver.rb
69
+ - lib/verbatim/schemas/semver.rb
70
+ - lib/verbatim/schemas/semver_compare.rb
71
+ - lib/verbatim/segment.rb
72
+ - lib/verbatim/types.rb
73
+ - lib/verbatim/version.rb
74
+ homepage: https://github.com/dsdugal/verbatim
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ homepage_uri: https://github.com/dsdugal/verbatim
79
+ source_code_uri: https://github.com/dsdugal/verbatim
80
+ changelog_uri: https://github.com/dsdugal/verbatim/blob/main/CHANGELOG.md
81
+ rubygems_mfa_required: 'true'
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 3.2.0
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 4.0.9
97
+ specification_version: 4
98
+ summary: Declarative software version schemas
99
+ test_files: []