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
rocq_solid_mcp/models.py
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from .config import DEFAULT_MESSAGE_LIMIT, bounded_message_limit
|
|
10
|
+
|
|
11
|
+
DEFAULT_LONG_RUNNING_SECONDS = 3.0
|
|
12
|
+
MAX_LONG_RUNNING_COMMANDS = 10
|
|
13
|
+
LocationFormat = Literal["range", "point"]
|
|
14
|
+
DiagnosticSeverity = Literal["info", "warning", "error"]
|
|
15
|
+
DEFAULT_DIAGNOSTIC_SEVERITY: DiagnosticSeverity = "error"
|
|
16
|
+
DIAGNOSTIC_LEVEL_PRIORITY = {
|
|
17
|
+
"info": 1,
|
|
18
|
+
"warning": 2,
|
|
19
|
+
"error": 3,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RenderOptions:
|
|
25
|
+
include_proof_state: bool = True
|
|
26
|
+
include_timeout_context: bool = True
|
|
27
|
+
message_limit: int | None = DEFAULT_MESSAGE_LIMIT
|
|
28
|
+
severity: DiagnosticSeverity = DEFAULT_DIAGNOSTIC_SEVERITY
|
|
29
|
+
location_format: LocationFormat = "range"
|
|
30
|
+
deduplicate_proof_errors: bool = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def lsp_to_jsonable(value: Any) -> Any:
|
|
34
|
+
"""Convert common structured values into JSON-compatible data."""
|
|
35
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
36
|
+
return value
|
|
37
|
+
if isinstance(value, dict):
|
|
38
|
+
return {str(k): lsp_to_jsonable(v) for k, v in value.items()}
|
|
39
|
+
if isinstance(value, (list, tuple)):
|
|
40
|
+
return [lsp_to_jsonable(v) for v in value]
|
|
41
|
+
if hasattr(value, "value"):
|
|
42
|
+
return lsp_to_jsonable(value.value)
|
|
43
|
+
if hasattr(value, "__dict__"):
|
|
44
|
+
return {
|
|
45
|
+
key: lsp_to_jsonable(item)
|
|
46
|
+
for key, item in vars(value).items()
|
|
47
|
+
if not key.startswith("_")
|
|
48
|
+
}
|
|
49
|
+
return str(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Diagnostic(BaseModel):
|
|
53
|
+
range: dict[str, Any] = Field(default_factory=dict)
|
|
54
|
+
message: str
|
|
55
|
+
severity: int | None = None
|
|
56
|
+
code: str | int | None = None
|
|
57
|
+
source: str | None = None
|
|
58
|
+
sentence: str | None = None
|
|
59
|
+
data: Any | None = None
|
|
60
|
+
proof_name: str | None = None
|
|
61
|
+
proof_state: Any | None = None
|
|
62
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_lsp(
|
|
66
|
+
cls,
|
|
67
|
+
diagnostic: Any,
|
|
68
|
+
proof_state: Any | None = None,
|
|
69
|
+
sentence: str | None = None,
|
|
70
|
+
) -> "Diagnostic":
|
|
71
|
+
raw = lsp_to_jsonable(diagnostic)
|
|
72
|
+
data = raw.get("data")
|
|
73
|
+
proof_name = data.get("proofName") if isinstance(data, dict) else None
|
|
74
|
+
raw_range = raw.get("range", {})
|
|
75
|
+
range_ = raw_range if isinstance(raw_range, dict) else {}
|
|
76
|
+
return cls(
|
|
77
|
+
range=range_,
|
|
78
|
+
message=raw.get("message", ""),
|
|
79
|
+
severity=raw.get("severity"),
|
|
80
|
+
code=raw.get("code"),
|
|
81
|
+
source=raw.get("source"),
|
|
82
|
+
sentence=sentence if sentence else None,
|
|
83
|
+
data=data,
|
|
84
|
+
proof_name=proof_name if isinstance(proof_name, str) else None,
|
|
85
|
+
proof_state=proof_state if not is_empty_value(proof_state) else None,
|
|
86
|
+
raw=raw,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def render(self, options: RenderOptions | None = None) -> dict[str, Any]:
|
|
90
|
+
options = options or RenderOptions()
|
|
91
|
+
return pruned_dict(
|
|
92
|
+
{
|
|
93
|
+
"level": diagnostic_level(self.severity),
|
|
94
|
+
"message": self.message,
|
|
95
|
+
"location": location_string(self.range, options.location_format),
|
|
96
|
+
"code": self.code,
|
|
97
|
+
"source": self.source,
|
|
98
|
+
"sentence": self.sentence,
|
|
99
|
+
"proof_name": self.proof_name,
|
|
100
|
+
"proof_state": (
|
|
101
|
+
self.proof_state if options.include_proof_state else None
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RunAtOutput(BaseModel):
|
|
108
|
+
severity: int | None = None
|
|
109
|
+
text: str
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_feedback(cls, feedback: Any) -> "RunAtOutput | None":
|
|
113
|
+
raw = lsp_to_jsonable(feedback)
|
|
114
|
+
if isinstance(raw, (list, tuple)) and len(raw) >= 2:
|
|
115
|
+
severity = raw[0] if isinstance(raw[0], int) else None
|
|
116
|
+
text = raw[1]
|
|
117
|
+
return cls(severity=severity, text=str(text))
|
|
118
|
+
if isinstance(raw, dict):
|
|
119
|
+
severity = raw.get("level")
|
|
120
|
+
text = raw.get("text")
|
|
121
|
+
if text is None:
|
|
122
|
+
text = raw.get("message")
|
|
123
|
+
if text is None:
|
|
124
|
+
return None
|
|
125
|
+
return cls(
|
|
126
|
+
severity=severity if isinstance(severity, int) else None,
|
|
127
|
+
text=str(text),
|
|
128
|
+
)
|
|
129
|
+
if raw is None:
|
|
130
|
+
return None
|
|
131
|
+
return cls(text=str(raw))
|
|
132
|
+
|
|
133
|
+
def render(self, options: RenderOptions | None = None) -> dict[str, Any]:
|
|
134
|
+
return pruned_dict(
|
|
135
|
+
{
|
|
136
|
+
"level": diagnostic_level(self.severity),
|
|
137
|
+
"text": self.text,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class LongRunningCommand(BaseModel):
|
|
143
|
+
range: dict[str, Any] = Field(default_factory=dict)
|
|
144
|
+
text: str
|
|
145
|
+
time_seconds: float
|
|
146
|
+
|
|
147
|
+
def render(self, options: RenderOptions | None = None) -> dict[str, Any]:
|
|
148
|
+
options = options or RenderOptions()
|
|
149
|
+
return pruned_dict(
|
|
150
|
+
{
|
|
151
|
+
"location": location_string(self.range, options.location_format),
|
|
152
|
+
"text": self.text,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class RunAtResult(BaseModel):
|
|
158
|
+
status: Literal["complete", "timeout", "failed"]
|
|
159
|
+
file: str
|
|
160
|
+
commands: str
|
|
161
|
+
location: str | None = None
|
|
162
|
+
output: list[RunAtOutput] = Field(default_factory=list)
|
|
163
|
+
uri: str | None = None
|
|
164
|
+
workspace: str | None = None
|
|
165
|
+
version: int | None = None
|
|
166
|
+
timed_out: bool = False
|
|
167
|
+
restarted: bool = False
|
|
168
|
+
message: str | None = None
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def from_feedback(
|
|
172
|
+
cls,
|
|
173
|
+
*,
|
|
174
|
+
status: Literal["complete", "timeout", "failed"],
|
|
175
|
+
file: str,
|
|
176
|
+
commands: str,
|
|
177
|
+
location: str | None,
|
|
178
|
+
uri: str | None,
|
|
179
|
+
workspace: str | None,
|
|
180
|
+
version: int | None,
|
|
181
|
+
feedback: Any,
|
|
182
|
+
timed_out: bool = False,
|
|
183
|
+
restarted: bool = False,
|
|
184
|
+
message: str | None = None,
|
|
185
|
+
) -> "RunAtResult":
|
|
186
|
+
output = [
|
|
187
|
+
item
|
|
188
|
+
for entry in _feedback_entries(feedback)
|
|
189
|
+
if (item := RunAtOutput.from_feedback(entry)) is not None
|
|
190
|
+
]
|
|
191
|
+
return cls(
|
|
192
|
+
status=status,
|
|
193
|
+
file=file,
|
|
194
|
+
commands=commands,
|
|
195
|
+
location=location,
|
|
196
|
+
output=output,
|
|
197
|
+
uri=uri,
|
|
198
|
+
workspace=workspace,
|
|
199
|
+
version=version,
|
|
200
|
+
timed_out=timed_out,
|
|
201
|
+
restarted=restarted,
|
|
202
|
+
message=message,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def render(self, options: RenderOptions | None = None) -> dict[str, Any]:
|
|
206
|
+
options = options or RenderOptions()
|
|
207
|
+
return pruned_dict(
|
|
208
|
+
{
|
|
209
|
+
"status": self.status,
|
|
210
|
+
"file": display_file(self.file, self.workspace),
|
|
211
|
+
"location": self.location,
|
|
212
|
+
"commands": self.commands,
|
|
213
|
+
"output": [item.render(options) for item in self.output],
|
|
214
|
+
"timed_out": True if self.timed_out else None,
|
|
215
|
+
"restarted": True if self.restarted else None,
|
|
216
|
+
"message": self.message,
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TimeoutContext(BaseModel):
|
|
222
|
+
operation: str = "coq/getDocument"
|
|
223
|
+
timeout_kind: str = "request"
|
|
224
|
+
request_timeout_seconds: int | float | None = None
|
|
225
|
+
cancel_grace_seconds: int | float
|
|
226
|
+
checked_until: dict[str, int] | None = None
|
|
227
|
+
latest_processing_range: dict[str, Any] | None = None
|
|
228
|
+
next_unchecked: dict[str, Any] | None = None
|
|
229
|
+
command_timeout_seconds: int | float | None = None
|
|
230
|
+
timeout_after_error_seconds: int | float | None = None
|
|
231
|
+
timed_out_range: dict[str, Any] | None = None
|
|
232
|
+
timed_out_command: dict[str, Any] | None = None
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_manager_state(
|
|
236
|
+
cls,
|
|
237
|
+
*,
|
|
238
|
+
content: str,
|
|
239
|
+
document: dict[str, Any] | None,
|
|
240
|
+
progress: dict[str, Any] | None,
|
|
241
|
+
request_timeout_seconds: int | float,
|
|
242
|
+
cancel_grace_seconds: int | float,
|
|
243
|
+
timeout_kind: str = "request",
|
|
244
|
+
command_timeout_seconds: int | float | None = None,
|
|
245
|
+
timeout_after_error_seconds: int | float | None = None,
|
|
246
|
+
timed_out_range: dict[str, Any] | None = None,
|
|
247
|
+
) -> "TimeoutContext":
|
|
248
|
+
completed = _completed_only(document) if document else None
|
|
249
|
+
completed_range = completed.get("range") if completed else None
|
|
250
|
+
completed_end = (
|
|
251
|
+
range_end(completed_range) if isinstance(completed_range, dict) else None
|
|
252
|
+
)
|
|
253
|
+
processing_range = _latest_processing_range(progress)
|
|
254
|
+
progress_start = (
|
|
255
|
+
range_start(processing_range)
|
|
256
|
+
if isinstance(processing_range, dict)
|
|
257
|
+
else None
|
|
258
|
+
)
|
|
259
|
+
checked_until = completed_end or progress_start
|
|
260
|
+
|
|
261
|
+
return cls(
|
|
262
|
+
timeout_kind=timeout_kind,
|
|
263
|
+
request_timeout_seconds=request_timeout_seconds,
|
|
264
|
+
cancel_grace_seconds=cancel_grace_seconds,
|
|
265
|
+
checked_until=checked_until,
|
|
266
|
+
latest_processing_range=processing_range,
|
|
267
|
+
next_unchecked=_next_unchecked_preview(content, checked_until),
|
|
268
|
+
command_timeout_seconds=command_timeout_seconds,
|
|
269
|
+
timeout_after_error_seconds=timeout_after_error_seconds,
|
|
270
|
+
timed_out_range=timed_out_range,
|
|
271
|
+
timed_out_command=(
|
|
272
|
+
_source_preview_for_range(content, timed_out_range)
|
|
273
|
+
if timed_out_range is not None
|
|
274
|
+
else None
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def render(self, options: RenderOptions | None = None) -> dict[str, Any]:
|
|
279
|
+
options = options or RenderOptions()
|
|
280
|
+
return pruned_dict(
|
|
281
|
+
{
|
|
282
|
+
"operation": self.operation,
|
|
283
|
+
"timeout_kind": self.timeout_kind,
|
|
284
|
+
"request_timeout_seconds": self.request_timeout_seconds,
|
|
285
|
+
"cancel_grace_seconds": self.cancel_grace_seconds,
|
|
286
|
+
"checked_until": position_string(self.checked_until),
|
|
287
|
+
"latest_processing_range": location_string(
|
|
288
|
+
self.latest_processing_range,
|
|
289
|
+
options.location_format,
|
|
290
|
+
),
|
|
291
|
+
"next_unchecked": self.next_unchecked,
|
|
292
|
+
"command_timeout_seconds": self.command_timeout_seconds,
|
|
293
|
+
"timeout_after_error_seconds": self.timeout_after_error_seconds,
|
|
294
|
+
"timed_out_range": location_string(
|
|
295
|
+
self.timed_out_range,
|
|
296
|
+
options.location_format,
|
|
297
|
+
),
|
|
298
|
+
"timed_out_command": self.timed_out_command,
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class CheckResult(BaseModel):
|
|
304
|
+
status: Literal["complete", "timeout", "failed"]
|
|
305
|
+
file: str
|
|
306
|
+
uri: str | None = None
|
|
307
|
+
workspace: str | None = None
|
|
308
|
+
version: int | None = None
|
|
309
|
+
completed: dict[str, Any] | None = None
|
|
310
|
+
diagnostics: list[Diagnostic] = Field(default_factory=list)
|
|
311
|
+
timed_out: bool = False
|
|
312
|
+
restarted: bool = False
|
|
313
|
+
timeout_context: TimeoutContext | None = None
|
|
314
|
+
long_running_commands: list[LongRunningCommand] = Field(default_factory=list)
|
|
315
|
+
message: str | None = None
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def errors(self) -> list[Diagnostic]:
|
|
319
|
+
return diagnostic_buckets(self.diagnostics)["errors"]
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def error_count(self) -> int:
|
|
323
|
+
return len(self.errors)
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def warning_count(self) -> int:
|
|
327
|
+
return len(diagnostic_buckets(self.diagnostics)["warnings"])
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def info_count(self) -> int:
|
|
331
|
+
return len(diagnostic_buckets(self.diagnostics)["infos"])
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
def from_diagnostics(
|
|
335
|
+
cls,
|
|
336
|
+
*,
|
|
337
|
+
status: Literal["complete", "timeout", "failed"],
|
|
338
|
+
file: str,
|
|
339
|
+
uri: str | None,
|
|
340
|
+
workspace: str | None,
|
|
341
|
+
version: int | None,
|
|
342
|
+
completed: dict[str, Any] | None,
|
|
343
|
+
diagnostics: list[Diagnostic],
|
|
344
|
+
timed_out: bool = False,
|
|
345
|
+
restarted: bool = False,
|
|
346
|
+
timeout_context: TimeoutContext | None = None,
|
|
347
|
+
long_running_commands: list[LongRunningCommand] | None = None,
|
|
348
|
+
message: str | None = None,
|
|
349
|
+
) -> "CheckResult":
|
|
350
|
+
return cls(
|
|
351
|
+
status=status,
|
|
352
|
+
file=file,
|
|
353
|
+
uri=uri,
|
|
354
|
+
workspace=workspace,
|
|
355
|
+
version=version,
|
|
356
|
+
completed=completed,
|
|
357
|
+
diagnostics=diagnostics,
|
|
358
|
+
timed_out=timed_out,
|
|
359
|
+
restarted=restarted,
|
|
360
|
+
timeout_context=timeout_context,
|
|
361
|
+
long_running_commands=long_running_commands or [],
|
|
362
|
+
message=message,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def render(self, options: RenderOptions | None = None) -> dict[str, Any]:
|
|
366
|
+
options = options or RenderOptions()
|
|
367
|
+
message_limit = (
|
|
368
|
+
None
|
|
369
|
+
if options.message_limit is None
|
|
370
|
+
else bounded_message_limit(options.message_limit)
|
|
371
|
+
)
|
|
372
|
+
diagnostics = _render_diagnostics(self.diagnostics, options)
|
|
373
|
+
buckets = diagnostic_buckets(diagnostics)
|
|
374
|
+
limited_diagnostics = (
|
|
375
|
+
diagnostics
|
|
376
|
+
if message_limit is None
|
|
377
|
+
else limit_diagnostics(diagnostics, message_limit)
|
|
378
|
+
)
|
|
379
|
+
limited_buckets = diagnostic_buckets(limited_diagnostics)
|
|
380
|
+
omitted_errors = len(buckets["errors"]) - len(limited_buckets["errors"])
|
|
381
|
+
omitted_warnings = len(buckets["warnings"]) - len(limited_buckets["warnings"])
|
|
382
|
+
omitted_infos = len(buckets["infos"]) - len(limited_buckets["infos"])
|
|
383
|
+
omitted_counts = diagnostic_counts(
|
|
384
|
+
errors=omitted_errors,
|
|
385
|
+
warnings=omitted_warnings,
|
|
386
|
+
infos=omitted_infos,
|
|
387
|
+
)
|
|
388
|
+
has_omissions = bool(omitted_counts)
|
|
389
|
+
timeout_context = (
|
|
390
|
+
self.timeout_context.render(options)
|
|
391
|
+
if self.status == "timeout"
|
|
392
|
+
and self.timeout_context is not None
|
|
393
|
+
and options.include_timeout_context
|
|
394
|
+
else None
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return pruned_dict(
|
|
398
|
+
{
|
|
399
|
+
"status": self.status,
|
|
400
|
+
"file": display_file(self.file, self.workspace),
|
|
401
|
+
"counts": diagnostic_counts(
|
|
402
|
+
errors=len(buckets["errors"]),
|
|
403
|
+
warnings=len(buckets["warnings"]),
|
|
404
|
+
infos=len(buckets["infos"]),
|
|
405
|
+
),
|
|
406
|
+
"omitted_counts": omitted_counts,
|
|
407
|
+
"message_limit": message_limit if has_omissions else None,
|
|
408
|
+
"diagnostics": [
|
|
409
|
+
diagnostic.render(options) for diagnostic in limited_diagnostics
|
|
410
|
+
],
|
|
411
|
+
"timed_out": True if self.timed_out else None,
|
|
412
|
+
"restarted": True if self.restarted else None,
|
|
413
|
+
"timeout_context": timeout_context,
|
|
414
|
+
"long_running_commands": [
|
|
415
|
+
command.render(options) for command in self.long_running_commands
|
|
416
|
+
],
|
|
417
|
+
"message": self.message,
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def diagnostic_buckets(diagnostics: list[Diagnostic]) -> dict[str, list[Diagnostic]]:
|
|
423
|
+
buckets: dict[str, list[Diagnostic]] = {
|
|
424
|
+
"errors": [],
|
|
425
|
+
"warnings": [],
|
|
426
|
+
"infos": [],
|
|
427
|
+
}
|
|
428
|
+
for diagnostic in diagnostics:
|
|
429
|
+
buckets[diagnostic_bucket(diagnostic)].append(diagnostic)
|
|
430
|
+
return buckets
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def limit_diagnostics(
|
|
434
|
+
diagnostics: list[Diagnostic], message_limit: int
|
|
435
|
+
) -> list[Diagnostic]:
|
|
436
|
+
counts = {"errors": 0, "warnings": 0, "infos": 0}
|
|
437
|
+
result: list[Diagnostic] = []
|
|
438
|
+
for diagnostic in diagnostics:
|
|
439
|
+
bucket = diagnostic_bucket(diagnostic)
|
|
440
|
+
if counts[bucket] >= message_limit:
|
|
441
|
+
continue
|
|
442
|
+
counts[bucket] += 1
|
|
443
|
+
result.append(diagnostic)
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def diagnostic_bucket(diagnostic: Diagnostic) -> str:
|
|
448
|
+
if diagnostic.severity == 1:
|
|
449
|
+
return "errors"
|
|
450
|
+
if diagnostic.severity == 2:
|
|
451
|
+
return "warnings"
|
|
452
|
+
return "infos"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def location_string(
|
|
456
|
+
range_: Any,
|
|
457
|
+
location_format: LocationFormat = "range",
|
|
458
|
+
) -> str | None:
|
|
459
|
+
"""Render an LSP range as a compact human-facing 1-based location."""
|
|
460
|
+
if not isinstance(range_, dict):
|
|
461
|
+
return None
|
|
462
|
+
start = range_start(range_)
|
|
463
|
+
end = range_end(range_)
|
|
464
|
+
if start is None or end is None:
|
|
465
|
+
return None
|
|
466
|
+
if location_format == "point":
|
|
467
|
+
return position_string(start)
|
|
468
|
+
return f"{position_string(start)} to {position_string(end)}"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def position_string(position: Any) -> str | None:
|
|
472
|
+
position_ = _position_dict(position)
|
|
473
|
+
if position_ is None:
|
|
474
|
+
return None
|
|
475
|
+
return f"{position_['line'] + 1}:{position_['character'] + 1}"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def range_start(range_: Any) -> dict[str, int] | None:
|
|
479
|
+
if not isinstance(range_, dict):
|
|
480
|
+
return None
|
|
481
|
+
return _position_dict(range_.get("start"))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def range_end(range_: Any) -> dict[str, int] | None:
|
|
485
|
+
if not isinstance(range_, dict):
|
|
486
|
+
return None
|
|
487
|
+
return _position_dict(range_.get("end"))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def diagnostic_level(severity: int | None) -> str | None:
|
|
491
|
+
if severity == 1:
|
|
492
|
+
return "error"
|
|
493
|
+
if severity == 2:
|
|
494
|
+
return "warning"
|
|
495
|
+
if severity in (3, 4):
|
|
496
|
+
return "info"
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def run_at_location(line: int, character: int) -> str:
|
|
501
|
+
return f"{line}:{character}"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def diagnostic_counts(*, errors: int, warnings: int, infos: int) -> dict[str, int]:
|
|
505
|
+
counts = {
|
|
506
|
+
"errors": errors,
|
|
507
|
+
"warnings": warnings,
|
|
508
|
+
"infos": infos,
|
|
509
|
+
}
|
|
510
|
+
return {key: count for key, count in counts.items() if count > 0}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def display_file(file: str, workspace: str | None) -> str:
|
|
514
|
+
path = Path(file)
|
|
515
|
+
if workspace is not None:
|
|
516
|
+
try:
|
|
517
|
+
return path.resolve().relative_to(Path(workspace).resolve()).as_posix()
|
|
518
|
+
except ValueError:
|
|
519
|
+
pass
|
|
520
|
+
return file
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def long_running_commands_from_perf_data(
|
|
524
|
+
*,
|
|
525
|
+
content: str,
|
|
526
|
+
perf_data: Any,
|
|
527
|
+
threshold_seconds: float,
|
|
528
|
+
executed_ranges: list[dict[str, Any]] | None = None,
|
|
529
|
+
limit: int = MAX_LONG_RUNNING_COMMANDS,
|
|
530
|
+
) -> list[LongRunningCommand]:
|
|
531
|
+
raw = lsp_to_jsonable(perf_data)
|
|
532
|
+
if not isinstance(raw, dict) or threshold_seconds <= 0 or limit <= 0:
|
|
533
|
+
return []
|
|
534
|
+
|
|
535
|
+
timings = raw.get("timings")
|
|
536
|
+
if not isinstance(timings, list):
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
executed_range_keys = (
|
|
540
|
+
{_range_key(range_) for range_ in executed_ranges}
|
|
541
|
+
if executed_ranges
|
|
542
|
+
else None
|
|
543
|
+
)
|
|
544
|
+
commands: list[LongRunningCommand] = []
|
|
545
|
+
for timing in timings:
|
|
546
|
+
if not isinstance(timing, dict):
|
|
547
|
+
continue
|
|
548
|
+
info = timing.get("info")
|
|
549
|
+
range_ = timing.get("range")
|
|
550
|
+
if not isinstance(info, dict) or not isinstance(range_, dict):
|
|
551
|
+
continue
|
|
552
|
+
if (
|
|
553
|
+
executed_range_keys is not None
|
|
554
|
+
and _range_key(range_) not in executed_range_keys
|
|
555
|
+
):
|
|
556
|
+
continue
|
|
557
|
+
if info.get("cache_hit") is True:
|
|
558
|
+
continue
|
|
559
|
+
time_seconds = info.get("time")
|
|
560
|
+
if not isinstance(time_seconds, (int, float)):
|
|
561
|
+
continue
|
|
562
|
+
if float(time_seconds) < threshold_seconds:
|
|
563
|
+
continue
|
|
564
|
+
text = source_text_for_range(content, range_)
|
|
565
|
+
if not text:
|
|
566
|
+
continue
|
|
567
|
+
commands.append(
|
|
568
|
+
LongRunningCommand(
|
|
569
|
+
range=range_,
|
|
570
|
+
text=text,
|
|
571
|
+
time_seconds=float(time_seconds),
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
if len(commands) >= limit:
|
|
575
|
+
break
|
|
576
|
+
return commands
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def pruned_dict(value: dict[str, Any]) -> dict[str, Any]:
|
|
580
|
+
result: dict[str, Any] = {}
|
|
581
|
+
for key, item in value.items():
|
|
582
|
+
pruned = prune_empty(item)
|
|
583
|
+
if not is_empty_value(pruned):
|
|
584
|
+
result[key] = pruned
|
|
585
|
+
return result
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def prune_empty(value: Any) -> Any:
|
|
589
|
+
if isinstance(value, dict):
|
|
590
|
+
return pruned_dict(value)
|
|
591
|
+
if isinstance(value, list):
|
|
592
|
+
return [
|
|
593
|
+
pruned
|
|
594
|
+
for item in value
|
|
595
|
+
if not is_empty_value(pruned := prune_empty(item))
|
|
596
|
+
]
|
|
597
|
+
return value
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def is_empty_value(value: Any) -> bool:
|
|
601
|
+
return value is None or value == [] or value == {}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _render_diagnostics(
|
|
605
|
+
diagnostics: list[Diagnostic],
|
|
606
|
+
options: RenderOptions,
|
|
607
|
+
) -> list[Diagnostic]:
|
|
608
|
+
diagnostics = [
|
|
609
|
+
diagnostic
|
|
610
|
+
for diagnostic in diagnostics
|
|
611
|
+
if diagnostic_matches_severity(diagnostic, options.severity)
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
if not options.deduplicate_proof_errors:
|
|
615
|
+
return diagnostics
|
|
616
|
+
|
|
617
|
+
# Keep a single error per proof, choosing the source-earliest one rather
|
|
618
|
+
# than the first to arrive. coq-lsp can publish a timed-out document's
|
|
619
|
+
# partial diagnostics out of source order, so picking by list position would
|
|
620
|
+
# let a later subgoal's error mask the real first error of the proof. All
|
|
621
|
+
# proof_name-less errors form one group, so a failed top-level sentence
|
|
622
|
+
# still collapses its downstream cascade to its earliest error.
|
|
623
|
+
kept_error_index: dict[str | None, int] = {}
|
|
624
|
+
for index, diagnostic in enumerate(diagnostics):
|
|
625
|
+
if diagnostic.severity != 1:
|
|
626
|
+
continue
|
|
627
|
+
group = diagnostic.proof_name
|
|
628
|
+
incumbent = kept_error_index.get(group)
|
|
629
|
+
if incumbent is None or _diagnostic_position_key(
|
|
630
|
+
diagnostic
|
|
631
|
+
) < _diagnostic_position_key(diagnostics[incumbent]):
|
|
632
|
+
kept_error_index[group] = index
|
|
633
|
+
|
|
634
|
+
filtered: list[Diagnostic] = []
|
|
635
|
+
for index, diagnostic in enumerate(diagnostics):
|
|
636
|
+
if (
|
|
637
|
+
diagnostic.severity == 1
|
|
638
|
+
and kept_error_index.get(diagnostic.proof_name) != index
|
|
639
|
+
):
|
|
640
|
+
continue
|
|
641
|
+
filtered.append(diagnostic)
|
|
642
|
+
return filtered
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _diagnostic_position_key(diagnostic: Diagnostic) -> tuple[int, int, int]:
|
|
646
|
+
start = range_start(diagnostic.range)
|
|
647
|
+
if start is None:
|
|
648
|
+
# Range-less diagnostics rank after positioned ones.
|
|
649
|
+
return (1, 0, 0)
|
|
650
|
+
return (0, start["line"], start["character"])
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def diagnostic_matches_severity(
|
|
654
|
+
diagnostic: Diagnostic,
|
|
655
|
+
minimum_severity: DiagnosticSeverity,
|
|
656
|
+
) -> bool:
|
|
657
|
+
level = diagnostic_level(diagnostic.severity) or "info"
|
|
658
|
+
return (
|
|
659
|
+
DIAGNOSTIC_LEVEL_PRIORITY[level]
|
|
660
|
+
>= DIAGNOSTIC_LEVEL_PRIORITY[minimum_severity]
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _completed_only(document: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
665
|
+
if not isinstance(document, dict):
|
|
666
|
+
return None
|
|
667
|
+
completed = document.get("completed")
|
|
668
|
+
return completed if isinstance(completed, dict) else None
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _latest_processing_range(progress: Any) -> dict[str, Any] | None:
|
|
672
|
+
if not isinstance(progress, dict):
|
|
673
|
+
return None
|
|
674
|
+
processing = progress.get("processing")
|
|
675
|
+
if not isinstance(processing, list):
|
|
676
|
+
return None
|
|
677
|
+
for item in processing:
|
|
678
|
+
if not isinstance(item, dict):
|
|
679
|
+
continue
|
|
680
|
+
range_ = item.get("range")
|
|
681
|
+
if isinstance(range_, dict):
|
|
682
|
+
return range_
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _source_preview_for_range(
|
|
687
|
+
content: str, range_: dict[str, Any]
|
|
688
|
+
) -> dict[str, Any] | None:
|
|
689
|
+
return _next_unchecked_preview(content, range_start(range_))
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def source_text_for_range(content: str, range_: dict[str, Any]) -> str | None:
|
|
693
|
+
start = range_start(range_)
|
|
694
|
+
end = range_end(range_)
|
|
695
|
+
if start is None or end is None:
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
lines = content.splitlines(keepends=True)
|
|
699
|
+
start_line = start["line"]
|
|
700
|
+
end_line = end["line"]
|
|
701
|
+
if start_line < 0 or end_line < start_line or start_line >= len(lines):
|
|
702
|
+
return None
|
|
703
|
+
if end_line >= len(lines):
|
|
704
|
+
end_line = len(lines) - 1
|
|
705
|
+
|
|
706
|
+
if start_line == end_line:
|
|
707
|
+
text = lines[start_line][start["character"] : end["character"]]
|
|
708
|
+
else:
|
|
709
|
+
parts = [lines[start_line][start["character"] :]]
|
|
710
|
+
parts.extend(lines[start_line + 1 : end_line])
|
|
711
|
+
parts.append(lines[end_line][: end["character"]])
|
|
712
|
+
text = "".join(parts)
|
|
713
|
+
return _truncate_preview_line(" ".join(text.strip().split()), limit=240)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _next_unchecked_preview(
|
|
717
|
+
content: str, position: dict[str, int] | None
|
|
718
|
+
) -> dict[str, Any] | None:
|
|
719
|
+
if position is None:
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
line_number = position["line"]
|
|
723
|
+
character = position["character"]
|
|
724
|
+
lines = content.splitlines()
|
|
725
|
+
for current_line_number in range(line_number, len(lines)):
|
|
726
|
+
line = lines[current_line_number]
|
|
727
|
+
start = character if current_line_number == line_number else 0
|
|
728
|
+
start = max(0, min(start, len(line)))
|
|
729
|
+
for index in range(start, len(line)):
|
|
730
|
+
if not line[index].isspace():
|
|
731
|
+
return {
|
|
732
|
+
"location": f"{current_line_number + 1}:{index + 1}",
|
|
733
|
+
"line": _truncate_preview_line(line.strip()),
|
|
734
|
+
}
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _truncate_preview_line(line: str, limit: int = 160) -> str:
|
|
739
|
+
if len(line) <= limit:
|
|
740
|
+
return line
|
|
741
|
+
return f"{line[: limit - 3]}..."
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _position_dict(value: Any) -> dict[str, int] | None:
|
|
745
|
+
if not isinstance(value, dict):
|
|
746
|
+
return None
|
|
747
|
+
line = value.get("line")
|
|
748
|
+
character = value.get("character")
|
|
749
|
+
if isinstance(line, int) and isinstance(character, int):
|
|
750
|
+
return {"line": line, "character": character}
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _range_key(range_: dict[str, Any]) -> str:
|
|
755
|
+
start = range_start(range_)
|
|
756
|
+
end = range_end(range_)
|
|
757
|
+
return f"{start}:{end}"
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _feedback_entries(feedback: Any) -> list[Any]:
|
|
761
|
+
raw = lsp_to_jsonable(feedback)
|
|
762
|
+
if isinstance(raw, dict):
|
|
763
|
+
entries = raw.get("feedback")
|
|
764
|
+
return entries if isinstance(entries, list) else []
|
|
765
|
+
if isinstance(raw, list):
|
|
766
|
+
return raw
|
|
767
|
+
return []
|