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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ast
|
|
6
|
+
module Merge
|
|
7
|
+
module Recipe
|
|
8
|
+
# Base configuration for merge presets.
|
|
9
|
+
#
|
|
10
|
+
# A Preset provides merge configuration (signature generators, node typing,
|
|
11
|
+
# preferences) without requiring a template file. This is useful for
|
|
12
|
+
# defining reusable merge behaviors that can be applied to any merge operation.
|
|
13
|
+
#
|
|
14
|
+
# `Config` inherits from `Preset` and adds template/target file handling
|
|
15
|
+
# for standalone recipe execution.
|
|
16
|
+
#
|
|
17
|
+
# @example Loading a preset
|
|
18
|
+
# preset = Preset.load("presets/gemfile.yml")
|
|
19
|
+
# merger = Prism::Merge::SmartMerger.new(
|
|
20
|
+
# template, destination,
|
|
21
|
+
# **preset.to_h
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# @example Creating a preset programmatically
|
|
25
|
+
# preset = Preset.new(
|
|
26
|
+
# "name" => "my_preset",
|
|
27
|
+
# "merge" => { "preference" => "template" }
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# @see Config For full recipe with template/target support
|
|
31
|
+
# @see ScriptLoader For loading Ruby scripts from companion folders
|
|
32
|
+
class Preset
|
|
33
|
+
# @return [String] Preset name
|
|
34
|
+
attr_reader :name
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] Preset description
|
|
37
|
+
attr_reader :description
|
|
38
|
+
|
|
39
|
+
# @return [Symbol] Parser to use (:prism, :markly, :psych, etc.)
|
|
40
|
+
attr_reader :parser
|
|
41
|
+
|
|
42
|
+
# @return [Hash] Merge configuration
|
|
43
|
+
attr_reader :merge_config
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] Freeze token for preserving sections
|
|
46
|
+
attr_reader :freeze_token
|
|
47
|
+
|
|
48
|
+
# @return [String, nil] Path to the preset file (for script resolution)
|
|
49
|
+
attr_reader :preset_path
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Load a preset from a YAML file.
|
|
53
|
+
#
|
|
54
|
+
# @param path [String] Path to the preset YAML file
|
|
55
|
+
# @return [Preset]
|
|
56
|
+
# @raise [ArgumentError] If file not found
|
|
57
|
+
def load(path)
|
|
58
|
+
raise ArgumentError, "Preset file not found: #{path}" unless File.exist?(path)
|
|
59
|
+
|
|
60
|
+
yaml = YAML.safe_load_file(path, permitted_classes: [Regexp, Symbol])
|
|
61
|
+
new(yaml, preset_path: path)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Initialize a preset from a hash.
|
|
66
|
+
#
|
|
67
|
+
# @param config [Hash] Parsed YAML config or programmatic config
|
|
68
|
+
# @param preset_path [String, nil] Path to preset file (for script resolution)
|
|
69
|
+
def initialize(config, preset_path: nil)
|
|
70
|
+
@preset_path = preset_path
|
|
71
|
+
@name = config["name"] || "unnamed"
|
|
72
|
+
@description = config["description"]
|
|
73
|
+
@parser = (config["parser"] || "prism").to_sym
|
|
74
|
+
@merge_config = parse_merge_config(config["merge"] || {})
|
|
75
|
+
@freeze_token = config["freeze_token"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get the merge preference setting.
|
|
79
|
+
#
|
|
80
|
+
# @return [Symbol, Hash] Preference (:template, :destination, or per-type hash)
|
|
81
|
+
def preference
|
|
82
|
+
merge_config[:preference] || :template
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get the add_missing setting, loading as callable if it's a script reference.
|
|
86
|
+
#
|
|
87
|
+
# @return [Boolean, Proc] Boolean value or callable filter
|
|
88
|
+
def add_missing
|
|
89
|
+
value = merge_config[:add_missing]
|
|
90
|
+
return true if value.nil?
|
|
91
|
+
return value if value == true || value == false
|
|
92
|
+
return value if value.respond_to?(:call)
|
|
93
|
+
|
|
94
|
+
# It's a script reference - load it
|
|
95
|
+
script_loader.load_callable(value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Convenience alias for boolean check.
|
|
99
|
+
#
|
|
100
|
+
# @return [Boolean, Proc]
|
|
101
|
+
def add_missing?
|
|
102
|
+
add_missing
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get the signature_generator callable, loading from script if needed.
|
|
106
|
+
#
|
|
107
|
+
# @return [Proc, nil] Signature generator callable
|
|
108
|
+
def signature_generator
|
|
109
|
+
value = merge_config[:signature_generator]
|
|
110
|
+
return if value.nil?
|
|
111
|
+
return value if value.respond_to?(:call)
|
|
112
|
+
|
|
113
|
+
script_loader.load_callable(value)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get the node_typing configuration with callables loaded.
|
|
117
|
+
#
|
|
118
|
+
# @return [Hash, nil] Hash of type => callable
|
|
119
|
+
def node_typing
|
|
120
|
+
value = merge_config[:node_typing]
|
|
121
|
+
return if value.nil?
|
|
122
|
+
return value if value.is_a?(Hash) && value.values.all? { |v| v.respond_to?(:call) }
|
|
123
|
+
|
|
124
|
+
script_loader.load_callable_hash(value)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Convert preset to a hash suitable for SmartMerger options.
|
|
128
|
+
#
|
|
129
|
+
# @return [Hash]
|
|
130
|
+
def to_h
|
|
131
|
+
{
|
|
132
|
+
preference: preference,
|
|
133
|
+
add_template_only_nodes: add_missing,
|
|
134
|
+
signature_generator: signature_generator,
|
|
135
|
+
node_typing: node_typing,
|
|
136
|
+
freeze_token: freeze_token,
|
|
137
|
+
}.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Get the script loader instance.
|
|
141
|
+
#
|
|
142
|
+
# @return [ScriptLoader]
|
|
143
|
+
def script_loader
|
|
144
|
+
@script_loader ||= ScriptLoader.new(recipe_path: preset_path)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
protected
|
|
148
|
+
|
|
149
|
+
def parse_merge_config(config)
|
|
150
|
+
{
|
|
151
|
+
preference: parse_preference(config["preference"]),
|
|
152
|
+
add_missing: config["add_missing"],
|
|
153
|
+
replace_mode: config["replace_mode"] == true,
|
|
154
|
+
match_by: Array(config["match_by"]).map(&:to_sym),
|
|
155
|
+
deep: config["deep"] == true,
|
|
156
|
+
signature_generator: config["signature_generator"],
|
|
157
|
+
node_typing: config["node_typing"],
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_preference(pref)
|
|
162
|
+
return :template if pref.nil?
|
|
163
|
+
return pref.to_sym if pref.is_a?(String)
|
|
164
|
+
|
|
165
|
+
# Hash of type => preference
|
|
166
|
+
pref.transform_keys(&:to_sym).transform_values(&:to_sym) if pref.is_a?(Hash)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Recipe
|
|
6
|
+
# Executes a merge recipe against target files.
|
|
7
|
+
#
|
|
8
|
+
# The runner:
|
|
9
|
+
# 1. Loads the template file
|
|
10
|
+
# 2. Expands target file globs
|
|
11
|
+
# 3. For each target, finds the injection point and performs the merge
|
|
12
|
+
# 4. Collects results for reporting
|
|
13
|
+
#
|
|
14
|
+
# @example Running a recipe
|
|
15
|
+
# recipe = Recipe::Config.load(".merge-recipes/gem_family_section.yml")
|
|
16
|
+
# runner = Recipe::Runner.new(recipe, dry_run: true)
|
|
17
|
+
# results = runner.run
|
|
18
|
+
# puts runner.summary
|
|
19
|
+
#
|
|
20
|
+
# @example With custom parser
|
|
21
|
+
# runner = Recipe::Runner.new(recipe, parser: :markly, base_dir: "/path/to/project")
|
|
22
|
+
# results = runner.run
|
|
23
|
+
#
|
|
24
|
+
# @see Config For recipe configuration
|
|
25
|
+
# @see ScriptLoader For loading Ruby scripts from recipe folders
|
|
26
|
+
#
|
|
27
|
+
class Runner
|
|
28
|
+
# Result of processing a single file
|
|
29
|
+
Result = Struct.new(:path, :relative_path, :status, :changed, :has_anchor, :message, :stats, :error, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
# @return [Config] The recipe being executed
|
|
32
|
+
attr_reader :recipe
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] Whether this is a dry run
|
|
35
|
+
attr_reader :dry_run
|
|
36
|
+
|
|
37
|
+
# @return [String] Base directory for path resolution
|
|
38
|
+
attr_reader :base_dir
|
|
39
|
+
|
|
40
|
+
# @return [Symbol] Parser to use (:markly, :commonmarker, :prism, :psych, etc.)
|
|
41
|
+
attr_reader :parser
|
|
42
|
+
|
|
43
|
+
# @return [Array<Result>] Results from the last run
|
|
44
|
+
attr_reader :results
|
|
45
|
+
|
|
46
|
+
# Initialize a recipe runner.
|
|
47
|
+
#
|
|
48
|
+
# @param recipe [Config] The recipe to execute
|
|
49
|
+
# @param dry_run [Boolean] If true, don't write files
|
|
50
|
+
# @param base_dir [String, nil] Base directory for path resolution
|
|
51
|
+
# @param parser [Symbol] Which parser to use
|
|
52
|
+
# @param verbose [Boolean] Enable verbose output
|
|
53
|
+
def initialize(recipe, dry_run: false, base_dir: nil, parser: :markly, verbose: false)
|
|
54
|
+
@recipe = recipe
|
|
55
|
+
@dry_run = dry_run
|
|
56
|
+
@base_dir = base_dir || Dir.pwd
|
|
57
|
+
@parser = parser
|
|
58
|
+
@verbose = verbose
|
|
59
|
+
@results = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Run the recipe against all target files.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<Result>] Results for each processed file
|
|
65
|
+
def run
|
|
66
|
+
@results = []
|
|
67
|
+
|
|
68
|
+
template_content = load_template
|
|
69
|
+
# Let the recipe expand targets from its own location
|
|
70
|
+
target_files = recipe.expand_targets
|
|
71
|
+
|
|
72
|
+
target_files.each do |target_path|
|
|
73
|
+
result = process_file(target_path, template_content)
|
|
74
|
+
@results << result
|
|
75
|
+
yield result if block_given?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@results
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get results grouped by status.
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash<Symbol, Array<Result>>]
|
|
84
|
+
def results_by_status
|
|
85
|
+
@results.group_by(&:status)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get a summary hash of the run.
|
|
89
|
+
#
|
|
90
|
+
# @return [Hash]
|
|
91
|
+
def summary
|
|
92
|
+
by_status = results_by_status
|
|
93
|
+
{
|
|
94
|
+
total: @results.size,
|
|
95
|
+
updated: (by_status[:updated] || []).size,
|
|
96
|
+
would_update: (by_status[:would_update] || []).size,
|
|
97
|
+
unchanged: (by_status[:unchanged] || []).size,
|
|
98
|
+
skipped: (by_status[:skipped] || []).size,
|
|
99
|
+
errors: (by_status[:error] || []).size,
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Format results as an array of hashes for TableTennis.
|
|
104
|
+
#
|
|
105
|
+
# @return [Array<Hash>]
|
|
106
|
+
def results_table
|
|
107
|
+
@results.map do |r|
|
|
108
|
+
{
|
|
109
|
+
file: r.relative_path,
|
|
110
|
+
status: r.status.to_s,
|
|
111
|
+
changed: r.changed ? "yes" : "no",
|
|
112
|
+
message: r.message,
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Format summary as an array of hashes for TableTennis.
|
|
118
|
+
#
|
|
119
|
+
# @return [Array<Hash>]
|
|
120
|
+
def summary_table
|
|
121
|
+
s = summary
|
|
122
|
+
[
|
|
123
|
+
{metric: "Total files", value: s[:total]},
|
|
124
|
+
{metric: "Updated", value: dry_run ? s[:would_update] : s[:updated]},
|
|
125
|
+
{metric: "Unchanged", value: s[:unchanged]},
|
|
126
|
+
{metric: "Skipped (no anchor)", value: s[:skipped]},
|
|
127
|
+
{metric: "Errors", value: s[:errors]},
|
|
128
|
+
]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def load_template
|
|
134
|
+
# Let the recipe resolve the template path from its own location
|
|
135
|
+
path = recipe.template_absolute_path
|
|
136
|
+
raise ArgumentError, "Template not found: #{path}" unless File.exist?(path)
|
|
137
|
+
|
|
138
|
+
File.read(path)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def process_file(target_path, template_content)
|
|
142
|
+
relative_path = make_relative(target_path)
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
destination_content = File.read(target_path)
|
|
146
|
+
|
|
147
|
+
# Use PartialTemplateMerger which handles finding injection point and merging
|
|
148
|
+
merger = PartialTemplateMerger.new(
|
|
149
|
+
template: template_content,
|
|
150
|
+
destination: destination_content,
|
|
151
|
+
anchor: recipe.injection[:anchor] || {},
|
|
152
|
+
boundary: recipe.injection[:boundary],
|
|
153
|
+
parser: parser,
|
|
154
|
+
preference: recipe.preference,
|
|
155
|
+
add_missing: recipe.add_missing,
|
|
156
|
+
when_missing: recipe.when_missing,
|
|
157
|
+
replace_mode: recipe.replace_mode?,
|
|
158
|
+
signature_generator: recipe.signature_generator,
|
|
159
|
+
node_typing: recipe.node_typing,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
result = merger.merge
|
|
163
|
+
|
|
164
|
+
if result.section_found?
|
|
165
|
+
create_result_from_merge(target_path, relative_path, destination_content, result)
|
|
166
|
+
else
|
|
167
|
+
handle_missing_anchor_result(target_path, relative_path, result)
|
|
168
|
+
end
|
|
169
|
+
rescue => e
|
|
170
|
+
Result.new(
|
|
171
|
+
path: target_path,
|
|
172
|
+
relative_path: relative_path,
|
|
173
|
+
status: :error,
|
|
174
|
+
changed: false,
|
|
175
|
+
has_anchor: false,
|
|
176
|
+
message: e.message,
|
|
177
|
+
error: e,
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def create_result_from_merge(target_path, relative_path, _destination_content, merge_result)
|
|
183
|
+
changed = merge_result.changed
|
|
184
|
+
|
|
185
|
+
if changed
|
|
186
|
+
unless dry_run
|
|
187
|
+
File.write(target_path, merge_result.content)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
Result.new(
|
|
191
|
+
path: target_path,
|
|
192
|
+
relative_path: relative_path,
|
|
193
|
+
status: dry_run ? :would_update : :updated,
|
|
194
|
+
changed: true,
|
|
195
|
+
has_anchor: true,
|
|
196
|
+
message: dry_run ? "Would update" : "Updated",
|
|
197
|
+
stats: merge_result.stats,
|
|
198
|
+
)
|
|
199
|
+
else
|
|
200
|
+
Result.new(
|
|
201
|
+
path: target_path,
|
|
202
|
+
relative_path: relative_path,
|
|
203
|
+
status: :unchanged,
|
|
204
|
+
changed: false,
|
|
205
|
+
has_anchor: true,
|
|
206
|
+
message: "No changes needed",
|
|
207
|
+
stats: merge_result.stats,
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def handle_missing_anchor_result(target_path, relative_path, merge_result)
|
|
213
|
+
# PartialTemplateMerger already handled when_missing logic
|
|
214
|
+
status = if merge_result.changed
|
|
215
|
+
dry_run ? :would_update : :updated
|
|
216
|
+
else
|
|
217
|
+
:skipped
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if merge_result.changed && !dry_run
|
|
221
|
+
File.write(target_path, merge_result.content)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
Result.new(
|
|
225
|
+
path: target_path,
|
|
226
|
+
relative_path: relative_path,
|
|
227
|
+
status: status,
|
|
228
|
+
changed: merge_result.changed,
|
|
229
|
+
has_anchor: false,
|
|
230
|
+
message: merge_result.message || "No matching anchor found",
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def make_relative(path)
|
|
235
|
+
# Try to make path relative to base_dir first
|
|
236
|
+
if path.start_with?(base_dir)
|
|
237
|
+
return path.sub("#{base_dir}/", "")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# If recipe has a path, try relative to recipe's parent directory
|
|
241
|
+
if recipe.recipe_path
|
|
242
|
+
recipe_base = File.dirname(recipe.recipe_path, 2)
|
|
243
|
+
if path.start_with?(recipe_base)
|
|
244
|
+
return path.sub("#{recipe_base}/", "")
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Fall back to the path itself
|
|
249
|
+
path
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Recipe
|
|
6
|
+
# Loads Ruby scripts referenced by a recipe from a companion folder.
|
|
7
|
+
#
|
|
8
|
+
# Convention: A recipe at `.merge-recipes/my_recipe.yml` can reference
|
|
9
|
+
# scripts in `.merge-recipes/my_recipe/` folder.
|
|
10
|
+
#
|
|
11
|
+
# Scripts must define a callable object (lambda, proc, or object with #call method).
|
|
12
|
+
#
|
|
13
|
+
# @example Script reference in recipe YAML
|
|
14
|
+
# merge:
|
|
15
|
+
# signature_generator: scripts/signature_generator.rb
|
|
16
|
+
# node_typing:
|
|
17
|
+
# heading: scripts/heading_typing.rb
|
|
18
|
+
# add_missing: scripts/add_missing_filter.rb
|
|
19
|
+
#
|
|
20
|
+
# @example Script file content (signature_generator.rb)
|
|
21
|
+
# # Must return a callable
|
|
22
|
+
# lambda do |node|
|
|
23
|
+
# text = node.respond_to?(:to_plaintext) ? node.to_plaintext.to_s : node.to_s
|
|
24
|
+
# if text.include?("gem family")
|
|
25
|
+
# [:gem_family, :section]
|
|
26
|
+
# else
|
|
27
|
+
# nil
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @see Config For recipe configuration
|
|
32
|
+
# @see Runner For recipe execution
|
|
33
|
+
#
|
|
34
|
+
class ScriptLoader
|
|
35
|
+
# @return [String, nil] Base directory for script resolution
|
|
36
|
+
attr_reader :base_dir
|
|
37
|
+
|
|
38
|
+
# @return [Hash] Cache of loaded scripts
|
|
39
|
+
attr_reader :script_cache
|
|
40
|
+
|
|
41
|
+
# Initialize a script loader.
|
|
42
|
+
#
|
|
43
|
+
# @param recipe_path [String, nil] Path to recipe file (determines script folder)
|
|
44
|
+
# @param base_dir [String, nil] Override base directory for scripts
|
|
45
|
+
def initialize(recipe_path: nil, base_dir: nil)
|
|
46
|
+
@base_dir = determine_base_dir(recipe_path, base_dir)
|
|
47
|
+
@script_cache = {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Load a callable from a script reference.
|
|
51
|
+
#
|
|
52
|
+
# @param reference [String, Proc, nil] Script path, inline expression, or existing callable
|
|
53
|
+
# @return [Proc, nil] The callable, or nil if reference is nil
|
|
54
|
+
# @raise [ArgumentError] If script not found or doesn't return a callable
|
|
55
|
+
def load_callable(reference)
|
|
56
|
+
return if reference.nil?
|
|
57
|
+
return reference if reference.respond_to?(:call)
|
|
58
|
+
|
|
59
|
+
# Check if it's an inline lambda expression
|
|
60
|
+
if inline_expression?(reference)
|
|
61
|
+
return evaluate_inline_expression(reference)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# It's a file path reference
|
|
65
|
+
load_script_file(reference)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Load a hash of callables (e.g., node_typing config).
|
|
69
|
+
#
|
|
70
|
+
# @param config [Hash, nil] Hash with script references as values
|
|
71
|
+
# @return [Hash, nil] Hash with callables as values
|
|
72
|
+
def load_callable_hash(config)
|
|
73
|
+
return if config.nil? || config.empty?
|
|
74
|
+
|
|
75
|
+
config.transform_values { |ref| load_callable(ref) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if scripts directory exists.
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean]
|
|
81
|
+
def scripts_available?
|
|
82
|
+
!!(base_dir && Dir.exist?(base_dir))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# List available scripts.
|
|
86
|
+
#
|
|
87
|
+
# @return [Array<String>] Script filenames
|
|
88
|
+
def available_scripts
|
|
89
|
+
return [] unless scripts_available?
|
|
90
|
+
|
|
91
|
+
Dir.glob(File.join(base_dir, "**/*.rb")).map do |path|
|
|
92
|
+
path.sub("#{base_dir}/", "")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def determine_base_dir(recipe_path, override_base_dir)
|
|
99
|
+
return override_base_dir if override_base_dir
|
|
100
|
+
|
|
101
|
+
return unless recipe_path
|
|
102
|
+
|
|
103
|
+
# Convention: scripts folder has same name as recipe (without extension)
|
|
104
|
+
recipe_dir = File.dirname(recipe_path)
|
|
105
|
+
recipe_basename = File.basename(recipe_path, ".*")
|
|
106
|
+
scripts_dir = File.join(recipe_dir, recipe_basename)
|
|
107
|
+
|
|
108
|
+
scripts_dir if Dir.exist?(scripts_dir)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def inline_expression?(reference)
|
|
112
|
+
return false unless reference.is_a?(String)
|
|
113
|
+
|
|
114
|
+
# Check for inline lambda/proc syntax
|
|
115
|
+
reference.strip.start_with?("->", "lambda", "proc", "->(")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def evaluate_inline_expression(expression)
|
|
119
|
+
# Evaluate the expression in a clean binding
|
|
120
|
+
# rubocop:disable Security/Eval
|
|
121
|
+
result = eval(expression, TOPLEVEL_BINDING.dup, "(inline)", 1)
|
|
122
|
+
# rubocop:enable Security/Eval
|
|
123
|
+
|
|
124
|
+
unless result.respond_to?(:call)
|
|
125
|
+
raise ArgumentError, "Inline expression must return a callable, got: #{result.class}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
result
|
|
129
|
+
rescue SyntaxError => e
|
|
130
|
+
raise ArgumentError, "Invalid inline expression syntax: #{e.message}"
|
|
131
|
+
rescue => e
|
|
132
|
+
raise ArgumentError, "Failed to evaluate inline expression: #{e.message}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def load_script_file(path)
|
|
136
|
+
# Check cache first
|
|
137
|
+
return script_cache[path] if script_cache.key?(path)
|
|
138
|
+
|
|
139
|
+
absolute_path = resolve_script_path(path)
|
|
140
|
+
|
|
141
|
+
unless File.exist?(absolute_path)
|
|
142
|
+
raise ArgumentError, "Script not found: #{path} (looked in #{absolute_path})"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
script_content = File.read(absolute_path)
|
|
146
|
+
|
|
147
|
+
# Evaluate the script - it should return a callable
|
|
148
|
+
# rubocop:disable Security/Eval
|
|
149
|
+
result = eval(script_content, TOPLEVEL_BINDING.dup, absolute_path, 1)
|
|
150
|
+
# rubocop:enable Security/Eval
|
|
151
|
+
|
|
152
|
+
unless result.respond_to?(:call)
|
|
153
|
+
raise ArgumentError, "Script #{path} must return a callable (lambda, proc, or object with #call), got: #{result.class}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Cache and return
|
|
157
|
+
script_cache[path] = result
|
|
158
|
+
result
|
|
159
|
+
rescue SyntaxError => e
|
|
160
|
+
raise ArgumentError, "Syntax error in script #{path}: #{e.message}"
|
|
161
|
+
rescue => e
|
|
162
|
+
raise ArgumentError, "Failed to load script #{path}: #{e.message}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def resolve_script_path(path)
|
|
166
|
+
# If path is absolute, use it directly
|
|
167
|
+
return path if File.absolute_path?(path)
|
|
168
|
+
|
|
169
|
+
# If we have a base_dir, resolve relative to it
|
|
170
|
+
if base_dir
|
|
171
|
+
resolved = File.expand_path(path, base_dir)
|
|
172
|
+
return resolved if File.exist?(resolved)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Fall back to current directory
|
|
176
|
+
File.expand_path(path)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Recipe namespace for YAML-based merge recipe functionality.
|
|
6
|
+
#
|
|
7
|
+
# This module contains classes for loading, configuring, and executing
|
|
8
|
+
# merge recipes that define how to perform partial template merges.
|
|
9
|
+
#
|
|
10
|
+
# @example Loading and running a recipe
|
|
11
|
+
# recipe = Ast::Merge::Recipe::Config.load("my_recipe.yml")
|
|
12
|
+
# runner = Ast::Merge::Recipe::Runner.new(recipe, dry_run: true)
|
|
13
|
+
# results = runner.run
|
|
14
|
+
#
|
|
15
|
+
# @see Recipe::Config Recipe configuration and loading
|
|
16
|
+
# @see Recipe::Runner Recipe execution
|
|
17
|
+
# @see Recipe::ScriptLoader Loading Ruby scripts from recipe folders
|
|
18
|
+
#
|
|
19
|
+
module Recipe
|
|
20
|
+
autoload :Config, "ast/merge/recipe/config"
|
|
21
|
+
autoload :Preset, "ast/merge/recipe/preset"
|
|
22
|
+
autoload :Runner, "ast/merge/recipe/runner"
|
|
23
|
+
autoload :ScriptLoader, "ast/merge/recipe/script_loader"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|