rails_map 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.idea/.gitignore +8 -0
  3. data/.idea/material_theme_project_new.xml +12 -0
  4. data/AUTHENTICATION.md +221 -0
  5. data/CHANGELOG.md +75 -0
  6. data/Gemfile +10 -0
  7. data/LICENSE.txt +21 -0
  8. data/QUICKSTART.md +156 -0
  9. data/README.md +178 -0
  10. data/Rakefile +8 -0
  11. data/app/controllers/rails_map/docs_controller.rb +73 -0
  12. data/app/models/rails_map/user.rb +12 -0
  13. data/app/views/rails_map/docs/_styles.html.erb +489 -0
  14. data/app/views/rails_map/docs/controller.html.erb +137 -0
  15. data/app/views/rails_map/docs/index.html.erb +212 -0
  16. data/app/views/rails_map/docs/model.html.erb +214 -0
  17. data/app/views/rails_map/docs/routes.html.erb +139 -0
  18. data/config/initializers/rails_map.example.rb +44 -0
  19. data/config/routes.rb +9 -0
  20. data/docs/index.html +1354 -0
  21. data/lib/generators/rails_map/install_generator.rb +49 -0
  22. data/lib/generators/rails_map/templates/README +47 -0
  23. data/lib/generators/rails_map/templates/initializer.rb +38 -0
  24. data/lib/generators/rails_map/templates/migration.rb +14 -0
  25. data/lib/rails_map/auth.rb +15 -0
  26. data/lib/rails_map/configuration.rb +35 -0
  27. data/lib/rails_map/engine.rb +11 -0
  28. data/lib/rails_map/generators/html_generator.rb +120 -0
  29. data/lib/rails_map/parsers/model_parser.rb +257 -0
  30. data/lib/rails_map/parsers/route_parser.rb +356 -0
  31. data/lib/rails_map/railtie.rb +46 -0
  32. data/lib/rails_map/version.rb +5 -0
  33. data/lib/rails_map.rb +66 -0
  34. data/templates/controller.html.erb +74 -0
  35. data/templates/index.html.erb +64 -0
  36. data/templates/layout.html.erb +289 -0
  37. data/templates/model.html.erb +219 -0
  38. data/templates/routes.html.erb +86 -0
  39. metadata +144 -0
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMap
4
+ module Parsers
5
+ class RouteParser
6
+ RouteInfo = Struct.new(
7
+ :verb, :path, :controller, :action, :name, :constraints, :defaults, :base_path,
8
+ :path_params, :query_params, :request_body_params,
9
+ keyword_init: true
10
+ )
11
+
12
+ def parse
13
+ routes = collect_routes
14
+ Rails.logger.info "RouteParser: Collected #{routes.size} routes" if defined?(Rails.logger)
15
+ grouped = group_by_controller(routes)
16
+ Rails.logger.info "RouteParser: Grouped into #{grouped.size} controllers" if defined?(Rails.logger)
17
+ grouped
18
+ end
19
+
20
+ private
21
+
22
+ def collect_routes
23
+ return [] unless defined?(Rails)
24
+
25
+ Rails.application.routes.routes.map do |route|
26
+ next if internal_route?(route)
27
+
28
+ requirements = route.requirements
29
+ controller = requirements[:controller]
30
+ action = requirements[:action]
31
+
32
+ next unless controller && action
33
+
34
+ path = extract_path(route)
35
+ path_params = extract_path_params(path)
36
+
37
+ RouteInfo.new(
38
+ verb: extract_verb(route),
39
+ path: path,
40
+ controller: controller,
41
+ action: action,
42
+ name: route.name,
43
+ constraints: extract_constraints(route),
44
+ defaults: extract_defaults(route),
45
+ base_path: extract_base_path(path),
46
+ path_params: path_params,
47
+ query_params: extract_query_params(controller, action),
48
+ request_body_params: extract_body_params(controller, action)
49
+ )
50
+ end.compact
51
+ end
52
+
53
+ def extract_verb(route)
54
+ verb = route.verb
55
+ return verb if verb.is_a?(String)
56
+ return verb.source.gsub(/[$^]/, "") if verb.respond_to?(:source)
57
+
58
+ "ANY"
59
+ end
60
+
61
+ def extract_path(route)
62
+ path = route.path.spec.to_s
63
+ # Remove format segment if present
64
+ path.gsub("(.:format)", "")
65
+ end
66
+
67
+ def extract_path_params(path)
68
+ # Extract parameters from path like :id, :user_id, etc.
69
+ params = []
70
+ path.scan(/:(\w+)/).flatten.each do |param|
71
+ params << {
72
+ name: param,
73
+ type: infer_param_type(param),
74
+ required: true,
75
+ location: 'path'
76
+ }
77
+ end
78
+ params
79
+ end
80
+
81
+ def extract_query_params(controller, action)
82
+ # Try to extract query parameters from controller action
83
+ params = []
84
+ begin
85
+ controller_file = find_controller_file(controller)
86
+ return params unless controller_file && File.exist?(controller_file)
87
+
88
+ content = File.read(controller_file)
89
+
90
+ # Look for params.permit or params.require patterns in the action
91
+ action_content = extract_action_content(content, action)
92
+ return params unless action_content
93
+
94
+ # Extract permitted parameters
95
+ permitted_params = extract_permitted_params(action_content)
96
+
97
+ # For GET requests, these are typically query params
98
+ permitted_params.each do |param|
99
+ params << {
100
+ name: param[:name],
101
+ type: param[:type] || 'string',
102
+ required: param[:required] || false,
103
+ location: 'query'
104
+ }
105
+ end
106
+ rescue => e
107
+ Rails.logger.debug "Could not extract query params for #{controller}##{action}: #{e.message}" if defined?(Rails.logger)
108
+ end
109
+ params
110
+ end
111
+
112
+ def extract_body_params(controller, action)
113
+ # Try to extract request body parameters from controller action
114
+ params = []
115
+ begin
116
+ controller_file = find_controller_file(controller)
117
+ return params unless controller_file && File.exist?(controller_file)
118
+
119
+ content = File.read(controller_file)
120
+
121
+ # Look for params.permit or params.require patterns in the action
122
+ action_content = extract_action_content(content, action)
123
+ return params unless action_content
124
+
125
+ # Extract permitted parameters
126
+ permitted_params = extract_permitted_params(action_content)
127
+
128
+ # For POST/PUT/PATCH requests, these are typically body params
129
+ permitted_params.each do |param|
130
+ params << {
131
+ name: param[:name],
132
+ type: param[:type] || 'string',
133
+ required: param[:required] || false,
134
+ location: 'body'
135
+ }
136
+ end
137
+ rescue => e
138
+ Rails.logger.debug "Could not extract body params for #{controller}##{action}: #{e.message}" if defined?(Rails.logger)
139
+ end
140
+ params
141
+ end
142
+
143
+ def find_controller_file(controller)
144
+ return nil unless defined?(Rails.root)
145
+ Rails.root.join('app', 'controllers', "#{controller}_controller.rb")
146
+ end
147
+
148
+ def extract_action_content(content, action)
149
+ # Extract the content of a specific action method
150
+ action_regex = /def\s+#{Regexp.escape(action)}\b.*?(?=\n\s*def\s|\n\s*private\s|\n\s*protected\s|\nend\s*\z)/m
151
+ match = content.match(action_regex)
152
+ match ? match[0] : nil
153
+ end
154
+
155
+ def extract_permitted_params(action_content)
156
+ params = []
157
+
158
+ # Pattern 1: params.require(:model).permit(:attr1, :attr2, ...)
159
+ require_permit_pattern = /params\.require\(:(\w+)\)\.permit\((.*?)\)/m
160
+ if match = action_content.match(require_permit_pattern)
161
+ model_name = match[1]
162
+ permitted_attrs = match[2]
163
+
164
+ # Extract individual attributes
165
+ permitted_attrs.scan(/:(\w+)/).flatten.each do |attr|
166
+ params << {
167
+ name: "#{model_name}[#{attr}]",
168
+ type: infer_param_type(attr),
169
+ required: true
170
+ }
171
+ end
172
+ end
173
+
174
+ # Pattern 2: params.permit(:attr1, :attr2, ...)
175
+ permit_pattern = /params\.permit\((.*?)\)/m
176
+ action_content.scan(permit_pattern).flatten.each do |permitted_attrs|
177
+ permitted_attrs.scan(/:(\w+)/).flatten.each do |attr|
178
+ # Avoid duplicates
179
+ unless params.any? { |p| p[:name].include?(attr) }
180
+ params << {
181
+ name: attr,
182
+ type: infer_param_type(attr),
183
+ required: false
184
+ }
185
+ end
186
+ end
187
+ end
188
+
189
+ # Pattern 3: params[:key] or params['key']
190
+ params_access_pattern = /params\[['"]?:?(\w+)['"]?\]/
191
+ action_content.scan(params_access_pattern).flatten.uniq.each do |attr|
192
+ # Avoid duplicates and common Rails params
193
+ next if %w[controller action format id].include?(attr)
194
+ unless params.any? { |p| p[:name] == attr || p[:name].include?(attr) }
195
+ params << {
196
+ name: attr,
197
+ type: infer_param_type(attr),
198
+ required: false
199
+ }
200
+ end
201
+ end
202
+
203
+ params.uniq { |p| p[:name] }
204
+ end
205
+
206
+ def infer_param_type(param_name)
207
+ # Infer parameter type from name
208
+ case param_name.to_s
209
+ when /_(id|ids)$/
210
+ 'integer'
211
+ when /^is_/, /^has_/, /_flag$/, /_enabled$/
212
+ 'boolean'
213
+ when /_at$/, /_date$/
214
+ 'datetime'
215
+ when /_count$/, /_number$/, /^count_/, /^num_/
216
+ 'integer'
217
+ when /_price$/, /_amount$/, /_total$/
218
+ 'decimal'
219
+ when /_email$/
220
+ 'email'
221
+ when /_url$/
222
+ 'url'
223
+ else
224
+ 'string'
225
+ end
226
+ end
227
+
228
+ def extract_constraints(route)
229
+ constraints = {}
230
+ route.requirements.each do |key, value|
231
+ next if %i[controller action].include?(key)
232
+
233
+ constraints[key] = value.is_a?(Regexp) ? value.source : value.to_s
234
+ end
235
+ constraints
236
+ end
237
+
238
+ def extract_defaults(route)
239
+ defaults = {}
240
+ route.defaults.each do |key, value|
241
+ next if %i[controller action].include?(key)
242
+
243
+ defaults[key] = value.to_s
244
+ end
245
+ defaults
246
+ end
247
+
248
+ def group_by_controller(routes)
249
+ grouped = routes.group_by(&:controller)
250
+
251
+ grouped.transform_values do |controller_routes|
252
+ sorted_routes = controller_routes.sort_by { |r| [r.path, verb_order(r.verb)] }
253
+ {
254
+ routes: sorted_routes,
255
+ actions: controller_routes.map(&:action).uniq.sort,
256
+ base_path: find_common_base_path(controller_routes)
257
+ }
258
+ end.sort_by { |controller, _| controller.downcase }.to_h
259
+ end
260
+
261
+ def extract_base_path(path)
262
+ # Extract first meaningful segment: /users/:id -> /users
263
+ segments = path.split('/').reject(&:empty?)
264
+ return '/' if segments.empty?
265
+
266
+ # Take first segment that doesn't start with : or *
267
+ base = segments.take_while { |s| !s.start_with?(':', '*') }
268
+ base.empty? ? "/#{segments.first}" : "/#{base.join('/')}"
269
+ end
270
+
271
+ def find_common_base_path(routes)
272
+ paths = routes.map(&:base_path).uniq
273
+ return paths.first if paths.size == 1
274
+
275
+ # Find common prefix
276
+ paths.min_by(&:length) || '/'
277
+ end
278
+
279
+ def verb_order(verb)
280
+ # Order: GET, POST, PUT, PATCH, DELETE, others
281
+ order = { 'GET' => 0, 'POST' => 1, 'PUT' => 2, 'PATCH' => 3, 'DELETE' => 4 }
282
+ order[verb.to_s.upcase] || 5
283
+ end
284
+
285
+ def internal_route?(route)
286
+ # Rails 7+ uses internal? method
287
+ return true if route.respond_to?(:internal?) && route.internal?
288
+
289
+ # Rails 6.x uses internal attribute
290
+ return true if route.respond_to?(:internal) && route.internal
291
+
292
+ # Check if it's a Rails internal route by path
293
+ path = route.path.spec.to_s rescue ''
294
+ return true if path.start_with?('/rails/')
295
+
296
+ # Check controller namespace - exclude gem/engine controllers
297
+ controller = route.requirements[:controller].to_s
298
+ return true if excluded_controller?(controller)
299
+
300
+ false
301
+ end
302
+
303
+ def excluded_controller?(controller)
304
+ return true if controller.nil? || controller.empty?
305
+
306
+ controller_downcase = controller.to_s.downcase
307
+
308
+ # Exclude common gem/internal controller namespaces (check first segment)
309
+ excluded_prefixes = %w[
310
+ rails_map
311
+ action_mailbox
312
+ action_cable
313
+ active_storage
314
+ action_text
315
+ turbo
316
+ devise
317
+ sidekiq
318
+ letter_opener
319
+ better_errors
320
+ web_console
321
+ solid_queue
322
+ solid_cache
323
+ mission_control
324
+ rails
325
+ graphiql
326
+ pghero
327
+ blazer
328
+ flipper
329
+ rswag
330
+ swagger
331
+ avo
332
+ administrate
333
+ rails_admin
334
+ good_job
335
+ que
336
+ delayed
337
+ ]
338
+
339
+ # Check if controller starts with any excluded prefix
340
+ first_segment = controller_downcase.split('/').first
341
+ return true if excluded_prefixes.include?(first_segment)
342
+
343
+ # Check prefix match (e.g., "action_mailbox/ingresses/...")
344
+ return true if excluded_prefixes.any? { |prefix| controller_downcase.start_with?("#{prefix}/") }
345
+
346
+ # Check if controller file exists in app/controllers of host app
347
+ if defined?(Rails.root)
348
+ controller_file = Rails.root.join('app', 'controllers', "#{controller}_controller.rb")
349
+ return true unless File.exist?(controller_file)
350
+ end
351
+
352
+ false
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RailsMap
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :rails_map
8
+
9
+ rake_tasks do
10
+ namespace :doc do
11
+ desc "Generate HTML documentation for routes and models"
12
+ task generate: :environment do
13
+ puts "Generating documentation..."
14
+ RailsMap.generate
15
+ end
16
+
17
+ desc "Generate documentation and open in browser"
18
+ task open: :generate do
19
+ index_path = File.join(RailsMap.configuration.output_dir, "index.html")
20
+
21
+ if File.exist?(index_path)
22
+ system("open", index_path) || system("xdg-open", index_path) || system("start", index_path)
23
+ else
24
+ puts "Documentation not found at #{index_path}"
25
+ end
26
+ end
27
+
28
+ desc "Clean generated documentation"
29
+ task clean: :environment do
30
+ output_dir = RailsMap.configuration.output_dir
31
+ if Dir.exist?(output_dir)
32
+ FileUtils.rm_rf(output_dir)
33
+ puts "Removed documentation at #{output_dir}"
34
+ else
35
+ puts "No documentation found at #{output_dir}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Allow configuration via Rails initializer
42
+ initializer "rails_map.configure" do
43
+ # Configuration can be done in config/initializers/rails_map.rb
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMap
4
+ VERSION = "1.1.0"
5
+ end
data/lib/rails_map.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_map/version"
4
+ require_relative "rails_map/configuration"
5
+ require_relative "rails_map/auth"
6
+ require_relative "rails_map/parsers/route_parser"
7
+ require_relative "rails_map/parsers/model_parser"
8
+ require_relative "rails_map/generators/html_generator"
9
+
10
+ module RailsMap
11
+ class Error < StandardError; end
12
+
13
+ class << self
14
+ attr_writer :configuration
15
+
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration)
22
+ end
23
+
24
+ def generate
25
+ Generator.new(configuration).generate
26
+ end
27
+ end
28
+
29
+ class Generator
30
+ attr_reader :config
31
+
32
+ def initialize(config)
33
+ @config = config
34
+ end
35
+
36
+ def generate
37
+ ensure_output_directory
38
+
39
+ routes_data = Parsers::RouteParser.new.parse
40
+ models_data = Parsers::ModelParser.new.parse
41
+
42
+ generator = Generators::HtmlGenerator.new(
43
+ routes: routes_data,
44
+ models: models_data,
45
+ output_dir: config.output_dir
46
+ )
47
+
48
+ generator.generate_all
49
+
50
+ puts "Documentation generated successfully in #{config.output_dir}"
51
+ end
52
+
53
+ private
54
+
55
+ def ensure_output_directory
56
+ FileUtils.mkdir_p(config.output_dir)
57
+ FileUtils.mkdir_p(File.join(config.output_dir, "controllers"))
58
+ FileUtils.mkdir_p(File.join(config.output_dir, "models"))
59
+ end
60
+ end
61
+ end
62
+
63
+ if defined?(Rails::Railtie)
64
+ require_relative "rails_map/engine"
65
+ require_relative "rails_map/railtie"
66
+ end
@@ -0,0 +1,74 @@
1
+ <div class="breadcrumb">
2
+ <a href="../index.html">Home</a>
3
+ <span>/</span>
4
+ <a href="../routes.html">Routes</a>
5
+ <span>/</span>
6
+ <span><%= controller_name.camelize %>Controller</span>
7
+ </div>
8
+
9
+ <div class="card">
10
+ <div class="card-header">
11
+ <h2 class="card-title"><%= controller_name.camelize %>Controller</h2>
12
+ <span class="badge badge-any"><%= routes.size %> routes</span>
13
+ </div>
14
+
15
+ <div style="display: flex; gap: 2rem; margin-bottom: 1.5rem; flex-wrap: wrap;">
16
+ <div>
17
+ <strong>Base Path:</strong><br>
18
+ <code style="background: var(--primary-color); color: white; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 1rem;"><%= base_path || '/' %></code>
19
+ </div>
20
+ <div>
21
+ <strong>Actions:</strong><br>
22
+ <% actions.each_with_index do |action, index| %>
23
+ <code><%= action %></code><%= index < actions.size - 1 ? ' ' : '' %>
24
+ <% end %>
25
+ </div>
26
+ </div>
27
+
28
+ <% if routes.any? %>
29
+ <div class="table-container">
30
+ <table>
31
+ <thead>
32
+ <tr>
33
+ <th>Method</th>
34
+ <th>Path</th>
35
+ <th>Action</th>
36
+ <th>Route Name</th>
37
+ <th>Constraints</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <% routes.each do |route| %>
42
+ <tr>
43
+ <td>
44
+ <span class="badge badge-<%= route.verb.downcase.split('|').first %>">
45
+ <%= route.verb %>
46
+ </span>
47
+ </td>
48
+ <td><code><%= route.path %></code></td>
49
+ <td><code><%= route.action %></code></td>
50
+ <td>
51
+ <% if route.name %>
52
+ <code><%= route.name %></code>
53
+ <% else %>
54
+ <span style="color: var(--text-muted);">—</span>
55
+ <% end %>
56
+ </td>
57
+ <td>
58
+ <% if route.constraints.any? %>
59
+ <% route.constraints.each do |key, value| %>
60
+ <code><%= key %>: <%= value %></code><br>
61
+ <% end %>
62
+ <% else %>
63
+ <span style="color: var(--text-muted);">—</span>
64
+ <% end %>
65
+ </td>
66
+ </tr>
67
+ <% end %>
68
+ </tbody>
69
+ </table>
70
+ </div>
71
+ <% else %>
72
+ <div class="empty-state">No routes found for this controller</div>
73
+ <% end %>
74
+ </div>
@@ -0,0 +1,64 @@
1
+ <div class="stats">
2
+ <div class="stat">
3
+ <div class="stat-value"><%= controllers_count %></div>
4
+ <div class="stat-label">Controllers</div>
5
+ </div>
6
+ <div class="stat">
7
+ <div class="stat-value"><%= routes_count %></div>
8
+ <div class="stat-label">Routes</div>
9
+ </div>
10
+ <div class="stat">
11
+ <div class="stat-value"><%= models_count %></div>
12
+ <div class="stat-label">Models</div>
13
+ </div>
14
+ </div>
15
+
16
+ <div class="nav">
17
+ <a href="routes.html">All Routes</a>
18
+ </div>
19
+
20
+ <h2 class="section-header">Controllers</h2>
21
+ <% if controllers.any? %>
22
+ <div class="grid">
23
+ <% controllers.each do |controller, data| %>
24
+ <div class="card">
25
+ <div class="card-header">
26
+ <h3 class="card-title"><%= controller.camelize %>Controller</h3>
27
+ <span class="badge badge-any"><%= data[:routes].size %> routes</span>
28
+ </div>
29
+ <p style="margin-bottom: 0.5rem;">
30
+ <code style="background: var(--primary-color); color: white; padding: 0.25rem 0.5rem; border-radius: 0.25rem;"><%= data[:base_path] || '/' %></code>
31
+ </p>
32
+ <p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem;">
33
+ Actions: <%= data[:actions].join(', ') %>
34
+ </p>
35
+ <a href="controllers/<%= controller.gsub('/', '_') %>.html" class="link">View Details →</a>
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+ <% else %>
40
+ <div class="empty-state">No controllers found</div>
41
+ <% end %>
42
+
43
+ <h2 class="section-header">Models</h2>
44
+ <% if models.any? %>
45
+ <div class="grid">
46
+ <% models.each do |name, model| %>
47
+ <div class="card">
48
+ <div class="card-header">
49
+ <h3 class="card-title"><%= name %></h3>
50
+ <span class="badge badge-any"><%= model.columns.size %> columns</span>
51
+ </div>
52
+ <p style="color: var(--text-muted); margin-bottom: 0.5rem;">
53
+ Table: <code><%= model.table_name || 'N/A' %></code>
54
+ </p>
55
+ <p style="color: var(--text-muted); margin-bottom: 1rem;">
56
+ <%= model.associations.size %> associations
57
+ </p>
58
+ <a href="models/<%= name.underscore.gsub('/', '_') %>.html" class="link">View Details →</a>
59
+ </div>
60
+ <% end %>
61
+ </div>
62
+ <% else %>
63
+ <div class="empty-state">No models found</div>
64
+ <% end %>