adornment-core 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 +34 -0
- data/LICENSE +21 -0
- data/README.md +669 -0
- data/lib/adornment/core/annotation.rb +326 -0
- data/lib/adornment/core/base.rb +116 -0
- data/lib/adornment/core/diagnostics/lint.rb +150 -0
- data/lib/adornment/core/errors.rb +62 -0
- data/lib/adornment/core/hooks.rb +77 -0
- data/lib/adornment/core/immutable.rb +56 -0
- data/lib/adornment/core/private/annotation_methods.rb +61 -0
- data/lib/adornment/core/private/attachment.rb +122 -0
- data/lib/adornment/core/private/pending.rb +142 -0
- data/lib/adornment/core/private/query.rb +136 -0
- data/lib/adornment/core/private/site.rb +45 -0
- data/lib/adornment/core/private/state.rb +117 -0
- data/lib/adornment/core/private/utils.rb +40 -0
- data/lib/adornment/core/reflection.rb +146 -0
- data/lib/adornment/core/version.rb +8 -0
- data/lib/adornment/core.rb +200 -0
- metadata +70 -0
data/README.md
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
# adornment-core
|
|
2
|
+
|
|
3
|
+
The declarative metadata engine behind Adornment: annotation types, declarations above `def`,
|
|
4
|
+
attachment, reflection along Ruby's method resolution, verification and diagnostics.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require "adornment/core"
|
|
8
|
+
|
|
9
|
+
class Traced
|
|
10
|
+
include Adornment::Annotation[]
|
|
11
|
+
|
|
12
|
+
attr_reader :span
|
|
13
|
+
|
|
14
|
+
def initialize(span: nil)
|
|
15
|
+
@span = span
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Orders
|
|
20
|
+
include Adornment[Traced]
|
|
21
|
+
|
|
22
|
+
Traced(span: "orders.fetch")
|
|
23
|
+
def fetch(id) = id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Orders.annotations_for(:fetch) # => [#<Traced span="orders.fetch">]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Annotations are frozen values you reflect on. **Core never changes how a method runs**: no
|
|
30
|
+
wrappers, no method replacement, nothing prepended to the owner. A call to an annotated method
|
|
31
|
+
executes no Adornment code and allocates nothing extra.
|
|
32
|
+
- Pure Ruby, no runtime dependencies. Prism (a default gem since Ruby 3.3) is loaded only to run
|
|
33
|
+
the lint. Declarations never read source files.
|
|
34
|
+
- CRuby only, Ruby >= 3.3 (see [Ruby versions](#ruby-versions) for what was actually tested).
|
|
35
|
+
- Version 0.1.0, the first release.
|
|
36
|
+
|
|
37
|
+
Core owns annotation types, declarations, attachment, reflection, verification and diagnostics.
|
|
38
|
+
HTTP, operation execution, persistence, coercion and runtime type checking belong to the gems
|
|
39
|
+
built on it (`adornment-api`, `adornment-operation`, `adornment-repo`, ...).
|
|
40
|
+
|
|
41
|
+
### Gem names
|
|
42
|
+
|
|
43
|
+
| gem | require | role |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `adornment-core` (this gem) | `require "adornment/core"` | the engine |
|
|
46
|
+
| `adornment` | `require "adornment"` | the umbrella gem: loads every part |
|
|
47
|
+
|
|
48
|
+
Everything lives in the `Adornment` namespace.
|
|
49
|
+
|
|
50
|
+
## The one pitfall: write `Traced()`, not `Traced`
|
|
51
|
+
|
|
52
|
+
A capitalized name with no arguments and no parentheses is a **constant read** in Ruby: it
|
|
53
|
+
declares nothing.
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
Traced # WRONG: a constant read
|
|
57
|
+
def a = 1
|
|
58
|
+
|
|
59
|
+
Traced() # right
|
|
60
|
+
def b = 1
|
|
61
|
+
|
|
62
|
+
Traced span: "x" # right: arguments make it a method call
|
|
63
|
+
def c = 1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Ruby gives no hook for a constant read. The [lint](#lint) finds these after the fact, and
|
|
67
|
+
`Adornment.verify_all!` runs it by default.
|
|
68
|
+
|
|
69
|
+
## Defining types
|
|
70
|
+
|
|
71
|
+
Core has one kind of annotation: method annotations, written above `def`. Per-parameter metadata
|
|
72
|
+
is an extension's own business ([Per-parameter metadata in extensions](#per-parameter-metadata-in-extensions)).
|
|
73
|
+
|
|
74
|
+
An annotation type is an ordinary named class that includes `Adornment::Annotation[...]`. The
|
|
75
|
+
brackets take the type's policies; `Adornment::Annotation[]` takes the defaults, and a bare
|
|
76
|
+
`include Adornment::Annotation` raises. Fields are instance variables set by an ordinary
|
|
77
|
+
`initialize`: Ruby itself decides what is positional, what is a keyword, what is required and
|
|
78
|
+
what the defaults are.
|
|
79
|
+
|
|
80
|
+
### Method annotation types
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
require "adornment/core"
|
|
84
|
+
|
|
85
|
+
class Route
|
|
86
|
+
include Adornment::Annotation[repeatable: true] # several per method (default: false)
|
|
87
|
+
|
|
88
|
+
attr_reader :path, :verbs
|
|
89
|
+
|
|
90
|
+
def initialize(path, verbs: [:GET]) # path positional, verbs a keyword with a default
|
|
91
|
+
@path = path
|
|
92
|
+
@verbs = verbs.map(&:to_sym) # normalize freely: Adornment freezes afterwards
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class Raises
|
|
97
|
+
include Adornment::Annotation[targets: [:instance]]
|
|
98
|
+
|
|
99
|
+
attr_reader :errors
|
|
100
|
+
|
|
101
|
+
def initialize(errors)
|
|
102
|
+
@errors = errors
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
raise unless Route.new("/users").verbs == [:GET]
|
|
107
|
+
raise unless Route.new("/users", verbs: ["POST"]) == Route.new("/users", verbs: [:POST])
|
|
108
|
+
raise unless Route.new("/x").frozen? && Route.new("/x", verbs: [+"put"]).verbs.frozen?
|
|
109
|
+
raise unless Raises.new([KeyError, IndexError]).errors == [KeyError, IndexError]
|
|
110
|
+
raise unless Route.repeatable? && Raises.targets == [:instance]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Policies of method annotation types (all optional; each `include` stores only what it is given):
|
|
114
|
+
|
|
115
|
+
| Policy | Values | Default |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `repeatable:` | `true` or `false` | `false` |
|
|
118
|
+
| `targets:` | a non-empty subset of `[:instance, :singleton]` | both |
|
|
119
|
+
|
|
120
|
+
Construction rules:
|
|
121
|
+
|
|
122
|
+
- Declaration methods (`Route "/users", verbs: [:GET]`) call `Type.new` with the arguments as
|
|
123
|
+
written. Wrong arguments raise Ruby's
|
|
124
|
+
own `ArgumentError` (`wrong number of arguments`, `missing keyword: :path`,
|
|
125
|
+
`unknown keyword: :colour`) from the initializer.
|
|
126
|
+
- After `initialize` returns, `new` applies the [freezing rule](#immutability) to every instance
|
|
127
|
+
variable and freezes the instance. Do not freeze in the initializer: an instance that is already
|
|
128
|
+
frozen is kept as it is, and deep freezing is then the initializer's job.
|
|
129
|
+
- Every instance variable is a field: it takes part in `==` (same class, equal fields), `eql?`,
|
|
130
|
+
`hash`, `to_h`, inspection (`#<Route path="/users", verbs=[:GET]>`) and pattern matching
|
|
131
|
+
(`in { path: String => path }`). Keep helpers and caches out of instance variables.
|
|
132
|
+
- There is no `with`: build a new instance.
|
|
133
|
+
- `Type[...]` is not a constructor: it raises `Adornment::TargetError`, which shows the call form
|
|
134
|
+
(`Get["/x"]` → `Get("/x")`), so a misplaced bracket form fails loudly.
|
|
135
|
+
- Subclasses inherit the policies. A subclass may include `Adornment::Annotation[...]` again to
|
|
136
|
+
override some of them: values replace inherited ones (arrays are not concatenated).
|
|
137
|
+
|
|
138
|
+
## Activating a class or module
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
require "adornment/core"
|
|
142
|
+
class Traced
|
|
143
|
+
include Adornment::Annotation[]
|
|
144
|
+
|
|
145
|
+
attr_reader :span
|
|
146
|
+
|
|
147
|
+
def initialize(span: nil)
|
|
148
|
+
@span = span
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
class A
|
|
153
|
+
include Adornment[Traced] # declaration method named after the type: Traced(...)
|
|
154
|
+
Traced(span: "a")
|
|
155
|
+
def a = 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
class B
|
|
159
|
+
include Adornment[Trace: Traced] # an alias names the declaration method
|
|
160
|
+
Trace(span: "b")
|
|
161
|
+
def b = 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class C
|
|
165
|
+
include Adornment[] # no declaration methods
|
|
166
|
+
adorn Traced.new(span: "c") # instances only: build them first, reuse them freely
|
|
167
|
+
def c = 1
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
class D
|
|
171
|
+
extend Adornment::Core::Target # the explicit, low-level form
|
|
172
|
+
use_annotations Traced
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
raise unless [A.annotations_for(:a), B.annotations_for(:b), C.annotations_for(:c)].map { |l| l.first.span } == %w[a b c]
|
|
176
|
+
raise unless !Object.respond_to?(:Traced) # nothing is defined globally
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- Declaration methods are singleton methods of the owner, and its subclasses inherit them.
|
|
180
|
+
Nothing is added to `Object`, `Module` or unrelated classes.
|
|
181
|
+
- `include Adornment` (bare), `extend Adornment[...]` and `prepend Adornment[...]` raise
|
|
182
|
+
`ArgumentError`.
|
|
183
|
+
- A class that is not an annotation type raises `ArgumentError` ("X is not an annotation type;
|
|
184
|
+
include Adornment::Annotation[...]"); so does `adorn Route` (a type, not an instance: write
|
|
185
|
+
`adorn Route.new(...)`) and `adorn` with any object that is not an annotation instance.
|
|
186
|
+
- A declaration method `Route("/x")` is `adorn Route.new("/x")`: both record the user's line
|
|
187
|
+
once, and the declaration's location travels with it until it attaches.
|
|
188
|
+
|
|
189
|
+
## Which definitions take a declaration
|
|
190
|
+
|
|
191
|
+
Pending declarations attach, in order, to the **next method definition in the same owner, made
|
|
192
|
+
from the same fiber**, whatever makes that definition.
|
|
193
|
+
|
|
194
|
+
| Construct | Takes the declarations? |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `def`, endless `def`, `private def`, `protected def`, `def` in `class_eval` (string or block) | yes |
|
|
197
|
+
| `define_method` with a block, Proc, Method or UnboundMethod | yes |
|
|
198
|
+
| `def self.x`, `define_singleton_method` | yes, on the singleton side (unless the type narrows `targets` to `:instance`) |
|
|
199
|
+
| `attr_reader`, `attr_writer`, `attr_accessor` | yes: the first method generated (`a` for `attr_accessor :a`) |
|
|
200
|
+
| `alias`, `alias_method` (also in `class << self`) | yes: an alias is annotated like any method (see [Aliases](#aliases)) |
|
|
201
|
+
| `private :inherited_method`, `private_class_method :inherited`, `module_function :inherited` | yes: Ruby makes an own entry in this owner, an override that runs the ancestor's body |
|
|
202
|
+
| `module_function :name` singleton copy | yes, on the singleton side: a copy, like an alias (the instance method keeps its own) |
|
|
203
|
+
| any other method defined in between, e.g. by a macro | **yes** |
|
|
204
|
+
|
|
205
|
+
- A definition takes the pending declarations; a definition with nothing pending changes
|
|
206
|
+
nothing. Redefining a method with declarations replaces its annotations; redefining it without
|
|
207
|
+
declarations keeps them. Removal is not tracked: after `remove_method` or `undef_method` the
|
|
208
|
+
method's annotations are hidden from reflection while the owner does not define it, and an
|
|
209
|
+
unannotated redefinition shows them again. Pending declarations are untouched by removals.
|
|
210
|
+
- A non-repeatable type declared twice for one method raises `Adornment::DuplicateAnnotation`.
|
|
211
|
+
A type declared for the wrong kind of method raises `Adornment::InvalidTarget`. Both clear the
|
|
212
|
+
pending declarations.
|
|
213
|
+
- Hooks: core prepends `method_added`, `singleton_method_added` and `const_added` to an
|
|
214
|
+
activated owner's singleton class, once per ancestry (subclasses inherit them). Like any Ruby
|
|
215
|
+
hook, an owner's own `self.method_added` must call `super`. A subclass's own hook runs before
|
|
216
|
+
the inherited one, so a declaration it makes before `super` attaches to the method being
|
|
217
|
+
defined.
|
|
218
|
+
|
|
219
|
+
A declaration cannot reach methods generated by a macro or the second name of an
|
|
220
|
+
`attr_reader :a, :b`; to annotate such a method, define it with `def` or `define_method`.
|
|
221
|
+
|
|
222
|
+
### Aliases
|
|
223
|
+
|
|
224
|
+
An alias starts with no annotations (nothing is copied from the original) and takes the
|
|
225
|
+
declarations above it like any definition, whether it comes from `alias`, `alias_method` or
|
|
226
|
+
`define_method(name, instance_method(other))`. An alias has the original's body, so it shares
|
|
227
|
+
the original's signature, but not its annotations: an alias that needs a parameter declaration
|
|
228
|
+
takes its own above it.
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
require "adornment/core"
|
|
232
|
+
class Route
|
|
233
|
+
include Adornment::Annotation[]
|
|
234
|
+
|
|
235
|
+
attr_reader :path
|
|
236
|
+
|
|
237
|
+
def initialize(path)
|
|
238
|
+
@path = path
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
class UsersApi
|
|
243
|
+
include Adornment[Route]
|
|
244
|
+
|
|
245
|
+
Route("/users/:id")
|
|
246
|
+
def show(id) = id
|
|
247
|
+
|
|
248
|
+
Route("/profile/:id") # attaches to profile only
|
|
249
|
+
alias_method :profile, :show
|
|
250
|
+
|
|
251
|
+
alias helper show # unannotated: a plain Ruby method
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
raise unless UsersApi.annotations_for(:show) == [Route.new("/users/:id")]
|
|
255
|
+
raise unless UsersApi.annotations_for(:profile) == [Route.new("/profile/:id")]
|
|
256
|
+
raise unless UsersApi.annotations_for(:helper).empty?
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Leftovers: declarations that never attach
|
|
260
|
+
|
|
261
|
+
Pending declarations belong to one owner and wait for that owner's
|
|
262
|
+
next method definition. Nothing traces body ends, so these raise `Adornment::LeftoverAnnotations`,
|
|
263
|
+
naming the owner, the annotation and where it was declared, and clear the pending declarations:
|
|
264
|
+
|
|
265
|
+
- a declaration on another owner while declarations are still pending;
|
|
266
|
+
- a constant added to the owner while it has pending declarations (a nested `class Inner`,
|
|
267
|
+
`LIMIT = 10`).
|
|
268
|
+
|
|
269
|
+
A leftover at the end of a body is not raised there, so it never masks an exception unwinding the
|
|
270
|
+
body. It stays pending until one of the above, the owner's next definition (also in a later
|
|
271
|
+
reopening) or verification. `Owner.verify_adornments!` checks one owner and
|
|
272
|
+
`Adornment.verify_all!` checks everything pending. Call it once your code is loaded, for example
|
|
273
|
+
at the end of boot or in a test.
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
require "adornment/core"
|
|
277
|
+
class Tag
|
|
278
|
+
include Adornment::Annotation[]
|
|
279
|
+
|
|
280
|
+
attr_reader :name
|
|
281
|
+
|
|
282
|
+
def initialize(name)
|
|
283
|
+
@name = name
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
begin
|
|
288
|
+
class Service
|
|
289
|
+
include Adornment[Tag]
|
|
290
|
+
Tag("fetch")
|
|
291
|
+
class Inner; end # const_added(:Inner) while Tag is pending
|
|
292
|
+
def fetch = 1
|
|
293
|
+
end
|
|
294
|
+
rescue Adornment::LeftoverAnnotations => e
|
|
295
|
+
raise unless e.leftovers.keys == [Service] && e.message.include?("declared at")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
Built = Class.new { include Adornment[Tag]; Tag("in a block body") }
|
|
299
|
+
begin
|
|
300
|
+
Class.new { include Adornment[Tag]; Tag("another owner") }
|
|
301
|
+
raise "expected a leftover"
|
|
302
|
+
rescue Adornment::LeftoverAnnotations => e
|
|
303
|
+
raise unless e.leftovers.keys == [Built]
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Reflection
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
require "adornment/core"
|
|
311
|
+
class Traced
|
|
312
|
+
include Adornment::Annotation[]
|
|
313
|
+
|
|
314
|
+
attr_reader :span
|
|
315
|
+
|
|
316
|
+
def initialize(span: nil)
|
|
317
|
+
@span = span
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
class Tag
|
|
322
|
+
include Adornment::Annotation[repeatable: true]
|
|
323
|
+
|
|
324
|
+
attr_reader :name
|
|
325
|
+
|
|
326
|
+
def initialize(name)
|
|
327
|
+
@name = name
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
class Parent
|
|
332
|
+
include Adornment[Traced, Tag]
|
|
333
|
+
|
|
334
|
+
Traced(span: "p")
|
|
335
|
+
Tag("a")
|
|
336
|
+
Tag("b")
|
|
337
|
+
def fetch = 1
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
class Child < Parent
|
|
341
|
+
def fetch = super # unannotated override
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
class Heir < Parent; end # no override: Parent#fetch is the resolved method
|
|
345
|
+
|
|
346
|
+
raise unless Parent.annotation(:fetch, Traced) == Traced.new(span: "p")
|
|
347
|
+
raise unless Parent.annotated_with(Tag) == { fetch: [Tag.new("a"), Tag.new("b")] }
|
|
348
|
+
raise unless Heir.annotation(:fetch, Traced) == Traced.new(span: "p")
|
|
349
|
+
raise unless Child.annotations_for(:fetch).empty? && Child.annotation(:fetch, Traced).nil? # strict
|
|
350
|
+
raise unless Child.find_annotation(:fetch, Traced) == Traced.new(span: "p") # walks ancestors
|
|
351
|
+
raise unless Child.declarations_for(:fetch).all? { |d| d.owner == Parent && !d.resolved }
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
| Owner method | Returns |
|
|
355
|
+
|---|---|
|
|
356
|
+
| `annotations_for(name, type = nil, singleton: false)` | the resolved method's annotations |
|
|
357
|
+
| `annotation(name, type, singleton: false)` | the resolved method's annotation of `type`, or nil (raises for repeatable types) |
|
|
358
|
+
| `find_annotation(name, type, singleton: false)` | the same, falling back to the nearest overridden method that has one (raises for repeatable types) |
|
|
359
|
+
| `annotated_with(type, singleton: false)` | `{name => annotation}`, or `{name => [annotations]}` for repeatable types |
|
|
360
|
+
| `own_annotations_for(name, type = nil, singleton: false)` | only this owner's annotations |
|
|
361
|
+
| `own_annotated_methods(singleton: false)` | the names this owner annotated and still defines, in first-annotation order |
|
|
362
|
+
| `declarations_for(name, singleton: false)` | every `Adornment::Core::Declaration` (`annotation`, `owner`, `method_name`, `singleton`, `resolved`) along the ancestors |
|
|
363
|
+
| `pending_adornments` | annotations still waiting for a definition (current fiber) |
|
|
364
|
+
|
|
365
|
+
- Everything is relative to the **resolved method**: the body Ruby's lookup reaches. Its owner is
|
|
366
|
+
the defining owner, so for a method from an included module, the module.
|
|
367
|
+
- Queries are strict, as in Java: an override does not carry the annotations of the method it
|
|
368
|
+
overrides. `find_annotation` walks the overridden methods, nearest first (like Spring's
|
|
369
|
+
`findAnnotation`); `declarations_for` lists all of them.
|
|
370
|
+
- A visibility change (`private :fetch` in a subclass) is an override like any other: it has its
|
|
371
|
+
own annotations (none unless declared), and `find_annotation` reaches the parent's.
|
|
372
|
+
`undef_method` stops reflection; `remove_method` falls through to ancestors.
|
|
373
|
+
- Classes and modules that include, prepend or extend an activated module get this read-only API
|
|
374
|
+
automatically, without declaration methods.
|
|
375
|
+
- Results are frozen. Reflection never locks. It is meant for boot and compilation, not for each
|
|
376
|
+
request: extensions compile what they need once.
|
|
377
|
+
|
|
378
|
+
### Discovery
|
|
379
|
+
|
|
380
|
+
`Adornment.annotated_with(type, singleton: false)` searches every registered owner and returns the
|
|
381
|
+
*defining* owners: `{Owner => {name => annotation(s)}}`. Owners that only inherit annotated
|
|
382
|
+
methods are not listed; ask them with `Owner.annotated_with(type)`. Load your code first: a class
|
|
383
|
+
that was never loaded cannot be found. `Adornment.registered_owners` lists the activated owners
|
|
384
|
+
that are still reachable. The registry holds them weakly, so dropped classes can be collected.
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
require "adornment/core"
|
|
388
|
+
class Route
|
|
389
|
+
include Adornment::Annotation[]
|
|
390
|
+
|
|
391
|
+
attr_reader :path
|
|
392
|
+
|
|
393
|
+
def initialize(path)
|
|
394
|
+
@path = path
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
class Users
|
|
399
|
+
include Adornment[Route]
|
|
400
|
+
Route("/users")
|
|
401
|
+
def index = 1
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
class Admins < Users; end # inherits index, declares nothing
|
|
405
|
+
|
|
406
|
+
raise unless Adornment.annotated_with(Route) == { Users => { index: Route.new("/users") } }
|
|
407
|
+
raise unless Admins.annotated_with(Route) == { index: Route.new("/users") }
|
|
408
|
+
Adornment.unregister(Users)
|
|
409
|
+
raise unless Adornment.annotated_with(Route).empty? && Users.annotations_for(:index) == [Route.new("/users")]
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`Adornment.unregister(owner)` only removes the owner from discovery and from `verify_all!`. Its
|
|
413
|
+
hooks stay installed and its metadata stays readable through its own reflection API. Declaring on
|
|
414
|
+
it again does not register it again; activating it again (`include Adornment[...]`) does.
|
|
415
|
+
|
|
416
|
+
## Attachment-time validation
|
|
417
|
+
|
|
418
|
+
An annotation type may define `validate(site)`. It runs whenever an annotation of the type is
|
|
419
|
+
about to attach, after the target and duplicate checks and before anything is written; it reads
|
|
420
|
+
and raises, never mutates. `site` answers `owner`, `name`, `method` (an `UnboundMethod`),
|
|
421
|
+
`annotations` (the complete proposed set, frozen), `instance?` and `singleton?`. One site is
|
|
422
|
+
shared by every annotation of the definition.
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
require "adornment/core"
|
|
426
|
+
|
|
427
|
+
class Reader
|
|
428
|
+
include Adornment::Annotation[]
|
|
429
|
+
|
|
430
|
+
def validate(site)
|
|
431
|
+
raise ArgumentError, "annotates a reader without parameters" unless site.method.parameters.empty?
|
|
432
|
+
raise ArgumentError, "cannot be combined with Writer" if site.annotations.any?(Writer)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
class Writer
|
|
437
|
+
include Adornment::Annotation[]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
class Model
|
|
441
|
+
include Adornment[Reader, Writer]
|
|
442
|
+
|
|
443
|
+
Reader()
|
|
444
|
+
def name = "n"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
begin
|
|
448
|
+
Model.class_eval do
|
|
449
|
+
Reader()
|
|
450
|
+
def age(unit) = unit
|
|
451
|
+
end
|
|
452
|
+
rescue Adornment::DefinitionError => e
|
|
453
|
+
raise unless e.message.start_with?("Model#age: annotates a reader") && e.cause.is_a?(ArgumentError)
|
|
454
|
+
end
|
|
455
|
+
raise unless Model.annotations_for(:age).empty? && Model.pending_adornments.empty?
|
|
456
|
+
|
|
457
|
+
begin
|
|
458
|
+
Model.class_eval do
|
|
459
|
+
Reader()
|
|
460
|
+
Writer()
|
|
461
|
+
def title = "t"
|
|
462
|
+
end
|
|
463
|
+
rescue Adornment::DefinitionError => e
|
|
464
|
+
raise unless e.message.include?("cannot be combined")
|
|
465
|
+
end
|
|
466
|
+
raise unless Model.annotations_for(:title).empty?
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
- Every `StandardError` from a validator becomes `Adornment::DefinitionError`, with the original
|
|
470
|
+
as `cause`. The message starts with `Owner#method` (`Owner.method` for singleton methods) and
|
|
471
|
+
ends with `(declared at path:line)`; that location is also the first backtrace entry.
|
|
472
|
+
- On failure nothing is attached and no declaration stays pending; the method itself stays
|
|
473
|
+
defined, and defining it again (with or without declarations) works as usual.
|
|
474
|
+
- Validators run outside the owner's lock.
|
|
475
|
+
|
|
476
|
+
## Per-parameter metadata in extensions
|
|
477
|
+
|
|
478
|
+
Core has no parameter annotations: no attachment, signature checking or resolution for
|
|
479
|
+
parameters. An extension that needs per-parameter metadata keeps its own ordinary value objects
|
|
480
|
+
inside one of its method annotations, and reads them back with the reflection API and Ruby's
|
|
481
|
+
`Method#parameters`. adornment-api's `Params` is built this way; the minimal form is:
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
require "adornment/core"
|
|
485
|
+
|
|
486
|
+
module Vocabulary
|
|
487
|
+
# Ordinary metadata: core knows nothing about it.
|
|
488
|
+
Query = Data.define(:type, :default) do
|
|
489
|
+
def initialize(type:, default: nil) = super
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
class Args # the extension's own declaration
|
|
494
|
+
include Adornment::Annotation[]
|
|
495
|
+
|
|
496
|
+
attr_reader :parameters
|
|
497
|
+
|
|
498
|
+
def initialize(parameters)
|
|
499
|
+
@parameters = parameters
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
class Items
|
|
504
|
+
include Vocabulary # makes Query resolvable, as for any constant
|
|
505
|
+
include Adornment[Args]
|
|
506
|
+
|
|
507
|
+
Args({ page: Query.new(Integer, 1), per: Query.new(Integer) })
|
|
508
|
+
def index(page:, per: 20) = [page, per]
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
declared = Items.annotation(:index, Args).parameters
|
|
512
|
+
raise unless declared == { page: Vocabulary::Query.new(Integer, 1), per: Vocabulary::Query.new(Integer) }
|
|
513
|
+
raise unless declared.frozen? # the freezing rule
|
|
514
|
+
raise unless Items.instance_method(:index).parameters == [%i[keyreq page], %i[key per]]
|
|
515
|
+
raise unless Items.new.index(page: 2) == [2, 20] # core never touches the method
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
- Everything here is plain core: the declaration is an ordinary method annotation (attached to the
|
|
519
|
+
next definition, strict reflection, non-repeatable unless the type says otherwise), and its
|
|
520
|
+
values follow the [freezing rule](#immutability): the Hash is copied and frozen, the metadata
|
|
521
|
+
objects are kept as they are (make them immutable yourself, as `Data` is).
|
|
522
|
+
- The metadata objects are not annotations: `adorn`, `include Adornment[...]` and
|
|
523
|
+
`annotated_with` reject them.
|
|
524
|
+
- What the declaration means (which parameters it must cover, what a missing value becomes, how
|
|
525
|
+
values are resolved) is the extension's decision; core reads no source files and never looks at
|
|
526
|
+
signature defaults.
|
|
527
|
+
- An extension checks a declaration against the method in its type's `validate(site)` (see
|
|
528
|
+
[Attachment-time validation](#attachment-time-validation)), or from its own `method_added` hook
|
|
529
|
+
on the classes it activates (calling `super`, as for any hook), using `pending_adornments`
|
|
530
|
+
before `super` and reflection after it.
|
|
531
|
+
|
|
532
|
+
## Lint
|
|
533
|
+
|
|
534
|
+
`Adornment.lint(*owners_or_paths)` parses source with Prism. It flags statements that sit directly
|
|
535
|
+
in a class or module body, consist only of a constant (`Traced` or `A::Traced`), and match a
|
|
536
|
+
declaration method or type registered on that owner. It evaluates nothing and cannot know intent.
|
|
537
|
+
Suppress a diagnostic with `# adornment:ignore` on the same line.
|
|
538
|
+
|
|
539
|
+
`Adornment.verify_all!(lint: :warn)` runs it. `:warn` (the default) prints the diagnostics,
|
|
540
|
+
`:error` raises `Adornment::LintError`, and `false` skips the lint.
|
|
541
|
+
|
|
542
|
+
## Public API
|
|
543
|
+
|
|
544
|
+
This is the supported surface. Extension gems need nothing else, and
|
|
545
|
+
[`examples/events`](examples/events/events.rb) is a working extension that uses only this.
|
|
546
|
+
|
|
547
|
+
**`Adornment.*`** (all declared in `lib/adornment/core.rb`):
|
|
548
|
+
|
|
549
|
+
| | |
|
|
550
|
+
|---|---|
|
|
551
|
+
| `Adornment[*types, **aliases]` | a module whose `include` activates the includer |
|
|
552
|
+
| `activated?(owner)` | whether `owner` (or an ancestor) is activated |
|
|
553
|
+
| `annotated_with(type, singleton: false)` | global discovery over registered owners |
|
|
554
|
+
| `registered_owners` | the registered, reachable owners |
|
|
555
|
+
| `verify_all!(lint: :warn)` | report every leftover (and lint) at once |
|
|
556
|
+
| `lint(*owners_or_paths)` | the bare-constant lint |
|
|
557
|
+
| `unregister(owner)` | remove an owner from discovery and verification |
|
|
558
|
+
| `immutable_copy(value)` | the freezing rule, for extension values |
|
|
559
|
+
|
|
560
|
+
**Owner API:** `Adornment::Core::Target` provides `use_annotations`, `adorn` and
|
|
561
|
+
`verify_adornments!`. Activation also extends the owner with `Adornment::Core::Reflection`,
|
|
562
|
+
which provides `annotations_for`, `own_annotations_for`, `annotation`, `find_annotation`,
|
|
563
|
+
`annotated_with`, `own_annotated_methods`, `declarations_for` and `pending_adornments`.
|
|
564
|
+
Declaration methods (`Traced(...)`) are defined per owner.
|
|
565
|
+
|
|
566
|
+
**Types:** `Adornment::Annotation[...]` defines an annotation type (included in a plain class);
|
|
567
|
+
`Adornment::Annotation` is also the marker module of its instances. Every type answers
|
|
568
|
+
`short_name`, `new` (Ruby's construction, then the freezing rule), `repeatable?`, `targets` and
|
|
569
|
+
`[]` (raises `TargetError`). Instances answer `validate(site)` (a no-op unless the type defines
|
|
570
|
+
it), `==`, `eql?`, `hash`, `to_h` and `deconstruct_keys`.
|
|
571
|
+
|
|
572
|
+
**Records:** `Adornment::Core::Declaration`, `Adornment::Core::Pending::PendingDeclaration`
|
|
573
|
+
(`annotation`, `location`), `Adornment::Core::Site` and `Adornment::Core::Lint::Diagnostic`.
|
|
574
|
+
`Adornment[...]` returns an `Adornment::Core::Base`.
|
|
575
|
+
|
|
576
|
+
**Errors:** `Adornment::Error`, with the subclasses `DuplicateAnnotation`, `InvalidTarget`,
|
|
577
|
+
`TargetError`, `DefinitionError`, `LeftoverAnnotations` (`leftovers`) and `LintError`
|
|
578
|
+
(`diagnostics`), plus
|
|
579
|
+
`Adornment::UnsupportedPlatformError` (a `LoadError`).
|
|
580
|
+
|
|
581
|
+
The names users write every day (`Adornment[...]`, `Adornment::Annotation`, the errors) live
|
|
582
|
+
directly under `Adornment`; the owner API and records above live under `Adornment::Core`.
|
|
583
|
+
Everything else under `Adornment::Core` (state, registry, hooks, pending store) is internal and
|
|
584
|
+
may change in any release.
|
|
585
|
+
|
|
586
|
+
## Threads, fibers and immutability
|
|
587
|
+
|
|
588
|
+
- Pending declarations are **fiber-local**, keyed by the owner (by identity); one owner at a time
|
|
589
|
+
per fiber.
|
|
590
|
+
- Reflection never locks: entries are frozen Arrays, and whole-side reads get a frozen snapshot.
|
|
591
|
+
Writes take the owner's lock. No user code runs under it.
|
|
592
|
+
- Defining annotated methods on one class from several threads at once does not lose or corrupt
|
|
593
|
+
registrations, but the ordering is unspecified. **Concurrent definition is unsupported.**
|
|
594
|
+
|
|
595
|
+
### Immutability
|
|
596
|
+
|
|
597
|
+
Annotation instances are frozen. Array, Hash, Set and String values are stored as deep-frozen
|
|
598
|
+
copies, so your objects are never mutated or frozen. A value that is already deeply immutable
|
|
599
|
+
(`Ractor.shareable?`) is kept as it is. Other objects are kept by reference. Use
|
|
600
|
+
`Ractor.shareable?(annotation)` if you need full shareability.
|
|
601
|
+
|
|
602
|
+
The rule is applied by `new` after the initializer returns, to every instance variable: so it
|
|
603
|
+
covers what the initializer builds while normalizing (`@values = values.map(&:to_s)` stores a
|
|
604
|
+
frozen Array of frozen Strings). Defaults written in the initializer are copied like any value;
|
|
605
|
+
a frozen constant default is kept as it is. An initializer that freezes the instance itself opts
|
|
606
|
+
out: the instance is then kept as it is.
|
|
607
|
+
|
|
608
|
+
## How it works, and what it costs
|
|
609
|
+
|
|
610
|
+
- Definitions are observed through `method_added`, `singleton_method_added` and `const_added` of
|
|
611
|
+
activated owners only.
|
|
612
|
+
- A definition with no pending declaration for its owner takes a path that allocates nothing and
|
|
613
|
+
looks up no method body. A definition on an activated owner costs well under a microsecond more
|
|
614
|
+
than on a plain class; see [benchmarks](docs/benchmarks.md).
|
|
615
|
+
- Nothing is traced: no TracePoint is ever enabled. Leftovers are found by the next declaration
|
|
616
|
+
on another owner, by `const_added` on the owner, or by verification.
|
|
617
|
+
- Calls to annotated methods run the method itself. The specs check owner, source location, zero
|
|
618
|
+
extra allocations, and that no Adornment code runs during calls.
|
|
619
|
+
- `require "adornment/core"` loads only the declaration and reflection engine. The lint (and
|
|
620
|
+
Prism) is autoloaded on first use.
|
|
621
|
+
|
|
622
|
+
[docs/architecture.md](docs/architecture.md) explains the internals and the techniques taken from
|
|
623
|
+
sorbet-runtime.
|
|
624
|
+
|
|
625
|
+
## Limitations
|
|
626
|
+
|
|
627
|
+
1. **CRuby only.** Lock-free reads rely on GVL-atomic `Hash#[]` with Symbol keys.
|
|
628
|
+
2. **A leftover at the end of a body is not reported there.** It is reported by the next
|
|
629
|
+
declaration on another owner, a constant added to the owner, or verification; until then it
|
|
630
|
+
attaches to the owner's next definition from the same fiber, also in a later reopening.
|
|
631
|
+
3. **Reopening an existing nested class** (`class Inner` when `Inner` is already defined) adds no
|
|
632
|
+
constant, so it does not report the outer owner's pending declarations. Declarations left in a
|
|
633
|
+
fiber that finishes are dropped with it.
|
|
634
|
+
4. **A macro between a declaration and its `def`** takes the declaration if it defines a method.
|
|
635
|
+
5. **`attr_*` with several names** attaches to the first generated method only.
|
|
636
|
+
6. **Concurrent definition** of one owner from several threads is unsupported. Pending
|
|
637
|
+
declarations are fiber-local, so other fibers and threads never see them.
|
|
638
|
+
7. **No per-parameter metadata.** Core neither attaches nor checks anything per parameter;
|
|
639
|
+
extensions do (see [Per-parameter metadata in extensions](#per-parameter-metadata-in-extensions)).
|
|
640
|
+
8. **Validators see attachments, not every redefinition.** An unannotated redefinition keeps the
|
|
641
|
+
old annotations without running `validate` again; check what matters again where you compile.
|
|
642
|
+
|
|
643
|
+
## Ruby versions
|
|
644
|
+
|
|
645
|
+
Declared: CRuby >= 3.3 (`required_ruby_version`). Actually tested for this release:
|
|
646
|
+
|
|
647
|
+
| Ruby | YJIT off | YJIT on | where |
|
|
648
|
+
|---|---|---|---|
|
|
649
|
+
| 4.0.5 (arm64-darwin25) | full suite | full suite | local |
|
|
650
|
+
| 3.4.11 (`ruby:3.4-slim`, aarch64-linux) | full suite | full suite | Docker |
|
|
651
|
+
| 3.3.12 (`ruby:3.3-slim`, aarch64-linux) | full suite | full suite | Docker |
|
|
652
|
+
|
|
653
|
+
See [docs/testing.md](docs/testing.md) for the exact versions, commands and results. Other
|
|
654
|
+
engines raise `Adornment::UnsupportedPlatformError` on require.
|
|
655
|
+
|
|
656
|
+
## Development
|
|
657
|
+
|
|
658
|
+
```bash
|
|
659
|
+
bundle install
|
|
660
|
+
bundle exec rake # specs
|
|
661
|
+
BUNDLE_WITH=tooling bundle install # once, for RuboCop (an optional Gemfile group)
|
|
662
|
+
BUNDLE_WITH=tooling bundle exec rubocop
|
|
663
|
+
ruby benchmarks/compare.rb # speed against the recorded baseline (RECORD=1 to re-record)
|
|
664
|
+
ruby benchmarks/memory_compare.rb # retained memory against the recorded baseline
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
## License
|
|
668
|
+
|
|
669
|
+
MIT
|