python-delphi-lsp 2.1.0__py3-none-any.whl → 2.2.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.
delphi_lsp/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2.1.0"
1
+ __version__ = "2.2.0"
delphi_lsp/agent_cache.py CHANGED
@@ -20,6 +20,8 @@ import threading
20
20
  import time
21
21
  from types import ModuleType
22
22
 
23
+ from watchfiles import watch
24
+
23
25
  from ._version import __version__
24
26
  from .agent_context import AgentContext
25
27
  from .agent_protocol import AgentProtocolError
@@ -32,10 +34,15 @@ DEFAULT_IDLE_TIMEOUT = 1800
32
34
  DEFAULT_STARTUP_TIMEOUT = 120.0
33
35
  _MAX_MESSAGE_BYTES = 1024 * 1024
34
36
  _CONNECTION_TIMEOUT = 2.0
37
+ _CLIENT_RESPONSE_TIMEOUT = 120.0
35
38
  _MEMORY_SIZE = re.compile(r"^(?P<count>[1-9][0-9]*)(?P<suffix>[KMG]?)$", re.IGNORECASE)
36
39
  _STARTUP_DIAGNOSTIC_BYTES = 16 * 1024
37
40
  _STARTUP_TOKEN_RE = re.compile(r"(?i)(token\b[^\n\r]*?:?\s*['\"]?[A-Za-z0-9_-]+['\"]?|\b[a-zA-Z0-9_-]{32,})")
38
41
  _START_LOCK_INCOMPLETE_GRACE_SECONDS = 1.0
42
+ _CACHE_REVISION_CHECK_INTERVAL_SECONDS = 3600.0
43
+ _WATCHED_SUFFIXES = frozenset(
44
+ {".pas", ".pp", ".inc", ".dpr", ".dpk", ".dproj", ".cfg"}
45
+ )
39
46
 
40
47
 
41
48
  def estimate_deep_size(value: object) -> int:
@@ -475,7 +482,7 @@ def _start_lock(root: str | Path, timeout: float):
475
482
  def _client_exchange(metadata: CacheMetadata, request: dict[str, object]) -> CacheClientResponse:
476
483
  try:
477
484
  with socket.create_connection(("127.0.0.1", metadata.port), timeout=2) as connection:
478
- connection.settimeout(3)
485
+ connection.settimeout(_CLIENT_RESPONSE_TIMEOUT)
479
486
  request_without_token = {key: value for key, value in request.items() if key != "token"}
480
487
  connection.sendall(json.dumps({"token": metadata.token, **request_without_token}, separators=(",", ":")).encode("utf-8") + b"\n")
481
488
  response = _read_line(connection)
@@ -541,6 +548,7 @@ class _CacheService:
541
548
  metadata.project_file or None,
542
549
  workers=metadata.workers,
543
550
  worker_memory_budget_bytes=metadata.max_memory_bytes,
551
+ revision_check_interval_seconds=_CACHE_REVISION_CHECK_INTERVAL_SECONDS,
544
552
  )
545
553
  self.budget = CacheBudget(metadata.max_memory_bytes)
546
554
  self.stats = CacheStats()
@@ -556,15 +564,14 @@ class _CacheService:
556
564
  def prewarm(self) -> None:
557
565
  started = time.monotonic()
558
566
  try:
559
- self.context.handle({"action": "find", "query": "", "max_items": 1, "max_chars": 256})
560
- self.last_revision = self.context.workspace.workspace_revision
567
+ self.last_revision = self.context.prewarm_navigation()
561
568
  self.cache_state = "warm"
562
569
  except AgentProtocolError as error:
563
570
  if error.code != "project_required":
564
571
  raise
565
572
  self.cache_state = "ready"
566
573
  self.last_budget = self.budget.enforce(
567
- measure=lambda: estimate_deep_size(self.context.cache_roots()),
574
+ measure=lambda: self.context.estimated_cache_bytes,
568
575
  evict_auxiliary=self.context.evict_auxiliary_caches,
569
576
  evict_navigation=self.context.evict_navigation_caches,
570
577
  )
