ffast 0.2.2 → 0.2.4

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/fast-pattern-expert/SKILL.md +71 -0
  3. data/.github/workflows/release.yml +27 -0
  4. data/.github/workflows/ruby.yml +34 -0
  5. data/.gitignore +2 -0
  6. data/Fastfile +105 -18
  7. data/README.md +21 -7
  8. data/bin/console +1 -1
  9. data/bin/fast-experiment +3 -0
  10. data/bin/fast-mcp +7 -0
  11. data/fast.gemspec +1 -3
  12. data/ideia_blog_post.md +36 -0
  13. data/lib/fast/cli.rb +74 -23
  14. data/lib/fast/experiment.rb +19 -2
  15. data/lib/fast/git.rb +1 -1
  16. data/lib/fast/mcp_server.rb +341 -0
  17. data/lib/fast/node.rb +258 -0
  18. data/lib/fast/prism_adapter.rb +327 -0
  19. data/lib/fast/rewriter.rb +64 -10
  20. data/lib/fast/scan.rb +207 -0
  21. data/lib/fast/shortcut.rb +16 -4
  22. data/lib/fast/source.rb +116 -0
  23. data/lib/fast/source_rewriter.rb +153 -0
  24. data/lib/fast/sql/rewriter.rb +36 -7
  25. data/lib/fast/sql.rb +15 -17
  26. data/lib/fast/summary.rb +440 -0
  27. data/lib/fast/version.rb +1 -1
  28. data/lib/fast.rb +218 -101
  29. data/mkdocs.yml +19 -4
  30. data/requirements-docs.txt +3 -0
  31. metadata +18 -59
  32. data/docs/command_line.md +0 -238
  33. data/docs/editors-integration.md +0 -46
  34. data/docs/experiments.md +0 -155
  35. data/docs/git.md +0 -115
  36. data/docs/ideas.md +0 -70
  37. data/docs/index.md +0 -404
  38. data/docs/pry-integration.md +0 -27
  39. data/docs/research.md +0 -93
  40. data/docs/shortcuts.md +0 -323
  41. data/docs/similarity_tutorial.md +0 -176
  42. data/docs/sql-support.md +0 -253
  43. data/docs/syntax.md +0 -395
  44. data/docs/videos.md +0 -16
  45. data/docs/walkthrough.md +0 -135
  46. data/examples/build_stubbed_and_let_it_be_experiment.rb +0 -51
  47. data/examples/experimental_replacement.rb +0 -46
  48. data/examples/find_usage.rb +0 -26
  49. data/examples/let_it_be_experiment.rb +0 -11
  50. data/examples/method_complexity.rb +0 -37
  51. data/examples/search_duplicated.rb +0 -15
  52. data/examples/similarity_research.rb +0 -58
  53. data/examples/simple_rewriter.rb +0 -6
  54. data/experiments/let_it_be_experiment.rb +0 -9
  55. data/experiments/remove_useless_hook.rb +0 -9
  56. data/experiments/replace_create_with_build_stubbed.rb +0 -10
data/lib/fast/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fast'
4
+ require 'fast/source'
4
5
  require 'fast/version'
5
6
  require 'fast/sql'
6
7
  require 'coderay'
@@ -11,18 +12,33 @@ require 'ostruct'
11
12
  # It defines #report and #highlight functions that can be used to pretty print
12
13
  # code and results from the search.
13
14
  module Fast
15
+ module SymbolExtension
16
+ def loc
17
+ OpenStruct.new(expression: OpenStruct.new(line: 0))
18
+ end
19
+ end
20
+
14
21
  module_function
15
22
 
16
23
  # Highligh some source code based on the node.
17
24
  # Useful for printing code with syntax highlight.
18
25
  # @param show_sexp [Boolean] prints node expression instead of code
19
26
  # @param colorize [Boolean] skips `CodeRay` processing when false.
20
- def highlight(node, show_sexp: false, colorize: true, sql: false)
27
+ # @param level [Integer] defines the max depth to print the AST.
28
+ def highlight(node, show_sexp: false, colorize: true, sql: false, level: nil)
21
29
  output =
22
30
  if node.respond_to?(:loc) && !show_sexp
