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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +2 -2
- data/README.md +463 -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 +129 -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 +176 -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 +329 -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 +252 -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 +61 -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,168 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
# Helper class to handle file insertion logic, particularly for inserting content
|
5
|
+
# after frozen_string_literal comments while maintaining proper formatting
|
6
|
+
class FileInsertionHelper
|
7
|
+
FROZEN_STRING_LITERAL_REGEX = /^# frozen_string_literal: true$/
|
8
|
+
|
9
|
+
class << self
|
10
|
+
# Insert content at a specific class definition location
|
11
|
+
#
|
12
|
+
# @param file_path [String] Path to the file
|
13
|
+
# @param class_name [String] Name of the class to find
|
14
|
+
# @param annotation [String] The annotation to insert
|
15
|
+
# @return [Boolean] Whether insertion was successful
|
16
|
+
def insert_at_class_definition(file_path, _class_name, annotation)
|
17
|
+
return false unless File.exist?(file_path)
|
18
|
+
|
19
|
+
content = File.read(file_path)
|
20
|
+
|
21
|
+
# First remove any existing annotations
|
22
|
+
content = Schema::Annotation.remove(content) if Schema::Annotation.extract(content)
|
23
|
+
|
24
|
+
modified_content = insert_after_frozen_string_literal(content, annotation)
|
25
|
+
File.write(file_path, modified_content)
|
26
|
+
true
|
27
|
+
rescue StandardError
|
28
|
+
false
|
29
|
+
end
|
30
|
+
|
31
|
+
# Insert content at a specific line number
|
32
|
+
#
|
33
|
+
# @param file_path [String] Path to the file
|
34
|
+
# @param line_number [Integer] Line number where class is defined
|
35
|
+
# @param annotation [String] The annotation to insert
|
36
|
+
# @return [Boolean] Whether insertion was successful
|
37
|
+
def insert_at_line(file_path, line_number, annotation)
|
38
|
+
content = File.read(file_path)
|
39
|
+
|
40
|
+
# First remove any existing annotations
|
41
|
+
content = Schema::Annotation.remove(content) if Schema::Annotation.extract(content)
|
42
|
+
|
43
|
+
lines = content.split("\n", -1) # Preserve trailing newlines
|
44
|
+
|
45
|
+
# Find correct insertion point considering frozen_string_literal
|
46
|
+
insert_index = find_insertion_point_for_line(lines, line_number)
|
47
|
+
|
48
|
+
lines.insert(insert_index, annotation)
|
49
|
+
|
50
|
+
# Preserve original line ending
|
51
|
+
result = lines.join("\n")
|
52
|
+
result += "\n" if content.end_with?("\n") && !result.end_with?("\n")
|
53
|
+
|
54
|
+
File.write(file_path, result)
|
55
|
+
true
|
56
|
+
rescue StandardError
|
57
|
+
false
|
58
|
+
end
|
59
|
+
|
60
|
+
# Insert content after frozen_string_literal comment with proper spacing
|
61
|
+
#
|
62
|
+
# @param content [String] The original file content
|
63
|
+
# @param insertion_content [String] The content to insert
|
64
|
+
# @return [String] The modified content
|
65
|
+
def insert_after_frozen_string_literal(content, insertion_content)
|
66
|
+
# First check if frozen_string_literal exists
|
67
|
+
unless content.match?(/^# frozen_string_literal: true/)
|
68
|
+
# If no frozen_string_literal, insert at the beginning with newline
|
69
|
+
return "#{insertion_content}\n#{content}"
|
70
|
+
end
|
71
|
+
|
72
|
+
lines = content.split("\n", -1) # Preserve empty lines
|
73
|
+
insert_index = find_frozen_string_literal_index(lines)
|
74
|
+
|
75
|
+
return content unless insert_index
|
76
|
+
|
77
|
+
# Ensure proper spacing after frozen_string_literal
|
78
|
+
insert_index = ensure_blank_line_after_frozen_literal(lines, insert_index)
|
79
|
+
|
80
|
+
# Insert the new content
|
81
|
+
lines.insert(insert_index, insertion_content)
|
82
|
+
|
83
|
+
# Preserve original line ending
|
84
|
+
result = lines.join("\n")
|
85
|
+
result += "\n" if content.end_with?("\n") && !result.end_with?("\n")
|
86
|
+
|
87
|
+
result
|
88
|
+
end
|
89
|
+
|
90
|
+
# Remove content that was inserted after frozen_string_literal
|
91
|
+
#
|
92
|
+
# @param content [String] The file content
|
93
|
+
# @param marker_start [String] Start marker to identify content to remove
|
94
|
+
# @param marker_end [String] End marker to identify content to remove
|
95
|
+
# @return [String] The content with insertion removed
|
96
|
+
def remove_after_frozen_string_literal(content, marker_start, marker_end)
|
97
|
+
# Use regex to remove the marked content
|
98
|
+
pattern = /^.*#{Regexp.escape(marker_start)}.*$\n(.*\n)*?^.*#{Regexp.escape(marker_end)}.*$\n/
|
99
|
+
content.gsub(pattern, '')
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
# Find the correct insertion point for a line, considering frozen_string_literal
|
105
|
+
#
|
106
|
+
# @param lines [Array<String>] Array of file lines
|
107
|
+
# @param line_number [Integer] Target line number (1-based)
|
108
|
+
# @return [Integer] Index where annotation should be inserted
|
109
|
+
def find_insertion_point_for_line(lines, line_number)
|
110
|
+
# Convert 1-based line number to 0-based index
|
111
|
+
target_index = line_number - 1
|
112
|
+
|
113
|
+
# Ensure target_index is within bounds
|
114
|
+
target_index = 0 if target_index.negative?
|
115
|
+
target_index = lines.length if target_index > lines.length
|
116
|
+
|
117
|
+
# Insert before the class definition line
|
118
|
+
insert_index = target_index
|
119
|
+
|
120
|
+
# If previous line is a comment (but not frozen_string_literal), insert before it
|
121
|
+
while insert_index.positive? &&
|
122
|
+
lines[insert_index - 1].strip.start_with?('#') &&
|
123
|
+
!lines[insert_index - 1].match?(FROZEN_STRING_LITERAL_REGEX)
|
124
|
+
insert_index -= 1
|
125
|
+
end
|
126
|
+
|
127
|
+
# Ensure we have a blank line before the annotation if needed
|
128
|
+
if insert_index.positive? && !lines[insert_index - 1].strip.empty?
|
129
|
+
lines.insert(insert_index, '')
|
130
|
+
insert_index += 1
|
131
|
+
end
|
132
|
+
|
133
|
+
insert_index
|
134
|
+
end
|
135
|
+
|
136
|
+
# Find the index of the frozen_string_literal line
|
137
|
+
#
|
138
|
+
# @param lines [Array<String>] Array of file lines
|
139
|
+
# @return [Integer, nil] Index after the frozen_string_literal line
|
140
|
+
def find_frozen_string_literal_index(lines)
|
141
|
+
lines.each_with_index do |line, index|
|
142
|
+
return index + 1 if line.match?(FROZEN_STRING_LITERAL_REGEX)
|
143
|
+
end
|
144
|
+
nil
|
145
|
+
end
|
146
|
+
|
147
|
+
# Ensure there's a blank line after frozen_string_literal
|
148
|
+
#
|
149
|
+
# @param lines [Array<String>] Array of file lines
|
150
|
+
# @param insert_index [Integer] Index where we want to insert
|
151
|
+
# @return [Integer] Updated insert index
|
152
|
+
def ensure_blank_line_after_frozen_literal(lines, insert_index)
|
153
|
+
if insert_index < lines.length && !lines[insert_index].strip.empty?
|
154
|
+
# No blank line exists, add one
|
155
|
+
lines.insert(insert_index, '')
|
156
|
+
elsif insert_index >= lines.length
|
157
|
+
# At end of file, add blank line
|
158
|
+
lines.insert(insert_index, '')
|
159
|
+
else
|
160
|
+
# There's already a blank line, move past it
|
161
|
+
end
|
162
|
+
insert_index += 1
|
163
|
+
|
164
|
+
insert_index
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Mailer
|
5
|
+
# Handles adding mailer annotations to mailer files
|
6
|
+
class Annotator
|
7
|
+
def initialize(dry_run: false)
|
8
|
+
@dry_run = dry_run
|
9
|
+
@mailers = RailsLens::Mailer::Extractor.call
|
10
|
+
@changed_files = []
|
11
|
+
end
|
12
|
+
|
13
|
+
# Annotate all mailer files with mailer information
|
14
|
+
#
|
15
|
+
# @param pattern [String] Glob pattern for mailer files
|
16
|
+
# @param exclusion [String] Glob pattern for files to exclude
|
17
|
+
# @return [Array<String>] List of changed files
|
18
|
+
def annotate_all(pattern: '**/*_mailer.rb', exclusion: 'vendor/**/*_mailer.rb')
|
19
|
+
# Simply annotate all mailer files we found via their source locations
|
20
|
+
source_paths_map.each do |source_path, methods|
|
21
|
+
# Skip vendor files or files matching exclusion pattern
|
22
|
+
next if exclusion && source_path.include?('vendor/')
|
23
|
+
|
24
|
+
annotate_file(path: source_path, methods: methods) if File.exist?(source_path)
|
25
|
+
end
|
26
|
+
|
27
|
+
@changed_files
|
28
|
+
end
|
29
|
+
|
30
|
+
# Remove mailer annotations from all mailer files
|
31
|
+
#
|
32
|
+
# @param pattern [String] Glob pattern for mailer files
|
33
|
+
# @param exclusion [String] Glob pattern for files to exclude
|
34
|
+
# @return [Array<String>] List of changed files
|
35
|
+
def remove_all(pattern: '**/*_mailer.rb', exclusion: 'vendor/**/*_mailer.rb')
|
36
|
+
# Remove annotations from all mailer files we found via their source locations
|
37
|
+
source_paths_map.each_key do |source_path|
|
38
|
+
# Skip vendor files or files matching exclusion pattern
|
39
|
+
next if exclusion && source_path.include?('vendor/')
|
40
|
+
|
41
|
+
remove_annotations_from_file(source_path) if File.exist?(source_path)
|
42
|
+
end
|
43
|
+
|
44
|
+
@changed_files
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
# Map source paths to their respective mailer methods
|
50
|
+
#
|
51
|
+
# @return [Hash] Source paths mapped to their methods
|
52
|
+
def source_paths_map
|
53
|
+
map = {}
|
54
|
+
|
55
|
+
@mailers.each_value do |methods|
|
56
|
+
methods.each do |method_name, method_info|
|
57
|
+
source_path = method_info[:source_path]
|
58
|
+
next unless source_path
|
59
|
+
|
60
|
+
map[source_path] ||= {}
|
61
|
+
map[source_path][method_name] = method_info
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
map
|
66
|
+
end
|
67
|
+
|
68
|
+
# Annotate a single mailer file
|
69
|
+
#
|
70
|
+
# @param path [String] Path to mailer file
|
71
|
+
# @param methods [Hash] Method data for the mailer
|
72
|
+
# @return [void]
|
73
|
+
def annotate_file(path:, methods:)
|
74
|
+
# First, remove any existing annotations
|
75
|
+
remove_annotations_from_file(path)
|
76
|
+
|
77
|
+
# Use our precise parser to find all mailer classes
|
78
|
+
parser_result = RailsLens::Parsers::PrismParser.parse_file(path)
|
79
|
+
mailer_classes = parser_result.classes.select { |cls| cls.name.end_with?('Mailer') }
|
80
|
+
|
81
|
+
# Collect all annotations for this file first (to handle multiple classes properly)
|
82
|
+
annotations_to_insert = []
|
83
|
+
|
84
|
+
# Annotate each mailer class individually
|
85
|
+
mailer_classes.each do |mailer_class|
|
86
|
+
# Find methods for this specific mailer class from the original @mailers data
|
87
|
+
class_methods = {}
|
88
|
+
|
89
|
+
# Look through all mailer classes to find methods for this specific class
|
90
|
+
@mailers.each do |mailer_class_name, mailer_methods|
|
91
|
+
next unless mailer_class_name == mailer_class.name || mailer_class_name == mailer_class.full_name
|
92
|
+
|
93
|
+
# Filter methods that are in this specific file
|
94
|
+
file_methods = mailer_methods.select do |_method_name, method_info|
|
95
|
+
method_info[:source_path] == path
|
96
|
+
end
|
97
|
+
class_methods.merge!(file_methods)
|
98
|
+
end
|
99
|
+
|
100
|
+
next if class_methods.empty?
|
101
|
+
|
102
|
+
# Get class-level information from first method
|
103
|
+
first_method = class_methods.values.first
|
104
|
+
next unless first_method
|
105
|
+
|
106
|
+
# Build class annotation
|
107
|
+
annotation_lines = []
|
108
|
+
annotation_lines << '# <rails-lens:mailers:begin>'
|
109
|
+
|
110
|
+
# Add delivery method
|
111
|
+
annotation_lines << "# DELIVERY_METHOD: #{first_method[:delivery_method]}" if first_method[:delivery_method]
|
112
|
+
|
113
|
+
# Add locales
|
114
|
+
annotation_lines << "# LOCALES: #{first_method[:locales].join(', ')}" if first_method[:locales]&.any?
|
115
|
+
|
116
|
+
# Add defaults
|
117
|
+
if first_method[:defaults]&.any?
|
118
|
+
defaults_strings = first_method[:defaults].map { |k, v| "#{k}: #{v}" }
|
119
|
+
annotation_lines << "# DEFAULTS: #{defaults_strings.join(', ')}"
|
120
|
+
end
|
121
|
+
|
122
|
+
annotation_lines << '# <rails-lens:mailers:end>'
|
123
|
+
annotation_block = annotation_lines.join("\n")
|
124
|
+
|
125
|
+
# Store annotation for batch processing
|
126
|
+
annotations_to_insert << {
|
127
|
+
class_name: mailer_class.name,
|
128
|
+
line_number: mailer_class.line_number,
|
129
|
+
annotation: annotation_block
|
130
|
+
}
|
131
|
+
end
|
132
|
+
|
133
|
+
# Insert all annotations in reverse line order (bottom to top) to preserve line numbers
|
134
|
+
annotations_to_insert.sort_by { |ann| -ann[:line_number] }.each do |annotation_info|
|
135
|
+
success = RailsLens::FileInsertionHelper.insert_at_class_definition(
|
136
|
+
path,
|
137
|
+
annotation_info[:class_name],
|
138
|
+
annotation_info[:annotation]
|
139
|
+
)
|
140
|
+
|
141
|
+
warn "Could not annotate #{annotation_info[:class_name]} in #{path}" unless success
|
142
|
+
end
|
143
|
+
|
144
|
+
# Add file to changed files list if any annotations were inserted
|
145
|
+
return unless annotations_to_insert.any?
|
146
|
+
|
147
|
+
@changed_files << path
|
148
|
+
end
|
149
|
+
|
150
|
+
# Remove annotations from a single file
|
151
|
+
#
|
152
|
+
# @param path [String] Path to mailer file
|
153
|
+
# @return [void]
|
154
|
+
def remove_annotations_from_file(path)
|
155
|
+
content = File.read(path)
|
156
|
+
original_content = content.dup
|
157
|
+
|
158
|
+
# Remove rails-lens mailer annotations
|
159
|
+
content.gsub!(/^.*<rails-lens:mailers:begin>.*$\n/, '')
|
160
|
+
content.gsub!(/^.*<rails-lens:mailers:end>.*$\n/, '')
|
161
|
+
content.gsub!(/^\s*#\s*== Mailer Information.*$\n/, '')
|
162
|
+
content.gsub!(/^\s*#\s*Templates:.*$\n/, '')
|
163
|
+
content.gsub!(/^\s*#\s*Formats:.*$\n/, '')
|
164
|
+
content.gsub!(/^\s*#\s*FORMATS:.*$\n/, '')
|
165
|
+
content.gsub!(/^\s*#\s*Delivery Method:.*$\n/, '')
|
166
|
+
content.gsub!(/^\s*#\s*DELIVERY_METHOD:.*$\n/, '')
|
167
|
+
content.gsub!(/^\s*#\s*Parameters:.*$\n/, '')
|
168
|
+
content.gsub!(/^\s*#\s*Locales:.*$\n/, '')
|
169
|
+
content.gsub!(/^\s*#\s*LOCALES:.*$\n/, '')
|
170
|
+
content.gsub!(/^\s*#\s*Defaults:.*$\n/, '')
|
171
|
+
content.gsub!(/^\s*#\s*DEFAULTS:.*$\n/, '')
|
172
|
+
|
173
|
+
return unless content != original_content
|
174
|
+
|
175
|
+
write_content_to_file(path: path, content: content)
|
176
|
+
end
|
177
|
+
|
178
|
+
# Extract formats from template filenames
|
179
|
+
#
|
180
|
+
# @param templates [Array<String>] Template filenames
|
181
|
+
# @return [Array<String>] Extracted formats
|
182
|
+
def extract_formats_from_templates(templates)
|
183
|
+
formats = []
|
184
|
+
|
185
|
+
templates.each do |template|
|
186
|
+
# Extract format from filename like "method_name.html.erb" or "method_name.text.erb"
|
187
|
+
parts = template.split('.')
|
188
|
+
if parts.length >= 2
|
189
|
+
format = parts[-2] # Get the format part (html, text, etc.)
|
190
|
+
formats << format unless formats.include?(format)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
formats.sort
|
195
|
+
end
|
196
|
+
|
197
|
+
# Write content to file
|
198
|
+
#
|
199
|
+
# @param path [String] File path
|
200
|
+
# @param content [String] File content
|
201
|
+
# @return [void]
|
202
|
+
def write_content_to_file(path:, content:)
|
203
|
+
return if @dry_run
|
204
|
+
|
205
|
+
File.write(path, content)
|
206
|
+
@changed_files << path
|
207
|
+
end
|
208
|
+
|
209
|
+
# Check if a mailer file exists and contains mailer classes
|
210
|
+
#
|
211
|
+
# @param path [String] File path
|
212
|
+
# @return [Boolean] Whether file exists and contains mailer classes
|
213
|
+
def mailer_file_exists?(path)
|
214
|
+
return false unless File.exist?(path)
|
215
|
+
|
216
|
+
begin
|
217
|
+
parser_result = RailsLens::Parsers::PrismParser.parse_file(path)
|
218
|
+
parser_result.classes.any? { |cls| cls.name.end_with?('Mailer') }
|
219
|
+
rescue RailsLens::ParseError, Errno::ENOENT, IOError
|
220
|
+
# Fallback to filename-based detection
|
221
|
+
File.basename(path).end_with?('_mailer.rb')
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,201 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Mailer
|
5
|
+
# Handles extracting mailer information from Rails application
|
6
|
+
class Extractor
|
7
|
+
class << self
|
8
|
+
# Extract all mailer information from Rails application
|
9
|
+
#
|
10
|
+
# @return [Hash] Mailer information organized by mailer class and method
|
11
|
+
def call
|
12
|
+
# Check if ActionMailer is available
|
13
|
+
return {} unless defined?(ActionMailer::Base)
|
14
|
+
|
15
|
+
mailers = {}
|
16
|
+
|
17
|
+
find_mailer_classes.each do |mailer_class|
|
18
|
+
mailer_info = extract_mailer_info(mailer_class)
|
19
|
+
next if mailer_info.empty?
|
20
|
+
|
21
|
+
mailers[mailer_class.name] = mailer_info
|
22
|
+
end
|
23
|
+
|
24
|
+
mailers
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# Find all mailer classes in the application
|
30
|
+
#
|
31
|
+
# @return [Array<Class>] Array of mailer classes
|
32
|
+
def find_mailer_classes
|
33
|
+
return [] unless defined?(ActionMailer::Base)
|
34
|
+
|
35
|
+
# Find all classes that inherit from ActionMailer::Base
|
36
|
+
# No need to load files - they should already be loaded
|
37
|
+
mailer_classes = []
|
38
|
+
|
39
|
+
ObjectSpace.each_object(Class) do |klass|
|
40
|
+
mailer_classes << klass if klass < ActionMailer::Base && klass != ActionMailer::Base
|
41
|
+
end
|
42
|
+
|
43
|
+
mailer_classes
|
44
|
+
end
|
45
|
+
|
46
|
+
# Extract information for a single mailer class
|
47
|
+
#
|
48
|
+
# @param mailer_class [Class] Mailer class to analyze
|
49
|
+
# @return [Hash] Mailer information
|
50
|
+
def extract_mailer_info(mailer_class)
|
51
|
+
mailer_info = {}
|
52
|
+
|
53
|
+
# Get mailer methods (exclude inherited ActionMailer methods)
|
54
|
+
mailer_methods = mailer_class.instance_methods(false)
|
55
|
+
|
56
|
+
mailer_methods.each do |method_name|
|
57
|
+
method_info = extract_method_info(mailer_class, method_name)
|
58
|
+
next if method_info.empty?
|
59
|
+
|
60
|
+
mailer_info[method_name.to_s] = method_info
|
61
|
+
end
|
62
|
+
|
63
|
+
mailer_info
|
64
|
+
end
|
65
|
+
|
66
|
+
# Extract information for a single mailer method
|
67
|
+
#
|
68
|
+
# @param mailer_class [Class] Mailer class
|
69
|
+
# @param method_name [Symbol] Method name
|
70
|
+
# @return [Hash] Method information
|
71
|
+
def extract_method_info(mailer_class, method_name)
|
72
|
+
method_info = {}
|
73
|
+
|
74
|
+
# Get method source location
|
75
|
+
method_obj = mailer_class.instance_method(method_name)
|
76
|
+
source_location = method_obj.source_location
|
77
|
+
return {} unless source_location
|
78
|
+
|
79
|
+
method_info[:source_path] = source_location[0]
|
80
|
+
method_info[:line_number] = source_location[1]
|
81
|
+
method_info[:class_name] = mailer_class.name
|
82
|
+
|
83
|
+
# Extract templates
|
84
|
+
method_info[:templates] = find_templates(mailer_class, method_name)
|
85
|
+
|
86
|
+
# Extract delivery method
|
87
|
+
method_info[:delivery_method] = extract_delivery_method(mailer_class)
|
88
|
+
|
89
|
+
# Extract method parameters
|
90
|
+
method_info[:parameters] = extract_method_parameters(method_obj)
|
91
|
+
|
92
|
+
# Extract locales (from template files)
|
93
|
+
method_info[:locales] = extract_locales(mailer_class, method_name)
|
94
|
+
|
95
|
+
# Extract default values
|
96
|
+
method_info[:defaults] = extract_defaults(mailer_class)
|
97
|
+
|
98
|
+
method_info
|
99
|
+
end
|
100
|
+
|
101
|
+
# Find template files for a mailer method
|
102
|
+
#
|
103
|
+
# @param mailer_class [Class] Mailer class
|
104
|
+
# @param method_name [Symbol] Method name
|
105
|
+
# @return [Array<String>] Template file names
|
106
|
+
def find_templates(mailer_class, method_name)
|
107
|
+
templates = []
|
108
|
+
mailer_name = mailer_class.name.underscore
|
109
|
+
|
110
|
+
# Look for templates in app/views/mailer_name/
|
111
|
+
template_dir = Rails.root.join("app/views/#{mailer_name}")
|
112
|
+
return templates unless File.directory?(template_dir)
|
113
|
+
|
114
|
+
Dir.glob(template_dir.join("#{method_name}.*")).each do |template_path|
|
115
|
+
templates << File.basename(template_path)
|
116
|
+
end
|
117
|
+
|
118
|
+
templates.sort
|
119
|
+
end
|
120
|
+
|
121
|
+
# Extract delivery method configuration
|
122
|
+
#
|
123
|
+
# @param mailer_class [Class] Mailer class
|
124
|
+
# @return [String] Delivery method name
|
125
|
+
def extract_delivery_method(mailer_class)
|
126
|
+
delivery_method = mailer_class.delivery_method
|
127
|
+
delivery_method ? delivery_method.to_s : 'smtp'
|
128
|
+
end
|
129
|
+
|
130
|
+
# Extract method parameters
|
131
|
+
#
|
132
|
+
# @param method_obj [Method] Method object
|
133
|
+
# @return [Array<Hash>] Parameter information
|
134
|
+
def extract_method_parameters(method_obj)
|
135
|
+
parameters = []
|
136
|
+
|
137
|
+
method_obj.parameters.each do |param_type, param_name|
|
138
|
+
param_info = {
|
139
|
+
name: param_name.to_s,
|
140
|
+
type: param_type.to_s
|
141
|
+
}
|
142
|
+
|
143
|
+
parameters << param_info
|
144
|
+
end
|
145
|
+
|
146
|
+
parameters
|
147
|
+
end
|
148
|
+
|
149
|
+
# Extract available locales for a mailer method
|
150
|
+
#
|
151
|
+
# @param mailer_class [Class] Mailer class
|
152
|
+
# @param method_name [Symbol] Method name
|
153
|
+
# @return [Array<String>] Available locales
|
154
|
+
def extract_locales(mailer_class, method_name)
|
155
|
+
locales = []
|
156
|
+
mailer_name = mailer_class.name.underscore
|
157
|
+
|
158
|
+
# Look for locale-specific templates
|
159
|
+
template_dir = Rails.root.join("app/views/#{mailer_name}")
|
160
|
+
return locales unless File.directory?(template_dir)
|
161
|
+
|
162
|
+
Dir.glob(template_dir.join("#{method_name}.*.erb")).each do |template_path|
|
163
|
+
basename = File.basename(template_path, '.erb')
|
164
|
+
parts = basename.split('.')
|
165
|
+
|
166
|
+
# Extract locale from filename like "method_name.en.html.erb"
|
167
|
+
if parts.length > 2
|
168
|
+
locale = parts[1]
|
169
|
+
locales << locale unless locales.include?(locale)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
locales.empty? ? ['en'] : locales.sort
|
174
|
+
end
|
175
|
+
|
176
|
+
# Extract default mailer settings
|
177
|
+
#
|
178
|
+
# @param mailer_class [Class] Mailer class
|
179
|
+
# @return [Hash] Default settings
|
180
|
+
def extract_defaults(mailer_class)
|
181
|
+
defaults = {}
|
182
|
+
|
183
|
+
# Extract default from
|
184
|
+
if mailer_class.respond_to?(:default) && mailer_class.default[:from]
|
185
|
+
defaults[:from] = mailer_class.default[:from]
|
186
|
+
end
|
187
|
+
|
188
|
+
# Extract default reply_to
|
189
|
+
if mailer_class.respond_to?(:default) && mailer_class.default[:reply_to]
|
190
|
+
defaults[:reply_to] = mailer_class.default[:reply_to]
|
191
|
+
end
|
192
|
+
|
193
|
+
# Extract layout
|
194
|
+
defaults[:layout] = mailer_class._layout if mailer_class.respond_to?(:_layout) && mailer_class._layout
|
195
|
+
|
196
|
+
defaults
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|