ast-merge 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +194 -1
  4. data/README.md +235 -53
  5. data/exe/ast-merge-recipe +366 -0
  6. data/lib/ast/merge/ast_node.rb +224 -24
  7. data/lib/ast/merge/comment/block.rb +6 -0
  8. data/lib/ast/merge/comment/empty.rb +6 -0
  9. data/lib/ast/merge/comment/line.rb +6 -0
  10. data/lib/ast/merge/comment/parser.rb +9 -7
  11. data/lib/ast/merge/conflict_resolver_base.rb +8 -1
  12. data/lib/ast/merge/content_match_refiner.rb +278 -0
  13. data/lib/ast/merge/debug_logger.rb +6 -1
  14. data/lib/ast/merge/detector/base.rb +193 -0
  15. data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
  16. data/lib/ast/merge/detector/mergeable.rb +369 -0
  17. data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
  18. data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
  19. data/lib/ast/merge/file_analyzable.rb +5 -3
  20. data/lib/ast/merge/freeze_node_base.rb +1 -1
  21. data/lib/ast/merge/match_refiner_base.rb +1 -1
  22. data/lib/ast/merge/match_score_base.rb +1 -1
  23. data/lib/ast/merge/merge_result_base.rb +4 -1
  24. data/lib/ast/merge/merger_config.rb +33 -31
  25. data/lib/ast/merge/navigable_statement.rb +630 -0
  26. data/lib/ast/merge/partial_template_merger.rb +432 -0
  27. data/lib/ast/merge/recipe/config.rb +198 -0
  28. data/lib/ast/merge/recipe/preset.rb +171 -0
  29. data/lib/ast/merge/recipe/runner.rb +254 -0
  30. data/lib/ast/merge/recipe/script_loader.rb +181 -0
  31. data/lib/ast/merge/recipe.rb +26 -0
  32. data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
  33. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
  34. data/lib/ast/merge/rspec.rb +33 -2
  35. data/lib/ast/merge/section_typing.rb +52 -50
  36. data/lib/ast/merge/smart_merger_base.rb +86 -3
  37. data/lib/ast/merge/text/line_node.rb +42 -9
  38. data/lib/ast/merge/text/section_splitter.rb +12 -10
  39. data/lib/ast/merge/text/word_node.rb +47 -14
  40. data/lib/ast/merge/version.rb +1 -1
  41. data/lib/ast/merge.rb +10 -6
  42. data/sig/ast/merge.rbs +389 -2
  43. data.tar.gz.sig +0 -0
  44. metadata +76 -12
  45. metadata.gz.sig +0 -0
  46. data/lib/ast/merge/fenced_code_block_detector.rb +0 -211
  47. data/lib/ast/merge/region.rb +0 -124
  48. data/lib/ast/merge/region_detector_base.rb +0 -114
  49. data/lib/ast/merge/region_mergeable.rb +0 -364
  50. data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
  51. data/lib/ast/merge/yaml_frontmatter_detector.rb +0 -108
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # AST Merge Recipe Runner
5
+ #
6
+ # Run YAML-based merge recipes against target files.
7
+ # This is a shipped executable that can be used after installing the ast-merge gem.
8
+ #
9
+ # Usage:
10
+ # ast-merge-recipe RECIPE_FILE [options]
11
+ #
12
+ # Examples:
13
+ # ast-merge-recipe .merge-recipes/gem_family_section.yml --dry-run
14
+ # ast-merge-recipe .merge-recipes/gem_family_section.yml --verbose --parser=commonmarker
15
+
16
+ require "bundler/inline"
17
+ require "optparse"
18
+ require "yaml"
19
+
20
+ # Parse options first to get merge_gems before bundler/inline
21
+ options = {
22
+ dry_run: false,
23
+ verbose: false,
24
+ parser: :markly,
25
+ base_dir: Dir.pwd,
26
+ recipe_file: nil,
27
+ merge_gems: [],
28
+ dev_mode: ENV.fetch("KETTLE_RB_DEV", "false").casecmp?("true"),
29
+ dev_root: nil,
30
+ }
31
+
32
+ # Pre-parse to extract recipe file and check for merge_gems in recipe
33
+ # We need to do this before bundler/inline to know which gems to load
34
+ ARGV.each do |arg|
35
+ case arg
36
+ when /^--dev-root=(.+)$/
37
+ options[:dev_root] = File.expand_path($1)
38
+ when /^-/
39
+ # Skip options for now
40
+ else
41
+ options[:recipe_file] ||= arg
42
+ end
43
+ end
44
+
45
+ # If recipe file specified, try to load merge_gems from it
46
+ recipe_merge_gems = []
47
+ if options[:recipe_file] && File.exist?(options[:recipe_file])
48
+ begin
49
+ recipe_config = YAML.safe_load_file(options[:recipe_file], permitted_classes: [Symbol])
50
+ if recipe_config.is_a?(Hash) && recipe_config["merge_gems"]
51
+ recipe_merge_gems = Array(recipe_config["merge_gems"])
52
+ end
53
+ rescue
54
+ # Ignore errors here, we'll catch them later
55
+ end
56
+ end
57
+
58
+ # Determine dev root for local gems
59
+ dev_root = options[:dev_root] || ENV["AST_MERGE_DEV_ROOT"]
60
+ if options[:dev_mode] && dev_root.nil?
61
+ # Try to find dev root by looking for ast-merge directory
62
+ possible_roots = [
63
+ File.expand_path("../..", __FILE__),
64
+ File.expand_path("../../..", __FILE__),
65
+ Dir.pwd,
66
+ ]
67
+ dev_root = possible_roots.find { |p| File.exist?(File.join(p, "ast-merge.gemspec")) }
68
+ end
69
+
70
+ # Load dependencies via bundler/inline
71
+ gemfile do
72
+ source "https://gem.coop"
73
+
74
+ if options[:dev_mode] && dev_root
75
+ # Development mode - use local gems
76
+ gem "ast-merge", path: dev_root
77
+ gem "tree_haver", path: File.join(dev_root, "vendor/tree_haver")
78
+ gem "markdown-merge", path: File.join(dev_root, "vendor/markdown-merge")
79
+ gem "markly-merge", path: File.join(dev_root, "vendor/markly-merge")
80
+ gem "commonmarker-merge", path: File.join(dev_root, "vendor/commonmarker-merge")
81
+ gem "prism-merge", path: File.join(dev_root, "vendor/prism-merge")
82
+ gem "psych-merge", path: File.join(dev_root, "vendor/psych-merge")
83
+ else
84
+ # Production mode - use released gems
85
+ # gem.coop gems need a source block
86
+ gem "ast-merge"
87
+ gem "tree_haver"
88
+ gem "markdown-merge"
89
+ gem "markly-merge"
90
+ end
91
+
92
+ # Load additional merge gems specified in recipe
93
+ recipe_merge_gems.each do |gem_spec|
94
+ case gem_spec
95
+ when String
96
+ gem(gem_spec)
97
+ when Hash
98
+ name = gem_spec["name"] || gem_spec[:name]
99
+ gem_opts = {}
100
+ gem_opts[:version] = gem_spec["version"] || gem_spec[:version] if gem_spec["version"] || gem_spec[:version]
101
+ gem_opts[:path] = gem_spec["path"] || gem_spec[:path] if gem_spec["path"] || gem_spec[:path]
102
+ gem_opts[:git] = gem_spec["git"] || gem_spec[:git] if gem_spec["git"] || gem_spec[:git]
103
+ gem_opts[:branch] = gem_spec["branch"] || gem_spec[:branch] if gem_spec["branch"] || gem_spec[:branch]
104
+ gem_opts[:require] = gem_spec["require"] || gem_spec[:require] if gem_spec.key?("require") || gem_spec.key?(:require)
105
+
106
+ if gem_opts.empty?
107
+ gem(name)
108
+ else
109
+ gem(name, **gem_opts)
110
+ end
111
+ end
112
+ end
113
+
114
+ # Try to load table_tennis for nice output
115
+ gem "table_tennis", require: false
116
+ end
117
+
118
+ # Now load the actual libraries
119
+ require "ast-merge"
120
+
121
+ # Try to load table_tennis
122
+ begin
123
+ require "table_tennis"
124
+ HAS_TABLE_TENNIS = true
125
+ rescue LoadError
126
+ HAS_TABLE_TENNIS = false
127
+ end
128
+
129
+ # ANSI color helpers
130
+ module Colors
131
+ class << self
132
+ def green(str) = "\e[32m#{str}\e[0m"
133
+ def red(str) = "\e[31m#{str}\e[0m"
134
+ def yellow(str) = "\e[33m#{str}\e[0m"
135
+ def cyan(str) = "\e[36m#{str}\e[0m"
136
+ def bold(str) = "\e[1m#{str}\e[0m"
137
+ def dim(str) = "\e[2m#{str}\e[0m"
138
+ end
139
+ end
140
+
141
+ # Main runner class
142
+ class AstMergeRecipeCLI
143
+ VERSION = Ast::Merge::VERSION
144
+
145
+ def initialize
146
+ @options = {
147
+ dry_run: false,
148
+ verbose: false,
149
+ parser: :markly,
150
+ base_dir: Dir.pwd,
151
+ recipe_file: nil,
152
+ }
153
+ end
154
+
155
+ def run(argv = ARGV)
156
+ parse_options(argv)
157
+ validate_options!
158
+ execute_recipe
159
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
160
+ $stderr.puts Colors.red("ERROR: #{e.message}")
161
+ $stderr.puts
162
+ $stderr.puts @option_parser
163
+ exit(1)
164
+ rescue => e
165
+ $stderr.puts Colors.red("ERROR: #{e.message}")
166
+ $stderr.puts e.backtrace.first(5).join("\n") if @options[:verbose]
167
+ exit(1)
168
+ end
169
+
170
+ private
171
+
172
+ def parse_options(argv)
173
+ @option_parser = OptionParser.new do |opts|
174
+ opts.banner = "Usage: #{File.basename($0)} RECIPE_FILE [options]"
175
+ opts.separator("")
176
+ opts.separator("Run a YAML-based merge recipe against target files.")
177
+ opts.separator("")
178
+ opts.separator("Options:")
179
+
180
+ opts.on("-n", "--dry-run", "Show what would change without modifying files") do
181
+ @options[:dry_run] = true
182
+ end
183
+
184
+ opts.on("-v", "--verbose", "Show detailed output") do
185
+ @options[:verbose] = true
186
+ end
187
+
188
+ opts.on(
189
+ "-p",
190
+ "--parser=PARSER",
191
+ String,
192
+ "Parser to use (markly, commonmarker, prism, psych)",
193
+ "Default: markly",
194
+ ) do |parser|
195
+ @options[:parser] = parser.to_sym
196
+ end
197
+
198
+ opts.on(
199
+ "-d",
200
+ "--base-dir=DIR",
201
+ String,
202
+ "Base directory for path resolution",
203
+ "Default: current directory",
204
+ ) do |dir|
205
+ @options[:base_dir] = File.expand_path(dir)
206
+ end
207
+
208
+ opts.on(
209
+ "--dev-root=DIR",
210
+ String,
211
+ "Root directory for development gems (implies dev mode)",
212
+ ) do |dir|
213
+ # Already handled in pre-parse
214
+ end
215
+
216
+ opts.on("-V", "--version", "Show version") do
217
+ puts "ast-merge-recipe #{VERSION}"
218
+ exit(0)
219
+ end
220
+
221
+ opts.on("-h", "--help", "Show this help message") do
222
+ puts opts
223
+ puts
224
+ puts "Examples:"
225
+ puts " #{File.basename($0)} .merge-recipes/gem_family_section.yml --dry-run"
226
+ puts " #{File.basename($0)} recipe.yml --verbose --parser=commonmarker"
227
+ puts
228
+ puts "Recipe YAML format:"
229
+ puts " See lib/ast/merge/recipe/README.md for full documentation"
230
+ exit(0)
231
+ end
232
+ end
233
+
234
+ # Parse options, leaving non-option arguments
235
+ remaining = @option_parser.parse(argv)
236
+
237
+ # First non-option argument is the recipe file
238
+ @options[:recipe_file] = remaining.shift
239
+
240
+ # Warn about extra arguments
241
+ if remaining.any?
242
+ $stderr.puts Colors.yellow("WARNING: Ignoring extra arguments: #{remaining.join(", ")}")
243
+ end
244
+ end
245
+
246
+ def validate_options!
247
+ unless @options[:recipe_file]
248
+ $stderr.puts Colors.red("ERROR: No recipe file specified")
249
+ $stderr.puts
250
+ $stderr.puts @option_parser
251
+ exit(1)
252
+ end
253
+
254
+ recipe_path = File.expand_path(@options[:recipe_file])
255
+ unless File.exist?(recipe_path)
256
+ $stderr.puts Colors.red("ERROR: Recipe file not found: #{recipe_path}")
257
+ exit(1)
258
+ end
259
+
260
+ @options[:recipe_file] = recipe_path
261
+ end
262
+
263
+ def execute_recipe
264
+ print_header
265
+
266
+ # Load recipe
267
+ recipe = Ast::Merge::Recipe::Config.load(@options[:recipe_file])
268
+ print_recipe_info(recipe)
269
+
270
+ # Create runner
271
+ runner = Ast::Merge::Recipe::Runner.new(
272
+ recipe,
273
+ dry_run: @options[:dry_run],
274
+ base_dir: @options[:base_dir],
275
+ parser: @options[:parser],
276
+ verbose: @options[:verbose],
277
+ )
278
+
279
+ # Run and display results
280
+ puts Colors.cyan("Processing files...")
281
+ puts
282
+
283
+ runner.run do |result|
284
+ print_result(result)
285
+ end
286
+
287
+ print_summary(runner)
288
+
289
+ # Exit with error if there were failures
290
+ exit(1) if runner.summary[:errors] > 0
291
+ end
292
+
293
+ def print_header
294
+ puts Colors.bold("=" * 70)
295
+ puts Colors.bold("AST Merge Recipe Runner")
296
+ puts Colors.bold("=" * 70)
297
+ puts
298
+ end
299
+
300
+ def print_recipe_info(recipe)
301
+ puts Colors.cyan("Recipe: #{recipe.name}")
302
+ puts Colors.dim(" #{recipe.description}") if recipe.description
303
+ puts
304
+ puts Colors.yellow("Mode: #{@options[:dry_run] ? "DRY RUN" : "LIVE"}")
305
+ puts Colors.dim("Parser: #{@options[:parser]}")
306
+ puts
307
+ end
308
+
309
+ def print_result(result)
310
+ symbol = status_symbol(result.status)
311
+ puts " #{symbol} #{result.relative_path}"
312
+
313
+ if @options[:verbose] || result.status == :error
314
+ puts Colors.dim(" #{result.message}") if result.message
315
+ end
316
+
317
+ if @options[:verbose] && result.stats
318
+ puts Colors.dim(" Stats: #{result.stats.inspect}")
319
+ end
320
+
321
+ if result.error && @options[:verbose]
322
+ puts Colors.red(" #{result.error.class}: #{result.error.message}")
323
+ puts Colors.dim(" #{result.error.backtrace&.first(3)&.join("\n ")}")
324
+ end
325
+ end
326
+
327
+ def print_summary(runner)
328
+ puts
329
+ puts Colors.bold("=" * 70)
330
+ puts Colors.bold("Summary")
331
+ puts Colors.bold("=" * 70)
332
+ puts
333
+
334
+ summary = runner.summary
335
+
336
+ if HAS_TABLE_TENNIS
337
+ puts TableTennis.new(runner.summary_table)
338
+ else
339
+ puts " Total files: #{summary[:total]}"
340
+ if @options[:dry_run]
341
+ puts " Would update: #{summary[:would_update]}"
342
+ else
343
+ puts " Updated: #{summary[:updated]}"
344
+ end
345
+ puts " Unchanged: #{summary[:unchanged]}"
346
+ puts " Skipped (no anchor):#{summary[:skipped]}"
347
+ puts " Errors: #{summary[:errors]}" if summary[:errors] > 0
348
+ end
349
+
350
+ puts
351
+ end
352
+
353
+ def status_symbol(status)
354
+ case status
355
+ when :updated then Colors.green("✓")
356
+ when :would_update then Colors.yellow("~")
357
+ when :unchanged then Colors.dim("○")
358
+ when :skipped then Colors.dim("-")
359
+ when :error then Colors.red("✗")
360
+ else "?"
361
+ end
362
+ end
363
+ end
364
+
365
+ # Run the CLI
366
+ AstMergeRecipeCLI.new.run
@@ -2,27 +2,78 @@
2
2
 
