rfmt 1.6.3 → 2.0.0.beta1

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. data/CHANGELOG.md +38 -0
  3. data/Cargo.lock +90 -1225
  4. data/Cargo.toml +6 -0
  5. data/README.md +53 -21
  6. data/exe/rfmt-lsp +8 -0
  7. data/exe/rfmt_fast +12 -0
  8. data/ext/rfmt/Cargo.toml +9 -28
  9. data/ext/rfmt/src/ast/mod.rs +2 -0
  10. data/ext/rfmt/src/config/mod.rs +267 -30
  11. data/ext/rfmt/src/error/mod.rs +14 -3
  12. data/ext/rfmt/src/format/formatter.rs +22 -11
  13. data/ext/rfmt/src/format/registry.rs +8 -0
  14. data/ext/rfmt/src/format/rule.rs +208 -136
  15. data/ext/rfmt/src/format/rules/call.rs +14 -22
  16. data/ext/rfmt/src/format/rules/fallback.rs +4 -11
  17. data/ext/rfmt/src/format/rules/if_unless.rs +25 -3
  18. data/ext/rfmt/src/format/rules/loops.rs +3 -1
  19. data/ext/rfmt/src/format/rules/variable_write.rs +16 -9
  20. data/ext/rfmt/src/lib.rs +45 -12
  21. data/ext/rfmt/src/parser/mod.rs +2 -0
  22. data/ext/rfmt/src/parser/native_adapter.rs +2735 -0
  23. data/ext/rfmt/src/parser/prism_adapter.rs +5 -0
  24. data/ext/rfmt/src/validation.rs +69 -0
  25. data/ext/rfmt/tests/fixtures/parity/comments_mixed.rb +9 -0
  26. data/ext/rfmt/tests/fixtures/parity/constructs.rb +50 -0
  27. data/ext/rfmt/tests/fixtures/parity/embdoc.rb +12 -0
  28. data/ext/rfmt/tests/fixtures/parity/heredoc_assign.rb +15 -0
  29. data/ext/rfmt/tests/fixtures/parity/heredoc_call_args.rb +17 -0
  30. data/ext/rfmt/tests/fixtures/parity/metadata_classes.rb +30 -0
  31. data/ext/rfmt/tests/fixtures/parity/metadata_conditionals.rb +14 -0
  32. data/ext/rfmt/tests/fixtures/parity/metadata_defs.rb +33 -0
  33. data/ext/rfmt/tests/fixtures/parity/multibyte.rb +14 -0
  34. data/ext/rfmt/tests/fixtures/parity/numeric.rb +6 -0
  35. data/ext/rfmt/tests/fixtures/parity/plain.rb +31 -0
  36. data/ext/rfmt/tests/native_parity.rs +245 -0
  37. data/ext/rfmt/tests/ruby_prism_smoke.rs +66 -0
  38. data/lib/rfmt/cli.rb +37 -7
  39. data/lib/rfmt/configuration.rb +2 -20
  40. data/lib/rfmt/lsp/document_store.rb +27 -0
  41. data/lib/rfmt/lsp/formatter.rb +37 -0
  42. data/lib/rfmt/lsp/message_io.rb +55 -0
  43. data/lib/rfmt/lsp/server.rb +168 -0
  44. data/lib/rfmt/lsp/uri.rb +35 -0
  45. data/lib/rfmt/lsp/workspace.rb +62 -0
  46. data/lib/rfmt/lsp.rb +8 -0
  47. data/lib/rfmt/version.rb +1 -1
  48. data/lib/rfmt.rb +47 -19
  49. metadata +55 -4
  50. data/lib/rfmt/prism_bridge.rb +0 -529
  51. data/lib/rfmt/prism_node_extractor.rb +0 -115
data/lib/rfmt/cli.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'thor'
4
5
 
5
6
  # Check for verbose flag before loading rfmt to set debug mode early
@@ -44,11 +45,15 @@ module Rfmt
44
45
 
45
46
  # Command Line Interface for rfmt
46
47
  class CLI < Thor
48
+ def self.exit_on_failure?
49
+ true
50
+ end
51
+
47
52
  # Constants
48
53
  PROGRESS_THRESHOLD = 20 # Show progress for file counts >= this
49
54
  PROGRESS_INTERVAL = 10 # Update progress every N files
50
55
 
