markdown-merge 1.0.3 → 7.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +99 -482
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
  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 +5 -3
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +489 -47
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +77 -6
  16. data/lib/markdown/merge/debug_logger.rb +2 -2
  17. data/lib/markdown/merge/document_problems.rb +3 -3
  18. data/lib/markdown/merge/file_aligner.rb +433 -133
  19. data/lib/markdown/merge/file_analysis.rb +387 -51
  20. data/lib/markdown/merge/file_analysis_base.rb +188 -51
  21. data/lib/markdown/merge/gap_line_node.rb +14 -8
  22. data/lib/markdown/merge/link_definition_node.rb +5 -5
  23. data/lib/markdown/merge/link_parser.rb +60 -60
  24. data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
  25. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  26. data/lib/markdown/merge/list_merger.rb +322 -0
  27. data/lib/markdown/merge/markdown_structure.rb +3 -3
  28. data/lib/markdown/merge/merge_result.rb +321 -4
  29. data/lib/markdown/merge/node_type_normalizer.rb +6 -6
  30. data/lib/markdown/merge/output_builder.rb +101 -19
  31. data/lib/markdown/merge/partial_template_merger.rb +248 -27
  32. data/lib/markdown/merge/preservation_support.rb +291 -0
  33. data/lib/markdown/merge/smart_merger.rb +62 -14
  34. data/lib/markdown/merge/smart_merger_base.rb +929 -60
  35. data/lib/markdown/merge/table_match_algorithm.rb +22 -27
  36. data/lib/markdown/merge/table_match_refiner.rb +6 -10
  37. data/lib/markdown/merge/version.rb +5 -4
  38. data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
  39. data/lib/markdown/merge/wrapper_support.rb +194 -0
  40. data/lib/markdown/merge.rb +669 -122
  41. data/lib/markdown-merge.rb +9 -4
  42. data/sig/markdown/merge.rbs +3 -336
  43. data.tar.gz.sig +0 -0
  44. metadata +104 -93
  45. metadata.gz.sig +0 -0
  46. data/CHANGELOG.md +0 -308
  47. data/CITATION.cff +0 -20
  48. data/CODE_OF_CONDUCT.md +0 -134
  49. data/CONTRIBUTING.md +0 -227
  50. data/FUNDING.md +0 -74
  51. data/LICENSE.txt +0 -21
  52. data/REEK +0 -0
  53. data/RUBOCOP.md +0 -71
  54. data/SECURITY.md +0 -21
@@ -24,7 +24,7 @@ module Markdown
24
24
  #
25
25
  # @see FileAnalysisBase
26
26
  # @see SmartMergerBase
27
- class FileAligner
27
+ class FileAligner < ::Ast::Merge::FileAlignerBase
28
28
  # @return [FileAnalysisBase] Template file analysis
29
29
  attr_reader :template_analysis
30
30
 
@@ -39,157 +39,457 @@ module Markdown
39
39
  # @param template_analysis [FileAnalysisBase] Analysis of the template file
40
40
  # @param dest_analysis [FileAnalysisBase] Analysis of the destination file
41
41
  # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
42
- def initialize(template_analysis, dest_analysis, match_refiner: nil)
43
- @template_analysis = template_analysis
44
- @dest_analysis = dest_analysis
45
- @match_refiner = match_refiner
42
+ def initialize(template_analysis, dest_analysis, match_refiner: nil, **options)
43
+ super(template_analysis, dest_analysis, match_refiner: match_refiner, **options)
46
44
  end
47
45
 
