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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules.rb +96 -3
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/plugins_command.rb +308 -0
  7. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  8. data/lib/rigor/cli/skill_command.rb +170 -0
  9. data/lib/rigor/cli.rb +37 -1
  10. data/lib/rigor/configuration/severity_profile.rb +3 -0
  11. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  12. data/lib/rigor/inference/expression_typer.rb +69 -30
  13. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  14. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  15. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  16. data/lib/rigor/inference/mutation_widening.rb +285 -0
  17. data/lib/rigor/inference/narrowing.rb +72 -4
  18. data/lib/rigor/inference/scope_indexer.rb +409 -12
  19. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  20. data/lib/rigor/scope.rb +195 -4
  21. data/lib/rigor/version.rb +1 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  23. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  24. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  25. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  27. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  28. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  29. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  34. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  35. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  36. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  37. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  42. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  43. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  44. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  45. data/sig/rigor/scope.rbs +23 -0
  46. data/skills/rigor-baseline-reduce/SKILL.md +100 -0
  47. data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
  48. data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
  49. data/skills/rigor-plugin-author/SKILL.md +95 -0
  50. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
  51. data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
  52. data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
  53. data/skills/rigor-project-init/SKILL.md +129 -0
  54. data/skills/rigor-project-init/references/01-detect.md +101 -0
  55. data/skills/rigor-project-init/references/02-configure.md +185 -0
  56. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
  57. data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
  58. 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 :path:` / `scope :module:` / `scope :as:`
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, arity_segments: [":#{singular}_id"])
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.count { |frame| frame[:kind] == :scope }
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 then ["/#{pluralize(frame[:parent])}/:#{frame[:parent]}_id"]
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 media settings
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
- return word.chomp("es") if word.end_with?("ses") || word.end_with?("xes")
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
- # `Rails.application.routes.draw do ... end` —
207
- # interpret the block body.
208
- interpret_block_body(node, context)
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 :get, :post, :patch, :put, :delete
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
- actions = restrict_actions(node, DEFAULT_RESOURCE_ACTIONS)
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(name, actions, base_arity, context, plural: true)
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
- actions = restrict_actions(node, DEFAULT_SINGULAR_ACTIONS)
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(name, actions, base_arity, context, plural: false)
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
- # Nested `resources :things` inside `resource :profile`
271
- # is rare; we still descend so the inner declarations
272
- # collect their own helpers.
273
- interpret_block_body(node, context)
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
- # `get "/about", to: "static#about", as: :about`
302
- path = string_argument(node, 0)
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 generates a helper
307
- # name from the path. For our static analysis
308
- # we only register helpers when we can name them
309
- # confidently i.e. when `as:` is present.
310
- return if as_name.nil?
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
- name = "#{context.helper_prefix}#{as_name}_path"
313
- arity = context.parent_segment_count + count_path_placeholders(path)
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}#{path || ''}",
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
- return unless context.parent_segment_count.positive? || in_singular_resource?(context)
1225
+ resource = context.innermost_resource
1226
+ return interpret_block_body(node, context) if resource.nil?
325
1227
 
326
- # The Context doesn't currently distinguish
327
- # "inside resources" from "inside resource" — for
328
- # v0.1.0 we treat both the same way and let the
329
- # explicit `as:` in member/collection do the
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
- singular = singularize_word(name.to_s)
346
- plural_form = plural ? name.to_s : singular # `resource :foo` uses singular path
347
- path_base = "#{context.path_prefix}/#{plural_form}"
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
- # `:create` / `:update` / `:destroy` don't generate
360
- # `*_path` helpers separate from the show / index
361
- # helper Rails reuses for their forms; the case
362
- # statement returns nil for those and the caller
363
- # skips them.
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}new_#{singular}_path",
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: "#{helper_prefix}#{name}_path",
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}edit_#{singular}_path",
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
- options = options_hash(node)
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
- path.scan(/:[a-z_][a-z0-9_]*/).size
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 media settings
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
- return word.chomp("es") if word.end_with?("ses") || word.end_with?("xes")
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