steep 2.1.0.dev.1 → 2.1.0
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 +35 -0
- data/CLAUDE.md +2 -2
- data/README.md +2 -2
- data/bin/steep-check.rb +4 -4
- data/lib/steep/ast/types/helper.rb +0 -1
- data/lib/steep/ast/types/name.rb +1 -1
- data/lib/steep/cli.rb +32 -3
- data/lib/steep/daemon.rb +49 -9
- data/lib/steep/drivers/init.rb +4 -3
- data/lib/steep/drivers/langserver.rb +33 -1
- data/lib/steep/drivers/query.rb +40 -2
- data/lib/steep/interface/builder.rb +24 -4
- data/lib/steep/server/base_worker.rb +15 -0
- data/lib/steep/server/command_socket.rb +181 -0
- data/lib/steep/server/custom_methods.rb +12 -0
- data/lib/steep/server/master.rb +383 -2
- data/lib/steep/server/type_check_worker.rb +48 -2
- data/lib/steep/services/goto_service.rb +6 -2
- data/lib/steep/services/hover_provider/content.rb +1 -1
- data/lib/steep/source.rb +3 -4
- data/lib/steep/subtyping/check.rb +6 -3
- data/lib/steep/tagged_logging.rb +4 -1
- data/lib/steep/type_construction.rb +20 -13
- data/lib/steep/type_inference/case_when.rb +2 -2
- data/lib/steep/type_inference/send_args.rb +9 -5
- data/lib/steep/version.rb +1 -1
- data/lib/steep.rb +1 -0
- data/steep.gemspec +2 -8
- metadata +6 -5
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Steep
|
|
4
|
+
module Server
|
|
5
|
+
# Accepts connections on a UNIX socket and forwards the received requests to the master
|
|
6
|
+
#
|
|
7
|
+
# Started by `steep langserver` so that CLI commands like `steep query` and `steep check` can
|
|
8
|
+
# talk to the language server the IDE is running, instead of a standalone daemon process.
|
|
9
|
+
#
|
|
10
|
+
class CommandSocket
|
|
11
|
+
LSP = LanguageServer::Protocol
|
|
12
|
+
|
|
13
|
+
# A connection from a CLI client
|
|
14
|
+
#
|
|
15
|
+
# `#write` never raises even if the client is disconnected, because it may be called from
|
|
16
|
+
# the master's write thread, which should keep running for other clients.
|
|
17
|
+
#
|
|
18
|
+
class Session
|
|
19
|
+
attr_reader :socket
|
|
20
|
+
|
|
21
|
+
def initialize(socket)
|
|
22
|
+
@socket = socket
|
|
23
|
+
@writer = LSP::Transport::Io::Writer.new(socket)
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@closed = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(message)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
return if @closed
|
|
31
|
+
@writer.write(message)
|
|
32
|
+
end
|
|
33
|
+
rescue SystemCallError, IOError
|
|
34
|
+
close()
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def close
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@closed = true
|
|
40
|
+
end
|
|
41
|
+
begin
|
|
42
|
+
@socket.close
|
|
43
|
+
rescue IOError
|
|
44
|
+
# Already closed
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_reader :master, :configuration
|
|
50
|
+
|
|
51
|
+
def initialize(master:, configuration:)
|
|
52
|
+
@master = master
|
|
53
|
+
@configuration = configuration
|
|
54
|
+
@server = nil
|
|
55
|
+
@accept_thread = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def socket_path
|
|
59
|
+
configuration.socket_path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Binds the UNIX socket and starts accepting connections in a background thread
|
|
63
|
+
#
|
|
64
|
+
# Returns `false` without binding when another process (a standalone `steep server` daemon or
|
|
65
|
+
# another language server) is already serving on the socket.
|
|
66
|
+
#
|
|
67
|
+
def start
|
|
68
|
+
if File.exist?(socket_path)
|
|
69
|
+
if socket_alive?
|
|
70
|
+
Steep.logger.warn { "Command socket is already served by another process: #{socket_path}" }
|
|
71
|
+
return false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
unless File.socket?(socket_path)
|
|
75
|
+
Steep.logger.error { "#{socket_path} exists but is not a socket, skipping command socket setup" }
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
File.delete(socket_path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@server = UNIXServer.new(socket_path)
|
|
83
|
+
File.chmod(0o600, socket_path)
|
|
84
|
+
|
|
85
|
+
@accept_thread = Thread.new do
|
|
86
|
+
Thread.current.abort_on_exception = false
|
|
87
|
+
accept_loop()
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Steep.logger.info { "Command socket is ready: #{socket_path}" }
|
|
91
|
+
|
|
92
|
+
true
|
|
93
|
+
rescue Errno::EADDRINUSE
|
|
94
|
+
Steep.logger.warn { "Command socket is already served by another process: #{socket_path}" }
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def stop
|
|
99
|
+
server = @server
|
|
100
|
+
@server = nil
|
|
101
|
+
|
|
102
|
+
if server
|
|
103
|
+
begin
|
|
104
|
+
server.close
|
|
105
|
+
rescue IOError
|
|
106
|
+
# Already closed
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@accept_thread&.join(1)
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
File.delete(socket_path)
|
|
113
|
+
rescue Errno::ENOENT
|
|
114
|
+
# Already deleted
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def socket_alive?
|
|
122
|
+
socket = UNIXSocket.new(socket_path)
|
|
123
|
+
socket.close
|
|
124
|
+
true
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def accept_loop
|
|
130
|
+
while server = @server
|
|
131
|
+
socket =
|
|
132
|
+
begin
|
|
133
|
+
server.accept
|
|
134
|
+
rescue IOError, SystemCallError => error
|
|
135
|
+
Steep.logger.info { "Command socket accept loop stopping: #{error.inspect}" }
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The loop must survive anything but the accept errors above: it runs with
|
|
140
|
+
# `abort_on_exception` disabled, so an uncaught error would kill the thread
|
|
141
|
+
# silently and every connection after it would sit in the backlog forever.
|
|
142
|
+
begin
|
|
143
|
+
Steep.logger.info { "Command socket accepted a connection" }
|
|
144
|
+
|
|
145
|
+
# `session` is passed as a thread argument: `while` shares its locals across
|
|
146
|
+
# iterations, so a block reading `session` directly would serve the session of a
|
|
147
|
+
# later iteration when the accept loop wins the race against the thread's start,
|
|
148
|
+
# leaving the accepted connection unread.
|
|
149
|
+
Thread.new(Session.new(socket)) do |session|
|
|
150
|
+
Thread.current.abort_on_exception = false
|
|
151
|
+
serve(session)
|
|
152
|
+
end
|
|
153
|
+
rescue StandardError => error
|
|
154
|
+
Steep.logger.error { "Command socket could not serve an accepted connection: #{error.inspect}" }
|
|
155
|
+
begin
|
|
156
|
+
socket.close
|
|
157
|
+
rescue IOError
|
|
158
|
+
# Already closed
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
ensure
|
|
163
|
+
Steep.logger.info { "Command socket accept loop finished" }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def serve(session)
|
|
167
|
+
reader = LSP::Transport::Io::Reader.new(session.socket)
|
|
168
|
+
reader.read do |message|
|
|
169
|
+
Steep.logger.info { "Command socket received message: method=#{message[:method] || "-"}, id=#{message[:id] || "-"}" }
|
|
170
|
+
master.process_command_socket_message(message, session)
|
|
171
|
+
end
|
|
172
|
+
rescue IOError, SystemCallError => error
|
|
173
|
+
Steep.logger.warn { "Command socket client error: #{error.inspect}" }
|
|
174
|
+
ensure
|
|
175
|
+
Steep.logger.info { "Command socket connection closed" }
|
|
176
|
+
master.finish_command_socket_session(session)
|
|
177
|
+
session.close
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -96,6 +96,18 @@ module Steep
|
|
|
96
96
|
{ id: id, result: result }
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
|
+
|
|
100
|
+
module Query__Diagnostics
|
|
101
|
+
METHOD = "$/steep/query/diagnostics"
|
|
102
|
+
|
|
103
|
+
def self.request(id, params)
|
|
104
|
+
{ method: METHOD, id: id, params: params }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.response(id, result)
|
|
108
|
+
{ id: id, result: result }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
99
111
|
end
|
|
100
112
|
end
|
|
101
113
|
end
|
data/lib/steep/server/master.rb
CHANGED
|
@@ -186,6 +186,12 @@ module Steep
|
|
|
186
186
|
attr_accessor :typecheck_automatically
|
|
187
187
|
attr_reader :start_type_checking_queue
|
|
188
188
|
|
|
189
|
+
# Type check requests waiting for the current type check to finish
|
|
190
|
+
attr_reader :pending_typecheck_requests
|
|
191
|
+
|
|
192
|
+
# Callbacks to be called when no type check is running anymore
|
|
193
|
+
attr_reader :typecheck_quiescent_callbacks
|
|
194
|
+
|
|
189
195
|
def initialize(project:, reader:, writer:, interaction_worker:, typecheck_workers:, queue: Queue.new, refork: false)
|
|
190
196
|
@project = project
|
|
191
197
|
@reader = reader
|
|
@@ -199,6 +205,11 @@ module Steep
|
|
|
199
205
|
@write_queue = SizedQueue.new(100)
|
|
200
206
|
@refork_mutex = Mutex.new
|
|
201
207
|
@need_to_refork = refork
|
|
208
|
+
@typecheck_quiescent_callbacks = []
|
|
209
|
+
@pending_typecheck_requests = []
|
|
210
|
+
@project_file_mtimes = nil
|
|
211
|
+
@command_socket_requests = {}
|
|
212
|
+
@command_socket_mutex = Mutex.new
|
|
202
213
|
|
|
203
214
|
@controller = TypeCheckController.new(project: project)
|
|
204
215
|
@result_controller = ResultController.new()
|
|
@@ -245,7 +256,7 @@ module Steep
|
|
|
245
256
|
case job.dest
|
|
246
257
|
when :client
|
|
247
258
|
Steep.logger.info { "Processing SendMessageJob: dest=client, method=#{job.message[:method] || "-"}, id=#{job.message[:id] || "-"}" }
|
|
248
|
-
|
|
259
|
+
write_message_to_client(job.message)
|
|
249
260
|
when WorkerProcess
|
|
250
261
|
refork_mutex.synchronize do
|
|
251
262
|
Steep.logger.info { "Processing SendMessageJob: dest=#{job.dest.name}, method=#{job.message[:method] || "-"}, id=#{job.message[:id] || "-"}" }
|
|
@@ -420,6 +431,8 @@ module Steep
|
|
|
420
431
|
progress.end()
|
|
421
432
|
end
|
|
422
433
|
|
|
434
|
+
reset_project_file_mtimes()
|
|
435
|
+
|
|
423
436
|
if file_system_watcher_supported?
|
|
424
437
|
setup_file_system_watcher()
|
|
425
438
|
end
|
|
@@ -445,6 +458,7 @@ module Steep
|
|
|
445
458
|
|
|
446
459
|
unless controller.open_paths.include?(path)
|
|
447
460
|
updated_watched_files << path
|
|
461
|
+
record_project_file_mtime(path)
|
|
448
462
|
|
|
449
463
|
case type
|
|
450
464
|
when LSP::Constant::FileChangeType::CREATED, LSP::Constant::FileChangeType::CHANGED
|
|
@@ -684,7 +698,7 @@ module Steep
|
|
|
684
698
|
request.inline_paths << [target_name.to_sym, Pathname(path)]
|
|
685
699
|
end
|
|
686
700
|
|
|
687
|
-
start_type_check(request: request, last_request:
|
|
701
|
+
start_type_check(request: request, last_request: current_type_check_request)
|
|
688
702
|
|
|
689
703
|
when CustomMethods::TypeCheckGroups::METHOD
|
|
690
704
|
params = message[:params] #: CustomMethods::TypeCheckGroups::params
|
|
@@ -703,6 +717,15 @@ module Steep
|
|
|
703
717
|
request.needs_response = false
|
|
704
718
|
start_type_check(request: request, last_request: current_type_check_request, report_progress_threshold: 0)
|
|
705
719
|
|
|
720
|
+
when CustomMethods::Query__Diagnostics::METHOD
|
|
721
|
+
id = message[:id]
|
|
722
|
+
params = message[:params] #: CustomMethods::Query__Diagnostics::params
|
|
723
|
+
paths = params[:paths]
|
|
724
|
+
|
|
725
|
+
run_when_typecheck_quiescent do
|
|
726
|
+
collect_query_diagnostics(id, paths)
|
|
727
|
+
end
|
|
728
|
+
|
|
706
729
|
when "$/ping"
|
|
707
730
|
enqueue_write_job SendMessageJob.to_client(
|
|
708
731
|
message: {
|
|
@@ -805,6 +828,17 @@ module Steep
|
|
|
805
828
|
|
|
806
829
|
def start_type_check(request: nil, last_request:, progress: nil, include_unchanged: false, report_progress_threshold: 10, needs_response: nil)
|
|
807
830
|
Steep.logger.tagged "#start_type_check(#{progress&.guid || request&.guid}, #{last_request&.guid}" do
|
|
831
|
+
if (current = current_type_check_request) && typecheck_request_from_command_socket?(current)
|
|
832
|
+
# Never supersede a type check that a command socket client is waiting for
|
|
833
|
+
if request
|
|
834
|
+
Steep.logger.info { "Queueing type check request #{request.guid} until the command socket request finishes" }
|
|
835
|
+
pending_typecheck_requests << request
|
|
836
|
+
else
|
|
837
|
+
Steep.logger.info { "Deferring automatic type checking until the command socket request finishes" }
|
|
838
|
+
end
|
|
839
|
+
return
|
|
840
|
+
end
|
|
841
|
+
|
|
808
842
|
if last_request
|
|
809
843
|
finish_type_check(last_request)
|
|
810
844
|
end
|
|
@@ -884,6 +918,7 @@ module Steep
|
|
|
884
918
|
finish_type_check(current)
|
|
885
919
|
@current_type_check_request = nil
|
|
886
920
|
refork_workers
|
|
921
|
+
start_pending_typecheck()
|
|
887
922
|
end
|
|
888
923
|
end
|
|
889
924
|
end
|
|
@@ -955,6 +990,17 @@ module Steep
|
|
|
955
990
|
refork_finished.pop
|
|
956
991
|
end
|
|
957
992
|
end
|
|
993
|
+
|
|
994
|
+
# The reforked workers started from a copy of the primary's state, which has the
|
|
995
|
+
# results of the primary's assigned paths only, and the results the replaced workers
|
|
996
|
+
# had computed are gone with them. Type check everything again so that queries
|
|
997
|
+
# reading the stored results, like `$/steep/query/diagnostics`, see all files.
|
|
998
|
+
job_queue << -> do
|
|
999
|
+
guid = SecureRandom.uuid
|
|
1000
|
+
request = controller.make_all_request(guid: guid, progress: work_done_progress(guid))
|
|
1001
|
+
request.needs_response = false
|
|
1002
|
+
start_type_check(request: request, last_request: current_type_check_request, report_progress_threshold: 0)
|
|
1003
|
+
end
|
|
958
1004
|
end
|
|
959
1005
|
end
|
|
960
1006
|
|
|
@@ -1021,6 +1067,341 @@ module Steep
|
|
|
1021
1067
|
write_queue.push(job)
|
|
1022
1068
|
end
|
|
1023
1069
|
|
|
1070
|
+
# Calls the block once no type check is running
|
|
1071
|
+
#
|
|
1072
|
+
# Starts a type check for dirty paths first, so that the block observes up-to-date results.
|
|
1073
|
+
#
|
|
1074
|
+
def run_when_typecheck_quiescent(&block)
|
|
1075
|
+
unless current_type_check_request
|
|
1076
|
+
guid = SecureRandom.uuid
|
|
1077
|
+
if request = controller.make_request(guid: guid, progress: work_done_progress(guid))
|
|
1078
|
+
request.needs_response = false
|
|
1079
|
+
start_type_check(request: request, last_request: nil)
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
if current_type_check_request || !pending_typecheck_requests.empty?
|
|
1084
|
+
typecheck_quiescent_callbacks << block
|
|
1085
|
+
else
|
|
1086
|
+
yield
|
|
1087
|
+
end
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
# Starts the next queued type check request if any, or the automatic type check for dirty paths
|
|
1091
|
+
#
|
|
1092
|
+
# Calls the quiescent callbacks when nothing is left to type check.
|
|
1093
|
+
#
|
|
1094
|
+
def start_pending_typecheck
|
|
1095
|
+
while request = pending_typecheck_requests.shift
|
|
1096
|
+
start_type_check(request: request, last_request: nil)
|
|
1097
|
+
return if current_type_check_request
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
if typecheck_automatically && initialize_params
|
|
1101
|
+
guid = SecureRandom.uuid
|
|
1102
|
+
if request = controller.make_request(guid: guid, progress: work_done_progress(guid))
|
|
1103
|
+
request.needs_response = false
|
|
1104
|
+
start_type_check(request: request, last_request: nil)
|
|
1105
|
+
return if current_type_check_request
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
flush_typecheck_quiescent_callbacks()
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# Calls the waiting quiescent callbacks, once a type check for the paths that went dirty
|
|
1113
|
+
# in the meantime finishes
|
|
1114
|
+
#
|
|
1115
|
+
def flush_typecheck_quiescent_callbacks
|
|
1116
|
+
return if typecheck_quiescent_callbacks.empty?
|
|
1117
|
+
return if current_type_check_request
|
|
1118
|
+
|
|
1119
|
+
guid = SecureRandom.uuid
|
|
1120
|
+
if request = controller.make_request(guid: guid, progress: work_done_progress(guid))
|
|
1121
|
+
request.needs_response = false
|
|
1122
|
+
start_type_check(request: request, last_request: nil)
|
|
1123
|
+
return if current_type_check_request
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
callbacks = typecheck_quiescent_callbacks.dup
|
|
1127
|
+
typecheck_quiescent_callbacks.clear
|
|
1128
|
+
callbacks.each(&:call)
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# Collects the stored diagnostics from all typecheck workers and sends the response
|
|
1132
|
+
#
|
|
1133
|
+
# `paths` is an array of absolute path strings to filter the result, or `nil` to return everything.
|
|
1134
|
+
#
|
|
1135
|
+
def collect_query_diagnostics(id, paths)
|
|
1136
|
+
uris = paths&.map {|path| PathHelper.to_uri(Pathname(path)).to_s }
|
|
1137
|
+
|
|
1138
|
+
result_controller << group_request do |group|
|
|
1139
|
+
typecheck_workers.each do |worker|
|
|
1140
|
+
group << send_request(method: CustomMethods::Query__Diagnostics::METHOD, params: nil, worker: worker)
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
group.on_completion do |handlers|
|
|
1144
|
+
diagnostics = {} #: Hash[String, Array[untyped]]
|
|
1145
|
+
|
|
1146
|
+
handlers.each do |handler|
|
|
1147
|
+
result = handler.result or next
|
|
1148
|
+
result.each do |entry|
|
|
1149
|
+
array = diagnostics[entry[:uri]] ||= []
|
|
1150
|
+
array.concat(entry[:diagnostics] || [])
|
|
1151
|
+
array.uniq!
|
|
1152
|
+
end
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
# @type var result: CustomMethods::Query__Diagnostics::result
|
|
1156
|
+
result =
|
|
1157
|
+
if uris
|
|
1158
|
+
# Files the server has not type checked yet are reported with `diagnostics: nil`
|
|
1159
|
+
uris.sort.map do |uri|
|
|
1160
|
+
{ uri: uri, diagnostics: diagnostics[uri] }
|
|
1161
|
+
end
|
|
1162
|
+
else
|
|
1163
|
+
diagnostics.keys.sort.map do |uri|
|
|
1164
|
+
{ uri: uri, diagnostics: diagnostics.fetch(uri) }
|
|
1165
|
+
end
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
enqueue_write_job SendMessageJob.to_client(
|
|
1169
|
+
message: CustomMethods::Query__Diagnostics.response(id, result)
|
|
1170
|
+
)
|
|
1171
|
+
end
|
|
1172
|
+
end
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
# Methods that command socket clients cannot send because they control the server lifecycle
|
|
1176
|
+
# The only methods a command socket client may send: what `steep check` and
|
|
1177
|
+
# `steep query` use. Everything else is rejected -- lifecycle methods because the
|
|
1178
|
+
# server belongs to whoever started it, and document synchronization because the
|
|
1179
|
+
# LSP client owns the content of the files it opens.
|
|
1180
|
+
COMMAND_SOCKET_ALLOWED_METHODS = [
|
|
1181
|
+
CustomMethods::TypeCheck::METHOD,
|
|
1182
|
+
CustomMethods::Query__Diagnostics::METHOD,
|
|
1183
|
+
CustomMethods::Query__Definition::METHOD,
|
|
1184
|
+
"textDocument/hover",
|
|
1185
|
+
"$/ping"
|
|
1186
|
+
].freeze
|
|
1187
|
+
|
|
1188
|
+
# Notifications that are copied to command socket sessions with a pending `$/steep/typecheck` request
|
|
1189
|
+
COMMAND_SOCKET_FORWARDED_NOTIFICATIONS = ["textDocument/publishDiagnostics", "window/showMessage"].freeze
|
|
1190
|
+
|
|
1191
|
+
# Processes a message received from a command socket client
|
|
1192
|
+
#
|
|
1193
|
+
# Requests are assigned a fresh id and enqueued as if they came from the LSP client,
|
|
1194
|
+
# and the responses are routed back to the session through `#write_message_to_client`.
|
|
1195
|
+
#
|
|
1196
|
+
# May be called from any thread.
|
|
1197
|
+
#
|
|
1198
|
+
def process_command_socket_message(message, session)
|
|
1199
|
+
method = message[:method]
|
|
1200
|
+
id = message[:id]
|
|
1201
|
+
|
|
1202
|
+
if method && !COMMAND_SOCKET_ALLOWED_METHODS.include?(method.to_s)
|
|
1203
|
+
Steep.logger.warn { "Command socket: rejected `#{method}`" }
|
|
1204
|
+
if id
|
|
1205
|
+
session.write({
|
|
1206
|
+
id: id,
|
|
1207
|
+
error: {
|
|
1208
|
+
code: LSP::Constant::ErrorCodes::INVALID_REQUEST,
|
|
1209
|
+
message: "`#{method}` is not allowed through the command socket"
|
|
1210
|
+
}
|
|
1211
|
+
})
|
|
1212
|
+
end
|
|
1213
|
+
return
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
unless initialize_params
|
|
1217
|
+
if id
|
|
1218
|
+
session.write({
|
|
1219
|
+
id: id,
|
|
1220
|
+
error: {
|
|
1221
|
+
code: LSP::Constant::ErrorCodes::SERVER_NOT_INITIALIZED,
|
|
1222
|
+
message: "The language server is not initialized yet"
|
|
1223
|
+
}
|
|
1224
|
+
})
|
|
1225
|
+
end
|
|
1226
|
+
return
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
case
|
|
1230
|
+
when method && id
|
|
1231
|
+
guid = SecureRandom.uuid
|
|
1232
|
+
@command_socket_mutex.synchronize do
|
|
1233
|
+
@command_socket_requests[guid] = [session, id, method.to_s == CustomMethods::TypeCheck::METHOD]
|
|
1234
|
+
end
|
|
1235
|
+
message = message.merge({ id: guid })
|
|
1236
|
+
when id
|
|
1237
|
+
# A response from the client, but nothing is waiting for it
|
|
1238
|
+
return
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
# Reload files changed on disk before processing, because command socket clients
|
|
1242
|
+
# modify files without sending `didChange` notifications
|
|
1243
|
+
job_queue << -> do
|
|
1244
|
+
sync_project_files_from_disk()
|
|
1245
|
+
process_message_from_client(message)
|
|
1246
|
+
end
|
|
1247
|
+
rescue ClosedQueueError
|
|
1248
|
+
Steep.logger.warn { "Command socket: server is shutting down" }
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# Deregisters the pending requests of a disconnected command socket session
|
|
1252
|
+
def finish_command_socket_session(session)
|
|
1253
|
+
@command_socket_mutex.synchronize do
|
|
1254
|
+
@command_socket_requests.delete_if {|_, entry| entry[0] == session }
|
|
1255
|
+
end
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
def write_message_to_client(message)
|
|
1259
|
+
unless message.key?(:method)
|
|
1260
|
+
entry = @command_socket_mutex.synchronize { @command_socket_requests.delete(message[:id]) }
|
|
1261
|
+
if entry
|
|
1262
|
+
session, original_id, _ = entry
|
|
1263
|
+
session.write(message.merge({ id: original_id }))
|
|
1264
|
+
return
|
|
1265
|
+
end
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
writer.write(message)
|
|
1269
|
+
|
|
1270
|
+
if message.key?(:method) && !message.key?(:id)
|
|
1271
|
+
forward_notification_to_command_sessions(message)
|
|
1272
|
+
end
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
def forward_notification_to_command_sessions(message)
|
|
1276
|
+
return unless COMMAND_SOCKET_FORWARDED_NOTIFICATIONS.include?(message[:method].to_s)
|
|
1277
|
+
|
|
1278
|
+
sessions = @command_socket_mutex.synchronize do
|
|
1279
|
+
@command_socket_requests.each_value.with_object([]) do |entry, array|
|
|
1280
|
+
session, _, typecheck = entry
|
|
1281
|
+
array << session if typecheck
|
|
1282
|
+
end
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
sessions.uniq!
|
|
1286
|
+
sessions.each {|session| session.write(message) }
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
def typecheck_request_from_command_socket?(request)
|
|
1290
|
+
@command_socket_mutex.synchronize do
|
|
1291
|
+
@command_socket_requests.key?(request.guid)
|
|
1292
|
+
end
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
# Records the mtimes of all project files, as the baseline of `#sync_project_files_from_disk`
|
|
1296
|
+
def reset_project_file_mtimes
|
|
1297
|
+
mtimes = {} #: Hash[Pathname, Time?]
|
|
1298
|
+
each_project_file_path do |path|
|
|
1299
|
+
mtimes[path] = file_mtime(path)
|
|
1300
|
+
end
|
|
1301
|
+
@project_file_mtimes = mtimes
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
def record_project_file_mtime(path)
|
|
1305
|
+
if mtimes = @project_file_mtimes
|
|
1306
|
+
mtimes[path] = file_mtime(path)
|
|
1307
|
+
end
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
def file_mtime(path)
|
|
1311
|
+
File.mtime(path)
|
|
1312
|
+
rescue SystemCallError
|
|
1313
|
+
nil
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def each_project_file_path(&block)
|
|
1317
|
+
loader = Services::FileLoader.new(base_dir: project.base_dir)
|
|
1318
|
+
project.targets.each do |target|
|
|
1319
|
+
loader.each_path_in_target(target) do |relative_path|
|
|
1320
|
+
yield project.absolute_path(relative_path)
|
|
1321
|
+
end
|
|
1322
|
+
end
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
# Detects files changed on disk since the last load/sync, and reloads them
|
|
1326
|
+
#
|
|
1327
|
+
# Skips files open in the editor because the client owns their contents.
|
|
1328
|
+
# Does nothing until the initial project load finishes.
|
|
1329
|
+
#
|
|
1330
|
+
def sync_project_files_from_disk
|
|
1331
|
+
mtimes = @project_file_mtimes or return
|
|
1332
|
+
|
|
1333
|
+
changes = {} #: Hash[Pathname, Symbol]
|
|
1334
|
+
seen = Set[] #: Set[Pathname]
|
|
1335
|
+
|
|
1336
|
+
each_project_file_path do |path|
|
|
1337
|
+
next if seen.include?(path)
|
|
1338
|
+
seen << path
|
|
1339
|
+
|
|
1340
|
+
mtime = file_mtime(path)
|
|
1341
|
+
recorded = mtimes[path]
|
|
1342
|
+
|
|
1343
|
+
case
|
|
1344
|
+
when recorded.nil? && mtime
|
|
1345
|
+
changes[path] = mtimes.key?(path) ? :changed : :created
|
|
1346
|
+
when recorded && mtime.nil?
|
|
1347
|
+
changes[path] = :deleted
|
|
1348
|
+
when recorded != mtime
|
|
1349
|
+
changes[path] = :changed
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
mtimes[path] = mtime
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
deleted_paths = mtimes.keys.select {|path| !seen.include?(path) && mtimes[path] && file_mtime(path).nil? }
|
|
1356
|
+
deleted_paths.each do |path|
|
|
1357
|
+
changes[path] = :deleted
|
|
1358
|
+
mtimes[path] = nil
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
changes.delete_if {|path, _| controller.open_paths.include?(path) }
|
|
1362
|
+
return if changes.empty?
|
|
1363
|
+
|
|
1364
|
+
Steep.logger.info { "Command socket: reloading #{changes.size} file(s) changed on disk" }
|
|
1365
|
+
|
|
1366
|
+
changes.each do |path, type|
|
|
1367
|
+
content =
|
|
1368
|
+
if type == :deleted
|
|
1369
|
+
""
|
|
1370
|
+
else
|
|
1371
|
+
begin
|
|
1372
|
+
path.read
|
|
1373
|
+
rescue SystemCallError
|
|
1374
|
+
next
|
|
1375
|
+
end
|
|
1376
|
+
end
|
|
1377
|
+
|
|
1378
|
+
case
|
|
1379
|
+
when controller.code_path?(path)
|
|
1380
|
+
controller.add_dirty_code_path(path)
|
|
1381
|
+
when controller.signature_path?(path)
|
|
1382
|
+
controller.add_dirty_signature_path(path)
|
|
1383
|
+
when controller.inline_path?(path)
|
|
1384
|
+
controller.add_dirty_inline_path(path, content)
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
broadcast_notification(CustomMethods::FileReset.notification({ uri: PathHelper.to_uri(path).to_s, content: content }))
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
if typecheck_automatically
|
|
1391
|
+
start_type_checking_queue.execute do
|
|
1392
|
+
job_queue.push(
|
|
1393
|
+
-> do
|
|
1394
|
+
start_type_check(
|
|
1395
|
+
last_request: current_type_check_request,
|
|
1396
|
+
progress: work_done_progress(SecureRandom.uuid),
|
|
1397
|
+
needs_response: false
|
|
1398
|
+
)
|
|
1399
|
+
end
|
|
1400
|
+
)
|
|
1401
|
+
end
|
|
1402
|
+
end
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1024
1405
|
def work_done_progress(guid)
|
|
1025
1406
|
if work_done_progress_supported?
|
|
1026
1407
|
WorkDoneProgress.new(guid) do |message|
|