rigortype 0.1.11 → 0.1.13
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/check_rules.rb +96 -3
- 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/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli/skill_command.rb +170 -0
- data/lib/rigor/cli.rb +37 -1
- data/lib/rigor/configuration/severity_profile.rb +3 -0
- 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 +195 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
- 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 +64 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
- data/sig/rigor/scope.rbs +23 -0
- data/skills/rigor-baseline-reduce/SKILL.md +100 -0
- data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
- data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
- data/skills/rigor-plugin-author/SKILL.md +95 -0
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
- data/skills/rigor-project-init/SKILL.md +129 -0
- data/skills/rigor-project-init/references/01-detect.md +101 -0
- data/skills/rigor-project-init/references/02-configure.md +185 -0
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
- data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
- metadata +22 -1
|
@@ -22,6 +22,7 @@ module Rigor
|
|
|
22
22
|
# - `resource :name`
|
|
23
23
|
# - `get/post/patch/put/delete "path", to:, as:`
|
|
24
24
|
# - `root to: "..."` / `root "..."`
|
|
25
|
+
# - `scope "path", as: :name do ... end` (with `as:` key)
|
|
25
26
|
# - One level of `namespace :foo do ... end`
|
|
26
27
|
# - One level of nested `resources` (`resources :users
|
|
27
28
|
# do; resources :posts; end`)
|
|
@@ -30,7 +31,7 @@ module Rigor
|
|
|
30
31
|
#
|
|
31
32
|
# Out of scope for v0.1.0 (silent skips):
|
|
32
33
|
#
|
|
33
|
-
# - `scope
|
|
34
|
+
# - `scope path:` / `scope module:` (path/module-only, no `as:`)
|
|
34
35
|
# - Constraints (`constraints: { id: /\d+/ }`)
|
|
35
36
|
# - `mount` / engine routes
|
|
36
37
|
# - `direct(:name) { |obj| ... }`
|
|
@@ -58,14 +59,23 @@ module Rigor
|
|
|
58
59
|
module_function
|
|
59
60
|
|
|
60
61
|
# @param contents [String] raw `config/routes.rb` source
|
|
62
|
+
# @param file_reader [Proc, nil] called with `"name.rb"` to load a
|
|
63
|
+
# draw partial from `config/routes/name.rb`. Returns file contents
|
|
64
|
+
# or nil when the file is absent.
|
|
61
65
|
# @return [HelperTable]
|
|
62
|
-
def parse(contents)
|
|
66
|
+
def parse(contents, file_reader: nil, custom_helpers: [])
|
|
63
67
|
parse_result = Prism.parse(contents)
|
|
64
|
-
return HelperTable.new([]) unless parse_result.errors.empty?
|
|
68
|
+
return HelperTable.new([], custom_helpers: custom_helpers) unless parse_result.errors.empty?
|
|
65
69
|
|
|
66
|
-
context = Context.new
|
|
70
|
+
context = Context.new(file_reader: file_reader)
|
|
67
71
|
interpret(parse_result.value, context)
|
|
68
72
|
|
|
73
|
+
# Apply name-transform alias rules discovered during
|
|
74
|
+
# the walk (the `direct(name.sub(X, Y)) do |...| send(
|
|
75
|
+
# "#{name}_url", ...) end` GitLab pattern — see
|
|
76
|
+
# `collect_alias_rules` below).
|
|
77
|
+
apply_alias_rules(context)
|
|
78
|
+
|
|
69
79
|
# Each helper has both `_path` and `_url` forms.
|
|
70
80
|
paired = context.entries.flat_map do |entry|
|
|
71
81
|
[
|
|
@@ -79,21 +89,98 @@ module Rigor
|
|
|
79
89
|
)
|
|
80
90
|
]
|
|
81
91
|
end
|
|
82
|
-
HelperTable.new(paired)
|
|
92
|
+
HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# For every registered alias rule `(from_str, to_str,
|
|
96
|
+
# arity_delta)`, find every existing entry whose name
|
|
97
|
+
# contains `from_str` and register an aliased entry with
|
|
98
|
+
# `from_str → to_str` substitution. The pattern matches
|
|
99
|
+
# GitLab's shorthand-helper idiom: a loop over registered
|
|
100
|
+
# route names that invokes `direct(new_name) do |project,
|
|
101
|
+
# *args| send("#{name}_url", project&.namespace,
|
|
102
|
+
# project, *args) end` where `new_name = name.sub(FROM,
|
|
103
|
+
# TO)`. The direct block extracts the namespace from
|
|
104
|
+
# `project` and forwards the rest, so the alias's arity
|
|
105
|
+
# is one LESS than the original helper's
|
|
106
|
+
# (`namespace_project_blob_path(namespace_id, project_id,
|
|
107
|
+
# blob_id)` → `project_blob_path(project, blob_id)`).
|
|
108
|
+
# We register each alias under the original entry's
|
|
109
|
+
# arity AND `original - 1` to span both interpretations.
|
|
110
|
+
def apply_alias_rules(context)
|
|
111
|
+
return if context.alias_rules.empty?
|
|
112
|
+
|
|
113
|
+
aliases = []
|
|
114
|
+
context.alias_rules.each do |from, to|
|
|
115
|
+
context.entries.each do |entry|
|
|
116
|
+
next unless entry.name.include?(from)
|
|
117
|
+
|
|
118
|
+
new_name = entry.name.sub(from, to)
|
|
119
|
+
next if new_name == entry.name
|
|
120
|
+
|
|
121
|
+
# Register at original arity AND at arity-1 so the
|
|
122
|
+
# common GitLab `|project, *args|` pattern (which
|
|
123
|
+
# collapses namespace_id + project_id into project)
|
|
124
|
+
# passes the arity check.
|
|
125
|
+
[entry.arity, [entry.arity - 1, 0].max].uniq.each do |arity|
|
|
126
|
+
aliases << HelperTable::Entry.new(
|
|
127
|
+
name: new_name, arity: arity,
|
|
128
|
+
path: entry.path, http_method: entry.http_method,
|
|
129
|
+
action: entry.action
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
context.entries.concat(aliases)
|
|
83
135
|
end
|
|
84
136
|
|
|
85
137
|
# Per-parse mutable accumulator. Tracks the current
|
|
86
138
|
# nesting prefix (namespaces + parent resource) and the
|
|
87
139
|
# entries collected so far.
|
|
88
140
|
class Context
|
|
89
|
-
attr_reader :entries
|
|
141
|
+
attr_reader :entries, :file_reader, :devise_resources, :alias_rules
|
|
90
142
|
|
|
91
|
-
def initialize
|
|
143
|
+
def initialize(file_reader: nil)
|
|
92
144
|
@entries = []
|
|
145
|
+
@file_reader = file_reader
|
|
93
146
|
# Stack of prefix segments. Each entry is one of:
|
|
94
147
|
# - `{ kind: :namespace, name: "admin" }`
|
|
95
148
|
# - `{ kind: :scope, parent: "user", arity_segments: [":user_id"] }`
|
|
149
|
+
# - `{ kind: :as_scope, name: "event", path: "/:event_slug", arity: 1 }`
|
|
96
150
|
@stack = []
|
|
151
|
+
# Devise resource segments (singularised) declared
|
|
152
|
+
# via `devise_for :resource`. Drives the
|
|
153
|
+
# OmniAuth-helper recognition in `HelperTable#omniauth_match?`.
|
|
154
|
+
@devise_resources = []
|
|
155
|
+
# Registered `concern :name do ... end` blocks.
|
|
156
|
+
# Keyed by Symbol name; value is the block's body
|
|
157
|
+
# node. `resources :foo, concerns: :name do ... end`
|
|
158
|
+
# replays the body at the resource's site.
|
|
159
|
+
@concerns = {}
|
|
160
|
+
# `(from_str, to_str)` pairs collected from
|
|
161
|
+
# iterative `direct(name.sub(FROM, TO)) do ... end`
|
|
162
|
+
# patterns. Applied after parsing via
|
|
163
|
+
# `apply_alias_rules` to generate substituted-name
|
|
164
|
+
# aliases for every matching entry — closes
|
|
165
|
+
# GitLab's `namespace_project_*` → `project_*`
|
|
166
|
+
# shorthand idiom.
|
|
167
|
+
@alias_rules = []
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def register_alias_rule(from, to)
|
|
171
|
+
@alias_rules << [from, to]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def record_devise_resource(name)
|
|
175
|
+
@devise_resources << name.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def register_concern(name, body_node)
|
|
179
|
+
@concerns[name.to_sym] = body_node
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def concern_body(name)
|
|
183
|
+
@concerns[name.to_sym]
|
|
97
184
|
end
|
|
98
185
|
|
|
99
186
|
def push_namespace(name)
|
|
@@ -105,7 +192,103 @@ module Rigor
|
|
|
105
192
|
|
|
106
193
|
def push_resource(parent_name)
|
|
107
194
|
singular = singularize(parent_name.to_s)
|
|
108
|
-
@stack.push(kind: :scope, parent: singular,
|
|
195
|
+
@stack.push(kind: :scope, parent: singular, parent_plural: parent_name.to_s,
|
|
196
|
+
arity_segments: [":#{singular}_id"])
|
|
197
|
+
yield
|
|
198
|
+
ensure
|
|
199
|
+
@stack.pop
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# `resource :foo do ... end` (SINGULAR resource) —
|
|
203
|
+
# adds the resource name to the helper prefix and
|
|
204
|
+
# path for nested declarations, but DOESN'T
|
|
205
|
+
# contribute a dynamic `:id` segment (singular
|
|
206
|
+
# resources have no `:id`). So `resource :instance
|
|
207
|
+
# do; resources :domain_blocks; end` generates
|
|
208
|
+
# `instance_domain_blocks_path` (arity 0), not
|
|
209
|
+
# `instance_domain_blocks_path(:id)`.
|
|
210
|
+
#
|
|
211
|
+
# Helper-prefix segment uses the AS-GIVEN name (no
|
|
212
|
+
# singularising — `resource :foo` keeps `foo`).
|
|
213
|
+
def push_singular_resource(parent_name)
|
|
214
|
+
name = parent_name.to_s
|
|
215
|
+
@stack.push(kind: :singular_scope, parent: name, path_segment: "/#{name}")
|
|
216
|
+
yield
|
|
217
|
+
ensure
|
|
218
|
+
@stack.pop
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# `member do ... end` / `collection do ... end` —
|
|
222
|
+
# records the mode so subsequent shorthand HTTP-verb
|
|
223
|
+
# calls (`post :memorialize`) inside the block can
|
|
224
|
+
# derive their helper name from the enclosing
|
|
225
|
+
# resource. The frame also carries the immediate
|
|
226
|
+
# parent's singular / plural names so member /
|
|
227
|
+
# collection actions can pick the correct form
|
|
228
|
+
# (`memorialize_account_path(id)` vs
|
|
229
|
+
# `memorialize_accounts_path`).
|
|
230
|
+
def push_action_block(mode, parent_singular, parent_plural)
|
|
231
|
+
@stack.push(kind: :"#{mode}_block", parent_singular: parent_singular, parent_plural: parent_plural)
|
|
232
|
+
yield
|
|
233
|
+
ensure
|
|
234
|
+
@stack.pop
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# `with_options only: [:index], concerns: :batch do
|
|
238
|
+
# ... end` — every call inside the block inherits
|
|
239
|
+
# these default options (each call's own options
|
|
240
|
+
# override the defaults). `effective_options_for`
|
|
241
|
+
# below merges them in caller-precedence order.
|
|
242
|
+
def push_with_options(defaults)
|
|
243
|
+
@stack.push(kind: :with_options, defaults: defaults)
|
|
244
|
+
yield
|
|
245
|
+
ensure
|
|
246
|
+
@stack.pop
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The merged default-options chain from every
|
|
250
|
+
# enclosing `with_options` frame on the stack
|
|
251
|
+
# (outer-most first; inner frames take precedence).
|
|
252
|
+
# `handle_resources` / `handle_resource` merge this
|
|
253
|
+
# with the node's own option hash so a bare
|
|
254
|
+
# `resources :links` inside `with_options only:
|
|
255
|
+
# [:index], concerns: :batch do ... end` is
|
|
256
|
+
# treated as if it had those options written
|
|
257
|
+
# inline.
|
|
258
|
+
def with_options_defaults
|
|
259
|
+
defaults = {}
|
|
260
|
+
@stack.each do |frame|
|
|
261
|
+
next unless frame[:kind] == :with_options
|
|
262
|
+
|
|
263
|
+
defaults = defaults.merge(frame[:defaults])
|
|
264
|
+
end
|
|
265
|
+
defaults
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Returns the top-most `:scope` frame's singular /
|
|
269
|
+
# plural names, or `nil` when not inside a resources
|
|
270
|
+
# / resource block. Used by `handle_member_or_collection`
|
|
271
|
+
# to push the action-block frame.
|
|
272
|
+
def innermost_resource
|
|
273
|
+
scope_frame = @stack.rfind { |f| f[:kind] == :scope }
|
|
274
|
+
return nil if scope_frame.nil?
|
|
275
|
+
|
|
276
|
+
{ singular: scope_frame[:parent], plural: scope_frame[:parent_plural] || scope_frame[:parent] }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Top-most `:member_block` / `:collection_block`
|
|
280
|
+
# frame, or nil when not in a shorthand action
|
|
281
|
+
# context. `handle_explicit_route` uses this to
|
|
282
|
+
# detect a `post :memorialize` symbol-only call shape.
|
|
283
|
+
def innermost_action_block
|
|
284
|
+
@stack.rfind { |f| %i[member_block collection_block].include?(f[:kind]) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# `scope "/:slug", as: "foo" do ... end` — adds a
|
|
288
|
+
# helper-name prefix without the "parent resource" arity
|
|
289
|
+
# arithmetic that push_resource uses.
|
|
290
|
+
def push_as_scope(name, path, arity_count)
|
|
291
|
+
@stack.push(kind: :as_scope, name: name.to_s, path: path, arity: arity_count)
|
|
109
292
|
yield
|
|
110
293
|
ensure
|
|
111
294
|
@stack.pop
|
|
@@ -118,6 +301,55 @@ module Rigor
|
|
|
118
301
|
segments.map { |segment| "#{segment}_" }.join
|
|
119
302
|
end
|
|
120
303
|
|
|
304
|
+
# The chain of resource-singular segments above the
|
|
305
|
+
# current scope, in order — used to form member-route
|
|
306
|
+
# helper names of the form `<as>_<singular_chain>_path`.
|
|
307
|
+
# Includes nested resources (`resources :projects do;
|
|
308
|
+
# resources :issues do; get 'foo', as: 'bar'; end; end`
|
|
309
|
+
# → `bar_project_issue_path`). Returns `""` outside any
|
|
310
|
+
# `:scope` / `:singular_scope` frame.
|
|
311
|
+
def resource_singular_chain
|
|
312
|
+
segments = @stack.filter_map do |frame|
|
|
313
|
+
case frame[:kind]
|
|
314
|
+
when :scope then frame[:parent]
|
|
315
|
+
when :singular_scope then frame[:parent]
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
segments.empty? ? "" : "#{segments.join('_')}_"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Namespace-only prefix (drops the resource-singular
|
|
322
|
+
# segments that `helper_prefix` includes). Used so that
|
|
323
|
+
# an `:as => 'foo'` inside `namespace :admin do
|
|
324
|
+
# resources :projects do; ...; end; end` generates
|
|
325
|
+
# `admin_foo_project_path` — namespace first, then
|
|
326
|
+
# `:as` action prefix, then singular chain.
|
|
327
|
+
def namespace_only_prefix
|
|
328
|
+
segments = @stack.filter_map do |frame|
|
|
329
|
+
case frame[:kind]
|
|
330
|
+
when :namespace then frame[:name]
|
|
331
|
+
when :as_scope then frame[:name]
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
segments.empty? ? "" : "#{segments.join('_')}_"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# True when we're inside a `resources` or `resource`
|
|
338
|
+
# scope (the AST has pushed a `:scope` or
|
|
339
|
+
# `:singular_scope` frame). Drives member-route
|
|
340
|
+
# helper-name generation in `handle_explicit_route`.
|
|
341
|
+
def inside_resource_scope?
|
|
342
|
+
@stack.any? { |frame| %i[scope singular_scope].include?(frame[:kind]) }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The innermost `:scope` frame (plural resource),
|
|
346
|
+
# carrying `parent` (singular) and `parent_plural`.
|
|
347
|
+
# Used by the inline `:on => :collection` / `:on =>
|
|
348
|
+
# :member` form to derive the helper name.
|
|
349
|
+
def innermost_scope_frame
|
|
350
|
+
@stack.rfind { |f| f[:kind] == :scope }
|
|
351
|
+
end
|
|
352
|
+
|
|
121
353
|
# Path prefix — including the parent's `:user_id`
|
|
122
354
|
# segments for nested resources and the namespace
|
|
123
355
|
# path prefix.
|
|
@@ -129,8 +361,16 @@ module Rigor
|
|
|
129
361
|
# Number of dynamic segments (`:user_id`-style)
|
|
130
362
|
# captured by the parent scope chain. Used to
|
|
131
363
|
# compute helper arity for nested resources.
|
|
364
|
+
# Each nested-resource `:scope` frame contributes 1;
|
|
365
|
+
# each `:as_scope` frame contributes its own arity.
|
|
132
366
|
def parent_segment_count
|
|
133
|
-
@stack.
|
|
367
|
+
@stack.sum do |frame|
|
|
368
|
+
case frame[:kind]
|
|
369
|
+
when :scope then 1
|
|
370
|
+
when :as_scope then frame[:arity]
|
|
371
|
+
else 0
|
|
372
|
+
end
|
|
373
|
+
end
|
|
134
374
|
end
|
|
135
375
|
|
|
136
376
|
private
|
|
@@ -139,13 +379,30 @@ module Rigor
|
|
|
139
379
|
case frame[:kind]
|
|
140
380
|
when :namespace then frame[:name]
|
|
141
381
|
when :scope then frame[:parent]
|
|
382
|
+
when :as_scope
|
|
383
|
+
# An `:as_scope` frame with an empty `:name` is a
|
|
384
|
+
# path-only frame pushed by `scope(path: 'X')`
|
|
385
|
+
# without `:as` — contributes path/arity but no
|
|
386
|
+
# helper-prefix segment.
|
|
387
|
+
frame[:name].to_s.empty? ? nil : frame[:name]
|
|
388
|
+
when :singular_scope then frame[:parent]
|
|
142
389
|
end
|
|
143
390
|
end
|
|
144
391
|
|
|
145
392
|
def frame_path_segments(frame)
|
|
146
393
|
case frame[:kind]
|
|
147
394
|
when :namespace then ["/#{frame[:name]}"]
|
|
148
|
-
when :scope
|
|
395
|
+
when :scope
|
|
396
|
+
# Use the as-given plural when available
|
|
397
|
+
# (`parent_plural` was captured at
|
|
398
|
+
# push_resource time) — the singularize /
|
|
399
|
+
# pluralize round-trip is lossy for irregular
|
|
400
|
+
# forms (`media → medium → medias`) but the
|
|
401
|
+
# original name is always correct.
|
|
402
|
+
plural = frame[:parent_plural] || pluralize(frame[:parent])
|
|
403
|
+
["/#{plural}/:#{frame[:parent]}_id"]
|
|
404
|
+
when :as_scope then frame[:path] ? [frame[:path]] : []
|
|
405
|
+
when :singular_scope then [frame[:path_segment]]
|
|
149
406
|
else []
|
|
150
407
|
end
|
|
151
408
|
end
|
|
@@ -167,23 +424,64 @@ module Rigor
|
|
|
167
424
|
# Redmine hit this 81× across `news_path(id)` calls.
|
|
168
425
|
UNCOUNTABLE = %w[
|
|
169
426
|
equipment information rice money species series fish
|
|
170
|
-
sheep jeans police news
|
|
427
|
+
sheep jeans police news settings
|
|
171
428
|
].to_set.freeze
|
|
172
429
|
private_constant :UNCOUNTABLE
|
|
173
430
|
|
|
431
|
+
# Latin / Greek irregular plurals Rails ships in its
|
|
432
|
+
# default inflector. `media` → `medium` is the
|
|
433
|
+
# dominant Rails-app case (Mastodon's `resources
|
|
434
|
+
# :media, only: [:show]` generates `medium_path(id)`,
|
|
435
|
+
# not `media_path`). Pre-fix `media` was in
|
|
436
|
+
# UNCOUNTABLE which produced `media_path` for both
|
|
437
|
+
# index and show — incorrect.
|
|
438
|
+
IRREGULAR_SINGULARS = {
|
|
439
|
+
"media" => "medium",
|
|
440
|
+
"data" => "datum",
|
|
441
|
+
"criteria" => "criterion",
|
|
442
|
+
"phenomena" => "phenomenon"
|
|
443
|
+
}.freeze
|
|
444
|
+
private_constant :IRREGULAR_SINGULARS
|
|
445
|
+
|
|
174
446
|
def singularize(word)
|
|
447
|
+
return IRREGULAR_SINGULARS[word] if IRREGULAR_SINGULARS.key?(word)
|
|
175
448
|
return word if UNCOUNTABLE.include?(word)
|
|
176
449
|
return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
|
|
177
|
-
|
|
450
|
+
# Rails ships specific `*es$` → `*` rules for
|
|
451
|
+
# `(alias|status)es` and `(bus)es` (these have
|
|
452
|
+
# singular endings that the generic-chomp rules
|
|
453
|
+
# would mis-singularise to `aliase` / `statuse` /
|
|
454
|
+
# `buse`).
|
|
455
|
+
return word.chomp("es") if /(?:alias|status|bus)es\z/.match?(word)
|
|
456
|
+
# Rails only chomps `es` after `x|ch|ss|sh|z` for
|
|
457
|
+
# the generic case (`inflect.singular(/(x|ch|ss|sh)
|
|
458
|
+
# es$/i, '\1')`). A generic `ses` suffix removed
|
|
459
|
+
# `es` and broke `databases` → `databas` (correct:
|
|
460
|
+
# `database`). Restrict to the explicit suffix set.
|
|
461
|
+
return word.chomp("es") if word.end_with?("ches", "shes", "sses", "xes", "zes")
|
|
462
|
+
# Words ending in `ss` are their own singular —
|
|
463
|
+
# Rails' default inflector ships
|
|
464
|
+
# `inflect.singular(/(ss)$/i, '\1')` that preserves
|
|
465
|
+
# the double-s. Mastodon's `resources :custom_css`
|
|
466
|
+
# was singularising to `custom_cs` and producing a
|
|
467
|
+
# bogus `custom_cs_path(id)`.
|
|
468
|
+
return word if word.end_with?("ss")
|
|
178
469
|
return word.chomp("s") if word.end_with?("s")
|
|
179
470
|
|
|
180
471
|
word
|
|
181
472
|
end
|
|
182
473
|
|
|
183
474
|
def pluralize(word)
|
|
475
|
+
return IRREGULAR_SINGULARS.key(word) if IRREGULAR_SINGULARS.value?(word)
|
|
184
476
|
return word if UNCOUNTABLE.include?(word)
|
|
185
|
-
return word if word.end_with?("s")
|
|
477
|
+
return word if word.end_with?("s") && !word.end_with?("ss")
|
|
186
478
|
return "#{word.chomp('y')}ies" if word.end_with?("y") && word.length > 1
|
|
479
|
+
# Words ending in s/sh/ch/x/z take "es" in plural,
|
|
480
|
+
# matching Rails' default inflector. Without this
|
|
481
|
+
# `async_refresh` (singular) pluralised to
|
|
482
|
+
# `async_refreshs`, mismatching the actual
|
|
483
|
+
# `/async_refreshes` URL.
|
|
484
|
+
return "#{word}es" if word.end_with?("ss", "sh", "ch", "x", "z")
|
|
187
485
|
|
|
188
486
|
"#{word}s"
|
|
189
487
|
end
|
|
@@ -203,9 +501,26 @@ module Rigor
|
|
|
203
501
|
def interpret_call(node, context)
|
|
204
502
|
case node.name
|
|
205
503
|
when :draw
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
504
|
+
if node.block
|
|
505
|
+
# `Rails.application.routes.draw do ... end`
|
|
506
|
+
interpret_block_body(node, context)
|
|
507
|
+
else
|
|
508
|
+
# `draw(:admin)` — routing partial at
|
|
509
|
+
# config/routes/{name}.rb.
|
|
510
|
+
load_drawn_routes(node, context)
|
|
511
|
+
end
|
|
512
|
+
when :draw_all
|
|
513
|
+
# `draw_all :user` — from the
|
|
514
|
+
# `action_dispatch-draw_all` gem (used by GitLab
|
|
515
|
+
# FOSS). Same single-file load semantics as `draw`
|
|
516
|
+
# — the gem just allows multiple route-dir search
|
|
517
|
+
# paths (we look in the one we know about).
|
|
518
|
+
# GitLab's `draw_all :user` loads
|
|
519
|
+
# `config/routes/user.rb` containing `devise_for
|
|
520
|
+
# :users, controllers: ...` plus the broader user
|
|
521
|
+
# route catalogue; without this every Devise
|
|
522
|
+
# session helper reads as `unknown-helper`.
|
|
523
|
+
load_drawn_routes(node, context)
|
|
209
524
|
when :namespace
|
|
210
525
|
handle_namespace(node, context)
|
|
211
526
|
when :resources
|
|
@@ -214,7 +529,18 @@ module Rigor
|
|
|
214
529
|
handle_resource(node, context)
|
|
215
530
|
when :root
|
|
216
531
|
handle_root(node, context)
|
|
217
|
-
when :
|
|
532
|
+
when :scope
|
|
533
|
+
handle_scope(node, context)
|
|
534
|
+
when :get, :post, :patch, :put, :delete, :match
|
|
535
|
+
# `match 'login', :to => 'a#b', :as => :signin, :via => [:get, :post]`
|
|
536
|
+
# generates the same helper-name shape as `get` /
|
|
537
|
+
# `post` (first arg = path string, `:as` overrides the
|
|
538
|
+
# derived helper name). The only difference is the
|
|
539
|
+
# HTTP-method set (driven by `:via`); the helper-name
|
|
540
|
+
# generation logic is identical, so reuse the same
|
|
541
|
+
# handler. Redmine relies heavily on `match` for
|
|
542
|
+
# multi-method endpoints (`account/lost_password`,
|
|
543
|
+
# `news/preview`, etc.).
|
|
218
544
|
handle_explicit_route(node, context)
|
|
219
545
|
when :member, :collection
|
|
220
546
|
# Inside a `resources` block, `member do ... end`
|
|
@@ -222,11 +548,150 @@ module Rigor
|
|
|
222
548
|
# routes. Interpreted only when we have a parent
|
|
223
549
|
# scope (otherwise the call is meaningless).
|
|
224
550
|
handle_member_or_collection(node, context)
|
|
551
|
+
when :devise_for
|
|
552
|
+
handle_devise_for(node, context)
|
|
553
|
+
when :use_doorkeeper
|
|
554
|
+
handle_use_doorkeeper(node, context)
|
|
555
|
+
when :mount
|
|
556
|
+
handle_mount(node, context)
|
|
557
|
+
when :with_options
|
|
558
|
+
handle_with_options(node, context)
|
|
559
|
+
when :concern
|
|
560
|
+
handle_concern_definition(node, context)
|
|
561
|
+
when :direct
|
|
562
|
+
# `direct(:name) do |args...| ... end` — Rails DSL
|
|
563
|
+
# for a custom URL helper. We register `name_path`
|
|
564
|
+
# and (auto-paired) `name_url`. The arity comes
|
|
565
|
+
# from the block's required-parameter count. Only
|
|
566
|
+
# literal Symbol/String names are handled here;
|
|
567
|
+
# non-literal forms are caught by the
|
|
568
|
+
# `detect_alias_rule_in_each` walker upstream.
|
|
569
|
+
handle_direct(node, context)
|
|
570
|
+
when :each
|
|
571
|
+
# `.each do |name| ... end` — scan the block for
|
|
572
|
+
# the GitLab `direct(name.sub(X, Y)) do ... end`
|
|
573
|
+
# alias-generation idiom. If found, record the
|
|
574
|
+
# (X, Y) pair so `apply_alias_rules` can expand
|
|
575
|
+
# every matching registered entry.
|
|
576
|
+
detect_alias_rule_in_each(node, context)
|
|
577
|
+
interpret_block_body(node, context)
|
|
225
578
|
else
|
|
226
579
|
interpret_block_body(node, context)
|
|
227
580
|
end
|
|
228
581
|
end
|
|
229
582
|
|
|
583
|
+
# `direct(:name) do |arg1, arg2, ...| ... end` — Rails
|
|
584
|
+
# adds a custom URL helper. We register `name_path`
|
|
585
|
+
# with the block's required-arg count as the arity (the
|
|
586
|
+
# auto-pairing in `parse` adds `name_url`). Only handled
|
|
587
|
+
# for literal Symbol or String first-arg.
|
|
588
|
+
def handle_direct(node, context)
|
|
589
|
+
first = node.arguments&.arguments&.first
|
|
590
|
+
name = case first
|
|
591
|
+
when Prism::SymbolNode then first.unescaped
|
|
592
|
+
when Prism::StringNode then first.unescaped
|
|
593
|
+
end
|
|
594
|
+
return if name.nil? || name.empty?
|
|
595
|
+
|
|
596
|
+
block = node.block
|
|
597
|
+
arity = if block.is_a?(Prism::BlockNode)
|
|
598
|
+
block_required_arity(block)
|
|
599
|
+
else
|
|
600
|
+
0
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
context.entries << HelperTable::Entry.new(
|
|
604
|
+
name: name.end_with?("_path") || name.end_with?("_url") ? name : "#{name}_path",
|
|
605
|
+
arity: arity, path: "/<direct:#{name}>",
|
|
606
|
+
http_method: :get, action: :custom
|
|
607
|
+
)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def block_required_arity(block_node)
|
|
611
|
+
params = block_node.parameters
|
|
612
|
+
return 0 unless params.is_a?(Prism::BlockParametersNode)
|
|
613
|
+
return 0 unless params.parameters.is_a?(Prism::ParametersNode)
|
|
614
|
+
|
|
615
|
+
(params.parameters.requireds || []).size
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Walks a `.each do |loop_var| ... end` body looking for:
|
|
619
|
+
# - `<new_name_var> = <loop_var>.sub(FROM_STR, TO_STR)`
|
|
620
|
+
# - `direct(<new_name_var>) do |...| ... end`
|
|
621
|
+
# When both shapes appear with the same `<new_name_var>`,
|
|
622
|
+
# registers `(FROM_STR, TO_STR)` as an alias rule. The
|
|
623
|
+
# iterated collection (`Rails.application.routes.set`)
|
|
624
|
+
# is assumed to yield the names of already-registered
|
|
625
|
+
# helpers — GitLab's idiom. Other iterations that happen
|
|
626
|
+
# to match the shape are rare; the FP risk is bounded
|
|
627
|
+
# because alias generation only adds entries.
|
|
628
|
+
def detect_alias_rule_in_each(each_node, context)
|
|
629
|
+
block = each_node.block
|
|
630
|
+
return unless block.is_a?(Prism::BlockNode)
|
|
631
|
+
|
|
632
|
+
loop_var = first_block_parameter_name(block)
|
|
633
|
+
return if loop_var.nil?
|
|
634
|
+
|
|
635
|
+
body = block.body
|
|
636
|
+
return if body.nil?
|
|
637
|
+
|
|
638
|
+
# First pass: gather every `<var> = <loop_var>.sub(FROM, TO)`
|
|
639
|
+
# assignment.
|
|
640
|
+
sub_assignments = {}
|
|
641
|
+
walk_for_alias_pattern(body) do |node|
|
|
642
|
+
next unless node.is_a?(Prism::LocalVariableWriteNode)
|
|
643
|
+
next unless node.value.is_a?(Prism::CallNode)
|
|
644
|
+
next unless node.value.name == :sub
|
|
645
|
+
|
|
646
|
+
recv = node.value.receiver
|
|
647
|
+
next unless recv.is_a?(Prism::LocalVariableReadNode) && recv.name == loop_var
|
|
648
|
+
|
|
649
|
+
args = node.value.arguments&.arguments || []
|
|
650
|
+
next if args.size != 2
|
|
651
|
+
|
|
652
|
+
from = string_value(args[0])
|
|
653
|
+
to = string_value(args[1])
|
|
654
|
+
next if from.nil? || to.nil?
|
|
655
|
+
|
|
656
|
+
sub_assignments[node.name] = [from, to]
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
return if sub_assignments.empty?
|
|
660
|
+
|
|
661
|
+
# Second pass: find `direct(<var>) do ... end` calls
|
|
662
|
+
# whose first arg is one of the captured assignment
|
|
663
|
+
# variables.
|
|
664
|
+
walk_for_alias_pattern(body) do |node|
|
|
665
|
+
next unless node.is_a?(Prism::CallNode) && node.name == :direct
|
|
666
|
+
next if node.arguments.nil?
|
|
667
|
+
|
|
668
|
+
first = node.arguments.arguments.first
|
|
669
|
+
next unless first.is_a?(Prism::LocalVariableReadNode)
|
|
670
|
+
next unless (rule = sub_assignments[first.name])
|
|
671
|
+
|
|
672
|
+
context.register_alias_rule(rule[0], rule[1])
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def walk_for_alias_pattern(node, &)
|
|
677
|
+
return unless node.is_a?(Prism::Node)
|
|
678
|
+
|
|
679
|
+
yield node
|
|
680
|
+
node.compact_child_nodes.each { |child| walk_for_alias_pattern(child, &) }
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def first_block_parameter_name(block_node)
|
|
684
|
+
params = block_node.parameters
|
|
685
|
+
return nil unless params.is_a?(Prism::BlockParametersNode)
|
|
686
|
+
return nil unless params.parameters.is_a?(Prism::ParametersNode)
|
|
687
|
+
|
|
688
|
+
required = params.parameters.requireds
|
|
689
|
+
return nil if required.nil? || required.empty?
|
|
690
|
+
|
|
691
|
+
first = required.first
|
|
692
|
+
first.respond_to?(:name) ? first.name : nil
|
|
693
|
+
end
|
|
694
|
+
|
|
230
695
|
def interpret_block_body(node, context)
|
|
231
696
|
body = node.block&.body
|
|
232
697
|
return if body.nil?
|
|
@@ -241,16 +706,175 @@ module Rigor
|
|
|
241
706
|
context.push_namespace(name) { interpret_block_body(node, context) }
|
|
242
707
|
end
|
|
243
708
|
|
|
709
|
+
# `devise_for :users [, skip: [...], path: "..."]` —
|
|
710
|
+
# generates the standard Devise route-helper catalogue
|
|
711
|
+
# for the named resource. Symbol-literal first arg
|
|
712
|
+
# only; non-literal forms (e.g. dynamic resource names
|
|
713
|
+
# built from constants) are silently skipped because the
|
|
714
|
+
# helper SET depends on the literal name and we cannot
|
|
715
|
+
# statically resolve a variable here. `skip:` is read
|
|
716
|
+
# so the project's omitted controllers do not register.
|
|
717
|
+
def handle_devise_for(node, context)
|
|
718
|
+
resource = symbol_argument(node, 0)
|
|
719
|
+
return if resource.nil?
|
|
720
|
+
|
|
721
|
+
skip = Array(keyword_array(node, :skip)).map(&:to_sym)
|
|
722
|
+
resource_segment = DeviseRoutes.singularize(resource.to_s)
|
|
723
|
+
context.record_devise_resource(resource_segment)
|
|
724
|
+
DeviseRoutes.generate(resource: resource, skip: skip).each do |entry|
|
|
725
|
+
context.entries << entry
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# `with_options X do ... end` — applies the `X`
|
|
730
|
+
# options hash as defaults for every call inside the
|
|
731
|
+
# block. Mastodon's `with_options only: [:index],
|
|
732
|
+
# concerns: :batch do resources :links; resources
|
|
733
|
+
# :tags; end` lets us register the inner resources
|
|
734
|
+
# with the implicit defaults — closing `batch_*_path`
|
|
735
|
+
# cluster generated via the `:batch` concern.
|
|
736
|
+
#
|
|
737
|
+
# We push a `:with_options` frame carrying the
|
|
738
|
+
# defaults; `effective_options_for(node)` (in
|
|
739
|
+
# `handle_resources` / `handle_resource`) merges them
|
|
740
|
+
# in before reading specific option keys.
|
|
741
|
+
def handle_with_options(node, context)
|
|
742
|
+
defaults = options_hash(node)
|
|
743
|
+
context.push_with_options(defaults) do
|
|
744
|
+
interpret_block_body(node, context)
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# `mount Foo::Engine, at: '/path', as: :name` —
|
|
749
|
+
# mounted Rails engines. The mount adds a single
|
|
750
|
+
# helper for the mount point itself: `<as>_path` /
|
|
751
|
+
# `<as>_url` (arity 0). The engine's own internal
|
|
752
|
+
# helpers are out of scope (they'd need the engine's
|
|
753
|
+
# routes.rb available; almost no static parser
|
|
754
|
+
# follows that). Mastodon uses `mount Sidekiq::Web,
|
|
755
|
+
# at: 'sidekiq', as: :sidekiq` and similar.
|
|
756
|
+
#
|
|
757
|
+
# When `as:` is omitted Rails derives a helper name
|
|
758
|
+
# from the engine class — that's harder to compute
|
|
759
|
+
# statically, so we silently skip.
|
|
760
|
+
def handle_mount(node, context)
|
|
761
|
+
options = options_hash(node)
|
|
762
|
+
as_name = options[:as]
|
|
763
|
+
return if as_name.nil?
|
|
764
|
+
|
|
765
|
+
at_path = options[:at]
|
|
766
|
+
path = at_path.is_a?(String) ? "/#{at_path.delete_prefix('/')}" : "/#{as_name}"
|
|
767
|
+
context.entries << HelperTable::Entry.new(
|
|
768
|
+
name: "#{context.helper_prefix}#{as_name}_path",
|
|
769
|
+
arity: context.parent_segment_count,
|
|
770
|
+
path: "#{context.path_prefix}#{path}",
|
|
771
|
+
http_method: nil, action: :mount
|
|
772
|
+
)
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# `use_doorkeeper do ... end` — Doorkeeper gem's
|
|
776
|
+
# standard OAuth route helpers (`oauth_token_path`,
|
|
777
|
+
# `oauth_authorization_path`, `oauth_application_path`,
|
|
778
|
+
# etc.). We generate the full catalogue plus walk the
|
|
779
|
+
# block body for `skip_controllers <names>` calls so
|
|
780
|
+
# the project's omitted controllers don't register.
|
|
781
|
+
# `controllers <hash>` mappings inside the block change
|
|
782
|
+
# the serving controller class but not the helper
|
|
783
|
+
# names — they can stay unmodelled.
|
|
784
|
+
def handle_use_doorkeeper(node, context)
|
|
785
|
+
skip = collect_doorkeeper_skips(node)
|
|
786
|
+
DoorkeeperRoutes.generate(skip: skip).each do |entry|
|
|
787
|
+
context.entries << entry
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def collect_doorkeeper_skips(node)
|
|
792
|
+
body = node.block&.body
|
|
793
|
+
return [] if body.nil?
|
|
794
|
+
|
|
795
|
+
skips = []
|
|
796
|
+
body.compact_child_nodes.each do |child|
|
|
797
|
+
next unless child.is_a?(Prism::CallNode) && child.name == :skip_controllers
|
|
798
|
+
next if child.receiver
|
|
799
|
+
|
|
800
|
+
(child.arguments&.arguments || []).each do |arg|
|
|
801
|
+
skips << arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode)
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
skips
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def keyword_array(node, key)
|
|
808
|
+
arg = options_hash(node)[key]
|
|
809
|
+
arg.is_a?(Array) ? arg : nil
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# `scope "/:slug", as: "event" do ... end`
|
|
813
|
+
#
|
|
814
|
+
# When `as:` is present the block body is interpreted under a
|
|
815
|
+
# new `:as_scope` stack frame that adds the given prefix to
|
|
816
|
+
# every helper registered inside. Dynamic path segments
|
|
817
|
+
# (`:slug`) are counted so nested-resource arities stay
|
|
818
|
+
# correct.
|
|
819
|
+
#
|
|
820
|
+
# When `as:` is absent the block is interpreted without any
|
|
821
|
+
# prefix change — helper names are unaffected by the scope's
|
|
822
|
+
# path, which matches Rails' behaviour for path-only scopes.
|
|
823
|
+
def handle_scope(node, context)
|
|
824
|
+
as_name = keyword_symbol(node, :as)
|
|
825
|
+
# `scope :path_arg, as: :name` (path as a positional
|
|
826
|
+
# arg) vs `scope(path: ':project_id', as: :project)`
|
|
827
|
+
# (path as a `:path` keyword). GitLab's project routes
|
|
828
|
+
# rely on the latter for the `*namespace_id` /
|
|
829
|
+
# `:project_id` outer scopes. The leading `/` is added
|
|
830
|
+
# if missing so the path-prefix join produces clean
|
|
831
|
+
# segments.
|
|
832
|
+
path = string_argument(node, 0) || keyword_value_string(node, :path)
|
|
833
|
+
path = "/#{path}" if path && !path.start_with?("/")
|
|
834
|
+
arity = path ? count_path_placeholders(path) : 0
|
|
835
|
+
|
|
836
|
+
if as_name.nil?
|
|
837
|
+
# Even without `:as`, a `scope(path: 'groups/*id')`
|
|
838
|
+
# still contributes its path / arity segments to
|
|
839
|
+
# helpers declared inside (GitLab's
|
|
840
|
+
# `scope(path: 'groups/*id', controller: :groups)
|
|
841
|
+
# do; get :edit, as: :edit_group end` → arity 1).
|
|
842
|
+
# Skip the frame when there's also no path
|
|
843
|
+
# contribution (a pure `scope(module: :foo)` with
|
|
844
|
+
# no helper-name / path effect).
|
|
845
|
+
return interpret_block_body(node, context) if path.nil? || arity.zero?
|
|
846
|
+
|
|
847
|
+
context.push_as_scope("", path, arity) do
|
|
848
|
+
interpret_block_body(node, context)
|
|
849
|
+
end
|
|
850
|
+
return
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
context.push_as_scope(as_name.to_s, path, arity) do
|
|
854
|
+
interpret_block_body(node, context)
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
244
858
|
def handle_resources(node, context)
|
|
245
859
|
name = symbol_argument(node, 0)
|
|
246
860
|
return interpret_block_body(node, context) if name.nil?
|
|
247
861
|
|
|
248
|
-
|
|
862
|
+
options = effective_options_for(node, context)
|
|
863
|
+
actions = restrict_actions_from(options, DEFAULT_RESOURCE_ACTIONS)
|
|
249
864
|
base_arity = context.parent_segment_count
|
|
865
|
+
# `resources :collections, as: :actor_collections`
|
|
866
|
+
# remaps the helper name family —
|
|
867
|
+
# `actor_collections_path` for index,
|
|
868
|
+
# `actor_collection_path(id)` for show. Path / arity
|
|
869
|
+
# are unaffected. Mastodon uses this inside a
|
|
870
|
+
# concern (`resources :collections, only: [:show],
|
|
871
|
+
# as: :actor_collections`).
|
|
872
|
+
helper_name = options[:as] || name
|
|
250
873
|
|
|
251
|
-
register_resourceful_helpers(
|
|
874
|
+
register_resourceful_helpers(helper_name, actions, base_arity, context, plural: true)
|
|
252
875
|
|
|
253
876
|
context.push_resource(name) do
|
|
877
|
+
replay_concerns_from_options(options, context)
|
|
254
878
|
interpret_block_body(node, context)
|
|
255
879
|
end
|
|
256
880
|
end
|
|
@@ -259,18 +883,82 @@ module Rigor
|
|
|
259
883
|
name = symbol_argument(node, 0)
|
|
260
884
|
return interpret_block_body(node, context) if name.nil?
|
|
261
885
|
|
|
262
|
-
|
|
886
|
+
options = effective_options_for(node, context)
|
|
887
|
+
actions = restrict_actions_from(options, DEFAULT_SINGULAR_ACTIONS)
|
|
263
888
|
base_arity = context.parent_segment_count
|
|
889
|
+
helper_name = options[:as] || name
|
|
264
890
|
|
|
265
891
|
# Singular resource — no `:id` segment, no `:index`
|
|
266
892
|
# / pluralised helper. The "show" helper is
|
|
267
893
|
# `<name>_path` (singular).
|
|
268
|
-
register_resourceful_helpers(
|
|
894
|
+
register_resourceful_helpers(helper_name, actions, base_arity, context, plural: false)
|
|
895
|
+
|
|
896
|
+
# Push a `:singular_scope` frame so nested
|
|
897
|
+
# declarations pick up the singular resource's
|
|
898
|
+
# name in their helper prefix (Mastodon's
|
|
899
|
+
# `resource :instance do; scope module: :instances
|
|
900
|
+
# do; resources :domain_blocks; end; end` →
|
|
901
|
+
# `instance_domain_blocks_path`). The singular
|
|
902
|
+
# frame adds NO `:id` segment to arity — singular
|
|
903
|
+
# resources don't carry one.
|
|
904
|
+
context.push_singular_resource(name) do
|
|
905
|
+
replay_concerns(node, context)
|
|
906
|
+
interpret_block_body(node, context)
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# `concern :account_resources do ... end` registers the
|
|
911
|
+
# body for later replay; we DO NOT interpret it at the
|
|
912
|
+
# definition site (the body has no parent-resource
|
|
913
|
+
# context yet). Concerns at the top level land in the
|
|
914
|
+
# Context's `concerns` map by Symbol name.
|
|
915
|
+
def handle_concern_definition(node, context)
|
|
916
|
+
name = symbol_argument(node, 0)
|
|
917
|
+
body = node.block&.body
|
|
918
|
+
return if name.nil? || body.nil?
|
|
919
|
+
|
|
920
|
+
context.register_concern(name, body)
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# `resources :accounts, concerns: :account_resources do ... end`
|
|
924
|
+
# — replays the registered concern body inside the
|
|
925
|
+
# current Context (which already has the accounts
|
|
926
|
+
# resource frame pushed). Supports both single-symbol
|
|
927
|
+
# (`concerns: :name`) and array-of-symbols
|
|
928
|
+
# (`concerns: [:a, :b]`) forms.
|
|
929
|
+
def replay_concerns(resource_node, context)
|
|
930
|
+
replay_concerns_from_options(effective_options_for(resource_node, context), context)
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
def replay_concerns_from_options(options, context)
|
|
934
|
+
concerns_value = options[:concerns]
|
|
935
|
+
return if concerns_value.nil?
|
|
269
936
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
937
|
+
Array(concerns_value).each do |concern_name|
|
|
938
|
+
body = context.concern_body(concern_name)
|
|
939
|
+
next if body.nil?
|
|
940
|
+
|
|
941
|
+
body.compact_child_nodes.each { |child| interpret(child, context) }
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# Returns the merged option hash for a node — the
|
|
946
|
+
# caller's own options override `with_options`
|
|
947
|
+
# defaults from the surrounding Context stack. Used by
|
|
948
|
+
# `handle_resources` / `handle_resource` so a bare
|
|
949
|
+
# `resources :foo` inside `with_options only: [:index],
|
|
950
|
+
# concerns: :batch do ... end` is treated as if it
|
|
951
|
+
# had those options written inline.
|
|
952
|
+
def effective_options_for(node, context)
|
|
953
|
+
context.with_options_defaults.merge(options_hash(node))
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
# Reads the value of an options-hash key. Distinct from
|
|
957
|
+
# `keyword_symbol` / `keyword_array` because `concerns:`
|
|
958
|
+
# accepts EITHER a single Symbol or an Array of Symbols
|
|
959
|
+
# — same Rails idiom Rails accepts.
|
|
960
|
+
def keyword_value(node, key)
|
|
961
|
+
options_hash(node)[key]
|
|
274
962
|
end
|
|
275
963
|
|
|
276
964
|
def handle_root(node, context)
|
|
@@ -298,37 +986,249 @@ module Rigor
|
|
|
298
986
|
end
|
|
299
987
|
|
|
300
988
|
def handle_explicit_route(node, context)
|
|
301
|
-
#
|
|
302
|
-
|
|
989
|
+
# Member / collection block shorthand: `post :memorialize`
|
|
990
|
+
# inside `member do ... end` (no path arg, just a
|
|
991
|
+
# SymbolNode). Rails generates a helper based on the
|
|
992
|
+
# action name + the enclosing resource: a member
|
|
993
|
+
# action becomes `<action>_<singular_chain>_path(id)`,
|
|
994
|
+
# a collection action becomes
|
|
995
|
+
# `<action>_<plural_chain>_path`.
|
|
996
|
+
return register_member_collection_action(node, context) if member_collection_shorthand?(node, context)
|
|
997
|
+
|
|
998
|
+
# `:on => :collection` / `:on => :member` inline form
|
|
999
|
+
# — Rails accepts this as a shorthand for `collection
|
|
1000
|
+
# do ... end` / `member do ... end` wrappers around a
|
|
1001
|
+
# single action. `get 'report', :on => :collection`
|
|
1002
|
+
# inside `resources :time_entries` generates
|
|
1003
|
+
# `report_time_entries_path` (collection-style: action
|
|
1004
|
+
# prefix on the plural resource name). Treat the action
|
|
1005
|
+
# name as the path's basename string (Redmine's idiom).
|
|
1006
|
+
on_target = keyword_symbol(node, :on)
|
|
1007
|
+
if on_target && context.inside_resource_scope?
|
|
1008
|
+
path_arg = string_argument(node, 0) || symbol_argument(node, 0)&.to_s
|
|
1009
|
+
as_name = keyword_symbol(node, :as) || path_arg
|
|
1010
|
+
return register_on_target_action(node, context, as_name, on_target) if as_name
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# `get "/about", to: "static#about", as: :about` —
|
|
1014
|
+
# path as the first positional arg. Also handle the
|
|
1015
|
+
# hashrocket-key form `get 'help/*path' => 'help#show',
|
|
1016
|
+
# as: :help_page` where the path is the String key in
|
|
1017
|
+
# the trailing keyword/options hash (its value is the
|
|
1018
|
+
# `controller#action`). Without this, the arity check
|
|
1019
|
+
# underestimates because the placeholder count comes
|
|
1020
|
+
# from a nil path.
|
|
1021
|
+
path = string_argument(node, 0) || hashrocket_path_key(node)
|
|
303
1022
|
as_name = keyword_symbol(node, :as)
|
|
304
1023
|
return if as_name.nil? && path.nil?
|
|
305
1024
|
|
|
306
|
-
# When `as:` is omitted, Rails
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
|
|
1025
|
+
# When `as:` is omitted, Rails derives a helper name
|
|
1026
|
+
# from the path for static paths (no :segment). We
|
|
1027
|
+
# do the same when the path has no placeholders. The
|
|
1028
|
+
# special case `get '/'` inside a `:as_scope` (e.g.
|
|
1029
|
+
# `scope path: '/blob/*id', as: :blob do; get '/' end`)
|
|
1030
|
+
# has empty path content after stripping the leading
|
|
1031
|
+
# slash — Rails reuses the enclosing as_scope's name.
|
|
1032
|
+
# We surface that by registering the helper with an
|
|
1033
|
+
# empty `as_name`, which `explicit_route_helper_name`
|
|
1034
|
+
# then composes from `helper_prefix.chomp("_")`.
|
|
1035
|
+
if as_name.nil?
|
|
1036
|
+
return if path.nil? || path.include?(":")
|
|
1037
|
+
|
|
1038
|
+
as_name = path.delete_prefix("/").tr("/", "_")
|
|
1039
|
+
if as_name.empty? && context.helper_prefix.empty?
|
|
1040
|
+
# `get '/'` style — only meaningful when the
|
|
1041
|
+
# helper_prefix is non-empty (otherwise Rails would
|
|
1042
|
+
# generate a root helper, handled elsewhere).
|
|
1043
|
+
return
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
name = explicit_route_helper_name(context, as_name)
|
|
1048
|
+
total_placeholders = count_path_placeholders(path)
|
|
1049
|
+
optional = count_optional_path_placeholders(path)
|
|
1050
|
+
base_arity = context.parent_segment_count + total_placeholders
|
|
1051
|
+
# Register the maximum-arity entry; if the path
|
|
1052
|
+
# carries `(...)` optional segments (e.g.
|
|
1053
|
+
# `'settings(/:tab)'`), register additional entries
|
|
1054
|
+
# for each smaller arity so the call-site arity check
|
|
1055
|
+
# accepts `settings_project_path(:id)` (no `:tab`).
|
|
1056
|
+
(0..optional).each do |drop|
|
|
1057
|
+
context.entries << HelperTable::Entry.new(
|
|
1058
|
+
name: name, arity: base_arity - drop,
|
|
1059
|
+
path: "#{context.path_prefix}#{path || ''}",
|
|
1060
|
+
http_method: node.name, action: :custom
|
|
1061
|
+
)
|
|
1062
|
+
end
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# Builds the helper name for an explicit `get` / `post`
|
|
1066
|
+
# / `match` route. Rails uses two distinct shapes:
|
|
1067
|
+
#
|
|
1068
|
+
# - **Outside a `resources` scope** (top-level or inside
|
|
1069
|
+
# `namespace` / `scope`): `<namespace_prefix><as>_path`
|
|
1070
|
+
# — e.g. `match 'login', as: :signin` →
|
|
1071
|
+
# `signin_path`; inside `namespace :admin` →
|
|
1072
|
+
# `admin_signin_path`.
|
|
1073
|
+
# - **Inside a `resources` scope**: the `:as` acts as
|
|
1074
|
+
# the ACTION prefix on the resource's singular chain.
|
|
1075
|
+
# `resources :projects do; get 'settings', as:
|
|
1076
|
+
# :settings; end` → `settings_project_path(:id)` (NOT
|
|
1077
|
+
# `project_settings_path`). The namespace prefix still
|
|
1078
|
+
# leads: `namespace :admin do; resources :projects do;
|
|
1079
|
+
# get 'foo', as: :bar; end; end` →
|
|
1080
|
+
# `admin_bar_project_path(:id)`.
|
|
1081
|
+
def explicit_route_helper_name(context, as_name)
|
|
1082
|
+
# Empty `as_name` is the `get '/'`-inside-an-as_scope
|
|
1083
|
+
# case (e.g. `scope path: '/blob/*id', as: :blob do;
|
|
1084
|
+
# get '/' end` → `blob_path(:id)`). Use the
|
|
1085
|
+
# helper_prefix as-is (drop the trailing underscore)
|
|
1086
|
+
# — Rails reuses the enclosing scope's name verbatim.
|
|
1087
|
+
return "#{context.helper_prefix.chomp('_')}_path" if as_name.to_s.empty?
|
|
1088
|
+
|
|
1089
|
+
if context.inside_resource_scope?
|
|
1090
|
+
"#{context.namespace_only_prefix}#{as_name}_#{context.resource_singular_chain}path"
|
|
1091
|
+
else
|
|
1092
|
+
"#{context.helper_prefix}#{as_name}_path"
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# True when the call is `<verb> :symbol [, options...]`
|
|
1097
|
+
# or `<verb> 'string'` inside one of:
|
|
1098
|
+
# - a `member do ... end` / `collection do ... end`
|
|
1099
|
+
# block (canonical shorthand context), or
|
|
1100
|
+
# - a `resources :name do ... end` / `resource :name
|
|
1101
|
+
# do ... end` block at the *direct* nesting level.
|
|
1102
|
+
# Rails defaults a bare symbol- or string-named verb
|
|
1103
|
+
# inside resources to a member action (GitLab's
|
|
1104
|
+
# `resource :application_settings do; match :general,
|
|
1105
|
+
# via: [...] end` → `general_application_settings_path`).
|
|
1106
|
+
def member_collection_shorthand?(node, context)
|
|
1107
|
+
return false unless context.innermost_action_block || context.inside_resource_scope?
|
|
1108
|
+
|
|
1109
|
+
first_arg = node.arguments&.arguments&.first
|
|
1110
|
+
return true if first_arg.is_a?(Prism::SymbolNode)
|
|
1111
|
+
|
|
1112
|
+
# A plain action-name string with no `/` or `:` —
|
|
1113
|
+
# treat it as if it were a symbol (`get 'report'` ==
|
|
1114
|
+
# `get :report` inside member/collection block).
|
|
1115
|
+
first_arg.is_a?(Prism::StringNode) &&
|
|
1116
|
+
!first_arg.unescaped.include?("/") &&
|
|
1117
|
+
!first_arg.unescaped.include?(":")
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# Generates the member / collection action helper.
|
|
1121
|
+
# Member: `<action>_<helper_prefix>path(id)`, arity =
|
|
1122
|
+
# parent_segment_count (which already includes the
|
|
1123
|
+
# enclosing resource's `:id`).
|
|
1124
|
+
# Collection: `<action>_<plural_helper_prefix>path`,
|
|
1125
|
+
# arity = parent_segment_count - 1 (no `:id` segment;
|
|
1126
|
+
# the collection URL is /<resource>/<action>).
|
|
1127
|
+
def register_member_collection_action(node, context)
|
|
1128
|
+
action_name = symbol_argument(node, 0)&.to_s ||
|
|
1129
|
+
string_argument(node, 0).to_s
|
|
1130
|
+
frame = context.innermost_action_block
|
|
1131
|
+
# No `member do` / `collection do` wrapper but we're
|
|
1132
|
+
# inside a resources block — Rails defaults a bare
|
|
1133
|
+
# `<verb> :symbol` inside resources to a MEMBER
|
|
1134
|
+
# action (e.g. `get :preview` → `preview_<singular>_path
|
|
1135
|
+
# (:id)`).
|
|
1136
|
+
if frame.nil?
|
|
1137
|
+
register_member_action(node, context, action_name)
|
|
1138
|
+
elsif frame[:kind] == :member_block
|
|
1139
|
+
register_member_action(node, context, action_name)
|
|
1140
|
+
else
|
|
1141
|
+
register_collection_action(node, context, action_name, frame)
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
# `get 'report', :on => :collection` (or `:member`)
|
|
1146
|
+
# inline form inside `resources`. The helper name shape
|
|
1147
|
+
# matches member_action / collection_action: the action
|
|
1148
|
+
# name prefixes the resource's singular (member) or
|
|
1149
|
+
# plural (collection). Closes Redmine's
|
|
1150
|
+
# `report_time_entries_path` cluster.
|
|
1151
|
+
def register_on_target_action(node, context, action_name, on_target)
|
|
1152
|
+
scope_frame = context.innermost_scope_frame
|
|
1153
|
+
return if scope_frame.nil?
|
|
1154
|
+
|
|
1155
|
+
parent_singular = scope_frame[:parent]
|
|
1156
|
+
parent_plural = scope_frame[:parent_plural] || parent_singular
|
|
1157
|
+
|
|
1158
|
+
case on_target
|
|
1159
|
+
when :member
|
|
1160
|
+
name = "#{action_name}_#{context.helper_prefix}path"
|
|
1161
|
+
arity = context.parent_segment_count
|
|
1162
|
+
when :collection
|
|
1163
|
+
plural_prefix = context.helper_prefix.sub(/#{parent_singular}_\z/, "#{parent_plural}_")
|
|
1164
|
+
name = "#{action_name}_#{plural_prefix}path"
|
|
1165
|
+
arity = [context.parent_segment_count - 1, 0].max
|
|
1166
|
+
else
|
|
1167
|
+
return
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
context.entries << HelperTable::Entry.new(
|
|
1171
|
+
name: name, arity: arity,
|
|
1172
|
+
path: "#{context.path_prefix}/#{action_name}",
|
|
1173
|
+
http_method: node.name, action: :custom
|
|
1174
|
+
)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
def register_member_action(node, context, action_name)
|
|
1178
|
+
# `helper_prefix` already ends with "_" (each segment
|
|
1179
|
+
# appends one); the formula below yields e.g.
|
|
1180
|
+
# `memorialize_admin_account_path`. Arity equals the
|
|
1181
|
+
# parent segment count — Rails member URLs carry the
|
|
1182
|
+
# enclosing resource's `:id`, which the :scope frame
|
|
1183
|
+
# already counts.
|
|
1184
|
+
name = "#{action_name}_#{context.helper_prefix}path"
|
|
1185
|
+
context.entries << HelperTable::Entry.new(
|
|
1186
|
+
name: name, arity: context.parent_segment_count,
|
|
1187
|
+
path: "#{context.path_prefix}/#{action_name}",
|
|
1188
|
+
http_method: node.name, action: :custom
|
|
1189
|
+
)
|
|
1190
|
+
end
|
|
311
1191
|
|
|
312
|
-
|
|
313
|
-
|
|
1192
|
+
def register_collection_action(node, context, action_name, frame)
|
|
1193
|
+
# Collection URL drops the immediate parent's `:id`
|
|
1194
|
+
# segment. The plural helper prefix swaps the
|
|
1195
|
+
# singular form (in `helper_prefix`) for the plural
|
|
1196
|
+
# — the immediate-resource frame stored both.
|
|
1197
|
+
plural_prefix = context.helper_prefix.sub(/#{frame[:parent_singular]}_\z/, "#{frame[:parent_plural]}_")
|
|
1198
|
+
name = "#{action_name}_#{plural_prefix}path"
|
|
1199
|
+
arity = [context.parent_segment_count - 1, 0].max
|
|
314
1200
|
context.entries << HelperTable::Entry.new(
|
|
315
1201
|
name: name, arity: arity,
|
|
316
|
-
path: "#{context.path_prefix}
|
|
1202
|
+
path: "#{context.path_prefix}/#{action_name}",
|
|
317
1203
|
http_method: node.name, action: :custom
|
|
318
1204
|
)
|
|
319
1205
|
end
|
|
320
1206
|
|
|
1207
|
+
def load_drawn_routes(node, context)
|
|
1208
|
+
return unless context.file_reader
|
|
1209
|
+
|
|
1210
|
+
name = symbol_argument(node, 0) || string_argument(node, 0)&.to_sym
|
|
1211
|
+
return unless name
|
|
1212
|
+
|
|
1213
|
+
sub_contents = context.file_reader.call("#{name}.rb")
|
|
1214
|
+
return unless sub_contents
|
|
1215
|
+
|
|
1216
|
+
sub_result = Prism.parse(sub_contents)
|
|
1217
|
+
return if sub_result.errors.any?
|
|
1218
|
+
|
|
1219
|
+
interpret(sub_result.value, context)
|
|
1220
|
+
end
|
|
1221
|
+
|
|
321
1222
|
def handle_member_or_collection(node, context)
|
|
322
1223
|
# Only meaningful when we're inside a `resources` /
|
|
323
1224
|
# `resource` block. The Context's stack tells us.
|
|
324
|
-
|
|
1225
|
+
resource = context.innermost_resource
|
|
1226
|
+
return interpret_block_body(node, context) if resource.nil?
|
|
325
1227
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
# naming work.
|
|
331
|
-
interpret_block_body(node, context)
|
|
1228
|
+
mode = node.name # :member or :collection
|
|
1229
|
+
context.push_action_block(mode, resource[:singular], resource[:plural]) do
|
|
1230
|
+
interpret_block_body(node, context)
|
|
1231
|
+
end
|
|
332
1232
|
end
|
|
333
1233
|
|
|
334
1234
|
def in_singular_resource?(*)
|
|
@@ -342,9 +1242,23 @@ module Rigor
|
|
|
342
1242
|
# `plural: true` for `resources :users`, `false` for
|
|
343
1243
|
# `resource :profile`.
|
|
344
1244
|
def register_resourceful_helpers(name, actions, base_arity, context, plural:)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
1245
|
+
# Singular resources (`resource :foo`) use the
|
|
1246
|
+
# given name AS-IS for both path and helper —
|
|
1247
|
+
# singularising would mangle a deliberately-plural
|
|
1248
|
+
# singular-DSL name like Mastodon's
|
|
1249
|
+
# `resource :relationships, only: [:show, :update]`
|
|
1250
|
+
# (Rails generates `relationships_path`, not
|
|
1251
|
+
# `relationship_path`). Plural resources still
|
|
1252
|
+
# singularise for the show / new / edit helpers.
|
|
1253
|
+
# Plural resources singularise for show / new / edit
|
|
1254
|
+
# helpers (`resources :users` → `user_path(id)`);
|
|
1255
|
+
# singular resources use the name AS-IS even when it
|
|
1256
|
+
# looks plural (Mastodon's `resource :relationships,
|
|
1257
|
+
# only: [:show, :update]` → `relationships_path`).
|
|
1258
|
+
# The path segment uses `name` in both shapes — Rails
|
|
1259
|
+
# never singularises the URL.
|
|
1260
|
+
singular = plural ? singularize_word(name.to_s) : name.to_s
|
|
1261
|
+
path_base = "#{context.path_prefix}/#{name}"
|
|
348
1262
|
|
|
349
1263
|
actions.each do |action|
|
|
350
1264
|
entry = entry_for_action(
|
|
@@ -356,30 +1270,95 @@ module Rigor
|
|
|
356
1270
|
end
|
|
357
1271
|
end
|
|
358
1272
|
|
|
359
|
-
#
|
|
360
|
-
#
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
1273
|
+
# Maps an action keyword to the route-helper entry it
|
|
1274
|
+
# produces. The five "named-helper" actions
|
|
1275
|
+
# (`:index` / `:show` / `:new` / `:edit` plus
|
|
1276
|
+
# singular-resource `:show`) generate a distinct
|
|
1277
|
+
# helper; the three "verb-only" actions (`:create` /
|
|
1278
|
+
# `:update` / `:destroy`) Rails serves under the same
|
|
1279
|
+
# path-helper Rails reuses for show / index forms — so
|
|
1280
|
+
# we emit them too, otherwise an `only: [:create]`
|
|
1281
|
+
# resource (e.g. Mastodon's `resource :inbox, only:
|
|
1282
|
+
# [:create]`) registers NO helpers and downstream
|
|
1283
|
+
# callers see a false `unknown-helper inbox_path`.
|
|
1284
|
+
# The HelperTable already dedupes by name, so a
|
|
1285
|
+
# resource that lists both `:show` and `:update` does
|
|
1286
|
+
# not double-register.
|
|
364
1287
|
def entry_for_action(action, name:, singular:, base_arity:, path_base:, helper_prefix:, plural:)
|
|
365
1288
|
case action
|
|
366
|
-
when :index then index_entry(plural, helper_prefix, name, base_arity, path_base)
|
|
1289
|
+
when :index then index_entry(plural, helper_prefix, name, base_arity, path_base, singular)
|
|
367
1290
|
when :show then show_entry(plural, helper_prefix, singular, base_arity, path_base)
|
|
368
1291
|
when :new
|
|
369
1292
|
HelperTable::Entry.new(
|
|
370
|
-
name: "#{helper_prefix}
|
|
1293
|
+
name: "new_#{helper_prefix}#{singular}_path",
|
|
371
1294
|
arity: base_arity, path: "#{path_base}/new",
|
|
372
1295
|
http_method: :get, action: :new
|
|
373
1296
|
)
|
|
374
1297
|
when :edit then edit_entry(plural, helper_prefix, singular, base_arity, path_base)
|
|
1298
|
+
when :create
|
|
1299
|
+
# Plural `resources` collection POST shares the
|
|
1300
|
+
# index helper (`<name>_path` → collection URL).
|
|
1301
|
+
# Singular `resource` POST shares the show helper
|
|
1302
|
+
# (`<name>_path` → resource URL). Both shapes
|
|
1303
|
+
# produce a `<prefix><name>_path` entry; only the
|
|
1304
|
+
# arity / path differ.
|
|
1305
|
+
create_entry(plural, helper_prefix, name, singular, base_arity, path_base)
|
|
1306
|
+
when :update, :destroy
|
|
1307
|
+
# Member PATCH / PUT / DELETE on plural resources
|
|
1308
|
+
# share the show helper (`<prefix><singular>_path(id)`).
|
|
1309
|
+
# Singular-resource PATCH / DELETE shares
|
|
1310
|
+
# `<prefix><name>_path` (no `:id`).
|
|
1311
|
+
show_entry(plural, helper_prefix, singular, base_arity, path_base)
|
|
1312
|
+
end
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
def create_entry(plural, helper_prefix, name, singular, base_arity, path_base)
|
|
1316
|
+
if plural
|
|
1317
|
+
HelperTable::Entry.new(
|
|
1318
|
+
name: "#{helper_prefix}#{name}_path",
|
|
1319
|
+
arity: base_arity, path: path_base,
|
|
1320
|
+
http_method: :post, action: :create
|
|
1321
|
+
)
|
|
1322
|
+
else
|
|
1323
|
+
HelperTable::Entry.new(
|
|
1324
|
+
name: "#{helper_prefix}#{singular}_path",
|
|
1325
|
+
arity: base_arity, path: path_base,
|
|
1326
|
+
http_method: :post, action: :create
|
|
1327
|
+
)
|
|
375
1328
|
end
|
|
376
1329
|
end
|
|
377
1330
|
|
|
378
|
-
def index_entry(plural, helper_prefix, name, base_arity, path_base)
|
|
1331
|
+
def index_entry(plural, helper_prefix, name, base_arity, path_base, singular)
|
|
379
1332
|
return nil unless plural
|
|
380
1333
|
|
|
1334
|
+
# Rails appends `_index_path` to the index helper
|
|
1335
|
+
# name when the singular form of the resource matches
|
|
1336
|
+
# the plural form AND the noun isn't in the canonical
|
|
1337
|
+
# UNCOUNTABLE list. The collision would otherwise put
|
|
1338
|
+
# both the index (`:id`-less) and show (`:id`-bearing)
|
|
1339
|
+
# helpers under the same name, and Rails disambiguates
|
|
1340
|
+
# by suffixing the index form. Mastodon's
|
|
1341
|
+
# `resources :reblogged_by, controller:
|
|
1342
|
+
# :reblogged_by_accounts, only: :index` and similar
|
|
1343
|
+
# rely on this — calls like
|
|
1344
|
+
# `api_v1_status_reblogged_by_index_url(status.id)`
|
|
1345
|
+
# would otherwise read as `unknown-helper`.
|
|
1346
|
+
#
|
|
1347
|
+
# Rails adds `_index_` whenever `singular == plural`
|
|
1348
|
+
# — including for UNCOUNTABLE nouns (Redmine's
|
|
1349
|
+
# `resources :news` registers `news_index_path` for
|
|
1350
|
+
# the index). The earlier "skip UNCOUNTABLE" carve-out
|
|
1351
|
+
# was empirically wrong; only the
|
|
1352
|
+
# IRREGULAR-singular path (e.g. `media → medium`)
|
|
1353
|
+
# actually has `singular != plural` and skips
|
|
1354
|
+
# `_index_`.
|
|
1355
|
+
index_name = if name.to_s == singular
|
|
1356
|
+
"#{helper_prefix}#{name}_index_path"
|
|
1357
|
+
else
|
|
1358
|
+
"#{helper_prefix}#{name}_path"
|
|
1359
|
+
end
|
|
381
1360
|
HelperTable::Entry.new(
|
|
382
|
-
name:
|
|
1361
|
+
name: index_name,
|
|
383
1362
|
arity: base_arity, path: path_base,
|
|
384
1363
|
http_method: :get, action: :index
|
|
385
1364
|
)
|
|
@@ -399,14 +1378,17 @@ module Rigor
|
|
|
399
1378
|
edit_path = plural ? "#{path_base}/:id/edit" : "#{path_base}/edit"
|
|
400
1379
|
edit_arity = plural ? base_arity + 1 : base_arity
|
|
401
1380
|
HelperTable::Entry.new(
|
|
402
|
-
name: "#{helper_prefix}
|
|
1381
|
+
name: "edit_#{helper_prefix}#{singular}_path",
|
|
403
1382
|
arity: edit_arity, path: edit_path,
|
|
404
1383
|
http_method: :get, action: :edit
|
|
405
1384
|
)
|
|
406
1385
|
end
|
|
407
1386
|
|
|
408
1387
|
def restrict_actions(node, default)
|
|
409
|
-
|
|
1388
|
+
restrict_actions_from(options_hash(node), default)
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
def restrict_actions_from(options, default)
|
|
410
1392
|
# `resources :foo, only: :show` is the same as
|
|
411
1393
|
# `only: [:show]` in Rails; `options_hash` preserves the
|
|
412
1394
|
# Symbol shape from the source, so coerce here.
|
|
@@ -447,6 +1429,35 @@ module Rigor
|
|
|
447
1429
|
options_hash(node)[key]
|
|
448
1430
|
end
|
|
449
1431
|
|
|
1432
|
+
# Reads a keyword option whose value is a literal String
|
|
1433
|
+
# (returned as-is). Returns nil when the key is missing
|
|
1434
|
+
# or the value is non-literal. Used for `scope(path:
|
|
1435
|
+
# ':project_id', ...)` shape parsing where `:path` is
|
|
1436
|
+
# passed as a keyword rather than a positional arg.
|
|
1437
|
+
def keyword_value_string(node, key)
|
|
1438
|
+
value = options_hash(node)[key]
|
|
1439
|
+
value.is_a?(String) ? value : nil
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1442
|
+
# `get 'help/*path' => 'help#show', as: :help_page` —
|
|
1443
|
+
# the path lives in a String-keyed AssocNode of the
|
|
1444
|
+
# trailing keyword/options hash (its value is the
|
|
1445
|
+
# `controller#action` String). Returns the path String,
|
|
1446
|
+
# or nil when no such hashrocket-key entry exists.
|
|
1447
|
+
def hashrocket_path_key(node)
|
|
1448
|
+
args = node.arguments&.arguments || []
|
|
1449
|
+
last = args.last
|
|
1450
|
+
return nil unless last.is_a?(Prism::KeywordHashNode)
|
|
1451
|
+
|
|
1452
|
+
last.elements.each do |element|
|
|
1453
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
1454
|
+
next unless element.key.is_a?(Prism::StringNode)
|
|
1455
|
+
|
|
1456
|
+
return element.key.unescaped
|
|
1457
|
+
end
|
|
1458
|
+
nil
|
|
1459
|
+
end
|
|
1460
|
+
|
|
450
1461
|
def symbol_value(node)
|
|
451
1462
|
node.is_a?(Prism::SymbolNode) ? node.unescaped.to_sym : nil
|
|
452
1463
|
end
|
|
@@ -465,7 +1476,23 @@ module Rigor
|
|
|
465
1476
|
def count_path_placeholders(path)
|
|
466
1477
|
return 0 if path.nil?
|
|
467
1478
|
|
|
468
|
-
|
|
1479
|
+
# Both `:name` (regular segment) and `*name` (wildcard
|
|
1480
|
+
# / "globbing" segment) are Rails path parameters and
|
|
1481
|
+
# contribute to helper arity. GitLab's
|
|
1482
|
+
# `path: '*namespace_id'` outer scope adds 1 to the
|
|
1483
|
+
# arity of every helper underneath.
|
|
1484
|
+
path.scan(/[:*][a-z_][a-z0-9_]*/).size
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
# Number of placeholders inside `(...)` groups — Rails'
|
|
1488
|
+
# optional-segment syntax (`'settings(/:tab)'`). Required
|
|
1489
|
+
# arity is `count_path_placeholders - count_optional`;
|
|
1490
|
+
# callers accepting a `(required..required+optional)`
|
|
1491
|
+
# arity range register an entry per achievable arity.
|
|
1492
|
+
def count_optional_path_placeholders(path)
|
|
1493
|
+
return 0 if path.nil?
|
|
1494
|
+
|
|
1495
|
+
path.scan(/\([^)]*\)/).sum { |group| group.scan(/[:*][a-z_][a-z0-9_]*/).size }
|
|
469
1496
|
end
|
|
470
1497
|
|
|
471
1498
|
# Shared with `Context::Inflector#singularize` — kept in
|
|
@@ -473,13 +1500,34 @@ module Rigor
|
|
|
473
1500
|
# other.
|
|
474
1501
|
UNCOUNTABLE = %w[
|
|
475
1502
|
equipment information rice money species series fish
|
|
476
|
-
sheep jeans police news
|
|
1503
|
+
sheep jeans police news settings
|
|
477
1504
|
].to_set.freeze
|
|
478
1505
|
|
|
1506
|
+
# Same `IRREGULAR_SINGULARS` map as `Context#singularize`.
|
|
1507
|
+
IRREGULAR_SINGULARS = {
|
|
1508
|
+
"media" => "medium",
|
|
1509
|
+
"data" => "datum",
|
|
1510
|
+
"criteria" => "criterion",
|
|
1511
|
+
"phenomena" => "phenomenon"
|
|
1512
|
+
}.freeze
|
|
1513
|
+
|
|
479
1514
|
def singularize_word(word)
|
|
1515
|
+
return IRREGULAR_SINGULARS[word] if IRREGULAR_SINGULARS.key?(word)
|
|
480
1516
|
return word if UNCOUNTABLE.include?(word)
|
|
481
1517
|
return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
|
|
482
|
-
|
|
1518
|
+
# Rails-shipped `(alias|status|bus)es$` → `$1`
|
|
1519
|
+
# specifics — without these the generic chomp rules
|
|
1520
|
+
# mis-singularise `statuses` → `statuse` and
|
|
1521
|
+
# `aliases` → `aliase`.
|
|
1522
|
+
return word.chomp("es") if /(?:alias|status|bus)es\z/.match?(word)
|
|
1523
|
+
# Match the Rails default-inflector rule:
|
|
1524
|
+
# `(x|ch|ss|sh)es$` → strip "es". A generic `ses`
|
|
1525
|
+
# suffix would over-strip (`databases` → `databas`).
|
|
1526
|
+
return word.chomp("es") if word.end_with?("ches", "shes", "sses", "xes", "zes")
|
|
1527
|
+
# Preserve trailing `ss` — Rails ships
|
|
1528
|
+
# `inflect.singular(/(ss)$/i, '\1')` so `custom_css`
|
|
1529
|
+
# singularises as `custom_css`, not `custom_cs`.
|
|
1530
|
+
return word if word.end_with?("ss")
|
|
483
1531
|
return word.chomp("s") if word.end_with?("s")
|
|
484
1532
|
|
|
485
1533
|
word
|