refactor(lsp): cache analysis results and debounce diagnostics
build_and_puplish.yml / build_and_publish (release) Successful in 29s
build_and_puplish.yml / build_and_publish (release) Successful in 29s
This change adds cached and incremental analysis for the LSP server to improve responsiveness. The client now debounces diagnostic updates to avoid excessive recomputation. The server introduces per-document line caches and various caches for completions, inlay hints, and metadata to support faster, incremental updates. - Debounce diagnostics on text changes to reduce noise. - Add caches for completions, inlay hints, and metadata. - Introduce incremental analysis with per-document line caches.
This commit is contained in:
+320
-5
@@ -11,15 +11,16 @@ from pygls.workspace.text_document import TextDocument
|
||||
from tclint.format import FormatterOpts
|
||||
from tclint.lexer import TclSyntaxError
|
||||
from tclint.violations import Violation
|
||||
|
||||
from tools import checks, parser
|
||||
from tools.completion_items import CompletionCollector
|
||||
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.proc_docs import build_proc_docs
|
||||
|
||||
from tools.variable_index import ProcRange, build_variable_index
|
||||
|
||||
DIAGNOSTIC_SOURCE = "nx-post-support"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TclLanguageServer(server.LanguageServer):
|
||||
@@ -33,15 +34,40 @@ class TclLanguageServer(server.LanguageServer):
|
||||
self.proc_signatures: dict = {}
|
||||
self.proc_docs: dict = {}
|
||||
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
|
||||
self.variable_indexes: dict[
|
||||
str,
|
||||
tuple[
|
||||
int | None,
|
||||
tuple[set[str], dict[str, set[str]], list[ProcRange]],
|
||||
],
|
||||
] = {}
|
||||
# Cache: (uri, version) -> (tree, violations)
|
||||
self._ast_cache = {}
|
||||
self._line_cache: dict[tuple[str, int | None], tuple[str, ...]] = {}
|
||||
self._parser_lock = threading.RLock()
|
||||
self._index_lock = threading.RLock()
|
||||
self._index_tokens: dict[str, int] = {}
|
||||
self._index_versions: dict[str, int | None] = {}
|
||||
self._committed_index_versions: dict[str, int | None] = {}
|
||||
self._next_index_token = 0
|
||||
self._diagnostic_tokens: dict[str, int] = {}
|
||||
self._next_diagnostic_token = 0
|
||||
self._index_generation = 0
|
||||
self._workspace_completion_cache: tuple[int, tuple] = (-1, ())
|
||||
self._custom_function_names_cache: tuple[int, frozenset[str]] = (
|
||||
-1,
|
||||
frozenset(),
|
||||
)
|
||||
self._proc_metadata_cache: dict[
|
||||
str, tuple[int, dict[str, list[str]], dict[str, str]]
|
||||
] = {}
|
||||
self._custom_inlay_cache: dict[
|
||||
str, tuple[int, dict[str, InlayHintSignature]]
|
||||
] = {}
|
||||
self._analysis_lock = threading.RLock()
|
||||
self._analysis_timers: dict[str, threading.Timer] = {}
|
||||
self._analysis_tokens: dict[str, int] = {}
|
||||
self._next_analysis_token = 0
|
||||
|
||||
def _parse_source(self, source: str):
|
||||
self.parser.violations = []
|
||||
@@ -74,11 +100,24 @@ class TclLanguageServer(server.LanguageServer):
|
||||
self._ast_cache[key] = (tree, violations)
|
||||
return tree, violations
|
||||
|
||||
def get_lines(self, document: TextDocument) -> tuple[str, ...]:
|
||||
"""Return split source lines once per document version."""
|
||||
key = (document.uri, document.version)
|
||||
with self._parser_lock:
|
||||
lines = self._line_cache.get(key)
|
||||
if lines is None:
|
||||
lines = tuple(document.source.splitlines())
|
||||
self._line_cache[key] = lines
|
||||
return lines
|
||||
|
||||
def clear_cache_for_uri(self, uri: str):
|
||||
with self._parser_lock:
|
||||
to_delete = [key for key in self._ast_cache if key[0] == uri]
|
||||
for key in to_delete:
|
||||
del self._ast_cache[key]
|
||||
for key in list(self._line_cache):
|
||||
if key[0] == uri:
|
||||
del self._line_cache[key]
|
||||
|
||||
@staticmethod
|
||||
def _normalized_path(path: pathlib.Path | str) -> str:
|
||||
@@ -101,6 +140,263 @@ class TclLanguageServer(server.LanguageServer):
|
||||
) -> bool:
|
||||
return cls._normalized_path(first) == cls._normalized_path(second)
|
||||
|
||||
def _invalidate_workspace_caches_locked(self) -> None:
|
||||
"""Invalidate request-level aggregates after an index mutation."""
|
||||
self._index_generation += 1
|
||||
self._workspace_completion_cache = (-1, ())
|
||||
self._custom_function_names_cache = (-1, frozenset())
|
||||
self._proc_metadata_cache.clear()
|
||||
self._custom_inlay_cache.clear()
|
||||
|
||||
def completion_items_snapshot(self) -> tuple:
|
||||
"""Return de-duplicated workspace completion items, cached by generation."""
|
||||
with self._index_lock:
|
||||
generation, items = self._workspace_completion_cache
|
||||
if generation == self._index_generation:
|
||||
return items
|
||||
|
||||
merged = []
|
||||
seen = set()
|
||||
for path_items in self.poco_completion.values():
|
||||
for item in path_items:
|
||||
key = (item.label, getattr(item, "kind", None))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(item)
|
||||
|
||||
items = tuple(merged)
|
||||
self._workspace_completion_cache = (self._index_generation, items)
|
||||
return items
|
||||
|
||||
def custom_function_names_snapshot(self) -> frozenset[str]:
|
||||
"""Return custom completion labels for semantic highlighting."""
|
||||
with self._index_lock:
|
||||
generation, names = self._custom_function_names_cache
|
||||
if generation == self._index_generation:
|
||||
return names
|
||||
|
||||
names = frozenset(
|
||||
item.label
|
||||
for path_items in self.poco_completion.values()
|
||||
for item in path_items
|
||||
)
|
||||
self._custom_function_names_cache = (self._index_generation, names)
|
||||
return names
|
||||
|
||||
def proc_metadata_snapshot(
|
||||
self, current_path: pathlib.Path | str
|
||||
) -> tuple[dict[str, list[str]], dict[str, str]]:
|
||||
"""Return merged proc metadata, preferring declarations in the active file."""
|
||||
normalized_current = self._normalized_path(current_path)
|
||||
with self._index_lock:
|
||||
cached = self._proc_metadata_cache.get(normalized_current)
|
||||
if cached is not None and cached[0] == self._index_generation:
|
||||
return cached[1], cached[2]
|
||||
|
||||
signatures: dict[str, list[str]] = {}
|
||||
docs: dict[str, str] = {}
|
||||
signature_paths = sorted(
|
||||
self.proc_signatures,
|
||||
key=lambda path: self._normalized_path(path).casefold(),
|
||||
)
|
||||
doc_paths = sorted(
|
||||
self.proc_docs,
|
||||
key=lambda path: self._normalized_path(path).casefold(),
|
||||
)
|
||||
|
||||
for path in signature_paths:
|
||||
if self._normalized_path(path) != normalized_current:
|
||||
signatures.update(self.proc_signatures[path])
|
||||
for path in signature_paths:
|
||||
if self._normalized_path(path) == normalized_current:
|
||||
signatures.update(self.proc_signatures[path])
|
||||
|
||||
for path in doc_paths:
|
||||
if self._normalized_path(path) != normalized_current:
|
||||
docs.update(self.proc_docs[path])
|
||||
for path in doc_paths:
|
||||
if self._normalized_path(path) == normalized_current:
|
||||
docs.update(self.proc_docs[path])
|
||||
|
||||
cached_value = (self._index_generation, signatures, docs)
|
||||
self._proc_metadata_cache[normalized_current] = cached_value
|
||||
return signatures, docs
|
||||
|
||||
def proc_documentation(
|
||||
self, name: str, current_path: pathlib.Path | str
|
||||
) -> str | None:
|
||||
_, docs = self.proc_metadata_snapshot(current_path)
|
||||
return docs.get(name)
|
||||
|
||||
def custom_inlay_signatures_snapshot(
|
||||
self, current_path: pathlib.Path | str
|
||||
) -> dict[str, InlayHintSignature]:
|
||||
"""Return custom inlay signatures cached until the workspace index changes."""
|
||||
normalized_current = self._normalized_path(current_path)
|
||||
with self._index_lock:
|
||||
cached = self._custom_inlay_cache.get(normalized_current)
|
||||
if cached is not None and cached[0] == self._index_generation:
|
||||
return cached[1]
|
||||
|
||||
signatures = build_custom_inlay_signatures(
|
||||
self.proc_signatures,
|
||||
self.proc_docs,
|
||||
self.navigation_indexes,
|
||||
os.fspath(current_path),
|
||||
)
|
||||
self._custom_inlay_cache[normalized_current] = (
|
||||
self._index_generation,
|
||||
signatures,
|
||||
)
|
||||
return signatures
|
||||
|
||||
def variable_index_for_document(
|
||||
self, document: TextDocument, tree=None
|
||||
) -> tuple[set[str], dict[str, set[str]], list[ProcRange]]:
|
||||
"""Return the per-version variable index used by completion requests."""
|
||||
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||
with self._index_lock:
|
||||
cached = self.variable_indexes.get(filepath)
|
||||
if cached is not None and cached[0] == document.version:
|
||||
return cached[1]
|
||||
|
||||
if tree is None:
|
||||
tree = self.get_tree(document)
|
||||
variable_index = build_variable_index(document.source, tree)
|
||||
|
||||
with self._index_lock:
|
||||
cached = self.variable_indexes.get(filepath)
|
||||
if (
|
||||
cached is None
|
||||
or cached[0] is None
|
||||
or document.version is None
|
||||
or cached[0] <= document.version
|
||||
):
|
||||
self.variable_indexes[filepath] = (document.version, variable_index)
|
||||
return variable_index
|
||||
return cached[1]
|
||||
|
||||
def index_is_current(self, document: TextDocument) -> bool:
|
||||
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||
with self._index_lock:
|
||||
return (
|
||||
filepath in self._committed_index_versions
|
||||
and self._committed_index_versions[filepath] == document.version
|
||||
)
|
||||
|
||||
def index_update_pending(self, filepath: pathlib.Path | str) -> bool:
|
||||
filepath = os.fspath(filepath)
|
||||
with self._index_lock:
|
||||
return (
|
||||
filepath not in self._committed_index_versions
|
||||
or self._index_versions.get(filepath)
|
||||
!= self._committed_index_versions[filepath]
|
||||
)
|
||||
|
||||
def _cancel_document_analysis(self, uri: str) -> None:
|
||||
with self._analysis_lock:
|
||||
timer = self._analysis_timers.pop(uri, None)
|
||||
self._analysis_tokens.pop(uri, None)
|
||||
if timer is not None:
|
||||
timer.cancel()
|
||||
|
||||
def cancel_analysis_under_uri(self, uri: str) -> None:
|
||||
"""Cancel delayed analysis for a closed/deleted file or folder."""
|
||||
try:
|
||||
target = pathlib.Path(uris.to_fs_path(uri))
|
||||
except (TypeError, ValueError):
|
||||
self._cancel_document_analysis(uri)
|
||||
return
|
||||
|
||||
with self._analysis_lock:
|
||||
matching_uris = []
|
||||
for pending_uri in self._analysis_timers:
|
||||
try:
|
||||
pending_path = pathlib.Path(uris.to_fs_path(pending_uri))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if self._is_same_or_child(pending_path, target):
|
||||
matching_uris.append(pending_uri)
|
||||
|
||||
timers = [self._analysis_timers.pop(key) for key in matching_uris]
|
||||
for key in matching_uris:
|
||||
self._analysis_tokens.pop(key, None)
|
||||
|
||||
for timer in timers:
|
||||
timer.cancel()
|
||||
|
||||
def _invalidate_document_work(self, document: TextDocument) -> None:
|
||||
"""Prevent older diagnostic/index work from committing after a new edit."""
|
||||
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||
with self._index_lock:
|
||||
self._next_diagnostic_token += 1
|
||||
self._diagnostic_tokens[document.uri] = self._next_diagnostic_token
|
||||
self._next_index_token += 1
|
||||
self._index_tokens[filepath] = self._next_index_token
|
||||
self._index_versions[filepath] = document.version
|
||||
|
||||
def schedule_document_analysis(
|
||||
self, document: TextDocument, delay_seconds: float = 0.15
|
||||
) -> None:
|
||||
"""Coalesce rapid edits and analyze only the latest immutable snapshot."""
|
||||
snapshot = TextDocument(
|
||||
uri=document.uri,
|
||||
source=document.source,
|
||||
version=document.version,
|
||||
language_id=document.language_id,
|
||||
)
|
||||
self._invalidate_document_work(snapshot)
|
||||
|
||||
with self._analysis_lock:
|
||||
previous = self._analysis_timers.pop(snapshot.uri, None)
|
||||
if previous is not None:
|
||||
previous.cancel()
|
||||
|
||||
self._next_analysis_token += 1
|
||||
token = self._next_analysis_token
|
||||
self._analysis_tokens[snapshot.uri] = token
|
||||
|
||||
def analyze() -> None:
|
||||
with self._analysis_lock:
|
||||
if self._analysis_tokens.get(snapshot.uri) != token:
|
||||
return
|
||||
|
||||
try:
|
||||
diagnostic_state = self.diagnostic_snapshot(snapshot.uri)
|
||||
if (
|
||||
diagnostic_state is None
|
||||
or diagnostic_state[0] != snapshot.version
|
||||
):
|
||||
self.compute_diagnostics(snapshot)
|
||||
with self._analysis_lock:
|
||||
if self._analysis_tokens.get(snapshot.uri) != token:
|
||||
return
|
||||
if not self.index_is_current(snapshot):
|
||||
self.update_poco_completion_for_file(snapshot)
|
||||
except Exception:
|
||||
LOGGER.exception("Delayed analysis failed for %s", snapshot.uri)
|
||||
finally:
|
||||
with self._analysis_lock:
|
||||
if self._analysis_tokens.get(snapshot.uri) == token:
|
||||
self._analysis_tokens.pop(snapshot.uri, None)
|
||||
self._analysis_timers.pop(snapshot.uri, None)
|
||||
|
||||
timer = threading.Timer(delay_seconds, analyze)
|
||||
timer.daemon = True
|
||||
self._analysis_timers[snapshot.uri] = timer
|
||||
timer.start()
|
||||
|
||||
def analyze_document_now(self, document: TextDocument) -> None:
|
||||
"""Cancel delayed work and synchronously analyze the current document."""
|
||||
self._cancel_document_analysis(document.uri)
|
||||
self._invalidate_document_work(document)
|
||||
diagnostic_state = self.diagnostic_snapshot(document.uri)
|
||||
if diagnostic_state is None or diagnostic_state[0] != document.version:
|
||||
self.compute_diagnostics(document)
|
||||
if not self.index_is_current(document):
|
||||
self.update_poco_completion_for_file(document)
|
||||
|
||||
def index_snapshot(self) -> tuple[dict, dict, dict]:
|
||||
"""Return stable copies for request handlers running beside the indexer."""
|
||||
with self._index_lock:
|
||||
@@ -148,6 +444,9 @@ class TclLanguageServer(server.LanguageServer):
|
||||
self.proc_signatures.pop(filepath, None)
|
||||
self.proc_docs.pop(filepath, None)
|
||||
self.navigation_indexes.pop(filepath, None)
|
||||
self.variable_indexes.pop(filepath, None)
|
||||
self._committed_index_versions.pop(filepath, None)
|
||||
self._invalidate_workspace_caches_locked()
|
||||
|
||||
def indexed_paths_under_uri(self, uri: str) -> list[pathlib.Path]:
|
||||
target = pathlib.Path(uris.to_fs_path(uri))
|
||||
@@ -156,6 +455,7 @@ class TclLanguageServer(server.LanguageServer):
|
||||
indexed_paths.update(self.proc_signatures)
|
||||
indexed_paths.update(self.proc_docs)
|
||||
indexed_paths.update(self.navigation_indexes)
|
||||
indexed_paths.update(self.variable_indexes)
|
||||
indexed_paths.update(self._index_tokens)
|
||||
return [
|
||||
pathlib.Path(path)
|
||||
@@ -165,20 +465,28 @@ class TclLanguageServer(server.LanguageServer):
|
||||
|
||||
def remove_file_state(self, uri: str) -> None:
|
||||
"""Remove cached and indexed state for a file or a complete folder."""
|
||||
self.cancel_analysis_under_uri(uri)
|
||||
target = pathlib.Path(uris.to_fs_path(uri))
|
||||
|
||||
with self._index_lock:
|
||||
index_changed = False
|
||||
for store in (
|
||||
self.poco_completion,
|
||||
self.proc_signatures,
|
||||
self.proc_docs,
|
||||
self.navigation_indexes,
|
||||
self.variable_indexes,
|
||||
self._index_tokens,
|
||||
self._index_versions,
|
||||
self._committed_index_versions,
|
||||
):
|
||||
for path in list(store):
|
||||
if self._is_same_or_child(path, target):
|
||||
del store[path]
|
||||
index_changed = True
|
||||
|
||||
if index_changed:
|
||||
self._invalidate_workspace_caches_locked()
|
||||
|
||||
diagnostic_uris = set(self.diagnostics)
|
||||
diagnostic_uris.update(self._diagnostic_tokens)
|
||||
@@ -192,13 +500,16 @@ class TclLanguageServer(server.LanguageServer):
|
||||
self._diagnostic_tokens.pop(diagnostic_uri, None)
|
||||
|
||||
with self._parser_lock:
|
||||
for key in list(self._ast_cache):
|
||||
cached_keys = set(self._ast_cache)
|
||||
cached_keys.update(self._line_cache)
|
||||
for key in cached_keys:
|
||||
try:
|
||||
cached_path = pathlib.Path(uris.to_fs_path(key[0]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if self._is_same_or_child(cached_path, target):
|
||||
del self._ast_cache[key]
|
||||
self._ast_cache.pop(key, None)
|
||||
self._line_cache.pop(key, None)
|
||||
|
||||
def update_poco_completion_for_file(
|
||||
self,
|
||||
@@ -225,8 +536,9 @@ class TclLanguageServer(server.LanguageServer):
|
||||
navigation_index = build_file_symbol_index(
|
||||
filepath, document.uri, tree
|
||||
)
|
||||
variable_index = build_variable_index(document.source, tree)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error parsing {filepath}: {e}")
|
||||
LOGGER.debug("Error parsing %s: %s", filepath, e)
|
||||
self._discard_index_update(filepath, token)
|
||||
return False
|
||||
|
||||
@@ -241,6 +553,9 @@ class TclLanguageServer(server.LanguageServer):
|
||||
self.proc_signatures[filepath] = dict(collector.proc_signatures)
|
||||
self.proc_docs[filepath] = docs
|
||||
self.navigation_indexes[filepath] = navigation_index
|
||||
self.variable_indexes[filepath] = (document.version, variable_index)
|
||||
self._committed_index_versions[filepath] = document.version
|
||||
self._invalidate_workspace_caches_locked()
|
||||
return True
|
||||
|
||||
def format(
|
||||
|
||||
Reference in New Issue
Block a user