rails_lens 0.0.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +2 -2
- data/README.md +393 -9
- data/exe/rails_lens +25 -0
- data/lib/rails_lens/analyzers/association_analyzer.rb +111 -0
- data/lib/rails_lens/analyzers/base.rb +35 -0
- data/lib/rails_lens/analyzers/best_practices_analyzer.rb +114 -0
- data/lib/rails_lens/analyzers/column_analyzer.rb +97 -0
- data/lib/rails_lens/analyzers/composite_keys.rb +62 -0
- data/lib/rails_lens/analyzers/database_constraints.rb +35 -0
- data/lib/rails_lens/analyzers/delegated_types.rb +124 -0
- data/lib/rails_lens/analyzers/enums.rb +34 -0
- data/lib/rails_lens/analyzers/error_handling.rb +66 -0
- data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +47 -0
- data/lib/rails_lens/analyzers/generated_columns.rb +56 -0
- data/lib/rails_lens/analyzers/index_analyzer.rb +128 -0
- data/lib/rails_lens/analyzers/inheritance.rb +212 -0
- data/lib/rails_lens/analyzers/notes.rb +325 -0
- data/lib/rails_lens/analyzers/performance_analyzer.rb +110 -0
- data/lib/rails_lens/annotation_pipeline.rb +87 -0
- data/lib/rails_lens/cli.rb +170 -0
- data/lib/rails_lens/cli_error_handler.rb +86 -0
- data/lib/rails_lens/commands.rb +164 -0
- data/lib/rails_lens/connection.rb +133 -0
- data/lib/rails_lens/erd/column_type_formatter.rb +32 -0
- data/lib/rails_lens/erd/domain_color_mapper.rb +40 -0
- data/lib/rails_lens/erd/mysql_column_type_formatter.rb +19 -0
- data/lib/rails_lens/erd/postgresql_column_type_formatter.rb +19 -0
- data/lib/rails_lens/erd/visualizer.rb +301 -0
- data/lib/rails_lens/errors.rb +78 -0
- data/lib/rails_lens/extension_loader.rb +261 -0
- data/lib/rails_lens/extensions/base.rb +194 -0
- data/lib/rails_lens/extensions/closure_tree_ext.rb +157 -0
- data/lib/rails_lens/file_insertion_helper.rb +168 -0
- data/lib/rails_lens/mailer/annotator.rb +226 -0
- data/lib/rails_lens/mailer/extractor.rb +201 -0
- data/lib/rails_lens/model_detector.rb +241 -0
- data/lib/rails_lens/parsers/class_info.rb +46 -0
- data/lib/rails_lens/parsers/module_info.rb +33 -0
- data/lib/rails_lens/parsers/parser_result.rb +55 -0
- data/lib/rails_lens/parsers/prism_parser.rb +90 -0
- data/lib/rails_lens/parsers.rb +10 -0
- data/lib/rails_lens/providers/association_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/base.rb +37 -0
- data/lib/rails_lens/providers/best_practices_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/column_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/composite_keys_provider.rb +11 -0
- data/lib/rails_lens/providers/database_constraints_provider.rb +11 -0
- data/lib/rails_lens/providers/delegated_types_provider.rb +11 -0
- data/lib/rails_lens/providers/enums_provider.rb +11 -0
- data/lib/rails_lens/providers/extension_notes_provider.rb +20 -0
- data/lib/rails_lens/providers/extensions_provider.rb +22 -0
- data/lib/rails_lens/providers/foreign_key_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/generated_columns_provider.rb +11 -0
- data/lib/rails_lens/providers/index_notes_provider.rb +20 -0
- data/lib/rails_lens/providers/inheritance_provider.rb +23 -0
- data/lib/rails_lens/providers/notes_provider_base.rb +25 -0
- data/lib/rails_lens/providers/performance_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/schema_provider.rb +48 -0
- data/lib/rails_lens/providers/section_provider_base.rb +28 -0
- data/lib/rails_lens/railtie.rb +17 -0
- data/lib/rails_lens/rake_bootstrapper.rb +18 -0
- data/lib/rails_lens/route/annotator.rb +268 -0
- data/lib/rails_lens/route/extractor.rb +133 -0
- data/lib/rails_lens/route/parser.rb +59 -0
- data/lib/rails_lens/schema/adapters/base.rb +345 -0
- data/lib/rails_lens/schema/adapters/database_info.rb +118 -0
- data/lib/rails_lens/schema/adapters/mysql.rb +279 -0
- data/lib/rails_lens/schema/adapters/postgresql.rb +197 -0
- data/lib/rails_lens/schema/adapters/sqlite3.rb +96 -0
- data/lib/rails_lens/schema/annotation.rb +144 -0
- data/lib/rails_lens/schema/annotation_manager.rb +202 -0
- data/lib/rails_lens/tasks/annotate.rake +35 -0
- data/lib/rails_lens/tasks/erd.rake +24 -0
- data/lib/rails_lens/tasks/mailers.rake +27 -0
- data/lib/rails_lens/tasks/routes.rake +27 -0
- data/lib/rails_lens/tasks/schema.rake +108 -0
- data/lib/rails_lens/version.rb +5 -0
- data/lib/rails_lens.rb +138 -5
- metadata +215 -11
@@ -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,48 @@
|
|
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
|
+
lines << "database_dialect = \"#{adapter_name}\""
|
22
|
+
|
23
|
+
# Add basic database information
|
24
|
+
begin
|
25
|
+
db_name = begin
|
26
|
+
connection.database_version
|
27
|
+
rescue StandardError
|
28
|
+
'unknown'
|
29
|
+
end
|
30
|
+
lines << "database_version = \"#{db_name}\""
|
31
|
+
rescue StandardError
|
32
|
+
# Skip if can't get version
|
33
|
+
end
|
34
|
+
|
35
|
+
lines << ''
|
36
|
+
lines << '# This is an abstract class that establishes a database connection'
|
37
|
+
lines << '# but does not have an associated table.'
|
38
|
+
|
39
|
+
lines.join("\n")
|
40
|
+
else
|
41
|
+
# Add schema information for regular models
|
42
|
+
adapter = Connection.adapter_for(model_class)
|
43
|
+
adapter.generate_annotation(model_class)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
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
|