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