48
- # Perform alignment between template and destination statements
49
- #
50
- # @return [Array<Hash>] Alignment entries with type, indices, and nodes
51
- def align
52
- template_statements = @template_analysis.statements
53
- dest_statements = @dest_analysis.statements
54
-
55
- # Build signature maps
56
- template_by_sig = build_signature_map(template_statements, @template_analysis)
57
- dest_by_sig = build_signature_map(dest_statements, @dest_analysis)
58
-
59
- # Track which indices have been matched
60
- matched_template = Set.new
61
- matched_dest = Set.new
62
- alignment = []
63
-
64
- # First pass: find matches by signature
65
- template_by_sig.each do |sig, template_indices|
66
- next unless dest_by_sig.key?(sig)
67
-
68
- dest_indices = dest_by_sig[sig]
69
-
70
- # Match indices pairwise (first template with first dest, etc.)
71
- template_indices.zip(dest_indices).each do |t_idx, d_idx|
72
- next unless t_idx && d_idx
73
-
74
- alignment << {
75
- type: :match,
76
- template_index: t_idx,
77
- dest_index: d_idx,
78
- signature: sig,
79
- template_node: template_statements[t_idx],
80
- dest_node: dest_statements[d_idx],
81
- }
82
-
83
- matched_template << t_idx
84
- matched_dest << d_idx
85
- end
86
- end
46
+ private
87
47
 
88
- # Apply match refiner to find additional fuzzy matches
89
- if @match_refiner
90
- unmatched_t_nodes = template_statements.each_with_index.reject { |_, i| matched_template.include?(i) }.map(&:first)
91
- unmatched_d_nodes = dest_statements.each_with_index.reject { |_, i| matched_dest.include?(i) }.map(&:first)
92
-
93
- unless unmatched_t_nodes.empty? || unmatched_d_nodes.empty?
94
- refiner_matches = @match_refiner.call(unmatched_t_nodes, unmatched_d_nodes, {
95
- template_analysis: @template_analysis,
96
- dest_analysis: @dest_analysis,
97
- })
98
-
99
- refiner_matches.each do |match|
100
- t_idx = template_statements.index(match.template_node)
101
- d_idx = dest_statements.index(match.dest_node)
102
-
103
- next unless t_idx && d_idx
104
- next if matched_template.include?(t_idx) || matched_dest.include?(d_idx)
105
-
106
- alignment << {
107
- type: :match,
108
- template_index: t_idx,
109
- dest_index: d_idx,
110
- signature: [:refined_match, match.score],
111
- template_node: match.template_node,
112
- dest_node: match.dest_node,
113
- }
114
-
115
- matched_template << t_idx
116
- matched_dest << d_idx
117
- end
118
- end
119
- end
48
+ def signature_for(analysis, index)
49
+ signature = analysis.signature_at(index)
50
+ return signature if signature.nil?
51
+
52
+ signature = contextual_list_signature(analysis, index, signature) if list_signature?(signature)
53
+
54
+ contextualize_signature_with_heading_owner_path(analysis, index, signature)
55
+ end
120
56
 