@@ -605,7 +612,7 @@ class _CacheService:
605
612
  if before != after:
606
613
  self.stats.invalidations += 1
607
614
  self.last_budget = self.budget.enforce(
608
- measure=lambda: estimate_deep_size(self.context.cache_roots()),
615
+ measure=lambda: self.context.estimated_cache_bytes,
609
616
  evict_auxiliary=self.context.evict_auxiliary_caches,
610
617
  evict_navigation=self.context.evict_navigation_caches,
611
618
  )
@@ -642,10 +649,31 @@ class _CacheService:
642
649
  "parallel_seconds": self.context.parallel_stats.elapsed_seconds,
643
650
  "parallel_fallbacks": self.stats.parallel_fallbacks,
644
651
  "idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
645
- "workspace_revision": self.context.workspace.workspace_revision,
652
+ "workspace_revision": self.last_revision,
646
653
  }
647
654
 
648
655
 
656
+ def _watch_filter(_change: object, path: str) -> bool:
657
+ return Path(path).suffix.casefold() in _WATCHED_SUFFIXES
658
+
659
+
660
+ def _watch_workspace(service: _CacheService) -> None:
661
+ try:
662
+ for changes in watch(
663
+ service.metadata.root,
664
+ watch_filter=_watch_filter,
665
+ stop_event=service.shutdown,
666
+ debounce=50,
667
+ step=20,
668
+ recursive=True,
669
+ raise_interrupt=False,
670
+ ):
671
+ if changes:
672
+ service.context.invalidate_revision_cache()
673
+ except (OSError, RuntimeError):
674
+ service.context.invalidate_revision_cache()
675
+
676
+
649
677
  def _serve_connection(connection: socket.socket, service: _CacheService) -> None:
650
678
  try:
651
679
  line = _read_line(connection)
@@ -691,9 +719,17 @@ def run_cache_daemon(
691
719
  idle_timeout,
692
720
  time.time(),
693
721
  )
722
+ watcher: threading.Thread | None = None
694
723
  try:
695
724
  service = _CacheService(metadata)
696
725
  service.prewarm()
726
+ watcher = threading.Thread(
727
+ target=_watch_workspace,
728
+ args=(service,),
729
+ name="delphi-cache-watcher",
730
+ daemon=True,
731
+ )
732
+ watcher.start()
697
733
  _write_metadata(metadata)
698
734
  while not service.shutdown.is_set() and time.monotonic() - service.last_activity < idle_timeout:
699
735
  try:
