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,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