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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/Cargo.lock +90 -1225
- data/Cargo.toml +6 -0
- data/README.md +53 -21
- data/exe/rfmt-lsp +8 -0
- data/exe/rfmt_fast +12 -0
- data/ext/rfmt/Cargo.toml +9 -28
- data/ext/rfmt/src/ast/mod.rs +2 -0
- data/ext/rfmt/src/config/mod.rs +267 -30
- data/ext/rfmt/src/error/mod.rs +14 -3
- data/ext/rfmt/src/format/formatter.rs +22 -11
- data/ext/rfmt/src/format/registry.rs +8 -0
- data/ext/rfmt/src/format/rule.rs +208 -136
- data/ext/rfmt/src/format/rules/call.rs +14 -22
- data/ext/rfmt/src/format/rules/fallback.rs +4 -11
- data/ext/rfmt/src/format/rules/if_unless.rs +25 -3
- data/ext/rfmt/src/format/rules/loops.rs +3 -1
- data/ext/rfmt/src/format/rules/variable_write.rs +16 -9
- data/ext/rfmt/src/lib.rs +45 -12
- data/ext/rfmt/src/parser/mod.rs +2 -0
- data/ext/rfmt/src/parser/native_adapter.rs +2735 -0
- data/ext/rfmt/src/parser/prism_adapter.rs +5 -0
- data/ext/rfmt/src/validation.rs +69 -0
- data/ext/rfmt/tests/fixtures/parity/comments_mixed.rb +9 -0
- data/ext/rfmt/tests/fixtures/parity/constructs.rb +50 -0
- data/ext/rfmt/tests/fixtures/parity/embdoc.rb +12 -0
- data/ext/rfmt/tests/fixtures/parity/heredoc_assign.rb +15 -0
- data/ext/rfmt/tests/fixtures/parity/heredoc_call_args.rb +17 -0
- data/ext/rfmt/tests/fixtures/parity/metadata_classes.rb +30 -0
- data/ext/rfmt/tests/fixtures/parity/metadata_conditionals.rb +14 -0
- data/ext/rfmt/tests/fixtures/parity/metadata_defs.rb +33 -0
- data/ext/rfmt/tests/fixtures/parity/multibyte.rb +14 -0
- data/ext/rfmt/tests/fixtures/parity/numeric.rb +6 -0
- data/ext/rfmt/tests/fixtures/parity/plain.rb +31 -0
- data/ext/rfmt/tests/native_parity.rs +245 -0
- data/ext/rfmt/tests/ruby_prism_smoke.rs +66 -0
- data/lib/rfmt/cli.rb +37 -7
- data/lib/rfmt/configuration.rb +2 -20
- data/lib/rfmt/lsp/document_store.rb +27 -0
- data/lib/rfmt/lsp/formatter.rb +37 -0
- data/lib/rfmt/lsp/message_io.rb +55 -0
- data/lib/rfmt/lsp/server.rb +168 -0
- data/lib/rfmt/lsp/uri.rb +35 -0
- data/lib/rfmt/lsp/workspace.rb +62 -0
- data/lib/rfmt/lsp.rb +8 -0
- data/lib/rfmt/version.rb +1 -1
- data/lib/rfmt.rb +47 -19
- metadata +55 -4
- data/lib/rfmt/prism_bridge.rb +0 -529
- 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
|
|
128
|
+
desc 'config', 'Show the effective configuration the formatter will use'
|
|
124
129
|
def config_cmd
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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]
|
data/lib/rfmt/configuration.rb
CHANGED
|
@@ -3,17 +3,13 @@
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
|
|
5
5
|
module Rfmt
|
|
6
|
-
#
|
|
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
|
data/lib/rfmt/lsp/uri.rb
ADDED
|
@@ -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
data/lib/rfmt/version.rb
CHANGED
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
['
|
|
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
|