3
3
  module Ast
4
4
  module Merge
5
- # Base class for AST nodes in the ast-merge framework.
5
+ # Base class for synthetic AST nodes in the ast-merge framework.
6
6
  #
7
- # This provides a common API that works across different AST implementations
8
- # (Prism, TreeSitter, custom comment nodes, etc.) enabling uniform handling
9
- # in merge operations.
7
+ # "Synthetic" nodes are nodes that aren't backed by a real parser - they're
8
+ # created by ast-merge for representing content that doesn't have a native
9
+ # AST (comments, text lines, env file entries, etc.).
10
10
  #
11
- # Subclasses should implement:
12
- # - #slice - returns the source text for the node
13
- # - #location - returns an object responding to start_line/end_line
14
- # - #children - returns child nodes (empty array for leaf nodes)
15
- # - #signature - returns a signature array for matching (optional, can use default)
11
+ # This class implements the TreeHaver::Node protocol, making it compatible
12
+ # with all code that expects TreeHaver nodes. This allows synthetic nodes
13
+ # to be used interchangeably with parser-backed nodes in merge operations.
16
14
  #
17
- # @abstract
15
+ # Implements the TreeHaver::Node protocol:
16
+ # - type → String node type
17
+ # - text / slice → Source text content
18
+ # - start_byte / end_byte → Byte offsets
19
+ # - start_point / end_point → Point (row, column)
20
+ # - children → Array of child nodes
21
+ # - named? / structural? → Node classification
22
+ # - inner_node → Returns self (no wrapping layer for synthetic nodes)
23
+ #
24
+ # Adds merge-specific methods:
25
+ # - signature → Array used for matching nodes across files
26
+ # - normalized_content → Cleaned text for comparison
27
+ #
28
+ # @example Subclassing for custom node types
29
+ # class MyNode < AstNode
30
+ # def type
31
+ # "my_node"
32
+ # end
33
+ #
34
+ # def signature
35
+ # [:my_node, normalized_content]
36
+ # end
37
+ # end
38
+ #
39
+ # @see TreeHaver::Node The protocol this class implements
40
+ # @see Comment::Line Example synthetic node for comments
41
+ # @see Text::LineNode Example synthetic node for text lines
18
42
  class AstNode
