textbringer 17 → 19

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/exe/txtb +1 -1
  3. data/lib/textbringer/buffer.rb +37 -3
  4. data/lib/textbringer/commands/buffers.rb +4 -2
  5. data/lib/textbringer/commands/clipboard.rb +21 -6
  6. data/lib/textbringer/commands/completion.rb +133 -0
  7. data/lib/textbringer/commands/ctags.rb +1 -1
  8. data/lib/textbringer/commands/files.rb +11 -1
  9. data/lib/textbringer/commands/help.rb +1 -1
  10. data/lib/textbringer/commands/isearch.rb +4 -10
  11. data/lib/textbringer/commands/ispell.rb +0 -2
  12. data/lib/textbringer/commands/lsp.rb +389 -0
  13. data/lib/textbringer/commands/misc.rb +2 -1
  14. data/lib/textbringer/commands.rb +7 -3
  15. data/lib/textbringer/completion_popup.rb +188 -0
  16. data/lib/textbringer/faces/basic.rb +3 -1
  17. data/lib/textbringer/faces/completion.rb +4 -0
  18. data/lib/textbringer/floating_window.rb +327 -0
  19. data/lib/textbringer/input_methods/skk_input_method.rb +751 -0
  20. data/lib/textbringer/lsp/client.rb +568 -0
  21. data/lib/textbringer/lsp/server_registry.rb +138 -0
  22. data/lib/textbringer/mode.rb +3 -1
  23. data/lib/textbringer/modes/programming_mode.rb +17 -8
  24. data/lib/textbringer/modes/transient_mark_mode.rb +9 -2
  25. data/lib/textbringer/utils.rb +14 -10
  26. data/lib/textbringer/version.rb +1 -1
  27. data/lib/textbringer/window.rb +116 -19
  28. data/lib/textbringer.rb +7 -0
  29. data/sig/lib/textbringer/buffer.rbs +483 -0
  30. data/sig/lib/textbringer/color.rbs +9 -0
  31. data/sig/lib/textbringer/commands/buffers.rbs +93 -0
  32. data/sig/lib/textbringer/commands/clipboard.rbs +17 -0
  33. data/sig/lib/textbringer/commands/completion.rbs +20 -0
  34. data/sig/lib/textbringer/commands/ctags.rbs +11 -0
  35. data/sig/lib/textbringer/commands/dabbrev.rbs +4 -0
  36. data/sig/lib/textbringer/commands/files.rbs +29 -0
  37. data/sig/lib/textbringer/commands/fill.rbs +5 -0
  38. data/sig/lib/textbringer/commands/help.rbs +28 -0
  39. data/sig/lib/textbringer/commands/input_method.rbs +6 -0
  40. data/sig/lib/textbringer/commands/isearch.rbs +38 -0
  41. data/sig/lib/textbringer/commands/ispell.rbs +39 -0
  42. data/sig/lib/textbringer/commands/keyboard_macro.rbs +25 -0
  43. data/sig/lib/textbringer/commands/lsp.rbs +8 -0
  44. data/sig/lib/textbringer/commands/misc.rbs +74 -0
  45. data/sig/lib/textbringer/commands/rectangle.rbs +19 -0
  46. data/sig/lib/textbringer/commands/register.rbs +31 -0
  47. data/sig/lib/textbringer/commands/replace.rbs +17 -0
  48. data/sig/lib/textbringer/commands/server.rbs +31 -0
  49. data/sig/lib/textbringer/commands/ucs_normalize.rbs +9 -0
  50. data/sig/lib/textbringer/commands/windows.rbs +45 -0
  51. data/sig/lib/textbringer/commands.rbs +21 -0
  52. data/sig/lib/textbringer/completion_popup.rbs +40 -0
  53. data/sig/lib/textbringer/controller.rbs +58 -0
  54. data/sig/lib/textbringer/default_output.rbs +7 -0
  55. data/sig/lib/textbringer/errors.rbs +3 -0
  56. data/sig/lib/textbringer/face.rbs +19 -0
  57. data/sig/lib/textbringer/floating_window.rbs +42 -0
  58. data/sig/lib/textbringer/global_minor_mode.rbs +7 -0
  59. data/sig/lib/textbringer/input_method.rbs +28 -0
  60. data/sig/lib/textbringer/input_methods/hangul_input_method.rbs +12 -0
  61. data/sig/lib/textbringer/input_methods/hiragana_input_method.rbs +12 -0
  62. data/sig/lib/textbringer/input_methods/t_code_input_method.rbs +49 -0
  63. data/sig/lib/textbringer/keymap.rbs +33 -0
  64. data/sig/lib/textbringer/lsp/client.rbs +21 -0
  65. data/sig/lib/textbringer/lsp/server_registry.rbs +23 -0
  66. data/sig/lib/textbringer/minor_mode.rbs +12 -0
  67. data/sig/lib/textbringer/mode.rbs +70 -0
  68. data/sig/lib/textbringer/modes/backtrace_mode.rbs +8 -0
  69. data/sig/lib/textbringer/modes/buffer_list_mode.rbs +5 -0
  70. data/sig/lib/textbringer/modes/c_mode.rbs +21 -0
  71. data/sig/lib/textbringer/modes/completion_list_mode.rbs +5 -0
  72. data/sig/lib/textbringer/modes/fundamental_mode.rbs +3 -0
  73. data/sig/lib/textbringer/modes/help_mode.rbs +7 -0
  74. data/sig/lib/textbringer/modes/overwrite_mode.rbs +15 -0
  75. data/sig/lib/textbringer/modes/programming_mode.rbs +14 -0
  76. data/sig/lib/textbringer/modes/ruby_mode.rbs +57 -0
  77. data/sig/lib/textbringer/plugin.rbs +3 -0
  78. data/sig/lib/textbringer/ring.rbs +36 -0
  79. data/sig/lib/textbringer/utils.rbs +95 -0
  80. data/sig/lib/textbringer/window.rbs +183 -0
  81. data/textbringer.gemspec +1 -0
  82. metadata +76 -2
