feat(lsp): add incremental indexing and file ops support

Adds a thread-safe incremental index and snapshot API for LSP.
Introduces cache invalidation and file operation hooks for delete
and rename. This keeps indices in sync with disk changes.
Supports reindexing TCL files from disk when needed.

- Adds workspace file change handlers to sync indices on delete/rename.
- Introduces locking and snapshot helpers to safely access shared state.
- Refactors to invalidate caches on edits and reindex TCL files.
This commit is contained in:
Christoph Brandau
2026-08-17 08:45:09 +02:00
parent a39aee1b9d
commit f5bd79f067
5 changed files with 555 additions and 151 deletions
@@ -0,0 +1,214 @@
import sys
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
from lsp_tclserver import TclLanguageServer
def _server() -> TclLanguageServer:
return TclLanguageServer(name="stability-test", version="1", max_workers=4)
def _document(path: Path, source: str, version: int = 1) -> TextDocument:
return TextDocument(
uri=path.as_uri(),
source=source,
version=version,
language_id="tcl",
)
def test_duplicate_proc_stays_indexed_when_other_file_is_removed(tmp_path: Path):
server = _server()
first = _document(
tmp_path / "first.tcl", "proc shared {first} { return $first }"
)
second = _document(
tmp_path / "second.tcl", "proc shared {second} { return $second }"
)
assert server.update_poco_completion_for_file(first)
assert server.update_poco_completion_for_file(second)
_, signatures, _ = server.index_snapshot()
assert "shared" in signatures[first.path]
assert "shared" in signatures[second.path]
server.diagnostics[first.uri] = (first.version, [])
server.remove_file_state(first.uri)
completions, signatures, docs = server.index_snapshot()
assert first.path not in completions
assert first.path not in signatures
assert first.path not in docs
assert "shared" in signatures[second.path]
assert server.diagnostic_snapshot(first.uri) is None
def test_close_replaces_unsaved_index_with_saved_file(tmp_path: Path, monkeypatch):
path = tmp_path / "close.tcl"
path.write_text("proc saved_proc {} { return }", encoding="utf-8")
document = _document(path, "proc unsaved_proc {} { return }")
server = _server()
server.update_poco_completion_for_file(document)
server.get_tree(document)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
lsp_server.did_close(
lsp.DidCloseTextDocumentParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri)
)
)
_, signatures, _ = server.index_snapshot()
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)
def test_delete_and_rename_notifications_update_index(tmp_path: Path, monkeypatch):
server = _server()
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
deleted_path = tmp_path / "deleted.tcl"
deleted = _document(deleted_path, "proc deleted_proc {} { return }")
server.update_poco_completion_for_file(deleted)
lsp_server.did_delete_files(
lsp.DeleteFilesParams(files=[lsp.FileDelete(uri=deleted.uri)])
)
_, signatures, _ = server.index_snapshot()
assert deleted.path not in signatures
old_path = tmp_path / "old.tcl"
new_path = tmp_path / "new.tcl"
old_path.write_text("proc renamed_proc {} { return }", encoding="utf-8")
old_document = _document(old_path, old_path.read_text(encoding="utf-8"))
server.update_poco_completion_for_file(old_document)
old_path.rename(new_path)
lsp_server.did_rename_files(
lsp.RenameFilesParams(
files=[
lsp.FileRename(old_uri=old_path.as_uri(), new_uri=new_path.as_uri())
]
)
)
_, signatures, _ = server.index_snapshot()
assert old_document.path not in signatures
renamed_signatures = next(
value
for indexed_path, value in signatures.items()
if server.paths_equal(indexed_path, new_path)
)
assert "renamed_proc" in renamed_signatures
def test_parallel_file_indexing_keeps_every_file(tmp_path: Path):
server = _server()
documents = [
_document(
tmp_path / f"parallel_{index}.tcl",
f"proc parallel_{index} {{value}} {{ return $value }}",
)
for index in range(20)
]
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(
executor.map(
lambda document: server.update_poco_completion_for_file(
document, cache_tree=False
),
documents,
)
)
assert all(results)
completions, signatures, _ = server.index_snapshot()
assert len(completions) == len(documents)
for index, document in enumerate(documents):
assert f"parallel_{index}" in signatures[document.path]
def test_disk_index_cannot_overwrite_newer_open_document(tmp_path: Path):
server = _server()
path = tmp_path / "versioned.tcl"
open_document = _document(path, "proc current_proc {} { return }", version=5)
disk_document = TextDocument(
uri=path.as_uri(),
source="proc stale_proc {} { return }",
version=None,
language_id="tcl",
)
assert server.update_poco_completion_for_file(open_document)
assert not server.update_poco_completion_for_file(
disk_document, cache_tree=False
)
_, signatures, _ = server.index_snapshot()
assert "current_proc" in signatures[open_document.path]
assert "stale_proc" not in signatures[open_document.path]
def test_repeated_lint_does_not_mutate_cached_violations(tmp_path: Path):
server = _server()
document = _document(
tmp_path / "lint.tcl",
"proc invalid {{optional 1} required} { return }",
)
first = server.linter(document)
second = server.linter(document)
assert len(first) == 1
assert len(second) == 1
assert second[0].message == first[0].message
def test_diagnostics_keep_latest_document_version(tmp_path: Path):
server = _server()
path = tmp_path / "diagnostics.tcl"
first = _document(path, "set value 1", version=1)
latest = _document(path, "set value 2", version=2)
server.compute_diagnostics(first)
server.clear_cache_for_uri(first.uri)
server.compute_diagnostics(latest)
server.compute_diagnostics(first)
version, _ = server.diagnostic_snapshot(first.uri)
assert version == 2
def test_delete_invalidates_in_flight_diagnostics(tmp_path: Path, monkeypatch):
server = _server()
document = _document(tmp_path / "in_flight.tcl", "set value 1")
started = Event()
release = Event()
def slow_diagnostics(_document):
started.set()
assert release.wait(timeout=5)
return []
monkeypatch.setattr(server, "_compute_diagnostics", slow_diagnostics)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(server.compute_diagnostics, document)
assert started.wait(timeout=5)
server.remove_file_state(document.uri)
release.set()
future.result(timeout=5)
assert server.diagnostic_snapshot(document.uri) is None