19
- # Simple location struct for nodes that don't have a native location object
43
+ include Comparable
44
+
45
+ # Point class compatible with TreeHaver::Point
46
+ # Provides both method and hash-style access to row/column
47
+ Point = Struct.new(:row, :column, keyword_init: true) do
48
+ # Hash-like access for compatibility
49
+ def [](key)
50
+ case key
51
+ when :row, "row" then row
52
+ when :column, "column" then column
53
+ end
54
+ end
55
+
56
+ def to_h
57
+ {row: row, column: column}
58
+ end
59
+
60
+ def to_s
61
+ "(#{row}, #{column})"
62
+ end
63
+
64
+ def inspect
65
+ "#<Ast::Merge::AstNode::Point row=#{row} column=#{column}>"
66
+ end
67
+ end
68
+
69
+ # Location struct for tracking source positions
70
+ # Compatible with TreeHaver location expectations
20
71
  Location = Struct.new(:start_line, :end_line, :start_column, :end_column, keyword_init: true) do
21
72
  # Check if a line number falls within this location
22
- # @param line_number [Integer] The line number to check
73
+ # @param line_number [Integer] The line number to check (1-based)
23
74
  # @return [Boolean] true if the line number is within the range
24
75
  def cover?(line_number)
25
- line_number >= start_line && line_number <= end_line
76
+ line_number.between?(start_line, end_line)
26
77
  end
