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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61fe63803b92dfed0c5185092a5880c13fe0aaa9ac65103340607e903528b984
4
+ data.tar.gz: 76ba972e18d00eec9dcd8dbd2ff887fcf356f8a2017ebfca2808bbb2202f287f
5
+ SHA512:
6
+ metadata.gz: d79a8b6ffaa31bda300e49a4d016132db737223a4f1fedbdac6c24f8fea7c7150b577d1ab7ae95bea1cd7b1fdc7527cf0616db49681540fa846e5aa3ac31d565
7
+ data.tar.gz: bc41895ff023194ab431a442fb17a63b23a07fcbfc5f9535fcc358656ada131dde5316d112109ccfd509b74c01757e18d76944d7b2a71b1c44ac1387660f9390
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ <!-- An entry is one sentence — `Add` / `Fix` / `**Breaking:**`, the symbol, what changed, ≈40
2
+ words; a breaking entry earns a second sentence for the migration only. -->
3
+
4
+ ## [Unreleased]
5
+
6
+ ## [0.1.0] - 2026-11-02
7
+
8
+ - Add `Typemap::Mapper#load` and `#dump`: a Hash onto a data class or an accessor class and back, strictly typed, then validated — limits, required keys, the class's own `validate` — the first problem raised as a `Typemap::DataError` with the value's path.
9
+ - Add frozen data-class results: after `load`, every Array, Hash and String a data object holds is frozen in place, defaults and the caller's Strings included, so `config.engines << "x"` raises; an accessor class's values stay mutable.
10
+ - Add a suggestion for a misspelled key, `rasie_at_percent: unknown key, did you mean raise_at_percent?`, and limits in words, `must be at least 1, got 0` rather than `0 is not in 1..`.
11
+ - Add a hint at the fix for a classic mistake, worded for `load`'s `source:` — `:toml`'s `write [[server]], not [server]`, `:json`'s `wrap it in [ ]`, `:yaml`, or the default `:ruby`'s `got Integer 70` — and a class named without its namespace, `expected Array[Server]`.
12
+ - Add an array entry's name to an error path: a class method `entry_name(raw)`, given the entry as a Hash with String keys, turns `server[1].url: required` into `server[1] "grok".url: required`; a blank result leaves the bare index.
13
+ - Add required keys: a data class's keyword without a default, or an accessor class's `constrain :name, type: "T?", required: true`; one missing raises `name: required`, nested ones with their full path.
14
+ - Add the `Object` type: plain data — String, Integer, Float, bool, Array, Hash with String keys, `nil` nested inside — passed through on load and checked on dump, so `Hash[String, Object]` holds keys sent somewhere verbatim.
15
+ - Add whole-number loading: an `Integer` property takes `70.0` as `70`, a `Float` property takes `1` as `1.0`; `70.5`, NaN and an Integer a Float would round still raise.
16
+ - Add the `unique: true` and `unique_by:` limits for an Array — `unique_by: :id` names a property of the entries, `unique_by: ->(m) { m.url.downcase }` a function — reported at the second entry, `server[2] "local".id: duplicate of server[0], both "local"`.
17
+ - Add the `not_blank: true` limit for a String, which refuses `""` and `" "`, and `not_empty: true` for an Array or a Hash; a miss reads `url: must not be blank`, the path down to the key.
18
+ - Add `Typemap::Constrain` — `constrain :name, type: "Integer", in: 1..100, format: /…/` and `ignore :name`, metadata the mapper reads, defining nothing on the class.
19
+ - Add a class's own unknown-key policy, `unknown :ignore` or `unknown :raise`, which wins over `load`'s `unknown:` for that class's keys wherever it appears — a table another program owns stays lenient in an otherwise strict file.
20
+ - Add `Typemap::Mapper::BasicTypes`, the default type strategy (types from `constrain type:`), and `Typemap::Mapper::RBSTypes` (types from `sig/*.rbs`, with the app's `rbs` gem).
21
+ - Add `Typemap::DefinitionError`, like `DataError` a `Typemap::Error`, raised the first time the mapper reads a class it cannot map — no type, a default the type rejects, a limit the type cannot take.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Martin Vysny
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,419 @@
1
+ # Typemap
2
+
3
+ Typemap maps a Hash onto a Ruby class you already wrote, and back, checking every value on the
4
+ way: `"70"` is never 70, and the data never names a class. Your class stays yours — a
5
+ `Data.define` or a plain class with `attr_accessor`s, typed by `constrain` lines or by your RBS
6
+ signatures. Reading and writing TOML, JSON or YAML, and finding the file, stay with the app;
7
+ Typemap owns the mapping and the checking, and does that one thing well.
8
+
9
+ Pre-release. Requires Ruby 3.3+; no runtime dependencies.
10
+
11
+ ## A quick look
12
+
13
+ ```ruby
14
+ require "typemap"
15
+ require "tomlrb"
16
+
17
+ class Ballooning < Data.define(:raise_at_percent, :shrink_at_percent, :pinned_vms)
18
+ extend Typemap::Constrain
19
+
20
+ constrain :raise_at_percent, :shrink_at_percent, type: "Integer", in: 1..100
21
+ constrain :pinned_vms, type: "Array[String]"
22
+
23
+ def initialize(raise_at_percent: 65, shrink_at_percent: 55, pinned_vms: []) = super
24
+
25
+ def validate
26
+ raise "raise_at_percent must be above shrink_at_percent" unless raise_at_percent > shrink_at_percent
27
+ end
28
+ end
29
+
30
+ mapper = Typemap::Mapper.new
31
+ ballooning = mapper.load(Ballooning, Tomlrb.load_file("ballooning.toml"), source: :toml)
32
+ ```
33
+
34
+ A file that does not fit raises `Typemap::DataError`, with the path of the offending value and,
35
+ for a classic mistake, the fix in the file's own syntax:
36
+
37
+ ```
38
+ raise_at_percent: expected Integer, got "70"; remove the quotes
39
+ raise_at_percent: must be 1 to 100, got 170
40
+ pinned_vms: expected Array[String], got a single String; wrap it in [ ]
41
+ pinned_vms[1]: expected String, got 7; quote it to make it a string
42
+ rasie_at_percent: unknown key, did you mean raise_at_percent?
43
+ rasie: unknown key; Ballooning has raise_at_percent, shrink_at_percent, pinned_vms
44
+ raise_at_percent must be above shrink_at_percent
45
+ ```
46
+
47
+ > **Always pass `source:` when the Hash came from a file.** The messages above speak TOML because
48
+ > the load says `source: :toml`. Leave it out and the default, `:ruby`, words them for a Hash
49
+ > built in code — `got Integer 7`, `wrap it in an array` — which tells a user editing a file
50
+ > nothing about what to type. See [Naming the source](#naming-the-source).
51
+
52
+ ## Loading and saving
53
+
54
+ ```ruby
55
+ mapper = Typemap::Mapper.new
56
+
57
+ config = mapper.load(Config, Tomlrb.load_file(path), source: :toml) # Tomlrb, JSON, YAML.safe_load: any Hash
58
+ state = mapper.load(State, JSON.load_file(path), source: :json, unknown: :ignore) # drop keys a newer version wrote
59
+ File.write(path, JSON.pretty_generate(mapper.dump(state))) # String keys, nested objects as Hashes
60
+ ```
61
+
62
+ - **A key present replaces the default; a key absent keeps it.** A nested Hash loads onto the
63
+ default's nested object, so a file that sets one nested key keeps the other nested defaults.
64
+ - **An unknown key raises**, unless `unknown: :ignore`; a class may set its own policy
65
+ ([Unknown keys](#unknown-keys)).
66
+ - **Keys may be Strings or Symbols** (`JSON.load_file(path, symbolize_names: true)` works).
67
+ - **`dump` writes properties in declaration order** and checks every value as `load` would — its
68
+ type, its limits, a required key, the class's `validate` — so a bug that put a wrong value in
69
+ an object is caught before it reaches the file.
70
+ - **Every object in the result is validated**, the ones the file left out included: a default
71
+ nested object must pass its limits and `validate` too.
72
+ - **A data class comes back frozen all the way down.** Every Array, Hash and String it holds is
73
+ frozen, defaults included, so `config.engines << "x"` raises `FrozenError`; mutate a copy,
74
+ `config.engines.dup`. An accessor class's values stay mutable, being meant to change.
75
+ Typemap freezes in place: a String from your Hash that a data object ends up holding is frozen
76
+ there too, and so is a constant a default names (`engines: DEFAULT_ENGINES`). Your Arrays and
77
+ Hashes never are; the result holds Typemap's own.
78
+ - **The first problem raises.** Typemap does not collect every error.
79
+ - **A mapper is safe to share between threads**; it works out each class once and remembers it.
80
+
81
+ ### Naming the source
82
+
83
+ `load`'s `source:` names where the Hash came from — `:toml`, `:json`, `:yaml`, or `:ruby`, the
84
+ default, for a Hash built in code — and a load error words its fix in that syntax. The mistake is
85
+ the same in every source; only the advice changes:
86
+
87
+ | `source:` | `[server]` where an array belongs | `7` where a String belongs |
88
+ |---|---|---|
89
+ | `:ruby` (default) | `wrap it in an array` | `got Integer 7` |
90
+ | `:toml` | `write [[server]], not [server], or server = [{…}]` | `got 7; quote it to make it a string` |
91
+ | `:json` | `wrap it in [ ]` | `got 7; quote it to make it a string` |
92
+ | `:yaml` | `put "- " before it` | `got 7; quote it to make it a string` |
93
+
94
+ The default is `:ruby` because Typemap does not know where your Hash came from; when it came
95
+ from a file, say so, or the user who edits that file reads Ruby class names instead of the fix.
96
+
97
+ ## Your class
98
+
99
+ Two shapes of class map. A bare `new` is the default instance: every key the file leaves out
100
+ keeps its value there.
101
+
102
+ - **A data class**, `Data.define`: its properties are its members. Give a member a default with
103
+ `def initialize(a: 1, b: "x") = super`; a member without one is a [required key](#required-keys).
104
+ - **An accessor class**, mutable: its properties are its **public reader + writer pairs** —
105
+ `attr_accessor`s, or a hand-written `def foo` / `def foo=(value)`, inherited ones included.
106
+ It is built with a bare `new`, then a setter per property.
107
+
108
+ ```ruby
109
+ class State
110
+ extend Typemap::Constrain
111
+
112
+ attr_accessor :system_pane_visible, :log_pane_visible
113
+ attr_accessor :on_change # a listener, not a setting
114
+ attr_reader :path # a reader alone is not a property
115
+
116
+ constrain :system_pane_visible, :log_pane_visible, type: "bool"
117
+ ignore :on_change
118
+
119
+ def initialize
120
+ @system_pane_visible = true
121
+ @log_pane_visible = true
122
+ end
123
+ end
124
+ ```
125
+
126
+ An accessor that is not a setting goes `private`, or stays public under `ignore`. Left public, it
127
+ has no type, and Typemap raises when it first reads the class — it never serializes it quietly.
128
+
129
+ **Validation across properties** goes in the class's own `validate`, which Typemap calls on
130
+ every object once the whole result is built, nested objects first, on `load` and on `dump`;
131
+ whatever it raises becomes a `Typemap::DataError` carrying the object's path.
132
+
133
+ ### `constrain` at a glance
134
+
135
+ `extend Typemap::Constrain` gives the class three macros. `constrain` declares a property's type
136
+ and limits, `ignore` leaves a public accessor out of the mapping, and `unknown` says what a load
137
+ does with a key the class has no property for ([Unknown keys](#unknown-keys)). None of them
138
+ defines anything on the class.
139
+
140
+ | Option | Takes | On | A miss reads |
141
+ |---|---|---|---|
142
+ | `type:` | an RBS type String: `"Integer"`, `"String?"`, `"Array[Server]"` ([Types](#types)) | every property, unless its type comes from [RBS](#types-from-rbs-signatures) | `expected Integer, got "70"` |
143
+ | `in:` | a Range: `1..100`, `(1..)` | a number | `must be 1 to 100, got 170` |
144
+ | `in:` | an Array of allowed values: `%w[short verbose]` | a number or a String | `must be one of "short", "verbose", got "x"` |
145
+ | `format:` | a Regexp | a String | `"ab" does not match /\A[a-z]\z/` |
146
+ | `not_blank:` | `true` | a String | `must not be blank` |
147
+ | `not_empty:` | `true` | an Array or a Hash | `must not be empty` |
148
+ | `unique:` | `true` | an Array | `tags[2]: duplicate of tags[0], both "a"` |
149
+ | `unique_by:` | a property, `:id`, or a function, `->(s) { s.url.downcase }` | an Array | `server[2] "local".id: duplicate of server[0], both "local"` |
150
+ | `required:` | `true` | an accessor class's `T?` defaulting to `nil` ([Required keys](#required-keys)) | `api_key: required` |
151
+
152
+ ```ruby
153
+ constrain :raise_at_percent, :shrink_at_percent, type: "Integer" # several properties at once
154
+ constrain :raise_at_percent, in: 1..100 # a later call adds to the first
155
+ ignore :on_change # a listener, not a setting
156
+ ```
157
+
158
+ - **Calls for one property merge**, and an option given twice for it raises `ArgumentError`, as
159
+ does a misspelled option the moment the class body runs.
160
+ - **A limit the type cannot take** is a `Typemap::DefinitionError`, raised when the mapper first
161
+ reads the class: `format:` on an Integer, `unique:` on a Hash, `unique_by: :idd` on a class
162
+ with no `idd`.
163
+ - **A subclass inherits its superclass's constraints** and may add to them. Where both give the
164
+ same option, the subclass's wins.
165
+ - **A `nil` passes every limit**, so on a `String?`, `not_blank: true` means "if given, not
166
+ blank". The exception is a `nil` entry in an array, which `unique:` and `unique_by:` compare
167
+ like any other value.
168
+
169
+ ### Required keys
170
+
171
+ A key the file must give, one with no sensible default, such as an API key:
172
+
173
+ ```ruby
174
+ class Llm < Data.define(:api_key, :model)
175
+ extend Typemap::Constrain
176
+
177
+ constrain :api_key, :model, type: "String"
178
+
179
+ def initialize(api_key:, model: "gpt-5") = super # api_key has no default: required
180
+ end
181
+
182
+ class Secret # an accessor class
183
+ extend Typemap::Constrain
184
+
185
+ attr_accessor :api_key
186
+
187
+ constrain :api_key, type: "String?", required: true
188
+
189
+ def initialize = @api_key = nil
190
+ end
191
+ ```
192
+
193
+ - **A data class: a keyword without a default.** `api_key` stays a non-nil `String`. A plain
194
+ `Data.define(:a, :b)` requires every member. `required:` on a data class raises.
195
+ - **An accessor class: `required: true` on a `T?` defaulting to `nil`.** A bare `new` sets every
196
+ property, so the type admits the `nil` and the app handles it (`config.api_key or raise`). For
197
+ a non-nil type, use a data class.
198
+ - **A missing key raises with its full path**, `llm.api_key: required`, also when the file leaves
199
+ out the whole `[llm]` table. An accessor class refuses an explicit JSON `null` too; a data
200
+ class's `String?` keyword takes it.
201
+ - **`dump` raises** on a required accessor still `nil`, since the file would not load back.
202
+
203
+ ### Unknown keys
204
+
205
+ A key the class has no property for raises, unless `load` gets `unknown: :ignore`. That is all
206
+ or nothing for one file. A file shared by several programs often needs both: strict everywhere,
207
+ except for a table another program owns, which may gain keys tomorrow that this app doesn't
208
+ read. `unknown` sets the policy on that table's class:
209
+
210
+ ```ruby
211
+ class UiTable < Data.define(:show_thinking)
212
+ extend Typemap::Constrain
213
+
214
+ constrain :show_thinking, type: "bool"
215
+ unknown :ignore # the TUI owns [ui]; a key it adds must not stop this app from starting
216
+
217
+ def initialize(show_thinking: true) = super
218
+ end
219
+ ```
220
+
221
+ - **The class's policy wins over `load`'s, both ways.** `unknown` takes `load`'s two values,
222
+ `:ignore` and `:raise`; a class that gives neither follows `load`'s `unknown:`.
223
+ - **It covers the class's own keys, wherever the class appears**: a table, or each entry of an
224
+ array. A class nested in it keeps its own policy, or `load`'s. A subclass inherits it.
225
+ - **An ignored key is dropped silently.** Typemap does not report which keys it dropped.
226
+ - **`unknown :raise` inside an `unknown: :ignore` load** brings back the failure `:ignore` avoids:
227
+ a newer version of the app that adds a key to that class's table breaks this one.
228
+ - **A value other than `:raise` or `:ignore`**, or `unknown` given twice in one class, raises
229
+ `ArgumentError` the moment the class body runs.
230
+
231
+ ### Naming an array entry
232
+
233
+ An error inside an array names the entry by its index, `server[1].url: required`. A class
234
+ method `entry_name` adds the name the file gave it, so nobody counts `[[server]]` headers:
235
+
236
+ ```ruby
237
+ class Server < Data.define(:id, :url)
238
+ extend Typemap::Constrain
239
+
240
+ constrain :id, :url, type: "String"
241
+
242
+ def self.entry_name(raw) = raw["id"] # or "#{raw["host"]}:#{raw["port"]}", anything
243
+ end
244
+ ```
245
+
246
+ ```
247
+ server[1] "grok".url: required
248
+ ```
249
+
250
+ - **`raw` is the entry as a file would hold it**: a Hash with String keys, its values not yet
251
+ checked. Once the entry is built, for a later check or a `dump`, it is the entry dumped, defaults
252
+ included.
253
+ - **The name is the result's `to_s`, stripped**: an Integer id names the entry too, and
254
+ `" grok "` prints as `"grok"`. **No name, just the index**, when that comes out empty — `nil`,
255
+ `""`, `" "`. When `entry_name` raises, Typemap warns and raises the original error unnamed.
256
+
257
+ ## Types
258
+
259
+ Every property needs a declared type: from `constrain type:`, or from your RBS signatures (below).
260
+ The default never supplies one — a default of `65` would otherwise reject `65.5` from the file.
261
+
262
+ | Type | Takes | Rejects |
263
+ |---|---|---|
264
+ | `Integer` | `70`; `70.0`, loaded as `70` | `"70"`, `70.5` |
265
+ | `Float` | `0.5`; `1`, loaded as `1.0` | `"0.5"` |
266
+ | `String` | `"a"` | `:a` |
267
+ | `bool` | `true`, `false` | `0`, `"yes"` |
268
+ | `T?` | `nil`, or a `T` | |
269
+ | `"short" \| "verbose"` | one of the literals | anything else; `1 \| 2` rejects `1.0` |
270
+ | `Array[T]` | an Array of `T` | |
271
+ | `Hash[String, T]` | a Hash of `T` | |
272
+ | `Ballooning` | a Hash, loaded into your class | |
273
+ | `Object` | plain data, passed through as the file holds it | a `Time`, a `Proc`, a key that is no String |
274
+
275
+ That is the whole list: any other type raises `Typemap::DefinitionError` when the mapper first
276
+ reads the class. `Object` is for a value whose meaning is someone else's, such as extra keys an
277
+ app sends to a server verbatim (`Hash[String, Object]`). Typemap checks only that it is plain
278
+ data — a String, Integer, Float, bool, Array or Hash, `nil` nested inside — on `load` and on
279
+ `dump`, and turns every Symbol key into a String.
280
+
281
+ Types are RBS strings, never Ruby classes: `"Integer"` does not take `nil`, `"Integer?"` does. A
282
+ name resolves the way Ruby resolves it in the code beside it — `"Size"` inside `module App` finds
283
+ `App::Size` first; `"::Size"` or `"Other::Size"` says exactly which.
284
+
285
+ **Limits** narrow a type:
286
+
287
+ ```ruby
288
+ constrain :raise_at_percent, type: "Integer", in: 1..100 # a range, for a number
289
+ constrain :min_gib, type: "Integer", in: (1..) # an endless range: parenthesize it
290
+ constrain :mode, type: "String", in: %w[short verbose] # a list of values
291
+ constrain :name, type: "String", format: /\A[a-z]+\z/ # a pattern, for a String
292
+ constrain :url, type: "String", not_blank: true # not "", not " "
293
+ constrain :command, type: "Array[String]", not_empty: true # at least one element; a Hash too
294
+ constrain :tags, type: "Array[String]", unique: true # no two entries equal
295
+ constrain :server, type: "Array[Server]", unique_by: :id # no two entries share an id
296
+ constrain :mirror, type: "Array[Mirror]", unique_by: ->(m) { m.url.downcase }
297
+ ```
298
+
299
+ `unique_by:` takes a property of the entries' class, or a function of the entry for anything
300
+ more. A duplicate is reported at the second entry, which is named like any entry error, and
301
+ the detail gives the first entry's index:
302
+
303
+ ```
304
+ server[2] "local".id: duplicate of server[0], both "local"
305
+ ```
306
+
307
+ `unique: true` on entries of your own class compares them with `eql?`. A data class has value
308
+ equality; on a class that only compares by identity, `unique: true` raises. Two `nil`s are
309
+ duplicates, as `nil == nil` is in Ruby.
310
+
311
+ Parenthesize an endless range at the end of a line: `in: 1..` followed by a newline makes Ruby
312
+ read the next line as the range's end.
313
+
314
+ A miss reads `must be 1 to 100, got 170`, `must be at least 1, got 0`, `must not be blank`. A
315
+ `format:` miss prints the pattern, which the person editing the file may not read; for a message
316
+ in your own words, raise it from `validate` instead. Every option, in one table:
317
+ [`constrain` at a glance](#constrain-at-a-glance).
318
+
319
+ ## Problems in the class itself
320
+
321
+ A class Typemap cannot map raises `Typemap::DefinitionError` the first time the mapper reads it —
322
+ before it looks at any file, so a gap never waits for a user to set that key:
323
+
324
+ ```
325
+ Config#name: no type: give it `constrain :name, type: "…"` (…)
326
+ Config#port: its default nil does not fit Integer
327
+ Config#port: format: /x/ limits only a String, not Integer
328
+ Config#ratio: unsupported type: Set[…]
329
+ Config: constrain names prot, not a property
330
+ Config#api_key: required: on String, which can never be missing; required: takes a String? defaulting to nil
331
+ Config: must build with a bare new, its default instance (wrong number of arguments (given 0, expected 1))
332
+ Config#initialize takes [[:keyrest, :opts]]; Typemap reads which keys are required only from `name:` and `name: default` keywords
333
+ ```
334
+
335
+ Both `Typemap::DataError` and `Typemap::DefinitionError` are `Typemap::Error`s.
336
+
337
+ ## Types from RBS signatures
338
+
339
+ A project that keeps `sig/*.rbs` can take its types from there instead of `constrain type:`:
340
+
341
+ ```ruby
342
+ mapper = Typemap::Mapper.new(types: Typemap::Mapper::RBSTypes.new)
343
+ ```
344
+
345
+ - A property's type is its reader's: `attr_reader` / `attr_accessor name: T`, or
346
+ `def name: () -> T`, along the class's ancestors.
347
+ - `RBSTypes` finds the `sig/` directory beside the class's own source, walking up from its file;
348
+ `RBSTypes.new(sig: ["sig/app.rbs"])` names the files instead.
349
+ - `constrain` still gives limits; a `constrain type:` for a property the signatures already type
350
+ raises, since a type declared twice drifts.
351
+ - It needs the `rbs` gem — add `gem "rbs"` to your Gemfile. Typemap itself never depends on it.
352
+
353
+ [sord](https://github.com/AaronC81/sord) writes `sig/*.rbs` from YARD tags
354
+ (`sord gen sig/app.rbs --rbs`). A data class is typed with YARD's `@!attribute` directive, in the
355
+ `class X < Data.define(…)` form:
356
+
357
+ ```ruby
358
+ # @!attribute [r] raise_at_percent
359
+ # @return [Integer] grow the guest above this share of its memory
360
+ class Ballooning < Data.define(:raise_at_percent)
361
+ ```
362
+
363
+ ## Example: config and state files on Linux
364
+
365
+ Typemap does not find files; your app does. The simple Linux case, by the
366
+ [XDG Base Directory spec](https://specifications.freedesktop.org/basedir-spec/latest/) —
367
+ config read from the first file found, state written whole and renamed into place:
368
+
369
+ ```ruby
370
+ require "json"
371
+ require "fileutils"
372
+ require "tomlrb"
373
+ require "typemap"
374
+
375
+ module Paths
376
+ # An XDG variable, or its default when unset, empty or relative (the spec calls that invalid).
377
+ def self.xdg(var, default) = ENV[var].to_s.start_with?("/") ? ENV[var] : File.join(Dir.home, default)
378
+
379
+ def self.config_file(app)
380
+ dirs = [xdg("XDG_CONFIG_HOME", ".config"), *ENV.fetch("XDG_CONFIG_DIRS", "/etc/xdg").split(":"), "/etc"]
381
+ dirs.map { |dir| File.join(dir, app, "config.toml") }.find { |file| File.exist?(file) }
382
+ end
383
+
384
+ def self.state_file(app) = File.join(xdg("XDG_STATE_HOME", ".local/state"), app, "state.json")
385
+ end
386
+
387
+ MAPPER = Typemap::Mapper.new
388
+
389
+ def load_config
390
+ file = Paths.config_file("myapp")
391
+ MAPPER.load(Config, file ? Tomlrb.load_file(file) : {}, source: :toml) # no file: the defaults, or `api_key: required`
392
+ rescue Typemap::DataError => e
393
+ abort "#{file || "config.toml"}: #{e.message}" # ~/.config/myapp/config.toml: port: expected Integer, got "80"; remove the quotes
394
+ end
395
+
396
+ def load_state
397
+ file = Paths.state_file("myapp")
398
+ File.exist?(file) ? MAPPER.load(State, JSON.load_file(file), source: :json, unknown: :ignore) : State.new
399
+ end
400
+
401
+ def save_state(state)
402
+ file = Paths.state_file("myapp")
403
+ FileUtils.mkdir_p(File.dirname(file))
404
+ File.write("#{file}.tmp", JSON.pretty_generate(MAPPER.dump(state)))
405
+ File.rename("#{file}.tmp", file) # a crash mid-write never leaves a truncated file
406
+ end
407
+ ```
408
+
409
+ That is as far as Typemap goes. Merging several config files, `config.d/` fragments, env-var
410
+ overrides, macOS and Windows locations are the app's call, or another gem's.
411
+
412
+ ## Design
413
+
414
+ Why Typemap is shaped this way — and why not dry-struct, ActiveModel or `T::Struct` — is in
415
+ [design/decisions.md](design/decisions.md).
416
+
417
+ ## License
418
+
419
+ MIT; see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typemap
4
+ # The class-side annotations the mapper reads: `extend Typemap::Constrain`, then
5
+ #
6
+ # constrain :raise_at_percent, :shrink_at_percent, type: "Integer", in: 1..100
7
+ # constrain :pinned_vms, type: "Array[String]"
8
+ # constrain :api_key, type: "String?", required: true # an accessor class's nil default
9
+ # constrain :url, type: "String", not_blank: true # not "" and not " "
10
+ # constrain :servers, type: "Array[Server]", unique_by: :id
11
+ # ignore :on_change
12
+ # unknown :ignore # keys this app does not read
13
+ #
14
+ # Each call records metadata and defines nothing — no accessor, no default, no `initialize`;
15
+ # the class stays as written (`D_constrain_macro`). An unknown option is Ruby's own
16
+ # `ArgumentError` the moment the class body runs.
17
+ module Constrain
18
+ # What `constrain` recorded for one property.
19
+ #
20
+ # @!attribute [r] type
21
+ # @return [String, nil] the RBS type string, `nil` when the type comes from elsewhere
22
+ # @!attribute [r] type_owner
23
+ # @return [Module, nil] the class that gave `type:`; its namespace resolves the names in it
24
+ # @!attribute [r] in
25
+ # @return [Range, Array, nil] the allowed range, or the allowed values
26
+ # @!attribute [r] format
27
+ # @return [Regexp, nil] the pattern a String must match
28
+ # @!attribute [r] not_blank
29
+ # @return [true, nil] a String must hold more than whitespace
30
+ # @!attribute [r] not_empty
31
+ # @return [true, nil] an Array or a Hash must hold at least one element
32
+ # @!attribute [r] unique
33
+ # @return [true, nil] no two entries of an Array may be equal
34
+ # @!attribute [r] unique_by
35
+ # @return [Symbol, Proc, nil] what of an Array's entry no two entries may share
36
+ # @!attribute [r] required
37
+ # @return [Boolean, nil] whether a loaded or dumped object must hold a non-nil value
38
+ Constraint = Data.define(:type, :type_owner, :in, :format, :not_blank, :not_empty, :unique, :unique_by,
39
+ :required)
40
+
41
+ # Records a type and limits for one or more properties; several calls for one property merge,
42
+ # but one option given twice raises.
43
+ #
44
+ # @param names [Array<Symbol>] the properties
45
+ # @param type [String, nil] an RBS type string, `"Integer"`, `"String?"`, `"Array[String]"`;
46
+ # never a Ruby class, so nil-ability is always spelled out (`D_types_declared`)
47
+ # @param in [Range, Array, nil] the allowed range of a number, or the allowed values
48
+ # @param format [Regexp, nil] the pattern a String must match
49
+ # @param not_blank [true, nil] `true`: a String must hold more than whitespace (`strip.empty?`
50
+ # fails it). Bean Validation's `@NotBlank`, except that a `String?`'s `nil` passes.
51
+ # @param not_empty [true, nil] `true`: an Array or a Hash must hold an element; a String
52
+ # takes `not_blank:` instead
53
+ # @param unique [true, nil] `true`: no two entries of an Array may be equal (`eql?`), `nil`s
54
+ # included; entries of a class need value equality, as a data class has
55
+ # @param unique_by [Symbol, Proc, nil] what no two entries of an Array may share: a property
56
+ # of the entries' class, `:id`, or a function of the entry, `->(s) { s.url.downcase }`
57
+ # @param required [Boolean, nil] `true`: the file must give this key, on an accessor class
58
+ # whose property is a `T?` defaulting to `nil` — a loaded or dumped object still holding
59
+ # `nil` raises. A data class says it by a keyword without a default instead.
60
+ # @return [void]
61
+ # @raise [ArgumentError] on no names, nothing to record, a wrongly typed option, `unique:` and
62
+ # `unique_by:` together, or an option already given for that property
63
+ def constrain(*names, type: nil, in: nil, format: nil, not_blank: nil, not_empty: nil, unique: nil,
64
+ unique_by: nil, required: nil)
65
+ given = { type: type, in: binding.local_variable_get(:in), format: format, not_blank: not_blank,
66
+ not_empty: not_empty, unique: unique, unique_by: unique_by, required: required }.compact
67
+ check_constrain_args(names, given)
68
+ given[:type_owner] = self if type
69
+ names.each do |name|
70
+ entry = typemap_constraints[name] ||= {}
71
+ given.each do |key, value|
72
+ raise ArgumentError, "constrain :#{name}: #{key}: given twice" if entry.key?(key)
73
+
74
+ entry[key] = value
75
+ end
76
+ end
77
+ end
78
+
79
+ # Leaves public accessors out of the mapping — a listener the app sets from outside, say. A
80
+ # private or protected accessor is left out already.
81
+ #
82
+ # @param names [Array<Symbol>] the accessors to skip
83
+ # @return [void]
84
+ # @raise [ArgumentError] on no names, or a name that is not a Symbol
85
+ def ignore(*names)
86
+ raise ArgumentError, "ignore: name at least one property" if names.empty?
87
+
88
+ names.each do |name|
89
+ raise ArgumentError, "ignore: #{name.inspect} is not a Symbol" unless name.is_a?(Symbol)
90
+ end
91
+ typemap_ignored.concat(names - typemap_ignored)
92
+ end
93
+
94
+ # Sets what a load does with a key of this class that names no property, overriding `load`'s
95
+ # `unknown:` both ways:
96
+ #
97
+ # unknown :ignore # a table another program owns, which may grow keys this app doesn't read
98
+ #
99
+ # Only this class's own keys: a class nested in it keeps its own policy, or `load`'s. A
100
+ # subclass inherits it.
101
+ #
102
+ # @param policy [Symbol] `:raise` or `:ignore`, the values `load`'s `unknown:` takes
103
+ # @return [void]
104
+ # @raise [ArgumentError] on another value, or a second call in one class
105
+ def unknown(policy)
106
+ unless %i[raise ignore].include?(policy)
107
+ raise ArgumentError, "unknown: must be :raise or :ignore, got #{policy.inspect}"
108
+ end
109
+ raise ArgumentError, "unknown: given twice" if typemap_unknown
110
+
111
+ @typemap_unknown = policy
112
+ end
113
+
114
+ # @api private
115
+ # @return [Hash{Symbol => Hash}] what `constrain` recorded on this class itself
116
+ def typemap_constraints
117
+ @typemap_constraints ||= {}
118
+ end
119
+
120
+ # @api private
121
+ # @return [Array<Symbol>] what `ignore` recorded on this class itself
122
+ def typemap_ignored
123
+ @typemap_ignored ||= []
124
+ end
125
+
126
+ # @api private
127
+ # @return [Symbol, nil] what `unknown` recorded on this class itself
128
+ def typemap_unknown = @typemap_unknown
129
+
130
+ # The constraints of `klass` and its ancestors, a subclass's option winning over its
131
+ # superclass's.
132
+ #
133
+ # @param klass [Class]
134
+ # @return [Hash{Symbol => Constraint}]
135
+ def self.constraints_for(klass)
136
+ merged = {}
137
+ klass.ancestors.reverse_each do |mod|
138
+ next unless mod.singleton_class.include?(Constrain)
139
+
140
+ mod.typemap_constraints.each { |name, options| (merged[name] ||= {}).merge!(options) }
141
+ end
142
+ merged.transform_values do |options|
143
+ Constraint.new(type: options[:type], type_owner: options[:type_owner], in: options[:in],
144
+ format: options[:format], not_blank: options[:not_blank],
145
+ not_empty: options[:not_empty], unique: options[:unique],
146
+ unique_by: options[:unique_by], required: options[:required])
147
+ end
148
+ end
149
+
150
+ # @param klass [Class]
151
+ # @return [Array<Symbol>] the names `klass` and its ancestors `ignore`
152
+ def self.ignored_for(klass)
153
+ klass.ancestors.select { |mod| mod.singleton_class.include?(Constrain) }.flat_map(&:typemap_ignored).uniq
154
+ end
155
+
156
+ # @param klass [Class]
157
+ # @return [Symbol, nil] the `unknown` policy of `klass` or its nearest ancestor that gives one;
158
+ # `nil` when none does, leaving it to `load`'s `unknown:`
159
+ def self.unknown_for(klass)
160
+ klass.ancestors.each do |mod|
161
+ return mod.typemap_unknown if mod.singleton_class.include?(Constrain) && mod.typemap_unknown
162
+ end
163
+ nil
164
+ end
165
+
166
+ private
167
+
168
+ def check_constrain_args(names, given)
169
+ raise ArgumentError, "constrain: name at least one property" if names.empty?
170
+
171
+ names.each do |name|
172
+ raise ArgumentError, "constrain: #{name.inspect} is not a Symbol" unless name.is_a?(Symbol)
173
+ end
174
+ if given.empty?
175
+ raise ArgumentError,
176
+ "constrain #{names.inspect}: give type:, in:, format:, not_blank:, not_empty:, unique:, " \
177
+ "unique_by: or required:"
178
+ end
179
+ given.each { |key, value| check_constrain_option(key, value) }
180
+ return unless given.key?(:unique) && given.key?(:unique_by)
181
+
182
+ raise ArgumentError, "constrain: unique: and unique_by: together; unique_by: alone says what to compare"
183
+ end
184
+
185
+ def check_constrain_option(key, value)
186
+ expected = case key
187
+ when :type then "an RBS type String" unless value.is_a?(String)
188
+ when :in then "a Range or an Array" unless value.is_a?(Range) || value.is_a?(Array)
189
+ when :format then "a Regexp" unless value.is_a?(Regexp)
190
+ when :unique_by then "a Symbol or a Proc" unless value.is_a?(Symbol) || value.is_a?(Proc)
191
+ when :required then "true or false" unless [true, false].include?(value)
192
+ # A switch: `false` would mean what leaving it out does — for the negated names, a
193
+ # double negative.
194
+ else "true, or left out" unless value == true
195
+ end
196
+ raise ArgumentError, "constrain: #{key}: must be #{expected}, got #{value.inspect}" if expected
197
+ end
198
+ end
199
+ end