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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +673 -0
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +44 -0
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +742 -0
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +199 -0
  16. data/lib/markdown/merge/debug_logger.rb +26 -0
  17. data/lib/markdown/merge/document_problems.rb +190 -0
  18. data/lib/markdown/merge/file_aligner.rb +496 -0
  19. data/lib/markdown/merge/file_analysis.rb +689 -0
  20. data/lib/markdown/merge/file_analysis_base.rb +766 -0
  21. data/lib/markdown/merge/freeze_node.rb +93 -0
  22. data/lib/markdown/merge/gap_line_node.rb +142 -0
  23. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  24. data/lib/markdown/merge/link_definition_node.rb +157 -0
  25. data/lib/markdown/merge/link_parser.rb +421 -0
  26. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  27. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  28. data/lib/markdown/merge/list_merger.rb +322 -0
  29. data/lib/markdown/merge/markdown_structure.rb +123 -0
  30. data/lib/markdown/merge/merge_result.rb +483 -0
  31. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  32. data/lib/markdown/merge/output_builder.rb +248 -0
  33. data/lib/markdown/merge/partial_template_merger.rb +555 -0
  34. data/lib/markdown/merge/preservation_support.rb +291 -0
  35. data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
  36. data/lib/markdown/merge/smart_merger.rb +269 -0
  37. data/lib/markdown/merge/smart_merger_base.rb +1490 -0
  38. data/lib/markdown/merge/source_preserving_provider.rb +814 -0
  39. data/lib/markdown/merge/table_match_algorithm.rb +499 -0
  40. data/lib/markdown/merge/table_match_refiner.rb +132 -0
  41. data/lib/markdown/merge/version.rb +5 -3
  42. data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
  43. data/lib/markdown/merge/wrapper_support.rb +194 -0
  44. data/lib/markdown/merge.rb +271 -87
  45. data/lib/markdown-merge.rb +7 -1
  46. data/sig/markdown/merge.rbs +62 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +289 -15
  49. metadata.gz.sig +0 -0
