python-delphi-lsp 3.6.0__cp310-abi3-win_amd64.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.
Files changed (56) hide show
  1. delphi_lsp/__init__.py +184 -0
  2. delphi_lsp/_version.py +1 -0
  3. delphi_lsp/agent_cache.py +1574 -0
  4. delphi_lsp/agent_cli.py +902 -0
  5. delphi_lsp/agent_context.py +3907 -0
  6. delphi_lsp/agent_cpg.py +349 -0
  7. delphi_lsp/agent_cpg_builder.py +666 -0
  8. delphi_lsp/agent_layers.py +1410 -0
  9. delphi_lsp/agent_metrics.py +526 -0
  10. delphi_lsp/agent_protocol.py +445 -0
  11. delphi_lsp/agent_relations.py +1881 -0
  12. delphi_lsp/agent_templates.py +614 -0
  13. delphi_lsp/agent_wiki.py +2853 -0
  14. delphi_lsp/agent_workspace.py +860 -0
  15. delphi_lsp/ast_serialize.py +126 -0
  16. delphi_lsp/binary.py +250 -0
  17. delphi_lsp/comment_builder.py +28 -0
  18. delphi_lsp/consts.py +345 -0
  19. delphi_lsp/delphi_lsp_native.pyd +0 -0
  20. delphi_lsp/delphiast_lexer.py +399 -0
  21. delphi_lsp/delphiast_parser.py +2189 -0
  22. delphi_lsp/delphiast_tokens.py +41 -0
  23. delphi_lsp/grammar.py +459 -0
  24. delphi_lsp/incremental.py +1139 -0
  25. delphi_lsp/lark_builder.py +2684 -0
  26. delphi_lsp/lark_tokens.py +236 -0
  27. delphi_lsp/lexical_scanner.py +30 -0
  28. delphi_lsp/lsp_server.py +6698 -0
  29. delphi_lsp/metrics.py +1452 -0
  30. delphi_lsp/native_bridge.py +300 -0
  31. delphi_lsp/navigation_cache.py +189 -0
  32. delphi_lsp/nodes.py +462 -0
  33. delphi_lsp/parallel_outline.py +662 -0
  34. delphi_lsp/parse_artifacts.py +969 -0
  35. delphi_lsp/parser.py +342 -0
  36. delphi_lsp/parser_backend.py +139 -0
  37. delphi_lsp/preprocessor.py +1122 -0
  38. delphi_lsp/progress.py +26 -0
  39. delphi_lsp/project_config.py +285 -0
  40. delphi_lsp/project_discovery.py +932 -0
  41. delphi_lsp/project_indexer.py +470 -0
  42. delphi_lsp/py.typed +1 -0
  43. delphi_lsp/response_cache.py +465 -0
  44. delphi_lsp/semantic.py +394 -0
  45. delphi_lsp/semantic_builder.py +2302 -0
  46. delphi_lsp/source_reader.py +17 -0
  47. delphi_lsp/wiki_external_sort.py +392 -0
  48. delphi_lsp/wiki_memory.py +548 -0
  49. delphi_lsp/workspace.py +83 -0
  50. delphi_lsp/writer.py +73 -0
  51. python_delphi_lsp-3.6.0.dist-info/METADATA +766 -0
  52. python_delphi_lsp-3.6.0.dist-info/RECORD +56 -0
  53. python_delphi_lsp-3.6.0.dist-info/WHEEL +4 -0
  54. python_delphi_lsp-3.6.0.dist-info/entry_points.txt +3 -0
  55. python_delphi_lsp-3.6.0.dist-info/licenses/LICENSE +373 -0
  56. python_delphi_lsp-3.6.0.dist-info/sboms/python-delphi-lsp.cyclonedx.json +770 -0
