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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c392b524dfcc75df005176d7e8dc65337be7d94f26575f9b0816c40a7e061d4c
|
|
4
|
+
data.tar.gz: f660922c286c77a2b2ac6e863ae4a8f2427eb50ba7a70c50838d0d56e524c898
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 56c8ad664c64fb5f94992d1eadb163276c14494ab7c452b19d32d2a7fd81fcaedbf5eb89707cd0d9b0151b90ef7d20efce805a680b594bfd1be7be644e5d383d
|
|
7
|
+
data.tar.gz: 0f68bef0e391fc137904d96287d5daad54bbd29de293477b705138436bb5c6ff0efe80cbf94d584b2a69d459aa748418b7a5f43aeeacd501df6cef80ef0f6be9
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-rspec
|
|
3
|
+
|
|
4
|
+
AllCops:
|
|
5
|
+
TargetRubyVersion: 3.2
|
|
6
|
+
NewCops: enable
|
|
7
|
+
SuggestExtensions: false
|
|
8
|
+
|
|
9
|
+
Style/Documentation:
|
|
10
|
+
Enabled: false
|
|
11
|
+
|
|
12
|
+
Layout/LineLength:
|
|
13
|
+
Max: 200
|
|
14
|
+
|
|
15
|
+
Metrics/BlockLength:
|
|
16
|
+
Exclude:
|
|
17
|
+
- 'spec/**/*'
|
|
18
|
+
|
|
19
|
+
Metrics/MethodLength:
|
|
20
|
+
Max: 50
|
|
21
|
+
|
|
22
|
+
Metrics/AbcSize:
|
|
23
|
+
Max: 40
|
|
24
|
+
|
|
25
|
+
Metrics/ClassLength:
|
|
26
|
+
Max: 400
|
|
27
|
+
|
|
28
|
+
Metrics/CyclomaticComplexity:
|
|
29
|
+
Max: 12
|
|
30
|
+
|
|
31
|
+
Metrics/PerceivedComplexity:
|
|
32
|
+
Max: 12
|
|
33
|
+
|
|
34
|
+
RSpec/DescribedClass:
|
|
35
|
+
EnforcedStyle: explicit
|
|
36
|
+
|
|
37
|
+
RSpec/ExampleLength:
|
|
38
|
+
Max: 25
|
|
39
|
+
|
|
40
|
+
RSpec/MultipleExpectations:
|
|
41
|
+
Max: 20
|
|
42
|
+
|
|
43
|
+
RSpec/SpecFilePathFormat:
|
|
44
|
+
Enabled: false
|
|
45
|
+
|
|
46
|
+
Capybara/RSpec/PredicateMatcher:
|
|
47
|
+
Enabled: false
|
|
48
|
+
|
|
49
|
+
Style/IfUnlessModifier:
|
|
50
|
+
Enabled: false
|
|
51
|
+
|
|
52
|
+
Style/MultipleComparison:
|
|
53
|
+
Enabled: false
|
|
54
|
+
|
|
55
|
+
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Natsuzora::Contract` namespace which absorbs the formerly separate `subaru` gem.
|
|
7
|
+
Provides parsing of `.ntzc` contract notation and validation of JSON data
|
|
8
|
+
against contracts. Two-generation diff markers (`+`, `-`, `*`) are supported.
|
|
9
|
+
- Module-level helpers: `Natsuzora::Contract.parse(input)`,
|
|
10
|
+
`Natsuzora::Contract.parse_file(input)`,
|
|
11
|
+
`Natsuzora::Contract.parse_file_with_diff(input)`,
|
|
12
|
+
`Natsuzora::Contract.validate(contract, data)`,
|
|
13
|
+
`Natsuzora::Contract.validate_with_target(document, data, target:)`.
|
|
14
|
+
- Contract AST nodes are grouped under `Natsuzora::Contract::AST::`
|
|
15
|
+
(`Any`, `Scalar`, `Record`, `List`, `Ref`, with `Node` as the abstract
|
|
16
|
+
base). `Natsuzora::Contract::AST.from_h(hash)` rebuilds an AST tree
|
|
17
|
+
from its hash representation.
|
|
18
|
+
- `Natsuzora::Contract::TypeRefResolver` walks an AST tree replacing
|
|
19
|
+
`AST::Ref` nodes with concrete contracts; configurable via
|
|
20
|
+
`on_missing` / `on_unavailable` / `on_cyclic` callbacks. Cyclic type
|
|
21
|
+
references (e.g. `type A { ref: B }; type B { ref: A }`) raise via
|
|
22
|
+
`on_cyclic` instead of recursing forever.
|
|
23
|
+
- Resource limits to bound pathological input:
|
|
24
|
+
- `Natsuzora::Renderer::MAX_RENDER_DEPTH` (1024) — caps recursion
|
|
25
|
+
through nested `{[#if]}` / `{[#each]}` / `{[!include]}` blocks.
|
|
26
|
+
- `Natsuzora::Renderer::MAX_OUTPUT_BYTES` (50 MiB) — caps the
|
|
27
|
+
rendered output size, checked per `{[#each]}` iteration.
|
|
28
|
+
- `Natsuzora::Contract::Validator::MAX_VALIDATE_DEPTH` (64) — caps
|
|
29
|
+
nesting depth of validated data trees.
|
|
30
|
+
Exceeding any limit raises `Natsuzora::RenderError` or
|
|
31
|
+
`Natsuzora::Contract::ValidationError` with a descriptive message.
|
|
32
|
+
- Shared JSON tests under `tests/contract/` for cross-language parity with
|
|
33
|
+
the Rust `natsuzora-contract` crate.
|
|
34
|
+
- `Natsuzora::Payload` class wrapping render input data. The class
|
|
35
|
+
encapsulates the boundary between untrusted host data and Natsuzora's
|
|
36
|
+
internal value space (Symbol→String key adaptation, whole-number
|
|
37
|
+
Float→Integer coercion, plus type/range validation).
|
|
38
|
+
- `Natsuzora::DataNormalizable` mixin providing `normalize_data(data)` for
|
|
39
|
+
pure data adaptation (no exceptions raised).
|
|
40
|
+
- `Natsuzora::Validator.validate_data!(data)` for asserting that a
|
|
41
|
+
prepared value conforms to the Natsuzora type system.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
- **Breaking** (pre-1.0): `Natsuzora::Template#render` now accepts a
|
|
45
|
+
`Natsuzora::Payload` instead of a raw `Hash`. The top-level facade
|
|
46
|
+
`Natsuzora.render(source, data, ...)` is unchanged and wraps `data` in a
|
|
47
|
+
Payload internally; only callers that construct a `Template` directly
|
|
48
|
+
via `Natsuzora.parse` need to update.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# Before
|
|
52
|
+
template = Natsuzora.parse(source)
|
|
53
|
+
template.render(hash)
|
|
54
|
+
|
|
55
|
+
# After
|
|
56
|
+
template = Natsuzora.parse(source)
|
|
57
|
+
template.render(Natsuzora::Payload.new(hash))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 0.2.0
|
|
61
|
+
|
|
62
|
+
Initial release of the natsuzora template language.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'rspec/core/rake_task'
|
|
6
|
+
require 'rubocop/rake_task'
|
|
7
|
+
|
|
8
|
+
LEXER_ROOT = File.expand_path(__dir__)
|
|
9
|
+
LEXERS = {
|
|
10
|
+
contract: {
|
|
11
|
+
source: File.join(LEXER_ROOT, 'lib', 'natsuzora', 'lexers', 'contract.rb'),
|
|
12
|
+
output: File.join(LEXER_ROOT, 'lib', 'natsuzora', 'data', 'lexers', 'contract.lkt1')
|
|
13
|
+
},
|
|
14
|
+
template: {
|
|
15
|
+
source: File.join(LEXER_ROOT, 'lib', 'natsuzora', 'lexers', 'template.rb'),
|
|
16
|
+
output: File.join(LEXER_ROOT, 'lib', 'natsuzora', 'data', 'lexers', 'template.lkt1')
|
|
17
|
+
}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
21
|
+
RuboCop::RakeTask.new
|
|
22
|
+
|
|
23
|
+
def build_program(source)
|
|
24
|
+
require 'lexer_kit'
|
|
25
|
+
LexerKit.load_builder(source).compile
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def compile_lexers
|
|
29
|
+
require 'lexer_kit'
|
|
30
|
+
|
|
31
|
+
LEXERS.each do |name, config|
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(config[:output]))
|
|
33
|
+
LexerKit::Format::LKT1.save(build_program(config[:source]), path: config[:output])
|
|
34
|
+
puts "Compiled #{name} lexer to #{config[:output]}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def lexer_check_failures
|
|
39
|
+
require 'lexer_kit'
|
|
40
|
+
|
|
41
|
+
LEXERS.each_with_object([]) do |(name, config), failures|
|
|
42
|
+
unless File.file?(config[:output])
|
|
43
|
+
failures << "Missing #{name} lexer: #{config[:output]}"
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
expected_binary = build_program(config[:source]).to_binary
|
|
48
|
+
actual_binary = LexerKit.load_lexer(config[:output]).to_binary
|
|
49
|
+
failures << "Stale #{name} lexer: #{config[:output]}" unless actual_binary == expected_binary
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
failures << "Invalid #{name} lexer: #{e.class}: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def abort_if_lexers_stale
|
|
56
|
+
failures = lexer_check_failures
|
|
57
|
+
return if failures.empty?
|
|
58
|
+
|
|
59
|
+
failures.each { |failure| warn failure }
|
|
60
|
+
abort 'Precompiled lexer files are not up to date. Run `rake lexers:compile`.'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
namespace :lexers do
|
|
64
|
+
desc 'Compile LexerKit DSL files into precompiled .lkt1 programs'
|
|
65
|
+
task :compile do
|
|
66
|
+
compile_lexers
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc 'Verify precompiled LexerKit programs are present and up to date'
|
|
70
|
+
task :check do
|
|
71
|
+
abort_if_lexers_stale
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
task default: %i[lexers:check spec rubocop]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
module AST
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :line, :column
|
|
7
|
+
|
|
8
|
+
def initialize(line: nil, column: nil)
|
|
9
|
+
@line = line
|
|
10
|
+
@column = column
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Template < Node
|
|
15
|
+
attr_reader :nodes
|
|
16
|
+
|
|
17
|
+
def initialize(nodes, **)
|
|
18
|
+
super(**)
|
|
19
|
+
@nodes = nodes
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Text < Node
|
|
24
|
+
attr_reader :content
|
|
25
|
+
|
|
26
|
+
def initialize(content, **)
|
|
27
|
+
super(**)
|
|
28
|
+
@content = content
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Variable < Node
|
|
33
|
+
attr_reader :path, :modifier
|
|
34
|
+
|
|
35
|
+
# modifier: nil (default), :nullable (?), :required (!)
|
|
36
|
+
def initialize(path, modifier: nil, **)
|
|
37
|
+
super(**)
|
|
38
|
+
@path = path
|
|
39
|
+
@modifier = modifier
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class IfBlock < Node
|
|
44
|
+
attr_reader :condition, :then_nodes, :else_nodes
|
|
45
|
+
|
|
46
|
+
def initialize(condition:, then_nodes:, else_nodes: nil, **)
|
|
47
|
+
super(**)
|
|
48
|
+
@condition = condition
|
|
49
|
+
@then_nodes = then_nodes
|
|
50
|
+
@else_nodes = else_nodes
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class UnlessBlock < Node
|
|
55
|
+
attr_reader :condition, :body_nodes
|
|
56
|
+
|
|
57
|
+
def initialize(condition:, body_nodes:, **)
|
|
58
|
+
super(**)
|
|
59
|
+
@condition = condition
|
|
60
|
+
@body_nodes = body_nodes
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class EachBlock < Node
|
|
65
|
+
attr_reader :collection, :item_name, :body_nodes
|
|
66
|
+
|
|
67
|
+
def initialize(collection:, item_name:, body_nodes:, **)
|
|
68
|
+
super(**)
|
|
69
|
+
@collection = collection
|
|
70
|
+
@item_name = item_name
|
|
71
|
+
@body_nodes = body_nodes
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class UnsecureOutput < Node
|
|
76
|
+
attr_reader :path
|
|
77
|
+
|
|
78
|
+
def initialize(path:, **)
|
|
79
|
+
super(**)
|
|
80
|
+
@path = path
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Include < Node
|
|
85
|
+
attr_reader :name, :args
|
|
86
|
+
|
|
87
|
+
def initialize(name:, args:, **)
|
|
88
|
+
super(**)
|
|
89
|
+
@name = name
|
|
90
|
+
@args = args
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
# Manages variable scope and resolution during rendering.
|
|
5
|
+
class Context
|
|
6
|
+
def initialize(root_data)
|
|
7
|
+
raise Natsuzora::TypeError, 'Root data must be an object' unless root_data.is_a?(Hash)
|
|
8
|
+
|
|
9
|
+
@root = root_data
|
|
10
|
+
@local_stack = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve(variable)
|
|
14
|
+
path = variable.path
|
|
15
|
+
line = variable.line
|
|
16
|
+
column = variable.column
|
|
17
|
+
|
|
18
|
+
name = path.first
|
|
19
|
+
raise UndefinedVariableError.new('Undefined variable: <empty path>', line: line, column: column) unless name
|
|
20
|
+
|
|
21
|
+
value = resolve_name(name, line: line, column: column)
|
|
22
|
+
|
|
23
|
+
path[1..].each do |segment|
|
|
24
|
+
value = access_property(value, segment, line: line, column: column)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def push_scope(bindings = {})
|
|
31
|
+
validate_no_shadowing!(bindings)
|
|
32
|
+
@local_stack.push(bindings)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def push_include_scope(bindings)
|
|
36
|
+
@local_stack.push(bindings)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pop_scope
|
|
40
|
+
@local_stack.pop
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with_scope(bindings, include_scope: false)
|
|
44
|
+
if include_scope
|
|
45
|
+
push_include_scope(bindings)
|
|
46
|
+
else
|
|
47
|
+
push_scope(bindings)
|
|
48
|
+
end
|
|
49
|
+
yield
|
|
50
|
+
ensure
|
|
51
|
+
pop_scope
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def resolve_name(name, line: nil, column: nil)
|
|
57
|
+
@local_stack.reverse_each do |scope|
|
|
58
|
+
return scope[name] if scope.key?(name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return @root[name] if @root.key?(name)
|
|
62
|
+
|
|
63
|
+
raise UndefinedVariableError.new("Undefined variable: #{name}", line: line, column: column)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_no_shadowing!(bindings)
|
|
67
|
+
bindings.each_key do |name|
|
|
68
|
+
name_str = name.to_s
|
|
69
|
+
origin = binding_origin(name_str)
|
|
70
|
+
next unless origin
|
|
71
|
+
|
|
72
|
+
raise ShadowingError,
|
|
73
|
+
"Cannot shadow existing variable '#{name_str}' (already defined in #{origin})"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def binding_origin(name)
|
|
78
|
+
return 'root data' if @root.key?(name)
|
|
79
|
+
|
|
80
|
+
@local_stack.reverse_each do |scope|
|
|
81
|
+
return 'outer local scope' if scope.key?(name)
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def access_property(value, key, line: nil, column: nil)
|
|
87
|
+
raise TypeError, "Cannot access property '#{key}' on non-object" unless value.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
unless value.key?(key)
|
|
90
|
+
raise UndefinedVariableError.new("Undefined property: #{key}", line: line, column: column)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
value[key]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Natsuzora
|
|
6
|
+
module Contract
|
|
7
|
+
module AST
|
|
8
|
+
# Any value (unconstrained).
|
|
9
|
+
class Any < Node
|
|
10
|
+
def to_h
|
|
11
|
+
{ 'kind' => 'any' }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ==(other)
|
|
15
|
+
other.is_a?(Any)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Natsuzora
|
|
6
|
+
module Contract
|
|
7
|
+
module AST
|
|
8
|
+
# Ordered list of items of a single contract type.
|
|
9
|
+
# Serialized as 'kind' => 'array' for JSON compatibility.
|
|
10
|
+
class List < Node
|
|
11
|
+
attr_reader :items
|
|
12
|
+
|
|
13
|
+
def initialize(items)
|
|
14
|
+
super()
|
|
15
|
+
@items = items
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{ 'kind' => 'array', 'items' => @items.to_h }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.is_a?(List) && other.items == @items
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
module Contract
|
|
5
|
+
module AST
|
|
6
|
+
# Base class for contract AST nodes.
|
|
7
|
+
# Each subclass implements +to_h+ for JSON serialization.
|
|
8
|
+
# Use +Natsuzora::Contract::AST.from_h+ for deserialization.
|
|
9
|
+
class Node
|
|
10
|
+
def to_h
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Natsuzora
|
|
6
|
+
module Contract
|
|
7
|
+
module AST
|
|
8
|
+
# Record-style object with named properties.
|
|
9
|
+
# Serialized as 'kind' => 'object' for JSON compatibility.
|
|
10
|
+
class Record < Node
|
|
11
|
+
attr_reader :properties, :required
|
|
12
|
+
|
|
13
|
+
def initialize(properties = {}, required = [])
|
|
14
|
+
super()
|
|
15
|
+
@properties = properties
|
|
16
|
+
@required = required
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
h = { 'kind' => 'object', 'properties' => @properties.transform_values(&:to_h) }
|
|
21
|
+
h['required'] = @required unless @required.empty?
|
|
22
|
+
h
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(Record) &&
|
|
27
|
+
other.properties == @properties &&
|
|
28
|
+
other.required == @required
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Natsuzora
|
|
6
|
+
module Contract
|
|
7
|
+
module AST
|
|
8
|
+
# Type reference (used during parsing, resolved before use).
|
|
9
|
+
class Ref < Node
|
|
10
|
+
attr_reader :name
|
|
11
|
+
|
|
12
|
+
def initialize(name)
|
|
13
|
+
super()
|
|
14
|
+
@name = name
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
{ 'kind' => 'ref', 'name' => @name }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ==(other)
|
|
22
|
+
other.is_a?(Ref) && other.name == @name
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
require_relative '../scalar_type'
|
|
5
|
+
|
|
6
|
+
module Natsuzora
|
|
7
|
+
module Contract
|
|
8
|
+
module AST
|
|
9
|
+
# Scalar value with type and modifier.
|
|
10
|
+
class Scalar < Node
|
|
11
|
+
# Modifier for null/empty handling on scalar values.
|
|
12
|
+
module Modifier
|
|
13
|
+
# Default: null not allowed, empty string allowed
|
|
14
|
+
NONE = :none
|
|
15
|
+
# `?` modifier: null allowed
|
|
16
|
+
NULLABLE = :nullable
|
|
17
|
+
# `!` modifier: null not allowed, empty string not allowed
|
|
18
|
+
REQUIRED = :required
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :scalar_type, :modifier
|
|
22
|
+
|
|
23
|
+
def initialize(scalar_type, modifier = Modifier::NONE)
|
|
24
|
+
super()
|
|
25
|
+
@scalar_type = scalar_type
|
|
26
|
+
@modifier = modifier
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def nullable?
|
|
30
|
+
@modifier == Modifier::NULLABLE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def required?
|
|
34
|
+
@modifier == Modifier::REQUIRED
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def accepts?(data)
|
|
38
|
+
case @scalar_type
|
|
39
|
+
when ScalarType::STRING then data.is_a?(String)
|
|
40
|
+
when ScalarType::INTEGER then data.is_a?(Integer)
|
|
41
|
+
when ScalarType::BOOL then data.is_a?(TrueClass) || data.is_a?(FalseClass)
|
|
42
|
+
when ScalarType::SCALAR then data.is_a?(String) || data.is_a?(Integer)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
h = { 'kind' => 'scalar', 'type' => @scalar_type.to_s }
|
|
48
|
+
h['modifier'] = @modifier.to_s unless @modifier == Modifier::NONE
|
|
49
|
+
h
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ==(other)
|
|
53
|
+
other.is_a?(Scalar) &&
|
|
54
|
+
other.scalar_type == @scalar_type &&
|
|
55
|
+
other.modifier == @modifier
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ast/node'
|
|
4
|
+
require_relative 'ast/any'
|
|
5
|
+
require_relative 'ast/scalar'
|
|
6
|
+
require_relative 'ast/record'
|
|
7
|
+
require_relative 'ast/list'
|
|
8
|
+
require_relative 'ast/ref'
|
|
9
|
+
|
|
10
|
+
module Natsuzora
|
|
11
|
+
module Contract
|
|
12
|
+
# Contract AST nodes and their serialization helpers.
|
|
13
|
+
module AST
|
|
14
|
+
# Build an AST::Node tree from its hash representation.
|
|
15
|
+
def self.from_h(hash)
|
|
16
|
+
case hash['kind']
|
|
17
|
+
when 'any'
|
|
18
|
+
Any.new
|
|
19
|
+
when 'scalar'
|
|
20
|
+
scalar_type = hash['type'].to_sym
|
|
21
|
+
modifier = (hash['modifier'] || 'none').to_sym
|
|
22
|
+
Scalar.new(scalar_type, modifier)
|
|
23
|
+
when 'object'
|
|
24
|
+
required = hash['required'] || []
|
|
25
|
+
properties = (hash['properties'] || {}).transform_values { |v| from_h(v) }
|
|
26
|
+
Record.new(properties, required)
|
|
27
|
+
when 'array'
|
|
28
|
+
items = from_h(hash['items'])
|
|
29
|
+
List.new(items)
|
|
30
|
+
when 'ref'
|
|
31
|
+
Ref.new(hash['name'])
|
|
32
|
+
else
|
|
33
|
+
raise ArgumentError, "Unknown contract kind: #{hash['kind']}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../lexer_loader'
|
|
4
|
+
|
|
5
|
+
module Natsuzora
|
|
6
|
+
module Contract
|
|
7
|
+
module CompiledLexer
|
|
8
|
+
LEXER_PATH = File.expand_path('../data/lexers/contract.lkt1', __dir__)
|
|
9
|
+
|
|
10
|
+
def self.instance
|
|
11
|
+
@instance ||= LexerLoader.load_compiled(LEXER_PATH)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
module Contract
|
|
5
|
+
# Diff marker for 2-generation contracts.
|
|
6
|
+
module DiffMarker
|
|
7
|
+
# `+` - Field will be added in next generation
|
|
8
|
+
ADDED = :added
|
|
9
|
+
# `-` - Field will be removed in next generation
|
|
10
|
+
REMOVED = :removed
|
|
11
|
+
# `*` - Field type will change in next generation
|
|
12
|
+
CHANGED = :changed
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|