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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typemap
|
|
4
|
+
# The root of Typemap's own errors; rescue it to catch both kinds below.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# The data does not fit the class: a value of the wrong type or outside its limits, an unknown
|
|
9
|
+
# key, a required key missing, a failed `validate`. The file's author fixes it; the message starts
|
|
10
|
+
# with the path of the offending value and, for a classic mistake whose fix is mechanical, ends
|
|
11
|
+
# with the fix, worded for the `source:` given to `load`:
|
|
12
|
+
#
|
|
13
|
+
# ballooning.raise_at_percent: expected Integer, got "70"; remove the quotes # :toml
|
|
14
|
+
# server: expected Array[Server], got a single Server; write [[server]], not [server], or server = [{…}]
|
|
15
|
+
# ui.sandbox: unknown key; sandbox belongs to the top level, before any [header]
|
|
16
|
+
# ballooning.raise_at_percent: expected Integer, got String "70" # :ruby
|
|
17
|
+
#
|
|
18
|
+
# An array entry whose class names it with `entry_name` shows that after its index,
|
|
19
|
+
# `server[1] "grok".url`.
|
|
20
|
+
#
|
|
21
|
+
# A type is named as RBS spells it, a class without its namespace on load (`Server`, not
|
|
22
|
+
# `Shared::Config::Server`), in full on dump, never by a format's word for a Hash, TOML's "table"
|
|
23
|
+
# (`D_source_wording`). No line number: the parsers keep none in the Hash they return, so the
|
|
24
|
+
# path stands in.
|
|
25
|
+
class DataError < Error
|
|
26
|
+
# @return [String] the dotted path of the offending value, `""` for the root object
|
|
27
|
+
attr_reader :path
|
|
28
|
+
|
|
29
|
+
# @return [String] what is wrong with the value: the message without its path
|
|
30
|
+
attr_reader :detail
|
|
31
|
+
|
|
32
|
+
# @param path [String] the dotted path of the offending value, `""` for the root
|
|
33
|
+
# @param detail [String] what is wrong with it
|
|
34
|
+
def initialize(path, detail)
|
|
35
|
+
@path = path
|
|
36
|
+
@detail = detail
|
|
37
|
+
super(path.empty? ? detail : "#{path}: #{detail}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The class cannot be mapped: a property with no type, a type declared twice, a default the
|
|
42
|
+
# type rejects, a class `new` cannot build bare. The class's author fixes it; it is raised
|
|
43
|
+
# when the mapper first reads the class, whatever the file holds.
|
|
44
|
+
class DefinitionError < Error
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typemap
|
|
4
|
+
class Mapper
|
|
5
|
+
# The shape of an accessor class — a mutable class built by a bare `new` and then its
|
|
6
|
+
# setters. Its properties are its public reader + writer pairs (`D_accessor_properties`):
|
|
7
|
+
# only methods count, never ivars, and a private or protected accessor is left out.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module AccessorShape
|
|
11
|
+
READER = /\A[a-z_][A-Za-z0-9_]*\z/
|
|
12
|
+
private_constant :READER
|
|
13
|
+
|
|
14
|
+
# Public reader + writer pairs in declaration order: sorted by the reader's source line,
|
|
15
|
+
# since `public_instance_methods` comes in no declared order (`R_ruby_reflection`), and
|
|
16
|
+
# within one line — `attr_accessor :a, :b` — by where the name stands in it, read from the
|
|
17
|
+
# source file. A reader with no source location (one defined in C) sorts last, by name.
|
|
18
|
+
#
|
|
19
|
+
# @param klass [Class]
|
|
20
|
+
# @return [Array<Symbol>]
|
|
21
|
+
def self.properties(klass)
|
|
22
|
+
names = klass.public_instance_methods.select do |name|
|
|
23
|
+
name.match?(READER) && klass.public_method_defined?(:"#{name}=") && klass.instance_method(name).arity.zero?
|
|
24
|
+
end
|
|
25
|
+
sources = Hash.new { |cache, file| cache[file] = File.exist?(file) ? File.readlines(file) : [] }
|
|
26
|
+
names.sort_by do |name|
|
|
27
|
+
file, line = klass.instance_method(name).source_location
|
|
28
|
+
next [1, "", 0, 0, name.to_s] unless file
|
|
29
|
+
|
|
30
|
+
column = sources[file][line - 1]&.index(/\b#{name}\b/) || 0
|
|
31
|
+
[0, file, line, column, name.to_s]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A bare `new` sets every property, so none is required by the class itself; `required:`
|
|
36
|
+
# says it instead.
|
|
37
|
+
#
|
|
38
|
+
# @param _klass [Class]
|
|
39
|
+
# @param _names [Array<Symbol>]
|
|
40
|
+
# @return [Array<Symbol>] always empty
|
|
41
|
+
def self.required_keywords(_klass, _names) = []
|
|
42
|
+
|
|
43
|
+
# @param _klass [Class]
|
|
44
|
+
# @param _names [Array<Symbol>] the properties `constrain` gave `required:`
|
|
45
|
+
# @return [void]
|
|
46
|
+
def self.check_required_option(_klass, _names) = nil
|
|
47
|
+
|
|
48
|
+
# @param object [Object]
|
|
49
|
+
# @param name [Symbol]
|
|
50
|
+
# @return [Object] the property's value
|
|
51
|
+
def self.read(object, name) = object.public_send(name)
|
|
52
|
+
|
|
53
|
+
# Leaves a property's value mutable, as the class is (`D_frozen_containers`).
|
|
54
|
+
#
|
|
55
|
+
# @param _value [Object]
|
|
56
|
+
# @return [void]
|
|
57
|
+
def self.freeze_value(_value) = nil
|
|
58
|
+
|
|
59
|
+
# A fresh bare `new`, then a setter for every property: `base`'s value, or the loaded one.
|
|
60
|
+
# `base` itself is never changed — it may be a default shared beyond this load.
|
|
61
|
+
#
|
|
62
|
+
# @param klass [Class]
|
|
63
|
+
# @param base [Object] supplies every value `loaded` does not
|
|
64
|
+
# @param loaded [Hash{Symbol => Object}] the values read from the file
|
|
65
|
+
# @param names [Array<Symbol>] the mapped properties
|
|
66
|
+
# @return [Object]
|
|
67
|
+
def self.build(klass, base, loaded, names)
|
|
68
|
+
object = klass.new
|
|
69
|
+
names.each { |name| object.public_send(:"#{name}=", loaded.fetch(name) { base.public_send(name) }) }
|
|
70
|
+
object
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typemap
|
|
4
|
+
class Mapper
|
|
5
|
+
# The default type strategy, needing no gem: every property's type comes from
|
|
6
|
+
# `constrain type:`, parsed by Typemap's own {TypeParser}. A property without one raises —
|
|
7
|
+
# a type is never read off the default (`D_types_declared`).
|
|
8
|
+
class BasicTypes
|
|
9
|
+
# @param klass [Class] the class being read
|
|
10
|
+
# @param name [Symbol] the property
|
|
11
|
+
# @param constraint [Constrain::Constraint, nil] what `constrain` recorded for it
|
|
12
|
+
# @return [Object] the property's {Type}
|
|
13
|
+
# @raise [DefinitionError] when `constrain` gave no type, or the type is unsupported
|
|
14
|
+
def type_for(klass, name, constraint)
|
|
15
|
+
unless constraint&.type
|
|
16
|
+
raise DefinitionError, "no type: give it `constrain :#{name}, type: \"…\"` " \
|
|
17
|
+
"(#{klass} uses Typemap::Constrain), or map it with Typemap::Mapper::RBSTypes"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
TypeBuilder.build(TypeParser.parse(constraint.type), constraint.type_owner.name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typemap
|
|
4
|
+
class Mapper
|
|
5
|
+
# The shape of a data class (`Data.define`): its properties are its `members`, in order, and
|
|
6
|
+
# an instance is built in one `new(**values)`. A keyword without a default is a required key:
|
|
7
|
+
#
|
|
8
|
+
# Llm = Data.define(:api_key, :model) do
|
|
9
|
+
# def initialize(api_key:, model: "gpt-5") = super # api_key required, model not
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module DataShape
|
|
14
|
+
READABLE = %i[key keyreq].freeze
|
|
15
|
+
private_constant :READABLE
|
|
16
|
+
|
|
17
|
+
# @param klass [Class] a `Data` subclass
|
|
18
|
+
# @return [Array<Symbol>] its members, in declaration order
|
|
19
|
+
def self.properties(klass) = klass.members
|
|
20
|
+
|
|
21
|
+
# The keywords `klass.new` cannot build without, read off `initialize`: every member when
|
|
22
|
+
# `Data`'s own `initialize` is left as is, else its `name:` keywords.
|
|
23
|
+
#
|
|
24
|
+
# @param klass [Class] a `Data` subclass
|
|
25
|
+
# @param names [Array<Symbol>] the mapped members, `ignore`d ones left out
|
|
26
|
+
# @return [Array<Symbol>]
|
|
27
|
+
# @raise [DefinitionError] on an `initialize` taking anything but `name:` / `name: default`
|
|
28
|
+
# keywords, one with no keyword for a member, or one requiring a keyword the file cannot give
|
|
29
|
+
def self.required_keywords(klass, names)
|
|
30
|
+
init = klass.instance_method(:initialize)
|
|
31
|
+
return unmapped_checked(klass, klass.members, names) if init.owner == Data
|
|
32
|
+
|
|
33
|
+
params = init.parameters
|
|
34
|
+
unless params.all? { |kind, _| READABLE.include?(kind) }
|
|
35
|
+
raise DefinitionError, "#{klass}#initialize takes #{params.inspect}; Typemap reads which keys are " \
|
|
36
|
+
"required only from `name:` and `name: default` keywords"
|
|
37
|
+
end
|
|
38
|
+
missing = klass.members - params.map(&:last)
|
|
39
|
+
raise DefinitionError, "#{klass}#initialize takes no keyword #{missing.join(", ")}" unless missing.empty?
|
|
40
|
+
|
|
41
|
+
unmapped_checked(klass, params.filter_map { |kind, name| name if kind == :keyreq }, names)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param klass [Class]
|
|
45
|
+
# @param names [Array<Symbol>] the properties `constrain` gave `required:`
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [DefinitionError] unless `names` is empty: a data class's keyword without a default
|
|
48
|
+
# is how it says "required"
|
|
49
|
+
def self.check_required_option(klass, names)
|
|
50
|
+
return if names.empty?
|
|
51
|
+
|
|
52
|
+
raise DefinitionError, "#{klass}##{names.first}: required: is for an accessor class; " \
|
|
53
|
+
"in a data class, a keyword without a default is required"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param object [Data]
|
|
57
|
+
# @param name [Symbol]
|
|
58
|
+
# @return [Object] the property's value
|
|
59
|
+
def self.read(object, name) = object.public_send(name)
|
|
60
|
+
|
|
61
|
+
# Freezes a property's value in place, Arrays, Hashes and Strings all the way down: the
|
|
62
|
+
# instance is frozen, so what it holds is too (`D_frozen_containers`).
|
|
63
|
+
#
|
|
64
|
+
# @param value [Object]
|
|
65
|
+
# @return [void]
|
|
66
|
+
def self.freeze_value(value) = Type.deep_freeze(value)
|
|
67
|
+
|
|
68
|
+
# @param klass [Class]
|
|
69
|
+
# @param base [Data] supplies every value `loaded` does not
|
|
70
|
+
# @param loaded [Hash{Symbol => Object}] the values read from the file
|
|
71
|
+
# @return [Data]
|
|
72
|
+
def self.build(klass, base, loaded)
|
|
73
|
+
klass.new(**klass.members.to_h { |name| [name, base.public_send(name)] }, **loaded)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Array<Symbol>] `required`
|
|
77
|
+
# @raise [DefinitionError] if one of them is not mapped, so no file can give it
|
|
78
|
+
def self.unmapped_checked(klass, required, names)
|
|
79
|
+
unmapped = required - names
|
|
80
|
+
return required if unmapped.empty?
|
|
81
|
+
|
|
82
|
+
raise DefinitionError, "#{klass}#initialize requires #{unmapped.join(", ")}, not a mapped property, " \
|
|
83
|
+
"so no file can give it"
|
|
84
|
+
end
|
|
85
|
+
private_class_method :unmapped_checked
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typemap
|
|
4
|
+
class Mapper
|
|
5
|
+
# `constrain`'s limits for one property, checked after its type. A `nil` value (of a `T?`
|
|
6
|
+
# type) is never limited.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] in
|
|
9
|
+
# @return [Range, Array, nil] the allowed range of a number, or the allowed values
|
|
10
|
+
# @!attribute [r] format
|
|
11
|
+
# @return [Regexp, nil] the pattern a String must match
|
|
12
|
+
# @!attribute [r] not_blank
|
|
13
|
+
# @return [Boolean] a String must hold more than whitespace
|
|
14
|
+
# @!attribute [r] not_empty
|
|
15
|
+
# @return [Boolean] an Array or a Hash must hold at least one element
|
|
16
|
+
# @!attribute [r] unique
|
|
17
|
+
# @return [Boolean] no two entries of an Array may be equal
|
|
18
|
+
# @!attribute [r] unique_by
|
|
19
|
+
# @return [Symbol, Proc, nil] what no two entries of an Array may share: a property, or a
|
|
20
|
+
# function of the entry
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
Limits = Data.define(:in, :format, :not_blank, :not_empty, :unique, :unique_by) do
|
|
24
|
+
# @return [Limits] no limits at all
|
|
25
|
+
def self.none = new(in: nil, format: nil, not_blank: false, not_empty: false, unique: false, unique_by: nil)
|
|
26
|
+
|
|
27
|
+
# Raises unless the type can take these limits: `in:` a Range on a number, `in:` an Array
|
|
28
|
+
# on a number or a String, `format:` and `not_blank:` on a String, `not_empty:` on an Array
|
|
29
|
+
# or a Hash, `unique:` and `unique_by:` on an Array — `unique:` on entries with value
|
|
30
|
+
# equality, `unique_by:` a Symbol on entries of a class that has that property.
|
|
31
|
+
#
|
|
32
|
+
# @param type [Object] the property's {Type}
|
|
33
|
+
# @return [void]
|
|
34
|
+
# @raise [DefinitionError]
|
|
35
|
+
def check_against(type)
|
|
36
|
+
check_in_against(type)
|
|
37
|
+
check_unique_against(type)
|
|
38
|
+
if format && !type.limits.include?(:format)
|
|
39
|
+
raise DefinitionError, "format: #{format.inspect} limits only a String, not #{type}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if not_blank && !type.limits.include?(:not_blank)
|
|
43
|
+
hint = ("; an Array or a Hash takes not_empty:" if type.limits.include?(:not_empty))
|
|
44
|
+
raise DefinitionError, "not_blank: limits only a String, not #{type}#{hint}"
|
|
45
|
+
end
|
|
46
|
+
return unless not_empty && !type.limits.include?(:not_empty)
|
|
47
|
+
|
|
48
|
+
# A String takes not_empty: in Bean Validation; here it would pass " ", the typo
|
|
49
|
+
# not_blank: exists to catch.
|
|
50
|
+
hint = ("; a String takes not_blank:, which refuses \" \" too" if type.limits.include?(:not_blank))
|
|
51
|
+
raise DefinitionError, "not_empty: limits only an Array or a Hash, not #{type}#{hint}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raises in words a person editing the file reads, not Ruby's `1..`:
|
|
55
|
+
#
|
|
56
|
+
# must be 1 to 100, got 170 # in: 1..100
|
|
57
|
+
# must be at least 1 and below 10, got 10 # in: 1...10
|
|
58
|
+
# must be one of "short", "verbose", got "x" # in: %w[short verbose]
|
|
59
|
+
# must not be blank # not_blank: true, got " "
|
|
60
|
+
# must not be empty # not_empty: true, got []
|
|
61
|
+
#
|
|
62
|
+
# @param value [Object] a value that has already passed the type
|
|
63
|
+
# @param path [String]
|
|
64
|
+
# @return [void]
|
|
65
|
+
# @raise [DataError] if the value is outside the limits
|
|
66
|
+
def check(value, path)
|
|
67
|
+
return if value.nil?
|
|
68
|
+
|
|
69
|
+
raise DataError.new(path, "must not be blank") if not_blank && value.strip.empty?
|
|
70
|
+
raise DataError.new(path, "must not be empty") if not_empty && value.empty?
|
|
71
|
+
|
|
72
|
+
allowed = self.in
|
|
73
|
+
if allowed.is_a?(Range) && !allowed.cover?(value)
|
|
74
|
+
raise DataError.new(path, "must be #{Limits.range_words(allowed)}, got #{value.inspect}")
|
|
75
|
+
end
|
|
76
|
+
if allowed.is_a?(Array) && !allowed.include?(value)
|
|
77
|
+
raise DataError.new(path, "must be one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}")
|
|
78
|
+
end
|
|
79
|
+
return unless format && !format.match?(value)
|
|
80
|
+
|
|
81
|
+
raise DataError.new(path, "#{value.inspect} does not match #{format.inspect}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Raises on the second of two entries sharing a key, under `unique:` or `unique_by:`:
|
|
85
|
+
#
|
|
86
|
+
# server[2] "local".id: duplicate of server[0], both "local" # unique_by: :id
|
|
87
|
+
# mirror[3]: duplicate of mirror[1], both "http://x" # unique_by: a Proc
|
|
88
|
+
#
|
|
89
|
+
# Kept apart from {#check}, which knows no type: the entries are named through the
|
|
90
|
+
# property's `Array[T]`.
|
|
91
|
+
#
|
|
92
|
+
# @param value [Array, nil] a value that has already passed the type
|
|
93
|
+
# @param type [Object] the property's {Type}, an `Array[T]` or an `Array[T]?`
|
|
94
|
+
# @param path [String]
|
|
95
|
+
# @param mapper [Mapper, nil] names the entries; `nil` for a default, checked before any
|
|
96
|
+
# entry can be dumped for its name
|
|
97
|
+
# @return [void]
|
|
98
|
+
# @raise [DataError] on a duplicate
|
|
99
|
+
def check_unique(value, type, path, mapper)
|
|
100
|
+
by = unique_by || (true if unique)
|
|
101
|
+
return if by.nil? || value.nil?
|
|
102
|
+
|
|
103
|
+
Limits.array_of(type).check_unique(value, by, path, mapper)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param type [Object] an `Array[T]`, or an `Array[T]?`
|
|
107
|
+
# @return [Type::ArrayOf]
|
|
108
|
+
def self.array_of(type) = type.is_a?(Type::Optional) ? type.inner : type
|
|
109
|
+
|
|
110
|
+
# @param range [Range] a numeric range; an endless or beginless one too
|
|
111
|
+
# @return [String] `1 to 100`, `at least 1`, `at most 100`, `below 10`, `at least 1 and below 10`
|
|
112
|
+
def self.range_words(range)
|
|
113
|
+
from = range.begin&.inspect
|
|
114
|
+
to = range.end&.inspect
|
|
115
|
+
upper = range.exclude_end? ? "below #{to}" : "at most #{to}"
|
|
116
|
+
if from && to
|
|
117
|
+
range.exclude_end? ? "at least #{from} and #{upper}" : "#{from} to #{to}"
|
|
118
|
+
elsif from
|
|
119
|
+
"at least #{from}"
|
|
120
|
+
else
|
|
121
|
+
upper
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def check_unique_against(type)
|
|
128
|
+
return unless unique || unique_by
|
|
129
|
+
if unique && unique_by
|
|
130
|
+
raise DefinitionError, "unique: and unique_by: together; unique_by: alone says what to compare"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
option = unique ? "unique:" : "unique_by: #{unique_by.inspect}"
|
|
134
|
+
unless type.limits.include?(:unique)
|
|
135
|
+
hint = ("; a Hash's keys are unique already" if type.limits.include?(:not_empty))
|
|
136
|
+
raise DefinitionError, "#{option} limits only an Array, not #{type}#{hint}"
|
|
137
|
+
end
|
|
138
|
+
element = Limits.array_of(type).element
|
|
139
|
+
unique ? check_unique_entries(element, type) : check_unique_by_entries(element, type)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `Kernel#hash` is identity: no two entries would ever match, so the limit would check nothing.
|
|
143
|
+
def check_unique_entries(element, type)
|
|
144
|
+
element = element.inner if element.is_a?(Type::Optional)
|
|
145
|
+
return unless element.is_a?(Type::ClassRef) && element.klass.instance_method(:hash).owner == Kernel
|
|
146
|
+
|
|
147
|
+
raise DefinitionError, "unique: on #{type} compares the entries by identity, as #{element} defines no " \
|
|
148
|
+
"eql? and hash, so no two ever match; say what to compare with unique_by:"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def check_unique_by_entries(element, type)
|
|
152
|
+
return unless unique_by.is_a?(Symbol)
|
|
153
|
+
unless element.is_a?(Type::ClassRef)
|
|
154
|
+
raise DefinitionError, "#{unique_by.inspect} reads a property of each entry, so unique_by: takes it " \
|
|
155
|
+
"on an Array of a class, not #{type}; a Proc takes any entry"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
klass = element.klass
|
|
159
|
+
names = Plan.property_names(klass, Plan.shape_for(klass))
|
|
160
|
+
return if names.include?(unique_by)
|
|
161
|
+
|
|
162
|
+
guesses = DidYouMean::SpellChecker.new(dictionary: names).correct(unique_by)
|
|
163
|
+
fix = guesses.empty? ? "; #{element} has #{names.join(", ")}" : ", did you mean #{guesses.join(" or ")}?"
|
|
164
|
+
raise DefinitionError, "unique_by: #{unique_by.inspect} names no property of #{element}#{fix}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def check_in_against(type)
|
|
168
|
+
allowed = self.in
|
|
169
|
+
unless allowed.nil? || type.limits.include?(:in)
|
|
170
|
+
raise DefinitionError, "in: #{allowed.inspect} cannot limit #{type}"
|
|
171
|
+
end
|
|
172
|
+
return unless allowed.is_a?(Range) && !numeric_range?(allowed, type)
|
|
173
|
+
|
|
174
|
+
raise DefinitionError,
|
|
175
|
+
"in: #{allowed.inspect} is a range, so it limits only a number with number ends, not #{type}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def numeric_range?(range, type)
|
|
179
|
+
type.numeric? && [range.begin, range.end].all? { |end_| end_.nil? || end_.is_a?(Numeric) }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typemap
|
|
4
|
+
class Mapper
|
|
5
|
+
# Resolves a constant name written in a type — `Integer`, `Size`, `Tuile::Size`, `::Size` —
|
|
6
|
+
# the way Ruby resolves it in the code beside it: a leading `::` is absolute, anything else is
|
|
7
|
+
# looked up from the declaring namespace outward (`UI::Foo::Size`, then `UI::Size`, then
|
|
8
|
+
# `Size`). Qualified names are allowed, never required. Inheritance is not searched, only the
|
|
9
|
+
# lexical scopes.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module NameResolver
|
|
13
|
+
# @param name [String] the name as written, `"Size"`, `"Tuile::Size"`, `"::Size"`
|
|
14
|
+
# @param namespace [String, nil] the declaring class's full name, `nil` for the top level
|
|
15
|
+
# @return [Module] the constant
|
|
16
|
+
# @raise [DefinitionError] if no scope defines it
|
|
17
|
+
def self.resolve(name, namespace)
|
|
18
|
+
return lookup(Object, name.delete_prefix("::")) || unknown(name) if name.start_with?("::")
|
|
19
|
+
|
|
20
|
+
scopes = namespace.to_s.split("::")
|
|
21
|
+
scopes.length.downto(0) do |depth|
|
|
22
|
+
base = depth.zero? ? Object : lookup(Object, scopes.first(depth).join("::"))
|
|
23
|
+
found = base && lookup(base, name)
|
|
24
|
+
return found if found
|
|
25
|
+
end
|
|
26
|
+
unknown(name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param base [Module]
|
|
30
|
+
# @param path [String] `"A::B"`, looked up segment by segment without inheritance
|
|
31
|
+
# @return [Module, nil]
|
|
32
|
+
def self.lookup(base, path)
|
|
33
|
+
path.split("::").reduce(base) do |mod, segment|
|
|
34
|
+
return nil unless mod.is_a?(Module) && mod.const_defined?(segment, false)
|
|
35
|
+
|
|
36
|
+
mod.const_get(segment, false)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @raise [DefinitionError]
|
|
41
|
+
def self.unknown(name)
|
|
42
|
+
raise DefinitionError, "unknown constant #{name}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typemap
|
|
4
|
+
class Mapper
|
|
5
|
+
# One mappable property: its name, its {Type}, its {Limits}, and whether `required:` was given.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] name
|
|
8
|
+
# @return [Symbol]
|
|
9
|
+
# @!attribute [r] type
|
|
10
|
+
# @return [Object] a {Type}
|
|
11
|
+
# @!attribute [r] limits
|
|
12
|
+
# @return [Limits]
|
|
13
|
+
# @!attribute [r] required
|
|
14
|
+
# @return [Boolean] `constrain required: true`: a loaded or dumped object must not hold `nil`
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
Property = Data.define(:name, :type, :limits, :required)
|
|
18
|
+
|
|
19
|
+
# What the mapper knows about one class, worked out once — when the class is first read —
|
|
20
|
+
# and checked then, whatever a file will hold: the shape, every property with its type and
|
|
21
|
+
# limits, and the bare `new`'s values. Building it raises {DefinitionError} for a class that
|
|
22
|
+
# cannot be mapped.
|
|
23
|
+
#
|
|
24
|
+
# A data class with a required keyword has no bare `new`: {#check_default} runs on each
|
|
25
|
+
# load's base instead.
|
|
26
|
+
#
|
|
27
|
+
# @!attribute [r] klass
|
|
28
|
+
# @return [Class]
|
|
29
|
+
# @!attribute [r] shape
|
|
30
|
+
# @return [Module] {DataShape} or {AccessorShape}
|
|
31
|
+
# @!attribute [r] properties
|
|
32
|
+
# @return [Array<Property>] in declaration order
|
|
33
|
+
# @!attribute [r] required_keywords
|
|
34
|
+
# @return [Array<Symbol>] the keywords a data class cannot build without; a load with no base
|
|
35
|
+
# must find each in the file
|
|
36
|
+
# @!attribute [r] unknown
|
|
37
|
+
# @return [Symbol, nil] the class's `unknown` policy, `:raise` or `:ignore`; `nil` follows
|
|
38
|
+
# `load`'s `unknown:`
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
Plan = Data.define(:klass, :shape, :properties, :required_keywords, :unknown) do
|
|
42
|
+
# @param klass [Class]
|
|
43
|
+
# @param types [#type_for] the mapper's type strategy
|
|
44
|
+
# @return [Plan]
|
|
45
|
+
# @raise [DefinitionError]
|
|
46
|
+
def self.build(klass, types)
|
|
47
|
+
raise DefinitionError, "#{klass.inspect} is not a class" unless klass.is_a?(Class)
|
|
48
|
+
|
|
49
|
+
shape = shape_for(klass)
|
|
50
|
+
names = property_names(klass, shape)
|
|
51
|
+
constraints = Constrain.constraints_for(klass)
|
|
52
|
+
stray = constraints.keys - names
|
|
53
|
+
raise DefinitionError, "#{klass}: constrain names #{stray.join(", ")}, not a property" unless stray.empty?
|
|
54
|
+
|
|
55
|
+
shape.check_required_option(klass, constraints.select { |_, constraint| constraint.required }.keys)
|
|
56
|
+
required_keywords = shape.required_keywords(klass, names)
|
|
57
|
+
default = bare_new(klass) if required_keywords.empty?
|
|
58
|
+
properties = names.map { |name| property(klass, name, constraints[name], types) }
|
|
59
|
+
plan = new(klass: klass, shape: shape, properties: properties, required_keywords: required_keywords,
|
|
60
|
+
unknown: Constrain.unknown_for(klass))
|
|
61
|
+
plan.check_default(default) if default
|
|
62
|
+
plan
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param klass [Class]
|
|
66
|
+
# @return [Module] {DataShape} or {AccessorShape}
|
|
67
|
+
def self.shape_for(klass) = klass < Data ? DataShape : AccessorShape
|
|
68
|
+
|
|
69
|
+
# @raise [DefinitionError] unless `klass.new` works with no arguments
|
|
70
|
+
def self.bare_new(klass)
|
|
71
|
+
klass.new
|
|
72
|
+
rescue ArgumentError => e
|
|
73
|
+
raise DefinitionError, "#{klass}: must build with a bare new, its default instance (#{e.message})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param klass [Class]
|
|
77
|
+
# @param shape [Module] {DataShape} or {AccessorShape}
|
|
78
|
+
# @return [Array<Symbol>] the mapped properties, `ignore`d ones left out
|
|
79
|
+
# @raise [DefinitionError] on an `ignore` naming no property, or no property left
|
|
80
|
+
def self.property_names(klass, shape)
|
|
81
|
+
all = shape.properties(klass)
|
|
82
|
+
ignored = Constrain.ignored_for(klass)
|
|
83
|
+
stray = ignored - all
|
|
84
|
+
raise DefinitionError, "#{klass}: ignore names #{stray.join(", ")}, not a property" unless stray.empty?
|
|
85
|
+
|
|
86
|
+
names = all - ignored
|
|
87
|
+
raise DefinitionError, "#{klass} has no properties: neither a data class nor an accessor class" if names.empty?
|
|
88
|
+
|
|
89
|
+
names
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.property(klass, name, constraint, types)
|
|
93
|
+
type = types.type_for(klass, name, constraint)
|
|
94
|
+
limits = if constraint
|
|
95
|
+
Limits.new(in: constraint.in, format: constraint.format,
|
|
96
|
+
not_blank: constraint.not_blank || false, not_empty: constraint.not_empty || false,
|
|
97
|
+
unique: constraint.unique || false, unique_by: constraint.unique_by)
|
|
98
|
+
else
|
|
99
|
+
Limits.none
|
|
100
|
+
end
|
|
101
|
+
limits.check_against(type)
|
|
102
|
+
required = constraint&.required || false
|
|
103
|
+
if required && !type.accepts?(nil)
|
|
104
|
+
raise DefinitionError, "required: on #{type}, which can never be missing; required: takes a " \
|
|
105
|
+
"#{type}? defaulting to nil"
|
|
106
|
+
end
|
|
107
|
+
Property.new(name: name, type: type, limits: limits, required: required)
|
|
108
|
+
rescue DefinitionError => e
|
|
109
|
+
raise DefinitionError, "#{klass}##{name}: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Checks the values of a default instance — the bare `new`, or a load's base built from the
|
|
113
|
+
# required keywords alone — against their types and limits; a required keyword's value is
|
|
114
|
+
# the file's, so it is left to the load.
|
|
115
|
+
#
|
|
116
|
+
# @param default [Object] an instance of {#klass}
|
|
117
|
+
# @return [void]
|
|
118
|
+
# @raise [DefinitionError] naming `Klass#property`
|
|
119
|
+
def check_default(default)
|
|
120
|
+
properties.each do |property|
|
|
121
|
+
next if required_keywords.include?(property.name)
|
|
122
|
+
|
|
123
|
+
value = shape.read(default, property.name)
|
|
124
|
+
check_default_value(property, value)
|
|
125
|
+
rescue DefinitionError => e
|
|
126
|
+
raise DefinitionError, "#{klass}##{property.name}: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Array<Class>] the classes of nested properties, to be planned too
|
|
131
|
+
def nested_classes = properties.flat_map { |property| property.type.nested_classes }.uniq
|
|
132
|
+
|
|
133
|
+
# @return [Array<Symbol>]
|
|
134
|
+
def names = properties.map(&:name)
|
|
135
|
+
|
|
136
|
+
# Whether `raw` is a table meant for {#klass}, however broken: a Hash that is empty or has
|
|
137
|
+
# one of its keys. Gates the `[[server]]` / `[server]` hints, which a half-written table
|
|
138
|
+
# needs most — a test of whether it loads would drop them right there.
|
|
139
|
+
#
|
|
140
|
+
# { "id" => "a", "url" => 3 } # an entry of Server, with a wrong type
|
|
141
|
+
# { "port" => 1 } # no entry
|
|
142
|
+
#
|
|
143
|
+
# @param raw [Object]
|
|
144
|
+
# @return [Boolean]
|
|
145
|
+
def entry_like?(raw)
|
|
146
|
+
raw.is_a?(Hash) && (raw.empty? || raw.keys.map(&:to_s).intersect?(names.map(&:to_s)))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @param base [Object] an instance of {#klass}, supplying every value not loaded; never changed
|
|
150
|
+
# @param loaded [Hash{Symbol => Object}] the values read from the file
|
|
151
|
+
# @return [Object] a new instance
|
|
152
|
+
def build(base, loaded)
|
|
153
|
+
shape == DataShape ? shape.build(klass, base, loaded) : shape.build(klass, base, loaded, names)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def check_default_value(property, value)
|
|
159
|
+
if property.required && !value.nil?
|
|
160
|
+
raise DefinitionError, "required: its default must be nil, so a file that leaves the key out fails, " \
|
|
161
|
+
"not #{value.inspect}"
|
|
162
|
+
end
|
|
163
|
+
unless property.type.accepts?(value)
|
|
164
|
+
raise DefinitionError, "its default #{value.inspect} does not fit #{property.type}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
property.limits.check(value, "")
|
|
168
|
+
property.limits.check_unique(value, property.type, "", nil)
|
|
169
|
+
rescue DataError => e
|
|
170
|
+
raise DefinitionError, "its default #{e.message}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|