23
- wrap_source_range(node).source
31
+ if level
32
+ Fast.fold_source(node, level: level)
33
+ else
34
+ wrap_source_range(node).source
35
+ end
36
+ elsif show_sexp && level && Fast.ast_node?(node)
37
+ Fast.fold_ast(node, level: level).to_s
38
+ elsif show_sexp
39
+ node.to_s
24
40
  else
25
- node
41
+ node.to_s
26
42
  end
27
43
  return output unless colorize
28
44
 
@@ -33,7 +49,7 @@ module Fast
33
49
  # and fixes end of the expression including heredoc strings.
34
50
  def wrap_source_range(node)
35
51
  expression = node.loc.expression
36
- Parser::Source::Range.new(
52
+ Fast::Source.range(
37
53
  expression.source_buffer,
38
54
  first_position_from_expression(node),
39
55
  last_position_from_expression(node) || expression.end_pos
@@ -50,7 +66,7 @@ module Fast
50
66
  # to show the proper whitespaces for identing the next lines of the code.
51
67
  def first_position_from_expression(node)
52
68
  expression = node.loc.expression
53
- if node.parent && node.parent.loc.expression.line != expression.line
69
+ if node.respond_to?(:parent) && node.parent && node.parent.loc.expression.line != expression.line
54
70
  expression.begin_pos - expression.column
55
71
  else
56
72
  expression.begin_pos
@@ -59,15 +75,19 @@ module Fast
59
75
 
60
76
  # Combines {.highlight} with files printing file name in the head with the
61
77
  # source line.
62
- # @param result [Astrolabe::Node]
78
+ # @param result [Fast::Node]
63
79
  # @param show_sexp [Boolean] Show string expression instead of source
64
80
  # @param file [String] Show the file name and result line before content
65
81
  # @param headless [Boolean] Skip printing the file name and line before content
82
+ # @param level [Integer] Skip exploring deep branches of AST when showing sexp
66
83
  # @example
67
84
  # Fast.report(result, file: 'file.rb')
68
- def report(result, show_link: false, show_permalink: false, show_sexp: false, file: nil, headless: false, bodyless: false, colorize: true) # rubocop:disable Metrics/ParameterLists
85
+ def report(result, show_link: false, show_permalink: false, show_sexp: false, file: nil, headless: false, bodyless: false, colorize: true, level: nil) # rubocop:disable Metrics/ParameterLists
69
86
  if file
70
- line = result.loc.expression.line if result.is_a?(Parser::AST::Node)
87
+ if result.is_a?(Symbol) && !result.respond_to?(:loc)
88
+ result.extend(SymbolExtension)
89
+ end
90
+ line = result.loc.expression.line if Fast.ast_node?(result) && result.respond_to?(:loc)
71
91
  if show_link
72
92
  puts(result.link)
73
93
  elsif show_permalink
@@ -76,23 +96,20 @@ module Fast
76
96
  puts(highlight("# #{file}:#{line}", colorize: colorize))
77
97
  end
78
98
  end
79
- puts(highlight(result, show_sexp: show_sexp, colorize: colorize)) unless bodyless
99
+ puts(highlight(result, show_sexp: show_sexp, colorize: colorize, level: level)) unless bodyless
80
100
  end
81
101
 
82
102
  # Command Line Interface for Fast
83
103
  class Cli # rubocop:disable Metrics/ClassLength
84
- attr_reader :pattern, :show_sexp, :pry, :from_code, :similar, :help
104
+ attr_reader :pattern, :show_sexp, :pry, :from_code, :similar, :help, :level
85
105
  def initialize(args)
86
- args = replace_args_with_shortcut(args) if args.first&.start_with?('.')
87
-
88
- @pattern, *@files = args.reject { |arg| arg.start_with? '-' }
106
+ args = args.dup
107
+ args = replace_args_with_shortcut(args) if shortcut_name_from(args)
89
108
  @colorize = STDOUT.isatty
90
-
91
109
  option_parser.parse! args
110
+ @pattern, @files = extract_pattern_and_files(args)
92
111
 
93
- @files = [*@files].reject { |arg| arg.start_with?('-') }
94
112
  @sql ||= @files.any? && @files.all? { |file| file.end_with?('.sql') }
95
-
96
113
  require 'fast/sql' if @sql
97
114
  end
98
115
 
@@ -103,6 +120,10 @@ module Fast
103
120
  @debug = true
104
121
  end
105
122
 
123
+ opts.on('-l', '--level LEVELS', 'Maximum depth to print the AST') do |level|
124
+ @level = level.to_i
125
+ end
126
+
106
127
  opts.on('--ast', 'Print AST instead of code') do
107
128
  @show_sexp = true
108
129
  end
@@ -154,6 +175,16 @@ module Fast
154
175
  @from_code = true
155
176
  end
156
177
 
178
+ opts.on('--validate-pattern PATTERN', 'Validate a node pattern') do |pattern|
179
+ begin
180
+ Fast.expression(pattern)
181
+ puts "Pattern is valid."
182
+ rescue StandardError => e
183
+ puts "Invalid pattern: #{e.message}"
184
+ end
185
+ exit
186
+ end
187
+
157
188
  opts.on_tail('--version', 'Show version') do
158
189
  puts Fast::VERSION
159
190
  exit
@@ -166,13 +197,14 @@ module Fast
166
197
  end
167
198
 
168
199
  def replace_args_with_shortcut(args)
169
- shortcut = find_shortcut args.first[1..]
200
+ shortcut_name = shortcut_name_from(args)
201
+ shortcut = find_shortcut(shortcut_name)
170
202
 
171
203
  if shortcut.single_run_with_block?
172
204
  shortcut.run
173
205
  exit
174
206
  else
175
- args.one? ? shortcut.args : shortcut.merge_args(args[1..])
207
+ shortcut.args
176
208
  end
177
209
  end
178
210
 
@@ -206,7 +238,7 @@ module Fast
206
238
 
207
239
  if @files.empty?
208
240
  ast ||= Fast.public_send( @sql ? :parse_sql : :ast, @pattern)
209
- puts Fast.highlight(ast, show_sexp: @show_sexp, colorize: @colorize, sql: @sql)
241
+ puts Fast.highlight(ast, show_sexp: @show_sexp, colorize: @colorize, sql: @sql, level: @level)
210
242
  else
211
243
  search
212
244
  end
@@ -272,20 +304,39 @@ module Fast
272
304
  show_sexp: @show_sexp,
273
305
  headless: @headless,
274
306
  bodyless: @bodyless,
275
- colorize: @colorize)
307
+ colorize: @colorize,
308
+ level: @level)
309
+ end
310
+
311
+ def shortcut_name_from(args)
312
+ command = args.find { |arg| !arg.start_with?('-') }
313
+ return unless command&.start_with?('.')
314
+
315
+ command[1..]
316
+ end
317
+
318
+ def extract_pattern_and_files(args)
319
+ return [nil, []] if args.empty?
320
+
321
+ files_start = args.index { |arg| File.exist?(arg) || File.directory?(arg) }
322
+ if files_start
323
+ [args[0...files_start].join(' '), args[files_start..]]
324
+ else
325
+ [args.join(' '), []]
326
+ end
276
327
  end
