markdown-merge 7.0.0 → 7.1.3
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 +673 -0
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
- 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 +44 -0
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +742 -0
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +199 -0
- data/lib/markdown/merge/debug_logger.rb +26 -0
- data/lib/markdown/merge/document_problems.rb +190 -0
- data/lib/markdown/merge/file_aligner.rb +496 -0
- data/lib/markdown/merge/file_analysis.rb +689 -0
- data/lib/markdown/merge/file_analysis_base.rb +766 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +142 -0
- data/lib/markdown/merge/link_definition_formatter.rb +49 -0
- data/lib/markdown/merge/link_definition_node.rb +157 -0
- data/lib/markdown/merge/link_parser.rb +421 -0
- data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
- 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 +123 -0
- data/lib/markdown/merge/merge_result.rb +483 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +248 -0
- data/lib/markdown/merge/partial_template_merger.rb +555 -0
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/smart_merger.rb +269 -0
- data/lib/markdown/merge/smart_merger_base.rb +1490 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/table_match_algorithm.rb +499 -0
- data/lib/markdown/merge/table_match_refiner.rb +132 -0
- data/lib/markdown/merge/version.rb +5 -3
- data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +271 -87
- data/lib/markdown-merge.rb +7 -1
- data/sig/markdown/merge.rbs +62 -0
- data.tar.gz.sig +0 -0
- metadata +289 -15
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Base class for smart Markdown file merging.
|
|
6
|
+
#
|
|
7
|
+
# Orchestrates the smart merge process for Markdown files using
|
|
8
|
+
# FileAnalysisBase, FileAligner, ConflictResolver, and MergeResult to
|
|
9
|
+
# merge two Markdown files intelligently. Freeze blocks marked with
|
|
10
|
+
# HTML comments are preserved exactly as-is.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses must implement:
|
|
13
|
+
# - #create_file_analysis(content, **options) - Create parser-specific FileAnalysis
|
|
14
|
+
# - #node_to_source(node, analysis) - Convert a node to source text
|
|
15
|
+
#
|
|
16
|
+
# SmartMergerBase provides flexible configuration for different merge scenarios:
|
|
17
|
+
# - Preserve destination customizations (default)
|
|
18
|
+
# - Apply template updates
|
|
19
|
+
# - Add new sections from template
|
|
20
|
+
# - Inner-merge fenced code blocks using language-specific mergers (optional)
|
|
21
|
+
#
|
|
22
|
+
# @example Subclass implementation
|
|
23
|
+
# class SmartMerger < Markdown::Merge::SmartMergerBase
|
|
24
|
+
# def create_file_analysis(content, **options)
|
|
25
|
+
# FileAnalysis.new(content, **options)
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# def node_to_source(node, analysis)
|
|
29
|
+
# case node
|
|
30
|
+
# when FreezeNode
|
|
31
|
+
# node.full_text
|
|
32
|
+
# else
|
|
33
|
+
# analysis.source_range(node.start_line, node.end_line)
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# @abstract Subclass and implement parser-specific methods
|
|
39
|
+
# @see FileAnalysisBase
|
|
40
|
+
# @see FileAligner
|
|
41
|
+
# @see ConflictResolver
|
|
42
|
+
# @see MergeResult
|
|
43
|
+
class SmartMergerBase
|
|
44
|
+
include PreservationSupport
|
|
45
|
+
|
|
46
|
+
# @return [FileAnalysisBase] Analysis of the template file
|
|
47
|
+
attr_reader :template_analysis
|
|
48
|
+
|
|
49
|
+
# @return [FileAnalysisBase] Analysis of the destination file
|
|
50
|
+
attr_reader :dest_analysis
|
|
51
|
+
|
|
52
|
+
# @return [FileAligner] Aligner for finding matches and differences
|
|
53
|
+
attr_reader :aligner
|
|
54
|
+
|
|
55
|
+
# @return [ConflictResolver] Resolver for handling conflicting content
|
|
56
|
+
attr_reader :resolver
|
|
57
|
+
|
|
58
|
+
# @return [CodeBlockMerger, nil] Merger for fenced code blocks
|
|
59
|
+
attr_reader :code_block_merger
|
|
60
|
+
|
|
61
|
+
# @return [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
62
|
+
attr_reader :node_typing
|
|
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
|
+
|
|
68
|
+
# Creates a new SmartMerger for intelligent Markdown file merging.
|
|
69
|
+
#
|
|
70
|
+
# @param template_content [String] Template Markdown source code
|
|
71
|
+
# @param dest_content [String] Destination Markdown source code
|
|
72
|
+
#
|
|
73
|
+
# @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
|
|
74
|
+
# The proc receives a node and should return one of:
|
|
75
|
+
# - An array representing the node's signature
|
|
76
|
+
# - `nil` to indicate the node should have no signature
|
|
77
|
+
# - The original node to fall through to default signature computation
|
|
78
|
+
#
|
|
79
|
+
# @param preference [Symbol, Hash] Controls which version to use when nodes
|
|
80
|
+
# have matching signatures but different content:
|
|
81
|
+
# - `:destination` (default) - Use destination version (preserves customizations)
|
|
82
|
+
# - `:template` - Use template version (applies updates)
|
|
83
|
+
# - Hash for per-type preferences: `{ default: :destination, gem_table: :template }`
|
|
84
|
+
#
|
|
85
|
+
# @param add_template_only_nodes [Boolean, #call] Controls whether to add nodes that only
|
|
86
|
+
# exist in template:
|
|
87
|
+
# - `false` (default) - Skip template-only nodes
|
|
88
|
+
# - `true` - Add all template-only nodes to result
|
|
89
|
+
# - Callable (Proc/Lambda) - Called with (node, entry) for each template-only node.
|
|
90
|
+
# Return truthy to add the node, falsey to skip it.
|
|
91
|
+
# @example Filter to only add gem family link refs
|
|
92
|
+
# add_template_only_nodes: ->(node, entry) {
|
|
93
|
+
# sig = entry[:signature]
|
|
94
|
+
# sig.is_a?(Array) && sig.first == :gem_family
|
|
95
|
+
# }
|
|
96
|
+
#
|
|
97
|
+
# @param inner_merge_code_blocks [Boolean, CodeBlockMerger] Controls inner-merge for
|
|
98
|
+
# fenced code blocks:
|
|
99
|
+
# - `true` - Enable inner-merge using default CodeBlockMerger
|
|
100
|
+
# - `false` (default) - Disable inner-merge (use standard conflict resolution)
|
|
101
|
+
# - `CodeBlockMerger` instance - Use custom CodeBlockMerger
|
|
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
|
+
#
|
|
110
|
+
# @param freeze_token [String] Token to use for freeze block markers.
|
|
111
|
+
# Default: "markdown-merge"
|
|
112
|
+
#
|
|
113
|
+
# @param match_refiner [#call, nil] Optional match refiner for fuzzy matching of
|
|
114
|
+
# unmatched nodes. Default: nil (fuzzy matching disabled).
|
|
115
|
+
# Set to TableMatchRefiner.new to enable fuzzy table matching.
|
|
116
|
+
#
|
|
117
|
+
# @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
118
|
+
# for per-node-type merge preferences. Maps node type names to callables that
|
|
119
|
+
# can wrap nodes with custom merge_types for use with Hash-based preference.
|
|
120
|
+
# @example
|
|
121
|
+
# node_typing = {
|
|
122
|
+
# table: ->(node) {
|
|
123
|
+
# text = node.to_plaintext
|
|
124
|
+
# if text.include?("tree_haver")
|
|
125
|
+
# Ast::Merge::NodeTyping.with_merge_type(node, :gem_family_table)
|
|
126
|
+
# else
|
|
127
|
+
# node
|
|
128
|
+
# end
|
|
129
|
+
# }
|
|
130
|
+
# }
|
|
131
|
+
# merger = SmartMerger.new(template, dest,
|
|
132
|
+
# node_typing: node_typing,
|
|
133
|
+
# preference: { default: :destination, gem_family_table: :template })
|
|
134
|
+
#
|
|
135
|
+
# @param normalize_whitespace [Boolean, Symbol] Whitespace normalization mode:
|
|
136
|
+
# - `false` (default) - No normalization
|
|
137
|
+
# - `true` or `:basic` - Collapse excessive blank lines (3+ → 2)
|
|
138
|
+
# - `:link_refs` - Basic + remove blank lines between consecutive link reference definitions
|
|
139
|
+
# - `:strict` - All normalizations (same as :link_refs currently)
|
|
140
|
+
#
|
|
141
|
+
# @param rehydrate_link_references [Boolean] If true, convert inline links/images
|
|
142
|
+
# to reference-style when a matching link reference definition exists. Default: false
|
|
143
|
+
#
|
|
144
|
+
# @param parser_options [Hash] Additional parser-specific options
|
|
145
|
+
#
|
|
146
|
+
# @raise [TemplateParseError] If template has syntax errors
|
|
147
|
+
# @raise [DestinationParseError] If destination has syntax errors
|
|
148
|
+
def initialize(
|
|
149
|
+
template_content,
|
|
150
|
+
dest_content,
|
|
151
|
+
signature_generator: nil,
|
|
152
|
+
preference: :destination,
|
|
153
|
+
add_template_only_nodes: false,
|
|
154
|
+
inner_merge_code_blocks: false,
|
|
155
|
+
inner_merge_lists: false,
|
|
156
|
+
remove_template_missing_nodes: false,
|
|
157
|
+
corruption_handling: :heal,
|
|
158
|
+
freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN,
|
|
159
|
+
match_refiner: nil,
|
|
160
|
+
node_typing: nil,
|
|
161
|
+
resolution_mode: :eager,
|
|
162
|
+
unresolved_policy: nil,
|
|
163
|
+
normalize_whitespace: false,
|
|
164
|
+
rehydrate_link_references: false,
|
|
165
|
+
**parser_options
|
|
166
|
+
)
|
|
167
|
+
@preference = preference
|
|
168
|
+
@add_template_only_nodes = add_template_only_nodes
|
|
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
|
+
)
|
|
175
|
+
@node_typing = node_typing
|
|
176
|
+
@resolution_mode = resolution_mode
|
|
177
|
+
@unresolved_policy = Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
|
|
178
|
+
@normalize_whitespace = normalize_whitespace
|
|
179
|
+
@rehydrate_link_references = rehydrate_link_references
|
|
180
|
+
|
|
181
|
+
# Validate node_typing if provided
|
|
182
|
+
Ast::Merge::NodeTyping.validate!(node_typing) if node_typing
|
|
183
|
+
validate_resolution_mode!(resolution_mode)
|
|
184
|
+
|
|
185
|
+
# Set up code block merger
|
|
186
|
+
@code_block_merger = case inner_merge_code_blocks
|
|
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
|
|
209
|
+
|
|
210
|
+
# Parse template
|
|
211
|
+
begin
|
|
212
|
+
@template_analysis = create_file_analysis(
|
|
213
|
+
template_content,
|
|
214
|
+
freeze_token: freeze_token,
|
|
215
|
+
signature_generator: signature_generator,
|
|
216
|
+
**parser_options
|
|
217
|
+
)
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
raise template_parse_error_class.new(errors: [e])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Parse destination
|
|
223
|
+
begin
|
|
224
|
+
@dest_analysis = create_file_analysis(
|
|
225
|
+
dest_content,
|
|
226
|
+
freeze_token: freeze_token,
|
|
227
|
+
signature_generator: signature_generator,
|
|
228
|
+
**parser_options
|
|
229
|
+
)
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
raise destination_parse_error_class.new(errors: [e])
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
@aligner = FileAligner.new(@template_analysis, @dest_analysis, match_refiner: @match_refiner)
|
|
235
|
+
@resolver = ConflictResolver.new(
|
|
236
|
+
preference: @preference,
|
|
237
|
+
template_analysis: @template_analysis,
|
|
238
|
+
dest_analysis: @dest_analysis,
|
|
239
|
+
resolution_mode: @resolution_mode,
|
|
240
|
+
unresolved_policy: @unresolved_policy
|
|
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)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Create a FileAnalysis instance for the given content.
|
|
257
|
+
#
|
|
258
|
+
# @abstract Subclasses must implement this method
|
|
259
|
+
# @param content [String] Markdown content to analyze
|
|
260
|
+
# @param options [Hash] Analysis options
|
|
261
|
+
# @return [FileAnalysisBase] File analysis instance
|
|
262
|
+
def create_file_analysis(content, **options)
|
|
263
|
+
raise NotImplementedError, "#{self.class} must implement #create_file_analysis"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Returns the TemplateParseError class to use.
|
|
267
|
+
#
|
|
268
|
+
# Subclasses should override to return their parser-specific error class.
|
|
269
|
+
#
|
|
270
|
+
# @return [Class] TemplateParseError class
|
|
271
|
+
def template_parse_error_class
|
|
272
|
+
TemplateParseError
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Returns the DestinationParseError class to use.
|
|
276
|
+
#
|
|
277
|
+
# Subclasses should override to return their parser-specific error class.
|
|
278
|
+
#
|
|
279
|
+
# @return [Class] DestinationParseError class
|
|
280
|
+
def destination_parse_error_class
|
|
281
|
+
DestinationParseError
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Perform the merge operation and return the merged content as a string.
|
|
285
|
+
#
|
|
286
|
+
# @return [String] The merged Markdown content
|
|
287
|
+
def merge
|
|
288
|
+
merge_result.content
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Perform the merge operation and return the full MergeResult object.
|
|
292
|
+
#
|
|
293
|
+
# @return [MergeResult] The merge result containing merged content and metadata
|
|
294
|
+
def merge_result
|
|
295
|
+
return @merge_result if @merge_result
|
|
296
|
+
|
|
297
|
+
@merge_result = DebugLogger.time('SmartMergerBase#merge') do
|
|
298
|
+
prepare_runtime_session!
|
|
299
|
+
alignment = DebugLogger.time('SmartMergerBase#align') do
|
|
300
|
+
@aligner.align
|
|
301
|
+
end
|
|
302
|
+
|
|
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
|
+
})
|
|
309
|
+
|
|
310
|
+
# Process alignment using OutputBuilder
|
|
311
|
+
builder, stats, frozen_blocks, conflicts, unresolved_cases = DebugLogger.time('SmartMergerBase#process') do
|
|
312
|
+
process_alignment(alignment)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Get content from OutputBuilder
|
|
316
|
+
raw_content = builder.to_s
|
|
317
|
+
content = raw_content
|
|
318
|
+
|
|
319
|
+
# Collect problems from post-processing
|
|
320
|
+
problems = DocumentProblems.new
|
|
321
|
+
|
|
322
|
+
# Apply post-processing transformations
|
|
323
|
+
content, problems = apply_post_processing(content, problems)
|
|
324
|
+
complete_runtime_session!(content: content, stats: stats, problems: problems,
|
|
325
|
+
unresolved_cases: unresolved_cases)
|
|
326
|
+
|
|
327
|
+
# Get final content from OutputBuilder
|
|
328
|
+
MergeResult.new(
|
|
329
|
+
content: content,
|
|
330
|
+
raw_content: raw_content,
|
|
331
|
+
conflicts: conflicts,
|
|
332
|
+
frozen_blocks: frozen_blocks,
|
|
333
|
+
stats: stats,
|
|
334
|
+
problems: problems,
|
|
335
|
+
unresolved_cases: unresolved_cases
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
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
|
+
|
|
374
|
+
# Get merge statistics (convenience method).
|
|
375
|
+
#
|
|
376
|
+
# @return [Hash] Statistics from the merge result
|
|
377
|
+
def stats
|
|
378
|
+
merge_result.stats
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
private
|
|
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
|
+
|
|
524
|
+
# Apply post-processing transformations to merged content.
|
|
525
|
+
#
|
|
526
|
+
# @param content [String] The merged content
|
|
527
|
+
# @param problems [DocumentProblems] Problems collector to add to
|
|
528
|
+
# @return [Array<String, DocumentProblems>] [transformed_content, problems]
|
|
529
|
+
def apply_post_processing(content, problems)
|
|
530
|
+
content = collapse_cross_source_preamble_prefixes(content)
|
|
531
|
+
|
|
532
|
+
# Apply whitespace normalization if enabled
|
|
533
|
+
if @normalize_whitespace
|
|
534
|
+
# Support both boolean and symbol modes
|
|
535
|
+
mode = @normalize_whitespace == true ? :basic : @normalize_whitespace
|
|
536
|
+
normalizer = WhitespaceNormalizer.new(content, mode: mode)
|
|
537
|
+
content = normalizer.normalize
|
|
538
|
+
problems.merge!(normalizer.problems)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Apply link reference rehydration if enabled
|
|
542
|
+
if @rehydrate_link_references
|
|
543
|
+
rehydrator = LinkReferenceRehydrator.new(content)
|
|
544
|
+
content = rehydrator.rehydrate
|
|
545
|
+
problems.merge!(rehydrator.problems)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
[content, problems]
|
|
549
|
+
end
|
|
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
|
+
|
|
610
|
+
# Process alignment entries and build result using OutputBuilder
|
|
611
|
+
#
|
|
612
|
+
# @param alignment [Array<Hash>] Alignment entries
|
|
613
|
+
# @return [Array] [output_builder, stats, frozen_blocks, conflicts, unresolved_cases]
|
|
614
|
+
def process_alignment(alignment)
|
|
615
|
+
builder = OutputBuilder.new
|
|
616
|
+
frozen_blocks = []
|
|
617
|
+
conflicts = []
|
|
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
|
|
623
|
+
|
|
624
|
+
alignment.each_with_index do |entry, index|
|
|
625
|
+
case entry[:type]
|
|
626
|
+
when :match
|
|
627
|
+
preserve_removed_separator_gap = false
|
|
628
|
+
frozen = process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
|
|
629
|
+
frozen_blocks << frozen if frozen
|
|
630
|
+
when :template_only
|
|
631
|
+
preserve_removed_separator_gap = false
|
|
632
|
+
process_template_only_to_builder(entry, builder, stats)
|
|
633
|
+
when :dest_only
|
|
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
|
+
)
|
|
643
|
+
frozen_blocks << frozen if frozen
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
[builder, stats, frozen_blocks, conflicts, unresolved_cases]
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Process a matched node pair, adding to OutputBuilder
|
|
651
|
+
#
|
|
652
|
+
# @param entry [Hash] Alignment entry
|
|
653
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
654
|
+
# @param stats [Hash] Statistics hash to update
|
|
655
|
+
# @return [Hash, nil] Frozen block info if applicable
|
|
656
|
+
def process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
|
|
657
|
+
template_node = apply_node_typing(entry[:template_node])
|
|
658
|
+
dest_node = apply_node_typing(entry[:dest_node])
|
|
659
|
+
|
|
660
|
+
# Try inner-merge for code blocks first
|
|
661
|
+
if @code_block_merger && code_block_node?(template_node) && code_block_node?(dest_node)
|
|
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)
|
|
671
|
+
return if inner_result
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
resolution = @resolver.resolve(
|
|
675
|
+
template_node,
|
|
676
|
+
dest_node,
|
|
677
|
+
template_index: entry[:template_index],
|
|
678
|
+
dest_index: entry[:dest_index]
|
|
679
|
+
)
|
|
680
|
+
conflicts << resolution[:conflict] if resolution[:conflict]
|
|
681
|
+
|
|
682
|
+
frozen_info = nil
|
|
683
|
+
|
|
684
|
+
# Use unwrapped node for source extraction
|
|
685
|
+
raw_template_node = Ast::Merge::NodeTyping.unwrap(template_node)
|
|
686
|
+
raw_dest_node = Ast::Merge::NodeTyping.unwrap(dest_node)
|
|
687
|
+
|
|
688
|
+
case resolution[:source]
|
|
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
|
|
699
|
+
stats[:nodes_modified] += 1 if resolution[:decision] != :identical
|
|
700
|
+
emitted_range = builder.add_node_source(raw_template_node, @template_analysis)
|
|
701
|
+
when :destination
|
|
702
|
+
if raw_dest_node.respond_to?(:freeze_node?) && raw_dest_node.freeze_node?
|
|
703
|
+
frozen_info = {
|
|
704
|
+
start_line: raw_dest_node.start_line,
|
|
705
|
+
end_line: raw_dest_node.end_line,
|
|
706
|
+
reason: raw_dest_node.reason
|
|
707
|
+
}
|
|
708
|
+
end
|
|
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)
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
frozen_info
|
|
718
|
+
end
|
|
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
|
+
|
|
785
|
+
# Apply node typing to a node if node_typing is configured.
|
|
786
|
+
#
|
|
787
|
+
# For markdown nodes, this supports matching by:
|
|
788
|
+
# 1. Node class name (standard NodeTyping behavior)
|
|
789
|
+
# 2. Canonical node type (e.g., :heading, :table, :paragraph)
|
|
790
|
+
#
|
|
791
|
+
# Note: Markdown nodes are pre-wrapped with canonical merge_type by
|
|
792
|
+
# NodeTypeNormalizer during parsing. This method allows custom node_typing
|
|
793
|
+
# to override or refine that canonical type.
|
|
794
|
+
#
|
|
795
|
+
# @param node [Object] The node to potentially wrap with merge_type
|
|
796
|
+
# @return [Object] The node, possibly wrapped with NodeTyping::Wrapper
|
|
797
|
+
def apply_node_typing(node)
|
|
798
|
+
return node unless @node_typing
|
|
799
|
+
return node unless node
|
|
800
|
+
|
|
801
|
+
# For markdown nodes, check if there's a custom callable for the canonical type.
|
|
802
|
+
# This takes precedence because nodes are pre-wrapped by NodeTypeNormalizer.
|
|
803
|
+
if node.respond_to?(:type)
|
|
804
|
+
canonical_type = node.type
|
|
805
|
+
callable = @node_typing[canonical_type] ||
|
|
806
|
+
@node_typing[canonical_type.to_s] ||
|
|
807
|
+
@node_typing[canonical_type.to_sym]
|
|
808
|
+
if callable
|
|
809
|
+
# Call the custom lambda - it may return a refined typed node
|
|
810
|
+
# or the original node unchanged
|
|
811
|
+
return callable.call(node)
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Fall back to standard class-name-based matching
|
|
816
|
+
result = Ast::Merge::NodeTyping.process(node, @node_typing)
|
|
817
|
+
return result if Ast::Merge::NodeTyping.typed_node?(result)
|
|
818
|
+
|
|
819
|
+
node
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Check if a node is a code block.
|
|
823
|
+
#
|
|
824
|
+
# @param node [Object] Node to check
|
|
825
|
+
# @return [Boolean] true if the node is a code block
|
|
826
|
+
def code_block_node?(node)
|
|
827
|
+
return false if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
828
|
+
|
|
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
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
# Try to inner-merge two code block nodes, adding to OutputBuilder
|
|
879
|
+
#
|
|
880
|
+
# @param template_node [Object] Template code block
|
|
881
|
+
# @param dest_node [Object] Destination code block
|
|
882
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
883
|
+
# @param stats [Hash] Statistics hash to update
|
|
884
|
+
# @return [Boolean] true if merged, false to fall back to standard resolution
|
|
885
|
+
def try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats, conflicts, unresolved_cases)
|
|
886
|
+
result = @code_block_merger.merge_code_blocks(
|
|
887
|
+
template_node,
|
|
888
|
+
dest_node,
|
|
889
|
+
preference: @preference,
|
|
890
|
+
runtime_session: @runtime_session,
|
|
891
|
+
parent_operation: @runtime_root_operation,
|
|
892
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
893
|
+
resolution_mode: @resolution_mode,
|
|
894
|
+
unresolved_policy: @unresolved_policy
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
if result[:merged]
|
|
898
|
+
stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
|
|
899
|
+
stats[:inner_merges] ||= 0
|
|
900
|
+
stats[:inner_merges] += 1
|
|
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) })
|
|
911
|
+
true
|
|
912
|
+
else
|
|
913
|
+
DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
|
|
914
|
+
false # Fall back to standard resolution
|
|
915
|
+
end
|
|
916
|
+
end
|
|
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
|
+
|
|
976
|
+
# Try to inner-merge two code block nodes.
|
|
977
|
+
#
|
|
978
|
+
# @deprecated Use try_inner_merge_code_block_to_builder instead
|
|
979
|
+
# @param template_node [Object] Template code block
|
|
980
|
+
# @param dest_node [Object] Destination code block
|
|
981
|
+
# @param stats [Hash] Statistics hash to update
|
|
982
|
+
# @return [Array, nil] [content_string, nil] if merged, nil to fall back to standard resolution
|
|
983
|
+
def try_inner_merge_code_block(template_node, dest_node, stats)
|
|
984
|
+
result = @code_block_merger.merge_code_blocks(
|
|
985
|
+
template_node,
|
|
986
|
+
dest_node,
|
|
987
|
+
preference: @preference,
|
|
988
|
+
add_template_only_nodes: @add_template_only_nodes
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
if result[:merged]
|
|
992
|
+
stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
|
|
993
|
+
stats[:inner_merges] ||= 0
|
|
994
|
+
stats[:inner_merges] += 1
|
|
995
|
+
[result[:content], nil]
|
|
996
|
+
else
|
|
997
|
+
DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
|
|
998
|
+
nil # Fall back to standard resolution
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# Process a template-only node, adding to OutputBuilder
|
|
1003
|
+
#
|
|
1004
|
+
# @param entry [Hash] Alignment entry
|
|
1005
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
1006
|
+
# @param stats [Hash] Statistics hash to update
|
|
1007
|
+
# @return [void]
|
|
1008
|
+
def process_template_only_to_builder(entry, builder, stats)
|
|
1009
|
+
return unless should_add_template_only_node?(entry)
|
|
1010
|
+
|
|
1011
|
+
stats[:nodes_added] += 1
|
|
1012
|
+
builder.add_node_source(entry[:template_node], @template_analysis)
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
# Determine if a template-only node should be added.
|
|
1016
|
+
#
|
|
1017
|
+
# Gap lines (blank lines/whitespace) represent formatting. Document-trailing gap lines
|
|
1018
|
+
# (at the very end with no more content after them) follow preference. Other gap lines
|
|
1019
|
+
# Determine if a template-only node should be added.
|
|
1020
|
+
#
|
|
1021
|
+
# Gap lines (blank lines) and all other nodes follow the add_template_only_nodes setting.
|
|
1022
|
+
# When false (default), template-only content is skipped.
|
|
1023
|
+
# When true, all template-only content including gap lines is included.
|
|
1024
|
+
#
|
|
1025
|
+
# @param entry [Hash] Alignment entry with :template_node and :signature
|
|
1026
|
+
# @return [Boolean] true if the node should be added
|
|
1027
|
+
def should_add_template_only_node?(entry)
|
|
1028
|
+
node = entry[:template_node]
|
|
1029
|
+
|
|
1030
|
+
case @add_template_only_nodes
|
|
1031
|
+
when false, nil
|
|
1032
|
+
false
|
|
1033
|
+
when true
|
|
1034
|
+
true
|
|
1035
|
+
else
|
|
1036
|
+
# Callable filter
|
|
1037
|
+
if @add_template_only_nodes.respond_to?(:call)
|
|
1038
|
+
@add_template_only_nodes.call(node, entry)
|
|
1039
|
+
else
|
|
1040
|
+
true
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
# Process a destination-only node, adding to OutputBuilder.
|
|
1046
|
+
#
|
|
1047
|
+
# All dest-only nodes are included, including gap lines (formatting).
|
|
1048
|
+
#
|
|
1049
|
+
# @param entry [Hash] Alignment entry
|
|
1050
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
1051
|
+
# @param stats [Hash] Statistics hash to update
|
|
1052
|
+
# @return [Hash, nil] Frozen block info if applicable
|
|
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)
|
|
1055
|
+
node = entry[:dest_node]
|
|
1056
|
+
|
|
1057
|
+
frozen_info = nil
|
|
1058
|
+
|
|
1059
|
+
if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
1060
|
+
frozen_info = {
|
|
1061
|
+
start_line: node.start_line,
|
|
1062
|
+
end_line: node.end_line,
|
|
1063
|
+
reason: node.reason
|
|
1064
|
+
}
|
|
1065
|
+
end
|
|
1066
|
+
|
|
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)
|
|
1423
|
+
end
|
|
1424
|
+
|
|
1425
|
+
# Convert a node to its source text.
|
|
1426
|
+
#
|
|
1427
|
+
# Default implementation uses source positions and falls back to to_commonmark.
|
|
1428
|
+
# Subclasses may override for parser-specific behavior.
|
|
1429
|
+
#
|
|
1430
|
+
# @param node [Object] Node to convert
|
|
1431
|
+
# @param analysis [FileAnalysisBase] Analysis for source lookup
|
|
1432
|
+
# @return [String] Source text
|
|
1433
|
+
def node_to_source(node, analysis)
|
|
1434
|
+
# Check for any FreezeNode type (base class or subclass)
|
|
1435
|
+
if node.is_a?(Ast::Merge::FreezeNodeBase)
|
|
1436
|
+
node.full_text
|
|
1437
|
+
else
|
|
1438
|
+
pos = node.source_position
|
|
1439
|
+
start_line = pos&.dig(:start_line)
|
|
1440
|
+
end_line = pos&.dig(:end_line)
|
|
1441
|
+
|
|
1442
|
+
return node.to_commonmark unless start_line && end_line
|
|
1443
|
+
|
|
1444
|
+
analysis.source_range(start_line, end_line)
|
|
1445
|
+
end
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
# Check if a gap line is document-trailing (no more content after it).
|
|
1449
|
+
#
|
|
1450
|
+
# A gap line is document-trailing if there are no more content nodes after it
|
|
1451
|
+
# in the statements list. We check all siblings after this gap line - if they're
|
|
1452
|
+
# all gap lines (no content), then this is document-trailing.
|
|
1453
|
+
#
|
|
1454
|
+
# @param gap_line [GapLineNode] The gap line to check
|
|
1455
|
+
# @param analysis [FileAnalysisBase] The analysis containing the gap line
|
|
1456
|
+
# @return [Boolean] true if the gap line is document-trailing
|
|
1457
|
+
def gap_line_is_document_trailing?(gap_line, analysis)
|
|
1458
|
+
# Find this gap line's index in the statements
|
|
1459
|
+
statements = analysis.statements
|
|
1460
|
+
gap_index = statements.index(gap_line)
|
|
1461
|
+
|
|
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
|
+
})
|
|
1467
|
+
|
|
1468
|
+
return true if gap_index.nil? # Shouldn't happen, but treat as trailing if missing
|
|
1469
|
+
|
|
1470
|
+
# Check all statements after this gap line
|
|
1471
|
+
# If they're ALL gap lines (no content nodes), then this is document-trailing
|
|
1472
|
+
(gap_index + 1...statements.length).each do |i|
|
|
1473
|
+
node = statements[i]
|
|
1474
|
+
# If we find a non-gap-line node, this gap line is NOT document-trailing
|
|
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
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1484
|
+
# All remaining nodes are gap lines (or no nodes after), so this is document-trailing
|
|
1485
|
+
DebugLogger.debug('Gap line IS document-trailing - no content after it')
|
|
1486
|
+
true
|
|
1487
|
+
end
|
|
1488
|
+
end
|
|
1489
|
+
end
|
|
1490
|
+
end
|