@@ -0,0 +1,499 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Algorithm for computing match scores between two Markdown tables.
6
+ #
7
+ # This algorithm uses multiple factors to determine how well two tables match:
8
+ # - (A) Percentage of matching header cells (using Levenshtein similarity)
9
+ # - (B) Percentage of matching cells in the first column (using Levenshtein similarity)
10
+ # - (C) Average percentage of matching cells in rows with matching first column
11
+ # - (D) Percentage of matching total cells
12
+ # - (E) Position distance weight (closer tables score higher)
13
+ #
14
+ # Cell comparisons use Levenshtein distance to compute similarity, allowing
15
+ # partial matches (e.g., "Value" vs "Values" would get a high similarity score).
16
+ #
17
+ # The final score is the weighted average of these factors.
18
+ #
19
+ # @example Basic usage
20
+ # algorithm = TableMatchAlgorithm.new
21
+ # score = algorithm.call(table_a, table_b)
22
+ #
23
+ # @example With position information
24
+ # algorithm = TableMatchAlgorithm.new(
25
+ # position_a: 0, # First table in template
26
+ # position_b: 2, # Third table in destination
27
+ # total_tables_a: 3,
28
+ # total_tables_b: 3
29
+ # )
30
+ # score = algorithm.call(table_a, table_b)
31
+ class TableMatchAlgorithm
32
+ # Default weights for each factor in the algorithm
33
+ DEFAULT_WEIGHTS = {
34
+ header_match: 0.25, # (A) Header row matching
35
+ first_column: 0.20, # (B) First column matching
36
+ row_content: 0.25, # (C) Content in matching rows
37
+ total_cells: 0.15, # (D) Overall cell matching
38
+ position: 0.15 # (E) Position distance
39
+ }.freeze
40
+
41
+ # Minimum similarity threshold to consider cells as potentially matching
42
+ # for first column lookup (used in row content matching)
43
+ FIRST_COLUMN_SIMILARITY_THRESHOLD = 0.7
44
+
45
+ # @return [Integer, nil] Position of table A in its document (0-indexed)
46
+ attr_reader :position_a
47
+
48
+ # @return [Integer, nil] Position of table B in its document (0-indexed)
49
+ attr_reader :position_b
50
+
51
+ # @return [Integer] Total number of tables in document A
52
+ attr_reader :total_tables_a
53
+
54
+ # @return [Integer] Total number of tables in document B
55
+ attr_reader :total_tables_b
56
+
57
+ # @return [Hash] Weights for each scoring factor
58
+ attr_reader :weights
59
+
60
+ # @return [Symbol] The markdown backend being used
61
+ attr_reader :backend
62
+
63
+ # Initialize the table match algorithm.
64
+ #
65
+ # @param position_a [Integer, nil] Position of first table in its document
66
+ # @param position_b [Integer, nil] Position of second table in its document
67
+ # @param total_tables_a [Integer] Total tables in first document (default: 1)
68
+ # @param total_tables_b [Integer] Total tables in second document (default: 1)
69
+ # @param weights [Hash] Custom weights for scoring factors
70
+ # @param backend [Symbol] Markdown backend for type normalization (default: :commonmarker)
71
+ def initialize(position_a: nil, position_b: nil, total_tables_a: 1, total_tables_b: 1, weights: {},
72
+ backend: :commonmarker)
73
+ @position_a = position_a
74
+ @position_b = position_b
75
+ @total_tables_a = [total_tables_a, 1].max
76
+ @total_tables_b = [total_tables_b, 1].max
77
+ @weights = DEFAULT_WEIGHTS.merge(weights)
78
+ @backend = backend
79
+ end
80
+
81
+ # Compute the match score between two tables.
82
+ #
83
+ # @param table_a [Object] First table node
84
+ # @param table_b [Object] Second table node
85
+ # @return [Float] Score between 0.0 and 1.0
86
+ def call(table_a, table_b)
87
+ rows_a = extract_rows(table_a)
88
+ rows_b = extract_rows(table_b)
89
+
90
+ return 0.0 if rows_a.empty? || rows_b.empty?
91
+
92
+ scores = {
93
+ header_match: compute_header_match(rows_a, rows_b),
94
+ first_column: compute_first_column_match(rows_a, rows_b),
95
+ row_content: compute_row_content_match(rows_a, rows_b),
96
+ total_cells: compute_total_cells_match(rows_a, rows_b),
97
+ position: compute_position_score
98
+ }
99
+
100
+ weighted_average(scores)
101
+ end
102
+
103
+ private
104
+
105
+ # Compute Levenshtein distance between two strings.
106
+ #
107
+ # Uses the Wagner-Fischer algorithm with O(min(m,n)) space.
108
+ #
109
+ # @param str_a [String] First string
110
+ # @param str_b [String] Second string
111
+ # @return [Integer] Edit distance between the strings
112
+ def levenshtein_distance(str_a, str_b)
113
+ return str_b.length if str_a.empty?
114
+ return str_a.length if str_b.empty?
115
+
116
+ # Ensure str_a is the shorter string for space optimization
117
+ str_a, str_b = str_b, str_a if str_a.length > str_b.length
118
+
119
+ m = str_a.length
120
+ n = str_b.length
121
+
122
+ # Only need two rows at a time
123
+ prev_row = (0..m).to_a
124
+ curr_row = Array.new(m + 1, 0)
125
+
126
+ (1..n).each do |j|
127
+ curr_row[0] = j
128
+
129
+ (1..m).each do |i|
130
+ cost = str_a[i - 1] == str_b[j - 1] ? 0 : 1
131
+ curr_row[i] = [
132
+ curr_row[i - 1] + 1, # insertion
133
+ prev_row[i] + 1, # deletion
134
+ prev_row[i - 1] + cost # substitution
135
+ ].min
136
+ end
137
+
138
+ prev_row, curr_row = curr_row, prev_row
139
+ end
140
+
141
+ prev_row[m]
142
+ end
143
+
144
+ # Compute similarity between two strings using Levenshtein distance.
145
+ #
146
+ # @param str_a [String] First string
147
+ # @param str_b [String] Second string
148
+ # @return [Float] Similarity score between 0.0 and 1.0
149
+ def string_similarity(str_a, str_b)
150
+ a = normalize(str_a)
151
+ b = normalize(str_b)
152
+
153
+ return 1.0 if a == b
154
+ return 1.0 if a.empty? && b.empty?
155
+ return 0.0 if a.empty? || b.empty?
156
+
157
+ max_len = [a.length, b.length].max
158
+ distance = levenshtein_distance(a, b)
159
+
160
+ 1.0 - (distance.to_f / max_len)
161
+ end
162
+
163
+ # Extract rows from a table node as arrays of cell text.
164
+ #
165
+ # Subclasses may override this for parser-specific iteration.
166
+ #
167
+ # @param table [Object] Table node
168
+ # @return [Array<Array<String>>] Array of rows, each row is array of cell texts
169
+ def extract_rows(table)
170
+ rows = []
171
+ child = table.first_child
172
+ while child
173
+ rows << extract_cells(child) if table_row_type?(child)
174
+ child = next_sibling(child)
175
+ end
176
+ rows
177
+ end
178
+
179
+ # Check if a node is a table row type.
180
+ #
181
+ # Uses NodeTypeNormalizer to map backend-specific types to canonical types,
182
+ # enabling portable type checking across different markdown parsers.
183
+ #
184
+ # NOTE: We use `type` here instead of `merge_type` because this method operates
185
+ # on child nodes of tables (table_row, table_header), not top-level statements.
186
+ # Only top-level statements are wrapped by NodeTypeNormalizer with `merge_type`.
187
+ # However, we use NodeTypeNormalizer.canonical_type to normalize the raw type.
188
+ #
189
+ # @param node [Object] Node to check
190
+ # @return [Boolean] true if this is a table row
191
+ def table_row_type?(node)
192
+ return false unless node.respond_to?(:type)
193
+
194
+ # Normalize the type using NodeTypeNormalizer for backend portability
195
+ canonical = NodeTypeNormalizer.canonical_type(node.type, @backend || :commonmarker)
196
+ %i[table_row table_header].include?(canonical)
197
+ end
198
+
199
+ # Get the next sibling of a node.
200
+ #
201
+ # Different parsers use different methods (next vs next_sibling).
202
+ #
203
+ # @param node [Object] Current node
204
+ # @return [Object, nil] Next sibling or nil
205
+ def next_sibling(node)
206
+ if node.respond_to?(:next_sibling)
207
+ node.next_sibling
208
+ elsif node.respond_to?(:next)
209
+ node.next
210
+ end
211
+ end
212
+
213
+ # Extract cell texts from a table row.
214
+ #
215
+ # Uses NodeTypeNormalizer to map backend-specific types to canonical types,
216
+ # enabling portable type checking across different markdown parsers.
217
+ #
218
+ # NOTE: We use `type` here instead of `merge_type` because this method operates
219
+ # on child nodes of table rows (table_cell), not top-level statements.
220
+ # Only top-level statements are wrapped by NodeTypeNormalizer with `merge_type`.
221
+ # However, we use NodeTypeNormalizer.canonical_type to normalize the raw type.
222
+ #
223
+ # @param row [Object] Table row node
224
+ # @return [Array<String>] Array of cell text contents
225
+ def extract_cells(row)
226
+ cells = []
227
+ child = row.first_child
228
+ while child
229
+ if child.respond_to?(:type)
230
+ canonical = NodeTypeNormalizer.canonical_type(child.type, @backend || :commonmarker)
231
+ cells << extract_text_content(child) if canonical == :table_cell
232
+ end
233
+ child = next_sibling(child)
234
+ end
235
+ cells
236
+ end
237
+
238
+ # Extract all text content from a node.
239
+ #
240
+ # Uses recursive traversal instead of `walk` for compatibility
241
+ # with tree_haver nodes which don't have a `walk` method.
242
+ #
243
+ # @param node [Object] Node to extract text from
244
+ # @return [String] Concatenated text content
245
+ def extract_text_content(node)
246
+ text_parts = []
247
+ collect_text_recursive(node, text_parts)
248
+ text_parts.join.strip
249
+ end
250
+
251
+ # Recursively collect text content from a node and its descendants.
252
+ #
253
+ # Uses NodeTypeNormalizer to map backend-specific types to canonical types,
254
+ # enabling portable type checking across different markdown parsers.
255
+ #
256
+ # NOTE: We use `type` here instead of `merge_type` because this method operates
257
+ # on child nodes (text, code), not top-level statements.
258
+ # Only top-level statements are wrapped by NodeTypeNormalizer with `merge_type`.
259
+ # However, we use NodeTypeNormalizer.canonical_type to normalize the raw type.
260
+ #
261
+ # @param node [Object] The node to traverse
262
+ # @param text_parts [Array<String>] Array to accumulate text into
263
+ # @return [void]
264
+ def collect_text_recursive(node, text_parts)
265
+ # Normalize the type using NodeTypeNormalizer for backend portability
266
+ canonical_type = NodeTypeNormalizer.canonical_type(node.type, @backend || :commonmarker)
267
+
268
+ # Collect text from text and code nodes
269
+ if %i[text code].include?(canonical_type)
270
+ content = if node.respond_to?(:string_content)
271
+ node.string_content.to_s
272
+ elsif node.respond_to?(:text)
273
+ node.text.to_s
274
+ else
275
+ ''
276
+ end
277
+ text_parts << content unless content.empty?
278
+ end
279
+
280
+ # Recurse into children - support both children array and first_child iteration
281
+ if node.respond_to?(:children)
282
+ node.children.each do |child|
283
+ collect_text_recursive(child, text_parts)
284
+ end
285
+ elsif node.respond_to?(:first_child)
286
+ child = node.first_child
287
+ while child
288
+ collect_text_recursive(child, text_parts)
289
+ child = if child.respond_to?(:next_sibling)
290
+ child.next_sibling
291
+ else
292
+ (child.respond_to?(:next) ? child.next : nil)
293
+ end
294
+ end
295
+ end
296
+ end
297
+
298
+ # (A) Compute header row match percentage using Levenshtein similarity.
299
+ #
300
+ # @param rows_a [Array<Array<String>>] Rows from table A
301
+ # @param rows_b [Array<Array<String>>] Rows from table B
302
+ # @return [Float] Average similarity of header cells (0.0-1.0)
303
+ def compute_header_match(rows_a, rows_b)
304
+ header_a = rows_a.first || []
305
+ header_b = rows_b.first || []
306
+
307
+ return 1.0 if header_a.empty? && header_b.empty?
308
+ return 0.0 if header_a.empty? || header_b.empty?
309
+
310
+ max_cells = [header_a.size, header_b.size].max
311
+
312
+ # Compute similarity for each cell pair
313
+ similarities = header_a.zip(header_b).map do |a, b|
314
+ next 0.0 if a.nil? || b.nil?
315
+
316
+ string_similarity(a, b)
317
+ end
318
+
319
+ # Pad with zeros for missing cells
320
+ (max_cells - similarities.size).times { similarities << 0.0 }
321
+
322
+ similarities.sum / max_cells
323
+ end
324
+
325
+ # (B) Compute first column match percentage using Levenshtein similarity.
326
+ #
327
+ # @param rows_a [Array<Array<String>>] Rows from table A
328
+ # @param rows_b [Array<Array<String>>] Rows from table B
329
+ # @return [Float] Percentage of matching first column cells (0.0-1.0)
330
+ def compute_first_column_match(rows_a, rows_b)
331
+ col_a = rows_a.map { |row| row.first }.compact
332
+ col_b = rows_b.map { |row| row.first }.compact
333
+
334
+ return 1.0 if col_a.empty? && col_b.empty?
335
+ return 0.0 if col_a.empty? || col_b.empty?
336
+
337
+ # For each cell in column A, find best match in column B
338
+ total_similarity = 0.0
339
+ col_a.each do |cell_a|
340
+ best_match = col_b.map { |cell_b| string_similarity(cell_a, cell_b) }.max || 0.0
341
+ total_similarity += best_match
342
+ end
343
+
344
+ # Also check cells in B that might not have matches in A
345
+ col_b.each do |cell_b|
346
+ best_match = col_a.map { |cell_a| string_similarity(cell_a, cell_b) }.max || 0.0
347
+ total_similarity += best_match
348
+ end
349
+
350
+ # Average over total cells
351
+ total_cells = col_a.size + col_b.size
352
+ total_cells > 0 ? total_similarity / total_cells : 0.0
353
+ end
354
+
355
+ # (C) Compute average match percentage for rows with matching first column.
356
+ #
357
+ # Uses Levenshtein similarity to find matching rows by first column.
358
+ #
359
+ # @param rows_a [Array<Array<String>>] Rows from table A
360
+ # @param rows_b [Array<Array<String>>] Rows from table B
361
+ # @return [Float] Average percentage of matching cells in linked rows (0.0-1.0)
362
+ def compute_row_content_match(rows_a, rows_b)
363
+ return 0.0 if rows_a.empty? || rows_b.empty?
364
+
365
+ match_scores = []
366
+
367
+ rows_a.each do |row_a|
368
+ first_col_a = row_a.first
369
+ next if first_col_a.nil?
370
+
371
+ # Find best matching row in B based on first column similarity
372
+ best_row_match = nil
373
+ best_first_col_similarity = 0.0
374
+
375
+ rows_b.each do |row_b|
376
+ first_col_b = row_b.first
377
+ next if first_col_b.nil?
378
+
379
+ similarity = string_similarity(first_col_a, first_col_b)
380
+ if similarity > best_first_col_similarity && similarity >= FIRST_COLUMN_SIMILARITY_THRESHOLD
381
+ best_first_col_similarity = similarity
382
+ best_row_match = row_b
383
+ end
384
+ end
385
+
386
+ next unless best_row_match
387
+
388
+ # Compute row content similarity
389
+ match_scores << row_match_score(row_a, best_row_match)
390
+ end
391
+
392
+ return 0.0 if match_scores.empty?
393
+
394
+ match_scores.sum / match_scores.size
395
+ end
396
+
397
+ # Compute match score between two rows using Levenshtein similarity.
398
+ #
399
+ # @param row_a [Array<String>] First row
400
+ # @param row_b [Array<String>] Second row
401
+ # @return [Float] Average similarity of cells (0.0-1.0)
402
+ def row_match_score(row_a, row_b)
403
+ max_cells = [row_a.size, row_b.size].max
404
+ return 1.0 if max_cells == 0
405
+
406
+ similarities = row_a.zip(row_b).map do |a, b|
407
+ next 0.0 if a.nil? || b.nil?
408
+
409
+ string_similarity(a, b)
410
+ end
411
+
412
+ # Pad with zeros for missing cells
413
+ (max_cells - similarities.size).times { similarities << 0.0 }
414
+
415
+ similarities.sum / max_cells
416
+ end
417
+
418
+ # (D) Compute total cells match percentage using Levenshtein similarity.
419
+ #
420
+ # @param rows_a [Array<Array<String>>] Rows from table A
421
+ # @param rows_b [Array<Array<String>>] Rows from table B
422
+ # @return [Float] Percentage of matching total cells (0.0-1.0)
423
+ def compute_total_cells_match(rows_a, rows_b)
424
+ cells_a = rows_a.flatten.compact
425
+ cells_b = rows_b.flatten.compact
426
+
427
+ return 1.0 if cells_a.empty? && cells_b.empty?
428
+ return 0.0 if cells_a.empty? || cells_b.empty?
429
+
430
+ # For each cell in A, find best match in B
431
+ used_b_indices = Set.new
432
+ total_similarity = 0.0
433
+
434
+ cells_a.each do |cell_a|
435
+ best_similarity = 0.0
436
+ best_index = nil
437
+
438
+ cells_b.each_with_index do |cell_b, idx|
439
+ next if used_b_indices.include?(idx)
440
+
441
+ similarity = string_similarity(cell_a, cell_b)
442
+ if similarity > best_similarity
443
+ best_similarity = similarity
444
+ best_index = idx
445
+ end
446
+ end
447
+
448
+ if best_index && best_similarity > 0.5
449
+ used_b_indices << best_index
450
+ total_similarity += best_similarity
451
+ end
452
+ end
453
+
454
+ # Calculate score based on how many cells found good matches
455
+ max_cells = [cells_a.size, cells_b.size].max
456
+ total_similarity / max_cells
457
+ end
458
+
459
+ # (E) Compute position-based score.
460
+ #
461
+ # Tables at similar positions in their documents score higher.
462
+ #
463
+ # @return [Float] Position similarity score (0.0-1.0)
464
+ def compute_position_score
465
+ return 1.0 if position_a.nil? || position_b.nil?
466
+
467
+ # Normalize positions to 0-1 range based on total tables
468
+ norm_pos_a = position_a.to_f / total_tables_a
469
+ norm_pos_b = position_b.to_f / total_tables_b
470
+
471
+ # Distance is absolute difference in normalized positions
472
+ distance = (norm_pos_a - norm_pos_b).abs
473
+
474
+ # Convert to similarity (1.0 = same position, 0.0 = max distance)
475
+ 1.0 - distance
476
+ end
477
+
478
+ # Normalize a cell value for comparison.
479
+ #
480
+ # @param value [String, nil] Cell value
481
+ # @return [String] Normalized value (downcased, stripped)
482
+ def normalize(value)
483
+ value.to_s.strip.downcase
484
+ end
485
+
486
+ # Compute weighted average of scores.
487
+ #
488
+ # @param scores [Hash<Symbol, Float>] Individual scores by factor
489
+ # @return [Float] Weighted average score
490
+ def weighted_average(scores)
491
+ total_weight = weights.values.sum
492
+ return 0.0 if total_weight == 0
493
+
494
+ weighted_sum = scores.sum { |key, score| score * weights.fetch(key, 0) }
495
+ weighted_sum / total_weight
496
+ end
497
+ end
498
+ end
499
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Match refiner for Markdown tables that didn't match by exact signature.
6
+ #
7
+ # This refiner uses the TableMatchAlgorithm to pair tables that have:
8
+ # - Similar but not identical headers
9
+ # - Similar structure (row/column counts)
10
+ # - Similar content in key columns
11
+ #
12
+ # Tables are matched using a multi-factor scoring algorithm that considers:
13
+ # - Header cell similarity
14
+ # - First column (row label) similarity
15
+ # - Overall content overlap
16
+ # - Position in document
17
+ #
18
+ # @example Basic usage
19
+ # refiner = TableMatchRefiner.new(threshold: 0.5)
20
+ # matches = refiner.call(template_nodes, dest_nodes)
21
+ #
22
+ # @example With custom algorithm options
23
+ # refiner = TableMatchRefiner.new(
24
+ # threshold: 0.6,
25
+ # algorithm_options: {
26
+ # weights: { header_match: 0.4, position: 0.1 }
27
+ # }
28
+ # )
29
+ #
30
+ # @see Ast::Merge::MatchRefinerBase
31
+ # @see TableMatchAlgorithm
32
+ class TableMatchRefiner < Ast::Merge::MatchRefinerBase
33
+ # @return [Hash] Options passed to TableMatchAlgorithm
34
+ attr_reader :algorithm_options
35
+
36
+ # @return [Symbol] The markdown backend being used
37
+ attr_reader :backend
38
+
39
+ # Initialize a table match refiner.
40
+ #
41
+ # @param threshold [Float] Minimum score to accept a match (default: 0.5)
42
+ # @param algorithm_options [Hash] Options for TableMatchAlgorithm
43
+ # @param backend [Symbol] Markdown backend for type normalization (default: :commonmarker)
44
+ def initialize(threshold: DEFAULT_THRESHOLD, algorithm_options: {}, backend: :commonmarker, **options)
45
+ super(threshold: threshold, node_types: [:table], **options)
46
+ @algorithm_options = algorithm_options
47
+ @backend = backend
48
+ end
49
+
50
+ # Find matches between unmatched table nodes.
51
+ #
52
+ # @param template_nodes [Array] Unmatched nodes from template
53
+ # @param dest_nodes [Array] Unmatched nodes from destination
54
+ # @param context [Hash] Additional context (may contain :template_analysis, :dest_analysis)
55
+ # @return [Array<MatchResult>] Array of table matches
56
+ def call(template_nodes, dest_nodes, _context = {})
57
+ template_tables = extract_tables(template_nodes)
58
+ dest_tables = extract_tables(dest_nodes)
59
+
60
+ return [] if template_tables.empty? || dest_tables.empty?
61
+
62
+ # Build position information for better matching
63
+ total_template = template_tables.size
64
+ total_dest = dest_tables.size
65
+
66
+ greedy_match(template_tables, dest_tables) do |t_node, d_node|
67
+ t_idx = template_tables.index(t_node) || 0
68
+ d_idx = dest_tables.index(d_node) || 0
69
+
70
+ compute_table_similarity(t_node, d_node, t_idx, d_idx, total_template, total_dest)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ # Extract table nodes from a collection.
77
+ #
78
+ # @param nodes [Array] Nodes to filter
79
+ # @return [Array] Table nodes
80
+ def extract_tables(nodes)
81
+ nodes.select { |n| table_node?(n) }
82
+ end
83
+
84
+ # Check if a node is a table.
85
+ #
86
+ # Handles wrapped nodes (merge_type is symbol) and raw nodes (type is string).
87
+ #
88
+ # @param node [Object] Node to check
89
+ # @return [Boolean]
90
+ def table_node?(node)
91
+ # Check if it's a typed wrapper node first
92
+ return Ast::Merge::NodeTyping.merge_type_for(node) == :table if Ast::Merge::NodeTyping.typed_node?(node)
93
+
94
+ # Check merge_type directly (wrapped nodes from NodeTypeNormalizer)
95
+ return node.merge_type == :table if node.respond_to?(:merge_type) && node.merge_type
96
+
97
+ # Check raw type (string comparison for tree_haver nodes)
98
+ if node.respond_to?(:type)
99
+ node_type = node.type
100
+ return [:table, 'table'].include?(node_type) || node_type.to_s == 'table'
101
+ end
102
+
103
+ # Fallback: class name check
104
+ return true if node.class.name.to_s.include?('Table')
105
+
106
+ false
107
+ end
108
+
109
+ # Compute similarity score between two tables.
110
+ #
111
+ # @param t_table [Object] Template table
112
+ # @param d_table [Object] Destination table
113
+ # @param t_idx [Integer] Template table index
114
+ # @param d_idx [Integer] Destination table index
115
+ # @param total_t [Integer] Total template tables
116
+ # @param total_d [Integer] Total destination tables
117
+ # @return [Float] Similarity score (0.0-1.0)
118
+ def compute_table_similarity(t_table, d_table, t_idx, d_idx, total_t, total_d)
119
+ algorithm = TableMatchAlgorithm.new(
120
+ position_a: t_idx,
121
+ position_b: d_idx,
122
+ total_tables_a: total_t,
123
+ total_tables_b: total_d,
124
+ backend: @backend,
125
+ **algorithm_options
126
+ )
127
+
128
+ algorithm.call(t_table, d_table)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Markdown
4
4
  module Merge
5
+ # Version namespace for this gem.
5
6
  module Version
6
- VERSION = "7.0.0"
7
+ # Current gem version.
8
+ VERSION = '7.1.3'
7
9
  end
8
-
9
- VERSION = Version::VERSION
10
+ # Current gem version exposed at the traditional constant location.
11
+ VERSION = Version::VERSION # Traditional Constant Location
10
12
  end
11
13
  end