jev-feels 0.2.0 → 1.1.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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +280 -78
- data/context7.json +1 -1
- data/lib/feels/active_model.rb +5 -0
- data/lib/feels/string.rb +12 -0
- data/lib/jev/active_model.rb +30 -0
- data/lib/jev/client.rb +16 -20
- data/lib/jev/definition.rb +91 -0
- data/lib/jev/errors.rb +1 -0
- data/lib/jev/feels.rb +12 -0
- data/lib/jev/harness.rb +215 -0
- data/lib/jev/match.rb +28 -0
- data/lib/jev/model.rb +111 -0
- data/lib/jev/query.rb +33 -0
- data/lib/jev/registry.rb +74 -10
- data/lib/jev/result.rb +165 -0
- data/lib/jev/version.rb +1 -1
- data/lib/jev-feels/active_model.rb +3 -0
- data/lib/jev.rb +187 -37
- metadata +15 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
Definition = Data.define(:name, :type, :instructions, :choices, :levels) do
|
|
5
|
+
def self.build(name:, instructions:, choices: nil, levels: nil)
|
|
6
|
+
instructions = instructions.to_s.dup.freeze
|
|
7
|
+
raise ArgumentError, "cannot use choices: and levels: together" if choices && levels
|
|
8
|
+
|
|
9
|
+
new(name: name.to_sym, instructions: instructions, **typed_fields(choices, levels))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def simple_noul?
|
|
13
|
+
type == :noul
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def public_value
|
|
17
|
+
simple_noul? ? instructions : self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def level_names
|
|
21
|
+
levels&.keys
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def level_descriptions
|
|
25
|
+
levels&.values
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_question
|
|
29
|
+
question = { "type" => type.to_s, "instructions" => instructions }
|
|
30
|
+
criteria = question_criteria
|
|
31
|
+
question["criteria"] = criteria if criteria
|
|
32
|
+
question
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.typed_fields(choices, levels)
|
|
36
|
+
if choices
|
|
37
|
+
{ type: :choice, choices: normalize_choices(choices), levels: nil }
|
|
38
|
+
elsif levels
|
|
39
|
+
{ type: :score, levels: normalize_levels(levels), choices: nil }
|
|
40
|
+
else
|
|
41
|
+
{ type: :noul, choices: nil, levels: nil }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_class_method :typed_fields
|
|
45
|
+
|
|
46
|
+
def self.normalize_choices(choices)
|
|
47
|
+
raise ArgumentError, "choices must be a Hash" unless choices.is_a?(Hash)
|
|
48
|
+
raise ArgumentError, "choices cannot be empty" if choices.empty?
|
|
49
|
+
|
|
50
|
+
unique_keys(choices, "choice").freeze
|
|
51
|
+
end
|
|
52
|
+
private_class_method :normalize_choices
|
|
53
|
+
|
|
54
|
+
def self.normalize_levels(levels)
|
|
55
|
+
raise ArgumentError, "levels must be a Hash" unless levels.is_a?(Hash)
|
|
56
|
+
raise ArgumentError, "levels must have at least 2 entries" if levels.size < 2
|
|
57
|
+
|
|
58
|
+
unique_keys(levels, "level").freeze
|
|
59
|
+
end
|
|
60
|
+
private_class_method :normalize_levels
|
|
61
|
+
|
|
62
|
+
def self.unique_keys(pairs, label)
|
|
63
|
+
seen = {}
|
|
64
|
+
pairs.each do |key, description|
|
|
65
|
+
symbol = normalize_key(key, label)
|
|
66
|
+
raise ArgumentError, "duplicate #{label} key: #{symbol.inspect}" if seen.key?(symbol)
|
|
67
|
+
|
|
68
|
+
seen[symbol] = description.to_s.dup.freeze
|
|
69
|
+
end
|
|
70
|
+
seen
|
|
71
|
+
end
|
|
72
|
+
private_class_method :unique_keys
|
|
73
|
+
|
|
74
|
+
def self.normalize_key(key, label)
|
|
75
|
+
raise ArgumentError, "invalid #{label} key: #{key.inspect}" unless key.is_a?(Symbol) || key.is_a?(String)
|
|
76
|
+
raise ArgumentError, "invalid #{label} key: #{key.inspect}" if key.to_s.empty?
|
|
77
|
+
|
|
78
|
+
key.to_sym
|
|
79
|
+
end
|
|
80
|
+
private_class_method :normalize_key
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def question_criteria
|
|
85
|
+
case type
|
|
86
|
+
when :choice then choices.transform_keys(&:to_s)
|
|
87
|
+
when :score then level_descriptions
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/jev/errors.rb
CHANGED
data/lib/jev/feels.rb
CHANGED
|
@@ -10,6 +10,18 @@ module Jev
|
|
|
10
10
|
def feels?(...)
|
|
11
11
|
Jev.feels?(self, ...)
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
def decide(...)
|
|
15
|
+
Jev.decide(self, ...)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def score(...)
|
|
19
|
+
Jev.score(self, ...)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def measure(...)
|
|
23
|
+
Jev.measure(self, ...)
|
|
24
|
+
end
|
|
13
25
|
end
|
|
14
26
|
end
|
|
15
27
|
end
|
data/lib/jev/harness.rb
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Jev
|
|
6
|
+
module Harness
|
|
7
|
+
THREAD_KEY = :jev_transport
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def current_transport(configuration)
|
|
12
|
+
Thread.current[THREAD_KEY] || configuration.transport || Transport.new(configuration)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def stub(answers, &)
|
|
16
|
+
raise ArgumentError, "Jev.stub requires a block" unless block_given?
|
|
17
|
+
|
|
18
|
+
overlay(StubTransport.new(answers), &)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record(&)
|
|
22
|
+
raise ArgumentError, "Jev.record requires a block" unless block_given?
|
|
23
|
+
|
|
24
|
+
tape = Tape.new
|
|
25
|
+
inner = current_transport(Jev.configuration)
|
|
26
|
+
overlay(RecordingTransport.new(inner, tape), &)
|
|
27
|
+
tape
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replay(tape, &)
|
|
31
|
+
raise ArgumentError, "Jev.replay requires a block" unless block_given?
|
|
32
|
+
|
|
33
|
+
tape = Tape.parse(tape) unless tape.is_a?(Tape)
|
|
34
|
+
overlay(ReplayTransport.new(tape), &)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def overlay(transport)
|
|
38
|
+
previous = Thread.current[THREAD_KEY]
|
|
39
|
+
Thread.current[THREAD_KEY] = transport
|
|
40
|
+
yield
|
|
41
|
+
ensure
|
|
42
|
+
Thread.current[THREAD_KEY] = previous
|
|
43
|
+
end
|
|
44
|
+
private_class_method :overlay
|
|
45
|
+
|
|
46
|
+
class Tape
|
|
47
|
+
def initialize(entries = [])
|
|
48
|
+
@entries = entries
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def record(payload, response)
|
|
52
|
+
@entries << { "request" => canonical(payload), "response" => canonical(response) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def lookup(payload)
|
|
56
|
+
request = canonical(payload)
|
|
57
|
+
entry = @entries.find { |item| item["request"] == request }
|
|
58
|
+
raise ReplayError, replay_message(payload) unless entry
|
|
59
|
+
|
|
60
|
+
entry["response"]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_json(*)
|
|
64
|
+
JSON.generate({ "version" => 1, "entries" => @entries })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.parse(json)
|
|
68
|
+
payload = json.is_a?(String) ? JSON.parse(json) : json
|
|
69
|
+
raise ArgumentError, "tape is not a JSON object" unless payload.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
entries = payload["entries"]
|
|
72
|
+
raise ArgumentError, "tape is missing entries" unless entries.is_a?(Array)
|
|
73
|
+
|
|
74
|
+
new(entries)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def canonical(value)
|
|
80
|
+
case value
|
|
81
|
+
when Hash
|
|
82
|
+
value.to_h.transform_keys(&:to_s).sort.to_h.transform_values { |item| canonical(item) }
|
|
83
|
+
when Array
|
|
84
|
+
value.map { |item| canonical(item) }
|
|
85
|
+
else
|
|
86
|
+
value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def replay_message(payload)
|
|
91
|
+
ids = payload.is_a?(Hash) ? payload["questions"]&.keys : nil
|
|
92
|
+
"Jev replay has no recorded answer for questions #{ids.inspect}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class RecordingTransport
|
|
97
|
+
def initialize(inner, tape)
|
|
98
|
+
@inner = inner
|
|
99
|
+
@tape = tape
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def call(payload)
|
|
103
|
+
response = @inner.call(payload)
|
|
104
|
+
@tape.record(payload, response)
|
|
105
|
+
response
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class ReplayTransport
|
|
110
|
+
def initialize(tape)
|
|
111
|
+
@tape = tape
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def call(payload)
|
|
115
|
+
@tape.lookup(payload)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class StubTransport
|
|
120
|
+
def initialize(answers)
|
|
121
|
+
@answers = answers.to_h.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def call(payload)
|
|
125
|
+
questions = payload.fetch("questions")
|
|
126
|
+
{
|
|
127
|
+
"answers" => questions.to_h { |id, question| [id, answer_for(id, question)] }
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def answer_for(id, question)
|
|
134
|
+
stub = @answers[id.to_sym] || @answers[Jev.send(:name_for_instructions, question["instructions"])]
|
|
135
|
+
raise ArgumentError, "unstubbed Jev question: #{id}" if stub.nil?
|
|
136
|
+
|
|
137
|
+
case question["type"]
|
|
138
|
+
when "noul" then noul_answer(stub)
|
|
139
|
+
when "choice" then choice_answer(stub, question)
|
|
140
|
+
when "score" then score_answer(stub, question)
|
|
141
|
+
else
|
|
142
|
+
raise ArgumentError, "unstubbed Jev question: #{id}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def noul_answer(stub)
|
|
147
|
+
noul = stub.is_a?(Hash) ? stub[:noul] || stub["noul"] || stub[:probability] : stub
|
|
148
|
+
{ "type" => "noul", "noul" => Float(noul) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def choice_answer(stub, question)
|
|
152
|
+
winner, confidence, probabilities = unpack_stub(stub, :choice) { stub.to_s }
|
|
153
|
+
probabilities ||= default_choice_probabilities(question["criteria"] || {}, winner)
|
|
154
|
+
{
|
|
155
|
+
"type" => "choice",
|
|
156
|
+
"choice" => winner.to_s,
|
|
157
|
+
"confidence" => Float(confidence),
|
|
158
|
+
"probabilities" => probabilities.to_h { |key, value| [key.to_s, Float(value)] }
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def default_choice_probabilities(criteria, winner)
|
|
163
|
+
keys = criteria.keys.map(&:to_s)
|
|
164
|
+
keys << winner unless keys.include?(winner)
|
|
165
|
+
keys.to_h { |key| [key, key == winner ? 1.0 : 0.0] }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def score_answer(stub, question)
|
|
169
|
+
score, confidence, probabilities = unpack_stub(stub, :score) { stub }
|
|
170
|
+
probabilities ||= default_score_probabilities(question["criteria"] || [], score)
|
|
171
|
+
{
|
|
172
|
+
"type" => "score",
|
|
173
|
+
"score" => Float(score),
|
|
174
|
+
"confidence" => Float(confidence),
|
|
175
|
+
"probabilities" => score_probability_hash(probabilities, question)
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def unpack_stub(stub, key)
|
|
180
|
+
return [yield, 1.0, nil] unless stub_hash?(stub, key)
|
|
181
|
+
|
|
182
|
+
[stub_field(stub, key), stub_field(stub, :confidence) || 1.0, stub_field(stub, :probabilities)]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def stub_hash?(stub, key)
|
|
186
|
+
stub.is_a?(Hash) && (stub.key?(key) || stub.key?(key.to_s))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def stub_field(stub, key)
|
|
190
|
+
stub[key] || stub[key.to_s]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def default_score_probabilities(criteria, score)
|
|
194
|
+
size = [criteria.size, 1].max
|
|
195
|
+
nearest = Float(score).round.clamp(0, size - 1)
|
|
196
|
+
size.times.to_h { |index| [index.to_s, index == nearest ? 1.0 : 0.0] }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def score_probability_hash(probabilities, question)
|
|
200
|
+
names = Jev.send(:level_names_for, question["instructions"])
|
|
201
|
+
probabilities.to_h do |key, value|
|
|
202
|
+
index = score_index(key, names)
|
|
203
|
+
[index.to_s, Float(value)]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def score_index(key, names)
|
|
208
|
+
return key if key.is_a?(Integer)
|
|
209
|
+
return Integer(key) if key.is_a?(String) && key.match?(/\A\d+\z/)
|
|
210
|
+
|
|
211
|
+
names&.index(key.to_sym) || Integer(key)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
data/lib/jev/match.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Matcher
|
|
5
|
+
def initialize(choice)
|
|
6
|
+
@choice = choice
|
|
7
|
+
@handled = false
|
|
8
|
+
@result = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def on(*keys)
|
|
12
|
+
return @result if @handled
|
|
13
|
+
return unless keys.map(&:to_sym).include?(@choice)
|
|
14
|
+
|
|
15
|
+
@handled = true
|
|
16
|
+
@result = yield
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def otherwise
|
|
20
|
+
return @result if @handled
|
|
21
|
+
|
|
22
|
+
@handled = true
|
|
23
|
+
@result = yield
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :result
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/jev/model.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
module Model
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend ClassMethods
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def inherited(subclass)
|
|
11
|
+
super
|
|
12
|
+
subclass.extend ClassMethods
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def feels(attribute, name, description = nil, **options)
|
|
16
|
+
declare_jev(attribute, name, description, options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decide(attribute, name, description = nil, **options)
|
|
20
|
+
declare_jev(attribute, name, description, options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def score(attribute, name, description = nil, **options)
|
|
24
|
+
declare_jev(attribute, name, description, options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def bind_jev_attribute(attribute, name)
|
|
28
|
+
raise ArgumentError, "attribute must be a Symbol" unless attribute.is_a?(Symbol)
|
|
29
|
+
|
|
30
|
+
(@jev_feels_attributes ||= {})[name.to_sym] = attribute
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def jev_feels_attribute(name)
|
|
34
|
+
current = self
|
|
35
|
+
while current && current != Object
|
|
36
|
+
found = current.instance_variable_get(:@jev_feels_attributes)&.[](name)
|
|
37
|
+
return found if found
|
|
38
|
+
|
|
39
|
+
current = current.superclass
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def jev_bound_fields
|
|
44
|
+
fields = []
|
|
45
|
+
current = self
|
|
46
|
+
while current && current != Object
|
|
47
|
+
fields.concat(Array(current.instance_variable_get(:@jev_feels_attributes)&.values))
|
|
48
|
+
current = current.superclass
|
|
49
|
+
end
|
|
50
|
+
fields.uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def declare_jev(attribute, name, description, options)
|
|
56
|
+
bind_jev_attribute(attribute, name)
|
|
57
|
+
return if description.nil? && options.empty?
|
|
58
|
+
|
|
59
|
+
Jev.define(self, name, description, **options)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def feels(predicate)
|
|
64
|
+
Jev.feels(jev_text_for(predicate), self.class, predicate)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def feels?(predicate, **)
|
|
68
|
+
Jev.feels?(jev_text_for(predicate), self.class, predicate, **)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def decide(predicate, **)
|
|
72
|
+
Jev.decide(jev_text_for(predicate), self.class, predicate, **)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def score(predicate, **)
|
|
76
|
+
Jev.score(jev_text_for(predicate), self.class, predicate, **)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def measure(predicate = nil, **, &)
|
|
80
|
+
if block_given?
|
|
81
|
+
Jev.measure(jev_batch_text, self.class, **, &)
|
|
82
|
+
else
|
|
83
|
+
raise ArgumentError, "question is required" if predicate.nil?
|
|
84
|
+
|
|
85
|
+
Jev.measure(jev_text_for(predicate), self.class, predicate, **)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def match(predicate, **, &)
|
|
90
|
+
Jev.match(jev_text_for(predicate), self.class, predicate, **, &)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def jev_text_for(predicate)
|
|
96
|
+
attribute = self.class.jev_feels_attribute(predicate.to_sym) || raise(
|
|
97
|
+
UndefinedDefinition, "Undefined Jev definition: #{predicate.inspect} for #{self.class}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
public_send(attribute).to_s
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def jev_batch_text
|
|
104
|
+
fields = self.class.jev_bound_fields
|
|
105
|
+
raise ArgumentError, "no Jev field is bound for #{self.class}" if fields.empty?
|
|
106
|
+
raise ArgumentError, "measure block needs one field, got #{fields.inspect}" if fields.size > 1
|
|
107
|
+
|
|
108
|
+
public_send(fields.first).to_s
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/jev/query.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jev
|
|
4
|
+
class Query
|
|
5
|
+
attr_reader :questions
|
|
6
|
+
|
|
7
|
+
def initialize(scope)
|
|
8
|
+
@scope = scope
|
|
9
|
+
@questions = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def feels(name, **options)
|
|
13
|
+
add(name, options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def decide(name, **options)
|
|
17
|
+
add(name, options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def score(name, **options)
|
|
21
|
+
add(name, options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def add(name, options)
|
|
27
|
+
raise ArgumentError, "batch questions must be a defined Symbol" unless name.is_a?(Symbol)
|
|
28
|
+
raise ArgumentError, "duplicate batch question: #{name.inspect}" if @questions.key?(name)
|
|
29
|
+
|
|
30
|
+
@questions[name] = Jev.send(:resolve_definition, name, scope: @scope, **options)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/jev/registry.rb
CHANGED
|
@@ -7,32 +7,96 @@ module Jev
|
|
|
7
7
|
@definitions = {}
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def define(scope, name,
|
|
10
|
+
def define(scope, name, definition)
|
|
11
11
|
key = name.to_sym
|
|
12
|
-
|
|
12
|
+
scope_key = storage_key(scope)
|
|
13
13
|
@mutex.synchronize do
|
|
14
|
-
(@definitions[
|
|
14
|
+
(@definitions[scope_key] ||= {})[key] = definition
|
|
15
15
|
end
|
|
16
|
-
|
|
16
|
+
definition.public_value
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def fetch(name, scope: nil, fallback: true)
|
|
19
|
+
def fetch(name, scope: nil, fallback: true, inherit: true)
|
|
20
20
|
key = name.to_sym
|
|
21
21
|
@mutex.synchronize do
|
|
22
|
-
|
|
23
|
-
return scoped if scoped
|
|
24
|
-
return unless fallback || scope.nil?
|
|
22
|
+
return @definitions.dig(nil, key) if scope.nil?
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
lookup_keys(scope, inherit: inherit).each do |scope_key|
|
|
25
|
+
found = @definitions.dig(scope_key, key)
|
|
26
|
+
return found if found
|
|
27
|
+
end
|
|
28
|
+
return @definitions.dig(nil, key) if fallback
|
|
29
|
+
|
|
30
|
+
nil
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def all(scope = nil)
|
|
31
|
-
@mutex.synchronize
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
(@definitions[storage_key(scope)] || {}).transform_values(&:public_value).freeze
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def name_for_instructions(instructions)
|
|
41
|
+
each_definition { |name, definition| return name if definition.instructions == instructions }
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_by_instructions(instructions)
|
|
46
|
+
each_definition { |_name, definition| return definition if definition.instructions == instructions }
|
|
47
|
+
nil
|
|
32
48
|
end
|
|
33
49
|
|
|
34
50
|
def reset!
|
|
35
51
|
@mutex.synchronize { @definitions.clear }
|
|
36
52
|
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def each_definition(&block)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@definitions.each_value do |defs|
|
|
59
|
+
defs.each(&block)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def storage_key(scope)
|
|
65
|
+
return if scope.nil?
|
|
66
|
+
|
|
67
|
+
key =
|
|
68
|
+
case scope
|
|
69
|
+
when String, Symbol then scope.to_s
|
|
70
|
+
when Module then scope.name || scope
|
|
71
|
+
else raise ArgumentError, "scope must be a Module, String, or Symbol"
|
|
72
|
+
end
|
|
73
|
+
raise ArgumentError, "scope must be a non-empty String" if key.is_a?(String) && key.empty?
|
|
74
|
+
|
|
75
|
+
key
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def lookup_keys(scope, inherit:)
|
|
79
|
+
keys = [storage_key(scope)]
|
|
80
|
+
return keys unless inherit
|
|
81
|
+
|
|
82
|
+
klass = scope.is_a?(Class) ? scope : constantize(scope)
|
|
83
|
+
return keys unless klass.is_a?(Class)
|
|
84
|
+
|
|
85
|
+
current = klass.superclass
|
|
86
|
+
while current && current != Object
|
|
87
|
+
keys << (current.name || current)
|
|
88
|
+
current = current.superclass
|
|
89
|
+
end
|
|
90
|
+
keys.uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def constantize(name)
|
|
94
|
+
name = name.to_s if name.is_a?(Symbol)
|
|
95
|
+
return unless name.is_a?(String)
|
|
96
|
+
|
|
97
|
+
name.split("::").reduce(Object) { |mod, part| mod.const_get(part, false) }
|
|
98
|
+
rescue NameError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
37
101
|
end
|
|
38
102
|
end
|