ast-merge 1.1.0 → 2.0.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/CHANGELOG.md +198 -7
- data/README.md +208 -39
- data/exe/ast-merge-recipe +366 -0
- 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 +2 -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/merge_result_base.rb +4 -1
- 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/smart_merger_base.rb +86 -3
- 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 +60 -16
- metadata.gz.sig +0 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +0 -313
- 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 -88
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Merges a partial template into a specific section of a destination document.
|
|
6
|
+
#
|
|
7
|
+
# Unlike the full SmartMerger which merges entire documents, PartialTemplateMerger:
|
|
8
|
+
# 1. Finds a specific section in the destination (using InjectionPoint)
|
|
9
|
+
# 2. Replaces/merges only that section with the template
|
|
10
|
+
# 3. Leaves the rest of the destination unchanged
|
|
11
|
+
#
|
|
12
|
+
# This is useful for updating a specific section (like a "Gem Family" section)
|
|
13
|
+
# across multiple files while preserving file-specific content.
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# merger = PartialTemplateMerger.new(
|
|
17
|
+
# template: template_content,
|
|
18
|
+
# destination: destination_content,
|
|
19
|
+
# anchor: { type: :heading, text: /Gem Family/ },
|
|
20
|
+
# parser: :markly
|
|
21
|
+
# )
|
|
22
|
+
# result = merger.merge
|
|
23
|
+
# puts result.content
|
|
24
|
+
#
|
|
25
|
+
# @example With boundary
|
|
26
|
+
# merger = PartialTemplateMerger.new(
|
|
27
|
+
# template: template_content,
|
|
28
|
+
# destination: destination_content,
|
|
29
|
+
# anchor: { type: :heading, text: /Installation/ },
|
|
30
|
+
# boundary: { type: :heading }, # Stop at next heading
|
|
31
|
+
# parser: :markly
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
class PartialTemplateMerger
|
|
35
|
+
# Result of a partial template merge
|
|
36
|
+
class Result
|
|
37
|
+
# @return [String] The merged content
|
|
38
|
+
attr_reader :content
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] Whether the destination had a matching section
|
|
41
|
+
attr_reader :has_section
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] Whether the content changed
|
|
44
|
+
attr_reader :changed
|
|
45
|
+
|
|
46
|
+
# @return [Hash] Statistics about the merge
|
|
47
|
+
attr_reader :stats
|
|
48
|
+
|
|
49
|
+
# @return [InjectionPoint, nil] The injection point found (if any)
|
|
50
|
+
attr_reader :injection_point
|
|
51
|
+
|
|
52
|
+
# @return [String, nil] Message about the merge
|
|
53
|
+
attr_reader :message
|
|
54
|
+
|
|
55
|
+
def initialize(content:, has_section:, changed:, stats: {}, injection_point: nil, message: nil)
|
|
56
|
+
@content = content
|
|
57
|
+
@has_section = has_section
|
|
58
|
+
@changed = changed
|
|
59
|
+
@stats = stats
|
|
60
|
+
@injection_point = injection_point
|
|
61
|
+
@message = message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] Whether a section was found
|
|
65
|
+
def section_found?
|
|
66
|
+
has_section
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String] The template content (the section to inject)
|
|
71
|
+
attr_reader :template
|
|
72
|
+
|
|
73
|
+
# @return [String] The destination content
|
|
74
|
+
attr_reader :destination
|
|
75
|
+
|
|
76
|
+
# @return [Hash] Anchor matcher configuration
|
|
77
|
+
attr_reader :anchor
|
|
78
|
+
|
|
79
|
+
# @return [Hash, nil] Boundary matcher configuration
|
|
80
|
+
attr_reader :boundary
|
|
81
|
+
|
|
82
|
+
# @return [Symbol] Parser to use (:markly, :commonmarker, etc.)
|
|
83
|
+
attr_reader :parser
|
|
84
|
+
|
|
85
|
+
# @return [Symbol, Hash] Merge preference (:template, :destination, or per-type hash)
|
|
86
|
+
attr_reader :preference
|
|
87
|
+
|
|
88
|
+
# @return [Boolean, Proc] Whether to add template-only nodes
|
|
89
|
+
attr_reader :add_missing
|
|
90
|
+
|
|
91
|
+
# @return [Symbol] What to do when section not found (:skip, :append, :prepend)
|
|
92
|
+
attr_reader :when_missing
|
|
93
|
+
|
|
94
|
+
# @return [Proc, nil] Custom signature generator for node matching
|
|
95
|
+
attr_reader :signature_generator
|
|
96
|
+
|
|
97
|
+
# @return [Hash, nil] Node typing configuration for per-type preferences
|
|
98
|
+
attr_reader :node_typing
|
|
99
|
+
|
|
100
|
+
# Initialize a PartialTemplateMerger.
|
|
101
|
+
#
|
|
102
|
+
# @param template [String] The template content (the section to merge in)
|
|
103
|
+
# @param destination [String] The destination content
|
|
104
|
+
# @param anchor [Hash] Anchor matcher: { type: :heading, text: /pattern/ }
|
|
105
|
+
# @param boundary [Hash, nil] Boundary matcher (defaults to same type as anchor)
|
|
106
|
+
# @param parser [Symbol] Parser to use (:markly, :commonmarker, :prism, :psych)
|
|
107
|
+
# @param preference [Symbol, Hash] Which content wins (:template, :destination, or per-type hash)
|
|
108
|
+
# @param add_missing [Boolean, Proc] Whether to add template nodes not in destination
|
|
109
|
+
# @param when_missing [Symbol] What to do if section not found (:skip, :append, :prepend)
|
|
110
|
+
# @param replace_mode [Boolean] If true, template replaces section entirely (no merge)
|
|
111
|
+
# @param signature_generator [Proc, nil] Custom signature generator for SmartMerger
|
|
112
|
+
# @param node_typing [Hash, nil] Node typing configuration for per-type preferences
|
|
113
|
+
def initialize(
|
|
114
|
+
template:,
|
|
115
|
+
destination:,
|
|
116
|
+
anchor:,
|
|
117
|
+
boundary: nil,
|
|
118
|
+
parser: :markly,
|
|
119
|
+
preference: :template,
|
|
120
|
+
add_missing: true,
|
|
121
|
+
when_missing: :skip,
|
|
122
|
+
replace_mode: false,
|
|
123
|
+
signature_generator: nil,
|
|
124
|
+
node_typing: nil
|
|
125
|
+
)
|
|
126
|
+
@template = template
|
|
127
|
+
@destination = destination
|
|
128
|
+
@anchor = normalize_matcher(anchor)
|
|
129
|
+
@boundary = boundary ? normalize_matcher(boundary) : nil
|
|
130
|
+
@parser = parser
|
|
131
|
+
@preference = preference
|
|
132
|
+
@add_missing = add_missing
|
|
133
|
+
@when_missing = when_missing
|
|
134
|
+
@replace_mode = replace_mode
|
|
135
|
+
@signature_generator = signature_generator
|
|
136
|
+
@node_typing = node_typing
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Perform the partial template merge.
|
|
140
|
+
#
|
|
141
|
+
# @return [Result] The merge result
|
|
142
|
+
def merge
|
|
143
|
+
# Parse destination and find injection point
|
|
144
|
+
d_analysis = create_analysis(destination)
|
|
145
|
+
d_statements = NavigableStatement.build_list(d_analysis.statements)
|
|
146
|
+
|
|
147
|
+
finder = InjectionPointFinder.new(d_statements)
|
|
148
|
+
injection_point = finder.find(
|
|
149
|
+
type: anchor[:type],
|
|
150
|
+
text: anchor[:text],
|
|
151
|
+
position: :replace,
|
|
152
|
+
boundary_type: boundary&.dig(:type),
|
|
153
|
+
boundary_text: boundary&.dig(:text),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if injection_point.nil?
|
|
157
|
+
return handle_missing_section(d_analysis)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Found the section - now merge
|
|
161
|
+
perform_section_merge(d_analysis, d_statements, injection_point)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def normalize_matcher(matcher)
|
|
167
|
+
return {} if matcher.nil?
|
|
168
|
+
|
|
169
|
+
result = {}
|
|
170
|
+
result[:type] = matcher[:type]&.to_sym
|
|
171
|
+
result[:text] = normalize_text_pattern(matcher[:text])
|
|
172
|
+
result[:level] = matcher[:level] if matcher[:level]
|
|
173
|
+
result[:level_lte] = matcher[:level_lte] if matcher[:level_lte]
|
|
174
|
+
result[:level_gte] = matcher[:level_gte] if matcher[:level_gte]
|
|
175
|
+
result.compact
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def normalize_text_pattern(text)
|
|
179
|
+
return if text.nil?
|
|
180
|
+
return text if text.is_a?(Regexp)
|
|
181
|
+
|
|
182
|
+
# Handle /regex/ syntax in strings
|
|
183
|
+
if text.is_a?(String) && text.start_with?("/") && text.end_with?("/")
|
|
184
|
+
Regexp.new(text[1..-2])
|
|
185
|
+
else
|
|
186
|
+
text
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def handle_missing_section(_d_analysis)
|
|
191
|
+
case when_missing
|
|
192
|
+
when :skip
|
|
193
|
+
Result.new(
|
|
194
|
+
content: destination,
|
|
195
|
+
has_section: false,
|
|
196
|
+
changed: false,
|
|
197
|
+
message: "Section not found, skipping",
|
|
198
|
+
)
|
|
199
|
+
when :append
|
|
200
|
+
# Append template at end of document
|
|
201
|
+
new_content = destination.chomp + "\n\n" + template
|
|
202
|
+
Result.new(
|
|
203
|
+
content: new_content,
|
|
204
|
+
has_section: false,
|
|
205
|
+
changed: true,
|
|
206
|
+
message: "Section not found, appended template",
|
|
207
|
+
)
|
|
208
|
+
when :prepend
|
|
209
|
+
# Prepend template at start (after any frontmatter)
|
|
210
|
+
new_content = template + "\n\n" + destination
|
|
211
|
+
Result.new(
|
|
212
|
+
content: new_content,
|
|
213
|
+
has_section: false,
|
|
214
|
+
changed: true,
|
|
215
|
+
message: "Section not found, prepended template",
|
|
216
|
+
)
|
|
217
|
+
else
|
|
218
|
+
Result.new(
|
|
219
|
+
content: destination,
|
|
220
|
+
has_section: false,
|
|
221
|
+
changed: false,
|
|
222
|
+
message: "Section not found, no action taken",
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def perform_section_merge(_d_analysis, d_statements, injection_point)
|
|
228
|
+
# Determine section boundaries in destination
|
|
229
|
+
section_start_idx = injection_point.anchor.index
|
|
230
|
+
section_end_idx = find_section_end(d_statements, injection_point)
|
|
231
|
+
|
|
232
|
+
# Extract the three parts: before, section, after
|
|
233
|
+
before_statements = d_statements[0...section_start_idx]
|
|
234
|
+
section_statements = d_statements[section_start_idx..section_end_idx]
|
|
235
|
+
after_statements = d_statements[(section_end_idx + 1)..]
|
|
236
|
+
|
|
237
|
+
# Determine the merged section content
|
|
238
|
+
section_content = statements_to_content(section_statements)
|
|
239
|
+
merged_section, stats = merge_section_content(section_content)
|
|
240
|
+
|
|
241
|
+
# Reconstruct the document
|
|
242
|
+
before_content = statements_to_content(before_statements)
|
|
243
|
+
after_content = statements_to_content(after_statements)
|
|
244
|
+
|
|
245
|
+
new_content = build_merged_content(before_content, merged_section, after_content)
|
|
246
|
+
|
|
247
|
+
changed = new_content != destination
|
|
248
|
+
|
|
249
|
+
Result.new(
|
|
250
|
+
content: new_content,
|
|
251
|
+
has_section: true,
|
|
252
|
+
changed: changed,
|
|
253
|
+
stats: stats,
|
|
254
|
+
injection_point: injection_point,
|
|
255
|
+
message: changed ? "Section merged successfully" : "Section unchanged",
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def merge_section_content(section_content)
|
|
260
|
+
# Use SmartMerger for intelligent merging of the section
|
|
261
|
+
# The behavior depends on preference setting:
|
|
262
|
+
# - :template with replace_mode: true -> full replacement
|
|
263
|
+
# - :template with replace_mode: false -> merge with template winning conflicts
|
|
264
|
+
# - :destination -> merge with destination winning conflicts
|
|
265
|
+
|
|
266
|
+
if replace_mode?
|
|
267
|
+
# Full replacement: just use template content directly
|
|
268
|
+
[template, {mode: :replace}]
|
|
269
|
+
else
|
|
270
|
+
# Intelligent merge: use SmartMerger
|
|
271
|
+
merger = create_smart_merger(template, section_content)
|
|
272
|
+
result = merger.merge_result
|
|
273
|
+
[result.content, result.stats.merge(mode: :merge)]
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Check if we're in replace mode (vs merge mode)
|
|
278
|
+
# Replace mode means template completely replaces the section
|
|
279
|
+
def replace_mode?
|
|
280
|
+
@replace_mode == true
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def find_section_end(statements, injection_point)
|
|
284
|
+
# If boundary was specified and found, use it (exclusive - section ends before boundary)
|
|
285
|
+
if injection_point.boundary
|
|
286
|
+
return injection_point.boundary.index - 1
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Otherwise, find the next node of same type (for headings, same or higher level)
|
|
290
|
+
anchor = injection_point.anchor
|
|
291
|
+
anchor_type = anchor.type
|
|
292
|
+
|
|
293
|
+
# For headings, find next heading of same or higher level
|
|
294
|
+
if heading_type?(anchor_type)
|
|
295
|
+
anchor_level = get_heading_level(anchor)
|
|
296
|
+
|
|
297
|
+
((anchor.index + 1)...statements.length).each do |idx|
|
|
298
|
+
stmt = statements[idx]
|
|
299
|
+
if heading_type?(stmt.type)
|
|
300
|
+
stmt_level = get_heading_level(stmt)
|
|
301
|
+
if stmt_level && anchor_level && stmt_level <= anchor_level
|
|
302
|
+
# Found next heading of same or higher level - section ends before it
|
|
303
|
+
return idx - 1
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
else
|
|
308
|
+
# For non-headings, find next node of same type
|
|
309
|
+
((anchor.index + 1)...statements.length).each do |idx|
|
|
310
|
+
stmt = statements[idx]
|
|
311
|
+
if stmt.type == anchor_type
|
|
312
|
+
return idx - 1
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Section extends to end of document
|
|
318
|
+
statements.length - 1
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def heading_type?(type)
|
|
322
|
+
type.to_s == "heading" || type == :heading || type == :header
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def get_heading_level(stmt)
|
|
326
|
+
inner = stmt.respond_to?(:unwrapped_node) ? stmt.unwrapped_node : stmt.node
|
|
327
|
+
|
|
328
|
+
if inner.respond_to?(:header_level)
|
|
329
|
+
inner.header_level
|
|
330
|
+
elsif inner.respond_to?(:level)
|
|
331
|
+
inner.level
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def statements_to_content(statements)
|
|
336
|
+
return "" if statements.nil? || statements.empty?
|
|
337
|
+
|
|
338
|
+
statements.map do |stmt|
|
|
339
|
+
node = stmt.respond_to?(:node) ? stmt.node : stmt
|
|
340
|
+
node_to_text(node)
|
|
341
|
+
end.join
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def node_to_text(node)
|
|
345
|
+
# Unwrap if needed
|
|
346
|
+
inner = node
|
|
347
|
+
while inner.respond_to?(:inner_node) && inner.inner_node != inner
|
|
348
|
+
inner = inner.inner_node
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
if inner.respond_to?(:to_commonmark)
|
|
352
|
+
inner.to_commonmark.to_s
|
|
353
|
+
elsif inner.respond_to?(:to_s)
|
|
354
|
+
inner.to_s
|
|
355
|
+
else
|
|
356
|
+
""
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def build_merged_content(before, section, after)
|
|
361
|
+
parts = []
|
|
362
|
+
|
|
363
|
+
# Before content
|
|
364
|
+
unless before.nil? || before.strip.empty?
|
|
365
|
+
parts << before.chomp
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Merged section
|
|
369
|
+
unless section.nil? || section.strip.empty?
|
|
370
|
+
parts << section.chomp
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# After content
|
|
374
|
+
unless after.nil? || after.strip.empty?
|
|
375
|
+
parts << after.chomp
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
result = parts.join("\n\n")
|
|
379
|
+
result += "\n" unless result.end_with?("\n")
|
|
380
|
+
result
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def create_analysis(content)
|
|
384
|
+
case parser
|
|
385
|
+
when :markly
|
|
386
|
+
require "markly/merge" unless defined?(Markly::Merge)
|
|
387
|
+
Markly::Merge::FileAnalysis.new(content)
|
|
388
|
+
when :commonmarker
|
|
389
|
+
require "commonmarker/merge" unless defined?(Commonmarker::Merge)
|
|
390
|
+
Commonmarker::Merge::FileAnalysis.new(content)
|
|
391
|
+
when :prism
|
|
392
|
+
require "prism/merge" unless defined?(Prism::Merge)
|
|
393
|
+
Prism::Merge::FileAnalysis.new(content)
|
|
394
|
+
when :psych
|
|
395
|
+
require "psych/merge" unless defined?(Psych::Merge)
|
|
396
|
+
Psych::Merge::FileAnalysis.new(content)
|
|
397
|
+
else
|
|
398
|
+
raise ArgumentError, "Unknown parser: #{parser}"
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def create_smart_merger(template_content, destination_content)
|
|
403
|
+
merger_class = case parser
|
|
404
|
+
when :markly
|
|
405
|
+
require "markly/merge" unless defined?(Markly::Merge)
|
|
406
|
+
Markly::Merge::SmartMerger
|
|
407
|
+
when :commonmarker
|
|
408
|
+
require "commonmarker/merge" unless defined?(Commonmarker::Merge)
|
|
409
|
+
Commonmarker::Merge::SmartMerger
|
|
410
|
+
when :prism
|
|
411
|
+
require "prism/merge" unless defined?(Prism::Merge)
|
|
412
|
+
Prism::Merge::SmartMerger
|
|
413
|
+
when :psych
|
|
414
|
+
require "psych/merge" unless defined?(Psych::Merge)
|
|
415
|
+
Psych::Merge::SmartMerger
|
|
416
|
+
else
|
|
417
|
+
raise ArgumentError, "Unknown parser: #{parser}"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Build options hash, only including non-nil values
|
|
421
|
+
options = {
|
|
422
|
+
preference: preference,
|
|
423
|
+
add_template_only_nodes: add_missing,
|
|
424
|
+
}
|
|
425
|
+
options[:signature_generator] = signature_generator if signature_generator
|
|
426
|
+
options[:node_typing] = node_typing if node_typing
|
|
427
|
+
|
|
428
|
+
merger_class.new(template_content, destination_content, **options)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "preset"
|
|
5
|
+
|
|
6
|
+
module Ast
|
|
7
|
+
module Merge
|
|
8
|
+
module Recipe
|
|
9
|
+
# Loads and represents a merge recipe from YAML configuration.
|
|
10
|
+
#
|
|
11
|
+
# A recipe extends Preset with:
|
|
12
|
+
# - Template file specification
|
|
13
|
+
# - Target file patterns
|
|
14
|
+
# - Injection point configuration
|
|
15
|
+
# - when_missing behavior
|
|
16
|
+
#
|
|
17
|
+
# @example Loading a recipe
|
|
18
|
+
# recipe = Config.load(".merge-recipes/gem_family_section.yml")
|
|
19
|
+
# recipe.name # => "gem_family_section"
|
|
20
|
+
# recipe.template_path # => "GEM_FAMILY_SECTION.md"
|
|
21
|
+
# recipe.targets # => ["README.md", "vendor/*/README.md"]
|
|
22
|
+
#
|
|
23
|
+
# @example Recipe YAML format
|
|
24
|
+
# name: gem_family_section
|
|
25
|
+
# description: Update gem family section in README files
|
|
26
|
+
#
|
|
27
|
+
# template: GEM_FAMILY_SECTION.md
|
|
28
|
+
#
|
|
29
|
+
# targets:
|
|
30
|
+
# - "README.md"
|
|
31
|
+
# - "vendor/*/README.md"
|
|
32
|
+
#
|
|
33
|
+
# injection:
|
|
34
|
+
# anchor:
|
|
35
|
+
# type: heading
|
|
36
|
+
# text: /Gem Family/
|
|
37
|
+
# position: replace
|
|
38
|
+
# boundary:
|
|
39
|
+
# type: heading
|
|
40
|
+
# same_or_shallower: true
|
|
41
|
+
#
|
|
42
|
+
# merge:
|
|
43
|
+
# preference: template
|
|
44
|
+
# add_missing: true
|
|
45
|
+
#
|
|
46
|
+
# when_missing: skip
|
|
47
|
+
#
|
|
48
|
+
# @see Preset For base configuration without template/targets
|
|
49
|
+
# @see Runner For executing recipes
|
|
50
|
+
# @see ScriptLoader For loading Ruby scripts from recipe folders
|
|
51
|
+
class Config < Preset
|
|
52
|
+
# @return [String] Path to template file (relative to recipe or absolute)
|
|
53
|
+
attr_reader :template_path
|
|
54
|
+
|
|
55
|
+
# @return [Array<String>] Glob patterns for target files
|
|
56
|
+
attr_reader :targets
|
|
57
|
+
|
|
58
|
+
# @return [Hash] Injection point configuration
|
|
59
|
+
attr_reader :injection
|
|
60
|
+
|
|
61
|
+
# @return [Symbol] Behavior when injection anchor not found (:skip, :add, :error)
|
|
62
|
+
attr_reader :when_missing
|
|
63
|
+
|
|
64
|
+
# Alias for compatibility - recipe_path points to the same file as preset_path
|
|
65
|
+
def recipe_path
|
|
66
|
+
preset_path
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Load a recipe from a YAML file.
|
|
71
|
+
#
|
|
72
|
+
# @param path [String] Path to the recipe YAML file
|
|
73
|
+
# @return [Config] Loaded recipe
|
|
74
|
+
# @raise [ArgumentError] If file doesn't exist or is invalid
|
|
75
|
+
def load(path)
|
|
76
|
+
raise ArgumentError, "Recipe file not found: #{path}" unless File.exist?(path)
|
|
77
|
+
|
|
78
|
+
yaml = YAML.safe_load_file(path, permitted_classes: [Regexp, Symbol])
|
|
79
|
+
new(yaml, preset_path: path)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Create a recipe from a hash (parsed YAML or programmatic).
|
|
84
|
+
#
|
|
85
|
+
# @param config [Hash] Recipe configuration
|
|
86
|
+
# @param preset_path [String, nil] Path to recipe file (for relative path resolution)
|
|
87
|
+
# @param recipe_path [String, nil] Alias for preset_path (backward compatibility)
|
|
88
|
+
def initialize(config, preset_path: nil, recipe_path: nil)
|
|
89
|
+
# Support both preset_path and recipe_path for backward compatibility
|
|
90
|
+
effective_path = preset_path || recipe_path
|
|
91
|
+
super(config, preset_path: effective_path)
|
|
92
|
+
|
|
93
|
+
@template_path = config["template"] || raise(ArgumentError, "Recipe must have 'template' key")
|
|
94
|
+
@targets = Array(config["targets"] || ["*.md"])
|
|
95
|
+
@injection = parse_injection(config["injection"] || {})
|
|
96
|
+
@when_missing = (config["when_missing"] || "skip").to_sym
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get the absolute path to the template file.
|
|
100
|
+
#
|
|
101
|
+
# @param base_dir [String] Base directory for relative paths
|
|
102
|
+
# @return [String] Absolute path to template
|
|
103
|
+
def template_absolute_path(base_dir: nil)
|
|
104
|
+
return @template_path if File.absolute_path?(@template_path)
|
|
105
|
+
|
|
106
|
+
base = base_dir || (preset_path ? File.dirname(preset_path) : Dir.pwd)
|
|
107
|
+
File.expand_path(@template_path, base)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Expand target globs to actual file paths.
|
|
111
|
+
#
|
|
112
|
+
# @param base_dir [String] Base directory for glob expansion
|
|
113
|
+
# @return [Array<String>] Absolute paths to target files
|
|
114
|
+
def expand_targets(base_dir: nil)
|
|
115
|
+
base = base_dir || (preset_path ? File.dirname(preset_path) : Dir.pwd)
|
|
116
|
+
|
|
117
|
+
targets.flat_map do |pattern|
|
|
118
|
+
if File.absolute_path?(pattern)
|
|
119
|
+
Dir.glob(pattern)
|
|
120
|
+
else
|
|
121
|
+
# Expand and normalize to remove .. segments
|
|
122
|
+
expanded_pattern = File.expand_path(pattern, base)
|
|
123
|
+
Dir.glob(expanded_pattern)
|
|
124
|
+
end
|
|
125
|
+
end.uniq.sort
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Build an InjectionPointFinder query from the injection config.
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash] Arguments for InjectionPointFinder#find
|
|
131
|
+
def finder_query
|
|
132
|
+
anchor = injection[:anchor] || {}
|
|
133
|
+
boundary = injection[:boundary] || {}
|
|
134
|
+
|
|
135
|
+
query = {
|
|
136
|
+
type: anchor[:type],
|
|
137
|
+
text: anchor[:text],
|
|
138
|
+
position: injection[:position] || :replace,
|
|
139
|
+
boundary_type: boundary[:type],
|
|
140
|
+
boundary_text: boundary[:text],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# Support tree-depth based boundary detection
|
|
144
|
+
# same_or_shallower: true means "end at next sibling (same tree level or above)"
|
|
145
|
+
if boundary[:same_or_shallower]
|
|
146
|
+
query[:boundary_same_or_shallower] = true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
query.compact
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Whether to use replace mode (template replaces section entirely).
|
|
153
|
+
#
|
|
154
|
+
# @return [Boolean]
|
|
155
|
+
def replace_mode?
|
|
156
|
+
merge_config[:replace_mode] == true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def parse_injection(config)
|
|
162
|
+
return {} if config.empty?
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
anchor: parse_matcher(config["anchor"] || {}),
|
|
166
|
+
position: (config["position"] || "replace").to_sym,
|
|
167
|
+
boundary: parse_matcher(config["boundary"] || {}),
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def parse_matcher(config)
|
|
172
|
+
return if config.empty?
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
type: config["type"]&.to_sym,
|
|
176
|
+
text: parse_text_pattern(config["text"]),
|
|
177
|
+
level: config["level"],
|
|
178
|
+
level_lte: config["level_lte"],
|
|
179
|
+
level_gte: config["level_gte"],
|
|
180
|
+
same_or_shallower: config["same_or_shallower"] == true,
|
|
181
|
+
}.compact
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_text_pattern(text)
|
|
185
|
+
return if text.nil?
|
|
186
|
+
return text if text.is_a?(Regexp)
|
|
187
|
+
|
|
188
|
+
# Handle /regex/ syntax in YAML strings
|
|
189
|
+
if text.is_a?(String) && text.start_with?("/") && text.end_with?("/")
|
|
190
|
+
Regexp.new(text[1..-2])
|
|
191
|
+
else
|
|
192
|
+
text
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|