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,317 @@
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: 'search_ruby_ast',
15
+ 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.',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ pattern: { type: 'string', description: 'Fast AST pattern, e.g. "(def match?)" or "(send nil :raise ...)".' },
20
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files or directories to search.' },
21
+ show_ast: { type: 'boolean', description: 'Include s-expression AST in results (default: false).' }
22
+ },
23
+ required: ['pattern', 'paths']
24
+ }
25
+ },
26
+ {
27
+ name: 'ruby_method_source',
28
+ description: 'Extract source of a Ruby method by name across files. Optionally filter by class name.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ method_name: { type: 'string', description: 'Method name, e.g. "initialize".' },
33
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files or directories to search.' },
34
+ class_name: { type: 'string', description: 'Optional class name to restrict results, e.g. "Matcher".' },
35
+ show_ast: { type: 'boolean', description: 'Include s-expression AST in results (default: false).' }
36
+ },
37
+ required: ['method_name', 'paths']
38
+ }
39
+ },
40
+ {
41
+ name: 'ruby_class_source',
42
+ description: 'Extract the full source of a Ruby class by name.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ class_name: { type: 'string', description: 'Class name to extract, e.g. "Rewriter".' },
47
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files or directories to search.' },
48
+ show_ast: { type: 'boolean', description: 'Include s-expression AST in results (default: false).' }
49
+ },
50
+ required: ['class_name', 'paths']
51
+ }
52
+ },
53
+ {
54
+ name: 'rewrite_ruby',
55
+ description: 'Apply a Fast pattern replacement to Ruby source code. Returns the rewritten source. Does NOT write to disk.',
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ source: { type: 'string', description: 'Ruby source code to rewrite.' },
60
+ pattern: { type: 'string', description: 'Fast AST pattern to match nodes for replacement.' },
61
+ replacement: { type: 'string', description: 'Ruby expression to replace matched node source with.' }
62
+ },
63
+ required: ['source', 'pattern', 'replacement']
64
+ }
65
+ },
66
+ {
67
+ name: 'rewrite_ruby_file',
68
+ description: 'Apply a Fast pattern replacement to a Ruby file in-place. Returns lines changed and a diff. Use rewrite_ruby first to preview.',
69
+ inputSchema: {
70
+ type: 'object',
71
+ properties: {
72
+ file: { type: 'string', description: 'Path to the Ruby file to rewrite.' },
73
+ pattern: { type: 'string', description: 'Fast AST pattern to match nodes for replacement.' },
74
+ replacement: { type: 'string', description: 'Ruby expression to replace matched node source with.' }
75
+ },
76
+ required: ['file', 'pattern', 'replacement']
77
+ }
78
+ },
79
+ {
80
+ name: 'run_fast_experiment',
81
+ 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.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ name: { type: 'string', description: 'Name of the experiment, e.g. "RSpec/UseBuildStubbed"' },
86
+ lookup: { type: 'string', description: 'Folder or file to target, e.g. "spec"' },
87
+ search: { type: 'string', description: 'Fast AST search pattern to find nodes.' },
88
+ edit: { type: 'string', description: 'Ruby code to evaluate in Rewriter context. Has access to `node` variable. Example: `replace(node.loc.expression, "build_stubbed")`' },
89
+ 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}`' }
90
+ },
91
+ required: ['name', 'lookup', 'search', 'edit', 'policy']
92
+ }
93
+ }
94
+ ].freeze
95
+
96
+
97
+ def self.run!
98
+ new.run
99
+ end
100
+
101
+ def run
102
+ STDOUT.sync = true
103
+
104
+ while (line = STDIN.gets)
105
+ line = line.strip
106
+ next if line.empty?
107
+
108
+ begin
109
+ request = JSON.parse(line)
110
+ handle_request(request)
111
+ rescue JSON::ParserError => e
112
+ write_error(nil, -32700, 'Parse error', e.message)
113
+ rescue StandardError => e
114
+ write_error(request&.fetch('id', nil), -32603, 'Internal error', e.message)
115
+ end
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def handle_request(request)
122
+ id = request['id']
123
+ method = request['method']
124
+ params = request['params'] || {}
125
+
126
+ case method
127
+ when 'initialize'
128
+ write_response(id, {
129
+ protocolVersion: '2024-11-05',
130
+ capabilities: { tools: {} },
131
+ serverInfo: { name: 'fast-mcp', version: Fast::VERSION }
132
+ })
133
+ when 'tools/list'
134
+ write_response(id, { tools: TOOLS })
135
+ when 'tools/call'
136
+ handle_tool_call(id, params)
137
+ when 'notifications/initialized'
138
+ nil
139
+ else
140
+ write_error(id, -32601, 'Method not found', "#{method} not supported") if id
141
+ end
142
+ end
143
+
144
+ def handle_tool_call(id, params)
145
+ tool_name = params['name']
146
+ args = params['arguments'] || {}
147
+ show_ast = args['show_ast'] || false
148
+
149
+ result =
150
+ case tool_name
151
+ when 'search_ruby_ast'
152
+ execute_search(args['pattern'], args['paths'], show_ast: show_ast)
153
+ when 'ruby_method_source'
154
+ execute_method_search(args['method_name'], args['paths'],
155
+ class_name: args['class_name'], show_ast: show_ast)
156
+ when 'ruby_class_source'
157
+ execute_class_search(args['class_name'], args['paths'], show_ast: show_ast)
158
+ when 'rewrite_ruby'
159
+ execute_rewrite(args['source'], args['pattern'], args['replacement'])
160
+ when 'rewrite_ruby_file'
161
+ execute_rewrite_file(args['file'], args['pattern'], args['replacement'])
162
+ when 'run_fast_experiment'
163
+ execute_fast_experiment(args['name'], args['lookup'], args['search'], args['edit'], args['policy'])
164
+ else
165
+ raise "Unknown tool: #{tool_name}"
166
+ end
167
+
168
+ write_response(id, { content: [{ type: 'text', text: JSON.generate(result) }] })
169
+ rescue => e
170
+ write_error(id, -32603, 'Tool execution failed', e.message)
171
+ end
172
+
173
+ def execute_search(pattern, paths, show_ast: false)
174
+ results = []
175
+ on_result = ->(file, matches) do
176
+ matches.compact.each do |node|
177
+ next unless (exp = node_expression(node))
178
+
179
+ entry = {
180
+ file: file,
181
+ line_start: exp.line,
182
+ line_end: exp.last_line,
183
+ code: Fast.highlight(node, colorize: false)
184
+ }
185
+ entry[:ast] = Fast.highlight(node, show_sexp: true, colorize: false) if show_ast
186
+ results << entry
187
+ end
188
+ end
189
+
190
+ Fast.search_all(pattern, paths, parallel: false, on_result: on_result)
191
+ results
192
+ end
193
+
194
+ def execute_method_search(method_name, paths, class_name: nil, show_ast: false)
195
+ pattern = "(def #{method_name})"
196
+ results = execute_search(pattern, paths, show_ast: show_ast)
197
+ return results unless class_name
198
+
199
+ # Filter: keep only methods whose file contains the class
200
+ results.select do |r|
201
+ class_defined_in_file?(class_name, r[:file])
202
+ end
203
+ end
204
+
205
+ def execute_class_search(class_name, paths, show_ast: false)
206
+ # Use simple (class ...) pattern then filter by name — avoids nil/superclass edge cases
207
+ results = []
208
+ on_result = ->(file, matches) do
209
+ matches.compact.each do |node|
210
+ next unless node.type == :class
211
+ next unless node.children.first&.children&.last&.to_s == class_name
212
+ next unless (exp = node_expression(node))
213
+
214
+ entry = {
215
+ file: file,
216
+ line_start: exp.line,
217
+ line_end: exp.last_line,
218
+ code: Fast.highlight(node, colorize: false)
219
+ }
220
+ entry[:ast] = Fast.highlight(node, show_sexp: true, colorize: false) if show_ast
221
+ results << entry
222
+ end
223
+ end
224
+ Fast.search_all('(class ...)', paths, parallel: false, on_result: on_result)
225
+ results.select { |r| r[:file] } # already filtered above
226
+ end
227
+
228
+ def execute_rewrite(source, pattern, replacement)
229
+ ast = Fast.ast(source)
230
+ result = Fast.replace(pattern, ast, source) do |node|
231
+ replace(node.loc.expression, replacement)
232
+ end
233
+ { original: source, rewritten: result, changed: result != source }
234
+ end
235
+
236
+ def execute_rewrite_file(file, pattern, replacement)
237
+ raise "File not found: #{file}" unless File.exist?(file)
238
+
239
+ original = File.read(file)
240
+ rewritten = Fast.replace_file(pattern, file) do |node|
241
+ replace(node.loc.expression, replacement)
242
+ end
243
+
244
+ return { file: file, changed: false } if rewritten.nil? || rewritten == original
245
+
246
+ # Build a compact line-level diff
247
+ orig_lines = original.lines
248
+ rewritten_lines = rewritten.lines
249
+ diff = orig_lines.each_with_index.filter_map do |line, i|
250
+ new_line = rewritten_lines[i]
251
+ next if line == new_line
252
+
253
+ { line: i + 1, before: line.rstrip, after: (new_line&.rstrip || '') }
254
+ end
255
+
256
+ File.write(file, rewritten)
257
+ { file: file, changed: true, diff: diff }
258
+ end
259
+
260
+ def execute_fast_experiment(name, lookup_path, search_pattern, edit_code, policy_command)
261
+ require 'fast/experiment'
262
+ original_stdout = $stdout.dup
263
+ capture_output = StringIO.new
264
+ $stdout = capture_output
265
+
266
+ begin
267
+ experiment = Fast.experiment(name) do
268
+ lookup lookup_path
269
+ search search_pattern
270
+ edit do |node, *captures|
271
+ eval(edit_code)
272
+ end
273
+ policy do |new_file|
274
+ cmd = policy_command.gsub('{file}', new_file)
275
+ system(cmd)
276
+ end
277
+ end
278
+ experiment.run
279
+ ensure
280
+ $stdout = original_stdout
281
+ end
282
+
283
+ # Exclude any color from captured output
284
+ log = capture_output.string.gsub(/\e\[([;\d]+)?m/, '')
285
+
286
+ { experiment: name, log: log }
287
+ end
288
+
289
+ # Returns loc.expression if available
290
+ def node_expression(node)
291
+ return unless node.respond_to?(:loc) && node.loc.respond_to?(:expression)
292
+
293
+ node.loc.expression
294
+ end
295
+
296
+ # Check whether a class is defined anywhere in the file's AST
297
+ def class_defined_in_file?(class_name, file)
298
+ Fast.search_file('(class ...)', file).any? do |node|
299
+ node.children.first&.children&.last&.to_s == class_name
300
+ end
301
+ rescue
302
+ false
303
+ end
304
+
305
+ def write_response(id, result)
306
+ STDOUT.puts({ jsonrpc: '2.0', id: id, result: result }.to_json)
307
+ end
308
+
309
+ def write_error(id, code, message, data = nil)
310
+ err = { code: code, message: message }
311
+ err[:data] = data if data
312
+ response = { jsonrpc: '2.0', error: err }
313
+ response[:id] = id if id
314
+ STDOUT.puts response.to_json
315
+ end
316
+ end
317
+ end
data/lib/fast/node.rb ADDED
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ class Node
5
+ attr_reader :type, :children, :loc
6
+
7
+ def initialize(type, children = [], properties = {})
8
+ @type = type.to_sym
9
+ @children = Array(children).freeze
10
+ @loc = properties[:location]
11
+ assign_parents!
12
+ end
13
+
14
+ class << self
15
+ def set_parent(node, parent)
16
+ NODE_PARENTS[node] = parent
17
+ end
18
+
19
+ def parent_for(node)
20
+ NODE_PARENTS[node]
21
+ end
22
+ end
23
+
24
+ # @return [String] with path of the file or simply buffer name.
25
+ def buffer_name
26
+ expression.source_buffer.name
27
+ end
28
+
29
+ # @return [Fast::Source::Range] from the expression
30
+ def expression
31
+ loc.expression
32
+ end
33
+
34
+ # Backward-compatible alias for callers that still use `location`.
35
+ def location
36
+ loc
37
+ end
38
+
39
+ # @return [String] with the content of the #expression
40
+ def source
41
+ expression.source
42
+ end
43
+
44
+ # @return [Boolean] true if a file exists with the #buffer_name
45
+ def from_file?
46
+ File.exist?(buffer_name)
47
+ end
48
+
49
+ def each_child_node
50
+ return enum_for(:each_child_node) unless block_given?
51
+
52
+ children.select { |child| Fast.ast_node?(child) }.each { |child| yield child }
53
+ end
54
+
55
+ def each_descendant(*types, &block)
56
+ return enum_for(:each_descendant, *types) unless block_given?
57
+
58
+ each_child_node do |child|
59
+ yield child if types.empty? || types.include?(child.type)
60
+ child.each_descendant(*types, &block) if child.respond_to?(:each_descendant)
61
+ end
62
+ end
63
+
64
+ def root?
65
+ parent.nil?
66
+ end
67
+
68
+ def parent
69
+ self.class.parent_for(self)
70
+ end
71
+
72
+ def updated(type = nil, children = nil, properties = nil)
73
+ updated_node = self.class.new(
74
+ type || self.type,
75
+ children || self.children,
76
+ { location: properties&.fetch(:location, loc) || loc }
77
+ )
78
+ updated_node.send(:assign_parents!)
79
+ updated_node
80
+ end
81
+
82
+ def ==(other)
83
+ Fast.ast_node?(other) && type == other.type && children == other.children
84
+ end
85
+ alias eql? ==
86
+
87
+ def hash
88
+ [type, children].hash
89
+ end
90
+
91
+ def to_a
92
+ children.dup
93
+ end
94
+
95
+ def <<(child)
96
+ updated(nil, children + [child])
97
+ end
98
+
99
+ def deconstruct
100
+ to_a
101
+ end
102
+
103
+ def to_ast
104
+ self
105
+ end
106
+
107
+ def to_sexp
108
+ format_node(:sexp)
109
+ end
110
+
111
+ def to_s
112
+ to_sexp
113
+ end
114
+
115
+ def inspect
116
+ format_node(:inspect)
117
+ end
118
+
119
+ def respond_to_missing?(method_name, include_private = false)
120
+ type_query_method?(method_name) || super
121
+ end
122
+
123
+ def method_missing(method_name, *args, &block)
124
+ return type == type_query_name(method_name) if type_query_method?(method_name) && args.empty? && !block
125
+
126
+ super
127
+ end
128
+
129
+ # @return [Array<String>] with authors from the current expression range
130
+ def blame_authors
131
+ `git blame -L #{expression.first_line},#{expression.last_line} #{buffer_name}`.lines.map do |line|
132
+ line.split('(')[1].split(/\d+/).first.strip
133
+ end
134
+ end
135
+
136
+ # @return [String] with the first element from #blame_authors
137
+ def author
138
+ blame_authors.first
139
+ end
140
+
141
+ # Search recursively into a node and its children using a pattern.
142
+ # @param [String] pattern
143
+ # @param [Array] *args extra arguments to interpolate in the pattern.
144
+ # @return [Array<Fast::Node>>] with files and results
145
+ def search(pattern, *args)
146
+ Fast.search(pattern, self, *args)
147
+ end
148
+
149
+ # Captures elements from search recursively
150
+ # @param [String] pattern
151
+ # @param [Array] *args extra arguments to interpolate in the pattern.
152
+ # @return [Array<Fast::Node>>] with files and results
153
+ def capture(pattern, *args)
154
+ Fast.capture(pattern, self, *args)
155
+ end
156
+
157
+ private
158
+
159
+ def assign_parents!
160
+ each_child_node do |child|
161
+ self.class.set_parent(child, self)
162
+ child.send(:assign_parents!) if child.respond_to?(:assign_parents!, true)
163
+ end
164
+ end
165
+
166
+ def type_query_method?(method_name)
167
+ method_name.to_s.end_with?('_type?')
168
+ end
169
+
170
+ def type_query_name(method_name)
171
+ method_name.to_s.delete_suffix('_type?').to_sym
172
+ end
173
+
174
+ def format_node(style)
175
+ opener = style == :inspect ? "s(:#{type}" : "(#{type}"
176
+ separator = style == :inspect ? ', ' : ' '
177
+ inline_children = children.map { |child| format_atom(child, style, inline: true) }
178
+ return "#{opener})" if inline_children.empty?
179
+
180
+ if children.none? { |child| Fast.ast_node?(child) } && inline_children.all? { |child| !child.include?("\n") }
181
+ return "#{opener}#{separator}#{inline_children.join(separator)})"
182
+ end
183
+
184
+ lines = [opener]
185
+ current_line = +''
186
+
187
+ children.each do |child|
188
+ formatted = format_atom(child, style, inline: false)
189
+ if formatted.include?("\n")
190
+ flush_current_line!(lines, current_line, style)
191
+ current_line.clear
192
+ lines << indent_multiline(formatted)
193
+ elsif Fast.ast_node?(child)
194
+ flush_current_line!(lines, current_line, style)
195
+ current_line = formatted.dup
196
+ else
197
+ current_line << separator unless current_line.empty?
198
+ current_line << formatted
199
+ end
200
+ end
201
+
202
+ flush_current_line!(lines, current_line, style)
203
+ if lines.length > 2 && scalar_line?(lines[1], style)
204
+ lines[0] = "#{lines[0]}#{separator}#{lines[1].strip}"
205
+ lines.delete_at(1)
206
+ end
207
+ lines[-1] = "#{lines[-1]})"
208
+ lines.join("\n")
209
+ end
210
+
211
+ def flush_current_line!(lines, current_line, style)
212
+ return if current_line.empty?
213
+
214
+ line = style == :inspect ? " #{current_line}," : " #{current_line}"
215
+ lines << line
216
+ end
217
+
218
+ def indent_multiline(text)
219
+ text.lines.map { |line| " #{line}" }.join
220
+ end
221
+
222
+ def format_atom(atom, style, inline:)
223
+ if Fast.ast_node?(atom)
224
+ text = style == :inspect ? atom.inspect : atom.to_sexp
225
+ return text if inline || !text.include?("\n")
226
+
227
+ style == :inspect ? trim_trailing_comma(text) : text
228
+ else
229
+ format_scalar(atom, style)
230
+ end
231
+ end
232
+
233
+ def trim_trailing_comma(text)
234
+ lines = text.lines
235
+ lines[-1] = lines[-1].sub(/,\z/, '')
236
+ lines.join
237
+ end
238
+
239
+ def scalar_line?(line, style)
240
+ stripped = line.strip
241
+ return false if stripped.empty?
242
+
243
+ opener = style == :inspect ? 's(' : '('
244
+ !stripped.start_with?(opener)
245
+ end
246
+
247
+ def format_scalar(value, _style)
248
+ case value
249
+ when Symbol, String
250
+ value.inspect
251
+ when Array
252
+ "[#{value.map { |item| format_atom(item, :sexp, inline: true) }.join(', ')}]"
253
+ else
254
+ value.inspect
255
+ end
256
+ end
257
+ end
258
+ end