rfmt 1.6.3-x86_64-linux → 1.7.0-x86_64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f590757d8569ca48a5312d0671759def5cee9bc6c23bed48354e19d594dddf5
4
- data.tar.gz: 24d4c203675cf0a8fced50db72658c89c5fdda7ee74c75c2a9a5e9fb5b8bb26f
3
+ metadata.gz: 0f88fbe75db61892eb755ad85a5e726632948858b279080967c2272f1ab1d3c6
4
+ data.tar.gz: d9c3f992757e3179e9f26602c3410cb1609286c839d97603d07f38341b782238
5
5
  SHA512:
6
- metadata.gz: a83cb835242ede397ed9ddeb821c37c20a42edca78aef496419161a920736492d64eedb0c2816264437048a4548d9b9bf31e0e39bd0b9973fd99c97383ecce82
7
- data.tar.gz: f7a515ce7977a348b80efe89fb093ebeccf470ee80a2d63ba3cc25eb27585d8586f38ba008bc28c5cda70bfac4b4d1038f877a53a43ac6cb5b230619c7aa1d35
6
+ metadata.gz: 16c46e05a978c98a89b246205b2bfef36454412afbac175e7aef5dbe6d5581b39409101c7549ea7408840e2a6adbf21d43202af674fbd38950feaea559cbe9c0
7
+ data.tar.gz: a38fac5c2c9cc0070a59ef022d9efb6a76bfb5fb01b39d8d1ace700eb51165fa53522c06fc48f2f4b1fd1f64acbd5e3758c76c4e120b7f779b276a96f0fa68b2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.7.0] - 2026-06-04
4
+
5
+ rfmt now ships a standalone LSP server. Point any LSP-capable editor at `rfmt-lsp` and you get format-on-save — no Ruby LSP, no Gemfile, and no editor-specific plugin required. This makes rfmt usable from Helix, Neovim, Emacs, and any other LSP client, including in projects that don't bundle rfmt.
6
+
7
+ ### Added
8
+
9
+ - Standalone LSP server `rfmt-lsp` (#108). Provides format-on-save in any LSP-capable editor without requiring Ruby LSP or a Gemfile:
10
+ - `textDocument/formatting` with full document sync; unsaved buffer contents are formatted via `didOpen`/`didChange` tracking
11
+ - `.rfmt.yml` is resolved relative to the workspace root (`rootUri`/`workspaceFolders`)
12
+ - Graceful handling of syntax errors (returns no edits instead of crashing), empty files, and unsupported LSP methods
13
+ - New `exe/rfmt-lsp` executable shipped with the gem
14
+ - Editor setup guides for the standalone server (Neovim, Helix, Emacs eglot) in `docs/editors.md`
15
+
3
16
  ## [1.6.3] - 2026-04-24
4
17
 
5
18
  Minor stability refinements on top of the 1.6.x architecture release. See the 1.6.1 notes below for the feature set this series delivers.
data/README.md CHANGED
@@ -361,7 +361,35 @@ end
361
361
 
362
362
  ## Editor Integration
363
363
 
364
- rfmt integrates with editors through [Ruby LSP](https://shopify.github.io/ruby-lsp/). For detailed setup instructions, see [Editor Integration Guide](docs/editors.md).
364
+ rfmt can integrate with editors in two ways:
365
+
366
+ - Standalone LSP: run `rfmt-lsp` directly from your editor. This works well for single
367
+ Ruby scripts or projects without a Gemfile.
368
+ - Ruby LSP add-on: use rfmt as the formatter inside
369
+ [Ruby LSP](https://shopify.github.io/ruby-lsp/).
370
+
371
+ For detailed setup instructions, see [Editor Integration Guide](docs/editors.md).
372
+
373
+ ### Standalone LSP
374
+
375
+ After installing rfmt, configure your editor's Ruby language server command to `rfmt-lsp`.
376
+
377
+ ```bash
378
+ gem install rfmt
379
+ rfmt-lsp
380
+ ```
381
+
382
+ Example Helix configuration:
383
+
384
+ ```toml
385
+ [language-server.rfmt]
386
+ command = "rfmt-lsp"
387
+
388
+ [[language]]
389
+ name = "ruby"
390
+ language-servers = ["rfmt"]
391
+ auto-format = true
392
+ ```
365
393
 
366
394
  ### VSCode (Quick Start)
367
395
 
data/exe/rfmt-lsp ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $VERBOSE = nil unless ENV['RFMT_VERBOSE']
5
+
6
+ require 'rfmt/lsp/server'
7
+
8
+ exit Rfmt::LSP::Server.new.run
data/exe/rfmt_fast ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Fast launcher for rfmt without gem warnings
5
+
6
+ # Suppress all warnings
7
+ $VERBOSE = nil
8
+
9
+ # Direct load without rubygems overhead
10
+ require_relative '../lib/rfmt/cli'
11
+
12
+ Rfmt::CLI.start(ARGV)
data/lib/rfmt/3.3/rfmt.so CHANGED
Binary file
data/lib/rfmt/3.4/rfmt.so CHANGED
Binary file
@@ -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 = '1.7.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rfmt
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.3
4
+ version: 1.7.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - fujitani sora
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-24 00:00:00.000000000 Z
11
+ date: 2026-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -38,6 +38,48 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: language_server-protocol
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.17'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.17'
55
+ - !ruby/object:Gem::Dependency
56
+ name: parallel
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.24'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.24'
69
+ - !ruby/object:Gem::Dependency
70
+ name: prism
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.6'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.6'
41
83
  - !ruby/object:Gem::Dependency
42
84
  name: thor
43
85
  requirement: !ruby/object:Gem::Requirement
@@ -57,6 +99,7 @@ email:
57
99
  - fujitanisora0414@gmail.com
58
100
  executables:
59
101
  - rfmt
102
+ - rfmt-lsp
60
103
  extensions: []
61
104
  extra_rdoc_files: []
62
105
  files:
@@ -64,12 +107,21 @@ files:
64
107
  - LICENSE.txt
65
108
  - README.md
66
109
  - exe/rfmt
110
+ - exe/rfmt-lsp
111
+ - exe/rfmt_fast
67
112
  - lib/rfmt.rb
68
113
  - lib/rfmt/3.3/rfmt.so
69
114
  - lib/rfmt/3.4/rfmt.so
70
115
  - lib/rfmt/cache.rb
71
116
  - lib/rfmt/cli.rb
72
117
  - lib/rfmt/configuration.rb
118
+ - lib/rfmt/lsp.rb
119
+ - lib/rfmt/lsp/document_store.rb
120
+ - lib/rfmt/lsp/formatter.rb
121
+ - lib/rfmt/lsp/message_io.rb
122
+ - lib/rfmt/lsp/server.rb
123
+ - lib/rfmt/lsp/uri.rb
124
+ - lib/rfmt/lsp/workspace.rb
73
125
  - lib/rfmt/native_extension_loader.rb
74
126
  - lib/rfmt/prism_bridge.rb
75
127
  - lib/rfmt/prism_node_extractor.rb