Class: Markdown::Merge::SmartMergerBase Abstract

Inherits:
Object
  • Object
show all
Includes:
PreservationSupport
Defined in:
lib/markdown/merge/smart_merger_base.rb

Overview

This class is abstract.

Subclass and implement parser-specific methods

Base class for smart Markdown file merging.

Orchestrates the smart merge process for Markdown files using FileAnalysisBase, FileAligner, ConflictResolver, and MergeResult to merge two Markdown files intelligently. Freeze blocks marked with HTML comments are preserved exactly as-is.

Subclasses must implement:

  • #create_file_analysis(content, **options) - Create parser-specific FileAnalysis
  • #node_to_source(node, analysis) - Convert a node to source text

SmartMergerBase provides flexible configuration for different merge scenarios:

  • Preserve destination customizations (default)
  • Apply template updates
  • Add new sections from template
  • Inner-merge fenced code blocks using language-specific mergers (optional)

Examples:

Subclass implementation

class SmartMerger < Markdown::Merge::SmartMergerBase
  def create_file_analysis(content, **options)
    FileAnalysis.new(content, **options)
  end

  def node_to_source(node, analysis)
    case node
    when FreezeNode
      node.full_text
    else
      analysis.source_range(node.start_line, node.end_line)
    end
  end
end

See Also:

Direct Known Subclasses

SmartMerger

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(template_content, dest_content, signature_generator: nil, preference: :destination, add_template_only_nodes: false, inner_merge_code_blocks: false, inner_merge_lists: false, remove_template_missing_nodes: false, corruption_handling: :heal, freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN, match_refiner: nil, node_typing: nil, resolution_mode: :eager, unresolved_policy: nil, normalize_whitespace: false, rehydrate_link_references: false, **parser_options) ⇒ SmartMergerBase

Creates a new SmartMerger for intelligent Markdown file merging.

