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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Adornment
|
|
4
|
+
# Method annotation types are ordinary named classes configured by including
|
|
5
|
+
# `Adornment::Annotation[...]`. Their fields are instance variables set by an ordinary
|
|
6
|
+
# `initialize`, so Ruby itself decides what is positional, what is a keyword and what is
|
|
7
|
+
# required:
|
|
8
|
+
#
|
|
9
|
+
# class Route
|
|
10
|
+
# include Adornment::Annotation[repeatable: true]
|
|
11
|
+
#
|
|
12
|
+
# attr_reader :path, :verbs
|
|
13
|
+
#
|
|
14
|
+
# def initialize(path, verbs: [:GET])
|
|
15
|
+
# @path = path
|
|
16
|
+
# @verbs = verbs.map(&:to_sym)
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# After `initialize` returns, `new` copies and deep-freezes every instance variable
|
|
21
|
+
# ({Adornment.immutable_copy}) and freezes the instance, so an initializer neither freezes nor
|
|
22
|
+
# copies anything itself. Every instance variable is a field: it takes part in `==`, `hash`,
|
|
23
|
+
# `to_h`, inspection and pattern matching.
|
|
24
|
+
#
|
|
25
|
+
# This module is the instance marker of method annotations. Including it bare is an error:
|
|
26
|
+
# `Adornment::Annotation[...]` (or `[]` for the defaults) returns the policy module that
|
|
27
|
+
# includes it. Policies: `repeatable:` (false) and `targets:` (`[:instance, :singleton]`).
|
|
28
|
+
module Annotation
|
|
29
|
+
class << self
|
|
30
|
+
# @param args [Array] must be empty: policies are keywords
|
|
31
|
+
# @param policies [Hash] `repeatable:`, `targets:`; only the given ones are stored (the
|
|
32
|
+
# others are inherited, or defaults)
|
|
33
|
+
# @return [Adornment::Core::PolicyModule] the policy module to include
|
|
34
|
+
# @raise [ArgumentError] for positional arguments, unknown policies or invalid values
|
|
35
|
+
def [](*args, **policies)
|
|
36
|
+
unless args.empty?
|
|
37
|
+
raise ArgumentError, "Adornment::Annotation[...] takes policies as keywords " \
|
|
38
|
+
"(#{Core::Policies::KEYS.map { |k| "#{k}:" }.join(', ')}), got #{args.map(&:inspect).join(', ')}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Core::PolicyModule.new(policies)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def append_features(base)
|
|
47
|
+
return super if base.is_a?(Core::PolicyModule)
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "use include Adornment::Annotation[...] (policies, or [] for the defaults), " \
|
|
50
|
+
"not bare Adornment::Annotation"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def prepend_features(_base)
|
|
54
|
+
raise ArgumentError, "use include Adornment::Annotation[...], not prepend"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extend_object(_base)
|
|
58
|
+
raise ArgumentError, "use include Adornment::Annotation[...], not extend"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Attachment-time check, called with the {Core::Site} the annotation is about to attach to
|
|
63
|
+
# (once per annotation; every annotation of one definition gets the same site, with the
|
|
64
|
+
# complete proposed set). Read and raise; never mutate. A `StandardError` raised here is
|
|
65
|
+
# re-raised as {Adornment::DefinitionError} (its `cause`) and nothing is attached. Called
|
|
66
|
+
# again for every attachment, so it must tolerate repeats. The default does nothing.
|
|
67
|
+
#
|
|
68
|
+
# @param site [Adornment::Core::Site]
|
|
69
|
+
# @return [void]
|
|
70
|
+
def validate(site); end
|
|
71
|
+
|
|
72
|
+
# @yieldparam field [Symbol]
|
|
73
|
+
# @yieldparam value [Object]
|
|
74
|
+
# @yieldreturn [Array(Object, Object)] a key-value pair, as for `Hash#to_h` with a block
|
|
75
|
+
# @return [Hash{Symbol => Object}] the fields (instance variables without `@`), in the order
|
|
76
|
+
# the initializer set them; with a block, the pairs it returns
|
|
77
|
+
def to_h(&block)
|
|
78
|
+
out = {}
|
|
79
|
+
instance_variables.each { |ivar| out[ivar.name.delete_prefix("@").to_sym] = instance_variable_get(ivar) }
|
|
80
|
+
block ? out.to_h(&block) : out
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Pattern matching on the fields: `in { path: String => path }`.
|
|
84
|
+
# @param _keys [Array<Symbol>, nil] ignored: every field is returned
|
|
85
|
+
# @return [Hash{Symbol => Object}]
|
|
86
|
+
def deconstruct_keys(_keys) = to_h
|
|
87
|
+
|
|
88
|
+
# Same class and equal fields (`==` on each instance variable).
|
|
89
|
+
# @param other [Object]
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def ==(other)
|
|
92
|
+
return true if equal?(other)
|
|
93
|
+
return false unless other.class == self.class
|
|
94
|
+
|
|
95
|
+
ivars = instance_variables
|
|
96
|
+
return false unless ivars.size == other.instance_variables.size
|
|
97
|
+
|
|
98
|
+
ivars.each do |ivar|
|
|
99
|
+
unless other.instance_variable_defined?(ivar) && instance_variable_get(ivar) == other.instance_variable_get(ivar)
|
|
100
|
+
return false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Same class and `eql?` fields: the Hash-key equality that matches {#hash}.
|
|
107
|
+
# @param other [Object]
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def eql?(other)
|
|
110
|
+
return true if equal?(other)
|
|
111
|
+
return false unless other.class == self.class
|
|
112
|
+
|
|
113
|
+
ivars = instance_variables
|
|
114
|
+
return false unless ivars.size == other.instance_variables.size
|
|
115
|
+
|
|
116
|
+
ivars.each do |ivar|
|
|
117
|
+
unless other.instance_variable_defined?(ivar) && instance_variable_get(ivar).eql?(other.instance_variable_get(ivar))
|
|
118
|
+
return false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Integer] from the class and the fields, independent of field order
|
|
125
|
+
def hash = [self.class, to_h].hash
|
|
126
|
+
|
|
127
|
+
# @return [String] `#<TypeName field=value, ...>`
|
|
128
|
+
def inspect
|
|
129
|
+
name = self.class.name || "Adornment::Annotation"
|
|
130
|
+
pairs = instance_variables.map do |ivar|
|
|
131
|
+
"#{ivar.name.delete_prefix('@')}=#{instance_variable_get(ivar).inspect}"
|
|
132
|
+
end
|
|
133
|
+
pairs.empty? ? "#<#{name}>" : "#<#{name} #{pairs.join(', ')}>"
|
|
134
|
+
end
|
|
135
|
+
alias to_s inspect
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
module Core
|
|
139
|
+
# The module `Adornment::Annotation[...]` returns: the explicitly given policies, plus the
|
|
140
|
+
# method annotation marker. Including it in a class makes that class a method annotation type.
|
|
141
|
+
# @api private
|
|
142
|
+
class PolicyModule < Module
|
|
143
|
+
# @return [Hash] the explicitly given policies (frozen)
|
|
144
|
+
attr_reader :overrides
|
|
145
|
+
|
|
146
|
+
# @param policies [Hash] validated by {Policies.validate}
|
|
147
|
+
# @raise [ArgumentError] for unknown policies or invalid values
|
|
148
|
+
def initialize(policies)
|
|
149
|
+
super()
|
|
150
|
+
@overrides = Policies.validate(policies)
|
|
151
|
+
include(Annotation) # passes Annotation's guard: this is a PolicyModule
|
|
152
|
+
|
|
153
|
+
freeze
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [String] e.g. `Adornment::Annotation[repeatable: true]`
|
|
157
|
+
def inspect = "Adornment::Annotation[#{@overrides.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')}]"
|
|
158
|
+
alias to_s inspect
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
# Checks the host, includes the module, then installs the type behavior on the host.
|
|
163
|
+
def append_features(host)
|
|
164
|
+
Policies.check_host!(self, host)
|
|
165
|
+
super
|
|
166
|
+
Policies.install(host)
|
|
167
|
+
host
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def prepend_features(_host)
|
|
171
|
+
raise ArgumentError, "use include #{inspect}, not prepend"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def extend_object(_host)
|
|
175
|
+
raise ArgumentError, "use include #{inspect}, not extend"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Merges and validates method annotation policies. Each policy module stores only its
|
|
180
|
+
# overrides; a type's policy merges the policy modules of its ancestors, least specific first,
|
|
181
|
+
# then applies the defaults. The merged policy is cached on the type and dropped (for the type
|
|
182
|
+
# and its subclasses) whenever a policy module is included.
|
|
183
|
+
# @api private
|
|
184
|
+
module Policies
|
|
185
|
+
# The policy names `Annotation[...]` accepts.
|
|
186
|
+
KEYS = %i[repeatable targets].freeze
|
|
187
|
+
# The method kinds a type may target.
|
|
188
|
+
SIDES = %i[instance singleton].freeze
|
|
189
|
+
# The policy of a type that sets nothing.
|
|
190
|
+
DEFAULTS = { repeatable: false, targets: SIDES }.freeze
|
|
191
|
+
# The instance variable caching a type's merged policy.
|
|
192
|
+
IVAR = :@__adornment_policy__
|
|
193
|
+
|
|
194
|
+
@lock = Mutex.new
|
|
195
|
+
|
|
196
|
+
class << self
|
|
197
|
+
# @param policies [Hash] `repeatable:` and/or `targets:`
|
|
198
|
+
# @return [Hash] the validated, frozen overrides
|
|
199
|
+
# @raise [ArgumentError] for unknown keys, a non-boolean `repeatable:`, or `targets:`
|
|
200
|
+
# that is not a non-empty subset of {SIDES}
|
|
201
|
+
def validate(policies)
|
|
202
|
+
unknown = policies.keys - KEYS
|
|
203
|
+
unless unknown.empty?
|
|
204
|
+
raise ArgumentError, "unknown annotation polic(y/ies) #{unknown.map { |k| "#{k}:" }.join(', ')}; " \
|
|
205
|
+
"allowed: #{KEYS.map { |k| "#{k}:" }.join(', ')}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
out = {}
|
|
209
|
+
policies.each { |key, value| out[key] = key == :repeatable ? boolean(value) : sides(value) }
|
|
210
|
+
out.freeze
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @param type [Class] an annotation type
|
|
214
|
+
# @return [Hash] the type's merged policy (frozen), computed once and cached on the type
|
|
215
|
+
# (a frozen type cannot hold the cache, so it is merged on every read)
|
|
216
|
+
def of(type)
|
|
217
|
+
cached = type.instance_variable_get(IVAR)
|
|
218
|
+
return cached if cached
|
|
219
|
+
|
|
220
|
+
policy = merged(type.ancestors.grep(PolicyModule))
|
|
221
|
+
type.frozen? ? policy : type.instance_variable_set(IVAR, policy)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Checks an inclusion into `host` before Ruby includes it: a plain class.
|
|
225
|
+
# @param mod [PolicyModule] the module being included (for the message)
|
|
226
|
+
# @param host [Module]
|
|
227
|
+
# @return [void]
|
|
228
|
+
# @raise [ArgumentError] for a module, a `Data` or a `Struct` subclass
|
|
229
|
+
def check_host!(mod, host)
|
|
230
|
+
return if host.is_a?(Class) && !(host < Data) && !(host < Struct)
|
|
231
|
+
|
|
232
|
+
raise ArgumentError, "#{mod.inspect} can only be included in a plain class " \
|
|
233
|
+
"(fields are instance variables set by initialize), not #{host.inspect}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Installs the class behavior after the inclusion and drops cached policies below `host`.
|
|
237
|
+
# @param host [Class] the new (or re-configured) annotation type
|
|
238
|
+
# @return [void]
|
|
239
|
+
def install(host)
|
|
240
|
+
@lock.synchronize { forget(host) }
|
|
241
|
+
host.extend(TypeMethods)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Applies the freezing rule to an initialized instance: every instance variable is copied
|
|
245
|
+
# and deep-frozen, then the instance is frozen. An instance its initializer froze already
|
|
246
|
+
# is kept as it is.
|
|
247
|
+
# @param instance [Adornment::Annotation]
|
|
248
|
+
# @return [Adornment::Annotation] `instance`, frozen
|
|
249
|
+
def seal(instance)
|
|
250
|
+
return instance if instance.frozen?
|
|
251
|
+
|
|
252
|
+
instance.instance_variables.each do |ivar|
|
|
253
|
+
value = instance.instance_variable_get(ivar)
|
|
254
|
+
copied = Immutable.copy(value)
|
|
255
|
+
instance.instance_variable_set(ivar, copied) unless copied.equal?(value)
|
|
256
|
+
end
|
|
257
|
+
instance.freeze
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
# Drops the cached policy of `type` and of its subclasses (recursively).
|
|
263
|
+
def forget(type)
|
|
264
|
+
type.remove_instance_variable(IVAR) if type.instance_variable_defined?(IVAR) && !type.frozen?
|
|
265
|
+
type.subclasses.each { |sub| forget(sub) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Policy modules nearest first => the merged, defaulted policy.
|
|
269
|
+
def merged(modules)
|
|
270
|
+
out = {}
|
|
271
|
+
modules.reverse_each { |m| out.merge!(m.overrides) }
|
|
272
|
+
DEFAULTS.merge(out).freeze
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @return [Boolean] `value`, when it is true or false
|
|
276
|
+
def boolean(value)
|
|
277
|
+
return value if [true, false].include?(value)
|
|
278
|
+
|
|
279
|
+
raise ArgumentError, "repeatable: must be true or false, got #{value.inspect}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @return [Array<Symbol>] the frozen, de-duplicated sides `value` names
|
|
283
|
+
def sides(value)
|
|
284
|
+
list = Array(value).map { |t| t.respond_to?(:to_sym) ? t.to_sym : t }.uniq
|
|
285
|
+
if list.empty? || !(list - SIDES).empty?
|
|
286
|
+
raise ArgumentError, "targets: must be a non-empty subset of #{SIDES.inspect}, got #{value.inspect}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
list.freeze
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Class-level construction and readers of annotation types.
|
|
295
|
+
# @api private
|
|
296
|
+
module TypeMethods
|
|
297
|
+
# Ruby's own construction (the initializer's positional and keyword parameters), then the
|
|
298
|
+
# freezing rule ({Policies.seal}).
|
|
299
|
+
# @return [Adornment::Annotation] frozen
|
|
300
|
+
def new(...) = Policies.seal(super)
|
|
301
|
+
|
|
302
|
+
# @return [String] the last segment of the constant name (`Traced` for `Obs::Traced`)
|
|
303
|
+
def short_name = name ? name.split("::").last : inspect
|
|
304
|
+
|
|
305
|
+
# @return [Boolean] whether several annotations of this type may annotate one method
|
|
306
|
+
def repeatable? = adornment_policy[:repeatable]
|
|
307
|
+
|
|
308
|
+
# @return [Array<Symbol>] the method kinds (`:instance`, `:singleton`) the type may annotate
|
|
309
|
+
def targets = adornment_policy[:targets]
|
|
310
|
+
|
|
311
|
+
# @api private
|
|
312
|
+
# @return [Hash] the merged policy
|
|
313
|
+
def adornment_policy = @__adornment_policy__ || Policies.of(self)
|
|
314
|
+
|
|
315
|
+
# Not a constructor for method annotations: they are declared above the method.
|
|
316
|
+
# @param args [Array] shown in the error as the call form
|
|
317
|
+
# @param kwargs [Hash] shown in the error as the call form
|
|
318
|
+
# @return [void] never returns
|
|
319
|
+
# @raise [TargetError] always, naming the call form (`Get["/x"]` → `Get("/x")`)
|
|
320
|
+
def [](*args, **kwargs)
|
|
321
|
+
raise TargetError, "#{short_name} is a method annotation; write it as a call above the method: " \
|
|
322
|
+
"`#{short_name}(#{Core::Utils.render_arguments(args, kwargs)})`"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Adornment
|
|
4
|
+
module Core
|
|
5
|
+
# The anonymous module returned by `Adornment[...]`. Including it activates the includer
|
|
6
|
+
# (`extend Target`) and defines its declaration methods (`use_annotations`).
|
|
7
|
+
class Base < Module
|
|
8
|
+
# @api private
|
|
9
|
+
# @param types [Array<Class>] annotation types
|
|
10
|
+
# @param aliases [Hash{Symbol => Class}] declaration method name => annotation type
|
|
11
|
+
# @raise [ArgumentError] for a class that is not an annotation type
|
|
12
|
+
def initialize(types, aliases)
|
|
13
|
+
super()
|
|
14
|
+
types.each { |t| Core::Utils.check_type!(t) }
|
|
15
|
+
aliases.each_value { |t| Core::Utils.check_type!(t) }
|
|
16
|
+
@types = types.dup.freeze
|
|
17
|
+
@aliases = aliases.transform_keys(&:to_sym).freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [String] e.g. `Adornment[Traced, Cache: Cached]`
|
|
21
|
+
def inspect
|
|
22
|
+
parts = @types.map { |t| t.name || t.inspect } + @aliases.map { |k, t| "#{k}: #{t.name || t.inspect}" }
|
|
23
|
+
"Adornment[#{parts.join(', ')}]"
|
|
24
|
+
end
|
|
25
|
+
alias to_s inspect
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Activates the includer and defines its declaration methods.
|
|
30
|
+
def included(base)
|
|
31
|
+
super
|
|
32
|
+
base.extend(Target)
|
|
33
|
+
base.use_annotations(*@types, **@aliases)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extend_object(_base)
|
|
37
|
+
raise ArgumentError, "extend #{inspect} is not supported; use include #{inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def prepend_features(_base)
|
|
41
|
+
raise ArgumentError, "prepend #{inspect} is not supported; use include #{inspect}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Owner API for declaring: `extend Adornment::Core::Target`, then `use_annotations`.
|
|
46
|
+
# `include Adornment[...]` does both in one line. Extending activates the owner.
|
|
47
|
+
module Target
|
|
48
|
+
# Activation: the owner is registered for discovery, gets the read-only {Reflection} API,
|
|
49
|
+
# the definition hooks (once per ancestry: a subclass of an activated class inherits them)
|
|
50
|
+
# and, for a module, {Core::Propagation} to pass reflection on to its includers. The owner's
|
|
51
|
+
# state is created on first use.
|
|
52
|
+
# @api private
|
|
53
|
+
# @param base [Module] the owner being activated
|
|
54
|
+
# @raise [ArgumentError] for a singleton class or anything that is not a class or module
|
|
55
|
+
def self.extended(base)
|
|
56
|
+
super
|
|
57
|
+
unless base.is_a?(Module) && !base.singleton_class?
|
|
58
|
+
raise ArgumentError, "Adornment can only activate classes and modules, not #{base.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Core::Registry.register(base) # also after an unregister
|
|
62
|
+
singleton = base.singleton_class
|
|
63
|
+
base.extend(Reflection) unless singleton.include?(Reflection)
|
|
64
|
+
singleton.prepend(Core::Hooks) unless singleton.include?(Core::Hooks)
|
|
65
|
+
singleton.prepend(Core::Propagation) unless base.is_a?(Class) || singleton.include?(Core::Propagation)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Defines one declaration method per type, named after the last segment of the type's
|
|
69
|
+
# constant name, or after the alias key: `use_annotations Traced, Cache: MyLib::Cached`.
|
|
70
|
+
#
|
|
71
|
+
# @param types [Array<Class>] method annotation types
|
|
72
|
+
# @param aliases [Hash{Symbol => Class}] declaration method name => type
|
|
73
|
+
# @return [nil]
|
|
74
|
+
# @raise [ArgumentError] for anonymous types without an alias, non-annotation types, or a
|
|
75
|
+
# name that collides with a singleton method not defined by Adornment
|
|
76
|
+
def use_annotations(*types, **aliases)
|
|
77
|
+
named = types.to_h { |t| [Core::Utils.declaration_name(t), t] }.merge(aliases.transform_keys(&:to_sym))
|
|
78
|
+
named.each_value { |type| Core::Utils.check_type!(type) }
|
|
79
|
+
named.each_key do |name|
|
|
80
|
+
next unless respond_to?(name, true) # Check for collisions with other methods
|
|
81
|
+
next if method(name).owner.is_a?(AnnotationMethods) # Check if the method is defined by Adornment
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "#{inspect}.#{name} already exists and was not defined by Adornment; " \
|
|
84
|
+
"rename the declaration method with an alias (#{name.to_s.downcase}_alias: Type)"
|
|
85
|
+
end
|
|
86
|
+
annotation_methods = Core::AnnotationMethods.for(self)
|
|
87
|
+
named.each { |name, type| annotation_methods.add(name, type) }
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Declares annotation instances for the next method definition, without declaration methods:
|
|
92
|
+
# `adorn Route.new("/users")`, `adorn ADMIN_ONLY`, `adorn first, second`. Only instances:
|
|
93
|
+
# build them with `Type.new(...)`.
|
|
94
|
+
#
|
|
95
|
+
# @param annotations [Array<Adornment::Annotation>]
|
|
96
|
+
# @return [nil]
|
|
97
|
+
# @raise [ArgumentError] with no arguments, or for a type or any other non-instance
|
|
98
|
+
# @raise [Adornment::DuplicateAnnotation] for a second non-repeatable annotation of one type
|
|
99
|
+
# @raise [Adornment::LeftoverAnnotations] when another owner still has pending declarations
|
|
100
|
+
def adorn(*annotations)
|
|
101
|
+
raise ArgumentError, "adorn needs at least one annotation instance" if annotations.empty?
|
|
102
|
+
|
|
103
|
+
location = caller_locations(1, 1).first
|
|
104
|
+
annotations.each { |annotation| Core::Attachment.declare(self, annotation, location) }
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Raises if this owner has declarations (in the current fiber) that never attached, and
|
|
109
|
+
# clears them.
|
|
110
|
+
#
|
|
111
|
+
# @return [nil]
|
|
112
|
+
# @raise [Adornment::LeftoverAnnotations]
|
|
113
|
+
def verify_adornments! = Core::Attachment.verify(self)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Adornment
|
|
4
|
+
module Core
|
|
5
|
+
# Heuristic lint for bare annotation constants (loaded on first use).
|
|
6
|
+
#
|
|
7
|
+
# A line holding only `Traced` inside a class body is a constant read: it declares nothing. The
|
|
8
|
+
# lint parses source files with Prism and flags statements directly in a `class` or `module`
|
|
9
|
+
# body that consist only of a constant read or constant path whose text matches a declaration
|
|
10
|
+
# method or type registered on that owner. It evaluates nothing and cannot know intent. A
|
|
11
|
+
# `# adornment:ignore` comment on the statement's line suppresses its diagnostic.
|
|
12
|
+
module Lint
|
|
13
|
+
# A comment on a statement's line that suppresses its diagnostic.
|
|
14
|
+
IGNORE = /#\s*adornment:ignore\b/
|
|
15
|
+
|
|
16
|
+
# The LoadError message when Prism is not available.
|
|
17
|
+
PRISM_MISSING = "the adornment lint reads source with Prism: Ruby 3.3+ ships it as a default gem; " \
|
|
18
|
+
"on other Rubies add gem \"prism\" to your Gemfile"
|
|
19
|
+
|
|
20
|
+
# One possible mistake.
|
|
21
|
+
#
|
|
22
|
+
# @!attribute [r] path
|
|
23
|
+
# @return [String]
|
|
24
|
+
# @!attribute [r] line
|
|
25
|
+
# @return [Integer]
|
|
26
|
+
# @!attribute [r] owner
|
|
27
|
+
# @return [String] the enclosing class or module as written in the source
|
|
28
|
+
# @!attribute [r] constant
|
|
29
|
+
# @return [String] the flagged text
|
|
30
|
+
Diagnostic = Data.define(:path, :line, :owner, :constant) do
|
|
31
|
+
# @return [String]
|
|
32
|
+
def message
|
|
33
|
+
"`#{constant}` alone is a constant read and declares nothing; did you mean `#{constant}()`? " \
|
|
34
|
+
"(add `# adornment:ignore` if intended)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
def to_s = "#{path}:#{line}: in #{owner}: #{message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Lints the files that define `targets`: owners are mapped to their source files, Strings
|
|
43
|
+
# are file paths; every file is parsed once.
|
|
44
|
+
# @param targets [Array<Module, String>] registered owners or file paths
|
|
45
|
+
# @return [Array<Diagnostic>]
|
|
46
|
+
# @raise [LoadError] when Prism cannot be loaded
|
|
47
|
+
def run(targets)
|
|
48
|
+
load_prism
|
|
49
|
+
owners = Core::Registry.owners
|
|
50
|
+
by_name = owners.select(&:name).to_h { |o| [o.name, o] }
|
|
51
|
+
fallback = owners.flat_map { |o| names_for(o) }.uniq
|
|
52
|
+
files = targets.flat_map { |t| t.is_a?(Module) ? files_for(t) : [File.expand_path(t.to_s)] }.uniq
|
|
53
|
+
files.select { |f| File.file?(f) }.flat_map { |f| lint_file(f, by_name, fallback) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Requires Prism unless it is loaded already.
|
|
57
|
+
# @api private
|
|
58
|
+
# @return [void]
|
|
59
|
+
# @raise [LoadError] with {PRISM_MISSING}
|
|
60
|
+
def load_prism
|
|
61
|
+
return if defined?(::Prism.parse_file)
|
|
62
|
+
|
|
63
|
+
require "prism"
|
|
64
|
+
rescue LoadError
|
|
65
|
+
raise LoadError, PRISM_MISSING
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Files that define `owner`: its constant location and its methods' source locations.
|
|
69
|
+
# Sources that are not files (eval strings) are skipped.
|
|
70
|
+
# @api private
|
|
71
|
+
# @param owner [Module]
|
|
72
|
+
# @return [Array<String>] existing file paths
|
|
73
|
+
def files_for(owner)
|
|
74
|
+
paths = []
|
|
75
|
+
if owner.name
|
|
76
|
+
loc = begin
|
|
77
|
+
Object.const_source_location(owner.name)
|
|
78
|
+
rescue NameError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
paths << loc[0] if loc && !loc.empty?
|
|
82
|
+
end
|
|
83
|
+
[owner, owner.singleton_class].each do |mod|
|
|
84
|
+
(mod.instance_methods(false) + mod.private_instance_methods(false)).each do |name|
|
|
85
|
+
loc = mod.instance_method(name).source_location
|
|
86
|
+
paths << loc[0] if loc
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
paths.uniq.select { |p| File.file?(p) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Declaration method names (aliases included) and full type names of the method annotations
|
|
93
|
+
# usable on `owner`.
|
|
94
|
+
# @api private
|
|
95
|
+
# @param owner [Module]
|
|
96
|
+
# @return [Array<String>]
|
|
97
|
+
def names_for(owner)
|
|
98
|
+
Core::AnnotationMethods.types_of(owner).flat_map { |name, type| [name.to_s, type.name].compact }.uniq
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Parses one file and flags bare constants in class and module bodies.
|
|
102
|
+
# @api private
|
|
103
|
+
# @param path [String]
|
|
104
|
+
# @param by_name [Hash{String => Module}] registered owners by constant name
|
|
105
|
+
# @param fallback [Array<String>] names checked in bodies of unregistered owners
|
|
106
|
+
# @return [Array<Diagnostic>]
|
|
107
|
+
def lint_file(path, by_name, fallback)
|
|
108
|
+
result = Prism.parse_file(path)
|
|
109
|
+
ignored = result.comments.filter_map { |c| c.location.start_line if c.location.slice.match?(IGNORE) }
|
|
110
|
+
found = []
|
|
111
|
+
walk(result.value, [], by_name) do |statements, owner_name, owner|
|
|
112
|
+
names = owner ? names_for(owner) : fallback
|
|
113
|
+
statements.each do |node|
|
|
114
|
+
next unless %i[constant_read_node constant_path_node].include?(node.type)
|
|
115
|
+
next unless names.include?(node.slice)
|
|
116
|
+
next if ignored.include?(node.location.start_line)
|
|
117
|
+
|
|
118
|
+
found << Diagnostic.new(path: path, line: node.location.start_line, owner: owner_name, constant: node.slice)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
found
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Yields each class or module body's statement list with its owner name and registered
|
|
125
|
+
# owner.
|
|
126
|
+
# @api private
|
|
127
|
+
# @param node [Prism::Node]
|
|
128
|
+
# @param nesting [Array<String>] the enclosing constant paths
|
|
129
|
+
# @param by_name [Hash{String => Module}]
|
|
130
|
+
# @yieldparam statements [Array<Prism::Node>]
|
|
131
|
+
# @yieldparam owner_name [String] the nesting as written, joined with `::`
|
|
132
|
+
# @yieldparam owner [Module, nil] the registered owner of that name
|
|
133
|
+
# @return [void]
|
|
134
|
+
def walk(node, nesting, by_name, &)
|
|
135
|
+
return unless node.is_a?(Prism::Node)
|
|
136
|
+
|
|
137
|
+
if %i[class_node module_node].include?(node.type)
|
|
138
|
+
inner = nesting + [node.constant_path.slice]
|
|
139
|
+
full = inner.join("::")
|
|
140
|
+
owner = by_name[full] || by_name[node.constant_path.slice]
|
|
141
|
+
yield(node.body.body, full, owner) if node.body&.type == :statements_node
|
|
142
|
+
node.body&.compact_child_nodes&.each { |child| walk(child, inner, by_name, &) }
|
|
143
|
+
else
|
|
144
|
+
node.compact_child_nodes.each { |child| walk(child, nesting, by_name, &) }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Adornment
|
|
4
|
+
# Base class of every error Adornment raises (except {UnsupportedPlatformError}).
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A non-repeatable annotation type was declared twice for one method.
|
|
8
|
+
class DuplicateAnnotation < Error; end
|
|
9
|
+
|
|
10
|
+
# An annotation was declared for a method kind (instance or singleton) its type does not target.
|
|
11
|
+
class InvalidTarget < Error; end
|
|
12
|
+
|
|
13
|
+
# A method annotation was written with brackets (`Get["/x"]`) instead of as a call above the
|
|
14
|
+
# method (`Get "/x"`).
|
|
15
|
+
class TargetError < Error; end
|
|
16
|
+
|
|
17
|
+
# An annotation's {Adornment::Annotation#validate} rejected the method it was about to attach
|
|
18
|
+
# to. The validator's own exception is the `cause`; the message names the method
|
|
19
|
+
# (`Owner#name` or `Owner.name`) and, when known, the declaration's location, which is also
|
|
20
|
+
# the first backtrace entry. Nothing was attached.
|
|
21
|
+
class DefinitionError < Error; end
|
|
22
|
+
|
|
23
|
+
# Declarations that never attached to a method.
|
|
24
|
+
class LeftoverAnnotations < Error
|
|
25
|
+
# @return [Hash{Module => Array<Core::Pending::PendingDeclaration>}] frozen; leftovers per owner
|
|
26
|
+
attr_reader :leftovers
|
|
27
|
+
|
|
28
|
+
# @param leftovers [Hash{Module => Array<Core::Pending::PendingDeclaration>}]
|
|
29
|
+
# @param reason [String, nil] why they can no longer attach, prepended to the message
|
|
30
|
+
def initialize(leftovers, reason = nil)
|
|
31
|
+
@leftovers = leftovers.transform_values { |list| list.dup.freeze }.freeze
|
|
32
|
+
super(build_message(reason))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @return [String] the reason (if any), then each owner's leftovers with their locations
|
|
38
|
+
def build_message(reason)
|
|
39
|
+
lines = @leftovers.map do |owner, pending|
|
|
40
|
+
items = pending.map { |p| " #{p.annotation.inspect}#{" (declared at #{p.location})" if p.location}" }
|
|
41
|
+
"#{owner.inspect}: #{pending.size} declaration(s) not attached to any method:\n#{items.join("\n")}"
|
|
42
|
+
end
|
|
43
|
+
lines.unshift("#{reason}:") if reason
|
|
44
|
+
lines.join("\n")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The bare-constant lint found possible mistakes and ran with `lint: :error`.
|
|
49
|
+
class LintError < Error
|
|
50
|
+
# @return [Array<Adornment::Core::Lint::Diagnostic>]
|
|
51
|
+
attr_reader :diagnostics
|
|
52
|
+
|
|
53
|
+
# @param diagnostics [Array<Adornment::Core::Lint::Diagnostic>]
|
|
54
|
+
def initialize(diagnostics)
|
|
55
|
+
@diagnostics = diagnostics.dup.freeze
|
|
56
|
+
super(diagnostics.join("\n"))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Raised by `require "adornment/core"` on Ruby engines other than CRuby.
|
|
61
|
+
class UnsupportedPlatformError < LoadError; end
|
|
62
|
+
end
|