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,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 []