kiwimatecoder 0.1.0__py3-none-any.whl
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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Language-server lifecycle: detect, route by extension, start lazily.
|
|
2
|
+
|
|
3
|
+
Built-in presets cover the common languages and are used when their command is
|
|
4
|
+
on ``PATH``; entries under ``lsp.servers`` override or extend them. Servers are
|
|
5
|
+
started on first use for the language that owns a file's extension, and a failed
|
|
6
|
+
start is recorded so later calls fail fast with a clear reason instead of
|
|
7
|
+
re-spawning. ``shutdown`` stops every live client and clears recorded failures.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from kiwimatecoder import config
|
|
21
|
+
from kiwimatecoder.lsp.client import LspClient, LspError, LspTimeout
|
|
22
|
+
|
|
23
|
+
INITIALIZE_TIMEOUT = 10.0
|
|
24
|
+
|
|
25
|
+
# name -> {command, args, extensions}. The manager also merges user overrides
|
|
26
|
+
# from the ``lsp.servers`` config section on top of these.
|
|
27
|
+
SERVER_PRESETS: dict[str, dict[str, Any]] = {
|
|
28
|
+
"python": {
|
|
29
|
+
"command": "pyright-langserver",
|
|
30
|
+
"args": ["--stdio"],
|
|
31
|
+
"extensions": [".py", ".pyi"],
|
|
32
|
+
},
|
|
33
|
+
"typescript": {
|
|
34
|
+
"command": "typescript-language-server",
|
|
35
|
+
"args": ["--stdio"],
|
|
36
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx"],
|
|
37
|
+
},
|
|
38
|
+
"go": {"command": "gopls", "args": [], "extensions": [".go"]},
|
|
39
|
+
"rust": {"command": "rust-analyzer", "args": [], "extensions": [".rs"]},
|
|
40
|
+
"c/cpp": {
|
|
41
|
+
"command": "clangd",
|
|
42
|
+
"args": [],
|
|
43
|
+
"extensions": [".c", ".h", ".cpp", ".hpp"],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
LSP_DISABLED_MESSAGE = (
|
|
48
|
+
"LSP is disabled. Enable it with /lsp on or "
|
|
49
|
+
"`kiwimatecoder config lsp enable on`."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class LspServerSpec:
|
|
55
|
+
"""The effective command and file extensions for one language server."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
command: str
|
|
59
|
+
args: tuple[str, ...]
|
|
60
|
+
extensions: tuple[str, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _spec(name: str, preset: dict[str, Any]) -> LspServerSpec:
|
|
64
|
+
return LspServerSpec(
|
|
65
|
+
name=name,
|
|
66
|
+
command=str(preset["command"]),
|
|
67
|
+
args=tuple(str(arg) for arg in preset.get("args") or ()),
|
|
68
|
+
extensions=tuple(str(ext) for ext in preset.get("extensions") or ()),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def effective_servers() -> dict[str, LspServerSpec]:
|
|
73
|
+
"""Return presets merged with ``lsp.servers`` overrides (no PATH check).
|
|
74
|
+
|
|
75
|
+
User-configured entries are listed first, so an override or a custom
|
|
76
|
+
server wins extension routing over a built-in preset with the same suffix.
|
|
77
|
+
"""
|
|
78
|
+
specs: dict[str, LspServerSpec] = {}
|
|
79
|
+
for name, override in config.get_lsp()["servers"].items():
|
|
80
|
+
preset = SERVER_PRESETS.get(name)
|
|
81
|
+
if preset is None:
|
|
82
|
+
specs[name] = LspServerSpec(
|
|
83
|
+
name=name,
|
|
84
|
+
command=str(override["command"]),
|
|
85
|
+
args=tuple(str(arg) for arg in override["args"]),
|
|
86
|
+
extensions=tuple(str(ext) for ext in override["extensions"]),
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
specs[name] = LspServerSpec(
|
|
90
|
+
name=name,
|
|
91
|
+
command=str(override["command"] or preset["command"]),
|
|
92
|
+
args=tuple(
|
|
93
|
+
str(arg) for arg in (override["args"] or preset["args"])
|
|
94
|
+
),
|
|
95
|
+
extensions=tuple(
|
|
96
|
+
str(ext)
|
|
97
|
+
for ext in (override["extensions"] or preset["extensions"])
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
for name, preset in SERVER_PRESETS.items():
|
|
101
|
+
if name not in specs:
|
|
102
|
+
specs[name] = _spec(name, preset)
|
|
103
|
+
return specs
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def available_servers() -> dict[str, LspServerSpec]:
|
|
107
|
+
"""Effective servers whose command resolves on ``PATH``."""
|
|
108
|
+
return {
|
|
109
|
+
name: spec
|
|
110
|
+
for name, spec in effective_servers().items()
|
|
111
|
+
if shutil.which(spec.command)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class LspManager:
|
|
116
|
+
"""Owns live LSP clients and routes files to them by extension."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
console: Console | None = None,
|
|
121
|
+
root: str | Path | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._console = console if console is not None else Console()
|
|
124
|
+
self._root = (
|
|
125
|
+
Path(root).expanduser().resolve() if root is not None else Path.cwd()
|
|
126
|
+
)
|
|
127
|
+
self._clients: dict[str, LspClient] = {}
|
|
128
|
+
self._failures: dict[str, str] = {}
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def root(self) -> Path:
|
|
132
|
+
"""The workspace root used for relative paths and server handshakes."""
|
|
133
|
+
return self._root
|
|
134
|
+
|
|
135
|
+
def set_root(self, root: str | Path) -> None:
|
|
136
|
+
"""Point the manager at a new workspace root."""
|
|
137
|
+
self._root = Path(root).expanduser().resolve()
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def clients(self) -> dict[str, LspClient]:
|
|
141
|
+
"""A copy of the language -> live client map."""
|
|
142
|
+
return dict(self._clients)
|
|
143
|
+
|
|
144
|
+
def failure_for(self, language: str) -> str | None:
|
|
145
|
+
"""The recorded start failure for ``language``, if any."""
|
|
146
|
+
return self._failures.get(language)
|
|
147
|
+
|
|
148
|
+
def servers(self) -> dict[str, LspServerSpec]:
|
|
149
|
+
"""Effective servers: presets merged with ``lsp.servers`` overrides."""
|
|
150
|
+
return effective_servers()
|
|
151
|
+
|
|
152
|
+
def available_servers(self) -> dict[str, LspServerSpec]:
|
|
153
|
+
"""Effective servers whose command resolves on ``PATH``."""
|
|
154
|
+
return {
|
|
155
|
+
name: spec
|
|
156
|
+
for name, spec in effective_servers().items()
|
|
157
|
+
if shutil.which(spec.command)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def language_for(self, path: str | Path) -> str | None:
|
|
161
|
+
"""Return the language whose extensions claim ``path``, if any."""
|
|
162
|
+
suffix = Path(path).suffix.lower()
|
|
163
|
+
if not suffix:
|
|
164
|
+
return None
|
|
165
|
+
for name, spec in self.servers().items():
|
|
166
|
+
if suffix in spec.extensions:
|
|
167
|
+
return name
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def diagnostics_for(
|
|
171
|
+
self, path: str | Path, timeout: float | None = None
|
|
172
|
+
) -> list[dict[str, Any]]:
|
|
173
|
+
"""Return diagnostics for ``path``, starting its server on first use."""
|
|
174
|
+
settings = config.get_lsp()
|
|
175
|
+
if not settings["enabled"]:
|
|
176
|
+
raise LspError(LSP_DISABLED_MESSAGE)
|
|
177
|
+
deadline = self._deadline(settings["timeout"], timeout)
|
|
178
|
+
client, resolved = self._prepare(path, deadline)
|
|
179
|
+
client.sync(resolved, _read_text(resolved))
|
|
180
|
+
remaining = max(0.05, deadline - time.monotonic())
|
|
181
|
+
return client.diagnostics(resolved, remaining)
|
|
182
|
+
|
|
183
|
+
def definition(
|
|
184
|
+
self,
|
|
185
|
+
path: str | Path,
|
|
186
|
+
line: int,
|
|
187
|
+
character: int,
|
|
188
|
+
timeout: float | None = None,
|
|
189
|
+
) -> list[dict[str, Any]]:
|
|
190
|
+
"""Return definition locations for a 1-based position."""
|
|
191
|
+
return self._locations(
|
|
192
|
+
"definition", path, line, character, None, timeout
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def references(
|
|
196
|
+
self,
|
|
197
|
+
path: str | Path,
|
|
198
|
+
line: int,
|
|
199
|
+
character: int,
|
|
200
|
+
include_declaration: bool = True,
|
|
201
|
+
timeout: float | None = None,
|
|
202
|
+
) -> list[dict[str, Any]]:
|
|
203
|
+
"""Return reference locations for a 1-based position."""
|
|
204
|
+
return self._locations(
|
|
205
|
+
"references", path, line, character, include_declaration, timeout
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def shutdown(self) -> None:
|
|
209
|
+
"""Stop every live client and clear recorded failures."""
|
|
210
|
+
for language in list(self._clients):
|
|
211
|
+
client = self._clients.pop(language)
|
|
212
|
+
try:
|
|
213
|
+
client.shutdown()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
self._failures.clear()
|
|
217
|
+
|
|
218
|
+
# -- internals ----------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _deadline(configured: float, timeout: float | None) -> float:
|
|
222
|
+
budget = float(configured) if timeout is None else min(
|
|
223
|
+
float(timeout), float(configured)
|
|
224
|
+
)
|
|
225
|
+
return time.monotonic() + max(0.05, budget)
|
|
226
|
+
|
|
227
|
+
def _locations(
|
|
228
|
+
self,
|
|
229
|
+
action: str,
|
|
230
|
+
path: str | Path,
|
|
231
|
+
line: int,
|
|
232
|
+
character: int,
|
|
233
|
+
include_declaration: bool | None,
|
|
234
|
+
timeout: float | None,
|
|
235
|
+
) -> list[dict[str, Any]]:
|
|
236
|
+
settings = config.get_lsp()
|
|
237
|
+
if not settings["enabled"]:
|
|
238
|
+
raise LspError(LSP_DISABLED_MESSAGE)
|
|
239
|
+
deadline = self._deadline(settings["timeout"], timeout)
|
|
240
|
+
client, resolved = self._prepare(path, deadline)
|
|
241
|
+
client.sync(resolved, _read_text(resolved))
|
|
242
|
+
remaining = max(0.05, deadline - time.monotonic())
|
|
243
|
+
if action == "definition":
|
|
244
|
+
return client.definition(
|
|
245
|
+
resolved, int(line) - 1, int(character) - 1, timeout=remaining
|
|
246
|
+
)
|
|
247
|
+
return client.references(
|
|
248
|
+
resolved,
|
|
249
|
+
int(line) - 1,
|
|
250
|
+
int(character) - 1,
|
|
251
|
+
include_declaration=bool(include_declaration),
|
|
252
|
+
timeout=remaining,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _prepare(
|
|
256
|
+
self, path: str | Path, deadline: float
|
|
257
|
+
) -> tuple[LspClient, Path]:
|
|
258
|
+
resolved = Path(path).expanduser()
|
|
259
|
+
if not resolved.is_absolute():
|
|
260
|
+
resolved = self._root / resolved
|
|
261
|
+
resolved = resolved.resolve()
|
|
262
|
+
if not resolved.is_file():
|
|
263
|
+
raise LspError(f"File not found: {path}")
|
|
264
|
+
language = self.language_for(resolved)
|
|
265
|
+
if language is None:
|
|
266
|
+
raise LspError(
|
|
267
|
+
f"No language server is configured for {resolved.name}."
|
|
268
|
+
)
|
|
269
|
+
return self._client_for(language, deadline), resolved
|
|
270
|
+
|
|
271
|
+
def _client_for(
|
|
272
|
+
self, language: str, deadline: float | None = None
|
|
273
|
+
) -> LspClient:
|
|
274
|
+
client = self._clients.get(language)
|
|
275
|
+
if client is not None:
|
|
276
|
+
if client.running:
|
|
277
|
+
return client
|
|
278
|
+
client.close()
|
|
279
|
+
self._clients.pop(language, None)
|
|
280
|
+
failure = self._failures.get(language)
|
|
281
|
+
if failure:
|
|
282
|
+
raise LspError(failure)
|
|
283
|
+
spec = self.servers().get(language)
|
|
284
|
+
if spec is None:
|
|
285
|
+
raise LspError(f"Unknown language server '{language}'.")
|
|
286
|
+
command = shutil.which(spec.command)
|
|
287
|
+
if command is None:
|
|
288
|
+
reason = (
|
|
289
|
+
f"language server command '{spec.command}' was not found on PATH."
|
|
290
|
+
)
|
|
291
|
+
self._failures[language] = reason
|
|
292
|
+
raise LspError(reason)
|
|
293
|
+
|
|
294
|
+
initialize_timeout = INITIALIZE_TIMEOUT
|
|
295
|
+
if deadline is not None:
|
|
296
|
+
remaining = deadline - time.monotonic()
|
|
297
|
+
if remaining <= 0:
|
|
298
|
+
raise LspTimeout(
|
|
299
|
+
f"no time left to start the '{language}' language server"
|
|
300
|
+
)
|
|
301
|
+
initialize_timeout = min(initialize_timeout, remaining)
|
|
302
|
+
started: LspClient | None = None
|
|
303
|
+
try:
|
|
304
|
+
started = LspClient(
|
|
305
|
+
command,
|
|
306
|
+
spec.args,
|
|
307
|
+
root=self._root,
|
|
308
|
+
initialize_timeout=initialize_timeout,
|
|
309
|
+
request_timeout=initialize_timeout,
|
|
310
|
+
)
|
|
311
|
+
started.initialize()
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
if started is not None:
|
|
314
|
+
started.close()
|
|
315
|
+
reason = f"could not start '{spec.command}': {exc}"
|
|
316
|
+
self._failures[language] = reason
|
|
317
|
+
raise LspError(reason) from exc
|
|
318
|
+
self._clients[language] = started
|
|
319
|
+
return started
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _read_text(path: Path) -> str:
|
|
323
|
+
try:
|
|
324
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
raise LspError(f"Could not read {path}: {exc}") from exc
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
_MANAGER: LspManager | None = None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_manager() -> LspManager | None:
|
|
333
|
+
"""Return the session's LSP manager, if one has been created."""
|
|
334
|
+
return _MANAGER
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def set_manager(manager: LspManager | None) -> None:
|
|
338
|
+
"""Install (or clear) the session's LSP manager."""
|
|
339
|
+
global _MANAGER
|
|
340
|
+
_MANAGER = manager
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def ensure_manager(
|
|
344
|
+
console: Console | None = None, root: str | Path | None = None
|
|
345
|
+
) -> LspManager:
|
|
346
|
+
"""Return the session manager, creating it on first use."""
|
|
347
|
+
manager = get_manager()
|
|
348
|
+
if manager is None:
|
|
349
|
+
manager = LspManager(console=console, root=root)
|
|
350
|
+
set_manager(manager)
|
|
351
|
+
elif root is not None:
|
|
352
|
+
manager.set_root(root)
|
|
353
|
+
return manager
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""LSP base-protocol framing: ``Content-Length`` headers around JSON payloads.
|
|
2
|
+
|
|
3
|
+
The codec is pure (no I/O) so it can be unit-tested directly. ``encode_message``
|
|
4
|
+
produces one complete frame; ``decode_messages`` consumes a possibly-partial
|
|
5
|
+
byte buffer and returns every complete message plus the unparsed remainder, so
|
|
6
|
+
a reader can call it again as more bytes arrive.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
HEADER_SEPARATOR = b"\r\n\r\n"
|
|
15
|
+
ALT_HEADER_SEPARATOR = b"\n\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LspProtocolError(ValueError):
|
|
19
|
+
"""Raised when a frame header or body cannot be decoded."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def encode_message(message: dict[str, Any]) -> bytes:
|
|
23
|
+
"""Encode ``message`` as one LSP frame (header + UTF-8 JSON body)."""
|
|
24
|
+
body = json.dumps(
|
|
25
|
+
message, separators=(",", ":"), ensure_ascii=False
|
|
26
|
+
).encode("utf-8")
|
|
27
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
28
|
+
return header + body
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decode_messages(buffer: bytes) -> tuple[list[dict[str, Any]], bytes]:
|
|
32
|
+
"""Decode every complete frame in ``buffer``; return (messages, remainder).
|
|
33
|
+
|
|
34
|
+
A partial header or body is left in the remainder for the next call. A
|
|
35
|
+
malformed header or body raises :class:`LspProtocolError`; callers decide
|
|
36
|
+
whether to discard the connection.
|
|
37
|
+
"""
|
|
38
|
+
messages: list[dict[str, Any]] = []
|
|
39
|
+
while buffer:
|
|
40
|
+
split = buffer.find(HEADER_SEPARATOR)
|
|
41
|
+
separator_length = len(HEADER_SEPARATOR)
|
|
42
|
+
if split < 0:
|
|
43
|
+
split = buffer.find(ALT_HEADER_SEPARATOR)
|
|
44
|
+
separator_length = len(ALT_HEADER_SEPARATOR)
|
|
45
|
+
if split < 0:
|
|
46
|
+
return messages, buffer # incomplete header
|
|
47
|
+
header = buffer[:split].decode("ascii", "replace")
|
|
48
|
+
length = _content_length(header)
|
|
49
|
+
body_start = split + separator_length
|
|
50
|
+
if len(buffer) - body_start < length:
|
|
51
|
+
return messages, buffer # incomplete body
|
|
52
|
+
body = buffer[body_start : body_start + length]
|
|
53
|
+
try:
|
|
54
|
+
message = json.loads(body.decode("utf-8"))
|
|
55
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
56
|
+
raise LspProtocolError(f"invalid JSON body: {exc}") from exc
|
|
57
|
+
if not isinstance(message, dict):
|
|
58
|
+
raise LspProtocolError("message body must be a JSON object")
|
|
59
|
+
messages.append(message)
|
|
60
|
+
buffer = buffer[body_start + length :]
|
|
61
|
+
return messages, buffer
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _content_length(header: str) -> int:
|
|
65
|
+
"""Return the ``Content-Length`` value from a frame header."""
|
|
66
|
+
for line in header.replace("\r\n", "\n").split("\n"):
|
|
67
|
+
name, _, value = line.partition(":")
|
|
68
|
+
if name.strip().lower() != "content-length":
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
length = int(value.strip())
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise LspProtocolError(
|
|
74
|
+
f"invalid Content-Length: {value.strip()!r}"
|
|
75
|
+
) from exc
|
|
76
|
+
if length < 0:
|
|
77
|
+
raise LspProtocolError("negative Content-Length")
|
|
78
|
+
return length
|
|
79
|
+
raise LspProtocolError("missing Content-Length header")
|