121
- # Second pass: add template-only entries
122
- template_statements.each_with_index do |stmt, idx|
123
- next if matched_template.include?(idx)
124
-
125
- alignment << {
126
- type: :template_only,
127
- template_index: idx,
128
- dest_index: nil,
129
- signature: @template_analysis.signature_at(idx),
130
- template_node: stmt,
131
- dest_node: nil,
57
+ def apply_match_refiner!(alignment, template_statements:, dest_statements:, matched_template:, matched_dest:)
58
+ return unless match_refiner
59
+
60
+ unmatched_template = template_statements.each_with_index.reject do |_, i|
61
+ matched_template.include?(i)
62
+ end.map(&:first)
63
+ unmatched_dest = dest_statements.each_with_index.reject { |_, i| matched_dest.include?(i) }.map(&:first)
64
+ return if unmatched_template.empty? || unmatched_dest.empty?
65
+
66
+ refined_matches = match_refiner.call(
67
+ unmatched_template,
68
+ unmatched_dest,
69
+ {
70
+ template_analysis: template_analysis,
71
+ dest_analysis: dest_analysis
132
72
  }
73
+ )
74
+
75
+ Array(refined_matches).each do |match|
76
+ template_statement = refined_match_template_statement(match)
77
+ dest_statement = refined_match_dest_statement(match)
78
+ template_index = template_statements.index(template_statement)
79
+ dest_index = dest_statements.index(dest_statement)
80
+
81
+ next unless template_index && dest_index
82
+ next if matched_template.include?(template_index) || matched_dest.include?(dest_index)
83
+ next unless refined_match_heading_owner_compatible?(template_index, dest_index)
84
+
85
+ entry = build_match_entry(
86
+ signature: refined_match_signature(match),
87
+ template_index: template_index,
88
+ dest_index: dest_index,
89
+ template_statement: template_statement,
90
+ dest_statement: dest_statement
91
+ )
92
+ repair_dest_index = adjacent_section_hijack_repair_sort_dest_index(template_index, dest_index)
93
+ entry[:repair_dest_index] = repair_dest_index if repair_dest_index
94
+ alignment << entry
95
+
96
+ matched_template << template_index
97
+ matched_dest << dest_index
133
98
  end
99
+ end
134
100
 
135
- # Third pass: add dest-only entries
136
- dest_statements.each_with_index do |stmt, idx|
137
- next if matched_dest.include?(idx)
138
-
139
- alignment << {
140
- type: :dest_only,
141
- template_index: nil,
142
- dest_index: idx,
143
- signature: @dest_analysis.signature_at(idx),
144
- template_node: nil,
145
- dest_node: stmt,
146
- }
101
+ def contextualize_signature_with_heading_owner_path(analysis, index, signature)
102
+ return signature unless section_owned_signature?(signature)
103
+ return signature if synthetic_section_label_paragraph?(analysis, index)
104
+ return signature if gap_line_after_synthetic_section_label?(analysis, index)
105
+
106
+ owner_path = heading_owner_path_for(analysis, index)
107
+ return signature if owner_path.empty?
108
+
109
+ [signature.first, owner_path, *signature.drop(1)]
110
+ end
111
+
112
+ def section_owned_signature?(signature)
113
+ signature.is_a?(Array) && %i[
114
+ paragraph
115
+ code_block
116
+ blockquote
117
+ block_quote
118
+ table
119
+ custom_block
120
+ hrule
121
+ thematic_break
122
+ gap_line_after
123
+ ].include?(signature.first)
124
+ end
125
+
126
+ def heading_owner_path_for(analysis, index)
127
+ heading_owner_paths_for(analysis).fetch(index, EMPTY_HEADING_OWNER_PATH)
128
+ end
129
+
130
+ def heading_owner_paths_for(analysis)
131
+ @heading_owner_paths ||= {}
132
+ @heading_owner_paths.fetch(analysis.object_id) do
133
+ @heading_owner_paths[analysis.object_id] = build_heading_owner_paths(analysis)
147
134
  end
135
+ end
148
136
 
149
- # Sort by appearance order (destination order for matched/dest-only, then template-only)
150
- alignment.sort_by! do |entry|
151
- case entry[:type]
152
- when :match
153
- [0, entry[:dest_index]]
154
- when :dest_only
155
- [0, entry[:dest_index]]
156
- when :template_only
157
- [1, entry[:template_index]]
137
+ EMPTY_HEADING_OWNER_PATH = [].freeze
138
+
139
+ def build_heading_owner_paths(analysis)
140
+ statements = statements_for(analysis)
141
+ stack = []
142
+
143
+ statements.each_with_index.each_with_object([]) do |(_statement, index), paths|
144
+ signature = analysis.signature_at(index)
145
+ if heading_signature?(signature)
146
+ level = signature[1]
147
+ stack.pop while stack.any? && stack.last[:level] >= level
148
+ paths << stack.map { |entry| entry[:signature] }.freeze
149
+ stack << { level: level, signature: signature }
158
150
  else
159
- # :nocov: defensive - only :match, :dest_only, :template_only types are created
160
- [2, 0] # Unknown types sort last
161
- # :nocov:
151
+ paths << stack.map { |entry| entry[:signature] }.freeze
162
152
  end
153
+ end.freeze
154
+ end
155
+
156
+ def refined_match_heading_owner_compatible?(template_index, dest_index)
157
+ template_owner_path = heading_owner_path_for(template_analysis, template_index)
158
+ dest_owner_path = heading_owner_path_for(dest_analysis, dest_index)
159
+ return true if template_owner_path == dest_owner_path
160
+
161
+ refined_list_repair_compatible?(template_index, dest_index) ||
162
+ adjacent_section_hijack_repair_compatible?(template_index, dest_index)
163
+ end
164
+
165
+ def refined_list_repair_compatible?(template_index, dest_index)
166
+ template_signature = template_analysis.signature_at(template_index)
167
+ dest_signature = dest_analysis.signature_at(dest_index)
168
+ return false unless list_signature?(template_signature) && list_signature?(dest_signature)
169
+ return true if synthetic_label_scoped_list?(template_analysis, template_index)
170
+
171
+ template_count = template_signature[2].to_i
172
+ dest_count = dest_signature[2].to_i
173
+ [template_count, dest_count].max >= [template_count, dest_count].min + 2
174
+ end
175
+
176
+ def adjacent_section_hijack_repair_compatible?(template_index, dest_index)
177
+ template_signature = template_analysis.signature_at(template_index)
178
+ dest_signature = dest_analysis.signature_at(dest_index)
179
+ return false unless repairable_hijacked_section_signature?(template_signature, dest_signature)
180
+ return false unless exact_statement_text_match?(template_analysis, template_index, dest_analysis, dest_index)
181
+
182
+ template_owner = section_owner_for(template_analysis, template_index)
183
+ dest_owner = section_owner_for(dest_analysis, dest_index)
184
+ return false unless template_owner && dest_owner
185
+ return false if template_owner[:signature] == dest_owner[:signature]
186
+ return false unless section_empty_for_heading_signature?(dest_analysis, template_owner[:signature])
187
+
188
+ neighboring_section_signatures?(template_analysis, template_owner[:signature], dest_owner[:signature]) &&
189
+ neighboring_section_signatures?(dest_analysis, template_owner[:signature], dest_owner[:signature])
190
+ end
191
+
192
+ def template_only_entry_context(template_index:, matched_entries_by_template_position:, **)
193
+ next_match = next_match_for_template_only_entry(
194
+ template_index,
195
+ matched_entries_by_template_position
196
+ )
197
+
198
+ {
199
+ anchor_dest_index: anchor_dest_index_for_entry(next_match),
200
+ anchor_position: next_match ? :before : :append
201
+ }
202
+ end
203
+
204
+ def next_match_for_template_only_entry(template_index, matched_entries_by_template_position)
205
+ same_owner_match = next_same_owner_match_for_template_only_entry(
206
+ template_index,
207
+ matched_entries_by_template_position
208
+ )
209
+ return same_owner_match if same_owner_match
210
+
211
+ section_boundary_match = next_section_boundary_match_for_template_only_entry(
212
+ template_index,
213
+ matched_entries_by_template_position
214
+ )
215
+ return section_boundary_match if section_boundary_match
216
+
217
+ _previous_match, next_match = surrounding_matched_entries(matched_entries_by_template_position, template_index)
218
+ next_match
219
+ end
220
+
221
+ def next_same_owner_match_for_template_only_entry(template_index, matched_entries_by_template_position)
222
+ owner = section_owner_for(template_analysis, template_index)
223
+ return unless owner
224
+
225
+ matched_entries_by_template_position.find do |entry|
226
+ candidate_index = entry[:template_index]
227
+ next false unless candidate_index && candidate_index > template_index
228
+
229
+ candidate_signature = template_analysis.signature_at(candidate_index)
230
+ next false if candidate_signature.is_a?(Array) && candidate_signature.first == :gap_line_after
231
+
232
+ candidate_owner = section_owner_for(template_analysis, candidate_index)
233
+ candidate_owner && candidate_owner[:signature] == owner[:signature]
234
+ end
235
+ end
236
+
237
+ def next_section_boundary_match_for_template_only_entry(template_index, matched_entries_by_template_position)
238
+ owner = section_owner_for(template_analysis, template_index)
239
+ return unless owner
240
+
241
+ matched_entries_by_template_position.find do |entry|
242
+ candidate_index = entry[:template_index]
243
+ next false unless candidate_index && candidate_index > owner[:heading_index]
244
+
245
+ candidate_signature = template_analysis.signature_at(candidate_index)
246
+ heading_signature?(candidate_signature) && candidate_signature[1] <= owner[:level]
163
247
  end
248
+ end
164
249
 
165
- DebugLogger.debug("Alignment complete", {
166
- total: alignment.size,
167
- matches: alignment.count { |e| e[:type] == :match },
168
- template_only: alignment.count { |e| e[:type] == :template_only },
169
- dest_only: alignment.count { |e| e[:type] == :dest_only },
170
- })
250
+ def section_owner_for(analysis, index)
251
+ statements = statements_for(analysis)
252
+ statement = statements[index]
253
+ signature = analysis.signature_at(index)
254
+ return unless statement && signature
171
255
 
172
- alignment
256
+ if heading_signature?(signature)
257
+ { heading_index: index, level: signature[1], owner_path: heading_owner_path_for(analysis, index),
258
+ signature: signature }
259
+ else
260
+ owner_path = heading_owner_path_for(analysis, index)
261
+ return if owner_path.empty?
262
+
263
+ owner_signature = owner_path.last
264
+ owner_index = nearest_owner_heading_index_for(analysis, index, owner_signature)
265
+ return unless owner_index
266
+
267
+ { heading_index: owner_index, level: owner_signature[1], owner_path: owner_path, signature: owner_signature }
268
+ end
173
269
  end
174
270
 
175
- private
271
+ def nearest_owner_heading_index_for(analysis, index, owner_signature)
272
+ index.downto(0) do |current_index|
273
+ signature = analysis.signature_at(current_index)
274
+ next unless heading_signature?(signature)
275
+ return current_index if signature == owner_signature
276
+ end
176
277
 
177
- # Build a map from signatures to statement indices
178
- #
179
- # @param statements [Array] List of statements
180
- # @param analysis [FileAnalysisBase] Analysis for signature generation
181
- # @return [Hash<Array, Array<Integer>>] Map from signature to indices
182
- def build_signature_map(statements, analysis)
183
- map = Hash.new { |h, k| h[k] = [] }
184
-
185
- statements.each_with_index do |_stmt, idx|
186
- sig = analysis.signature_at(idx)
187
- # :nocov: defensive - signature_at always returns a value for valid indices
188
- map[sig] << idx if sig
189
- # :nocov:
278
+ nil
279
+ end
280
+
281
+ def log_alignment(alignment)
282
+ DebugLogger.debug('Alignment complete', {
283
+ total: alignment.size,
284
+ matches: alignment.count { |e| e[:type] == :match },
285
+ template_only: alignment.count { |e| e[:type] == :template_only },
286
+ dest_only: alignment.count { |e| e[:type] == :dest_only }
287
+ })
288
+ end
289
+
290
+ def match_sort_key(entry)
291
+ [0, anchor_dest_index_for_entry(entry), 0, entry[:template_index] || 0]
292
+ end
293
+
294
+ def template_only_sort_key(entry, _dest_size)
295
+ anchor_dest_index = entry[:anchor_dest_index]
296
+
297
+ case entry[:anchor_position]
298
+ when :before
299
+ [0, anchor_dest_index, -1, entry[:template_index]]
300
+ else
301
+ [1, entry[:template_index], 0, 0]
302
+ end
303
+ end
304
+
305
+ def list_signature?(signature)
306
+ signature.is_a?(Array) && signature.first == :list
307
+ end
308
+
309
+ def contextual_list_signature(analysis, index, signature)
310
+ statement = statements_for(analysis)[index]
311
+ list_type = signature[1]
312
+ preceding_context_index = nearest_list_context_index(analysis, index)
313
+ preceding_context = preceding_context_index ? analysis.signature_at(preceding_context_index) : nil
314
+ owner_path = if preceding_context_index && synthetic_section_label_paragraph?(analysis, preceding_context_index)
315
+ EMPTY_HEADING_OWNER_PATH
316
+ else
317
+ heading_owner_path_for(analysis, index)
318
+ end
319
+ first_anchor = first_list_item_anchor(statement, analysis)
320
+ [:list, owner_path, list_type, preceding_context, first_anchor]
321
+ end
322
+
323
+ def heading_signature?(signature)
324
+ signature.is_a?(Array) && signature.first == :heading
325
+ end
326
+
327
+ def nearest_list_context_index(analysis, index)
328
+ (index - 1).downto(0) do |current_index|
329
+ candidate = analysis.signature_at(current_index)
330
+ next unless contextual_predecessor_signature?(candidate)
331
+
332
+ return current_index
333
+ end
334
+
335
+ nil
336
+ end
337
+
338
+ def contextual_predecessor_signature?(signature)
339
+ signature.is_a?(Array) && %i[heading paragraph code_block].include?(signature.first)
340
+ end
341
+
342
+ def synthetic_section_label_paragraph?(analysis, index)
343
+ signature = analysis.signature_at(index)
344
+ return false unless signature.is_a?(Array) && signature.first == :paragraph
345
+
346
+ statement = statements_for(analysis)[index]
347
+ return false unless statement&.respond_to?(:text)
348
+
349
+ text = statement.text.to_s.strip
350
+ return false if text.empty? || text.length > 120
351
+ return false if text.include?("\n")
352
+ return false if text.match?(/[.!?:]\z/)
353
+
354
+ text.split(/\s+/).length <= 12
355
+ end
356
+
357
+ def synthetic_label_scoped_list?(analysis, index)
358
+ return false unless list_signature?(analysis.signature_at(index))
359
+
360
+ context_index = nearest_list_context_index(analysis, index)
361
+ context_index && synthetic_section_label_paragraph?(analysis, context_index)
362
+ end
363
+
364
+ def gap_line_after_synthetic_section_label?(analysis, index)
365
+ signature = analysis.signature_at(index)
366
+ return false unless signature.is_a?(Array) && signature.first == :gap_line_after
367
+ return false unless index.positive?
368
+
369
+ synthetic_section_label_paragraph?(analysis, index - 1)
370
+ end
371
+
372
+ def repairable_hijacked_section_signature?(template_signature, dest_signature)
373
+ return false unless template_signature == dest_signature
374
+
375
+ paragraph_signature?(template_signature) || list_signature?(template_signature)
376
+ end
377
+
378
+ def paragraph_signature?(signature)
379
+ signature.is_a?(Array) && signature.first == :paragraph
380
+ end
381
+
382
+ def exact_statement_text_match?(template_analysis, template_index, dest_analysis, dest_index)
383
+ template_statement = statements_for(template_analysis)[template_index]
384
+ dest_statement = statements_for(dest_analysis)[dest_index]
385
+ return false unless template_statement&.respond_to?(:text) && dest_statement&.respond_to?(:text)
386
+
387
+ template_statement.text.to_s == dest_statement.text.to_s
388
+ end
389
+
390
+ def adjacent_section_hijack_repair_sort_dest_index(template_index, dest_index)
391
+ return unless adjacent_section_hijack_repair_compatible?(template_index, dest_index)
392
+
393
+ template_owner = section_owner_for(template_analysis, template_index)
394
+ return unless template_owner
395
+
396
+ heading_index = heading_index_for_signature(dest_analysis, template_owner[:signature])
397
+ return unless heading_index
398
+
399
+ boundary_index = next_section_boundary_heading_index(dest_analysis, heading_index)
400
+ return heading_index + 0.5 unless boundary_index
401
+
402
+ boundary_index - 0.5
403
+ end
404
+
405
+ def anchor_dest_index_for_entry(entry)
406
+ entry&.fetch(:repair_dest_index, entry[:dest_index])
407
+ end
408
+
409
+ def section_empty_for_heading_signature?(analysis, heading_signature)
410
+ heading_index = heading_index_for_signature(analysis, heading_signature)
411
+ return false unless heading_index
412
+
413
+ next_heading_index = next_section_boundary_heading_index(analysis, heading_index)
414
+ last_index = next_heading_index ? next_heading_index - 1 : statements_for(analysis).length - 1
415
+ ((heading_index + 1)..last_index).none? do |index|
416
+ signature = analysis.signature_at(index)
417
+ signature && signature.first != :gap_line_after && !heading_signature?(signature)
418
+ end
419
+ end
420
+
421
+ def neighboring_section_signatures?(analysis, first_signature, second_signature)
422
+ first_index = heading_index_for_signature(analysis, first_signature)
423
+ second_index = heading_index_for_signature(analysis, second_signature)
424
+ return false unless first_index && second_index
425
+
426
+ neighboring_heading_signatures(analysis, first_index).include?(second_signature)
427
+ end
428
+
429
+ def neighboring_heading_signatures(analysis, heading_index)
430
+ signature = analysis.signature_at(heading_index)
431
+ return [] unless heading_signature?(signature)
432
+
433
+ level = signature[1]
434
+ parent_path = heading_owner_path_for(analysis, heading_index)
435
+ headings = statements_for(analysis).each_index.filter_map do |index|
436
+ candidate = analysis.signature_at(index)
437
+ next unless heading_signature?(candidate)
438
+ next unless candidate[1] == level
439
+ next unless heading_owner_path_for(analysis, index) == parent_path
440
+
441
+ candidate
190
442
  end
443
+ current_position = headings.index(signature)
444
+ return [] unless current_position
445
+
446
+ [headings[current_position - 1], headings[current_position + 1]].compact
447
+ end
448
+
449
+ def heading_index_for_signature(analysis, heading_signature)
450
+ statements_for(analysis).each_index.find do |index|
451
+ analysis.signature_at(index) == heading_signature
452
+ end
453
+ end
454
+
455
+ def next_section_boundary_heading_index(analysis, heading_index)
456
+ signature = analysis.signature_at(heading_index)
457
+ return unless heading_signature?(signature)
458
+
459
+ level = signature[1]
460
+ ((heading_index + 1)...statements_for(analysis).length).find do |index|
461
+ candidate = analysis.signature_at(index)
462
+ heading_signature?(candidate) && candidate[1] <= level
463
+ end
464
+ end
465
+
466
+ def first_list_item_anchor(statement, analysis)
467
+ raw = Ast::Merge::NodeTyping.unwrap(statement)
468
+ first_item = nil
469
+
470
+ raw.each do |child|
471
+ next unless child.respond_to?(:type) && %w[list_item item].include?(child.type.to_s)
472
+
473
+ first_item = child
474
+ break
475
+ end
476
+
477
+ return '' unless first_item
478
+
479
+ text = if analysis && first_item.respond_to?(:source_position) && first_item.source_position
480
+ pos = first_item.source_position
481
+ analysis.source_range(pos[:start_line], pos[:end_line]).to_s
482
+ else
483
+ first_item.respond_to?(:text) ? first_item.text.to_s : ''
484
+ end
191
485
 
192
- map
486
+ text
487
+ .strip
488
+ .sub(/\A(?:[-*+]|\d+\.)\s+/, '')
489
+ .gsub(/[^\p{L}\p{N}]+/u, ' ')
490
+ .gsub(/\s+/, ' ')
491
+ .downcase
492
+ .strip
193
493
  end
194
494
  end
195
495
  end