markdown-merge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +251 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +1087 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +253 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +405 -0
- data/lib/markdown/merge/cleanse.rb +42 -0
- data/lib/markdown/merge/code_block_merger.rb +300 -0
- data/lib/markdown/merge/conflict_resolver.rb +128 -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 +196 -0
- data/lib/markdown/merge/file_analysis.rb +353 -0
- data/lib/markdown/merge/file_analysis_base.rb +629 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +136 -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/markdown_structure.rb +123 -0
- data/lib/markdown/merge/merge_result.rb +166 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +166 -0
- data/lib/markdown/merge/partial_template_merger.rb +334 -0
- data/lib/markdown/merge/smart_merger.rb +221 -0
- data/lib/markdown/merge/smart_merger_base.rb +621 -0
- data/lib/markdown/merge/table_match_algorithm.rb +504 -0
- data/lib/markdown/merge/table_match_refiner.rb +136 -0
- data/lib/markdown/merge/version.rb +12 -0
- data/lib/markdown/merge/whitespace_normalizer.rb +251 -0
- data/lib/markdown/merge.rb +149 -0
- data/lib/markdown-merge.rb +4 -0
- data/sig/markdown/merge.rbs +341 -0
- data.tar.gz.sig +0 -0
- metadata +365 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,621 @@
|
|
|
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
|
+
# @return [FileAnalysisBase] Analysis of the template file
|
|
45
|
+
attr_reader :template_analysis
|
|
46
|
+
|
|
47
|
+
# @return [FileAnalysisBase] Analysis of the destination file
|
|
48
|
+
attr_reader :dest_analysis
|
|
49
|
+
|
|
50
|
+
# @return [FileAligner] Aligner for finding matches and differences
|
|
51
|
+
attr_reader :aligner
|
|
52
|
+
|
|
53
|
+
# @return [ConflictResolver] Resolver for handling conflicting content
|
|
54
|
+
attr_reader :resolver
|
|
55
|
+
|
|
56
|
+
# @return [CodeBlockMerger, nil] Merger for fenced code blocks
|
|
57
|
+
attr_reader :code_block_merger
|
|
58
|
+
|
|
59
|
+
# @return [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
60
|
+
attr_reader :node_typing
|
|
61
|
+
|
|
62
|
+
# Creates a new SmartMerger for intelligent Markdown file merging.
|
|
63
|
+
#
|
|
64
|
+
# @param template_content [String] Template Markdown source code
|
|
65
|
+
# @param dest_content [String] Destination Markdown source code
|
|
66
|
+
#
|
|
67
|
+
# @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
|
|
68
|
+
# The proc receives a node and should return one of:
|
|
69
|
+
# - An array representing the node's signature
|
|
70
|
+
# - `nil` to indicate the node should have no signature
|
|
71
|
+
# - The original node to fall through to default signature computation
|
|
72
|
+
#
|
|
73
|
+
# @param preference [Symbol, Hash] Controls which version to use when nodes
|
|
74
|
+
# have matching signatures but different content:
|
|
75
|
+
# - `:destination` (default) - Use destination version (preserves customizations)
|
|
76
|
+
# - `:template` - Use template version (applies updates)
|
|
77
|
+
# - Hash for per-type preferences: `{ default: :destination, gem_table: :template }`
|
|
78
|
+
#
|
|
79
|
+
# @param add_template_only_nodes [Boolean, #call] Controls whether to add nodes that only
|
|
80
|
+
# exist in template:
|
|
81
|
+
# - `false` (default) - Skip template-only nodes
|
|
82
|
+
# - `true` - Add all template-only nodes to result
|
|
83
|
+
# - Callable (Proc/Lambda) - Called with (node, entry) for each template-only node.
|
|
84
|
+
# Return truthy to add the node, falsey to skip it.
|
|
85
|
+
# @example Filter to only add gem family link refs
|
|
86
|
+
# add_template_only_nodes: ->(node, entry) {
|
|
87
|
+
# sig = entry[:signature]
|
|
88
|
+
# sig.is_a?(Array) && sig.first == :gem_family
|
|
89
|
+
# }
|
|
90
|
+
#
|
|
91
|
+
# @param inner_merge_code_blocks [Boolean, CodeBlockMerger] Controls inner-merge for
|
|
92
|
+
# fenced code blocks:
|
|
93
|
+
# - `true` - Enable inner-merge using default CodeBlockMerger
|
|
94
|
+
# - `false` (default) - Disable inner-merge (use standard conflict resolution)
|
|
95
|
+
# - `CodeBlockMerger` instance - Use custom CodeBlockMerger
|
|
96
|
+
#
|
|
97
|
+
# @param freeze_token [String] Token to use for freeze block markers.
|
|
98
|
+
# Default: "markdown-merge"
|
|
99
|
+
#
|
|
100
|
+
# @param match_refiner [#call, nil] Optional match refiner for fuzzy matching of
|
|
101
|
+
# unmatched nodes. Default: nil (fuzzy matching disabled).
|
|
102
|
+
# Set to TableMatchRefiner.new to enable fuzzy table matching.
|
|
103
|
+
#
|
|
104
|
+
# @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
105
|
+
# for per-node-type merge preferences. Maps node type names to callables that
|
|
106
|
+
# can wrap nodes with custom merge_types for use with Hash-based preference.
|
|
107
|
+
# @example
|
|
108
|
+
# node_typing = {
|
|
109
|
+
# table: ->(node) {
|
|
110
|
+
# text = node.to_plaintext
|
|
111
|
+
# if text.include?("tree_haver")
|
|
112
|
+
# Ast::Merge::NodeTyping.with_merge_type(node, :gem_family_table)
|
|
113
|
+
# else
|
|
114
|
+
# node
|
|
115
|
+
# end
|
|
116
|
+
# }
|
|
117
|
+
# }
|
|
118
|
+
# merger = SmartMerger.new(template, dest,
|
|
119
|
+
# node_typing: node_typing,
|
|
120
|
+
# preference: { default: :destination, gem_family_table: :template })
|
|
121
|
+
#
|
|
122
|
+
# @param normalize_whitespace [Boolean, Symbol] Whitespace normalization mode:
|
|
123
|
+
# - `false` (default) - No normalization
|
|
124
|
+
# - `true` or `:basic` - Collapse excessive blank lines (3+ → 2)
|
|
125
|
+
# - `:link_refs` - Basic + remove blank lines between consecutive link reference definitions
|
|
126
|
+
# - `:strict` - All normalizations (same as :link_refs currently)
|
|
127
|
+
#
|
|
128
|
+
# @param rehydrate_link_references [Boolean] If true, convert inline links/images
|
|
129
|
+
# to reference-style when a matching link reference definition exists. Default: false
|
|
130
|
+
#
|
|
131
|
+
# @param parser_options [Hash] Additional parser-specific options
|
|
132
|
+
#
|
|
133
|
+
# @raise [TemplateParseError] If template has syntax errors
|
|
134
|
+
# @raise [DestinationParseError] If destination has syntax errors
|
|
135
|
+
def initialize(
|
|
136
|
+
template_content,
|
|
137
|
+
dest_content,
|
|
138
|
+
signature_generator: nil,
|
|
139
|
+
preference: :destination,
|
|
140
|
+
add_template_only_nodes: false,
|
|
141
|
+
inner_merge_code_blocks: false,
|
|
142
|
+
freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN,
|
|
143
|
+
match_refiner: nil,
|
|
144
|
+
node_typing: nil,
|
|
145
|
+
normalize_whitespace: false,
|
|
146
|
+
rehydrate_link_references: false,
|
|
147
|
+
**parser_options
|
|
148
|
+
)
|
|
149
|
+
@preference = preference
|
|
150
|
+
@add_template_only_nodes = add_template_only_nodes
|
|
151
|
+
@match_refiner = match_refiner
|
|
152
|
+
@node_typing = node_typing
|
|
153
|
+
@normalize_whitespace = normalize_whitespace
|
|
154
|
+
@rehydrate_link_references = rehydrate_link_references
|
|
155
|
+
|
|
156
|
+
# Validate node_typing if provided
|
|
157
|
+
Ast::Merge::NodeTyping.validate!(node_typing) if node_typing
|
|
158
|
+
|
|
159
|
+
# Set up code block merger
|
|
160
|
+
@code_block_merger = case inner_merge_code_blocks
|
|
161
|
+
when true
|
|
162
|
+
CodeBlockMerger.new
|
|
163
|
+
when false
|
|
164
|
+
nil
|
|
165
|
+
when CodeBlockMerger
|
|
166
|
+
inner_merge_code_blocks
|
|
167
|
+
else
|
|
168
|
+
raise ArgumentError, "inner_merge_code_blocks must be true, false, or a CodeBlockMerger instance"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Parse template
|
|
172
|
+
begin
|
|
173
|
+
@template_analysis = create_file_analysis(
|
|
174
|
+
template_content,
|
|
175
|
+
freeze_token: freeze_token,
|
|
176
|
+
signature_generator: signature_generator,
|
|
177
|
+
**parser_options,
|
|
178
|
+
)
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
raise template_parse_error_class.new(errors: [e])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Parse destination
|
|
184
|
+
begin
|
|
185
|
+
@dest_analysis = create_file_analysis(
|
|
186
|
+
dest_content,
|
|
187
|
+
freeze_token: freeze_token,
|
|
188
|
+
signature_generator: signature_generator,
|
|
189
|
+
**parser_options,
|
|
190
|
+
)
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
raise destination_parse_error_class.new(errors: [e])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@aligner = FileAligner.new(@template_analysis, @dest_analysis, match_refiner: @match_refiner)
|
|
196
|
+
@resolver = ConflictResolver.new(
|
|
197
|
+
preference: @preference,
|
|
198
|
+
template_analysis: @template_analysis,
|
|
199
|
+
dest_analysis: @dest_analysis,
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Create a FileAnalysis instance for the given content.
|
|
204
|
+
#
|
|
205
|
+
# @abstract Subclasses must implement this method
|
|
206
|
+
# @param content [String] Markdown content to analyze
|
|
207
|
+
# @param options [Hash] Analysis options
|
|
208
|
+
# @return [FileAnalysisBase] File analysis instance
|
|
209
|
+
def create_file_analysis(content, **options)
|
|
210
|
+
raise NotImplementedError, "#{self.class} must implement #create_file_analysis"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Returns the TemplateParseError class to use.
|
|
214
|
+
#
|
|
215
|
+
# Subclasses should override to return their parser-specific error class.
|
|
216
|
+
#
|
|
217
|
+
# @return [Class] TemplateParseError class
|
|
218
|
+
def template_parse_error_class
|
|
219
|
+
TemplateParseError
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Returns the DestinationParseError class to use.
|
|
223
|
+
#
|
|
224
|
+
# Subclasses should override to return their parser-specific error class.
|
|
225
|
+
#
|
|
226
|
+
# @return [Class] DestinationParseError class
|
|
227
|
+
def destination_parse_error_class
|
|
228
|
+
DestinationParseError
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Perform the merge operation and return the merged content as a string.
|
|
232
|
+
#
|
|
233
|
+
# @return [String] The merged Markdown content
|
|
234
|
+
def merge
|
|
235
|
+
merge_result.content
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Perform the merge operation and return the full MergeResult object.
|
|
239
|
+
#
|
|
240
|
+
# @return [MergeResult] The merge result containing merged content and metadata
|
|
241
|
+
def merge_result
|
|
242
|
+
return @merge_result if @merge_result
|
|
243
|
+
|
|
244
|
+
@merge_result = DebugLogger.time("SmartMergerBase#merge") do
|
|
245
|
+
alignment = DebugLogger.time("SmartMergerBase#align") do
|
|
246
|
+
@aligner.align
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
DebugLogger.debug("Alignment complete", {
|
|
250
|
+
total_entries: alignment.size,
|
|
251
|
+
matches: alignment.count { |e| e[:type] == :match },
|
|
252
|
+
template_only: alignment.count { |e| e[:type] == :template_only },
|
|
253
|
+
dest_only: alignment.count { |e| e[:type] == :dest_only },
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
# Process alignment using OutputBuilder
|
|
257
|
+
builder, stats, frozen_blocks, conflicts = DebugLogger.time("SmartMergerBase#process") do
|
|
258
|
+
process_alignment(alignment)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Get content from OutputBuilder
|
|
262
|
+
content = builder.to_s
|
|
263
|
+
|
|
264
|
+
# Collect problems from post-processing
|
|
265
|
+
problems = DocumentProblems.new
|
|
266
|
+
|
|
267
|
+
# Apply post-processing transformations
|
|
268
|
+
content, problems = apply_post_processing(content, problems)
|
|
269
|
+
|
|
270
|
+
# Get final content from OutputBuilder
|
|
271
|
+
MergeResult.new(
|
|
272
|
+
content: content,
|
|
273
|
+
conflicts: conflicts,
|
|
274
|
+
frozen_blocks: frozen_blocks,
|
|
275
|
+
stats: stats,
|
|
276
|
+
problems: problems,
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Get merge statistics (convenience method).
|
|
282
|
+
#
|
|
283
|
+
# @return [Hash] Statistics from the merge result
|
|
284
|
+
def stats
|
|
285
|
+
merge_result.stats
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
# Apply post-processing transformations to merged content.
|
|
291
|
+
#
|
|
292
|
+
# @param content [String] The merged content
|
|
293
|
+
# @param problems [DocumentProblems] Problems collector to add to
|
|
294
|
+
# @return [Array<String, DocumentProblems>] [transformed_content, problems]
|
|
295
|
+
def apply_post_processing(content, problems)
|
|
296
|
+
# Apply whitespace normalization if enabled
|
|
297
|
+
if @normalize_whitespace
|
|
298
|
+
# Support both boolean and symbol modes
|
|
299
|
+
mode = (@normalize_whitespace == true) ? :basic : @normalize_whitespace
|
|
300
|
+
normalizer = WhitespaceNormalizer.new(content, mode: mode)
|
|
301
|
+
content = normalizer.normalize
|
|
302
|
+
problems.merge!(normalizer.problems)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Apply link reference rehydration if enabled
|
|
306
|
+
if @rehydrate_link_references
|
|
307
|
+
rehydrator = LinkReferenceRehydrator.new(content)
|
|
308
|
+
content = rehydrator.rehydrate
|
|
309
|
+
problems.merge!(rehydrator.problems)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
[content, problems]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Process alignment entries and build result using OutputBuilder
|
|
316
|
+
#
|
|
317
|
+
# @param alignment [Array<Hash>] Alignment entries
|
|
318
|
+
# @return [Array] [output_builder, stats, frozen_blocks, conflicts]
|
|
319
|
+
def process_alignment(alignment)
|
|
320
|
+
builder = OutputBuilder.new
|
|
321
|
+
frozen_blocks = []
|
|
322
|
+
conflicts = []
|
|
323
|
+
stats = {nodes_added: 0, nodes_removed: 0, nodes_modified: 0}
|
|
324
|
+
|
|
325
|
+
alignment.each do |entry|
|
|
326
|
+
case entry[:type]
|
|
327
|
+
when :match
|
|
328
|
+
frozen = process_match_to_builder(entry, builder, stats)
|
|
329
|
+
frozen_blocks << frozen if frozen
|
|
330
|
+
when :template_only
|
|
331
|
+
process_template_only_to_builder(entry, builder, stats)
|
|
332
|
+
when :dest_only
|
|
333
|
+
frozen = process_dest_only_to_builder(entry, builder, stats)
|
|
334
|
+
frozen_blocks << frozen if frozen
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
[builder, stats, frozen_blocks, conflicts]
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Process a matched node pair, adding to OutputBuilder
|
|
342
|
+
#
|
|
343
|
+
# @param entry [Hash] Alignment entry
|
|
344
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
345
|
+
# @param stats [Hash] Statistics hash to update
|
|
346
|
+
# @return [Hash, nil] Frozen block info if applicable
|
|
347
|
+
def process_match_to_builder(entry, builder, stats)
|
|
348
|
+
template_node = apply_node_typing(entry[:template_node])
|
|
349
|
+
dest_node = apply_node_typing(entry[:dest_node])
|
|
350
|
+
|
|
351
|
+
# Try inner-merge for code blocks first
|
|
352
|
+
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)
|
|
354
|
+
return if inner_result
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
resolution = @resolver.resolve(
|
|
358
|
+
template_node,
|
|
359
|
+
dest_node,
|
|
360
|
+
template_index: entry[:template_index],
|
|
361
|
+
dest_index: entry[:dest_index],
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
frozen_info = nil
|
|
365
|
+
|
|
366
|
+
# Use unwrapped node for source extraction
|
|
367
|
+
raw_template_node = Ast::Merge::NodeTyping.unwrap(template_node)
|
|
368
|
+
raw_dest_node = Ast::Merge::NodeTyping.unwrap(dest_node)
|
|
369
|
+
|
|
370
|
+
case resolution[:source]
|
|
371
|
+
when :template
|
|
372
|
+
stats[:nodes_modified] += 1 if resolution[:decision] != :identical
|
|
373
|
+
builder.add_node_source(raw_template_node, @template_analysis)
|
|
374
|
+
when :destination
|
|
375
|
+
if raw_dest_node.respond_to?(:freeze_node?) && raw_dest_node.freeze_node?
|
|
376
|
+
frozen_info = {
|
|
377
|
+
start_line: raw_dest_node.start_line,
|
|
378
|
+
end_line: raw_dest_node.end_line,
|
|
379
|
+
reason: raw_dest_node.reason,
|
|
380
|
+
}
|
|
381
|
+
end
|
|
382
|
+
builder.add_node_source(raw_dest_node, @dest_analysis)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
frozen_info
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Apply node typing to a node if node_typing is configured.
|
|
389
|
+
#
|
|
390
|
+
# For markdown nodes, this supports matching by:
|
|
391
|
+
# 1. Node class name (standard NodeTyping behavior)
|
|
392
|
+
# 2. Canonical node type (e.g., :heading, :table, :paragraph)
|
|
393
|
+
#
|
|
394
|
+
# Note: Markdown nodes are pre-wrapped with canonical merge_type by
|
|
395
|
+
# NodeTypeNormalizer during parsing. This method allows custom node_typing
|
|
396
|
+
# to override or refine that canonical type.
|
|
397
|
+
#
|
|
398
|
+
# @param node [Object] The node to potentially wrap with merge_type
|
|
399
|
+
# @return [Object] The node, possibly wrapped with NodeTyping::Wrapper
|
|
400
|
+
def apply_node_typing(node)
|
|
401
|
+
return node unless @node_typing
|
|
402
|
+
return node unless node
|
|
403
|
+
|
|
404
|
+
# For markdown nodes, check if there's a custom callable for the canonical type.
|
|
405
|
+
# This takes precedence because nodes are pre-wrapped by NodeTypeNormalizer.
|
|
406
|
+
if node.respond_to?(:type)
|
|
407
|
+
canonical_type = node.type
|
|
408
|
+
callable = @node_typing[canonical_type] ||
|
|
409
|
+
@node_typing[canonical_type.to_s] ||
|
|
410
|
+
@node_typing[canonical_type.to_sym]
|
|
411
|
+
if callable
|
|
412
|
+
# Call the custom lambda - it may return a refined typed node
|
|
413
|
+
# or the original node unchanged
|
|
414
|
+
return callable.call(node)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Fall back to standard class-name-based matching
|
|
419
|
+
result = Ast::Merge::NodeTyping.process(node, @node_typing)
|
|
420
|
+
return result if Ast::Merge::NodeTyping.typed_node?(result)
|
|
421
|
+
|
|
422
|
+
node
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Check if a node is a code block.
|
|
426
|
+
#
|
|
427
|
+
# @param node [Object] Node to check
|
|
428
|
+
# @return [Boolean] true if the node is a code block
|
|
429
|
+
def code_block_node?(node)
|
|
430
|
+
return false if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
431
|
+
|
|
432
|
+
node.respond_to?(:type) && node.type == :code_block
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Try to inner-merge two code block nodes, adding to OutputBuilder
|
|
436
|
+
#
|
|
437
|
+
# @param template_node [Object] Template code block
|
|
438
|
+
# @param dest_node [Object] Destination code block
|
|
439
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
440
|
+
# @param stats [Hash] Statistics hash to update
|
|
441
|
+
# @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)
|
|
443
|
+
result = @code_block_merger.merge_code_blocks(
|
|
444
|
+
template_node,
|
|
445
|
+
dest_node,
|
|
446
|
+
preference: @preference,
|
|
447
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if result[:merged]
|
|
451
|
+
stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
|
|
452
|
+
stats[:inner_merges] ||= 0
|
|
453
|
+
stats[:inner_merges] += 1
|
|
454
|
+
builder.add_raw(result[:content])
|
|
455
|
+
true
|
|
456
|
+
else
|
|
457
|
+
DebugLogger.debug("Inner-merge skipped", {reason: result[:reason]})
|
|
458
|
+
false # Fall back to standard resolution
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Try to inner-merge two code block nodes.
|
|
463
|
+
#
|
|
464
|
+
# @deprecated Use try_inner_merge_code_block_to_builder instead
|
|
465
|
+
# @param template_node [Object] Template code block
|
|
466
|
+
# @param dest_node [Object] Destination code block
|
|
467
|
+
# @param stats [Hash] Statistics hash to update
|
|
468
|
+
# @return [Array, nil] [content_string, nil] if merged, nil to fall back to standard resolution
|
|
469
|
+
def try_inner_merge_code_block(template_node, dest_node, stats)
|
|
470
|
+
result = @code_block_merger.merge_code_blocks(
|
|
471
|
+
template_node,
|
|
472
|
+
dest_node,
|
|
473
|
+
preference: @preference,
|
|
474
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if result[:merged]
|
|
478
|
+
stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
|
|
479
|
+
stats[:inner_merges] ||= 0
|
|
480
|
+
stats[:inner_merges] += 1
|
|
481
|
+
[result[:content], nil]
|
|
482
|
+
else
|
|
483
|
+
DebugLogger.debug("Inner-merge skipped", {reason: result[:reason]})
|
|
484
|
+
nil # Fall back to standard resolution
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Process a template-only node, adding to OutputBuilder
|
|
489
|
+
#
|
|
490
|
+
# @param entry [Hash] Alignment entry
|
|
491
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
492
|
+
# @param stats [Hash] Statistics hash to update
|
|
493
|
+
# @return [void]
|
|
494
|
+
def process_template_only_to_builder(entry, builder, stats)
|
|
495
|
+
return unless should_add_template_only_node?(entry)
|
|
496
|
+
|
|
497
|
+
stats[:nodes_added] += 1
|
|
498
|
+
builder.add_node_source(entry[:template_node], @template_analysis)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Determine if a template-only node should be added.
|
|
502
|
+
#
|
|
503
|
+
# Gap lines (blank lines/whitespace) represent formatting. Document-trailing gap lines
|
|
504
|
+
# (at the very end with no more content after them) follow preference. Other gap lines
|
|
505
|
+
# Determine if a template-only node should be added.
|
|
506
|
+
#
|
|
507
|
+
# Gap lines (blank lines) and all other nodes follow the add_template_only_nodes setting.
|
|
508
|
+
# When false (default), template-only content is skipped.
|
|
509
|
+
# When true, all template-only content including gap lines is included.
|
|
510
|
+
#
|
|
511
|
+
# @param entry [Hash] Alignment entry with :template_node and :signature
|
|
512
|
+
# @return [Boolean] true if the node should be added
|
|
513
|
+
def should_add_template_only_node?(entry)
|
|
514
|
+
node = entry[:template_node]
|
|
515
|
+
|
|
516
|
+
case @add_template_only_nodes
|
|
517
|
+
when false, nil
|
|
518
|
+
false
|
|
519
|
+
when true
|
|
520
|
+
true
|
|
521
|
+
else
|
|
522
|
+
# Callable filter
|
|
523
|
+
if @add_template_only_nodes.respond_to?(:call)
|
|
524
|
+
@add_template_only_nodes.call(node, entry)
|
|
525
|
+
else
|
|
526
|
+
true
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Process a destination-only node, adding to OutputBuilder.
|
|
532
|
+
#
|
|
533
|
+
# All dest-only nodes are included, including gap lines (formatting).
|
|
534
|
+
#
|
|
535
|
+
# @param entry [Hash] Alignment entry
|
|
536
|
+
# @param builder [OutputBuilder] Output builder to add to
|
|
537
|
+
# @param stats [Hash] Statistics hash to update
|
|
538
|
+
# @return [Hash, nil] Frozen block info if applicable
|
|
539
|
+
def process_dest_only_to_builder(entry, builder, stats)
|
|
540
|
+
node = entry[:dest_node]
|
|
541
|
+
|
|
542
|
+
frozen_info = nil
|
|
543
|
+
|
|
544
|
+
if node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
545
|
+
frozen_info = {
|
|
546
|
+
start_line: node.start_line,
|
|
547
|
+
end_line: node.end_line,
|
|
548
|
+
reason: node.reason,
|
|
549
|
+
}
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
builder.add_node_source(node, @dest_analysis)
|
|
553
|
+
frozen_info
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Convert a node to its source text.
|
|
557
|
+
#
|
|
558
|
+
# Default implementation uses source positions and falls back to to_commonmark.
|
|
559
|
+
# Subclasses may override for parser-specific behavior.
|
|
560
|
+
#
|
|
561
|
+
# @param node [Object] Node to convert
|
|
562
|
+
# @param analysis [FileAnalysisBase] Analysis for source lookup
|
|
563
|
+
# @return [String] Source text
|
|
564
|
+
def node_to_source(node, analysis)
|
|
565
|
+
# Check for any FreezeNode type (base class or subclass)
|
|
566
|
+
if node.is_a?(Ast::Merge::FreezeNodeBase)
|
|
567
|
+
node.full_text
|
|
568
|
+
else
|
|
569
|
+
pos = node.source_position
|
|
570
|
+
start_line = pos&.dig(:start_line)
|
|
571
|
+
end_line = pos&.dig(:end_line)
|
|
572
|
+
|
|
573
|
+
return node.to_commonmark unless start_line && end_line
|
|
574
|
+
|
|
575
|
+
analysis.source_range(start_line, end_line)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Check if a gap line is document-trailing (no more content after it).
|
|
580
|
+
#
|
|
581
|
+
# A gap line is document-trailing if there are no more content nodes after it
|
|
582
|
+
# in the statements list. We check all siblings after this gap line - if they're
|
|
583
|
+
# all gap lines (no content), then this is document-trailing.
|
|
584
|
+
#
|
|
585
|
+
# @param gap_line [GapLineNode] The gap line to check
|
|
586
|
+
# @param analysis [FileAnalysisBase] The analysis containing the gap line
|
|
587
|
+
# @return [Boolean] true if the gap line is document-trailing
|
|
588
|
+
def gap_line_is_document_trailing?(gap_line, analysis)
|
|
589
|
+
# Find this gap line's index in the statements
|
|
590
|
+
statements = analysis.statements
|
|
591
|
+
gap_index = statements.index(gap_line)
|
|
592
|
+
|
|
593
|
+
DebugLogger.debug("Checking if gap line is document-trailing", {
|
|
594
|
+
gap_line_number: gap_line.line_number,
|
|
595
|
+
gap_index: gap_index,
|
|
596
|
+
total_statements: statements.length,
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
return true if gap_index.nil? # Shouldn't happen, but treat as trailing if missing
|
|
600
|
+
|
|
601
|
+
# Check all statements after this gap line
|
|
602
|
+
# If they're ALL gap lines (no content nodes), then this is document-trailing
|
|
603
|
+
(gap_index + 1...statements.length).each do |i|
|
|
604
|
+
node = statements[i]
|
|
605
|
+
# If we find a non-gap-line node, this gap line is NOT document-trailing
|
|
606
|
+
unless node.is_a?(GapLineNode)
|
|
607
|
+
DebugLogger.debug("Found content after gap line", {
|
|
608
|
+
next_node_index: i,
|
|
609
|
+
next_node_type: node.class.name,
|
|
610
|
+
})
|
|
611
|
+
return false
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# All remaining nodes are gap lines (or no nodes after), so this is document-trailing
|
|
616
|
+
DebugLogger.debug("Gap line IS document-trailing - no content after it")
|
|
617
|
+
true
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|