Skip to content

Commit 1c61c90

Browse files
committed
Add Ruby LSP extension for hover
1 parent 76399c6 commit 1c61c90

File tree

18 files changed

+40476
-12
lines changed

18 files changed

+40476
-12
lines changed

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ AllCops:
1010
NewCops: disable
1111
SuggestExtensions: false
1212
TargetRubyVersion: 2.7
13+
Exclude:
14+
- "test/dummy/db/**/*.rb"
1315

1416
Sorbet/FalseSigil:
1517
Enabled: false

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ gem "rubocop-sorbet", "~> 0.7", require: false
1717

1818
gem "sorbet-static-and-runtime"
1919
gem "tapioca", "~> 0.11", require: false
20+
21+
gem "ruby-lsp", path: "../ruby-lsp"

Gemfile.lock

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
PATH
2+
remote: ../ruby-lsp
3+
specs:
4+
ruby-lsp (0.4.3)
5+
language_server-protocol (~> 3.17.0)
6+
sorbet-runtime
7+
syntax_tree (>= 6.1.1, < 7)
8+
19
PATH
210
remote: .
311
specs:
@@ -184,10 +192,6 @@ GEM
184192
rubocop (~> 1.44)
185193
rubocop-sorbet (0.7.0)
186194
rubocop (>= 0.90.0)
187-
ruby-lsp (0.4.3)
188-
language_server-protocol (~> 3.17.0)
189-
sorbet-runtime
190-
syntax_tree (>= 6.1.1, < 7)
191195
ruby-progressbar (1.13.0)
192196
sorbet (0.5.10736)
193197
sorbet-static (= 0.5.10736)
@@ -243,6 +247,7 @@ DEPENDENCIES
243247
rubocop-rake (~> 0.6.0)
244248
rubocop-shopify (~> 2.12)
245249
rubocop-sorbet (~> 0.7)
250+
ruby-lsp!
246251
sorbet-static-and-runtime
247252
sqlite3
248253
tapioca (~> 0.11)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RailsRubyLsp
5+
class ModelsController < ApplicationController
6+
extend T::Sig
7+
8+
sig { returns(T.untyped) }
9+
def show
10+
const = Object.const_get(params[:id]) # rubocop:disable Sorbet/ConstantsFromStrings
11+
12+
if const < ActiveRecord::Base
13+
render(json: {
14+
columns: const.columns.map { |column| [column.name, column.type] },
15+
})
16+
else
17+
head(:not_found)
18+
end
19+
rescue NameError
20+
head(:not_found)
21+
end
22+
end
23+
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
# frozen_string_literal: true
33

44
RailsRubyLsp::Engine.routes.draw do
5+
resources :models, only: [:show]
56
end

lib/rails_ruby_lsp/engine.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,21 @@
77
module RailsRubyLsp
88
class Engine < ::Rails::Engine
99
isolate_namespace RailsRubyLsp
10+
11+
initializer "rails_ruby_lsp.routes" do
12+
config.after_initialize do |app|
13+
if Rails.env.development?
14+
app.routes.prepend do
15+
T.bind(self, ActionDispatch::Routing::Mapper)
16+
mount(RailsRubyLsp::Engine => "/rails_ruby_lsp")
17+
end
18+
end
19+
20+
host = ENV.fetch("HOST") { "localhost" }
21+
port = ENV.fetch("PORT") { "3000" }
22+
23+
File.write("#{Rails.root}/tmp/app_uri.txt", "http://#{host}:#{port}")
24+
end
25+
end
1026
end
1127
end

lib/ruby_lsp/extension.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require_relative "rails_client"
5+
require_relative "hover"
6+
7+
module RailsRubyLsp
8+
module RubyLsp
9+
class Extension < ::RubyLsp::Extensions::Base
10+
class << self
11+
extend T::Sig
12+
13+
sig { override.void }
14+
def activate
15+
# Must be the last statement in activate since it raises to display a notification for the user
16+
RailsRubyLsp::RailsClient.instance.check_if_server_is_running!
17+
end
18+
end
19+
end
20+
end
21+
end

lib/ruby_lsp/hover.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RailsRubyLsp
5+
class Hover < ::RubyLsp::Extensions::Hover
6+
class << self
7+
extend T::Sig
8+
9+
sig { override.params(target: SyntaxTree::Node).returns(T.nilable(String)) }
10+
def run(target)
11+
case target
12+
when SyntaxTree::Const
13+
model = RailsClient.instance.model(target.value)
14+
return if model.nil?
15+
16+
schema_file = File.join(RailsClient.instance.root, "db", "schema.rb")
17+
content = +""
18+
content << "[Schema](file://#{schema_file})\n\n" if File.exist?(schema_file)
19+
content << model[:columns].map { |name, type| "**#{name}** | #{type}\n" }.join("\n")
20+
content
21+
end
22+
end
23+
end
24+
end
25+
end

lib/ruby_lsp/rails_client.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "singleton"
5+
require "net/http"
6+
7+
module RailsRubyLsp
8+
class RailsClient
9+
class ServerNotRunningError < StandardError; end
10+
11+
extend T::Sig
12+
include Singleton
13+
14+
SERVER_NOT_RUNNING_MESSAGE = "Rails server is not running. " \
15+
"To get Rails features in the editor, boot the Rails server and restart the Ruby LSP"
16+
17+
sig { returns(String) }
18+
attr_reader :root
19+
20+
sig { void }
21+
def initialize
22+
@root = T.let(Dir.exist?("test/dummy") ? File.join(Dir.pwd, "test", "dummy") : Dir.pwd, String)
23+
base_uri = File.read("#{@root}/tmp/app_uri.txt").chomp
24+
25+
@uri = T.let("#{base_uri}/rails_ruby_lsp", String)
26+
end
27+
28+
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
29+
def model(name)
30+
response = request("models/#{name}")
31+
return unless response.code == "200"
32+
33+
JSON.parse(response.body.chomp, symbolize_names: true)
34+
rescue Errno::ECONNREFUSED
35+
raise ServerNotRunningError, SERVER_NOT_RUNNING_MESSAGE
36+
end
37+
38+
sig { void }
39+
def check_if_server_is_running!
40+
# Check if the Rails server is running. Warn the user to boot it for Rails features
41+
pid_file = ENV.fetch("PIDFILE") { File.join(@root, "tmp", "pids", "server.pid") }
42+
43+
# If the PID file doesn't exist, then the server hasn't been booted
44+
raise ServerNotRunningError, SERVER_NOT_RUNNING_MESSAGE unless File.exist?(pid_file)
45+
46+
pid = File.read(pid_file).to_i
47+
48+
begin
49+
# Issuing an EXIT signal to an existing process actually doesn't make the server shutdown. But if this
50+
# call succeeds, then the server is running. If the PID doesn't exist, Errno::ESRCH is raised
51+
Process.kill(T.must(Signal.list["EXIT"]), pid)
52+
rescue Errno::ESRCH
53+
raise ServerNotRunningError, SERVER_NOT_RUNNING_MESSAGE
54+
end
55+
end
56+
57+
private
58+
59+
sig { params(path: String).returns(Net::HTTPResponse) }
60+
def request(path)
61+
Net::HTTP.get_response(URI("#{@uri}/#{path}"))
62+
end
63+
end
64+
end

sorbet/config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
.
33
--ignore=tmp/
44
--ignore=vendor/
5+
--ignore=test/dummy/db

0 commit comments

Comments
 (0)