27
78
  end
28
79
 
@@ -32,28 +83,164 @@ module Ast
32
83
  # @return [String] The source text for this node
33
84
  attr_reader :slice
34
85
 
86
+ # @return [String, nil] The full source text (for text extraction)
87
+ attr_reader :source
88
+
35
89
  # Initialize a new AstNode.
36
90
  #
37
91
  # @param slice [String] The source text for this node
38
- # @param location [Location, #start_line] Location object or anything responding to start_line/end_line
39
- def initialize(slice:, location:)
92
+ # @param location [Location, #start_line] Location object
93
+ # @param source [String, nil] Full source text (optional)
94
+ def initialize(slice:, location:, source: nil)
40
95
  @slice = slice
41
96
  @location = location
97
+ @source = source
42
98
  end
43
99
 
100
+ # TreeHaver::Node protocol: inner_node
101
+ # For synthetic nodes, this returns self (no wrapping layer)
102
+ #
103
+ # @return [AstNode] self
104
+ def inner_node
105
+ self
106
+ end
107
+
108
+ # TreeHaver::Node protocol: type
109
+ # Returns the node type as a string.
110
+ # Subclasses should override this with specific type names.
111
+ #
112
+ # @return [String] Node type
113
+ def type
114
+ # Default: derive from class name (MyNode → "my_node")
115
+ self.class.name.split("::").last
116
+ .gsub(/([A-Z])/, '_\1')
117
+ .downcase
118
+ .sub(/^_/, "")
119
+ end
120
+
121
+ # Alias for tree-sitter compatibility
122
+ alias_method :kind, :type
123
+
124
+ # TreeHaver::Node protocol: text
125
+ # @return [String] The source text
126
+ def text
127
+ slice.to_s
128
+ end
129
+
130
+ # TreeHaver::Node protocol: start_byte
131
+ # Calculates byte offset from source if available, otherwise estimates from lines
132
+ #
133
+ # @return [Integer] Starting byte offset
134
+ def start_byte
135
+ return 0 unless source && location
136
+
137
+ # Calculate byte offset from line/column
138
+ lines = source.lines
139
+ byte_offset = 0
140
+ (0...(location.start_line - 1)).each do |i|
141
+ byte_offset += lines[i]&.bytesize || 0
142
+ end
143
+ byte_offset + (location.start_column || 0)
144
+ end
145
+
146
+ # TreeHaver::Node protocol: end_byte
147
+ #
148
+ # @return [Integer] Ending byte offset
149
+ def end_byte
150
+ start_byte + slice.to_s.bytesize
151
+ end
152
+
153
+ # TreeHaver::Node protocol: start_point
154
+ # Returns a Point with row (0-based) and column
155
+ #
156
+ # @return [Point] Starting position
157
+ def start_point
158
+ Point.new(
159
+ row: (location&.start_line || 1) - 1, # Convert to 0-based
160
+ column: location&.start_column || 0,
161
+ )
162
+ end
163
+
164
+ # TreeHaver::Node protocol: end_point
165
+ # Returns a Point with row (0-based) and column
166
+ #
167
+ # @return [Point] Ending position
168
+ def end_point
169
+ Point.new(
170
+ row: (location&.end_line || 1) - 1, # Convert to 0-based
171
+ column: location&.end_column || 0,
172
+ )
173
+ end
174
+
175
+ # TreeHaver::Node protocol: children
44
176
  # @return [Array<AstNode>] Child nodes (empty for leaf nodes)