277
328
 
278
329
  # Find shortcut by name. Preloads all `Fastfiles` before start.
279
330
  # @param name [String]
280
- # @return [Fast::Shortcut]
281
331
  def find_shortcut(name)
282
332
  unless defined? Fast::Shortcut
283
333
  require 'fast/shortcut'
284
334
  Fast.load_fast_files!
285
335
  end
286
336
 
287
- shortcut = Fast.shortcuts[name] || Fast.shortcuts[name.to_sym]
288
- shortcut || exit_shortcut_not_found(name)
337
+ shortcut = Fast.shortcuts[name.to_sym]
338
+ exit_shortcut_not_found(name) unless shortcut
339
+ shortcut
289
340
  end
290
341
 
291
342
  # Exit process with warning message bolding the shortcut that was not found.
@@ -91,6 +91,7 @@ module Fast
91
91
  class Experiment
92
92
  attr_writer :files
93
93
  attr_reader :name, :replacement, :expression, :files_or_folders, :ok_if
94
+ attr_accessor :autoclean
94
95
 
95
96
  def initialize(name, &block)
96
97
  @name = name
@@ -137,6 +138,10 @@ module Fast
137
138
  def run
138
139
  files.map(&method(:run_with))
139
140
  end
141
+
142
+ def autoclean?
143
+ !!@autoclean
144
+ end
140
145
  end
