ast-merge 1.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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +852 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/ast/merge/ast_node.rb +87 -0
  14. data/lib/ast/merge/comment/block.rb +195 -0
  15. data/lib/ast/merge/comment/empty.rb +78 -0
  16. data/lib/ast/merge/comment/line.rb +138 -0
  17. data/lib/ast/merge/comment/parser.rb +278 -0
  18. data/lib/ast/merge/comment/style.rb +282 -0
  19. data/lib/ast/merge/comment.rb +36 -0
  20. data/lib/ast/merge/conflict_resolver_base.rb +399 -0
  21. data/lib/ast/merge/debug_logger.rb +271 -0
  22. data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
  23. data/lib/ast/merge/file_analyzable.rb +307 -0
  24. data/lib/ast/merge/freezable.rb +82 -0
  25. data/lib/ast/merge/freeze_node_base.rb +434 -0
  26. data/lib/ast/merge/match_refiner_base.rb +312 -0
  27. data/lib/ast/merge/match_score_base.rb +135 -0
  28. data/lib/ast/merge/merge_result_base.rb +169 -0
  29. data/lib/ast/merge/merger_config.rb +258 -0
  30. data/lib/ast/merge/node_typing.rb +373 -0
  31. data/lib/ast/merge/region.rb +124 -0
  32. data/lib/ast/merge/region_detector_base.rb +114 -0
  33. data/lib/ast/merge/region_mergeable.rb +364 -0
  34. data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
  35. data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
  36. data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
  37. data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
  38. data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
  39. data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
  40. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
  41. data/lib/ast/merge/rspec/shared_examples.rb +26 -0
  42. data/lib/ast/merge/rspec.rb +4 -0
  43. data/lib/ast/merge/section_typing.rb +303 -0
  44. data/lib/ast/merge/smart_merger_base.rb +417 -0
  45. data/lib/ast/merge/text/conflict_resolver.rb +161 -0
  46. data/lib/ast/merge/text/file_analysis.rb +168 -0
  47. data/lib/ast/merge/text/line_node.rb +142 -0
  48. data/lib/ast/merge/text/merge_result.rb +42 -0
  49. data/lib/ast/merge/text/section.rb +93 -0
  50. data/lib/ast/merge/text/section_splitter.rb +397 -0
  51. data/lib/ast/merge/text/smart_merger.rb +141 -0
  52. data/lib/ast/merge/text/word_node.rb +86 -0
  53. data/lib/ast/merge/text.rb +35 -0
  54. data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
  55. data/lib/ast/merge/version.rb +12 -0
  56. data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
  57. data/lib/ast/merge.rb +165 -0
  58. data/lib/ast-merge.rb +4 -0
  59. data/sig/ast/merge.rbs +195 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +326 -0
  62. metadata.gz.sig +0 -0
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for conflict resolvers across all *-merge gems.
6
+ #
7
+ # Provides common functionality for resolving conflicts between template
8
+ # and destination content during merge operations. Supports three resolution
9
+ # strategies that can be selected based on the needs of each file format:
10
+ #
11
+ # - `:node` - Per-node resolution (resolve individual node pairs)
12
+ # - `:batch` - Batch resolution (resolve entire file using signature maps)
13
+ # - `:boundary` - Boundary resolution (resolve sections/ranges of content)
14
+ #
15
+ # @example Node-based resolution (commonmarker-merge style)
16
+ # class ConflictResolver < Ast::Merge::ConflictResolverBase
17
+ # def initialize(preference:, template_analysis:, dest_analysis:)
18
+ # super(
19
+ # strategy: :node,
20
+ # preference: preference,
21
+ # template_analysis: template_analysis,
22
+ # dest_analysis: dest_analysis
23
+ # )
24
+ # end
25
+ #
26
+ # # Called for each node pair
27
+ # def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
28
+ # # Return resolution hash
29
+ # end
30
+ # end
31
+ #
32
+ # @example Batch resolution (psych-merge/json-merge style)
33
+ # class ConflictResolver < Ast::Merge::ConflictResolverBase
34
+ # def initialize(template_analysis, dest_analysis, preference: :destination)
35
+ # super(
36
+ # strategy: :batch,
37
+ # preference: preference,
38
+ # template_analysis: template_analysis,
39
+ # dest_analysis: dest_analysis
40
+ # )
41
+ # end
42
+ #
43
+ # # Called once for entire merge
44
+ # def resolve_batch(result)
45
+ # # Populate result with merged content
46
+ # end
47
+ # end
48
+ #
49
+ # @example Boundary resolution (prism-merge style)
50
+ # class ConflictResolver < Ast::Merge::ConflictResolverBase
51
+ # def initialize(template_analysis, dest_analysis, preference: :destination)
52
+ # super(
53
+ # strategy: :boundary,
54
+ # preference: preference,
55
+ # template_analysis: template_analysis,
56
+ # dest_analysis: dest_analysis
57
+ # )
58
+ # end
59
+ #
60
+ # # Called for each boundary (section with differences)
61
+ # def resolve_boundary(boundary, result)
62
+ # # Process boundary and populate result
63
+ # end
64
+ # end
65
+ #
66
+ # @abstract Subclass and implement resolve_node_pair, resolve_batch, or resolve_boundary
67
+ class ConflictResolverBase
68
+ # Decision constants - shared across all conflict resolvers
69
+
70
+ # Use destination version (customization preserved)
71
+ DECISION_DESTINATION = :destination
72
+
73
+ # Use template version (update applied)
74
+ DECISION_TEMPLATE = :template
75
+
76
+ # Content was added from template (template-only)
77
+ DECISION_ADDED = :added
78
+
79
+ # Content preserved from frozen block
80
+ DECISION_FROZEN = :frozen
81
+
82
+ # Content was identical (no conflict)
83
+ DECISION_IDENTICAL = :identical
84
+
85
+ # Content was kept from destination (signature match, dest preferred)
86
+ DECISION_KEPT_DEST = :kept_destination
87
+
88
+ # Content was kept from template (signature match, template preferred)
89
+ DECISION_KEPT_TEMPLATE = :kept_template
90
+
91
+ # Content was appended from destination (dest-only)
92
+ DECISION_APPENDED = :appended
93
+
94
+ # Content preserved from freeze block marker
95
+ DECISION_FREEZE_BLOCK = :freeze_block
96
+
97
+ # Content requires recursive merge (container types)
98
+ DECISION_RECURSIVE = :recursive
99
+
100
+ # Content was replaced (signature match with different content)
101
+ DECISION_REPLACED = :replaced
102
+
103
+ # @return [Symbol] Resolution strategy (:node, :batch, or :boundary)
104
+ attr_reader :strategy
105
+
106
+ # @return [Symbol, Hash] Merge preference.
107
+ # As Symbol: :destination or :template (applies to all nodes)
108
+ # As Hash: Maps node types/merge_types to preferences
109
+ # @example { default: :destination, lint_gem: :template }
110
+ attr_reader :preference
111
+
112
+ # @return [Object] Template file analysis
113
+ attr_reader :template_analysis
114
+
115
+ # @return [Object] Destination file analysis
116
+ attr_reader :dest_analysis
117
+
118
+ # @return [Boolean] Whether to add template-only nodes (batch strategy)
119
+ attr_reader :add_template_only_nodes
120
+
121
+ # Initialize the conflict resolver
122
+ #
123
+ # @param strategy [Symbol] Resolution strategy (:node, :batch, or :boundary)
124
+ # @param preference [Symbol, Hash] Which version to prefer.
125
+ # As Symbol: :destination or :template (applies to all nodes)
126
+ # As Hash: Maps node types/merge_types to preferences
127
+ # - Use :default key for fallback preference
128
+ # @example { default: :destination, lint_gem: :template }
129
+ # @param template_analysis [Object] Analysis of the template file
130
+ # @param dest_analysis [Object] Analysis of the destination file
131
+ # @param add_template_only_nodes [Boolean] Whether to add nodes only in template (batch/boundary strategy)
132
+ def initialize(strategy:, preference:, template_analysis:, dest_analysis:, add_template_only_nodes: false)
133
+ unless %i[node batch boundary].include?(strategy)
134
+ raise ArgumentError, "Invalid strategy: #{strategy}. Must be :node, :batch, or :boundary"
135
+ end
136
+
137
+ validate_preference!(preference)
138
+
139
+ @strategy = strategy
140
+ @preference = preference
141
+ @template_analysis = template_analysis
142
+ @dest_analysis = dest_analysis
143
+ @add_template_only_nodes = add_template_only_nodes
144
+ end
145
+
146
+ # Resolve conflicts using the configured strategy
147
+ #
148
+ # For :node strategy, this delegates to resolve_node_pair
149
+ # For :batch strategy, this delegates to resolve_batch
150
+ # For :boundary strategy, this delegates to resolve_boundary
151
+ #
152
+ # @param args [Array] Arguments passed to the strategy method
153
+ # @return [Object] Resolution result (format depends on strategy)
154
+ def resolve(*args, **kwargs)
155
+ case @strategy
156
+ when :node
157
+ resolve_node_pair(*args, **kwargs)
158
+ when :batch
159
+ resolve_batch(*args)
160
+ when :boundary
161
+ resolve_boundary(*args)
162
+ end
163
+ end
164
+
165
+ # Check if a node is a freeze node using duck typing
166
+ #
167
+ # @param node [Object] Node to check
168
+ # @return [Boolean] True if node is a freeze node
169
+ def freeze_node?(node)
170
+ node.respond_to?(:freeze_node?) && node.freeze_node?
171
+ end
172
+
173
+ # Get the preference for a specific node.
174
+ #
175
+ # When preference is a Hash, looks up the preference for the node's
176
+ # merge_type (if wrapped with NodeTyping) or falls back to :default.
177
+ #
178
+ # @param node [Object, nil] The node to get preference for
179
+ # @return [Symbol] :destination or :template
180
+ #
181
+ # @example With Symbol preference
182
+ # preference_for_node(any_node) # => returns @preference
183
+ #
184
+ # @example With Hash preference and typed node
185
+ # # Given preference: { default: :destination, lint_gem: :template }
186
+ # preference_for_node(lint_gem_node) # => :template
187
+ # preference_for_node(other_node) # => :destination
188
+ def preference_for_node(node)
189
+ return default_preference unless @preference.is_a?(Hash)
190
+ return default_preference unless node
191
+
192
+ # Check if node has a merge_type (from NodeTyping)
193
+ merge_type = NodeTyping.merge_type_for(node)
194
+ return @preference.fetch(merge_type) { default_preference } if merge_type
195
+
196
+ # Fall back to default
197
+ default_preference
198
+ end
199
+
200
+ # Get the default preference (used as fallback).
201
+ #
202
+ # @return [Symbol] :destination or :template
203
+ def default_preference
204
+ if @preference.is_a?(Hash)
205
+ @preference.fetch(:default, :destination)
206
+ else
207
+ @preference
208
+ end
209
+ end
210
+
211
+ # Check if Hash-based per-type preferences are configured.
212
+ #
213
+ # @return [Boolean] true if preference is a Hash
214
+ def per_type_preference?
215
+ @preference.is_a?(Hash)
216
+ end
217
+
218
+ protected
219
+
220
+ # Resolve a single node pair (for :node strategy)
221
+ # Override this method in subclasses using node strategy
222
+ #
223
+ # @param template_node [Object] Node from template
224
+ # @param dest_node [Object] Node from destination
225
+ # @param template_index [Integer] Index in template statements
226
+ # @param dest_index [Integer] Index in destination statements
227
+ # @return [Hash] Resolution with :source, :decision, and node references
228
+ def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
229
+ raise NotImplementedError, "Subclass must implement resolve_node_pair for :node strategy"
230
+ end
231
+
232
+ # Resolve all conflicts in batch (for :batch strategy)
233
+ # Override this method in subclasses using batch strategy
234
+ #
235
+ # @param result [Object] Result object to populate
236
+ # @return [void]
237
+ def resolve_batch(result)
238
+ raise NotImplementedError, "Subclass must implement resolve_batch for :batch strategy"
239
+ end
240
+
241
+ # Resolve a boundary/section (for :boundary strategy)
242
+ # Override this method in subclasses using boundary strategy
243
+ #
244
+ # Boundaries represent sections of content where template and destination
245
+ # differ. This strategy is useful for ASTs where content is processed
246
+ # in ranges/sections rather than individual nodes or all at once.
247
+ #
248
+ # @param boundary [Object] Boundary object with template_range and dest_range
249
+ # @param result [Object] Result object to populate
250
+ # @return [void]
251
+ def resolve_boundary(boundary, result)
252
+ raise NotImplementedError, "Subclass must implement resolve_boundary for :boundary strategy"
253
+ end
254
+
255
+ # Build a signature map from nodes
256
+ # Useful for batch resolution strategy
257
+ #
258
+ # @param nodes [Array] Nodes to map
259
+ # @param analysis [Object] Analysis for signature generation
260
+ # @return [Hash] Map of signature => [{node:, index:}, ...]
261
+ def build_signature_map(nodes, analysis)
262
+ map = {}
263
+ nodes.each_with_index do |node, idx|
264
+ sig = analysis.generate_signature(node)
265
+ next unless sig
266
+
267
+ map[sig] ||= []
268
+ map[sig] << {node: node, index: idx}
269
+ end
270
+ map
271
+ end
272
+
273
+ # Build a signature map from node_info hashes
274
+ # Useful for boundary resolution strategy where nodes are wrapped in info hashes
275
+ #
276
+ # @param node_infos [Array<Hash>] Node info hashes with :signature and :index keys
277
+ # @return [Hash] Map of signature => [node_info, ...]
278
+ def build_signature_map_from_infos(node_infos)
279
+ map = Hash.new { |h, k| h[k] = [] }
280
+ node_infos.each do |node_info|
281
+ sig = node_info[:signature]
282
+ map[sig] << node_info if sig
283
+ end
284
+ map
285
+ end
286
+
287
+ # Check if two line ranges overlap
288
+ #
289
+ # @param range1 [Range] First range
290
+ # @param range2 [Range] Second range
291
+ # @return [Boolean] True if ranges overlap
292
+ def ranges_overlap?(range1, range2)
293
+ range1.begin <= range2.end && range2.begin <= range1.end
294
+ end
295
+
296
+ # Create a resolution hash for frozen block
297
+ #
298
+ # @param source [Symbol] :template or :destination
299
+ # @param template_node [Object] Template node
300
+ # @param dest_node [Object] Destination node
301
+ # @param reason [String, nil] Freeze reason
302
+ # @return [Hash] Resolution hash
303
+ def frozen_resolution(source:, template_node:, dest_node:, reason: nil)
304
+ {
305
+ source: source,
306
+ decision: DECISION_FROZEN,
307
+ template_node: template_node,
308
+ dest_node: dest_node,
309
+ reason: reason,
310
+ }
311
+ end
312
+
313
+ # Create a resolution hash for identical content
314
+ #
315
+ # @param template_node [Object] Template node
316
+ # @param dest_node [Object] Destination node
317
+ # @return [Hash] Resolution hash
318
+ def identical_resolution(template_node:, dest_node:)
319
+ {
320
+ source: :destination,
321
+ decision: DECISION_IDENTICAL,
322
+ template_node: template_node,
323
+ dest_node: dest_node,
324
+ }
325
+ end
326
+
327
+ # Create a resolution hash based on preference.
328
+ # Supports per-node-type preferences when a Hash is configured.
329
+ #
330
+ # When per-type preferences are configured, checks template_node for
331
+ # merge_type (from NodeTyping wrapping). If template_node has no merge_type,
332
+ # falls back to dest_node's merge_type, then to the default preference.
333
+ #
334
+ # @param template_node [Object] Template node (may be a Wrapper)
335
+ # @param dest_node [Object] Destination node (may be a Wrapper)
336
+ # @return [Hash] Resolution hash
337
+ def preference_resolution(template_node:, dest_node:)
338
+ # Get the appropriate preference for this node pair
339
+ # Template node's merge_type takes precedence, then dest_node's
340
+ node_preference = if NodeTyping.typed_node?(template_node)
341
+ preference_for_node(template_node)
342
+ elsif NodeTyping.typed_node?(dest_node)
343
+ preference_for_node(dest_node)
344
+ else
345
+ default_preference
346
+ end
347
+
348
+ if node_preference == :template
349
+ {
350
+ source: :template,
351
+ decision: DECISION_TEMPLATE,
352
+ template_node: template_node,
353
+ dest_node: dest_node,
354
+ }
355
+ else
356
+ {
357
+ source: :destination,
358
+ decision: DECISION_DESTINATION,
359
+ template_node: template_node,
360
+ dest_node: dest_node,
361
+ }
362
+ end
363
+ end
364
+
365
+ private
366
+
367
+ # Validate the preference parameter.
368
+ #
369
+ # @param preference [Symbol, Hash] The preference to validate
370
+ # @raise [ArgumentError] If preference is invalid
371
+ def validate_preference!(preference)
372
+ if preference.is_a?(Hash)
373
+ validate_hash_preference!(preference)
374
+ elsif !%i[destination template].include?(preference)
375
+ raise ArgumentError, "Invalid preference: #{preference}. Must be :destination, :template, or a Hash"
376
+ end
377
+ end
378
+
379
+ # Validate a Hash preference configuration.
380
+ #
381
+ # @param preference [Hash] The preference hash to validate
382
+ # @raise [ArgumentError] If any key or value is invalid
383
+ def validate_hash_preference!(preference)
384
+ preference.each do |key, value|
385
+ unless key.is_a?(Symbol)
386
+ raise ArgumentError,
387
+ "preference Hash keys must be Symbols, got #{key.class} for #{key.inspect}"
388
+ end
389
+
390
+ unless %i[destination template].include?(value)
391
+ raise ArgumentError,
392
+ "preference Hash values must be :destination or :template, " \
393
+ "got #{value.inspect} for key #{key.inspect}"
394
+ end
395
+ end
396
+ end
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base debug logging utility for AST merge libraries.
6
+ # Provides conditional debug output based on environment configuration.
7
+ #
8
+ # This module is designed to be extended by file-type-specific merge libraries
9
+ # (e.g., Prism::Merge, Psych::Merge) which configure their own environment
10
+ # variable and log prefix.
11
+ #
12
+ # == Minimal Integration
13
+ #
14
+ # Simply extend this module and configure your environment variable and log prefix:
15
+ #
16
+ # @example Creating a custom debug logger (minimal integration)
17
+ # module MyMerge
18
+ # module DebugLogger
19
+ # extend Ast::Merge::DebugLogger
20
+ #
21
+ # self.env_var_name = "MY_MERGE_DEBUG"
22
+ # self.log_prefix = "[MyMerge]"
23
+ # end
24
+ # end
25
+ #
26
+ # == Overriding Methods
27
+ #
28
+ # When you +extend+ a module, its instance methods become singleton methods on
29
+ # your module. To override inherited behavior, you must define *singleton methods*
30
+ # (+def self.method_name+), not instance methods (+def method_name+).
31
+ #
32
+ # @example Overriding a method (correct - singleton method)
33
+ # module MyMerge
34
+ # module DebugLogger
35
+ # extend Ast::Merge::DebugLogger
36
+ #
37
+ # self.env_var_name = "MY_MERGE_DEBUG"
38
+ # self.log_prefix = "[MyMerge]"
39
+ #
40
+ # # Override extract_node_info for custom node types
41
+ # def self.extract_node_info(node)
42
+ # case node
43
+ # when MyMerge::CustomNode
44
+ # {type: "CustomNode", lines: "#{node.start_line}..#{node.end_line}"}
45
+ # else
46
+ # # Delegate to base implementation
47
+ # Ast::Merge::DebugLogger.extract_node_info(node)
48
+ # end
49
+ # end
50
+ # end
51
+ # end
52
+ #
53
+ # @example Enable debug logging
54
+ # ENV['AST_MERGE_DEBUG'] = '1'
55
+ # Ast::Merge::DebugLogger.debug("Processing node", {type: "mapping", line: 5})
56
+ #
57
+ # == Testing with Shared Examples
58
+ #
59
+ # Use the provided shared examples to validate your integration:
60
+ #
61
+ # require "ast/merge/rspec/shared_examples"
62
+ #
63
+ # RSpec.describe MyMerge::DebugLogger do
64
+ # it_behaves_like "Ast::Merge::DebugLogger" do
65
+ # let(:described_logger) { MyMerge::DebugLogger }
66
+ # let(:env_var_name) { "MY_MERGE_DEBUG" }
67
+ # let(:log_prefix) { "[MyMerge]" }
68
+ # end
69
+ # end
70
+ #
71
+ # @note Shared examples require +silent_stream+ and +rspec-stubbed_env+ gems.
72
+ module DebugLogger
73
+ # Benchmark is optional - gracefully degrade if not available
74
+ BENCHMARK_AVAILABLE = begin
75
+ require "benchmark"
76
+ true
77
+ rescue LoadError
78
+ # :nocov:
79
+ # Platform-specific: benchmark is part of Ruby stdlib, LoadError only on unusual Ruby builds
80
+ false
81
+ # :nocov:
82
+ end
83
+
84
+ class << self
85
+ # @return [String] Environment variable name to check for debug mode
86
+ attr_accessor :env_var_name
87
+
88
+ # @return [String] Prefix for log messages
89
+ attr_accessor :log_prefix
90
+
91
+ # Hook called when a module extends Ast::Merge::DebugLogger.
92
+ # Sets up attr_accessor for env_var_name and log_prefix on the extending module,
93
+ # and copies the BENCHMARK_AVAILABLE constant.
94
+ #
95
+ # @param base [Module] The module that is extending this module
96
+ def extended(base)
97
+ # Create a module with the accessors and prepend it to the singleton class.
98
+ # This avoids "method redefined" warnings when extending multiple times.
99
+ accessors_module = Module.new do
100
+ attr_accessor :env_var_name
101
+ attr_accessor :log_prefix
102
+ end
103
+ base.singleton_class.prepend(accessors_module)
104
+
105
+ # Set default values (inherit from Ast::Merge::DebugLogger)
106
+ base.env_var_name = env_var_name
107
+ base.log_prefix = log_prefix
108
+
109
+ # Copy the BENCHMARK_AVAILABLE constant
110
+ base.const_set(:BENCHMARK_AVAILABLE, BENCHMARK_AVAILABLE) unless base.const_defined?(:BENCHMARK_AVAILABLE)
111
+ end
112
+ end
113
+
114
+ # Default configuration
115
+ self.env_var_name = "AST_MERGE_DEBUG"
116
+ self.log_prefix = "[Ast::Merge]"
117
+
118
+ # Check if debug mode is enabled
119
+ #
120
+ # @return [Boolean]
121
+ def enabled?
122
+ val = ENV[env_var_name]
123
+ %w[1 true].include?(val)
124
+ end
125
+
126
+ # Get the environment variable name.
127
+ # When called as a module method (via extend self), returns own config.
128
+ # When called as instance method, checks class first, then falls back to base.
129
+ #
130
+ # @return [String]
131
+ def env_var_name
132
+ if is_a?(Module) && singleton_class.method_defined?(:env_var_name)
133
+ # Called as module method on a module that extended us
134
+ (self.class.superclass == Module) ? @env_var_name : self.class.env_var_name
135
+ elsif self.class.respond_to?(:env_var_name)
136
+ self.class.env_var_name
137
+ else
138
+ Ast::Merge::DebugLogger.env_var_name
139
+ end
140
+ end
141
+
142
+ # Get the log prefix.
143
+ # When called as a module method (via extend self), returns own config.
144
+ # When called as instance method, checks class first, then falls back to base.
145
+ #
146
+ # @return [String]
147
+ def log_prefix
148
+ if is_a?(Module) && singleton_class.method_defined?(:log_prefix)
149
+ # Called as module method on a module that extended us
150
+ (self.class.superclass == Module) ? @log_prefix : self.class.log_prefix
151
+ elsif self.class.respond_to?(:log_prefix)
152
+ self.class.log_prefix
153
+ else
154
+ Ast::Merge::DebugLogger.log_prefix
155
+ end
156
+ end
157
+
158
+ # Log a debug message with optional context
159
+ #
160
+ # @param message [String] The debug message
161
+ # @param context [Hash] Optional context to include
162
+ def debug(message, context = {})
163
+ return unless enabled?
164
+
165
+ output = "#{log_prefix} #{message}"
166
+ output += " #{context.inspect}" unless context.empty?
167
+ warn(output)
168
+ end
169
+
170
+ # Log an info message (always shown when debug is enabled)
171
+ #
172
+ # @param message [String] The info message
173
+ def info(message)
174
+ return unless enabled?
175
+
176
+ warn("#{log_prefix} INFO] #{message}")
177
+ end
178
+
179
+ # Log a warning message (always shown)
180
+ #
181
+ # @param message [String] The warning message
182
+ def warning(message)
183
+ warn("#{log_prefix} WARNING] #{message}")
184
+ end
185
+
186
+ # Time a block and log the duration
187
+ #
188
+ # @param operation [String] Name of the operation
189
+ # @yield The block to time
190
+ # @return [Object] The result of the block
191
+ def time(operation)
192
+ return yield unless enabled?
193
+
194
+ unless BENCHMARK_AVAILABLE
195
+ warning("Benchmark gem not available - timing disabled for: #{operation}")
196
+ return yield
197
+ end
198
+
199
+ debug("Starting: #{operation}")
200
+ result = nil
201
+ timing = Benchmark.measure { result = yield }
202
+ debug("Completed: #{operation}", {
203
+ real_ms: (timing.real * 1000).round(2),
204
+ user_ms: (timing.utime * 1000).round(2),
205
+ system_ms: (timing.stime * 1000).round(2),
206
+ })
207
+ result
208
+ end
209
+
210
+ # Log node information - override in submodules for file-type-specific logging
211
+ #
212
+ # @param node [Object] Node to log information about
213
+ # @param label [String] Label for the node
214
+ def log_node(node, label: "Node")
215
+ return unless enabled?
216
+
217
+ info = extract_node_info(node)
218
+ debug(label, info)
219
+ end
220
+
221
+ # Extract information from a node for logging.
222
+ # Override in submodules for file-type-specific node types.
223
+ #
224
+ # @param node [Object] Node to extract info from
225
+ # @return [Hash] Node information
226
+ def extract_node_info(node)
227
+ type_name = safe_type_name(node)
228
+ lines = extract_lines(node)
229
+
230
+ info = {type: type_name}
231
+ info[:lines] = lines if lines
232
+ info
233
+ end
234
+
235
+ # Safely extract the type name from a node
236
+ #
237
+ # @param node [Object] Node to get type from
238
+ # @return [String] Type name
239
+ def safe_type_name(node)
240
+ klass = node.class
241
+ if klass.respond_to?(:name) && klass.name
242
+ klass.name.split("::").last
243
+ else
244
+ klass.to_s
245
+ end
246
+ rescue StandardError
247
+ node.class.to_s
248
+ end
249
+
250
+ # Extract line information from a node if available
251
+ #
252
+ # @param node [Object] Node to extract lines from
253
+ # @return [String, nil] Line range string or nil
254
+ def extract_lines(node)
255
+ if node.respond_to?(:location)
256
+ loc = node.location
257
+ if loc.respond_to?(:start_line) && loc.respond_to?(:end_line)
258
+ "#{loc.start_line}..#{loc.end_line}"
259
+ elsif loc.respond_to?(:start_line)
260
+ loc.start_line.to_s
261
+ end
262
+ elsif node.respond_to?(:start_line) && node.respond_to?(:end_line)
263
+ "#{node.start_line}..#{node.end_line}"
264
+ end
265
+ end
266
+
267
+ # Make all methods available as both instance and module methods
268
+ extend self
269
+ end
270
+ end
271
+ end