rfmt 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/Cargo.lock +1748 -133
- data/README.md +458 -19
- data/exe/rfmt +15 -0
- data/ext/rfmt/Cargo.toml +46 -1
- data/ext/rfmt/extconf.rb +5 -5
- data/ext/rfmt/spec/config_spec.rb +39 -0
- data/ext/rfmt/spec/spec_helper.rb +16 -0
- data/ext/rfmt/src/ast/mod.rs +335 -0
- data/ext/rfmt/src/config/mod.rs +403 -0
- data/ext/rfmt/src/emitter/mod.rs +347 -0
- data/ext/rfmt/src/error/mod.rs +48 -0
- data/ext/rfmt/src/lib.rs +59 -36
- data/ext/rfmt/src/logging/logger.rs +128 -0
- data/ext/rfmt/src/logging/mod.rs +3 -0
- data/ext/rfmt/src/parser/mod.rs +9 -0
- data/ext/rfmt/src/parser/prism_adapter.rs +407 -0
- data/ext/rfmt/src/policy/mod.rs +36 -0
- data/ext/rfmt/src/policy/validation.rs +18 -0
- data/lib/rfmt/cache.rb +120 -0
- data/lib/rfmt/cli.rb +280 -0
- data/lib/rfmt/configuration.rb +95 -0
- data/lib/rfmt/prism_bridge.rb +255 -0
- data/lib/rfmt/prism_node_extractor.rb +81 -0
- data/lib/rfmt/rfmt.so +0 -0
- data/lib/rfmt/version.rb +1 -1
- data/lib/rfmt.rb +156 -5
- metadata +29 -7
- data/lib/rfmt/rfmt.bundle +0 -0
data/lib/rfmt/cli.rb
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'rfmt'
|
|
5
|
+
require 'rfmt/configuration'
|
|
6
|
+
require 'rfmt/cache'
|
|
7
|
+
|
|
8
|
+
module Rfmt
|
|
9
|
+
# Cache management commands
|
|
10
|
+
class CacheCommands < Thor
|
|
11
|
+
desc 'clear', 'Clear all cache data'
|
|
12
|
+
option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
|
|
13
|
+
def clear
|
|
14
|
+
cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
|
|
15
|
+
cache = Cache.new(**cache_opts)
|
|
16
|
+
cache.clear
|
|
17
|
+
say 'Cache cleared', :green
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc 'stats', 'Show cache statistics'
|
|
21
|
+
option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
|
|
22
|
+
def stats
|
|
23
|
+
cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
|
|
24
|
+
cache = Cache.new(**cache_opts)
|
|
25
|
+
stats = cache.stats
|
|
26
|
+
say "Cache directory: #{stats[:cache_dir]}", :blue
|
|
27
|
+
say "Total files in cache: #{stats[:total_files]}", :blue
|
|
28
|
+
say "Cache size: #{(stats[:cache_size_bytes] / 1024.0).round(2)} KB", :blue
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc 'prune', 'Remove cache entries for files that no longer exist'
|
|
32
|
+
option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
|
|
33
|
+
def prune
|
|
34
|
+
cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
|
|
35
|
+
cache = Cache.new(**cache_opts)
|
|
36
|
+
pruned = cache.prune
|
|
37
|
+
say "Pruned #{pruned} stale cache entries", :green
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Command Line Interface for rfmt
|
|
42
|
+
class CLI < Thor
|
|
43
|
+
class_option :config, type: :string, desc: 'Path to configuration file'
|
|
44
|
+
class_option :verbose, type: :boolean, aliases: '-v', desc: 'Verbose output'
|
|
45
|
+
|
|
46
|
+
desc 'format [FILES]', 'Format Ruby files'
|
|
47
|
+
option :write, type: :boolean, default: true, desc: 'Write formatted output'
|
|
48
|
+
option :check, type: :boolean, desc: "Check if files are formatted (don't write)"
|
|
49
|
+
option :diff, type: :boolean, desc: 'Show diff of changes'
|
|
50
|
+
option :diff_format, type: :string, default: 'unified', desc: 'Diff format: unified, side_by_side, or color'
|
|
51
|
+
option :parallel, type: :boolean, default: true, desc: 'Process files in parallel'
|
|
52
|
+
option :jobs, type: :numeric, desc: 'Number of parallel jobs (default: CPU count)'
|
|
53
|
+
option :cache, type: :boolean, default: true, desc: 'Use cache to skip unchanged files'
|
|
54
|
+
option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
|
|
55
|
+
def format(*files)
|
|
56
|
+
config = load_config
|
|
57
|
+
files = files.empty? ? config.files_to_format : files.flatten
|
|
58
|
+
|
|
59
|
+
if files.empty?
|
|
60
|
+
say 'No files to format', :yellow
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Initialize cache
|
|
65
|
+
cache = if options[:cache]
|
|
66
|
+
cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
|
|
67
|
+
Cache.new(**cache_opts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Filter files using cache
|
|
71
|
+
if cache
|
|
72
|
+
original_count = files.size
|
|
73
|
+
files = files.select { |file| cache.needs_formatting?(file) }
|
|
74
|
+
skipped = original_count - files.size
|
|
75
|
+
say "Skipped #{skipped} unchanged file(s) (cache hit)", :blue if skipped.positive? && options[:verbose]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if files.empty?
|
|
79
|
+
say 'All files are already formatted', :green
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
say "Formatting #{files.size} file(s)...", :blue if options[:verbose]
|
|
84
|
+
|
|
85
|
+
results = if options[:parallel] && files.size > 1
|
|
86
|
+
format_files_parallel(files)
|
|
87
|
+
else
|
|
88
|
+
format_files_sequential(files)
|
|
89
|
+
end
|
|
90
|
+
handle_results(results, cache)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
desc 'check [FILES]', 'Check if files need formatting'
|
|
94
|
+
def check(*files)
|
|
95
|
+
invoke :format, files, check: true, write: false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
desc 'version', 'Show version'
|
|
99
|
+
def version
|
|
100
|
+
say "rfmt #{Rfmt::VERSION}"
|
|
101
|
+
say "Rust extension: #{Rfmt.rust_version}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
desc 'config', 'Show current configuration'
|
|
105
|
+
def config_cmd
|
|
106
|
+
config = load_config
|
|
107
|
+
require 'json'
|
|
108
|
+
say JSON.pretty_generate(config.config)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
desc 'cache SUBCOMMAND', 'Manage cache'
|
|
112
|
+
subcommand 'cache', CacheCommands
|
|
113
|
+
|
|
114
|
+
desc 'init', 'Initialize rfmt configuration'
|
|
115
|
+
option :force, type: :boolean, desc: 'Overwrite existing configuration'
|
|
116
|
+
option :path, type: :string, default: '.rfmt.yml', desc: 'Configuration file path'
|
|
117
|
+
def init
|
|
118
|
+
config_file = options[:path] || '.rfmt.yml'
|
|
119
|
+
|
|
120
|
+
# Use Rfmt::Config module for consistent behavior
|
|
121
|
+
result = Rfmt::Config.init(config_file, force: options[:force] || false)
|
|
122
|
+
|
|
123
|
+
if result
|
|
124
|
+
say "Created #{config_file}", :green
|
|
125
|
+
else
|
|
126
|
+
say "Configuration file already exists at #{config_file}. Use --force to overwrite.", :yellow
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def load_config
|
|
133
|
+
if options[:config]
|
|
134
|
+
Configuration.new(file: options[:config])
|
|
135
|
+
else
|
|
136
|
+
Configuration.discover
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def format_files_sequential(files)
|
|
141
|
+
files.map do |file|
|
|
142
|
+
format_single_file(file)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def format_files_parallel(files)
|
|
147
|
+
require 'parallel'
|
|
148
|
+
|
|
149
|
+
# Determine number of processes to use
|
|
150
|
+
process_count = options[:jobs] || Parallel.processor_count
|
|
151
|
+
|
|
152
|
+
say "Processing #{files.size} files with #{process_count} parallel jobs...", :blue if options[:verbose]
|
|
153
|
+
|
|
154
|
+
Parallel.map(files, in_processes: process_count) do |file|
|
|
155
|
+
format_single_file(file)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def format_single_file(file)
|
|
160
|
+
start_time = Time.now
|
|
161
|
+
source = File.read(file)
|
|
162
|
+
|
|
163
|
+
formatted = Rfmt.format(source)
|
|
164
|
+
changed = source != formatted
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
file: file,
|
|
168
|
+
changed: changed,
|
|
169
|
+
original: source,
|
|
170
|
+
formatted: formatted,
|
|
171
|
+
duration: Time.now - start_time,
|
|
172
|
+
error: nil
|
|
173
|
+
}
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
{
|
|
176
|
+
file: file,
|
|
177
|
+
error: e.message,
|
|
178
|
+
duration: Time.now - start_time
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_results(results, cache = nil)
|
|
183
|
+
failed_count = 0
|
|
184
|
+
changed_count = 0
|
|
185
|
+
error_count = 0
|
|
186
|
+
|
|
187
|
+
results.each do |result|
|
|
188
|
+
if result[:error]
|
|
189
|
+
say "Error in #{result[:file]}: #{result[:error]}", :red
|
|
190
|
+
error_count += 1
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if result[:changed]
|
|
195
|
+
changed_count += 1
|
|
196
|
+
|
|
197
|
+
if options[:check]
|
|
198
|
+
say "#{result[:file]} needs formatting", :yellow
|
|
199
|
+
failed_count += 1
|
|
200
|
+
show_diff(result[:file], result[:original], result[:formatted]) if options[:diff]
|
|
201
|
+
elsif options[:diff]
|
|
202
|
+
show_diff(result[:file], result[:original], result[:formatted])
|
|
203
|
+
elsif options[:write]
|
|
204
|
+
File.write(result[:file], result[:formatted])
|
|
205
|
+
say "Formatted #{result[:file]}", :green if options[:verbose]
|
|
206
|
+
|
|
207
|
+
# Update cache after successful write
|
|
208
|
+
cache&.mark_formatted(result[:file])
|
|
209
|
+
else
|
|
210
|
+
puts result[:formatted]
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
say "#{result[:file]} already formatted", :blue if options[:verbose]
|
|
214
|
+
|
|
215
|
+
# Update cache even if no changes (file was checked)
|
|
216
|
+
cache&.mark_formatted(result[:file])
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Save cache to disk
|
|
221
|
+
cache&.save
|
|
222
|
+
|
|
223
|
+
# Summary
|
|
224
|
+
say "\n#{results.size} file(s) processed", :blue if options[:verbose]
|
|
225
|
+
say "#{changed_count} file(s) changed", :yellow if changed_count.positive? && options[:verbose]
|
|
226
|
+
say "#{error_count} error(s)", :red if error_count.positive?
|
|
227
|
+
|
|
228
|
+
exit(1) if (options[:check] && failed_count.positive?) || error_count.positive?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def show_diff(file, original, formatted)
|
|
232
|
+
require 'diffy'
|
|
233
|
+
|
|
234
|
+
say "\n#{'=' * 80}", :blue
|
|
235
|
+
say "Diff for #{file}:", :yellow
|
|
236
|
+
say '=' * 80, :blue
|
|
237
|
+
|
|
238
|
+
case options[:diff_format]
|
|
239
|
+
when 'unified'
|
|
240
|
+
diff = Diffy::Diff.new(original, formatted, context: 3)
|
|
241
|
+
puts diff.to_s(:color)
|
|
242
|
+
when 'side_by_side'
|
|
243
|
+
diff = Diffy::Diff.new(original, formatted, context: 3)
|
|
244
|
+
# Side-by-side is not well supported in terminal, use unified with more context
|
|
245
|
+
puts diff.to_s(:color)
|
|
246
|
+
when 'color'
|
|
247
|
+
show_colored_line_diff(original, formatted)
|
|
248
|
+
else
|
|
249
|
+
diff = Diffy::Diff.new(original, formatted, context: 3)
|
|
250
|
+
puts diff.to_s(:color)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
say "#{'=' * 80}\n", :blue
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def show_colored_line_diff(original, formatted)
|
|
257
|
+
require 'diff/lcs'
|
|
258
|
+
|
|
259
|
+
original_lines = original.split("\n")
|
|
260
|
+
formatted_lines = formatted.split("\n")
|
|
261
|
+
|
|
262
|
+
diffs = Diff::LCS.sdiff(original_lines, formatted_lines)
|
|
263
|
+
|
|
264
|
+
diffs.each_with_index do |diff, idx|
|
|
265
|
+
line_num = idx + 1
|
|
266
|
+
case diff.action
|
|
267
|
+
when '-'
|
|
268
|
+
say "#{line_num}: - #{diff.old_element}", :red
|
|
269
|
+
when '+'
|
|
270
|
+
say "#{line_num}: + #{diff.new_element}", :green
|
|
271
|
+
when '='
|
|
272
|
+
say "#{line_num}: #{diff.old_element}", :white
|
|
273
|
+
when '!'
|
|
274
|
+
say "#{line_num}: - #{diff.old_element}", :red
|
|
275
|
+
say "#{line_num}: + #{diff.new_element}", :green
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Rfmt
|
|
6
|
+
# Configuration management for rfmt
|
|
7
|
+
class Configuration
|
|
8
|
+
class ConfigError < StandardError; end
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
'version' => '1.0',
|
|
12
|
+
'formatting' => {
|
|
13
|
+
'line_length' => 100,
|
|
14
|
+
'indent_width' => 2,
|
|
15
|
+
'indent_style' => 'spaces'
|
|
16
|
+
},
|
|
17
|
+
'include' => ['**/*.rb'],
|
|
18
|
+
'exclude' => ['vendor/**/*', 'tmp/**/*', 'node_modules/**/*']
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
CONFIG_FILES = ['.rfmt.yml', '.rfmt.yaml', 'rfmt.yml', 'rfmt.yaml'].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :config
|
|
24
|
+
|
|
25
|
+
def initialize(options = {})
|
|
26
|
+
@config = load_configuration(options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Discover configuration file in current directory
|
|
30
|
+
def self.discover
|
|
31
|
+
config_file = CONFIG_FILES.find { |file| File.exist?(file) }
|
|
32
|
+
config_file ? new(file: config_file) : new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get list of files to format based on include/exclude patterns
|
|
36
|
+
def files_to_format(base_path: '.')
|
|
37
|
+
include_patterns = @config['include']
|
|
38
|
+
exclude_patterns = @config['exclude']
|
|
39
|
+
|
|
40
|
+
included_files = include_patterns.flat_map { |pattern| Dir.glob(File.join(base_path, pattern)) }
|
|
41
|
+
excluded_files = exclude_patterns.flat_map { |pattern| Dir.glob(File.join(base_path, pattern)) }
|
|
42
|
+
|
|
43
|
+
(included_files - excluded_files).select { |f| File.file?(f) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get formatting configuration
|
|
47
|
+
def formatting_config
|
|
48
|
+
@config['formatting']
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def load_configuration(options)
|
|
54
|
+
config = deep_dup(DEFAULT_CONFIG)
|
|
55
|
+
|
|
56
|
+
# Load from file if specified
|
|
57
|
+
if (file = options[:file] || options['file'])
|
|
58
|
+
file_config = YAML.load_file(file)
|
|
59
|
+
config = deep_merge(config, file_config)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Override with options
|
|
63
|
+
options.delete(:file)
|
|
64
|
+
options.delete('file')
|
|
65
|
+
config = deep_merge(config, options) unless options.empty?
|
|
66
|
+
|
|
67
|
+
validate_config!(config)
|
|
68
|
+
config
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_config!(config)
|
|
72
|
+
line_length = config.dig('formatting', 'line_length')
|
|
73
|
+
raise ConfigError, 'line_length must be positive' if line_length && line_length <= 0
|
|
74
|
+
|
|
75
|
+
indent_width = config.dig('formatting', 'indent_width')
|
|
76
|
+
raise ConfigError, 'indent_width must be positive' if indent_width && indent_width <= 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def deep_merge(hash1, hash2)
|
|
80
|
+
hash1.merge(hash2) do |_key, old_val, new_val|
|
|
81
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
82
|
+
deep_merge(old_val, new_val)
|
|
83
|
+
else
|
|
84
|
+
new_val
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def deep_dup(hash)
|
|
90
|
+
hash.transform_values do |value|
|
|
91
|
+
value.is_a?(Hash) ? deep_dup(value) : value.dup
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'prism_node_extractor'
|
|
6
|
+
|
|
7
|
+
module Rfmt
|
|
8
|
+
# PrismBridge provides the Ruby-side integration with the Prism parser
|
|
9
|
+
# It parses Ruby source code and converts the AST to a JSON format
|
|
10
|
+
# that can be consumed by the Rust formatter
|
|
11
|
+
class PrismBridge
|
|
12
|
+
extend PrismNodeExtractor
|
|
13
|
+
|
|
14
|
+
class ParseError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Parse Ruby source code and return serialized AST
|
|
17
|
+
# @param source [String] Ruby source code to parse
|
|
18
|
+
# @return [String] JSON-serialized AST with comments
|
|
19
|
+
# @raise [ParseError] if parsing fails
|
|
20
|
+
def self.parse(source)
|
|
21
|
+
result = Prism.parse(source)
|
|
22
|
+
|
|
23
|
+
handle_parse_errors(result) if result.failure?
|
|
24
|
+
|
|
25
|
+
serialize_ast_with_comments(result)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Parse Ruby source code from a file
|
|
29
|
+
# @param file_path [String] Path to Ruby file
|
|
30
|
+
# @return [String] JSON-serialized AST
|
|
31
|
+
# @raise [ParseError] if parsing fails
|
|
32
|
+
# @raise [Errno::ENOENT] if file doesn't exist
|
|
33
|
+
def self.parse_file(file_path)
|
|
34
|
+
source = File.read(file_path)
|
|
35
|
+
parse(source)
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
raise ParseError, "File not found: #{file_path}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Handle parsing errors from Prism
|
|
41
|
+
def self.handle_parse_errors(result)
|
|
42
|
+
errors = result.errors.map do |error|
|
|
43
|
+
{
|
|
44
|
+
line: error.location.start_line,
|
|
45
|
+
column: error.location.start_column,
|
|
46
|
+
message: error.message
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
error_messages = errors.map do |err|
|
|
51
|
+
"#{err[:line]}:#{err[:column]}: #{err[:message]}"
|
|
52
|
+
end.join("\n")
|
|
53
|
+
|
|
54
|
+
raise ParseError, "Parse errors:\n#{error_messages}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Serialize the Prism AST to JSON
|
|
58
|
+
def self.serialize_ast(node)
|
|
59
|
+
JSON.generate(convert_node(node))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Serialize the Prism AST with comments to JSON
|
|
63
|
+
def self.serialize_ast_with_comments(result)
|
|
64
|
+
comments = result.comments.map do |comment|
|
|
65
|
+
{
|
|
66
|
+
comment_type: comment.class.name.split('::').last.downcase.gsub('comment', ''),
|
|
67
|
+
location: {
|
|
68
|
+
start_line: comment.location.start_line,
|
|
69
|
+
start_column: comment.location.start_column,
|
|
70
|
+
end_line: comment.location.end_line,
|
|
71
|
+
end_column: comment.location.end_column,
|
|
72
|
+
start_offset: comment.location.start_offset,
|
|
73
|
+
end_offset: comment.location.end_offset
|
|
74
|
+
},
|
|
75
|
+
text: comment.location.slice,
|
|
76
|
+
position: 'leading' # Default position, will be refined by Rust
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
JSON.generate({
|
|
81
|
+
ast: convert_node(result.value),
|
|
82
|
+
comments: comments
|
|
83
|
+
})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Convert a Prism node to our internal representation
|
|
87
|
+
def self.convert_node(node)
|
|
88
|
+
return nil if node.nil?
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
node_type: node_type_name(node),
|
|
92
|
+
location: extract_location(node),
|
|
93
|
+
children: extract_children(node),
|
|
94
|
+
metadata: extract_metadata(node),
|
|
95
|
+
comments: extract_comments(node),
|
|
96
|
+
formatting: extract_formatting(node)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get the node type name from Prism node
|
|
101
|
+
def self.node_type_name(node)
|
|
102
|
+
# Prism node class names are like "Prism::ProgramNode"
|
|
103
|
+
# We want just "program_node" in snake_case
|
|
104
|
+
node.class.name.split('::').last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
105
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Extract location information from node
|
|
109
|
+
def self.extract_location(node)
|
|
110
|
+
loc = node.location
|
|
111
|
+
{
|
|
112
|
+
start_line: loc.start_line,
|
|
113
|
+
start_column: loc.start_column,
|
|
114
|
+
end_line: loc.end_line,
|
|
115
|
+
end_column: loc.end_column,
|
|
116
|
+
start_offset: loc.start_offset,
|
|
117
|
+
end_offset: loc.end_offset
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Extract child nodes
|
|
122
|
+
def self.extract_children(node)
|
|
123
|
+
children = []
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
# Different node types have different child accessors
|
|
127
|
+
children = case node
|
|
128
|
+
when Prism::ProgramNode
|
|
129
|
+
node.statements ? node.statements.body : []
|
|
130
|
+
when Prism::StatementsNode
|
|
131
|
+
node.body || []
|
|
132
|
+
when Prism::ClassNode
|
|
133
|
+
[
|
|
134
|
+
node.constant_path,
|
|
135
|
+
node.superclass,
|
|
136
|
+
node.body
|
|
137
|
+
].compact
|
|
138
|
+
when Prism::ModuleNode
|
|
139
|
+
[
|
|
140
|
+
node.constant_path,
|
|
141
|
+
node.body
|
|
142
|
+
].compact
|
|
143
|
+
when Prism::DefNode
|
|
144
|
+
params = if node.parameters
|
|
145
|
+
node.parameters.child_nodes.compact
|
|
146
|
+
else
|
|
147
|
+
[]
|
|
148
|
+
end
|
|
149
|
+
params + [node.body].compact
|
|
150
|
+
when Prism::CallNode
|
|
151
|
+
result = []
|
|
152
|
+
result << node.receiver if node.receiver
|
|
153
|
+
result.concat(node.arguments.child_nodes.compact) if node.arguments
|
|
154
|
+
result << node.block if node.block
|
|
155
|
+
result
|
|
156
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
157
|
+
[
|
|
158
|
+
node.predicate,
|
|
159
|
+
node.statements,
|
|
160
|
+
node.consequent
|
|
161
|
+
].compact
|
|
162
|
+
when Prism::ArrayNode
|
|
163
|
+
node.elements || []
|
|
164
|
+
when Prism::HashNode
|
|
165
|
+
node.elements || []
|
|
166
|
+
when Prism::BlockNode
|
|
167
|
+
params = if node.parameters
|
|
168
|
+
node.parameters.child_nodes.compact
|
|
169
|
+
else
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
172
|
+
params + [node.body].compact
|
|
173
|
+
else
|
|
174
|
+
# For unknown types, try to get child nodes if they exist
|
|
175
|
+
[]
|
|
176
|
+
end
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
# Log warning in debug mode but continue processing
|
|
179
|
+
warn "Warning: Failed to extract children from #{node.class}: #{e.message}" if $DEBUG
|
|
180
|
+
children = []
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
children.compact.map { |child| convert_node(child) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Extract metadata specific to node type
|
|
187
|
+
def self.extract_metadata(node)
|
|
188
|
+
metadata = {}
|
|
189
|
+
|
|
190
|
+
case node
|
|
191
|
+
when Prism::ClassNode
|
|
192
|
+
if (name = extract_node_name(node))
|
|
193
|
+
metadata['name'] = name
|
|
194
|
+
end
|
|
195
|
+
if (superclass = extract_superclass_name(node))
|
|
196
|
+
metadata['superclass'] = superclass
|
|
197
|
+
end
|
|
198
|
+
when Prism::ModuleNode
|
|
199
|
+
if (name = extract_node_name(node))
|
|
200
|
+
metadata['name'] = name
|
|
201
|
+
end
|
|
202
|
+
when Prism::DefNode
|
|
203
|
+
if (name = extract_node_name(node))
|
|
204
|
+
metadata['name'] = name
|
|
205
|
+
end
|
|
206
|
+
metadata['parameters_count'] = extract_parameter_count(node).to_s
|
|
207
|
+
when Prism::CallNode
|
|
208
|
+
if (name = extract_node_name(node))
|
|
209
|
+
metadata['name'] = name
|
|
210
|
+
end
|
|
211
|
+
if (message = extract_message_name(node))
|
|
212
|
+
metadata['message'] = message
|
|
213
|
+
end
|
|
214
|
+
when Prism::StringNode
|
|
215
|
+
if (content = extract_string_content(node))
|
|
216
|
+
metadata['content'] = content
|
|
217
|
+
end
|
|
218
|
+
when Prism::IntegerNode
|
|
219
|
+
if (value = extract_literal_value(node))
|
|
220
|
+
metadata['value'] = value
|
|
221
|
+
end
|
|
222
|
+
when Prism::FloatNode
|
|
223
|
+
if (value = extract_literal_value(node))
|
|
224
|
+
metadata['value'] = value
|
|
225
|
+
end
|
|
226
|
+
when Prism::SymbolNode
|
|
227
|
+
if (value = extract_literal_value(node))
|
|
228
|
+
metadata['value'] = value
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
metadata
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Extract comments associated with the node
|
|
236
|
+
def self.extract_comments(_node)
|
|
237
|
+
# Prism attaches comments to the parse result, not individual nodes
|
|
238
|
+
# For Phase 1, we'll return empty array and implement in Phase 2
|
|
239
|
+
[]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Extract formatting information
|
|
243
|
+
def self.extract_formatting(node)
|
|
244
|
+
loc = node.location
|
|
245
|
+
{
|
|
246
|
+
indent_level: 0, # Will be calculated during formatting
|
|
247
|
+
needs_blank_line_before: false,
|
|
248
|
+
needs_blank_line_after: false,
|
|
249
|
+
preserve_newlines: false,
|
|
250
|
+
multiline: loc.start_line != loc.end_line,
|
|
251
|
+
original_formatting: nil # Can store original text if needed
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rfmt
|
|
4
|
+
# PrismNodeExtractor provides safe methods to extract information from Prism nodes
|
|
5
|
+
# This module encapsulates the logic for accessing Prism node properties,
|
|
6
|
+
# making the code resilient to Prism API changes
|
|
7
|
+
module PrismNodeExtractor
|
|
8
|
+
# Extract the name from a node
|
|
9
|
+
# @param node [Prism::Node] The node to extract name from
|
|
10
|
+
# @return [String, nil] The node name or nil if not available
|
|
11
|
+
def extract_node_name(node)
|
|
12
|
+
return nil unless node.respond_to?(:name)
|
|
13
|
+
|
|
14
|
+
node.name.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Extract superclass name from a class node
|
|
18
|
+
# @param class_node [Prism::ClassNode] The class node
|
|
19
|
+
# @return [String, nil] The superclass name or nil if not available
|
|
20
|
+
def extract_superclass_name(class_node)
|
|
21
|
+
return nil unless class_node.respond_to?(:superclass)
|
|
22
|
+
|
|
23
|
+
sc = class_node.superclass
|
|
24
|
+
return nil if sc.nil?
|
|
25
|
+
|
|
26
|
+
case sc
|
|
27
|
+
when Prism::ConstantReadNode
|
|
28
|
+
sc.name.to_s
|
|
29
|
+
when Prism::ConstantPathNode
|
|
30
|
+
# Try full_name first, fall back to name
|
|
31
|
+
if sc.respond_to?(:full_name)
|
|
32
|
+
sc.full_name.to_s
|
|
33
|
+
elsif sc.respond_to?(:name)
|
|
34
|
+
sc.name.to_s
|
|
35
|
+
else
|
|
36
|
+
sc.to_s
|
|
37
|
+
end
|
|
38
|
+
else
|
|
39
|
+
sc.to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Extract parameter count from a method definition node
|
|
44
|
+
# @param def_node [Prism::DefNode] The method definition node
|
|
45
|
+
# @return [Integer] The number of parameters (0 if none)
|
|
46
|
+
def extract_parameter_count(def_node)
|
|
47
|
+
return 0 unless def_node.respond_to?(:parameters)
|
|
48
|
+
return 0 if def_node.parameters.nil?
|
|
49
|
+
return 0 unless def_node.parameters.respond_to?(:child_nodes)
|
|
50
|
+
|
|
51
|
+
def_node.parameters.child_nodes.compact.length
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Extract message name from a call node
|
|
55
|
+
# @param call_node [Prism::CallNode] The call node
|
|
56
|
+
# @return [String, nil] The message name or nil if not available
|
|
57
|
+
def extract_message_name(call_node)
|
|
58
|
+
return nil unless call_node.respond_to?(:message)
|
|
59
|
+
|
|
60
|
+
call_node.message.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Extract content from a string node
|
|
64
|
+
# @param string_node [Prism::StringNode] The string node
|
|
65
|
+
# @return [String, nil] The string content or nil if not available
|
|
66
|
+
def extract_string_content(string_node)
|
|
67
|
+
return nil unless string_node.respond_to?(:content)
|
|
68
|
+
|
|
69
|
+
string_node.content
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Extract value from a literal node (Integer, Float, Symbol)
|
|
73
|
+
# @param node [Prism::Node] The literal node
|
|
74
|
+
# @return [String, nil] The value as string or nil if not available
|
|
75
|
+
def extract_literal_value(node)
|
|
76
|
+
return nil unless node.respond_to?(:value)
|
|
77
|
+
|
|
78
|
+
node.value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|