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,225 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import suppress
5
+ from typing import Any
6
+
7
+
8
+ class NotificationStore:
9
+ def __init__(self) -> None:
10
+ self._diagnostics: dict[tuple[str, int | None], list[dict[str, Any]]] = {}
11
+ self._diagnostic_events: dict[tuple[str, int | None], asyncio.Event] = {}
12
+ self._latest_versions_by_uri: dict[str, int | None] = {}
13
+ self._file_progress: dict[tuple[str, int | None], dict[str, Any]] = {}
14
+ self._latest_progress_versions_by_uri: dict[str, int | None] = {}
15
+ self._file_perf_data: dict[tuple[str, int | None], dict[str, Any]] = {}
16
+ self._perf_events: dict[tuple[str, int | None], asyncio.Event] = {}
17
+ self._latest_perf_versions_by_uri: dict[str, int | None] = {}
18
+ self._execution_info: dict[
19
+ tuple[str, int | None], tuple[int, dict[str, Any]]
20
+ ] = {}
21
+ self._execution_ranges: dict[tuple[str, int | None], list[dict[str, Any]]] = {}
22
+ self._execution_events: dict[tuple[str, int | None], asyncio.Event] = {}
23
+ self._execution_sequence = 0
24
+
25
+ def handle(self, method: str, params: Any | None) -> None:
26
+ if method == "textDocument/publishDiagnostics" and isinstance(params, dict):
27
+ self._handle_publish_diagnostics(params)
28
+ if method == "$/coq/fileProgress" and isinstance(params, dict):
29
+ self._handle_file_progress(params)
30
+ if method == "$/coq/filePerfData" and isinstance(params, dict):
31
+ self._handle_file_perf_data(params)
32
+ if method == "$/coq/executionInformation" and isinstance(params, dict):
33
+ self._handle_execution_info(params)
34
+
35
+ def clear(self, uri: str, version: int) -> None:
36
+ # A session serializes calls for one URI, so starting a new version makes
37
+ # all notification payloads and waiter events for older versions stale.
38
+ for mapping in (
39
+ self._diagnostics,
40
+ self._diagnostic_events,
41
+ self._file_progress,
42
+ self._file_perf_data,
43
+ self._perf_events,
44
+ self._execution_info,
45
+ self._execution_ranges,
46
+ self._execution_events,
47
+ ):
48
+ for key in [key for key in mapping if key[0] == uri]:
49
+ mapping.pop(key, None)
50
+ self._latest_versions_by_uri.pop(uri, None)
51
+ self._latest_progress_versions_by_uri.pop(uri, None)
52
+ self._latest_perf_versions_by_uri.pop(uri, None)
53
+
54
+ def _clear_stale_perf_data(self, uri: str, version: int | None) -> None:
55
+ for key in list(self._file_perf_data):
56
+ if key[0] == uri and key[1] != version:
57
+ self._file_perf_data.pop(key, None)
58
+ self._perf_events.pop(key, None)
59
+
60
+ def _clear_stale_execution_data(self, uri: str, version: int | None) -> None:
61
+ for key in list(self._execution_info):
62
+ if key[0] == uri and key[1] != version:
63
+ self._execution_info.pop(key, None)
64
+ self._execution_ranges.pop(key, None)
65
+ self._execution_events.pop(key, None)
66
+
67
+ def latest_file_progress(self, uri: str, version: int) -> dict[str, Any] | None:
68
+ key = (uri, version)
69
+ if key in self._file_progress:
70
+ return self._file_progress[key]
71
+
72
+ latest = self._latest_progress_versions_by_uri.get(uri)
73
+ if latest == version:
74
+ return self._file_progress.get((uri, latest))
75
+ return self._file_progress.get((uri, None))
76
+
77
+ def latest_file_perf_data(self, uri: str, version: int) -> dict[str, Any] | None:
78
+ key = (uri, version)
79
+ if key in self._file_perf_data:
80
+ return self._file_perf_data[key]
81
+
82
+ latest = self._latest_perf_versions_by_uri.get(uri)
83
+ if latest == version:
84
+ return self._file_perf_data.get((uri, latest))
85
+ return self._file_perf_data.get((uri, None))
86
+
87
+ async def wait_for_file_perf_data(
88
+ self, uri: str, version: int, settle_seconds: float = 0.25
89
+ ) -> dict[str, Any] | None:
90
+ key = (uri, version)
91
+ if self.latest_file_perf_data(uri, version) is None:
92
+ event = self._perf_events.setdefault(key, asyncio.Event())
93
+ with suppress(TimeoutError):
94
+ await asyncio.wait_for(event.wait(), timeout=settle_seconds)
95
+ return self.latest_file_perf_data(uri, version)
96
+
97
+ def latest_execution_sequence(self, uri: str, version: int) -> int:
98
+ info = self._execution_info.get((uri, version))
99
+ return info[0] if info is not None else 0
100
+
101
+ def latest_execution_info(self, uri: str, version: int) -> dict[str, Any] | None:
102
+ info = self._execution_info.get((uri, version))
103
+ return info[1] if info is not None else None
104
+
105
+ def execution_ranges(self, uri: str, version: int) -> list[dict[str, Any]]:
106
+ ranges = self._execution_ranges.get((uri, version))
107
+ if ranges is not None:
108
+ return list(ranges)
109
+ return list(self._execution_ranges.get((uri, None), []))
110
+
111
+ async def wait_for_execution_info(
112
+ self,
113
+ uri: str,
114
+ version: int,
115
+ *,
116
+ after_sequence: int,
117
+ timeout: float | None,
118
+ ) -> tuple[int, dict[str, Any]] | None:
119
+ key = (uri, version)
120
+ event = self._execution_events.setdefault(key, asyncio.Event())
121
+ while True:
122
+ event.clear()
123
+ info = self._execution_info.get(key)
124
+ if info is not None and info[0] > after_sequence:
125
+ return info
126
+ with suppress(TimeoutError):
127
+ await asyncio.wait_for(event.wait(), timeout=timeout)
128
+ continue
129
+ return None
130
+
131
+ def has_error_diagnostic(self, uri: str, version: int) -> bool:
132
+ # Exact-version lookup only: stale errors from a previous document
133
+ # version must not arm the after-error deadline.
134
+ diagnostics = self._diagnostics.get((uri, version), [])
135
+ return any(
136
+ isinstance(diagnostic, dict) and diagnostic.get("severity") == 1
137
+ for diagnostic in diagnostics
138
+ )
139
+
140
+ async def wait_for_error_diagnostic(self, uri: str, version: int) -> None:
141
+ key = (uri, version)
142
+ event = self._diagnostic_events.setdefault(key, asyncio.Event())
143
+ while True:
144
+ event.clear()
145
+ if self.has_error_diagnostic(uri, version):
146
+ return
147
+ await event.wait()
148
+
149
+ async def wait_for_diagnostics(
150
+ self, uri: str, version: int, settle_seconds: float = 0.25
151
+ ) -> list[dict[str, Any]]:
152
+ key = (uri, version)
153
+ if key not in self._diagnostics:
154
+ event = self._diagnostic_events.setdefault(key, asyncio.Event())
155
+ with suppress(TimeoutError):
156
+ await asyncio.wait_for(event.wait(), timeout=settle_seconds)
157
+
158
+ if key in self._diagnostics:
159
+ return self._diagnostics[key]
160
+
161
+ latest = self._latest_versions_by_uri.get(uri)
162
+ if latest == version:
163
+ return self._diagnostics.get((uri, latest), [])
164
+ return self._diagnostics.get((uri, None), [])
165
+
166
+ def _handle_publish_diagnostics(self, params: dict[str, Any]) -> None:
167
+ uri = params.get("uri")
168
+ version = params.get("version")
169
+ diagnostics = params.get("diagnostics", [])
170
+ if isinstance(uri, str) and isinstance(diagnostics, list):
171
+ key = (uri, version)
172
+ self._diagnostics[key] = diagnostics
173
+ self._latest_versions_by_uri[uri] = version
174
+ if event := self._diagnostic_events.get(key):
175
+ event.set()
176
+
177
+ def _handle_file_progress(self, params: dict[str, Any]) -> None:
178
+ text_document = params.get("textDocument")
179
+ if not isinstance(text_document, dict):
180
+ return
181
+
182
+ uri = text_document.get("uri")
183
+ version = text_document.get("version")
184
+ if not isinstance(uri, str):
185
+ return
186
+
187
+ self._file_progress[(uri, version)] = params
188
+ self._latest_progress_versions_by_uri[uri] = version
189
+
190
+ def _handle_file_perf_data(self, params: dict[str, Any]) -> None:
191
+ text_document = params.get("textDocument")
192
+ if not isinstance(text_document, dict):
193
+ return
194
+
195
+ uri = text_document.get("uri")
196
+ version = text_document.get("version")
197
+ timings = params.get("timings")
198
+ if not isinstance(uri, str) or not isinstance(timings, list):
199
+ return
200
+
201
+ key = (uri, version)
202
+ self._clear_stale_perf_data(uri, version)
203
+ self._file_perf_data[key] = params
204
+ self._latest_perf_versions_by_uri[uri] = version
205
+ if event := self._perf_events.get(key):
206
+ event.set()
207
+
208
+ def _handle_execution_info(self, params: dict[str, Any]) -> None:
209
+ text_document = params.get("textDocument")
210
+ if not isinstance(text_document, dict):
211
+ return
212
+
213
+ uri = text_document.get("uri")
214
+ version = text_document.get("version")
215
+ range_ = params.get("range")
216
+ if not isinstance(uri, str) or not isinstance(range_, dict):
217
+ return
218
+
219
+ self._execution_sequence += 1
220
+ key = (uri, version)
221
+ self._clear_stale_execution_data(uri, version)
222
+ self._execution_info[key] = (self._execution_sequence, params)
223
+ self._execution_ranges.setdefault(key, []).append(range_)
224
+ if event := self._execution_events.get(key):
225
+ event.set()
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .models import Diagnostic, is_empty_value, source_text_for_range
7
+ from .pretty import normalize_proof_state
8
+
9
+
10
+ def build_diagnostics(
11
+ raw: list[dict[str, Any]],
12
+ document: dict[str, Any] | None,
13
+ *,
14
+ content: str | None = None,
15
+ supplemental_proof_states: dict[str, Any] | None = None,
16
+ ) -> list[Diagnostic]:
17
+ diagnostic_range_keys = {
18
+ _range_key(_diagnostic_sentence_range(diagnostic)) for diagnostic in raw
19
+ }
20
+ proof_states = _proof_states_by_range(document, diagnostic_range_keys)
21
+ _merge_supplemental_proof_states(proof_states, supplemental_proof_states)
22
+ diagnostics: list[Diagnostic] = []
23
+ for diagnostic in raw:
24
+ sentence_range = _diagnostic_sentence_range(diagnostic)
25
+ diagnostics.append(
26
+ Diagnostic.from_lsp(
27
+ diagnostic,
28
+ proof_state=proof_states.get(_range_key(sentence_range)),
29
+ sentence=(
30
+ source_text_for_range(content, sentence_range)
31
+ if content is not None
32
+ else None
33
+ ),
34
+ )
35
+ )
36
+ return diagnostics
37
+
38
+
39
+ def _proof_states_by_range(
40
+ document: dict[str, Any] | None,
41
+ wanted_range_keys: set[str],
42
+ ) -> dict[str, Any]:
43
+ if not document:
44
+ return {}
45
+ states: dict[str, Any] = {}
46
+ for span in document.get("spans", []):
47
+ if not isinstance(span, dict):
48
+ continue
49
+ goals = span.get("goals")
50
+ range_ = span.get("range")
51
+ if goals is not None and isinstance(range_, dict):
52
+ key = _range_key(range_)
53
+ if key not in wanted_range_keys:
54
+ continue
55
+ normalized = normalize_proof_state(goals)
56
+ if not is_empty_value(normalized):
57
+ states[key] = normalized
58
+ return states
59
+
60
+
61
+ def _merge_supplemental_proof_states(
62
+ states: dict[str, Any],
63
+ supplemental: dict[str, Any] | None,
64
+ ) -> None:
65
+ if not supplemental:
66
+ return
67
+ for key, proof_state in supplemental.items():
68
+ normalized = normalize_proof_state(proof_state)
69
+ if not isinstance(normalized, dict) or is_empty_value(normalized):
70
+ continue
71
+ current = states.get(key)
72
+ if isinstance(current, dict):
73
+ for field, value in normalized.items():
74
+ if field not in current and not is_empty_value(value):
75
+ current[field] = value
76
+ continue
77
+ states[key] = normalized
78
+
79
+
80
+ def _diagnostic_sentence_range(diagnostic: Any) -> dict[str, Any]:
81
+ if not isinstance(diagnostic, dict):
82
+ return {}
83
+ data = diagnostic.get("data")
84
+ if isinstance(data, dict) and isinstance(data.get("sentenceRange"), dict):
85
+ return data["sentenceRange"]
86
+ range_ = diagnostic.get("range")
87
+ return range_ if isinstance(range_, dict) else {}
88
+
89
+
90
+ def _range_key(range_: dict[str, Any]) -> str:
91
+ return json.dumps(range_, sort_keys=True, separators=(",", ":"))
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .models import is_empty_value, pruned_dict
6
+
7
+
8
+ def pp_to_text(value: Any) -> str:
9
+ """Render rocq-lsp's serialized Pp tree into readable plain text."""
10
+ if value is None:
11
+ return ""
12
+ if isinstance(value, str):
13
+ return value
14
+ if not isinstance(value, list) or not value:
15
+ return str(value)
16
+
17
+ tag = value[0]
18
+ if tag == "Pp_empty":
19
+ return ""
20
+ if tag == "Pp_string":
21
+ return str(value[1]) if len(value) > 1 else ""
22
+ if tag == "Pp_sized_string":
23
+ return str(value[2]) if len(value) > 2 else ""
24
+ if tag == "Pp_glue":
25
+ return "".join(pp_to_text(item) for item in _list_at(value, 1))
26
+ if tag in {"Pp_box", "Pp_tag"}:
27
+ return pp_to_text(value[2]) if len(value) > 2 else ""
28
+ if tag == "Pp_print_break":
29
+ indent = value[2] if len(value) > 2 and isinstance(value[2], int) else 0
30
+ return "\n" + (" " * max(indent, 0))
31
+ if tag == "Pp_force_newline":
32
+ return "\n"
33
+ if tag == "Pp_comment":
34
+ return "\n".join(str(item) for item in _list_at(value, 1))
35
+ return str(value)
36
+
37
+
38
+ def normalize_proof_state(value: Any) -> Any:
39
+ """Turn rocq-lsp goal answers into agent-facing JSON."""
40
+ if _is_pp(value):
41
+ return pp_to_text(value)
42
+ if isinstance(value, list):
43
+ return [
44
+ normalized
45
+ for item in value
46
+ if not is_empty_value(normalized := normalize_proof_state(item))
47
+ ]
48
+ if not isinstance(value, dict):
49
+ return value
50
+
51
+ if _looks_like_goal(value):
52
+ return _normalize_goal(value)
53
+ if _looks_like_goal_config(value):
54
+ return _normalize_goal_config(value)
55
+ if _looks_like_message(value):
56
+ return _normalize_message(value)
57
+
58
+ result: dict[str, Any] = {}
59
+ for key, item in value.items():
60
+ if key in {"textDocument", "position", "range", "error", "program"}:
61
+ continue
62
+ normalized = normalize_proof_state(item)
63
+ if key == "goals" and isinstance(normalized, dict):
64
+ result.update(normalized)
65
+ continue
66
+ if not is_empty_value(normalized):
67
+ result[key] = normalized
68
+ return result
69
+
70
+
71
+ def _normalize_goal(value: dict[str, Any]) -> dict[str, Any]:
72
+ info = value.get("info") if isinstance(value.get("info"), dict) else {}
73
+ return pruned_dict(
74
+ {
75
+ "name": normalize_proof_state(info.get("name")),
76
+ "hypotheses": _normalize_hypotheses(value.get("hyps", [])),
77
+ "conclusion": normalize_proof_state(value.get("ty")),
78
+ }
79
+ )
80
+
81
+
82
+ def _normalize_goal_config(value: dict[str, Any]) -> dict[str, Any]:
83
+ goals = normalize_proof_state(value.get("goals", []))
84
+ given_up = normalize_proof_state(value.get("given_up", []))
85
+ result = {
86
+ "goals": goals,
87
+ "bullet": normalize_proof_state(value.get("bullet")),
88
+ "shelved_goals": normalize_proof_state(value.get("shelf", [])),
89
+ }
90
+ if given_up != goals:
91
+ result["given_up_goals"] = given_up
92
+ return pruned_dict(result)
93
+
94
+
95
+ def _normalize_message(value: dict[str, Any]) -> dict[str, Any]:
96
+ result = dict(value)
97
+ if "text" in result:
98
+ result["text"] = normalize_proof_state(result["text"])
99
+ return pruned_dict(result)
100
+
101
+
102
+ def _normalize_hypotheses(value: Any) -> list[str]:
103
+ if not isinstance(value, list):
104
+ return []
105
+
106
+ hypotheses: list[str] = []
107
+ for item in value:
108
+ hypothesis = _normalize_hypothesis(item)
109
+ if hypothesis:
110
+ hypotheses.append(hypothesis)
111
+ return hypotheses
112
+
113
+
114
+ def _normalize_hypothesis(value: Any) -> str | None:
115
+ if not isinstance(value, dict):
116
+ normalized = normalize_proof_state(value)
117
+ return str(normalized) if normalized is not None else None
118
+
119
+ names = _hypothesis_names(value.get("names", []))
120
+ ty = normalize_proof_state(value.get("ty"))
121
+ definition = normalize_proof_state(value.get("def"))
122
+ if not names:
123
+ return str(ty) if ty is not None else None
124
+
125
+ prefix = " ".join(names)
126
+ if definition is not None and ty is not None:
127
+ return f"{prefix} := {definition} : {ty}"
128
+ if ty is not None:
129
+ return f"{prefix} : {ty}"
130
+ if definition is not None:
131
+ return f"{prefix} := {definition}"
132
+ return prefix
133
+
134
+
135
+ def _hypothesis_names(value: Any) -> list[str]:
136
+ normalized = normalize_proof_state(value)
137
+ if isinstance(normalized, list):
138
+ return [str(item) for item in normalized if item is not None]
139
+ if normalized is None:
140
+ return []
141
+ return [str(normalized)]
142
+
143
+
144
+ def _is_pp(value: Any) -> bool:
145
+ return (
146
+ isinstance(value, list)
147
+ and len(value) > 0
148
+ and isinstance(value[0], str)
149
+ and value[0].startswith("Pp_")
150
+ )
151
+
152
+
153
+ def _looks_like_goal(value: dict[str, Any]) -> bool:
154
+ return "hyps" in value and "ty" in value
155
+
156
+
157
+ def _looks_like_goal_config(value: dict[str, Any]) -> bool:
158
+ return "goals" in value and "stack" in value and "shelf" in value
159
+
160
+
161
+ def _looks_like_message(value: dict[str, Any]) -> bool:
162
+ return "text" in value and "level" in value
163
+
164
+
165
+ def _list_at(value: list[Any], index: int) -> list[Any]:
166
+ item = value[index] if len(value) > index else []
167
+ return item if isinstance(item, list) else []