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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/fast-pattern-expert/SKILL.md +71 -0
  3. data/.github/workflows/release.yml +27 -0
  4. data/.github/workflows/ruby.yml +34 -0
  5. data/.gitignore +2 -0
  6. data/Fastfile +105 -18
  7. data/README.md +21 -7
  8. data/bin/console +1 -1
  9. data/bin/fast-experiment +3 -0
  10. data/bin/fast-mcp +7 -0
  11. data/fast.gemspec +1 -3
  12. data/ideia_blog_post.md +36 -0
  13. data/lib/fast/cli.rb +74 -23
  14. data/lib/fast/experiment.rb +19 -2
  15. data/lib/fast/git.rb +1 -1
  16. data/lib/fast/mcp_server.rb +341 -0
  17. data/lib/fast/node.rb +258 -0
  18. data/lib/fast/prism_adapter.rb +327 -0
  19. data/lib/fast/rewriter.rb +64 -10
  20. data/lib/fast/scan.rb +207 -0
  21. data/lib/fast/shortcut.rb +16 -4
  22. data/lib/fast/source.rb +116 -0
  23. data/lib/fast/source_rewriter.rb +153 -0
  24. data/lib/fast/sql/rewriter.rb +36 -7
  25. data/lib/fast/sql.rb +15 -17
  26. data/lib/fast/summary.rb +440 -0
  27. data/lib/fast/version.rb +1 -1
  28. data/lib/fast.rb +218 -101
  29. data/mkdocs.yml +19 -4
  30. data/requirements-docs.txt +3 -0
  31. metadata +18 -59
  32. data/docs/command_line.md +0 -238
  33. data/docs/editors-integration.md +0 -46
  34. data/docs/experiments.md +0 -155
  35. data/docs/git.md +0 -115
  36. data/docs/ideas.md +0 -70
  37. data/docs/index.md +0 -404
  38. data/docs/pry-integration.md +0 -27
  39. data/docs/research.md +0 -93
  40. data/docs/shortcuts.md +0 -323
  41. data/docs/similarity_tutorial.md +0 -176
  42. data/docs/sql-support.md +0 -253
  43. data/docs/syntax.md +0 -395
  44. data/docs/videos.md +0 -16
  45. data/docs/walkthrough.md +0 -135
  46. data/examples/build_stubbed_and_let_it_be_experiment.rb +0 -51
  47. data/examples/experimental_replacement.rb +0 -46
  48. data/examples/find_usage.rb +0 -26
  49. data/examples/let_it_be_experiment.rb +0 -11
  50. data/examples/method_complexity.rb +0 -37
  51. data/examples/search_duplicated.rb +0 -15
  52. data/examples/similarity_research.rb +0 -58
  53. data/examples/simple_rewriter.rb +0 -6
  54. data/experiments/let_it_be_experiment.rb +0 -9
  55. data/experiments/remove_useless_hook.rb +0 -9
  56. 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 [Astrolabe::Node] ast with the current AST to search.
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 < Parser::TreeRewriter
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
- buffer = Parser::Source::Buffer.new('replacement')
73
- buffer.source = source || ast.loc.expression.source
74
- buffer
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).grep(Parser::AST::Node).map(&:type).uniq
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
- super(node)
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 [Astrolabe::Node] node that will be yield in the replacement block
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
- fast_files.each(&method(:load))
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<Astrolabe::Node>] with file => search results.
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
@@ -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