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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +55 -0
  4. data/CHANGELOG.md +62 -0
  5. data/Rakefile +75 -0
  6. data/lib/natsuzora/ast.rb +94 -0
  7. data/lib/natsuzora/context.rb +96 -0
  8. data/lib/natsuzora/contract/ast/any.rb +20 -0
  9. data/lib/natsuzora/contract/ast/list.rb +28 -0
  10. data/lib/natsuzora/contract/ast/node.rb +16 -0
  11. data/lib/natsuzora/contract/ast/record.rb +33 -0
  12. data/lib/natsuzora/contract/ast/ref.rb +27 -0
  13. data/lib/natsuzora/contract/ast/scalar.rb +60 -0
  14. data/lib/natsuzora/contract/ast.rb +38 -0
  15. data/lib/natsuzora/contract/compiled_lexer.rb +15 -0
  16. data/lib/natsuzora/contract/diff_marker.rb +15 -0
  17. data/lib/natsuzora/contract/document.rb +45 -0
  18. data/lib/natsuzora/contract/field.rb +62 -0
  19. data/lib/natsuzora/contract/parse_error.rb +16 -0
  20. data/lib/natsuzora/contract/parser.rb +362 -0
  21. data/lib/natsuzora/contract/scalar_type.rb +17 -0
  22. data/lib/natsuzora/contract/type_def.rb +39 -0
  23. data/lib/natsuzora/contract/type_ref_resolver.rb +56 -0
  24. data/lib/natsuzora/contract/validation_target.rb +13 -0
  25. data/lib/natsuzora/contract/validator.rb +179 -0
  26. data/lib/natsuzora/contract.rb +23 -0
  27. data/lib/natsuzora/data/lexers/contract.lkt1 +1 -0
  28. data/lib/natsuzora/data/lexers/template.lkt1 +1 -0
  29. data/lib/natsuzora/data_normalizable.rb +31 -0
  30. data/lib/natsuzora/errors.rb +37 -0
  31. data/lib/natsuzora/html_escape.rb +21 -0
  32. data/lib/natsuzora/lexer/compiled_lexer.rb +15 -0
  33. data/lib/natsuzora/lexer/token_processor.rb +156 -0
  34. data/lib/natsuzora/lexer.rb +95 -0
  35. data/lib/natsuzora/lexer_loader.rb +15 -0
  36. data/lib/natsuzora/lexers/contract.rb +24 -0
  37. data/lib/natsuzora/lexers/template.rb +31 -0
  38. data/lib/natsuzora/parser.rb +419 -0
  39. data/lib/natsuzora/payload.rb +35 -0
  40. data/lib/natsuzora/renderer.rb +132 -0
  41. data/lib/natsuzora/template.rb +34 -0
  42. data/lib/natsuzora/template_loader.rb +118 -0
  43. data/lib/natsuzora/token.rb +20 -0
  44. data/lib/natsuzora/validator.rb +73 -0
  45. data/lib/natsuzora/value.rb +73 -0
  46. data/lib/natsuzora/version.rb +5 -0
  47. data/lib/natsuzora.rb +30 -0
  48. 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