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.
@@ -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 []