141
146
 
142
147
  # Suggest possible combinations of occurrences to replace.
@@ -285,7 +290,7 @@ module Fast
285
290
  @fail_experiments << combination
286
291
  end
287
292
 
288
- # @return [Array<Astrolabe::Node>]
293
+ # @return [Array<Fast::Node>]
289
294
  def search_cases
290
295
  Fast.search(experiment.expression, @ast) || []
291
296
  end
@@ -323,15 +328,25 @@ module Fast
323
328
  filename
324
329
  end
325
330
 
331
+ def cleanup_generated_files!
332
+ Dir.glob(File.join(File.dirname(@file), "experiment_*_#{File.basename(@file)}")).each do |generated_file|
333
+ File.delete(generated_file) if File.exist?(generated_file)
334
+ end
335
+ end
336
+
326
337
  def done!
327
338
  count_executed_combinations = @fail_experiments.size + @ok_experiments.size
328
339
  puts "Done with #{@file} after #{count_executed_combinations} combinations"
329
- return unless perfect_combination = @ok_experiments.last # rubocop:disable Lint/AssignmentInCondition
340
+ unless perfect_combination = @ok_experiments.last # rubocop:disable Lint/AssignmentInCondition
341
+ cleanup_generated_files! if experiment.autoclean?
342
+ return
343
+ end
330
344
 
331
345
  puts 'The following changes were applied to the file:'
332
346
  `diff #{experimental_filename(perfect_combination)} #{@file}`
333
347
  puts "mv #{experimental_filename(perfect_combination)} #{@file}"
334
348
  `mv #{experimental_filename(perfect_combination)} #{@file}`
349
+ cleanup_generated_files! if experiment.autoclean?
335
350
  end
336
351
 
337
352
  # Increase the `@round` by 1 to {ExperimentCombinations#generate_combinations}.
@@ -346,6 +361,7 @@ module Fast
346
361
  end
347
362
 
348
363
  def run
364
+ cleanup_generated_files! if experiment.autoclean?
349
365
  while (combinations = build_combinations).any?
350
366
  if combinations.size > 1000
351
367
  puts "Ignoring #{@file} because it has #{combinations.size} possible combinations"
@@ -378,6 +394,7 @@ module Fast
378
394
  else
379
395
  failed_with(combination)
380
396
  puts "🔴 #{experimental_file} - Combination: #{combination}"
397
+ File.delete(experimental_file) if experiment.autoclean? && File.exist?(experimental_file)
381
398
  end
382
399
  end
383
400
  end
data/lib/fast/git.rb CHANGED
@@ -8,7 +8,7 @@ module Fast
8
8
  # @example
9
9
  # require 'fast/git'
10
10
  # Fast.ast_from_file('lib/fast.rb').git_log.first.author.name # => "Jonatas Davi Paganini"
11
- class Node < Astrolabe::Node
11
+ class Node
12
12
  # @return [Git::Base] from current directory
13
13
  def git
