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
|
@@ -41,6 +41,8 @@ module Markdown
|
|
|
41
41
|
# @see ConflictResolver
|
|
42
42
|
# @see MergeResult
|
|
43
43
|
class SmartMergerBase
|
|
44
|
+
include PreservationSupport
|
|
45
|
+
|
|
44
46
|
# @return [FileAnalysisBase] Analysis of the template file
|
|
45
47
|
attr_reader :template_analysis
|
|
46
48
|
|
|
@@ -59,6 +61,10 @@ module Markdown
|
|
|
59
61
|
# @return [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
60
62
|
attr_reader :node_typing
|
|
61
63
|
|
|
64
|
+
# @return [Ast::Merge::Runtime::Session, nil] Runtime session for this merge
|
|
65
|
+
attr_reader :runtime_session
|
|
66
|
+
attr_reader :corruption_handling, :resolution_mode, :unresolved_policy
|
|
67
|
+
|
|
62
68
|
# Creates a new SmartMerger for intelligent Markdown file merging.
|
|
63
69
|
#
|
|
64
70
|
# @param template_content [String] Template Markdown source code
|
|
@@ -94,6 +100,13 @@ module Markdown
|
|
|
94
100
|
# - `false` (default) - Disable inner-merge (use standard conflict resolution)
|
|
95
101
|
# - `CodeBlockMerger` instance - Use custom CodeBlockMerger
|
|
96
102
|
#
|
|
103
|
+
# @param remove_template_missing_nodes [Boolean] Controls whether to remove nodes that only
|
|
104
|
+
# exist in destination when not present in template:
|
|
105
|
+
# - `false` (default) - Preserve destination-only nodes
|
|
106
|
+
# - `true` - Remove destination-only structural nodes while still preserving
|
|
107
|
+
# freeze blocks, standalone HTML comment-only fragments, and parser-consumed
|
|
108
|
+
# non-structural content such as link reference definitions
|
|
109
|
+
#
|
|
97
110
|
# @param freeze_token [String] Token to use for freeze block markers.
|
|
98
111
|
# Default: "markdown-merge"
|
|
99
112
|
#
|
|
@@ -139,34 +152,60 @@ module Markdown
|
|
|
139
152
|
preference: :destination,
|
|
140
153
|
add_template_only_nodes: false,
|
|
141
154
|
inner_merge_code_blocks: false,
|
|
155
|
+
inner_merge_lists: false,
|
|
156
|
+
remove_template_missing_nodes: false,
|
|
157
|
+
corruption_handling: :heal,
|
|
142
158
|
freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN,
|
|
143
159
|
match_refiner: nil,
|
|
144
160
|
node_typing: nil,
|
|
161
|
+
resolution_mode: :eager,
|
|
162
|
+
unresolved_policy: nil,
|
|
145
163
|
normalize_whitespace: false,
|
|
146
164
|
rehydrate_link_references: false,
|
|
147
165
|
**parser_options
|
|
148
166
|
)
|
|
149
167
|
@preference = preference
|
|
150
168
|
@add_template_only_nodes = add_template_only_nodes
|
|
151
|
-
@
|
|
169
|
+
@remove_template_missing_nodes = remove_template_missing_nodes
|
|
170
|
+
@corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
|
|
171
|
+
@match_refiner = match_refiner || default_match_refiner(
|
|
172
|
+
inner_merge_lists: inner_merge_lists,
|
|
173
|
+
inner_merge_code_blocks: inner_merge_code_blocks
|
|
174
|
+
)
|
|
152
175
|
@node_typing = node_typing
|
|
176
|
+
@resolution_mode = resolution_mode
|
|
177
|
+
@unresolved_policy = Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
|
|
153
178
|
@normalize_whitespace = normalize_whitespace
|
|
154
179
|
@rehydrate_link_references = rehydrate_link_references
|
|
155
180
|
|
|
156
181
|
# Validate node_typing if provided
|
|
157
182
|
Ast::Merge::NodeTyping.validate!(node_typing) if node_typing
|
|
183
|
+
validate_resolution_mode!(resolution_mode)
|
|
158
184
|
|
|
159
185
|
# Set up code block merger
|
|
160
186
|
@code_block_merger = case inner_merge_code_blocks
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
when true
|
|
188
|
+
CodeBlockMerger.new
|
|
189
|
+
when false
|
|
190
|
+
nil
|
|
191
|
+
when CodeBlockMerger
|
|
192
|
+
inner_merge_code_blocks
|
|
193
|
+
else
|
|
194
|
+
raise ArgumentError,
|
|
195
|
+
'inner_merge_code_blocks must be true, false, or a CodeBlockMerger instance'
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Set up list merger
|
|
199
|
+
@list_merger = case inner_merge_lists
|
|
200
|
+
when true
|
|
201
|
+
ListMerger.new
|
|
202
|
+
when false
|
|
203
|
+
nil
|
|
204
|
+
when ListMerger
|
|
205
|
+
inner_merge_lists
|
|
206
|
+
else
|
|
207
|
+
raise ArgumentError, 'inner_merge_lists must be true, false, or a ListMerger instance'
|
|
208
|
+
end
|
|
170
209
|
|
|
171
210
|
# Parse template
|
|
172
211
|
begin
|
|
@@ -174,7 +213,7 @@ module Markdown
|
|
|
174
213
|
template_content,
|
|
175
214
|
freeze_token: freeze_token,
|
|
176
215
|
signature_generator: signature_generator,
|
|
177
|
-
**parser_options
|
|
216
|
+
**parser_options
|
|
178
217
|
)
|
|
179
218
|
rescue StandardError => e
|
|
180
219
|
raise template_parse_error_class.new(errors: [e])
|
|
@@ -186,7 +225,7 @@ module Markdown
|
|
|
186
225
|
dest_content,
|
|
187
226
|
freeze_token: freeze_token,
|
|
188
227
|
signature_generator: signature_generator,
|
|
189
|
-
**parser_options
|
|
228
|
+
**parser_options
|
|
190
229
|
)
|
|
191
230
|
rescue StandardError => e
|
|
192
231
|
raise destination_parse_error_class.new(errors: [e])
|
|
@@ -197,7 +236,21 @@ module Markdown
|
|
|
197
236
|
preference: @preference,
|
|
198
237
|
template_analysis: @template_analysis,
|
|
199
238
|
dest_analysis: @dest_analysis,
|
|
239
|
+
resolution_mode: @resolution_mode,
|
|
240
|
+
unresolved_policy: @unresolved_policy
|
|
200
241
|
)
|
|
242
|
+
@runtime_session = nil
|
|
243
|
+
@runtime_root_operation = nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def default_match_refiner(inner_merge_lists:, inner_merge_code_blocks:)
|
|
247
|
+
refiners = []
|
|
248
|
+
refiners << ListMatchRefiner.new if inner_merge_lists
|
|
249
|
+
refiners << CodeBlockMatchRefiner.new if inner_merge_code_blocks
|
|
250
|
+
return if refiners.empty?
|
|
251
|
+
return refiners.first if refiners.length == 1
|
|
252
|
+
|
|
253
|
+
Ast::Merge::CompositeMatchRefiner.new(*refiners)
|
|
201
254
|
end
|
|
202
255
|
|
|
203
256
|
# Create a FileAnalysis instance for the given content.
|
|
@@ -241,43 +294,83 @@ module Markdown
|
|
|
241
294
|
def merge_result
|
|
242
295
|
return @merge_result if @merge_result
|
|
243
296
|
|
|
244
|
-
@merge_result = DebugLogger.time(
|
|
245
|
-
|
|
297
|
+
@merge_result = DebugLogger.time('SmartMergerBase#merge') do
|
|
298
|
+
prepare_runtime_session!
|
|
299
|
+
alignment = DebugLogger.time('SmartMergerBase#align') do
|
|
246
300
|
@aligner.align
|
|
247
301
|
end
|
|
248
302
|
|
|
249
|
-
DebugLogger.debug(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
303
|
+
DebugLogger.debug('Alignment complete', {
|
|
304
|
+
total_entries: alignment.size,
|
|
305
|
+
matches: alignment.count { |e| e[:type] == :match },
|
|
306
|
+
template_only: alignment.count { |e| e[:type] == :template_only },
|
|
307
|
+
dest_only: alignment.count { |e| e[:type] == :dest_only }
|
|
308
|
+
})
|
|
255
309
|
|
|
256
310
|
# Process alignment using OutputBuilder
|
|
257
|
-
builder, stats, frozen_blocks, conflicts = DebugLogger.time(
|
|
311
|
+
builder, stats, frozen_blocks, conflicts, unresolved_cases = DebugLogger.time('SmartMergerBase#process') do
|
|
258
312
|
process_alignment(alignment)
|
|
259
313
|
end
|
|
260
314
|
|
|
261
315
|
# Get content from OutputBuilder
|
|
262
|
-
|
|
316
|
+
raw_content = builder.to_s
|
|
317
|
+
content = raw_content
|
|
263
318
|
|
|
264
319
|
# Collect problems from post-processing
|
|
265
320
|
problems = DocumentProblems.new
|
|
266
321
|
|
|
267
322
|
# Apply post-processing transformations
|
|
268
323
|
content, problems = apply_post_processing(content, problems)
|
|
324
|
+
complete_runtime_session!(content: content, stats: stats, problems: problems,
|
|
325
|
+
unresolved_cases: unresolved_cases)
|
|
269
326
|
|
|
270
327
|
# Get final content from OutputBuilder
|
|
271
328
|
MergeResult.new(
|
|
272
329
|
content: content,
|
|
330
|
+
raw_content: raw_content,
|
|
273
331
|
conflicts: conflicts,
|
|
274
332
|
frozen_blocks: frozen_blocks,
|
|
275
333
|
stats: stats,
|
|
276
334
|
problems: problems,
|
|
335
|
+
unresolved_cases: unresolved_cases
|
|
277
336
|
)
|
|
278
337
|
end
|
|
279
338
|
end
|
|
280
339
|
|
|
340
|
+
# Perform the merge and return a hash with content, debug info, and runtime data.
|
|
341
|
+
#
|
|
342
|
+
# @return [Hash] Hash with :content, :debug, :runtime, and :statistics keys
|
|
343
|
+
def merge_with_debug
|
|
344
|
+
result = merge_result
|
|
345
|
+
template_analysis_debug = {
|
|
346
|
+
valid: @template_analysis&.valid? || false,
|
|
347
|
+
statements: @template_analysis&.statements&.size || 0
|
|
348
|
+
}
|
|
349
|
+
dest_analysis_debug = {
|
|
350
|
+
valid: @dest_analysis&.valid? || false,
|
|
351
|
+
statements: @dest_analysis&.statements&.size || 0
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
{
|
|
355
|
+
content: result.content,
|
|
356
|
+
debug: {
|
|
357
|
+
template_statements: template_analysis_debug[:statements],
|
|
358
|
+
dest_statements: dest_analysis_debug[:statements],
|
|
359
|
+
preference: @preference,
|
|
360
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
361
|
+
remove_template_missing_nodes: @remove_template_missing_nodes,
|
|
362
|
+
corruption_handling: @corruption_handling,
|
|
363
|
+
runtime_operation_count: runtime_session&.operations&.size || 0,
|
|
364
|
+
runtime_diagnostic_count: runtime_session&.diagnostics&.size || 0
|
|
365
|
+
},
|
|
366
|
+
runtime: runtime_session&.to_h,
|
|
367
|
+
statistics: result.stats,
|
|
368
|
+
decisions: result.stats,
|
|
369
|
+
template_analysis: template_analysis_debug,
|
|
370
|
+
dest_analysis: dest_analysis_debug
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
281
374
|
# Get merge statistics (convenience method).
|
|
282
375
|
#
|
|
283
376
|
# @return [Hash] Statistics from the merge result
|
|
@@ -287,16 +380,159 @@ module Markdown
|
|
|
287
380
|
|
|
288
381
|
private
|
|
289
382
|
|
|
383
|
+
def prepare_runtime_session!
|
|
384
|
+
root_surface = Ast::Merge::Runtime::Surface.new(
|
|
385
|
+
surface_kind: :markdown_document,
|
|
386
|
+
declared_language: :markdown,
|
|
387
|
+
effective_language: :markdown,
|
|
388
|
+
address: 'document[0]',
|
|
389
|
+
reconstruction_strategy: :portable_write,
|
|
390
|
+
metadata: {
|
|
391
|
+
backend: @template_analysis.respond_to?(:backend) ? @template_analysis.backend : nil
|
|
392
|
+
}.compact
|
|
393
|
+
)
|
|
394
|
+
registry = Ast::Merge::Runtime::DelegationRegistry.new(
|
|
395
|
+
delegates: runtime_delegates,
|
|
396
|
+
metadata: {
|
|
397
|
+
source: :markdown_merge
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
@runtime_session = Ast::Merge::Runtime::Session.new(
|
|
402
|
+
policy_context: runtime_policy_context,
|
|
403
|
+
metadata: runtime_metadata,
|
|
404
|
+
delegation_registry: registry
|
|
405
|
+
)
|
|
406
|
+
@runtime_root_operation = Ast::Merge::Runtime::Operation.new(
|
|
407
|
+
operation_id: 'markdown-document-root',
|
|
408
|
+
surface: root_surface,
|
|
409
|
+
template_fragment: @template_analysis.source.to_s,
|
|
410
|
+
destination_fragment: @dest_analysis.source.to_s,
|
|
411
|
+
requested_strategy: :merge_document,
|
|
412
|
+
options: {
|
|
413
|
+
inner_merge_code_blocks: !@code_block_merger.nil?,
|
|
414
|
+
inner_merge_lists: !@list_merger.nil?,
|
|
415
|
+
corruption_handling: @corruption_handling,
|
|
416
|
+
resolution_mode: @resolution_mode,
|
|
417
|
+
unresolved_policy: @unresolved_policy.to_h
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
@runtime_session.register(
|
|
421
|
+
@runtime_root_operation,
|
|
422
|
+
frame: Ast::Merge::Runtime::Frame.new(
|
|
423
|
+
operation_id: @runtime_root_operation.operation_id,
|
|
424
|
+
depth: 0,
|
|
425
|
+
surface_path: root_surface.address,
|
|
426
|
+
language_chain: [:markdown]
|
|
427
|
+
),
|
|
428
|
+
delegate: @runtime_session.resolve_delegate_for(root_surface)
|
|
429
|
+
)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def complete_runtime_session!(content:, stats:, problems:, unresolved_cases: [])
|
|
433
|
+
return unless @runtime_root_operation
|
|
434
|
+
|
|
435
|
+
delegated_children = @runtime_root_operation.children.select do |operation|
|
|
436
|
+
operation.surface.surface_kind == :markdown_fenced_code_block
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
child_result = Ast::Merge::Runtime::ChildResult.new(
|
|
440
|
+
replacement_text: content,
|
|
441
|
+
diagnostics: @runtime_root_operation.diagnostics,
|
|
442
|
+
capabilities_used: runtime_capabilities_used_for(delegated_children),
|
|
443
|
+
capabilities_missing: runtime_capabilities_missing_for(delegated_children),
|
|
444
|
+
unresolved_cases: unresolved_cases,
|
|
445
|
+
metadata: {
|
|
446
|
+
child_operation_ids: @runtime_root_operation.children.map(&:operation_id),
|
|
447
|
+
stats: stats,
|
|
448
|
+
problems: problems.all
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if child_result.unresolved?
|
|
453
|
+
@runtime_root_operation.unresolved!(result: child_result)
|
|
454
|
+
else
|
|
455
|
+
@runtime_root_operation.complete!(result: child_result)
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def runtime_policy_context
|
|
460
|
+
{
|
|
461
|
+
preference: @preference,
|
|
462
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
463
|
+
remove_template_missing_nodes: @remove_template_missing_nodes,
|
|
464
|
+
corruption_handling: @corruption_handling,
|
|
465
|
+
resolution_mode: @resolution_mode,
|
|
466
|
+
unresolved_policy: @unresolved_policy.to_h
|
|
467
|
+
}
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def validate_resolution_mode!(resolution_mode)
|
|
471
|
+
return if Ast::Merge::MergerConfig::VALID_RESOLUTION_MODES.include?(resolution_mode)
|
|
472
|
+
|
|
473
|
+
raise ArgumentError,
|
|
474
|
+
"Invalid resolution_mode: #{resolution_mode.inspect}. " \
|
|
475
|
+
"Must be one of: #{Ast::Merge::MergerConfig::VALID_RESOLUTION_MODES.map(&:inspect).join(', ')}"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def runtime_metadata
|
|
479
|
+
{
|
|
480
|
+
merger_class: self.class.name,
|
|
481
|
+
inner_merge_code_blocks: !@code_block_merger.nil?,
|
|
482
|
+
inner_merge_lists: !@list_merger.nil?
|
|
483
|
+
}
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def runtime_delegates
|
|
487
|
+
[runtime_markdown_delegate, *@code_block_merger&.runtime_delegates.to_a]
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def runtime_markdown_delegate
|
|
491
|
+
Ast::Merge::Runtime::Delegate.new(
|
|
492
|
+
name: 'markdown-document',
|
|
493
|
+
priority: 10,
|
|
494
|
+
surface_kinds: [:markdown_document],
|
|
495
|
+
languages: [:markdown],
|
|
496
|
+
feature_profile: safe_runtime_feature_profile_for(@dest_analysis),
|
|
497
|
+
capabilities: { merge: [:markdown_document] },
|
|
498
|
+
metadata: {
|
|
499
|
+
source: :markdown_merge
|
|
500
|
+
}
|
|
501
|
+
)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def safe_runtime_feature_profile_for(analysis)
|
|
505
|
+
return unless analysis&.respond_to?(:feature_profile)
|
|
506
|
+
|
|
507
|
+
analysis.feature_profile
|
|
508
|
+
rescue StandardError
|
|
509
|
+
nil
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def runtime_capabilities_used_for(delegated_children)
|
|
513
|
+
capabilities = [:top_level_merge]
|
|
514
|
+
capabilities << :delegated_child_merge unless delegated_children.empty?
|
|
515
|
+
capabilities
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def runtime_capabilities_missing_for(delegated_children)
|
|
519
|
+
return [] unless delegated_children.any?(&:failed?)
|
|
520
|
+
|
|
521
|
+
[:delegated_child_merge]
|
|
522
|
+
end
|
|
523
|
+
|
|
290
524
|
# Apply post-processing transformations to merged content.
|
|
291
525
|
#
|
|
292
526
|
# @param content [String] The merged content
|
|
293
527
|
# @param problems [DocumentProblems] Problems collector to add to
|
|
294
528
|
# @return [Array<String, DocumentProblems>] [transformed_content, problems]
|
|
295
529
|
def apply_post_processing(content, problems)
|
|
530
|
+
content = collapse_cross_source_preamble_prefixes(content)
|
|
531
|
+
|
|
296
532
|
# Apply whitespace normalization if enabled
|
|
297
533
|
if @normalize_whitespace
|
|
298
534
|
# Support both boolean and symbol modes
|
|
299
|
-
mode =
|
|
535
|
+
mode = @normalize_whitespace == true ? :basic : @normalize_whitespace
|
|
300
536
|
normalizer = WhitespaceNormalizer.new(content, mode: mode)
|
|
301
537
|
content = normalizer.normalize
|
|
302
538
|
problems.merge!(normalizer.problems)
|
|
@@ -312,30 +548,103 @@ module Markdown
|
|
|
312
548
|
[content, problems]
|
|
313
549
|
end
|
|
314
550
|
|
|
551
|
+
STANDALONE_HTML_COMMENT_LINE_RE = /\A\s*<!--.*?-->\s*\z/
|
|
552
|
+
|
|
553
|
+
def collapse_cross_source_preamble_prefixes(content)
|
|
554
|
+
template_comments, = leading_standalone_comment_run(@template_analysis.source.to_s)
|
|
555
|
+
return content if template_comments.empty?
|
|
556
|
+
|
|
557
|
+
merged_comments, remainder = leading_standalone_comment_run(content)
|
|
558
|
+
return content if merged_comments.empty?
|
|
559
|
+
|
|
560
|
+
destination_specific_comments = merged_comments.reject { |line| template_comments.include?(line) }
|
|
561
|
+
return content if destination_specific_comments.empty?
|
|
562
|
+
|
|
563
|
+
should_heal = ::Ast::Merge::Healer.handle(
|
|
564
|
+
mode: @corruption_handling,
|
|
565
|
+
kind: :duplicate_template_preamble_prefix,
|
|
566
|
+
message: 'merged Markdown preamble begins with duplicated template-owned standalone comment lines',
|
|
567
|
+
prefix: '[markdown-merge]',
|
|
568
|
+
error_class: Markdown::Merge::CorruptionDetectedError,
|
|
569
|
+
warner: lambda { |formatted|
|
|
570
|
+
DebugLogger.debug_warning(formatted, {
|
|
571
|
+
template_comment_lines: template_comments.length,
|
|
572
|
+
merged_comment_lines: merged_comments.length,
|
|
573
|
+
destination_specific_comment_lines: destination_specific_comments.length
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
return content unless should_heal
|
|
578
|
+
|
|
579
|
+
remainder = remainder.sub(/\A(?:\s*\n)+/, '')
|
|
580
|
+
rebuilt = destination_specific_comments.join("\n")
|
|
581
|
+
return rebuilt if remainder.empty?
|
|
582
|
+
|
|
583
|
+
"#{rebuilt}\n\n#{remainder}"
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def leading_standalone_comment_run(text)
|
|
587
|
+
lines = text.to_s.split("\n", -1)
|
|
588
|
+
comment_lines = []
|
|
589
|
+
index = 0
|
|
590
|
+
|
|
591
|
+
while index < lines.length
|
|
592
|
+
line = lines[index]
|
|
593
|
+
if line.strip.empty?
|
|
594
|
+
comment_lines << line if comment_lines.any?
|
|
595
|
+
index += 1
|
|
596
|
+
next
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
break unless STANDALONE_HTML_COMMENT_LINE_RE.match?(line)
|
|
600
|
+
|
|
601
|
+
comment_lines << line
|
|
602
|
+
index += 1
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
normalized_comment_lines = comment_lines.reject(&:empty?)
|
|
606
|
+
remainder = lines[index..]&.join("\n").to_s
|
|
607
|
+
[normalized_comment_lines, remainder]
|
|
608
|
+
end
|
|
609
|
+
|
|
315
610
|
# Process alignment entries and build result using OutputBuilder
|
|
316
611
|
#
|
|
317
612
|
# @param alignment [Array<Hash>] Alignment entries
|
|
318
|
-
# @return [Array] [output_builder, stats, frozen_blocks, conflicts]
|
|
613
|
+
# @return [Array] [output_builder, stats, frozen_blocks, conflicts, unresolved_cases]
|
|
319
614
|
def process_alignment(alignment)
|
|
320
615
|
builder = OutputBuilder.new
|
|
321
616
|
frozen_blocks = []
|
|
322
617
|
conflicts = []
|
|
323
|
-
|
|
618
|
+
unresolved_cases = []
|
|
619
|
+
stats = { nodes_added: 0, nodes_removed: 0, nodes_modified: 0 }
|
|
620
|
+
preserve_removed_separator_gap = false
|
|
621
|
+
link_ownership_context = removal_mode_link_ownership_context(alignment) if @remove_template_missing_nodes
|
|
622
|
+
removal_comment_ownership = removal_mode_comment_ownership_context(alignment) if @remove_template_missing_nodes
|
|
324
623
|
|
|
325
|
-
alignment.
|
|
624
|
+
alignment.each_with_index do |entry, index|
|
|
326
625
|
case entry[:type]
|
|
327
626
|
when :match
|
|
328
|
-
|
|
627
|
+
preserve_removed_separator_gap = false
|
|
628
|
+
frozen = process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
|
|
329
629
|
frozen_blocks << frozen if frozen
|
|
330
630
|
when :template_only
|
|
631
|
+
preserve_removed_separator_gap = false
|
|
331
632
|
process_template_only_to_builder(entry, builder, stats)
|
|
332
633
|
when :dest_only
|
|
333
|
-
frozen = process_dest_only_to_builder(
|
|
634
|
+
frozen, preserve_removed_separator_gap = process_dest_only_to_builder(
|
|
635
|
+
entry,
|
|
636
|
+
builder,
|
|
637
|
+
stats,
|
|
638
|
+
preserve_separator_gap: preserve_removed_separator_gap,
|
|
639
|
+
remaining_entries: alignment[(index + 1)..] || [],
|
|
640
|
+
link_ownership_context: link_ownership_context,
|
|
641
|
+
removal_comment_ownership: removal_comment_ownership&.[](entry[:dest_index])
|
|
642
|
+
)
|
|
334
643
|
frozen_blocks << frozen if frozen
|
|
335
644
|
end
|
|
336
645
|
end
|
|
337
646
|
|
|
338
|
-
[builder, stats, frozen_blocks, conflicts]
|
|
647
|
+
[builder, stats, frozen_blocks, conflicts, unresolved_cases]
|
|
339
648
|
end
|
|
340
649
|
|
|
341
650
|
# Process a matched node pair, adding to OutputBuilder
|
|
@@ -344,13 +653,21 @@ module Markdown
|
|
|
344
653
|
# @param builder [OutputBuilder] Output builder to add to
|
|
345
654
|
# @param stats [Hash] Statistics hash to update
|
|
346
655
|
# @return [Hash, nil] Frozen block info if applicable
|
|
347
|
-
def process_match_to_builder(entry, builder, stats)
|
|
656
|
+
def process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
|
|
348
657
|
template_node = apply_node_typing(entry[:template_node])
|
|
349
658
|
dest_node = apply_node_typing(entry[:dest_node])
|
|
350
659
|
|
|
351
660
|
# Try inner-merge for code blocks first
|
|
352
661
|
if @code_block_merger && code_block_node?(template_node) && code_block_node?(dest_node)
|
|
353
|
-
inner_result = try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats
|
|
662
|
+
inner_result = try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats, conflicts,
|
|
663
|
+
unresolved_cases)
|
|
664
|
+
return if inner_result
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Try inner-merge for lists
|
|
668
|
+
if @list_merger && list_node?(template_node) && list_node?(dest_node)
|
|
669
|
+
inner_result = try_inner_merge_list_to_builder(template_node, dest_node, builder, stats, conflicts,
|
|
670
|
+
unresolved_cases)
|
|
354
671
|
return if inner_result
|
|
355
672
|
end
|
|
356
673
|
|
|
@@ -358,8 +675,9 @@ module Markdown
|
|
|
358
675
|
template_node,
|
|
359
676
|
dest_node,
|
|
360
677
|
template_index: entry[:template_index],
|
|
361
|
-
dest_index: entry[:dest_index]
|
|
678
|
+
dest_index: entry[:dest_index]
|
|
362
679
|
)
|
|
680
|
+
conflicts << resolution[:conflict] if resolution[:conflict]
|
|
363
681
|
|
|
364
682
|
frozen_info = nil
|
|
365
683
|
|
|
@@ -369,22 +687,101 @@ module Markdown
|
|
|
369
687
|
|
|
370
688
|
case resolution[:source]
|
|
371
689
|
when :template
|
|
690
|
+
preserved_link_definitions = preserved_destination_link_definitions_for_match(raw_template_node,
|
|
691
|
+
raw_dest_node)
|
|
692
|
+
preserved_link_definitions.each do |link_definition|
|
|
693
|
+
builder.add_node_source(link_definition, @dest_analysis)
|
|
694
|
+
end
|
|
695
|
+
unless preserved_link_definitions.empty?
|
|
696
|
+
stats[:preserved_destination_link_definitions] ||= 0
|
|
697
|
+
stats[:preserved_destination_link_definitions] += preserved_link_definitions.length
|
|
698
|
+
end
|
|
372
699
|
stats[:nodes_modified] += 1 if resolution[:decision] != :identical
|
|
373
|
-
builder.add_node_source(raw_template_node, @template_analysis)
|
|
700
|
+
emitted_range = builder.add_node_source(raw_template_node, @template_analysis)
|
|
374
701
|
when :destination
|
|
375
702
|
if raw_dest_node.respond_to?(:freeze_node?) && raw_dest_node.freeze_node?
|
|
376
703
|
frozen_info = {
|
|
377
704
|
start_line: raw_dest_node.start_line,
|
|
378
705
|
end_line: raw_dest_node.end_line,
|
|
379
|
-
reason: raw_dest_node.reason
|
|
706
|
+
reason: raw_dest_node.reason
|
|
380
707
|
}
|
|
381
708
|
end
|
|
382
|
-
builder.add_node_source(raw_dest_node, @dest_analysis)
|
|
709
|
+
emitted_range = builder.add_node_source(raw_dest_node, @dest_analysis)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
if resolution[:unresolved_case]
|
|
713
|
+
unresolved_cases << unresolved_case_with_output_range(resolution[:unresolved_case],
|
|
714
|
+
emitted_range)
|
|
383
715
|
end
|
|
384
716
|
|
|
385
717
|
frozen_info
|
|
386
718
|
end
|
|
387
719
|
|
|
720
|
+
def unresolved_case_with_output_range(unresolved_case, output_range)
|
|
721
|
+
return unresolved_case unless unresolved_case && output_range
|
|
722
|
+
|
|
723
|
+
metadata = unresolved_case.metadata.dup
|
|
724
|
+
relative_output_range = metadata.delete(:relative_output_range)
|
|
725
|
+
metadata[:output_range] =
|
|
726
|
+
if relative_output_range
|
|
727
|
+
output_range_from_relative(output_range, relative_output_range)
|
|
728
|
+
else
|
|
729
|
+
output_range
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
Ast::Merge::Runtime::ResolutionCase.new(
|
|
733
|
+
case_id: unresolved_case.case_id,
|
|
734
|
+
reason: unresolved_case.reason,
|
|
735
|
+
candidates: unresolved_case.candidates,
|
|
736
|
+
provisional_winner: unresolved_case.provisional_winner,
|
|
737
|
+
surface_path: unresolved_case.surface_path,
|
|
738
|
+
operation_id: unresolved_case.operation_id,
|
|
739
|
+
metadata: metadata
|
|
740
|
+
)
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def output_range_from_relative(parent_range, relative_range)
|
|
744
|
+
start_offset, = Array(parent_range)
|
|
745
|
+
relative_start, relative_end = Array(relative_range)
|
|
746
|
+
return parent_range unless [start_offset, relative_start, relative_end].all?
|
|
747
|
+
|
|
748
|
+
[start_offset + relative_start.to_i, start_offset + relative_end.to_i]
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def preserved_destination_link_definitions_for_match(template_node, dest_node)
|
|
752
|
+
destination_link_definitions = consumed_link_definitions_within(dest_node, @dest_analysis)
|
|
753
|
+
return [] if destination_link_definitions.empty?
|
|
754
|
+
|
|
755
|
+
template_signatures = consumed_link_definitions_within(template_node, @template_analysis)
|
|
756
|
+
.map(&:signature)
|
|
757
|
+
.to_set
|
|
758
|
+
|
|
759
|
+
destination_link_definitions.reject do |link_definition|
|
|
760
|
+
template_signatures.include?(link_definition.signature)
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def consumed_link_definitions_within(node, analysis)
|
|
765
|
+
return [] if skip_link_ownership_scanning_for_node?(node, analysis)
|
|
766
|
+
|
|
767
|
+
pos = node.source_position
|
|
768
|
+
start_line = pos&.dig(:start_line)
|
|
769
|
+
end_line = pos&.dig(:end_line)
|
|
770
|
+
return [] unless start_line && end_line
|
|
771
|
+
|
|
772
|
+
(start_line..end_line).filter_map do |line_number|
|
|
773
|
+
LinkDefinitionNode.parse(analysis.source_range(line_number, line_number), line_number: line_number)
|
|
774
|
+
end.then { |definitions| unique_link_definitions_by_signature(definitions) }
|
|
775
|
+
rescue StandardError => e
|
|
776
|
+
return [] if missing_source_position_protocol_error?(e)
|
|
777
|
+
|
|
778
|
+
raise
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def missing_source_position_protocol_error?(error)
|
|
782
|
+
error.is_a?(NoMethodError) || error.class.name == 'RSpec::Mocks::MockExpectationError'
|
|
783
|
+
end
|
|
784
|
+
|
|
388
785
|
# Apply node typing to a node if node_typing is configured.
|
|
389
786
|
#
|
|
390
787
|
# For markdown nodes, this supports matching by:
|
|
@@ -406,8 +803,8 @@ module Markdown
|
|
|
406
803
|
if node.respond_to?(:type)
|
|
407
804
|
canonical_type = node.type
|
|
408
805
|
callable = @node_typing[canonical_type] ||
|
|
409
|
-
|
|
410
|
-
|
|
806
|
+
@node_typing[canonical_type.to_s] ||
|
|
807
|
+
@node_typing[canonical_type.to_sym]
|
|
411
808
|
if callable
|
|
412
809
|
# Call the custom lambda - it may return a refined typed node
|
|
413
810
|
# or the original node unchanged
|
|
@@ -429,7 +826,53 @@ module Markdown
|
|
|
429
826
|
def code_block_node?(node)
|
|
430
827
|
return false if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
431
828
|
|
|
432
|
-
node.respond_to?(:type) && node.type ==
|
|
829
|
+
node.respond_to?(:type) && node.type.to_s == 'code_block'
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Check if a node is an ordered or unordered list.
|
|
833
|
+
#
|
|
834
|
+
# @param node [Object] Node to check
|
|
835
|
+
# @return [Boolean] true if the node is a list
|
|
836
|
+
def list_node?(node)
|
|
837
|
+
return false if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
838
|
+
|
|
839
|
+
node.respond_to?(:type) && node.type.to_s == 'list'
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Try to inner-merge two list nodes at the item level, adding to OutputBuilder.
|
|
843
|
+
#
|
|
844
|
+
# @param template_node [Object] Template list node
|
|
845
|
+
# @param dest_node [Object] Destination list node
|
|
846
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
847
|
+
# @param stats [Hash] Statistics hash to update
|
|
848
|
+
# @return [Boolean] true if merged, false to fall back to standard resolution
|
|
849
|
+
def try_inner_merge_list_to_builder(template_node, dest_node, builder, stats, conflicts, unresolved_cases)
|
|
850
|
+
result = @list_merger.merge_lists(
|
|
851
|
+
template_node,
|
|
852
|
+
dest_node,
|
|
853
|
+
preference: @preference.is_a?(Hash) ? @preference.fetch(:default, :destination) : @preference,
|
|
854
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
855
|
+
template_analysis: @template_analysis,
|
|
856
|
+
dest_analysis: @dest_analysis,
|
|
857
|
+
resolution_mode: @resolution_mode,
|
|
858
|
+
unresolved_policy: @unresolved_policy
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
if result[:merged]
|
|
862
|
+
stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
|
|
863
|
+
stats[:inner_merges] ||= 0
|
|
864
|
+
stats[:inner_merges] += 1
|
|
865
|
+
emitted_range = builder.add_raw(result[:content])
|
|
866
|
+
remapped_cases = Array(result[:unresolved_cases]).map do |resolution_case|
|
|
867
|
+
unresolved_case_with_output_range(resolution_case, emitted_range)
|
|
868
|
+
end
|
|
869
|
+
unresolved_cases.concat(remapped_cases)
|
|
870
|
+
conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
|
|
871
|
+
true
|
|
872
|
+
else
|
|
873
|
+
DebugLogger.debug('List inner-merge skipped', { reason: result[:reason] })
|
|
874
|
+
false
|
|
875
|
+
end
|
|
433
876
|
end
|
|
434
877
|
|
|
435
878
|
# Try to inner-merge two code block nodes, adding to OutputBuilder
|
|
@@ -439,26 +882,97 @@ module Markdown
|
|
|
439
882
|
# @param builder [OutputBuilder] Output builder to add to
|
|
440
883
|
# @param stats [Hash] Statistics hash to update
|
|
441
884
|
# @return [Boolean] true if merged, false to fall back to standard resolution
|
|
442
|
-
def try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats)
|
|
885
|
+
def try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats, conflicts, unresolved_cases)
|
|
443
886
|
result = @code_block_merger.merge_code_blocks(
|
|
444
887
|
template_node,
|
|
445
888
|
dest_node,
|
|
446
889
|
preference: @preference,
|
|
890
|
+
runtime_session: @runtime_session,
|
|
891
|
+
parent_operation: @runtime_root_operation,
|
|
447
892
|
add_template_only_nodes: @add_template_only_nodes,
|
|
893
|
+
resolution_mode: @resolution_mode,
|
|
894
|
+
unresolved_policy: @unresolved_policy
|
|
448
895
|
)
|
|
449
896
|
|
|
450
897
|
if result[:merged]
|
|
451
898
|
stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
|
|
452
899
|
stats[:inner_merges] ||= 0
|
|
453
900
|
stats[:inner_merges] += 1
|
|
454
|
-
builder.add_raw(result[:content])
|
|
901
|
+
emitted_range = builder.add_raw(result[:content])
|
|
902
|
+
remapped_cases = remap_delegated_unresolved_cases(
|
|
903
|
+
result[:unresolved_cases],
|
|
904
|
+
result[:runtime_operation_id],
|
|
905
|
+
result[:runtime_surface_path],
|
|
906
|
+
emitted_range,
|
|
907
|
+
result[:metadata]
|
|
908
|
+
)
|
|
909
|
+
unresolved_cases.concat(remapped_cases)
|
|
910
|
+
conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
|
|
455
911
|
true
|
|
456
912
|
else
|
|
457
|
-
DebugLogger.debug(
|
|
913
|
+
DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
|
|
458
914
|
false # Fall back to standard resolution
|
|
459
915
|
end
|
|
460
916
|
end
|
|
461
917
|
|
|
918
|
+
def remap_delegated_unresolved_cases(unresolved_cases, runtime_operation_id, runtime_surface_path,
|
|
919
|
+
output_range = nil, delegated_metadata = nil)
|
|
920
|
+
root_apply_candidates = delegated_metadata.to_h[:root_apply_candidates_by_case_id].to_h
|
|
921
|
+
delegated_apply_renderer = delegated_metadata.to_h[:delegated_apply_renderer]
|
|
922
|
+
Array(unresolved_cases).map do |resolution_case|
|
|
923
|
+
suffix = delegated_surface_suffix_for(resolution_case.surface_path)
|
|
924
|
+
metadata = resolution_case.metadata.merge(
|
|
925
|
+
delegated_case_id: resolution_case.case_id
|
|
926
|
+
)
|
|
927
|
+
apply_candidates = root_apply_candidates[resolution_case.case_id]
|
|
928
|
+
if output_range && apply_candidates
|
|
929
|
+
metadata = metadata.merge(
|
|
930
|
+
output_range: output_range,
|
|
931
|
+
output_candidate_by_selection: apply_candidates
|
|
932
|
+
)
|
|
933
|
+
end
|
|
934
|
+
if output_range && delegated_apply_renderer
|
|
935
|
+
metadata = metadata.merge(
|
|
936
|
+
output_range: output_range,
|
|
937
|
+
delegated_apply_group: runtime_operation_id,
|
|
938
|
+
delegated_apply_renderer: delegated_apply_renderer,
|
|
939
|
+
delegated_applied_selections: {},
|
|
940
|
+
delegated_root_applied_selections: {},
|
|
941
|
+
delegated_runtime_operation_id: runtime_operation_id,
|
|
942
|
+
delegated_runtime_surface_path: runtime_surface_path
|
|
943
|
+
)
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
Ast::Merge::Runtime::ResolutionCase.new(
|
|
947
|
+
case_id: "#{runtime_operation_id}-#{resolution_case.case_id}",
|
|
948
|
+
reason: resolution_case.reason,
|
|
949
|
+
candidates: resolution_case.candidates,
|
|
950
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
951
|
+
surface_path: [runtime_surface_path, suffix].compact.join(' > '),
|
|
952
|
+
operation_id: runtime_operation_id,
|
|
953
|
+
metadata: metadata
|
|
954
|
+
)
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def delegated_surface_suffix_for(surface_path)
|
|
959
|
+
path = surface_path.to_s
|
|
960
|
+
return if path.empty? || path == 'document[0]'
|
|
961
|
+
|
|
962
|
+
path.sub(/\Adocument\[0\]\s*>\s*/, '')
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def conflict_for_resolution_case(resolution_case)
|
|
966
|
+
{
|
|
967
|
+
case_id: resolution_case.case_id,
|
|
968
|
+
reason: resolution_case.reason,
|
|
969
|
+
template: resolution_case.candidates[:template],
|
|
970
|
+
destination: resolution_case.candidates[:destination],
|
|
971
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
972
|
+
location: resolution_case.surface_path
|
|
973
|
+
}.compact
|
|
974
|
+
end
|
|
975
|
+
|
|
462
976
|
# Try to inner-merge two code block nodes.
|
|
463
977
|
#
|
|
464
978
|
# @deprecated Use try_inner_merge_code_block_to_builder instead
|
|
@@ -471,7 +985,7 @@ module Markdown
|
|
|
471
985
|
template_node,
|
|
472
986
|
dest_node,
|
|
473
987
|
preference: @preference,
|
|
474
|
-
add_template_only_nodes: @add_template_only_nodes
|
|
988
|
+
add_template_only_nodes: @add_template_only_nodes
|
|
475
989
|
)
|
|
476
990
|
|
|
477
991
|
if result[:merged]
|
|
@@ -480,7 +994,7 @@ module Markdown
|
|
|
480
994
|
stats[:inner_merges] += 1
|
|
481
995
|
[result[:content], nil]
|
|
482
996
|
else
|
|
483
|
-
DebugLogger.debug(
|
|
997
|
+
DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
|
|
484
998
|
nil # Fall back to standard resolution
|
|
485
999
|
end
|
|
486
1000
|
end
|
|
@@ -536,7 +1050,8 @@ module Markdown
|
|
|
536
1050
|
# @param builder [OutputBuilder] Output builder to add to
|
|
537
1051
|
# @param stats [Hash] Statistics hash to update
|
|
538
1052
|
# @return [Hash, nil] Frozen block info if applicable
|
|
539
|
-
def process_dest_only_to_builder(entry, builder, stats
|
|
1053
|
+
def process_dest_only_to_builder(entry, builder, stats, preserve_separator_gap: false, remaining_entries: [],
|
|
1054
|
+
link_ownership_context: nil, removal_comment_ownership: nil)
|
|
540
1055
|
node = entry[:dest_node]
|
|
541
1056
|
|
|
542
1057
|
frozen_info = nil
|
|
@@ -545,12 +1060,366 @@ module Markdown
|
|
|
545
1060
|
frozen_info = {
|
|
546
1061
|
start_line: node.start_line,
|
|
547
1062
|
end_line: node.end_line,
|
|
548
|
-
reason: node.reason
|
|
1063
|
+
reason: node.reason
|
|
549
1064
|
}
|
|
550
1065
|
end
|
|
551
1066
|
|
|
552
|
-
|
|
553
|
-
|
|
1067
|
+
unless @remove_template_missing_nodes
|
|
1068
|
+
builder.add_node_source(node, @dest_analysis)
|
|
1069
|
+
return [frozen_info, false]
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
if preserve_removed_dest_only_node?(node, removal_comment_ownership)
|
|
1073
|
+
if link_definition_node?(node)
|
|
1074
|
+
return [frozen_info, false] unless preserve_removed_link_definition_node?(node, link_ownership_context)
|
|
1075
|
+
|
|
1076
|
+
link_ownership_context[:preserved] << node.signature if link_ownership_context
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
if standalone_comment_node?(node,
|
|
1080
|
+
@dest_analysis) && preserved_removal_comment_node?(node,
|
|
1081
|
+
removal_comment_ownership)
|
|
1082
|
+
stats[:preserved_destination_comment_fragments] ||= 0
|
|
1083
|
+
stats[:preserved_destination_comment_fragments] += 1
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
builder.add_node_source(node, @dest_analysis)
|
|
1087
|
+
return [frozen_info, preserve_separator_gap_after_removed_node?(node, remaining_entries)]
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
if preserve_removed_separator_gap_line?(node)
|
|
1091
|
+
return [frozen_info, false] if builder.empty? || builder.blank_line_terminated?
|
|
1092
|
+
|
|
1093
|
+
should_preserve_gap = preserve_separator_gap && separator_gap_needed_after_removed_node?(remaining_entries)
|
|
1094
|
+
should_preserve_gap ||= leading_separator_gap_before_preserved_comment_needed?(remaining_entries)
|
|
1095
|
+
return [frozen_info, false] unless should_preserve_gap
|
|
1096
|
+
|
|
1097
|
+
builder.add_node_source(node, @dest_analysis)
|
|
1098
|
+
return [frozen_info, false]
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
if removable_destination_only_node?(node)
|
|
1102
|
+
preserved_link_definitions = preserved_destination_link_definitions_for_removed_node(node,
|
|
1103
|
+
link_ownership_context)
|
|
1104
|
+
|
|
1105
|
+
if preserved_link_definitions.any?
|
|
1106
|
+
builder.add_gap_line(count: 1) unless builder.empty? || builder.blank_line_terminated?
|
|
1107
|
+
|
|
1108
|
+
preserved_link_definitions.each do |link_definition|
|
|
1109
|
+
builder.add_node_source(link_definition, @dest_analysis)
|
|
1110
|
+
link_ownership_context[:preserved] << link_definition.signature if link_ownership_context
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
stats[:preserved_destination_link_definitions] ||= 0
|
|
1114
|
+
stats[:preserved_destination_link_definitions] += preserved_link_definitions.length
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
stats[:nodes_removed] += 1
|
|
1118
|
+
return [nil, preserve_separator_gap || separator_gap_needed_after_removed_node?(remaining_entries)]
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
[nil, preserve_separator_gap]
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# Removal-mode node classification — Markdown-family local.
|
|
1125
|
+
#
|
|
1126
|
+
# These predicates live here rather than in PreservationSupport because their
|
|
1127
|
+
# semantics are specific to full-document removal mode and would be meaningless
|
|
1128
|
+
# or misleading in a shared preservation context:
|
|
1129
|
+
#
|
|
1130
|
+
# * preserve_removed_separator_gap_line? — named entry-point for removal mode;
|
|
1131
|
+
# delegates directly to PreservationSupport#blank_gap_line_node?.
|
|
1132
|
+
#
|
|
1133
|
+
# * removable_destination_only_node? — intentionally does NOT exclude standalone
|
|
1134
|
+
# comment nodes. Standalone comments ARE removable in removal mode unless they
|
|
1135
|
+
# are owned by a remove plan. This makes it distinct from
|
|
1136
|
+
# PreservationSupport#structural_preservation_statement?, which excludes all
|
|
1137
|
+
# three non-structural categories (gap lines, standalone comments, link defs).
|
|
1138
|
+
#
|
|
1139
|
+
# * boundary_owner_statement? — identifies any non-blank-gap-line statement as a
|
|
1140
|
+
# valid boundary anchor for remove-plan construction. This is a removal-mode
|
|
1141
|
+
# concept only; PartialTemplateMerger has no equivalent boundary-anchoring need.
|
|
1142
|
+
def preserve_removed_dest_only_node?(node, removal_comment_ownership = nil)
|
|
1143
|
+
return true if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
1144
|
+
return true if link_definition_node?(node)
|
|
1145
|
+
return true if non_blank_gap_line_node?(node)
|
|
1146
|
+
|
|
1147
|
+
preserved_removal_comment_node?(node, removal_comment_ownership)
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
def preserve_separator_gap_after_removed_node?(node, remaining_entries = [])
|
|
1151
|
+
standalone_comment_node?(node, @dest_analysis) && separator_gap_needed_after_removed_node?(remaining_entries)
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
def leading_separator_gap_before_preserved_comment_needed?(remaining_entries)
|
|
1155
|
+
next_entry = remaining_entries.find do |entry|
|
|
1156
|
+
entry[:type] != :dest_only || !preserve_removed_separator_gap_line?(entry[:dest_node])
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
next_entry && next_entry[:type] == :dest_only && standalone_comment_node?(next_entry[:dest_node],
|
|
1160
|
+
@dest_analysis)
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
def separator_gap_needed_after_removed_node?(remaining_entries)
|
|
1164
|
+
remaining_entries.any? { |entry| entry_kept_after_removed_node?(entry) }
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
def entry_kept_after_removed_node?(entry)
|
|
1168
|
+
case entry[:type]
|
|
1169
|
+
when :match
|
|
1170
|
+
true
|
|
1171
|
+
when :template_only
|
|
1172
|
+
should_add_template_only_node?(entry)
|
|
1173
|
+
when :dest_only
|
|
1174
|
+
node = entry[:dest_node]
|
|
1175
|
+
(node.respond_to?(:freeze_node?) && node.freeze_node?) || preserve_removed_dest_only_node?(node)
|
|
1176
|
+
else
|
|
1177
|
+
false
|
|
1178
|
+
end
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
def preserve_removed_separator_gap_line?(node)
|
|
1182
|
+
blank_gap_line_node?(node)
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def removable_destination_only_node?(node)
|
|
1186
|
+
!gap_line_node?(node) && !link_definition_node?(node)
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def removal_mode_link_ownership_context(alignment)
|
|
1190
|
+
needed = Set.new
|
|
1191
|
+
available = Set.new
|
|
1192
|
+
|
|
1193
|
+
alignment.each do |entry|
|
|
1194
|
+
kept_node, analysis = kept_node_for_link_ownership(entry)
|
|
1195
|
+
next unless kept_node && analysis
|
|
1196
|
+
|
|
1197
|
+
link_reference_signatures_within(kept_node, analysis).each { |signature| needed << signature }
|
|
1198
|
+
link_definition_signatures_within(kept_node, analysis).each { |signature| available << signature }
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
{ needed: needed, available: available, preserved: Set.new }
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
def removal_mode_comment_ownership_context(alignment)
|
|
1205
|
+
contexts = {}
|
|
1206
|
+
run_entries = []
|
|
1207
|
+
|
|
1208
|
+
alignment.each do |entry|
|
|
1209
|
+
if removal_mode_comment_run_entry?(entry)
|
|
1210
|
+
run_entries << entry
|
|
1211
|
+
next
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
merge_removal_comment_ownership_context!(contexts, run_entries)
|
|
1215
|
+
run_entries = []
|
|
1216
|
+
end
|
|
1217
|
+
|
|
1218
|
+
merge_removal_comment_ownership_context!(contexts, run_entries)
|
|
1219
|
+
contexts
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
def merge_removal_comment_ownership_context!(contexts, run_entries)
|
|
1223
|
+
ownership = removal_comment_ownership_for_run(run_entries)
|
|
1224
|
+
return contexts unless ownership
|
|
1225
|
+
|
|
1226
|
+
Array(run_entries).each do |entry|
|
|
1227
|
+
contexts[entry[:dest_index]] = ownership
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
contexts
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
def removal_comment_ownership_for_run(run_entries)
|
|
1234
|
+
entries = Array(run_entries)
|
|
1235
|
+
return if entries.empty?
|
|
1236
|
+
return unless entries.any? { |entry| removal_mode_removable_structural_node?(entry[:dest_node]) }
|
|
1237
|
+
|
|
1238
|
+
remove_plan = removal_mode_remove_plan_for_entries(entries)
|
|
1239
|
+
return unless remove_plan
|
|
1240
|
+
|
|
1241
|
+
{
|
|
1242
|
+
remove_plan: remove_plan,
|
|
1243
|
+
owned_comment_region_keys: remove_plan_preserved_comment_keys(remove_plan),
|
|
1244
|
+
owned_comment_node_keys: removal_mode_owned_comment_node_keys(remove_plan, entries)
|
|
1245
|
+
}.freeze
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1248
|
+
def removal_mode_comment_run_entry?(entry)
|
|
1249
|
+
return false unless entry[:type] == :dest_only
|
|
1250
|
+
|
|
1251
|
+
node = entry[:dest_node]
|
|
1252
|
+
preserve_removed_separator_gap_line?(node) ||
|
|
1253
|
+
standalone_comment_node?(node, @dest_analysis) ||
|
|
1254
|
+
removal_mode_removable_structural_node?(node)
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
def removal_mode_removable_structural_node?(node)
|
|
1258
|
+
removable_destination_only_node?(node) && !preserve_removed_dest_only_node?(node)
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
def removal_mode_remove_plan_for_entries(entries)
|
|
1262
|
+
first_entry = entries.first
|
|
1263
|
+
last_entry = entries.last
|
|
1264
|
+
return unless first_entry && last_entry
|
|
1265
|
+
|
|
1266
|
+
first_dest_index = first_entry[:dest_index]
|
|
1267
|
+
last_dest_index = last_entry[:dest_index]
|
|
1268
|
+
return if first_dest_index.nil? || last_dest_index.nil?
|
|
1269
|
+
|
|
1270
|
+
statements = entries.map { |entry| entry[:dest_node] }
|
|
1271
|
+
|
|
1272
|
+
leading_statement = preceding_boundary_statement(first_dest_index)
|
|
1273
|
+
trailing_statement = following_boundary_statement(last_dest_index)
|
|
1274
|
+
|
|
1275
|
+
Ast::Merge::StructuralEdit::RemovePlanSupport.build_remove_plan(
|
|
1276
|
+
analysis: @dest_analysis,
|
|
1277
|
+
statements: statements,
|
|
1278
|
+
leading_statement: leading_statement,
|
|
1279
|
+
trailing_statement: trailing_statement,
|
|
1280
|
+
source: :smart_merger_base_removal_mode
|
|
1281
|
+
)
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
def preceding_boundary_statement(dest_index)
|
|
1285
|
+
@dest_analysis.statements[0...dest_index].reverse_each.find { |statement| boundary_owner_statement?(statement) }
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def following_boundary_statement(dest_index)
|
|
1289
|
+
Array(@dest_analysis.statements[(dest_index + 1)..]).find { |statement| boundary_owner_statement?(statement) }
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
def boundary_owner_statement?(statement)
|
|
1293
|
+
statement && !preserve_removed_separator_gap_line?(statement)
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def removal_mode_owned_comment_node_keys(remove_plan, run_entries)
|
|
1297
|
+
remove_plan_preserved_comment_keys_for_nodes(
|
|
1298
|
+
remove_plan,
|
|
1299
|
+
nodes: Array(run_entries).map { |entry| entry[:dest_node] },
|
|
1300
|
+
analysis: @dest_analysis
|
|
1301
|
+
)
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
def preserved_removal_comment_node?(node, removal_comment_ownership = nil)
|
|
1305
|
+
return false unless standalone_comment_node?(node, @dest_analysis)
|
|
1306
|
+
return true unless removal_comment_ownership
|
|
1307
|
+
|
|
1308
|
+
remove_plan_owns_comment_node?(
|
|
1309
|
+
node,
|
|
1310
|
+
@dest_analysis,
|
|
1311
|
+
removal_comment_ownership.fetch(:remove_plan),
|
|
1312
|
+
preserved_comment_keys: removal_comment_ownership.fetch(:owned_comment_region_keys)
|
|
1313
|
+
) || removal_comment_ownership.fetch(:owned_comment_node_keys).include?(preserved_comment_node_key(node,
|
|
1314
|
+
@dest_analysis))
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
def kept_node_for_link_ownership(entry)
|
|
1318
|
+
case entry[:type]
|
|
1319
|
+
when :match
|
|
1320
|
+
template_node = apply_node_typing(entry[:template_node])
|
|
1321
|
+
dest_node = apply_node_typing(entry[:dest_node])
|
|
1322
|
+
|
|
1323
|
+
resolution = @resolver.resolve(
|
|
1324
|
+
template_node,
|
|
1325
|
+
dest_node,
|
|
1326
|
+
template_index: entry[:template_index],
|
|
1327
|
+
dest_index: entry[:dest_index]
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
if resolution[:source] == :template
|
|
1331
|
+
[Ast::Merge::NodeTyping.unwrap(template_node), @template_analysis]
|
|
1332
|
+
else
|
|
1333
|
+
[Ast::Merge::NodeTyping.unwrap(dest_node), @dest_analysis]
|
|
1334
|
+
end
|
|
1335
|
+
when :template_only
|
|
1336
|
+
return unless should_add_template_only_node?(entry)
|
|
1337
|
+
|
|
1338
|
+
[Ast::Merge::NodeTyping.unwrap(entry[:template_node]), @template_analysis]
|
|
1339
|
+
when :dest_only
|
|
1340
|
+
node = entry[:dest_node]
|
|
1341
|
+
return unless preserve_removed_dest_only_node?(node)
|
|
1342
|
+
return if link_definition_node?(node)
|
|
1343
|
+
|
|
1344
|
+
[node, @dest_analysis]
|
|
1345
|
+
end
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
def preserved_destination_link_definitions_for_removed_node(node, link_ownership_context)
|
|
1349
|
+
return [] unless link_ownership_context
|
|
1350
|
+
|
|
1351
|
+
destination_link_definitions = consumed_link_definitions_within(node, @dest_analysis)
|
|
1352
|
+
return [] if destination_link_definitions.empty?
|
|
1353
|
+
|
|
1354
|
+
needed_signatures = link_ownership_context.fetch(:needed)
|
|
1355
|
+
available_signatures = link_ownership_context.fetch(:available)
|
|
1356
|
+
preserved_signatures = link_ownership_context.fetch(:preserved)
|
|
1357
|
+
|
|
1358
|
+
destination_link_definitions.reject do |link_definition|
|
|
1359
|
+
signature = link_definition.signature
|
|
1360
|
+
!needed_signatures.include?(signature) ||
|
|
1361
|
+
available_signatures.include?(signature) ||
|
|
1362
|
+
preserved_signatures.include?(signature)
|
|
1363
|
+
end
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
def preserve_removed_link_definition_node?(node, link_ownership_context)
|
|
1367
|
+
return true unless link_ownership_context
|
|
1368
|
+
|
|
1369
|
+
signature = node.signature
|
|
1370
|
+
!link_ownership_context.fetch(:available).include?(signature) &&
|
|
1371
|
+
!link_ownership_context.fetch(:preserved).include?(signature)
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
def link_definition_signatures_within(node, analysis)
|
|
1375
|
+
return Set.new if standalone_comment_node?(node, analysis)
|
|
1376
|
+
|
|
1377
|
+
if node.is_a?(LinkDefinitionNode)
|
|
1378
|
+
Set[node.signature]
|
|
1379
|
+
else
|
|
1380
|
+
consumed_link_definitions_within(node, analysis).map(&:signature).to_set
|
|
1381
|
+
end
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
def link_reference_signatures_within(node, analysis)
|
|
1385
|
+
return Set.new if skip_link_ownership_scanning_for_node?(node, analysis)
|
|
1386
|
+
|
|
1387
|
+
source = node_to_source(node, analysis)
|
|
1388
|
+
return Set.new if source.nil? || source.empty?
|
|
1389
|
+
|
|
1390
|
+
source.scan(/!?\[([^\]]+)\]\[([^\]]*)\]/).each_with_object(Set.new) do |(text_label, explicit_label), signatures|
|
|
1391
|
+
label = explicit_label.to_s.empty? ? text_label : explicit_label
|
|
1392
|
+
normalized_label = label.to_s.downcase
|
|
1393
|
+
next if normalized_label.empty?
|
|
1394
|
+
|
|
1395
|
+
signatures << [:link_definition, normalized_label]
|
|
1396
|
+
end
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
def skip_link_ownership_scanning_for_node?(node, analysis)
|
|
1400
|
+
return true if gap_line_node?(node) || link_definition_node?(node)
|
|
1401
|
+
return true if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
1402
|
+
return true if standalone_comment_node?(node, analysis)
|
|
1403
|
+
|
|
1404
|
+
literal_link_ownership_context_node?(node)
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
def unique_link_definitions_by_signature(link_definitions)
|
|
1408
|
+
seen = Set.new
|
|
1409
|
+
|
|
1410
|
+
link_definitions.each_with_object([]) do |link_definition, unique_definitions|
|
|
1411
|
+
signature = link_definition.signature
|
|
1412
|
+
next if seen.include?(signature)
|
|
1413
|
+
|
|
1414
|
+
seen << signature
|
|
1415
|
+
unique_definitions << link_definition
|
|
1416
|
+
end
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
def literal_link_ownership_context_node?(node)
|
|
1420
|
+
return false unless node.respond_to?(:type)
|
|
1421
|
+
|
|
1422
|
+
%w[code_block html html_block custom_block].include?(node.type.to_s)
|
|
554
1423
|
end
|
|
555
1424
|
|
|
556
1425
|
# Convert a node to its source text.
|
|
@@ -590,11 +1459,11 @@ module Markdown
|
|
|
590
1459
|
statements = analysis.statements
|
|
591
1460
|
gap_index = statements.index(gap_line)
|
|
592
1461
|
|
|
593
|
-
DebugLogger.debug(
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1462
|
+
DebugLogger.debug('Checking if gap line is document-trailing', {
|
|
1463
|
+
gap_line_number: gap_line.line_number,
|
|
1464
|
+
gap_index: gap_index,
|
|
1465
|
+
total_statements: statements.length
|
|
1466
|
+
})
|
|
598
1467
|
|
|
599
1468
|
return true if gap_index.nil? # Shouldn't happen, but treat as trailing if missing
|
|
600
1469
|
|
|
@@ -603,17 +1472,17 @@ module Markdown
|
|
|
603
1472
|
(gap_index + 1...statements.length).each do |i|
|
|
604
1473
|
node = statements[i]
|
|
605
1474
|
# If we find a non-gap-line node, this gap line is NOT document-trailing
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1475
|
+
next if gap_line_node?(node)
|
|
1476
|
+
|
|
1477
|
+
DebugLogger.debug('Found content after gap line', {
|
|
1478
|
+
next_node_index: i,
|
|
1479
|
+
next_node_type: node.class.name
|
|
1480
|
+
})
|
|
1481
|
+
return false
|
|
613
1482
|
end
|
|
614
1483
|
|
|
615
1484
|
# All remaining nodes are gap lines (or no nodes after), so this is document-trailing
|
|
616
|
-
DebugLogger.debug(
|
|
1485
|
+
DebugLogger.debug('Gap line IS document-trailing - no content after it')
|
|
617
1486
|
true
|
|
618
1487
|
end
|
|
619
1488
|
end
|