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
data/lib/markdown/merge.rb
CHANGED
|
@@ -1,149 +1,696 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# Shared merge infrastructure
|
|
8
|
-
require "ast/merge"
|
|
9
|
-
|
|
10
|
-
# tree_haver provides unified markdown parsing via multiple backends
|
|
11
|
-
require "tree_haver"
|
|
12
|
-
|
|
13
|
-
# This gem - only require version
|
|
14
|
-
require_relative "merge/version"
|
|
3
|
+
require 'ast/merge'
|
|
4
|
+
require 'tree_haver'
|
|
5
|
+
require_relative 'merge/version'
|
|
15
6
|
|
|
16
7
|
module Markdown
|
|
17
|
-
# Smart merging for Markdown files using AST-based parsers via tree_haver.
|
|
18
|
-
#
|
|
19
|
-
# Markdown::Merge provides intelligent Markdown merging with support for
|
|
20
|
-
# multiple parsing backends (Commonmarker, Markly) through tree_haver:
|
|
21
|
-
# - Standalone SmartMerger that works with any available backend
|
|
22
|
-
# - Matching structural elements (headings, paragraphs, lists, etc.) between files
|
|
23
|
-
# - Preserving frozen sections marked with HTML comments
|
|
24
|
-
# - Resolving conflicts based on configurable preferences
|
|
25
|
-
# - Node type normalization for portable merge rules across backends
|
|
26
|
-
#
|
|
27
|
-
# Can be used directly or through parser-specific wrappers
|
|
28
|
-
# (commonmarker-merge, markly-merge) that provide hard dependencies
|
|
29
|
-
# and backend-specific defaults.
|
|
30
|
-
#
|
|
31
|
-
# @example Direct usage with auto backend detection
|
|
32
|
-
# require "markdown/merge"
|
|
33
|
-
# merger = Markdown::Merge::SmartMerger.new(template, destination)
|
|
34
|
-
# result = merger.merge
|
|
35
|
-
#
|
|
36
|
-
# @example With specific backend
|
|
37
|
-
# merger = Markdown::Merge::SmartMerger.new(
|
|
38
|
-
# template,
|
|
39
|
-
# destination,
|
|
40
|
-
# backend: :markly,
|
|
41
|
-
# flags: Markly::DEFAULT,
|
|
42
|
-
# extensions: [:table, :strikethrough]
|
|
43
|
-
# )
|
|
44
|
-
# result = merger.merge
|
|
45
|
-
#
|
|
46
|
-
# @example Using via commonmarker-merge
|
|
47
|
-
# require "commonmarker/merge"
|
|
48
|
-
# merger = Commonmarker::Merge::SmartMerger.new(template, destination)
|
|
49
|
-
# result = merger.merge
|
|
50
|
-
#
|
|
51
|
-
# @see SmartMerger Main entry point for merging
|
|
52
|
-
# @see FileAnalysis For parsing and analyzing Markdown files
|
|
53
|
-
# @see NodeTypeNormalizer For type normalization across backends
|
|
54
8
|
module Merge
|
|
55
|
-
|
|
56
|
-
|
|
9
|
+
PACKAGE_NAME = 'markdown-merge'
|
|
10
|
+
BACKEND_REFERENCES = {
|
|
11
|
+
'kreuzberg-language-pack' => TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND,
|
|
12
|
+
'commonmarker' => TreeHaver::BackendReference.new(id: 'commonmarker', family: 'native').freeze,
|
|
13
|
+
'markly' => TreeHaver::BackendReference.new(id: 'markly', family: 'native').freeze,
|
|
14
|
+
'kramdown' => TreeHaver::BackendReference.new(id: 'kramdown', family: 'native').freeze
|
|
15
|
+
}.freeze
|
|
16
|
+
BACKEND_REGISTRY = Struct.new(:registered, :mutex).new(false, Mutex.new)
|
|
17
|
+
|
|
57
18
|
class Error < Ast::Merge::Error; end
|
|
58
19
|
|
|
59
|
-
# Raised when a Markdown file has parsing errors.
|
|
60
|
-
# Inherits from Ast::Merge::ParseError for consistency across merge gems.
|
|
61
|
-
#
|
|
62
|
-
# @example Handling parse errors
|
|
63
|
-
# begin
|
|
64
|
-
# analysis = FileAnalysis.new(markdown_content)
|
|
65
|
-
# rescue ParseError => e
|
|
66
|
-
# puts "Markdown syntax error: #{e.message}"
|
|
67
|
-
# e.errors.each { |error| puts " #{error}" }
|
|
68
|
-
# end
|
|
69
20
|
class ParseError < Ast::Merge::ParseError
|
|
70
|
-
# @param message [String, nil] Error message (auto-generated if nil)
|
|
71
|
-
# @param content [String, nil] The Markdown source that failed to parse
|
|
72
|
-
# @param errors [Array] Parse errors from Markdown
|
|
73
21
|
def initialize(message = nil, content: nil, errors: [])
|
|
74
22
|
super(message, errors: errors, content: content)
|
|
75
23
|
end
|
|
76
24
|
end
|
|
77
25
|
|
|
78
|
-
# Raised when the template file has syntax errors.
|
|
79
|
-
#
|
|
80
|
-
# @example Handling template parse errors
|
|
81
|
-
# begin
|
|
82
|
-
# merger = SmartMerger.new(template, destination)
|
|
83
|
-
# result = merger.merge
|
|
84
|
-
# rescue TemplateParseError => e
|
|
85
|
-
# puts "Template syntax error: #{e.message}"
|
|
86
|
-
# e.errors.each { |error| puts " #{error.message}" }
|
|
87
|
-
# end
|
|
88
26
|
class TemplateParseError < ParseError; end
|
|
89
27
|
|
|
90
|
-
# Raised when the destination file has syntax errors.
|
|
91
|
-
#
|
|
92
|
-
# @example Handling destination parse errors
|
|
93
|
-
# begin
|
|
94
|
-
# merger = SmartMerger.new(template, destination)
|
|
95
|
-
# result = merger.merge
|
|
96
|
-
# rescue DestinationParseError => e
|
|
97
|
-
# puts "Destination syntax error: #{e.message}"
|
|
98
|
-
# e.errors.each { |error| puts " #{error.message}" }
|
|
99
|
-
# end
|
|
100
28
|
class DestinationParseError < ParseError; end
|
|
101
29
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
autoload :
|
|
105
|
-
autoload :
|
|
106
|
-
autoload :
|
|
107
|
-
autoload :
|
|
108
|
-
autoload :
|
|
109
|
-
autoload :
|
|
110
|
-
autoload :
|
|
111
|
-
autoload :
|
|
112
|
-
autoload :
|
|
113
|
-
autoload :
|
|
114
|
-
autoload :
|
|
115
|
-
autoload :
|
|
116
|
-
autoload :
|
|
117
|
-
autoload :LinkDefinitionFormatter,
|
|
118
|
-
autoload :
|
|
119
|
-
autoload :
|
|
120
|
-
autoload :
|
|
121
|
-
autoload :
|
|
122
|
-
autoload :
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
autoload :NodeTypeNormalizer,
|
|
126
|
-
autoload :
|
|
127
|
-
autoload :
|
|
128
|
-
autoload :
|
|
30
|
+
class CorruptionDetectedError < Error; end
|
|
31
|
+
|
|
32
|
+
autoload :BackendSupport, 'markdown/merge/backend_support'
|
|
33
|
+
autoload :Cleanse, 'markdown/merge/cleanse'
|
|
34
|
+
autoload :CodeBlockMatchRefiner, 'markdown/merge/code_block_match_refiner'
|
|
35
|
+
autoload :CodeBlockMerger, 'markdown/merge/code_block_merger'
|
|
36
|
+
autoload :CommentTracker, 'markdown/merge/comment_tracker'
|
|
37
|
+
autoload :ConflictResolver, 'markdown/merge/conflict_resolver'
|
|
38
|
+
autoload :DebugLogger, 'markdown/merge/debug_logger'
|
|
39
|
+
autoload :DocumentProblems, 'markdown/merge/document_problems'
|
|
40
|
+
autoload :FileAligner, 'markdown/merge/file_aligner'
|
|
41
|
+
autoload :FileAnalysis, 'markdown/merge/file_analysis'
|
|
42
|
+
autoload :FileAnalysisBase, 'markdown/merge/file_analysis_base'
|
|
43
|
+
autoload :FreezeNode, 'markdown/merge/freeze_node'
|
|
44
|
+
autoload :GapLineNode, 'markdown/merge/gap_line_node'
|
|
45
|
+
autoload :LinkDefinitionFormatter, 'markdown/merge/link_definition_formatter'
|
|
46
|
+
autoload :LinkDefinitionNode, 'markdown/merge/link_definition_node'
|
|
47
|
+
autoload :LinkParser, 'markdown/merge/link_parser'
|
|
48
|
+
autoload :LinkReferenceRehydrator, 'markdown/merge/link_reference_rehydrator'
|
|
49
|
+
autoload :ListMatchRefiner, 'markdown/merge/list_match_refiner'
|
|
50
|
+
autoload :ListMerger, 'markdown/merge/list_merger'
|
|
51
|
+
autoload :MarkdownStructure, 'markdown/merge/markdown_structure'
|
|
52
|
+
autoload :MergeResult, 'markdown/merge/merge_result'
|
|
53
|
+
autoload :NodeTypeNormalizer, 'markdown/merge/node_type_normalizer'
|
|
54
|
+
autoload :OutputBuilder, 'markdown/merge/output_builder'
|
|
55
|
+
autoload :PartialTemplateMerger, 'markdown/merge/partial_template_merger'
|
|
56
|
+
autoload :PreservationSupport, 'markdown/merge/preservation_support'
|
|
57
|
+
autoload :SmartMerger, 'markdown/merge/smart_merger'
|
|
58
|
+
autoload :SmartMergerBase, 'markdown/merge/smart_merger_base'
|
|
59
|
+
autoload :TableMatchAlgorithm, 'markdown/merge/table_match_algorithm'
|
|
60
|
+
autoload :TableMatchRefiner, 'markdown/merge/table_match_refiner'
|
|
61
|
+
autoload :WhitespaceNormalizer, 'markdown/merge/whitespace_normalizer'
|
|
62
|
+
autoload :WrapperSupport, 'markdown/merge/wrapper_support'
|
|
63
|
+
|
|
64
|
+
def register_backend!
|
|
65
|
+
BACKEND_REGISTRY.mutex.synchronize do
|
|
66
|
+
return if BACKEND_REGISTRY.registered
|
|
67
|
+
|
|
68
|
+
TreeHaver::BackendRegistry.register(BACKEND_REFERENCES.fetch('kreuzberg-language-pack'))
|
|
69
|
+
|
|
70
|
+
grammar_finder = TreeHaver::GrammarFinder.new(:markdown)
|
|
71
|
+
grammar_finder.register! if grammar_finder.available?
|
|
72
|
+
|
|
73
|
+
BACKEND_REGISTRY.registered = true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def markdown_feature_profile
|
|
78
|
+
{
|
|
79
|
+
family: 'markdown',
|
|
80
|
+
supported_dialects: ['markdown'],
|
|
81
|
+
supported_policies: []
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def available_markdown_backends
|
|
86
|
+
BACKEND_REFERENCES.filter_map do |backend_id, reference|
|
|
87
|
+
reference if markdown_backend_available_for_analysis?(backend_id)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def markdown_backend_feature_profile(backend: nil)
|
|
92
|
+
resolved_backend = resolve_backend(backend)
|
|
93
|
+
unless BACKEND_REFERENCES.key?(resolved_backend)
|
|
94
|
+
return unsupported_feature_result("Unsupported Markdown backend #{resolved_backend}.")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
markdown_feature_profile.merge(
|
|
98
|
+
backend: resolved_backend,
|
|
99
|
+
backend_ref: BACKEND_REFERENCES.fetch(resolved_backend).to_h
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def markdown_plan_context(backend: nil)
|
|
104
|
+
profile = markdown_backend_feature_profile(backend: backend)
|
|
105
|
+
return profile if profile[:ok] == false
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
family_profile: markdown_feature_profile,
|
|
109
|
+
feature_profile: {
|
|
110
|
+
backend: profile[:backend],
|
|
111
|
+
supports_dialects: false,
|
|
112
|
+
supported_policies: profile[:supported_policies]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_markdown(source, dialect, backend: nil)
|
|
118
|
+
return unsupported_feature_result("Unsupported Markdown dialect #{dialect}.") unless dialect == 'markdown'
|
|
119
|
+
|
|
120
|
+
resolved_backend = resolve_backend(backend)
|
|
121
|
+
unless BACKEND_REFERENCES.key?(resolved_backend)
|
|
122
|
+
return unsupported_feature_result("Unsupported Markdown backend #{resolved_backend}.")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
register_backend!
|
|
126
|
+
parser = TreeHaver.with_backend(resolved_backend) { TreeHaver.parser_for(:markdown) }
|
|
127
|
+
tree = parser.parse(source)
|
|
128
|
+
collect_parse_errors(tree.root_node)
|
|
129
|
+
|
|
130
|
+
normalized_source = normalize_source(source)
|
|
131
|
+
{
|
|
132
|
+
ok: true,
|
|
133
|
+
diagnostics: [],
|
|
134
|
+
analysis: {
|
|
135
|
+
kind: 'markdown',
|
|
136
|
+
dialect: dialect,
|
|
137
|
+
normalized_source: normalized_source,
|
|
138
|
+
root_kind: 'document',
|
|
139
|
+
owners: collect_markdown_owners(normalized_source)
|
|
140
|
+
},
|
|
141
|
+
policies: []
|
|
142
|
+
}
|
|
143
|
+
rescue TreeHaver::Error, StandardError => e
|
|
144
|
+
parse_failure_result(e)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def match_markdown_owners(template, destination)
|
|
148
|
+
destination_paths = destination[:owners].to_h { |owner| [owner[:path], true] }
|
|
149
|
+
template_paths = template[:owners].to_h { |owner| [owner[:path], true] }
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
matched: template[:owners]
|
|
153
|
+
.filter { |owner| destination_paths[owner[:path]] }
|
|
154
|
+
.map { |owner| { template_path: owner[:path], destination_path: owner[:path] } },
|
|
155
|
+
unmatched_template: template[:owners].map { |owner| owner[:path] }.reject { |path| destination_paths[path] },
|
|
156
|
+
unmatched_destination: destination[:owners].map { |owner| owner[:path] }.reject { |path| template_paths[path] }
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def merge_markdown(template_source, destination_source, dialect, backend: nil)
|
|
161
|
+
template = parse_markdown(template_source, dialect, backend: backend)
|
|
162
|
+
return template unless template[:ok]
|
|
163
|
+
|
|
164
|
+
destination = parse_markdown(destination_source, dialect, backend: backend)
|
|
165
|
+
return destination unless destination[:ok]
|
|
166
|
+
|
|
167
|
+
destination_sections = collect_markdown_sections(
|
|
168
|
+
destination.dig(:analysis, :normalized_source),
|
|
169
|
+
destination.dig(:analysis, :owners)
|
|
170
|
+
)
|
|
171
|
+
template_sections = collect_markdown_sections(
|
|
172
|
+
template.dig(:analysis, :normalized_source),
|
|
173
|
+
template.dig(:analysis, :owners)
|
|
174
|
+
)
|
|
175
|
+
destination_paths = destination_sections.to_h { |section| [section[:path], true] }
|
|
176
|
+
merged_sections = destination_sections.map { |section| section[:text] }.reject(&:empty?) +
|
|
177
|
+
template_sections
|
|
178
|
+
.reject { |section| destination_paths[section[:path]] || section[:text].empty? }
|
|
179
|
+
.map { |section| section[:text] }
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
ok: true,
|
|
183
|
+
diagnostics: [],
|
|
184
|
+
output: "#{merged_sections.join("\n\n").strip}\n",
|
|
185
|
+
policies: []
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def markdown_embedded_families(analysis)
|
|
190
|
+
analysis[:owners].filter_map do |owner|
|
|
191
|
+
next unless owner[:owner_kind] == 'code_fence'
|
|
192
|
+
next if owner[:info_string].to_s.empty?
|
|
193
|
+
|
|
194
|
+
family = code_fence_family(owner[:info_string])
|
|
195
|
+
dialect = code_fence_dialect(owner[:info_string], family)
|
|
196
|
+
next unless family && dialect
|
|
197
|
+
|
|
198
|
+
{
|
|
199
|
+
path: owner[:path],
|
|
200
|
+
language: owner[:info_string],
|
|
201
|
+
family: family,
|
|
202
|
+
dialect: dialect
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def markdown_discovered_surfaces(analysis)
|
|
208
|
+
markdown_embedded_families(analysis).map do |candidate|
|
|
209
|
+
Ast::Merge.discovered_surface(
|
|
210
|
+
surface_kind: 'markdown_fenced_code_block',
|
|
211
|
+
declared_language: candidate[:language],
|
|
212
|
+
effective_language: candidate[:dialect],
|
|
213
|
+
address: "document[0] > fenced_code_block[#{candidate[:path]}]",
|
|
214
|
+
parent_address: 'document[0]',
|
|
215
|
+
owner: Ast::Merge.surface_owner_ref(kind: 'structural_owner', address: candidate[:path]),
|
|
216
|
+
reconstruction_strategy: 'portable_write',
|
|
217
|
+
metadata: {
|
|
218
|
+
family: candidate[:family],
|
|
219
|
+
dialect: candidate[:dialect],
|
|
220
|
+
path: candidate[:path]
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def markdown_delegated_child_operations(analysis, parent_operation_id: 'markdown-document-0')
|
|
227
|
+
markdown_discovered_surfaces(analysis).each_with_index.map do |surface, index|
|
|
228
|
+
Ast::Merge.delegated_child_operation(
|
|
229
|
+
operation_id: "markdown-fence-#{index}",
|
|
230
|
+
parent_operation_id: parent_operation_id,
|
|
231
|
+
requested_strategy: 'delegate_child_surface',
|
|
232
|
+
language_chain: ['markdown', surface[:effective_language]],
|
|
233
|
+
surface: surface
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def apply_markdown_delegated_child_outputs(source, delegated_operations, apply_plan, applied_children)
|
|
239
|
+
lines = normalize_source(source).split("\n")
|
|
240
|
+
ranges = markdown_fence_ranges(source)
|
|
241
|
+
operations_by_id = delegated_operations.to_h { |operation| [operation[:operation_id], operation] }
|
|
242
|
+
outputs_by_id = applied_children.to_h { |entry| [entry[:operation_id], entry[:output]] }
|
|
243
|
+
|
|
244
|
+
replacements = apply_plan[:entries].filter_map do |entry|
|
|
245
|
+
operation = operations_by_id[entry.dig(:delegated_group, :child_operation_id)]
|
|
246
|
+
output = outputs_by_id[entry.dig(:delegated_group, :child_operation_id)]
|
|
247
|
+
next if operation.nil? || output.nil?
|
|
248
|
+
|
|
249
|
+
owner_path = operation.dig(:surface, :owner, :address)
|
|
250
|
+
range = ranges[owner_path]
|
|
251
|
+
if range.nil?
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
diagnostics: [{ severity: 'error', category: 'configuration_error',
|
|
255
|
+
message: "missing fenced-code range for #{owner_path}" }],
|
|
256
|
+
policies: []
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
{ range: range, output: output }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
replacements.sort_by { |entry| -entry[:range][:start] }.each do |entry|
|
|
264
|
+
body_lines = entry[:output].empty? ? [] : entry[:output].sub(/\n\z/, '').split("\n")
|
|
265
|
+
lines[entry[:range][:start] + 1...entry[:range][:end]] = body_lines
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
ok: true,
|
|
270
|
+
diagnostics: [],
|
|
271
|
+
output: "#{lines.join("\n").sub(/\n+\z/, '')}\n",
|
|
272
|
+
policies: []
|
|
273
|
+
}
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def merge_markdown_with_nested_outputs(template_source, destination_source, dialect, nested_outputs, backend: nil)
|
|
277
|
+
Ast::Merge.execute_nested_merge(
|
|
278
|
+
nested_outputs,
|
|
279
|
+
default_family: 'markdown',
|
|
280
|
+
request_id_prefix: 'nested_markdown_child',
|
|
281
|
+
merge_parent: -> { merge_markdown(template_source, destination_source, dialect, backend: backend) },
|
|
282
|
+
discover_operations: lambda { |merged_output|
|
|
283
|
+
analysis = parse_markdown(merged_output, dialect, backend: backend)
|
|
284
|
+
next { ok: false, diagnostics: analysis[:diagnostics] || [] } unless analysis[:ok]
|
|
285
|
+
|
|
286
|
+
{
|
|
287
|
+
ok: true,
|
|
288
|
+
diagnostics: [],
|
|
289
|
+
operations: markdown_delegated_child_operations(analysis[:analysis])
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
apply_resolved_outputs: lambda { |merged_output, operations, apply_plan, applied_children|
|
|
293
|
+
apply_markdown_delegated_child_outputs(
|
|
294
|
+
merged_output,
|
|
295
|
+
operations,
|
|
296
|
+
apply_plan,
|
|
297
|
+
applied_children
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def merge_markdown_with_reviewed_nested_outputs(template_source, destination_source, dialect, review_state,
|
|
304
|
+
applied_children, backend: nil)
|
|
305
|
+
Ast::Merge.execute_reviewed_nested_merge(
|
|
306
|
+
review_state,
|
|
307
|
+
'markdown',
|
|
308
|
+
applied_children,
|
|
309
|
+
merge_parent: -> { merge_markdown(template_source, destination_source, dialect, backend: backend) },
|
|
310
|
+
discover_operations: lambda { |merged_output|
|
|
311
|
+
analysis = parse_markdown(merged_output, dialect, backend: backend)
|
|
312
|
+
next({ ok: false, diagnostics: analysis[:diagnostics] || [] }) unless analysis[:ok]
|
|
313
|
+
|
|
314
|
+
{
|
|
315
|
+
ok: true,
|
|
316
|
+
diagnostics: [],
|
|
317
|
+
operations: markdown_delegated_child_operations(analysis[:analysis])
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
apply_resolved_outputs: lambda { |merged_output, operations, apply_plan, resolved_children|
|
|
321
|
+
apply_markdown_delegated_child_outputs(
|
|
322
|
+
merged_output,
|
|
323
|
+
operations,
|
|
324
|
+
apply_plan,
|
|
325
|
+
resolved_children
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def merge_markdown_with_reviewed_nested_outputs_from_replay_bundle(template_source, destination_source, dialect,
|
|
332
|
+
replay_bundle, backend: nil)
|
|
333
|
+
execution = Array(replay_bundle[:reviewed_nested_executions]).find { |entry| entry[:family] == 'markdown' }
|
|
334
|
+
unless execution
|
|
335
|
+
return { ok: false,
|
|
336
|
+
diagnostics: [{ severity: 'error', category: 'configuration_error', message: 'review replay bundle does not include a reviewed nested execution for markdown.' }], policies: [] }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
merge_markdown_with_reviewed_nested_outputs(
|
|
340
|
+
template_source,
|
|
341
|
+
destination_source,
|
|
342
|
+
dialect,
|
|
343
|
+
execution[:review_state],
|
|
344
|
+
execution[:applied_children],
|
|
345
|
+
backend: backend
|
|
346
|
+
)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def merge_markdown_with_reviewed_nested_outputs_from_review_state(template_source, destination_source, dialect,
|
|
350
|
+
review_state, backend: nil)
|
|
351
|
+
execution = Array(review_state[:reviewed_nested_executions]).find { |entry| entry[:family] == 'markdown' }
|
|
352
|
+
unless execution
|
|
353
|
+
return { ok: false,
|
|
354
|
+
diagnostics: [{ severity: 'error', category: 'configuration_error', message: 'review state does not include a reviewed nested execution for markdown.' }], policies: [] }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
merge_markdown_with_reviewed_nested_outputs(
|
|
358
|
+
template_source,
|
|
359
|
+
destination_source,
|
|
360
|
+
dialect,
|
|
361
|
+
execution[:review_state],
|
|
362
|
+
execution[:applied_children],
|
|
363
|
+
backend: backend
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def merge_markdown_with_reviewed_nested_outputs_from_replay_bundle_envelope(template_source, destination_source,
|
|
368
|
+
dialect, envelope, backend: nil)
|
|
369
|
+
replay_bundle, import_error = Ast::Merge.import_review_replay_bundle_envelope(envelope)
|
|
370
|
+
if import_error
|
|
371
|
+
return { ok: false,
|
|
372
|
+
diagnostics: [{ severity: 'error', category: import_error[:category], message: import_error[:message] }], policies: [] }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
merge_markdown_with_reviewed_nested_outputs_from_replay_bundle(
|
|
376
|
+
template_source,
|
|
377
|
+
destination_source,
|
|
378
|
+
dialect,
|
|
379
|
+
replay_bundle,
|
|
380
|
+
backend: backend
|
|
381
|
+
)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def merge_markdown_with_reviewed_nested_outputs_from_review_state_envelope(template_source, destination_source,
|
|
385
|
+
dialect, envelope, backend: nil)
|
|
386
|
+
review_state, import_error = Ast::Merge.import_conformance_manifest_review_state_envelope(envelope)
|
|
387
|
+
if import_error
|
|
388
|
+
return { ok: false,
|
|
389
|
+
diagnostics: [{ severity: 'error', category: import_error[:category], message: import_error[:message] }], policies: [] }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
merge_markdown_with_reviewed_nested_outputs_from_review_state(
|
|
393
|
+
template_source,
|
|
394
|
+
destination_source,
|
|
395
|
+
dialect,
|
|
396
|
+
review_state,
|
|
397
|
+
backend: backend
|
|
398
|
+
)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def normalize_source(source)
|
|
402
|
+
source.gsub(/\r\n?/, "\n")
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def slugify(value)
|
|
406
|
+
slug = value
|
|
407
|
+
.strip
|
|
408
|
+
.downcase
|
|
409
|
+
.gsub(/[`*_~\[\]()<>]/, '')
|
|
410
|
+
.gsub(/[^a-z0-9]+/, '-')
|
|
411
|
+
.gsub(/\A-+|-+\z/, '')
|
|
412
|
+
slug.empty? ? 'section' : slug
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def collect_markdown_owners(source)
|
|
416
|
+
owners = []
|
|
417
|
+
heading_index = 0
|
|
418
|
+
code_fence_index = 0
|
|
419
|
+
lines = source.split("\n")
|
|
420
|
+
index = 0
|
|
421
|
+
|
|
422
|
+
while index < lines.length
|
|
423
|
+
line = lines[index]
|
|
424
|
+
if (heading = line.match(/^(#+)\s+(.+?)\s*#*\s*$/)) && heading[1].length.between?(1, 6)
|
|
425
|
+
level = heading[1].length
|
|
426
|
+
owners << {
|
|
427
|
+
path: "/heading/#{heading_index}",
|
|
428
|
+
owner_kind: 'heading',
|
|
429
|
+
match_key: "h#{level}:#{slugify(heading[2])}",
|
|
430
|
+
level: level
|
|
431
|
+
}
|
|
432
|
+
heading_index += 1
|
|
433
|
+
index += 1
|
|
434
|
+
next
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
if (fence = line.match(/^\s*(`{3,}|~{3,})\s*(.*?)\s*$/))
|
|
438
|
+
marker = fence[1]
|
|
439
|
+
marker_char = marker[0]
|
|
440
|
+
marker_length = marker.length
|
|
441
|
+
info_string = fence[2].strip.split(/\s+/).first.to_s
|
|
442
|
+
owners << {
|
|
443
|
+
path: "/code_fence/#{code_fence_index}",
|
|
444
|
+
owner_kind: 'code_fence',
|
|
445
|
+
match_key: "fence:#{info_string.empty? ? 'plain' : info_string}",
|
|
446
|
+
**(info_string.empty? ? {} : { info_string: info_string })
|
|
447
|
+
}
|
|
448
|
+
code_fence_index += 1
|
|
449
|
+
|
|
450
|
+
index += 1
|
|
451
|
+
while index < lines.length
|
|
452
|
+
trimmed = lines[index].strip
|
|
453
|
+
break if trimmed.length >= marker_length &&
|
|
454
|
+
trimmed.start_with?(marker_char * marker_length) &&
|
|
455
|
+
trimmed.delete(marker_char).empty?
|
|
456
|
+
|
|
457
|
+
index += 1
|
|
458
|
+
end
|
|
459
|
+
index += 1
|
|
460
|
+
next
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
index += 1
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
owners
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def markdown_owner_start_indices(source)
|
|
470
|
+
starts = {}
|
|
471
|
+
lines = normalize_source(source).split("\n")
|
|
472
|
+
heading_index = 0
|
|
473
|
+
code_fence_index = 0
|
|
474
|
+
index = 0
|
|
475
|
+
|
|
476
|
+
while index < lines.length
|
|
477
|
+
line = lines[index]
|
|
478
|
+
if (heading = line.match(/^(#+)\s+(.+?)\s*#*\s*$/)) && heading[1].length.between?(1, 6)
|
|
479
|
+
starts["/heading/#{heading_index}"] = index
|
|
480
|
+
heading_index += 1
|
|
481
|
+
index += 1
|
|
482
|
+
next
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
if (fence = line.match(/^\s*(`{3,}|~{3,})\s*(.*?)\s*$/))
|
|
486
|
+
starts["/code_fence/#{code_fence_index}"] = index
|
|
487
|
+
code_fence_index += 1
|
|
488
|
+
marker = fence[1]
|
|
489
|
+
marker_char = marker[0]
|
|
490
|
+
marker_length = marker.length
|
|
491
|
+
index += 1
|
|
492
|
+
while index < lines.length
|
|
493
|
+
trimmed = lines[index].strip
|
|
494
|
+
break if trimmed.length >= marker_length &&
|
|
495
|
+
trimmed.start_with?(marker_char * marker_length) &&
|
|
496
|
+
trimmed.delete(marker_char).empty?
|
|
497
|
+
|
|
498
|
+
index += 1
|
|
499
|
+
end
|
|
500
|
+
index += 1
|
|
501
|
+
next
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
index += 1
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
starts
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def collect_markdown_sections(source, owners)
|
|
511
|
+
lines = normalize_source(source).split("\n")
|
|
512
|
+
starts = markdown_owner_start_indices(source)
|
|
513
|
+
ordered = owners.filter_map do |owner|
|
|
514
|
+
start = starts[owner[:path]]
|
|
515
|
+
next if start.nil?
|
|
516
|
+
|
|
517
|
+
{ owner: owner, start: start }
|
|
518
|
+
end.sort_by { |entry| entry[:start] }
|
|
519
|
+
|
|
520
|
+
ordered.each_with_index.map do |entry, index|
|
|
521
|
+
finish = ordered[index + 1]&.dig(:start) || lines.length
|
|
522
|
+
{
|
|
523
|
+
path: entry.dig(:owner, :path),
|
|
524
|
+
text: lines[entry[:start]...finish].join("\n").strip
|
|
525
|
+
}
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def markdown_fence_ranges(source)
|
|
530
|
+
ranges = {}
|
|
531
|
+
code_fence_index = 0
|
|
532
|
+
lines = normalize_source(source).split("\n")
|
|
533
|
+
index = 0
|
|
534
|
+
|
|
535
|
+
while index < lines.length
|
|
536
|
+
line = lines[index]
|
|
537
|
+
if (fence = line.match(/^\s*(`{3,}|~{3,})\s*(.*?)\s*$/))
|
|
538
|
+
marker = fence[1]
|
|
539
|
+
marker_char = marker[0]
|
|
540
|
+
marker_length = marker.length
|
|
541
|
+
closing_index = index
|
|
542
|
+
cursor = index + 1
|
|
543
|
+
while cursor < lines.length
|
|
544
|
+
trimmed = lines[cursor].strip
|
|
545
|
+
if trimmed.length >= marker_length &&
|
|
546
|
+
trimmed.start_with?(marker_char * marker_length) &&
|
|
547
|
+
trimmed.delete(marker_char).empty?
|
|
548
|
+
closing_index = cursor
|
|
549
|
+
break
|
|
550
|
+
end
|
|
551
|
+
closing_index = cursor if cursor == lines.length - 1
|
|
552
|
+
cursor += 1
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
ranges["/code_fence/#{code_fence_index}"] = { start: index, end: closing_index }
|
|
556
|
+
code_fence_index += 1
|
|
557
|
+
index = closing_index + 1
|
|
558
|
+
next
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
index += 1
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
ranges
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def code_fence_family(info_string)
|
|
568
|
+
case info_string.to_s.downcase
|
|
569
|
+
when 'ts', 'typescript'
|
|
570
|
+
'typescript'
|
|
571
|
+
when 'rust', 'rs'
|
|
572
|
+
'rust'
|
|
573
|
+
when 'go'
|
|
574
|
+
'go'
|
|
575
|
+
when 'json', 'jsonc', 'json5'
|
|
576
|
+
'json'
|
|
577
|
+
when 'yaml', 'yml'
|
|
578
|
+
'yaml'
|
|
579
|
+
when 'toml'
|
|
580
|
+
'toml'
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def code_fence_dialect(info_string, family)
|
|
585
|
+
case family
|
|
586
|
+
when 'typescript', 'rust', 'go', 'yaml', 'toml'
|
|
587
|
+
family
|
|
588
|
+
when 'json'
|
|
589
|
+
%w[json jsonc json5].include?(info_string.to_s.downcase) ? info_string.to_s.downcase : 'json'
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def resolve_backend(backend)
|
|
594
|
+
return backend.to_s unless backend.to_s.empty?
|
|
595
|
+
|
|
596
|
+
current = TreeHaver.current_backend_id
|
|
597
|
+
return current if BACKEND_REFERENCES.key?(current.to_s) && markdown_backend_available_for_analysis?(current)
|
|
598
|
+
|
|
599
|
+
BACKEND_REFERENCES.keys.find { |backend_id| markdown_backend_available_for_analysis?(backend_id) } ||
|
|
600
|
+
'kreuzberg-language-pack'
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def markdown_backend_available_for_analysis?(backend_id)
|
|
604
|
+
register_backend!
|
|
605
|
+
registrations = TreeHaver.registered_languages(:markdown)
|
|
606
|
+
case backend_id.to_s
|
|
607
|
+
when 'commonmarker', 'markly', 'kramdown'
|
|
608
|
+
registrations.dig(backend_id.to_sym, :backend_module)&.then do |backend_module|
|
|
609
|
+
!backend_module.respond_to?(:available?) || backend_module.available?
|
|
610
|
+
end
|
|
611
|
+
when 'kreuzberg-language-pack'
|
|
612
|
+
registrations.key?(:tree_sitter) || registrations.key?(:tslp)
|
|
613
|
+
else
|
|
614
|
+
false
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def collect_parse_errors(node)
|
|
619
|
+
raise TreeHaver::NotAvailable, 'Markdown parse returned no root node' unless node
|
|
620
|
+
return unless node.respond_to?(:has_error?) && node.has_error?
|
|
621
|
+
|
|
622
|
+
raise TreeHaver::NotAvailable,
|
|
623
|
+
'Markdown parse contains syntax errors'
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def parse_failure_result(error)
|
|
627
|
+
{
|
|
628
|
+
ok: false,
|
|
629
|
+
diagnostics: [{ severity: 'error', category: 'parse_error', message: error.message }],
|
|
630
|
+
policies: []
|
|
631
|
+
}
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def unsupported_feature_result(message)
|
|
635
|
+
{
|
|
636
|
+
ok: false,
|
|
637
|
+
diagnostics: [{ severity: 'error', category: 'unsupported_feature', message: message }],
|
|
638
|
+
policies: []
|
|
639
|
+
}
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
module_function(
|
|
643
|
+
:register_backend!,
|
|
644
|
+
:markdown_feature_profile,
|
|
645
|
+
:available_markdown_backends,
|
|
646
|
+
:markdown_backend_feature_profile,
|
|
647
|
+
:markdown_plan_context,
|
|
648
|
+
:parse_markdown,
|
|
649
|
+
:match_markdown_owners,
|
|
650
|
+
:merge_markdown,
|
|
651
|
+
:markdown_embedded_families,
|
|
652
|
+
:markdown_discovered_surfaces,
|
|
653
|
+
:markdown_delegated_child_operations,
|
|
654
|
+
:apply_markdown_delegated_child_outputs,
|
|
655
|
+
:merge_markdown_with_reviewed_nested_outputs,
|
|
656
|
+
:merge_markdown_with_reviewed_nested_outputs_from_replay_bundle,
|
|
657
|
+
:merge_markdown_with_reviewed_nested_outputs_from_replay_bundle_envelope,
|
|
658
|
+
:merge_markdown_with_reviewed_nested_outputs_from_review_state,
|
|
659
|
+
:merge_markdown_with_reviewed_nested_outputs_from_review_state_envelope,
|
|
660
|
+
:merge_markdown_with_nested_outputs,
|
|
661
|
+
:normalize_source,
|
|
662
|
+
:slugify,
|
|
663
|
+
:collect_markdown_owners,
|
|
664
|
+
:markdown_owner_start_indices,
|
|
665
|
+
:collect_markdown_sections,
|
|
666
|
+
:markdown_fence_ranges,
|
|
667
|
+
:code_fence_family,
|
|
668
|
+
:code_fence_dialect,
|
|
669
|
+
:resolve_backend,
|
|
670
|
+
:markdown_backend_available_for_analysis?,
|
|
671
|
+
:collect_parse_errors,
|
|
672
|
+
:parse_failure_result,
|
|
673
|
+
:unsupported_feature_result
|
|
674
|
+
)
|
|
129
675
|
end
|
|
130
676
|
end
|
|
131
677
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
678
|
+
%w[
|
|
679
|
+
commonmarker/merge/backend
|
|
680
|
+
markly/merge/backend
|
|
681
|
+
].each do |feature|
|
|
682
|
+
require feature
|
|
683
|
+
rescue LoadError
|
|
684
|
+
nil
|
|
685
|
+
end
|
|
686
|
+
|
|
136
687
|
if defined?(Ast::Merge::RSpec::MergeGemRegistry)
|
|
137
688
|
Ast::Merge::RSpec::MergeGemRegistry.register(
|
|
138
689
|
:markdown_merge,
|
|
139
|
-
require_path:
|
|
140
|
-
merger_class:
|
|
690
|
+
require_path: 'markdown/merge',
|
|
691
|
+
merger_class: 'Markdown::Merge::SmartMerger',
|
|
141
692
|
test_source: "# Test\n\nParagraph",
|
|
142
693
|
category: :markdown,
|
|
143
|
-
skip_instantiation: true
|
|
694
|
+
skip_instantiation: true
|
|
144
695
|
)
|
|
145
696
|
end
|
|
146
|
-
|
|
147
|
-
Markdown::Merge::Version.class_eval do
|
|
148
|
-
extend VersionGem::Basic
|
|
149
|
-
end
|