typemap 0.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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typemap
4
+ class Mapper
5
+ # The type strategy for a project that keeps `sig/*.rbs` — written by hand, or by sord from
6
+ # YARD tags. A property's type is its reader's in the signature files: an `attr_reader` /
7
+ # `attr_accessor`, or a zero-argument `def name: () -> T`, found along the class's ancestors.
8
+ # `constrain type:` is still allowed for a property the signatures do not cover, but never
9
+ # for one they do — a type declared twice drifts (`D_types_declared`).
10
+ #
11
+ # Needs the `rbs` gem, the app's dependency and never Typemap's; only its parser is used,
12
+ # not its type environment (`R_rbs_parser`), and not a reader of our own (`D_rbs_gem_parser`).
13
+ class RBSTypes
14
+ # @param sig [Array<String>, nil] the `.rbs` files, or directories searched for them; `nil`
15
+ # finds the `sig/` directory beside each class's own source, walking up from its file
16
+ # @raise [LoadError] if the `rbs` gem cannot be loaded
17
+ def initialize(sig: nil)
18
+ begin
19
+ require "rbs"
20
+ rescue ::LoadError => e
21
+ raise ::LoadError,
22
+ "Typemap::Mapper::RBSTypes needs the rbs gem: add `gem \"rbs\"` to the Gemfile (#{e.message})"
23
+ end
24
+ @sig = sig&.then { |paths| expand(paths) }
25
+ @indexes = {}
26
+ @lock = Mutex.new
27
+ end
28
+
29
+ # @param klass [Class] the class being read
30
+ # @param name [Symbol] the property
31
+ # @param constraint [Constrain::Constraint, nil] what `constrain` recorded for it
32
+ # @return [Object] the property's {Type}
33
+ # @raise [DefinitionError] when neither the signatures nor `constrain` give a type, when
34
+ # both do, or the type is unsupported
35
+ def type_for(klass, name, constraint)
36
+ found = signature_type(klass, name)
37
+ if found && constraint&.type
38
+ raise DefinitionError, "type declared twice: in #{found[:where]} and by `constrain type:`; keep one"
39
+ end
40
+ return TypeBuilder.build(parse_type(constraint.type), constraint.type_owner.name) if constraint&.type
41
+
42
+ unless found
43
+ raise DefinitionError, "no type: #{klass}##{name} has no reader in #{files_for(klass).join(", ")}, " \
44
+ "and no `constrain :#{name}, type: \"…\"`"
45
+ end
46
+
47
+ build(found, name)
48
+ end
49
+
50
+ # A `constrain type:` string through the `rbs` gem's own parser, as a {TypeParser} tree.
51
+ #
52
+ # @param source [String]
53
+ # @return [Array]
54
+ # @raise [DefinitionError] on a syntax error or an unsupported type
55
+ def parse_type(source)
56
+ parsed = ::RBS::Parser.parse_type(source)
57
+ raise DefinitionError, "type #{source.inspect}: empty" unless parsed
58
+
59
+ tree(parsed)
60
+ rescue ::RBS::ParsingError => e
61
+ raise DefinitionError, "type #{source.inspect}: #{e.message}"
62
+ end
63
+
64
+ private
65
+
66
+ def build(found, name)
67
+ TypeBuilder.build(tree(found[:type]), found[:namespace])
68
+ rescue DefinitionError => e
69
+ raise DefinitionError, "#{e.message} (#{name} at #{found[:where]})"
70
+ end
71
+
72
+ # @return [Hash, nil] `{type:, namespace:, where:}` of the first ancestor whose signature
73
+ # declares a reader for `name`
74
+ def signature_type(klass, name)
75
+ index = index_for(files_for(klass))
76
+ klass.ancestors.each do |mod|
77
+ next if mod.name.nil?
78
+
79
+ hits = (index[mod.name] || []).flat_map { |decl| readers(decl, name) }
80
+ next if hits.empty?
81
+
82
+ if hits.map { |hit| hit[:type].to_s }.uniq.size > 1
83
+ raise DefinitionError, "type declared twice: #{hits.map { |hit| hit[:where] }.join(" and ")}"
84
+ end
85
+
86
+ return hits.first.merge(namespace: mod.name)
87
+ end
88
+ nil
89
+ end
90
+
91
+ def readers(decl, name)
92
+ decl.members.filter_map do |member|
93
+ type = reader_type(member, name)
94
+ next unless type
95
+
96
+ location = member.location
97
+ { type: type, where: "#{location&.buffer&.name}:#{location&.start_line}" }
98
+ end
99
+ end
100
+
101
+ def reader_type(member, name)
102
+ case member
103
+ when ::RBS::AST::Members::AttrReader, ::RBS::AST::Members::AttrAccessor
104
+ member.type if member.name == name && member.kind == :instance
105
+ when ::RBS::AST::Members::MethodDefinition
106
+ return unless member.name == name && member.kind == :instance
107
+
108
+ function = member.overloads.first.method_type.type
109
+ function.return_type if function.respond_to?(:required_positionals) && function.required_positionals.empty?
110
+ end
111
+ end
112
+
113
+ # The `rbs` gem's type AST, translated to {TypeParser}'s tree.
114
+ def tree(type)
115
+ case type
116
+ when ::RBS::Types::ClassInstance then [:name, type.name.to_s, type.args.map { |arg| tree(arg) }]
117
+ when ::RBS::Types::Optional then [:optional, tree(type.type)]
118
+ when ::RBS::Types::Union then [:union, type.types.map { |member| tree(member) }]
119
+ when ::RBS::Types::Literal then [:literal, type.literal]
120
+ when ::RBS::Types::Bases::Bool then [:bool]
121
+ when ::RBS::Types::Bases::Nil then [:nil]
122
+ when ::RBS::Types::Bases::Any then [:untyped]
123
+ else TypeBuilder.unsupported(type.to_s)
124
+ end
125
+ end
126
+
127
+ def files_for(klass)
128
+ return @sig if @sig
129
+
130
+ source = klass.name && Module.const_source_location(klass.name)&.first
131
+ unless source
132
+ raise DefinitionError,
133
+ "#{klass.inspect}: no source file to find sig/ from; pass RBSTypes.new(sig: [...])"
134
+ end
135
+
136
+ dir = File.dirname(File.expand_path(source))
137
+ until File.directory?(File.join(dir, "sig"))
138
+ parent = File.dirname(dir)
139
+ raise DefinitionError, "#{klass}: no sig/ directory above #{source}" if parent == dir
140
+
141
+ dir = parent
142
+ end
143
+ expand([File.join(dir, "sig")])
144
+ end
145
+
146
+ def expand(paths)
147
+ paths.flat_map { |path| File.directory?(path) ? Dir.glob(File.join(path, "**", "*.rbs")) : [path] }.sort
148
+ end
149
+
150
+ def index_for(files)
151
+ @lock.synchronize { @indexes[files] ||= files.each_with_object({}) { |file, index| add_file(file, index) } }
152
+ end
153
+
154
+ def add_file(file, index)
155
+ buffer = ::RBS::Buffer.new(name: file, content: File.read(file))
156
+ _, _, decls = ::RBS::Parser.parse_signature(buffer)
157
+ add_decls(decls, [], index)
158
+ rescue ::RBS::ParsingError => e
159
+ raise DefinitionError, "#{file}: #{e.message}"
160
+ end
161
+
162
+ def add_decls(decls, namespace, index)
163
+ decls.each do |decl|
164
+ next unless decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
165
+
166
+ written = decl.name.to_s
167
+ full = written.start_with?("::") ? written.delete_prefix("::").split("::") : namespace + written.split("::")
168
+ (index[full.join("::")] ||= []) << decl
169
+ add_decls(decl.members, full, index)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typemap
4
+ class Mapper
5
+ # The types the mapper checks a value against — the small subset of RBS it supports, whether
6
+ # the type came from `constrain type:` or from `sig/*.rbs` (`D_types_declared`). Each type
7
+ # answers five questions:
8
+ #
9
+ # - `load(value, path, session, base)` — the value as the property holds it, or a
10
+ # {DataError}; strict, never coerced, a whole number between Integer and Float aside;
11
+ # - `dump(value, mapper, path)` — the value as plain Hash/Array/scalar data;
12
+ # - `each_object(value, mapper, path) { |object, path| }` — the app's objects inside a value
13
+ # that has passed `load` or `dump`, for the validation that walks the result;
14
+ # - `accepts?(value)` — whether a default fits;
15
+ # - `limits` — which of `constrain`'s `in:` / `format:` / `not_blank:` / `not_empty:` /
16
+ # `unique:` the type can take.
17
+ #
18
+ # `to_s` is the RBS spelling, used in every message; `to_s(short: true)` drops each class's
19
+ # namespace, for load messages, whose reader writes the file and never sees the code.
20
+ #
21
+ # @api private
22
+ module Type
23
+ # `expected Integer, got "70"; remove the quotes`.
24
+ #
25
+ # @param type [#to_s] the expected type
26
+ # @param value [Object] what was found instead
27
+ # @param path [String]
28
+ # @param wording [Module] spells the found value: the load's {Wording}, {Wording::Ruby} on dump
29
+ # @param found [String, nil] words for the found value, in place of the wording's
30
+ # @param hint [String, nil] the fix, for a mistake whose fix is mechanical
31
+ # @raise [DataError] always
32
+ def self.mismatch(type, value, path, wording: Wording::Ruby, found: nil, hint: nil)
33
+ detail = "expected #{type}, got #{found || wording.found(value)}"
34
+ raise DataError.new(path, hint ? "#{detail}; #{hint}" : detail)
35
+ end
36
+
37
+ # @param klass [Class]
38
+ # @return [String] `Server` for `Shared::Config::Server`
39
+ def self.short_name(klass)
40
+ klass.name&.split("::")&.last || klass.inspect
41
+ end
42
+
43
+ # Whether the block loads without an error, for a hint on a value that has already failed;
44
+ # the loaded value is dropped.
45
+ #
46
+ # @yieldreturn [Object] the value loaded
47
+ # @return [Boolean]
48
+ def self.loads?
49
+ yield
50
+ true
51
+ rescue Error
52
+ false
53
+ end
54
+
55
+ # Whether a file wrote the value unquoted where a String belongs — a number, a bool, a
56
+ # parser's date-time: YAML's `no` is `false`, `1.10` is the Float `1.1`.
57
+ #
58
+ # @param value [Object]
59
+ # @return [Boolean]
60
+ def self.unquoted?(value)
61
+ !value.nil? && [String, Symbol, Array, Hash].none? { |klass| value.is_a?(klass) }
62
+ end
63
+
64
+ # A String a number's quotes were put around, `"70"`. Not what `Integer()` takes: `"0x1A"`
65
+ # and `"1_000"` may not have been meant as numbers.
66
+ NUMBER_TEXT = { Integer => /\A[+-]?\d+\z/, Float => /\A[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\z/ }.freeze
67
+
68
+ # @param path [String]
69
+ # @param key [String, Symbol]
70
+ # @return [String] the path of `key` under `path`
71
+ def self.child(path, key)
72
+ path.empty? ? key.to_s : "#{path}.#{key}"
73
+ end
74
+
75
+ # `Integer`, `Float` or `String`: the value must be an instance. On load alone a whole number
76
+ # crosses between the two numeric classes, exactly or not at all — `5` loads as `5.0`, `5.0`
77
+ # as `5`, `5.1` raises (`D_whole_numbers`); a default and a dumped value stay strict.
78
+ #
79
+ # @!attribute [r] klass
80
+ # @return [Class] `Integer`, `Float` or `String`
81
+ Scalar = Data.define(:klass) do
82
+ def load(value, path, session, _base)
83
+ return value if value.is_a?(klass)
84
+
85
+ whole = Type.whole_number(value, klass)
86
+ return whole unless whole.nil?
87
+
88
+ wording = session.wording
89
+ hint = if klass == String
90
+ wording.quote(value) if Type.unquoted?(value)
91
+ elsif value.is_a?(String) && Type::NUMBER_TEXT.fetch(klass).match?(value)
92
+ wording.unquote
93
+ end
94
+ Type.mismatch(self, value, path, wording: wording, hint: hint)
95
+ end
96
+
97
+ def dump(value, _mapper, path) = (value.is_a?(klass) ? value : Type.mismatch(self, value, path))
98
+ def each_object(_value, _mapper, _path) = nil
99
+ def accepts?(value) = value.is_a?(klass)
100
+ def limits = klass == String ? %i[in format not_blank] : %i[in]
101
+ def numeric? = klass != String
102
+ def nested_classes = []
103
+ def to_s(**) = klass.name
104
+ end
105
+
106
+ # `bool`: `true` or `false`.
107
+ Bool = Data.define do
108
+ def load(value, path, session, _base)
109
+ return value if accepts?(value)
110
+
111
+ wording = session.wording
112
+ Type.mismatch(self, value, path, wording: wording, hint: (wording.unquote if %w[true false].include?(value)))
113
+ end
114
+
115
+ def dump(value, _mapper, path) = (accepts?(value) ? value : Type.mismatch(self, value, path))
116
+ def each_object(_value, _mapper, _path) = nil
117
+ def accepts?(value) = [true, false].include?(value)
118
+ def limits = []
119
+ def numeric? = false
120
+ def nested_classes = []
121
+ def to_s(**) = "bool"
122
+ end
123
+
124
+ # A union of literals, `"short" | "verbose"`: the value must equal one of them, class
125
+ # included — `1.0` is not `1`.
126
+ #
127
+ # @!attribute [r] values
128
+ # @return [Array<String, Integer, Float, Boolean>]
129
+ Literals = Data.define(:values) do
130
+ def load(value, path, session, _base)
131
+ return value if accepts?(value)
132
+
133
+ wording = session.wording
134
+ hint = (wording.quote(value) if values.all?(String) && Type.unquoted?(value))
135
+ Type.mismatch(self, value, path, wording: wording, hint: hint)
136
+ end
137
+
138
+ def dump(value, _mapper, path) = (accepts?(value) ? value : Type.mismatch(self, value, path))
139
+ def each_object(_value, _mapper, _path) = nil
140
+ def accepts?(value) = values.any? { |literal| literal.eql?(value) }
141
+ def limits = []
142
+ def numeric? = false
143
+ def nested_classes = []
144
+ def to_s(**) = values.map(&:inspect).join(" | ")
145
+ end
146
+
147
+ # `T?`: `nil`, or a `T`.
148
+ #
149
+ # @!attribute [r] inner
150
+ # @return [Object] the type of a non-nil value
151
+ Optional = Data.define(:inner) do
152
+ def load(value, path, session, base) = (value.nil? ? nil : inner.load(value, path, session, base))
153
+ def dump(value, mapper, path) = (value.nil? ? nil : inner.dump(value, mapper, path))
154
+ def each_object(value, mapper, path, &) = (inner.each_object(value, mapper, path, &) unless value.nil?)
155
+ def accepts?(value) = value.nil? || inner.accepts?(value)
156
+ def limits = inner.limits
157
+ def numeric? = inner.numeric?
158
+ def nested_classes = inner.nested_classes
159
+
160
+ def to_s(short: false)
161
+ inner.is_a?(Literals) && inner.values.size > 1 ? "(#{inner})?" : "#{inner.to_s(short: short)}?"
162
+ end
163
+ end
164
+
165
+ # `Array[T]`: an Array whose every element is a `T`; an element's path is `key[3]`. A lone `T`
166
+ # where the Array belongs — TOML's `[server]` for `[[server]]` — raises
167
+ # `server: expected Array[Server], got a single Server; wrap it in an array`, the fix in the
168
+ # load's {Wording}.
169
+ #
170
+ # An entry whose class has a class method `entry_name(raw)` is named in its errors after its
171
+ # index — `server[1] "grok".url: required` — `raw` being the entry as a file would hold it:
172
+ #
173
+ # def self.entry_name(raw) = raw["id"]
174
+ #
175
+ # == Implementation details
176
+ #
177
+ # The name is worked out only when an entry's error passes through, so a load that succeeds
178
+ # never calls `entry_name`. `raw` is always plain data with String keys: on load the file's
179
+ # Hash, on validation and dump the entry dumped by {Mapper#entry_hash}. The name is the
180
+ # result's `to_s.strip`; no name, just the index, when that is empty — `nil` or `" "`. When
181
+ # `entry_name` raises, a warning says why, and the error the user needs is raised unnamed.
182
+ #
183
+ # @!attribute [r] element
184
+ # @return [Object] the elements' type
185
+ ArrayOf = Data.define(:element) do
186
+ def load(value, path, session, _base)
187
+ single(value, path, session) unless value.is_a?(Array)
188
+ value.each_with_index.map do |item, i|
189
+ at = "#{path}[#{i}]"
190
+ named(item, at, session.mapper) { element.load(item, at, session, nil) }
191
+ end
192
+ end
193
+
194
+ def dump(value, mapper, path)
195
+ Type.mismatch(self, value, path) unless value.is_a?(Array)
196
+ value.each_with_index.map do |item, i|
197
+ at = "#{path}[#{i}]"
198
+ named(item, at, mapper) { element.dump(item, mapper, at) }
199
+ end
200
+ end
201
+
202
+ def each_object(value, mapper, path, &)
203
+ value.each_with_index do |item, i|
204
+ at = "#{path}[#{i}]"
205
+ named(item, at, mapper) { element.each_object(item, mapper, at, &) }
206
+ end
207
+ end
208
+
209
+ def accepts?(value) = value.is_a?(Array) && value.all? { |item| element.accepts?(item) }
210
+ def limits = %i[not_empty unique]
211
+ def numeric? = false
212
+ def nested_classes = element.nested_classes
213
+ def to_s(short: false) = "Array[#{element.to_s(short: short)}]"
214
+
215
+ # Raises on the second of two entries whose keys are `eql?` — `nil` and `nil` included,
216
+ # as Ruby compares them. The path ends in the property when `by` names one; the detail
217
+ # names the first entry by index, then the shared key when it is plain data.
218
+ #
219
+ # @param value [Array] entries that have passed their own validation
220
+ # @param by [true, Symbol, Proc] the entry itself, its property, or a function of it
221
+ # @param path [String]
222
+ # @param mapper [Mapper, nil] names the entry; `nil` leaves the bare index
223
+ # @return [void]
224
+ # @raise [DataError] on a duplicate
225
+ def check_unique(value, by, path, mapper)
226
+ seen = {}
227
+ value.each_with_index do |item, i|
228
+ key = Type.unique_key(item, by)
229
+ first = seen[key]
230
+ seen[key] = i if first.nil?
231
+ next if first.nil?
232
+
233
+ at = "#{path}[#{i}]"
234
+ detail = "duplicate of #{path}[#{first}]#{", both #{key.inspect}" if Type.plain?(key)}"
235
+ error = DataError.new(by.is_a?(Symbol) ? "#{at}.#{by}" : at, detail)
236
+ mapper ? named(item, at, mapper) { raise error } : raise(error)
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ # @raise [DataError] always
243
+ def single(value, path, session)
244
+ spelled = to_s(short: true)
245
+ wording = session.wording
246
+ if single_entry?(value, path, session)
247
+ Type.mismatch(spelled, value, path, found: "a single #{entry_type.to_s(short: true)}",
248
+ hint: wording.wrap(path, value.is_a?(Hash)))
249
+ end
250
+ Type.mismatch(spelled, value, path, wording: wording)
251
+ end
252
+
253
+ # @return [Boolean] whether `value` is one entry: {Plan#entry_like?} for a class, one that
254
+ # loads otherwise — there being no keys to compare
255
+ def single_entry?(value, path, session)
256
+ return false if value.nil?
257
+
258
+ klass = entry_class
259
+ return session.mapper.plan_for(klass).entry_like?(value) if klass
260
+
261
+ Type.loads? { element.load(value, path, session, nil) }
262
+ end
263
+
264
+ # @return [Object] the type of a non-nil entry, `Server` for `Array[Server?]`
265
+ def entry_type = element.is_a?(Optional) ? element.inner : element
266
+
267
+ # @return [Class, nil] the entries' class, `Server` for `Array[Server?]` too
268
+ def entry_class = (entry_type.klass if entry_type.is_a?(ClassRef))
269
+
270
+ # @return [Object] the block's result
271
+ # @raise [DataError] the block's, its path naming the entry when it has a name
272
+ def named(item, at, mapper)
273
+ yield
274
+ rescue DataError => e
275
+ name = entry_name(item, at, mapper)
276
+ raise e if name.nil?
277
+
278
+ renamed = DataError.new("#{at} #{name.inspect}#{e.path.delete_prefix(at)}", e.detail)
279
+ renamed.set_backtrace(e.backtrace)
280
+ raise renamed, cause: e.cause
281
+ end
282
+
283
+ # @return [String, nil]
284
+ def entry_name(item, at, mapper)
285
+ klass = entry_class
286
+ return unless klass.respond_to?(:entry_name)
287
+
288
+ raw = case item
289
+ when Hash then item.transform_keys(&:to_s)
290
+ when klass then mapper.entry_hash(item)
291
+ end
292
+ name = raw && klass.entry_name(raw).to_s.strip
293
+ name unless name.nil? || name.empty?
294
+ rescue StandardError => e
295
+ warn "Typemap: #{at} left unnamed, #{klass}.entry_name: #{e.class}: #{e.message}"
296
+ nil
297
+ end
298
+ end
299
+
300
+ # `Hash[String, T]`: a Hash with String keys (a Symbol key is taken as its String) and a
301
+ # `T` under each.
302
+ #
303
+ # @!attribute [r] value
304
+ # @return [Object] the values' type
305
+ HashOf = Data.define(:value) do
306
+ def load(raw, path, session, _base)
307
+ Type.mismatch(to_s(short: true), raw, path, wording: session.wording) unless raw.is_a?(Hash)
308
+ Type.string_keys(raw, path).to_h { |key, item| [key, value.load(item, Type.child(path, key), session, nil)] }
309
+ end
310
+
311
+ def dump(raw, mapper, path)
312
+ Type.mismatch(self, raw, path) unless raw.is_a?(Hash)
313
+ Type.string_keys(raw, path).to_h { |key, item| [key, value.dump(item, mapper, Type.child(path, key))] }
314
+ end
315
+
316
+ def each_object(raw, mapper, path, &)
317
+ raw.each { |key, item| value.each_object(item, mapper, Type.child(path, key), &) }
318
+ end
319
+
320
+ def accepts?(raw) = raw.is_a?(Hash) && raw.all? { |key, item| key.is_a?(String) && value.accepts?(item) }
321
+ def limits = %i[not_empty]
322
+ def numeric? = false
323
+ def nested_classes = value.nested_classes
324
+ def to_s(short: false) = "Hash[String, #{value.to_s(short: short)}]"
325
+ end
326
+
327
+ # `Object`: plain data passed through as the file holds it, for a value whose meaning is
328
+ # someone else's — `Hash[String, Object]` for extra keys sent to a server verbatim:
329
+ #
330
+ # { "kwargs" => { "enable_thinking" => false, stop: nil } } # in
331
+ # { "kwargs" => { "enable_thinking" => false, "stop" => nil } } # out, new containers
332
+ #
333
+ # Plain data is what Typemap's other types load — a String, Integer, Float, bool, Array or
334
+ # Hash, `nil` nested inside — on load and dump alike, so a `Proc` or a TOML date-time
335
+ # raises. Keys are Strings all the way down, a Symbol taken as its String. `nil` in its own
336
+ # place needs `Object?`, as every type does. See `D_object_plain_data`.
337
+ Plain = Data.define do
338
+ def load(value, path, session, _base) = copy(value, path, session.wording)
339
+ def dump(value, _mapper, path) = copy(value, path, Wording::Ruby)
340
+
341
+ def each_object(_value, _mapper, _path) = nil
342
+ def accepts?(value) = !value.nil? && Type.plain?(value)
343
+ def limits = []
344
+ def numeric? = false
345
+ def nested_classes = []
346
+ def to_s(**) = "Object"
347
+
348
+ private
349
+
350
+ def copy(value, path, wording)
351
+ Type.mismatch(self, value, path, wording: wording) if value.nil?
352
+ Type.plain(value, path, wording)
353
+ end
354
+ end
355
+
356
+ # A class of the app's own — a data class or an accessor class — loaded from a Hash by
357
+ # the mapper, recursively.
358
+ #
359
+ # @!attribute [r] klass
360
+ # @return [Class]
361
+ ClassRef = Data.define(:klass) do
362
+ def load(value, path, session, base) = session.load_object(klass, value, path, base)
363
+
364
+ def dump(value, mapper, path)
365
+ Type.mismatch(self, value, path) unless value.is_a?(klass)
366
+ mapper.dump_object(value, path)
367
+ end
368
+
369
+ def each_object(value, _mapper, path) = yield(value, path)
370
+
371
+ def accepts?(value) = value.is_a?(klass)
372
+ def limits = []
373
+ def numeric? = false
374
+ def nested_classes = [klass]
375
+ def to_s(short: false) = short ? Type.short_name(klass) : (klass.name || klass.inspect)
376
+ end
377
+
378
+ # A whole number as the other numeric class, when it converts exactly: `5` as a Float is
379
+ # `5.0`, `5.0` as an Integer is `5`. Not `5.1`, NaN or infinity as an Integer, nor
380
+ # `2**53 + 1` as a Float, which would round.
381
+ #
382
+ # @param value [Object]
383
+ # @param klass [Class] the class the property declares
384
+ # @return [Integer, Float, nil] the converted value, or nil when it does not convert exactly
385
+ def self.whole_number(value, klass)
386
+ if klass == Float && value.is_a?(Integer)
387
+ float = value.to_f
388
+ float if float.finite? && float.to_i == value
389
+ elsif klass == Integer && value.is_a?(Float)
390
+ value.to_i if value.finite? && value.to_i == value
391
+ end
392
+ end
393
+
394
+ # @param item [Object] an array entry
395
+ # @param by [true, Symbol, Proc] `unique: true`, or `unique_by:`
396
+ # @return [Object] what `item` is compared by
397
+ def self.unique_key(item, by)
398
+ case by
399
+ when true then item
400
+ when Symbol then item.public_send(by)
401
+ else by.call(item)
402
+ end
403
+ end
404
+
405
+ PLAIN_SCALARS = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
406
+
407
+ # A copy of plain data, every Hash's keys as Strings.
408
+ #
409
+ # @param value [Object]
410
+ # @param path [String]
411
+ # @param wording [Module] the {Wording} that spells a value no type loads
412
+ # @return [Object] the copy
413
+ # @raise [DataError] on a value no Typemap type loads, or a key neither String nor Symbol
414
+ def self.plain(value, path, wording)
415
+ case value
416
+ when Array then value.each_with_index.map { |item, i| plain(item, "#{path}[#{i}]", wording) }
417
+ when Hash then string_keys(value, path).to_h { |key, item| [key, plain(item, child(path, key), wording)] }
418
+ when *PLAIN_SCALARS then value
419
+ else mismatch("plain data (String, Integer, Float, bool, nil, Array or Hash)", value, path, wording: wording)
420
+ end
421
+ end
422
+
423
+ # Freezes `value` in place when it is an Array, a Hash or a String, then each item, key and
424
+ # value inside; anything else is left as is, a data instance being frozen already and an
425
+ # accessor instance mutable by design. Freezes a String the caller passed in, too
426
+ # (`D_frozen_containers`).
427
+ #
428
+ # @param value [Object]
429
+ # @return [void]
430
+ def self.deep_freeze(value)
431
+ case value
432
+ when Array then value.freeze.each { |item| deep_freeze(item) }
433
+ when Hash
434
+ value.freeze.each do |key, item|
435
+ deep_freeze(key)
436
+ deep_freeze(item)
437
+ end
438
+ when String then value.freeze
439
+ end
440
+ nil
441
+ end
442
+
443
+ # @param value [Object]
444
+ # @return [Boolean] whether `value` is plain data with String keys, as a default must be
445
+ def self.plain?(value)
446
+ case value
447
+ when Array then value.all? { |item| plain?(item) }
448
+ when Hash then value.all? { |key, item| key.is_a?(String) && plain?(item) }
449
+ else PLAIN_SCALARS.any? { |klass| value.is_a?(klass) }
450
+ end
451
+ end
452
+
453
+ # A Hash's keys as Strings, a Symbol key taken as its String.
454
+ #
455
+ # @param hash [Hash]
456
+ # @param path [String]
457
+ # @return [Hash{String => Object}]
458
+ # @raise [DataError] on a key that is neither, or on `"a"` and `:a` both present
459
+ def self.string_keys(hash, path)
460
+ hash.each_with_object({}) do |(key, item), out|
461
+ raise DataError.new(path, "key #{key.inspect} is not a String") unless key.is_a?(String) || key.is_a?(Symbol)
462
+
463
+ name = key.to_s
464
+ raise DataError.new(path, "key #{name} given twice, as a String and as a Symbol") if out.key?(name)
465
+
466
+ out[name] = item
467
+ end
468
+ end
469
+ end
470
+ end
471
+ end