51
- class_option :config, type: :string, desc: 'Path to configuration file'
56
+ class_option :config, type: :string, desc: 'Path to configuration file (formatting options and file selection)'
52
57
  class_option :verbose, type: :boolean, desc: 'Verbose output'
53
58
 
54
59
  default_command :format
@@ -120,11 +125,11 @@ module Rfmt
120
125
  say "Rust extension: #{Rfmt.rust_version}"
121
126
  end
122
127
 
123
- desc 'config', 'Show current configuration'
128
+ desc 'config', 'Show the effective configuration the formatter will use'
124
129
  def config_cmd
125
- config = load_config
126
- require 'json'
127
- say JSON.pretty_generate(config.config)
130
+ say Rfmt.resolved_config(config_path: options[:config])
131
+ rescue Rfmt::Error => e
132
+ raise Thor::Error, e.message
128
133
  end
129
134
 
130
135
  desc 'cache SUBCOMMAND', 'Manage cache'
@@ -183,12 +188,24 @@ module Rfmt
183
188
 
184
189
  def load_config
185
190
  if options[:config]
191
+ validate_explicit_config!(options[:config])
186
192
  Configuration.new(file: options[:config])
187
193
  else
188
194
  Configuration.discover
189
195
  end
190
196
  end
191
197
 
198
+ # Fail fast once instead of repeating the same load error per file
199
+ def validate_explicit_config!(path)
200
+ raise Thor::Error, "Configuration file not found: #{path}" unless File.exist?(path)
201
+
202
+ begin
203
+ Rfmt.resolved_config(config_path: path)
204
+ rescue Rfmt::Error => e
205
+ raise Thor::Error, e.message
206
+ end
207
+ end
208
+
192
209
  def initialize_cache_if_enabled
193
210
  return nil unless options[:cache]
194
211
 
@@ -255,7 +272,7 @@ module Rfmt
255
272
  start_time = Time.now
256
273
  source = File.read(file)
257
274
 
258
- formatted = Rfmt.format(source)
275
+ formatted = Rfmt.format(source, config_path: options[:config])
259
276
  changed = source != formatted
260
277
 
