argot 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 96b176358ac20b03d7377f0555b06de9d0054cf464b686b9fda573381a0628c0
4
+ data.tar.gz: 8837551ecd8af3fe9306f37734e37a8a2ea1b8fc31c5b0568f4db7b410d53f70
5
+ SHA512:
6
+ metadata.gz: 65f000cb80437135c29ec679b712b6684f41b0a34e3cf8bab66511bd8a35ab1f4e12ef5a8b7b0b22be960f4d89180e528f69357aff8ecb3b1acb1ff0da72207b
7
+ data.tar.gz: 11ab730c7e7887a0c7d3dbc412b56f733a437594c82f1cc834a64a0c26da73e7fe7629a8fae79827b5a534945ea1d998a1140b397a8df72cb633d057d8586a26
data/.rubocop.yml ADDED
File without changes
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.4
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2024 Charlton Trezevant
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # Argot
2
+
3
+ > **Argot** (/är′gō/)
4
+ > _noun_
5
+ >
6
+ > A characteristic language of a particular group (as among thieves).
7
+
8
+ Argot is a simple gem for quickly and flexibly building minimal, validatable YAML schemas. Its original inspiration came from [this blog post](https://notes.burke.libbey.me/yaml-schema/) by [@burke](https://github.com/burke).
9
+
10
+ Argot aims to be:
11
+ - Simple (i.e., non-bureuacratic)
12
+ - Flexible
13
+ - Easy to Understand and Extend
14
+
15
+ Argot aims to avoid:
16
+ - Arcane syntax
17
+ - Having many layers of abstraction between the schema you write and the document you validate
18
+
19
+ ### Example Schema
20
+
21
+ ```yaml
22
+ %TAG ! tag:argot.packfiles.io,2024:
23
+ ---
24
+
25
+ # "version" is optional. When supplied, it must be the literal string "latest" or match the regular expression ^\d+.\d+.\d+$
26
+ # The !one tag describes a sequence of acceptable validation rules for a given key
27
+ !o version: !one
28
+ - !l "latest"
29
+ - !x '^\d+.\d+.\d+$'
30
+
31
+ # "farmer_name" is required, and its value must match the regular expression ^Farmer [a-zA-Z]+$
32
+ !r farmer_name: !x '^Farmer [a-zA-Z]+$'
33
+
34
+ # "farmer_level" must be an Integer type
35
+ !o farmer_level: !t Integer
36
+
37
+ # The !x tag can also be used to describe validations that are applied
38
+ # to any keys in a mapping whose name matches a regular expression
39
+ !o meats:
40
+ !x 'beef_from_.*':
41
+ !r grade: !one
42
+ - !l "A"
43
+ - !l "B"
44
+ - !l "C"
45
+ - !l "F"
46
+
47
+ # When a parent key is marked as optional, validation rules will be applied to its children only
48
+ # when that parent key is present in the document. In this case, the absence of the required
49
+ # pasture_uuid key will only fail validation if its parent goat_zone key is present.
50
+ !o farm:
51
+ !o goat_zone:
52
+ !r pasture_uuid: !x '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
53
+ !o goats_count: !t Integer
54
+ ```
55
+
56
+ ## Installation
57
+
58
+ Install the gem and add to the application's Gemfile by executing:
59
+
60
+ $ bundle add argot
61
+
62
+ If bundler is not being used to manage dependencies, install the gem by executing:
63
+
64
+ $ gem install argot
65
+
66
+ ## Usage
67
+
68
+ First, register an Argot schema:
69
+
70
+ ```ruby
71
+ test_schema = File.read("./test/fixtures/schemas/01.yml")
72
+ Argot::Schema.register(:test_schema, test_schema)
73
+ ```
74
+
75
+ Then, validate some YAML files:
76
+
77
+ ```ruby
78
+ rules = Argot::Schema.for(:test_schema)
79
+
80
+ passing_document = File.read("./test/fixtures/documents/01-pass.yml")
81
+ failing_document = File.read("./test/fixtures/documents/01-fail.yml")
82
+
83
+
84
+ validator = Argot::Validator.parse_and_validate(rules, failing_document)
85
+ validator.errors? # true
86
+ validator.safe_load # nil
87
+
88
+ validator = Argot::Validator.parse_and_validate(rules, passing_document)
89
+ validator.errors? # false
90
+ validator.safe_load # parsed document Hash
91
+ ```
92
+
93
+ ## Development
94
+
95
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
96
+
97
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
98
+
99
+ ## Contributing
100
+
101
+ Bug reports and pull requests are welcome on GitHub at https://github.com/chtzvt/argot.
102
+
103
+ ## License
104
+
105
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
106
+
107
+ Made with :heart: by [Packfiles :package:](https://packfiles.io)
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
@@ -0,0 +1,64 @@
1
+ module Argot
2
+ class Annotation
3
+ include Comparable
4
+
5
+ NOTICE = :notice
6
+ WARNING = :warning
7
+ FAILURE = :failure
8
+
9
+ ORDERED_LEVELS = {
10
+ NOTICE => 0,
11
+ WARNING => 1,
12
+ FAILURE => 2
13
+ }
14
+
15
+ attr_reader :start_line, :end_line, :start_column, :end_column, :level, :message, :title, :details
16
+ attr_accessor :path
17
+
18
+ def initialize(path: nil, location: nil, level: nil, message: nil, title: nil, details: nil)
19
+ @path = path || "input"
20
+
21
+ self.location = if location.is_a? Argot::Location
22
+ location
23
+ else
24
+ Argot::Location.new(**location)
25
+ end
26
+
27
+ @level = level || NOTICE
28
+ @message = message
29
+ @title = title || message
30
+ @details = details || message
31
+ end
32
+
33
+ def location=(location)
34
+ @start_line = location.start_line
35
+ @end_line = location.end_line
36
+ @start_column = location.start_column
37
+ @end_column = location.end_column
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ path: @path,
43
+ start_line: @start_line,
44
+ end_line: @end_line,
45
+ start_column: @start_column,
46
+ end_column: @end_column,
47
+ annotation_level: @level,
48
+ message: @message,
49
+ title: @title,
50
+ raw_details: @details
51
+ }
52
+ end
53
+
54
+ def to_s
55
+ msg = "[#{@level.to_s.upcase}] At lines:#{@start_line}-#{@end_line} cols:#{@start_column}-#{@end_column} of #{@path} -- #{@title}: #{@message}"
56
+ msg += " (#{@details})" unless @details == @message
57
+ msg
58
+ end
59
+
60
+ def <=>(other)
61
+ ORDERED_LEVELS[level] <=> ORDERED_LEVELS[other.level]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,12 @@
1
+ module Argot
2
+ class Location
3
+ attr_accessor :start_line, :end_line, :start_column, :end_column
4
+
5
+ def initialize(start_line: nil, end_line: nil, start_column: nil, end_column: nil)
6
+ @start_line = start_line || 1
7
+ @end_line = end_line || start_line || 1
8
+ @start_column = start_column || 1
9
+ @end_column = end_column || start_column || 1
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,386 @@
1
+ module Argot
2
+ module Schema
3
+ def self.register(id, schema_yaml)
4
+ @schemas ||= {}
5
+ @schemas[id.is_a?(Symbol) ? id : id.to_sym] = load(schema_yaml)
6
+ end
7
+
8
+ def self.for(id)
9
+ @schemas[id]&.dup
10
+ end
11
+
12
+ def self.load(schema_yaml)
13
+ schema = Schema::Parser.parse(schema_yaml)
14
+ ruleset = Schema::Ruleset.new
15
+ Schema::Compiler.compile(schema.tree, ruleset)
16
+ ruleset
17
+ end
18
+
19
+ class Ruleset
20
+ attr_reader :rules
21
+
22
+ def initialize
23
+ @rules = Hash.new { |hash, key| hash[key] = {key: [], value: []} }
24
+ end
25
+
26
+ def add(type, tag, path)
27
+ case type
28
+ when :key
29
+ @rules[path][:key] << tag
30
+ when :value
31
+ @rules[path][:value] << tag
32
+ end
33
+ end
34
+
35
+ def permit_path?(path)
36
+ return false if path.empty? || path.nil?
37
+
38
+ @rules.each_key do |key_path|
39
+ return true if match_path?(key_path, path)
40
+ end
41
+
42
+ false
43
+ end
44
+
45
+ def permit_value?(path, value)
46
+ rule = lookup(path)
47
+
48
+ return false if rule.nil?
49
+
50
+ rule[:value].any? { |tag| tag.validate(value) }
51
+ end
52
+
53
+ def lookup(path)
54
+ @rules.each do |key_path, rule_set|
55
+ return rule_set if match_path?(key_path, path)
56
+ end
57
+ nil
58
+ end
59
+
60
+ def paths
61
+ @rules.keys
62
+ end
63
+
64
+ def subkeys(partial_path)
65
+ return @rules.keys if partial_path.empty?
66
+
67
+ @rules.keys.map do |key_path|
68
+ if match_subpath?(key_path, partial_path)
69
+ next_element = key_path[partial_path.size]
70
+ [next_element] if next_element
71
+ end
72
+ end.compact!.uniq
73
+ end
74
+
75
+ def required_path?(path)
76
+ parent_path = path.take((path.length >= 1) ? path.length - 1 : 0)
77
+ parent_rule_set = lookup(parent_path)
78
+ rule_set = lookup(path)
79
+
80
+ if parent_rule_set.nil? && rule_set.nil?
81
+ false
82
+ elsif parent_rule_set.nil? && rule_set[:key].any? { |tag| tag.is_a?(Argot::Tag::Required) }
83
+ true
84
+ elsif !parent_rule_set.nil? && parent_rule_set[:key].any? { |tag| tag.is_a?(Argot::Tag::Optional) } && !rule_set.nil? && rule_set[:key].any? { |tag| tag.is_a?(Argot::Tag::Required) }
85
+ true
86
+ else
87
+ false
88
+ end
89
+ end
90
+
91
+ def match_path?(key_path, lookup_path)
92
+ return false unless key_path.size == lookup_path.size
93
+
94
+ key_path.zip(lookup_path).all? do |key_part, lookup_part|
95
+ case key_part
96
+ when String
97
+ key_part == lookup_part
98
+ when Regexp
99
+ if lookup_part.is_a?(String)
100
+ key_part.match?(lookup_part)
101
+ elsif lookup_part.is_a?(Regexp)
102
+ key_part == lookup_part
103
+ end
104
+ else
105
+ false
106
+ end
107
+ end
108
+ end
109
+
110
+ def match_subpath?(key_path, partial_path)
111
+ return false unless key_path.size > partial_path.size
112
+
113
+ key_path.first(partial_path.size).zip(partial_path).all? do |key_part, partial_part|
114
+ case key_part
115
+ when String
116
+ key_part == partial_part
117
+ when Regexp
118
+ if partial_part.is_a?(String)
119
+ key_part.match?(partial_part)
120
+ elsif partial_part.is_a?(Regexp)
121
+ key_part == partial_part
122
+ end
123
+ else
124
+ false
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ class Compiler
131
+ def self.compile(node, ruleset)
132
+ return unless node.is_a? Argot::Schema::Node
133
+
134
+ return unless node.value.is_a?(Array)
135
+
136
+ node.value.each do |child|
137
+ next if child.is_a? Argot::Schema::Node
138
+
139
+ child_node_annotation = child.first
140
+ child_node = child.last
141
+
142
+ if child_node_annotation.tag_is_a?(Tag::Pattern)
143
+ ruleset.add(:key, Tag::Optional.new, child_node.path)
144
+ else
145
+ ruleset.add(:key, child_node_annotation.tag, child_node.path)
146
+ end
147
+
148
+ if child_node.tag?
149
+ if child_node.tag_is_a?(Argot::Tag::OneOf)
150
+ child_node.value.each do |oneof_opt|
151
+ if oneof_opt.tag?
152
+ ruleset.add(:value, oneof_opt.tag, oneof_opt.path)
153
+ else
154
+ compile(oneof_opt, ruleset)
155
+ end
156
+ end
157
+ else
158
+ ruleset.add(:value, child_node.tag, child_node.path)
159
+ end
160
+ end
161
+
162
+ compile(child_node, ruleset)
163
+ end
164
+ end
165
+ end
166
+
167
+ class Node
168
+ attr_accessor :value, :path
169
+ attr_reader :tag, :location
170
+
171
+ def initialize(value: nil, tag: nil, location: nil, path: [])
172
+ @value = value
173
+ self.tag = tag
174
+ configure_tag!
175
+ self.location = location
176
+ @path = path
177
+ end
178
+
179
+ def tag?
180
+ !@tag.nil?
181
+ end
182
+
183
+ def tag_is_a?(klass)
184
+ @tag.is_a?(klass)
185
+ end
186
+
187
+ def configure_tag!
188
+ @tag.configure(@value) if tag?
189
+ end
190
+
191
+ def tag=(tag)
192
+ @tag = Argot::Tag.for(tag)
193
+ end
194
+
195
+ def location=(loc)
196
+ @location = Argot::Location.new(**loc)
197
+ end
198
+ end
199
+
200
+ class Parser < ::Psych::Handler
201
+ attr_reader :tree, :locations, :current_path
202
+
203
+ class << self
204
+ def parse(schema_yaml)
205
+ handler = Argot::Schema::Parser.new
206
+ parser = Psych::Parser.new(handler)
207
+ parser.parse(schema_yaml)
208
+ handler
209
+ end
210
+ end
211
+
212
+ def initialize
213
+ super
214
+ @context = []
215
+ @tree = nil
216
+ @current_path = []
217
+ @locations = {}
218
+ @scalar_scanner = build_safe_scalar_scanner
219
+ end
220
+
221
+ def build_safe_scalar_scanner
222
+ class_loader = Psych::ClassLoader::Restricted.new([], [])
223
+ Psych::ScalarScanner.new(class_loader)
224
+ end
225
+
226
+ def context_empty?
227
+ @context.empty?
228
+ end
229
+
230
+ def current_context
231
+ @context.last
232
+ end
233
+
234
+ def in_sequence?
235
+ current_context&.key?(:sequence)
236
+ end
237
+
238
+ def in_mapping?
239
+ current_context&.key?(:mapping)
240
+ end
241
+
242
+ def push_path(val)
243
+ @current_path.push(val)
244
+ end
245
+
246
+ def pop_path
247
+ @current_path.pop
248
+ end
249
+
250
+ def dup_path
251
+ @current_path.dup
252
+ end
253
+
254
+ def event_location(start_line, start_column, end_line, end_column)
255
+ @current_location = {
256
+ start_line: start_line + 1,
257
+ end_line: end_line + 1,
258
+ start_column: start_column + 1,
259
+ end_column: end_column + 1
260
+ }
261
+ end
262
+
263
+ def start_mapping(anchor, tag, implicit, style)
264
+ @context.push({mapping: [], tag: tag, key: nil})
265
+ end
266
+
267
+ def end_mapping
268
+ mapping = @context.pop
269
+
270
+ node = Node.new(
271
+ value: mapping[:mapping],
272
+ tag: mapping[:tag],
273
+ location: @current_location.dup,
274
+ path: dup_path
275
+ )
276
+
277
+ if context_empty?
278
+ @tree = node
279
+ return
280
+ end
281
+
282
+ if in_sequence?
283
+ current_context[:sequence] << node
284
+ elsif in_mapping?
285
+ if current_context[:key].nil?
286
+ raise Argot::Unprocessable, "Unexpected state in end_mapping"
287
+ else
288
+ current_context[:mapping] << [current_context[:key], node]
289
+ current_context[:key] = nil
290
+ pop_path
291
+ end
292
+ else
293
+ raise Argot::Unprocessable, "Unknown context in end_mapping"
294
+ end
295
+ end
296
+
297
+ def scalar(value, anchor, tag, plain, quoted, style)
298
+ parsed_value = parse_scalar(value)
299
+
300
+ node = Node.new(
301
+ value: parsed_value,
302
+ tag: tag,
303
+ location: @current_location.dup,
304
+ path: dup_path
305
+ )
306
+
307
+ if context_empty?
308
+ @tree = node
309
+ return
310
+ end
311
+
312
+ if in_sequence?
313
+ current_context[:sequence] << node
314
+ return
315
+ end
316
+
317
+ if in_mapping?
318
+ if current_context[:key].nil?
319
+ # We are processing a key
320
+ if node.tag_is_a?(Tag::Pattern)
321
+ push_path Regexp.new(parsed_value)
322
+ else
323
+ push_path parsed_value.to_s
324
+ end
325
+
326
+ location_key = @current_path.join(".")
327
+ @locations[location_key] = node.location
328
+
329
+ current_context[:key] = node
330
+ else
331
+ # We are processing a value
332
+
333
+ current_context[:mapping] << [current_context[:key], node]
334
+ current_context[:key] = nil
335
+ pop_path
336
+ end
337
+ return
338
+ end
339
+
340
+ raise Argot::Unprocessable, "Unknown context in scalar"
341
+ end
342
+
343
+ def start_sequence(anchor, tag, implicit, style)
344
+ @context.push({sequence: [], tag: tag})
345
+ end
346
+
347
+ def end_sequence
348
+ sequence = @context.pop
349
+
350
+ node = Node.new(
351
+ value: sequence[:sequence],
352
+ tag: sequence[:tag],
353
+ location: @current_location.dup,
354
+ path: dup_path
355
+ )
356
+
357
+ if context_empty?
358
+ @tree = node
359
+ return
360
+ end
361
+
362
+ if in_sequence?
363
+ current_context[:sequence] << node
364
+ elsif in_mapping?
365
+ if current_context[:key].nil?
366
+ raise Argot::Unprocessable, "Unexpected state in end_sequence"
367
+ else
368
+ current_context[:mapping] << [current_context[:key], node]
369
+ current_context[:key] = nil
370
+ pop_path
371
+ end
372
+ else
373
+ raise Argot::Unprocessable, "Unknown context in end_sequence"
374
+ end
375
+ end
376
+
377
+ private
378
+
379
+ def parse_scalar(value)
380
+ @scalar_scanner.tokenize(value)
381
+ rescue ArgumentError, Psych::SyntaxError
382
+ value.to_s
383
+ end
384
+ end
385
+ end
386
+ end
data/lib/argot/tag.rb ADDED
@@ -0,0 +1,76 @@
1
+ module Argot
2
+ module Tag
3
+ @tags = {}
4
+ @global_uri = "tag:argot.packfiles.io,2024"
5
+
6
+ def self.register(klass)
7
+ full_tag = "#{klass.uri}:#{klass.tag_name}"
8
+ @tags[full_tag] = Object.const_get(klass.name)
9
+ end
10
+
11
+ def self.for(tag)
12
+ @tags[tag]&.new
13
+ end
14
+
15
+ def self.tags
16
+ @tags
17
+ end
18
+
19
+ def self.global_uri
20
+ @global_uri
21
+ end
22
+
23
+ def self.global_uri=(uri)
24
+ @global_uri = uri
25
+ end
26
+
27
+ class Base
28
+ class << self
29
+ attr_reader :annotation_type
30
+
31
+ def annotates_key?
32
+ @annotation_type == :key || @annotation_type == :key_or_value
33
+ end
34
+
35
+ def annotates_value?
36
+ @annotation_type == :value || @annotation_type == :key_or_value
37
+ end
38
+
39
+ def annotates(arg)
40
+ @annotation_type = arg if %i[key value key_or_value].include?(arg)
41
+ end
42
+
43
+ def tag_name(name = nil)
44
+ if name
45
+ @tag_name = name
46
+ Argot::Tag.register(self)
47
+ end
48
+ @tag_name
49
+ end
50
+
51
+ def uri(uri = nil)
52
+ return Argot::Tag.global_uri if uri.nil?
53
+
54
+ @uri = "tag:" + uri
55
+ end
56
+ end
57
+
58
+ def annotation_type
59
+ self.class.annotation_type
60
+ end
61
+
62
+ def annotates_key?
63
+ self.class.annotates_key?
64
+ end
65
+
66
+ def annotates_value?
67
+ self.class.annotates_value?
68
+ end
69
+
70
+ def configure(value)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ Dir[File.join(__dir__, "tags", "*.rb")].each { |file| require file }
@@ -0,0 +1,25 @@
1
+ module Argot
2
+ module Tag
3
+ class Literal < Base
4
+ tag_name "literal"
5
+ tag_name "l"
6
+ annotates :value
7
+
8
+ def initialize
9
+ @expected_value = nil
10
+ end
11
+
12
+ def configure(value)
13
+ @expected_value = value
14
+ end
15
+
16
+ def expectation
17
+ "'#{@expected_value}'"
18
+ end
19
+
20
+ def validate(value)
21
+ value == @expected_value
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module Argot
2
+ module Tag
3
+ class OneOf < Base
4
+ tag_name "oneof"
5
+ tag_name "one"
6
+
7
+ def initialize
8
+ super
9
+ @options = []
10
+ end
11
+
12
+ def configure(options)
13
+ @options = options
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ module Argot
2
+ module Tag
3
+ class Optional < Base
4
+ tag_name "optional"
5
+ tag_name "o"
6
+ annotates :key
7
+
8
+ def validate(value)
9
+ # Optional fields do not produce errors if missing
10
+ true
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ module Argot
2
+ module Tag
3
+ class Pattern < Base
4
+ tag_name "rexpr"
5
+ tag_name "x"
6
+ annotates :key_or_value
7
+
8
+ def initialize
9
+ super
10
+ @regex = nil
11
+ end
12
+
13
+ def configure(pattern)
14
+ @regex = Regexp.new(pattern)
15
+ end
16
+
17
+ def expectation
18
+ "a match for the regular expression '#{@regex.source}'"
19
+ end
20
+
21
+ def validate(value)
22
+ value.to_s.match?(@regex)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,47 @@
1
+ module Argot
2
+ module Tag
3
+ class Range < Base
4
+ tag_name "range"
5
+ tag_name "rg"
6
+ annotates :value
7
+
8
+ def initialize
9
+ super
10
+ @range = nil
11
+ end
12
+
13
+ def configure(range_str)
14
+ # Parse the range string using a regex
15
+ # Supports formats like '1..10', '1...10', '1-10', '1 to 10'
16
+ if range_str =~ /\A\s*(\d+)\s*(\.\.\.?|-|to)\s*(\d+)\s*\z/
17
+ start_num = Regexp.last_match(1).to_i
18
+ operator = Regexp.last_match(2)
19
+ end_num = Regexp.last_match(3).to_i
20
+
21
+ inclusive = ["..", "-", "to"].include?(operator)
22
+ exclusive = operator == "..."
23
+
24
+ @range = if inclusive
25
+ (start_num..end_num)
26
+ elsif exclusive
27
+ (start_num...end_num)
28
+ end
29
+ else
30
+ @range = nil
31
+ end
32
+ end
33
+
34
+ def expectation
35
+ "a value between #{@range.min} and #{@range.max}"
36
+ end
37
+
38
+ def validate(value)
39
+ return false if @range.nil?
40
+
41
+ return false unless value.is_a?(Numeric)
42
+
43
+ @range.cover?(value)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,14 @@
1
+ module Argot
2
+ module Tag
3
+ class Required < Base
4
+ tag_name "required"
5
+ tag_name "r"
6
+
7
+ annotates :key
8
+
9
+ def validate(value)
10
+ true
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ module Argot
2
+ module Tag
3
+ class Type < Base
4
+ tag_name "type"
5
+ tag_name "t"
6
+ annotates :value
7
+
8
+ def initialize
9
+ super
10
+ @expected_types = nil
11
+ end
12
+
13
+ def configure(type_name)
14
+ @expected_types = case type_name.downcase
15
+ when "string"
16
+ [String]
17
+ when "integer"
18
+ [Integer]
19
+ when "float"
20
+ [Float]
21
+ when "array"
22
+ [Array]
23
+ when "hash", "map"
24
+ [Hash]
25
+ when "boolean"
26
+ [TrueClass, FalseClass]
27
+ else
28
+ []
29
+ end
30
+ end
31
+
32
+ def expectation
33
+ return "Boolean" if @expected_types.length == 2
34
+
35
+ @expected_types.first.name
36
+ end
37
+
38
+ def validate(value)
39
+ return false if @expected_types.empty?
40
+
41
+ @expected_types.any? { |t| value.is_a?(t) }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,291 @@
1
+ module Argot
2
+ class Validator < Psych::Handler
3
+ attr_reader :errors
4
+
5
+ class << self
6
+ def parse_and_validate(ruleset, file_content, file_name: "input")
7
+ handler = Argot::Validator.new(ruleset, file_name)
8
+ parser = Psych::Parser.new(handler)
9
+ parser.parse(file_content)
10
+ handler.validate_document
11
+ handler
12
+ end
13
+
14
+ def load(ruleset, file_content, file_name: "input", **)
15
+ handler = parse_and_validate(ruleset, file_content, file_name: file_name)
16
+
17
+ handler.safe_load(**)
18
+ end
19
+ end
20
+
21
+ def initialize(ruleset, file_name)
22
+ super()
23
+ @ruleset = ruleset
24
+ @file_name = file_name || "input"
25
+ @context = []
26
+ @current_path = []
27
+ @encountered_paths = {}
28
+ @missing_keys = {}
29
+ @errors = []
30
+ @current_location = {}
31
+ @scalar_scanner = build_safe_scalar_scanner
32
+
33
+ @stream_node = Psych::Nodes::Stream.new
34
+ @document_node = Psych::Nodes::Document.new([], [], true)
35
+ @stream_node.children << @document_node
36
+ end
37
+
38
+ def build_safe_scalar_scanner
39
+ class_loader = Psych::ClassLoader::Restricted.new([], [])
40
+ Psych::ScalarScanner.new(class_loader)
41
+ end
42
+
43
+ def errors?
44
+ !@errors.empty?
45
+ end
46
+
47
+ def tree
48
+ @stream_node
49
+ end
50
+
51
+ def safe_load(permitted_classes: [], permitted_symbols: [], aliases: false, filename: nil, fallback: nil, symbolize_names: false, freeze: false, strict_integer: false, ignore_validation_errors: false)
52
+ return nil if errors? && !ignore_validation_errors
53
+ return fallback unless @document_node
54
+
55
+ class_loader = Psych::ClassLoader::Restricted.new(permitted_classes.map(&:to_s), permitted_symbols.map(&:to_s))
56
+
57
+ scanner = Psych::ScalarScanner.new class_loader, strict_integer: strict_integer
58
+
59
+ visitor = if aliases
60
+ Psych::Visitors::ToRuby.new scanner, class_loader, symbolize_names: symbolize_names, freeze: freeze
61
+ else
62
+ Psych::Visitors::NoAliasRuby.new scanner, class_loader, symbolize_names: symbolize_names, freeze: freeze
63
+ end
64
+
65
+ begin
66
+ result = visitor.accept @document_node
67
+ rescue Psych::DisallowedClass => e
68
+ raise Argot::MaliciousInput, e.message
69
+ end
70
+
71
+ result
72
+ end
73
+
74
+ def event_location(start_line, start_column, end_line, end_column)
75
+ @current_location = {
76
+ start_line: start_line + 1,
77
+ end_line: end_line + 1,
78
+ start_column: start_column + 1,
79
+ end_column: end_column + 1
80
+ }
81
+ end
82
+
83
+ def start_mapping(anchor, tag, implicit, style)
84
+ mapping = Psych::Nodes::Mapping.new(anchor, tag, implicit, style)
85
+ push_context({node: mapping, key: nil})
86
+ end
87
+
88
+ def end_mapping
89
+ mapping = pop_context[:node]
90
+
91
+ if context_empty?
92
+ append_tree mapping
93
+ return
94
+ end
95
+
96
+ if in_sequence?
97
+ append_child mapping
98
+ elsif in_mapping?
99
+ raise Argot::Unprocessable, "Unexpected state in end_mapping" if current_context[:key].nil?
100
+
101
+ append_child current_context[:key]
102
+ append_child mapping
103
+ current_context[:key] = nil
104
+ pop_path
105
+ end
106
+ end
107
+
108
+ def scalar(value, anchor, tag, plain, quoted, style)
109
+ scalar = Psych::Nodes::Scalar.new(value, anchor, tag, plain, quoted, style)
110
+
111
+ if in_sequence?
112
+ append_child scalar
113
+ validate_node scalar
114
+ pop_path
115
+ elsif in_mapping?
116
+ if current_context[:key].nil?
117
+ push_path value
118
+ current_context[:key] = scalar
119
+ else
120
+ append_child current_context[:key]
121
+ append_child scalar
122
+ validate_node scalar
123
+
124
+ current_context[:key] = nil
125
+ pop_path
126
+ end
127
+ else
128
+ raise Argot::Unprocessable, "Unknown context in scalar"
129
+ end
130
+ end
131
+
132
+ def start_sequence(anchor, tag, implicit, style)
133
+ sequence = Psych::Nodes::Sequence.new(anchor, tag, implicit, style)
134
+ push_context({node: sequence})
135
+ end
136
+
137
+ def end_sequence
138
+ sequence = pop_context[:node]
139
+
140
+ if context_empty?
141
+ append_tree sequence
142
+ elsif in_sequence?
143
+ append_child sequence
144
+ elsif in_mapping?
145
+ raise Argot::Unprocessable, "Unexpected state in end_sequence" if current_context[:key].nil?
146
+
147
+ append_child current_context[:key]
148
+ append_child sequence
149
+ current_context[:key] = nil
150
+ end
151
+ end
152
+
153
+ def validate_document
154
+ # Check for required keys
155
+ @ruleset.paths.each do |ruleset_path|
156
+ parent_path = ruleset_path.take(ruleset_path.length - 1)
157
+
158
+ if @ruleset.required_path?(ruleset_path) && !encountered_path?(ruleset_path) && encountered_path?(parent_path)
159
+ emit_missing_key_error(ruleset_path)
160
+ end
161
+ end
162
+
163
+ # Check for optionally required keys (required keys that are children of an optional parent key)
164
+ @encountered_paths.each_key do |tracked_path|
165
+ subkey_paths = @ruleset.subkeys(tracked_path)
166
+
167
+ subkey_paths.each do |subkey_path_part|
168
+ subkey_path = tracked_path + subkey_path_part
169
+ rule_set = @ruleset.lookup(subkey_path)
170
+
171
+ next unless rule_set&.key?(:key)
172
+
173
+ emit_missing_key_error(subkey_path) if @ruleset.required_path?(subkey_path) && !encountered_path?(subkey_path)
174
+ end
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ def in_sequence?
181
+ current_context[:node].is_a?(Psych::Nodes::Sequence)
182
+ end
183
+
184
+ def in_mapping?
185
+ current_context[:node].is_a?(Psych::Nodes::Mapping)
186
+ end
187
+
188
+ def append_child(node)
189
+ return unless @ruleset.permit_path?(@current_path)
190
+
191
+ @encountered_paths[@current_path.dup] = @current_location.dup unless @encountered_paths.key?(@current_path)
192
+ current_context[:node].children << node
193
+ end
194
+
195
+ def append_tree(node)
196
+ @document_node.children << node
197
+ end
198
+
199
+ def context_empty?
200
+ @context.empty?
201
+ end
202
+
203
+ def clear_context
204
+ @context = []
205
+ end
206
+
207
+ def push_context(val)
208
+ @context.push(val)
209
+ end
210
+
211
+ def pop_context
212
+ @context.pop
213
+ end
214
+
215
+ def current_context
216
+ @context.last
217
+ end
218
+
219
+ def push_path(val)
220
+ @current_path.push(val)
221
+ end
222
+
223
+ def pop_path
224
+ @current_path.pop
225
+ end
226
+
227
+ def parse_scalar(value)
228
+ @scalar_scanner.tokenize(value)
229
+ rescue ArgumentError, Psych::SyntaxError
230
+ value.to_s
231
+ end
232
+
233
+ def validate_node(node)
234
+ value = parse_scalar(node.value)
235
+
236
+ rule_set = @ruleset.lookup(@current_path)
237
+
238
+ if rule_set.nil?
239
+ @errors << Argot::Annotation.new(path: @file_name,
240
+ location: @current_location.dup,
241
+ level: Annotation::WARNING,
242
+ title: "Invalid Key",
243
+ message: "'#{@current_path.join(".")}' is not permitted in this document.")
244
+ return
245
+ end
246
+
247
+ return unless rule_set[:value].any?
248
+
249
+ validation_errors = []
250
+ valid = false
251
+
252
+ rule_set[:value].each do |tag|
253
+ validation_errors.append(tag.expectation)
254
+ valid = true if tag.validate(value)
255
+ end
256
+
257
+ return if valid
258
+
259
+ expectations = (validation_errors.size > 1) ? "#{validation_errors[0..-2].join(", ")}, or #{validation_errors[-1]}" : validation_errors.first
260
+
261
+ @errors << Argot::Annotation.new(path: @file_name,
262
+ location: @current_location.dup,
263
+ level: Annotation::FAILURE,
264
+ title: "Invalid Value",
265
+ message: "The value '#{value}' is invalid for #{@current_path.join(".")}",
266
+ details: "Expected #{expectations}")
267
+ end
268
+
269
+ def encountered_path?(subkey_path)
270
+ return true if subkey_path.empty?
271
+
272
+ @encountered_paths.keys.any? do |tracked_path|
273
+ @ruleset.match_path?(subkey_path, tracked_path)
274
+ end
275
+ end
276
+
277
+ def emit_missing_key_error(subkey_path)
278
+ return if @missing_keys.key?(subkey_path)
279
+
280
+ @errors << Argot::Annotation.new(
281
+ path: @file_name,
282
+ location: @encountered_paths[subkey_path] || @encountered_paths[subkey_path[..subkey_path.length - 2]] || {},
283
+ level: Argot::Annotation::FAILURE,
284
+ title: "Missing Key",
285
+ message: "The key '#{subkey_path.join(".")}' is required, but missing."
286
+ )
287
+
288
+ @missing_keys[subkey_path] = nil
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Argot
4
+ VERSION = "1.0.0"
5
+ end
data/lib/argot.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+ require_relative "argot/version"
5
+ require_relative "argot/location"
6
+ require_relative "argot/annotation"
7
+ require_relative "argot/tag"
8
+ require_relative "argot/validator"
9
+ require_relative "argot/schema"
10
+
11
+ module Argot
12
+ class Error < StandardError; end
13
+
14
+ class Unprocessable < Error; end
15
+
16
+ class MaliciousInput < Unprocessable; end
17
+ end
data/sig/argot.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Argot
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: argot
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Charlton Trezevant
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-06-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: psych
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.1'
27
+ description: Quickly and flexibly build minimal, validatable YAML schemas.
28
+ email:
29
+ - charlton@packfiles.io
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".rubocop.yml"
35
+ - ".ruby-version"
36
+ - LICENSE.txt
37
+ - README.md
38
+ - Rakefile
39
+ - lib/argot.rb
40
+ - lib/argot/annotation.rb
41
+ - lib/argot/location.rb
42
+ - lib/argot/schema.rb
43
+ - lib/argot/tag.rb
44
+ - lib/argot/tags/literal.rb
45
+ - lib/argot/tags/oneof.rb
46
+ - lib/argot/tags/optional.rb
47
+ - lib/argot/tags/pattern.rb
48
+ - lib/argot/tags/range.rb
49
+ - lib/argot/tags/required.rb
50
+ - lib/argot/tags/type.rb
51
+ - lib/argot/validator.rb
52
+ - lib/argot/version.rb
53
+ - sig/argot.rbs
54
+ homepage: https://github.com/chtzvt/argot
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ homepage_uri: https://github.com/chtzvt/argot
59
+ source_code_uri: https://github.com/chtzvt/argot
60
+ changelog_uri: https://github.com/chtzvt/argot
61
+ post_install_message:
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.0.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.5.11
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Argot is a simple gem for quickly and flexibly building minimal, validatable
80
+ YAML schemas.
81
+ test_files: []