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,252 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent'
|
4
|
+
|
5
|
+
module RailsLens
|
6
|
+
class ModelDetector
|
7
|
+
class << self
|
8
|
+
def detect_models(options = {})
|
9
|
+
# Always eager load all models
|
10
|
+
eager_load_models
|
11
|
+
|
12
|
+
# Find all ActiveRecord models (always use ActiveRecord::Base as it's always defined)
|
13
|
+
models = find_descendants_of(ActiveRecord::Base)
|
14
|
+
|
15
|
+
# Filter and sort models
|
16
|
+
models = filter_models(models, options)
|
17
|
+
models.sort_by { |model| model.name || '' }
|
18
|
+
end
|
19
|
+
|
20
|
+
def model_for_table(table_name)
|
21
|
+
detect_models.find { |model| model.table_name == table_name }
|
22
|
+
end
|
23
|
+
|
24
|
+
def abstract_models
|
25
|
+
detect_models.select(&:abstract_class?)
|
26
|
+
end
|
27
|
+
|
28
|
+
def concrete_models
|
29
|
+
detect_models.reject(&:abstract_class?)
|
30
|
+
end
|
31
|
+
|
32
|
+
def sti_base_models
|
33
|
+
concrete_models.select { |model| has_sti_column?(model) }
|
34
|
+
end
|
35
|
+
|
36
|
+
def sti_child_models
|
37
|
+
concrete_models.select { |model| model.superclass != ActiveRecord::Base && concrete_models.include?(model.superclass) }
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def eager_load_models
|
43
|
+
# Zeitwerk is always available in Rails 7+
|
44
|
+
Zeitwerk::Loader.eager_load_all
|
45
|
+
end
|
46
|
+
|
47
|
+
def find_descendants_of(base_class)
|
48
|
+
base_class.descendants.select do |klass|
|
49
|
+
klass.name && !klass.name.start_with?('HABTM_')
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def filter_models(models, options)
|
54
|
+
# Data provenance trace - log filtering decisions
|
55
|
+
trace_filtering = options[:trace_filtering] || ENV.fetch('RAILS_LENS_TRACE_FILTERING', nil)
|
56
|
+
|
57
|
+
original_count = models.size
|
58
|
+
Rails.logger.debug { "[ModelDetector] Starting with #{original_count} models" } if trace_filtering
|
59
|
+
|
60
|
+
# Remove anonymous classes and non-class objects
|
61
|
+
before_count = models.size
|
62
|
+
models = models.select { |model| model.is_a?(Class) && model.name.present? }
|
63
|
+
log_filter_step('Anonymous/unnamed class removal', before_count, models.size, trace_filtering)
|
64
|
+
|
65
|
+
# Filter by namespace if specified
|
66
|
+
if options[:namespace]
|
67
|
+
namespace = options[:namespace].to_s
|
68
|
+
before_count = models.size
|
69
|
+
models = models.select { |model| model.name.start_with?(namespace) }
|
70
|
+
log_filter_step("Namespace filter (#{namespace})", before_count, models.size, trace_filtering)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Exclude specific models
|
74
|
+
if options[:exclude]
|
75
|
+
exclude_patterns = Array(options[:exclude])
|
76
|
+
before_count = models.size
|
77
|
+
models = models.reject do |model|
|
78
|
+
excluded = exclude_patterns.any? do |pattern|
|
79
|
+
case pattern
|
80
|
+
when Regexp
|
81
|
+
model.name.match?(pattern)
|
82
|
+
when String
|
83
|
+
model.name == pattern || model.table_name == pattern
|
84
|
+
else
|
85
|
+
false
|
86
|
+
end
|
87
|
+
end
|
88
|
+
if excluded && trace_filtering
|
89
|
+
Rails.logger.debug do
|
90
|
+
"[ModelDetector] Excluding #{model.name}: matched exclude pattern"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
excluded
|
94
|
+
end
|
95
|
+
log_filter_step('Exclude patterns', before_count, models.size, trace_filtering)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Include only specific models
|
99
|
+
if options[:include]
|
100
|
+
include_patterns = Array(options[:include])
|
101
|
+
before_count = models.size
|
102
|
+
models = models.select do |model|
|
103
|
+
included = include_patterns.any? do |pattern|
|
104
|
+
case pattern
|
105
|
+
when Regexp
|
106
|
+
model.name.match?(pattern)
|
107
|
+
when String
|
108
|
+
model.name == pattern || model.table_name == pattern
|
109
|
+
else
|
110
|
+
false
|
111
|
+
end
|
112
|
+
end
|
113
|
+
if included && trace_filtering
|
114
|
+
Rails.logger.debug do
|
115
|
+
"[ModelDetector] Including #{model.name}: matched include pattern"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
if !included && trace_filtering
|
119
|
+
Rails.logger.debug { "[ModelDetector] Excluding #{model.name}: did not match include patterns" }
|
120
|
+
end
|
121
|
+
included
|
122
|
+
end
|
123
|
+
log_filter_step('Include patterns', before_count, models.size, trace_filtering)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Exclude abstract models and models without valid tables
|
127
|
+
before_count = models.size
|
128
|
+
models = filter_models_concurrently(models, trace_filtering, options)
|
129
|
+
log_filter_step('Abstract/invalid table removal', before_count, models.size, trace_filtering)
|
130
|
+
|
131
|
+
# Exclude tables from configuration
|
132
|
+
excluded_tables = RailsLens.config.schema[:exclude_tables]
|
133
|
+
before_count = models.size
|
134
|
+
models = models.reject do |model|
|
135
|
+
begin
|
136
|
+
excluded = excluded_tables.include?(model.table_name)
|
137
|
+
if excluded && trace_filtering
|
138
|
+
Rails.logger.debug do
|
139
|
+
"[ModelDetector] Excluding #{model.name}: table '#{model.table_name}' in exclude_tables config"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
excluded
|
143
|
+
rescue ActiveRecord::ConnectionNotDefined
|
144
|
+
# This can happen in multi-db setups if the connection is not yet established
|
145
|
+
# We will assume the model should be kept in this case
|
146
|
+
if trace_filtering
|
147
|
+
Rails.logger.debug do
|
148
|
+
"[ModelDetector] Keeping #{model.name}: connection not defined, assuming keep"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
false
|
152
|
+
end
|
153
|
+
rescue ActiveRecord::StatementInvalid => e
|
154
|
+
if trace_filtering
|
155
|
+
Rails.logger.debug do
|
156
|
+
"[ModelDetector] Keeping #{model.name}: database error checking exclude_tables - #{e.message}"
|
157
|
+
end
|
158
|
+
end
|
159
|
+
false
|
160
|
+
end
|
161
|
+
log_filter_step('Configuration exclude_tables', before_count, models.size, trace_filtering)
|
162
|
+
|
163
|
+
if trace_filtering
|
164
|
+
Rails.logger.debug do
|
165
|
+
"[ModelDetector] Final result: #{models.size} models after all filtering"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
Rails.logger.debug { "[ModelDetector] Final models: #{models.map(&:name).join(', ')}" } if trace_filtering
|
169
|
+
|
170
|
+
models
|
171
|
+
end
|
172
|
+
|
173
|
+
def log_filter_step(step_name, before_count, after_count, trace_filtering)
|
174
|
+
return unless trace_filtering
|
175
|
+
|
176
|
+
filtered_count = before_count - after_count
|
177
|
+
if filtered_count.positive?
|
178
|
+
Rails.logger.debug do
|
179
|
+
"[ModelDetector] #{step_name}: filtered out #{filtered_count} models (#{before_count} -> #{after_count})"
|
180
|
+
end
|
181
|
+
else
|
182
|
+
Rails.logger.debug { "[ModelDetector] #{step_name}: no models filtered (#{after_count} remain)" }
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def filter_models_concurrently(models, trace_filtering, options = {})
|
187
|
+
# Use concurrent futures to check table existence in parallel
|
188
|
+
futures = models.map do |model|
|
189
|
+
Concurrent::Future.execute do
|
190
|
+
should_exclude = false
|
191
|
+
reason = nil
|
192
|
+
|
193
|
+
begin
|
194
|
+
# Skip abstract models unless explicitly included
|
195
|
+
if model.abstract_class? && !options[:include_abstract]
|
196
|
+
should_exclude = true
|
197
|
+
reason = 'abstract class'
|
198
|
+
# For abstract models that are included, skip table checks
|
199
|
+
elsif model.abstract_class? && options[:include_abstract]
|
200
|
+
reason = 'abstract class (included)'
|
201
|
+
# Skip models without configured tables
|
202
|
+
elsif !model.table_name
|
203
|
+
should_exclude = true
|
204
|
+
reason = 'no table name'
|
205
|
+
# Skip models whose tables don't exist
|
206
|
+
elsif !model.table_exists?
|
207
|
+
should_exclude = true
|
208
|
+
reason = "table '#{model.table_name}' does not exist"
|
209
|
+
# Additional check: Skip models that don't have any columns
|
210
|
+
elsif model.columns.empty?
|
211
|
+
should_exclude = true
|
212
|
+
reason = "table '#{model.table_name}' has no columns"
|
213
|
+
else
|
214
|
+
reason = "table '#{model.table_name}' exists with #{model.columns.size} columns"
|
215
|
+
end
|
216
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined => e
|
217
|
+
should_exclude = true
|
218
|
+
reason = "database error checking model - #{e.message}"
|
219
|
+
rescue NameError, NoMethodError => e
|
220
|
+
should_exclude = true
|
221
|
+
reason = "method error checking model - #{e.message}"
|
222
|
+
rescue StandardError => e
|
223
|
+
# Catch any other errors and exclude the model to prevent ERD corruption
|
224
|
+
should_exclude = true
|
225
|
+
reason = "unexpected error checking model - #{e.message}"
|
226
|
+
end
|
227
|
+
|
228
|
+
if trace_filtering
|
229
|
+
action = should_exclude ? 'Excluding' : 'Keeping'
|
230
|
+
Rails.logger.debug { "[ModelDetector] #{action} #{model.name}: #{reason}" }
|
231
|
+
end
|
232
|
+
|
233
|
+
{ model: model, exclude: should_exclude }
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# Wait for all futures to complete and filter results
|
238
|
+
results = futures.map(&:value!)
|
239
|
+
results.reject { |result| result[:exclude] }.pluck(:model)
|
240
|
+
end
|
241
|
+
|
242
|
+
def has_sti_column?(model)
|
243
|
+
return false unless model.table_exists?
|
244
|
+
|
245
|
+
sti_column = model.inheritance_column
|
246
|
+
model.column_names.include?(sti_column)
|
247
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined
|
248
|
+
false
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Parsers
|
5
|
+
class ClassInfo
|
6
|
+
attr_reader :name, :line_number, :column, :end_line, :namespace
|
7
|
+
|
8
|
+
def initialize(name:, line_number:, column:, end_line:, namespace: nil)
|
9
|
+
@name = name
|
10
|
+
@line_number = line_number
|
11
|
+
@column = column
|
12
|
+
@end_line = end_line
|
13
|
+
@namespace = namespace
|
14
|
+
end
|
15
|
+
|
16
|
+
def full_name
|
17
|
+
if namespace.present?
|
18
|
+
"#{namespace}::#{name}"
|
19
|
+
else
|
20
|
+
name
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def matches?(class_name)
|
25
|
+
class_name_str = class_name.to_s
|
26
|
+
|
27
|
+
# Exact matches
|
28
|
+
return true if name == class_name_str
|
29
|
+
return true if full_name == class_name_str
|
30
|
+
|
31
|
+
# Handle simple name match only if no namespace specified in query
|
32
|
+
return true if class_name_str.exclude?('::') && (name == class_name_str)
|
33
|
+
|
34
|
+
false
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
full_name
|
39
|
+
end
|
40
|
+
|
41
|
+
def inspect
|
42
|
+
"#<ClassInfo name=#{name} line=#{line_number} namespace=#{namespace}>"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Parsers
|
5
|
+
class ModuleInfo
|
6
|
+
attr_reader :name, :line_number, :column, :end_line, :namespace
|
7
|
+
|
8
|
+
def initialize(name:, line_number:, column:, end_line:, namespace: nil)
|
9
|
+
@name = name
|
10
|
+
@line_number = line_number
|
11
|
+
@column = column
|
12
|
+
@end_line = end_line
|
13
|
+
@namespace = namespace
|
14
|
+
end
|
15
|
+
|
16
|
+
def full_name
|
17
|
+
if namespace.present?
|
18
|
+
"#{namespace}::#{name}"
|
19
|
+
else
|
20
|
+
name
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_s
|
25
|
+
full_name
|
26
|
+
end
|
27
|
+
|
28
|
+
def inspect
|
29
|
+
"#<ModuleInfo name=#{name} line=#{line_number} namespace=#{namespace}>"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Parsers
|
5
|
+
class ParserResult
|
6
|
+
attr_reader :classes, :modules, :file_path
|
7
|
+
|
8
|
+
def initialize(classes:, modules:, file_path:)
|
9
|
+
@classes = classes
|
10
|
+
@modules = modules
|
11
|
+
@file_path = file_path
|
12
|
+
end
|
13
|
+
|
14
|
+
def find_class(name)
|
15
|
+
classes.find { |cls| cls.matches?(name) }
|
16
|
+
end
|
17
|
+
|
18
|
+
def find_module(name)
|
19
|
+
modules.find { |mod| mod.name == name || mod.full_name == name }
|
20
|
+
end
|
21
|
+
|
22
|
+
def class_names
|
23
|
+
classes.map(&:full_name)
|
24
|
+
end
|
25
|
+
|
26
|
+
def module_names
|
27
|
+
modules.map(&:full_name)
|
28
|
+
end
|
29
|
+
|
30
|
+
def empty?
|
31
|
+
classes.empty? && modules.empty?
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
lines = ["File: #{file_path}"]
|
36
|
+
|
37
|
+
unless modules.empty?
|
38
|
+
lines << 'Modules:'
|
39
|
+
modules.each { |mod| lines << " #{mod}" }
|
40
|
+
end
|
41
|
+
|
42
|
+
unless classes.empty?
|
43
|
+
lines << 'Classes:'
|
44
|
+
classes.each { |cls| lines << " #{cls}" }
|
45
|
+
end
|
46
|
+
|
47
|
+
lines.join("\n")
|
48
|
+
end
|
49
|
+
|
50
|
+
def inspect
|
51
|
+
"#<ParserResult file=#{file_path} classes=#{classes.size} modules=#{modules.size}>"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'prism'
|
4
|
+
|
5
|
+
module RailsLens
|
6
|
+
module Parsers
|
7
|
+
class PrismParser
|
8
|
+
def self.parse_file(file_path)
|
9
|
+
source = File.read(file_path)
|
10
|
+
parsed = Prism.parse(source)
|
11
|
+
|
12
|
+
classes = []
|
13
|
+
modules = []
|
14
|
+
|
15
|
+
traverse_node(parsed.value, classes, modules)
|
16
|
+
|
17
|
+
ParserResult.new(
|
18
|
+
classes: classes,
|
19
|
+
modules: modules,
|
20
|
+
file_path: file_path
|
21
|
+
)
|
22
|
+
rescue Prism::ParseError, Errno::ENOENT, IOError
|
23
|
+
# Return empty result on parse errors
|
24
|
+
ParserResult.new(
|
25
|
+
classes: [],
|
26
|
+
modules: [],
|
27
|
+
file_path: file_path
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.traverse_node(node, classes, modules, namespace = [])
|
32
|
+
return unless node
|
33
|
+
|
34
|
+
case node
|
35
|
+
when Prism::ClassNode
|
36
|
+
class_name = extract_constant_name(node.constant_path)
|
37
|
+
|
38
|
+
classes << ClassInfo.new(
|
39
|
+
name: class_name,
|
40
|
+
line_number: node.location.start_line,
|
41
|
+
column: node.location.start_column,
|
42
|
+
end_line: node.location.end_line,
|
43
|
+
namespace: namespace.join('::').presence
|
44
|
+
)
|
45
|
+
|
46
|
+
# Process nested classes and modules
|
47
|
+
traverse_children(node, classes, modules, namespace + [class_name])
|
48
|
+
|
49
|
+
when Prism::ModuleNode
|
50
|
+
module_name = extract_constant_name(node.constant_path)
|
51
|
+
|
52
|
+
modules << ModuleInfo.new(
|
53
|
+
name: module_name,
|
54
|
+
line_number: node.location.start_line,
|
55
|
+
column: node.location.start_column,
|
56
|
+
end_line: node.location.end_line,
|
57
|
+
namespace: namespace.join('::').presence
|
58
|
+
)
|
59
|
+
|
60
|
+
# Process nested classes and modules
|
61
|
+
traverse_children(node, classes, modules, namespace + [module_name])
|
62
|
+
|
63
|
+
else
|
64
|
+
# For other node types, recursively traverse children
|
65
|
+
traverse_children(node, classes, modules, namespace)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.traverse_children(node, classes, modules, namespace = [])
|
70
|
+
return unless node.respond_to?(:child_nodes)
|
71
|
+
|
72
|
+
node.child_nodes.each do |child_node|
|
73
|
+
traverse_node(child_node, classes, modules, namespace)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.extract_constant_name(constant_path)
|
78
|
+
case constant_path
|
79
|
+
when Prism::ConstantReadNode
|
80
|
+
constant_path.name.to_s
|
81
|
+
when Prism::ConstantPathNode
|
82
|
+
# For nested constants like A::B::C, extract the last part
|
83
|
+
extract_constant_name(constant_path.child)
|
84
|
+
else
|
85
|
+
constant_path.to_s
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Providers
|
5
|
+
# Base class for all annotation content providers
|
6
|
+
class Base
|
7
|
+
# Returns the type of content this provider generates
|
8
|
+
# :schema - Primary schema information (only one allowed)
|
9
|
+
# :section - A named section with structured content
|
10
|
+
# :notes - Analysis notes and recommendations
|
11
|
+
def type
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #type"
|
13
|
+
end
|
14
|
+
|
15
|
+
# Returns true if this provider should process the given model
|
16
|
+
def applicable?(_model_class)
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
# Processes the model and returns content
|
21
|
+
# For :schema type - returns a string with the schema content
|
22
|
+
# For :section type - returns a hash with { title: String, content: String } or nil
|
23
|
+
# For :notes type - returns an array of note strings
|
24
|
+
def process(model_class)
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #process"
|
26
|
+
end
|
27
|
+
|
28
|
+
protected
|
29
|
+
|
30
|
+
def model_has_table?(model_class)
|
31
|
+
!model_class.abstract_class? && model_class.table_exists?
|
32
|
+
rescue StandardError
|
33
|
+
false
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Providers
|
5
|
+
class ExtensionNotesProvider < Base
|
6
|
+
def type
|
7
|
+
:notes
|
8
|
+
end
|
9
|
+
|
10
|
+
def applicable?(model_class)
|
11
|
+
RailsLens.config.extensions[:enabled] && model_has_table?(model_class)
|
12
|
+
end
|
13
|
+
|
14
|
+
def process(model_class)
|
15
|
+
results = ExtensionLoader.apply_extensions(model_class)
|
16
|
+
results[:notes]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Providers
|
5
|
+
class ExtensionsProvider < Base
|
6
|
+
def type
|
7
|
+
:section
|
8
|
+
end
|
9
|
+
|
10
|
+
def process(model_class)
|
11
|
+
results = ExtensionLoader.apply_extensions(model_class)
|
12
|
+
|
13
|
+
return nil if results[:annotations].empty?
|
14
|
+
|
15
|
+
{
|
16
|
+
title: '== Extensions',
|
17
|
+
content: results[:annotations].join("\n")
|
18
|
+
}
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|