markdown-merge 1.0.3 → 7.1.1
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
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +99 -482
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
- data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
- data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
- data/lib/markdown/merge/cleanse.rb +5 -3
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +489 -47
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +77 -6
- data/lib/markdown/merge/debug_logger.rb +2 -2
- data/lib/markdown/merge/document_problems.rb +3 -3
- data/lib/markdown/merge/file_aligner.rb +433 -133
- data/lib/markdown/merge/file_analysis.rb +387 -51
- data/lib/markdown/merge/file_analysis_base.rb +188 -51
- data/lib/markdown/merge/gap_line_node.rb +14 -8
- data/lib/markdown/merge/link_definition_node.rb +5 -5
- data/lib/markdown/merge/link_parser.rb +60 -60
- data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
- data/lib/markdown/merge/list_match_refiner.rb +98 -0
- data/lib/markdown/merge/list_merger.rb +322 -0
- data/lib/markdown/merge/markdown_structure.rb +3 -3
- data/lib/markdown/merge/merge_result.rb +321 -4
- data/lib/markdown/merge/node_type_normalizer.rb +6 -6
- data/lib/markdown/merge/output_builder.rb +101 -19
- data/lib/markdown/merge/partial_template_merger.rb +248 -27
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/smart_merger.rb +62 -14
- data/lib/markdown/merge/smart_merger_base.rb +929 -60
- data/lib/markdown/merge/table_match_algorithm.rb +22 -27
- data/lib/markdown/merge/table_match_refiner.rb +6 -10
- data/lib/markdown/merge/version.rb +5 -4
- data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +669 -122
- data/lib/markdown-merge.rb +9 -4
- data/sig/markdown/merge.rbs +3 -336
- data.tar.gz.sig +0 -0
- metadata +104 -93
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -308
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- data/SECURITY.md +0 -21
|
@@ -30,9 +30,25 @@ module Markdown
|
|
|
30
30
|
# )
|
|
31
31
|
#
|
|
32
32
|
class PartialTemplateMerger < Ast::Merge::PartialTemplateMergerBase
|
|
33
|
+
include PreservationSupport
|
|
34
|
+
|
|
33
35
|
# Re-export Result class from base for convenience
|
|
34
36
|
Result = Ast::Merge::PartialTemplateMergerBase::Result
|
|
35
37
|
|
|
38
|
+
class << self
|
|
39
|
+
def default_backend
|
|
40
|
+
:markly
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def file_analysis_class
|
|
44
|
+
FileAnalysis
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def smart_merger_class
|
|
48
|
+
SmartMerger
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
36
52
|
# @return [Symbol] Backend to use (:markly, :commonmarker)
|
|
37
53
|
attr_reader :backend
|
|
38
54
|
|
|
@@ -57,7 +73,7 @@ module Markdown
|
|
|
57
73
|
destination:,
|
|
58
74
|
anchor:,
|
|
59
75
|
boundary: nil,
|
|
60
|
-
backend:
|
|
76
|
+
backend: self.class.default_backend,
|
|
61
77
|
preference: :template,
|
|
62
78
|
add_missing: true,
|
|
63
79
|
when_missing: :skip,
|
|
@@ -117,7 +133,7 @@ module Markdown
|
|
|
117
133
|
changed: result.changed,
|
|
118
134
|
stats: result.stats.merge(problems: problems.all),
|
|
119
135
|
injection_point: result.injection_point,
|
|
120
|
-
message: result.message
|
|
136
|
+
message: result.message
|
|
121
137
|
)
|
|
122
138
|
else
|
|
123
139
|
result
|
|
@@ -126,15 +142,37 @@ module Markdown
|
|
|
126
142
|
|
|
127
143
|
protected
|
|
128
144
|
|
|
145
|
+
def merge_section_content(section_content, section_context: nil)
|
|
146
|
+
return super unless replace_mode?
|
|
147
|
+
|
|
148
|
+
template_analysis = create_analysis(template)
|
|
149
|
+
preserved_fragment_insertions = preserved_destination_insertions(
|
|
150
|
+
create_analysis(section_content),
|
|
151
|
+
template_analysis,
|
|
152
|
+
source_remove_plan: section_context&.fetch(:source_remove_plan, nil),
|
|
153
|
+
destination_section_statements: section_context&.fetch(:section_statements, nil),
|
|
154
|
+
destination_section_analysis: section_context&.fetch(:analysis, nil)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return [template, { mode: :replace }] if preserved_fragment_insertions.empty?
|
|
158
|
+
|
|
159
|
+
preservation_stats = replace_mode_preservation_stats(preserved_fragment_insertions)
|
|
160
|
+
|
|
161
|
+
[
|
|
162
|
+
render_template_with_preserved_insertions(template_analysis, preserved_fragment_insertions),
|
|
163
|
+
preservation_stats
|
|
164
|
+
]
|
|
165
|
+
end
|
|
166
|
+
|
|
129
167
|
# Validate the backend parameter.
|
|
130
168
|
#
|
|
131
169
|
# @param backend [Symbol] The backend to validate
|
|
132
170
|
# @raise [ArgumentError] If backend is not supported
|
|
133
171
|
def validate_backend!(backend)
|
|
134
|
-
valid_backends = [
|
|
172
|
+
valid_backends = %i[auto markly commonmarker kramdown]
|
|
135
173
|
return if valid_backends.include?(backend.to_sym)
|
|
136
174
|
|
|
137
|
-
raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(
|
|
175
|
+
raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(', ')}"
|
|
138
176
|
end
|
|
139
177
|
|
|
140
178
|
# Create a FileAnalysis for the given content.
|
|
@@ -142,7 +180,7 @@ module Markdown
|
|
|
142
180
|
# @param content [String] The content to analyze
|
|
143
181
|
# @return [FileAnalysis] A FileAnalysis instance
|
|
144
182
|
def create_analysis(content)
|
|
145
|
-
|
|
183
|
+
self.class.file_analysis_class.new(content, backend: backend)
|
|
146
184
|
end
|
|
147
185
|
|
|
148
186
|
# Create a SmartMerger for merging the section.
|
|
@@ -155,7 +193,7 @@ module Markdown
|
|
|
155
193
|
options = {
|
|
156
194
|
preference: preference,
|
|
157
195
|
add_template_only_nodes: add_missing,
|
|
158
|
-
backend: backend
|
|
196
|
+
backend: backend
|
|
159
197
|
}
|
|
160
198
|
|
|
161
199
|
# Use custom signature generator if provided, otherwise use position-based
|
|
@@ -166,7 +204,7 @@ module Markdown
|
|
|
166
204
|
options[:node_typing] = node_typing if node_typing
|
|
167
205
|
options[:match_refiner] = match_refiner if match_refiner
|
|
168
206
|
|
|
169
|
-
|
|
207
|
+
self.class.smart_merger_class.new(template_content, destination_content, **options)
|
|
170
208
|
end
|
|
171
209
|
|
|
172
210
|
# Build a signature generator that uses type-based matching for tables.
|
|
@@ -199,10 +237,10 @@ module Markdown
|
|
|
199
237
|
# provide a custom signature_generator.
|
|
200
238
|
lambda do |node|
|
|
201
239
|
type_str = node.type.to_s
|
|
202
|
-
if type_str ==
|
|
240
|
+
if type_str == 'table'
|
|
203
241
|
# All tables within a section merge get the same signature.
|
|
204
242
|
# This ensures template table replaces destination table.
|
|
205
|
-
[
|
|
243
|
+
%i[table section_table]
|
|
206
244
|
else
|
|
207
245
|
# Return node for default signature computation
|
|
208
246
|
node
|
|
@@ -236,12 +274,12 @@ module Markdown
|
|
|
236
274
|
|
|
237
275
|
((anchor.index + 1)...statements.length).each do |idx|
|
|
238
276
|
stmt = statements[idx]
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
277
|
+
next unless heading_type?(stmt.type)
|
|
278
|
+
|
|
279
|
+
stmt_level = get_heading_level(stmt)
|
|
280
|
+
if stmt_level && anchor_level && stmt_level <= anchor_level
|
|
281
|
+
# Found next heading of same or higher level - section ends before it
|
|
282
|
+
return idx - 1
|
|
245
283
|
end
|
|
246
284
|
end
|
|
247
285
|
|
|
@@ -250,16 +288,12 @@ module Markdown
|
|
|
250
288
|
end
|
|
251
289
|
|
|
252
290
|
# For non-headings, use boundary if specified and found
|
|
253
|
-
if injection_point.boundary
|
|
254
|
-
return injection_point.boundary.index - 1
|
|
255
|
-
end
|
|
291
|
+
return injection_point.boundary.index - 1 if injection_point.boundary
|
|
256
292
|
|
|
257
293
|
# Otherwise, find next node of same type
|
|
258
294
|
((anchor.index + 1)...statements.length).each do |idx|
|
|
259
295
|
stmt = statements[idx]
|
|
260
|
-
if stmt.type == anchor_type
|
|
261
|
-
return idx - 1
|
|
262
|
-
end
|
|
296
|
+
return idx - 1 if stmt.type == anchor_type
|
|
263
297
|
end
|
|
264
298
|
|
|
265
299
|
# Section extends to end of document
|
|
@@ -274,12 +308,10 @@ module Markdown
|
|
|
274
308
|
# @param node [Object] The node to convert
|
|
275
309
|
# @param analysis [FileAnalysis, nil] The analysis object for source lookup
|
|
276
310
|
# @return [String] The source text
|
|
277
|
-
def
|
|
311
|
+
def node_to_source(node, analysis = nil)
|
|
278
312
|
# Unwrap if needed
|
|
279
313
|
inner = node
|
|
280
|
-
while inner.respond_to?(:inner_node) && inner.inner_node != inner
|
|
281
|
-
inner = inner.inner_node
|
|
282
|
-
end
|
|
314
|
+
inner = inner.inner_node while inner.respond_to?(:inner_node) && inner.inner_node != inner
|
|
283
315
|
|
|
284
316
|
# Prefer source-based extraction to preserve original formatting
|
|
285
317
|
# (link references, table padding, etc.)
|
|
@@ -302,18 +334,207 @@ module Markdown
|
|
|
302
334
|
elsif inner.respond_to?(:to_s)
|
|
303
335
|
inner.to_s
|
|
304
336
|
else
|
|
305
|
-
|
|
337
|
+
''
|
|
306
338
|
end
|
|
307
339
|
end
|
|
308
340
|
|
|
341
|
+
alias node_to_text node_to_source
|
|
342
|
+
|
|
309
343
|
private
|
|
310
344
|
|
|
345
|
+
def replace_mode_preservation_stats(insertions)
|
|
346
|
+
comment_count = 0
|
|
347
|
+
link_definition_count = 0
|
|
348
|
+
|
|
349
|
+
insertions.each_value do |fragments|
|
|
350
|
+
fragments.each do |fragment|
|
|
351
|
+
case fragment[:kind]
|
|
352
|
+
when :standalone_comment
|
|
353
|
+
comment_count += 1
|
|
354
|
+
when :link_definition
|
|
355
|
+
link_definition_count += 1
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
{
|
|
361
|
+
mode: :replace,
|
|
362
|
+
preserved_destination_comment_fragments: comment_count,
|
|
363
|
+
preserved_destination_link_definitions: link_definition_count
|
|
364
|
+
}.reject { |_key, value| value == 0 }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def preserved_destination_insertions(destination_analysis, template_analysis, source_remove_plan: nil,
|
|
368
|
+
destination_section_statements: nil, destination_section_analysis: nil)
|
|
369
|
+
insertions = Hash.new { |hash, key| hash[key] = [] }
|
|
370
|
+
remove_plan_owned_comment_region_keys = if source_remove_plan
|
|
371
|
+
rebase_preserved_comment_keys(
|
|
372
|
+
remove_plan_preserved_comment_keys(source_remove_plan),
|
|
373
|
+
line_offset: source_remove_plan.remove_start_line - 1
|
|
374
|
+
)
|
|
375
|
+
else
|
|
376
|
+
Set.new
|
|
377
|
+
end
|
|
378
|
+
template_has_standalone_comments = template_analysis.statements.any? do |statement|
|
|
379
|
+
standalone_comment_node?(statement, template_analysis)
|
|
380
|
+
end
|
|
381
|
+
template_link_definition_signatures = template_analysis.statements.each_with_object(Set.new) do |statement, signatures|
|
|
382
|
+
signatures << statement.signature if link_definition_node?(statement)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
preserve_comment_insertions_from_remove_plan(
|
|
386
|
+
insertions,
|
|
387
|
+
source_remove_plan,
|
|
388
|
+
destination_section_statements,
|
|
389
|
+
destination_section_analysis || destination_analysis,
|
|
390
|
+
template_has_standalone_comments: template_has_standalone_comments
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
structural_index = 0
|
|
394
|
+
pending_gap_count = 0
|
|
395
|
+
|
|
396
|
+
destination_analysis.statements.each do |statement|
|
|
397
|
+
if structural_preservation_statement?(statement, destination_analysis)
|
|
398
|
+
structural_index += 1
|
|
399
|
+
pending_gap_count = 0
|
|
400
|
+
elsif gap_line_node?(statement)
|
|
401
|
+
pending_gap_count += 1 if insertions[structural_index].any?
|
|
402
|
+
else
|
|
403
|
+
next if remove_plan_owns_comment_node?(
|
|
404
|
+
statement,
|
|
405
|
+
destination_analysis,
|
|
406
|
+
source_remove_plan,
|
|
407
|
+
preserved_comment_keys: remove_plan_owned_comment_region_keys
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
fragment = preserved_fragment_for_node(
|
|
411
|
+
statement,
|
|
412
|
+
destination_analysis,
|
|
413
|
+
template_has_standalone_comments: template_has_standalone_comments,
|
|
414
|
+
template_link_definition_signatures: template_link_definition_signatures
|
|
415
|
+
)
|
|
416
|
+
next unless fragment
|
|
417
|
+
|
|
418
|
+
append_preserved_fragment(insertions, structural_index, fragment, gap_count: pending_gap_count)
|
|
419
|
+
pending_gap_count = 0
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
insertions.reject { |_index, fragments| fragments.empty? }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def preserve_comment_insertions_from_remove_plan(insertions, source_remove_plan, destination_section_statements,
|
|
427
|
+
destination_section_analysis, template_has_standalone_comments:)
|
|
428
|
+
return if template_has_standalone_comments
|
|
429
|
+
return unless source_remove_plan && destination_section_statements && destination_section_analysis
|
|
430
|
+
|
|
431
|
+
structural_index_by_owner, final_structural_index = structural_index_lookup(destination_section_statements,
|
|
432
|
+
destination_section_analysis)
|
|
433
|
+
remove_plan_comment_insertion_specs(
|
|
434
|
+
source_remove_plan,
|
|
435
|
+
insertion_index_by_owner: structural_index_by_owner,
|
|
436
|
+
final_insertion_index: final_structural_index
|
|
437
|
+
).each do |spec|
|
|
438
|
+
append_preserved_fragment(
|
|
439
|
+
insertions,
|
|
440
|
+
spec.fetch(:insertion_index),
|
|
441
|
+
spec.fetch(:fragment),
|
|
442
|
+
gap_count: spec.fetch(:gap_count)
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def structural_index_lookup(statements, analysis)
|
|
448
|
+
structural_index = 0
|
|
449
|
+
lookup = {}
|
|
450
|
+
|
|
451
|
+
Array(statements).each do |statement|
|
|
452
|
+
owner = statement.respond_to?(:node) ? statement.node : statement
|
|
453
|
+
lookup[attachment_owner_key(owner)] = structural_index
|
|
454
|
+
|
|
455
|
+
next unless structural_preservation_statement?(statement, analysis)
|
|
456
|
+
|
|
457
|
+
structural_index += 1
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
[lookup, structural_index]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def append_preserved_fragment(insertions, structural_index, fragment, gap_count: 0)
|
|
464
|
+
if insertions[structural_index].any?
|
|
465
|
+
fragment[:separator] = preserved_fragment_separator(
|
|
466
|
+
gap_count: gap_count,
|
|
467
|
+
previous_kind: insertions[structural_index].last.fetch(:kind),
|
|
468
|
+
current_kind: fragment.fetch(:kind)
|
|
469
|
+
)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
insertions[structural_index] << fragment
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def render_template_with_preserved_insertions(template_analysis, insertions)
|
|
476
|
+
result = +''
|
|
477
|
+
structural_index = 0
|
|
478
|
+
statements = template_analysis.statements
|
|
479
|
+
index = 0
|
|
480
|
+
|
|
481
|
+
while index < statements.length
|
|
482
|
+
statement = statements[index]
|
|
483
|
+
|
|
484
|
+
if gap_line_node?(statement) && insertions.key?(structural_index) && next_structural_statement_after?(
|
|
485
|
+
statements, index, template_analysis
|
|
486
|
+
)
|
|
487
|
+
index += 1 while index < statements.length && gap_line_node?(statements[index])
|
|
488
|
+
result = append_preserved_fragments(result, insertions.delete(structural_index))
|
|
489
|
+
next
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
result << node_to_source(statement, template_analysis)
|
|
493
|
+
structural_index += 1 if structural_preservation_statement?(statement, template_analysis)
|
|
494
|
+
index += 1
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
if insertions.key?(structural_index)
|
|
498
|
+
result = append_preserved_fragments(result, insertions.delete(structural_index))
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
result
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def append_preserved_fragments(content, fragments)
|
|
505
|
+
result = content.sub(/\n+\z/, "\n")
|
|
506
|
+
|
|
507
|
+
unless result.empty?
|
|
508
|
+
if result.end_with?("\n")
|
|
509
|
+
result << "\n" unless result.end_with?("\n\n")
|
|
510
|
+
else
|
|
511
|
+
result << "\n\n"
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
result << render_preserved_fragment_block(fragments)
|
|
516
|
+
result << "\n"
|
|
517
|
+
result << "\n" unless result.end_with?("\n\n")
|
|
518
|
+
result
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def render_preserved_fragment_block(fragments)
|
|
522
|
+
fragments.each_with_object(+'').with_index do |(fragment, result), index|
|
|
523
|
+
result << fragment[:separator] if index.positive?
|
|
524
|
+
result << fragment.fetch(:text)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def next_structural_statement_after?(statements, start_index, analysis)
|
|
529
|
+
statements[(start_index + 1)..]&.any? { |statement| structural_preservation_statement?(statement, analysis) }
|
|
530
|
+
end
|
|
531
|
+
|
|
311
532
|
# Check if a type represents a heading node.
|
|
312
533
|
#
|
|
313
534
|
# @param type [Symbol, String] The node type
|
|
314
535
|
# @return [Boolean] true if this is a heading type
|
|
315
536
|
def heading_type?(type)
|
|
316
|
-
type.to_s ==
|
|
537
|
+
type.to_s == 'heading' || type == :heading || type == :header
|
|
317
538
|
end
|
|
318
539
|
|
|
319
540
|
# Get the heading level from a statement.
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Shared Markdown-local helper methods for statement classification,
|
|
6
|
+
# standalone comment fragment preservation, and remove-plan-backed range filtering.
|
|
7
|
+
#
|
|
8
|
+
# Provides the canonical gap-line / blank-gap-line / non-blank-gap-line /
|
|
9
|
+
# structural-for-preservation predicate set used by both SmartMergerBase
|
|
10
|
+
# (removal mode) and PartialTemplateMerger (replace-mode insertion indexing).
|
|
11
|
+
# Both classes should call these predicates rather than performing direct
|
|
12
|
+
# is_a?(GapLineNode) checks.
|
|
13
|
+
module PreservationSupport
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def normalized_preserved_fragment_text(text)
|
|
17
|
+
text.to_s.sub(/\n+\z/, '')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def standalone_comment_text?(text)
|
|
21
|
+
normalized_preserved_fragment_text(text).strip.match?(CommentTracker::STANDALONE_HTML_COMMENT_REGEX)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def standalone_comment_node?(node, analysis)
|
|
25
|
+
return false unless node.respond_to?(:source_position)
|
|
26
|
+
return false unless analysis.respond_to?(:comment_tracker)
|
|
27
|
+
return false unless analysis.respond_to?(:comment_node_at)
|
|
28
|
+
return false unless analysis.comment_tracker
|
|
29
|
+
|
|
30
|
+
pos = node.source_position
|
|
31
|
+
start_line = pos&.dig(:start_line)
|
|
32
|
+
end_line = pos&.dig(:end_line)
|
|
33
|
+
return false unless start_line && end_line
|
|
34
|
+
return false unless start_line == end_line
|
|
35
|
+
|
|
36
|
+
!!analysis.comment_node_at(start_line)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def link_definition_node?(node)
|
|
40
|
+
node.is_a?(LinkDefinitionNode) || (node.respond_to?(:merge_type) && node.merge_type == :link_definition)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def gap_line_node?(node)
|
|
44
|
+
node.is_a?(GapLineNode) ||
|
|
45
|
+
(node.respond_to?(:merge_type) && node.merge_type == :gap_line) ||
|
|
46
|
+
(node.respond_to?(:type) && node.type == :gap_line)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def blank_gap_line_node?(node)
|
|
50
|
+
return false unless gap_line_node?(node)
|
|
51
|
+
return node.blank? if node.respond_to?(:blank?)
|
|
52
|
+
|
|
53
|
+
text = if node.respond_to?(:text)
|
|
54
|
+
node.text
|
|
55
|
+
elsif node.respond_to?(:content)
|
|
56
|
+
node.content
|
|
57
|
+
else
|
|
58
|
+
''
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
text.to_s.strip.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns true when the node is a gap line whose content is non-blank
|
|
65
|
+
# (e.g. a consumed link-reference definition or similar inline content
|
|
66
|
+
# that was mapped to a GapLineNode).
|
|
67
|
+
#
|
|
68
|
+
# This is the canonical replacement for `node.is_a?(GapLineNode) && !node.blank?`
|
|
69
|
+
# and handles wrapped gap-line statement nodes uniformly alongside real
|
|
70
|
+
# GapLineNode instances.
|
|
71
|
+
#
|
|
72
|
+
# @param node [Object] The node to classify
|
|
73
|
+
# @return [Boolean] true when the node is a gap line with non-blank content
|
|
74
|
+
def non_blank_gap_line_node?(node)
|
|
75
|
+
gap_line_node?(node) && !blank_gap_line_node?(node)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def structural_preservation_statement?(statement, analysis)
|
|
79
|
+
!gap_line_node?(statement) &&
|
|
80
|
+
!standalone_comment_node?(statement, analysis) &&
|
|
81
|
+
!link_definition_node?(statement)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def standalone_comment_region?(region)
|
|
85
|
+
standalone_comment_text?(region.respond_to?(:text) ? region.text : nil)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def preserved_comment_region_key(region)
|
|
89
|
+
[
|
|
90
|
+
region.respond_to?(:start_line) ? region.start_line : nil,
|
|
91
|
+
region.respond_to?(:end_line) ? region.end_line : nil,
|
|
92
|
+
normalized_preserved_fragment_text(region.respond_to?(:text) ? region.text : nil)
|
|
93
|
+
]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def preserved_comment_node_key(node, analysis, text: nil)
|
|
97
|
+
pos = node.respond_to?(:source_position) ? node.source_position : nil
|
|
98
|
+
|
|
99
|
+
[
|
|
100
|
+
pos&.dig(:start_line),
|
|
101
|
+
pos&.dig(:end_line),
|
|
102
|
+
normalized_preserved_fragment_text(text || source_text_for_preserved_node(node, analysis))
|
|
103
|
+
]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def region_within_removed_range?(region, remove_plan)
|
|
107
|
+
return false unless region
|
|
108
|
+
|
|
109
|
+
start_line = region.respond_to?(:start_line) ? region.start_line : nil
|
|
110
|
+
end_line = region.respond_to?(:end_line) ? region.end_line : nil
|
|
111
|
+
return true unless start_line && end_line
|
|
112
|
+
|
|
113
|
+
start_line >= remove_plan.remove_start_line && end_line <= remove_plan.remove_end_line
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def remove_plan_preserved_comment_regions(remove_plan)
|
|
117
|
+
return [] unless remove_plan
|
|
118
|
+
|
|
119
|
+
regions = Array(remove_plan.promoted_comment_regions)
|
|
120
|
+
regions << remove_plan.trailing_boundary&.comment_attachment&.leading_region
|
|
121
|
+
|
|
122
|
+
seen = Set.new
|
|
123
|
+
regions.each_with_object([]) do |region, preserved_regions|
|
|
124
|
+
next unless standalone_comment_region?(region)
|
|
125
|
+
next unless region_within_removed_range?(region, remove_plan)
|
|
126
|
+
|
|
127
|
+
region_key = preserved_comment_region_key(region)
|
|
128
|
+
next if seen.include?(region_key)
|
|
129
|
+
|
|
130
|
+
seen << region_key
|
|
131
|
+
preserved_regions << region
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def remove_plan_preserved_comment_keys(remove_plan)
|
|
136
|
+
remove_plan_preserved_comment_regions(remove_plan).each_with_object(Set.new) do |region, keys|
|
|
137
|
+
keys << preserved_comment_region_key(region)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def rebase_preserved_comment_keys(keys, line_offset:)
|
|
142
|
+
Array(keys).each_with_object(Set.new) do |key, rebased_keys|
|
|
143
|
+
start_line, end_line, text = Array(key)
|
|
144
|
+
rebased_keys << [
|
|
145
|
+
start_line ? start_line - line_offset : nil,
|
|
146
|
+
end_line ? end_line - line_offset : nil,
|
|
147
|
+
text
|
|
148
|
+
]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def remove_plan_owns_comment_node?(node, analysis, remove_plan, preserved_comment_keys: nil)
|
|
153
|
+
return false unless remove_plan
|
|
154
|
+
return false unless standalone_comment_node?(node, analysis)
|
|
155
|
+
|
|
156
|
+
keys = preserved_comment_keys || remove_plan_preserved_comment_keys(remove_plan)
|
|
157
|
+
region = comment_region_for_node(node, analysis, kind: :orphan)
|
|
158
|
+
return false unless region_within_removed_range?(region, remove_plan)
|
|
159
|
+
|
|
160
|
+
keys.include?(preserved_comment_region_key(region))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def remove_plan_preserved_comment_keys_for_nodes(remove_plan, nodes:, analysis:)
|
|
164
|
+
keys = remove_plan_preserved_comment_keys(remove_plan)
|
|
165
|
+
|
|
166
|
+
Array(nodes).each do |node|
|
|
167
|
+
next unless standalone_comment_node?(node, analysis)
|
|
168
|
+
|
|
169
|
+
region = comment_region_for_node(node, analysis, kind: :orphan)
|
|
170
|
+
next unless region_within_removed_range?(region, remove_plan)
|
|
171
|
+
|
|
172
|
+
keys << preserved_comment_node_key(node, analysis)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
keys
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def remove_plan_comment_insertion_specs(remove_plan, insertion_index_by_owner:, final_insertion_index:)
|
|
179
|
+
return [] unless remove_plan
|
|
180
|
+
|
|
181
|
+
allowed_region_keys = remove_plan_preserved_comment_keys(remove_plan)
|
|
182
|
+
seen_region_keys = Set.new
|
|
183
|
+
|
|
184
|
+
Array(remove_plan.removed_attachments).each_with_object([]) do |attachment, specs|
|
|
185
|
+
append_remove_plan_comment_insertion_spec(
|
|
186
|
+
specs,
|
|
187
|
+
region: attachment.respond_to?(:leading_region) ? attachment.leading_region : nil,
|
|
188
|
+
insertion_index: insertion_index_by_owner[attachment_owner_key(attachment)],
|
|
189
|
+
gap_count: blank_gap_count(attachment.respond_to?(:leading_gap) ? attachment.leading_gap : nil),
|
|
190
|
+
allowed_region_keys: allowed_region_keys,
|
|
191
|
+
seen_region_keys: seen_region_keys
|
|
192
|
+
)
|
|
193
|
+
end.tap do |specs|
|
|
194
|
+
append_remove_plan_comment_insertion_spec(
|
|
195
|
+
specs,
|
|
196
|
+
region: remove_plan.trailing_boundary&.comment_attachment&.leading_region,
|
|
197
|
+
insertion_index: final_insertion_index,
|
|
198
|
+
gap_count: blank_gap_count(remove_plan.trailing_boundary&.comment_attachment&.leading_gap),
|
|
199
|
+
allowed_region_keys: allowed_region_keys,
|
|
200
|
+
seen_region_keys: seen_region_keys
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def comment_region_for_node(node, analysis, kind: :orphan, full_line_only: true)
|
|
206
|
+
return unless node.respond_to?(:source_position)
|
|
207
|
+
return unless analysis.respond_to?(:comment_region_for_range)
|
|
208
|
+
|
|
209
|
+
pos = node.source_position
|
|
210
|
+
start_line = pos&.dig(:start_line)
|
|
211
|
+
end_line = pos&.dig(:end_line)
|
|
212
|
+
return unless start_line && end_line
|
|
213
|
+
|
|
214
|
+
analysis.comment_region_for_range(start_line..end_line, kind: kind, full_line_only: full_line_only)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def preserved_fragment_for_node(
|
|
218
|
+
node,
|
|
219
|
+
analysis,
|
|
220
|
+
template_has_standalone_comments:,
|
|
221
|
+
template_link_definition_signatures:
|
|
222
|
+
)
|
|
223
|
+
if standalone_comment_node?(node, analysis)
|
|
224
|
+
return if template_has_standalone_comments
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
kind: :standalone_comment,
|
|
228
|
+
text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
|
|
229
|
+
}
|
|
230
|
+
elsif link_definition_node?(node)
|
|
231
|
+
return if template_link_definition_signatures.include?(node.signature)
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
kind: :link_definition,
|
|
235
|
+
text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def preserved_fragment_separator(gap_count:, previous_kind:, current_kind:)
|
|
241
|
+
return "\n\n" if gap_count.positive?
|
|
242
|
+
return "\n" if previous_kind == :link_definition && current_kind == :link_definition
|
|
243
|
+
|
|
244
|
+
"\n\n"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def blank_gap_count(gap)
|
|
248
|
+
return 0 unless gap&.respond_to?(:blank_line_count)
|
|
249
|
+
|
|
250
|
+
gap.blank_line_count
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def attachment_owner_key(owner_or_attachment)
|
|
254
|
+
owner = if owner_or_attachment.respond_to?(:owner)
|
|
255
|
+
owner_or_attachment.owner
|
|
256
|
+
else
|
|
257
|
+
owner_or_attachment
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
owner&.object_id
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def source_text_for_preserved_node(node, analysis)
|
|
264
|
+
if respond_to?(:node_to_source, true)
|
|
265
|
+
node_to_source(node, analysis)
|
|
266
|
+
else
|
|
267
|
+
''
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def append_remove_plan_comment_insertion_spec(specs, region:, insertion_index:, gap_count:, allowed_region_keys:,
|
|
272
|
+
seen_region_keys:)
|
|
273
|
+
return unless region && insertion_index
|
|
274
|
+
|
|
275
|
+
region_key = preserved_comment_region_key(region)
|
|
276
|
+
return unless allowed_region_keys.include?(region_key)
|
|
277
|
+
return if seen_region_keys.include?(region_key)
|
|
278
|
+
|
|
279
|
+
seen_region_keys << region_key
|
|
280
|
+
specs << {
|
|
281
|
+
insertion_index: insertion_index,
|
|
282
|
+
fragment: {
|
|
283
|
+
kind: :standalone_comment,
|
|
284
|
+
text: normalized_preserved_fragment_text(region.text)
|
|
285
|
+
},
|
|
286
|
+
gap_count: gap_count
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|