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:
@@ -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
|
||||
Reference in New Issue
Block a user