ffast 0.2.2 → 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 +102 -15
- 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/lib/fast/cli.rb +58 -26
- 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 +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 +435 -0
- data/lib/fast/version.rb +1 -1
- data/lib/fast.rb +140 -83
- data/mkdocs.yml +19 -4
- data/requirements-docs.txt +3 -0
- metadata +16 -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/source.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Source
|
|
5
|
+
class Buffer
|
|
6
|
+
attr_accessor :source
|
|
7
|
+
attr_reader :name
|
|
8
|
+
|
|
9
|
+
def initialize(name, source: nil)
|
|
10
|
+
@name = name
|
|
11
|
+
@source = source
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def source_range(begin_pos = 0, end_pos = source.to_s.length)
|
|
15
|
+
Fast::Source.range(self, begin_pos, end_pos)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Range
|
|
20
|
+
attr_reader :begin_pos, :end_pos, :source_buffer
|
|
21
|
+
|
|
22
|
+
def initialize(source_buffer, begin_pos, end_pos)
|
|
23
|
+
@source_buffer = source_buffer
|
|
24
|
+
@begin_pos = begin_pos
|
|
25
|
+
@end_pos = end_pos
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def begin
|
|
29
|
+
self.class.new(source_buffer, begin_pos, begin_pos)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def end
|
|
33
|
+
self.class.new(source_buffer, end_pos, end_pos)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def source
|
|
37
|
+
source_buffer.source.to_s[begin_pos...end_pos]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def line
|
|
41
|
+
first_line
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def first_line
|
|
45
|
+
source_buffer.source.to_s[0...begin_pos].count("\n") + 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def last_line
|
|
49
|
+
source_buffer.source.to_s[0...end_pos].count("\n") + 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def column
|
|
53
|
+
last_newline = source_buffer.source.to_s.rindex("\n", begin_pos - 1)
|
|
54
|
+
begin_pos - (last_newline ? last_newline + 1 : 0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_range
|
|
58
|
+
begin_pos...end_pos
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def join(other)
|
|
62
|
+
self.class.new(source_buffer, [begin_pos, other.begin_pos].min, [end_pos, other.end_pos].max)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def adjust(begin_pos: 0, end_pos: 0)
|
|
66
|
+
self.class.new(source_buffer, self.begin_pos + begin_pos, self.end_pos + end_pos)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class Map
|
|
71
|
+
attr_accessor :expression, :node
|
|
72
|
+
|
|
73
|
+
def initialize(expression)
|
|
74
|
+
@expression = expression
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def begin
|
|
78
|
+
expression.begin_pos
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def end
|
|
82
|
+
expression.end_pos
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def with_expression(new_expression)
|
|
86
|
+
duplicate_with(expression: new_expression)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def with_operator(operator)
|
|
90
|
+
duplicate_with(operator: operator)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def duplicate_with(overrides = {})
|
|
96
|
+
copy = dup
|
|
97
|
+
overrides.each { |name, value| copy.instance_variable_set(:"@#{name}", value) }
|
|
98
|
+
copy
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
module_function
|
|
103
|
+
|
|
104
|
+
def buffer(name, source: nil, buffer_class: Fast::Source::Buffer)
|
|
105
|
+
buffer_class.new(name, source: source)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def range(buffer, start_pos, end_pos)
|
|
109
|
+
Fast::Source::Range.new(buffer, start_pos, end_pos)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def map(expression)
|
|
113
|
+
Fast::Source::Map.new(expression)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'source'
|
|
4
|
+
|
|
5
|
+
module Fast
|
|
6
|
+
class SourceRewriter
|
|
7
|
+
Edit = Struct.new(:kind, :begin_pos, :end_pos, :content, :order, keyword_init: true)
|
|
8
|
+
ClobberingError = Class.new(StandardError)
|
|
9
|
+
|
|
10
|
+
attr_reader :source_buffer
|
|
11
|
+
|
|
12
|
+
def initialize(source_buffer)
|
|
13
|
+
@source_buffer = source_buffer
|
|
14
|
+
@edits = []
|
|
15
|
+
@order = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def replace(range, content)
|
|
19
|
+
add_edit(:replace, range, content.to_s)
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def remove(range)
|
|
24
|
+
replace(range, '')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def wrap(range, before, after)
|
|
28
|
+
insert_before(range, before) unless before.nil?
|
|
29
|
+
insert_after(range, after) unless after.nil?
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def insert_before(range, content)
|
|
34
|
+
add_edit(:insert_before, range.begin, content.to_s)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def insert_after(range, content)
|
|
39
|
+
add_edit(:insert_after, range.end, content.to_s)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def process
|
|
44
|
+
source = source_buffer.source.to_s
|
|
45
|
+
normalized_replacements = normalize_replacements
|
|
46
|
+
return source if normalized_replacements.empty? && insertions.empty?
|
|
47
|
+
|
|
48
|
+
before_insertions = build_insertions(:insert_before)
|
|
49
|
+
after_insertions = build_insertions(:insert_after)
|
|
50
|
+
|
|
51
|
+
result = +''
|
|
52
|
+
cursor = 0
|
|
53
|
+
|
|
54
|
+
normalized_replacements.each do |replacement|
|
|
55
|
+
result << emit_unreplaced_segment(source, cursor, replacement.begin_pos, before_insertions, after_insertions)
|
|
56
|
+
result << before_insertions.fetch(replacement.begin_pos, '')
|
|
57
|
+
result << replacement.content
|
|
58
|
+
result << after_insertions.fetch(replacement.end_pos, '')
|
|
59
|
+
cursor = replacement.end_pos
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
result << emit_unreplaced_segment(source, cursor, source.length, before_insertions, after_insertions)
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :edits
|
|
69
|
+
|
|
70
|
+
def add_edit(kind, range, content)
|
|
71
|
+
edits << Edit.new(
|
|
72
|
+
kind: kind,
|
|
73
|
+
begin_pos: range.begin_pos,
|
|
74
|
+
end_pos: range.end_pos,
|
|
75
|
+
content: content,
|
|
76
|
+
order: next_order
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def next_order
|
|
81
|
+
@order += 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def insertions
|
|
85
|
+
edits.select { |edit| insertion?(edit) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def replacements
|
|
89
|
+
edits.reject { |edit| insertion?(edit) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def insertion?(edit)
|
|
93
|
+
edit.kind == :insert_before || edit.kind == :insert_after
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_replacements
|
|
97
|
+
replacements
|
|
98
|
+
.sort_by { |edit| [edit.begin_pos, edit.end_pos, edit.order] }
|
|
99
|
+
.each_with_object([]) do |edit, normalized|
|
|
100
|
+
previous = normalized.last
|
|
101
|
+
if previous && overlaps?(previous, edit)
|
|
102
|
+
if deletion?(previous) && deletion?(edit)
|
|
103
|
+
previous.end_pos = [previous.end_pos, edit.end_pos].max
|
|
104
|
+
elsif same_range?(previous, edit)
|
|
105
|
+
previous.content = edit.content
|
|
106
|
+
previous.order = edit.order
|
|
107
|
+
else
|
|
108
|
+
raise ClobberingError, "Overlapping rewrite on #{edit.begin_pos}...#{edit.end_pos}"
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
normalized << edit.dup
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def overlaps?(left, right)
|
|
117
|
+
left.begin_pos < right.end_pos && right.begin_pos < left.end_pos
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def same_range?(left, right)
|
|
121
|
+
left.begin_pos == right.begin_pos && left.end_pos == right.end_pos
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def deletion?(edit)
|
|
125
|
+
edit.content.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_insertions(kind)
|
|
129
|
+
edits
|
|
130
|
+
.select { |edit| edit.kind == kind }
|
|
131
|
+
.group_by(&:begin_pos)
|
|
132
|
+
.transform_values do |position_edits|
|
|
133
|
+
position_edits
|
|
134
|
+
.sort_by(&:order)
|
|
135
|
+
.map(&:content)
|
|
136
|
+
.join
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def emit_unreplaced_segment(source, from, to, before_insertions, after_insertions)
|
|
141
|
+
segment = +''
|
|
142
|
+
cursor = from
|
|
143
|
+
|
|
144
|
+
while cursor < to
|
|
145
|
+
segment << before_insertions.fetch(cursor, '')
|
|
146
|
+
segment << source[cursor]
|
|
147
|
+
cursor += 1
|
|
148
|
+
segment << after_insertions.fetch(cursor, '')
|
|
149
|
+
end
|
|
150
|
+
segment
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/fast/sql/rewriter.rb
CHANGED
|
@@ -4,12 +4,12 @@ module Fast
|
|
|
4
4
|
# @see Fast::SQLRewriter
|
|
5
5
|
# @return string with the content updated in case the pattern matches.
|
|
6
6
|
def replace(pattern, ast, &replacement)
|
|
7
|
-
|
|
7
|
+
rewriter_for(pattern, ast, &replacement).rewrite!
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
# @return [Fast::SQL::Rewriter]
|
|
11
11
|
# @see Fast::Rewriter
|
|
12
|
-
def
|
|
12
|
+
def rewriter_for(pattern, ast, &replacement)
|
|
13
13
|
rewriter = Rewriter.new
|
|
14
14
|
rewriter.ast = ast
|
|
15
15
|
rewriter.search = pattern
|
|
@@ -41,24 +41,53 @@ module Fast
|
|
|
41
41
|
# @see Fast::Rewriter
|
|
42
42
|
class Rewriter < Fast::Rewriter
|
|
43
43
|
|
|
44
|
+
def rewrite!
|
|
45
|
+
replace_on(*types)
|
|
46
|
+
case ast
|
|
47
|
+
when Array
|
|
48
|
+
rewrite(buffer, ast.first)
|
|
49
|
+
else
|
|
50
|
+
rewrite(buffer, ast)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def source
|
|
55
|
+
super ||
|
|
56
|
+
begin
|
|
57
|
+
case ast
|
|
58
|
+
when Array
|
|
59
|
+
ast.first
|
|
60
|
+
else
|
|
61
|
+
ast
|
|
62
|
+
end.location.expression.source_buffer.source
|
|
63
|
+
end
|
|
64
|
+
end
|
|
44
65
|
# @return [Array<Symbol>] with all types that matches
|
|
45
66
|
def types
|
|
46
|
-
ast
|
|
67
|
+
case ast
|
|
68
|
+
when Array
|
|
69
|
+
ast.map(&:type)
|
|
70
|
+
when NilClass
|
|
71
|
+
[]
|
|
72
|
+
else
|
|
73
|
+
ast.type
|
|
74
|
+
end
|
|
47
75
|
end
|
|
48
76
|
|
|
49
77
|
# Generate methods for all affected types.
|
|
50
|
-
# Note the strategy is different from parent class,
|
|
78
|
+
# Note the strategy is different from parent class,
|
|
79
|
+
# it will not stop on first match, but will execute the replacement on
|
|
51
80
|
# all matching elements.
|
|
52
81
|
# @see Fast.replace
|
|
53
82
|
def replace_on(*types)
|
|
54
83
|
types.map do |type|
|
|
55
84
|
self.instance_exec do
|
|
56
|
-
self.class.define_method :"on_#{
|
|
85
|
+
self.class.define_method :"on_#{type}" do |node|
|
|
57
86
|
# SQL nodes are not being automatically invoked by the rewriter,
|
|
58
87
|
# so we need to match the root node and invoke on matching inner elements.
|
|
59
|
-
|
|
88
|
+
Fast.search(search, ast).each_with_index do |node, i|
|
|
60
89
|
@match_index += 1
|
|
61
|
-
execute_replacement(node,
|
|
90
|
+
execute_replacement(node, i)
|
|
62
91
|
end
|
|
63
92
|
end
|
|
64
93
|
end
|
data/lib/fast/sql.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'pg_query'
|
|
2
|
+
require_relative '../fast/source'
|
|
2
3
|
require_relative 'sql/rewriter'
|
|
3
4
|
|
|
4
5
|
module Fast
|
|
@@ -42,10 +43,10 @@ module Fast
|
|
|
42
43
|
# s(:val,
|
|
43
44
|
# s(:string,
|
|
44
45
|
# s(:str, "hello AST"))))))))
|
|
45
|
-
# `s` represents a Fast::Node
|
|
46
|
-
#
|
|
46
|
+
# `s` represents a Fast::Node with additional methods to access the tokens
|
|
47
|
+
# and location of the node.
|
|
47
48
|
# ast.search(:string).first.location.expression
|
|
48
|
-
# => #<
|
|
49
|
+
# => #<Fast::Source::Range (sql) 7...18>
|
|
49
50
|
def parse_sql(statement, buffer_name: "(sql)")
|
|
50
51
|
SQL.parse(statement, buffer_name: buffer_name)
|
|
51
52
|
end
|
|
@@ -53,26 +54,26 @@ module Fast
|
|
|
53
54
|
# This module contains methods to parse SQL statements and rewrite them.
|
|
54
55
|
# It uses PGQuery to parse the SQL statements.
|
|
55
56
|
# It uses Parser to rewrite the SQL statements.
|
|
56
|
-
# It uses
|
|
57
|
+
# It uses Fast::Source::Map to map the AST nodes to the SQL tokens.
|
|
57
58
|
#
|
|
58
59
|
# @example
|
|
59
60
|
# Fast::SQL.parse("select 1")
|
|
60
61
|
# => s(:select_stmt, s(:target_list, ...
|
|
61
62
|
# @see Fast::SQL::Node
|
|
62
63
|
module SQL
|
|
63
|
-
# The SQL source buffer is a subclass of
|
|
64
|
+
# The SQL source buffer is a subclass of Fast::Source::Buffer
|
|
64
65
|
# which contains the tokens of the SQL statement.
|
|
65
66
|
# When you call `ast.location.expression` it will return a range
|
|
66
67
|
# which is mapped to the tokens.
|
|
67
68
|
# @example
|
|
68
69
|
# ast = Fast::SQL.parse("select 1")
|
|
69
|
-
# ast.location.expression # => #<
|
|
70
|
+
# ast.location.expression # => #<Fast::Source::Range (sql) 0...9>
|
|
70
71
|
# ast.location.expression.source_buffer.tokens
|
|
71
72
|
# => [
|
|
72
73
|
# <PgQuery::ScanToken: start: 0, end: 6, token: :SELECT, keyword_kind: :RESERVED_KEYWORD>,
|
|
73
74
|
# <PgQuery::ScanToken: start: 7, end: 8, token: :ICONST, keyword_kind: :NO_KEYWORD>]
|
|
74
75
|
# @see Fast::SQL::Node
|
|
75
|
-
class SourceBuffer <
|
|
76
|
+
class SourceBuffer < Fast::Source::Buffer
|
|
76
77
|
def tokens
|
|
77
78
|
@tokens ||= PgQuery.scan(source).first.tokens
|
|
78
79
|
end
|
|
@@ -111,15 +112,13 @@ module Fast
|
|
|
111
112
|
return [] if statement.nil?
|
|
112
113
|
source_buffer = SQL::SourceBuffer.new(buffer_name, source: statement)
|
|
113
114
|
tree = PgQuery.parse(statement).tree
|
|
115
|
+
first, *, last = source_buffer.tokens
|
|
114
116
|
stmts = tree.stmts.map do |stmt|
|
|
115
|
-
v = clean_structure(stmt.stmt.to_h)
|
|
116
|
-
inner_stmt = statement[stmt.stmt_location, stmt.stmt_len]
|
|
117
|
-
first, *, last = source_buffer.tokens
|
|
118
117
|
from = stmt.stmt_location
|
|
119
|
-
to =
|
|
120
|
-
expression =
|
|
121
|
-
source_map =
|
|
122
|
-
sql_tree_to_ast(
|
|
118
|
+
to = stmt.stmt_len.zero? ? last.end : from + stmt.stmt_len
|
|
119
|
+
expression = Fast::Source.range(source_buffer, from, to)
|
|
120
|
+
source_map = Fast::Source.map(expression)
|
|
121
|
+
sql_tree_to_ast(clean_structure(stmt.stmt.to_h), source_buffer: source_buffer, source_map: source_map)
|
|
123
122
|
end.flatten
|
|
124
123
|
stmts.one? ? stmts.first : stmts
|
|
125
124
|
end
|
|
@@ -150,8 +149,8 @@ module Fast
|
|
|
150
149
|
when Hash
|
|
151
150
|
if (start = obj.delete(:location))
|
|
152
151
|
if (token = source_buffer.tokens.find{|e|e.start == start})
|
|
153
|
-
expression =
|
|
154
|
-
source_map =
|
|
152
|
+
expression = Fast::Source.range(source_buffer, token.start, token.end)
|
|
153
|
+
source_map = Fast::Source.map(expression)
|
|
155
154
|
end
|
|
156
155
|
end
|
|
157
156
|
obj.map do |key, value|
|
|
@@ -164,4 +163,3 @@ module Fast
|
|
|
164
163
|
end
|
|
165
164
|
end
|
|
166
165
|
end
|
|
167
|
-
|