rocq-solid-mcp 1.0.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.
- rocq_solid_mcp/__init__.py +6 -0
- rocq_solid_mcp/cli.py +63 -0
- rocq_solid_mcp/config.py +146 -0
- rocq_solid_mcp/manager.py +1082 -0
- rocq_solid_mcp/models.py +767 -0
- rocq_solid_mcp/notifications.py +225 -0
- rocq_solid_mcp/pipeline.py +91 -0
- rocq_solid_mcp/pretty.py +167 -0
- rocq_solid_mcp/rocq_client.py +980 -0
- rocq_solid_mcp/server.py +156 -0
- rocq_solid_mcp/session.py +84 -0
- rocq_solid_mcp-1.0.0.dist-info/METADATA +184 -0
- rocq_solid_mcp-1.0.0.dist-info/RECORD +17 -0
- rocq_solid_mcp-1.0.0.dist-info/WHEEL +5 -0
- rocq_solid_mcp-1.0.0.dist-info/entry_points.txt +3 -0
- rocq_solid_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- rocq_solid_mcp-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import codecs
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from .config import rocq_lsp_command, rocq_lsp_settings
|
|
16
|
+
from .notifications import NotificationStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TimeoutKind = Literal["request", "command", "after_error"]
|
|
20
|
+
STDERR_CHUNK_SIZE = 64 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RocqLspResponseError(RuntimeError):
|
|
24
|
+
def __init__(self, error: dict[str, Any]) -> None:
|
|
25
|
+
self.error = error
|
|
26
|
+
super().__init__(str(error))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RocqLspHardTimeout(TimeoutError):
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
timeout_kind: TimeoutKind = "request",
|
|
35
|
+
timed_out_range: dict[str, Any] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.timeout_kind = timeout_kind
|
|
38
|
+
self.timed_out_range = timed_out_range
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RocqLspProcessExited(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RocqLspUnavailableError(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class GetDocumentResult:
|
|
52
|
+
document: dict[str, Any]
|
|
53
|
+
timed_out: bool = False
|
|
54
|
+
timeout_kind: TimeoutKind | None = None
|
|
55
|
+
timed_out_range: dict[str, Any] | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class WatchdogTimeout:
|
|
60
|
+
"""The single, immutable outcome a watchdog publishes when it cancels."""
|
|
61
|
+
|
|
62
|
+
kind: TimeoutKind
|
|
63
|
+
seconds: float
|
|
64
|
+
timed_out_range: dict[str, Any] | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def language_id_for_path(path: Path) -> str:
|
|
68
|
+
name = path.name
|
|
69
|
+
if name.endswith(".mv"):
|
|
70
|
+
return "markdown"
|
|
71
|
+
if name.endswith(".lv") or name.endswith(".v.tex"):
|
|
72
|
+
return "latex"
|
|
73
|
+
return "rocq"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _end_of_content_position(content: str | None) -> tuple[int, int] | None:
|
|
77
|
+
"""0-based (line, character) just past the last non-whitespace character.
|
|
78
|
+
|
|
79
|
+
A sentence whose range ends at or beyond this position is the document's
|
|
80
|
+
final sentence: only whitespace follows it. Precomputing it keeps the
|
|
81
|
+
watchdog's final-sentence check O(1) per sentence instead of re-splitting
|
|
82
|
+
the document content on every wake-up.
|
|
83
|
+
"""
|
|
84
|
+
if content is None:
|
|
85
|
+
return None
|
|
86
|
+
stripped = content.rstrip()
|
|
87
|
+
if not stripped:
|
|
88
|
+
return (0, 0)
|
|
89
|
+
lines = stripped.splitlines()
|
|
90
|
+
return (len(lines) - 1, len(lines[-1]))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RocqLspClient:
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
binary: str,
|
|
98
|
+
use_opam: bool,
|
|
99
|
+
workspace: Path,
|
|
100
|
+
max_errors: int,
|
|
101
|
+
request_timeout: float,
|
|
102
|
+
notifications: NotificationStore,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.command = rocq_lsp_command(binary, use_opam=use_opam)
|
|
105
|
+
self.workspace = workspace
|
|
106
|
+
self.max_errors = max_errors
|
|
107
|
+
self.request_timeout = request_timeout
|
|
108
|
+
self.notifications = notifications
|
|
109
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
110
|
+
self._next_request_id = 1
|
|
111
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
112
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
113
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
114
|
+
self._reader_failure: RocqLspProcessExited | None = None
|
|
115
|
+
self.stderr_lines: list[str] = []
|
|
116
|
+
self.restart_required_after_external_cancellation = False
|
|
117
|
+
|
|
118
|
+
async def start(self) -> None:
|
|
119
|
+
self._ensure_launcher_available()
|
|
120
|
+
self._reader_failure = None
|
|
121
|
+
try:
|
|
122
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
123
|
+
*self.command,
|
|
124
|
+
cwd=self.workspace,
|
|
125
|
+
stdin=asyncio.subprocess.PIPE,
|
|
126
|
+
stdout=asyncio.subprocess.PIPE,
|
|
127
|
+
stderr=asyncio.subprocess.PIPE,
|
|
128
|
+
)
|
|
129
|
+
self._reader_task = asyncio.create_task(self._reader_loop())
|
|
130
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
131
|
+
|
|
132
|
+
await self.request(
|
|
133
|
+
"initialize",
|
|
134
|
+
{
|
|
135
|
+
"processId": os.getpid(),
|
|
136
|
+
"clientInfo": {"name": "rocq-solid-mcp"},
|
|
137
|
+
"rootPath": str(self.workspace),
|
|
138
|
+
"rootUri": self.workspace.as_uri(),
|
|
139
|
+
"workspaceFolders": [
|
|
140
|
+
{"uri": self.workspace.as_uri(), "name": self.workspace.name}
|
|
141
|
+
],
|
|
142
|
+
"capabilities": {
|
|
143
|
+
"textDocument": {
|
|
144
|
+
"publishDiagnostics": {
|
|
145
|
+
"relatedInformation": True,
|
|
146
|
+
"dataSupport": True,
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"workspace": {"workspaceFolders": True},
|
|
150
|
+
},
|
|
151
|
+
"initializationOptions": rocq_lsp_settings(self.max_errors),
|
|
152
|
+
"trace": "off",
|
|
153
|
+
},
|
|
154
|
+
timeout=self.request_timeout,
|
|
155
|
+
)
|
|
156
|
+
await self.notify("initialized", {})
|
|
157
|
+
except asyncio.CancelledError:
|
|
158
|
+
raise
|
|
159
|
+
except FileNotFoundError as exc:
|
|
160
|
+
program = str(exc.filename or self.command[0])
|
|
161
|
+
raise RocqLspUnavailableError(
|
|
162
|
+
self._command_not_found_message(program)
|
|
163
|
+
) from exc
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
raise RocqLspUnavailableError(
|
|
166
|
+
await self._startup_failure_message(exc)
|
|
167
|
+
) from exc
|
|
168
|
+
|
|
169
|
+
async def close(self, *, hard: bool = False) -> None:
|
|
170
|
+
if hard:
|
|
171
|
+
await self.kill_server()
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
with suppress(Exception):
|
|
175
|
+
await self.request("shutdown", None, timeout=2)
|
|
176
|
+
await self.notify("exit", None)
|
|
177
|
+
await self.kill_server()
|
|
178
|
+
|
|
179
|
+
async def kill_server(self) -> None:
|
|
180
|
+
for future in self._pending.values():
|
|
181
|
+
if not future.done():
|
|
182
|
+
future.cancel()
|
|
183
|
+
self._pending.clear()
|
|
184
|
+
|
|
185
|
+
for task in (self._reader_task, self._stderr_task):
|
|
186
|
+
if task is not None:
|
|
187
|
+
task.cancel()
|
|
188
|
+
|
|
189
|
+
process = self.process
|
|
190
|
+
if process is None:
|
|
191
|
+
return
|
|
192
|
+
if process.returncode is None:
|
|
193
|
+
process.terminate()
|
|
194
|
+
try:
|
|
195
|
+
await asyncio.wait_for(process.wait(), timeout=2)
|
|
196
|
+
except TimeoutError:
|
|
197
|
+
process.kill()
|
|
198
|
+
await process.wait()
|
|
199
|
+
|
|
200
|
+
def clear_pending_requests(self) -> None:
|
|
201
|
+
for future in self._pending.values():
|
|
202
|
+
if not future.done():
|
|
203
|
+
future.cancel()
|
|
204
|
+
self._pending.clear()
|
|
205
|
+
|
|
206
|
+
async def notify_change_configuration(self) -> None:
|
|
207
|
+
await self.notify(
|
|
208
|
+
"workspace/didChangeConfiguration",
|
|
209
|
+
{"settings": rocq_lsp_settings(self.max_errors)},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def notify_document_opened(
|
|
213
|
+
self, file_path: Path, content: str, version: int
|
|
214
|
+
) -> None:
|
|
215
|
+
await self.notify(
|
|
216
|
+
"textDocument/didOpen",
|
|
217
|
+
{
|
|
218
|
+
"textDocument": {
|
|
219
|
+
"uri": file_path.as_uri(),
|
|
220
|
+
"languageId": language_id_for_path(file_path),
|
|
221
|
+
"version": version,
|
|
222
|
+
"text": content,
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async def notify_document_changed(
|
|
228
|
+
self, file_path: Path, content: str, version: int
|
|
229
|
+
) -> None:
|
|
230
|
+
await self.notify(
|
|
231
|
+
"textDocument/didChange",
|
|
232
|
+
{
|
|
233
|
+
"textDocument": {"uri": file_path.as_uri(), "version": version},
|
|
234
|
+
"contentChanges": [{"text": content}],
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def request_get_document(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
uri: str,
|
|
242
|
+
version: int,
|
|
243
|
+
timeout: float | None,
|
|
244
|
+
hard_timeout_grace: float,
|
|
245
|
+
command_timeout: float | None = None,
|
|
246
|
+
timeout_after_error: float | None = None,
|
|
247
|
+
goals: str | None = None,
|
|
248
|
+
content: str | None = None,
|
|
249
|
+
) -> GetDocumentResult:
|
|
250
|
+
if command_timeout is not None and command_timeout <= 0:
|
|
251
|
+
command_timeout = None
|
|
252
|
+
if timeout_after_error is not None and timeout_after_error <= 0:
|
|
253
|
+
timeout_after_error = None
|
|
254
|
+
request_id = self._reserve_request()
|
|
255
|
+
future = self._pending[request_id]
|
|
256
|
+
self.restart_required_after_external_cancellation = False
|
|
257
|
+
watchdog: _CommandTimeoutWatchdog | None = None
|
|
258
|
+
watchdog_task: asyncio.Task[None] | None = None
|
|
259
|
+
params: dict[str, Any] = {"textDocument": {"uri": uri, "version": version}}
|
|
260
|
+
if goals is not None:
|
|
261
|
+
params["goals"] = goals
|
|
262
|
+
await self._send(
|
|
263
|
+
{
|
|
264
|
+
"jsonrpc": "2.0",
|
|
265
|
+
"id": request_id,
|
|
266
|
+
"method": "coq/getDocument",
|
|
267
|
+
"params": params,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
if command_timeout is not None or timeout_after_error is not None:
|
|
271
|
+
watchdog = _CommandTimeoutWatchdog(
|
|
272
|
+
client=self,
|
|
273
|
+
uri=uri,
|
|
274
|
+
version=version,
|
|
275
|
+
request_id=request_id,
|
|
276
|
+
command_timeout=command_timeout,
|
|
277
|
+
timeout_after_error=timeout_after_error,
|
|
278
|
+
content=content,
|
|
279
|
+
)
|
|
280
|
+
watchdog_task = asyncio.create_task(watchdog.run())
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
if watchdog is None or watchdog_task is None:
|
|
284
|
+
return await self._await_get_document(
|
|
285
|
+
request_id=request_id,
|
|
286
|
+
future=future,
|
|
287
|
+
timeout=timeout,
|
|
288
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
done, _ = await asyncio.wait(
|
|
292
|
+
{future, watchdog_task},
|
|
293
|
+
timeout=timeout,
|
|
294
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
295
|
+
)
|
|
296
|
+
if not done:
|
|
297
|
+
await self.notify("$/cancelRequest", {"id": request_id})
|
|
298
|
+
return await self._wait_for_cancelled_get_document(
|
|
299
|
+
request_id=request_id,
|
|
300
|
+
future=future,
|
|
301
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
302
|
+
timeout_kind="request",
|
|
303
|
+
timeout_seconds=timeout,
|
|
304
|
+
timed_out_range=None,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if future in done or future.done():
|
|
308
|
+
raw = future.result()
|
|
309
|
+
self._pending.pop(request_id, None)
|
|
310
|
+
if watchdog.timeout is not None and "error" in raw:
|
|
311
|
+
return self._get_document_timeout_result(raw, watchdog.timeout)
|
|
312
|
+
return GetDocumentResult(document=self._result_or_raise(raw))
|
|
313
|
+
|
|
314
|
+
await watchdog_task
|
|
315
|
+
if watchdog.timeout is None:
|
|
316
|
+
# The watchdog stopped without cancelling anything (it is
|
|
317
|
+
# advisory and contains its own failures), so keep waiting
|
|
318
|
+
# for the document under the plain request deadline.
|
|
319
|
+
return await self._await_get_document(
|
|
320
|
+
request_id=request_id,
|
|
321
|
+
future=future,
|
|
322
|
+
timeout=timeout,
|
|
323
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
324
|
+
)
|
|
325
|
+
if future.done():
|
|
326
|
+
raw = future.result()
|
|
327
|
+
self._pending.pop(request_id, None)
|
|
328
|
+
if "error" in raw:
|
|
329
|
+
return self._get_document_timeout_result(raw, watchdog.timeout)
|
|
330
|
+
return GetDocumentResult(document=self._result_or_raise(raw))
|
|
331
|
+
return await self._wait_for_cancelled_get_document(
|
|
332
|
+
request_id=request_id,
|
|
333
|
+
future=future,
|
|
334
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
335
|
+
timeout_kind=watchdog.timeout.kind,
|
|
336
|
+
timeout_seconds=watchdog.timeout.seconds,
|
|
337
|
+
timed_out_range=watchdog.timeout.timed_out_range,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
except asyncio.CancelledError:
|
|
341
|
+
await self._cancel_lsp_request_after_external_cancellation(
|
|
342
|
+
request_id=request_id,
|
|
343
|
+
future=future,
|
|
344
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
345
|
+
)
|
|
346
|
+
raise
|
|
347
|
+
finally:
|
|
348
|
+
if watchdog_task is not None:
|
|
349
|
+
watchdog_task.cancel()
|
|
350
|
+
with suppress(asyncio.CancelledError):
|
|
351
|
+
await watchdog_task
|
|
352
|
+
|
|
353
|
+
async def _await_get_document(
|
|
354
|
+
self,
|
|
355
|
+
*,
|
|
356
|
+
request_id: int,
|
|
357
|
+
future: asyncio.Future[dict[str, Any]],
|
|
358
|
+
timeout: float | None,
|
|
359
|
+
hard_timeout_grace: float,
|
|
360
|
+
) -> GetDocumentResult:
|
|
361
|
+
try:
|
|
362
|
+
raw = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
|
|
363
|
+
except TimeoutError:
|
|
364
|
+
await self.notify("$/cancelRequest", {"id": request_id})
|
|
365
|
+
return await self._wait_for_cancelled_get_document(
|
|
366
|
+
request_id=request_id,
|
|
367
|
+
future=future,
|
|
368
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
369
|
+
timeout_kind="request",
|
|
370
|
+
timeout_seconds=timeout,
|
|
371
|
+
timed_out_range=None,
|
|
372
|
+
)
|
|
373
|
+
self._pending.pop(request_id, None)
|
|
374
|
+
return GetDocumentResult(document=self._result_or_raise(raw))
|
|
375
|
+
|
|
376
|
+
def _get_document_timeout_result(
|
|
377
|
+
self, raw: dict[str, Any], watchdog_timeout: WatchdogTimeout
|
|
378
|
+
) -> GetDocumentResult:
|
|
379
|
+
document = {} if "error" in raw else self._result_or_raise(raw)
|
|
380
|
+
return GetDocumentResult(
|
|
381
|
+
document=document,
|
|
382
|
+
timed_out=True,
|
|
383
|
+
timeout_kind=watchdog_timeout.kind,
|
|
384
|
+
timed_out_range=watchdog_timeout.timed_out_range,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
async def _wait_for_cancelled_get_document(
|
|
388
|
+
self,
|
|
389
|
+
*,
|
|
390
|
+
request_id: int,
|
|
391
|
+
future: asyncio.Future[dict[str, Any]],
|
|
392
|
+
hard_timeout_grace: float,
|
|
393
|
+
timeout_kind: TimeoutKind,
|
|
394
|
+
timeout_seconds: float,
|
|
395
|
+
timed_out_range: dict[str, Any] | None,
|
|
396
|
+
) -> GetDocumentResult:
|
|
397
|
+
try:
|
|
398
|
+
raw = await asyncio.wait_for(
|
|
399
|
+
asyncio.shield(future), timeout=hard_timeout_grace
|
|
400
|
+
)
|
|
401
|
+
except asyncio.CancelledError:
|
|
402
|
+
await self._cancel_lsp_request_after_external_cancellation(
|
|
403
|
+
request_id=request_id,
|
|
404
|
+
future=future,
|
|
405
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
406
|
+
)
|
|
407
|
+
raise
|
|
408
|
+
except TimeoutError as exc:
|
|
409
|
+
self._pending.pop(request_id, None)
|
|
410
|
+
future.cancel()
|
|
411
|
+
raise RocqLspHardTimeout(
|
|
412
|
+
f"coq/getDocument {timeout_kind} timed out after "
|
|
413
|
+
f"{timeout_seconds}s and did not settle within "
|
|
414
|
+
f"{hard_timeout_grace}s",
|
|
415
|
+
timeout_kind=timeout_kind,
|
|
416
|
+
timed_out_range=timed_out_range,
|
|
417
|
+
) from exc
|
|
418
|
+
finally:
|
|
419
|
+
self._pending.pop(request_id, None)
|
|
420
|
+
|
|
421
|
+
document = {} if "error" in raw else self._result_or_raise(raw)
|
|
422
|
+
return GetDocumentResult(
|
|
423
|
+
document=document,
|
|
424
|
+
timed_out=True,
|
|
425
|
+
timeout_kind=timeout_kind,
|
|
426
|
+
timed_out_range=timed_out_range,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
async def request_goals(
|
|
430
|
+
self,
|
|
431
|
+
*,
|
|
432
|
+
uri: str,
|
|
433
|
+
version: int,
|
|
434
|
+
position: dict[str, int],
|
|
435
|
+
timeout: float | None = None,
|
|
436
|
+
pp_format: str = "Str",
|
|
437
|
+
compact: bool = True,
|
|
438
|
+
mode: str = "Prev",
|
|
439
|
+
) -> dict[str, Any]:
|
|
440
|
+
return await self.request(
|
|
441
|
+
"proof/goals",
|
|
442
|
+
{
|
|
443
|
+
"textDocument": {"uri": uri, "version": version},
|
|
444
|
+
"position": position,
|
|
445
|
+
"pp_format": pp_format,
|
|
446
|
+
"compact": compact,
|
|
447
|
+
"mode": mode,
|
|
448
|
+
},
|
|
449
|
+
timeout=timeout,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
async def request_run_at_point(
|
|
453
|
+
self,
|
|
454
|
+
*,
|
|
455
|
+
uri: str,
|
|
456
|
+
version: int,
|
|
457
|
+
position: dict[str, int],
|
|
458
|
+
commands: str,
|
|
459
|
+
timeout: float | None,
|
|
460
|
+
hard_timeout_grace: float,
|
|
461
|
+
) -> tuple[dict[str, Any], bool]:
|
|
462
|
+
request_id = self._reserve_request()
|
|
463
|
+
future = self._pending[request_id]
|
|
464
|
+
self.restart_required_after_external_cancellation = False
|
|
465
|
+
await self._send(
|
|
466
|
+
{
|
|
467
|
+
"jsonrpc": "2.0",
|
|
468
|
+
"id": request_id,
|
|
469
|
+
"method": "petanque/run_at_point",
|
|
470
|
+
"params": {
|
|
471
|
+
"textDocument": {"uri": uri, "version": version},
|
|
472
|
+
"position": position,
|
|
473
|
+
"command": commands,
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
raw = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
|
|
480
|
+
self._pending.pop(request_id, None)
|
|
481
|
+
return self._result_or_raise(raw), False
|
|
482
|
+
except TimeoutError as exc:
|
|
483
|
+
await self.notify("$/cancelRequest", {"id": request_id})
|
|
484
|
+
try:
|
|
485
|
+
raw = await asyncio.wait_for(
|
|
486
|
+
asyncio.shield(future), timeout=hard_timeout_grace
|
|
487
|
+
)
|
|
488
|
+
except asyncio.CancelledError:
|
|
489
|
+
await self._cancel_lsp_request_after_external_cancellation(
|
|
490
|
+
request_id=request_id,
|
|
491
|
+
future=future,
|
|
492
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
493
|
+
)
|
|
494
|
+
raise
|
|
495
|
+
except TimeoutError:
|
|
496
|
+
self._pending.pop(request_id, None)
|
|
497
|
+
future.cancel()
|
|
498
|
+
raise RocqLspHardTimeout(
|
|
499
|
+
f"petanque/run_at_point timed out after {timeout}s and did not "
|
|
500
|
+
f"settle within {hard_timeout_grace}s"
|
|
501
|
+
) from exc
|
|
502
|
+
finally:
|
|
503
|
+
self._pending.pop(request_id, None)
|
|
504
|
+
|
|
505
|
+
if "error" in raw:
|
|
506
|
+
return {}, True
|
|
507
|
+
return self._result_or_raise(raw), True
|
|
508
|
+
except asyncio.CancelledError:
|
|
509
|
+
await self._cancel_lsp_request_after_external_cancellation(
|
|
510
|
+
request_id=request_id,
|
|
511
|
+
future=future,
|
|
512
|
+
hard_timeout_grace=hard_timeout_grace,
|
|
513
|
+
)
|
|
514
|
+
raise
|
|
515
|
+
|
|
516
|
+
async def request(
|
|
517
|
+
self, method: str, params: Any | None, *, timeout: float | None = None
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
request_id = self._reserve_request()
|
|
520
|
+
future = self._pending[request_id]
|
|
521
|
+
await self._send(
|
|
522
|
+
{
|
|
523
|
+
"jsonrpc": "2.0",
|
|
524
|
+
"id": request_id,
|
|
525
|
+
"method": method,
|
|
526
|
+
"params": params,
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
try:
|
|
530
|
+
raw = await asyncio.wait_for(
|
|
531
|
+
asyncio.shield(future), timeout=timeout or self.request_timeout
|
|
532
|
+
)
|
|
533
|
+
return self._result_or_raise(raw)
|
|
534
|
+
finally:
|
|
535
|
+
self._pending.pop(request_id, None)
|
|
536
|
+
|
|
537
|
+
async def notify(self, method: str, params: Any | None = None) -> None:
|
|
538
|
+
await self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
|
539
|
+
|
|
540
|
+
async def _cancel_lsp_request_after_external_cancellation(
|
|
541
|
+
self,
|
|
542
|
+
*,
|
|
543
|
+
request_id: int,
|
|
544
|
+
future: asyncio.Future[dict[str, Any]],
|
|
545
|
+
hard_timeout_grace: float,
|
|
546
|
+
) -> None:
|
|
547
|
+
try:
|
|
548
|
+
await self.notify("$/cancelRequest", {"id": request_id})
|
|
549
|
+
await asyncio.wait_for(
|
|
550
|
+
asyncio.shield(future), timeout=hard_timeout_grace
|
|
551
|
+
)
|
|
552
|
+
except TimeoutError:
|
|
553
|
+
self.restart_required_after_external_cancellation = True
|
|
554
|
+
future.cancel()
|
|
555
|
+
except Exception:
|
|
556
|
+
self.restart_required_after_external_cancellation = True
|
|
557
|
+
future.cancel()
|
|
558
|
+
finally:
|
|
559
|
+
self._pending.pop(request_id, None)
|
|
560
|
+
|
|
561
|
+
def _reserve_request(self) -> int:
|
|
562
|
+
request_id = self._next_request_id
|
|
563
|
+
self._next_request_id += 1
|
|
564
|
+
self._pending[request_id] = asyncio.get_running_loop().create_future()
|
|
565
|
+
return request_id
|
|
566
|
+
|
|
567
|
+
async def _send(self, message: dict[str, Any]) -> None:
|
|
568
|
+
if self._reader_failure is not None:
|
|
569
|
+
raise self._reader_failure
|
|
570
|
+
process = self.process
|
|
571
|
+
if process is None or process.stdin is None:
|
|
572
|
+
raise RocqLspProcessExited("coq-lsp is not running")
|
|
573
|
+
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
|
|
574
|
+
process.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"))
|
|
575
|
+
process.stdin.write(body)
|
|
576
|
+
await process.stdin.drain()
|
|
577
|
+
|
|
578
|
+
async def _reader_loop(self) -> None:
|
|
579
|
+
try:
|
|
580
|
+
while True:
|
|
581
|
+
message = await self._read_message()
|
|
582
|
+
if message is None:
|
|
583
|
+
raise RocqLspProcessExited("coq-lsp stdout closed")
|
|
584
|
+
await self._dispatch(message)
|
|
585
|
+
except asyncio.CancelledError:
|
|
586
|
+
raise
|
|
587
|
+
except Exception as exc:
|
|
588
|
+
failure = (
|
|
589
|
+
exc
|
|
590
|
+
if isinstance(exc, RocqLspProcessExited)
|
|
591
|
+
else RocqLspProcessExited(f"coq-lsp JSON-RPC reader failed: {exc}")
|
|
592
|
+
)
|
|
593
|
+
self._reader_failure = failure
|
|
594
|
+
for future in self._pending.values():
|
|
595
|
+
if not future.done():
|
|
596
|
+
future.set_exception(failure)
|
|
597
|
+
|
|
598
|
+
async def _read_message(self) -> dict[str, Any] | None:
|
|
599
|
+
process = self.process
|
|
600
|
+
if process is None or process.stdout is None:
|
|
601
|
+
raise RocqLspProcessExited("coq-lsp is not running")
|
|
602
|
+
|
|
603
|
+
content_length: int | None = None
|
|
604
|
+
saw_header = False
|
|
605
|
+
while True:
|
|
606
|
+
line = await process.stdout.readline()
|
|
607
|
+
if line == b"":
|
|
608
|
+
if not saw_header:
|
|
609
|
+
return None
|
|
610
|
+
raise RocqLspProcessExited("coq-lsp stdout closed during headers")
|
|
611
|
+
if line in (b"\r\n", b"\n"):
|
|
612
|
+
if saw_header:
|
|
613
|
+
break
|
|
614
|
+
continue
|
|
615
|
+
saw_header = True
|
|
616
|
+
name, separator, value = line.decode(
|
|
617
|
+
"ascii", errors="replace"
|
|
618
|
+
).rstrip("\r\n").partition(":")
|
|
619
|
+
if not separator:
|
|
620
|
+
raise ValueError("Malformed JSON-RPC header from coq-lsp")
|
|
621
|
+
if name.lower() == "content-length":
|
|
622
|
+
if content_length is not None:
|
|
623
|
+
raise ValueError("Duplicate Content-Length header from coq-lsp")
|
|
624
|
+
content_length = int(value.strip())
|
|
625
|
+
|
|
626
|
+
if content_length is None:
|
|
627
|
+
raise ValueError("Missing Content-Length header from coq-lsp")
|
|
628
|
+
if content_length < 0:
|
|
629
|
+
raise ValueError("Negative Content-Length header from coq-lsp")
|
|
630
|
+
|
|
631
|
+
body = await process.stdout.readexactly(content_length)
|
|
632
|
+
return json.loads(body.decode("utf-8"))
|
|
633
|
+
|
|
634
|
+
async def _dispatch(self, message: dict[str, Any]) -> None:
|
|
635
|
+
if "id" in message and ("result" in message or "error" in message):
|
|
636
|
+
request_id = message["id"]
|
|
637
|
+
future = self._pending.get(request_id)
|
|
638
|
+
if future is not None and not future.done():
|
|
639
|
+
future.set_result(message)
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
method = message.get("method")
|
|
643
|
+
params = message.get("params")
|
|
644
|
+
if "id" in message and method:
|
|
645
|
+
await self._respond_to_server_request(message["id"], method, params)
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
if isinstance(method, str):
|
|
649
|
+
self.notifications.handle(method, params)
|
|
650
|
+
|
|
651
|
+
async def _respond_to_server_request(
|
|
652
|
+
self, request_id: int, method: str, params: Any | None
|
|
653
|
+
) -> None:
|
|
654
|
+
if method == "workspace/workspaceFolders":
|
|
655
|
+
result: Any = [{"uri": self.workspace.as_uri(), "name": self.workspace.name}]
|
|
656
|
+
elif method == "workspace/configuration":
|
|
657
|
+
items = (params or {}).get("items", []) if isinstance(params, dict) else []
|
|
658
|
+
result = [rocq_lsp_settings(self.max_errors) for _ in items]
|
|
659
|
+
else:
|
|
660
|
+
result = None
|
|
661
|
+
await self._send({"jsonrpc": "2.0", "id": request_id, "result": result})
|
|
662
|
+
|
|
663
|
+
async def _drain_stderr(self) -> None:
|
|
664
|
+
process = self.process
|
|
665
|
+
if process is None or process.stderr is None:
|
|
666
|
+
return
|
|
667
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
668
|
+
pending = ""
|
|
669
|
+
try:
|
|
670
|
+
while chunk := await process.stderr.read(STDERR_CHUNK_SIZE):
|
|
671
|
+
pending += decoder.decode(chunk)
|
|
672
|
+
lines = pending.split("\n")
|
|
673
|
+
pending = lines.pop()
|
|
674
|
+
for line in lines:
|
|
675
|
+
self._append_stderr_line(line.rstrip("\r"))
|
|
676
|
+
# A diagnostic need not contain a newline. Store fragments so
|
|
677
|
+
# draining never depends on asyncio's line-length limit.
|
|
678
|
+
if len(pending) >= STDERR_CHUNK_SIZE:
|
|
679
|
+
self._append_stderr_line(pending)
|
|
680
|
+
pending = ""
|
|
681
|
+
pending += decoder.decode(b"", final=True)
|
|
682
|
+
if pending:
|
|
683
|
+
self._append_stderr_line(pending.rstrip("\r"))
|
|
684
|
+
except asyncio.CancelledError:
|
|
685
|
+
raise
|
|
686
|
+
|
|
687
|
+
def _append_stderr_line(self, line: str) -> None:
|
|
688
|
+
self.stderr_lines.append(line)
|
|
689
|
+
if len(self.stderr_lines) > 200:
|
|
690
|
+
del self.stderr_lines[:100]
|
|
691
|
+
|
|
692
|
+
async def process_failure_message(self, exc: BaseException) -> str:
|
|
693
|
+
"""Add child exit status and recent stderr to a transport failure."""
|
|
694
|
+
await self._collect_process_stderr()
|
|
695
|
+
message = str(exc)
|
|
696
|
+
process = self.process
|
|
697
|
+
returncode = process.returncode if process is not None else None
|
|
698
|
+
if returncode is not None:
|
|
699
|
+
if returncode < 0:
|
|
700
|
+
signal_number = -returncode
|
|
701
|
+
try:
|
|
702
|
+
signal_name = signal.Signals(signal_number).name
|
|
703
|
+
except ValueError:
|
|
704
|
+
signal_name = "unknown signal"
|
|
705
|
+
message += (
|
|
706
|
+
f". coq-lsp exited with signal {signal_name} "
|
|
707
|
+
f"({signal_number})"
|
|
708
|
+
)
|
|
709
|
+
else:
|
|
710
|
+
message += f". coq-lsp exited with code {returncode}"
|
|
711
|
+
stderr = "\n".join(self.stderr_lines[-20:]).strip()
|
|
712
|
+
if stderr:
|
|
713
|
+
message += f". stderr:\n{stderr}"
|
|
714
|
+
return message
|
|
715
|
+
|
|
716
|
+
@staticmethod
|
|
717
|
+
def _result_or_raise(raw: dict[str, Any]) -> dict[str, Any]:
|
|
718
|
+
if "error" in raw:
|
|
719
|
+
raise RocqLspResponseError(raw["error"] or {})
|
|
720
|
+
result = raw.get("result")
|
|
721
|
+
if isinstance(result, dict):
|
|
722
|
+
return result
|
|
723
|
+
return {"result": result}
|
|
724
|
+
|
|
725
|
+
def _ensure_launcher_available(self) -> None:
|
|
726
|
+
launcher = self.command[0]
|
|
727
|
+
if _looks_like_path(launcher):
|
|
728
|
+
launcher_path = Path(launcher)
|
|
729
|
+
if not launcher_path.is_absolute():
|
|
730
|
+
launcher_path = self.workspace / launcher_path
|
|
731
|
+
if launcher_path.exists():
|
|
732
|
+
return
|
|
733
|
+
elif shutil.which(launcher) is not None:
|
|
734
|
+
return
|
|
735
|
+
raise RocqLspUnavailableError(self._command_not_found_message(launcher))
|
|
736
|
+
|
|
737
|
+
def _command_not_found_message(self, program: str) -> str:
|
|
738
|
+
tried = shlex.join(self.command)
|
|
739
|
+
if program == "opam":
|
|
740
|
+
advice = (
|
|
741
|
+
"Install opam, or set ROCQ_LSP_USE_OPAM=0 if ROCQ_LSP_BINARY "
|
|
742
|
+
"points to a directly runnable coq-lsp binary."
|
|
743
|
+
)
|
|
744
|
+
else:
|
|
745
|
+
advice = (
|
|
746
|
+
"Install bens-rocq-lsp/coq-lsp, set ROCQ_LSP_BINARY to its full "
|
|
747
|
+
"path, or set ROCQ_LSP_USE_OPAM=1 to run it through an opam switch."
|
|
748
|
+
)
|
|
749
|
+
if _looks_like_path(program):
|
|
750
|
+
reason = f"executable path '{program}' was not found"
|
|
751
|
+
else:
|
|
752
|
+
reason = f"required executable '{program}' was not found on PATH"
|
|
753
|
+
return f"Unable to start coq-lsp: {reason}. Tried command: {tried}. {advice}"
|
|
754
|
+
|
|
755
|
+
async def _startup_failure_message(self, exc: Exception) -> str:
|
|
756
|
+
await self._collect_process_stderr()
|
|
757
|
+
message = (
|
|
758
|
+
"Unable to start coq-lsp: the server exited or failed during "
|
|
759
|
+
f"initialization. Tried command: {shlex.join(self.command)}. "
|
|
760
|
+
f"Workspace: {self.workspace}. Cause: {exc}."
|
|
761
|
+
)
|
|
762
|
+
stderr = "\n".join(self.stderr_lines[-20:]).strip()
|
|
763
|
+
if stderr:
|
|
764
|
+
message += f" stderr:\n{stderr}"
|
|
765
|
+
message += (
|
|
766
|
+
" Ensure bens-rocq-lsp/coq-lsp is installed in the selected opam "
|
|
767
|
+
"switch or set ROCQ_LSP_BINARY/ROCQ_LSP_USE_OPAM to the correct "
|
|
768
|
+
"launcher."
|
|
769
|
+
)
|
|
770
|
+
return message
|
|
771
|
+
|
|
772
|
+
async def _collect_process_stderr(self) -> None:
|
|
773
|
+
process = self.process
|
|
774
|
+
if process is not None and process.returncode is None:
|
|
775
|
+
with suppress(Exception):
|
|
776
|
+
await asyncio.wait_for(process.wait(), timeout=0.2)
|
|
777
|
+
stderr_task = self._stderr_task
|
|
778
|
+
if stderr_task is not None and not stderr_task.done():
|
|
779
|
+
with suppress(Exception):
|
|
780
|
+
await asyncio.wait_for(asyncio.shield(stderr_task), timeout=0.2)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class _CommandTimeoutWatchdog:
|
|
784
|
+
"""Cancels one ``coq/getDocument`` request when a per-sentence or
|
|
785
|
+
after-error deadline expires.
|
|
786
|
+
|
|
787
|
+
The interface rules that keep the timing behavior easy to reason about:
|
|
788
|
+
|
|
789
|
+
- Every timer is an absolute event-loop instant. The per-sentence
|
|
790
|
+
deadline is anchored to the sentence's ``$/coq/executionInformation``
|
|
791
|
+
notification and the after-error deadline to the first observed error
|
|
792
|
+
diagnostic, so unrelated wake-ups can never restart a timer.
|
|
793
|
+
- The after-error deadline is armed in exactly one place, from the state
|
|
794
|
+
poll at the top of the loop; the error waiter task only wakes the loop.
|
|
795
|
+
- Which timeout fired is decided when the wait is armed — the earliest
|
|
796
|
+
deadline wins, and the after-error deadline wins ties — never
|
|
797
|
+
re-derived from the clock afterwards.
|
|
798
|
+
- The outcome is published exactly once, as the immutable
|
|
799
|
+
``self.timeout``, before the cancel notification is sent.
|
|
800
|
+
- The watchdog is advisory and contains its own failures: if sending the
|
|
801
|
+
cancel fails (for example because coq-lsp died), the request future is
|
|
802
|
+
the authoritative error channel and surfaces the real failure itself.
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
def __init__(
|
|
806
|
+
self,
|
|
807
|
+
*,
|
|
808
|
+
client: RocqLspClient,
|
|
809
|
+
uri: str,
|
|
810
|
+
version: int,
|
|
811
|
+
request_id: int,
|
|
812
|
+
command_timeout: float | None,
|
|
813
|
+
timeout_after_error: float | None,
|
|
814
|
+
content: str | None,
|
|
815
|
+
) -> None:
|
|
816
|
+
self._client = client
|
|
817
|
+
self._uri = uri
|
|
818
|
+
self._version = version
|
|
819
|
+
self._request_id = request_id
|
|
820
|
+
self._command_timeout = command_timeout
|
|
821
|
+
self._timeout_after_error = timeout_after_error
|
|
822
|
+
self._final_boundary = _end_of_content_position(content)
|
|
823
|
+
self.timeout: WatchdogTimeout | None = None
|
|
824
|
+
|
|
825
|
+
async def run(self) -> None:
|
|
826
|
+
try:
|
|
827
|
+
await self._watch()
|
|
828
|
+
except asyncio.CancelledError:
|
|
829
|
+
raise
|
|
830
|
+
except Exception:
|
|
831
|
+
# Advisory task: any I/O failure it can hit (for example a dead
|
|
832
|
+
# coq-lsp process while sending the cancel) also fails the
|
|
833
|
+
# pending request future, which is the authoritative channel.
|
|
834
|
+
return
|
|
835
|
+
|
|
836
|
+
async def _watch(self) -> None:
|
|
837
|
+
loop = asyncio.get_running_loop()
|
|
838
|
+
notifications = self._client.notifications
|
|
839
|
+
uri = self._uri
|
|
840
|
+
version = self._version
|
|
841
|
+
after_sequence = notifications.latest_execution_sequence(uri, version)
|
|
842
|
+
active_range: dict[str, Any] | None = None
|
|
843
|
+
sentence_deadline: float | None = None
|
|
844
|
+
after_error_deadline: float | None = None
|
|
845
|
+
watch_errors = self._timeout_after_error is not None
|
|
846
|
+
exec_task: asyncio.Task[tuple[int, dict[str, Any]] | None] | None = None
|
|
847
|
+
error_task: asyncio.Task[None] | None = None
|
|
848
|
+
|
|
849
|
+
try:
|
|
850
|
+
while True:
|
|
851
|
+
if (
|
|
852
|
+
watch_errors
|
|
853
|
+
and after_error_deadline is None
|
|
854
|
+
and notifications.has_error_diagnostic(uri, version)
|
|
855
|
+
):
|
|
856
|
+
# The only site that arms the after-error deadline. It
|
|
857
|
+
# never resets, and it also covers the final sentence and
|
|
858
|
+
# end-of-file finalization, which the per-sentence
|
|
859
|
+
# deadline exempts: after an error a bounded exit matters
|
|
860
|
+
# more than attributing that time precisely.
|
|
861
|
+
after_error_deadline = loop.time() + self._timeout_after_error
|
|
862
|
+
|
|
863
|
+
expiring = self._earliest_deadline(
|
|
864
|
+
sentence_deadline, after_error_deadline
|
|
865
|
+
)
|
|
866
|
+
if expiring is not None and expiring[0] <= loop.time():
|
|
867
|
+
await self._cancel(expiring, active_range)
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
if exec_task is None or exec_task.done():
|
|
871
|
+
exec_task = asyncio.create_task(
|
|
872
|
+
notifications.wait_for_execution_info(
|
|
873
|
+
uri,
|
|
874
|
+
version,
|
|
875
|
+
after_sequence=after_sequence,
|
|
876
|
+
timeout=None,
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
waiters: set[asyncio.Task[Any]] = {exec_task}
|
|
880
|
+
if watch_errors and after_error_deadline is None:
|
|
881
|
+
if error_task is None or error_task.done():
|
|
882
|
+
error_task = asyncio.create_task(
|
|
883
|
+
notifications.wait_for_error_diagnostic(uri, version)
|
|
884
|
+
)
|
|
885
|
+
waiters.add(error_task)
|
|
886
|
+
|
|
887
|
+
done, _ = await asyncio.wait(
|
|
888
|
+
waiters,
|
|
889
|
+
timeout=(
|
|
890
|
+
None if expiring is None else expiring[0] - loop.time()
|
|
891
|
+
),
|
|
892
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
893
|
+
)
|
|
894
|
+
if not done and expiring is not None:
|
|
895
|
+
await self._cancel(expiring, active_range)
|
|
896
|
+
return
|
|
897
|
+
if exec_task in done:
|
|
898
|
+
update = exec_task.result()
|
|
899
|
+
if update is not None:
|
|
900
|
+
after_sequence, params = update
|
|
901
|
+
range_ = (
|
|
902
|
+
params.get("range") if isinstance(params, dict) else None
|
|
903
|
+
)
|
|
904
|
+
active_range = range_ if isinstance(range_, dict) else None
|
|
905
|
+
sentence_deadline = self._sentence_deadline_for(
|
|
906
|
+
active_range, loop.time()
|
|
907
|
+
)
|
|
908
|
+
# A completed error waiter only wakes the loop; the poll at
|
|
909
|
+
# the top of the next iteration arms the deadline.
|
|
910
|
+
finally:
|
|
911
|
+
for task in (exec_task, error_task):
|
|
912
|
+
if task is not None and not task.done():
|
|
913
|
+
task.cancel()
|
|
914
|
+
with suppress(asyncio.CancelledError):
|
|
915
|
+
await task
|
|
916
|
+
|
|
917
|
+
def _earliest_deadline(
|
|
918
|
+
self,
|
|
919
|
+
sentence_deadline: float | None,
|
|
920
|
+
after_error_deadline: float | None,
|
|
921
|
+
) -> tuple[float, TimeoutKind, float] | None:
|
|
922
|
+
"""The next (deadline, kind, configured seconds) to enforce.
|
|
923
|
+
|
|
924
|
+
The after-error deadline wins ties so a bounded post-error exit is
|
|
925
|
+
reported as such.
|
|
926
|
+
"""
|
|
927
|
+
expiring: tuple[float, TimeoutKind, float] | None = None
|
|
928
|
+
for deadline, kind, seconds in (
|
|
929
|
+
(after_error_deadline, "after_error", self._timeout_after_error),
|
|
930
|
+
(sentence_deadline, "command", self._command_timeout),
|
|
931
|
+
):
|
|
932
|
+
if deadline is None:
|
|
933
|
+
continue
|
|
934
|
+
if expiring is None or deadline < expiring[0]:
|
|
935
|
+
expiring = (deadline, kind, seconds)
|
|
936
|
+
return expiring
|
|
937
|
+
|
|
938
|
+
def _sentence_deadline_for(
|
|
939
|
+
self, active_range: dict[str, Any] | None, now: float
|
|
940
|
+
) -> float | None:
|
|
941
|
+
"""Absolute deadline for the sentence that just started, if any.
|
|
942
|
+
|
|
943
|
+
The final sentence is exempt because execution notifications mark
|
|
944
|
+
sentence starts, not completion, so end-of-file finalization would
|
|
945
|
+
otherwise be attributed to it.
|
|
946
|
+
"""
|
|
947
|
+
if (
|
|
948
|
+
self._command_timeout is None
|
|
949
|
+
or active_range is None
|
|
950
|
+
or self._is_final_sentence(active_range)
|
|
951
|
+
):
|
|
952
|
+
return None
|
|
953
|
+
return now + self._command_timeout
|
|
954
|
+
|
|
955
|
+
def _is_final_sentence(self, range_: dict[str, Any]) -> bool:
|
|
956
|
+
if self._final_boundary is None:
|
|
957
|
+
return False
|
|
958
|
+
end = range_.get("end")
|
|
959
|
+
if not isinstance(end, dict):
|
|
960
|
+
return False
|
|
961
|
+
line = end.get("line")
|
|
962
|
+
character = end.get("character")
|
|
963
|
+
if not isinstance(line, int) or not isinstance(character, int):
|
|
964
|
+
return False
|
|
965
|
+
return (line, character) >= self._final_boundary
|
|
966
|
+
|
|
967
|
+
async def _cancel(
|
|
968
|
+
self,
|
|
969
|
+
expiring: tuple[float, TimeoutKind, float],
|
|
970
|
+
timed_out_range: dict[str, Any] | None,
|
|
971
|
+
) -> None:
|
|
972
|
+
_, kind, seconds = expiring
|
|
973
|
+
self.timeout = WatchdogTimeout(
|
|
974
|
+
kind=kind, seconds=seconds, timed_out_range=timed_out_range
|
|
975
|
+
)
|
|
976
|
+
await self._client.notify("$/cancelRequest", {"id": self._request_id})
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _looks_like_path(program: str) -> bool:
|
|
980
|
+
return "/" in program
|