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.
- checksums.yaml +4 -4
- data/.agents/fast-pattern-expert/SKILL.md +71 -0
- data/.github/workflows/release.yml +27 -0
- data/.github/workflows/ruby.yml +34 -0
- data/.gitignore +2 -0
- data/Fastfile +105 -18
- data/README.md +21 -7
- data/bin/console +1 -1
- data/bin/fast-experiment +3 -0
- data/bin/fast-mcp +7 -0
- data/fast.gemspec +1 -3
- data/ideia_blog_post.md +36 -0
- data/lib/fast/cli.rb +74 -23
- data/lib/fast/experiment.rb +19 -2
- data/lib/fast/git.rb +1 -1
- data/lib/fast/mcp_server.rb +341 -0
- data/lib/fast/node.rb +258 -0
- data/lib/fast/prism_adapter.rb +327 -0
- data/lib/fast/rewriter.rb +64 -10
- data/lib/fast/scan.rb +207 -0
- data/lib/fast/shortcut.rb +16 -4
- data/lib/fast/source.rb +116 -0
- data/lib/fast/source_rewriter.rb +153 -0
- data/lib/fast/sql/rewriter.rb +36 -7
- data/lib/fast/sql.rb +15 -17
- data/lib/fast/summary.rb +440 -0
- data/lib/fast/version.rb +1 -1
- data/lib/fast.rb +218 -101
- data/mkdocs.yml +19 -4
- data/requirements-docs.txt +3 -0
- metadata +18 -59
- data/docs/command_line.md +0 -238
- data/docs/editors-integration.md +0 -46
- data/docs/experiments.md +0 -155
- data/docs/git.md +0 -115
- data/docs/ideas.md +0 -70
- data/docs/index.md +0 -404
- data/docs/pry-integration.md +0 -27
- data/docs/research.md +0 -93
- data/docs/shortcuts.md +0 -323
- data/docs/similarity_tutorial.md +0 -176
- data/docs/sql-support.md +0 -253
- data/docs/syntax.md +0 -395
- data/docs/videos.md +0 -16
- data/docs/walkthrough.md +0 -135
- data/examples/build_stubbed_and_let_it_be_experiment.rb +0 -51
- data/examples/experimental_replacement.rb +0 -46
- data/examples/find_usage.rb +0 -26
- data/examples/let_it_be_experiment.rb +0 -11
- data/examples/method_complexity.rb +0 -37
- data/examples/search_duplicated.rb +0 -15
- data/examples/similarity_research.rb +0 -58
- data/examples/simple_rewriter.rb +0 -6
- data/experiments/let_it_be_experiment.rb +0 -9
- data/experiments/remove_useless_hook.rb +0 -9
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
288
|
-
|
|
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.
|
data/lib/fast/experiment.rb
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
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
|