@@ -704,6 +740,10 @@ def run_cache_daemon(
704
740
  connection.settimeout(_CONNECTION_TIMEOUT)
705
741
  _serve_connection(connection, service)
706
742
  finally:
743
+ if "service" in locals():
744
+ service.shutdown.set()
745
+ if watcher is not None:
746
+ watcher.join(timeout=2.0)
707
747
  listener.close()
708
748
  _remove_metadata_if_owned(metadata)
709
749
 
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from bisect import bisect_left, bisect_right
4
+ from collections import OrderedDict
4
5
  from collections.abc import Mapping
5
- from dataclasses import dataclass
6
+ from dataclasses import dataclass, replace
6
7
  from heapq import heappop, heappush
7
8
  import hashlib
8
9
  import json
9
10
  from pathlib import Path, PureWindowsPath
11
+ import sys
12
+ import time
10
13
  import unicodedata
11
14
 
12
15
  from .agent_protocol import (
@@ -22,12 +25,17 @@ from .agent_metrics import build_workspace_metrics, project_metric_item, unit_me
22
25
  from .agent_relations import ProjectRelationIndex, RelationTarget
23
26
  from .agent_workspace import AgentUnit, AgentWorkspace, unit_display_path, unit_source_path, unit_target_id
24
27
  from .consts import AttributeName, SyntaxNodeType
25
- from .lsp_server import multiline_string_block_end
28
+ from .lsp_server import build_outline_semantic_model, multiline_string_block_end
26
29
  from .nodes import CompoundSyntaxNode, SyntaxNode
27
30
  from .parser import DelphiParser
28
- from .semantic import Scope, ScopeKind, Symbol, SymbolKind
31
+ from .semantic import NamedTypeRef, Scope, ScopeKind, Symbol, SymbolKind
29
32
  from .metrics import ProjectMetrics
30
- from .parallel_outline import OutlineResult, OutlineTask, ParallelBuildStats, run_outline_tasks
33
+ from .parallel_outline import (
34
+ ParallelBuildStats,
35
+ ParallelOutlineError,
36
+ run_outline_tasks,
37
+ )
38
+ from .source_reader import read_source_text
31
39
 
32
40
 
33
41
  _ROUTINE_KINDS = frozenset(
@@ -90,9 +98,10 @@ _CALLING_CONVENTIONS = frozenset(
90
98
  {"cdecl", "pascal", "register", "safecall", "stdcall", "winapi"}
91
99
  )
92
100
  _SOURCE_CHUNK_CHARS = 6000
101
+ _RANKED_QUERY_CACHE_SIZE = 16
93
102
 
94
103
 
95
- @dataclass(frozen=True)
104
+ @dataclass(frozen=True, slots=True)
96
105
  class _Token:
97
106
  value: str
98
107
  start: int
@@ -137,6 +146,18 @@ class _SourceDocument:
137
146
  self.parser_spans: dict[str, tuple[int, int] | None] = {}
138
147
  self._full_parse_attempted = False
139
148
  self._full_parse_result: object | None = None
149
+ self.retained_bytes = (
150
+ sys.getsizeof(self)
151
+ + sys.getsizeof(self.text)
152
+ + sys.getsizeof(self.line_starts)
153
+ + len(self.line_starts) * 32
154
+ + sys.getsizeof(self.tokens)
155
+ + len(self.tokens) * 160
156
+ + sys.getsizeof(self.token_starts)
157
+ + len(self.token_starts) * 28
158
+ + sys.getsizeof(self.directive_starts)
159
+ + len(self.directive_starts) * 28
160
+ )
140
161
 
141
162
  def offset(self, line: int, column: int = 1) -> int:
142
163
  if not self.line_starts:
@@ -185,7 +206,78 @@ class _SourceDocument:
185
206
  return self._full_parse_result
186
207
 
187
208
 
188
- @dataclass(frozen=True)
209
+ @dataclass(frozen=True, slots=True)
210
+ class _SourceSpec:
211
+ source_path: Path
212
+ display_path: str
213
+ defines: tuple[str, ...]
214
+ include_paths: tuple[str, ...]
215
+
216
+
217
+ class _SourceStore:
218
+ """Load expensive tokenized source documents only when source evidence is requested."""
219
+
220
+ def __init__(
221
+ self,
222
+ specs: Mapping[Path, _SourceSpec],
223
+ *,
224
+ max_loaded_bytes: int,
225
+ ) -> None:
226
+ self._specs = dict(specs)
227
+ self._loaded: OrderedDict[Path, _SourceDocument] = OrderedDict()
228
+ self._loaded_bytes = 0
229
+ self._max_loaded_bytes = max(0, max_loaded_bytes)
230
+
231
+ def __getitem__(self, source_path: Path) -> _SourceDocument:
232
+ cached = self._loaded.pop(source_path, None)
233
+ if cached is not None:
234
+ self._loaded[source_path] = cached
235
+ return cached
236
+
237
+ spec = self._specs[source_path]
238
+ document = _SourceDocument(
239
+ spec.source_path,
240
+ spec.display_path,
241
+ read_source_text(spec.source_path),
242
+ defines=spec.defines,
243
+ include_paths=spec.include_paths,
244
+ )
245
+ if document.retained_bytes > self._max_loaded_bytes:
246
+ return document
247
+ while (
248
+ self._loaded
249
+ and self._loaded_bytes + document.retained_bytes > self._max_loaded_bytes
250
+ ):
251
+ _, evicted = self._loaded.popitem(last=False)
252
+ self._loaded_bytes -= evicted.retained_bytes
253
+ self._loaded[source_path] = document
254
+ self._loaded_bytes += document.retained_bytes
255
+ return document
256
+
257
+ @property
258
+ def loaded_count(self) -> int:
259
+ return len(self._loaded)
260
+
261
+ @property
262
+ def retained_bytes(self) -> int:
263
+ return self._loaded_bytes
264
+
265
+ @property
266
+ def metadata_bytes(self) -> int:
267
+ return 512 + sum(
268
+ 256
269
+ + sys.getsizeof(path)
270
+ + sys.getsizeof(spec)
271
+ + sys.getsizeof(spec.display_path)
272
+ for path, spec in self._specs.items()
273
+ )
274
+
275
+ def clear_loaded(self) -> None:
276
+ self._loaded.clear()
277
+ self._loaded_bytes = 0
278
+
279
+
280
+ @dataclass(frozen=True, slots=True)
189
281
  class _RawSymbol:
190
282
  symbol: Symbol
191
283
  source_path: Path
@@ -198,7 +290,115 @@ class _RawSymbol:
198
290
  signature: str
199
291
 
200
292
 
201
- @dataclass(frozen=True)
293
+ @dataclass(frozen=True, slots=True)
294
+ class _NavigationTask:
295
+ ordinal: int
296
+ source_path: str
297
+ display_path: str
298
+ unit_name: str
299
+ unit_path: str
300
+ unit_id: str
301
+ unit_has_error: bool
302
+ defines: tuple[str, ...]
303
+ include_paths: tuple[str, ...]
304
+
305
+
306
+ @dataclass(frozen=True, slots=True)
307
+ class _NavigationResult:
308
+ ordinal: int
309
+ source_path: str
310
+ text: str
311
+ model: None
312
+ lines_processed: int
313
+ symbols_discovered: int
314
+ read_error: str
315
+ raw_symbols: tuple[_RawSymbol, ...]
316
+
317
+
318
+ def _parse_navigation_task(task: _NavigationTask) -> _NavigationResult:
319
+ source_path = Path(task.source_path)
320
+ try:
321
+ text = read_source_text(source_path)
322
+ except (OSError, UnicodeError) as error:
323
+ return _NavigationResult(
324
+ task.ordinal,
325
+ task.source_path,
326
+ "",
327
+ None,
328
+ 0,
329
+ 0,
330
+ str(error),
331
+ (),
332
+ )
333
+ try:
334
+ model = build_outline_semantic_model(
335
+ text,
336
+ task.source_path,
337
+ defines=task.defines,
338
+ )
339
+ document = _SourceDocument(
340
+ source_path,
341
+ task.display_path,
342
+ text,
343
+ defines=task.defines,
344
+ include_paths=task.include_paths,
345
+ )
346
+ unit = AgentUnit(
347
+ unit_id=task.unit_id,
348
+ name=task.unit_name,
349
+ path=task.unit_path,
350
+ has_error=task.unit_has_error,
351
+ )
352
+ symbols = _collect_raw_symbols(model.unit_scope, unit, source_path, document)
353
+ raw_symbols = _detach_raw_symbols(
354
+ _exclude_routine_locals(symbols, document),
355
+ task.unit_name,
356
+ )
357
+ except Exception as error:
358
+ raise ParallelOutlineError(
359
+ f"failed to build navigation shard for {task.source_path}: {error}"
360
+ ) from error
361
+ return _NavigationResult(
362
+ task.ordinal,
363
+ task.source_path,
364
+ "",
365
+ None,
366
+ text.count("\n") + (0 if not text or text.endswith(("\n", "\r")) else 1),
367
+ len(raw_symbols),
368
+ "",
369
+ raw_symbols,
370
+ )
371
+
372
+
373
+ def _detach_raw_symbols(
374
+ raw_symbols: list[_RawSymbol],
375
+ unit_name: str,
376
+ ) -> tuple[_RawSymbol, ...]:
377
+ detached_scope = Scope(kind=ScopeKind.UNIT, name=unit_name)
378
+ detached: list[_RawSymbol] = []
379
+ for raw in raw_symbols:
380
+ symbol = raw.symbol
381
+ flat_symbol = Symbol(
382
+ name=symbol.name,
383
+ kind=symbol.kind,
384
+ decl_range=symbol.decl_range,
385
+ name_range=symbol.name_range,
386
+ scope=detached_scope,
387
+ visibility=symbol.visibility,
388
+ type_ref=NamedTypeRef(symbol.type_ref.display_name()),
389
+ modifiers=set(symbol.modifiers),
390
+ attributes=dict(symbol.attributes),
391
+ doc=symbol.doc,
392
+ base_types=tuple(
393
+ NamedTypeRef(base_type.display_name())
394
+ for base_type in symbol.base_types
395
+ ),
396
+ )
397
+ detached.append(replace(raw, symbol=flat_symbol))
398
+ return tuple(detached)
399
+
400
+
401
+ @dataclass(frozen=True, slots=True)
202
402
  class _SymbolEntry:
203
403
  symbol: Symbol
204
404
  source_path: Path
@@ -228,14 +428,15 @@ class _SymbolEntry:
228
428
  }
229
429
 
230
430
 
231
- @dataclass(frozen=True)
431
+ @dataclass(frozen=True, slots=True)
232
432
  class _Registry:
233
433
  project_id: str
234
434
  revision: str
235
435
  entries: tuple[_SymbolEntry, ...]
236
436
  by_target: dict[str, _SymbolEntry]
237
- sources: dict[Path, _SourceDocument]
238
- ranked_queries: dict[str, tuple[_SymbolEntry, ...]]
437
+ sources: _SourceStore
438
+ ranked_queries: OrderedDict[str, tuple[_SymbolEntry, ...]]
439
+ static_retained_bytes: int
239
440
 
240
441
 
241
442
  class AgentContext:
@@ -245,10 +446,16 @@ class AgentContext:
245
446
  *,
246
447
  workers: int = 0,
247
448
  worker_memory_budget_bytes: int | None = None,
449
+ revision_check_interval_seconds: float = 0.0,
248
450
  ) -> None:
249
451
  self._workspace = workspace
250
452
  self._workers = workers
251
453
  self._worker_memory_budget_bytes = worker_memory_budget_bytes
454
+ self._revision_check_interval_seconds = max(
455
+ 0.0,
456
+ revision_check_interval_seconds,
457
+ )
458
+ self._last_revision_check_at = 0.0
252
459
  self._parallel_stats = ParallelBuildStats(0, 0, 0, 0.0, 0)
253
460
  project_id = workspace.active_project_id
254
461
  self._focus = Focus(project_id=project_id) if project_id else Focus()
@@ -266,11 +473,13 @@ class AgentContext:
266
473
  *,
267
474
  workers: int = 0,
268
475
  worker_memory_budget_bytes: int | None = None,
476
+ revision_check_interval_seconds: float = 0.0,
269
477
  ) -> AgentContext:
270
478
  return cls(
271
479
  AgentWorkspace.open(root, project_file=project_file),
272
480
  workers=workers,
273
481
  worker_memory_budget_bytes=worker_memory_budget_bytes,
482
+ revision_check_interval_seconds=revision_check_interval_seconds,
274
483
  )
275
484
 
276
485
  @property
@@ -293,16 +502,46 @@ class AgentContext:
293
502
  self._metrics,
294
503
  )
295
504
 
505
+ @property
506
+ def estimated_cache_bytes(self) -> int:
507
+ retained = self._workspace.estimated_cache_bytes
508
+ if self._registry is not None:
509
+ retained += (
510
+ self._registry.static_retained_bytes
511
+ + self._registry.sources.retained_bytes
512
+ + sum(
513
+ sys.getsizeof(query)
514
+ + sys.getsizeof(ranked)
515
+ + len(ranked) * 8
516
+ for query, ranked in self._registry.ranked_queries.items()
517
+ )
518
+ )
519
+ if self._relation_index is not None:
520
+ retained += max(4096, len(self._registry.entries) * 512) if self._registry else 4096
521
+ if self._metrics is not None:
522
+ retained += 4096 + len(self._metrics.units) * 1024
523
+ return retained
524
+
296
525
  def evict_auxiliary_caches(self) -> None:
297
526
  self._relation_index = None
298
527
  self._metrics = None
299
528
  self._metrics_revision = ""
529
+ if self._registry is not None:
530
+ self._registry.sources.clear_loaded()
300
531
 
301
532
  def evict_navigation_caches(self) -> None:
302
533
  self.evict_auxiliary_caches()
303
534
  self._registry = None
304
535
  self._workspace.evict_recomputable_caches()
305
536
 
537
+ def prewarm_navigation(self) -> str:
538
+ revision = self._refresh_workspace("")
539
+ self._require_registry(revision)
540
+ return revision
541
+
542
+ def invalidate_revision_cache(self) -> None:
543
+ self._last_revision_check_at = 0.0
544
+
306
545
  def handle(self, request: AgentRequest | Mapping[str, object]) -> AgentResponse:
307
546
  parsed = _validated_request(request)
308
547
  revision = self._refresh_workspace(parsed.project_id)
@@ -328,11 +567,12 @@ class AgentContext:
328
567
  return self._handle_focus(parsed, revision)
329
568
  if parsed.action == "find":
330
569
  registry = self._require_registry(revision)
331
- ranked = registry.ranked_queries.get(parsed.query)
570
+ ranked = registry.ranked_queries.pop(parsed.query, None)
332
571
  if ranked is None:
