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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +99 -482
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
- 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 +5 -3
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +489 -47
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +77 -6
- data/lib/markdown/merge/debug_logger.rb +2 -2
- data/lib/markdown/merge/document_problems.rb +3 -3
- data/lib/markdown/merge/file_aligner.rb +433 -133
- data/lib/markdown/merge/file_analysis.rb +387 -51
- data/lib/markdown/merge/file_analysis_base.rb +188 -51
- data/lib/markdown/merge/gap_line_node.rb +14 -8
- data/lib/markdown/merge/link_definition_node.rb +5 -5
- data/lib/markdown/merge/link_parser.rb +60 -60
- data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
- 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 +3 -3
- data/lib/markdown/merge/merge_result.rb +321 -4
- data/lib/markdown/merge/node_type_normalizer.rb +6 -6
- data/lib/markdown/merge/output_builder.rb +101 -19
- data/lib/markdown/merge/partial_template_merger.rb +248 -27
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/smart_merger.rb +62 -14
- data/lib/markdown/merge/smart_merger_base.rb +929 -60
- data/lib/markdown/merge/table_match_algorithm.rb +22 -27
- data/lib/markdown/merge/table_match_refiner.rb +6 -10
- data/lib/markdown/merge/version.rb +5 -4
- data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +669 -122
- data/lib/markdown-merge.rb +9 -4
- data/sig/markdown/merge.rbs +3 -336
- data.tar.gz.sig +0 -0
- metadata +104 -93
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -308
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- data/SECURITY.md +0 -21
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
3
5
|
module Markdown
|
|
4
6
|
module Merge
|
|
5
7
|
# Represents the result of a Markdown merge operation.
|
|
@@ -42,7 +44,7 @@ module Markdown
|
|
|
42
44
|
# @param stats [Hash] Merge statistics
|
|
43
45
|
# @param problems [DocumentProblems, nil] Document problems found
|
|
44
46
|
# @param options [Hash] Additional options for forward compatibility
|
|
45
|
-
def initialize(content:, conflicts: [], frozen_blocks: [], stats: {}, problems: nil, **options)
|
|
47
|
+
def initialize(content:, raw_content: nil, conflicts: [], frozen_blocks: [], stats: {}, problems: nil, **options)
|
|
46
48
|
super(
|
|
47
49
|
conflicts: conflicts,
|
|
48
50
|
frozen_blocks: frozen_blocks,
|
|
@@ -50,6 +52,7 @@ module Markdown
|
|
|
50
52
|
**options
|
|
51
53
|
)
|
|
52
54
|
@content_raw = content
|
|
55
|
+
@raw_content = raw_content || content
|
|
53
56
|
@problems = problems || DocumentProblems.new
|
|
54
57
|
end
|
|
55
58
|
|
|
@@ -136,7 +139,7 @@ module Markdown
|
|
|
136
139
|
#
|
|
137
140
|
# @return [String] Debug representation
|
|
138
141
|
def inspect
|
|
139
|
-
status = success? ?
|
|
142
|
+
status = success? ? 'success' : 'failed'
|
|
140
143
|
"#<#{self.class.name} #{status} conflicts=#{conflicts.size} frozen=#{frozen_blocks.size} " \
|
|
141
144
|
"added=#{nodes_added} removed=#{nodes_removed} modified=#{nodes_modified}>"
|
|
142
145
|
end
|
|
@@ -145,11 +148,325 @@ module Markdown
|
|
|
145
148
|
#
|
|
146
149
|
# @return [String] The merged content or empty string
|
|
147
150
|
def to_s
|
|
148
|
-
content ||
|
|
151
|
+
content || ''
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def to_unresolved_review_state(selections: {}, metadata: {})
|
|
155
|
+
normalized_selections = normalize_unresolved_resolutions(selections)
|
|
156
|
+
Ast::Merge::UnresolvedReviewState.new(
|
|
157
|
+
cases: serializable_unresolved_cases,
|
|
158
|
+
selections: delegated_applied_selections.merge(normalized_selections),
|
|
159
|
+
metadata: review_state_metadata(metadata, normalized_selections)
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def apply_unresolved_resolutions!(resolutions)
|
|
164
|
+
normalized = normalize_unresolved_resolutions(resolutions)
|
|
165
|
+
handled_case_ids = apply_grouped_delegated_resolutions!(normalized)
|
|
166
|
+
remaining = normalized.reject { |case_id, _| handled_case_ids.include?(case_id) }
|
|
167
|
+
apply_output_range_resolutions_descending!(remaining)
|
|
168
|
+
fallback = remaining.reject { |case_id, _| handled_case_ids.include?(case_id) }
|
|
169
|
+
super(fallback)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
protected
|
|
173
|
+
|
|
174
|
+
def apply_non_provisional_unresolved_resolution!(resolution_case, selection:, selected_candidate:)
|
|
175
|
+
raw_output_range = resolution_case.metadata[:output_range]
|
|
176
|
+
if raw_output_range && @content_raw != @raw_content
|
|
177
|
+
raise ArgumentError,
|
|
178
|
+
"cannot apply non-provisional resolution for case #{resolution_case.case_id} after post-processing transformed markdown output"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
output_range = normalize_output_range(raw_output_range)
|
|
182
|
+
return super unless output_range
|
|
183
|
+
|
|
184
|
+
selected_candidate = resolution_case.metadata.dig(:output_candidate_by_selection,
|
|
185
|
+
selection.to_sym) || selected_candidate
|
|
186
|
+
start_offset, end_offset = output_range
|
|
187
|
+
prefix = @content_raw.byteslice(0, start_offset).to_s
|
|
188
|
+
suffix = @content_raw.byteslice(end_offset..).to_s
|
|
189
|
+
@content_raw = "#{prefix}#{selected_candidate}#{suffix}"
|
|
190
|
+
@raw_content = @content_raw
|
|
149
191
|
end
|
|
150
192
|
|
|
151
193
|
private
|
|
152
194
|
|
|
195
|
+
SERIALIZATION_OMIT = Object.new.freeze
|
|
196
|
+
|
|
197
|
+
def apply_grouped_delegated_resolutions!(normalized_resolutions)
|
|
198
|
+
handled_case_ids = []
|
|
199
|
+
delegated_groups = @unresolved_cases
|
|
200
|
+
.group_by { |resolution_case| resolution_case.metadata[:delegated_apply_group] }
|
|
201
|
+
.reject { |group_id, _| group_id.nil? }
|
|
202
|
+
|
|
203
|
+
delegated_groups.each_value do |cases|
|
|
204
|
+
selected_cases = cases.select { |resolution_case| normalized_resolutions.key?(resolution_case.case_id) }
|
|
205
|
+
next if selected_cases.empty?
|
|
206
|
+
|
|
207
|
+
apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
|
|
208
|
+
handled_case_ids.concat(selected_cases.map(&:case_id))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
handled_case_ids
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
|
|
215
|
+
raw_output_range = cases.map { |resolution_case| resolution_case.metadata[:output_range] }.compact.uniq.fetch(0)
|
|
216
|
+
if @content_raw != @raw_content
|
|
217
|
+
raise ArgumentError,
|
|
218
|
+
"cannot apply non-provisional resolution for delegated block #{cases.first.metadata[:delegated_apply_group]} after post-processing transformed markdown output"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
output_range = normalize_output_range(raw_output_range)
|
|
222
|
+
renderer = cases.map do |resolution_case|
|
|
223
|
+
resolution_case.metadata[:delegated_apply_renderer]
|
|
224
|
+
end.compact.uniq.fetch(0)
|
|
225
|
+
prior_selections = cases.map do |resolution_case|
|
|
226
|
+
resolution_case.metadata[:delegated_applied_selections]
|
|
227
|
+
end.compact.reduce({}) do |memo, selections|
|
|
228
|
+
memo.merge(selections.transform_keys(&:to_s))
|
|
229
|
+
end
|
|
230
|
+
prior_root_selections = cases.map do |resolution_case|
|
|
231
|
+
resolution_case.metadata[:delegated_root_applied_selections]
|
|
232
|
+
end.compact.reduce({}) do |memo, selections|
|
|
233
|
+
memo.merge(selections.transform_keys(&:to_s))
|
|
234
|
+
end
|
|
235
|
+
prior_root_identities = cases.map do |resolution_case|
|
|
236
|
+
resolution_case.metadata[:delegated_root_case_identities]
|
|
237
|
+
end.compact.reduce({}) do |memo, identities|
|
|
238
|
+
memo.merge(identities.transform_keys(&:to_s))
|
|
239
|
+
end
|
|
240
|
+
delegated_selections = selected_cases.each_with_object(prior_selections) do |resolution_case, hash|
|
|
241
|
+
hash[resolution_case.metadata[:delegated_case_id]] = normalized_resolutions.fetch(resolution_case.case_id)
|
|
242
|
+
end
|
|
243
|
+
delegated_root_selections = selected_cases.each_with_object(prior_root_selections) do |resolution_case, hash|
|
|
244
|
+
hash[resolution_case.case_id] = normalized_resolutions.fetch(resolution_case.case_id)
|
|
245
|
+
end
|
|
246
|
+
delegated_root_identities = selected_cases.each_with_object(prior_root_identities) do |resolution_case, hash|
|
|
247
|
+
hash[resolution_case.case_id] = review_identity_for_case(resolution_case)
|
|
248
|
+
end
|
|
249
|
+
delegated_result = renderer.call(delegated_selections)
|
|
250
|
+
updated_output_range = replace_output_range!(output_range, delegated_result.fetch(:content))
|
|
251
|
+
previous_case_ids = cases.map(&:case_id)
|
|
252
|
+
@unresolved_cases.reject! { |resolution_case| previous_case_ids.include?(resolution_case.case_id) }
|
|
253
|
+
@conflicts.reject! { |conflict| previous_case_ids.include?(conflict[:case_id].to_s) }
|
|
254
|
+
remapped_cases = remap_delegated_cases_after_apply(
|
|
255
|
+
delegated_result[:unresolved_cases],
|
|
256
|
+
template_case: cases.first,
|
|
257
|
+
output_range: updated_output_range,
|
|
258
|
+
renderer: renderer,
|
|
259
|
+
applied_selections: delegated_selections,
|
|
260
|
+
root_applied_selections: delegated_root_selections,
|
|
261
|
+
root_case_identities: delegated_root_identities
|
|
262
|
+
)
|
|
263
|
+
remapped_cases.each { |resolution_case| add_unresolved_case(resolution_case) }
|
|
264
|
+
@conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def apply_output_range_resolutions_descending!(normalized_resolutions)
|
|
268
|
+
selected_cases = @unresolved_cases.select do |resolution_case|
|
|
269
|
+
normalized_resolutions.key?(resolution_case.case_id) && normalize_output_range(resolution_case.metadata[:output_range])
|
|
270
|
+
end
|
|
271
|
+
selected_cases
|
|
272
|
+
.sort_by { |resolution_case| -normalize_output_range(resolution_case.metadata[:output_range]).first }
|
|
273
|
+
.each do |resolution_case|
|
|
274
|
+
selection = normalized_resolutions.fetch(resolution_case.case_id)
|
|
275
|
+
apply_unresolved_resolution!(resolution_case, selection)
|
|
276
|
+
normalized_resolutions.delete(resolution_case.case_id)
|
|
277
|
+
@conflicts.reject! { |conflict| conflict[:case_id].to_s == resolution_case.case_id }
|
|
278
|
+
end
|
|
279
|
+
selected_case_ids = selected_cases.map(&:case_id)
|
|
280
|
+
@unresolved_cases.reject! { |resolution_case| selected_case_ids.include?(resolution_case.case_id) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def normalize_output_range(value)
|
|
284
|
+
range = Array(value)
|
|
285
|
+
return unless range.length == 2
|
|
286
|
+
|
|
287
|
+
start_offset = range[0].to_i
|
|
288
|
+
end_offset = range[1].to_i
|
|
289
|
+
return unless start_offset >= 0 && end_offset >= start_offset && @raw_content
|
|
290
|
+
return unless end_offset <= @raw_content.bytesize
|
|
291
|
+
|
|
292
|
+
[start_offset, end_offset]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def replace_output_range!(output_range, replacement)
|
|
296
|
+
start_offset, end_offset = output_range
|
|
297
|
+
prefix = @content_raw.byteslice(0, start_offset).to_s
|
|
298
|
+
suffix = @content_raw.byteslice(end_offset..).to_s
|
|
299
|
+
@content_raw = "#{prefix}#{replacement}#{suffix}"
|
|
300
|
+
@raw_content = @content_raw
|
|
301
|
+
[start_offset, start_offset + replacement.to_s.bytesize]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def remap_delegated_cases_after_apply(unresolved_cases, template_case:, output_range:, renderer:,
|
|
305
|
+
applied_selections:, root_applied_selections:, root_case_identities:)
|
|
306
|
+
operation_id = template_case.metadata[:delegated_runtime_operation_id]
|
|
307
|
+
surface_prefix = template_case.metadata[:delegated_runtime_surface_path]
|
|
308
|
+
delegated_group = template_case.metadata[:delegated_apply_group]
|
|
309
|
+
|
|
310
|
+
Array(unresolved_cases).map do |resolution_case|
|
|
311
|
+
suffix = delegated_surface_suffix_for(resolution_case.surface_path)
|
|
312
|
+
Ast::Merge::Runtime::ResolutionCase.new(
|
|
313
|
+
case_id: "#{operation_id}-#{resolution_case.case_id}",
|
|
314
|
+
reason: resolution_case.reason,
|
|
315
|
+
candidates: resolution_case.candidates,
|
|
316
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
317
|
+
surface_path: [surface_prefix, suffix].compact.join(' > '),
|
|
318
|
+
operation_id: operation_id,
|
|
319
|
+
metadata: resolution_case.metadata.merge(
|
|
320
|
+
delegated_case_id: resolution_case.case_id,
|
|
321
|
+
output_range: output_range,
|
|
322
|
+
delegated_apply_group: delegated_group,
|
|
323
|
+
delegated_apply_renderer: renderer,
|
|
324
|
+
delegated_applied_selections: applied_selections,
|
|
325
|
+
delegated_root_applied_selections: root_applied_selections,
|
|
326
|
+
delegated_root_case_identities: root_case_identities,
|
|
327
|
+
delegated_runtime_operation_id: operation_id,
|
|
328
|
+
delegated_runtime_surface_path: surface_prefix
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def delegated_surface_suffix_for(surface_path)
|
|
335
|
+
path = surface_path.to_s
|
|
336
|
+
return if path.empty? || path == 'document[0]'
|
|
337
|
+
|
|
338
|
+
path.sub(/\Adocument\[0\]\s*>\s*/, '')
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def conflict_for_resolution_case(resolution_case)
|
|
342
|
+
{
|
|
343
|
+
case_id: resolution_case.case_id,
|
|
344
|
+
reason: resolution_case.reason,
|
|
345
|
+
template: resolution_case.candidates[:template],
|
|
346
|
+
destination: resolution_case.candidates[:destination],
|
|
347
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
348
|
+
surface_path: resolution_case.surface_path
|
|
349
|
+
}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def serializable_unresolved_cases
|
|
353
|
+
@unresolved_cases.map do |resolution_case|
|
|
354
|
+
Ast::Merge::Runtime::ResolutionCase.new(
|
|
355
|
+
case_id: resolution_case.case_id,
|
|
356
|
+
reason: resolution_case.reason,
|
|
357
|
+
candidates: resolution_case.candidates,
|
|
358
|
+
provisional_winner: resolution_case.provisional_winner,
|
|
359
|
+
surface_path: resolution_case.surface_path,
|
|
360
|
+
operation_id: resolution_case.operation_id,
|
|
361
|
+
metadata: sanitize_review_state_metadata(
|
|
362
|
+
resolution_case.metadata.merge(review_identity: review_identity_for_case(resolution_case))
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def delegated_applied_selections
|
|
369
|
+
@unresolved_cases.each_with_object({}) do |resolution_case, selections|
|
|
370
|
+
selections.merge!(resolution_case.metadata[:delegated_root_applied_selections].to_h)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def review_state_metadata(metadata, normalized_selections)
|
|
375
|
+
metadata_hash = super(metadata, normalized_selections)
|
|
376
|
+
markdown_review_state = metadata_hash.fetch(:markdown_review_state,
|
|
377
|
+
metadata_hash.fetch('markdown_review_state', {})).to_h
|
|
378
|
+
metadata_hash.merge(
|
|
379
|
+
markdown_review_state: markdown_review_state.merge(
|
|
380
|
+
selection_identities: selection_review_identities(normalized_selections)
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def selection_review_identities(normalized_selections)
|
|
386
|
+
delegated_identities = @unresolved_cases.each_with_object({}) do |resolution_case, identities|
|
|
387
|
+
identities.merge!(resolution_case.metadata[:delegated_root_case_identities].to_h)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
normalized_selections.each_with_object(delegated_identities) do |(case_id, _selection), identities|
|
|
391
|
+
current_case = unresolved_case(case_id)
|
|
392
|
+
identities[case_id.to_s] = review_identity_for_case(current_case) if current_case
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def persisted_selection_identities(metadata)
|
|
397
|
+
markdown_review_state = metadata.fetch(:markdown_review_state, metadata.fetch('markdown_review_state', {})).to_h
|
|
398
|
+
markdown_review_state.fetch(:selection_identities, markdown_review_state.fetch('selection_identities', {})).to_h
|
|
399
|
+
.transform_keys(&:to_s)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def review_identity_for_case(resolution_case)
|
|
403
|
+
persisted_review_identity = case_metadata_value(resolution_case, :review_identity)
|
|
404
|
+
return persisted_review_identity if persisted_review_identity
|
|
405
|
+
|
|
406
|
+
Digest::SHA256.hexdigest(
|
|
407
|
+
[
|
|
408
|
+
resolution_case.surface_path,
|
|
409
|
+
resolution_case.reason,
|
|
410
|
+
resolution_case.provisional_winner,
|
|
411
|
+
case_metadata_value(resolution_case, :match_kind),
|
|
412
|
+
resolution_case.candidates[:template],
|
|
413
|
+
resolution_case.candidates[:destination]
|
|
414
|
+
].map(&:to_s).join("\u001f")
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def sanitize_review_state_metadata(value)
|
|
419
|
+
case value
|
|
420
|
+
when Hash
|
|
421
|
+
value.each_with_object({}) do |(key, entry), hash|
|
|
422
|
+
sanitized = sanitize_review_state_metadata(entry)
|
|
423
|
+
next if sanitized.equal?(SERIALIZATION_OMIT)
|
|
424
|
+
|
|
425
|
+
hash[key] = sanitized
|
|
426
|
+
end
|
|
427
|
+
when Array
|
|
428
|
+
value.filter_map do |entry|
|
|
429
|
+
sanitized = sanitize_review_state_metadata(entry)
|
|
430
|
+
sanitized unless sanitized.equal?(SERIALIZATION_OMIT)
|
|
431
|
+
end
|
|
432
|
+
else
|
|
433
|
+
return SERIALIZATION_OMIT if value.respond_to?(:call)
|
|
434
|
+
|
|
435
|
+
value
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def validate_review_state_compatibility!(state)
|
|
440
|
+
super
|
|
441
|
+
|
|
442
|
+
persisted_cases = state.cases.each_with_object({}) do |resolution_case, hash|
|
|
443
|
+
hash[resolution_case.case_id] = resolution_case
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
state.selections.each_key do |case_id|
|
|
447
|
+
current_case = unresolved_case(case_id)
|
|
448
|
+
persisted_case = persisted_cases[case_id]
|
|
449
|
+
if persisted_case && !case_metadata_value(persisted_case, :review_identity) &&
|
|
450
|
+
(persisted_case.surface_path != current_case.surface_path ||
|
|
451
|
+
case_metadata_value(persisted_case, :match_kind) != case_metadata_value(current_case, :match_kind))
|
|
452
|
+
raise ArgumentError,
|
|
453
|
+
"cannot apply markdown review state: case #{case_id} no longer matches the current unresolved markdown surface"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
next unless current_case&.metadata&.[](:output_range)
|
|
457
|
+
next if @content_raw == @raw_content
|
|
458
|
+
|
|
459
|
+
raise ArgumentError,
|
|
460
|
+
"cannot apply markdown review state for case #{case_id} after post-processing transformed markdown output"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def case_metadata_value(resolution_case, key)
|
|
465
|
+
return unless resolution_case
|
|
466
|
+
|
|
467
|
+
resolution_case.metadata[key] || resolution_case.metadata[key.to_s]
|
|
468
|
+
end
|
|
469
|
+
|
|
153
470
|
# Default statistics structure
|
|
154
471
|
#
|
|
155
472
|
# @return [Hash] Default stats hash
|
|
@@ -158,7 +475,7 @@ module Markdown
|
|
|
158
475
|
nodes_added: 0,
|
|
159
476
|
nodes_removed: 0,
|
|
160
477
|
nodes_modified: 0,
|
|
161
|
-
merge_time_ms: 0
|
|
478
|
+
merge_time_ms: 0
|
|
162
479
|
}
|
|
163
480
|
end
|
|
164
481
|
end
|
|
@@ -73,21 +73,21 @@ module Markdown
|
|
|
73
73
|
# Table child types
|
|
74
74
|
table_row: :table_row,
|
|
75
75
|
table_cell: :table_cell,
|
|
76
|
-
table_header: :table_header,
|
|
76
|
+
table_header: :table_header, # Some parsers distinguish header rows
|
|
77
77
|
# List child types
|
|
78
78
|
list_item: :list_item,
|
|
79
|
-
item: :list_item,
|
|
79
|
+
item: :list_item, # Alias
|
|
80
80
|
# Inline types (usually not top-level, but map them anyway)
|
|
81
81
|
text: :text,
|
|
82
82
|
softbreak: :softbreak,
|
|
83
83
|
linebreak: :linebreak,
|
|
84
84
|
code: :code,
|
|
85
|
-
code_inline: :code,
|
|
85
|
+
code_inline: :code, # Alias used by some parsers
|
|
86
86
|
html_inline: :html_inline,
|
|
87
87
|
emph: :emph,
|
|
88
88
|
strong: :strong,
|
|
89
89
|
link: :link,
|
|
90
|
-
image: :image
|
|
90
|
+
image: :image
|
|
91
91
|
}.freeze,
|
|
92
92
|
markly: {
|
|
93
93
|
# Block types - note different names from commonmarker
|
|
@@ -118,8 +118,8 @@ module Markdown
|
|
|
118
118
|
emph: :emph,
|
|
119
119
|
strong: :strong,
|
|
120
120
|
link: :link,
|
|
121
|
-
image: :image
|
|
122
|
-
}.freeze
|
|
121
|
+
image: :image
|
|
122
|
+
}.freeze
|
|
123
123
|
)
|
|
124
124
|
end
|
|
125
125
|
end
|
|
@@ -27,15 +27,20 @@ module Markdown
|
|
|
27
27
|
# @param auto_spacing [Boolean] Whether to automatically insert blank lines between structural elements
|
|
28
28
|
def initialize(preserve_formatting: true, auto_spacing: true)
|
|
29
29
|
@parts = []
|
|
30
|
+
@length = 0
|
|
30
31
|
@preserve_formatting = preserve_formatting
|
|
31
32
|
@auto_spacing = auto_spacing
|
|
32
33
|
@last_node_type = nil # Track previous node type for spacing decisions
|
|
34
|
+
@last_end_line = nil # Track previous node's end line for adjacency detection
|
|
35
|
+
@last_analysis = nil # Track previous node's analysis for same-source detection
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# Add a node's source content
|
|
36
39
|
#
|
|
37
40
|
# Automatically inserts structural blank lines when transitioning between
|
|
38
41
|
# certain node types (tables, headings, code blocks, etc.) if auto_spacing is enabled.
|
|
42
|
+
# Skips auto-spacing when nodes are adjacent in the same source — the original
|
|
43
|
+
# formatting is preserved by the source extraction.
|
|
39
44
|
#
|
|
40
45
|
# @param node [Object] Node to add (can be parser node, FreezeNode, LinkDefinitionNode, etc.)
|
|
41
46
|
# @param analysis [FileAnalysisBase] Analysis for accessing source
|
|
@@ -46,27 +51,30 @@ module Markdown
|
|
|
46
51
|
# Auto-spacing logic:
|
|
47
52
|
# - Skip for gap_line and freeze_block (they handle their own spacing)
|
|
48
53
|
# - Skip if last node was a gap_line (we already have spacing)
|
|
54
|
+
# - Skip if nodes are adjacent in the same source (original spacing is correct)
|
|
49
55
|
# - Otherwise, check MarkdownStructure.needs_blank_between? which handles
|
|
50
56
|
# contiguous types (like link_definitions that shouldn't have blanks between them)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
end
|
|
57
|
+
# Skip auto-spacing when nodes are adjacent lines in the same source.
|
|
58
|
+
# The original source formatting is correct — no blank line was there.
|
|
59
|
+
if !(%i[gap_line freeze_block].include?(current_type) ||
|
|
60
|
+
@last_node_type == :gap_line) && @auto_spacing && @last_node_type && current_type && MarkdownStructure.needs_blank_between?(@last_node_type,
|
|
61
|
+
current_type) && !same_source_adjacent?(
|
|
62
|
+
node, analysis
|
|
63
|
+
) && !(@parts.empty? || blank_line_terminated?)
|
|
64
|
+
# Only add spacing if we don't already have adequate blank lines
|
|
65
|
+
# Check the last part to see if it already ends with blank line(s)
|
|
66
|
+
add_gap_line(count: 1)
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
content = extract_source(node, analysis)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
return unless content && !content.empty?
|
|
71
|
+
|
|
72
|
+
range = append_part(content)
|
|
73
|
+
# Update last node type (track all node types for proper spacing)
|
|
74
|
+
@last_node_type = current_type
|
|
75
|
+
@last_end_line = node_end_line(node)
|
|
76
|
+
@last_analysis = analysis
|
|
77
|
+
range
|
|
70
78
|
end
|
|
71
79
|
|
|
72
80
|
# Add a reconstructed link definition
|
|
@@ -74,21 +82,21 @@ module Markdown
|
|
|
74
82
|
# @param node [LinkDefinitionNode] Link definition node
|
|
75
83
|
def add_link_definition(node)
|
|
76
84
|
formatted = LinkDefinitionFormatter.format(node)
|
|
77
|
-
|
|
85
|
+
append_part(formatted) if formatted && !formatted.empty?
|
|
78
86
|
end
|
|
79
87
|
|
|
80
88
|
# Add gap lines (blank line preservation)
|
|
81
89
|
#
|
|
82
90
|
# @param count [Integer] Number of blank lines to add
|
|
83
91
|
def add_gap_line(count: 1)
|
|
84
|
-
|
|
92
|
+
append_part("\n" * count) if count > 0
|
|
85
93
|
end
|
|
86
94
|
|
|
87
95
|
# Add raw text content
|
|
88
96
|
#
|
|
89
97
|
# @param text [String] Raw text to add
|
|
90
98
|
def add_raw(text)
|
|
91
|
-
|
|
99
|
+
append_part(text) if text && !text.empty?
|
|
92
100
|
end
|
|
93
101
|
|
|
94
102
|
# Get final content
|
|
@@ -105,13 +113,87 @@ module Markdown
|
|
|
105
113
|
@parts.empty?
|
|
106
114
|
end
|
|
107
115
|
|
|
116
|
+
# Check whether the current output already ends with a blank-line separator.
|
|
117
|
+
#
|
|
118
|
+
# This looks across part boundaries so a trailing blank line represented as
|
|
119
|
+
# separate content + gap parts still counts as an existing separator.
|
|
120
|
+
#
|
|
121
|
+
# @return [Boolean]
|
|
122
|
+
def blank_line_terminated?
|
|
123
|
+
trailing_newlines = 0
|
|
124
|
+
|
|
125
|
+
@parts.reverse_each do |part|
|
|
126
|
+
next if part.nil? || part.empty?
|
|
127
|
+
|
|
128
|
+
idx = part.length - 1
|
|
129
|
+
while idx >= 0 && part[idx] == "\n"
|
|
130
|
+
trailing_newlines += 1
|
|
131
|
+
idx -= 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
break if idx >= 0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
trailing_newlines >= 2
|
|
138
|
+
end
|
|
139
|
+
|
|
108
140
|
# Clear all content
|
|
109
141
|
def clear
|
|
110
142
|
@parts.clear
|
|
143
|
+
@length = 0
|
|
111
144
|
end
|
|
112
145
|
|
|
113
146
|
private
|
|
114
147
|
|
|
148
|
+
def append_part(text)
|
|
149
|
+
start_offset = @length
|
|
150
|
+
@parts << text
|
|
151
|
+
@length += text.bytesize
|
|
152
|
+
[start_offset, @length]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Check if the current node is adjacent (consecutive lines) to the previous
|
|
156
|
+
# node in the same source file. When true, the original source already has
|
|
157
|
+
# the correct inter-node spacing and auto-spacing should not add blank lines.
|
|
158
|
+
#
|
|
159
|
+
# @param node [Object] Current node being added
|
|
160
|
+
# @param analysis [FileAnalysisBase] Current node's analysis
|
|
161
|
+
# @return [Boolean] true if nodes are from the same source and adjacent
|
|
162
|
+
def same_source_adjacent?(node, analysis)
|
|
163
|
+
return false unless @last_end_line && @last_analysis
|
|
164
|
+
return false unless @last_analysis.equal?(analysis)
|
|
165
|
+
|
|
166
|
+
current_start = node_start_line(node)
|
|
167
|
+
return false unless current_start
|
|
168
|
+
|
|
169
|
+
# Adjacent: the current node starts on or immediately after the previous node ended
|
|
170
|
+
current_start <= @last_end_line + 1
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Extract start line from a node
|
|
174
|
+
#
|
|
175
|
+
# @param node [Object] Node to inspect
|
|
176
|
+
# @return [Integer, nil] 1-based start line
|
|
177
|
+
def node_start_line(node)
|
|
178
|
+
if node.respond_to?(:source_position)
|
|
179
|
+
node.source_position&.dig(:start_line)
|
|
180
|
+
elsif node.respond_to?(:start_line)
|
|
181
|
+
node.start_line
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Extract end line from a node
|
|
186
|
+
#
|
|
187
|
+
# @param node [Object] Node to inspect
|
|
188
|
+
# @return [Integer, nil] 1-based end line
|
|
189
|
+
def node_end_line(node)
|
|
190
|
+
if node.respond_to?(:source_position)
|
|
191
|
+
node.source_position&.dig(:end_line)
|
|
192
|
+
elsif node.respond_to?(:end_line)
|
|
193
|
+
node.end_line
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
115
197
|
# Extract source content from a node
|
|
116
198
|
#
|
|
117
199
|
# @param node [Object] Node to extract from
|