45
177
  def children
46
178
  []
47
179
  end
48
180
 
181
+ # TreeHaver::Node protocol: child_count
182
+ # @return [Integer] Number of children
183
+ def child_count
184
+ children.size
185
+ end
186
+
187
+ # TreeHaver::Node protocol: child(index)
188
+ # @param index [Integer] Child index
189
+ # @return [AstNode, nil] Child at index
190
+ def child(index)
191
+ children[index]
192
+ end
193
+
194
+ # TreeHaver::Node protocol: named?
195
+ # Synthetic nodes are always "named" (structural) nodes
196
+ #
197
+ # @return [Boolean] true
198
+ def named?
199
+ true
200
+ end
201
+
202
+ # TreeHaver::Node protocol: structural?
203
+ # Synthetic nodes are always structural
204
+ #
205
+ # @return [Boolean] true
206
+ def structural?
207
+ true
208
+ end
209
+
210
+ # TreeHaver::Node protocol: has_error?
211
+ # Synthetic nodes don't have parse errors
212
+ #
213
+ # @return [Boolean] false
214
+ def has_error?
215
+ false
216
+ end
217
+
218
+ # TreeHaver::Node protocol: missing?
219
+ # Synthetic nodes are never "missing"
220
+ #
221
+ # @return [Boolean] false
222
+ def missing?
223
+ false
224
+ end
225
+
226
+ # TreeHaver::Node protocol: each
227
+ # Iterate over children
228
+ #
229
+ # @yield [AstNode] Each child node
230
+ # @return [Enumerator, nil]
231
+ def each(&block)
232
+ return to_enum(__method__) unless block_given?
233
+ children.each(&block)
234
+ end
235
+
49
236
  # Generate a signature for this node for matching purposes.
