prism-merge 1.0.0
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +987 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/prism/merge/conflict_resolver.rb +463 -0
- data/lib/prism/merge/file_aligner.rb +381 -0
- data/lib/prism/merge/file_analysis.rb +298 -0
- data/lib/prism/merge/merge_result.rb +176 -0
- data/lib/prism/merge/smart_merger.rb +347 -0
- data/lib/prism/merge/version.rb +12 -0
- data/lib/prism/merge.rb +93 -0
- data/lib/prism-merge.rb +4 -0
- data/sig/prism/merge.rbs +265 -0
- data.tar.gz.sig +6 -0
- metadata +303 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Prism
|
|
6
|
+
module Merge
|
|
7
|
+
# Identifies sequential "anchor sections" where template and destination
|
|
8
|
+
# have identical or equivalent lines, defining merge boundaries.
|
|
9
|
+
# Similar to diff algorithm but AST-aware.
|
|
10
|
+
class FileAligner
|
|
11
|
+
# Represents a section of matching lines between files
|
|
12
|
+
Anchor = Struct.new(:template_start, :template_end, :dest_start, :dest_end, :match_type, :score) do
|
|
13
|
+
# @return [Range] Line range in template file covered by this anchor
|
|
14
|
+
def template_range
|
|
15
|
+
template_start..template_end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Range] Line range in destination file covered by this anchor
|
|
19
|
+
def dest_range
|
|
20
|
+
dest_start..dest_end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Integer] Number of lines covered by this anchor
|
|
24
|
+
def length
|
|
25
|
+
template_end - template_start + 1
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Represents a boundary where files differ
|
|
30
|
+
Boundary = Struct.new(:template_range, :dest_range, :prev_anchor, :next_anchor) do
|
|
31
|
+
# @return [Array<Integer>] Array of line numbers in template file within this boundary
|
|
32
|
+
def template_lines
|
|
33
|
+
return [] unless template_range
|
|
34
|
+
(template_range.begin..template_range.end).to_a
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Integer>] Array of line numbers in destination file within this boundary
|
|
38
|
+
def dest_lines
|
|
39
|
+
return [] unless dest_range
|
|
40
|
+
(dest_range.begin..dest_range.end).to_a
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :template_analysis, :dest_analysis, :anchors, :boundaries
|
|
45
|
+
|
|
46
|
+
# @param template_analysis [FileAnalysis] Template file analysis
|
|
47
|
+
# @param dest_analysis [FileAnalysis] Destination file analysis
|
|
48
|
+
def initialize(template_analysis, dest_analysis)
|
|
49
|
+
@template_analysis = template_analysis
|
|
50
|
+
@dest_analysis = dest_analysis
|
|
51
|
+
@anchors = []
|
|
52
|
+
@boundaries = []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Perform alignment and identify anchors and boundaries
|
|
56
|
+
# @return [Array<Boundary>] Boundaries requiring conflict resolution
|
|
57
|
+
def align
|
|
58
|
+
find_anchors
|
|
59
|
+
compute_boundaries
|
|
60
|
+
@boundaries
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Find matching sections between template and destination
|
|
66
|
+
def find_anchors
|
|
67
|
+
@anchors = []
|
|
68
|
+
|
|
69
|
+
# Special case: if files are identical, create one anchor for entire file
|
|
70
|
+
if @template_analysis.lines == @dest_analysis.lines
|
|
71
|
+
@anchors << Anchor.new(
|
|
72
|
+
1,
|
|
73
|
+
@template_analysis.lines.length,
|
|
74
|
+
1,
|
|
75
|
+
@dest_analysis.lines.length,
|
|
76
|
+
:exact_match,
|
|
77
|
+
@template_analysis.lines.length,
|
|
78
|
+
)
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Strategy: Find exact line matches and structural matches
|
|
83
|
+
# 1. Structural node matches (same signature) - FIRST to ensure matching blocks become anchors
|
|
84
|
+
# 2. Exact line matches (including comments)
|
|
85
|
+
# 3. Freeze blocks (always anchors)
|
|
86
|
+
|
|
87
|
+
# Start with node signature-based anchors (highest priority)
|
|
88
|
+
@anchors = []
|
|
89
|
+
add_node_signature_anchors
|
|
90
|
+
|
|
91
|
+
# Build mapping of normalized lines for quick lookup
|
|
92
|
+
template_line_map = build_line_map(@template_analysis)
|
|
93
|
+
dest_line_map = build_line_map(@dest_analysis)
|
|
94
|
+
|
|
95
|
+
# Find exact line matches using longest common subsequence approach
|
|
96
|
+
exact_matches = find_exact_line_matches(template_line_map, dest_line_map)
|
|
97
|
+
|
|
98
|
+
# Convert matches to anchors, merging consecutive matches
|
|
99
|
+
line_anchors = merge_consecutive_matches(exact_matches)
|
|
100
|
+
|
|
101
|
+
# Add line anchors that don't overlap with signature anchors
|
|
102
|
+
line_anchors.each do |anchor|
|
|
103
|
+
overlaps = @anchors.any? do |existing|
|
|
104
|
+
ranges_overlap?(existing.template_range, anchor.template_range) ||
|
|
105
|
+
ranges_overlap?(existing.dest_range, anchor.dest_range)
|
|
106
|
+
end
|
|
107
|
+
@anchors << anchor unless overlaps
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Add freeze block anchors
|
|
111
|
+
add_freeze_block_anchors
|
|
112
|
+
|
|
113
|
+
# Sort anchors by position
|
|
114
|
+
@anchors.sort_by! { |a| [a.template_start, a.dest_start] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_line_map(analysis)
|
|
118
|
+
map = {}
|
|
119
|
+
# Keywords that are too generic to use as anchors on their own
|
|
120
|
+
generic_keywords = %w[end else elsif when rescue ensure]
|
|
121
|
+
|
|
122
|
+
# Get line numbers covered by statement nodes - these shouldn't be matched line-by-line
|
|
123
|
+
statement_lines = Set.new
|
|
124
|
+
analysis.statements.each do |stmt|
|
|
125
|
+
(stmt.location.start_line..stmt.location.end_line).each do |line_num|
|
|
126
|
+
statement_lines << line_num
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
analysis.lines.each_with_index do |line, idx|
|
|
131
|
+
line_num = idx + 1
|
|
132
|
+
normalized = line.strip
|
|
133
|
+
next if normalized.empty?
|
|
134
|
+
# Skip overly generic lines that appear in many contexts
|
|
135
|
+
next if generic_keywords.include?(normalized)
|
|
136
|
+
# Skip lines that are part of statement nodes - they should be matched by signature
|
|
137
|
+
next if statement_lines.include?(line_num)
|
|
138
|
+
map[line_num] = normalized
|
|
139
|
+
end
|
|
140
|
+
map
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def find_exact_line_matches(template_map, dest_map)
|
|
144
|
+
matches = []
|
|
145
|
+
|
|
146
|
+
# Build reverse mapping from normalized content to line numbers
|
|
147
|
+
dest_content_to_lines = {}
|
|
148
|
+
dest_map.each do |line_num, content|
|
|
149
|
+
dest_content_to_lines[content] ||= []
|
|
150
|
+
dest_content_to_lines[content] << line_num
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Find matches - for each template line, find first matching dest line
|
|
154
|
+
used_dest_lines = Set.new
|
|
155
|
+
|
|
156
|
+
template_map.keys.sort.each do |t_line|
|
|
157
|
+
content = template_map[t_line]
|
|
158
|
+
next unless dest_content_to_lines[content]
|
|
159
|
+
|
|
160
|
+
# Find first unused destination line with matching content
|
|
161
|
+
d_line = dest_content_to_lines[content].find { |dl| !used_dest_lines.include?(dl) }
|
|
162
|
+
next unless d_line
|
|
163
|
+
|
|
164
|
+
matches << {
|
|
165
|
+
template_line: t_line,
|
|
166
|
+
dest_line: d_line,
|
|
167
|
+
content: content,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
used_dest_lines << d_line
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
matches
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def merge_consecutive_matches(matches)
|
|
177
|
+
return [] if matches.empty?
|
|
178
|
+
|
|
179
|
+
anchors = []
|
|
180
|
+
current_start_t = matches[0][:template_line]
|
|
181
|
+
current_start_d = matches[0][:dest_line]
|
|
182
|
+
current_end_t = current_start_t
|
|
183
|
+
current_end_d = current_start_d
|
|
184
|
+
|
|
185
|
+
matches[1..-1].each do |match|
|
|
186
|
+
t_line = match[:template_line]
|
|
187
|
+
d_line = match[:dest_line]
|
|
188
|
+
|
|
189
|
+
# Check if this extends the current sequence
|
|
190
|
+
if t_line == current_end_t + 1 && d_line == current_end_d + 1
|
|
191
|
+
# Sequence continues
|
|
192
|
+
else
|
|
193
|
+
# Save current anchor if it's substantial (at least 1 line)
|
|
194
|
+
if current_end_t - current_start_t >= 0
|
|
195
|
+
anchors << Anchor.new(
|
|
196
|
+
current_start_t,
|
|
197
|
+
current_end_t,
|
|
198
|
+
current_start_d,
|
|
199
|
+
current_end_d,
|
|
200
|
+
:exact_match,
|
|
201
|
+
current_end_t - current_start_t + 1,
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Start new sequence
|
|
206
|
+
current_start_t = t_line
|
|
207
|
+
current_start_d = d_line
|
|
208
|
+
end
|
|
209
|
+
current_end_t = t_line
|
|
210
|
+
current_end_d = d_line
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Don't forget the last anchor
|
|
214
|
+
if current_end_t - current_start_t >= 0
|
|
215
|
+
anchors << Anchor.new(
|
|
216
|
+
current_start_t,
|
|
217
|
+
current_end_t,
|
|
218
|
+
current_start_d,
|
|
219
|
+
current_end_d,
|
|
220
|
+
:exact_match,
|
|
221
|
+
current_end_t - current_start_t + 1,
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
anchors
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def add_node_signature_anchors
|
|
229
|
+
# Match nodes with identical signatures to create anchors
|
|
230
|
+
# This helps recognize blocks like appraise "name" with different contents as the same
|
|
231
|
+
template_nodes = @template_analysis.nodes_with_comments
|
|
232
|
+
dest_nodes = @dest_analysis.nodes_with_comments
|
|
233
|
+
|
|
234
|
+
# Build signature map for dest nodes
|
|
235
|
+
dest_sig_map = {}
|
|
236
|
+
dest_nodes.each do |node_info|
|
|
237
|
+
sig = node_info[:signature]
|
|
238
|
+
next unless sig
|
|
239
|
+
|
|
240
|
+
dest_sig_map[sig] ||= []
|
|
241
|
+
dest_sig_map[sig] << node_info
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Track which dest nodes have been matched
|
|
245
|
+
matched_dest = Set.new
|
|
246
|
+
|
|
247
|
+
# Find matching template nodes
|
|
248
|
+
template_nodes.each do |t_node_info|
|
|
249
|
+
sig = t_node_info[:signature]
|
|
250
|
+
next unless sig
|
|
251
|
+
next unless dest_sig_map[sig]
|
|
252
|
+
|
|
253
|
+
# Find first unmatched dest node with this signature
|
|
254
|
+
d_node_info = dest_sig_map[sig].find { |d| !matched_dest.include?(d[:index]) }
|
|
255
|
+
next unless d_node_info
|
|
256
|
+
|
|
257
|
+
# Create anchor for this matched node (including its leading comments)
|
|
258
|
+
t_start = t_node_info[:leading_comments].any? ? t_node_info[:leading_comments].first.location.start_line : t_node_info[:line_range].begin
|
|
259
|
+
t_end = t_node_info[:line_range].end
|
|
260
|
+
d_start = d_node_info[:leading_comments].any? ? d_node_info[:leading_comments].first.location.start_line : d_node_info[:line_range].begin
|
|
261
|
+
d_end = d_node_info[:line_range].end
|
|
262
|
+
|
|
263
|
+
# Check if this would completely overlap with existing anchors
|
|
264
|
+
# Only skip if an anchor already covers the EXACT same range
|
|
265
|
+
overlaps = @anchors.any? do |a|
|
|
266
|
+
a.template_start == t_start && a.template_end == t_end &&
|
|
267
|
+
a.dest_start == d_start && a.dest_end == d_end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
unless overlaps
|
|
271
|
+
@anchors << Anchor.new(
|
|
272
|
+
t_start,
|
|
273
|
+
t_end,
|
|
274
|
+
d_start,
|
|
275
|
+
d_end,
|
|
276
|
+
:signature_match,
|
|
277
|
+
t_end - t_start + 1,
|
|
278
|
+
)
|
|
279
|
+
matched_dest << d_node_info[:index]
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def add_freeze_block_anchors
|
|
285
|
+
# Freeze blocks in destination should always be preserved as anchors
|
|
286
|
+
@dest_analysis.freeze_blocks.each do |block|
|
|
287
|
+
line_range = block[:line_range]
|
|
288
|
+
|
|
289
|
+
# Check if there's a corresponding freeze block in template
|
|
290
|
+
template_block = @template_analysis.freeze_blocks.find do |tb|
|
|
291
|
+
tb[:start_marker] == block[:start_marker]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if template_block
|
|
295
|
+
# Check if there's already an anchor covering this range
|
|
296
|
+
# (from exact line matches)
|
|
297
|
+
existing_anchor = @anchors.find do |a|
|
|
298
|
+
a.template_start <= template_block[:line_range].begin &&
|
|
299
|
+
a.template_end >= template_block[:line_range].end &&
|
|
300
|
+
a.dest_start <= line_range.begin &&
|
|
301
|
+
a.dest_end >= line_range.end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Only create freeze block anchor if not already covered
|
|
305
|
+
unless existing_anchor
|
|
306
|
+
# Both files have this freeze block - create anchor
|
|
307
|
+
@anchors << Anchor.new(
|
|
308
|
+
template_block[:line_range].begin,
|
|
309
|
+
template_block[:line_range].end,
|
|
310
|
+
line_range.begin,
|
|
311
|
+
line_range.end,
|
|
312
|
+
:freeze_block,
|
|
313
|
+
100, # High score for freeze blocks
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def compute_boundaries
|
|
321
|
+
@boundaries = []
|
|
322
|
+
|
|
323
|
+
# Special case: no anchors means entire files are boundaries
|
|
324
|
+
if @anchors.empty?
|
|
325
|
+
@boundaries << Boundary.new(
|
|
326
|
+
1..@template_analysis.lines.length,
|
|
327
|
+
1..@dest_analysis.lines.length,
|
|
328
|
+
nil,
|
|
329
|
+
nil,
|
|
330
|
+
)
|
|
331
|
+
return
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Boundary before first anchor
|
|
335
|
+
first_anchor = @anchors.first
|
|
336
|
+
if first_anchor.template_start > 1 || first_anchor.dest_start > 1
|
|
337
|
+
template_range = (first_anchor.template_start > 1) ? (1..first_anchor.template_start - 1) : nil
|
|
338
|
+
dest_range = (first_anchor.dest_start > 1) ? (1..first_anchor.dest_start - 1) : nil
|
|
339
|
+
|
|
340
|
+
if template_range || dest_range
|
|
341
|
+
@boundaries << Boundary.new(template_range, dest_range, nil, first_anchor)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Boundaries between consecutive anchors
|
|
346
|
+
@anchors.each_cons(2) do |prev_anchor, next_anchor|
|
|
347
|
+
template_gap_start = prev_anchor.template_end + 1
|
|
348
|
+
template_gap_end = next_anchor.template_start - 1
|
|
349
|
+
dest_gap_start = prev_anchor.dest_end + 1
|
|
350
|
+
dest_gap_end = next_anchor.dest_start - 1
|
|
351
|
+
|
|
352
|
+
template_range = (template_gap_end >= template_gap_start) ? (template_gap_start..template_gap_end) : nil
|
|
353
|
+
dest_range = (dest_gap_end >= dest_gap_start) ? (dest_gap_start..dest_gap_end) : nil
|
|
354
|
+
|
|
355
|
+
if template_range || dest_range
|
|
356
|
+
@boundaries << Boundary.new(template_range, dest_range, prev_anchor, next_anchor)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Boundary after last anchor
|
|
361
|
+
last_anchor = @anchors.last
|
|
362
|
+
template_remaining = last_anchor.template_end < @template_analysis.lines.length
|
|
363
|
+
dest_remaining = last_anchor.dest_end < @dest_analysis.lines.length
|
|
364
|
+
|
|
365
|
+
if template_remaining || dest_remaining
|
|
366
|
+
template_range = template_remaining ? (last_anchor.template_end + 1..@template_analysis.lines.length) : nil
|
|
367
|
+
dest_range = dest_remaining ? (last_anchor.dest_end + 1..@dest_analysis.lines.length) : nil
|
|
368
|
+
|
|
369
|
+
if template_range || dest_range
|
|
370
|
+
@boundaries << Boundary.new(template_range, dest_range, last_anchor, nil)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def ranges_overlap?(range1, range2)
|
|
376
|
+
return false if range1.nil? || range2.nil?
|
|
377
|
+
range1.begin <= range2.end && range2.begin <= range1.end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Prism
|
|
6
|
+
module Merge
|
|
7
|
+
# Comprehensive metadata capture for a Ruby file being merged.
|
|
8
|
+
# Tracks Prism parse result, line-to-node mapping, comment associations,
|
|
9
|
+
# structural signatures, and sequential anchor lines for merge alignment.
|
|
10
|
+
class FileAnalysis
|
|
11
|
+
# Regex pattern for freeze block start marker.
|
|
12
|
+
# Matches comments like: # kettle-dev:freeze
|
|
13
|
+
# Case-insensitive to allow variations like FREEZE or Freeze
|
|
14
|
+
FREEZE_START = /#\s*kettle-dev:freeze/i
|
|
15
|
+
|
|
16
|
+
# Regex pattern for freeze block end marker.
|
|
17
|
+
# Matches comments like: # kettle-dev:unfreeze
|
|
18
|
+
# Case-insensitive to allow variations like UNFREEZE or Unfreeze
|
|
19
|
+
FREEZE_END = /#\s*kettle-dev:unfreeze/i
|
|
20
|
+
|
|
21
|
+
# Combined regex pattern for matching complete freeze blocks.
|
|
22
|
+
# Captures content between freeze/unfreeze markers (inclusive).
|
|
23
|
+
# Used to identify sections that should always be preserved from destination.
|
|
24
|
+
#
|
|
25
|
+
# @example Freeze block in Ruby code
|
|
26
|
+
# # kettle-dev:freeze
|
|
27
|
+
# CUSTOM_CONFIG = { key: "secret" }
|
|
28
|
+
# # kettle-dev:unfreeze
|
|
29
|
+
FREEZE_BLOCK = Regexp.new("(#{FREEZE_START.source}).*?(#{FREEZE_END.source})", Regexp::IGNORECASE | Regexp::MULTILINE)
|
|
30
|
+
|
|
31
|
+
attr_reader :content, :parse_result, :lines, :statements, :freeze_blocks
|
|
32
|
+
|
|
33
|
+
# @param content [String] Ruby source code to analyze
|
|
34
|
+
# @param signature_generator [Proc, nil] Optional proc to generate node signatures
|
|
35
|
+
def initialize(content, signature_generator: nil)
|
|
36
|
+
@content = content
|
|
37
|
+
@lines = content.lines
|
|
38
|
+
@parse_result = Prism.parse(content)
|
|
39
|
+
@statements = extract_statements
|
|
40
|
+
@freeze_blocks = extract_freeze_blocks
|
|
41
|
+
@signature_generator = signature_generator
|
|
42
|
+
@line_to_node_map = nil
|
|
43
|
+
@node_to_line_map = nil
|
|
44
|
+
@comment_map = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if parsing was successful
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def valid?
|
|
50
|
+
@parse_result.success?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get all top-level statement nodes
|
|
54
|
+
# @return [Array<Prism::Node>]
|
|
55
|
+
def extract_statements
|
|
56
|
+
return [] unless valid?
|
|
57
|
+
body = @parse_result.value.statements
|
|
58
|
+
return [] unless body
|
|
59
|
+
|
|
60
|
+
if body.is_a?(Prism::StatementsNode)
|
|
61
|
+
body.body.compact
|
|
62
|
+
else
|
|
63
|
+
[body].compact
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Extract freeze block information
|
|
68
|
+
# @return [Array<Hash>] Array of freeze block metadata
|
|
69
|
+
def extract_freeze_blocks
|
|
70
|
+
return [] unless content.match?(FREEZE_START)
|
|
71
|
+
|
|
72
|
+
blocks = []
|
|
73
|
+
content.to_enum(:scan, FREEZE_BLOCK).each do
|
|
74
|
+
match = Regexp.last_match
|
|
75
|
+
next unless match
|
|
76
|
+
|
|
77
|
+
start_idx = match.begin(0)
|
|
78
|
+
end_idx = match.end(0)
|
|
79
|
+
segment = match[0]
|
|
80
|
+
start_line = content[0...start_idx].count("\n") + 1
|
|
81
|
+
end_line = content[0...end_idx].count("\n") + 1
|
|
82
|
+
|
|
83
|
+
blocks << {
|
|
84
|
+
range: start_idx...end_idx,
|
|
85
|
+
line_range: start_line..end_line,
|
|
86
|
+
text: segment,
|
|
87
|
+
start_marker: segment&.lines&.first&.strip,
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
blocks
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build mapping from line numbers to AST nodes
|
|
95
|
+
# @return [Hash<Integer, Array<Prism::Node>>] Line number => nodes on that line
|
|
96
|
+
def line_to_node_map
|
|
97
|
+
@line_to_node_map ||= build_line_to_node_map
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Build mapping from nodes to line ranges
|
|
101
|
+
# @return [Hash<Prism::Node, Range>] Node => line range
|
|
102
|
+
def node_to_line_map
|
|
103
|
+
@node_to_line_map ||= build_node_to_line_map
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get nodes with their associated comments and metadata
|
|
107
|
+
# @return [Array<Hash>] Array of node info hashes
|
|
108
|
+
def nodes_with_comments
|
|
109
|
+
@nodes_with_comments ||= extract_nodes_with_comments
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get comment map by line number
|
|
113
|
+
# @return [Hash<Integer, Array<Prism::Comment>>] Line number => comments
|
|
114
|
+
def comment_map
|
|
115
|
+
@comment_map ||= build_comment_map
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get structural signature for a statement at given index
|
|
119
|
+
# @param index [Integer] Statement index
|
|
120
|
+
# @return [Array, nil] Signature array
|
|
121
|
+
def signature_at(index)
|
|
122
|
+
return if index < 0 || index >= statements.length
|
|
123
|
+
generate_signature(statements[index])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Generate signature for a node
|
|
127
|
+
# @param node [Prism::Node] Node to generate signature for
|
|
128
|
+
# @return [Array, nil] Signature array
|
|
129
|
+
def generate_signature(node)
|
|
130
|
+
if @signature_generator
|
|
131
|
+
@signature_generator.call(node)
|
|
132
|
+
else
|
|
133
|
+
default_signature(node)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Check if a line is within a freeze block
|
|
138
|
+
# @param line_num [Integer] 1-based line number
|
|
139
|
+
# @return [Boolean]
|
|
140
|
+
def in_freeze_block?(line_num)
|
|
141
|
+
freeze_blocks.any? { |block| block[:line_range].cover?(line_num) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Get the freeze block containing the given line, if any
|
|
145
|
+
# @param line_num [Integer] 1-based line number
|
|
146
|
+
# @return [Hash, nil] Freeze block metadata or nil
|
|
147
|
+
def freeze_block_at(line_num)
|
|
148
|
+
freeze_blocks.find { |block| block[:line_range].cover?(line_num) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get normalized line content (stripped)
|
|
152
|
+
# @param line_num [Integer] 1-based line number
|
|
153
|
+
# @return [String, nil]
|
|
154
|
+
def normalized_line(line_num)
|
|
155
|
+
return if line_num < 1 || line_num > lines.length
|
|
156
|
+
lines[line_num - 1].strip
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Get raw line content
|
|
160
|
+
# @param line_num [Integer] 1-based line number
|
|
161
|
+
# @return [String, nil]
|
|
162
|
+
def line_at(line_num)
|
|
163
|
+
return if line_num < 1 || line_num > lines.length
|
|
164
|
+
lines[line_num - 1]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def build_line_to_node_map
|
|
170
|
+
map = Hash.new { |h, k| h[k] = [] }
|
|
171
|
+
return map unless valid?
|
|
172
|
+
|
|
173
|
+
statements.each do |node|
|
|
174
|
+
start_line = node.location.start_line
|
|
175
|
+
end_line = node.location.end_line
|
|
176
|
+
(start_line..end_line).each do |line_num|
|
|
177
|
+
map[line_num] << node
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
map
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_node_to_line_map
|
|
185
|
+
map = {}
|
|
186
|
+
return map unless valid?
|
|
187
|
+
|
|
188
|
+
statements.each do |node|
|
|
189
|
+
map[node] = node.location.start_line..node.location.end_line
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
map
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def extract_nodes_with_comments
|
|
196
|
+
return [] unless valid?
|
|
197
|
+
|
|
198
|
+
statements.map.with_index do |stmt, idx|
|
|
199
|
+
prev_stmt = (idx > 0) ? statements[idx - 1] : nil
|
|
200
|
+
body_node = @parse_result.value.statements
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
node: stmt,
|
|
204
|
+
index: idx,
|
|
205
|
+
leading_comments: find_leading_comments(stmt, prev_stmt, body_node),
|
|
206
|
+
inline_comments: inline_comments_for_node(stmt),
|
|
207
|
+
signature: generate_signature(stmt),
|
|
208
|
+
line_range: stmt.location.start_line..stmt.location.end_line,
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def find_leading_comments(current_stmt, prev_stmt, body_node)
|
|
214
|
+
start_line = prev_stmt ? prev_stmt.location.end_line : 0
|
|
215
|
+
end_line = current_stmt.location.start_line
|
|
216
|
+
|
|
217
|
+
# Find all comments in the range
|
|
218
|
+
candidates = @parse_result.comments.select do |comment|
|
|
219
|
+
comment.location.start_line > start_line &&
|
|
220
|
+
comment.location.start_line < end_line
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Only include comments that are immediately adjacent to the statement
|
|
224
|
+
# (no blank lines between the comment and the statement)
|
|
225
|
+
adjacent_comments = []
|
|
226
|
+
expected_line = end_line - 1
|
|
227
|
+
|
|
228
|
+
candidates.reverse_each do |comment|
|
|
229
|
+
comment_line = comment.location.start_line
|
|
230
|
+
|
|
231
|
+
# Only include if this comment is immediately adjacent (no gaps)
|
|
232
|
+
if comment_line == expected_line
|
|
233
|
+
adjacent_comments.unshift(comment)
|
|
234
|
+
expected_line = comment_line - 1
|
|
235
|
+
else
|
|
236
|
+
# Gap found (blank line or code), stop looking
|
|
237
|
+
break
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
adjacent_comments
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def inline_comments_for_node(stmt)
|
|
245
|
+
@parse_result.comments.select do |comment|
|
|
246
|
+
# Check if comment is on the same line as the start of the statement
|
|
247
|
+
# and appears after the statement text begins
|
|
248
|
+
comment.location.start_line == stmt.location.start_line &&
|
|
249
|
+
comment.location.start_offset > stmt.location.start_offset
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def build_comment_map
|
|
254
|
+
map = Hash.new { |h, k| h[k] = [] }
|
|
255
|
+
return map unless valid?
|
|
256
|
+
|
|
257
|
+
@parse_result.comments.each do |comment|
|
|
258
|
+
line = comment.location.start_line
|
|
259
|
+
map[line] << comment
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
map
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Default signature generation
|
|
266
|
+
def default_signature(node)
|
|
267
|
+
return [:nil] unless node
|
|
268
|
+
|
|
269
|
+
# For conditional nodes, signature should be based on the condition only,
|
|
270
|
+
# not the body, so conditionals with same condition but different bodies
|
|
271
|
+
# are recognized as matching
|
|
272
|
+
case node
|
|
273
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
274
|
+
condition_slice = node.predicate&.slice || ""
|
|
275
|
+
[node.class.name.split("::").last.to_sym, condition_slice]
|
|
276
|
+
when Prism::ConstantWriteNode, Prism::GlobalVariableWriteNode,
|
|
277
|
+
Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode,
|
|
278
|
+
Prism::LocalVariableWriteNode
|
|
279
|
+
# For variable/constant assignments, signature based on name only,
|
|
280
|
+
# not the value, so assignments with same name but different values
|
|
281
|
+
# are recognized as matching
|
|
282
|
+
name = node.respond_to?(:name) ? node.name.to_s : node.slice.split("=").first.strip
|
|
283
|
+
[node.class.name.split("::").last.to_sym, name]
|
|
284
|
+
when Prism::CallNode
|
|
285
|
+
# For method calls with blocks, signature based on method name and arguments only,
|
|
286
|
+
# not the block body, so calls with same name/args but different blocks
|
|
287
|
+
# are recognized as matching
|
|
288
|
+
method_name = node.name.to_s
|
|
289
|
+
# Extract just the arguments (not the block)
|
|
290
|
+
arg_signature = node.arguments&.arguments&.map { |arg| arg.slice }&.join(", ") || ""
|
|
291
|
+
[node.class.name.split("::").last.to_sym, method_name, arg_signature]
|
|
292
|
+
else
|
|
293
|
+
[node.class.name.split("::").last.to_sym, node.slice]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|