rigortype 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  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/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -0,0 +1,1538 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "helper_table"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class RailsRoutes < Rigor::Plugin::Base
10
+ # Statically interprets `config/routes.rb`'s DSL via
11
+ # Prism — never executes the file. The interpreter is
12
+ # deliberately narrow; it covers the subset documented
13
+ # in the plugin's README and degrades silently on
14
+ # constructs it doesn't recognise.
15
+ #
16
+ # Recognised DSL surface (per the Rails-plugins
17
+ # roadmap):
18
+ #
19
+ # - `Rails.application.routes.draw do ... end` (entry
20
+ # block; the body is interpreted)
21
+ # - `resources :name [, only: [...] | except: [...]]`
22
+ # - `resource :name`
23
+ # - `get/post/patch/put/delete "path", to:, as:`
24
+ # - `root to: "..."` / `root "..."`
25
+ # - `scope "path", as: :name do ... end` (with `as:` key)
26
+ # - One level of `namespace :foo do ... end`
27
+ # - One level of nested `resources` (`resources :users
28
+ # do; resources :posts; end`)
29
+ # - `member do ... end` / `collection do ... end`
30
+ # inside `resources`
31
+ #
32
+ # Out of scope for v0.1.0 (silent skips):
33
+ #
34
+ # - `scope path:` / `scope module:` (path/module-only, no `as:`)
35
+ # - Constraints (`constraints: { id: /\d+/ }`)
36
+ # - `mount` / engine routes
37
+ # - `direct(:name) { |obj| ... }`
38
+ # - Format restrictions
39
+ module RoutesParser
40
+ # Standard resource actions Rails generates by default.
41
+ DEFAULT_RESOURCE_ACTIONS = %i[index show new create edit update destroy].freeze
42
+ # Default actions for `resource` (singular) — no index,
43
+ # no `:id` segment.
44
+ DEFAULT_SINGULAR_ACTIONS = %i[show new create edit update destroy].freeze
45
+
46
+ # Helper-name conventions per action. `:show` and
47
+ # `:update` / `:destroy` share the singular-form
48
+ # helper (Rails dedupes).
49
+ ACTION_HTTP_METHODS = {
50
+ index: :get,
51
+ show: :get,
52
+ new: :get,
53
+ create: :post,
54
+ edit: :get,
55
+ update: :patch, # also :put
56
+ destroy: :delete
57
+ }.freeze
58
+
59
+ module_function
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.
65
+ # @return [HelperTable]
66
+ def parse(contents, file_reader: nil, custom_helpers: [])
67
+ parse_result = Prism.parse(contents)
68
+ return HelperTable.new([], custom_helpers: custom_helpers) unless parse_result.errors.empty?
69
+
70
+ context = Context.new(file_reader: file_reader)
71
+ interpret(parse_result.value, context)
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
+
79
+ # Each helper has both `_path` and `_url` forms.
80
+ paired = context.entries.flat_map do |entry|
81
+ [
82
+ entry,
83
+ HelperTable::Entry.new(
84
+ name: entry.name.sub(/_path\z/, "_url"),
85
+ arity: entry.arity,
86
+ path: entry.path,
87
+ http_method: entry.http_method,
88
+ action: entry.action
89
+ )
90
+ ]
91
+ end
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)
135
+ end
136
+
137
+ # Per-parse mutable accumulator. Tracks the current
138
+ # nesting prefix (namespaces + parent resource) and the
139
+ # entries collected so far.
140
+ class Context
141
+ attr_reader :entries, :file_reader, :devise_resources, :alias_rules
142
+
143
+ def initialize(file_reader: nil)
144
+ @entries = []
145
+ @file_reader = file_reader
146
+ # Stack of prefix segments. Each entry is one of:
147
+ # - `{ kind: :namespace, name: "admin" }`
148
+ # - `{ kind: :scope, parent: "user", arity_segments: [":user_id"] }`
149
+ # - `{ kind: :as_scope, name: "event", path: "/:event_slug", arity: 1 }`
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]
184
+ end
185
+
186
+ def push_namespace(name)
187
+ @stack.push(kind: :namespace, name: name.to_s)
188
+ yield
189
+ ensure
190
+ @stack.pop
191
+ end
192
+
193
+ def push_resource(parent_name)
194
+ singular = singularize(parent_name.to_s)
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)
292
+ yield
293
+ ensure
294
+ @stack.pop
295
+ end
296
+
297
+ # Helper-name prefix from namespaces (`admin_`,
298
+ # `admin_users_`, …).
299
+ def helper_prefix
300
+ segments = @stack.filter_map { |frame| frame_helper_segment(frame) }
301
+ segments.map { |segment| "#{segment}_" }.join
302
+ end
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
+
353
+ # Path prefix — including the parent's `:user_id`
354
+ # segments for nested resources and the namespace
355
+ # path prefix.
356
+ def path_prefix
357
+ parts = @stack.flat_map { |frame| frame_path_segments(frame) }
358
+ parts.join
359
+ end
360
+
361
+ # Number of dynamic segments (`:user_id`-style)
362
+ # captured by the parent scope chain. Used to
363
+ # compute helper arity for nested resources.
364
+ # Each nested-resource `:scope` frame contributes 1;
365
+ # each `:as_scope` frame contributes its own arity.
366
+ def parent_segment_count
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
374
+ end
375
+
376
+ private
377
+
378
+ def frame_helper_segment(frame)
379
+ case frame[:kind]
380
+ when :namespace then frame[:name]
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]
389
+ end
390
+ end
391
+
392
+ def frame_path_segments(frame)
393
+ case frame[:kind]
394
+ when :namespace then ["/#{frame[:name]}"]
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]]
406
+ else []
407
+ end
408
+ end
409
+
410
+ # Tiny English inflector. Sufficient for the standard
411
+ # `posts` ↔ `post`, `users` ↔ `user` rename Rails
412
+ # generates by default; users with custom
413
+ # inflections need to author RBS by hand for the
414
+ # affected helpers (out of scope for v0.1.0).
415
+ #
416
+ # The canonical English uncountable noun set from
417
+ # ActiveSupport::Inflector::Inflections (Rails 8.x).
418
+ # `singularize("news")` returns `"news"` rather than
419
+ # `"new"`. Pre-fix the parser stripped the trailing
420
+ # 's' from `news`, so `resources :news` registered
421
+ # `new_path` / `news_path` / `new_news_path` (broken
422
+ # — Rails actually generates `news_path` for both
423
+ # index and show, with the show form taking `:id`).
424
+ # Redmine hit this 81× across `news_path(id)` calls.
425
+ UNCOUNTABLE = %w[
426
+ equipment information rice money species series fish
427
+ sheep jeans police news settings
428
+ ].to_set.freeze
429
+ private_constant :UNCOUNTABLE
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
+
446
+ def singularize(word)
447
+ return IRREGULAR_SINGULARS[word] if IRREGULAR_SINGULARS.key?(word)
448
+ return word if UNCOUNTABLE.include?(word)
449
+ return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
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")
469
+ return word.chomp("s") if word.end_with?("s")
470
+
471
+ word
472
+ end
473
+
474
+ def pluralize(word)
475
+ return IRREGULAR_SINGULARS.key(word) if IRREGULAR_SINGULARS.value?(word)
476
+ return word if UNCOUNTABLE.include?(word)
477
+ return word if word.end_with?("s") && !word.end_with?("ss")
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")
485
+
486
+ "#{word}s"
487
+ end
488
+ end
489
+
490
+ def interpret(node, context)
491
+ return unless node.is_a?(Prism::Node)
492
+
493
+ case node
494
+ when Prism::CallNode
495
+ interpret_call(node, context)
496
+ else
497
+ node.compact_child_nodes.each { |child| interpret(child, context) }
498
+ end
499
+ end
500
+
501
+ def interpret_call(node, context)
502
+ case node.name
503
+ when :draw
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)
524
+ when :namespace
525
+ handle_namespace(node, context)
526
+ when :resources
527
+ handle_resources(node, context)
528
+ when :resource
529
+ handle_resource(node, context)
530
+ when :root
531
+ handle_root(node, context)
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.).
544
+ handle_explicit_route(node, context)
545
+ when :member, :collection
546
+ # Inside a `resources` block, `member do ... end`
547
+ # / `collection do ... end` introduces extra
548
+ # routes. Interpreted only when we have a parent
549
+ # scope (otherwise the call is meaningless).
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)
578
+ else
579
+ interpret_block_body(node, context)
580
+ end
581
+ end
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
+
695
+ def interpret_block_body(node, context)
696
+ body = node.block&.body
697
+ return if body.nil?
698
+
699
+ body.compact_child_nodes.each { |child| interpret(child, context) }
700
+ end
701
+
702
+ def handle_namespace(node, context)
703
+ name = symbol_argument(node, 0)
704
+ return interpret_block_body(node, context) if name.nil?
705
+
706
+ context.push_namespace(name) { interpret_block_body(node, context) }
707
+ end
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
+
858
+ def handle_resources(node, context)
859
+ name = symbol_argument(node, 0)
860
+ return interpret_block_body(node, context) if name.nil?
861
+
862
+ options = effective_options_for(node, context)
863
+ actions = restrict_actions_from(options, DEFAULT_RESOURCE_ACTIONS)
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
873
+
874
+ register_resourceful_helpers(helper_name, actions, base_arity, context, plural: true)
875
+
876
+ context.push_resource(name) do
877
+ replay_concerns_from_options(options, context)
878
+ interpret_block_body(node, context)
879
+ end
880
+ end
881
+
882
+ def handle_resource(node, context)
883
+ name = symbol_argument(node, 0)
884
+ return interpret_block_body(node, context) if name.nil?
885
+
886
+ options = effective_options_for(node, context)
887
+ actions = restrict_actions_from(options, DEFAULT_SINGULAR_ACTIONS)
888
+ base_arity = context.parent_segment_count
889
+ helper_name = options[:as] || name
890
+
891
+ # Singular resource — no `:id` segment, no `:index`
892
+ # / pluralised helper. The "show" helper is
893
+ # `<name>_path` (singular).
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?
936
+
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]
962
+ end
963
+
964
+ def handle_root(node, context)
965
+ # `root to: "..."` / `root "..."` — single helper
966
+ # `root_path`, arity 0, GET. Real-world Rails apps also
967
+ # use `root :to => 'welcome#index', :as => 'home'` (the
968
+ # canonical Redmine idiom across 230+ call sites), which
969
+ # registers an additional `home_path` / `home_url` alias
970
+ # for the same path. Mastodon and Solidus also use the
971
+ # `as:` form occasionally for analytics-friendly URL
972
+ # naming.
973
+ path = context.path_prefix.empty? ? "/" : context.path_prefix
974
+ context.entries << HelperTable::Entry.new(
975
+ name: "#{context.helper_prefix}root_path",
976
+ arity: 0, path: path, http_method: :get, action: :root
977
+ )
978
+
979
+ alias_name = keyword_symbol(node, :as)
980
+ return if alias_name.nil?
981
+
982
+ context.entries << HelperTable::Entry.new(
983
+ name: "#{context.helper_prefix}#{alias_name}_path",
984
+ arity: 0, path: path, http_method: :get, action: :root
985
+ )
986
+ end
987
+
988
+ def handle_explicit_route(node, context)
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)
1022
+ as_name = keyword_symbol(node, :as)
1023
+ return if as_name.nil? && path.nil?
1024
+
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
1191
+
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
1200
+ context.entries << HelperTable::Entry.new(
1201
+ name: name, arity: arity,
1202
+ path: "#{context.path_prefix}/#{action_name}",
1203
+ http_method: node.name, action: :custom
1204
+ )
1205
+ end
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
+
1222
+ def handle_member_or_collection(node, context)
1223
+ # Only meaningful when we're inside a `resources` /
1224
+ # `resource` block. The Context's stack tells us.
1225
+ resource = context.innermost_resource
1226
+ return interpret_block_body(node, context) if resource.nil?
1227
+
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
1232
+ end
1233
+
1234
+ def in_singular_resource?(*)
1235
+ # Slice 1 doesn't model the singular-resource frame
1236
+ # separately; placeholder so member / collection
1237
+ # blocks at least descend.
1238
+ true
1239
+ end
1240
+
1241
+ # Generate the standard helpers for a resource(s).
1242
+ # `plural: true` for `resources :users`, `false` for
1243
+ # `resource :profile`.
1244
+ def register_resourceful_helpers(name, actions, base_arity, context, plural:)
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}"
1262
+
1263
+ actions.each do |action|
1264
+ entry = entry_for_action(
1265
+ action,
1266
+ name: name, singular: singular, base_arity: base_arity,
1267
+ path_base: path_base, helper_prefix: context.helper_prefix, plural: plural
1268
+ )
1269
+ context.entries << entry if entry
1270
+ end
1271
+ end
1272
+
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.
1287
+ def entry_for_action(action, name:, singular:, base_arity:, path_base:, helper_prefix:, plural:)
1288
+ case action
1289
+ when :index then index_entry(plural, helper_prefix, name, base_arity, path_base, singular)
1290
+ when :show then show_entry(plural, helper_prefix, singular, base_arity, path_base)
1291
+ when :new
1292
+ HelperTable::Entry.new(
1293
+ name: "new_#{helper_prefix}#{singular}_path",
1294
+ arity: base_arity, path: "#{path_base}/new",
1295
+ http_method: :get, action: :new
1296
+ )
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
+ )
1328
+ end
1329
+ end
1330
+
1331
+ def index_entry(plural, helper_prefix, name, base_arity, path_base, singular)
1332
+ return nil unless plural
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
1360
+ HelperTable::Entry.new(
1361
+ name: index_name,
1362
+ arity: base_arity, path: path_base,
1363
+ http_method: :get, action: :index
1364
+ )
1365
+ end
1366
+
1367
+ def show_entry(plural, helper_prefix, singular, base_arity, path_base)
1368
+ show_path = plural ? "#{path_base}/:id" : path_base
1369
+ show_arity = plural ? base_arity + 1 : base_arity
1370
+ HelperTable::Entry.new(
1371
+ name: "#{helper_prefix}#{singular}_path",
1372
+ arity: show_arity, path: show_path,
1373
+ http_method: :get, action: :show
1374
+ )
1375
+ end
1376
+
1377
+ def edit_entry(plural, helper_prefix, singular, base_arity, path_base)
1378
+ edit_path = plural ? "#{path_base}/:id/edit" : "#{path_base}/edit"
1379
+ edit_arity = plural ? base_arity + 1 : base_arity
1380
+ HelperTable::Entry.new(
1381
+ name: "edit_#{helper_prefix}#{singular}_path",
1382
+ arity: edit_arity, path: edit_path,
1383
+ http_method: :get, action: :edit
1384
+ )
1385
+ end
1386
+
1387
+ def restrict_actions(node, default)
1388
+ restrict_actions_from(options_hash(node), default)
1389
+ end
1390
+
1391
+ def restrict_actions_from(options, default)
1392
+ # `resources :foo, only: :show` is the same as
1393
+ # `only: [:show]` in Rails; `options_hash` preserves the
1394
+ # Symbol shape from the source, so coerce here.
1395
+ if (only = options[:only])
1396
+ Array(only) & default
1397
+ elsif (except = options[:except])
1398
+ default - Array(except)
1399
+ else
1400
+ default
1401
+ end
1402
+ end
1403
+
1404
+ def options_hash(node)
1405
+ args = node.arguments&.arguments || []
1406
+ last = args.last
1407
+ return {} unless last.is_a?(Prism::KeywordHashNode)
1408
+
1409
+ last.elements.each_with_object({}) do |element, into|
1410
+ next unless element.is_a?(Prism::AssocNode)
1411
+ next unless element.key.is_a?(Prism::SymbolNode)
1412
+
1413
+ value = symbol_array(element.value) || symbol_value(element.value) || string_value(element.value)
1414
+ into[element.key.unescaped.to_sym] = value
1415
+ end
1416
+ end
1417
+
1418
+ def symbol_argument(node, index)
1419
+ arg = (node.arguments&.arguments || [])[index]
1420
+ symbol_value(arg)
1421
+ end
1422
+
1423
+ def string_argument(node, index)
1424
+ arg = (node.arguments&.arguments || [])[index]
1425
+ string_value(arg)
1426
+ end
1427
+
1428
+ def keyword_symbol(node, key)
1429
+ options_hash(node)[key]
1430
+ end
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
+
1461
+ def symbol_value(node)
1462
+ node.is_a?(Prism::SymbolNode) ? node.unescaped.to_sym : nil
1463
+ end
1464
+
1465
+ def string_value(node)
1466
+ node.is_a?(Prism::StringNode) ? node.unescaped : nil
1467
+ end
1468
+
1469
+ def symbol_array(node)
1470
+ return nil unless node.is_a?(Prism::ArrayNode)
1471
+
1472
+ values = node.elements.map { |e| symbol_value(e) }
1473
+ values.all? ? values : nil
1474
+ end
1475
+
1476
+ def count_path_placeholders(path)
1477
+ return 0 if path.nil?
1478
+
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 }
1496
+ end
1497
+
1498
+ # Shared with `Context::Inflector#singularize` — kept in
1499
+ # sync until one of the two call sites can adopt the
1500
+ # other.
1501
+ UNCOUNTABLE = %w[
1502
+ equipment information rice money species series fish
1503
+ sheep jeans police news settings
1504
+ ].to_set.freeze
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
+
1514
+ def singularize_word(word)
1515
+ return IRREGULAR_SINGULARS[word] if IRREGULAR_SINGULARS.key?(word)
1516
+ return word if UNCOUNTABLE.include?(word)
1517
+ return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
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")
1531
+ return word.chomp("s") if word.end_with?("s")
1532
+
1533
+ word
1534
+ end
1535
+ end
1536
+ end
1537
+ end
1538
+ end