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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +194 -1
- data/README.md +235 -53
- data/exe/ast-merge-recipe +366 -0
- data/lib/ast/merge/ast_node.rb +224 -24
- data/lib/ast/merge/comment/block.rb +6 -0
- data/lib/ast/merge/comment/empty.rb +6 -0
- data/lib/ast/merge/comment/line.rb +6 -0
- data/lib/ast/merge/comment/parser.rb +9 -7
- 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 +6 -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/file_analyzable.rb +5 -3
- data/lib/ast/merge/freeze_node_base.rb +1 -1
- data/lib/ast/merge/match_refiner_base.rb +1 -1
- data/lib/ast/merge/match_score_base.rb +1 -1
- data/lib/ast/merge/merge_result_base.rb +4 -1
- data/lib/ast/merge/merger_config.rb +33 -31
- 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/section_typing.rb +52 -50
- data/lib/ast/merge/smart_merger_base.rb +86 -3
- data/lib/ast/merge/text/line_node.rb +42 -9
- data/lib/ast/merge/text/section_splitter.rb +12 -10
- data/lib/ast/merge/text/word_node.rb +47 -14
- 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 +76 -12
- metadata.gz.sig +0 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +0 -211
- 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 -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
|
data/lib/ast/merge/ast_node.rb
CHANGED
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
39
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
|
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
|