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