@@ -0,0 +1,568 @@
1
+ require "open3"
2
+ require "json"
3
+ require "securerandom"
4
+
5
+ module Textbringer
6
+ module LSP
7
+ class Client
8
+ class ServerError < StandardError; end
9
+ class TimeoutError < StandardError; end
10
+
11
+ attr_reader :root_path, :server_name, :server_capabilities
12
+
13
+ def initialize(command:, args: [], root_path:, server_name: nil, workspace_folders: nil)
14
+ @command = command
15
+ @args = args
16
+ @root_path = root_path
17
+ @server_name = server_name || command
18
+ @workspace_folders = Array(workspace_folders || @root_path)
19
+ @stdin = nil
20
+ @stdout = nil
21
+ @stderr = nil
22
+ @wait_thr = nil
23
+ @request_id = 0
24
+ @pending_requests = {}
25
+ @running = false
26
+ @initialized = false
27
+ @reader_thread = nil
28
+ @mutex = Mutex.new
29
+ @open_documents = {}
30
+ @server_capabilities = {}
31
+ end
32
+
33
+ def start
34
+ return if @running
35
+
36
+ begin
37
+ # Use Bundler's clean environment if available
38
+ if defined?(Bundler)
39
+ Bundler.with_unbundled_env do
40
+ @stdin, @stdout, @stderr, @wait_thr =
41
+ Open3.popen3(@command, *@args)
42
+ end
43
+ else
44
+ @stdin, @stdout, @stderr, @wait_thr =
45
+ Open3.popen3(@command, *@args)
46
+ end
47
+ rescue Errno::ENOENT
48
+ Utils.message("LSP server command not found: #{@command}")
49
+ return
50
+ end
51
+
52
+ @running = true
53
+ initialize_server_sync
54
+ start_reader_thread
55
+ end
56
+
57
+ def stop
58
+ return unless @running
59
+
60
+ shutdown
61
+ exit_server
62
+ cleanup
63
+ end
64
+
65
+ def running?
66
+ @running
67
+ end
68
+
69
+ def initialized?
70
+ @initialized
71
+ end
72
+
73
+ # Document synchronization
74
+
75
+ def did_open(uri:, language_id:, version:, text:)
76
+ return unless @initialized
77
+
78
+ send_notification("textDocument/didOpen", {
79
+ textDocument: {
80
+ uri: uri,
81
+ languageId: language_id,
82
+ version: version,
83
+ text: text
84
+ }
85
+ })
86
+ @open_documents[uri] = version
87
+ end
88
+
89
+ def did_change(uri:, version:, text: nil, range: nil, range_length: nil)
90
+ return unless @initialized
91
+ return unless @open_documents.key?(uri)
92
+
93
+ # Support both full and incremental sync
94
+ content_change = if range
95
+ # Incremental change
96
+ change = { range: range }
97
+ change[:rangeLength] = range_length if range_length
98
+ change[:text] = text if text
99
+ change
100
+ else
101
+ # Full document sync
102
+ { text: text }
103
+ end
104
+
105
+ send_notification("textDocument/didChange", {
106
+ textDocument: {
107
+ uri: uri,
108
+ version: version
109
+ },
110
+ contentChanges: [content_change]
111
+ })
112
+ @open_documents[uri] = version
113
+ end
114
+
115
+ def did_close(uri:)
116
+ return unless @initialized
117
+ return unless @open_documents.key?(uri)
118
+
119
+ send_notification("textDocument/didClose", {
120
+ textDocument: { uri: uri }
121
+ })
122
+ @open_documents.delete(uri)
123
+ end
124
+
125
+ def document_open?(uri)
126
+ @open_documents.key?(uri)
127
+ end
128
+
129
+ # Completion
130
+
131
+ def completion(uri:, line:, character:, context: nil, &callback)
132
+ return unless @initialized
133
+
134
+ params = {
135
+ textDocument: { uri: uri },
136
+ position: { line: line, character: character }
137
+ }
138
+ params[:context] = context if context
139
+
140
+ send_request("textDocument/completion", params) do |result, error|
141
+ if error
142
+ callback.call(nil, error) if callback
143
+ else
144
+ items = normalize_completion_result(result)
145
+ callback.call(items, nil) if callback
146
+ end
147
+ end
148
+ end
149
+
150
+ # Signature Help
151
+
152
+ def signature_help(uri:, line:, character:, context: nil, &callback)
153
+ return unless @initialized
154
+
155
+ params = {
156
+ textDocument: { uri: uri },
157
+ position: { line: line, character: character }
158
+ }
159
+ params[:context] = context if context
160
+
161
+ send_request("textDocument/signatureHelp", params) do |result, error|
162
+ callback.call(result, error) if callback
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def initialize_server_sync
169
+ @request_id += 1
170
+ id = @request_id
171
+
172
+ message = {
173
+ jsonrpc: "2.0",
174
+ id: id,
175
+ method: "initialize",
176
+ params: {
177
+ processId: Process.pid,
178
+ rootUri: "file://#{@root_path}",
179
+ rootPath: @root_path,
180
+ workspaceFolders: @workspace_folders.map { |path|
181
+ { uri: "file://#{path}", name: File.basename(path) }
182
+ },
183
+ capabilities: client_capabilities,
184
+ trace: "off"
185
+ }
186
+ }
187
+
188
+ write_message(message)
189
+
190
+ # Check if process is running
191
+ unless @wait_thr&.alive?
192
+ stderr_output = @stderr&.read rescue ""
193
+ Utils.message("LSP server failed to start: #{stderr_output.strip}")
194
+ cleanup
195
+ return
196
+ end
197
+
198
+ # Read messages synchronously until we get the initialize response.
199
+ # Server requests (e.g. window/workDoneProgress/create) that arrive
200
+ # before the response are handled inline.
201
+ timeout = Time.now + 5 # 5 second timeout
202
+ loop do
203
+ if Time.now > timeout
204
+ stderr_output = @stderr&.read_nonblock(1000, exception: false) rescue ""
205
+ Utils.message("LSP initialization timeout. stderr: #{stderr_output}")
206
+ cleanup
207
+ return
208
+ end
209
+
210
+ # Check if data is available to read (non-blocking check)
211
+ readable, = IO.select([@stdout], nil, nil, 0.1)
212
+ next unless readable
213
+
214
+ msg = read_message
215
+ unless msg
216
+ # Check if process died
217
+ unless @wait_thr&.alive?
218
+ stderr_output = @stderr&.read rescue ""
219
+ Utils.message("LSP server died: #{stderr_output.strip}")
220
+ cleanup
221
+ return
222
+ end
223
+ next
224
+ end
225
+
226
+ if msg.key?("id") && msg["id"] == id
227
+ # This is the initialize response
228
+ if msg.key?("error")
229
+ Utils.message(
230
+ "LSP initialization failed: #{msg["error"]["message"]}"
231
+ )
232
+ cleanup
233
+ else
234
+ @server_capabilities = msg["result"]["capabilities"] || {}
235
+ @initialized = true
236
+ send_notification("initialized", {})
237
+ Utils.message("LSP server #{@server_name} initialized")
238
+ end
239
+ return
240
+ elsif msg.key?("id") && msg.key?("method")
241
+ # Server request during initialization - handle it
242
+ handle_server_request(msg)
243
+ end
244
+ # Skip notifications during initialization
245
+ end
246
+ end
247
+
248
+ def client_capabilities
249
+ {
250
+ textDocument: {
251
+ completion: {
252
+ completionItem: {
253
+ snippetSupport: false,
254
+ deprecatedSupport: true,
255
+ labelDetailsSupport: true
256
+ },
257
+ completionItemKind: {
258
+ valueSet: (1..25).to_a
259
+ },
260
+ contextSupport: true
261
+ },
262
+ signatureHelp: {
263
+ signatureInformation: {
264
+ documentationFormat: ["plaintext"],
265
+ parameterInformation: {
266
+ labelOffsetSupport: true
267
+ }
268
+ },
269
+ contextSupport: true
270
+ },
271
+ synchronization: {
272
+ didSave: true,
273
+ willSave: false,
274
+ willSaveWaitUntil: false,
275
+ dynamicRegistration: false
276
+ }
277
+ },
278
+ workspace: {
279
+ workspaceFolders: true
280
+ }
281
+ }
282
+ end
283
+
284
+ def shutdown
285
+ return unless @initialized
286
+
287
+ shutdown_cv = ConditionVariable.new
288
+ send_request("shutdown", nil) do |_result, _error|
289
+ @mutex.synchronize do
290
+ @initialized = false
291
+ shutdown_cv.signal
292
+ end
293
+ end
294
+
295
+ # Wait for shutdown response with timeout
296
+ @mutex.synchronize do
297
+ shutdown_cv.wait(@mutex, 3) if @initialized
298
+ end
299
+ end
300
+
301
+ def exit_server
302
+ send_notification("exit", nil)
303
+ end
304
+
305
+ def cleanup
306
+ @mutex.synchronize do
307
+ @running = false
308
+ @initialized = false
309
+ @pending_requests.clear
310
+ @open_documents.clear
311
+ end
312
+
313
+ # Close IO streams first so reader thread exits naturally
314
+ @stdin&.close rescue nil
315
+ @stdout&.close rescue nil
316
+ @stderr&.close rescue nil
317
+ @stdin = @stdout = @stderr = nil
318
+
319
+ # Wait for reader thread to finish, then force kill if needed
320
+ if @reader_thread && @reader_thread != Thread.current
321
+ @reader_thread.join(1)
322
+ @reader_thread.kill if @reader_thread.alive?
323
+ end
324
+ @reader_thread = nil
325
+ end
326
+
327
+ def send_request(method, params, &callback)
328
+ @mutex.synchronize do
329
+ @request_id += 1
330
+ id = @request_id
331
+
332
+ message = {
333
+ jsonrpc: "2.0",
334
+ id: id,
335
+ method: method,
336
+ params: params
337
+ }
338
+
339
+ @pending_requests[id] = callback if callback
340
+ write_message(message)
341
+ id
342
+ end
343
+ end
344
+
345
+ def send_notification(method, params)
346
+ @mutex.synchronize do
347
+ message = {
348
+ jsonrpc: "2.0",
349
+ method: method,
350
+ params: params
351
+ }
352
+ write_message(message)
353
+ end
354
+ end
355
+
356
+ # NOTE: Callers must hold @mutex when calling this method.
357
+ def write_message(message)
358
+ return unless @stdin && !@stdin.closed?
359
+
360
+ json = message.to_json
361
+ header = "Content-Length: #{json.bytesize}\r\n\r\n"
362
+
363
+ @stdin.write(header)
364
+ @stdin.write(json)
365
+ @stdin.flush
366
+ rescue IOError, Errno::EPIPE => e
367
+ Utils.message("LSP write error: #{e.message}")
368
+ @running = false
369
+ @initialized = false
370
+ end
371
+
372
+ def start_reader_thread
373
+ @reader_thread = Thread.new do
374
+ read_messages
375
+ rescue StandardError => e
376
+ Utils.foreground do
377
+ Utils.message("LSP reader error: #{e.message}")
378
+ end
379
+ cleanup
380
+ end
381
+ end
382
+
383
+ def read_messages
384
+ while @running && @stdout && !@stdout.closed?
385
+ message = read_message
386
+ break unless message
387
+
388
+ Utils.foreground do
389
+ handle_message(message)
390
+ end
391
+ end
392
+ end
393
+
394
+ def read_message
395
+ # Read headers
396
+ headers = {}
397
+ while (line = @stdout.gets)
398
+ line = line.strip
399
+ break if line.empty?
400
+
401
+ if line =~ /\A([^:]+):\s*(.+)\z/
402
+ headers[$1] = $2
403
+ end
404
+ end
405
+
406
+ return nil if headers.empty?
407
+
408
+ # Read content
409
+ content_length = headers["Content-Length"]&.to_i
410
+ return nil unless content_length && content_length > 0
411
+
412
+ content = @stdout.read(content_length)
413
+ return nil unless content
414
+
415
+ JSON.parse(content)
416
+ rescue JSON::ParserError => e
417
+ Utils.foreground do
418
+ Utils.message("LSP JSON parse error: #{e.message}")
419
+ end
420
+ nil
421
+ rescue IOError
422
+ nil
423
+ end
424
+
425
+ def handle_message(message)
426
+ if message.key?("id") && message.key?("method")
427
+ # Request from server
428
+ handle_server_request(message)
429
+ elsif message.key?("id")
430
+ # Response to our request
431
+ handle_response(message)
432
+ else
433
+ # Notification from server
434
+ handle_notification(message)
435
+ end
436
+ end
437
+
438
+ def handle_response(message)
439
+ id = message["id"]
440
+ callback = @mutex.synchronize { @pending_requests.delete(id) }
441
+ return unless callback
442
+
443
+ if message.key?("error")
444
+ callback.call(nil, message["error"])
445
+ else
446
+ callback.call(message["result"], nil)
447
+ end
448
+ end
449
+
450
+ def handle_server_request(message)
451
+ # Handle server-initiated requests
452
+ id = message["id"]
453
+ method = message["method"]
454
+
455
+ case method
456
+ when "window/workDoneProgress/create"
457
+ # Accept progress token creation
458
+ send_response(id, nil, nil)
459
+ when "client/registerCapability"
460
+ # Accept capability registration
461
+ send_response(id, nil, nil)
462
+ when "workspace/workspaceFolders"
463
+ folders = @workspace_folders.map { |path|
464
+ { uri: "file://#{path}", name: File.basename(path) }
465
+ }
466
+ send_response(id, folders, nil)
467
+ else
468
+ # Unknown request - respond with method not found
469
+ send_response(id, nil, {
470
+ code: -32601,
471
+ message: "Method not found: #{method}"
472
+ })
473
+ end
474
+ end
475
+
476
+ def handle_notification(message)
477
+ method = message["method"]
478
+ params = message["params"]
479
+
480
+ case method
481
+ when "window/logMessage", "window/showMessage"
482
+ type = params["type"]
483
+ text = params["message"]
484
+ # Log messages (type: 1=Error, 2=Warning, 3=Info, 4=Log)
485
+ if type <= 2
486
+ Utils.message("[LSP] #{text}")
487
+ end
488
+ when "textDocument/publishDiagnostics"
489
+ # Could be used for error highlighting in the future
490
+ end
491
+ end
492
+
493
+ def send_response(id, result, error)
494
+ @mutex.synchronize do
495
+ message = {
496
+ jsonrpc: "2.0",
497
+ id: id
498
+ }
499
+
500
+ if error
501
+ message[:error] = error
502
+ else
503
+ message[:result] = result
504
+ end
505
+
506
+ write_message(message)
507
+ end
508
+ end
509
+
510
+ def normalize_completion_result(result)
511
+ return [] if result.nil?
512
+
513
+ items = if result.is_a?(Array)
514
+ result
515
+ elsif result.is_a?(Hash) && result["items"]
516
+ result["items"]
517
+ else
518
+ []
519
+ end
520
+
521
+ items.map { |item| normalize_completion_item(item) }
522
+ end
523
+
524
+ def normalize_completion_item(item)
525
+ {
526
+ label: item["label"],
527
+ insert_text: item["insertText"] || item["textEdit"]&.dig("newText") || item["label"],
528
+ detail: item["detail"],
529
+ kind: completion_item_kind_name(item["kind"]),
530
+ sort_text: item["sortText"] || item["label"],
531
+ filter_text: item["filterText"] || item["label"]
532
+ }
533
+ end
534
+
535
+ COMPLETION_ITEM_KINDS = {
536
+ 1 => "Text",
537
+ 2 => "Method",
538
+ 3 => "Function",
539
+ 4 => "Constructor",
540
+ 5 => "Field",
541
+ 6 => "Variable",
542
+ 7 => "Class",
543
+ 8 => "Interface",
544
+ 9 => "Module",
545
+ 10 => "Property",
546
+ 11 => "Unit",
547
+ 12 => "Value",
548
+ 13 => "Enum",
549
+ 14 => "Keyword",
550
+ 15 => "Snippet",
551
+ 16 => "Color",
552
+ 17 => "File",
553
+ 18 => "Reference",
554
+ 19 => "Folder",
555
+ 20 => "EnumMember",
556
+ 21 => "Constant",
557
+ 22 => "Struct",
558
+ 23 => "Event",
559
+ 24 => "Operator",
560
+ 25 => "TypeParameter"
561
+ }.freeze
562
+
563
+ def completion_item_kind_name(kind)
564
+ COMPLETION_ITEM_KINDS[kind]
565
+ end
566
+ end
567
+ end
568
+ end
@@ -0,0 +1,138 @@
1
+ module Textbringer
2
+ module LSP
3
+ class ServerRegistry
4
+ # Project root marker files/directories
5
+ PROJECT_ROOT_MARKERS = %w[
6
+ .git
7
+ .hg
8
+ .svn
9
+ Gemfile
10
+ package.json
11
+ Cargo.toml
12
+ go.mod
13
+ setup.py
14
+ pyproject.toml
15
+ Makefile
16
+ CMakeLists.txt
17
+ ].freeze
18
+
19
+ @server_configs = []
20
+ @clients = {}
21
+
22
+ class << self
23
+ attr_reader :server_configs
24
+
25
+ def register(language_id, command:, args: [], file_patterns: [], interpreter_patterns: [], mode: nil)
26
+ config = ServerConfig.new(
27
+ language_id: language_id,
28
+ command: command,
29
+ args: args,
30
+ file_patterns: file_patterns,
31
+ interpreter_patterns: interpreter_patterns,
32
+ mode: mode
33
+ )
34
+ @server_configs << config
35
+ config
36
+ end
37
+
38
+ def unregister(language_id)
39
+ @server_configs.reject! { |c| c.language_id == language_id }
40
+ # Stop any running clients for this language
41
+ @clients.delete_if do |key, client|
42
+ if key.start_with?("#{language_id}:")
43
+ client.stop rescue nil
44
+ true
45
+ else
46
+ false
47
+ end
48
+ end
49
+ end
50
+
51
+ def find_config_for_buffer(buffer)
52
+ mode_name = buffer.mode&.name
53
+
54
+ @server_configs.find do |config|
55
+ if config.mode && mode_name
56
+ config.mode == mode_name
57
+ elsif buffer.file_name && !config.file_patterns.empty?
58
+ config.file_patterns.any? { |pattern| pattern.match?(buffer.file_name) }
59
+ else
60
+ false
61
+ end
62
+ end
63
+ end
64
+
65
+ def get_client_for_buffer(buffer)
66
+ config = find_config_for_buffer(buffer)
67
+ return nil unless config
68
+
69
+ root_path = find_project_root(buffer.file_name || Dir.pwd)
70
+ client_key = "#{config.language_id}:#{root_path}"
71
+
72
+ @clients[client_key] ||= begin
73
+ client = Client.new(
74
+ command: config.command,
75
+ args: config.args,
76
+ root_path: root_path,
77
+ server_name: config.language_id
78
+ )
79
+ client.start
80
+ client
81
+ end
82
+ end
83
+
84
+ def stop_client_for_buffer(buffer)
85
+ config = find_config_for_buffer(buffer)
86
+ return unless config
87
+
88
+ root_path = find_project_root(buffer.file_name || Dir.pwd)
89
+ client_key = "#{config.language_id}:#{root_path}"
90
+
91
+ client = @clients.delete(client_key)
92
+ client&.stop
93
+ end
94
+
95
+ def stop_all_clients
96
+ @clients.each_value do |client|
97
+ client.stop rescue nil
98
+ end
99
+ @clients.clear
100
+ end
101
+
102
+ def find_project_root(file_path)
103
+ dir = File.dirname(File.expand_path(file_path))
104
+
105
+ while dir != "/"
106
+ if PROJECT_ROOT_MARKERS.any? { |marker| File.exist?(File.join(dir, marker)) }
107
+ return dir
108
+ end
109
+ parent = File.dirname(dir)
110
+ break if parent == dir
111
+ dir = parent
112
+ end
113
+
114
+ # Fallback to the file's directory
115
+ File.dirname(File.expand_path(file_path))
116
+ end
117
+
118
+ def language_id_for_buffer(buffer)
119
+ config = find_config_for_buffer(buffer)
120
+ config&.language_id
121
+ end
122
+ end
123
+ end
124
+
125
+ class ServerConfig
126
+ attr_reader :language_id, :command, :args, :file_patterns, :interpreter_patterns, :mode
127
+
128
+ def initialize(language_id:, command:, args:, file_patterns:, interpreter_patterns:, mode:)
129
+ @language_id = language_id
130
+ @command = command
131
+ @args = args
132
+ @file_patterns = file_patterns
133
+ @interpreter_patterns = interpreter_patterns
134
+ @mode = mode
135
+ end
136
+ end
137
+ end
138
+ end
@@ -24,7 +24,9 @@ module Textbringer
24
24
 
25
25
  def self.define_generic_command(name, **options)
26
26
  command_name = (name.to_s + "_command").intern
27
- define_command(command_name, **options) do |*args|
27
+ define_command(command_name,
28
+ source_location_proc: -> { Buffer.current.mode.method(name).source_location rescue nil },
29
+ **options) do |*args|
28
30
  begin
29
31
  Buffer.current.mode.send(name, *args)
30
32
  rescue NoMethodError => e