261
278
  {
@@ -325,11 +342,24 @@ module Rfmt
325
342
  end
326
343
 
327
344
  def write_formatted_file(result, cache)
328
- File.write(result[:file], result[:formatted])
345
+ atomic_write(result[:file], result[:formatted])
329
346
  say "✓ Formatted #{result[:file]}", :green unless options[:quiet]
330
347
  cache&.mark_formatted(result[:file])
331
348
  end
332
349
 
350
+ # Temp file must live in the same directory: rename across filesystems is not atomic.
351
+ def atomic_write(path, content)
352
+ # rename would replace a symlink itself; write to its target instead
353
+ path = File.realpath(path) if File.symlink?(path)
354
+ tmp_path = "#{path}.rfmt-#{Process.pid}.tmp"
355
+ mode = File.stat(path).mode
356
+ File.write(tmp_path, content)
357
+ File.chmod(mode, tmp_path)
358
+ File.rename(tmp_path, path)
359
+ ensure
360
+ FileUtils.rm_f(tmp_path)
361
+ end
362
+
333
363
  def display_summary(stats, total_files)
334
364
  @last_stats = stats # Store for verbose details
335
365
  unchanged_count = total_files - stats[:changed] - stats[:errors]
@@ -3,17 +3,13 @@
3
3
  require 'yaml'
4
4
 
5
5
  module Rfmt
6
- # Configuration management for rfmt
6
+ # File selection and cache concerns only: formatter settings (indent_width
7
+ # etc.) live in the Rust Config, reached via Rfmt.format(config_path:).
7
8
  class Configuration
8
9
  class ConfigError < StandardError; end
9
10
 
10
11
  DEFAULT_CONFIG = {
11
12
  'version' => '1.0',
12
- 'formatting' => {
13
- 'line_length' => 100,
14
- 'indent_width' => 2,
15
- 'indent_style' => 'spaces'
16
- },
17
13
  'include' => ['**/*.rb'],
18
14
  'exclude' => ['vendor/**/*', 'tmp/**/*', 'node_modules/**/*']
19
15
  }.freeze
@@ -43,11 +39,6 @@ module Rfmt
43
39
  (included_files - excluded_files).select { |f| File.file?(f) }
44
40
  end
45
41
 
46
- # Get formatting configuration
47
- def formatting_config
48
- @config['formatting']
49
- end
50
-
51
42
  private
52
43
 
53
44
  def load_configuration(options)
@@ -64,18 +55,9 @@ module Rfmt
64
55
  options.delete('file')
65
56
  config = deep_merge(config, options) unless options.empty?
66
57
 
67
- validate_config!(config)
68
58
  config
69
59
  end
70
60
 
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
61
  def deep_merge(hash1, hash2)
80
62
  hash1.merge(hash2) do |_key, old_val, new_val|
81
63
  if old_val.is_a?(Hash) && new_val.is_a?(Hash)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rfmt
4
+ module LSP
5
+ class DocumentStore
6
+ def initialize
7
+ @documents = {}
8
+ end
9
+
10
+ def open(uri, text)
11
+ @documents[uri] = text
12
+ end
13
+
14
+ def change(uri, text)
15
+ @documents[uri] = text
16
+ end
17
+
18
+ def close(uri)
19
+ @documents.delete(uri)
20
+ end
21
+
22
+ def source_for(uri)
23
+ @documents[uri]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rfmt'
4
+
5
+ module Rfmt
6
+ module LSP
7
+ class Formatter
8
+ def self.format_edits(source)
9
+ formatted = source.empty? ? "\n" : Rfmt.format(source)
10
+ return [] if formatted == source
11
+
12
+ [
13
+ {
14
+ range: full_document_range(source),
15
+ newText: formatted
16
+ }
17
+ ]
18
+ rescue Rfmt::Error
19
+ []
20
+ end
21
+
22
+ def self.full_document_range(source)
23
+ return { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } if source.empty?
24
+
25
+ lines = source.split("\n", -1)
26
+
27
+ {
28
+ start: { line: 0, character: 0 },
29
+ end: {
30
+ line: lines.length - 1,
31
+ character: lines.last.length
32
+ }
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Rfmt
6
+ module LSP
7
+ # Reads and writes LSP JSON-RPC messages over an IO pair.
8
+ class MessageIO
9
+ HEADER_SEPARATOR = "\r\n\r\n"
10
+ CONTENT_LENGTH = /\AContent-Length:\s*(\d+)\z/i
11
+
12
+ def initialize(input: $stdin, output: $stdout)
13
+ @input = input
14
+ @output = output
15
+ end
16
+
17
+ def read_message
18
+ headers = read_headers
19
+ return nil if headers.nil?
20
+
21
+ content_length = headers.fetch('content-length').to_i
22
+ JSON.parse(@input.read(content_length))
23
+ end
24
+
25
+ def write_message(payload)
26
+ body = JSON.generate(payload)
27
+ @output.write("Content-Length: #{body.bytesize}#{HEADER_SEPARATOR}#{body}")
28
+ @output.flush if @output.respond_to?(:flush)
29
+ end
30
+
31
+ private
32
+
33
+ def read_headers
34
+ headers = {}
35
+
36
+ loop do
37
+ line = @input.gets
38
+ return nil if line.nil?
39
+
40
+ line = line.chomp
41
+ line = line.delete_suffix("\r")
42
+ break if line.empty?
43
+
44
+ match = CONTENT_LENGTH.match(line)
45
+ headers['content-length'] = match[1] if match
46
+ end
47
+
48
+ headers.fetch('content-length')
49
+ headers
50
+ rescue KeyError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'language_server/protocol'
4
+ require 'rfmt/version'
5
+
6
+ require_relative 'document_store'
7
+ require_relative 'formatter'
8
+ require_relative 'message_io'
9
+ require_relative 'uri'
10
+ require_relative 'workspace'
11
+
12
+ module Rfmt
13
+ module LSP
14
+ class Server
15
+ TEXT_DOCUMENT_SYNC_FULL = 1
16
+ METHOD_NOT_FOUND = -32_601
17
+ INTERNAL_ERROR = -32_603
18
+
19
+ def initialize(input: $stdin, output: $stdout)
20
+ @io = MessageIO.new(input: input, output: output)
21
+ @documents = DocumentStore.new
22
+ @workspace = Workspace.new
23
+ @shutdown_requested = false
24
+ end
25
+
26
+ def run
27
+ while (message = @io.read_message)
28
+ return @shutdown_requested ? 0 : 1 if handle_message(message) == :exit
29
+ end
30
+
31
+ 0
32
+ end
33
+
34
+ def handle_message(message)
35
+ method = message['method']
36
+ id = message['id']
37
+ params = message['params'] || {}
38
+
39
+ dispatch_message(method, id, params)
40
+ rescue StandardError => e
41
+ respond_error(id, INTERNAL_ERROR, e.message) if id
42
+ end
43
+
44
+ private
45
+
46
+ def dispatch_message(method, id, params)
47
+ if request_handler?(method)
48
+ dispatch_request(method, id, params)
49
+ else
50
+ dispatch_notification(method, params) || respond_method_not_found(id, method)
51
+ end
52
+ end
53
+
54
+ def request_handler?(method)
55
+ %w[initialize textDocument/formatting shutdown].include?(method)
56
+ end
57
+
58
+ def dispatch_request(method, id, params)
59
+ case method
60
+ when 'initialize'
61
+ handle_initialize(id, params)
62
+ when 'textDocument/formatting'
63
+ handle_formatting(id, params)
64
+ when 'shutdown'
65
+ handle_shutdown(id)
66
+ end
67
+ end
68
+
69
+ def dispatch_notification(method, params)
70
+ case method
71
+ when 'initialized'
72
+ true
73
+ when 'textDocument/didOpen'
74
+ handle_did_open(params)
75
+ true
76
+ when 'textDocument/didChange'
77
+ handle_did_change(params)
78
+ true
79
+ when 'textDocument/didClose'
80
+ handle_did_close(params)
81
+ true
82
+ when 'exit'
83
+ :exit
84
+ end
85
+ end
86
+
87
+ def respond_method_not_found(id, method)
88
+ respond_error(id, METHOD_NOT_FOUND, "Method not found: #{method}") if id
89
+ end
90
+
91
+ def handle_initialize(id, params)
92
+ @workspace.configure(params)
93
+
94
+ respond(id, {
95
+ capabilities: {
96
+ documentFormattingProvider: true,
97
+ textDocumentSync: TEXT_DOCUMENT_SYNC_FULL
98
+ },
99
+ serverInfo: {
100
+ name: 'rfmt',
101
+ version: Rfmt::VERSION
102
+ }
103
+ })
104
+ end
105
+
106
+ def handle_did_open(params)
107
+ text_document = params.fetch('textDocument')
108
+ @documents.open(text_document.fetch('uri'), text_document.fetch('text'))
109
+ end
110
+
111
+ def handle_did_change(params)
112
+ uri = params.fetch('textDocument').fetch('uri')
113
+ change = Array(params['contentChanges']).last
114
+ return unless change&.key?('text')
115
+
116
+ @documents.change(uri, change.fetch('text'))
117
+ end
118
+
119
+ def handle_did_close(params)
120
+ uri = params.fetch('textDocument').fetch('uri')
121
+ @documents.close(uri)
122
+ end
123
+
124
+ def handle_formatting(id, params)
125
+ uri = params.fetch('textDocument').fetch('uri')
126
+ source = @documents.source_for(uri) || read_file_source(uri)
127
+ edits = if source
128
+ @workspace.with_root_for(uri) { Formatter.format_edits(source) }
129
+ else
130
+ []
131
+ end
132
+
133
+ respond(id, edits)
134
+ end
135
+
136
+ def handle_shutdown(id)
137
+ @shutdown_requested = true
138
+ respond(id, nil)
139
+ end
140
+
141
+ def read_file_source(uri)
142
+ path = URI.file_uri_to_path(uri)
143
+ return nil unless path && File.file?(path)
144
+
145
+ File.read(path)
146
+ end
147
+
148
+ def respond(id, result)
149
+ @io.write_message({
150
+ jsonrpc: '2.0',
151
+ id: id,
152
+ result: result
153
+ })
154
+ end
155
+
156
+ def respond_error(id, code, message)
157
+ @io.write_message({
158
+ jsonrpc: '2.0',
159
+ id: id,
160
+ error: {
161
+ code: code,
162
+ message: message
163
+ }
164
+ })
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Rfmt
6
+ module LSP
7
+ module URI
8
+ module_function
9
+
10
+ def file_uri_to_path(uri)
11
+ parsed = ::URI.parse(uri)
12
+ return nil unless parsed.scheme == 'file'
13
+
14
+ percent_decode(parsed.path)
15
+ rescue ::URI::InvalidURIError
16
+ nil
17
+ end
18
+
19
+ def path_to_file_uri(path)
20
+ "file://#{percent_encode(File.expand_path(path))}"
21
+ end
22
+
23
+ def percent_decode(value)
24
+ value.gsub(/%[0-9A-Fa-f]{2}/) { |match| [match[1..].to_i(16)].pack('C') }
25
+ end
26
+
27
+ def percent_encode(value)
28
+ value.bytes.map do |byte|
29
+ char = byte.chr
30
+ char.match?(%r{[A-Za-z0-9._~/-]}) ? char : format('%%%02X', byte)
31
+ end.join
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ require_relative 'uri'
6
+
7
+ module Rfmt
8
+ module LSP
9
+ class Workspace
10
+ def initialize
11
+ @roots = []
12
+ end
13
+
14
+ def configure(params)
15
+ @roots = workspace_folder_roots(params)
16
+ root_uri = params['rootUri']
17
+ @roots << URI.file_uri_to_path(root_uri) if root_uri
18
+ @roots = @roots.compact.map { |root| File.expand_path(root) }.uniq
19
+ end
20
+
21
+ def root_for(uri)
22
+ path = URI.file_uri_to_path(uri)
23
+ return existing_root(@roots.first) unless path
24
+
25
+ matching_root = @roots
26
+ .select { |root| path_inside?(path, root) }
27
+ .max_by(&:length)
28
+ return existing_root(matching_root) if matching_root
29
+
30
+ existing_root(File.dirname(path))
31
+ end
32
+
33
+ def with_root_for(uri, &block)
34
+ root = root_for(uri)
35
+ return block.call unless root
36
+
37
+ Dir.chdir(root, &block)
38
+ end
39
+
40
+ private
41
+
42
+ def workspace_folder_roots(params)
43
+ Array(params['workspaceFolders']).filter_map do |folder|
44
+ URI.file_uri_to_path(folder['uri'])
45
+ end
46
+ end
47
+
48
+ def path_inside?(path, root)
49
+ relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
50
+ relative == '.' || !relative.start_with?('..')
51
+ rescue ArgumentError
52
+ false
53
+ end
54
+
55
+ def existing_root(root)
56
+ return nil unless root && File.directory?(root)
57
+
58
+ root
59
+ end
60
+ end
61
+ end
62
+ end
data/lib/rfmt/lsp.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lsp/document_store'
4
+ require_relative 'lsp/formatter'
5
+ require_relative 'lsp/message_io'
6
+ require_relative 'lsp/server'
7
+ require_relative 'lsp/uri'
8
+ require_relative 'lsp/workspace'
data/lib/rfmt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rfmt
4
- VERSION = '1.6.3'
4
+ VERSION = '2.0.0.beta1'
5
5
  end
data/lib/rfmt.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative 'rfmt/version'
4
4
  require_relative 'rfmt/native_extension_loader'
5
- require_relative 'rfmt/prism_bridge'
6
5
 
7
6
  # Load native extension with version-aware loader
8
7
  Rfmt::NativeExtensionLoader.load_extension
@@ -14,25 +13,43 @@ module Rfmt
14
13
  # AST validation errors
15
14
  class ValidationError < RfmtError; end
16
15
 
16
+ # Rust reports errors as plain StandardError with a [Rfmt::<kind>] prefix;
17
+ # these two kinds map onto the public exception classes.
18
+ NATIVE_PARSE_ERROR_PREFIX = '[Rfmt::ParseError] '
19
+ NATIVE_VALIDATION_ERROR_PREFIX = '[Rfmt::ValidationError] '
20
+ NATIVE_CONFIG_ERROR_PREFIX = '[Rfmt::ConfigError] '
21
+ private_constant :NATIVE_PARSE_ERROR_PREFIX, :NATIVE_VALIDATION_ERROR_PREFIX,
22
+ :NATIVE_CONFIG_ERROR_PREFIX
23
+
17
24
  # Format Ruby source code
25
+ # Parsing, config resolution, and output validation all happen natively in Rust
18
26
  # @param source [String] Ruby source code to format
27
+ # @param config_path [String, nil] Explicit config file path; nil discovers
28
+ # rfmt.yml/.rfmt.yml from the current directory upward (cached per process)
19
29
  # @return [String] Formatted Ruby code
20
- def self.format(source)
21
- # Step 1: Parse with Prism (Ruby side)
22
- prism_json = PrismBridge.parse(source)
23
-
24
- # Step 2: Format in Rust
25
- # Pass both source and AST to enable source extraction fallback
26
- format_code(source, prism_json)
27
- rescue PrismBridge::ParseError => e
28
- # Re-raise with more context
29
- raise Error, "Failed to parse Ruby code: #{e.message}"
30
- rescue RfmtError
31
- # Rust side errors are re-raised as-is to preserve error details
32
- raise
30
+ def self.format(source, config_path: nil)
31
+ if config_path
32
+ format_code_with_config(source, config_path.to_s)
33
+ else
34
+ format_code(source)
35
+ end
33
36
  rescue StandardError => e
34
- raise Error, "Unexpected error during formatting: #{e.class}: #{e.message}"
37
+ raise wrap_native_error(e)
38
+ end
39
+
40
+ def self.wrap_native_error(error)
41
+ message = error.message
42
+ if message.start_with?(NATIVE_PARSE_ERROR_PREFIX)
43
+ Error.new("Failed to parse Ruby code: #{message.delete_prefix(NATIVE_PARSE_ERROR_PREFIX)}")
44
+ elsif message.start_with?(NATIVE_VALIDATION_ERROR_PREFIX)
45
+ ValidationError.new(message.delete_prefix(NATIVE_VALIDATION_ERROR_PREFIX))
46
+ elsif message.start_with?(NATIVE_CONFIG_ERROR_PREFIX)
47
+ Error.new("Configuration error: #{message.delete_prefix(NATIVE_CONFIG_ERROR_PREFIX)}")
48
+ else
49
+ Error.new("Unexpected error during formatting: #{error.class}: #{message}")
50
+ end
35
51
  end
52
+ private_class_method :wrap_native_error
36
53
 
37
54
  # Format a Ruby file
38
55
  # @param path [String] Path to Ruby file
@@ -44,6 +61,15 @@ module Rfmt
44
61
  raise Error, "File not found: #{path}"
45
62
  end
46
63
 
64
+ # Effective configuration as the Rust formatter resolves it
65
+ # @param config_path [String, nil] Explicit config file path; nil discovers
66
+ # @return [String] YAML dump of the resolved configuration
67
+ def self.resolved_config(config_path: nil)
68
+ resolved_config_yaml(config_path&.to_s)
69
+ rescue StandardError => e
70
+ raise wrap_native_error(e)
71
+ end
72
+
47
73
  # Get version information
48
74
  # @return [String] Version string including Ruby and Rust versions
49
75
  def self.version_info
@@ -54,8 +80,9 @@ module Rfmt
54
80
  # @param source [String] Ruby source code
55
81
  # @return [String] AST representation
56
82
  def self.parse(source)
57
- prism_json = PrismBridge.parse(source)
58
- parse_to_json(prism_json)
83
+ parse_to_json(source)
84
+ rescue StandardError => e
85
+ raise wrap_native_error(e)
59
86
  end
60
87
 
61
88
  # Configuration management
@@ -114,7 +141,8 @@ module Rfmt
114
141
  current_dir = Dir.pwd
115
142
 
116
143
  loop do
117
- ['.rfmt.yml', '.rfmt.yaml', 'rfmt.yml', 'rfmt.yaml'].each do |filename|
144
+ # Same search order as the Rust side (config/mod.rs CONFIG_FILE_NAMES)
145
+ ['rfmt.yml', 'rfmt.yaml', '.rfmt.yml', '.rfmt.yaml'].each do |filename|
118
146
  config_path = File.join(current_dir, filename)
119
147
  return config_path if File.exist?(config_path)
120
148
  end
@@ -132,7 +160,7 @@ module Rfmt
132
160
  nil
133
161
  end
134
162
  if home_dir
135
- ['.rfmt.yml', '.rfmt.yaml', 'rfmt.yml', 'rfmt.yaml'].each do |filename|
163
+ ['rfmt.yml', 'rfmt.yaml', '.rfmt.yml', '.rfmt.yaml'].each do |filename|
136
164
  config_path = File.join(home_dir, filename)
137
165
  return config_path if File.exist?(config_path)
138
166
  end