333
572
  ranked = tuple(_ranked_entries(registry.entries, parsed.query))
334
- registry.ranked_queries.clear()
335
- registry.ranked_queries[parsed.query] = ranked
573
+ registry.ranked_queries[parsed.query] = ranked
574
+ while len(registry.ranked_queries) > _RANKED_QUERY_CACHE_SIZE:
575
+ registry.ranked_queries.popitem(last=False)
336
576
  items = [entry.card() for entry in ranked]
337
577
  return self._response(parsed, revision, items)
338
578
  if parsed.action == "inspect":
@@ -345,10 +585,25 @@ class AgentContext:
345
585
  def _refresh_workspace(self, requested_project_id: str) -> str:
346
586
  previous_project_id = self._workspace.active_project_id
347
587
  selected_project_id = requested_project_id or previous_project_id
348
- if selected_project_id:
588
+ now = time.monotonic()
589
+ selection_changed = bool(
590
+ requested_project_id
591
+ and requested_project_id != previous_project_id
592
+ )
593
+ revision_is_fresh = (
594
+ not selection_changed
595
+ and self._revision_check_interval_seconds > 0.0
596
+ and now - self._last_revision_check_at
597
+ < self._revision_check_interval_seconds
598
+ )
599
+ if revision_is_fresh:
600
+ revision = self._last_revision
601
+ elif selected_project_id:
349
602
  revision = self._workspace._select_project_with_revision(selected_project_id)
350
603
  else:
351
604
  revision = self._workspace.workspace_revision
605
+ if not revision_is_fresh:
606
+ self._last_revision_check_at = now
352
607
  current_project_id = self._workspace.active_project_id
353
608
 
354
609
  if current_project_id != previous_project_id:
@@ -731,36 +986,39 @@ def _build_registry(
731
986
  worker_memory_budget_bytes: int | None = None,
732
987
  ) -> tuple[_Registry, ParallelBuildStats]:
733
988
  raw_symbols: list[_RawSymbol] = []
734
- sources: dict[Path, _SourceDocument] = {}
735
989
  units = tuple(workspace.units)
990
+ source_specs = {
991
+ unit_source_path(workspace.root, unit): _SourceSpec(
992
+ unit_source_path(workspace.root, unit),
993
+ unit_display_path(workspace.root, unit),
994
+ workspace.defines,
995
+ workspace.include_paths,
996
+ )
997
+ for unit in units
998
+ }
736
999
 
737
- def consume_result(result: OutlineResult) -> None:
738
- unit = units[result.ordinal]
739
- source_path = unit_source_path(workspace.root, unit)
740
- display_path = unit_display_path(workspace.root, unit)
741
- if result.read_error or result.model is None:
1000
+ def consume_result(result: _NavigationResult) -> None:
1001
+ if result.read_error:
1002
+ unit = units[result.ordinal]
1003
+ display_path = unit_display_path(workspace.root, unit)
742
1004
  raise AgentProtocolError(
743
1005
  "source_unavailable",
744
1006
  f"Could not read selected source {display_path}.",
745
1007
  )
