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 +4 -4
- data/LICENSE +26 -0
- data/README.md +95 -0
- data/exe/kotoshu-lsp +7 -0
- data/lib/kotoshu/lsp/server.rb +513 -0
- data/lib/kotoshu/lsp/version.rb +7 -0
- data/lib/kotoshu/lsp.rb +13 -0
- metadata +15 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7af47ef2f73700181e352546f4e00ab0df3480bb4b1d96e3a60c2afe71ceb5da
|
|
4
|
+
data.tar.gz: 66a3bc0f8e1ad17e0bf63c0e221974034d05974f6d78da92757efb1d57aa57b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,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
|
data/lib/kotoshu/lsp.rb
ADDED
|
@@ -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.
|
|
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:
|
|
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:
|
|
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: []
|