14
14
  require 'git' unless defined? Git
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stringio'
5
+ require 'fast'
6
+ require 'fast/version'
7
+ require 'fast/cli'
8
+
9
+ module Fast
10
+ # Implements the Model Context Protocol (MCP) server over STDIO.
11
+ class McpServer
12
+ TOOLS = [
13
+ {
14
+ name: 'validate_fast_pattern',
15
+ description: 'Validate a Fast AST pattern. Returns true if valid, or a specific syntax error message if invalid.',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ pattern: { type: 'string', description: 'Fast AST pattern to validate.' }
20
+ },
21
+ required: ['pattern']
22
+ }
23
+ },
24
+ {
25
+ name: 'search_ruby_ast',
26
+ description: 'Search Ruby files using a Fast AST pattern. Returns file, line range, and source. Use show_ast=true only when you need the s-expression.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ pattern: { type: 'string', description: 'Fast AST pattern, e.g. "(def match?)" or "(send nil :raise ...)".' },
31
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files or directories to search.' },
32
+ show_ast: { type: 'boolean', description: 'Include s-expression AST in results (default: false).' }
33
+ },
34
+ required: ['pattern', 'paths']
35
+ }
36
+ },
37
+ {
38
+ name: 'ruby_method_source',
39
+ description: 'Extract source of a Ruby method by name across files. Optionally filter by class name.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ method_name: { type: 'string', description: 'Method name, e.g. "initialize".' },
44
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files or directories to search.' },
45
+ class_name: { type: 'string', description: 'Optional class name to restrict results, e.g. "Matcher".' },
46
+ show_ast: { type: 'boolean', description: 'Include s-expression AST in results (default: false).' }
47
+ },
48
+ required: ['method_name', 'paths']
49
+ }
50
+ },
51
+ {
52
+ name: 'ruby_class_source',
53
+ description: 'Extract the full source of a Ruby class by name.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ class_name: { type: 'string', description: 'Class name to extract, e.g. "Rewriter".' },
58
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files or directories to search.' },
59
+ show_ast: { type: 'boolean', description: 'Include s-expression AST in results (default: false).' }
60
+ },
61
+ required: ['class_name', 'paths']
62
+ }
63
+ },
64
+ {
65
+ name: 'rewrite_ruby',
66
+ description: 'Apply a Fast pattern replacement to Ruby source code. Returns the rewritten source. Does NOT write to disk.',
67
+ inputSchema: {
68
+ type: 'object',
69
+ properties: {
70
+ source: { type: 'string', description: 'Ruby source code to rewrite.' },
71
+ pattern: { type: 'string', description: 'Fast AST pattern to match nodes for replacement.' },
72
+ replacement: { type: 'string', description: 'Ruby expression to replace matched node source with.' }
73
+ },
74
+ required: ['source', 'pattern', 'replacement']
75
+ }
76
+ },
77
+ {
78
+ name: 'rewrite_ruby_file',
79
+ description: 'Apply a Fast pattern replacement to a Ruby file in-place. Returns lines changed and a diff. Use rewrite_ruby first to preview.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ file: { type: 'string', description: 'Path to the Ruby file to rewrite.' },
84
+ pattern: { type: 'string', description: 'Fast AST pattern to match nodes for replacement.' },
85
+ replacement: { type: 'string', description: 'Ruby expression to replace matched node source with.' }
86
+ },
87
+ required: ['file', 'pattern', 'replacement']
88
+ }
89
+ },
90
+ {
91
+ name: 'run_fast_experiment',
92
+ description: 'Propose and execute a Fast experiment to safely refactor code. The experiment is validated against a policy command (e.g. tests) and only successful rewrites are applied. Always use {file} in the policy command to refer to the modified test file.',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ name: { type: 'string', description: 'Name of the experiment, e.g. "RSpec/UseBuildStubbed"' },
97
+ lookup: { type: 'string', description: 'Folder or file to target, e.g. "spec"' },
98
+ search: { type: 'string', description: 'Fast AST search pattern to find nodes.' },
99
+ edit: { type: 'string', description: 'Ruby code to evaluate in Rewriter context. Has access to `node` variable. Example: `replace(node.loc.expression, "build_stubbed")`' },
100
+ policy: { type: 'string', description: 'Shell command returning exit status 0 on success. Uses {file} for the temporary file created during the rewrite round. Example: `bin/spring rspec --fail-fast {file}`' }
101
+ },
102
+ required: ['name', 'lookup', 'search', 'edit', 'policy']
103
+ }
104
+ }
105
+ ].freeze
106
+
107
+
108
+ def self.run!
109
+ new.run
110
+ end
111
+
112
+ def run
113
+ STDOUT.sync = true
114
+
115
+ while (line = STDIN.gets)
116
+ line = line.strip
117
+ next if line.empty?
118
+
119
+ begin
120
+ request = JSON.parse(line)
121
+ handle_request(request)
122
+ rescue JSON::ParserError => e
123
+ write_error(nil, -32700, 'Parse error', e.message)
124
+ rescue StandardError => e
125
+ write_error(request&.fetch('id', nil), -32603, 'Internal error', e.message)
126
+ end
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def handle_request(request)
133
+ id = request['id']
134
+ method = request['method']
135
+ params = request['params'] || {}
136
+
137
+ case method
138
+ when 'initialize'
139
+ write_response(id, {
140
+ protocolVersion: '2024-11-05',
141
+ capabilities: { tools: {} },
142
+ serverInfo: { name: 'fast-mcp', version: Fast::VERSION }
143
+ })
144
+ when 'tools/list'
145
+ write_response(id, { tools: TOOLS })
146
+ when 'tools/call'
147
+ handle_tool_call(id, params)
148
+ when 'notifications/initialized'
149
+ nil
150
+ else
151
+ write_error(id, -32601, 'Method not found', "#{method} not supported") if id
152
+ end
153
+ end
154
+
155
+ def handle_tool_call(id, params)
156
+ tool_name = params['name']
157
+ args = params['arguments'] || {}
158
+ show_ast = args['show_ast'] || false
159
+
160
+ if args['pattern'] && !args['pattern'].start_with?('(', '{', '[') && !args['pattern'].match?(/^[a-z_]+$/)
161
+ raise "Invalid Fast AST pattern: '#{args['pattern']}'. Did you mean to use an s-expression like '(#{args['pattern']})'?"
162
+ end
163
+
164
+ result =
165
+ case tool_name
166
+ when 'validate_fast_pattern'
167
+ execute_validate_pattern(args['pattern'])
168
+ when 'search_ruby_ast'
169
+ execute_search(args['pattern'], args['paths'], show_ast: show_ast)
170
+ when 'ruby_method_source'
171
+ execute_method_search(args['method_name'], args['paths'],
172
+ class_name: args['class_name'], show_ast: show_ast)
173
+ when 'ruby_class_source'
174
+ execute_class_search(args['class_name'], args['paths'], show_ast: show_ast)
175
+ when 'rewrite_ruby'
176
+ execute_rewrite(args['source'], args['pattern'], args['replacement'])
177
+ when 'rewrite_ruby_file'
178
+ execute_rewrite_file(args['file'], args['pattern'], args['replacement'])
179
+ when 'run_fast_experiment'
180
+ execute_fast_experiment(args['name'], args['lookup'], args['search'], args['edit'], args['policy'])
181
+ else
182
+ raise "Unknown tool: #{tool_name}"
183
+ end
184
+
185
+ write_response(id, { content: [{ type: 'text', text: JSON.generate(result) }] })
186
+ rescue => e
187
+ write_error(id, -32603, 'Tool execution failed', e.message)
188
+ end
189
+
190
+ def execute_validate_pattern(pattern)
191
+ Fast.expression(pattern)
192
+ { valid: true }
193
+ rescue StandardError => e
194
+ { valid: false, error: e.message }
195
+ end
196
+
197
+ def execute_search(pattern, paths, show_ast: false)
198
+ results = []
199
+ on_result = ->(file, matches) do
200
+ matches.compact.each do |node|
201
+ next unless (exp = node_expression(node))
202
+
203
+ entry = {
204
+ file: file,
205
+ line_start: exp.line,
206
+ line_end: exp.last_line,
207
+ code: Fast.highlight(node, colorize: false)
208
+ }
209
+ entry[:ast] = Fast.highlight(node, show_sexp: true, colorize: false) if show_ast
210
+ results << entry
211
+ end
212
+ end
213
+
214
+ Fast.search_all(pattern, paths, parallel: false, on_result: on_result)
215
+ results
216
+ end
217
+
218
+ def execute_method_search(method_name, paths, class_name: nil, show_ast: false)
219
+ pattern = "(def #{method_name})"
220
+ results = execute_search(pattern, paths, show_ast: show_ast)
221
+ return results unless class_name
222
+
223
+ # Filter: keep only methods whose file contains the class
224
+ results.select do |r|
225
+ class_defined_in_file?(class_name, r[:file])
226
+ end
227
+ end
228
+
229
+ def execute_class_search(class_name, paths, show_ast: false)
230
+ # Use simple (class ...) pattern then filter by name — avoids nil/superclass edge cases
231
+ results = []
232
+ on_result = ->(file, matches) do
233
+ matches.compact.each do |node|
234
+ next unless node.type == :class
235
+ next unless node.children.first&.children&.last&.to_s == class_name
236
+ next unless (exp = node_expression(node))
237
+
238
+ entry = {
239
+ file: file,
240
+ line_start: exp.line,
241
+ line_end: exp.last_line,
242
+ code: Fast.highlight(node, colorize: false)
243
+ }
244
+ entry[:ast] = Fast.highlight(node, show_sexp: true, colorize: false) if show_ast
245
+ results << entry
246
+ end
247
+ end
248
+ Fast.search_all('(class ...)', paths, parallel: false, on_result: on_result)
249
+ results.select { |r| r[:file] } # already filtered above
250
+ end
251
+
252
+ def execute_rewrite(source, pattern, replacement)
253
+ ast = Fast.ast(source)
254
+ result = Fast.replace(pattern, ast, source) do |node|
255
+ replace(node.loc.expression, replacement)
256
+ end
257
+ { original: source, rewritten: result, changed: result != source }
258
+ end
259
+
260
+ def execute_rewrite_file(file, pattern, replacement)
261
+ raise "File not found: #{file}" unless File.exist?(file)
262
+
263
+ original = File.read(file)
264
+ rewritten = Fast.replace_file(pattern, file) do |node|
265
+ replace(node.loc.expression, replacement)
266
+ end
267
+
268
+ return { file: file, changed: false } if rewritten.nil? || rewritten == original
269
+
270
+ # Build a compact line-level diff
271
+ orig_lines = original.lines
272
+ rewritten_lines = rewritten.lines
273
+ diff = orig_lines.each_with_index.filter_map do |line, i|
274
+ new_line = rewritten_lines[i]
275
+ next if line == new_line
276
+
277
+ { line: i + 1, before: line.rstrip, after: (new_line&.rstrip || '') }
278
+ end
279
+
280
+ File.write(file, rewritten)
281
+ { file: file, changed: true, diff: diff }
282
+ end
283
+
284
+ def execute_fast_experiment(name, lookup_path, search_pattern, edit_code, policy_command)
285
+ require 'fast/experiment'
286
+ original_stdout = $stdout.dup
287
+ capture_output = StringIO.new
288
+ $stdout = capture_output
289
+
290
+ begin
291
+ experiment = Fast.experiment(name) do
292
+ lookup lookup_path
293
+ search search_pattern
294
+ edit do |node, *captures|
295
+ eval(edit_code)
296
+ end
297
+ policy do |new_file|
298
+ cmd = policy_command.gsub('{file}', new_file)
299
+ system(cmd)
300
+ end
301
+ end
302
+ experiment.run
303
+ ensure
304
+ $stdout = original_stdout
305
+ end
306
+
307
+ # Exclude any color from captured output
308
+ log = capture_output.string.gsub(/\e\[([;\d]+)?m/, '')
309
+
310
+ { experiment: name, log: log }
311
+ end
312
+
313
+ # Returns loc.expression if available
314
+ def node_expression(node)
315
+ return unless node.respond_to?(:loc) && node.loc.respond_to?(:expression)
316
+
317
+ node.loc.expression
318
+ end
319
+
320
+ # Check whether a class is defined anywhere in the file's AST
321
+ def class_defined_in_file?(class_name, file)
322
+ Fast.search_file('(class ...)', file).any? do |node|
323
+ node.children.first&.children&.last&.to_s == class_name
324
+ end
325
+ rescue
326
+ false
327
+ end
328
+
329
+ def write_response(id, result)
330
+ STDOUT.puts({ jsonrpc: '2.0', id: id, result: result }.to_json)
331
+ end
332
+
333
+ def write_error(id, code, message, data = nil)
334
+ err = { code: code, message: message }
335
+ err[:data] = data if data
336
+ response = { jsonrpc: '2.0', error: err }
337
+ response[:id] = id if id
338
+ STDOUT.puts response.to_json
339
+ end
340
+ end
341
+ end