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,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