50
237
  #
51
238
  # Override in subclasses for custom signature logic.
52
- # Default returns the node class name and a normalized form of the slice.
239
+ # Default returns the node type and a normalized form of the slice.
53
240
  #
54
241
  # @return [Array] Signature array for matching
55
242
  def signature
56
- [self.class.name, normalized_content]
243
+ [type.to_sym, normalized_content]
57
244
  end
58
245
 
59
246
  # @return [String] Normalized content for signature comparison
@@ -61,9 +248,22 @@ module Ast
61
248
  slice.to_s.strip
62
249
  end
63
250
 
251
+ # Comparable: compare nodes by position
252
+ #
253
+ # @param other [AstNode] node to compare with
254
+ # @return [Integer, nil] -1, 0, 1, or nil if not comparable
255
+ def <=>(other)
256
+ return unless other.respond_to?(:start_byte) && other.respond_to?(:end_byte)
257
+
258
+ cmp = start_byte <=> other.start_byte
259
+ return cmp if cmp.nonzero?
260
+
261
+ end_byte <=> other.end_byte
262
+ end
263
+
64
264
  # @return [String] Human-readable representation
65
265
  def inspect
66
- "#<#{self.class.name} lines=#{location.start_line}..#{location.end_line} slice=#{slice.to_s[0..50].inspect}>"
266
+ "#<#{self.class.name} type=#{type} lines=#{location&.start_line}..#{location&.end_line}>"
67
267
  end
