ffast 0.2.0 → 0.2.3

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +27 -0
  3. data/.github/workflows/ruby.yml +34 -0
  4. data/.gitignore +2 -0
  5. data/Fastfile +146 -3
  6. data/README.md +244 -132
  7. data/bin/console +6 -1
  8. data/bin/fast-experiment +3 -0
  9. data/bin/fast-mcp +7 -0
  10. data/fast.gemspec +24 -7
  11. data/lib/fast/cli.rb +129 -38
  12. data/lib/fast/experiment.rb +19 -2
  13. data/lib/fast/git.rb +1 -1
  14. data/lib/fast/mcp_server.rb +317 -0
  15. data/lib/fast/node.rb +258 -0
  16. data/lib/fast/prism_adapter.rb +310 -0
  17. data/lib/fast/rewriter.rb +64 -10
  18. data/lib/fast/scan.rb +203 -0
  19. data/lib/fast/shortcut.rb +23 -6
  20. data/lib/fast/source.rb +116 -0
  21. data/lib/fast/source_rewriter.rb +153 -0
  22. data/lib/fast/sql/rewriter.rb +98 -0
  23. data/lib/fast/sql.rb +165 -0
  24. data/lib/fast/summary.rb +435 -0
  25. data/lib/fast/version.rb +1 -1
  26. data/lib/fast.rb +165 -79
  27. data/mkdocs.yml +27 -3
  28. data/requirements-docs.txt +3 -0
  29. metadata +48 -62
  30. data/docs/command_line.md +0 -238
  31. data/docs/editors-integration.md +0 -46
  32. data/docs/experiments.md +0 -153
  33. data/docs/ideas.md +0 -80
  34. data/docs/index.md +0 -402
  35. data/docs/pry-integration.md +0 -27
  36. data/docs/research.md +0 -93
  37. data/docs/shortcuts.md +0 -323
  38. data/docs/similarity_tutorial.md +0 -176
  39. data/docs/syntax.md +0 -395
  40. data/docs/videos.md +0 -16
  41. data/examples/build_stubbed_and_let_it_be_experiment.rb +0 -51
  42. data/examples/experimental_replacement.rb +0 -46
  43. data/examples/find_usage.rb +0 -26
  44. data/examples/let_it_be_experiment.rb +0 -11
  45. data/examples/method_complexity.rb +0 -37
  46. data/examples/search_duplicated.rb +0 -15
  47. data/examples/similarity_research.rb +0 -58
  48. data/examples/simple_rewriter.rb +0 -6
  49. data/experiments/let_it_be_experiment.rb +0 -9
  50. data/experiments/remove_useless_hook.rb +0 -9
  51. data/experiments/replace_create_with_build_stubbed.rb +0 -10
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require 'fast/source'
5
+
6
+ module Fast
7
+ module PrismAdapter
8
+ module_function
9
+
10
+ class Location
11
+ attr_accessor :node
12
+ attr_reader :expression
13
+
14
+ def initialize(buffer_name, source, start_offset, end_offset, prism_node: nil)
15
+ @buffer_name = buffer_name
16
+ @source = source
17
+ @prism_node = prism_node
18
+ buffer = Fast::Source.buffer(buffer_name, source: source)
19
+ @expression = Fast::Source.range(
20
+ buffer,
21
+ character_offset(source, start_offset),
22
+ character_offset(source, end_offset)
23
+ )
24
+ end
25
+
26
+ def name
27
+ return unless @prism_node&.respond_to?(:name_loc) && @prism_node.name_loc
28
+
29
+ range_for(@prism_node.name_loc)
30
+ end
31
+
32
+ def selector
33
+ return unless @prism_node&.respond_to?(:message_loc) && @prism_node.message_loc
34
+
35
+ range_for(@prism_node.message_loc)
36
+ end
37
+
38
+ def operator
39
+ return unless @prism_node&.respond_to?(:operator_loc) && @prism_node.operator_loc
40
+
41
+ range_for(@prism_node.operator_loc)
42
+ end
43
+
44
+ private
45
+
46
+ def character_offset(source, byte_offset)
47
+ source.byteslice(0, byte_offset).to_s.length
48
+ end
49
+
50
+ def range_for(prism_location)
51
+ buffer = Fast::Source.buffer(@buffer_name, source: @source)
52
+ Fast::Source.range(
53
+ buffer,
54
+ character_offset(@source, prism_location.start_offset),
55
+ character_offset(@source, prism_location.end_offset)
56
+ )
57
+ end
58
+ end
59
+
60
+ class Node < Fast::Node
61
+ def initialize(type, children:, loc:)
62
+ super(type, Array(children), location: loc)
63
+ end
64
+
65
+ def updated(type = nil, children = nil, properties = nil)
66
+ self.class.new(
67
+ type || self.type,
68
+ children: children || self.children,
69
+ loc: properties&.fetch(:location, loc) || loc
70
+ )
71
+ end
72
+ end
73
+
74
+ def parse(source, buffer_name: '(string)')
75
+ result = Prism.parse(source)
76
+ return unless result.success?
77
+
78
+ adapt(result.value, source, buffer_name)
79
+ end
80
+
81
+ def adapt(node, source, buffer_name)
82
+ return if node.nil?
83
+
84
+ case node
85
+ when Prism::ProgramNode
86
+ statements = adapt_statements(node.statements, source, buffer_name)
87
+ statements.is_a?(Node) ? statements : build_node(:begin, statements, node, source, buffer_name)
88
+ when Prism::StatementsNode
89
+ adapt_statements(node, source, buffer_name)
90
+ when Prism::ModuleNode
91
+ build_node(:module, [adapt(node.constant_path, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name)
92
+ when Prism::ClassNode
93
+ build_node(:class, [adapt(node.constant_path, source, buffer_name), adapt(node.superclass, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name)
94
+ when Prism::SingletonClassNode
95
+ build_node(:sclass, [adapt(node.expression, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name)
96
+ when Prism::DefNode
97
+ if node.receiver
98
+ build_node(:defs, [adapt(node.receiver, source, buffer_name), node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name)
99
+ else
100
+ build_node(:def, [node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name)
101
+ end
102
+ when Prism::BlockNode
103
+ return nil unless node.respond_to?(:call)
104
+
105
+ build_node(:block, [adapt_call_node(node.call, source, buffer_name), adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name)
106
+ when Prism::CallNode
107
+ if node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode)
108
+ return build_node(
109
+ :block,
110
+ [
111
+ adapt_call_node(node, source, buffer_name),
112
+ adapt_block_parameters(node.block.parameters, source, buffer_name),
113
+ adapt(node.block.body, source, buffer_name)
114
+ ],
115
+ node,
116
+ source,
117
+ buffer_name
118
+ )
119
+ end
120
+
121
+ adapt_call_node(node, source, buffer_name)
122
+ when Prism::ParenthesesNode
123
+ adapt(node.body, source, buffer_name)
124
+ when Prism::RangeNode
125
+ build_node(node.exclude_end? ? :erange : :irange, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name)
126
+ when Prism::BlockArgumentNode
127
+ build_node(:block_pass, [adapt(node.expression, source, buffer_name)], node, source, buffer_name)
128
+ when Prism::ConstantPathNode
129
+ build_const_path(node, source, buffer_name)
130
+ when Prism::ConstantReadNode
131
+ build_node(:const, [nil, node.name], node, source, buffer_name)
132
+ when Prism::ConstantWriteNode
133
+ build_node(:casgn, [nil, node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name)
134
+ when Prism::SymbolNode
135
+ build_node(:sym, [node.unescaped], node, source, buffer_name)
136
+ when Prism::StringNode
137
+ build_node(:str, [node.unescaped], node, source, buffer_name)
138
+ when Prism::InterpolatedStringNode
139
+ build_node(:dstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name)
140
+ when Prism::InterpolatedSymbolNode
141
+ build_node(:dsym, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name)
142
+ when Prism::ArrayNode
143
+ build_node(:array, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name)
144
+ when Prism::HashNode
145
+ build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name)
146
+ when Prism::KeywordHashNode
147
+ build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name)
148
+ when Prism::AssocNode
149
+ build_node(:pair, [adapt(node.key, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name)
150
+ when Prism::SelfNode
151
+ build_node(:self, [], node, source, buffer_name)
152
+ when Prism::LocalVariableReadNode
153
+ build_node(:lvar, [node.name], node, source, buffer_name)
154
+ when Prism::InstanceVariableReadNode
155
+ build_node(:ivar, [node.name], node, source, buffer_name)
156
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode
157
+ build_node(:ivasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name)
158
+ when Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode
159
+ build_node(:lvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name)
160
+ when Prism::LocalVariableOperatorWriteNode
161
+ build_node(
162
+ :op_asgn,
163
+ [
164
+ build_node(:lvasgn, [node.name], node, source, buffer_name),
165
+ (node.respond_to?(:binary_operator) ? node.binary_operator : node.operator),
166
+ adapt(node.value, source, buffer_name)
167
+ ],
168
+ node,
169
+ source,
170
+ buffer_name
171
+ )
172
+ when Prism::IntegerNode
173
+ build_node(:int, [node.value], node, source, buffer_name)
174
+ when Prism::FloatNode
175
+ build_node(:float, [node.value], node, source, buffer_name)
176
+ when Prism::TrueNode
177
+ build_node(:true, [], node, source, buffer_name)
178
+ when Prism::FalseNode
179
+ build_node(:false, [], node, source, buffer_name)
180
+ when Prism::NilNode
181
+ build_node(:nil, [], node, source, buffer_name)
182
+ when Prism::IfNode
183
+ build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name), adapt(node.consequent, source, buffer_name)], node, source, buffer_name)
184
+ when Prism::UnlessNode
185
+ build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.consequent, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name)
186
+ when Prism::CaseNode
187
+ children = [adapt(node.predicate, source, buffer_name)]
188
+ children.concat(node.conditions.map { |condition| adapt(condition, source, buffer_name) })
189
+ else_clause =
190
+ if node.respond_to?(:else_clause)
191
+ node.else_clause
192
+ elsif node.respond_to?(:consequent)
193
+ node.consequent
194
+ end
195
+ children << adapt_else_clause(else_clause, source, buffer_name) if else_clause
196
+ build_node(:case, children, node, source, buffer_name)
197
+ when Prism::WhenNode
198
+ condition =
199
+ if node.conditions.length == 1
200
+ adapt(node.conditions.first, source, buffer_name)
201
+ else
202
+ build_node(:array, node.conditions.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name)
203
+ end
204
+ build_node(:when, [condition, adapt(node.statements, source, buffer_name)].compact, node, source, buffer_name)
205
+ when Prism::ElseNode
206
+ adapt_else_clause(node, source, buffer_name)
207
+ when Prism::BeginNode, Prism::EmbeddedStatementsNode
208
+ statements = adapt_statements(node.statements, source, buffer_name)
209
+ children = statements.is_a?(Node) && statements.type == :begin ? statements.children : Array(statements)
210
+ build_node(:begin, children, node, source, buffer_name)
211
+ when Prism::EmbeddedVariableNode
212
+ build_node(:begin, [adapt(node.variable, source, buffer_name)].compact, node, source, buffer_name)
213
+ when Prism::LambdaNode
214
+ build_node(:lambda, [adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name)
215
+ else
216
+ nil
217
+ end
218
+ end
219
+
220
+ def adapt_statements(node, source, buffer_name)
221
+ return nil unless node
222
+
223
+ children = node.body.filter_map { |child| adapt(child, source, buffer_name) }
224
+ return nil if children.empty?
225
+ return children.first if children.one?
226
+
227
+ build_node(:begin, children, node, source, buffer_name)
228
+ end
229
+
230
+ def adapt_parameters(node, source, buffer_name)
231
+ return build_node(:args, [], nil, source, buffer_name) unless node
232
+
233
+ children = []
234
+ children.concat(node.requireds.map { |child| build_node(:arg, [child.name], child, source, buffer_name) }) if node.respond_to?(:requireds)
235
+ children.concat(node.optionals.map { |child| build_node(:optarg, [child.name, adapt(child.value, source, buffer_name)], child, source, buffer_name) }) if node.respond_to?(:optionals)
236
+ children << build_node(:restarg, [parameter_name(node.rest)], node.rest, source, buffer_name) if node.respond_to?(:rest) && node.rest
237
+ children.concat(node.posts.map { |child| build_node(:arg, [child.name], child, source, buffer_name) }) if node.respond_to?(:posts)
238
+ children.concat(node.keywords.map { |child| adapt_keyword_parameter(child, source, buffer_name) }) if node.respond_to?(:keywords)
239
+ children << build_node(:kwrestarg, [parameter_name(node.keyword_rest)], node.keyword_rest, source, buffer_name) if node.respond_to?(:keyword_rest) && node.keyword_rest
240
+ children << build_node(:blockarg, [parameter_name(node.block)], node.block, source, buffer_name) if node.respond_to?(:block) && node.block
241
+ build_node(:args, children, node, source, buffer_name)
242
+ end
243
+
244
+ def adapt_block_parameters(node, source, buffer_name)
245
+ return build_node(:args, [], nil, source, buffer_name) unless node
246
+
247
+ params = node.respond_to?(:parameters) ? node.parameters : node
248
+ adapt_parameters(params, source, buffer_name)
249
+ end
250
+
251
+ def adapt_keyword_parameter(node, source, buffer_name)
252
+ case node
253
+ when Prism::RequiredKeywordParameterNode
254
+ build_node(:kwarg, [node.name], node, source, buffer_name)
255
+ when Prism::OptionalKeywordParameterNode
256
+ build_node(:kwoptarg, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name)
257
+ else
258
+ build_node(:arg, [node.name], node, source, buffer_name)
259
+ end
260
+ end
261
+
262
+ def adapt_call_node(node, source, buffer_name)
263
+ children = [adapt(node.receiver, source, buffer_name), node.name]
264
+ children.concat(node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) } || [])
265
+ children << adapt(node.block, source, buffer_name) if node.respond_to?(:block) && node.block && !node.block.is_a?(Prism::BlockNode)
266
+ return build_node(:send, children, node, source, buffer_name) unless node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode)
267
+
268
+ end_offset = node.block.location.start_offset
269
+ while end_offset > node.location.start_offset && source.byteslice(end_offset - 1, 1)&.match?(/\s/)
270
+ end_offset -= 1
271
+ end
272
+
273
+ loc = Location.new(
274
+ buffer_name,
275
+ source,
276
+ node.location.start_offset,
277
+ end_offset,
278
+ prism_node: node
279
+ )
280
+ send_node = Node.new(:send, children: children, loc: loc)
281
+ loc.node = send_node
282
+ send_node
283
+ end
284
+
285
+ def adapt_else_clause(node, source, buffer_name)
286
+ adapt(node.statements, source, buffer_name)
287
+ end
288
+
289
+ def parameter_name(node)
290
+ node.respond_to?(:name) ? node.name : nil
291
+ end
292
+
293
+ def build_const_path(node, source, buffer_name)
294
+ parent = node.parent ? adapt(node.parent, source, buffer_name) : nil
295
+ build_node(:const, [parent, node.child.name], node, source, buffer_name)
296
+ end
297
+
298
+ def build_node(type, children, prism_node, source, buffer_name)
299
+ loc =
300
+ if prism_node
301
+ Location.new(buffer_name, source, prism_node.location.start_offset, prism_node.location.end_offset, prism_node: prism_node)
302
+ else
303
+ Location.new(buffer_name, source, 0, 0)
304
+ end
305
+ node = Node.new(type, children: children, loc: loc)
306
+ loc.node = node
307
+ node
308
+ end
309
+ end
310
+ end
data/lib/fast/rewriter.rb CHANGED
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fast/source'
4
+ require_relative 'source_rewriter'
5
+
3
6
  # Rewriter loads a set of methods related to automated replacement using
4
7
  # expressions and custom blocks of code.
5
8
  module Fast
6
9
  class << self
7
10
  # Replaces content based on a pattern.
8
- # @param [Astrolabe::Node] ast with the current AST to search.
11
+ # @param [Fast::Node] ast with the current AST to search.
9
12
  # @param [String] pattern with the expression to be targeting nodes.
10
13
  # @param [Proc] replacement gives the [Rewriter] context in the block.
11
14
  # @example
@@ -15,7 +18,9 @@ module Fast
15
18
  # @return [String] with the new source code after apply the replacement
16
19
  # @see Fast::Rewriter
17
20
  def replace(pattern, ast, source = nil, &replacement)
18
- rewriter_for(pattern, ast, source, &replacement).rewrite!
21
+ rewritten = rewriter_for(pattern, ast, source, &replacement).rewrite!
22
+ Fast.validate_ruby!(rewritten, buffer_name: ast.buffer_name) if rewritten
23
+ rewritten
19
24
  end
20
25
 
21
26
  # @return [Fast::Rewriter]
@@ -54,12 +59,11 @@ module Fast
54
59
  # rewriter.search ='(lvasgn _ ...)'
55
60
  # rewriter.replacement = -> (node) { replace(node.location.name, 'variable_renamed') }
56
61
  # rewriter.rewrite! # => "variable_renamed = 1"
57
- class Rewriter < Parser::TreeRewriter
62
+ class Rewriter
58
63
  # @return [Integer] with occurrence index
59
64
  attr_reader :match_index
60
65
  attr_accessor :search, :replacement, :source, :ast
61
66
  def initialize(*_args)
62
- super()
63
67
  @match_index = 0
64
68
  end
65
69
 
@@ -69,14 +73,18 @@ module Fast
69
73
  end
70
74
 
71
75
  def buffer
72
- buffer = Parser::Source::Buffer.new('replacement')
73
- buffer.source = source || ast.loc.expression.source
74
- buffer
76
+ Fast::Source.buffer('replacement', source: source || ast.loc.expression.source)
77
+ end
78
+
79
+ def rewrite(source_buffer, root)
80
+ @source_rewriter = Fast::SourceRewriter.new(source_buffer)
81
+ traverse(root)
82
+ @source_rewriter.process
75
83
  end
76
84
 
77
85
  # @return [Array<Symbol>] with all types that matches
78
86
  def types
79
- Fast.search(search, ast).grep(Parser::AST::Node).map(&:type).uniq
87
+ Fast.search(search, ast).select { |node| Fast.ast_node?(node) }.map(&:type).uniq
80
88
  end
81
89
 
82
90
  def match?(node)
@@ -92,13 +100,33 @@ module Fast
92
100
  @match_index += 1
93
101
  execute_replacement(node, captures)
94
102
  end
95
- super(node)
103
+ traverse_children(node)
96
104
  end
97
105
  end
98
106
  end
99
107
 
108
+ def remove(range)
109
+ @source_rewriter.remove(range)
110
+ end
111
+
112
+ def wrap(range, before, after)
113
+ @source_rewriter.wrap(range, before, after)
114
+ end
115
+
116
+ def insert_before(range, content)
117
+ @source_rewriter.insert_before(range, content)
118
+ end
119
+
120
+ def insert_after(range, content)
121
+ @source_rewriter.insert_after(range, content)
122
+ end
123
+
124
+ def replace(range, content)
125
+ @source_rewriter.replace(range, content)
126
+ end
127
+
100
128
  # Execute {#replacement} block
101
- # @param [Astrolabe::Node] node that will be yield in the replacement block
129
+ # @param [Fast::Node] node that will be yield in the replacement block
102
130
  # @param [Array<Object>, nil] captures are yield if {#replacement} take second argument.
103
131
  def execute_replacement(node, captures)
104
132
  if replacement.parameters.length == 1
@@ -107,5 +135,31 @@ module Fast
107
135
  instance_exec node, captures, &replacement
108
136
  end
109
137
  end
138
+
139
+ private
140
+
141
+ def traverse(node)
142
+ return if node.nil?
143
+
144
+ if node.is_a?(Array)
145
+ node.each { |child| traverse(child) }
146
+ return
147
+ end
148
+
149
+ return unless Fast.ast_node?(node)
150
+
151
+ handler = :"on_#{node.type}"
152
+ if respond_to?(handler, true)
153
+ public_send(handler, node)
154
+ else
155
+ traverse_children(node)
156
+ end
157
+ end
158
+
159
+ def traverse_children(node)
160
+ node.children.each do |child|
161
+ traverse(child) if Fast.ast_node?(child) || child.is_a?(Array)
162
+ end
163
+ end
110
164
  end
111
165
  end
data/lib/fast/scan.rb ADDED
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ class Scan
5
+ GROUPS = {
6
+ models: 'Models',
7
+ controllers: 'Controllers',
8
+ services: 'Services',
9
+ jobs: 'Jobs',
10
+ mailers: 'Mailers',
11
+ libraries: 'Libraries',
12
+ other: 'Other'
13
+ }.freeze
14
+
15
+ MAX_METHODS = 5
16
+ MAX_SIGNALS = 4
17
+ MAX_MACROS = 3
18
+
19
+ def initialize(locations, command_name: '.scan', level: nil)
20
+ @locations = Array(locations)
21
+ @command_name = command_name
22
+ @level = normalize_level(level)
23
+ end
24
+
25
+ def scan
26
+ files = Fast.ruby_files_from(*@locations)
27
+ grouped = files.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |file, memo|
28
+ entries = flatten_entries(Fast.summary(IO.read(file), file: file, command_name: @command_name).outline)
29
+ next if entries.empty?
30
+
31
+ memo[classify(file, entries)] << [file, entries]
32
+ end
33
+
34
+ print_grouped(grouped)
35
+ end
36
+
37
+ private
38
+
39
+ def classify(file, entries)
40
+ entries = structural_entries(entries)
41
+
42
+ return :models if file.include?('/models/') || model_like?(entries)
43
+ return :controllers if file.include?('/controllers/') || controller_like?(entries)
44
+ return :services if file.include?('/services/') || name_like?(entries, /Service\z/)
45
+ return :jobs if file.include?('/jobs/') || name_like?(entries, /Job\z/)
46
+ return :mailers if file.include?('/mailers/') || name_like?(entries, /Mailer\z/)
47
+ return :libraries if file.start_with?('lib/')
48
+
49
+ :other
50
+ end
51
+
52
+ def model_like?(entries)
53
+ entries.any? do |entry|
54
+ superclass = entry[:superclass].to_s
55
+ superclass.end_with?('ApplicationRecord', 'ActiveRecord::Base') ||
56
+ entry[:relationships].any? ||
57
+ entry[:validations].any? ||
58
+ entry[:scopes].any?
59
+ end
60
+ end
61
+
62
+ def controller_like?(entries)
63
+ entries.any? do |entry|
64
+ superclass = entry[:superclass].to_s
65
+ superclass.end_with?('Controller', 'BaseController', 'ApplicationController') ||
66
+ entry[:hooks].any? { |hook| hook.include?('_action') }
67
+ end
68
+ end
69
+
70
+ def name_like?(entries, pattern)
71
+ entries.any? { |entry| entry[:name].to_s.match?(pattern) }
72
+ end
73
+
74
+ def print_grouped(grouped)
75
+ GROUPS.each do |key, label|
76
+ files = grouped[key]
77
+ next if files.empty?
78
+
79
+ puts "#{label}:"
80
+ files.sort_by(&:first).each do |file, entries|
81
+ print_file(file, entries)
82
+ end
83
+ puts
84
+ end
85
+ end
86
+
87
+ def print_file(file, entries)
88
+ entries = structural_entries(entries)
89
+ return if entries.empty?
90
+
91
+ puts "- #{file}"
92
+ entries.each do |entry|
93
+ puts " #{object_signature(entry)}"
94
+
95
+ signals = build_signals(entry)
96
+ puts " signals: #{signals.join(' | ')}" if show_signals? && signals.any?
97
+
98
+ methods = build_methods(entry)
99
+ puts " methods: #{methods.join(', ')}" if show_methods? && methods.any?
100
+ end
101
+ end
102
+
103
+ def structural_entries(entries)
104
+ filtered = entries.select do |entry|
105
+ %i[module class].include?(entry[:kind]) && interesting_entry?(entry)
106
+ end
107
+ filtered.empty? ? entries.reject { |entry| entry[:kind] == :send } : filtered
108
+ end
109
+
110
+ def flatten_entries(entries, namespace = nil)
111
+ entries.flat_map do |entry|
112
+ qualified_name = qualify_name(namespace, entry[:name])
113
+ flattened_entry = entry.merge(
114
+ name: qualified_name,
115
+ nested: []
116
+ )
117
+
118
+ [flattened_entry] + flatten_entries(entry[:nested], qualified_name)
119
+ end
120
+ end
121
+
122
+ def qualify_name(namespace, name)
123
+ return name unless namespace && name
124
+ return name if name.include?('::')
125
+
126
+ "#{namespace}::#{name}"
127
+ end
128
+
129
+ def interesting_entry?(entry)
130
+ entry[:methods].values.any?(&:any?) ||
131
+ entry[:relationships].any? ||
132
+ entry[:hooks].any? ||
133
+ entry[:validations].any? ||
134
+ entry[:scopes].any? ||
135
+ entry[:macros].any? ||
136
+ entry[:mixins].any?
137
+ end
138
+
139
+ def object_signature(entry)
140
+ signature = entry[:name].to_s
141
+ return signature unless entry[:kind] == :class && entry[:superclass]
142
+
143
+ "#{signature} < #{entry[:superclass]}"
144
+ end
145
+
146
+ def build_signals(entry)
147
+ signals = []
148
+ signals << summarize_section('relationships', entry[:relationships]) if entry[:relationships].any?
149
+ signals << summarize_section('hooks', entry[:hooks]) if entry[:hooks].any?
150
+ signals << summarize_section('validations', entry[:validations]) if entry[:validations].any?
151
+ signals << summarize_section('scopes', entry[:scopes]) if entry[:scopes].any?
152
+ signals << summarize_section('macros', entry[:macros], limit: MAX_MACROS) if entry[:macros].any?
153
+ signals << summarize_section('mixins', entry[:mixins]) if entry[:mixins].any?
154
+ signals.first(MAX_SIGNALS)
155
+ end
156
+
157
+ def summarize_section(name, values, limit: 2)
158
+ preview = values.first(limit).join(', ')
159
+ suffix = values.length > limit ? ", +#{values.length - limit}" : ''
160
+ "#{name}=#{preview}#{suffix}"
161
+ end
162
+
163
+ def build_methods(entry)
164
+ public_methods = entry[:methods][:public].first(MAX_METHODS)
165
+ protected_methods = entry[:methods][:protected].first(2)
166
+ private_methods = entry[:methods][:private].first(2)
167
+
168
+ methods = public_methods.map { |method| qualify_method(entry, method) }
169
+ methods.concat(protected_methods.map { |method| "protected #{qualify_method(entry, method)}" })
170
+ methods.concat(private_methods.map { |method| "private #{qualify_method(entry, method)}" })
171
+ methods
172
+ end
173
+
174
+ def qualify_method(entry, signature)
175
+ method = signature.delete_prefix('def ')
176
+ separator = singleton_method?(method) || module_function_entry?(entry) ? '.' : '#'
177
+ method = method.delete_prefix('self.')
178
+ "#{entry[:name]}#{separator}#{method}"
179
+ end
180
+
181
+ def singleton_method?(method)
182
+ method.start_with?('self.')
183
+ end
184
+
185
+ def module_function_entry?(entry)
186
+ entry[:kind] == :module && entry[:macros].include?('module_function')
187
+ end
188
+
189
+ def normalize_level(level)
190
+ return 3 if level.nil?
191
+
192
+ [[level.to_i, 1].max, 3].min
193
+ end
194
+
195
+ def show_signals?
196
+ @level >= 2
197
+ end
198
+
199
+ def show_methods?
200
+ @level >= 3
201
+ end
202
+ end
203
+ end