ast-merge 1.0.0 → 2.0.0
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/CHANGELOG.md +194 -1
- data/README.md +235 -53
- data/exe/ast-merge-recipe +366 -0
- data/lib/ast/merge/ast_node.rb +224 -24
- data/lib/ast/merge/comment/block.rb +6 -0
- data/lib/ast/merge/comment/empty.rb +6 -0
- data/lib/ast/merge/comment/line.rb +6 -0
- data/lib/ast/merge/comment/parser.rb +9 -7
- data/lib/ast/merge/conflict_resolver_base.rb +8 -1
- data/lib/ast/merge/content_match_refiner.rb +278 -0
- data/lib/ast/merge/debug_logger.rb +6 -1
- data/lib/ast/merge/detector/base.rb +193 -0
- data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
- data/lib/ast/merge/detector/mergeable.rb +369 -0
- data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
- data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
- data/lib/ast/merge/file_analyzable.rb +5 -3
- data/lib/ast/merge/freeze_node_base.rb +1 -1
- data/lib/ast/merge/match_refiner_base.rb +1 -1
- data/lib/ast/merge/match_score_base.rb +1 -1
- data/lib/ast/merge/merge_result_base.rb +4 -1
- data/lib/ast/merge/merger_config.rb +33 -31
- data/lib/ast/merge/navigable_statement.rb +630 -0
- data/lib/ast/merge/partial_template_merger.rb +432 -0
- data/lib/ast/merge/recipe/config.rb +198 -0
- data/lib/ast/merge/recipe/preset.rb +171 -0
- data/lib/ast/merge/recipe/runner.rb +254 -0
- data/lib/ast/merge/recipe/script_loader.rb +181 -0
- data/lib/ast/merge/recipe.rb +26 -0
- data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
- data/lib/ast/merge/rspec.rb +33 -2
- data/lib/ast/merge/section_typing.rb +52 -50
- data/lib/ast/merge/smart_merger_base.rb +86 -3
- data/lib/ast/merge/text/line_node.rb +42 -9
- data/lib/ast/merge/text/section_splitter.rb +12 -10
- data/lib/ast/merge/text/word_node.rb +47 -14
- data/lib/ast/merge/version.rb +1 -1
- data/lib/ast/merge.rb +10 -6
- data/sig/ast/merge.rbs +389 -2
- data.tar.gz.sig +0 -0
- metadata +76 -12
- metadata.gz.sig +0 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +0 -211
- data/lib/ast/merge/region.rb +0 -124
- data/lib/ast/merge/region_detector_base.rb +0 -114
- data/lib/ast/merge/region_mergeable.rb +0 -364
- data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
- data/lib/ast/merge/yaml_frontmatter_detector.rb +0 -108
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ast
|
|
4
|
-
module Merge
|
|
5
|
-
##
|
|
6
|
-
# Mixin for adding region support to SmartMerger classes.
|
|
7
|
-
#
|
|
8
|
-
# This module provides functionality for detecting and handling regions
|
|
9
|
-
# within documents that should be merged with different strategies.
|
|
10
|
-
# Regions are portions of a document (like YAML frontmatter or fenced
|
|
11
|
-
# code blocks) that may require specialized merging.
|
|
12
|
-
#
|
|
13
|
-
# @example Basic region configuration
|
|
14
|
-
# class SmartMerger
|
|
15
|
-
# include RegionMergeable
|
|
16
|
-
#
|
|
17
|
-
# def initialize(template, dest, regions: [], region_placeholder: nil)
|
|
18
|
-
# @template_content = template
|
|
19
|
-
# @dest_content = dest
|
|
20
|
-
# setup_regions(regions: regions, region_placeholder: region_placeholder)
|
|
21
|
-
# end
|
|
22
|
-
# end
|
|
23
|
-
#
|
|
24
|
-
# @example With YAML frontmatter regions
|
|
25
|
-
# merger = SmartMerger.new(
|
|
26
|
-
# template,
|
|
27
|
-
# dest,
|
|
28
|
-
# regions: [
|
|
29
|
-
# {
|
|
30
|
-
# detector: YamlFrontmatterDetector.new,
|
|
31
|
-
# merger_class: SomeYamlMerger,
|
|
32
|
-
# merger_options: { preserve_order: true }
|
|
33
|
-
# }
|
|
34
|
-
# ]
|
|
35
|
-
# )
|
|
36
|
-
#
|
|
37
|
-
# @example With nested regions (code blocks in markdown)
|
|
38
|
-
# merger = SmartMerger.new(
|
|
39
|
-
# template,
|
|
40
|
-
# dest,
|
|
41
|
-
# regions: [
|
|
42
|
-
# {
|
|
43
|
-
# detector: FencedCodeBlockDetector.ruby,
|
|
44
|
-
# merger_class: Prism::Merge::SmartMerger,
|
|
45
|
-
# regions: [...] # Nested regions!
|
|
46
|
-
# }
|
|
47
|
-
# ]
|
|
48
|
-
# )
|
|
49
|
-
#
|
|
50
|
-
module RegionMergeable
|
|
51
|
-
# Default placeholder prefix for extracted regions
|
|
52
|
-
DEFAULT_PLACEHOLDER_PREFIX = "<<<AST_MERGE_REGION_"
|
|
53
|
-
DEFAULT_PLACEHOLDER_SUFFIX = ">>>"
|
|
54
|
-
|
|
55
|
-
##
|
|
56
|
-
# Configuration for a single region type.
|
|
57
|
-
#
|
|
58
|
-
# @attr detector [RegionDetectorBase] Detector instance for finding regions
|
|
59
|
-
# @attr merger_class [Class, nil] Merger class for merging region content (nil to skip merging)
|
|
60
|
-
# @attr merger_options [Hash] Options to pass to the region merger
|
|
61
|
-
# @attr regions [Array<Hash>] Nested region configurations (recursive)
|
|
62
|
-
#
|
|
63
|
-
RegionConfig = Struct.new(:detector, :merger_class, :merger_options, :regions, keyword_init: true) do
|
|
64
|
-
def initialize(detector:, merger_class: nil, merger_options: {}, regions: [])
|
|
65
|
-
super(
|
|
66
|
-
detector: detector,
|
|
67
|
-
merger_class: merger_class,
|
|
68
|
-
merger_options: merger_options || {},
|
|
69
|
-
regions: regions || [],
|
|
70
|
-
)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
##
|
|
75
|
-
# Extracted region with its content and placeholder.
|
|
76
|
-
#
|
|
77
|
-
# @attr region [Region] The detected region
|
|
78
|
-
# @attr config [RegionConfig] The configuration that matched this region
|
|
79
|
-
# @attr placeholder [String] The placeholder used in the document
|
|
80
|
-
# @attr merged_content [String, nil] The merged content (set after merging)
|
|
81
|
-
#
|
|
82
|
-
ExtractedRegion = Struct.new(:region, :config, :placeholder, :merged_content, keyword_init: true)
|
|
83
|
-
|
|
84
|
-
##
|
|
85
|
-
# Set up region handling for this merger instance.
|
|
86
|
-
#
|
|
87
|
-
# @param regions [Array<Hash>] Array of region configurations
|
|
88
|
-
# @param region_placeholder [String, nil] Custom placeholder prefix (optional)
|
|
89
|
-
# @raise [ArgumentError] if regions configuration is invalid
|
|
90
|
-
#
|
|
91
|
-
def setup_regions(regions:, region_placeholder: nil)
|
|
92
|
-
@region_configs = build_region_configs(regions)
|
|
93
|
-
@region_placeholder_prefix = region_placeholder || DEFAULT_PLACEHOLDER_PREFIX
|
|
94
|
-
@extracted_template_regions = []
|
|
95
|
-
@extracted_dest_regions = []
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
##
|
|
99
|
-
# Check if this merger has region configurations.
|
|
100
|
-
#
|
|
101
|
-
# @return [Boolean] true if regions are configured
|
|
102
|
-
#
|
|
103
|
-
def regions_configured?
|
|
104
|
-
@region_configs && !@region_configs.empty?
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
##
|
|
108
|
-
# Extract regions from the template content, replacing with placeholders.
|
|
109
|
-
#
|
|
110
|
-
# @param content [String] Template content
|
|
111
|
-
# @return [String] Content with regions replaced by placeholders
|
|
112
|
-
# @raise [PlaceholderCollisionError] if content contains placeholder text
|
|
113
|
-
#
|
|
114
|
-
def extract_template_regions(content)
|
|
115
|
-
return content unless regions_configured?
|
|
116
|
-
|
|
117
|
-
extract_regions(content, @extracted_template_regions)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
##
|
|
121
|
-
# Extract regions from the destination content, replacing with placeholders.
|
|
122
|
-
#
|
|
123
|
-
# @param content [String] Destination content
|
|
124
|
-
# @return [String] Content with regions replaced by placeholders
|
|
125
|
-
# @raise [PlaceholderCollisionError] if content contains placeholder text
|
|
126
|
-
#
|
|
127
|
-
def extract_dest_regions(content)
|
|
128
|
-
return content unless regions_configured?
|
|
129
|
-
|
|
130
|
-
extract_regions(content, @extracted_dest_regions)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
##
|
|
134
|
-
# Merge extracted regions and substitute them back into the merged content.
|
|
135
|
-
#
|
|
136
|
-
# @param merged_content [String] The merged content with placeholders
|
|
137
|
-
# @return [String] Content with placeholders replaced by merged regions
|
|
138
|
-
#
|
|
139
|
-
def substitute_merged_regions(merged_content)
|
|
140
|
-
return merged_content unless regions_configured?
|
|
141
|
-
|
|
142
|
-
result = merged_content
|
|
143
|
-
|
|
144
|
-
# Process regions in reverse order of extraction to handle nested placeholders
|
|
145
|
-
# We need to merge template and dest regions by their placeholder index
|
|
146
|
-
merge_and_substitute_regions(result)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
private
|
|
150
|
-
|
|
151
|
-
##
|
|
152
|
-
# Build RegionConfig objects from configuration hashes.
|
|
153
|
-
#
|
|
154
|
-
# @param configs [Array<Hash>] Array of configuration hashes
|
|
155
|
-
# @return [Array<RegionConfig>] Array of RegionConfig objects
|
|
156
|
-
#
|
|
157
|
-
def build_region_configs(configs)
|
|
158
|
-
return [] if configs.nil? || configs.empty?
|
|
159
|
-
|
|
160
|
-
configs.map do |config|
|
|
161
|
-
case config
|
|
162
|
-
when RegionConfig
|
|
163
|
-
config
|
|
164
|
-
when Hash
|
|
165
|
-
RegionConfig.new(
|
|
166
|
-
detector: config[:detector],
|
|
167
|
-
merger_class: config[:merger_class],
|
|
168
|
-
merger_options: config[:merger_options] || {},
|
|
169
|
-
regions: config[:regions] || [],
|
|
170
|
-
)
|
|
171
|
-
else
|
|
172
|
-
raise ArgumentError, "Invalid region config: #{config.inspect}"
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
##
|
|
178
|
-
# Extract regions from content, replacing with placeholders.
|
|
179
|
-
#
|
|
180
|
-
# @param content [String] Content to process
|
|
181
|
-
# @param storage [Array<ExtractedRegion>] Array to store extracted regions
|
|
182
|
-
# @return [String] Content with placeholders
|
|
183
|
-
#
|
|
184
|
-
def extract_regions(content, storage)
|
|
185
|
-
validate_no_placeholder_collision!(content)
|
|
186
|
-
|
|
187
|
-
result = content
|
|
188
|
-
region_index = storage.size
|
|
189
|
-
|
|
190
|
-
@region_configs.each do |config|
|
|
191
|
-
regions = config.detector.detect_all(result)
|
|
192
|
-
|
|
193
|
-
# Process regions in reverse order to maintain correct positions
|
|
194
|
-
regions.sort_by { |r| -r.start_line }.each do |region|
|
|
195
|
-
placeholder = build_placeholder(region_index)
|
|
196
|
-
region_index += 1
|
|
197
|
-
|
|
198
|
-
extracted = ExtractedRegion.new(
|
|
199
|
-
region: region,
|
|
200
|
-
config: config,
|
|
201
|
-
placeholder: placeholder,
|
|
202
|
-
merged_content: nil,
|
|
203
|
-
)
|
|
204
|
-
storage.unshift(extracted) # Add to front since we process in reverse
|
|
205
|
-
|
|
206
|
-
# Replace the region with the placeholder
|
|
207
|
-
result = replace_region_with_placeholder(result, region, placeholder)
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
storage.sort_by! { |e| placeholder_index(e.placeholder) }
|
|
212
|
-
result
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
##
|
|
216
|
-
# Validate that the content doesn't contain placeholder text.
|
|
217
|
-
#
|
|
218
|
-
# @param content [String] Content to validate
|
|
219
|
-
# @raise [PlaceholderCollisionError] if placeholder is found
|
|
220
|
-
#
|
|
221
|
-
def validate_no_placeholder_collision!(content)
|
|
222
|
-
return if content.nil? || content.empty?
|
|
223
|
-
|
|
224
|
-
if content.include?(@region_placeholder_prefix)
|
|
225
|
-
raise PlaceholderCollisionError, @region_placeholder_prefix
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
##
|
|
230
|
-
# Build a placeholder string for a given index.
|
|
231
|
-
#
|
|
232
|
-
# @param index [Integer] The region index
|
|
233
|
-
# @return [String] The placeholder string
|
|
234
|
-
#
|
|
235
|
-
def build_placeholder(index)
|
|
236
|
-
"#{@region_placeholder_prefix}#{index}#{DEFAULT_PLACEHOLDER_SUFFIX}"
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
##
|
|
240
|
-
# Extract the index from a placeholder string.
|
|
241
|
-
#
|
|
242
|
-
# @param placeholder [String] The placeholder string
|
|
243
|
-
# @return [Integer] The extracted index
|
|
244
|
-
#
|
|
245
|
-
def placeholder_index(placeholder)
|
|
246
|
-
placeholder.match(/#{Regexp.escape(@region_placeholder_prefix)}(\d+)/)[1].to_i
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
##
|
|
250
|
-
# Replace a region in content with a placeholder.
|
|
251
|
-
#
|
|
252
|
-
# @param content [String] The content
|
|
253
|
-
# @param region [Region] The region to replace
|
|
254
|
-
# @param placeholder [String] The placeholder to insert
|
|
255
|
-
# @return [String] Content with region replaced
|
|
256
|
-
#
|
|
257
|
-
def replace_region_with_placeholder(content, region, placeholder)
|
|
258
|
-
lines = content.lines
|
|
259
|
-
# Region line numbers are 1-indexed
|
|
260
|
-
start_idx = region.start_line - 1
|
|
261
|
-
end_idx = region.end_line - 1
|
|
262
|
-
|
|
263
|
-
# Replace the region lines with the placeholder
|
|
264
|
-
before = lines[0...start_idx]
|
|
265
|
-
after = lines[(end_idx + 1)..]
|
|
266
|
-
|
|
267
|
-
# Preserve the newline style
|
|
268
|
-
newline = content.include?("\r\n") ? "\r\n" : "\n"
|
|
269
|
-
placeholder_line = "#{placeholder}#{newline}"
|
|
270
|
-
|
|
271
|
-
(before + [placeholder_line] + (after || [])).join
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
##
|
|
275
|
-
# Merge and substitute regions back into the merged content.
|
|
276
|
-
#
|
|
277
|
-
# @param content [String] Merged content with placeholders
|
|
278
|
-
# @return [String] Content with merged regions substituted
|
|
279
|
-
#
|
|
280
|
-
def merge_and_substitute_regions(content)
|
|
281
|
-
result = content
|
|
282
|
-
|
|
283
|
-
# Build a mapping of placeholder index to extracted regions from both sources
|
|
284
|
-
template_by_idx = @extracted_template_regions.each_with_object({}) do |e, h|
|
|
285
|
-
h[placeholder_index(e.placeholder)] = e
|
|
286
|
-
end
|
|
287
|
-
dest_by_idx = @extracted_dest_regions.each_with_object({}) do |e, h|
|
|
288
|
-
h[placeholder_index(e.placeholder)] = e
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Find all placeholder indices in the merged content
|
|
292
|
-
all_indices = (template_by_idx.keys + dest_by_idx.keys).uniq.sort
|
|
293
|
-
|
|
294
|
-
all_indices.each do |idx|
|
|
295
|
-
template_extracted = template_by_idx[idx]
|
|
296
|
-
dest_extracted = dest_by_idx[idx]
|
|
297
|
-
placeholder = build_placeholder(idx)
|
|
298
|
-
|
|
299
|
-
merged_region_content = merge_region(template_extracted, dest_extracted)
|
|
300
|
-
result = result.gsub(placeholder, merged_region_content) if merged_region_content
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
result
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
##
|
|
307
|
-
# Merge a region from template and destination.
|
|
308
|
-
#
|
|
309
|
-
# @param template_extracted [ExtractedRegion, nil] Template region
|
|
310
|
-
# @param dest_extracted [ExtractedRegion, nil] Destination region
|
|
311
|
-
# @return [String, nil] Merged region content, or nil if no content
|
|
312
|
-
#
|
|
313
|
-
def merge_region(template_extracted, dest_extracted)
|
|
314
|
-
config = template_extracted&.config || dest_extracted&.config
|
|
315
|
-
return unless config
|
|
316
|
-
|
|
317
|
-
template_region = template_extracted&.region
|
|
318
|
-
dest_region = dest_extracted&.region
|
|
319
|
-
|
|
320
|
-
# Get the full text (including delimiters) for each region
|
|
321
|
-
template_text = template_region&.full_text || ""
|
|
322
|
-
dest_text = dest_region&.full_text || ""
|
|
323
|
-
|
|
324
|
-
# If no merger class, prefer destination content (preserve customizations)
|
|
325
|
-
unless config.merger_class
|
|
326
|
-
return dest_text.empty? ? template_text : dest_text
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
# Extract just the content (without delimiters) for merging
|
|
330
|
-
template_content = template_region&.content || ""
|
|
331
|
-
dest_content = dest_region&.content || ""
|
|
332
|
-
|
|
333
|
-
# Build merger options, including nested regions if configured
|
|
334
|
-
merger_options = config.merger_options.dup
|
|
335
|
-
merger_options[:regions] = config.regions unless config.regions.empty?
|
|
336
|
-
|
|
337
|
-
# Create the merger and merge the region content
|
|
338
|
-
merger = config.merger_class.new(template_content, dest_content, **merger_options)
|
|
339
|
-
merged_content = merger.merge
|
|
340
|
-
|
|
341
|
-
# Reconstruct with delimiters
|
|
342
|
-
reconstruct_region_with_delimiters(template_region || dest_region, merged_content)
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
##
|
|
346
|
-
# Reconstruct a region with its delimiters around the merged content.
|
|
347
|
-
#
|
|
348
|
-
# @param region [Region] The original region (for delimiter info)
|
|
349
|
-
# @param content [String] The merged content
|
|
350
|
-
# @return [String] Full region text with delimiters
|
|
351
|
-
#
|
|
352
|
-
def reconstruct_region_with_delimiters(region, content)
|
|
353
|
-
return content unless region&.delimiters
|
|
354
|
-
|
|
355
|
-
opening, closing = region.delimiters
|
|
356
|
-
|
|
357
|
-
# Ensure content ends with newline if it doesn't
|
|
358
|
-
normalized_content = content.end_with?("\n") ? content : "#{content}\n"
|
|
359
|
-
|
|
360
|
-
"#{opening}\n#{normalized_content}#{closing}\n"
|
|
361
|
-
end
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
end
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ast
|
|
4
|
-
module Merge
|
|
5
|
-
##
|
|
6
|
-
# Detects TOML frontmatter at the beginning of a document.
|
|
7
|
-
#
|
|
8
|
-
# TOML frontmatter is delimited by `+++` at the start and end,
|
|
9
|
-
# and must begin on the first line of the document (optionally
|
|
10
|
-
# preceded by a UTF-8 BOM). This format is commonly used by
|
|
11
|
-
# Hugo and other static site generators.
|
|
12
|
-
#
|
|
13
|
-
# @example TOML frontmatter
|
|
14
|
-
# +++
|
|
15
|
-
# title = "My Document"
|
|
16
|
-
# author = "Jane Doe"
|
|
17
|
-
# +++
|
|
18
|
-
#
|
|
19
|
-
# @example Usage
|
|
20
|
-
# detector = TomlFrontmatterDetector.new
|
|
21
|
-
# regions = detector.detect_all(markdown_source)
|
|
22
|
-
# # => [#<Region type=:toml_frontmatter content="title = \"My Document\"\n...">]
|
|
23
|
-
#
|
|
24
|
-
class TomlFrontmatterDetector < RegionDetectorBase
|
|
25
|
-
##
|
|
26
|
-
# Pattern for detecting TOML frontmatter.
|
|
27
|
-
# - Must start at beginning of document (or after BOM)
|
|
28
|
-
# - Opening delimiter is `+++` followed by optional whitespace and newline
|
|
29
|
-
# - Content is captured (non-greedy)
|
|
30
|
-
# - Closing delimiter is `+++` at start of line, followed by optional whitespace and newline/EOF
|
|
31
|
-
#
|
|
32
|
-
FRONTMATTER_PATTERN = /\A(?:\xEF\xBB\xBF)?(\+\+\+[ \t]*\r?\n)(.*?)(^\+\+\+[ \t]*(?:\r?\n|\z))/m
|
|
33
|
-
|
|
34
|
-
##
|
|
35
|
-
# @return [Symbol] the type identifier for TOML frontmatter regions
|
|
36
|
-
#
|
|
37
|
-
def region_type
|
|
38
|
-
:toml_frontmatter
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
##
|
|
42
|
-
# Detects TOML frontmatter at the beginning of the document.
|
|
43
|
-
#
|
|
44
|
-
# @param source [String] the source document to scan
|
|
45
|
-
# @return [Array<Region>] array containing at most one Region for frontmatter
|
|
46
|
-
#
|
|
47
|
-
def detect_all(source)
|
|
48
|
-
return [] if source.nil? || source.empty?
|
|
49
|
-
|
|
50
|
-
match = source.match(FRONTMATTER_PATTERN)
|
|
51
|
-
return [] unless match
|
|
52
|
-
|
|
53
|
-
opening_delimiter = match[1]
|
|
54
|
-
content = match[2]
|
|
55
|
-
closing_delimiter = match[3]
|
|
56
|
-
|
|
57
|
-
# Calculate line numbers
|
|
58
|
-
start_line = 1
|
|
59
|
-
|
|
60
|
-
# Count total newlines in the full match to determine end line
|
|
61
|
-
full_match = match[0]
|
|
62
|
-
total_newlines = full_match.count("\n")
|
|
63
|
-
end_line = total_newlines + (full_match.end_with?("\n") ? 0 : 1)
|
|
64
|
-
|
|
65
|
-
[
|
|
66
|
-
Region.new(
|
|
67
|
-
type: region_type,
|
|
68
|
-
content: content,
|
|
69
|
-
start_line: start_line,
|
|
70
|
-
end_line: end_line,
|
|
71
|
-
delimiters: [opening_delimiter.strip, closing_delimiter.strip],
|
|
72
|
-
metadata: {format: :toml},
|
|
73
|
-
),
|
|
74
|
-
]
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
private
|
|
78
|
-
|
|
79
|
-
##
|
|
80
|
-
# @return [Array<Region>] array containing at most one Region
|
|
81
|
-
#
|
|
82
|
-
def build_regions(source, matches)
|
|
83
|
-
# Not used - detect_all is overridden directly
|
|
84
|
-
raise NotImplementedError, "TomlFrontmatterDetector overrides detect_all directly"
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ast
|
|
4
|
-
module Merge
|
|
5
|
-
##
|
|
6
|
-
# Detects YAML frontmatter at the beginning of a document.
|
|
7
|
-
#
|
|
8
|
-
# YAML frontmatter is delimited by `---` at the start and end,
|
|
9
|
-
# and must begin on the first line of the document (optionally
|
|
10
|
-
# preceded by a UTF-8 BOM).
|
|
11
|
-
#
|
|
12
|
-
# @example YAML frontmatter
|
|
13
|
-
# ---
|
|
14
|
-
# title: My Document
|
|
15
|
-
# author: Jane Doe
|
|
16
|
-
# ---
|
|
17
|
-
#
|
|
18
|
-
# @example Usage
|
|
19
|
-
# detector = YamlFrontmatterDetector.new
|
|
20
|
-
# regions = detector.detect_all(markdown_source)
|
|
21
|
-
# # => [#<Region type=:yaml_frontmatter content="title: My Document\n...">]
|
|
22
|
-
#
|
|
23
|
-
class YamlFrontmatterDetector < RegionDetectorBase
|
|
24
|
-
##
|
|
25
|
-
# Pattern for detecting YAML frontmatter.
|
|
26
|
-
# - Must start at beginning of document (or after BOM)
|
|
27
|
-
# - Opening delimiter is `---` followed by optional whitespace and newline
|
|
28
|
-
# - Content is captured (non-greedy)
|
|
29
|
-
# - Closing delimiter is `---` at start of line, followed by optional whitespace and newline/EOF
|
|
30
|
-
#
|
|
31
|
-
FRONTMATTER_PATTERN = /\A(?:\xEF\xBB\xBF)?(---[ \t]*\r?\n)(.*?)(^---[ \t]*(?:\r?\n|\z))/m
|
|
32
|
-
|
|
33
|
-
##
|
|
34
|
-
# @return [Symbol] the type identifier for YAML frontmatter regions
|
|
35
|
-
#
|
|
36
|
-
def region_type
|
|
37
|
-
:yaml_frontmatter
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
##
|
|
41
|
-
# Detects YAML frontmatter at the beginning of the document.
|
|
42
|
-
#
|
|
43
|
-
# @param source [String] the source document to scan
|
|
44
|
-
# @return [Array<Region>] array containing at most one Region for frontmatter
|
|
45
|
-
#
|
|
46
|
-
def detect_all(source)
|
|
47
|
-
return [] if source.nil? || source.empty?
|
|
48
|
-
|
|
49
|
-
match = source.match(FRONTMATTER_PATTERN)
|
|
50
|
-
return [] unless match
|
|
51
|
-
|
|
52
|
-
opening_delimiter = match[1]
|
|
53
|
-
content = match[2]
|
|
54
|
-
closing_delimiter = match[3]
|
|
55
|
-
|
|
56
|
-
# Calculate line numbers
|
|
57
|
-
# Frontmatter starts at line 1 (or after BOM)
|
|
58
|
-
start_line = 1
|
|
59
|
-
# Count newlines in content to determine end line
|
|
60
|
-
# Opening delimiter ends at line 1
|
|
61
|
-
# Content spans from line 2 to line 2 + content_lines - 1
|
|
62
|
-
# Closing delimiter is on the next line
|
|
63
|
-
content_newlines = content.count("\n")
|
|
64
|
-
# end_line is the line with the closing ---
|
|
65
|
-
end_line = start_line + 1 + content_newlines
|
|
66
|
-
|
|
67
|
-
# Adjust if content ends without newline
|
|
68
|
-
end_line - 1 if content.end_with?("\n") && content_newlines > 0
|
|
69
|
-
|
|
70
|
-
# Actually, let's calculate more carefully
|
|
71
|
-
# Line 1: ---
|
|
72
|
-
# Line 2 to N: content
|
|
73
|
-
# Line N+1: ---
|
|
74
|
-
if content.empty?
|
|
75
|
-
0
|
|
76
|
-
else
|
|
77
|
-
content.count("\n") + (content.end_with?("\n") ? 0 : 1)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Simplify: count total newlines in the full match to determine end line
|
|
81
|
-
full_match = match[0]
|
|
82
|
-
total_newlines = full_match.count("\n")
|
|
83
|
-
end_line = total_newlines + (full_match.end_with?("\n") ? 0 : 1)
|
|
84
|
-
|
|
85
|
-
[
|
|
86
|
-
Region.new(
|
|
87
|
-
type: region_type,
|
|
88
|
-
content: content,
|
|
89
|
-
start_line: start_line,
|
|
90
|
-
end_line: end_line,
|
|
91
|
-
delimiters: [opening_delimiter.strip, closing_delimiter.strip],
|
|
92
|
-
metadata: {format: :yaml},
|
|
93
|
-
),
|
|
94
|
-
]
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
private
|
|
98
|
-
|
|
99
|
-
##
|
|
100
|
-
# @return [Array<Region>] array containing at most one Region
|
|
101
|
-
#
|
|
102
|
-
def build_regions(source, matches)
|
|
103
|
-
# Not used - detect_all is overridden directly
|
|
104
|
-
raise NotImplementedError, "YamlFrontmatterDetector overrides detect_all directly"
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|