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.
- delphi_lsp/__init__.py +184 -0
- delphi_lsp/_version.py +1 -0
- delphi_lsp/agent_cache.py +1574 -0
- delphi_lsp/agent_cli.py +902 -0
- delphi_lsp/agent_context.py +3907 -0
- delphi_lsp/agent_cpg.py +349 -0
- delphi_lsp/agent_cpg_builder.py +666 -0
- delphi_lsp/agent_layers.py +1410 -0
- delphi_lsp/agent_metrics.py +526 -0
- delphi_lsp/agent_protocol.py +445 -0
- delphi_lsp/agent_relations.py +1881 -0
- delphi_lsp/agent_templates.py +614 -0
- delphi_lsp/agent_wiki.py +2853 -0
- delphi_lsp/agent_workspace.py +860 -0
- delphi_lsp/ast_serialize.py +126 -0
- delphi_lsp/binary.py +250 -0
- delphi_lsp/comment_builder.py +28 -0
- delphi_lsp/consts.py +345 -0
- delphi_lsp/delphi_lsp_native.pyd +0 -0
- delphi_lsp/delphiast_lexer.py +399 -0
- delphi_lsp/delphiast_parser.py +2189 -0
- delphi_lsp/delphiast_tokens.py +41 -0
- delphi_lsp/grammar.py +459 -0
- delphi_lsp/incremental.py +1139 -0
- delphi_lsp/lark_builder.py +2684 -0
- delphi_lsp/lark_tokens.py +236 -0
- delphi_lsp/lexical_scanner.py +30 -0
- delphi_lsp/lsp_server.py +6698 -0
- delphi_lsp/metrics.py +1452 -0
- delphi_lsp/native_bridge.py +300 -0
- delphi_lsp/navigation_cache.py +189 -0
- delphi_lsp/nodes.py +462 -0
- delphi_lsp/parallel_outline.py +662 -0
- delphi_lsp/parse_artifacts.py +969 -0
- delphi_lsp/parser.py +342 -0
- delphi_lsp/parser_backend.py +139 -0
- delphi_lsp/preprocessor.py +1122 -0
- delphi_lsp/progress.py +26 -0
- delphi_lsp/project_config.py +285 -0
- delphi_lsp/project_discovery.py +932 -0
- delphi_lsp/project_indexer.py +470 -0
- delphi_lsp/py.typed +1 -0
- delphi_lsp/response_cache.py +465 -0
- delphi_lsp/semantic.py +394 -0
- delphi_lsp/semantic_builder.py +2302 -0
- delphi_lsp/source_reader.py +17 -0
- delphi_lsp/wiki_external_sort.py +392 -0
- delphi_lsp/wiki_memory.py +548 -0
- delphi_lsp/workspace.py +83 -0
- delphi_lsp/writer.py +73 -0
- python_delphi_lsp-3.6.0.dist-info/METADATA +766 -0
- python_delphi_lsp-3.6.0.dist-info/RECORD +56 -0
- python_delphi_lsp-3.6.0.dist-info/WHEEL +4 -0
- python_delphi_lsp-3.6.0.dist-info/entry_points.txt +3 -0
- python_delphi_lsp-3.6.0.dist-info/licenses/LICENSE +373 -0
- 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
|
+
]
|