refactor(lsp): cache analysis results and debounce diagnostics
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:
Christoph Brandau
2026-08-19 13:41:53 +02:00
parent ecb50be2b8
commit af195a577b
11 changed files with 616 additions and 181 deletions
@@ -3,17 +3,15 @@ from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Event
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import lsprotocol.types as lsp # type: ignore
from pygls.workspace.text_document import TextDocument
import lsp_server
import lsprotocol.types as lsp # type: ignore
from lsp_tclserver import TclLanguageServer
from pygls.workspace.text_document import TextDocument
def _server() -> TclLanguageServer:
@@ -63,6 +61,7 @@ def test_close_replaces_unsaved_index_with_saved_file(tmp_path: Path, monkeypatc
server = _server()
server.update_poco_completion_for_file(document)
server.get_tree(document)
server.get_lines(document)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
lsp_server.did_close(
@@ -75,6 +74,7 @@ def test_close_replaces_unsaved_index_with_saved_file(tmp_path: Path, monkeypatc
assert "unsaved_proc" not in signatures[document.path]
assert "saved_proc" in signatures[document.path]
assert all(key[0] != document.uri for key in server._ast_cache)
assert all(key[0] != document.uri for key in server._line_cache)
def test_delete_and_rename_notifications_update_index(tmp_path: Path, monkeypatch):
@@ -218,3 +218,52 @@ def test_delete_invalidates_in_flight_diagnostics(tmp_path: Path, monkeypatch):
future.result(timeout=5)
assert server.diagnostic_snapshot(document.uri) is None
def test_rapid_changes_analyze_only_latest_snapshot(tmp_path: Path, monkeypatch):
server = _server()
path = tmp_path / "debounced.tcl"
first = _document(path, "set value 1", version=1)
latest = _document(path, "set value 2", version=2)
calls = []
completed = Event()
monkeypatch.setattr(
server,
"compute_diagnostics",
lambda document: calls.append(("diagnostics", document.version)),
)
def record_index(document):
calls.append(("index", document.version))
completed.set()
return True
monkeypatch.setattr(server, "update_poco_completion_for_file", record_index)
server.schedule_document_analysis(first, delay_seconds=0.05)
server.schedule_document_analysis(latest, delay_seconds=0.05)
assert completed.wait(timeout=2)
assert calls == [("diagnostics", 2), ("index", 2)]
def test_variable_and_workspace_request_caches_are_reused(tmp_path: Path):
server = _server()
document = _document(
tmp_path / "cached.tcl",
"proc cached_proc {argument} { set local_value $argument }",
)
assert server.update_poco_completion_for_file(document)
first_variables = server.variable_index_for_document(document)
second_variables = server.variable_index_for_document(document)
first_completions = server.completion_items_snapshot()
second_completions = server.completion_items_snapshot()
first_names = server.custom_function_names_snapshot()
second_names = server.custom_function_names_snapshot()
assert first_variables is second_variables
assert first_completions is second_completions
assert first_names is second_names
assert "cached_proc" in first_names
+18 -3
View File
@@ -7,7 +7,6 @@ if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import lsprotocol.types as lsp # type: ignore
from tools.inlay_hint import (
InlayHintGenerator,
InlayHintParameter,
@@ -40,8 +39,7 @@ def _generate(
) -> list[lsp.InlayHint]:
tree = CustomParser().parse(source)
generator = InlayHintGenerator(source, signatures, **options)
tree.accept(generator, recurse=True)
return generator.hints
return generator.generate(tree)
def _labels(hints: list[lsp.InlayHint]) -> list[str]:
@@ -81,6 +79,23 @@ def test_only_hints_inside_requested_range_are_returned():
assert hints[0].position.line == 2
def test_range_walk_keeps_nested_commands_inside_proc_body():
source = "proc wrapper {} {\n test_proc nested\n}"
requested_range = lsp.Range(
start=lsp.Position(line=1, character=0),
end=lsp.Position(line=2, character=0),
)
hints = _generate(
source,
{"test_proc": _signature("value")},
requested_range=requested_range,
)
assert _labels(hints) == ["value:"]
assert hints[0].position.line == 1
def test_matching_variable_name_can_be_suppressed():
source = "test_proc $value $other"
signature = _signature("value", "result")