kotoshu-lsp 0.1.0 → 0.1.1

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: 8896f868dc8b62b6c4b009a8a66c7f1f1f66345c1810d7ead58c1ac6b92a22ed
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 7af47ef2f73700181e352546f4e00ab0df3480bb4b1d96e3a60c2afe71ceb5da
4
+ data.tar.gz: 66a3bc0f8e1ad17e0bf63c0e221974034d05974f6d78da92757efb1d57aa57b3
5
5
  SHA512:
6
- metadata.gz: 81369a5e80fd0fe94e41aa1baef80a8d099f6e5e26b6c8a67ead93ddd4b7ec6242e1aea5604419a9aaf9586f748acd12d8793a8afd15e9a12fc15288cffb4d3f
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: d41c5c06ed3ed8cb2158ecc7b8882c42cda0aa8b43d6a9b52902e5826067a492ed424a1ac5a38bec91be2fa82e7834db8fe01430c1696af5d8d9a133e0eef83f
7
+ data.tar.gz: 032c0037ccdb4daceae90d850accde1666ceb35f31057272928375a752db48584930b17f87d421f8bb6eb39b69b0e24955a7294f7814538bdfe9d8b75321d421
data/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2025-2026, Kotoshu contributors
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
+
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # kotoshu-lsp
2
+
3
+ Language Server Protocol server wrapping the [Kotoshu](https://github.com/kotoshu/kotoshu) spell checker.
4
+
5
+ ## Status
6
+
7
+ MVP. Implements `initialize`, `textDocument/didOpen|didChange|didClose`,
8
+ `textDocument/publishDiagnostics`, `textDocument/codeAction`,
9
+ `textDocument/hover`. Wraps `Kotoshu.check` directly.
10
+
11
+ Lives in the `kotoshu/` workspace as a sibling of the `kotoshu` library
12
+ gem. See `TODO.impl/60-lsp-server.md` for the full plan.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ gem install kotoshu-lsp
18
+ # or from source:
19
+ cd kotoshu-lsp && bundle install && bundle exec rake install
20
+ ```
21
+
22
+ Requires the `kotoshu` gem (auto-installed as a runtime dep).
23
+
24
+ ## Editor wiring
25
+
26
+ ### Neovim (built-in LSP)
27
+
28
+ ```lua
29
+ require('lspconfig').kotoshu.setup({
30
+ cmd = { 'kotoshu-lsp' },
31
+ filetypes = { 'text', 'markdown', 'asciidoc' },
32
+ })
33
+ ```
34
+
35
+ If `lspconfig` does not yet have a `kotoshu` entry, wire it manually:
36
+
37
+ ```lua
38
+ vim.lsp.start({
39
+ name = 'kotoshu',
40
+ cmd = { 'kotoshu-lsp' },
41
+ root_dir = vim.fs.dirname(vim.fs.find({ '.git' }, { upward = true })[1]),
42
+ })
43
+ ```
44
+
45
+ ### VS Code
46
+
47
+ Install the `kotoshu-lsp` extension (when published — `61-editor-ecosystem.md`).
48
+ Or wire it manually via `languageServerExample` extension boilerplate
49
+ with ` LanguageId: ['plaintext', 'markdown']` and
50
+ `command: 'kotoshu-lsp'`.
51
+
52
+ ### Emacs (`lsp-mode`)
53
+
54
+ ```elisp
55
+ (with-eval-after-load 'lsp-mode
56
+ (add-to-list 'lsp-language-id-configuration
57
+ '(text-mode . "plaintext"))
58
+ (lsp-register-client
59
+ (make-lsp-client :new-connection (lsp-stdio-connection '("kotoshu-lsp"))
60
+ :activation-fn (lsp-activate-on "plaintext" "markdown")
61
+ :server-id 'kotoshu)))
62
+ ```
63
+
64
+ ### Emacs (`eglot`)
65
+
66
+ ```elisp
67
+ (add-to-list 'eglot-server-programs
68
+ '((text-mode markdown-mode) . ("kotoshu-lsp")))
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ | Env var | Default | Purpose |
74
+ |---|---|---|
75
+ | `KOTOSHU_LSP_LOG` | unset | Path to write a per-session log |
76
+ | `KOTOSHU_LSP_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
77
+ | `KOTOSHU_OFFLINE` | unset | Never trigger downloads; misspelled words still flagged against cached dictionaries |
78
+
79
+ ## Protocol surface
80
+
81
+ | Method | Status |
82
+ |---|---|
83
+ | `initialize` | ✅ |
84
+ | `initialized` / `shutdown` / `exit` | ✅ |
85
+ | `textDocument/didOpen` | ✅ publishes diagnostics |
86
+ | `textDocument/didChange` (full sync) | ✅ republishes |
87
+ | `textDocument/didClose` | ✅ clears diagnostics |
88
+ | `textDocument/publishDiagnostics` | ✅ one diag per misspelled word, top-N suggestions in `data.suggestions` |
89
+ | `textDocument/codeAction` | ✅ one quickfix per suggestion + add-to-personal-dictionary |
90
+ | `textDocument/hover` | ✅ shows top-N suggestions on a flagged word |
91
+ | `textDocument/completion` | not implemented (returns `nil`) |
92
+
93
+ ## License
94
+
95
+ BSD-2-Clause, same as Kotoshu.
data/exe/kotoshu-lsp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "kotoshu/lsp"
6
+
7
+ Kotoshu::Lsp.run
@@ -0,0 +1,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "kotoshu"
6
+
7
+ module Kotoshu
8
+ module Lsp
9
+ module Protocol
10
+ DIAGNOSTIC_SEVERITY = {
11
+ error: 1,
12
+ warning: 2,
13
+ information: 3,
14
+ hint: 4
15
+ }.freeze
16
+
17
+ COMPLETION_TRIGGER_KIND = { invoked: 0, character: 2 }.freeze
18
+
19
+ module_function
20
+
21
+ def range_from_offsets(text, start_offset, end_offset)
22
+ line_start = line_of(text, start_offset)
23
+ char_start = start_offset - line_begin(text, start_offset)
24
+ line_end = line_of(text, end_offset)
25
+ char_end = end_offset - line_begin(text, end_offset)
26
+ {
27
+ start: { line: line_start, character: char_start },
28
+ end: { line: line_end, character: char_end }
29
+ }
30
+ end
31
+
32
+ def line_of(text, offset)
33
+ text[0...offset].count("\n")
34
+ end
35
+
36
+ def line_begin(text, offset)
37
+ return 0 if offset <= 0
38
+
39
+ last_nl = text.rindex("\n", offset - 1)
40
+ last_nl ? last_nl + 1 : 0
41
+ end
42
+ end
43
+
44
+ class DocumentStore
45
+ def initialize
46
+ @docs = {}
47
+ @mutex = Mutex.new
48
+ end
49
+
50
+ def set(uri, text, language_id = nil)
51
+ @mutex.synchronize do
52
+ @docs[uri] = { text: text, language_id: language_id, version: 0 }
53
+ end
54
+ end
55
+
56
+ def update(uri, text, version: nil)
57
+ @mutex.synchronize do
58
+ doc = @docs[uri]
59
+ return unless doc
60
+
61
+ doc[:text] = text
62
+ doc[:version] = version if version
63
+ end
64
+ end
65
+
66
+ def remove(uri)
67
+ @mutex.synchronize { @docs.delete(uri) }
68
+ end
69
+
70
+ def get(uri)
71
+ @mutex.synchronize { @docs[uri] }
72
+ end
73
+
74
+ def text(uri)
75
+ doc = get(uri)
76
+ doc ? doc[:text] : nil
77
+ end
78
+
79
+ # All open [uri, document] pairs, snapshot under the lock.
80
+ def each_open
81
+ @mutex.synchronize { @docs.dup.each { |uri, doc| yield(uri, doc) } }
82
+ end
83
+ end
84
+
85
+ class Checker
86
+ def initialize(logger:)
87
+ @logger = logger
88
+ @spellcheckers = {}
89
+ @spellcheckers_mutex = Mutex.new
90
+ @personal_dictionary_words = Set.new
91
+ @personal_dictionary_mtime = nil
92
+ end
93
+
94
+ def reset
95
+ @spellcheckers_mutex.synchronize { @spellcheckers.clear }
96
+ end
97
+
98
+ def check(text, language:)
99
+ Kotoshu.reset_spellchecker if @spellcheckers.empty?
100
+
101
+ checker = checker_for(language)
102
+ return empty_result(text) unless checker
103
+
104
+ result = checker.check(text)
105
+ filter_personal_dictionary(result)
106
+ result
107
+ rescue Kotoshu::ResourceNotSetupError => e
108
+ @logger.warn("resource not set up for #{language}: #{e.message}")
109
+ nil
110
+ rescue StandardError => e
111
+ @logger.error("check failed: #{e.class}: #{e.message}")
112
+ nil
113
+ end
114
+
115
+ private
116
+
117
+ def checker_for(language)
118
+ @spellcheckers_mutex.synchronize do
119
+ @spellcheckers[language] ||= begin
120
+ bundle = Kotoshu::ResourceManager.resolve(language: language)
121
+ Kotoshu::Spellchecker.new(resource_bundle: bundle)
122
+ rescue Kotoshu::ResourceNotSetupError
123
+ Kotoshu.setup(language)
124
+ bundle = Kotoshu::ResourceManager.resolve(language: language)
125
+ Kotoshu::Spellchecker.new(resource_bundle: bundle)
126
+ end
127
+ end
128
+ rescue StandardError => e
129
+ @logger.warn("could not resolve checker for #{language}: #{e.message}")
130
+ nil
131
+ end
132
+
133
+ def empty_result(_text)
134
+ Kotoshu::Models::Result::DocumentResult.success
135
+ end
136
+
137
+ # Words in the user personal dictionary (~/.config/kotoshu/
138
+ # personal.dic, KOTOSHU_PERSONAL_DIC override) never surface as
139
+ # diagnostics. Reloaded whenever the file mtime changes, so
140
+ # additions from the CLI or an editor take effect on the next
141
+ # check without a server restart.
142
+ def filter_personal_dictionary(result)
143
+ return result unless result.respond_to?(:errors) && result.errors
144
+
145
+ personal = personal_dictionary_words
146
+ return result if personal.empty?
147
+
148
+ result.errors.reject! { |err| personal.include?(err.word.to_s.downcase) }
149
+ result
150
+ end
151
+
152
+ def personal_dictionary_words
153
+ path = Kotoshu::PersonalDictionary.file_path
154
+ mtime = File.exist?(path) ? File.mtime(path) : nil
155
+ if mtime != @personal_dictionary_mtime
156
+ @personal_dictionary_words =
157
+ if mtime
158
+ Kotoshu::PersonalDictionary.words.map(&:downcase).to_set
159
+ else
160
+ Set.new
161
+ end
162
+ @personal_dictionary_mtime = mtime
163
+ end
164
+ @personal_dictionary_words
165
+ end
166
+ end
167
+
168
+ class DiagnosticsBuilder
169
+ MAX_SUGGESTIONS_PER_DIAG = 5
170
+
171
+ def self.from_result(document_result, full_text, source: "kotoshu")
172
+ return [] unless document_result&.errors
173
+
174
+ document_result.errors.map do |err|
175
+ from_word_error(err, full_text, source: source)
176
+ end
177
+ end
178
+
179
+ def self.from_word_error(word_error, full_text, source: "kotoshu")
180
+ start_offset = word_error.position || 0
181
+ end_offset = start_offset + word_error.word.length
182
+ range = Protocol.range_from_offsets(full_text, start_offset, end_offset)
183
+ suggestions = word_error.suggestions.first(MAX_SUGGESTIONS_PER_DIAG).map(&:word)
184
+
185
+ {
186
+ range: range,
187
+ severity: Protocol::DIAGNOSTIC_SEVERITY[:warning],
188
+ code: "misspelled",
189
+ source: source,
190
+ message: "Misspelled word: #{word_error.word}",
191
+ data: { suggestions: suggestions }
192
+ }
193
+ end
194
+ end
195
+
196
+ class Server
197
+ ADD_TO_PERSONAL_DICTIONARY_COMMAND = "kotoshu.addToPersonalDictionary"
198
+
199
+ CAPABILITIES = {
200
+ textDocumentSync: { openClose: true, change: 1, save: false },
201
+ completionProvider: { resolveProvider: false, triggerCharacters: [] },
202
+ codeActionProvider: true,
203
+ hoverProvider: true,
204
+ executeCommandProvider: { commands: [ADD_TO_PERSONAL_DICTIONARY_COMMAND] }
205
+ }.freeze
206
+
207
+ attr_reader :documents, :checker
208
+
209
+ def initialize(input: $stdin, output: $stdout, logger: nil)
210
+ @input = input
211
+ @output = output
212
+ @logger = logger || self.class.default_logger
213
+ @documents = DocumentStore.new
214
+ @checker = Checker.new(logger: @logger)
215
+ @shutdown_requested = false
216
+ @output_mutex = Mutex.new
217
+ end
218
+
219
+ def self.default_logger
220
+ log_path = ENV.fetch("KOTOSHU_LSP_LOG", nil)
221
+ logger = log_path ? Logger.new(File.open(log_path, "a")) : Logger.new(IO::NULL)
222
+ logger.level = ENV.fetch("KOTOSHU_LSP_LOG_LEVEL", "info").to_i
223
+ logger
224
+ end
225
+
226
+ def run
227
+ @logger.info("kotoshu-lsp server starting")
228
+ loop do
229
+ message = read_message
230
+ break if message.nil?
231
+ break if @shutdown_requested && message["method"] != "exit"
232
+
233
+ handle(message)
234
+ end
235
+ @logger.info("kotoshu-lsp server exiting")
236
+ rescue Interrupt
237
+ @logger.info("interrupted")
238
+ rescue StandardError => e
239
+ @logger.error("fatal: #{e.class}: #{e.message}")
240
+ @logger.error(e.backtrace.first(15).join("\n"))
241
+ raise
242
+ end
243
+
244
+ private
245
+
246
+ def handle(message)
247
+ method_name = message["method"]
248
+ params = message["params"] || {}
249
+ id = message["id"]
250
+
251
+ case method_name
252
+ when "initialize" then respond(id, handle_initialize(params))
253
+ when "initialized" then log(:info, "client initialized")
254
+ when "shutdown" then @shutdown_requested = true; respond(id, nil)
255
+ when "exit" then @shutdown_requested = true
256
+ when "textDocument/didOpen" then handle_did_open(params)
257
+ when "textDocument/didChange" then handle_did_change(params)
258
+ when "textDocument/didClose" then handle_did_close(params)
259
+ when "textDocument/codeAction" then respond(id, handle_code_action(params))
260
+ when "textDocument/hover" then respond(id, handle_hover(params))
261
+ when "textDocument/completion" then respond(id, nil)
262
+ when "workspace/executeCommand" then respond(id, handle_execute_command(params))
263
+ else
264
+ respond_error(id, -32601, "Method not found: #{method_name}") if id
265
+ end
266
+ end
267
+
268
+ def handle_initialize(params)
269
+ {
270
+ capabilities: CAPABILITIES,
271
+ serverInfo: { name: "kotoshu-lsp", version: Kotoshu::VERSION }
272
+ }
273
+ end
274
+
275
+ def handle_did_open(params)
276
+ td = params["textDocument"] || {}
277
+ uri = td["uri"]
278
+ text = td["text"] || ""
279
+ language_id = td["languageId"]
280
+ @documents.set(uri, text, language_id)
281
+
282
+ publish_diagnostics(uri, text, language_id_for(uri, language_id))
283
+ end
284
+
285
+ def handle_did_change(params)
286
+ td = params["textDocument"] || {}
287
+ uri = td["uri"]
288
+ changes = params["contentChanges"] || []
289
+ new_text = changes.last&.fetch("text")
290
+ return unless new_text
291
+
292
+ @documents.update(uri, new_text, version: td["version"])
293
+ doc = @documents.get(uri)
294
+ publish_diagnostics(uri, new_text, language_id_for(uri, doc&.fetch(:language_id, nil)))
295
+ end
296
+
297
+ def handle_did_close(params)
298
+ uri = (params["textDocument"] || {})["uri"]
299
+ @documents.remove(uri)
300
+ send_notification("textDocument/publishDiagnostics", { uri: uri, diagnostics: [] })
301
+ end
302
+
303
+ def publish_diagnostics(uri, text, language)
304
+ result = @checker.check(text, language: language)
305
+ diagnostics = result ? DiagnosticsBuilder.from_result(result, text) : []
306
+ send_notification("textDocument/publishDiagnostics",
307
+ { uri: uri, diagnostics: diagnostics })
308
+ end
309
+
310
+ # Server-side execution of the add-to-personal-dictionary
311
+ # quickfix. Clients that implement the command locally (the VS
312
+ # Code extension does) shadow this; every other LSP client gets
313
+ # it for free. Adding republishes diagnostics for all open
314
+ # documents so the word stops being flagged immediately.
315
+ def handle_execute_command(params)
316
+ case params["command"]
317
+ when ADD_TO_PERSONAL_DICTIONARY_COMMAND
318
+ uri, range = params["arguments"] || []
319
+ word = personal_word_at(uri, range)
320
+ return nil unless word && !word.empty?
321
+
322
+ Kotoshu::PersonalDictionary.add_word(word)
323
+ @checker.reset
324
+ republish_all_diagnostics
325
+ nil
326
+ else
327
+ nil
328
+ end
329
+ end
330
+
331
+ def personal_word_at(uri, range)
332
+ text = @documents.text(uri)
333
+ return nil unless text && range.is_a?(Hash)
334
+
335
+ start = range["start"] || {}
336
+ offset = offset_for_position(text, start["line"] || 0, start["character"] || 0)
337
+ return nil unless offset
338
+
339
+ word_at(text, offset)
340
+ end
341
+
342
+ def republish_all_diagnostics
343
+ @documents.each_open do |uri, doc|
344
+ language = language_id_for(uri, doc[:language_id])
345
+ publish_diagnostics(uri, doc[:text] || "", language)
346
+ end
347
+ end
348
+
349
+ def handle_code_action(params)
350
+ td = params["textDocument"] || {}
351
+ uri = td["uri"]
352
+ context = params["context"] || {}
353
+ range = params["range"] || {}
354
+
355
+ diags = context["diagnostics"] || []
356
+ actions = []
357
+ diags.each do |diag|
358
+ suggestions = (diag["data"] && diag["data"]["suggestions"]) || []
359
+ suggestions.each_with_index do |word, idx|
360
+ actions << code_action_replace(uri, diag["range"], word, idx.zero?)
361
+ end
362
+ actions << code_action_add_to_personal_dict(uri, diag["range"])
363
+ end
364
+ actions
365
+ end
366
+
367
+ def code_action_replace(uri, range, replacement, preferred)
368
+ action = {
369
+ title: "Kotoshu: change to \"#{replacement}\"",
370
+ kind: "quickfix",
371
+ edit: {
372
+ changes: { uri => [{ range: range, newText: replacement }] }
373
+ }
374
+ }
375
+ action[:isPreferred] = true if preferred
376
+ action
377
+ end
378
+
379
+ def code_action_add_to_personal_dict(uri, range)
380
+ {
381
+ title: "Kotoshu: add word to personal dictionary",
382
+ kind: "quickfix",
383
+ command: {
384
+ title: "Add to personal dictionary",
385
+ command: ADD_TO_PERSONAL_DICTIONARY_COMMAND,
386
+ arguments: [uri, range]
387
+ }
388
+ }
389
+ end
390
+
391
+ def handle_hover(params)
392
+ td = params["textDocument"] || {}
393
+ uri = td["uri"]
394
+ pos = params["position"] || {}
395
+ text = @documents.text(uri)
396
+ return nil unless text
397
+
398
+ offset = offset_for_position(text, pos["line"] || 0, pos["character"] || 0)
399
+ return nil unless offset
400
+
401
+ word = word_at(text, offset)
402
+ return nil unless word && !word.empty?
403
+
404
+ result = @checker.check(word, language: language_id_for(uri, @documents.get(uri)&.fetch(:language_id, nil)))
405
+ err = result&.errors&.first
406
+ return nil unless err
407
+
408
+ {
409
+ contents: [
410
+ "Misspelled: `#{err.word}`",
411
+ "Suggestions: #{err.suggestions.first(5).map(&:word).join(', ')}"
412
+ ]
413
+ }
414
+ end
415
+
416
+ def word_at(text, offset)
417
+ return "" if offset >= text.length || offset.negative?
418
+
419
+ start = offset
420
+ start -= 1 while start > 0 && word_char?(text[start - 1])
421
+ finish = offset
422
+ finish += 1 while finish < text.length && word_char?(text[finish])
423
+ text[start...finish]
424
+ end
425
+
426
+ def word_char?(char)
427
+ char =~ /[A-Za-z']/
428
+ end
429
+
430
+ def offset_for_position(text, line, character)
431
+ line_starts = [0]
432
+ text.each_char.with_index do |c, i|
433
+ line_starts << (i + 1) if c == "\n"
434
+ end
435
+ return nil unless line < line_starts.size
436
+
437
+ line_starts[line] + character
438
+ end
439
+
440
+ def language_id_for(uri, language_id)
441
+ return language_id if language_id && language_id != "plaintext"
442
+
443
+ ext = File.extname(uri || "").delete_prefix(".").downcase
444
+ EXTENSION_TO_LANG.fetch(ext, nil) || "en"
445
+ end
446
+
447
+ EXTENSION_TO_LANG = {
448
+ "md" => "en", "markdown" => "en", "txt" => "en",
449
+ "asciidoc" => "en", "adoc" => "en", "ad" => "en",
450
+ "rb" => "en", "py" => "en", "js" => "en", "ts" => "en",
451
+ "go" => "en", "rs" => "en", "java" => "en"
452
+ }.freeze
453
+
454
+ def read_message
455
+ headers = read_headers
456
+ return nil if headers.empty?
457
+
458
+ content_length = headers["content-length"].to_i
459
+ return nil if content_length.zero?
460
+
461
+ body = @input.read(content_length)
462
+ return nil unless body
463
+
464
+ JSON.parse(body)
465
+ rescue JSON::ParserError => e
466
+ @logger.error("malformed JSON message: #{e.message}")
467
+ retry
468
+ end
469
+
470
+ def read_headers
471
+ headers = {}
472
+ loop do
473
+ line = @input.gets
474
+ return headers if line.nil?
475
+ line = line.chomp
476
+ break if line.empty?
477
+
478
+ key, value = line.split(":", 2)
479
+ headers[key.strip.downcase] = value.strip if value
480
+ end
481
+ headers
482
+ end
483
+
484
+ def respond(id, result)
485
+ return unless id
486
+
487
+ send_message({ jsonrpc: "2.0", id: id, result: result })
488
+ end
489
+
490
+ def respond_error(id, code, message)
491
+ send_message({ jsonrpc: "2.0", id: id, error: { code: code, message: message } })
492
+ end
493
+
494
+ def send_notification(method, params)
495
+ send_message({ jsonrpc: "2.0", method: method, params: params })
496
+ end
497
+
498
+ def send_message(hash)
499
+ payload = JSON.generate(hash)
500
+ header = "Content-Length: #{payload.bytesize}\r\n\r\n"
501
+ @output_mutex.synchronize do
502
+ @output.write(header)
503
+ @output.write(payload)
504
+ @output.flush
505
+ end
506
+ end
507
+
508
+ def log(level, message)
509
+ @logger.public_send(level, message)
510
+ end
511
+ end
512
+ end
513
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kotoshu
4
+ module Lsp
5
+ VERSION = "0.1.1"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lsp/version"
4
+ require_relative "lsp/server"
5
+
6
+ module Kotoshu
7
+ module Lsp
8
+ def self.run(input: $stdin, output: $stdout, logger: nil)
9
+ server = Server.new(input: input, output: output, logger: logger)
10
+ server.run
11
+ end
12
+ end
13
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kotoshu-lsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: kotoshu
@@ -28,10 +29,17 @@ description: Wraps the Kotoshu gem with a JSON-RPC LSP server so every LSP-capab
28
29
  code actions, and hover.
29
30
  email:
30
31
  - open.source@ribose.com
31
- executables: []
32
+ executables:
33
+ - kotoshu-lsp
32
34
  extensions: []
33
35
  extra_rdoc_files: []
34
- files: []
36
+ files:
37
+ - LICENSE
38
+ - README.md
39
+ - exe/kotoshu-lsp
40
+ - lib/kotoshu/lsp.rb
41
+ - lib/kotoshu/lsp/server.rb
42
+ - lib/kotoshu/lsp/version.rb
35
43
  homepage: https://github.com/kotoshu/kotoshu-lsp
36
44
  licenses:
37
45
  - BSD-2-Clause
@@ -40,6 +48,7 @@ metadata:
40
48
  source_code_uri: https://github.com/kotoshu/kotoshu-lsp/tree/main
41
49
  changelog_uri: https://github.com/kotoshu/kotoshu-lsp/blob/main/CHANGELOG.md
42
50
  rubygems_mfa_required: 'true'
51
+ post_install_message:
43
52
  rdoc_options: []
44
53
  require_paths:
45
54
  - lib
@@ -54,7 +63,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
54
63
  - !ruby/object:Gem::Version
55
64
  version: '0'
56
65
  requirements: []
57
- rubygems_version: 4.0.16
66
+ rubygems_version: 3.5.22
67
+ signing_key:
58
68
  specification_version: 4
59
69
  summary: Language Server Protocol server for the Kotoshu spell checker
60
70
  test_files: []