746
- document = _SourceDocument(
747
- source_path,
748
- display_path,
749
- result.text,
750
- defines=workspace.defines,
751
- include_paths=workspace.include_paths,
752
- )
753
- sources[source_path] = document
754
- unit_symbols = _collect_raw_symbols(result.model.unit_scope, unit, source_path, document)
755
- raw_symbols.extend(_exclude_routine_locals(unit_symbols, document))
1008
+ raw_symbols.extend(result.raw_symbols)
756
1009
 
757
1010
  outline_batch = run_outline_tasks(
758
1011
  (
759
- OutlineTask(
1012
+ _NavigationTask(
760
1013
  ordinal,
761
1014
  str(unit_source_path(workspace.root, unit)),
1015
+ unit_display_path(workspace.root, unit),
1016
+ unit.name,
1017
+ unit.path,
1018
+ unit.unit_id,
1019
+ unit.has_error,
762
1020
  workspace.defines,
763
- True,
1021
+ workspace.include_paths,
764
1022
  )
765
1023
  for ordinal, unit in enumerate(units)
766
1024
  ),
@@ -768,6 +1026,7 @@ def _build_registry(
768
1026
  memory_budget_bytes=worker_memory_budget_bytes,
769
1027
  on_complete=consume_result,
770
1028
  retain_results=False,
1029
+ task_runner=_parse_navigation_task,
771
1030
  )
772
1031
 
773
1032
  ordered = sorted(raw_symbols, key=_raw_sort_key)
@@ -859,6 +1118,9 @@ def _build_registry(
859
1118
  )
860
1119
 
861
1120
  entries_tuple = tuple(sorted(with_parents, key=_entry_sort_key))
1121
+ source_cache_bytes = _source_cache_budget(worker_memory_budget_bytes)
1122
+ sources = _SourceStore(source_specs, max_loaded_bytes=source_cache_bytes)
1123
+ static_retained_bytes = _estimate_registry_bytes(entries_tuple, sources)
862
1124
  return (
863
1125
  _Registry(
864
1126
  project_id=project_id,
@@ -866,12 +1128,40 @@ def _build_registry(
866
1128
  entries=entries_tuple,
867
1129
  by_target={entry.target_id: entry for entry in entries_tuple},
868
1130
  sources=sources,
869
- ranked_queries={},
1131
+ ranked_queries=OrderedDict(),
1132
+ static_retained_bytes=static_retained_bytes,
870
1133
  ),
871
1134
  outline_batch.stats,
872
1135
  )
873
1136
 
874
1137
 
1138
+ def _source_cache_budget(total_budget_bytes: int | None) -> int:
1139
+ if total_budget_bytes is None:
1140
+ return 64 * 1024**2
1141
+ return max(0, min(128 * 1024**2, total_budget_bytes // 4))
1142
+
1143
+
1144
+ def _estimate_registry_bytes(
1145
+ entries: tuple[_SymbolEntry, ...],
1146
+ sources: _SourceStore,
1147
+ ) -> int:
1148
+ retained = 4096 + sources.metadata_bytes
1149
+ for entry in entries:
1150
+ retained += (
1151
+ 1536
1152
+ + sys.getsizeof(entry.path)
1153
+ + sys.getsizeof(entry.unit_id)
1154
+ + sys.getsizeof(entry.unit_name)
1155
+ + sys.getsizeof(entry.qualified_name)
1156
+ + sys.getsizeof(entry.owner)
1157
+ + sys.getsizeof(entry.signature)
1158
+ + sys.getsizeof(entry.target_id)
1159
+ + sys.getsizeof(entry.parent_target_id)
1160
+ )
1161
+ retained += len(entries) * 96
1162
+ return retained
1163
+
1164
+
875
1165
  def _stable_path_component(value: str) -> str:
876
1166
  normalized = unicodedata.normalize("NFC", value).replace("\\", "_").replace("/", "_")
877
1167
  return normalized or "unknown"