rails_lens 0.0.0 → 0.2.2

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +2 -2
  4. data/README.md +463 -9
  5. data/exe/rails_lens +25 -0
  6. data/lib/rails_lens/analyzers/association_analyzer.rb +111 -0
  7. data/lib/rails_lens/analyzers/base.rb +35 -0
  8. data/lib/rails_lens/analyzers/best_practices_analyzer.rb +114 -0
  9. data/lib/rails_lens/analyzers/column_analyzer.rb +97 -0
  10. data/lib/rails_lens/analyzers/composite_keys.rb +62 -0
  11. data/lib/rails_lens/analyzers/database_constraints.rb +35 -0
  12. data/lib/rails_lens/analyzers/delegated_types.rb +129 -0
  13. data/lib/rails_lens/analyzers/enums.rb +34 -0
  14. data/lib/rails_lens/analyzers/error_handling.rb +66 -0
  15. data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +47 -0
  16. data/lib/rails_lens/analyzers/generated_columns.rb +56 -0
  17. data/lib/rails_lens/analyzers/index_analyzer.rb +128 -0
  18. data/lib/rails_lens/analyzers/inheritance.rb +212 -0
  19. data/lib/rails_lens/analyzers/notes.rb +325 -0
  20. data/lib/rails_lens/analyzers/performance_analyzer.rb +110 -0
  21. data/lib/rails_lens/annotation_pipeline.rb +87 -0
  22. data/lib/rails_lens/cli.rb +176 -0
  23. data/lib/rails_lens/cli_error_handler.rb +86 -0
  24. data/lib/rails_lens/commands.rb +164 -0
  25. data/lib/rails_lens/connection.rb +133 -0
  26. data/lib/rails_lens/erd/column_type_formatter.rb +32 -0
  27. data/lib/rails_lens/erd/domain_color_mapper.rb +40 -0
  28. data/lib/rails_lens/erd/mysql_column_type_formatter.rb +19 -0
  29. data/lib/rails_lens/erd/postgresql_column_type_formatter.rb +19 -0
  30. data/lib/rails_lens/erd/visualizer.rb +329 -0
  31. data/lib/rails_lens/errors.rb +78 -0
  32. data/lib/rails_lens/extension_loader.rb +261 -0
  33. data/lib/rails_lens/extensions/base.rb +194 -0
  34. data/lib/rails_lens/extensions/closure_tree_ext.rb +157 -0
  35. data/lib/rails_lens/file_insertion_helper.rb +168 -0
  36. data/lib/rails_lens/mailer/annotator.rb +226 -0
  37. data/lib/rails_lens/mailer/extractor.rb +201 -0
  38. data/lib/rails_lens/model_detector.rb +252 -0
  39. data/lib/rails_lens/parsers/class_info.rb +46 -0
  40. data/lib/rails_lens/parsers/module_info.rb +33 -0
  41. data/lib/rails_lens/parsers/parser_result.rb +55 -0
  42. data/lib/rails_lens/parsers/prism_parser.rb +90 -0
  43. data/lib/rails_lens/parsers.rb +10 -0
  44. data/lib/rails_lens/providers/association_notes_provider.rb +11 -0
  45. data/lib/rails_lens/providers/base.rb +37 -0
  46. data/lib/rails_lens/providers/best_practices_notes_provider.rb +11 -0
  47. data/lib/rails_lens/providers/column_notes_provider.rb +11 -0
  48. data/lib/rails_lens/providers/composite_keys_provider.rb +11 -0
  49. data/lib/rails_lens/providers/database_constraints_provider.rb +11 -0
  50. data/lib/rails_lens/providers/delegated_types_provider.rb +11 -0
  51. data/lib/rails_lens/providers/enums_provider.rb +11 -0
  52. data/lib/rails_lens/providers/extension_notes_provider.rb +20 -0
  53. data/lib/rails_lens/providers/extensions_provider.rb +22 -0
  54. data/lib/rails_lens/providers/foreign_key_notes_provider.rb +11 -0
  55. data/lib/rails_lens/providers/generated_columns_provider.rb +11 -0
  56. data/lib/rails_lens/providers/index_notes_provider.rb +20 -0
  57. data/lib/rails_lens/providers/inheritance_provider.rb +23 -0
  58. data/lib/rails_lens/providers/notes_provider_base.rb +25 -0
  59. data/lib/rails_lens/providers/performance_notes_provider.rb +11 -0
  60. data/lib/rails_lens/providers/schema_provider.rb +61 -0
  61. data/lib/rails_lens/providers/section_provider_base.rb +28 -0
  62. data/lib/rails_lens/railtie.rb +17 -0
  63. data/lib/rails_lens/rake_bootstrapper.rb +18 -0
  64. data/lib/rails_lens/route/annotator.rb +268 -0
  65. data/lib/rails_lens/route/extractor.rb +133 -0
  66. data/lib/rails_lens/route/parser.rb +59 -0
  67. data/lib/rails_lens/schema/adapters/base.rb +345 -0
  68. data/lib/rails_lens/schema/adapters/database_info.rb +118 -0
  69. data/lib/rails_lens/schema/adapters/mysql.rb +279 -0
  70. data/lib/rails_lens/schema/adapters/postgresql.rb +197 -0
  71. data/lib/rails_lens/schema/adapters/sqlite3.rb +96 -0
  72. data/lib/rails_lens/schema/annotation.rb +144 -0
  73. data/lib/rails_lens/schema/annotation_manager.rb +202 -0
  74. data/lib/rails_lens/tasks/annotate.rake +35 -0
  75. data/lib/rails_lens/tasks/erd.rake +24 -0
  76. data/lib/rails_lens/tasks/mailers.rake +27 -0
  77. data/lib/rails_lens/tasks/routes.rake +27 -0
  78. data/lib/rails_lens/tasks/schema.rake +108 -0
  79. data/lib/rails_lens/version.rb +5 -0
  80. data/lib/rails_lens.rb +138 -5
  81. metadata +215 -11
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ class IndexNotesProvider < Base
6
+ def type
7
+ :notes
8
+ end
9
+
10
+ def applicable?(model_class)
11
+ model_has_table?(model_class)
12
+ end
13
+
14
+ def process(model_class)
15
+ analyzer = Analyzers::IndexAnalyzer.new(model_class)
16
+ analyzer.analyze
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ class InheritanceProvider < Base
6
+ def type
7
+ :section
8
+ end
9
+
10
+ def process(model_class)
11
+ analyzer = Analyzers::Inheritance.new(model_class)
12
+ content = analyzer.analyze
13
+
14
+ return nil unless content
15
+
16
+ {
17
+ title: nil, # Content already includes section header
18
+ content: content
19
+ }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ # Base class for notes providers that wrap analyzers
6
+ class NotesProviderBase < Base
7
+ def type
8
+ :notes
9
+ end
10
+
11
+ def applicable?(model_class)
12
+ model_has_table?(model_class)
13
+ end
14
+
15
+ def analyzer_class
16
+ raise NotImplementedError, "#{self.class} must implement #analyzer_class"
17
+ end
18
+
19
+ def process(model_class)
20
+ analyzer = analyzer_class.new(model_class)
21
+ analyzer.analyze
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ class PerformanceNotesProvider < NotesProviderBase
6
+ def analyzer_class
7
+ Analyzers::PerformanceAnalyzer
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ class SchemaProvider < Base
6
+ def type
7
+ :schema
8
+ end
9
+
10
+ def applicable?(_model_class)
11
+ true # Always applicable - handles both abstract and regular models
12
+ end
13
+
14
+ def process(model_class)
15
+ if model_class.abstract_class?
16
+ # For abstract classes, show database connection information in TOML format
17
+ connection = model_class.connection
18
+ adapter_name = connection.adapter_name
19
+
20
+ lines = []
21
+
22
+ # Get connection name
23
+ begin
24
+ connection_name = connection.pool.db_config.name
25
+ lines << "connection = \"#{connection_name}\""
26
+ rescue StandardError
27
+ lines << 'connection = "unknown"'
28
+ end
29
+
30
+ lines << "database_dialect = \"#{adapter_name}\""
31
+
32
+ # Add database version information
33
+ begin
34
+ db_version = connection.database_version
35
+ lines << "database_version = \"#{db_version}\""
36
+ rescue StandardError
37
+ lines << 'database_version = "unknown"'
38
+ end
39
+
40
+ # Add database name if available
41
+ begin
42
+ db_name = connection.current_database
43
+ lines << "database_name = \"#{db_name}\"" if db_name
44
+ rescue StandardError
45
+ # Skip if can't get database name
46
+ end
47
+
48
+ lines << ''
49
+ lines << '# This is an abstract class that establishes a database connection'
50
+ lines << '# but does not have an associated table.'
51
+
52
+ lines.join("\n")
53
+ else
54
+ # Add schema information for regular models
55
+ adapter = Connection.adapter_for(model_class)
56
+ adapter.generate_annotation(model_class)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ # Base class for section providers that wrap analyzers
6
+ class SectionProviderBase < Base
7
+ def type
8
+ :section
9
+ end
10
+
11
+ def analyzer_class
12
+ raise NotImplementedError, "#{self.class} must implement #analyzer_class"
13
+ end
14
+
15
+ def process(model_class)
16
+ analyzer = analyzer_class.new(model_class)
17
+ content = analyzer.analyze
18
+
19
+ return nil unless content
20
+
21
+ {
22
+ title: nil, # Analyzers include their own section headers
23
+ content: content
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module RailsLens
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :rails_lens
8
+
9
+ rake_tasks do
10
+ load 'rails_lens/tasks/annotate.rake'
11
+ load 'rails_lens/tasks/erd.rake'
12
+ load 'rails_lens/tasks/schema.rake'
13
+ load 'rails_lens/tasks/routes.rake'
14
+ load 'rails_lens/tasks/mailers.rake'
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ class RakeBootstrapper
5
+ class << self
6
+ def call
7
+ require 'rake'
8
+ load './Rakefile' if File.exist?('./Rakefile') && !Rake::Task.task_defined?(:environment)
9
+
10
+ begin
11
+ Rake::Task[:environment].invoke
12
+ rescue StandardError
13
+ nil
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Route
5
+ # Handles adding route annotations to controller files
6
+ class Annotator
7
+ def initialize(dry_run: false)
8
+ @dry_run = dry_run
9
+ @routes = RailsLens::Route::Extractor.call
10
+ @changed_files = []
11
+ end
12
+
13
+ # Annotate all controller files with route information
14
+ #
15
+ # @param pattern [String] Glob pattern for controller files
16
+ # @param exclusion [String] Glob pattern for files to exclude
17
+ # @return [Array<String>] List of changed files
18
+ def annotate_all(pattern: '**/*_controller.rb', exclusion: 'vendor/**/*_controller.rb')
19
+ controller_paths = Rails.root.glob(pattern)
20
+ .reject { |path| Rails.root.glob(exclusion).include?(path) }
21
+
22
+ source_paths_map.each do |source_path, actions|
23
+ # Use exact path matching or find controller files by parsing
24
+ if controller_paths.include?(source_path) || controller_file_exists?(source_path)
25
+ annotate_file(path: source_path, actions: actions)
26
+ end
27
+ end
28
+
29
+ @changed_files
30
+ end
31
+
32
+ # Remove route annotations from all controller files
33
+ #
34
+ # @param pattern [String] Glob pattern for controller files
35
+ # @param exclusion [String] Glob pattern for files to exclude
36
+ # @return [Array<String>] List of changed files
37
+ def remove_all(pattern: '**/*_controller.rb', exclusion: 'vendor/**/*_controller.rb')
38
+ controller_paths = Rails.root.glob(pattern)
39
+ .reject { |path| Rails.root.glob(exclusion).include?(path) }
40
+
41
+ # Also include controller files from source paths
42
+ all_controller_paths = (controller_paths + source_paths_map.keys).uniq
43
+ all_controller_paths.select! { |path| controller_file_exists?(path) }
44
+
45
+ all_controller_paths.each do |path|
46
+ remove_annotations_from_file(path)
47
+ end
48
+
49
+ @changed_files
50
+ end
51
+
52
+ private
53
+
54
+ # Map source paths to their respective routes
55
+ #
56
+ # @return [Hash] Source paths mapped to their routes
57
+ def source_paths_map
58
+ map = {}
59
+
60
+ @routes.each_value do |actions|
61
+ actions.each do |action, data|
62
+ data.each do |datum|
63
+ map[datum[:source_path]] ||= {}
64
+ map[datum[:source_path]][action] ||= []
65
+ map[datum[:source_path]][action].push(datum)
66
+ end
67
+ end
68
+ end
69
+
70
+ map
71
+ end
72
+
73
+ # Annotate a single controller file
74
+ #
75
+ # @param path [String] Path to controller file
76
+ # @param actions [Hash] Action data for the controller
77
+ # @return [void]
78
+ def annotate_file(path:, actions:)
79
+ parsed_file = RailsLens::Route::Parser.call(path: path, actions: actions.keys)
80
+
81
+ parsed_file[:groups].each_cons(2) do |prev, curr|
82
+ clean_group(prev)
83
+ next unless curr[:type] == :action
84
+
85
+ route_data = actions[curr[:action]]
86
+ next unless route_data&.any?
87
+
88
+ annotate_group(group: curr, route_data: route_data)
89
+ end
90
+
91
+ write_to_file(path: path, parsed_file: parsed_file)
92
+ end
93
+
94
+ # Remove annotations from a single file
95
+ #
96
+ # @param path [String] Path to controller file
97
+ # @return [void]
98
+ def remove_annotations_from_file(path)
99
+ content = File.read(path)
100
+ original_content = content.dup
101
+
102
+ # Remove rails-lens route annotations
103
+ content.gsub!(/^.*<rails-lens:routes:begin>.*$\n/, '')
104
+ content.gsub!(/^.*<rails-lens:routes:end>.*$\n/, '')
105
+ content.gsub!(/^\s*#\s*@route.*$\n/, '')
106
+ content.gsub!(/^\s*#\s*ROUTE:.*$\n/, '')
107
+
108
+ return unless content != original_content
109
+
110
+ write_content_to_file(path: path, content: content)
111
+ end
112
+
113
+ # Clean existing route annotations from a comment group
114
+ #
115
+ # @param group [Hash] Parsed group from parser
116
+ # @return [void]
117
+ def clean_group(group)
118
+ return unless group[:type] == :comment
119
+
120
+ # Remove existing route annotations
121
+ group[:body] = group[:body].gsub(/^\s*#\s*@route.*$\n/, '')
122
+ group[:body] = group[:body].gsub(/^\s*#\s*ROUTE:.*$\n/, '')
123
+ group[:body] = group[:body].gsub(/^.*<rails-lens:routes:begin>.*$\n/, '')
124
+ group[:body] = group[:body].gsub(/^.*<rails-lens:routes:end>.*$\n/, '')
125
+ end
126
+
127
+ # Add route annotations to a group
128
+ #
129
+ # @param group [Hash] Parsed group from parser
130
+ # @param route_data [Array<Hash>] Route data for the action
131
+ # @return [void]
132
+ def annotate_group(group:, route_data:)
133
+ whitespace = /^(\s*).*$/.match(group[:body])[1]
134
+
135
+ # Build annotation block
136
+ annotation_lines = []
137
+ annotation_lines << "#{whitespace}# <rails-lens:routes:begin>"
138
+
139
+ # Group routes by path only (not by name)
140
+ # This allows different named routes with same path to be merged
141
+ grouped_routes = {}
142
+ route_data.each do |datum|
143
+ key = datum[:path]
144
+ grouped_routes[key] ||= {
145
+ path: datum[:path],
146
+ name: datum[:name],
147
+ verbs: [],
148
+ defaults: datum[:defaults]
149
+ }
150
+
151
+ # If this path already has a different name, keep routes separate
152
+ if grouped_routes[key][:name] == datum[:name]
153
+ grouped_routes[key][:verbs] << datum[:verb]
154
+ else
155
+ # Create a unique key for this different route
156
+ unique_key = "#{datum[:path]}__#{datum[:name]}"
157
+ grouped_routes[unique_key] = {
158
+ path: datum[:path],
159
+ name: datum[:name],
160
+ verbs: [datum[:verb]],
161
+ defaults: datum[:defaults]
162
+ }
163
+ end
164
+ end
165
+
166
+ # Add grouped annotations
167
+ grouped_routes.values.reverse_each do |route|
168
+ annotation_lines << "#{whitespace}# #{format_route_annotation_structured(**route)}"
169
+ end
170
+
171
+ annotation_lines << "#{whitespace}# <rails-lens:routes:end>"
172
+
173
+ # Add to group
174
+ annotation_block = "#{annotation_lines.join("\n")}\n"
175
+ group[:body] = annotation_block + group[:body]
176
+ end
177
+
178
+ # Format a single route annotation
179
+ #
180
+ # @param verb [String] HTTP verb
181
+ # @param path [String] Route path
182
+ # @param name [String] Route name
183
+ # @param defaults [Hash] Default parameters
184
+ # @param source_path [String] Controller file path
185
+ # @return [String] Formatted annotation
186
+ def format_route_annotation(verb:, path:, name:, defaults:, source_path:)
187
+ annotation = "@route #{verb} #{path}"
188
+
189
+ if defaults&.any?
190
+ defaults_str = defaults.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
191
+ annotation += " {#{defaults_str}}"
192
+ end
193
+
194
+ annotation += " (#{name})" if name
195
+ annotation
196
+ end
197
+
198
+ # Format a single route annotation in structured format
199
+ #
200
+ # @param verbs [Array<String>] HTTP verbs (can be single verb for backward compatibility)
201
+ # @param path [String] Route path
202
+ # @param name [String] Route name
203
+ # @param defaults [Hash] Default parameters
204
+ # @param source_path [String] Controller file path (optional)
205
+ # @param verb [String] Single HTTP verb (for backward compatibility)
206
+ # @return [String] Formatted structured annotation
207
+ def format_route_annotation_structured(path:, name:, defaults:, verbs: nil, source_path: nil, verb: nil)
208
+ # Handle backward compatibility - if verbs not provided, use verb
209
+ verbs ||= [verb].compact
210
+
211
+ parts = []
212
+ parts << path
213
+ parts << "name: #{name}" if name
214
+
215
+ # Format verbs - use array syntax if multiple, single if one
216
+ if verbs.size > 1
217
+ parts << "via: [#{verbs.join(', ')}]"
218
+ elsif verbs.size == 1
219
+ parts << "via: #{verbs.first}"
220
+ end
221
+
222
+ parts << "defaults: #{defaults.inspect}" if defaults&.any?
223
+
224
+ "ROUTE: #{parts.join(', ')}"
225
+ end
226
+
227
+ # Write parsed file content back to disk
228
+ #
229
+ # @param path [String] File path
230
+ # @param parsed_file [Hash] Parsed file from parser
231
+ # @return [void]
232
+ def write_to_file(path:, parsed_file:)
233
+ new_content = parsed_file[:groups].pluck(:body).join
234
+ return if parsed_file[:content] == new_content
235
+
236
+ write_content_to_file(path: path, content: new_content)
237
+ end
238
+
239
+ # Write content to file
240
+ #
241
+ # @param path [String] File path
242
+ # @param content [String] File content
243
+ # @return [void]
244
+ def write_content_to_file(path:, content:)
245
+ return if @dry_run
246
+
247
+ File.write(path, content)
248
+ @changed_files << path
249
+ end
250
+
251
+ # Check if a controller file exists and contains controller classes
252
+ #
253
+ # @param path [String] File path
254
+ # @return [Boolean] Whether file exists and contains controller classes
255
+ def controller_file_exists?(path)
256
+ return false unless File.exist?(path)
257
+
258
+ begin
259
+ parser_result = RailsLens::Parsers::PrismParser.parse_file(path)
260
+ parser_result.classes.any? { |cls| cls.name.end_with?('Controller') }
261
+ rescue RailsLens::ParseError, Errno::ENOENT, IOError
262
+ # Fallback to filename-based detection
263
+ File.basename(path).end_with?('_controller.rb')
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Route
5
+ # Handles extracting route information from Rails application
6
+ class Extractor
7
+ class << self
8
+ # Extract all routes from Rails application
9
+ #
10
+ # @return [Hash] Routes hash organized by controller and action
11
+ def call
12
+ routes = {}
13
+
14
+ populate_routes(Rails.application, routes)
15
+ backfill_routes(routes)
16
+ end
17
+
18
+ private
19
+
20
+ # Recursively populate routes from Rails application (handles engines)
21
+ #
22
+ # @param app [Rails::Application] Rails application instance
23
+ # @param routes [Hash] Collection of all route info
24
+ # @return [void]
25
+ def populate_routes(app, routes)
26
+ app.routes.routes.each do |route|
27
+ if route.app.respond_to?(:engine?) && route.app.engine?
28
+ populate_routes(route.app.app, routes)
29
+ next
30
+ end
31
+
32
+ controller, action, defaults, source_path = extract_data_from(route)
33
+ next unless controller && action && source_path
34
+
35
+ routes[controller] ||= {}
36
+ routes[controller][action] ||= []
37
+
38
+ add_route_info(
39
+ route: route,
40
+ routes: routes,
41
+ controller: controller,
42
+ action: action,
43
+ defaults: defaults,
44
+ source_path: source_path
45
+ )
46
+ end
47
+ end
48
+
49
+ # Add formatted route info for the given parameters
50
+ #
51
+ # @param route [ActionDispatch::Journey::Route] Route instance
52
+ # @param routes [Hash] Collection of all route info
53
+ # @param controller [String] Controller name
54
+ # @param action [String] Action name
55
+ # @param defaults [Hash] Default parameters for route
56
+ # @param source_path [String] Path to controller file
57
+ # @return [void]
58
+ def add_route_info(route:, routes:, controller:, action:, defaults:, source_path:)
59
+ verbs_for(route).each do |verb|
60
+ route_info = {
61
+ verb: verb,
62
+ path: route.path.spec.to_s.gsub('(.:format)', ''),
63
+ name: route.name,
64
+ defaults: defaults,
65
+ source_path: source_path
66
+ }
67
+
68
+ routes[controller][action].push(route_info)
69
+ routes[controller][action].uniq!
70
+ end
71
+ end
72
+
73
+ # Extract HTTP verbs from route
74
+ #
75
+ # @param route [ActionDispatch::Journey::Route] Route instance
76
+ # @return [Array<String>] List of HTTP verbs
77
+ def verbs_for(route)
78
+ route_verb = route.verb.to_s
79
+ %w[GET POST PUT PATCH DELETE].select { |verb| route_verb.include?(verb) }
80
+ end
81
+
82
+ # Extract controller, action, defaults, and source path from route
83
+ #
84
+ # @param route [ActionDispatch::Journey::Route] Route instance
85
+ # @return [Array<String, String, Hash, String>] controller, action, defaults, source_path
86
+ def extract_data_from(route)
87
+ defaults = route.defaults.dup
88
+ controller = defaults.delete(:controller)
89
+ action = defaults.delete(:action)
90
+
91
+ return [nil, nil, nil, nil] unless controller && action
92
+
93
+ begin
94
+ controller_class = "#{controller.underscore.camelize}Controller".constantize
95
+ action_method = action.to_sym
96
+
97
+ source_path = if controller_class.method_defined?(action_method)
98
+ controller_class.instance_method(action_method).source_location&.first
99
+ end
100
+
101
+ [controller, action, defaults, source_path]
102
+ rescue NameError
103
+ [nil, nil, nil, nil]
104
+ end
105
+ end
106
+
107
+ # Backfill route names that might be missing
108
+ #
109
+ # @param routes [Hash] Routes hash
110
+ # @return [Hash] Backfilled routes hash
111
+ def backfill_routes(routes)
112
+ paths = {}
113
+
114
+ # Map paths to their verbs and names
115
+ routes.each_value do |actions|
116
+ actions.each_value do |data|
117
+ data.each do |datum|
118
+ paths[datum[:path]] ||= {}
119
+ paths[datum[:path]][datum[:verb]] ||= datum[:name]
120
+
121
+ # Backfill names for routes that don't have them
122
+ datum[:name] ||= paths.dig(datum[:path], datum[:verb])
123
+ datum[:name] ||= paths[datum[:path]]&.values&.compact&.first
124
+ end
125
+ end
126
+ end
127
+
128
+ routes
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Route
5
+ # Handles parsing controller files and groups lines into categories
6
+ class Parser
7
+ class << self
8
+ # Parse a controller file and return grouped content
9
+ #
10
+ # @param path [String] File path to parse
11
+ # @param actions [Array<String>] List of valid actions for this controller
12
+ # @return [Hash] { content => String, groups => Array<Hash> }
13
+ def call(path:, actions:)
14
+ groups = []
15
+ group = {}
16
+ content = File.read(path)
17
+
18
+ content.each_line.with_index do |line, index|
19
+ parsed_line = parse_line(line: line, actions: actions)
20
+
21
+ if group[:type] == parsed_line[:type]
22
+ # Same group. Push the current line into the current group.
23
+ group[:body] += line
24
+ else
25
+ # Now looking at a new group. Push the current group onto the array
26
+ # and start a new one.
27
+ groups.push(group) unless group.empty?
28
+ group = parsed_line.merge(line_number: index + 1)
29
+ end
30
+ end
31
+
32
+ # Push the last group onto the array and return.
33
+ groups.push(group)
34
+ { content: content, groups: groups }
35
+ end
36
+
37
+ private
38
+
39
+ # Parse a single line and determine its type
40
+ #
41
+ # @param line [String] A line of a file
42
+ # @param actions [Array<String>] List of valid actions for this controller
43
+ # @return [Hash] { type => Symbol, body => String, action => String }
44
+ def parse_line(line:, actions:)
45
+ comment_match = /^\s*#.*$/.match(line)
46
+ def_match = /^\s*def\s+(\w*)\s*\w*.*$/.match(line)
47
+
48
+ if comment_match
49
+ { type: :comment, body: line, action: nil }
50
+ elsif def_match && actions.include?(def_match[1])
51
+ { type: :action, body: line, action: def_match[1] }
52
+ else
53
+ { type: :code, body: line, action: nil }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end