perf(server): avoid unnecessary reparses and cache navigation definitions

Introduce several changes to reduce full-document reparses, lock contention and
redundant work when handling TclOO analysis and navigation:

- Add a cheap may_contain_classes pre-check and several cursor/receiver
  heuristics so completions, signature help and tcloo definitions skip the
  expensive marker reparse when the document cannot contain useful OO info.
- Allow passing an existing parsed tree into tcloo completion/signature/definition
  helpers; update callers to use the server's cached tree when available.
- Use a thread-local parser for request-time parse_source to avoid blocking the
  shared parser during background indexing, and add navigation_state() which
  returns cached definition identities (invalidated on index generation changes).
- Add a cheap name pre-filter (_may_resolve_to) for symbol matching and only
  run class highlighting when classes may exist.

These changes reduce contention and repeated parsing, improve responsiveness for
requests during background indexing, and cache navigation definition identities.
Tests were added/updated to assert caching and non-blocking behavior.
This commit is contained in:
Christoph Brandau
2026-09-23 12:04:36 +02:00
parent d9d619c1dc
commit b2e6e9d250
10 changed files with 276 additions and 40 deletions
+38 -5
View File
@@ -20,7 +20,12 @@ from tools.tcloo_completion import indexed_classes
from tools.file_sourcing import get_all_psc_files, psc_script_files
from tools.formatter import NxFormatter as Formatter
from tools.inlay_hint import InlayHintSignature, build_custom_inlay_signatures
from tools.navigation import FileSymbolIndex, build_file_symbol_index
from tools.navigation import (
FileSymbolIndex,
SymbolIdentity,
build_file_symbol_index,
definition_identities,
)
from tools.proc_docs import build_proc_docs
from tools.variable_index import ProcRange, build_variable_index
@@ -54,6 +59,7 @@ class TclLanguageServer(LanguageServer):
self._ast_cache = {}
self._line_cache: dict[tuple[str, int | None], tuple[str, ...]] = {}
self._parser_lock = threading.RLock()
self._thread_parsers = threading.local()
self._index_lock = threading.RLock()
self._index_tokens: dict[str, int] = {}
self._index_versions: dict[str, int | None] = {}
@@ -67,6 +73,9 @@ class TclLanguageServer(LanguageServer):
-1,
frozenset(),
)
self._definition_identities_cache: tuple[
int, frozenset[SymbolIdentity]
] = (-1, frozenset())
self._proc_metadata_cache: dict[
str, tuple[int, dict[str, list[str]], dict[str, str]]
] = {}
@@ -84,10 +93,17 @@ class TclLanguageServer(LanguageServer):
return tree, list(self.parser.violations)
def parse_source(self, source: str):
"""Parse without retaining an AST, serialized around the shared parser."""
with self._parser_lock:
tree, _ = self._parse_source(source)
return tree
"""Parse without retaining an AST, on a parser owned by this thread.
Background indexing must not hold the shared parser lock for whole
files while request handlers wait for their document's tree.
"""
local_parser = getattr(self._thread_parsers, "parser", None)
if local_parser is None:
# Plugin commands live in tclint's shared registry, see __init__.
local_parser = self._thread_parsers.parser = parser.CustomParser()
local_parser.violations = []
return local_parser.parse(source)
def get_tree(self, document: TextDocument):
key = (document.uri, document.version)
@@ -154,6 +170,7 @@ class TclLanguageServer(LanguageServer):
self._index_generation += 1
self._workspace_completion_cache = (-1, ())
self._custom_function_names_cache = (-1, frozenset())
self._definition_identities_cache = (-1, frozenset())
self._proc_metadata_cache.clear()
self._custom_inlay_cache.clear()
@@ -505,6 +522,22 @@ class TclLanguageServer(LanguageServer):
with self._index_lock:
return dict(self.navigation_indexes)
def navigation_state(
self,
) -> tuple[dict[str, FileSymbolIndex], frozenset[SymbolIdentity]]:
"""Return indexes plus their definitions, cached by index generation."""
with self._index_lock:
generation, definitions = self._definition_identities_cache
if generation != self._index_generation:
definitions = frozenset(
definition_identities(self.navigation_indexes)
)
self._definition_identities_cache = (
self._index_generation,
definitions,
)
return dict(self.navigation_indexes), definitions
def _begin_index_update(self, filepath: str, version: int | None) -> int | None:
with self._index_lock:
indexed_version = self._index_versions.get(filepath)