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,555 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Markdown-specific implementation of PartialTemplateMerger.
|
|
6
|
+
#
|
|
7
|
+
# Merges a partial template into a specific section of a destination markdown document.
|
|
8
|
+
# This class extends the parser-agnostic base with markdown-specific logic for:
|
|
9
|
+
# - Heading-level-aware section boundaries
|
|
10
|
+
# - Source-based text extraction to preserve link references and table formatting
|
|
11
|
+
# - Backend-specific parser initialization (Markly, Commonmarker)
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# merger = Markdown::Merge::PartialTemplateMerger.new(
|
|
15
|
+
# template: template_content,
|
|
16
|
+
# destination: destination_content,
|
|
17
|
+
# anchor: { type: :heading, text: /Gem Family/ },
|
|
18
|
+
# backend: :markly
|
|
19
|
+
# )
|
|
20
|
+
# result = merger.merge
|
|
21
|
+
# puts result.content
|
|
22
|
+
#
|
|
23
|
+
# @example With boundary
|
|
24
|
+
# merger = Markdown::Merge::PartialTemplateMerger.new(
|
|
25
|
+
# template: template_content,
|
|
26
|
+
# destination: destination_content,
|
|
27
|
+
# anchor: { type: :heading, text: /Installation/ },
|
|
28
|
+
# boundary: { type: :heading }, # Stop at next heading
|
|
29
|
+
# backend: :markly
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
class PartialTemplateMerger < Ast::Merge::PartialTemplateMergerBase
|
|
33
|
+
include PreservationSupport
|
|
34
|
+
|
|
35
|
+
# Re-export Result class from base for convenience
|
|
36
|
+
Result = Ast::Merge::PartialTemplateMergerBase::Result
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def default_backend
|
|
40
|
+
:markly
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def file_analysis_class
|
|
44
|
+
FileAnalysis
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def smart_merger_class
|
|
48
|
+
SmartMerger
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Symbol] Backend to use (:markly, :commonmarker)
|
|
53
|
+
attr_reader :backend
|
|
54
|
+
|
|
55
|
+
# Initialize a markdown PartialTemplateMerger.
|
|
56
|
+
#
|
|
57
|
+
# @param template [String] The template content (the section to merge in)
|
|
58
|
+
# @param destination [String] The destination content
|
|
59
|
+
# @param anchor [Hash] Anchor matcher: { type: :heading, text: /pattern/ }
|
|
60
|
+
# @param boundary [Hash, nil] Boundary matcher (defaults to same type as anchor)
|
|
61
|
+
# @param backend [Symbol] Backend to use (:markly, :commonmarker)
|
|
62
|
+
# @param preference [Symbol, Hash] Which content wins (:template, :destination, or per-type hash)
|
|
63
|
+
# @param add_missing [Boolean, Proc] Whether to add template nodes not in destination
|
|
64
|
+
# @param when_missing [Symbol] What to do if section not found (:skip, :append, :prepend)
|
|
65
|
+
# @param replace_mode [Boolean] If true, template replaces section entirely (no merge)
|
|
66
|
+
# @param signature_generator [Proc, nil] Custom signature generator for SmartMerger
|
|
67
|
+
# @param node_typing [Hash, nil] Node typing configuration for per-type preferences
|
|
68
|
+
# @param match_refiner [Object, nil] Match refiner for fuzzy matching (e.g., ContentMatchRefiner)
|
|
69
|
+
# @param normalize_whitespace [Boolean] If true, collapse excessive blank lines. Default: false
|
|
70
|
+
# @param rehydrate_link_references [Boolean] If true, convert inline links to reference style. Default: false
|
|
71
|
+
def initialize(
|
|
72
|
+
template:,
|
|
73
|
+
destination:,
|
|
74
|
+
anchor:,
|
|
75
|
+
boundary: nil,
|
|
76
|
+
backend: self.class.default_backend,
|
|
77
|
+
preference: :template,
|
|
78
|
+
add_missing: true,
|
|
79
|
+
when_missing: :skip,
|
|
80
|
+
replace_mode: false,
|
|
81
|
+
signature_generator: nil,
|
|
82
|
+
node_typing: nil,
|
|
83
|
+
match_refiner: nil,
|
|
84
|
+
normalize_whitespace: false,
|
|
85
|
+
rehydrate_link_references: false
|
|
86
|
+
)
|
|
87
|
+
validate_backend!(backend)
|
|
88
|
+
@backend = backend
|
|
89
|
+
@normalize_whitespace = normalize_whitespace
|
|
90
|
+
@rehydrate_link_references = rehydrate_link_references
|
|
91
|
+
super(
|
|
92
|
+
template: template,
|
|
93
|
+
destination: destination,
|
|
94
|
+
anchor: anchor,
|
|
95
|
+
boundary: boundary,
|
|
96
|
+
preference: preference,
|
|
97
|
+
add_missing: add_missing,
|
|
98
|
+
when_missing: when_missing,
|
|
99
|
+
replace_mode: replace_mode,
|
|
100
|
+
signature_generator: signature_generator,
|
|
101
|
+
node_typing: node_typing,
|
|
102
|
+
match_refiner: match_refiner,
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Perform the partial template merge with post-processing.
|
|
107
|
+
#
|
|
108
|
+
# @return [Result] The merge result
|
|
109
|
+
def merge
|
|
110
|
+
result = super
|
|
111
|
+
|
|
112
|
+
# Apply post-processing if enabled
|
|
113
|
+
if result.changed && (@normalize_whitespace || @rehydrate_link_references)
|
|
114
|
+
content = result.content
|
|
115
|
+
problems = DocumentProblems.new
|
|
116
|
+
|
|
117
|
+
if @normalize_whitespace
|
|
118
|
+
normalizer = WhitespaceNormalizer.new(content)
|
|
119
|
+
content = normalizer.normalize
|
|
120
|
+
problems.merge!(normalizer.problems)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if @rehydrate_link_references
|
|
124
|
+
rehydrator = LinkReferenceRehydrator.new(content)
|
|
125
|
+
content = rehydrator.rehydrate
|
|
126
|
+
problems.merge!(rehydrator.problems)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Return new result with transformed content and problems
|
|
130
|
+
Result.new(
|
|
131
|
+
content: content,
|
|
132
|
+
has_section: result.has_section,
|
|
133
|
+
changed: result.changed,
|
|
134
|
+
stats: result.stats.merge(problems: problems.all),
|
|
135
|
+
injection_point: result.injection_point,
|
|
136
|
+
message: result.message
|
|
137
|
+
)
|
|
138
|
+
else
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
protected
|
|
144
|
+
|
|
145
|
+
def merge_section_content(section_content, section_context: nil)
|
|
146
|
+
return super unless replace_mode?
|
|
147
|
+
|
|
148
|
+
template_analysis = create_analysis(template)
|
|
149
|
+
preserved_fragment_insertions = preserved_destination_insertions(
|
|
150
|
+
create_analysis(section_content),
|
|
151
|
+
template_analysis,
|
|
152
|
+
source_remove_plan: section_context&.fetch(:source_remove_plan, nil),
|
|
153
|
+
destination_section_statements: section_context&.fetch(:section_statements, nil),
|
|
154
|
+
destination_section_analysis: section_context&.fetch(:analysis, nil)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return [template, { mode: :replace }] if preserved_fragment_insertions.empty?
|
|
158
|
+
|
|
159
|
+
preservation_stats = replace_mode_preservation_stats(preserved_fragment_insertions)
|
|
160
|
+
|
|
161
|
+
[
|
|
162
|
+
render_template_with_preserved_insertions(template_analysis, preserved_fragment_insertions),
|
|
163
|
+
preservation_stats
|
|
164
|
+
]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Validate the backend parameter.
|
|
168
|
+
#
|
|
169
|
+
# @param backend [Symbol] The backend to validate
|
|
170
|
+
# @raise [ArgumentError] If backend is not supported
|
|
171
|
+
def validate_backend!(backend)
|
|
172
|
+
valid_backends = %i[auto markly commonmarker kramdown]
|
|
173
|
+
return if valid_backends.include?(backend.to_sym)
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(', ')}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Create a FileAnalysis for the given content.
|
|
179
|
+
#
|
|
180
|
+
# @param content [String] The content to analyze
|
|
181
|
+
# @return [FileAnalysis] A FileAnalysis instance
|
|
182
|
+
def create_analysis(content)
|
|
183
|
+
self.class.file_analysis_class.new(content, backend: backend)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Create a SmartMerger for merging the section.
|
|
187
|
+
#
|
|
188
|
+
# @param template_content [String] The template content
|
|
189
|
+
# @param destination_content [String] The destination section content
|
|
190
|
+
# @return [SmartMerger] A SmartMerger instance
|
|
191
|
+
def create_smart_merger(template_content, destination_content)
|
|
192
|
+
# Build options hash, only including non-nil values
|
|
193
|
+
options = {
|
|
194
|
+
preference: preference,
|
|
195
|
+
add_template_only_nodes: add_missing,
|
|
196
|
+
backend: backend
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Use custom signature generator if provided, otherwise use position-based
|
|
200
|
+
# table matching to ensure tables with different structures still match
|
|
201
|
+
# within a section merge context.
|
|
202
|
+
options[:signature_generator] = signature_generator || build_position_based_signature_generator
|
|
203
|
+
|
|
204
|
+
options[:node_typing] = node_typing if node_typing
|
|
205
|
+
options[:match_refiner] = match_refiner if match_refiner
|
|
206
|
+
|
|
207
|
+
self.class.smart_merger_class.new(template_content, destination_content, **options)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Build a signature generator that uses type-based matching for tables.
|
|
211
|
+
#
|
|
212
|
+
# This ensures that tables within a section are matched by type alone,
|
|
213
|
+
# allowing template tables to replace destination tables regardless of
|
|
214
|
+
# their exact structure (different headers, columns, etc.).
|
|
215
|
+
#
|
|
216
|
+
# In the context of partial template merging, this is the desired behavior:
|
|
217
|
+
# - Sections typically contain one table of each logical role
|
|
218
|
+
# - Template table should replace the destination table
|
|
219
|
+
# - Different table structures should still match by ordinal position
|
|
220
|
+
#
|
|
221
|
+
# The algorithm uses a stateless approach that assigns the same signature
|
|
222
|
+
# to all tables. Since PartialTemplateMerger merges **one section at a time**,
|
|
223
|
+
# each section typically has few tables, and the first table in template
|
|
224
|
+
# will match and replace the first table in destination.
|
|
225
|
+
#
|
|
226
|
+
# For more precise control over multiple tables within a section, provide
|
|
227
|
+
# a custom signature_generator.
|
|
228
|
+
#
|
|
229
|
+
# @return [Proc] A signature generator proc
|
|
230
|
+
def build_position_based_signature_generator
|
|
231
|
+
# Simple stateless approach: all tables get the same base signature.
|
|
232
|
+
# When preference is :template, this causes template table to replace
|
|
233
|
+
# destination table, which is the desired behavior.
|
|
234
|
+
#
|
|
235
|
+
# NOTE: If a section has multiple tables, they will ALL match each other,
|
|
236
|
+
# potentially causing unexpected behavior. For such cases, users should
|
|
237
|
+
# provide a custom signature_generator.
|
|
238
|
+
lambda do |node|
|
|
239
|
+
type_str = node.type.to_s
|
|
240
|
+
if type_str == 'table'
|
|
241
|
+
# All tables within a section merge get the same signature.
|
|
242
|
+
# This ensures template table replaces destination table.
|
|
243
|
+
%i[table section_table]
|
|
244
|
+
else
|
|
245
|
+
# Return node for default signature computation
|
|
246
|
+
node
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Find where the section ends.
|
|
252
|
+
#
|
|
253
|
+
# For headings, finds the next heading of same or higher level.
|
|
254
|
+
# For other node types, finds the next node of the same type.
|
|
255
|
+
#
|
|
256
|
+
# NOTE: For headings, we ALWAYS use heading-level-aware logic, ignoring
|
|
257
|
+
# any boundary from InjectionPointFinder. This is because InjectionPointFinder
|
|
258
|
+
# uses tree_depth for boundary detection, but in Markdown all headings are
|
|
259
|
+
# siblings at the same tree depth regardless of their level (H2, H3, H4 etc).
|
|
260
|
+
# Heading level semantics require comparing the actual heading level numbers.
|
|
261
|
+
#
|
|
262
|
+
# @param statements [Array<Navigable::Statement>] All statements
|
|
263
|
+
# @param injection_point [Navigable::InjectionPoint] The injection point
|
|
264
|
+
# @return [Integer] Index of the last statement in the section
|
|
265
|
+
def find_section_end(statements, injection_point)
|
|
266
|
+
anchor = injection_point.anchor
|
|
267
|
+
anchor_type = anchor.type
|
|
268
|
+
|
|
269
|
+
# For headings, ALWAYS use heading-level-aware logic
|
|
270
|
+
# This overrides any boundary from InjectionPointFinder because tree_depth
|
|
271
|
+
# doesn't reflect heading level semantics in Markdown
|
|
272
|
+
if heading_type?(anchor_type)
|
|
273
|
+
anchor_level = get_heading_level(anchor)
|
|
274
|
+
|
|
275
|
+
((anchor.index + 1)...statements.length).each do |idx|
|
|
276
|
+
stmt = statements[idx]
|
|
277
|
+
next unless heading_type?(stmt.type)
|
|
278
|
+
|
|
279
|
+
stmt_level = get_heading_level(stmt)
|
|
280
|
+
if stmt_level && anchor_level && stmt_level <= anchor_level
|
|
281
|
+
# Found next heading of same or higher level - section ends before it
|
|
282
|
+
return idx - 1
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# No boundary heading found - section extends to end of document
|
|
287
|
+
return statements.length - 1
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# For non-headings, use boundary if specified and found
|
|
291
|
+
return injection_point.boundary.index - 1 if injection_point.boundary
|
|
292
|
+
|
|
293
|
+
# Otherwise, find next node of same type
|
|
294
|
+
((anchor.index + 1)...statements.length).each do |idx|
|
|
295
|
+
stmt = statements[idx]
|
|
296
|
+
return idx - 1 if stmt.type == anchor_type
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Section extends to end of document
|
|
300
|
+
statements.length - 1
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Convert a node to its source text.
|
|
304
|
+
#
|
|
305
|
+
# Prefers source-based extraction to preserve original formatting
|
|
306
|
+
# (link references, table padding, etc.). Falls back to to_commonmark.
|
|
307
|
+
#
|
|
308
|
+
# @param node [Object] The node to convert
|
|
309
|
+
# @param analysis [FileAnalysis, nil] The analysis object for source lookup
|
|
310
|
+
# @return [String] The source text
|
|
311
|
+
def node_to_source(node, analysis = nil)
|
|
312
|
+
# Unwrap if needed
|
|
313
|
+
inner = node
|
|
314
|
+
inner = inner.inner_node while inner.respond_to?(:inner_node) && inner.inner_node != inner
|
|
315
|
+
|
|
316
|
+
# Prefer source-based extraction to preserve original formatting
|
|
317
|
+
# (link references, table padding, etc.)
|
|
318
|
+
if analysis&.respond_to?(:source_range)
|
|
319
|
+
pos = inner.source_position if inner.respond_to?(:source_position)
|
|
320
|
+
if pos
|
|
321
|
+
start_line = pos[:start_line]
|
|
322
|
+
end_line = pos[:end_line]
|
|
323
|
+
if start_line && end_line && start_line > 0
|
|
324
|
+
source_text = analysis.source_range(start_line, end_line)
|
|
325
|
+
# source_range already adds trailing newlines, don't add another
|
|
326
|
+
return source_text unless source_text.empty?
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Fallback to to_commonmark (for nodes without source position)
|
|
332
|
+
if inner.respond_to?(:to_commonmark)
|
|
333
|
+
inner.to_commonmark.to_s
|
|
334
|
+
elsif inner.respond_to?(:to_s)
|
|
335
|
+
inner.to_s
|
|
336
|
+
else
|
|
337
|
+
''
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
alias node_to_text node_to_source
|
|
342
|
+
|
|
343
|
+
private
|
|
344
|
+
|
|
345
|
+
def replace_mode_preservation_stats(insertions)
|
|
346
|
+
comment_count = 0
|
|
347
|
+
link_definition_count = 0
|
|
348
|
+
|
|
349
|
+
insertions.each_value do |fragments|
|
|
350
|
+
fragments.each do |fragment|
|
|
351
|
+
case fragment[:kind]
|
|
352
|
+
when :standalone_comment
|
|
353
|
+
comment_count += 1
|
|
354
|
+
when :link_definition
|
|
355
|
+
link_definition_count += 1
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
{
|
|
361
|
+
mode: :replace,
|
|
362
|
+
preserved_destination_comment_fragments: comment_count,
|
|
363
|
+
preserved_destination_link_definitions: link_definition_count
|
|
364
|
+
}.reject { |_key, value| value == 0 }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def preserved_destination_insertions(destination_analysis, template_analysis, source_remove_plan: nil,
|
|
368
|
+
destination_section_statements: nil, destination_section_analysis: nil)
|
|
369
|
+
insertions = Hash.new { |hash, key| hash[key] = [] }
|
|
370
|
+
remove_plan_owned_comment_region_keys = if source_remove_plan
|
|
371
|
+
rebase_preserved_comment_keys(
|
|
372
|
+
remove_plan_preserved_comment_keys(source_remove_plan),
|
|
373
|
+
line_offset: source_remove_plan.remove_start_line - 1
|
|
374
|
+
)
|
|
375
|
+
else
|
|
376
|
+
Set.new
|
|
377
|
+
end
|
|
378
|
+
template_has_standalone_comments = template_analysis.statements.any? do |statement|
|
|
379
|
+
standalone_comment_node?(statement, template_analysis)
|
|
380
|
+
end
|
|
381
|
+
template_link_definition_signatures = template_analysis.statements.each_with_object(Set.new) do |statement, signatures|
|
|
382
|
+
signatures << statement.signature if link_definition_node?(statement)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
preserve_comment_insertions_from_remove_plan(
|
|
386
|
+
insertions,
|
|
387
|
+
source_remove_plan,
|
|
388
|
+
destination_section_statements,
|
|
389
|
+
destination_section_analysis || destination_analysis,
|
|
390
|
+
template_has_standalone_comments: template_has_standalone_comments
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
structural_index = 0
|
|
394
|
+
pending_gap_count = 0
|
|
395
|
+
|
|
396
|
+
destination_analysis.statements.each do |statement|
|
|
397
|
+
if structural_preservation_statement?(statement, destination_analysis)
|
|
398
|
+
structural_index += 1
|
|
399
|
+
pending_gap_count = 0
|
|
400
|
+
elsif gap_line_node?(statement)
|
|
401
|
+
pending_gap_count += 1 if insertions[structural_index].any?
|
|
402
|
+
else
|
|
403
|
+
next if remove_plan_owns_comment_node?(
|
|
404
|
+
statement,
|
|
405
|
+
destination_analysis,
|
|
406
|
+
source_remove_plan,
|
|
407
|
+
preserved_comment_keys: remove_plan_owned_comment_region_keys
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
fragment = preserved_fragment_for_node(
|
|
411
|
+
statement,
|
|
412
|
+
destination_analysis,
|
|
413
|
+
template_has_standalone_comments: template_has_standalone_comments,
|
|
414
|
+
template_link_definition_signatures: template_link_definition_signatures
|
|
415
|
+
)
|
|
416
|
+
next unless fragment
|
|
417
|
+
|
|
418
|
+
append_preserved_fragment(insertions, structural_index, fragment, gap_count: pending_gap_count)
|
|
419
|
+
pending_gap_count = 0
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
insertions.reject { |_index, fragments| fragments.empty? }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def preserve_comment_insertions_from_remove_plan(insertions, source_remove_plan, destination_section_statements,
|
|
427
|
+
destination_section_analysis, template_has_standalone_comments:)
|
|
428
|
+
return if template_has_standalone_comments
|
|
429
|
+
return unless source_remove_plan && destination_section_statements && destination_section_analysis
|
|
430
|
+
|
|
431
|
+
structural_index_by_owner, final_structural_index = structural_index_lookup(destination_section_statements,
|
|
432
|
+
destination_section_analysis)
|
|
433
|
+
remove_plan_comment_insertion_specs(
|
|
434
|
+
source_remove_plan,
|
|
435
|
+
insertion_index_by_owner: structural_index_by_owner,
|
|
436
|
+
final_insertion_index: final_structural_index
|
|
437
|
+
).each do |spec|
|
|
438
|
+
append_preserved_fragment(
|
|
439
|
+
insertions,
|
|
440
|
+
spec.fetch(:insertion_index),
|
|
441
|
+
spec.fetch(:fragment),
|
|
442
|
+
gap_count: spec.fetch(:gap_count)
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def structural_index_lookup(statements, analysis)
|
|
448
|
+
structural_index = 0
|
|
449
|
+
lookup = {}
|
|
450
|
+
|
|
451
|
+
Array(statements).each do |statement|
|
|
452
|
+
owner = statement.respond_to?(:node) ? statement.node : statement
|
|
453
|
+
lookup[attachment_owner_key(owner)] = structural_index
|
|
454
|
+
|
|
455
|
+
next unless structural_preservation_statement?(statement, analysis)
|
|
456
|
+
|
|
457
|
+
structural_index += 1
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
[lookup, structural_index]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def append_preserved_fragment(insertions, structural_index, fragment, gap_count: 0)
|
|
464
|
+
if insertions[structural_index].any?
|
|
465
|
+
fragment[:separator] = preserved_fragment_separator(
|
|
466
|
+
gap_count: gap_count,
|
|
467
|
+
previous_kind: insertions[structural_index].last.fetch(:kind),
|
|
468
|
+
current_kind: fragment.fetch(:kind)
|
|
469
|
+
)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
insertions[structural_index] << fragment
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def render_template_with_preserved_insertions(template_analysis, insertions)
|
|
476
|
+
result = +''
|
|
477
|
+
structural_index = 0
|
|
478
|
+
statements = template_analysis.statements
|
|
479
|
+
index = 0
|
|
480
|
+
|
|
481
|
+
while index < statements.length
|
|
482
|
+
statement = statements[index]
|
|
483
|
+
|
|
484
|
+
if gap_line_node?(statement) && insertions.key?(structural_index) && next_structural_statement_after?(
|
|
485
|
+
statements, index, template_analysis
|
|
486
|
+
)
|
|
487
|
+
index += 1 while index < statements.length && gap_line_node?(statements[index])
|
|
488
|
+
result = append_preserved_fragments(result, insertions.delete(structural_index))
|
|
489
|
+
next
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
result << node_to_source(statement, template_analysis)
|
|
493
|
+
structural_index += 1 if structural_preservation_statement?(statement, template_analysis)
|
|
494
|
+
index += 1
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
if insertions.key?(structural_index)
|
|
498
|
+
result = append_preserved_fragments(result, insertions.delete(structural_index))
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
result
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def append_preserved_fragments(content, fragments)
|
|
505
|
+
result = content.sub(/\n+\z/, "\n")
|
|
506
|
+
|
|
507
|
+
unless result.empty?
|
|
508
|
+
if result.end_with?("\n")
|
|
509
|
+
result << "\n" unless result.end_with?("\n\n")
|
|
510
|
+
else
|
|
511
|
+
result << "\n\n"
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
result << render_preserved_fragment_block(fragments)
|
|
516
|
+
result << "\n"
|
|
517
|
+
result << "\n" unless result.end_with?("\n\n")
|
|
518
|
+
result
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def render_preserved_fragment_block(fragments)
|
|
522
|
+
fragments.each_with_object(+'').with_index do |(fragment, result), index|
|
|
523
|
+
result << fragment[:separator] if index.positive?
|
|
524
|
+
result << fragment.fetch(:text)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def next_structural_statement_after?(statements, start_index, analysis)
|
|
529
|
+
statements[(start_index + 1)..]&.any? { |statement| structural_preservation_statement?(statement, analysis) }
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Check if a type represents a heading node.
|
|
533
|
+
#
|
|
534
|
+
# @param type [Symbol, String] The node type
|
|
535
|
+
# @return [Boolean] true if this is a heading type
|
|
536
|
+
def heading_type?(type)
|
|
537
|
+
type.to_s == 'heading' || type == :heading || type == :header
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Get the heading level from a statement.
|
|
541
|
+
#
|
|
542
|
+
# @param stmt [NavigableStatement] The statement
|
|
543
|
+
# @return [Integer, nil] The heading level (1-6) or nil
|
|
544
|
+
def get_heading_level(stmt)
|
|
545
|
+
inner = stmt.respond_to?(:unwrapped_node) ? stmt.unwrapped_node : stmt.node
|
|
546
|
+
|
|
547
|
+
if inner.respond_to?(:header_level)
|
|
548
|
+
inner.header_level
|
|
549
|
+
elsif inner.respond_to?(:level)
|
|
550
|
+
inner.level
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|