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,1082 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from .config import RocqLspConfig, bounded_overall_timeout_seconds, load_config
|
|
14
|
+
from .models import (
|
|
15
|
+
CheckResult,
|
|
16
|
+
DEFAULT_LONG_RUNNING_SECONDS,
|
|
17
|
+
RunAtResult,
|
|
18
|
+
TimeoutContext,
|
|
19
|
+
long_running_commands_from_perf_data,
|
|
20
|
+
run_at_location,
|
|
21
|
+
)
|
|
22
|
+
from .pipeline import build_diagnostics
|
|
23
|
+
from .rocq_client import (
|
|
24
|
+
RocqLspClient,
|
|
25
|
+
RocqLspHardTimeout,
|
|
26
|
+
RocqLspProcessExited,
|
|
27
|
+
RocqLspResponseError,
|
|
28
|
+
RocqLspUnavailableError,
|
|
29
|
+
)
|
|
30
|
+
from .session import RocqLspSession
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class DuneBuildResult:
|
|
35
|
+
applied: bool = False
|
|
36
|
+
message: str | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def failed(self) -> bool:
|
|
40
|
+
return self.message is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class DuneDetection:
|
|
45
|
+
toplevel: Literal["rocq", "coq"] | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _dune_reported_build_actions(stderr: str) -> bool:
|
|
49
|
+
"""Whether dune's short-display stderr shows a real dependency rebuild.
|
|
50
|
+
|
|
51
|
+
With ``--display=short`` dune reports build actions on stderr, but two kinds
|
|
52
|
+
of line do not mean a dependency ``.vo`` was recompiled and must not force a
|
|
53
|
+
session restart:
|
|
54
|
+
|
|
55
|
+
- ``Entering directory``/``Leaving directory``, printed for any invocation
|
|
56
|
+
from a subdirectory of the workspace root even when nothing was rebuilt.
|
|
57
|
+
- coqdep dependency-graph regeneration (``rocq <dir>/.<name>.theory.d``),
|
|
58
|
+
which dune re-runs whenever the *edited target file itself* changes. Since
|
|
59
|
+
``dune <top> top --toplevel echo`` never compiles the target, this is
|
|
60
|
+
bookkeeping, not a dependency rebuild; treating it as one would restart the
|
|
61
|
+
session on every edit-then-recheck and discard the incremental cache.
|
|
62
|
+
|
|
63
|
+
Any other non-empty line (notably a ``.vo``/``.glob`` compile) means a
|
|
64
|
+
dependency was recompiled and the cached session must be refreshed.
|
|
65
|
+
"""
|
|
66
|
+
for line in stderr.splitlines():
|
|
67
|
+
stripped = line.strip()
|
|
68
|
+
if not stripped:
|
|
69
|
+
continue
|
|
70
|
+
if stripped.startswith("Entering directory ") or stripped.startswith(
|
|
71
|
+
"Leaving directory "
|
|
72
|
+
):
|
|
73
|
+
continue
|
|
74
|
+
if stripped.endswith(".theory.d"):
|
|
75
|
+
continue
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RocqLspManager:
|
|
81
|
+
def __init__(self, config: RocqLspConfig | None = None) -> None:
|
|
82
|
+
self.config = config or load_config()
|
|
83
|
+
# Sessions are keyed by resolved Rocq file path. Each session still
|
|
84
|
+
# starts coq-lsp with the inferred workspace as cwd/root.
|
|
85
|
+
self._sessions: dict[Path, RocqLspSession] = {}
|
|
86
|
+
self._sessions_lock = asyncio.Lock()
|
|
87
|
+
self._dune_lock = asyncio.Lock()
|
|
88
|
+
self._dune_detection_cache: dict[Path, DuneDetection] = {}
|
|
89
|
+
self._cleanup_task: asyncio.Task[None] | None = None
|
|
90
|
+
|
|
91
|
+
def start_idle_cleanup(self) -> None:
|
|
92
|
+
if self.config.idle_ttl_seconds <= 0:
|
|
93
|
+
return
|
|
94
|
+
if self._cleanup_task is None or self._cleanup_task.done():
|
|
95
|
+
self._cleanup_task = asyncio.create_task(self._idle_cleanup_loop())
|
|
96
|
+
|
|
97
|
+
async def close(self) -> None:
|
|
98
|
+
cleanup_task = self._cleanup_task
|
|
99
|
+
self._cleanup_task = None
|
|
100
|
+
if cleanup_task is not None:
|
|
101
|
+
cleanup_task.cancel()
|
|
102
|
+
with suppress(asyncio.CancelledError):
|
|
103
|
+
await cleanup_task
|
|
104
|
+
|
|
105
|
+
async with self._sessions_lock:
|
|
106
|
+
sessions = list(self._sessions.values())
|
|
107
|
+
self._sessions.clear()
|
|
108
|
+
for session in sessions:
|
|
109
|
+
await session.stop()
|
|
110
|
+
|
|
111
|
+
async def check_file(
|
|
112
|
+
self,
|
|
113
|
+
file: str,
|
|
114
|
+
command_timeout_seconds: float | None = None,
|
|
115
|
+
timeout_after_error_seconds: float | None = None,
|
|
116
|
+
overall_timeout_seconds: float | None = None,
|
|
117
|
+
long_running_seconds: float | None = DEFAULT_LONG_RUNNING_SECONDS,
|
|
118
|
+
clear_cache: bool = False,
|
|
119
|
+
) -> CheckResult:
|
|
120
|
+
command_timeout = self._effective_optional_seconds(
|
|
121
|
+
command_timeout_seconds, self.config.timeout_policy.command_seconds
|
|
122
|
+
)
|
|
123
|
+
timeout_after_error = self._effective_optional_seconds(
|
|
124
|
+
timeout_after_error_seconds,
|
|
125
|
+
self.config.timeout_policy.request_seconds_after_error,
|
|
126
|
+
)
|
|
127
|
+
overall_timeout = self._effective_overall_timeout(overall_timeout_seconds)
|
|
128
|
+
long_running_threshold = self._effective_optional_seconds(
|
|
129
|
+
long_running_seconds, DEFAULT_LONG_RUNNING_SECONDS
|
|
130
|
+
)
|
|
131
|
+
path = self._resolve_file(file)
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
return CheckResult.from_diagnostics(
|
|
134
|
+
status="failed",
|
|
135
|
+
file=str(path),
|
|
136
|
+
uri=None,
|
|
137
|
+
workspace=None,
|
|
138
|
+
version=None,
|
|
139
|
+
completed=None,
|
|
140
|
+
diagnostics=[],
|
|
141
|
+
message=f"File does not exist: {path}",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
workspace = self._infer_workspace(path)
|
|
145
|
+
session_key = self._session_key(path)
|
|
146
|
+
dune_build = await self._maybe_build_dune_dependencies(path)
|
|
147
|
+
if dune_build.failed:
|
|
148
|
+
return CheckResult.from_diagnostics(
|
|
149
|
+
status="failed",
|
|
150
|
+
file=str(path),
|
|
151
|
+
uri=path.as_uri(),
|
|
152
|
+
workspace=str(workspace),
|
|
153
|
+
version=None,
|
|
154
|
+
completed=None,
|
|
155
|
+
diagnostics=[],
|
|
156
|
+
message=dune_build.message,
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
session, created = await self._checkout_session(session_key, workspace)
|
|
160
|
+
except RocqLspUnavailableError as exc:
|
|
161
|
+
return CheckResult.from_diagnostics(
|
|
162
|
+
status="failed",
|
|
163
|
+
file=str(path),
|
|
164
|
+
uri=path.as_uri(),
|
|
165
|
+
workspace=str(workspace),
|
|
166
|
+
version=None,
|
|
167
|
+
completed=None,
|
|
168
|
+
diagnostics=[],
|
|
169
|
+
message=str(exc),
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
should_restart = clear_cache or dune_build.applied
|
|
173
|
+
restarted = False
|
|
174
|
+
if should_restart and not created:
|
|
175
|
+
session = await self._restart_checked_out_session(
|
|
176
|
+
session_key,
|
|
177
|
+
workspace,
|
|
178
|
+
session,
|
|
179
|
+
)
|
|
180
|
+
restarted = True
|
|
181
|
+
result = await self._check_with_session(
|
|
182
|
+
session,
|
|
183
|
+
session_key,
|
|
184
|
+
path,
|
|
185
|
+
command_timeout,
|
|
186
|
+
timeout_after_error,
|
|
187
|
+
overall_timeout,
|
|
188
|
+
long_running_threshold,
|
|
189
|
+
)
|
|
190
|
+
if restarted:
|
|
191
|
+
result = result.model_copy(update={"restarted": True})
|
|
192
|
+
return result
|
|
193
|
+
finally:
|
|
194
|
+
session.lock.release()
|
|
195
|
+
await self._release_session(session)
|
|
196
|
+
await self._cleanup_idle_sessions()
|
|
197
|
+
|
|
198
|
+
async def run_at(
|
|
199
|
+
self,
|
|
200
|
+
file: str,
|
|
201
|
+
commands: str,
|
|
202
|
+
line: int,
|
|
203
|
+
character: int = 1,
|
|
204
|
+
overall_timeout_seconds: float | None = None,
|
|
205
|
+
) -> RunAtResult:
|
|
206
|
+
overall_timeout = self._effective_overall_timeout(overall_timeout_seconds)
|
|
207
|
+
path = self._resolve_file(file)
|
|
208
|
+
stripped_commands = commands.strip()
|
|
209
|
+
location = (
|
|
210
|
+
run_at_location(line, character)
|
|
211
|
+
if isinstance(line, int)
|
|
212
|
+
and isinstance(character, int)
|
|
213
|
+
and line > 0
|
|
214
|
+
and character > 0
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
if not path.is_file():
|
|
218
|
+
return RunAtResult.from_feedback(
|
|
219
|
+
status="failed",
|
|
220
|
+
file=str(path),
|
|
221
|
+
commands=stripped_commands,
|
|
222
|
+
location=location,
|
|
223
|
+
uri=None,
|
|
224
|
+
workspace=None,
|
|
225
|
+
version=None,
|
|
226
|
+
feedback=[],
|
|
227
|
+
message=f"File does not exist: {path}",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
workspace = self._infer_workspace(path)
|
|
231
|
+
session_key = self._session_key(path)
|
|
232
|
+
validation_error = self._run_at_validation_error(
|
|
233
|
+
stripped_commands, line, character
|
|
234
|
+
)
|
|
235
|
+
if validation_error is not None:
|
|
236
|
+
return RunAtResult.from_feedback(
|
|
237
|
+
status="failed",
|
|
238
|
+
file=str(path),
|
|
239
|
+
commands=stripped_commands,
|
|
240
|
+
location=location,
|
|
241
|
+
uri=None,
|
|
242
|
+
workspace=str(workspace),
|
|
243
|
+
version=None,
|
|
244
|
+
feedback=[],
|
|
245
|
+
message=validation_error,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
dune_build = await self._maybe_build_dune_dependencies(path)
|
|
249
|
+
if dune_build.failed:
|
|
250
|
+
return RunAtResult.from_feedback(
|
|
251
|
+
status="failed",
|
|
252
|
+
file=str(path),
|
|
253
|
+
commands=stripped_commands,
|
|
254
|
+
location=location,
|
|
255
|
+
uri=path.as_uri(),
|
|
256
|
+
workspace=str(workspace),
|
|
257
|
+
version=None,
|
|
258
|
+
feedback=[],
|
|
259
|
+
message=dune_build.message,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
session, created = await self._checkout_session(session_key, workspace)
|
|
264
|
+
except RocqLspUnavailableError as exc:
|
|
265
|
+
return RunAtResult.from_feedback(
|
|
266
|
+
status="failed",
|
|
267
|
+
file=str(path),
|
|
268
|
+
commands=stripped_commands,
|
|
269
|
+
location=location,
|
|
270
|
+
uri=path.as_uri(),
|
|
271
|
+
workspace=str(workspace),
|
|
272
|
+
version=None,
|
|
273
|
+
feedback=[],
|
|
274
|
+
message=str(exc),
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
restarted = False
|
|
278
|
+
if dune_build.applied and not created:
|
|
279
|
+
session = await self._restart_checked_out_session(
|
|
280
|
+
session_key,
|
|
281
|
+
workspace,
|
|
282
|
+
session,
|
|
283
|
+
)
|
|
284
|
+
restarted = True
|
|
285
|
+
result = await self._run_at_with_session(
|
|
286
|
+
session,
|
|
287
|
+
session_key,
|
|
288
|
+
path,
|
|
289
|
+
stripped_commands,
|
|
290
|
+
line,
|
|
291
|
+
character,
|
|
292
|
+
overall_timeout,
|
|
293
|
+
)
|
|
294
|
+
if restarted:
|
|
295
|
+
result = result.model_copy(update={"restarted": True})
|
|
296
|
+
return result
|
|
297
|
+
finally:
|
|
298
|
+
session.lock.release()
|
|
299
|
+
await self._release_session(session)
|
|
300
|
+
await self._cleanup_idle_sessions()
|
|
301
|
+
|
|
302
|
+
async def _check_with_session(
|
|
303
|
+
self,
|
|
304
|
+
session: RocqLspSession,
|
|
305
|
+
session_key: Path,
|
|
306
|
+
path: Path,
|
|
307
|
+
command_timeout: float | None,
|
|
308
|
+
timeout_after_error: float | None,
|
|
309
|
+
overall_timeout: float | None,
|
|
310
|
+
long_running_threshold: float | None,
|
|
311
|
+
) -> CheckResult:
|
|
312
|
+
client = session.client
|
|
313
|
+
uri = path.as_uri()
|
|
314
|
+
workspace = str(session.workspace)
|
|
315
|
+
version = session.versions.get(uri, 0) + 1
|
|
316
|
+
try:
|
|
317
|
+
content = path.read_text(encoding="utf-8")
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
return CheckResult.from_diagnostics(
|
|
320
|
+
status="failed",
|
|
321
|
+
file=str(path),
|
|
322
|
+
uri=uri,
|
|
323
|
+
workspace=workspace,
|
|
324
|
+
version=version,
|
|
325
|
+
completed=None,
|
|
326
|
+
diagnostics=[],
|
|
327
|
+
message=str(exc),
|
|
328
|
+
)
|
|
329
|
+
session.notifications.clear(uri, version)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
if uri in session.versions:
|
|
333
|
+
await client.notify_document_changed(path, content, version)
|
|
334
|
+
else:
|
|
335
|
+
await client.notify_document_opened(path, content, version)
|
|
336
|
+
session.versions[uri] = version
|
|
337
|
+
|
|
338
|
+
get_document_result = await client.request_get_document(
|
|
339
|
+
uri=uri,
|
|
340
|
+
version=version,
|
|
341
|
+
timeout=overall_timeout,
|
|
342
|
+
hard_timeout_grace=self.config.timeout_policy.cancel_grace_seconds,
|
|
343
|
+
command_timeout=command_timeout,
|
|
344
|
+
timeout_after_error=timeout_after_error,
|
|
345
|
+
goals="Str",
|
|
346
|
+
content=content,
|
|
347
|
+
)
|
|
348
|
+
completed_doc = get_document_result.document
|
|
349
|
+
request_timed_out = get_document_result.timed_out
|
|
350
|
+
raw_diagnostics, perf_data = await self._check_notifications(
|
|
351
|
+
session, uri, version, long_running_threshold, request_timed_out
|
|
352
|
+
)
|
|
353
|
+
# Timed-out checks skip the supplemental goals requests: each one
|
|
354
|
+
# may wait up to the request deadline, which would stretch the
|
|
355
|
+
# bounded exit the timeout (notably the after-error deadline)
|
|
356
|
+
# just promised.
|
|
357
|
+
supplemental_proof_states = (
|
|
358
|
+
{}
|
|
359
|
+
if request_timed_out
|
|
360
|
+
else await self._incomplete_proof_goal_states(
|
|
361
|
+
client, uri, version, raw_diagnostics, overall_timeout
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
diagnostics = build_diagnostics(
|
|
365
|
+
raw_diagnostics,
|
|
366
|
+
completed_doc,
|
|
367
|
+
content=content,
|
|
368
|
+
supplemental_proof_states=supplemental_proof_states,
|
|
369
|
+
)
|
|
370
|
+
long_running_commands = (
|
|
371
|
+
long_running_commands_from_perf_data(
|
|
372
|
+
content=content,
|
|
373
|
+
perf_data=perf_data,
|
|
374
|
+
threshold_seconds=long_running_threshold,
|
|
375
|
+
executed_ranges=session.notifications.execution_ranges(
|
|
376
|
+
uri, version
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
if long_running_threshold is not None
|
|
380
|
+
else []
|
|
381
|
+
)
|
|
382
|
+
timeout_context = None
|
|
383
|
+
if request_timed_out:
|
|
384
|
+
timeout_context = self._build_timeout_context(
|
|
385
|
+
session=session,
|
|
386
|
+
uri=uri,
|
|
387
|
+
version=version,
|
|
388
|
+
content=content,
|
|
389
|
+
document=completed_doc,
|
|
390
|
+
timeout_kind=get_document_result.timeout_kind or "request",
|
|
391
|
+
timed_out_range=get_document_result.timed_out_range,
|
|
392
|
+
command_timeout=command_timeout,
|
|
393
|
+
timeout_after_error=timeout_after_error,
|
|
394
|
+
overall_timeout=overall_timeout,
|
|
395
|
+
)
|
|
396
|
+
return CheckResult.from_diagnostics(
|
|
397
|
+
status="timeout" if request_timed_out else "complete",
|
|
398
|
+
file=str(path),
|
|
399
|
+
uri=uri,
|
|
400
|
+
workspace=workspace,
|
|
401
|
+
version=version,
|
|
402
|
+
completed=self._completed_only(completed_doc),
|
|
403
|
+
diagnostics=diagnostics,
|
|
404
|
+
timed_out=request_timed_out,
|
|
405
|
+
timeout_context=timeout_context,
|
|
406
|
+
long_running_commands=long_running_commands,
|
|
407
|
+
)
|
|
408
|
+
except asyncio.CancelledError:
|
|
409
|
+
if getattr(client, "restart_required_after_external_cancellation", False):
|
|
410
|
+
await self._reset_session(session_key)
|
|
411
|
+
raise
|
|
412
|
+
except RocqLspHardTimeout as exc:
|
|
413
|
+
raw_diagnostics = await session.notifications.wait_for_diagnostics(
|
|
414
|
+
uri, version
|
|
415
|
+
)
|
|
416
|
+
diagnostics = build_diagnostics(raw_diagnostics, None, content=content)
|
|
417
|
+
timeout_context = self._build_timeout_context(
|
|
418
|
+
session=session,
|
|
419
|
+
uri=uri,
|
|
420
|
+
version=version,
|
|
421
|
+
content=content,
|
|
422
|
+
document=None,
|
|
423
|
+
timeout_kind=exc.timeout_kind,
|
|
424
|
+
timed_out_range=exc.timed_out_range,
|
|
425
|
+
command_timeout=command_timeout,
|
|
426
|
+
timeout_after_error=timeout_after_error,
|
|
427
|
+
overall_timeout=overall_timeout,
|
|
428
|
+
)
|
|
429
|
+
await self._reset_session(session_key)
|
|
430
|
+
return CheckResult.from_diagnostics(
|
|
431
|
+
status="timeout",
|
|
432
|
+
file=str(path),
|
|
433
|
+
uri=uri,
|
|
434
|
+
workspace=workspace,
|
|
435
|
+
version=version,
|
|
436
|
+
completed=None,
|
|
437
|
+
diagnostics=diagnostics,
|
|
438
|
+
timed_out=True,
|
|
439
|
+
restarted=True,
|
|
440
|
+
timeout_context=timeout_context,
|
|
441
|
+
message=str(exc),
|
|
442
|
+
)
|
|
443
|
+
except RocqLspResponseError as exc:
|
|
444
|
+
raw_diagnostics = await session.notifications.wait_for_diagnostics(
|
|
445
|
+
uri, version
|
|
446
|
+
)
|
|
447
|
+
diagnostics = build_diagnostics(raw_diagnostics, None, content=content)
|
|
448
|
+
return CheckResult.from_diagnostics(
|
|
449
|
+
status="failed",
|
|
450
|
+
file=str(path),
|
|
451
|
+
uri=uri,
|
|
452
|
+
workspace=workspace,
|
|
453
|
+
version=version,
|
|
454
|
+
completed=None,
|
|
455
|
+
diagnostics=diagnostics,
|
|
456
|
+
message=str(exc.error),
|
|
457
|
+
)
|
|
458
|
+
except (RocqLspProcessExited, ConnectionError) as exc:
|
|
459
|
+
# The coq-lsp process died mid-check. Reset the session so the
|
|
460
|
+
# next call starts a fresh process instead of reusing the dead
|
|
461
|
+
# one until the idle reaper runs.
|
|
462
|
+
raw_diagnostics = await session.notifications.wait_for_diagnostics(
|
|
463
|
+
uri, version
|
|
464
|
+
)
|
|
465
|
+
diagnostics = build_diagnostics(raw_diagnostics, None, content=content)
|
|
466
|
+
message = await client.process_failure_message(exc)
|
|
467
|
+
await self._reset_session(session_key)
|
|
468
|
+
return CheckResult.from_diagnostics(
|
|
469
|
+
status="failed",
|
|
470
|
+
file=str(path),
|
|
471
|
+
uri=uri,
|
|
472
|
+
workspace=workspace,
|
|
473
|
+
version=version,
|
|
474
|
+
completed=None,
|
|
475
|
+
diagnostics=diagnostics,
|
|
476
|
+
restarted=True,
|
|
477
|
+
message=message,
|
|
478
|
+
)
|
|
479
|
+
except Exception as exc:
|
|
480
|
+
raw_diagnostics = await session.notifications.wait_for_diagnostics(
|
|
481
|
+
uri, version
|
|
482
|
+
)
|
|
483
|
+
diagnostics = build_diagnostics(raw_diagnostics, None, content=content)
|
|
484
|
+
return CheckResult.from_diagnostics(
|
|
485
|
+
status="failed",
|
|
486
|
+
file=str(path),
|
|
487
|
+
uri=uri,
|
|
488
|
+
workspace=workspace,
|
|
489
|
+
version=version,
|
|
490
|
+
completed=None,
|
|
491
|
+
diagnostics=diagnostics,
|
|
492
|
+
message=str(exc),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
async def _run_at_with_session(
|
|
496
|
+
self,
|
|
497
|
+
session: RocqLspSession,
|
|
498
|
+
session_key: Path,
|
|
499
|
+
path: Path,
|
|
500
|
+
commands: str,
|
|
501
|
+
line: int,
|
|
502
|
+
character: int,
|
|
503
|
+
overall_timeout: float | None,
|
|
504
|
+
) -> RunAtResult:
|
|
505
|
+
client = session.client
|
|
506
|
+
uri = path.as_uri()
|
|
507
|
+
workspace = str(session.workspace)
|
|
508
|
+
version = session.versions.get(uri, 0) + 1
|
|
509
|
+
location = run_at_location(line, character)
|
|
510
|
+
try:
|
|
511
|
+
content = path.read_text(encoding="utf-8")
|
|
512
|
+
except Exception as exc:
|
|
513
|
+
return RunAtResult.from_feedback(
|
|
514
|
+
status="failed",
|
|
515
|
+
file=str(path),
|
|
516
|
+
commands=commands,
|
|
517
|
+
location=location,
|
|
518
|
+
uri=uri,
|
|
519
|
+
workspace=workspace,
|
|
520
|
+
version=version,
|
|
521
|
+
feedback=[],
|
|
522
|
+
message=str(exc),
|
|
523
|
+
)
|
|
524
|
+
session.notifications.clear(uri, version)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
if uri in session.versions:
|
|
528
|
+
await client.notify_document_changed(path, content, version)
|
|
529
|
+
else:
|
|
530
|
+
await client.notify_document_opened(path, content, version)
|
|
531
|
+
session.versions[uri] = version
|
|
532
|
+
|
|
533
|
+
result, request_timed_out = await client.request_run_at_point(
|
|
534
|
+
uri=uri,
|
|
535
|
+
version=version,
|
|
536
|
+
position={"line": line - 1, "character": character - 1},
|
|
537
|
+
commands=commands,
|
|
538
|
+
timeout=overall_timeout,
|
|
539
|
+
hard_timeout_grace=self.config.timeout_policy.cancel_grace_seconds,
|
|
540
|
+
)
|
|
541
|
+
return RunAtResult.from_feedback(
|
|
542
|
+
status="timeout" if request_timed_out else "complete",
|
|
543
|
+
file=str(path),
|
|
544
|
+
commands=commands,
|
|
545
|
+
location=location,
|
|
546
|
+
uri=uri,
|
|
547
|
+
workspace=workspace,
|
|
548
|
+
version=version,
|
|
549
|
+
feedback=result,
|
|
550
|
+
timed_out=request_timed_out,
|
|
551
|
+
)
|
|
552
|
+
except asyncio.CancelledError:
|
|
553
|
+
if getattr(client, "restart_required_after_external_cancellation", False):
|
|
554
|
+
await self._reset_session(session_key)
|
|
555
|
+
raise
|
|
556
|
+
except RocqLspHardTimeout as exc:
|
|
557
|
+
await self._reset_session(session_key)
|
|
558
|
+
return RunAtResult.from_feedback(
|
|
559
|
+
status="timeout",
|
|
560
|
+
file=str(path),
|
|
561
|
+
commands=commands,
|
|
562
|
+
location=location,
|
|
563
|
+
uri=uri,
|
|
564
|
+
workspace=workspace,
|
|
565
|
+
version=version,
|
|
566
|
+
feedback=[],
|
|
567
|
+
timed_out=True,
|
|
568
|
+
restarted=True,
|
|
569
|
+
message=str(exc),
|
|
570
|
+
)
|
|
571
|
+
except RocqLspResponseError as exc:
|
|
572
|
+
return RunAtResult.from_feedback(
|
|
573
|
+
status="failed",
|
|
574
|
+
file=str(path),
|
|
575
|
+
commands=commands,
|
|
576
|
+
location=location,
|
|
577
|
+
uri=uri,
|
|
578
|
+
workspace=workspace,
|
|
579
|
+
version=version,
|
|
580
|
+
feedback=self._response_error_feedback(exc.error),
|
|
581
|
+
message=self._response_error_message(exc.error),
|
|
582
|
+
)
|
|
583
|
+
except (RocqLspProcessExited, ConnectionError) as exc:
|
|
584
|
+
# The coq-lsp process died mid-request; reset so the next call
|
|
585
|
+
# starts a fresh process.
|
|
586
|
+
message = await client.process_failure_message(exc)
|
|
587
|
+
await self._reset_session(session_key)
|
|
588
|
+
return RunAtResult.from_feedback(
|
|
589
|
+
status="failed",
|
|
590
|
+
file=str(path),
|
|
591
|
+
commands=commands,
|
|
592
|
+
location=location,
|
|
593
|
+
uri=uri,
|
|
594
|
+
workspace=workspace,
|
|
595
|
+
version=version,
|
|
596
|
+
feedback=[],
|
|
597
|
+
restarted=True,
|
|
598
|
+
message=message,
|
|
599
|
+
)
|
|
600
|
+
except Exception as exc:
|
|
601
|
+
return RunAtResult.from_feedback(
|
|
602
|
+
status="failed",
|
|
603
|
+
file=str(path),
|
|
604
|
+
commands=commands,
|
|
605
|
+
location=location,
|
|
606
|
+
uri=uri,
|
|
607
|
+
workspace=workspace,
|
|
608
|
+
version=version,
|
|
609
|
+
feedback=[],
|
|
610
|
+
message=str(exc),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
async def _checkout_session(
|
|
614
|
+
self,
|
|
615
|
+
session_key: Path,
|
|
616
|
+
workspace: Path,
|
|
617
|
+
) -> tuple[RocqLspSession, bool]:
|
|
618
|
+
while True:
|
|
619
|
+
session, created = await self._reserve_session(session_key, workspace)
|
|
620
|
+
try:
|
|
621
|
+
await session.lock.acquire()
|
|
622
|
+
except BaseException:
|
|
623
|
+
await self._release_session(session)
|
|
624
|
+
raise
|
|
625
|
+
|
|
626
|
+
if await self._is_current_session(session_key, session):
|
|
627
|
+
if session.workspace == workspace:
|
|
628
|
+
return session, created
|
|
629
|
+
session.lock.release()
|
|
630
|
+
await self._release_session(session)
|
|
631
|
+
await self._reset_session(session_key)
|
|
632
|
+
continue
|
|
633
|
+
|
|
634
|
+
session.lock.release()
|
|
635
|
+
await self._release_session(session)
|
|
636
|
+
|
|
637
|
+
async def _restart_checked_out_session(
|
|
638
|
+
self,
|
|
639
|
+
session_key: Path,
|
|
640
|
+
workspace: Path,
|
|
641
|
+
session: RocqLspSession,
|
|
642
|
+
) -> RocqLspSession:
|
|
643
|
+
replacement = await self._create_session(workspace)
|
|
644
|
+
replacement.active_requests = 1
|
|
645
|
+
await replacement.lock.acquire()
|
|
646
|
+
|
|
647
|
+
replaced = False
|
|
648
|
+
async with self._sessions_lock:
|
|
649
|
+
if self._sessions.get(session_key) is session:
|
|
650
|
+
self._sessions[session_key] = replacement
|
|
651
|
+
replaced = True
|
|
652
|
+
|
|
653
|
+
if not replaced:
|
|
654
|
+
replacement.lock.release()
|
|
655
|
+
await self._release_session(replacement)
|
|
656
|
+
await replacement.stop(hard=True)
|
|
657
|
+
session.lock.release()
|
|
658
|
+
await self._release_session(session)
|
|
659
|
+
checked_out, _created = await self._checkout_session(session_key, workspace)
|
|
660
|
+
return checked_out
|
|
661
|
+
|
|
662
|
+
session.lock.release()
|
|
663
|
+
await self._release_session(session)
|
|
664
|
+
await session.stop(hard=True)
|
|
665
|
+
return replacement
|
|
666
|
+
|
|
667
|
+
@staticmethod
|
|
668
|
+
def _effective_optional_seconds(
|
|
669
|
+
override: float | None, default: float | None
|
|
670
|
+
) -> float | None:
|
|
671
|
+
"""Shared override convention for optional timeouts and thresholds.
|
|
672
|
+
|
|
673
|
+
``None`` uses the default, non-positive values disable, and positive
|
|
674
|
+
values enable.
|
|
675
|
+
"""
|
|
676
|
+
value = default if override is None else override
|
|
677
|
+
if value is None or value <= 0:
|
|
678
|
+
return None
|
|
679
|
+
return value
|
|
680
|
+
|
|
681
|
+
def _effective_overall_timeout(self, override: float | None) -> float | None:
|
|
682
|
+
if override is None:
|
|
683
|
+
return bounded_overall_timeout_seconds(
|
|
684
|
+
self.config.timeout_policy.request_seconds
|
|
685
|
+
)
|
|
686
|
+
if override <= 0:
|
|
687
|
+
return None
|
|
688
|
+
return bounded_overall_timeout_seconds(override)
|
|
689
|
+
|
|
690
|
+
def _build_timeout_context(
|
|
691
|
+
self,
|
|
692
|
+
*,
|
|
693
|
+
session: RocqLspSession,
|
|
694
|
+
uri: str,
|
|
695
|
+
version: int,
|
|
696
|
+
content: str,
|
|
697
|
+
document: dict[str, Any] | None,
|
|
698
|
+
timeout_kind: str,
|
|
699
|
+
timed_out_range: dict[str, Any] | None,
|
|
700
|
+
command_timeout: float | None,
|
|
701
|
+
timeout_after_error: float | None,
|
|
702
|
+
overall_timeout: float | None,
|
|
703
|
+
) -> TimeoutContext:
|
|
704
|
+
return TimeoutContext.from_manager_state(
|
|
705
|
+
content=content,
|
|
706
|
+
document=document,
|
|
707
|
+
progress=session.notifications.latest_file_progress(uri, version),
|
|
708
|
+
cancel_grace_seconds=self.config.timeout_policy.cancel_grace_seconds,
|
|
709
|
+
timeout_kind=timeout_kind,
|
|
710
|
+
request_timeout_seconds=overall_timeout,
|
|
711
|
+
command_timeout_seconds=(
|
|
712
|
+
command_timeout if timeout_kind == "command" else None
|
|
713
|
+
),
|
|
714
|
+
timeout_after_error_seconds=(
|
|
715
|
+
timeout_after_error if timeout_kind == "after_error" else None
|
|
716
|
+
),
|
|
717
|
+
timed_out_range=timed_out_range,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
async def _check_notifications(
|
|
721
|
+
self,
|
|
722
|
+
session: RocqLspSession,
|
|
723
|
+
uri: str,
|
|
724
|
+
version: int,
|
|
725
|
+
long_running_threshold: float | None,
|
|
726
|
+
request_timed_out: bool,
|
|
727
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
|
728
|
+
diagnostics_task = asyncio.create_task(
|
|
729
|
+
session.notifications.wait_for_diagnostics(uri, version)
|
|
730
|
+
)
|
|
731
|
+
perf_task: asyncio.Task[dict[str, Any] | None] | None = None
|
|
732
|
+
if long_running_threshold is not None and not request_timed_out:
|
|
733
|
+
perf_task = asyncio.create_task(
|
|
734
|
+
session.notifications.wait_for_file_perf_data(uri, version)
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
raw_diagnostics = await diagnostics_task
|
|
739
|
+
perf_data = await perf_task if perf_task is not None else None
|
|
740
|
+
return raw_diagnostics, perf_data
|
|
741
|
+
finally:
|
|
742
|
+
if perf_task is not None and not perf_task.done():
|
|
743
|
+
perf_task.cancel()
|
|
744
|
+
with suppress(asyncio.CancelledError):
|
|
745
|
+
await perf_task
|
|
746
|
+
if not diagnostics_task.done():
|
|
747
|
+
diagnostics_task.cancel()
|
|
748
|
+
with suppress(asyncio.CancelledError):
|
|
749
|
+
await diagnostics_task
|
|
750
|
+
|
|
751
|
+
async def _reserve_session(
|
|
752
|
+
self,
|
|
753
|
+
session_key: Path,
|
|
754
|
+
workspace: Path,
|
|
755
|
+
) -> tuple[RocqLspSession, bool]:
|
|
756
|
+
expired: list[RocqLspSession] = []
|
|
757
|
+
session: RocqLspSession | None = None
|
|
758
|
+
created = False
|
|
759
|
+
try:
|
|
760
|
+
async with self._sessions_lock:
|
|
761
|
+
expired = self._pop_idle_sessions_locked()
|
|
762
|
+
session = self._sessions.get(session_key)
|
|
763
|
+
if session is None:
|
|
764
|
+
session = await self._create_session(workspace)
|
|
765
|
+
self._sessions[session_key] = session
|
|
766
|
+
created = True
|
|
767
|
+
session.active_requests += 1
|
|
768
|
+
finally:
|
|
769
|
+
for expired_session in expired:
|
|
770
|
+
await expired_session.stop()
|
|
771
|
+
|
|
772
|
+
if session is None:
|
|
773
|
+
raise RuntimeError("failed to create rocq-lsp session")
|
|
774
|
+
return session, created
|
|
775
|
+
|
|
776
|
+
async def _is_current_session(
|
|
777
|
+
self, session_key: Path, session: RocqLspSession
|
|
778
|
+
) -> bool:
|
|
779
|
+
async with self._sessions_lock:
|
|
780
|
+
return self._sessions.get(session_key) is session
|
|
781
|
+
|
|
782
|
+
async def _create_session(self, workspace: Path) -> RocqLspSession:
|
|
783
|
+
session = RocqLspSession(workspace=workspace, config=self.config)
|
|
784
|
+
await session.start()
|
|
785
|
+
return session
|
|
786
|
+
|
|
787
|
+
async def _maybe_build_dune_dependencies(self, path: Path) -> DuneBuildResult:
|
|
788
|
+
if not self.config.dune_build:
|
|
789
|
+
return DuneBuildResult()
|
|
790
|
+
detection = await self._detect_dune(path)
|
|
791
|
+
if detection.toplevel is None:
|
|
792
|
+
return DuneBuildResult()
|
|
793
|
+
|
|
794
|
+
command = (
|
|
795
|
+
"dune",
|
|
796
|
+
detection.toplevel,
|
|
797
|
+
"top",
|
|
798
|
+
path.name,
|
|
799
|
+
"--toplevel",
|
|
800
|
+
"echo",
|
|
801
|
+
"--display=short",
|
|
802
|
+
)
|
|
803
|
+
result = await self._run_dune_command(path.parent, command)
|
|
804
|
+
if result is None:
|
|
805
|
+
return DuneBuildResult()
|
|
806
|
+
returncode, stdout, stderr = result
|
|
807
|
+
if returncode == 0:
|
|
808
|
+
# The echo toplevel always prints arguments to stdout. With
|
|
809
|
+
# --display=short, dune reports actual build actions on stderr,
|
|
810
|
+
# alongside "Entering directory"/"Leaving directory" navigation
|
|
811
|
+
# noise that must not be mistaken for a rebuild.
|
|
812
|
+
return DuneBuildResult(applied=_dune_reported_build_actions(stderr))
|
|
813
|
+
output = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
|
|
814
|
+
return DuneBuildResult(
|
|
815
|
+
message=output or f"dune dependency build failed for {path.name}"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
async def _detect_dune(self, path: Path) -> DuneDetection:
|
|
819
|
+
cached = self._dune_detection_cache.get(path)
|
|
820
|
+
if cached is not None:
|
|
821
|
+
return cached
|
|
822
|
+
|
|
823
|
+
if not await self._dune_command_succeeds(
|
|
824
|
+
path.parent,
|
|
825
|
+
("dune", "describe", "workspace"),
|
|
826
|
+
):
|
|
827
|
+
detection = DuneDetection()
|
|
828
|
+
self._dune_detection_cache[path] = detection
|
|
829
|
+
return detection
|
|
830
|
+
|
|
831
|
+
toplevel = await self._dune_toplevel(path)
|
|
832
|
+
detection = DuneDetection(toplevel=toplevel)
|
|
833
|
+
self._dune_detection_cache[path] = detection
|
|
834
|
+
return detection
|
|
835
|
+
|
|
836
|
+
async def _dune_toplevel(self, path: Path) -> str | None:
|
|
837
|
+
for toplevel in ("rocq", "coq"):
|
|
838
|
+
command = (
|
|
839
|
+
"dune",
|
|
840
|
+
toplevel,
|
|
841
|
+
"top",
|
|
842
|
+
"--no-build",
|
|
843
|
+
"--toplevel",
|
|
844
|
+
"echo",
|
|
845
|
+
path.name,
|
|
846
|
+
)
|
|
847
|
+
if await self._dune_command_succeeds(path.parent, command):
|
|
848
|
+
return toplevel
|
|
849
|
+
return None
|
|
850
|
+
|
|
851
|
+
async def _dune_command_succeeds(
|
|
852
|
+
self,
|
|
853
|
+
cwd: Path,
|
|
854
|
+
command: tuple[str, ...],
|
|
855
|
+
) -> bool:
|
|
856
|
+
result = await self._run_dune_command(cwd, command)
|
|
857
|
+
return result is not None and result[0] == 0
|
|
858
|
+
|
|
859
|
+
async def _run_dune_command(
|
|
860
|
+
self,
|
|
861
|
+
cwd: Path,
|
|
862
|
+
command: tuple[str, ...],
|
|
863
|
+
) -> tuple[int, str, str] | None:
|
|
864
|
+
async with self._dune_lock:
|
|
865
|
+
return await self._run_dune_command_unlocked(cwd, command)
|
|
866
|
+
|
|
867
|
+
async def _run_dune_command_unlocked(
|
|
868
|
+
self,
|
|
869
|
+
cwd: Path,
|
|
870
|
+
command: tuple[str, ...],
|
|
871
|
+
) -> tuple[int, str, str] | None:
|
|
872
|
+
try:
|
|
873
|
+
process = await asyncio.create_subprocess_exec(
|
|
874
|
+
*command,
|
|
875
|
+
cwd=cwd,
|
|
876
|
+
stdout=asyncio.subprocess.PIPE,
|
|
877
|
+
stderr=asyncio.subprocess.PIPE,
|
|
878
|
+
start_new_session=True,
|
|
879
|
+
)
|
|
880
|
+
except FileNotFoundError:
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
stdout, stderr = await asyncio.wait_for(
|
|
885
|
+
process.communicate(),
|
|
886
|
+
timeout=self.config.dune_timeout_seconds,
|
|
887
|
+
)
|
|
888
|
+
except asyncio.TimeoutError:
|
|
889
|
+
await self._kill_dune_process_group(process)
|
|
890
|
+
return (
|
|
891
|
+
1,
|
|
892
|
+
"",
|
|
893
|
+
f"dune dependency build timed out after "
|
|
894
|
+
f"{self.config.dune_timeout_seconds:g} seconds",
|
|
895
|
+
)
|
|
896
|
+
except asyncio.CancelledError:
|
|
897
|
+
await self._kill_dune_process_group(process)
|
|
898
|
+
raise
|
|
899
|
+
return (
|
|
900
|
+
process.returncode or 0,
|
|
901
|
+
stdout.decode("utf-8", errors="replace"),
|
|
902
|
+
stderr.decode("utf-8", errors="replace"),
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
async def _kill_dune_process_group(
|
|
906
|
+
self, process: asyncio.subprocess.Process
|
|
907
|
+
) -> None:
|
|
908
|
+
if process.returncode is None:
|
|
909
|
+
with suppress(ProcessLookupError):
|
|
910
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
911
|
+
with suppress(ProcessLookupError):
|
|
912
|
+
process.kill()
|
|
913
|
+
with suppress(ProcessLookupError):
|
|
914
|
+
await process.wait()
|
|
915
|
+
|
|
916
|
+
async def _release_session(self, session: RocqLspSession) -> None:
|
|
917
|
+
async with self._sessions_lock:
|
|
918
|
+
if session.active_requests > 0:
|
|
919
|
+
session.active_requests -= 1
|
|
920
|
+
session.last_used_at = time.monotonic()
|
|
921
|
+
|
|
922
|
+
async def _cleanup_idle_sessions(self) -> None:
|
|
923
|
+
async with self._sessions_lock:
|
|
924
|
+
expired = self._pop_idle_sessions_locked()
|
|
925
|
+
for session in expired:
|
|
926
|
+
await session.stop()
|
|
927
|
+
|
|
928
|
+
async def _idle_cleanup_loop(self) -> None:
|
|
929
|
+
interval = self._idle_cleanup_interval()
|
|
930
|
+
while True:
|
|
931
|
+
await asyncio.sleep(interval)
|
|
932
|
+
await self._cleanup_idle_sessions()
|
|
933
|
+
|
|
934
|
+
def _idle_cleanup_interval(self) -> float:
|
|
935
|
+
ttl = self.config.idle_ttl_seconds
|
|
936
|
+
if ttl <= 0:
|
|
937
|
+
return 60.0
|
|
938
|
+
return max(1.0, min(60.0, ttl / 2))
|
|
939
|
+
|
|
940
|
+
def _pop_idle_sessions_locked(self) -> list[RocqLspSession]:
|
|
941
|
+
ttl = self.config.idle_ttl_seconds
|
|
942
|
+
if ttl <= 0:
|
|
943
|
+
return []
|
|
944
|
+
|
|
945
|
+
now = time.monotonic()
|
|
946
|
+
expired: list[RocqLspSession] = []
|
|
947
|
+
for session_key, session in list(self._sessions.items()):
|
|
948
|
+
if session.active_requests > 0 or session.lock.locked():
|
|
949
|
+
continue
|
|
950
|
+
if now - session.last_used_at < ttl:
|
|
951
|
+
continue
|
|
952
|
+
expired.append(session)
|
|
953
|
+
del self._sessions[session_key]
|
|
954
|
+
return expired
|
|
955
|
+
|
|
956
|
+
async def _reset_session(self, session_key: Path) -> None:
|
|
957
|
+
async with self._sessions_lock:
|
|
958
|
+
session = self._sessions.pop(session_key, None)
|
|
959
|
+
if session is not None:
|
|
960
|
+
await session.stop(hard=True)
|
|
961
|
+
|
|
962
|
+
async def _incomplete_proof_goal_states(
|
|
963
|
+
self,
|
|
964
|
+
client: RocqLspClient,
|
|
965
|
+
uri: str,
|
|
966
|
+
version: int,
|
|
967
|
+
raw_diagnostics: list[dict[str, Any]],
|
|
968
|
+
overall_timeout: float | None,
|
|
969
|
+
) -> dict[str, Any]:
|
|
970
|
+
states: dict[str, Any] = {}
|
|
971
|
+
requested: set[str] = set()
|
|
972
|
+
for diagnostic in raw_diagnostics:
|
|
973
|
+
if not self._needs_incomplete_proof_goal_request(diagnostic):
|
|
974
|
+
continue
|
|
975
|
+
range_ = self._diagnostic_sentence_range(diagnostic)
|
|
976
|
+
position = self._range_start(range_)
|
|
977
|
+
if position is None:
|
|
978
|
+
continue
|
|
979
|
+
key = self._range_key(range_)
|
|
980
|
+
if key in requested:
|
|
981
|
+
continue
|
|
982
|
+
requested.add(key)
|
|
983
|
+
try:
|
|
984
|
+
states[key] = await client.request_goals(
|
|
985
|
+
uri=uri,
|
|
986
|
+
version=version,
|
|
987
|
+
position=position,
|
|
988
|
+
timeout=overall_timeout,
|
|
989
|
+
)
|
|
990
|
+
except Exception:
|
|
991
|
+
continue
|
|
992
|
+
return states
|
|
993
|
+
|
|
994
|
+
def _resolve_file(self, file: str) -> Path:
|
|
995
|
+
path = Path(file).expanduser()
|
|
996
|
+
if not path.is_absolute():
|
|
997
|
+
path = Path.cwd() / path
|
|
998
|
+
return path.resolve()
|
|
999
|
+
|
|
1000
|
+
@staticmethod
|
|
1001
|
+
def _session_key(file: Path) -> Path:
|
|
1002
|
+
return file.resolve()
|
|
1003
|
+
|
|
1004
|
+
def _infer_workspace(self, file: Path) -> Path:
|
|
1005
|
+
if self.config.workspace is not None:
|
|
1006
|
+
return self.config.workspace
|
|
1007
|
+
|
|
1008
|
+
for candidate in (file.parent, *file.parent.parents):
|
|
1009
|
+
if (candidate / "_RocqProject").exists() or (
|
|
1010
|
+
candidate / "_CoqProject"
|
|
1011
|
+
).exists():
|
|
1012
|
+
return candidate.resolve()
|
|
1013
|
+
return file.parent.resolve()
|
|
1014
|
+
|
|
1015
|
+
@staticmethod
|
|
1016
|
+
def _completed_only(document: dict[str, Any]) -> dict[str, Any] | None:
|
|
1017
|
+
completed = document.get("completed")
|
|
1018
|
+
return completed if isinstance(completed, dict) else None
|
|
1019
|
+
|
|
1020
|
+
@staticmethod
|
|
1021
|
+
def _needs_incomplete_proof_goal_request(diagnostic: Any) -> bool:
|
|
1022
|
+
if not isinstance(diagnostic, dict) or diagnostic.get("severity") != 1:
|
|
1023
|
+
return False
|
|
1024
|
+
message = diagnostic.get("message", "")
|
|
1025
|
+
return (
|
|
1026
|
+
isinstance(message, str)
|
|
1027
|
+
and "Attempt to save an incomplete proof" in message
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
@staticmethod
|
|
1031
|
+
def _diagnostic_sentence_range(diagnostic: Any) -> dict[str, Any]:
|
|
1032
|
+
if not isinstance(diagnostic, dict):
|
|
1033
|
+
return {}
|
|
1034
|
+
data = diagnostic.get("data")
|
|
1035
|
+
if isinstance(data, dict) and isinstance(data.get("sentenceRange"), dict):
|
|
1036
|
+
return data["sentenceRange"]
|
|
1037
|
+
range_ = diagnostic.get("range")
|
|
1038
|
+
return range_ if isinstance(range_, dict) else {}
|
|
1039
|
+
|
|
1040
|
+
@staticmethod
|
|
1041
|
+
def _range_start(range_: dict[str, Any]) -> dict[str, int] | None:
|
|
1042
|
+
start = range_.get("start")
|
|
1043
|
+
if not isinstance(start, dict):
|
|
1044
|
+
return None
|
|
1045
|
+
line = start.get("line")
|
|
1046
|
+
character = start.get("character")
|
|
1047
|
+
if isinstance(line, int) and isinstance(character, int):
|
|
1048
|
+
return {"line": line, "character": character}
|
|
1049
|
+
return None
|
|
1050
|
+
|
|
1051
|
+
@staticmethod
|
|
1052
|
+
def _range_key(range_: dict[str, Any]) -> str:
|
|
1053
|
+
return json.dumps(range_, sort_keys=True, separators=(",", ":"))
|
|
1054
|
+
|
|
1055
|
+
@staticmethod
|
|
1056
|
+
def _run_at_validation_error(
|
|
1057
|
+
commands: str, line: int, character: int
|
|
1058
|
+
) -> str | None:
|
|
1059
|
+
if not isinstance(line, int) or not isinstance(character, int):
|
|
1060
|
+
return "line and character must be positive 1-based integers"
|
|
1061
|
+
if line <= 0 or character <= 0:
|
|
1062
|
+
return "line and character must be positive 1-based integers"
|
|
1063
|
+
if not commands:
|
|
1064
|
+
return "commands must be a non-empty Rocq command string ending in '.'"
|
|
1065
|
+
if not commands.endswith("."):
|
|
1066
|
+
return "commands must be a non-empty Rocq command string ending in '.'"
|
|
1067
|
+
return None
|
|
1068
|
+
|
|
1069
|
+
@staticmethod
|
|
1070
|
+
def _response_error_message(error: dict[str, Any]) -> str:
|
|
1071
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
1072
|
+
return message if isinstance(message, str) else str(error)
|
|
1073
|
+
|
|
1074
|
+
@staticmethod
|
|
1075
|
+
def _response_error_feedback(error: dict[str, Any]) -> Any:
|
|
1076
|
+
if not isinstance(error, dict):
|
|
1077
|
+
return []
|
|
1078
|
+
data = error.get("data")
|
|
1079
|
+
if isinstance(data, dict):
|
|
1080
|
+
feedback = data.get("feedback")
|
|
1081
|
+
return feedback if isinstance(feedback, list) else []
|
|
1082
|
+
return []
|