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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +419 -0
- data/lib/typemap/constrain.rb +199 -0
- data/lib/typemap/error.rb +46 -0
- data/lib/typemap/mapper/accessor_shape.rb +74 -0
- data/lib/typemap/mapper/basic_types.rb +24 -0
- data/lib/typemap/mapper/data_shape.rb +88 -0
- data/lib/typemap/mapper/limits.rb +183 -0
- data/lib/typemap/mapper/name_resolver.rb +46 -0
- data/lib/typemap/mapper/plan.rb +174 -0
- data/lib/typemap/mapper/rbs_types.rb +174 -0
- data/lib/typemap/mapper/type.rb +471 -0
- data/lib/typemap/mapper/type_builder.rb +89 -0
- data/lib/typemap/mapper/type_parser.rb +157 -0
- data/lib/typemap/mapper/wording.rb +212 -0
- data/lib/typemap/mapper.rb +298 -0
- data/lib/typemap/version.rb +6 -0
- data/lib/typemap.rb +12 -0
- metadata +67 -0
|
@@ -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
|