kumi 0.0.0 → 0.0.4
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 +4 -4
- data/.rubocop.yml +113 -3
- data/CHANGELOG.md +21 -1
- data/CLAUDE.md +387 -0
- data/README.md +270 -20
- data/docs/development/README.md +120 -0
- data/docs/development/error-reporting.md +361 -0
- data/documents/AST.md +126 -0
- data/documents/DSL.md +154 -0
- data/documents/FUNCTIONS.md +132 -0
- data/documents/SYNTAX.md +367 -0
- data/examples/deep_schema_compilation_and_evaluation_benchmark.rb +106 -0
- data/examples/federal_tax_calculator_2024.rb +112 -0
- data/examples/wide_schema_compilation_and_evaluation_benchmark.rb +80 -0
- data/lib/generators/trait_engine/templates/schema_spec.rb.erb +27 -0
- data/lib/kumi/analyzer/constant_evaluator.rb +51 -0
- data/lib/kumi/analyzer/passes/definition_validator.rb +42 -0
- data/lib/kumi/analyzer/passes/dependency_resolver.rb +71 -0
- data/lib/kumi/analyzer/passes/input_collector.rb +55 -0
- data/lib/kumi/analyzer/passes/name_indexer.rb +24 -0
- data/lib/kumi/analyzer/passes/pass_base.rb +67 -0
- data/lib/kumi/analyzer/passes/toposorter.rb +72 -0
- data/lib/kumi/analyzer/passes/type_checker.rb +139 -0
- data/lib/kumi/analyzer/passes/type_consistency_checker.rb +45 -0
- data/lib/kumi/analyzer/passes/type_inferencer.rb +125 -0
- data/lib/kumi/analyzer/passes/unsat_detector.rb +107 -0
- data/lib/kumi/analyzer/passes/visitor_pass.rb +41 -0
- data/lib/kumi/analyzer.rb +54 -0
- data/lib/kumi/atom_unsat_solver.rb +349 -0
- data/lib/kumi/compiled_schema.rb +41 -0
- data/lib/kumi/compiler.rb +127 -0
- data/lib/kumi/domain/enum_analyzer.rb +53 -0
- data/lib/kumi/domain/range_analyzer.rb +83 -0
- data/lib/kumi/domain/validator.rb +84 -0
- data/lib/kumi/domain/violation_formatter.rb +40 -0
- data/lib/kumi/domain.rb +8 -0
- data/lib/kumi/error_reporter.rb +164 -0
- data/lib/kumi/error_reporting.rb +95 -0
- data/lib/kumi/errors.rb +116 -0
- data/lib/kumi/evaluation_wrapper.rb +22 -0
- data/lib/kumi/explain.rb +281 -0
- data/lib/kumi/export/deserializer.rb +39 -0
- data/lib/kumi/export/errors.rb +12 -0
- data/lib/kumi/export/node_builders.rb +140 -0
- data/lib/kumi/export/node_registry.rb +38 -0
- data/lib/kumi/export/node_serializers.rb +156 -0
- data/lib/kumi/export/serializer.rb +23 -0
- data/lib/kumi/export.rb +33 -0
- data/lib/kumi/function_registry/collection_functions.rb +92 -0
- data/lib/kumi/function_registry/comparison_functions.rb +31 -0
- data/lib/kumi/function_registry/conditional_functions.rb +36 -0
- data/lib/kumi/function_registry/function_builder.rb +92 -0
- data/lib/kumi/function_registry/logical_functions.rb +42 -0
- data/lib/kumi/function_registry/math_functions.rb +72 -0
- data/lib/kumi/function_registry/string_functions.rb +54 -0
- data/lib/kumi/function_registry/type_functions.rb +51 -0
- data/lib/kumi/function_registry.rb +138 -0
- data/lib/kumi/input/type_matcher.rb +92 -0
- data/lib/kumi/input/validator.rb +52 -0
- data/lib/kumi/input/violation_creator.rb +50 -0
- data/lib/kumi/input.rb +8 -0
- data/lib/kumi/parser/build_context.rb +25 -0
- data/lib/kumi/parser/dsl.rb +12 -0
- data/lib/kumi/parser/dsl_cascade_builder.rb +125 -0
- data/lib/kumi/parser/expression_converter.rb +58 -0
- data/lib/kumi/parser/guard_rails.rb +43 -0
- data/lib/kumi/parser/input_builder.rb +94 -0
- data/lib/kumi/parser/input_proxy.rb +29 -0
- data/lib/kumi/parser/parser.rb +66 -0
- data/lib/kumi/parser/schema_builder.rb +172 -0
- data/lib/kumi/parser/sugar.rb +108 -0
- data/lib/kumi/schema.rb +49 -0
- data/lib/kumi/schema_instance.rb +43 -0
- data/lib/kumi/syntax/declarations.rb +23 -0
- data/lib/kumi/syntax/expressions.rb +30 -0
- data/lib/kumi/syntax/node.rb +46 -0
- data/lib/kumi/syntax/root.rb +12 -0
- data/lib/kumi/syntax/terminal_expressions.rb +27 -0
- data/lib/kumi/syntax.rb +9 -0
- data/lib/kumi/types/builder.rb +21 -0
- data/lib/kumi/types/compatibility.rb +86 -0
- data/lib/kumi/types/formatter.rb +24 -0
- data/lib/kumi/types/inference.rb +40 -0
- data/lib/kumi/types/normalizer.rb +70 -0
- data/lib/kumi/types/validator.rb +35 -0
- data/lib/kumi/types.rb +64 -0
- data/lib/kumi/version.rb +1 -1
- data/lib/kumi.rb +7 -3
- data/scripts/generate_function_docs.rb +59 -0
- data/test_impossible_cascade.rb +51 -0
- metadata +93 -10
- data/sig/kumi.rbs +0 -4
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Input
|
5
|
+
class TypeMatcher
|
6
|
+
def self.matches?(value, declared_type)
|
7
|
+
case declared_type
|
8
|
+
when :integer
|
9
|
+
value.is_a?(Integer)
|
10
|
+
when :float
|
11
|
+
value.is_a?(Float) || value.is_a?(Integer) # Allow integer for float
|
12
|
+
when :string
|
13
|
+
value.is_a?(String)
|
14
|
+
when :boolean
|
15
|
+
value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
16
|
+
when :symbol
|
17
|
+
value.is_a?(Symbol)
|
18
|
+
when :any
|
19
|
+
true
|
20
|
+
else
|
21
|
+
# Handle complex types (arrays, hashes)
|
22
|
+
handle_complex_type(value, declared_type)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.infer_type(value)
|
27
|
+
case value
|
28
|
+
when Integer then :integer
|
29
|
+
when Float then :float
|
30
|
+
when String then :string
|
31
|
+
when TrueClass, FalseClass then :boolean
|
32
|
+
when Symbol then :symbol
|
33
|
+
when Array then { array: :mixed }
|
34
|
+
when Hash then { hash: %i[mixed mixed] }
|
35
|
+
else :unknown
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.format_type(type)
|
40
|
+
case type
|
41
|
+
when Symbol
|
42
|
+
type.to_s
|
43
|
+
when Hash
|
44
|
+
format_complex_type(type)
|
45
|
+
else
|
46
|
+
type.inspect
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private_class_method def self.handle_complex_type(value, declared_type)
|
51
|
+
return false unless declared_type.is_a?(Hash)
|
52
|
+
|
53
|
+
if declared_type.key?(:array)
|
54
|
+
handle_array_type(value, declared_type[:array])
|
55
|
+
elsif declared_type.key?(:hash)
|
56
|
+
handle_hash_type(value, declared_type[:hash])
|
57
|
+
else
|
58
|
+
false
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
private_class_method def self.handle_array_type(value, element_type)
|
63
|
+
return false unless value.is_a?(Array)
|
64
|
+
return true if element_type == :any
|
65
|
+
|
66
|
+
value.all? { |elem| matches?(elem, element_type) }
|
67
|
+
end
|
68
|
+
|
69
|
+
private_class_method def self.handle_hash_type(value, hash_spec)
|
70
|
+
return false unless value.is_a?(Hash)
|
71
|
+
|
72
|
+
key_type, value_type = hash_spec
|
73
|
+
return true if key_type == :any && value_type == :any
|
74
|
+
|
75
|
+
value.all? do |k, v|
|
76
|
+
matches?(k, key_type) && matches?(v, value_type)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
private_class_method def self.format_complex_type(type)
|
81
|
+
if type.key?(:array)
|
82
|
+
"array(#{format_type(type[:array])})"
|
83
|
+
elsif type.key?(:hash)
|
84
|
+
key_type, value_type = type[:hash]
|
85
|
+
"hash(#{format_type(key_type)}, #{format_type(value_type)})"
|
86
|
+
else
|
87
|
+
type.inspect
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "type_matcher"
|
4
|
+
require_relative "violation_creator"
|
5
|
+
|
6
|
+
module Kumi
|
7
|
+
module Input
|
8
|
+
class Validator
|
9
|
+
def self.validate_context(context, input_meta)
|
10
|
+
violations = []
|
11
|
+
|
12
|
+
context.each do |field, value|
|
13
|
+
meta = input_meta[field]
|
14
|
+
next unless meta
|
15
|
+
|
16
|
+
# Type validation first
|
17
|
+
if should_validate_type?(meta) && !TypeMatcher.matches?(value, meta[:type])
|
18
|
+
violations << ViolationCreator.create_type_violation(field, value, meta[:type])
|
19
|
+
next # Skip domain validation if type is wrong
|
20
|
+
end
|
21
|
+
|
22
|
+
# Domain validation second (only if type is correct)
|
23
|
+
if should_validate_domain?(meta) && !Domain::Validator.validate_field(field, value, meta[:domain])
|
24
|
+
violations << ViolationCreator.create_domain_violation(field, value, meta[:domain])
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
violations
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.type_matches?(value, declared_type)
|
32
|
+
TypeMatcher.matches?(value, declared_type)
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.infer_type(value)
|
36
|
+
TypeMatcher.infer_type(value)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.format_type(type)
|
40
|
+
TypeMatcher.format_type(type)
|
41
|
+
end
|
42
|
+
|
43
|
+
private_class_method def self.should_validate_type?(meta)
|
44
|
+
meta[:type] && meta[:type] != :any
|
45
|
+
end
|
46
|
+
|
47
|
+
private_class_method def self.should_validate_domain?(meta)
|
48
|
+
meta[:domain]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Input
|
5
|
+
class ViolationCreator
|
6
|
+
def self.create_type_violation(field, value, expected_type)
|
7
|
+
{
|
8
|
+
type: :type_violation,
|
9
|
+
field: field,
|
10
|
+
value: value,
|
11
|
+
expected_type: expected_type,
|
12
|
+
actual_type: TypeMatcher.infer_type(value),
|
13
|
+
message: format_type_violation_message(field, value, expected_type)
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.create_domain_violation(field, value, domain)
|
18
|
+
{
|
19
|
+
type: :domain_violation,
|
20
|
+
field: field,
|
21
|
+
value: value,
|
22
|
+
domain: domain,
|
23
|
+
message: Kumi::Domain::ViolationFormatter.format_message(field, value, domain)
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.create_missing_field_violation(field, expected_type)
|
28
|
+
{
|
29
|
+
type: :missing_field_violation,
|
30
|
+
field: field,
|
31
|
+
expected_type: expected_type,
|
32
|
+
message: format_missing_field_message(field, expected_type)
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
private_class_method def self.format_type_violation_message(field, value, expected_type)
|
37
|
+
actual_type = TypeMatcher.infer_type(value)
|
38
|
+
expected_formatted = TypeMatcher.format_type(expected_type)
|
39
|
+
actual_formatted = TypeMatcher.format_type(actual_type)
|
40
|
+
|
41
|
+
"Field :#{field} expected #{expected_formatted}, got #{value.inspect} of type #{actual_formatted}"
|
42
|
+
end
|
43
|
+
|
44
|
+
private_class_method def self.format_missing_field_message(field, expected_type)
|
45
|
+
expected_formatted = TypeMatcher.format_type(expected_type)
|
46
|
+
"Missing required field :#{field} of type #{expected_formatted}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/lib/kumi/input.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Parser
|
5
|
+
class BuildContext
|
6
|
+
attr_reader :inputs, :attributes, :traits
|
7
|
+
attr_accessor :current_location
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@inputs = []
|
11
|
+
@attributes = []
|
12
|
+
@traits = []
|
13
|
+
@input_block_defined = false
|
14
|
+
end
|
15
|
+
|
16
|
+
def input_block_defined?
|
17
|
+
@input_block_defined
|
18
|
+
end
|
19
|
+
|
20
|
+
def mark_input_block_defined!
|
21
|
+
@input_block_defined = true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Parser
|
5
|
+
class DslCascadeBuilder
|
6
|
+
include Syntax
|
7
|
+
|
8
|
+
attr_reader :cases
|
9
|
+
|
10
|
+
def initialize(context, loc)
|
11
|
+
@context = context
|
12
|
+
@cases = []
|
13
|
+
@loc = loc
|
14
|
+
end
|
15
|
+
|
16
|
+
def on(*args)
|
17
|
+
on_loc = current_location
|
18
|
+
validate_on_args(args, "on", on_loc)
|
19
|
+
|
20
|
+
trait_names = args[0..-2]
|
21
|
+
expr = args.last
|
22
|
+
|
23
|
+
condition = create_function_call(:all?, trait_names, on_loc)
|
24
|
+
result = ensure_syntax(expr)
|
25
|
+
add_case(condition, result)
|
26
|
+
end
|
27
|
+
|
28
|
+
def on_any(*args)
|
29
|
+
on_loc = current_location
|
30
|
+
validate_on_args(args, "on_any", on_loc)
|
31
|
+
|
32
|
+
trait_names = args[0..-2]
|
33
|
+
expr = args.last
|
34
|
+
|
35
|
+
condition = create_function_call(:any?, trait_names, on_loc)
|
36
|
+
result = ensure_syntax(expr)
|
37
|
+
add_case(condition, result)
|
38
|
+
end
|
39
|
+
|
40
|
+
def on_none(*args)
|
41
|
+
on_loc = current_location
|
42
|
+
validate_on_args(args, "on_none", on_loc)
|
43
|
+
|
44
|
+
trait_names = args[0..-2]
|
45
|
+
expr = args.last
|
46
|
+
|
47
|
+
condition = create_function_call(:none?, trait_names, on_loc)
|
48
|
+
result = ensure_syntax(expr)
|
49
|
+
add_case(condition, result)
|
50
|
+
end
|
51
|
+
|
52
|
+
def base(expr)
|
53
|
+
result = ensure_syntax(expr)
|
54
|
+
add_case(create_literal(true), result)
|
55
|
+
end
|
56
|
+
|
57
|
+
def method_missing(method_name, *args, &block)
|
58
|
+
return super if !args.empty? || block_given?
|
59
|
+
|
60
|
+
# Allow direct trait references in cascade conditions
|
61
|
+
create_binding(method_name, @loc)
|
62
|
+
end
|
63
|
+
|
64
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
65
|
+
true
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def current_location
|
71
|
+
caller_info = caller_locations(1, 1).first
|
72
|
+
Location.new(file: caller_info.path, line: caller_info.lineno, column: 0)
|
73
|
+
end
|
74
|
+
|
75
|
+
def validate_on_args(args, method_name, location)
|
76
|
+
raise_error("cascade '#{method_name}' requires at least one trait name", location) if args.empty?
|
77
|
+
|
78
|
+
return unless args.size == 1
|
79
|
+
|
80
|
+
raise_error("cascade '#{method_name}' requires an expression as the last argument", location)
|
81
|
+
end
|
82
|
+
|
83
|
+
def create_function_call(function_name, trait_names, location)
|
84
|
+
trait_bindings = trait_names.map { |name| create_binding(name, location) }
|
85
|
+
create_fn(function_name, trait_bindings)
|
86
|
+
end
|
87
|
+
|
88
|
+
def add_case(condition, result)
|
89
|
+
@cases << WhenCaseExpression.new(condition, result)
|
90
|
+
end
|
91
|
+
|
92
|
+
def ref(name)
|
93
|
+
@context.ref(name)
|
94
|
+
end
|
95
|
+
|
96
|
+
def fn(name, *args)
|
97
|
+
@context.fn(name, *args)
|
98
|
+
end
|
99
|
+
|
100
|
+
def create_literal(value)
|
101
|
+
@context.literal(value)
|
102
|
+
end
|
103
|
+
|
104
|
+
def create_fn(name, args)
|
105
|
+
@context.fn(name, args)
|
106
|
+
end
|
107
|
+
|
108
|
+
def input
|
109
|
+
@context.input
|
110
|
+
end
|
111
|
+
|
112
|
+
def ensure_syntax(expr)
|
113
|
+
@context.ensure_syntax(expr)
|
114
|
+
end
|
115
|
+
|
116
|
+
def raise_error(message, location)
|
117
|
+
@context.raise_error(message, location)
|
118
|
+
end
|
119
|
+
|
120
|
+
def create_binding(name, location)
|
121
|
+
Binding.new(name, loc: location)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Parser
|
5
|
+
class ExpressionConverter
|
6
|
+
include Syntax
|
7
|
+
include ErrorReporting
|
8
|
+
|
9
|
+
def initialize(context)
|
10
|
+
@context = context
|
11
|
+
end
|
12
|
+
|
13
|
+
def ensure_syntax(obj)
|
14
|
+
case obj
|
15
|
+
when Integer, String, TrueClass, FalseClass, Float, Regexp, Symbol
|
16
|
+
Literal.new(obj)
|
17
|
+
when Array
|
18
|
+
ListExpression.new(obj.map { |e| ensure_syntax(e) })
|
19
|
+
when Syntax::Node
|
20
|
+
obj
|
21
|
+
else
|
22
|
+
handle_complex_object(obj)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def ref(name)
|
27
|
+
Binding.new(name, loc: @context.current_location)
|
28
|
+
end
|
29
|
+
|
30
|
+
def literal(value)
|
31
|
+
Literal.new(value, loc: @context.current_location)
|
32
|
+
end
|
33
|
+
|
34
|
+
def fn(fn_name, *args)
|
35
|
+
expr_args = args.map { |a| ensure_syntax(a) }
|
36
|
+
CallExpression.new(fn_name, expr_args, loc: @context.current_location)
|
37
|
+
end
|
38
|
+
|
39
|
+
def input
|
40
|
+
InputProxy.new(@context)
|
41
|
+
end
|
42
|
+
|
43
|
+
def raise_error(message, location)
|
44
|
+
raise_syntax_error(message, location: location)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def handle_complex_object(obj)
|
50
|
+
if obj.class.instance_methods.include?(:to_ast_node)
|
51
|
+
obj.to_ast_node
|
52
|
+
else
|
53
|
+
raise_syntax_error("Invalid expression: #{obj.inspect}", location: @context.current_location)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Parser
|
5
|
+
module GuardRails
|
6
|
+
RESERVED = %i[input trait value fn lit ref].freeze
|
7
|
+
|
8
|
+
def self.included(base)
|
9
|
+
base.singleton_class.prepend(ClassMethods)
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
# prevent accidental addition of new DSL keywords
|
14
|
+
def method_added(name)
|
15
|
+
if GuardRails::RESERVED.include?(name)
|
16
|
+
# Check if this is a redefinition by looking at the call stack
|
17
|
+
# We want to allow the original definition but prevent redefinition
|
18
|
+
calling_location = caller_locations(1, 1).first
|
19
|
+
|
20
|
+
# Allow the original definition from schema_builder.rb
|
21
|
+
if calling_location&.path&.include?("schema_builder.rb")
|
22
|
+
super
|
23
|
+
return
|
24
|
+
end
|
25
|
+
|
26
|
+
# This is a redefinition attempt, prevent it
|
27
|
+
raise Kumi::Errors::SemanticError,
|
28
|
+
"DSL keyword `#{name}` is reserved; " \
|
29
|
+
"do not redefine it inside SchemaBuilder"
|
30
|
+
end
|
31
|
+
super
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# catch any stray method call inside DSL block
|
36
|
+
def method_missing(name, *_args)
|
37
|
+
raise NoMethodError, "unknown DSL keyword `#{name}`"
|
38
|
+
end
|
39
|
+
|
40
|
+
def respond_to_missing?(*) = false
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Parser
|
5
|
+
class InputBuilder
|
6
|
+
include Syntax
|
7
|
+
include ErrorReporting
|
8
|
+
|
9
|
+
def initialize(context)
|
10
|
+
@context = context
|
11
|
+
end
|
12
|
+
|
13
|
+
def key(name, type: :any, domain: nil)
|
14
|
+
normalized_type = normalize_type(type, name)
|
15
|
+
@context.inputs << FieldDecl.new(name, domain, normalized_type, loc: @context.current_location)
|
16
|
+
end
|
17
|
+
|
18
|
+
%i[integer float string boolean any].each do |type_name|
|
19
|
+
define_method(type_name) do |name, domain: nil|
|
20
|
+
@context.inputs << FieldDecl.new(name, domain, type_name, loc: @context.current_location)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def array(name_or_elem_type, **kwargs)
|
25
|
+
if kwargs.any?
|
26
|
+
create_array_field(name_or_elem_type, kwargs)
|
27
|
+
else
|
28
|
+
Kumi::Types.array(name_or_elem_type)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def hash(name_or_key_type, val_type = nil, **kwargs)
|
33
|
+
return Kumi::Types.hash(name_or_key_type, val_type) unless val_type.nil?
|
34
|
+
|
35
|
+
create_hash_field(name_or_key_type, kwargs)
|
36
|
+
end
|
37
|
+
|
38
|
+
def method_missing(method_name, *_args)
|
39
|
+
allowed_methods = "'key', 'integer', 'float', 'string', 'boolean', 'any', 'array', and 'hash'"
|
40
|
+
raise_syntax_error("Unknown method '#{method_name}' in input block. Only #{allowed_methods} are allowed.",
|
41
|
+
location: @context.current_location)
|
42
|
+
end
|
43
|
+
|
44
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
45
|
+
false
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def normalize_type(type, name)
|
51
|
+
Kumi::Types.normalize(type)
|
52
|
+
rescue ArgumentError => e
|
53
|
+
raise_syntax_error("Invalid type for input `#{name}`: #{e.message}", location: @context.current_location)
|
54
|
+
end
|
55
|
+
|
56
|
+
def create_array_field(field_name, options)
|
57
|
+
elem_spec = options[:elem]
|
58
|
+
domain = options[:domain]
|
59
|
+
elem_type = elem_spec.is_a?(Hash) && elem_spec[:type] ? elem_spec[:type] : :any
|
60
|
+
|
61
|
+
array_type = create_array_type(field_name, elem_type)
|
62
|
+
@context.inputs << FieldDecl.new(field_name, domain, array_type, loc: @context.current_location)
|
63
|
+
end
|
64
|
+
|
65
|
+
def create_array_type(field_name, elem_type)
|
66
|
+
Kumi::Types.array(elem_type)
|
67
|
+
rescue ArgumentError => e
|
68
|
+
raise_syntax_error("Invalid element type for array `#{field_name}`: #{e.message}", location: @context.current_location)
|
69
|
+
end
|
70
|
+
|
71
|
+
def create_hash_field(field_name, options)
|
72
|
+
key_spec = options[:key]
|
73
|
+
val_spec = options[:val] || options[:value]
|
74
|
+
domain = options[:domain]
|
75
|
+
|
76
|
+
key_type = extract_type(key_spec)
|
77
|
+
val_type = extract_type(val_spec)
|
78
|
+
|
79
|
+
hash_type = create_hash_type(field_name, key_type, val_type)
|
80
|
+
@context.inputs << FieldDecl.new(field_name, domain, hash_type, loc: @context.current_location)
|
81
|
+
end
|
82
|
+
|
83
|
+
def extract_type(spec)
|
84
|
+
spec.is_a?(Hash) && spec[:type] ? spec[:type] : :any
|
85
|
+
end
|
86
|
+
|
87
|
+
def create_hash_type(field_name, key_type, val_type)
|
88
|
+
Kumi::Types.hash(key_type, val_type)
|
89
|
+
rescue ArgumentError => e
|
90
|
+
raise_syntax_error("Invalid types for hash `#{field_name}`: #{e.message}", location: @context.current_location)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Parser
|
5
|
+
# Proxy object for input field references (input.field_name)
|
6
|
+
class InputProxy
|
7
|
+
include Syntax
|
8
|
+
|
9
|
+
def initialize(context)
|
10
|
+
@context = context
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def method_missing(method_name, *_args)
|
16
|
+
# Create a FieldRef node for the given method name
|
17
|
+
FieldRef.new(method_name, loc: @context.current_location)
|
18
|
+
end
|
19
|
+
|
20
|
+
# This method is called when the user tries to access a field
|
21
|
+
# on the input object, e.g. `input.field_name`.
|
22
|
+
# It is used to create a FieldRef node in the AST.
|
23
|
+
|
24
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
25
|
+
true # Allow any field name
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kumi
|
4
|
+
module Parser
|
5
|
+
class Parser
|
6
|
+
include Syntax
|
7
|
+
include ErrorReporting
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@context = BuildContext.new
|
11
|
+
@interface = SchemaBuilder.new(@context)
|
12
|
+
end
|
13
|
+
|
14
|
+
def parse(&rule_block)
|
15
|
+
enable_refinements(rule_block)
|
16
|
+
|
17
|
+
before_consts = ::Object.constants
|
18
|
+
@interface.freeze # stop singleton hacks
|
19
|
+
@interface.instance_eval(&rule_block)
|
20
|
+
added = ::Object.constants - before_consts
|
21
|
+
|
22
|
+
unless added.empty?
|
23
|
+
raise Kumi::Errors::SemanticError,
|
24
|
+
"DSL cannot define global constants: #{added.join(', ')}"
|
25
|
+
end
|
26
|
+
|
27
|
+
build_syntax_tree
|
28
|
+
rescue ArgumentError => e
|
29
|
+
handle_parse_error(e)
|
30
|
+
raise
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def enable_refinements(rule_block)
|
36
|
+
rule_block.binding.eval("using Kumi::Parser::Sugar::ExpressionRefinement")
|
37
|
+
rule_block.binding.eval("using Kumi::Parser::Sugar::NumericRefinement")
|
38
|
+
rule_block.binding.eval("using Kumi::Parser::Sugar::StringRefinement")
|
39
|
+
rescue RuntimeError, NoMethodError
|
40
|
+
# Refinements disabled in method scope - continue without them
|
41
|
+
end
|
42
|
+
|
43
|
+
def handle_parse_error(error)
|
44
|
+
return unless literal_comparison_error?(error)
|
45
|
+
|
46
|
+
warn <<~HINT
|
47
|
+
#{error.backtrace.first.split(':', 2).join(':')}: \
|
48
|
+
Literal‑left comparison failed because the schema block is \
|
49
|
+
defined inside a method (Ruby disallows refinements there).
|
50
|
+
|
51
|
+
• Move the `schema do … end` block to the top level of a class or module, OR
|
52
|
+
• Write the comparison as `input.age >= 80` (preferred), OR
|
53
|
+
• Wrap the literal: `lit(80) <= input.age`.
|
54
|
+
HINT
|
55
|
+
end
|
56
|
+
|
57
|
+
def literal_comparison_error?(error)
|
58
|
+
error.message =~ /comparison of Integer with Kumi::Syntax::/i
|
59
|
+
end
|
60
|
+
|
61
|
+
def build_syntax_tree
|
62
|
+
Root.new(@context.inputs, @context.attributes, @context.traits)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|