rigortype 0.1.10 → 0.1.12
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 +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/scope.rbs +22 -0
- metadata +157 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Actionmailer < Rigor::Plugin::Base
|
|
8
|
+
# Walks a parsed file's AST looking for
|
|
9
|
+
# `<MailerClass>.<action>(...)` calls and validates
|
|
10
|
+
# each against the {MailerIndex}. Recognises both:
|
|
11
|
+
#
|
|
12
|
+
# - `UserMailer.welcome(user)` — direct action call
|
|
13
|
+
# (the call returns a `Mail::Message` ready for
|
|
14
|
+
# `.deliver_now` / `.deliver_later`).
|
|
15
|
+
# - `UserMailer.with(user: u).welcome` — parametrized
|
|
16
|
+
# action call. The `.with(...)` call is treated as a
|
|
17
|
+
# pass-through; the action's argument shape is
|
|
18
|
+
# validated on the trailing `.welcome` invocation
|
|
19
|
+
# even though the receiver is a method-call chain
|
|
20
|
+
# rather than a constant.
|
|
21
|
+
#
|
|
22
|
+
# The analyzer is purely syntactic: it does not look
|
|
23
|
+
# at runtime mailer state. Constants that don't appear
|
|
24
|
+
# in the index are silently ignored — the rule has no
|
|
25
|
+
# opinion on non-mailer call shapes.
|
|
26
|
+
module Analyzer
|
|
27
|
+
# `.with(...)` is recognised as a forwarding step:
|
|
28
|
+
# the receiver of `.with(...)` is the mailer class,
|
|
29
|
+
# so the trailing action-method call's class context
|
|
30
|
+
# is the same.
|
|
31
|
+
WITH_METHODS = %i[with].freeze
|
|
32
|
+
|
|
33
|
+
# Ruby method names that ActionMailer reserves on the
|
|
34
|
+
# class itself. We don't validate against these as
|
|
35
|
+
# actions even if a mailer happens to override them
|
|
36
|
+
# — the user almost certainly meant the framework
|
|
37
|
+
# method, not their own action.
|
|
38
|
+
RESERVED_CLASS_METHODS = %i[
|
|
39
|
+
new allocate name superclass class
|
|
40
|
+
deliver_later deliver_now deliver_later! deliver_now!
|
|
41
|
+
mail headers attachments default
|
|
42
|
+
with parameters
|
|
43
|
+
respond_to? respond_to_missing? method_defined?
|
|
44
|
+
public_send send __send__ public_method
|
|
45
|
+
method instance_method methods
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# @param path [String]
|
|
53
|
+
# @param root [Prism::Node]
|
|
54
|
+
# @param mailer_index [MailerIndex]
|
|
55
|
+
# @return [Array<Diagnostic>]
|
|
56
|
+
def diagnose(path:, root:, mailer_index:)
|
|
57
|
+
diagnostics = []
|
|
58
|
+
walk(root) do |call_node|
|
|
59
|
+
class_name = mailer_class_for_call(call_node)
|
|
60
|
+
next if class_name.nil?
|
|
61
|
+
next if RESERVED_CLASS_METHODS.include?(call_node.name)
|
|
62
|
+
|
|
63
|
+
class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
|
|
64
|
+
next if class_entry.nil?
|
|
65
|
+
|
|
66
|
+
action_entry = class_entry.find_action(call_node.name)
|
|
67
|
+
if action_entry.nil?
|
|
68
|
+
# Skip `unknown-action` when the mailer's include
|
|
69
|
+
# set has any unresolved module — the unresolved
|
|
70
|
+
# module may legitimately define the action
|
|
71
|
+
# (gem-shipped concern, dynamically loaded
|
|
72
|
+
# mailer extension). Mirrors the same predicate
|
|
73
|
+
# `rigor-actionpack` uses for unknown-filter-method.
|
|
74
|
+
next if class_entry.unresolved_includes?
|
|
75
|
+
|
|
76
|
+
diagnostics << unknown_action_diagnostic(path, call_node, class_entry)
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
diagnostics << action_call_info(path, call_node, class_entry, action_entry)
|
|
81
|
+
arity_diag = arity_check(path, call_node, class_entry, action_entry)
|
|
82
|
+
diagnostics << arity_diag if arity_diag
|
|
83
|
+
end
|
|
84
|
+
diagnostics
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Walks the tree yielding every CallNode whose receiver
|
|
88
|
+
# resolves (directly or through `.with(...)`) to a
|
|
89
|
+
# constant.
|
|
90
|
+
def walk(node, &)
|
|
91
|
+
return unless node.is_a?(Prism::Node)
|
|
92
|
+
|
|
93
|
+
yield node if node.is_a?(Prism::CallNode) && action_call_candidate?(node)
|
|
94
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def action_call_candidate?(node)
|
|
98
|
+
# Skip anything that doesn't look like a mailer
|
|
99
|
+
# action call: no receiver, or a non-constant /
|
|
100
|
+
# non-`.with(...)` receiver.
|
|
101
|
+
return false if node.receiver.nil?
|
|
102
|
+
|
|
103
|
+
mailer_class_for_call(node) ? true : false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Extracts the mailer class name when the call's
|
|
107
|
+
# receiver is either:
|
|
108
|
+
# - A constant (`UserMailer.welcome(...)`), or
|
|
109
|
+
# - A `.with(...)` call whose receiver is a constant
|
|
110
|
+
# (`UserMailer.with(user: u).welcome`).
|
|
111
|
+
def mailer_class_for_call(node)
|
|
112
|
+
receiver = node.receiver
|
|
113
|
+
case receiver
|
|
114
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
115
|
+
constant_receiver_name(receiver)
|
|
116
|
+
when Prism::CallNode
|
|
117
|
+
return nil unless WITH_METHODS.include?(receiver.name)
|
|
118
|
+
|
|
119
|
+
constant_receiver_name(receiver.receiver)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def action_call_info(path, call_node, class_entry, action_entry)
|
|
124
|
+
location = call_node.location
|
|
125
|
+
Diagnostic.new(
|
|
126
|
+
path: path,
|
|
127
|
+
line: location.start_line,
|
|
128
|
+
column: location.start_column + 1,
|
|
129
|
+
severity: :info,
|
|
130
|
+
rule: "mailer-call",
|
|
131
|
+
message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
|
|
132
|
+
"matches mailer action (arity #{action_entry.arity_label})"
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def arity_check(path, call_node, class_entry, action_entry)
|
|
137
|
+
args = call_node.arguments&.arguments || []
|
|
138
|
+
actual = args.size
|
|
139
|
+
return nil if action_entry.accepts?(actual)
|
|
140
|
+
|
|
141
|
+
# Trailing keyword-hash relaxation. `Notify.foo(uid,
|
|
142
|
+
# gid, success_count: 5)` is 3 positional args from
|
|
143
|
+
# Prism's perspective (2 + a KeywordHashNode); the
|
|
144
|
+
# action's `def foo(uid, gid, success_count:)` has
|
|
145
|
+
# arity 2. When the call's trailing arg is a kwargs
|
|
146
|
+
# hash, allow `(actual - 1) ≤ max_arity` so kwargs-
|
|
147
|
+
# carrying calls don't surface as wrong-arity.
|
|
148
|
+
return nil if args.last.is_a?(Prism::KeywordHashNode) && action_entry.accepts?(actual - 1)
|
|
149
|
+
|
|
150
|
+
location = call_node.location
|
|
151
|
+
Diagnostic.new(
|
|
152
|
+
path: path,
|
|
153
|
+
line: location.start_line,
|
|
154
|
+
column: location.start_column + 1,
|
|
155
|
+
severity: :error,
|
|
156
|
+
rule: "wrong-arity",
|
|
157
|
+
message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
|
|
158
|
+
"expects #{action_entry.arity_label} argument(s), got #{actual}"
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def unknown_action_diagnostic(path, call_node, class_entry)
|
|
163
|
+
location = call_node.location
|
|
164
|
+
known = class_entry.actions.keys.sort.join(", ")
|
|
165
|
+
known_part = known.empty? ? "no actions defined" : "known actions: #{known}"
|
|
166
|
+
Diagnostic.new(
|
|
167
|
+
path: path,
|
|
168
|
+
line: location.start_line,
|
|
169
|
+
column: location.start_column + 1,
|
|
170
|
+
severity: :error,
|
|
171
|
+
rule: "unknown-action",
|
|
172
|
+
message: "`#{class_entry.class_name}.#{call_node.name}` is not a defined " \
|
|
173
|
+
"mailer action (#{known_part})"
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def constant_receiver_name(node)
|
|
178
|
+
case node
|
|
179
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
180
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def constant_path_name(node)
|
|
185
|
+
parts = []
|
|
186
|
+
current = node
|
|
187
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
188
|
+
parts.unshift(current.name.to_s)
|
|
189
|
+
current = current.parent
|
|
190
|
+
end
|
|
191
|
+
case current
|
|
192
|
+
when nil then "::#{parts.join('::')}"
|
|
193
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "mailer_index"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Actionmailer < Rigor::Plugin::Base
|
|
10
|
+
# Walks the configured mailer-search paths via the
|
|
11
|
+
# plugin's `IoBoundary`, parses each `.rb` file with
|
|
12
|
+
# Prism, and collects classes whose immediate superclass
|
|
13
|
+
# is one of the configured base classes.
|
|
14
|
+
#
|
|
15
|
+
# For each discovered class, the discoverer:
|
|
16
|
+
#
|
|
17
|
+
# - Reads the instance-side `def` nodes and records each
|
|
18
|
+
# one as an action method, capturing the arity envelope.
|
|
19
|
+
# - For each (class, action) pair, attempts to read every
|
|
20
|
+
# candidate view template under
|
|
21
|
+
# `app/views/<mailer_underscore>/<action>.{html,text}.erb`.
|
|
22
|
+
# Existing templates feed the IoBoundary's cache
|
|
23
|
+
# descriptor (so the cache invalidates when the
|
|
24
|
+
# template changes); missing templates are recorded so
|
|
25
|
+
# the plugin can surface a diagnostic on the mailer
|
|
26
|
+
# class definition.
|
|
27
|
+
#
|
|
28
|
+
# Limitations (intentional for v0.1.0):
|
|
29
|
+
#
|
|
30
|
+
# - Direct-superclass match only. `class CustomerMailer
|
|
31
|
+
# < BaseMailer` where `BaseMailer < ApplicationMailer`
|
|
32
|
+
# is NOT discovered. Add `BaseMailer` to
|
|
33
|
+
# `mailer_base_classes` if needed.
|
|
34
|
+
# - Action methods are read from the syntactic instance-
|
|
35
|
+
# side `def` list. Methods built via `define_method`,
|
|
36
|
+
# `private`, or non-action helpers (e.g. methods
|
|
37
|
+
# starting with `_`) are out of scope. The discoverer
|
|
38
|
+
# filters obvious non-actions (`initialize`, names
|
|
39
|
+
# prefixed with `_`).
|
|
40
|
+
# - Adding a brand-new view file under
|
|
41
|
+
# `app/views/<mailer>/` will NOT invalidate the
|
|
42
|
+
# cached index until something the mailer file
|
|
43
|
+
# touches changes. This is the standard read-tracking
|
|
44
|
+
# trade-off — only files we successfully read get
|
|
45
|
+
# digested into the descriptor.
|
|
46
|
+
class MailerDiscoverer
|
|
47
|
+
DEFAULT_VIEWS_ROOT = "app/views"
|
|
48
|
+
VIEW_FORMATS = %w[html text].freeze
|
|
49
|
+
VIEW_EXTENSIONS = %w[erb haml slim].freeze
|
|
50
|
+
|
|
51
|
+
# @param io_boundary [Rigor::Plugin::IoBoundary]
|
|
52
|
+
# @param search_paths [Array<String>] absolute or
|
|
53
|
+
# project-relative paths to scan for mailers.
|
|
54
|
+
# @param base_classes [Array<String>] direct
|
|
55
|
+
# superclasses that mark a class as a mailer.
|
|
56
|
+
# @param views_root [String] absolute or project-
|
|
57
|
+
# relative path to the views directory (typically
|
|
58
|
+
# `app/views`).
|
|
59
|
+
def initialize(io_boundary:, search_paths:, base_classes:, views_root: DEFAULT_VIEWS_ROOT)
|
|
60
|
+
@io_boundary = io_boundary
|
|
61
|
+
@search_paths = search_paths
|
|
62
|
+
@base_classes = base_classes.to_set
|
|
63
|
+
@views_root = views_root
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [MailerIndex]
|
|
67
|
+
def discover
|
|
68
|
+
# Two-pass: first collect every module's defs (for
|
|
69
|
+
# the include-following step), then build per-class
|
|
70
|
+
# entries that pull in actions from include'd modules.
|
|
71
|
+
# GitLab's `Notify` mailer derives every action from
|
|
72
|
+
# `Emails::*` concerns under `app/mailers/emails/`.
|
|
73
|
+
module_actions = {} # module_fqn => Hash<Symbol, ActionEntry>
|
|
74
|
+
class_visits = [] # collected (class_name, path, def_nodes, includes)
|
|
75
|
+
|
|
76
|
+
ruby_files_under(@search_paths).each do |path|
|
|
77
|
+
contents = read_safely(path)
|
|
78
|
+
next if contents.nil?
|
|
79
|
+
|
|
80
|
+
tree = Prism.parse(contents).value
|
|
81
|
+
walk_for_mailers(tree, []) do |class_name, def_nodes, includes|
|
|
82
|
+
class_visits << [class_name, path, def_nodes, includes]
|
|
83
|
+
end
|
|
84
|
+
collect_module_actions(tree, [], module_actions)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
entries = class_visits.map do |class_name, path, def_nodes, includes|
|
|
88
|
+
build_class_entry(class_name, path, def_nodes, includes, module_actions)
|
|
89
|
+
end
|
|
90
|
+
MailerIndex.new(entries)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def read_safely(path)
|
|
96
|
+
@io_boundary.read_file(path)
|
|
97
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ruby_files_under(roots)
|
|
102
|
+
roots.flat_map do |root|
|
|
103
|
+
absolute = File.expand_path(root)
|
|
104
|
+
next [] unless File.directory?(absolute)
|
|
105
|
+
|
|
106
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def walk_for_mailers(node, lexical_path, &)
|
|
111
|
+
return if node.nil?
|
|
112
|
+
|
|
113
|
+
case node
|
|
114
|
+
when Prism::ClassNode then visit_class(node, lexical_path, &)
|
|
115
|
+
when Prism::ModuleNode then visit_module(node, lexical_path, &)
|
|
116
|
+
else
|
|
117
|
+
node.compact_child_nodes.each { |child| walk_for_mailers(child, lexical_path, &) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def visit_class(node, lexical_path, &)
|
|
122
|
+
class_local_name = constant_path_name(node.constant_path)
|
|
123
|
+
return if class_local_name.nil?
|
|
124
|
+
|
|
125
|
+
full_name = (lexical_path + [class_local_name]).join("::")
|
|
126
|
+
superclass = constant_path_name(node.superclass) if node.superclass
|
|
127
|
+
if superclass && @base_classes.include?(superclass)
|
|
128
|
+
def_nodes = collect_action_defs(node.body)
|
|
129
|
+
includes = collect_includes(node.body)
|
|
130
|
+
yield full_name, def_nodes, includes
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
inner_path = lexical_path + [class_local_name]
|
|
134
|
+
walk_for_mailers(node.body, inner_path, &) if node.body
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Collects qualified-constant names passed to `include
|
|
138
|
+
# X` calls inside the class body. Used to look up
|
|
139
|
+
# concern-module action definitions (GitLab's
|
|
140
|
+
# `Notify` mailer derives every action from
|
|
141
|
+
# `Emails::Issues`, `Emails::MergeRequests`, etc.).
|
|
142
|
+
def collect_includes(body)
|
|
143
|
+
return [] if body.nil?
|
|
144
|
+
|
|
145
|
+
names = []
|
|
146
|
+
body.compact_child_nodes.each do |node|
|
|
147
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
|
|
148
|
+
|
|
149
|
+
(node.arguments&.arguments || []).each do |arg|
|
|
150
|
+
name = constant_path_name(arg)
|
|
151
|
+
names << name.delete_prefix("::") if name
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
names
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Walks the AST collecting every module's instance-side
|
|
158
|
+
# def nodes by fully-qualified module name. The same
|
|
159
|
+
# `collect_action_defs` filter applies (private /
|
|
160
|
+
# `_`-prefixed / callback-target methods skipped).
|
|
161
|
+
def collect_module_actions(node, lexical_path, accumulator)
|
|
162
|
+
return if node.nil?
|
|
163
|
+
|
|
164
|
+
case node
|
|
165
|
+
when Prism::ModuleNode
|
|
166
|
+
local_name = constant_path_name(node.constant_path)
|
|
167
|
+
return if local_name.nil?
|
|
168
|
+
|
|
169
|
+
full_name = (lexical_path + [local_name.delete_prefix("::")]).join("::")
|
|
170
|
+
if node.body
|
|
171
|
+
def_nodes = collect_action_defs(node.body)
|
|
172
|
+
entries = def_nodes.to_h { |def_node| [def_node.name, build_action_entry(def_node)] }
|
|
173
|
+
accumulator[full_name] = entries unless entries.empty?
|
|
174
|
+
collect_module_actions(node.body, lexical_path + [local_name.delete_prefix("::")], accumulator)
|
|
175
|
+
end
|
|
176
|
+
when Prism::ClassNode
|
|
177
|
+
local_name = constant_path_name(node.constant_path)
|
|
178
|
+
inner = local_name ? lexical_path + [local_name.delete_prefix("::")] : lexical_path
|
|
179
|
+
collect_module_actions(node.body, inner, accumulator) if node.body
|
|
180
|
+
else
|
|
181
|
+
node.compact_child_nodes.each { |child| collect_module_actions(child, lexical_path, accumulator) }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def visit_module(node, lexical_path, &)
|
|
186
|
+
module_local_name = constant_path_name(node.constant_path)
|
|
187
|
+
return if module_local_name.nil?
|
|
188
|
+
|
|
189
|
+
inner_path = lexical_path + [module_local_name]
|
|
190
|
+
walk_for_mailers(node.body, inner_path, &) if node.body
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def constant_path_name(node)
|
|
194
|
+
return nil if node.nil?
|
|
195
|
+
|
|
196
|
+
case node
|
|
197
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
198
|
+
when Prism::ConstantPathNode
|
|
199
|
+
parts = []
|
|
200
|
+
current = node
|
|
201
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
202
|
+
parts.unshift(current.name.to_s)
|
|
203
|
+
current = current.parent
|
|
204
|
+
end
|
|
205
|
+
case current
|
|
206
|
+
when nil then "::#{parts.join('::')}"
|
|
207
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Returns the instance-side `def` nodes that look like
|
|
213
|
+
# mailer actions. Filters non-actions:
|
|
214
|
+
# - `initialize`
|
|
215
|
+
# - methods starting with `_` (Ruby convention for
|
|
216
|
+
# private/internal)
|
|
217
|
+
# - `def self.<name>` (singleton-side)
|
|
218
|
+
# - methods after a bare `private` (or
|
|
219
|
+
# `public` → `private` transition) — these are
|
|
220
|
+
# internal helpers, not actions
|
|
221
|
+
# - methods named as a `private :foo` argument
|
|
222
|
+
# - methods named as a callback target
|
|
223
|
+
# (`before_action :name`, `after_action`,
|
|
224
|
+
# `around_action`)
|
|
225
|
+
#
|
|
226
|
+
# Pre-fix, Mastodon's `AdminMailer#process_params` /
|
|
227
|
+
# `set_instance` / `set_locale` / `set_important_headers!`
|
|
228
|
+
# all surfaced as missing-view because the bare `private`
|
|
229
|
+
# keyword wasn't honoured. ~19 false positives across
|
|
230
|
+
# Mastodon's mailers.
|
|
231
|
+
CALLBACK_DECLARATIONS = %i[before_action after_action around_action].freeze
|
|
232
|
+
private_constant :CALLBACK_DECLARATIONS
|
|
233
|
+
|
|
234
|
+
def collect_action_defs(body)
|
|
235
|
+
return [] if body.nil?
|
|
236
|
+
|
|
237
|
+
private_names, callback_names = collect_visibility_and_callbacks(body)
|
|
238
|
+
visibility = :public
|
|
239
|
+
|
|
240
|
+
body.compact_child_nodes.flat_map do |node|
|
|
241
|
+
visibility = next_visibility(node, visibility)
|
|
242
|
+
next [] unless node.is_a?(Prism::DefNode)
|
|
243
|
+
next [] if node.receiver.is_a?(Prism::SelfNode)
|
|
244
|
+
next [] if node.name == :initialize
|
|
245
|
+
next [] if node.name.to_s.start_with?("_")
|
|
246
|
+
next [] if visibility == :private
|
|
247
|
+
next [] if private_names.include?(node.name)
|
|
248
|
+
next [] if callback_names.include?(node.name)
|
|
249
|
+
|
|
250
|
+
[node]
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# First pass over the class body: collect (a) names
|
|
255
|
+
# passed to `private :foo` / `protected :foo` (explicit
|
|
256
|
+
# visibility-on-existing-method form), and (b) Symbol
|
|
257
|
+
# arguments to callback declarations
|
|
258
|
+
# (`before_action :setup`, etc.).
|
|
259
|
+
def collect_visibility_and_callbacks(body)
|
|
260
|
+
private_names = []
|
|
261
|
+
callback_names = []
|
|
262
|
+
|
|
263
|
+
body.compact_child_nodes.each do |node|
|
|
264
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
265
|
+
|
|
266
|
+
args = (node.arguments&.arguments || []).filter_map do |arg|
|
|
267
|
+
arg.is_a?(Prism::SymbolNode) ? arg.unescaped.to_sym : nil
|
|
268
|
+
end
|
|
269
|
+
next if args.empty?
|
|
270
|
+
|
|
271
|
+
case node.name
|
|
272
|
+
when :private, :protected then private_names.concat(args)
|
|
273
|
+
when *CALLBACK_DECLARATIONS then callback_names.concat(args)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
[private_names.to_set, callback_names.to_set]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Returns the new visibility scope state after observing
|
|
281
|
+
# `node`. Bare `private` / `protected` / `public` switch
|
|
282
|
+
# state; the `private :foo` arg-bearing form does NOT
|
|
283
|
+
# (already handled by `collect_visibility_and_callbacks`).
|
|
284
|
+
def next_visibility(node, current)
|
|
285
|
+
return current unless node.is_a?(Prism::CallNode)
|
|
286
|
+
return current unless node.receiver.nil?
|
|
287
|
+
return current unless (args = node.arguments&.arguments).nil? || args.empty?
|
|
288
|
+
|
|
289
|
+
case node.name
|
|
290
|
+
when :private then :private
|
|
291
|
+
when :protected then :protected
|
|
292
|
+
when :public then :public
|
|
293
|
+
else current
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def build_class_entry(class_name, file_path, def_nodes, includes = [], module_actions = {})
|
|
298
|
+
actions = def_nodes.to_h do |def_node|
|
|
299
|
+
entry = build_action_entry(def_node)
|
|
300
|
+
[entry.method_name, entry]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Merge in actions from include'd modules. The
|
|
304
|
+
# discoverer pre-collected every module's defs as
|
|
305
|
+
# `module_actions` keyed by fully-qualified module
|
|
306
|
+
# name. We resolve each include against that map —
|
|
307
|
+
# tries the full include name first, then walks down
|
|
308
|
+
# the class's lexical chain looking for a nested
|
|
309
|
+
# match (e.g. `Emails::Issues` inside `class Notify`
|
|
310
|
+
# at top-level resolves to top-level `Emails::Issues`).
|
|
311
|
+
# Includes we cannot resolve are silently skipped;
|
|
312
|
+
# the per-mailer `unresolved_includes?` predicate
|
|
313
|
+
# below (consumed by the analyzer) downgrades
|
|
314
|
+
# `unknown-action` to silence when any include is
|
|
315
|
+
# unresolved.
|
|
316
|
+
unresolved_includes = []
|
|
317
|
+
includes.each do |include_name|
|
|
318
|
+
inc_actions = module_actions[include_name]
|
|
319
|
+
if inc_actions.nil?
|
|
320
|
+
unresolved_includes << include_name
|
|
321
|
+
next
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
inc_actions.each do |method_name, entry|
|
|
325
|
+
actions[method_name] ||= entry
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
missing_views = actions.keys.reject { |action| view_exists?(class_name, action) }
|
|
330
|
+
|
|
331
|
+
MailerIndex::ClassEntry.new(
|
|
332
|
+
class_name: class_name,
|
|
333
|
+
file_path: file_path,
|
|
334
|
+
actions: actions,
|
|
335
|
+
missing_views: missing_views,
|
|
336
|
+
unresolved_includes: unresolved_includes.freeze
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def build_action_entry(def_node)
|
|
341
|
+
parameters = def_node.parameters
|
|
342
|
+
location = def_node.name_loc
|
|
343
|
+
|
|
344
|
+
if parameters.nil?
|
|
345
|
+
return MailerIndex::ActionEntry.new(
|
|
346
|
+
method_name: def_node.name,
|
|
347
|
+
min_arity: 0, max_arity: 0,
|
|
348
|
+
def_line: location.start_line,
|
|
349
|
+
def_column: location.start_column + 1
|
|
350
|
+
)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
required_count = (parameters.requireds || []).size
|
|
354
|
+
optional_count = (parameters.optionals || []).size
|
|
355
|
+
rest_present = !parameters.rest.nil?
|
|
356
|
+
|
|
357
|
+
MailerIndex::ActionEntry.new(
|
|
358
|
+
method_name: def_node.name,
|
|
359
|
+
min_arity: required_count,
|
|
360
|
+
max_arity: rest_present ? Float::INFINITY : required_count + optional_count,
|
|
361
|
+
def_line: location.start_line,
|
|
362
|
+
def_column: location.start_column + 1
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Checks whether *any* template under
|
|
367
|
+
# `app/views/<underscore>/<action>.{html,text}.{erb,haml,slim}`
|
|
368
|
+
# exists, by attempting to read each candidate via the
|
|
369
|
+
# IoBoundary. Successful reads are recorded by the
|
|
370
|
+
# boundary; failed reads (missing file or access
|
|
371
|
+
# denied) are swallowed.
|
|
372
|
+
def view_exists?(class_name, action_name)
|
|
373
|
+
views_root_absolute = File.expand_path(@views_root)
|
|
374
|
+
underscore_path = underscore(class_name.delete_prefix("::"))
|
|
375
|
+
mailer_dir = File.join(views_root_absolute, underscore_path)
|
|
376
|
+
|
|
377
|
+
VIEW_FORMATS.any? do |format|
|
|
378
|
+
VIEW_EXTENSIONS.any? do |ext|
|
|
379
|
+
candidate = File.join(mailer_dir, "#{action_name}.#{format}.#{ext}")
|
|
380
|
+
read_safely(candidate)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Convert `Foo::BarMailer` → `foo/bar_mailer`. Mirrors
|
|
386
|
+
# ActiveSupport's String#underscore for ASCII-only
|
|
387
|
+
# constant names; we don't try to be inflector-perfect
|
|
388
|
+
# here.
|
|
389
|
+
def underscore(name)
|
|
390
|
+
name.gsub("::", "/")
|
|
391
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
392
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
393
|
+
.downcase
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Actionmailer < Rigor::Plugin::Base
|
|
6
|
+
# Frozen catalogue of discovered Mailer classes, each
|
|
7
|
+
# carrying:
|
|
8
|
+
#
|
|
9
|
+
# - the action methods it defines (arity envelope per
|
|
10
|
+
# action; same shape as `rigor-activejob`'s
|
|
11
|
+
# `JobIndex::Entry`)
|
|
12
|
+
# - the source file path the class was declared in
|
|
13
|
+
# (used to anchor missing-view diagnostics on the
|
|
14
|
+
# mailer file)
|
|
15
|
+
# - the list of `(action, location)` pairs whose view
|
|
16
|
+
# templates are missing from `app/views/`
|
|
17
|
+
class MailerIndex
|
|
18
|
+
ActionEntry = Data.define(:method_name, :min_arity, :max_arity, :def_line, :def_column) do
|
|
19
|
+
def arity_label
|
|
20
|
+
return "#{min_arity}+" if max_arity == Float::INFINITY
|
|
21
|
+
return min_arity.to_s if min_arity == max_arity
|
|
22
|
+
|
|
23
|
+
"#{min_arity}..#{max_arity}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def accepts?(actual)
|
|
27
|
+
actual.between?(min_arity, max_arity)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
ClassEntry = Data.define(:class_name, :file_path, :actions, :missing_views, :unresolved_includes) do
|
|
32
|
+
def find_action(method_name)
|
|
33
|
+
actions[method_name.to_sym]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True when the mailer `include`s a module whose
|
|
37
|
+
# source we couldn't index (typically a gem-shipped
|
|
38
|
+
# concern that defines additional mailer actions).
|
|
39
|
+
# Analyzer downgrades `unknown-action` to silence in
|
|
40
|
+
# this case — the unresolved module may legitimately
|
|
41
|
+
# provide the action.
|
|
42
|
+
def unresolved_includes?
|
|
43
|
+
!unresolved_includes.empty?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :entries
|
|
48
|
+
|
|
49
|
+
def initialize(entries)
|
|
50
|
+
@entries = entries.freeze
|
|
51
|
+
@by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [ClassEntry, nil]
|
|
56
|
+
def find(class_name)
|
|
57
|
+
@by_name[class_name.to_s]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def known?(class_name)
|
|
61
|
+
@by_name.key?(class_name.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param file_path [String] absolute path of a mailer
|
|
65
|
+
# file (canonicalised — see plugin entry's
|
|
66
|
+
# `harvest`)
|
|
67
|
+
# @return [ClassEntry, nil]
|
|
68
|
+
def find_by_file(file_path)
|
|
69
|
+
@entries.find { |entry| entry.file_path == file_path }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def empty?
|
|
73
|
+
@entries.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def size
|
|
77
|
+
@entries.size
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def names
|
|
81
|
+
@by_name.keys
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|