natsuzora 0.4.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 +55 -0
- data/CHANGELOG.md +62 -0
- data/Rakefile +75 -0
- data/lib/natsuzora/ast.rb +94 -0
- data/lib/natsuzora/context.rb +96 -0
- data/lib/natsuzora/contract/ast/any.rb +20 -0
- data/lib/natsuzora/contract/ast/list.rb +28 -0
- data/lib/natsuzora/contract/ast/node.rb +16 -0
- data/lib/natsuzora/contract/ast/record.rb +33 -0
- data/lib/natsuzora/contract/ast/ref.rb +27 -0
- data/lib/natsuzora/contract/ast/scalar.rb +60 -0
- data/lib/natsuzora/contract/ast.rb +38 -0
- data/lib/natsuzora/contract/compiled_lexer.rb +15 -0
- data/lib/natsuzora/contract/diff_marker.rb +15 -0
- data/lib/natsuzora/contract/document.rb +45 -0
- data/lib/natsuzora/contract/field.rb +62 -0
- data/lib/natsuzora/contract/parse_error.rb +16 -0
- data/lib/natsuzora/contract/parser.rb +362 -0
- data/lib/natsuzora/contract/scalar_type.rb +17 -0
- data/lib/natsuzora/contract/type_def.rb +39 -0
- data/lib/natsuzora/contract/type_ref_resolver.rb +56 -0
- data/lib/natsuzora/contract/validation_target.rb +13 -0
- data/lib/natsuzora/contract/validator.rb +179 -0
- data/lib/natsuzora/contract.rb +23 -0
- data/lib/natsuzora/data/lexers/contract.lkt1 +1 -0
- data/lib/natsuzora/data/lexers/template.lkt1 +1 -0
- data/lib/natsuzora/data_normalizable.rb +31 -0
- data/lib/natsuzora/errors.rb +37 -0
- data/lib/natsuzora/html_escape.rb +21 -0
- data/lib/natsuzora/lexer/compiled_lexer.rb +15 -0
- data/lib/natsuzora/lexer/token_processor.rb +156 -0
- data/lib/natsuzora/lexer.rb +95 -0
- data/lib/natsuzora/lexer_loader.rb +15 -0
- data/lib/natsuzora/lexers/contract.rb +24 -0
- data/lib/natsuzora/lexers/template.rb +31 -0
- data/lib/natsuzora/parser.rb +419 -0
- data/lib/natsuzora/payload.rb +35 -0
- data/lib/natsuzora/renderer.rb +132 -0
- data/lib/natsuzora/template.rb +34 -0
- data/lib/natsuzora/template_loader.rb +118 -0
- data/lib/natsuzora/token.rb +20 -0
- data/lib/natsuzora/validator.rb +73 -0
- data/lib/natsuzora/value.rb +73 -0
- data/lib/natsuzora/version.rb +5 -0
- data/lib/natsuzora.rb +30 -0
- metadata +105 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'parse_error'
|
|
4
|
+
require_relative 'validation_target'
|
|
5
|
+
require_relative 'type_ref_resolver'
|
|
6
|
+
require_relative 'ast/any'
|
|
7
|
+
require_relative 'ast/record'
|
|
8
|
+
|
|
9
|
+
module Natsuzora
|
|
10
|
+
module Contract
|
|
11
|
+
# Parsed contract document with type definitions and root fields.
|
|
12
|
+
class Document
|
|
13
|
+
attr_reader :types, :fields
|
|
14
|
+
|
|
15
|
+
def initialize(types: {}, fields: {})
|
|
16
|
+
@types = types # Hash<String, TypeDef>
|
|
17
|
+
@fields = fields # Hash<String, Field>
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Build the resolved root AST::Record for the specified target.
|
|
21
|
+
def to_contract(target = ValidationTarget::CURRENT)
|
|
22
|
+
resolver = TypeRefResolver.new(
|
|
23
|
+
@types,
|
|
24
|
+
target: target,
|
|
25
|
+
on_missing: ->(name) { raise ParseError.new("undefined type '#{name}'", 0, 0) },
|
|
26
|
+
on_unavailable: ->(_) { AST::Any.new },
|
|
27
|
+
on_cyclic: ->(name) { raise ParseError.new("cyclic type reference '#{name}'", 0, 0) }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
properties = {}
|
|
31
|
+
required = []
|
|
32
|
+
|
|
33
|
+
@fields.each do |name, field|
|
|
34
|
+
contract = field.for_target(target)
|
|
35
|
+
next unless contract
|
|
36
|
+
|
|
37
|
+
properties[name] = resolver.resolve(contract)
|
|
38
|
+
required << name
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
AST::Record.new(properties, required)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'diff_marker'
|
|
4
|
+
require_relative 'validation_target'
|
|
5
|
+
|
|
6
|
+
module Natsuzora
|
|
7
|
+
module Contract
|
|
8
|
+
# A field with optional diff marker and type change information.
|
|
9
|
+
class Field
|
|
10
|
+
attr_reader :marker, :current_type, :next_type
|
|
11
|
+
|
|
12
|
+
def initialize(current_type, marker: nil, next_type: nil)
|
|
13
|
+
@marker = marker
|
|
14
|
+
@current_type = current_type
|
|
15
|
+
@next_type = next_type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Create a field without diff marker.
|
|
19
|
+
def self.new_plain(contract)
|
|
20
|
+
new(contract)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create an added field (+).
|
|
24
|
+
def self.added(contract)
|
|
25
|
+
new(contract, marker: DiffMarker::ADDED)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Create a removed field (-).
|
|
29
|
+
def self.removed(contract)
|
|
30
|
+
new(contract, marker: DiffMarker::REMOVED)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Create a changed field (*).
|
|
34
|
+
def self.changed(current, next_type)
|
|
35
|
+
new(current, marker: DiffMarker::CHANGED, next_type: next_type)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get the contract for the specified target generation.
|
|
39
|
+
# NOTE: Diff resolution table here mirrors TypeDef#available? — keep in sync.
|
|
40
|
+
def for_target(target)
|
|
41
|
+
# rubocop:disable Lint/DuplicateBranch
|
|
42
|
+
case [@marker, target]
|
|
43
|
+
in [nil, _]
|
|
44
|
+
@current_type
|
|
45
|
+
in [DiffMarker::ADDED, ValidationTarget::CURRENT]
|
|
46
|
+
nil
|
|
47
|
+
in [DiffMarker::ADDED, ValidationTarget::NEXT]
|
|
48
|
+
@current_type
|
|
49
|
+
in [DiffMarker::REMOVED, ValidationTarget::CURRENT]
|
|
50
|
+
@current_type
|
|
51
|
+
in [DiffMarker::REMOVED, ValidationTarget::NEXT]
|
|
52
|
+
nil
|
|
53
|
+
in [DiffMarker::CHANGED, ValidationTarget::CURRENT]
|
|
54
|
+
@current_type
|
|
55
|
+
in [DiffMarker::CHANGED, ValidationTarget::NEXT]
|
|
56
|
+
@next_type
|
|
57
|
+
end
|
|
58
|
+
# rubocop:enable Lint/DuplicateBranch
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
module Contract
|
|
5
|
+
# Error that can occur during parsing.
|
|
6
|
+
class ParseError < StandardError
|
|
7
|
+
attr_reader :line, :column
|
|
8
|
+
|
|
9
|
+
def initialize(message, line = 0, column = 0)
|
|
10
|
+
@line = line
|
|
11
|
+
@column = column
|
|
12
|
+
super("#{message} at line #{line}, column #{column}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'compiled_lexer'
|
|
4
|
+
require_relative 'parse_error'
|
|
5
|
+
require_relative 'diff_marker'
|
|
6
|
+
require_relative 'scalar_type'
|
|
7
|
+
require_relative 'ast/scalar'
|
|
8
|
+
require_relative 'ast/record'
|
|
9
|
+
require_relative 'ast/list'
|
|
10
|
+
require_relative 'ast/ref'
|
|
11
|
+
require_relative 'field'
|
|
12
|
+
require_relative 'type_def'
|
|
13
|
+
require_relative 'document'
|
|
14
|
+
|
|
15
|
+
module Natsuzora
|
|
16
|
+
module Contract
|
|
17
|
+
# Parser for contract notation.
|
|
18
|
+
# Uses LexerKit stream directly without creating Token objects.
|
|
19
|
+
class Parser
|
|
20
|
+
def initialize(input)
|
|
21
|
+
@stream = CompiledLexer.instance.stream(input)
|
|
22
|
+
# Position of the most recently consumed token; used for EOF error messages
|
|
23
|
+
# and for errors that point back at the just-read identifier (e.g. unknown scalar type).
|
|
24
|
+
@last_line = 1
|
|
25
|
+
@last_col = 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Parse the entire contract file.
|
|
29
|
+
def parse_file
|
|
30
|
+
types = {}
|
|
31
|
+
|
|
32
|
+
skip_separators
|
|
33
|
+
|
|
34
|
+
while type_definition?
|
|
35
|
+
type_name, contract = parse_type_def
|
|
36
|
+
types[type_name] = TypeDef.new(contract)
|
|
37
|
+
skip_separators
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
root = parse_object_body
|
|
41
|
+
|
|
42
|
+
Document.new(
|
|
43
|
+
types: types,
|
|
44
|
+
fields: build_fields_from_object(root)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parse with diff markers (2-generation support).
|
|
49
|
+
def parse_file_with_diff
|
|
50
|
+
types = {}
|
|
51
|
+
fields = {}
|
|
52
|
+
|
|
53
|
+
skip_separators
|
|
54
|
+
|
|
55
|
+
until eof?
|
|
56
|
+
marker = try_parse_diff_marker
|
|
57
|
+
|
|
58
|
+
if type_definition?
|
|
59
|
+
type_name, type_def = parse_type_def_with_diff(marker)
|
|
60
|
+
types[type_name] = type_def
|
|
61
|
+
elsif current_type == :IDENTIFIER
|
|
62
|
+
name, field = parse_field_with_diff(marker)
|
|
63
|
+
fields[name] = field
|
|
64
|
+
elsif marker
|
|
65
|
+
raise_current_parse_error("expected field name or 'type' after diff marker")
|
|
66
|
+
else
|
|
67
|
+
break
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
skip_separators
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Document.new(types: types, fields: fields)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def current_type
|
|
79
|
+
@stream.token_name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def current_text
|
|
83
|
+
@stream.text
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def peek_type
|
|
87
|
+
@stream.peek_token_name
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def eof?
|
|
91
|
+
@stream.eof?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def advance
|
|
95
|
+
@last_line, @last_col = @stream.line_col unless eof?
|
|
96
|
+
text = @stream.text
|
|
97
|
+
@stream.advance
|
|
98
|
+
text
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_error!
|
|
102
|
+
return unless @stream.error?
|
|
103
|
+
|
|
104
|
+
line, col = @stream.line_col
|
|
105
|
+
raise ParseError.new("unexpected character '#{@stream.text}'", line, col)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def skip_separators
|
|
109
|
+
while !eof? && (current_type == :NEWLINE || current_type == :COMMENT)
|
|
110
|
+
check_error!
|
|
111
|
+
advance
|
|
112
|
+
end
|
|
113
|
+
check_error! unless eof?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def expect_token(type, message)
|
|
117
|
+
return if !eof? && current_type == type
|
|
118
|
+
|
|
119
|
+
raise_at_current_or_last(message)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def expect_identifier
|
|
123
|
+
check_error!
|
|
124
|
+
raise_at_current_or_last('expected field name (lowercase identifier)') if eof? || current_type != :IDENTIFIER
|
|
125
|
+
|
|
126
|
+
advance
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def expect_type_name
|
|
130
|
+
check_error!
|
|
131
|
+
raise_at_current_or_last('expected type name (uppercase identifier)') if eof? || current_type != :TYPE_NAME
|
|
132
|
+
|
|
133
|
+
advance
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def try_parse_diff_marker
|
|
137
|
+
return nil if eof?
|
|
138
|
+
|
|
139
|
+
check_error!
|
|
140
|
+
case current_type
|
|
141
|
+
when :PLUS
|
|
142
|
+
advance
|
|
143
|
+
DiffMarker::ADDED
|
|
144
|
+
when :MINUS
|
|
145
|
+
advance
|
|
146
|
+
DiffMarker::REMOVED
|
|
147
|
+
when :STAR
|
|
148
|
+
advance
|
|
149
|
+
DiffMarker::CHANGED
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def type_definition?
|
|
154
|
+
return false if eof?
|
|
155
|
+
return false unless current_type == :IDENTIFIER && current_text == 'type'
|
|
156
|
+
|
|
157
|
+
peek_type == :TYPE_NAME
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parse_type_def
|
|
161
|
+
advance # consume 'type'
|
|
162
|
+
type_name = expect_type_name
|
|
163
|
+
expect_token(:OPEN_BRACE, "expected '{' after type name")
|
|
164
|
+
[type_name, parse_braced_object_body]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_type_def_with_diff(marker)
|
|
168
|
+
raise_current_parse_error('* marker is not allowed for type definitions') if marker == DiffMarker::CHANGED
|
|
169
|
+
|
|
170
|
+
type_name, contract = parse_type_def
|
|
171
|
+
[type_name, TypeDef.new(contract, marker: marker)]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def parse_object_body
|
|
175
|
+
properties = {}
|
|
176
|
+
required = []
|
|
177
|
+
|
|
178
|
+
skip_separators
|
|
179
|
+
|
|
180
|
+
while !eof? && current_type != :CLOSE_BRACE
|
|
181
|
+
skip_separators
|
|
182
|
+
break if eof? || current_type == :CLOSE_BRACE
|
|
183
|
+
|
|
184
|
+
name = expect_identifier
|
|
185
|
+
required << name
|
|
186
|
+
|
|
187
|
+
raise_at_current_or_last("expected ':' or '{'") if eof?
|
|
188
|
+
check_error!
|
|
189
|
+
contract = case current_type
|
|
190
|
+
when :COLON
|
|
191
|
+
advance
|
|
192
|
+
parse_type
|
|
193
|
+
when :OPEN_BRACE
|
|
194
|
+
parse_braced_object_body
|
|
195
|
+
else
|
|
196
|
+
raise_current_parse_error("expected ':' or '{'")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
properties[name] = contract
|
|
200
|
+
skip_separators
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
AST::Record.new(properties, required)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def parse_field_with_diff(marker)
|
|
207
|
+
name = expect_identifier
|
|
208
|
+
current_type_contract, next_type = parse_field_contract_with_diff(marker)
|
|
209
|
+
field = Field.new(current_type_contract, marker: marker, next_type: next_type)
|
|
210
|
+
[name, field]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parse_field_contract_with_diff(marker)
|
|
214
|
+
raise_at_current_or_last("expected ':' or '{'") if eof?
|
|
215
|
+
|
|
216
|
+
check_error!
|
|
217
|
+
case current_type
|
|
218
|
+
when :COLON
|
|
219
|
+
parse_typed_field_with_diff(marker)
|
|
220
|
+
when :OPEN_BRACE
|
|
221
|
+
parse_nested_object_field_with_diff(marker)
|
|
222
|
+
else
|
|
223
|
+
raise_current_parse_error("expected ':' or '{'")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def parse_typed_field_with_diff(marker)
|
|
228
|
+
advance
|
|
229
|
+
first_type = parse_type
|
|
230
|
+
|
|
231
|
+
if !eof? && current_type == :ARROW
|
|
232
|
+
raise_current_parse_error("'->' is only allowed with * marker") unless marker == DiffMarker::CHANGED
|
|
233
|
+
|
|
234
|
+
advance
|
|
235
|
+
return [first_type, parse_type]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
raise_at_current_or_last("* marker requires '->' for type change") if marker == DiffMarker::CHANGED
|
|
239
|
+
|
|
240
|
+
[first_type, nil]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def parse_nested_object_field_with_diff(marker)
|
|
244
|
+
raise_current_parse_error('* marker is not allowed for nested objects') if marker == DiffMarker::CHANGED
|
|
245
|
+
|
|
246
|
+
[parse_braced_object_body, nil]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def parse_braced_object_body
|
|
250
|
+
advance # consume '{'
|
|
251
|
+
inner = parse_object_body
|
|
252
|
+
expect_token(:CLOSE_BRACE, "expected '}'")
|
|
253
|
+
advance
|
|
254
|
+
inner
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def raise_current_parse_error(message)
|
|
258
|
+
line, col = @stream.line_col
|
|
259
|
+
raise ParseError.new(message, line, col)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def raise_at_current_or_last(message)
|
|
263
|
+
line, col = eof? ? [@last_line, @last_col] : @stream.line_col
|
|
264
|
+
raise ParseError.new(message, line, col)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def parse_type
|
|
268
|
+
return nil if eof?
|
|
269
|
+
|
|
270
|
+
check_error!
|
|
271
|
+
# Check for array type: []type or []{...}
|
|
272
|
+
if current_type == :OPEN_BRACKET
|
|
273
|
+
advance
|
|
274
|
+
expect_token(:CLOSE_BRACKET, "expected ']'")
|
|
275
|
+
advance
|
|
276
|
+
|
|
277
|
+
raise_at_current_or_last("expected type after '[]'") if eof?
|
|
278
|
+
|
|
279
|
+
items = case current_type
|
|
280
|
+
when :OPEN_BRACE
|
|
281
|
+
parse_braced_object_body
|
|
282
|
+
when :TYPE_NAME
|
|
283
|
+
parse_type_reference
|
|
284
|
+
else
|
|
285
|
+
parse_scalar_type
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
return AST::List.new(items)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Check for type reference (uppercase)
|
|
292
|
+
return parse_type_reference if current_type == :TYPE_NAME
|
|
293
|
+
|
|
294
|
+
parse_scalar_type
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def parse_type_reference
|
|
298
|
+
type_name = expect_type_name
|
|
299
|
+
AST::Ref.new(type_name)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def parse_scalar_type
|
|
303
|
+
type_name = expect_identifier
|
|
304
|
+
|
|
305
|
+
scalar_type = case type_name
|
|
306
|
+
when 'string' then ScalarType::STRING
|
|
307
|
+
when 'integer' then ScalarType::INTEGER
|
|
308
|
+
when 'bool' then ScalarType::BOOL
|
|
309
|
+
when 'scalar' then ScalarType::SCALAR
|
|
310
|
+
else
|
|
311
|
+
raise ParseError.new(
|
|
312
|
+
"unknown type '#{type_name}' (did you mean to use uppercase for a type reference?)",
|
|
313
|
+
@last_line, @last_col
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
AST::Scalar.new(scalar_type, parse_modifier)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def parse_modifier
|
|
321
|
+
return AST::Scalar::Modifier::NONE if eof?
|
|
322
|
+
|
|
323
|
+
check_error!
|
|
324
|
+
case current_type
|
|
325
|
+
when :QUESTION
|
|
326
|
+
advance
|
|
327
|
+
AST::Scalar::Modifier::NULLABLE
|
|
328
|
+
when :EXCLAMATION
|
|
329
|
+
advance
|
|
330
|
+
AST::Scalar::Modifier::REQUIRED
|
|
331
|
+
else
|
|
332
|
+
AST::Scalar::Modifier::NONE
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def build_fields_from_object(object_contract)
|
|
337
|
+
object_contract.properties.transform_values do |contract|
|
|
338
|
+
Field.new(contract)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Parse contract notation into a resolved Contract.
|
|
344
|
+
def self.parse(input)
|
|
345
|
+
parser = Parser.new(input)
|
|
346
|
+
file = parser.parse_file
|
|
347
|
+
file.to_contract
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Parse contract notation into a Document.
|
|
351
|
+
def self.parse_file(input)
|
|
352
|
+
parser = Parser.new(input)
|
|
353
|
+
parser.parse_file
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Parse contract notation with diff markers.
|
|
357
|
+
def self.parse_file_with_diff(input)
|
|
358
|
+
parser = Parser.new(input)
|
|
359
|
+
parser.parse_file_with_diff
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
module Contract
|
|
5
|
+
# Scalar type enumeration.
|
|
6
|
+
module ScalarType
|
|
7
|
+
# String type only
|
|
8
|
+
STRING = :string
|
|
9
|
+
# Integer type only
|
|
10
|
+
INTEGER = :integer
|
|
11
|
+
# Boolean type only (for truthiness checks)
|
|
12
|
+
BOOL = :bool
|
|
13
|
+
# String or Integer (stringifiable values, does NOT include bool)
|
|
14
|
+
SCALAR = :scalar
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'diff_marker'
|
|
4
|
+
require_relative 'validation_target'
|
|
5
|
+
|
|
6
|
+
module Natsuzora
|
|
7
|
+
module Contract
|
|
8
|
+
# A type definition with optional diff marker.
|
|
9
|
+
class TypeDef
|
|
10
|
+
attr_reader :marker, :contract
|
|
11
|
+
|
|
12
|
+
def initialize(contract, marker: nil)
|
|
13
|
+
@marker = marker
|
|
14
|
+
@contract = contract
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if this type is available for the specified target.
|
|
18
|
+
# NOTE: Diff resolution table here mirrors Field#for_target — keep in sync.
|
|
19
|
+
def available?(target)
|
|
20
|
+
# rubocop:disable Lint/DuplicateBranch
|
|
21
|
+
case [@marker, target]
|
|
22
|
+
in [nil, _]
|
|
23
|
+
true
|
|
24
|
+
in [DiffMarker::ADDED, ValidationTarget::CURRENT]
|
|
25
|
+
false
|
|
26
|
+
in [DiffMarker::ADDED, ValidationTarget::NEXT]
|
|
27
|
+
true
|
|
28
|
+
in [DiffMarker::REMOVED, ValidationTarget::CURRENT]
|
|
29
|
+
true
|
|
30
|
+
in [DiffMarker::REMOVED, ValidationTarget::NEXT]
|
|
31
|
+
false
|
|
32
|
+
in [DiffMarker::CHANGED, _]
|
|
33
|
+
false # Changed not allowed for type defs
|
|
34
|
+
end
|
|
35
|
+
# rubocop:enable Lint/DuplicateBranch
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ast/record'
|
|
4
|
+
require_relative 'ast/list'
|
|
5
|
+
require_relative 'ast/ref'
|
|
6
|
+
|
|
7
|
+
module Natsuzora
|
|
8
|
+
module Contract
|
|
9
|
+
# Walks an AST tree and replaces AST::Ref nodes with concrete contracts
|
|
10
|
+
# looked up from a TypeDef registry. Missing or unavailable refs are
|
|
11
|
+
# delegated to caller-supplied callbacks.
|
|
12
|
+
class TypeRefResolver
|
|
13
|
+
def initialize(types, target:, on_missing:, on_unavailable:, on_cyclic:)
|
|
14
|
+
@types = types
|
|
15
|
+
@target = target
|
|
16
|
+
@on_missing = on_missing
|
|
17
|
+
@on_unavailable = on_unavailable
|
|
18
|
+
@on_cyclic = on_cyclic
|
|
19
|
+
@visiting = Set.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolve(contract)
|
|
23
|
+
case contract
|
|
24
|
+
when AST::Ref
|
|
25
|
+
resolve_ref(contract.name)
|
|
26
|
+
when AST::Record
|
|
27
|
+
AST::Record.new(
|
|
28
|
+
contract.properties.transform_values { |c| resolve(c) },
|
|
29
|
+
contract.required
|
|
30
|
+
)
|
|
31
|
+
when AST::List
|
|
32
|
+
AST::List.new(resolve(contract.items))
|
|
33
|
+
else
|
|
34
|
+
contract
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def resolve_ref(name)
|
|
41
|
+
return @on_cyclic.call(name) if @visiting.include?(name)
|
|
42
|
+
|
|
43
|
+
type_def = @types[name]
|
|
44
|
+
return @on_missing.call(name) unless type_def
|
|
45
|
+
return @on_unavailable.call(name) unless type_def.available?(@target)
|
|
46
|
+
|
|
47
|
+
@visiting.add(name)
|
|
48
|
+
begin
|
|
49
|
+
resolve(type_def.contract)
|
|
50
|
+
ensure
|
|
51
|
+
@visiting.delete(name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
module Contract
|
|
5
|
+
# Validation target for 2-generation contracts.
|
|
6
|
+
module ValidationTarget
|
|
7
|
+
# Validate against current generation (default)
|
|
8
|
+
CURRENT = :current
|
|
9
|
+
# Validate against next generation
|
|
10
|
+
NEXT = :next
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|