68
268
 
69
269
  # @return [String] The source text
@@ -76,12 +276,12 @@ module Ast
76
276
  def unwrap
77
277
  self
78
278
  end
79
-
80
- # Check if this node responds to the Prism-style location API
81
- # @return [Boolean] true
82
- def respond_to_missing?(method, include_private = false)
83
- [:location, :slice].include?(method) || super
84
- end
85
279
  end
280
+
281
+ # Alias for clarity - SyntheticNode clearly indicates "not backed by a real parser"
282
+ # Use this alias when the distinction between synthetic and parser-backed nodes matters.
283
+ #
284
+ # @see AstNode
285
+ SyntheticNode = AstNode
86
286
  end
87
287
  end
@@ -41,6 +41,12 @@ module Ast
41
41
  # @return [Style] The comment style configuration
42
42
  attr_reader :style
43
43
 
44
+ # TreeHaver::Node protocol: type
45
+ # @return [String] "comment_block"
46
+ def type
47
+ "comment_block"
48
+ end
49
+
44
50
  # Initialize a new Block.
45
51
  #
46
52
  # For line-based comments, pass `children` array.
@@ -28,6 +28,12 @@ module Ast
28
28
  # @return [String] The actual line content (may have whitespace)
29
29
  attr_reader :text
30
30
 
31
+ # TreeHaver::Node protocol: type
32
+ # @return [String] "empty_line"
33
+ def type
34
+ "empty_line"
35
+ end
36
+
31
37
  # Initialize a new Empty line.
32
38
  #
33
39
  # @param line_number [Integer] The 1-based line number
@@ -38,6 +38,12 @@ module Ast
38
38
  # @return [Style] The comment style configuration
39
39
  attr_reader :style
40
40
 
41
+ # TreeHaver::Node protocol: type
42
+ # @return [String] "comment_line"
43
+ def type
44
+ "comment_line"
45
+ end
46
+
41
47
  # Initialize a new Line.
42
48
  #
43
49
  # @param text [String] The full comment text including delimiter