@@ -0,0 +1,465 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import stat
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .project_discovery import DelphiProjectDiscovery, discover_delphi_project
12
+
13
+ RESPONSE_CACHE_SCHEMA_VERSION = 1
14
+ RESPONSE_CACHE_PRODUCER_VERSION = "batch-response-v1"
15
+ DEFAULT_RESPONSE_CACHE_BYTES = 2 * 1024 * 1024 * 1024
16
+ _MAX_HEADER_BYTES = 16 * 1024
17
+ _MAX_RECORD_BYTES = 1024 * 1024 * 1024
18
+ _MAX_REVISION_MANIFEST_BYTES = 64 * 1024 * 1024
19
+ _REVISION_MANIFEST_NAME = "content-revision-v1.json"
20
+ _REVISION_MANIFEST_PRODUCER = "workspace-content-v1"
21
+ _REVISION_MANIFEST_SCHEMA = 1
22
+ _REVISION_PREFIX = b"python-delphi-lsp-workspace-content-v1\0"
23
+ _KEY_PREFIX = b"python-delphi-lsp-response-cache-v1\0"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ResponseCacheKey:
28
+ workspace_revision: str
29
+ command: str
30
+ layer: str
31
+ query: str
32
+ output_format: str
33
+ deep_projects: bool
34
+
35
+ def to_mapping(self) -> dict[str, object]:
36
+ return {
37
+ "command": self.command,
38
+ "deep_projects": self.deep_projects,
39
+ "layer": self.layer,
40
+ "output_format": self.output_format,
41
+ "producer_version": RESPONSE_CACHE_PRODUCER_VERSION,
42
+ "query": self.query,
43
+ "schema_version": RESPONSE_CACHE_SCHEMA_VERSION,
44
+ "workspace_revision": self.workspace_revision,
45
+ }
46
+
47
+ @property
48
+ def cache_key(self) -> str:
49
+ digest = hashlib.sha256()
50
+ digest.update(_KEY_PREFIX)
51
+ digest.update(_canonical_json(self.to_mapping()))
52
+ return digest.hexdigest()
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class WorkspaceContentSnapshot:
57
+ revision: str
58
+ discovery: DelphiProjectDiscovery
59
+
60
+
61
+ class ResponseCacheStore:
62
+ def __init__(
63
+ self,
64
+ root: str | Path,
65
+ *,
66
+ max_bytes: int = DEFAULT_RESPONSE_CACHE_BYTES,
67
+ ) -> None:
68
+ if (
69
+ isinstance(max_bytes, bool)
70
+ or not isinstance(max_bytes, int)
71
+ or max_bytes < 0
72
+ ):
73
+ raise ValueError("response cache byte budget must be non-negative")
74
+ self.root = Path(root)
75
+ self.max_bytes = max_bytes
76
+
77
+ def load(self, key: ResponseCacheKey) -> bytes | None:
78
+ path = self.path_for_key(key)
79
+ if self._has_unsafe_directory(path.parent):
80
+ return None
81
+ try:
82
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
83
+ try:
84
+ info = os.fstat(descriptor)
85
+ if (
86
+ not stat.S_ISREG(info.st_mode)
87
+ or info.st_size > _MAX_RECORD_BYTES
88
+ or (
89
+ os.name != "nt"
90
+ and (info.st_uid != os.getuid() or info.st_mode & 0o077)
91
+ )
92
+ ):
93
+ return None
94
+ with os.fdopen(descriptor, "rb", closefd=False) as stream:
95
+ data = stream.read(_MAX_RECORD_BYTES + 1)
96
+ finally:
97
+ os.close(descriptor)
98
+ except OSError:
99
+ return None
100
+ if len(data) > _MAX_RECORD_BYTES:
101
+ return None
102
+ header_bytes, separator, payload = data.partition(b"\n")
103
+ if not separator or len(header_bytes) > _MAX_HEADER_BYTES:
104
+ return None
105
+ try:
106
+ header = json.loads(header_bytes.decode("utf-8"))
107
+ except (UnicodeError, ValueError, RecursionError):
108
+ return None
109
+ if not isinstance(header, dict):
110
+ return None
111
+ if (
112
+ header.get("schema_version") != RESPONSE_CACHE_SCHEMA_VERSION
113
+ or header.get("producer_version") != RESPONSE_CACHE_PRODUCER_VERSION
114
+ or header.get("cache_key") != key.cache_key
115
+ or header.get("key") != key.to_mapping()
116
+ or header.get("payload_size") != len(payload)
117
+ or header.get("payload_sha256") != hashlib.sha256(payload).hexdigest()
118
+ ):
119
+ return None
120
+ return payload
121
+
122
+ def store(self, key: ResponseCacheKey, payload: bytes) -> None:
123
+ if not isinstance(payload, bytes):
124
+ raise TypeError("response cache payload must be bytes")
125
+ header = {
126
+ "cache_key": key.cache_key,
127
+ "key": key.to_mapping(),
128
+ "payload_sha256": hashlib.sha256(payload).hexdigest(),
129
+ "payload_size": len(payload),
130
+ "producer_version": RESPONSE_CACHE_PRODUCER_VERSION,
131
+ "schema_version": RESPONSE_CACHE_SCHEMA_VERSION,
132
+ }
133
+ header_bytes = _canonical_json(header)
134
+ if len(header_bytes) > _MAX_HEADER_BYTES:
135
+ raise ValueError("response cache header is too large")
136
+ record_size = len(header_bytes) + 1 + len(payload)
137
+ if record_size > _MAX_RECORD_BYTES:
138
+ raise ValueError("response cache record is too large")
139
+ if record_size > self.max_bytes:
140
+ return
141
+ path = self.path_for_key(key)
142
+ self._ensure_directory(path.parent)
143
+ descriptor, temporary = tempfile.mkstemp(prefix=".response-", dir=path.parent)
144
+ try:
145
+ if os.name != "nt":
146
+ os.fchmod(descriptor, 0o600)
147
+ with os.fdopen(descriptor, "wb") as stream:
148
+ stream.write(header_bytes)
149
+ stream.write(b"\n")
150
+ stream.write(payload)
151
+ stream.flush()
152
+ os.fsync(stream.fileno())
153
+ os.replace(temporary, path)
154
+ if os.name != "nt":
155
+ os.chmod(path, 0o600)
156
+ self._prune(path)
157
+ finally:
158
+ try:
159
+ os.unlink(temporary)
160
+ except FileNotFoundError:
161
+ pass
162
+
163
+ def path_for_key(self, key: ResponseCacheKey) -> Path:
164
+ cache_key = key.cache_key
165
+ return self.root / cache_key[:2] / f"{cache_key}.bin"
166
+
167
+ def _prune(self, current: Path) -> None:
168
+ if not self.root.exists() or self._has_unsafe_directory(self.root):
169
+ return
170
+ records: list[tuple[bool, int, str, Path, int]] = []
171
+ try:
172
+ buckets = tuple(self.root.iterdir())
173
+ except OSError:
174
+ return
175
+ for bucket in buckets:
176
+ if bucket.is_symlink() or not bucket.is_dir():
177
+ continue
178
+ try:
179
+ entries = tuple(bucket.iterdir())
180
+ except OSError:
181
+ continue
182
+ for entry in entries:
183
+ if entry.is_symlink() or entry.suffix != ".bin":
184
+ continue
185
+ try:
186
+ info = entry.stat(follow_symlinks=False)
187
+ except OSError:
188
+ continue
189
+ if not stat.S_ISREG(info.st_mode):
190
+ continue
191
+ records.append(
192
+ (
193
+ entry == current,
194
+ info.st_mtime_ns,
195
+ str(entry),
196
+ entry,
197
+ info.st_size,
198
+ )
199
+ )
200
+ retained_bytes = sum(item[4] for item in records)
201
+ for _is_current, _mtime, _name, entry, size in sorted(records):
202
+ if retained_bytes <= self.max_bytes:
203
+ break
204
+ try:
205
+ entry.unlink()
206
+ except OSError:
207
+ continue
208
+ retained_bytes -= size
209
+
210
+ def _has_unsafe_directory(self, directory: Path) -> bool:
211
+ return any(
212
+ path.is_symlink() for path in (self.root.parent, self.root, directory)
213
+ )
214
+
215
+ def _ensure_directory(self, directory: Path) -> None:
216
+ if self._has_unsafe_directory(directory):
217
+ raise OSError("response cache path is unsafe")
218
+ self.root.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
219
+ for path in (self.root, directory):
220
+ if path.exists() and path.is_symlink():
221
+ raise OSError("response cache path is unsafe")
222
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
223
+ if os.name != "nt":
224
+ os.chmod(path, 0o700)
225
+
226
+
227
+ def workspace_content_revision(
228
+ root: str | Path,
229
+ *,
230
+ project_file: str | Path | None = None,
231
+ ) -> str:
232
+ return workspace_content_snapshot(root, project_file=project_file).revision
233
+
234
+
235
+ def workspace_content_snapshot(
236
+ root: str | Path,
237
+ *,
238
+ project_file: str | Path | None = None,
239
+ ) -> WorkspaceContentSnapshot:
240
+ root_path = Path(root).expanduser().resolve()
241
+ resolved_project: Path | None = None
242
+ if project_file is not None:
243
+ resolved_project = Path(project_file).expanduser()
244
+ if not resolved_project.is_absolute():
245
+ resolved_project = root_path / resolved_project
246
+ resolved_project = resolved_project.resolve()
247
+ discovery = discover_delphi_project(
248
+ root_path,
249
+ project_file=resolved_project,
250
+ scan_workspace_sources=True,
251
+ )
252
+ paths = {
253
+ *(Path(value).expanduser().resolve() for value in discovery.project_files),
254
+ *(Path(value).expanduser().resolve() for value in discovery.config_files),
255
+ *(Path(value).expanduser().resolve() for value in discovery.source_files),
256
+ }
257
+ if resolved_project is not None:
258
+ paths.add(resolved_project)
259
+
260
+ digest = hashlib.sha256()
261
+ digest.update(_REVISION_PREFIX)
262
+ selection = {
263
+ "defines": sorted(discovery.defines),
264
+ "include_paths": sorted(
265
+ _display_path(Path(value), root_path) for value in discovery.include_paths
266
+ ),
267
+ "project_file": ""
268
+ if resolved_project is None
269
+ else _display_path(resolved_project, root_path),
270
+ "root": str(root_path),
271
+ "search_paths": sorted(
272
+ _display_path(Path(value), root_path) for value in discovery.search_paths
273
+ ),
274
+ }
275
+ digest.update(_canonical_json(selection))
276
+ cached_records = _load_revision_manifest(root_path)
277
+ current_records: dict[str, dict[str, object]] = {}
278
+ for path in sorted(
279
+ paths, key=lambda item: (_display_path(item, root_path).casefold(), str(item))
280
+ ):
281
+ display = _display_path(path, root_path)
282
+ digest.update(display.encode("utf-8", errors="surrogatepass"))
283
+ digest.update(b"\0")
284
+ metadata = _file_metadata(path)
285
+ content_digest = _cached_content_digest(
286
+ cached_records.get(display),
287
+ metadata,
288
+ )
289
+ if content_digest is None:
290
+ content_digest = _hash_file(path)
291
+ verified_metadata = _file_metadata(path)
292
+ if content_digest is not None and verified_metadata == metadata:
293
+ metadata = verified_metadata
294
+ else:
295
+ metadata = None
296
+ if content_digest is None:
297
+ digest.update(b"missing")
298
+ else:
299
+ digest.update(bytes.fromhex(content_digest))
300
+ if metadata is not None:
301
+ current_records[display] = {
302
+ **metadata,
303
+ "sha256": content_digest,
304
+ }
305
+ digest.update(b"\0")
306
+ if current_records != cached_records:
307
+ try:
308
+ _store_revision_manifest(root_path, current_records)
309
+ except (OSError, ValueError):
310
+ pass
311
+ return WorkspaceContentSnapshot(
312
+ revision=f"workspace_content_v1_{digest.hexdigest()}",
313
+ discovery=discovery,
314
+ )
315
+
316
+
317
+ def _file_metadata(path: Path) -> dict[str, int] | None:
318
+ try:
319
+ info = path.stat()
320
+ except OSError:
321
+ return None
322
+ if not stat.S_ISREG(info.st_mode):
323
+ return None
324
+ return {
325
+ "ctime_ns": info.st_ctime_ns,
326
+ "device": info.st_dev,
327
+ "inode": info.st_ino,
328
+ "mtime_ns": info.st_mtime_ns,
329
+ "size": info.st_size,
330
+ }
331
+
332
+
333
+ def _cached_content_digest(
334
+ record: object,
335
+ metadata: dict[str, int] | None,
336
+ ) -> str | None:
337
+ if not isinstance(record, dict) or metadata is None:
338
+ return None
339
+ if any(record.get(key) != value for key, value in metadata.items()):
340
+ return None
341
+ content_digest = record.get("sha256")
342
+ if (
343
+ not isinstance(content_digest, str)
344
+ or len(content_digest) != 64
345
+ or any(character not in "0123456789abcdef" for character in content_digest)
346
+ ):
347
+ return None
348
+ return content_digest
349
+
350
+
351
+ def _hash_file(path: Path) -> str | None:
352
+ try:
353
+ with path.open("rb") as stream:
354
+ digest = hashlib.sha256()
355
+ while chunk := stream.read(1024 * 1024):
356
+ digest.update(chunk)
357
+ except OSError:
358
+ return None
359
+ return digest.hexdigest()
360
+
361
+
362
+ def _load_revision_manifest(root: Path) -> dict[str, object]:
363
+ path = root / ".delphi-lsp" / _REVISION_MANIFEST_NAME
364
+ if path.parent.is_symlink():
365
+ return {}
366
+ try:
367
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
368
+ try:
369
+ info = os.fstat(descriptor)
370
+ if (
371
+ not stat.S_ISREG(info.st_mode)
372
+ or info.st_size > _MAX_REVISION_MANIFEST_BYTES
373
+ or (
374
+ os.name != "nt"
375
+ and (info.st_uid != os.getuid() or info.st_mode & 0o077)
376
+ )
377
+ ):
378
+ return {}
379
+ with os.fdopen(descriptor, "rb", closefd=False) as stream:
380
+ data = stream.read(_MAX_REVISION_MANIFEST_BYTES + 1)
381
+ finally:
382
+ os.close(descriptor)
383
+ except OSError:
384
+ return {}
385
+ if len(data) > _MAX_REVISION_MANIFEST_BYTES:
386
+ return {}
387
+ try:
388
+ manifest = json.loads(data.decode("utf-8"))
389
+ except (UnicodeError, ValueError, RecursionError):
390
+ return {}
391
+ if (
392
+ not isinstance(manifest, dict)
393
+ or manifest.get("schema_version") != _REVISION_MANIFEST_SCHEMA
394
+ or manifest.get("producer_version") != _REVISION_MANIFEST_PRODUCER
395
+ or not isinstance(manifest.get("records"), dict)
396
+ ):
397
+ return {}
398
+ return manifest["records"]
399
+
400
+
401
+ def _store_revision_manifest(
402
+ root: Path,
403
+ records: dict[str, dict[str, object]],
404
+ ) -> None:
405
+ directory = root / ".delphi-lsp"
406
+ path = directory / _REVISION_MANIFEST_NAME
407
+ if directory.is_symlink():
408
+ raise OSError("workspace revision manifest path is unsafe")
409
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
410
+ if os.name != "nt":
411
+ os.chmod(directory, 0o700)
412
+ encoded = _canonical_json(
413
+ {
414
+ "producer_version": _REVISION_MANIFEST_PRODUCER,
415
+ "records": records,
416
+ "schema_version": _REVISION_MANIFEST_SCHEMA,
417
+ }
418
+ )
419
+ if len(encoded) > _MAX_REVISION_MANIFEST_BYTES:
420
+ raise ValueError("workspace revision manifest is too large")
421
+ descriptor, temporary = tempfile.mkstemp(prefix=".revision-", dir=directory)
422
+ try:
423
+ if os.name != "nt":
424
+ os.fchmod(descriptor, 0o600)
425
+ with os.fdopen(descriptor, "wb") as stream:
426
+ stream.write(encoded)
427
+ stream.flush()
428
+ os.fsync(stream.fileno())
429
+ os.replace(temporary, path)
430
+ if os.name != "nt":
431
+ os.chmod(path, 0o600)
432
+ finally:
433
+ try:
434
+ os.unlink(temporary)
435
+ except FileNotFoundError:
436
+ pass
437
+
438
+
439
+ def _display_path(path: Path, root: Path) -> str:
440
+ resolved = path.expanduser().resolve()
441
+ try:
442
+ return resolved.relative_to(root).as_posix()
443
+ except ValueError:
444
+ return str(resolved)
445
+
446
+
447
+ def _canonical_json(payload: object) -> bytes:
448
+ return json.dumps(
449
+ payload,
450
+ ensure_ascii=False,
451
+ sort_keys=True,
452
+ separators=(",", ":"),
453
+ ).encode("utf-8")
454
+
455
+
456
+ __all__ = [
457
+ "DEFAULT_RESPONSE_CACHE_BYTES",
458
+ "RESPONSE_CACHE_PRODUCER_VERSION",
459
+ "RESPONSE_CACHE_SCHEMA_VERSION",
460
+ "ResponseCacheKey",
461
+ "ResponseCacheStore",
462
+ "WorkspaceContentSnapshot",
463
+ "workspace_content_revision",
464
+ "workspace_content_snapshot",
465
+ ]