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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +27 -0
- data/.github/workflows/ruby.yml +34 -0
- data/.gitignore +2 -0
- data/Fastfile +146 -3
- data/README.md +244 -132
- data/bin/console +6 -1
- data/bin/fast-experiment +3 -0
- data/bin/fast-mcp +7 -0
- data/fast.gemspec +24 -7
- data/lib/fast/cli.rb +129 -38
- data/lib/fast/experiment.rb +19 -2
- data/lib/fast/git.rb +1 -1
- data/lib/fast/mcp_server.rb +317 -0
- data/lib/fast/node.rb +258 -0
- data/lib/fast/prism_adapter.rb +310 -0
- data/lib/fast/rewriter.rb +64 -10
- data/lib/fast/scan.rb +203 -0
- data/lib/fast/shortcut.rb +23 -6
- data/lib/fast/source.rb +116 -0
- data/lib/fast/source_rewriter.rb +153 -0
- data/lib/fast/sql/rewriter.rb +98 -0
- data/lib/fast/sql.rb +165 -0
- data/lib/fast/summary.rb +435 -0
- data/lib/fast/version.rb +1 -1
- data/lib/fast.rb +165 -79
- data/mkdocs.yml +27 -3
- data/requirements-docs.txt +3 -0
- metadata +48 -62
- data/docs/command_line.md +0 -238
- data/docs/editors-integration.md +0 -46
- data/docs/experiments.md +0 -153
- data/docs/ideas.md +0 -80
- data/docs/index.md +0 -402
- 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/syntax.md +0 -395
- data/docs/videos.md +0 -16
- 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
|
@@ -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
|