markdown-merge 7.0.0 → 7.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +673 -0
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
- data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
- data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
- data/lib/markdown/merge/cleanse.rb +44 -0
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +742 -0
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +199 -0
- data/lib/markdown/merge/debug_logger.rb +26 -0
- data/lib/markdown/merge/document_problems.rb +190 -0
- data/lib/markdown/merge/file_aligner.rb +496 -0
- data/lib/markdown/merge/file_analysis.rb +689 -0
- data/lib/markdown/merge/file_analysis_base.rb +766 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +142 -0
- data/lib/markdown/merge/link_definition_formatter.rb +49 -0
- data/lib/markdown/merge/link_definition_node.rb +157 -0
- data/lib/markdown/merge/link_parser.rb +421 -0
- data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
- data/lib/markdown/merge/list_match_refiner.rb +98 -0
- data/lib/markdown/merge/list_merger.rb +322 -0
- data/lib/markdown/merge/markdown_structure.rb +123 -0
- data/lib/markdown/merge/merge_result.rb +483 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +248 -0
- data/lib/markdown/merge/partial_template_merger.rb +555 -0
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/smart_merger.rb +269 -0
- data/lib/markdown/merge/smart_merger_base.rb +1490 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/table_match_algorithm.rb +499 -0
- data/lib/markdown/merge/table_match_refiner.rb +132 -0
- data/lib/markdown/merge/version.rb +5 -3
- data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +271 -87
- data/lib/markdown-merge.rb +7 -1
- data/sig/markdown/merge.rbs +62 -0
- data.tar.gz.sig +0 -0
- metadata +289 -15
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Merges two Markdown list nodes at the item level.
|
|
6
|
+
#
|
|
7
|
+
# When a template list and destination list are matched (e.g., via fuzzy matching
|
|
8
|
+
# or a shared content fingerprint), this merger produces a result that is smarter
|
|
9
|
+
# than simply picking one whole list as the winner:
|
|
10
|
+
#
|
|
11
|
+
# - Items that appear in both lists are resolved by preference (template or dest).
|
|
12
|
+
# - Items that only appear in the destination are kept (project customisations).
|
|
13
|
+
# - Items that only appear in the template are added (new canonical steps).
|
|
14
|
+
#
|
|
15
|
+
# Item matching uses significant-token Jaccard overlap so minor wording differences
|
|
16
|
+
# (e.g., "Commit changes" vs "Commit your changes") still produce a match.
|
|
17
|
+
#
|
|
18
|
+
# The merged list is emitted as plain Markdown text (ordered `1. …` lines) and
|
|
19
|
+
# passed to the caller via `add_raw` on the OutputBuilder.
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage
|
|
22
|
+
# merger = ListMerger.new
|
|
23
|
+
# result = merger.merge_lists(template_node, dest_node,
|
|
24
|
+
# preference: :template,
|
|
25
|
+
# add_template_only_nodes: true,
|
|
26
|
+
# template_analysis: t_analysis,
|
|
27
|
+
# dest_analysis: d_analysis)
|
|
28
|
+
# if result[:merged]
|
|
29
|
+
# builder.add_raw(result[:content])
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @see SmartMergerBase#try_inner_merge_list_to_builder
|
|
33
|
+
class ListMerger
|
|
34
|
+
include Ast::Merge::JaccardSimilarity
|
|
35
|
+
|
|
36
|
+
# Minimum Jaccard token overlap to consider two list items as matching.
|
|
37
|
+
ITEM_MATCH_THRESHOLD = 0.35
|
|
38
|
+
|
|
39
|
+
# Merge two list nodes.
|
|
40
|
+
#
|
|
41
|
+
# @param template_node [Object] Template list node (tree_haver / Markly node)
|
|
42
|
+
# @param dest_node [Object] Destination list node
|
|
43
|
+
# @param preference [Symbol] :template or :destination — which wins for matched items
|
|
44
|
+
# @param add_template_only_nodes [Boolean] Whether to append template-only items
|
|
45
|
+
# @param template_analysis [FileAnalysisBase] Template file analysis (for source text)
|
|
46
|
+
# @param dest_analysis [FileAnalysisBase] Destination file analysis (for source text)
|
|
47
|
+
# @return [Hash] { merged: Boolean, content: String } or { merged: false, reason: String }
|
|
48
|
+
def merge_lists(template_node, dest_node,
|
|
49
|
+
preference:,
|
|
50
|
+
add_template_only_nodes: true,
|
|
51
|
+
template_analysis: nil,
|
|
52
|
+
dest_analysis: nil,
|
|
53
|
+
resolution_mode: :eager,
|
|
54
|
+
unresolved_policy: nil)
|
|
55
|
+
t_items = extract_items(template_node)
|
|
56
|
+
d_items = extract_items(dest_node)
|
|
57
|
+
|
|
58
|
+
return not_merged('empty list') if t_items.empty? && d_items.empty?
|
|
59
|
+
|
|
60
|
+
alignment = align_items(t_items, d_items)
|
|
61
|
+
lines, unresolved_cases = emit_lines(
|
|
62
|
+
alignment,
|
|
63
|
+
template_node: template_node,
|
|
64
|
+
dest_node: dest_node,
|
|
65
|
+
preference: preference,
|
|
66
|
+
add_template_only: add_template_only_nodes,
|
|
67
|
+
template_analysis: template_analysis,
|
|
68
|
+
dest_analysis: dest_analysis,
|
|
69
|
+
resolution_mode: resolution_mode,
|
|
70
|
+
unresolved_policy: Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
|
|
71
|
+
)
|
|
72
|
+
return not_merged('no lines emitted') if lines.empty?
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
merged: true,
|
|
76
|
+
content: lines.join("\n") + "\n",
|
|
77
|
+
stats: { decision: unresolved_cases.empty? ? :merged : :unresolved },
|
|
78
|
+
unresolved_cases: unresolved_cases
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# --- item extraction ---
|
|
85
|
+
|
|
86
|
+
def extract_items(list_node)
|
|
87
|
+
raw = Ast::Merge::NodeTyping.unwrap(list_node)
|
|
88
|
+
children =
|
|
89
|
+
if raw.respond_to?(:to_a)
|
|
90
|
+
raw.to_a
|
|
91
|
+
elsif raw.respond_to?(:children)
|
|
92
|
+
raw.children
|
|
93
|
+
elsif raw.respond_to?(:each)
|
|
94
|
+
raw.each.to_a
|
|
95
|
+
else
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
children.select do |item|
|
|
100
|
+
item.respond_to?(:type) && %w[list_item item].include?(item.type.to_s)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- alignment ---
|
|
105
|
+
|
|
106
|
+
# Produce an ordered array of alignment entries, each one of:
|
|
107
|
+
# { type: :match, template_item: …, dest_item: … }
|
|
108
|
+
# { type: :dest_only, dest_item: … }
|
|
109
|
+
# { type: :template_only, template_item: … }
|
|
110
|
+
def align_items(t_items, d_items)
|
|
111
|
+
t_tokens = t_items.map { |i| item_tokens(i) }
|
|
112
|
+
d_tokens = d_items.map { |i| item_tokens(i) }
|
|
113
|
+
|
|
114
|
+
matched_t = Set.new
|
|
115
|
+
matched_d = Set.new
|
|
116
|
+
candidates = []
|
|
117
|
+
|
|
118
|
+
t_items.each_with_index do |_t_item, ti|
|
|
119
|
+
d_items.each_with_index do |_d_item, di|
|
|
120
|
+
score = jaccard(t_tokens[ti], d_tokens[di])
|
|
121
|
+
next if score < ITEM_MATCH_THRESHOLD
|
|
122
|
+
|
|
123
|
+
candidates << { score: score, ti: ti, di: di }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Greedy best-first matching
|
|
128
|
+
matches = {} # ti => di
|
|
129
|
+
reverse = {} # di => ti
|
|
130
|
+
candidates.sort_by { |c| -c[:score] }.each do |c|
|
|
131
|
+
next if matched_t.include?(c[:ti]) || matched_d.include?(c[:di])
|
|
132
|
+
|
|
133
|
+
matches[c[:ti]] = c[:di]
|
|
134
|
+
reverse[c[:di]] = c[:ti]
|
|
135
|
+
matched_t << c[:ti]
|
|
136
|
+
matched_d << c[:di]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Walk destination order, interleaving template-only items before the
|
|
140
|
+
# dest item they were adjacent to in the template.
|
|
141
|
+
result = []
|
|
142
|
+
inserted_t = Set.new
|
|
143
|
+
|
|
144
|
+
d_items.each_with_index do |d_item, di|
|
|
145
|
+
# Before this dest item, insert any template-only items whose nearest
|
|
146
|
+
# matched template neighbour falls before this point.
|
|
147
|
+
t_items.each_with_index do |t_item, ti|
|
|
148
|
+
next if matched_t.include?(ti) || inserted_t.include?(ti)
|
|
149
|
+
|
|
150
|
+
# Find the first matched dest index for template items after ti
|
|
151
|
+
next_matched_di = (ti + 1..t_items.size - 1).find { |k| matches.key?(k) }&.then { |k| matches[k] }
|
|
152
|
+
insert_before = next_matched_di.nil? ? d_items.size : next_matched_di
|
|
153
|
+
next if insert_before > di
|
|
154
|
+
|
|
155
|
+
result << { type: :template_only, template_item: t_item }
|
|
156
|
+
inserted_t << ti
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if reverse.key?(di)
|
|
160
|
+
ti = reverse[di]
|
|
161
|
+
result << { type: :match, template_item: t_items[ti], dest_item: d_item }
|
|
162
|
+
else
|
|
163
|
+
result << { type: :dest_only, dest_item: d_item }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Append remaining template-only items that come after the last dest item
|
|
168
|
+
t_items.each_with_index do |t_item, ti|
|
|
169
|
+
next if matched_t.include?(ti) || inserted_t.include?(ti)
|
|
170
|
+
|
|
171
|
+
result << { type: :template_only, template_item: t_item }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
result
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# --- emission ---
|
|
178
|
+
|
|
179
|
+
def emit_lines(alignment, template_node:, dest_node:, preference:, add_template_only:,
|
|
180
|
+
template_analysis:, dest_analysis:, resolution_mode:, unresolved_policy:)
|
|
181
|
+
counter = 1
|
|
182
|
+
lines = []
|
|
183
|
+
unresolved_cases = []
|
|
184
|
+
current_offset = 0
|
|
185
|
+
|
|
186
|
+
alignment.each do |entry|
|
|
187
|
+
case entry[:type]
|
|
188
|
+
when :match
|
|
189
|
+
template_text = item_bare_text(entry[:template_item], template_analysis)
|
|
190
|
+
dest_text = item_bare_text(entry[:dest_item], dest_analysis)
|
|
191
|
+
if unresolved_match?(template_text, dest_text, resolution_mode: resolution_mode,
|
|
192
|
+
unresolved_policy: unresolved_policy)
|
|
193
|
+
provisional_winner = unresolved_policy.provisional_winner_for(:matched_list_item, fallback: preference)
|
|
194
|
+
selected_text = provisional_winner == :template ? template_text : dest_text
|
|
195
|
+
line = format_list_line(counter, selected_text)
|
|
196
|
+
lines << line
|
|
197
|
+
unresolved_cases << build_unresolved_case(
|
|
198
|
+
template_node: template_node,
|
|
199
|
+
dest_node: dest_node,
|
|
200
|
+
template_item: entry[:template_item],
|
|
201
|
+
dest_item: entry[:dest_item],
|
|
202
|
+
template_text: format_list_line(counter, template_text),
|
|
203
|
+
dest_text: format_list_line(counter, dest_text),
|
|
204
|
+
provisional_winner: provisional_winner,
|
|
205
|
+
output_range: [current_offset, current_offset + line.bytesize],
|
|
206
|
+
list_index: counter
|
|
207
|
+
)
|
|
208
|
+
else
|
|
209
|
+
node = preference == :template ? entry[:template_item] : entry[:dest_item]
|
|
210
|
+
analysis = preference == :template ? template_analysis : dest_analysis
|
|
211
|
+
text = item_bare_text(node, analysis)
|
|
212
|
+
lines << format_list_line(counter, text)
|
|
213
|
+
end
|
|
214
|
+
counter += 1
|
|
215
|
+
current_offset += lines.last.bytesize + 1
|
|
216
|
+
when :dest_only
|
|
217
|
+
text = item_bare_text(entry[:dest_item], dest_analysis)
|
|
218
|
+
lines << format_list_line(counter, text)
|
|
219
|
+
counter += 1
|
|
220
|
+
current_offset += lines.last.bytesize + 1
|
|
221
|
+
when :template_only
|
|
222
|
+
next unless add_template_only
|
|
223
|
+
|
|
224
|
+
text = item_bare_text(entry[:template_item], template_analysis)
|
|
225
|
+
lines << format_list_line(counter, text)
|
|
226
|
+
counter += 1
|
|
227
|
+
current_offset += lines.last.bytesize + 1
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
[lines, unresolved_cases]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Return the bare inline text of a list_item node (without the leading `1. ` marker).
|
|
235
|
+
def item_bare_text(item, analysis)
|
|
236
|
+
# Prefer source extraction for fidelity (preserves links, code spans, etc.)
|
|
237
|
+
if analysis && item.respond_to?(:source_position)
|
|
238
|
+
pos = item.source_position
|
|
239
|
+
if pos
|
|
240
|
+
raw = analysis.source_range(pos[:start_line], pos[:end_line]).strip
|
|
241
|
+
# Strip the ordered-list marker: `1. `, `2. `, `123. ` etc.
|
|
242
|
+
return raw.sub(/\A\d+\.\s+/, '')
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Fallback: use .text and strip marker
|
|
247
|
+
item.text.to_s.strip.sub(/\A\d+\.\s+/, '')
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# --- token helpers ---
|
|
251
|
+
|
|
252
|
+
def item_tokens(item)
|
|
253
|
+
text = item.respond_to?(:text) ? item.text.to_s : ''
|
|
254
|
+
extract_tokens(text)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def unresolved_match?(template_text, dest_text, resolution_mode:, unresolved_policy:)
|
|
258
|
+
resolution_mode.to_sym == :unresolved &&
|
|
259
|
+
unresolved_policy.unresolved_for?(:matched_list_item) &&
|
|
260
|
+
template_text != dest_text
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def format_list_line(counter, text)
|
|
264
|
+
"#{counter}. #{text}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def build_unresolved_case(template_node:, dest_node:, template_item:, dest_item:, template_text:, dest_text:,
|
|
268
|
+
provisional_winner:, output_range:, list_index:)
|
|
269
|
+
template_lines = source_span_for(template_item)
|
|
270
|
+
dest_lines = source_span_for(dest_item)
|
|
271
|
+
reference_line = template_lines&.first || dest_lines&.first || list_index
|
|
272
|
+
case_id = "markdown-matched_list_item-#{reference_line}-#{list_index}"
|
|
273
|
+
|
|
274
|
+
Ast::Merge::Runtime::ResolutionCase.new(
|
|
275
|
+
case_id: case_id,
|
|
276
|
+
reason: :conflict,
|
|
277
|
+
candidates: {
|
|
278
|
+
template: template_text,
|
|
279
|
+
destination: dest_text
|
|
280
|
+
},
|
|
281
|
+
provisional_winner: provisional_winner,
|
|
282
|
+
surface_path: "#{list_surface_path(template_node, dest_node)} > matched_list_item[index=#{list_index}]",
|
|
283
|
+
metadata: {
|
|
284
|
+
template_lines: template_lines,
|
|
285
|
+
destination_lines: dest_lines,
|
|
286
|
+
match_kind: :matched_list_item,
|
|
287
|
+
list_index: list_index,
|
|
288
|
+
relative_output_range: output_range
|
|
289
|
+
}.compact
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def list_surface_path(template_node, dest_node)
|
|
294
|
+
template_lines = source_span_for(template_node)
|
|
295
|
+
dest_lines = source_span_for(dest_node)
|
|
296
|
+
start_line, end_line = template_lines || dest_lines
|
|
297
|
+
|
|
298
|
+
if start_line && end_line
|
|
299
|
+
"document[0] > list[L#{start_line}-L#{end_line}]"
|
|
300
|
+
else
|
|
301
|
+
'document[0] > list'
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def source_span_for(node)
|
|
306
|
+
raw = Ast::Merge::NodeTyping.unwrap(node)
|
|
307
|
+
return unless raw.respond_to?(:source_position)
|
|
308
|
+
|
|
309
|
+
position = raw.source_position
|
|
310
|
+
start_line = position&.dig(:start_line)
|
|
311
|
+
end_line = position&.dig(:end_line)
|
|
312
|
+
return unless start_line && end_line
|
|
313
|
+
|
|
314
|
+
[start_line, end_line]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def not_merged(reason)
|
|
318
|
+
{ merged: false, reason: reason }
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Defines structural spacing rules for markdown elements.
|
|
6
|
+
#
|
|
7
|
+
# When merging markdown from different sources, gap lines from the original
|
|
8
|
+
# sources may not exist at transition points (e.g., when a dest-only table
|
|
9
|
+
# is followed by a template-only table). This module defines which node types
|
|
10
|
+
# require spacing before/after them for proper markdown formatting.
|
|
11
|
+
#
|
|
12
|
+
# Node types are categorized by their spacing needs:
|
|
13
|
+
# - NEEDS_BLANK_BEFORE: Nodes that need a blank line before them (headings, tables, etc.)
|
|
14
|
+
# - NEEDS_BLANK_AFTER: Nodes that need a blank line after them
|
|
15
|
+
# - CONTIGUOUS_TYPES: Nodes that should NOT have blank lines between consecutive instances
|
|
16
|
+
# (e.g., link_definition blocks should be together)
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# MarkdownStructure.needs_blank_before?(:table) # => true
|
|
20
|
+
# MarkdownStructure.needs_blank_after?(:heading) # => true
|
|
21
|
+
# MarkdownStructure.contiguous_type?(:link_definition) # => true
|
|
22
|
+
module MarkdownStructure
|
|
23
|
+
# Node types that should have a blank line BEFORE them
|
|
24
|
+
# (when preceded by other content)
|
|
25
|
+
NEEDS_BLANK_BEFORE = %i[
|
|
26
|
+
heading
|
|
27
|
+
paragraph
|
|
28
|
+
table
|
|
29
|
+
code_block
|
|
30
|
+
thematic_break
|
|
31
|
+
list
|
|
32
|
+
block_quote
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Node types that should have a blank line AFTER them
|
|
36
|
+
# (when followed by other content)
|
|
37
|
+
NEEDS_BLANK_AFTER = %i[
|
|
38
|
+
heading
|
|
39
|
+
paragraph
|
|
40
|
+
table
|
|
41
|
+
code_block
|
|
42
|
+
thematic_break
|
|
43
|
+
list
|
|
44
|
+
block_quote
|
|
45
|
+
link_definition
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
# Node types that should be contiguous (no blank lines between consecutive
|
|
49
|
+
# nodes of the same type). These form "blocks" that should stay together.
|
|
50
|
+
CONTIGUOUS_TYPES = %i[
|
|
51
|
+
link_definition
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Check if a node type needs a blank line before it
|
|
56
|
+
#
|
|
57
|
+
# @param node_type [Symbol, String] Node type to check
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def needs_blank_before?(node_type)
|
|
60
|
+
NEEDS_BLANK_BEFORE.include?(node_type.to_sym)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check if a node type needs a blank line after it
|
|
64
|
+
#
|
|
65
|
+
# @param node_type [Symbol, String] Node type to check
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def needs_blank_after?(node_type)
|
|
68
|
+
NEEDS_BLANK_AFTER.include?(node_type.to_sym)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if a node type is a contiguous type (should not have blank lines
|
|
72
|
+
# between consecutive nodes of the same type).
|
|
73
|
+
#
|
|
74
|
+
# @param node_type [Symbol, String] Node type to check
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def contiguous_type?(node_type)
|
|
77
|
+
CONTIGUOUS_TYPES.include?(node_type.to_sym)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if we should insert a blank line between two node types
|
|
81
|
+
#
|
|
82
|
+
# Rules:
|
|
83
|
+
# 1. If both types are the same contiguous type, NO blank line
|
|
84
|
+
# 2. If previous node needs blank after, YES blank line
|
|
85
|
+
# 3. If next node needs blank before, YES blank line
|
|
86
|
+
#
|
|
87
|
+
# @param prev_type [Symbol, String, nil] Previous node type
|
|
88
|
+
# @param next_type [Symbol, String, nil] Next node type
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def needs_blank_between?(prev_type, next_type)
|
|
91
|
+
return false if prev_type.nil? || next_type.nil?
|
|
92
|
+
|
|
93
|
+
prev_sym = prev_type.to_sym
|
|
94
|
+
next_sym = next_type.to_sym
|
|
95
|
+
|
|
96
|
+
# Same contiguous type - no blank line between them
|
|
97
|
+
return false if prev_sym == next_sym && contiguous_type?(prev_sym)
|
|
98
|
+
|
|
99
|
+
needs_blank_after?(prev_sym) || needs_blank_before?(next_sym)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get the node type from a node object
|
|
103
|
+
#
|
|
104
|
+
# Priority order:
|
|
105
|
+
# 1. merge_type - Explicit merge behavior classification (preferred)
|
|
106
|
+
# 2. type - Parser-specific type fallback
|
|
107
|
+
#
|
|
108
|
+
# @param node [Object] Node to get type from
|
|
109
|
+
# @return [Symbol, nil] Node type
|
|
110
|
+
def node_type(node)
|
|
111
|
+
return unless node
|
|
112
|
+
|
|
113
|
+
# Prefer merge_type when available - it's the explicit merge behavior classifier
|
|
114
|
+
if node.respond_to?(:merge_type)
|
|
115
|
+
node.merge_type.to_sym
|
|
116
|
+
elsif node.respond_to?(:type)
|
|
117
|
+
node.type.to_sym
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|