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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +198 -7
  4. data/README.md +208 -39
  5. data/exe/ast-merge-recipe +366 -0
  6. data/lib/ast/merge/conflict_resolver_base.rb +8 -1
  7. data/lib/ast/merge/content_match_refiner.rb +278 -0
  8. data/lib/ast/merge/debug_logger.rb +2 -1
  9. data/lib/ast/merge/detector/base.rb +193 -0
  10. data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
  11. data/lib/ast/merge/detector/mergeable.rb +369 -0
  12. data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
  13. data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
  14. data/lib/ast/merge/merge_result_base.rb +4 -1
  15. data/lib/ast/merge/navigable_statement.rb +630 -0
  16. data/lib/ast/merge/partial_template_merger.rb +432 -0
  17. data/lib/ast/merge/recipe/config.rb +198 -0
  18. data/lib/ast/merge/recipe/preset.rb +171 -0
  19. data/lib/ast/merge/recipe/runner.rb +254 -0
  20. data/lib/ast/merge/recipe/script_loader.rb +181 -0
  21. data/lib/ast/merge/recipe.rb +26 -0
  22. data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
  23. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
  24. data/lib/ast/merge/rspec.rb +33 -2
  25. data/lib/ast/merge/smart_merger_base.rb +86 -3
  26. data/lib/ast/merge/version.rb +1 -1
  27. data/lib/ast/merge.rb +10 -6
  28. data/sig/ast/merge.rbs +389 -2
  29. data.tar.gz.sig +0 -0
  30. metadata +60 -16
  31. metadata.gz.sig +0 -0
  32. data/lib/ast/merge/fenced_code_block_detector.rb +0 -313
  33. data/lib/ast/merge/region.rb +0 -124
  34. data/lib/ast/merge/region_detector_base.rb +0 -114
  35. data/lib/ast/merge/region_mergeable.rb +0 -364
  36. data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
  37. 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