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/rewriter.rb
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fast/source'
|
|
4
|
+
require_relative 'source_rewriter'
|
|
5
|
+
|
|
3
6
|
# Rewriter loads a set of methods related to automated replacement using
|
|
4
7
|
# expressions and custom blocks of code.
|
|
5
8
|
module Fast
|
|
6
9
|
class << self
|
|
7
10
|
# Replaces content based on a pattern.
|
|
8
|
-
# @param [
|
|
11
|
+
# @param [Fast::Node] ast with the current AST to search.
|
|
9
12
|
# @param [String] pattern with the expression to be targeting nodes.
|
|
10
13
|
# @param [Proc] replacement gives the [Rewriter] context in the block.
|
|
11
14
|
# @example
|
|
@@ -15,7 +18,9 @@ module Fast
|
|
|
15
18
|
# @return [String] with the new source code after apply the replacement
|
|
16
19
|
# @see Fast::Rewriter
|
|
17
20
|
def replace(pattern, ast, source = nil, &replacement)
|
|
18
|
-
rewriter_for(pattern, ast, source, &replacement).rewrite!
|
|
21
|
+
rewritten = rewriter_for(pattern, ast, source, &replacement).rewrite!
|
|
22
|
+
Fast.validate_ruby!(rewritten, buffer_name: ast.buffer_name) if rewritten
|
|
23
|
+
rewritten
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
# @return [Fast::Rewriter]
|
|
@@ -54,12 +59,11 @@ module Fast
|
|
|
54
59
|
# rewriter.search ='(lvasgn _ ...)'
|
|
55
60
|
# rewriter.replacement = -> (node) { replace(node.location.name, 'variable_renamed') }
|
|
56
61
|
# rewriter.rewrite! # => "variable_renamed = 1"
|
|
57
|
-
class Rewriter
|
|
62
|
+
class Rewriter
|
|
58
63
|
# @return [Integer] with occurrence index
|
|
59
64
|
attr_reader :match_index
|
|
60
65
|
attr_accessor :search, :replacement, :source, :ast
|
|
61
66
|
def initialize(*_args)
|
|
62
|
-
super()
|
|
63
67
|
@match_index = 0
|
|
64
68
|
end
|
|
65
69
|
|
|
@@ -69,14 +73,18 @@ module Fast
|
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
def buffer
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
Fast::Source.buffer('replacement', source: source || ast.loc.expression.source)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rewrite(source_buffer, root)
|
|
80
|
+
@source_rewriter = Fast::SourceRewriter.new(source_buffer)
|
|
81
|
+
traverse(root)
|
|
82
|
+
@source_rewriter.process
|
|
75
83
|
end
|
|
76
84
|
|
|
77
85
|
# @return [Array<Symbol>] with all types that matches
|
|
78
86
|
def types
|
|
79
|
-
Fast.search(search, ast).
|
|
87
|
+
Fast.search(search, ast).select { |node| Fast.ast_node?(node) }.map(&:type).uniq
|
|
80
88
|
end
|
|
81
89
|
|
|
82
90
|
def match?(node)
|
|
@@ -92,13 +100,33 @@ module Fast
|
|
|
92
100
|
@match_index += 1
|
|
93
101
|
execute_replacement(node, captures)
|
|
94
102
|
end
|
|
95
|
-
|
|
103
|
+
traverse_children(node)
|
|
96
104
|
end
|
|
97
105
|
end
|
|
98
106
|
end
|
|
99
107
|
|
|
108
|
+
def remove(range)
|
|
109
|
+
@source_rewriter.remove(range)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def wrap(range, before, after)
|
|
113
|
+
@source_rewriter.wrap(range, before, after)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def insert_before(range, content)
|
|
117
|
+
@source_rewriter.insert_before(range, content)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def insert_after(range, content)
|
|
121
|
+
@source_rewriter.insert_after(range, content)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def replace(range, content)
|
|
125
|
+
@source_rewriter.replace(range, content)
|
|
126
|
+
end
|
|
127
|
+
|
|
100
128
|
# Execute {#replacement} block
|
|
101
|
-
# @param [
|
|
129
|
+
# @param [Fast::Node] node that will be yield in the replacement block
|
|
102
130
|
# @param [Array<Object>, nil] captures are yield if {#replacement} take second argument.
|
|
103
131
|
def execute_replacement(node, captures)
|
|
104
132
|
if replacement.parameters.length == 1
|
|
@@ -107,5 +135,31 @@ module Fast
|
|
|
107
135
|
instance_exec node, captures, &replacement
|
|
108
136
|
end
|
|
109
137
|
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def traverse(node)
|
|
142
|
+
return if node.nil?
|
|
143
|
+
|
|
144
|
+
if node.is_a?(Array)
|
|
145
|
+
node.each { |child| traverse(child) }
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
return unless Fast.ast_node?(node)
|
|
150
|
+
|
|
151
|
+
handler = :"on_#{node.type}"
|
|
152
|
+
if respond_to?(handler, true)
|
|
153
|
+
public_send(handler, node)
|
|
154
|
+
else
|
|
155
|
+
traverse_children(node)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def traverse_children(node)
|
|
160
|
+
node.children.each do |child|
|
|
161
|
+
traverse(child) if Fast.ast_node?(child) || child.is_a?(Array)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
110
164
|
end
|
|
111
165
|
end
|
data/lib/fast/scan.rb
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
class Scan
|
|
5
|
+
GROUPS = {
|
|
6
|
+
models: 'Models',
|
|
7
|
+
controllers: 'Controllers',
|
|
8
|
+
services: 'Services',
|
|
9
|
+
jobs: 'Jobs',
|
|
10
|
+
mailers: 'Mailers',
|
|
11
|
+
libraries: 'Libraries',
|
|
12
|
+
other: 'Other'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
MAX_METHODS = 5
|
|
16
|
+
MAX_SIGNALS = 4
|
|
17
|
+
MAX_MACROS = 3
|
|
18
|
+
|
|
19
|
+
def initialize(locations, command_name: '.scan', level: nil)
|
|
20
|
+
@locations = Array(locations)
|
|
21
|
+
@command_name = command_name
|
|
22
|
+
@level = normalize_level(level)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def scan
|
|
26
|
+
files = Fast.ruby_files_from(*@locations)
|
|
27
|
+
grouped = files.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |file, memo|
|
|
28
|
+
begin
|
|
29
|
+
entries = flatten_entries(Fast.summary(IO.read(file), file: file, command_name: @command_name).outline)
|
|
30
|
+
next if entries.empty?
|
|
31
|
+
|
|
32
|
+
memo[classify(file, entries)] << [file, entries]
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
warn "Error scanning #{file}: #{e.message}" if Fast.debugging
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
print_grouped(grouped)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def classify(file, entries)
|
|
44
|
+
entries = structural_entries(entries)
|
|
45
|
+
|
|
46
|
+
return :models if file.include?('/models/') || model_like?(entries)
|
|
47
|
+
return :controllers if file.include?('/controllers/') || controller_like?(entries)
|
|
48
|
+
return :services if file.include?('/services/') || name_like?(entries, /Service\z/)
|
|
49
|
+
return :jobs if file.include?('/jobs/') || name_like?(entries, /Job\z/)
|
|
50
|
+
return :mailers if file.include?('/mailers/') || name_like?(entries, /Mailer\z/)
|
|
51
|
+
return :libraries if file.start_with?('lib/')
|
|
52
|
+
|
|
53
|
+
:other
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def model_like?(entries)
|
|
57
|
+
entries.any? do |entry|
|
|
58
|
+
superclass = entry[:superclass].to_s
|
|
59
|
+
superclass.end_with?('ApplicationRecord', 'ActiveRecord::Base') ||
|
|
60
|
+
entry[:relationships].any? ||
|
|
61
|
+
entry[:validations].any? ||
|
|
62
|
+
entry[:scopes].any?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def controller_like?(entries)
|
|
67
|
+
entries.any? do |entry|
|
|
68
|
+
superclass = entry[:superclass].to_s
|
|
69
|
+
superclass.end_with?('Controller', 'BaseController', 'ApplicationController') ||
|
|
70
|
+
entry[:hooks].any? { |hook| hook.include?('_action') }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def name_like?(entries, pattern)
|
|
75
|
+
entries.any? { |entry| entry[:name].to_s.match?(pattern) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def print_grouped(grouped)
|
|
79
|
+
GROUPS.each do |key, label|
|
|
80
|
+
files = grouped[key]
|
|
81
|
+
next if files.empty?
|
|
82
|
+
|
|
83
|
+
puts "#{label}:"
|
|
84
|
+
files.sort_by(&:first).each do |file, entries|
|
|
85
|
+
print_file(file, entries)
|
|
86
|
+
end
|
|
87
|
+
puts
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def print_file(file, entries)
|
|
92
|
+
entries = structural_entries(entries)
|
|
93
|
+
return if entries.empty?
|
|
94
|
+
|
|
95
|
+
puts "- #{file}"
|
|
96
|
+
entries.each do |entry|
|
|
97
|
+
puts " #{object_signature(entry)}"
|
|
98
|
+
|
|
99
|
+
signals = build_signals(entry)
|
|
100
|
+
puts " signals: #{signals.join(' | ')}" if show_signals? && signals.any?
|
|
101
|
+
|
|
102
|
+
methods = build_methods(entry)
|
|
103
|
+
puts " methods: #{methods.join(', ')}" if show_methods? && methods.any?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def structural_entries(entries)
|
|
108
|
+
filtered = entries.select do |entry|
|
|
109
|
+
%i[module class].include?(entry[:kind]) && interesting_entry?(entry)
|
|
110
|
+
end
|
|
111
|
+
filtered.empty? ? entries.reject { |entry| entry[:kind] == :send } : filtered
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def flatten_entries(entries, namespace = nil)
|
|
115
|
+
entries.flat_map do |entry|
|
|
116
|
+
qualified_name = qualify_name(namespace, entry[:name])
|
|
117
|
+
flattened_entry = entry.merge(
|
|
118
|
+
name: qualified_name,
|
|
119
|
+
nested: []
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
[flattened_entry] + flatten_entries(entry[:nested], qualified_name)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def qualify_name(namespace, name)
|
|
127
|
+
return name unless namespace && name
|
|
128
|
+
return name if name.include?('::')
|
|
129
|
+
|
|
130
|
+
"#{namespace}::#{name}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def interesting_entry?(entry)
|
|
134
|
+
entry[:methods].values.any?(&:any?) ||
|
|
135
|
+
entry[:relationships].any? ||
|
|
136
|
+
entry[:hooks].any? ||
|
|
137
|
+
entry[:validations].any? ||
|
|
138
|
+
entry[:scopes].any? ||
|
|
139
|
+
entry[:macros].any? ||
|
|
140
|
+
entry[:mixins].any?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def object_signature(entry)
|
|
144
|
+
signature = entry[:name].to_s
|
|
145
|
+
return signature unless entry[:kind] == :class && entry[:superclass]
|
|
146
|
+
|
|
147
|
+
"#{signature} < #{entry[:superclass]}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_signals(entry)
|
|
151
|
+
signals = []
|
|
152
|
+
signals << summarize_section('relationships', entry[:relationships]) if entry[:relationships].any?
|
|
153
|
+
signals << summarize_section('hooks', entry[:hooks]) if entry[:hooks].any?
|
|
154
|
+
signals << summarize_section('validations', entry[:validations]) if entry[:validations].any?
|
|
155
|
+
signals << summarize_section('scopes', entry[:scopes]) if entry[:scopes].any?
|
|
156
|
+
signals << summarize_section('macros', entry[:macros], limit: MAX_MACROS) if entry[:macros].any?
|
|
157
|
+
signals << summarize_section('mixins', entry[:mixins]) if entry[:mixins].any?
|
|
158
|
+
signals.first(MAX_SIGNALS)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def summarize_section(name, values, limit: 2)
|
|
162
|
+
preview = values.first(limit).join(', ')
|
|
163
|
+
suffix = values.length > limit ? ", +#{values.length - limit}" : ''
|
|
164
|
+
"#{name}=#{preview}#{suffix}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_methods(entry)
|
|
168
|
+
public_methods = entry[:methods][:public].first(MAX_METHODS)
|
|
169
|
+
protected_methods = entry[:methods][:protected].first(2)
|
|
170
|
+
private_methods = entry[:methods][:private].first(2)
|
|
171
|
+
|
|
172
|
+
methods = public_methods.map { |method| qualify_method(entry, method) }
|
|
173
|
+
methods.concat(protected_methods.map { |method| "protected #{qualify_method(entry, method)}" })
|
|
174
|
+
methods.concat(private_methods.map { |method| "private #{qualify_method(entry, method)}" })
|
|
175
|
+
methods
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def qualify_method(entry, signature)
|
|
179
|
+
method = signature.delete_prefix('def ')
|
|
180
|
+
separator = singleton_method?(method) || module_function_entry?(entry) ? '.' : '#'
|
|
181
|
+
method = method.delete_prefix('self.')
|
|
182
|
+
"#{entry[:name]}#{separator}#{method}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def singleton_method?(method)
|
|
186
|
+
method.start_with?('self.')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def module_function_entry?(entry)
|
|
190
|
+
entry[:kind] == :module && entry[:macros].include?('module_function')
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def normalize_level(level)
|
|
194
|
+
return 3 if level.nil?
|
|
195
|
+
|
|
196
|
+
[[level.to_i, 1].max, 3].min
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def show_signals?
|
|
200
|
+
@level >= 2
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def show_methods?
|
|
204
|
+
@level >= 3
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
data/lib/fast/shortcut.rb
CHANGED
|
@@ -12,14 +12,13 @@ module Fast
|
|
|
12
12
|
ENV['HOME'],
|
|
13
13
|
ENV['FAST_FILE_DIR'],
|
|
14
14
|
File.join(File.dirname(__FILE__), '..', '..')
|
|
15
|
-
].compact.map(&File.method(:expand_path)).uniq.freeze
|
|
15
|
+
].reverse.compact.map(&File.method(:expand_path)).uniq.freeze
|
|
16
16
|
|
|
17
17
|
# Store predefined searches with default paths through shortcuts.
|
|
18
18
|
# define your Fastfile in you root folder or
|
|
19
19
|
# @example Shortcut for finding validations in rails models
|
|
20
20
|
# Fast.shortcut(:validations, "(send nil {validate validates})", "app/models")
|
|
21
21
|
def shortcut(identifier, *args, &block)
|
|
22
|
-
puts "identifier #{identifier.inspect} will be override" if shortcuts.key?(identifier)
|
|
23
22
|
shortcuts[identifier] = Shortcut.new(*args, &block)
|
|
24
23
|
end
|
|
25
24
|
|
|
@@ -39,7 +38,20 @@ module Fast
|
|
|
39
38
|
|
|
40
39
|
# Loads `Fastfiles` from {.fast_files} list
|
|
41
40
|
def load_fast_files!
|
|
42
|
-
|
|
41
|
+
@loaded_fast_files ||= []
|
|
42
|
+
fast_files.each do |file|
|
|
43
|
+
next if @loaded_fast_files.include?(file)
|
|
44
|
+
|
|
45
|
+
load file
|
|
46
|
+
@loaded_fast_files << file
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_markdown_for_terminal(line)
|
|
51
|
+
require 'tty-markdown'
|
|
52
|
+
TTY::Markdown.parse(line)
|
|
53
|
+
rescue LoadError
|
|
54
|
+
line
|
|
43
55
|
end
|
|
44
56
|
end
|
|
45
57
|
|
|
@@ -78,7 +90,7 @@ module Fast
|
|
|
78
90
|
# Use ARGV to catch regular arguments from command line if the block is
|
|
79
91
|
# given.
|
|
80
92
|
#
|
|
81
|
-
# @return [Hash<String, Array<
|
|
93
|
+
# @return [Hash<String, Array<Fast::Node>>] with file => search results.
|
|
82
94
|
def run
|
|
83
95
|
Fast.instance_exec(&@block) if single_run_with_block?
|
|
84
96
|
end
|
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
|