Parameters:

  • template_content (String)

    Template Markdown source code

  • dest_content (String)

    Destination Markdown source code

  • signature_generator (Proc, nil) (defaults to: nil)

    Optional proc to generate custom node signatures. The proc receives a node and should return one of:

    • An array representing the node's signature
    • nil to indicate the node should have no signature
    • The original node to fall through to default signature computation
  • preference (Symbol, Hash) (defaults to: :destination)

    Controls which version to use when nodes have matching signatures but different content:

    • :destination (default) - Use destination version (preserves customizations)
    • :template - Use template version (applies updates)
    • Hash for per-type preferences: { default: :destination, gem_table: :template }
  • add_template_only_nodes (Boolean, #call) (defaults to: false)

    Controls whether to add nodes that only exist in template:

    • false (default) - Skip template-only nodes
    • true - Add all template-only nodes to result
    • Callable (Proc/Lambda) - Called with (node, entry) for each template-only node. Return truthy to add the node, falsey to skip it. @example Filter to only add gem family link refs add_template_only_nodes: ->(node, entry) { sig = entry sig.is_a?(Array) && sig.first == :gem_family }
  • inner_merge_code_blocks (Boolean, CodeBlockMerger) (defaults to: false)

    Controls inner-merge for fenced code blocks:

    • true - Enable inner-merge using default CodeBlockMerger
    • false (default) - Disable inner-merge (use standard conflict resolution)
    • CodeBlockMerger instance - Use custom CodeBlockMerger
  • remove_template_missing_nodes (Boolean) (defaults to: false)

    Controls whether to remove nodes that only exist in destination when not present in template:

    • false (default) - Preserve destination-only nodes
    • true - Remove destination-only structural nodes while still preserving freeze blocks, standalone HTML comment-only fragments, and parser-consumed non-structural content such as link reference definitions
  • freeze_token (String) (defaults to: FileAnalysisBase::DEFAULT_FREEZE_TOKEN)

    Token to use for freeze block markers. Default: "markdown-merge"

  • match_refiner (#call, nil) (defaults to: nil)

    Optional match refiner for fuzzy matching of unmatched nodes. Default: nil (fuzzy matching disabled). Set to TableMatchRefiner.new to enable fuzzy table matching.

  • node_typing (Hash{Symbol,String => #call}, nil) (defaults to: nil)

    Node typing configuration for per-node-type merge preferences. Maps node type names to callables that can wrap nodes with custom merge_types for use with Hash-based preference. @example node_typing = { table: ->(node) { text = node.to_plaintext if text.include?("tree_haver") Ast::Merge::NodeTyping.with_merge_type(node, :gem_family_table) else node end } } merger = SmartMerger.new(template, dest, node_typing: node_typing, preference: { default: :destination, gem_family_table: :template })

  • normalize_whitespace (Boolean, Symbol) (defaults to: false)

    Whitespace normalization mode:

    • false (default) - No normalization
    • true or :basic - Collapse excessive blank lines (3+ → 2)
    • :link_refs - Basic + remove blank lines between consecutive link reference definitions
    • :strict - All normalizations (same as :link_refs currently)
  • rehydrate_link_references (Boolean) (defaults to: false)

    If true, convert inline links/images to reference-style when a matching link reference definition exists. Default: false

  • parser_options (Hash)

    Additional parser-specific options

Raises:



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/markdown/merge/smart_merger_base.rb', line 148

def initialize(
  template_content,
  dest_content,
  signature_generator: nil,
  preference: :destination,
  add_template_only_nodes: false,
  inner_merge_code_blocks: false,
  inner_merge_lists: false,
  remove_template_missing_nodes: false,
  corruption_handling: :heal,
  freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN,
  match_refiner: nil,
  node_typing: nil,
  resolution_mode: :eager,
  unresolved_policy: nil,
  normalize_whitespace: false,
  rehydrate_link_references: false,
  **parser_options
)
  @preference = preference
  @add_template_only_nodes = add_template_only_nodes
  @remove_template_missing_nodes = remove_template_missing_nodes
  @corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
  @match_refiner = match_refiner || default_match_refiner(
    inner_merge_lists: inner_merge_lists,
    inner_merge_code_blocks: inner_merge_code_blocks
  )
  @node_typing = node_typing
  @resolution_mode = resolution_mode
  @unresolved_policy = Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
  @normalize_whitespace = normalize_whitespace
  @rehydrate_link_references = rehydrate_link_references

  # Validate node_typing if provided
  Ast::Merge::NodeTyping.validate!(node_typing) if node_typing
  validate_resolution_mode!(resolution_mode)

  # Set up code block merger
  @code_block_merger = case inner_merge_code_blocks
                       when true
                         CodeBlockMerger.new
                       when false
                         nil
                       when CodeBlockMerger
                         inner_merge_code_blocks
                       else
                         raise ArgumentError,
                               'inner_merge_code_blocks must be true, false, or a CodeBlockMerger instance'
                       end

  # Set up list merger
  @list_merger = case inner_merge_lists
                 when true
                   ListMerger.new
                 when false
                   nil
                 when ListMerger
                   inner_merge_lists
                 else
                   raise ArgumentError, 'inner_merge_lists must be true, false, or a ListMerger instance'
                 end

  # Parse template
  begin
    @template_analysis = create_file_analysis(
      template_content,
      freeze_token: freeze_token,
      signature_generator: signature_generator,
      **parser_options
    )
  rescue StandardError => e
    raise template_parse_error_class.new(errors: [e])
  end

  # Parse destination
  begin
    @dest_analysis = create_file_analysis(
      dest_content,
      freeze_token: freeze_token,
      signature_generator: signature_generator,
      **parser_options
    )
  rescue StandardError => e
    raise destination_parse_error_class.new(errors: [e])
  end

  @aligner = FileAligner.new(@template_analysis, @dest_analysis, match_refiner: @match_refiner)
  @resolver = ConflictResolver.new(
    preference: @preference,
    template_analysis: @template_analysis,
    dest_analysis: @dest_analysis,
    resolution_mode: @resolution_mode,
    unresolved_policy: @unresolved_policy
  )
  @runtime_session = nil
  @runtime_root_operation = nil
end

Instance Attribute Details

#alignerFileAligner (readonly)

Returns Aligner for finding matches and differences.

Returns:

  • (FileAligner)

    Aligner for finding matches and differences



53
54
55
# File 'lib/markdown/merge/smart_merger_base.rb', line 53

def aligner
  @aligner
end

#code_block_mergerCodeBlockMerger? (readonly)

Returns Merger for fenced code blocks.

Returns:



59
60
61
# File 'lib/markdown/merge/smart_merger_base.rb', line 59

def code_block_merger
  @code_block_merger
end

#corruption_handlingObject (readonly)

Returns the value of attribute corruption_handling.



66
67
68
# File 'lib/markdown/merge/smart_merger_base.rb', line 66

def corruption_handling
  @corruption_handling
end

#dest_analysisFileAnalysisBase (readonly)

Returns Analysis of the destination file.

Returns:



50
51
52
# File 'lib/markdown/merge/smart_merger_base.rb', line 50

def dest_analysis
  @dest_analysis
end

#node_typingHash{Symbol,String => #call}? (readonly)

Returns Node typing configuration.

Returns:

  • (Hash{Symbol,String => #call}, nil)

    Node typing configuration



62
63
64
# File 'lib/markdown/merge/smart_merger_base.rb', line 62

def node_typing
  @node_typing
end

#resolution_modeObject (readonly)

Returns the value of attribute resolution_mode.



66
67
68
# File 'lib/markdown/merge/smart_merger_base.rb', line 66

def resolution_mode
  @resolution_mode
end

#resolverConflictResolver (readonly)

Returns Resolver for handling conflicting content.

Returns:



56
57
58
# File 'lib/markdown/merge/smart_merger_base.rb', line 56

def resolver
  @resolver
end

#runtime_sessionAst::Merge::Runtime::Session? (readonly)

Returns Runtime session for this merge.

Returns:

  • (Ast::Merge::Runtime::Session, nil)

    Runtime session for this merge



65
66
67
# File 'lib/markdown/merge/smart_merger_base.rb', line 65

def runtime_session
  @runtime_session
end

#template_analysisFileAnalysisBase (readonly)

Returns Analysis of the template file.

Returns:



47
48
49
# File 'lib/markdown/merge/smart_merger_base.rb', line 47

def template_analysis
  @template_analysis
end

#unresolved_policyObject (readonly)

Returns the value of attribute unresolved_policy.



66
67
68
# File 'lib/markdown/merge/smart_merger_base.rb', line 66

def unresolved_policy
  @unresolved_policy
end

Instance Method Details

#create_file_analysis(content, **options) ⇒ FileAnalysisBase

This method is abstract.

Subclasses must implement this method

Create a FileAnalysis instance for the given content.

Parameters:

  • content (String)

    Markdown content to analyze

  • options (Hash)

    Analysis options

Returns:

Raises:

  • (NotImplementedError)


262
263
264
# File 'lib/markdown/merge/smart_merger_base.rb', line 262

def create_file_analysis(content, **options)
  raise NotImplementedError, "#{self.class} must implement #create_file_analysis"
end

#default_match_refiner(inner_merge_lists:, inner_merge_code_blocks:) ⇒ Object



246
247
248
249
250
251
252
253
254
# File 'lib/markdown/merge/smart_merger_base.rb', line 246

def default_match_refiner(inner_merge_lists:, inner_merge_code_blocks:)
  refiners = []
  refiners << ListMatchRefiner.new if inner_merge_lists
  refiners << CodeBlockMatchRefiner.new if inner_merge_code_blocks
  return if refiners.empty?
  return refiners.first if refiners.length == 1

  Ast::Merge::CompositeMatchRefiner.new(*refiners)
end

#destination_parse_error_classClass

Returns the DestinationParseError class to use.

Subclasses should override to return their parser-specific error class.

Returns:

  • (Class)

    DestinationParseError class



280
281
282
# File 'lib/markdown/merge/smart_merger_base.rb', line 280

def destination_parse_error_class
  DestinationParseError
end

#mergeString

Perform the merge operation and return the merged content as a string.

Returns:

  • (String)

    The merged Markdown content



287
288
289
# File 'lib/markdown/merge/smart_merger_base.rb', line 287

def merge
  merge_result.content
end

#merge_resultMergeResult

Perform the merge operation and return the full MergeResult object.

Returns:

  • (MergeResult)

    The merge result containing merged content and metadata



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/markdown/merge/smart_merger_base.rb', line 294

def merge_result
  return @merge_result if @merge_result

  @merge_result = DebugLogger.time('SmartMergerBase#merge') do
    prepare_runtime_session!
    alignment = DebugLogger.time('SmartMergerBase#align') do
      @aligner.align
    end

    DebugLogger.debug('Alignment complete', {
                        total_entries: alignment.size,
                        matches: alignment.count { |e| e[:type] == :match },
                        template_only: alignment.count { |e| e[:type] == :template_only },
                        dest_only: alignment.count { |e| e[:type] == :dest_only }
                      })

    # Process alignment using OutputBuilder
    builder, stats, frozen_blocks, conflicts, unresolved_cases = DebugLogger.time('SmartMergerBase#process') do
      process_alignment(alignment)
    end

    # Get content from OutputBuilder
    raw_content = builder.to_s
    content = raw_content

    # Collect problems from post-processing
    problems = DocumentProblems.new

    # Apply post-processing transformations
    content, problems = apply_post_processing(content, problems)
    complete_runtime_session!(content: content, stats: stats, problems: problems,
                              unresolved_cases: unresolved_cases)

    # Get final content from OutputBuilder
    MergeResult.new(
      content: content,
      raw_content: raw_content,
      conflicts: conflicts,
      frozen_blocks: frozen_blocks,
      stats: stats,
      problems: problems,
      unresolved_cases: unresolved_cases
    )
  end
end

#merge_with_debugHash

Perform the merge and return a hash with content, debug info, and runtime data.

Returns:

  • (Hash)

    Hash with :content, :debug, :runtime, and :statistics keys



343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/markdown/merge/smart_merger_base.rb', line 343

def merge_with_debug
  result = merge_result
  template_analysis_debug = {
    valid: @template_analysis&.valid? || false,
    statements: @template_analysis&.statements&.size || 0
  }
  dest_analysis_debug = {
    valid: @dest_analysis&.valid? || false,
    statements: @dest_analysis&.statements&.size || 0
  }

  {
    content: result.content,
    debug: {
      template_statements: template_analysis_debug[:statements],
      dest_statements: dest_analysis_debug[:statements],
      preference: @preference,
      add_template_only_nodes: @add_template_only_nodes,
      remove_template_missing_nodes: @remove_template_missing_nodes,
      corruption_handling: @corruption_handling,
      runtime_operation_count: runtime_session&.operations&.size || 0,
      runtime_diagnostic_count: runtime_session&.diagnostics&.size || 0
    },
    runtime: runtime_session&.to_h,
    statistics: result.stats,
    decisions: result.stats,
    template_analysis: template_analysis_debug,
    dest_analysis: dest_analysis_debug
  }
end

#statsHash

Get merge statistics (convenience method).

Returns:

  • (Hash)

    Statistics from the merge result



377
378
379
# File 'lib/markdown/merge/smart_merger_base.rb', line 377

def stats
  merge_result.stats
end

#template_parse_error_classClass

Returns the TemplateParseError class to use.

Subclasses should override to return their parser-specific error class.

Returns:

  • (Class)

    TemplateParseError class



271
272
273
# File 'lib/markdown/merge/smart_merger_base.rb', line 271

def template_parse_error_class
  TemplateParseError
end