configuratrix 0.0.1.alpha

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.
@@ -0,0 +1,314 @@
1
+ require 'delegate'
2
+
3
+ module Configuratrix
4
+ module Internal
5
+
6
+
7
+ # Module to prepend to the singleton class of an uninitialized config object to
8
+ # make it fail in a more descriptive way. Useful for the default of a
9
+ # non-required subconfig (via Config#new_unloaded).
10
+ module UnloadedConfig
11
+ def [](field_name)
12
+ if self.class.fields_schema.find { _1.attribute_name == field_name }
13
+ raise Err::NoSuchValue, <<~END
14
+ #{self.class} is unloaded; cannot access #{field_name}
15
+ END
16
+ end
17
+ super
18
+ end
19
+ end
20
+
21
+
22
+ # Module to prepend to the singleton class of an uninitialized config object to
23
+ # make it provide only default values for fields. Useful for the default of a
24
+ # non-required subconfig (via Config#new_defaulty).
25
+ module DefaultyConfig
26
+ def [](field_name)
27
+ schema_init unless instance_variable_defined? :@field_map
28
+ unless @field_map.include? field_name
29
+ field_schema = (
30
+ self.class.fields_schema.find { _1.attribute_name == field_name })
31
+ unless field_schema.nil?
32
+ @field_map[field_name] = field_schema.new
33
+ end
34
+ end
35
+ super
36
+ end
37
+ end
38
+
39
+
40
+ module SourceUtil
41
+ # Load each given source within a config schema, handling interdependencies.
42
+ def self.parse_all(sources, within:)
43
+ config_schema = within
44
+ todo = sources.dup
45
+ complete_sources = []
46
+
47
+ loop do
48
+ ready_sources, unready_sources = (
49
+ todo.partition { _1.ready? complete_sources } )
50
+ ready_sources.each do |source|
51
+ source.cross_configure complete_sources
52
+ source.parse config_schema
53
+ complete_sources << source
54
+ end
55
+
56
+ if unready_sources == todo
57
+ # Progress has stopped. If we have any complete sources, that'll do.
58
+ break unless complete_sources.empty?
59
+ raise Err::PathologicalSchema, <<~END
60
+ sources did not become ready: #{unready_sources.map &:name}
61
+ END
62
+ end
63
+ todo = unready_sources
64
+ end
65
+ complete_sources .map { [ _1.name, _1 ] } .to_h
66
+ end
67
+ end
68
+
69
+
70
+ # A range that keeps track of how many values a field may take. Ensures
71
+ # preconditions, normalizes range for easy comparison, and provides arity util
72
+ # methods.
73
+ class Arity < DelegateClass(Range)
74
+ private_class_method :new
75
+
76
+ # Validate, normalize, and internalize the given range as an arity.
77
+ def self.from_range(range)
78
+ lower = range.begin
79
+ unless Integer === lower and lower > 0
80
+ raise Err::BadArgument, <<~END
81
+ minimum arity must be a natural number, not #{lower}
82
+ END
83
+ end
84
+ upper = range.end || Float::INFINITY
85
+ unless (Integer === upper and upper >= lower) or upper == Float::INFINITY
86
+ raise Err::BadArgument, <<~END
87
+ invalid arity range #{lower} to #{upper}
88
+ END
89
+ end
90
+ upper -= 1 if range.exclude_end?
91
+ new Range.new(lower, upper, false)
92
+ end
93
+
94
+ # Quickly make an arity with exactly one value.
95
+ def self.from_int(int)
96
+ from_range(int..int)
97
+ end
98
+
99
+ # Allow arity to be compared to both ranges and to integers.
100
+ def ==(other)
101
+ case other
102
+ in nil
103
+ false
104
+ in Integer
105
+ self.begin == other and self.end == other
106
+ in Range
107
+ other_end = other.end || Float::INFINITY
108
+ other_end -= 1 if other.exclude_end?
109
+ self.begin == other.begin and self.end == other_end
110
+ in Arity
111
+ __getobj__ == other.__getobj__
112
+ else
113
+ raise Err::BadArgument, <<~END
114
+ comparison of Arity with #{other.inspect} failed
115
+ END
116
+ end
117
+ end
118
+ def !=(other); !(self == other); end
119
+
120
+ def satisfied_by?(count)
121
+ return true if unspecified?
122
+ include? count
123
+ end
124
+ def saturated_by?(count)
125
+ return false if unspecified?
126
+ count >= self.end
127
+ end
128
+ def unspecified?; self == Unspec; end
129
+ def demands_plurality?; self != Unspec and self != 1; end
130
+ def endless?; self.end.nil? or self.end == Float::INFINITY; end
131
+
132
+ def to_s
133
+ return '<unspecified>' if unspecified?
134
+ return self.begin.to_s if self.begin == self.end
135
+ super
136
+ end
137
+
138
+ Unspec = new(nil..nil)
139
+ end
140
+
141
+
142
+ # Class methods that facilitate defining nested trees of classes as with a
143
+ # configuration schema. Mix-in this module only with `extend`.
144
+ module NestingSchema
145
+ def self.extended(mod)
146
+ # To extend a class with NestingSchema is to say that the class's basename
147
+ # should also be a namespace for subclasses. Explicitly marking such
148
+ # classes with a constant allows possible further customization by
149
+ # subclassing without fragmenting the collection of classes under the
150
+ # nest's basename.
151
+ # See also #nest and its callers.
152
+ mod.const_set :NEST, mod
153
+ # It is useful for some nesting classes like Field to change the
154
+ # information given by #inspect, but there are times where #name will
155
+ # return nil but the address-based inspect gives useful information.
156
+ class << mod
157
+ alias :ruby_address :inspect
158
+ end
159
+ end
160
+
161
+ # Create a subclass of the current class, specified by block. If a name is
162
+ # given, the class will be set as a constant under base_namespace. The
163
+ # nesting path under base_namespace will be of the form Field::VitalVal for a
164
+ # class stemming from Field.nested_class!(:vital_val).
165
+ # Only the leaf of the parent class name is considered, so
166
+ # SomeModule::Field.nested_class! would place named subclasses in
167
+ # base_namespace::Field as well, not base_namespace::SomeModule::Field.
168
+ #
169
+ # Note: Being assigned to a constant for the first time is how Ruby
170
+ # classes get their internal names.
171
+ # Note: Ruby puts classes defined by class blocks into Object if that block is
172
+ # not already contained within a class or module block.
173
+ # Note: Here 'class block' and 'module block' refer only to blocks opened
174
+ # with `class <Classname>` or `module <Modulename>` since `Class.new`
175
+ # does not affect the constant nesting. See also Module::nesting.
176
+ def nested_class!(name=nil, under: Object, &block)
177
+ base_namespace = under
178
+ subclass = Class.new(self, &block)
179
+ unless name.nil?
180
+ subclass.alias!(name, under: base_namespace)
181
+ end
182
+ subclass
183
+ end
184
+
185
+ # Place create a recognizeable constant within base_namespace that refers to
186
+ # this class. Constants are of the form <base>::NestingBasename::Name. If
187
+ # no constant has previously been set to this class, then the full resulting
188
+ # name will also become the class's name.
189
+ # See Also: #nested_class!
190
+ def alias!(name=basename, under:Object)
191
+ base_namespace = under
192
+ # Put Fields in ::Field, etc.
193
+ nest_name = nest.const_name
194
+ unless base_namespace.constants(false).include? nest_name
195
+ base_namespace.const_set nest_name, Module.new
196
+ end
197
+ namespace = base_namespace.const_get nest_name
198
+
199
+ namespace.const_set NestingSchemaUtil.consty_name(name), self
200
+ end
201
+
202
+ # Access the portion of my schema that was specified as an extension of me by
203
+ # `other_class`.
204
+ def toggles(other_class)
205
+ our_kind_of_toggles = nest.const_get(:TOGGLE_TYPE, NO_INHERIT)
206
+ if !other_class.const_defined?(our_kind_of_toggles, NO_INHERIT)
207
+ raise Err::NoSuchValue, <<~END
208
+ #{other_class} is missing a
209
+ #{other_class}::#{our_kind_of_toggles} definition
210
+ END
211
+ end
212
+ their_toggles_for_us = other_class.const_get(our_kind_of_toggles)
213
+ toggle_registers[other_class] ||= their_toggles_for_us.new
214
+ end
215
+ def toggle_registers; @toggle_registers ||= {}; end
216
+
217
+ # Get this class's nest. The nest is the class under whose basename all
218
+ # subclasses are to be organized, e.g. ::Field::SomeField where Field was
219
+ # `extend`ed by NestingSchema.
220
+ def nest; self::NEST; end
221
+
222
+ # Get only the leaf name of the class, e.g. A from the class Foo::Bar::A.
223
+ def basename; name.split('::').last; end
224
+
225
+ def const_name; basename.to_sym; end
226
+
227
+ def attribute_name; NestingSchemaUtil.attry_name(basename); end
228
+
229
+ # These kinds of classes are often defined with do-blocks and so may not know
230
+ # their names at the point an error is raised. But, since the base classes
231
+ # this module extends are defined in class blocks, we know someone up the
232
+ # chain has a useful basename.
233
+ def name; super || "<anonymous #{nest.basename}>"; end
234
+
235
+ # #succ allows schema classes to be used in ranges
236
+ def succ; superclass; end
237
+
238
+ # spaceship allows schema classes to be used in ranges
239
+ def <=>(other)
240
+ return -1 if self < other
241
+ return 0 if self == other
242
+ return 1 if self > other
243
+ raise "can't compare #{self} to unrelated #{other}"
244
+ end
245
+
246
+ # Companion methods to include into the extended class to allow objects to
247
+ # more easily interact with their schemas.
248
+ module InstanceMethods
249
+ def schema; self.class; end
250
+
251
+ def name; schema.attribute_name; end
252
+
253
+ def type; schema.name; end
254
+ end
255
+ end
256
+
257
+
258
+ module NestingSchemaUtil
259
+ # Search for name among the members of lister, then call the appropriate
260
+ # lambda based on the situation.
261
+ # lister must take a boolean specifying whether to include inherited values
262
+ # in the list or not. This distinction allows for the separation of local
263
+ # and remote values.
264
+ def self.absent_local_or_remote(name, lister, absent:, local:, remote:)
265
+ if !lister.(true).include? name
266
+ absent.()
267
+ elsif lister.(false).include? name
268
+ local.()
269
+ else
270
+ remote.()
271
+ end
272
+ end
273
+
274
+ # #send :message to each class in class_range and `+` the resulting values
275
+ # all together. If base_case is not nil, the result of calling it without
276
+ # arguments is added at the end.
277
+ # Classes aren't normally usable in ranges, but NestingSchema classes form
278
+ # ranges by superclassing toward an ancestor.
279
+ def self.roll_up(message, class_range, base_case: nil)
280
+ value = class_range .map { _1.send message } .reduce :+
281
+
282
+ unless base_case.nil?
283
+ value += base_case.call
284
+ end
285
+
286
+ value
287
+ end
288
+
289
+ # Turn an :attribute_like symbol into a :ConstLike symbol.
290
+ def self.consty_name(name)
291
+ name .to_s .downcase .split('_') .map(&:capitalize) .join .to_sym
292
+ end
293
+
294
+ # Turn a :ConstLike symbol into an :attribute_like symbol.
295
+ def self.attry_name(name)
296
+ name .to_s .gsub(/([a-z])([A-Z])/, '\1_\2') .downcase .to_sym
297
+ end
298
+ end
299
+
300
+ class HookedArray < DelegateClass(Array)
301
+ def initialize(&block)
302
+ super []
303
+ @hook = block
304
+ end
305
+
306
+ def <<(new_entry)
307
+ @hook.call new_entry
308
+ super new_entry
309
+ self
310
+ end
311
+ end
312
+
313
+ end
314
+ end