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
+2
View File
@@ -1,6 +1,8 @@
## Unreleased ## Unreleased
- Add signature help for custom TCL procedures and built-in NX/MOM procedures - Add signature help for custom TCL procedures and built-in NX/MOM procedures
- Clean stale TCL indexes on close, delete, and rename operations
- Make background parsing and index updates thread-safe
## [0.0.1] ## [0.0.1]
+106 -48
View File
@@ -44,11 +44,11 @@ from pygls import uris, workspace
from common.load_data import standard_items from common.load_data import standard_items
from tools.folding_ranges import build_folding_ranges from tools.folding_ranges import build_folding_ranges
from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
from tools.completion_items import completion, remove_existing_items, remove_shared_keys
from tools.inlay_hint import InlayHintGenerator from tools.inlay_hint import InlayHintGenerator
from tools.signature_help import build_signature_help from tools.signature_help import build_signature_help
from tools.file_sourcing import get_all_psc_files, read_psc_file from tools.file_sourcing import get_all_psc_files, read_psc_file
from lsp_tclserver import TclLanguageServer from lsp_tclserver import TclLanguageServer
from pygls.workspace.text_document import TextDocument
WORKSPACE_SETTINGS = {} WORKSPACE_SETTINGS = {}
@@ -78,6 +78,7 @@ LSP_SERVER = TclLanguageServer(
def did_open(params: lsp.DidOpenTextDocumentParams) -> None: def did_open(params: lsp.DidOpenTextDocumentParams) -> None:
"""LSP handler for textDocument/didOpen request.""" """LSP handler for textDocument/didOpen request."""
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.clear_cache_for_uri(document.uri)
LSP_SERVER.compute_diagnostics(document) LSP_SERVER.compute_diagnostics(document)
# Also update custom completion and proc docs for this file # Also update custom completion and proc docs for this file
LSP_SERVER.update_poco_completion_for_file(document) LSP_SERVER.update_poco_completion_for_file(document)
@@ -90,18 +91,81 @@ def did_save(params: lsp.DidSaveTextDocumentParams) -> None:
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
def did_close(_: lsp.DidCloseTextDocumentParams) -> None: def did_close(params: lsp.DidCloseTextDocumentParams) -> None:
"""LSP handler for textDocument/didClose request.""" """LSP handler for textDocument/didClose request."""
uri = params.text_document.uri
LSP_SERVER.remove_file_state(uri)
_index_tcl_file_from_disk(uri)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CHANGE) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
def did_change(params: lsp.DidChangeTextDocumentParams) -> None: def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
"""LSP handler for textDocument/didChange request""" """LSP handler for textDocument/didChange request"""
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.clear_cache_for_uri(document.uri)
LSP_SERVER.compute_diagnostics(document) LSP_SERVER.compute_diagnostics(document)
LSP_SERVER.update_poco_completion_for_file(document) LSP_SERVER.update_poco_completion_for_file(document)
FILE_OPERATION_OPTIONS = lsp.FileOperationRegistrationOptions(
filters=[
lsp.FileOperationFilter(
scheme="file",
pattern=lsp.FileOperationPattern(glob="**/*"),
)
]
)
def _index_tcl_file_from_disk(uri: str) -> None:
if not uri.startswith("file:"):
return
path = pathlib.Path(uris.to_fs_path(uri))
if path.suffix.lower() != ".tcl" or not path.is_file():
return
try:
document = TextDocument(uri=uri, language_id="tcl")
LSP_SERVER.update_poco_completion_for_file(
document,
cache_tree=False,
require_file_exists=True,
)
except (OSError, UnicodeError) as error:
log_warning(f"Could not re-index {path}: {error}")
@LSP_SERVER.feature(lsp.WORKSPACE_DID_DELETE_FILES, FILE_OPERATION_OPTIONS)
def did_delete_files(params: lsp.DeleteFilesParams) -> None:
for deleted_file in params.files:
LSP_SERVER.remove_file_state(deleted_file.uri)
@LSP_SERVER.feature(lsp.WORKSPACE_DID_RENAME_FILES, FILE_OPERATION_OPTIONS)
def did_rename_files(params: lsp.RenameFilesParams) -> None:
for renamed_file in params.files:
old_path = pathlib.Path(uris.to_fs_path(renamed_file.old_uri))
new_path = pathlib.Path(uris.to_fs_path(renamed_file.new_uri))
indexed_paths = LSP_SERVER.indexed_paths_under_uri(renamed_file.old_uri)
new_index_paths: set[pathlib.Path] = set()
for indexed_path in indexed_paths:
if LSP_SERVER.paths_equal(indexed_path, old_path):
new_index_paths.add(new_path)
else:
relative_path = os.path.relpath(indexed_path, old_path)
new_index_paths.add(new_path / relative_path)
# Also handles renaming a previously unindexed file to a TCL file.
if new_path.suffix.lower() == ".tcl":
new_index_paths.add(new_path)
LSP_SERVER.remove_file_state(renamed_file.old_uri)
for new_index_path in new_index_paths:
_index_tcl_file_from_disk(new_index_path.as_uri())
@LSP_SERVER.feature( @LSP_SERVER.feature(
lsp.TEXT_DOCUMENT_DIAGNOSTIC, lsp.TEXT_DOCUMENT_DIAGNOSTIC,
lsp.DiagnosticOptions( lsp.DiagnosticOptions(
@@ -112,13 +176,18 @@ def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
) )
def document_diagnostic(params: lsp.DocumentDiagnosticParams): def document_diagnostic(params: lsp.DocumentDiagnosticParams):
"""Return diagnostics for the requested document""" """Return diagnostics for the requested document"""
was_cached = True uri = params.text_document.uri
if (uri := params.text_document.uri) not in LSP_SERVER.diagnostics: diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
was_cached = False was_cached = diagnostic_state is not None
if diagnostic_state is None:
doc = LSP_SERVER.workspace.get_text_document(uri) doc = LSP_SERVER.workspace.get_text_document(uri)
LSP_SERVER.compute_diagnostics(doc) LSP_SERVER.compute_diagnostics(doc)
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
version, diagnostics = LSP_SERVER.diagnostics[uri] if diagnostic_state is None:
return lsp.FullDocumentDiagnosticReport(items=[])
version, diagnostics = diagnostic_state
result_id = f"{uri}@{version}" result_id = f"{uri}@{version}"
if was_cached and result_id == params.previous_result_id: if was_cached and result_id == params.previous_result_id:
@@ -135,7 +204,8 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
# Base items # Base items
poco = [item for items in LSP_SERVER.poco_completion.values() for item in items] poco_completion, _, _ = LSP_SERVER.index_snapshot()
poco = [item for items in poco_completion.values() for item in items]
base_items = ( base_items = (
standard_items.tcl_keyword_list standard_items.tcl_keyword_list
+ standard_items.nx_procs + standard_items.nx_procs
@@ -170,14 +240,15 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
) )
break break
# Merge with de-duplication for variables only # Merge with de-duplication. Each file keeps its complete index, so a proc
# declared in multiple files must only appear once in the completion list.
merged: list[lsp.CompletionItem] = [] merged: list[lsp.CompletionItem] = []
seen_var_labels: set[str] = set() seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set()
for it in base_items + dynamic_items: for it in base_items + dynamic_items:
if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable: key = (it.label, getattr(it, "kind", None))
if it.label in seen_var_labels: if key in seen_items:
continue continue
seen_var_labels.add(it.label) seen_items.add(key)
merged.append(it) merged.append(it)
return lsp.CompletionList(is_incomplete=False, items=merged) return lsp.CompletionList(is_incomplete=False, items=merged)
@@ -197,18 +268,19 @@ def signature_help(params: lsp.SignatureHelpParams) -> lsp.SignatureHelp | None:
filepath = str(pathlib.Path(uris.to_fs_path(document.uri))) filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
custom_signatures: dict[str, list[str]] = {} custom_signatures: dict[str, list[str]] = {}
custom_docs: dict[str, str] = {} custom_docs: dict[str, str] = {}
_, proc_signatures, proc_docs = LSP_SERVER.index_snapshot()
# Prefer declarations from the current document if duplicate proc names # Prefer declarations from the current document if duplicate proc names
# exist in the workspace. # exist in the workspace.
for indexed_path, signatures in LSP_SERVER.proc_signatures.items(): for indexed_path, signatures in proc_signatures.items():
if indexed_path != filepath: if indexed_path != filepath:
custom_signatures.update(signatures) custom_signatures.update(signatures)
custom_signatures.update(LSP_SERVER.proc_signatures.get(filepath, {})) custom_signatures.update(proc_signatures.get(filepath, {}))
for indexed_path, docs in LSP_SERVER.proc_docs.items(): for indexed_path, docs in proc_docs.items():
if indexed_path != filepath: if indexed_path != filepath:
custom_docs.update(docs) custom_docs.update(docs)
custom_docs.update(LSP_SERVER.proc_docs.get(filepath, {})) custom_docs.update(proc_docs.get(filepath, {}))
return build_signature_help( return build_signature_help(
document.source, document.source,
@@ -248,7 +320,8 @@ def inlay_hints(params: lsp.InlayHintParams):
# Merge proc signatures across files and traverse once # Merge proc signatures across files and traverse once
merged_signatures = {} merged_signatures = {}
for sigs in LSP_SERVER.proc_signatures.values(): _, proc_signatures, _ = LSP_SERVER.index_snapshot()
for sigs in proc_signatures.values():
merged_signatures.update(sigs) merged_signatures.update(sigs)
generator = InlayHintGenerator(merged_signatures) generator = InlayHintGenerator(merged_signatures)
@@ -268,7 +341,8 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
data = [] data = []
plugins = [] plugins = []
hl = _Highlighter(plugins, LSP_SERVER.poco_completion) poco_completion, _, _ = LSP_SERVER.index_snapshot()
hl = _Highlighter(plugins, poco_completion)
# Reuse cached AST # Reuse cached AST
tree = LSP_SERVER.get_tree(document) tree = LSP_SERVER.get_tree(document)
@@ -360,7 +434,8 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
# 2) Otherwise, check if the token is a custom proc and show its preceding doc block # 2) Otherwise, check if the token is a custom proc and show its preceding doc block
# Build a merged map of proc -> docs gathered during initialization and updates # Build a merged map of proc -> docs gathered during initialization and updates
proc_docs: dict[str, str] = {} proc_docs: dict[str, str] = {}
for file_docs in LSP_SERVER.proc_docs.values(): _, _, indexed_proc_docs = LSP_SERVER.index_snapshot()
for file_docs in indexed_proc_docs.values():
proc_docs.update(file_docs) proc_docs.update(file_docs)
if token in proc_docs: if token in proc_docs:
@@ -420,7 +495,8 @@ def goto_definition(params: lsp.DefinitionParams):
# 2) Search in indexed files from proc_signatures # 2) Search in indexed files from proc_signatures
# Build list of candidate files that declare this token as a proc # Build list of candidate files that declare this token as a proc
candidate_files: list[str] = [] candidate_files: list[str] = []
for file_path, procs in LSP_SERVER.proc_signatures.items(): _, proc_signatures, _ = LSP_SERVER.index_snapshot()
for file_path, procs in proc_signatures.items():
if token in procs: if token in procs:
candidate_files.append(file_path) candidate_files.append(file_path)
@@ -530,42 +606,24 @@ def initialized(_params: lsp.InitializedParams):
for psc_file in psc_files: for psc_file in psc_files:
poco_files = read_psc_file(psc_file) poco_files = read_psc_file(psc_file)
for sourced_layer in poco_files: for sourced_layer in poco_files:
completion.reset()
try:
file_root = pathlib.Path(root).joinpath( file_root = pathlib.Path(root).joinpath(
sourced_layer.subfolder if sourced_layer.subfolder else "" sourced_layer.subfolder if sourced_layer.subfolder else ""
) )
for tcl_file in sourced_layer.files: for tcl_file in sourced_layer.files:
filepath = pathlib.Path(file_root).joinpath( filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl")
f"{tcl_file}.tcl"
)
if not filepath.exists(): if not filepath.exists():
continue continue
completion.reset() try:
document = LSP_SERVER.workspace.get_text_document( document = TextDocument(
filepath.as_uri() uri=filepath.as_uri(), language_id="tcl"
) )
tree = LSP_SERVER.parser.parse(document.source) LSP_SERVER.update_poco_completion_for_file(
tree.accept(completion, recurse=True) document,
remove_existing_items( cache_tree=False,
completion.custom_functions, LSP_SERVER.poco_completion require_file_exists=True,
) )
LSP_SERVER.poco_completion[str(filepath)] = ( except Exception as error:
completion.custom_functions log_to_output(f"Fehler beim Parsen von {filepath}: {error}")
)
remove_shared_keys(
LSP_SERVER.proc_signatures, completion.proc_signatures
)
LSP_SERVER.proc_signatures[str(filepath)] = (
completion.proc_signatures
)
from tools.proc_docs import build_proc_docs
LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(
tree, document.source
)
except Exception as e:
log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
log_to_output("Background indexing completed.") log_to_output("Background indexing completed.")
except Exception as e: except Exception as e:
log_to_output(f"Background indexing failed: {e}") log_to_output(f"Background indexing failed: {e}")
+212 -46
View File
@@ -1,16 +1,20 @@
import logging import logging
import os
import pathlib import pathlib
import threading
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
import lsprotocol.types as lsp import lsprotocol.types as lsp
from pygls.workspace.text_document import TextDocument
from tclint.lexer import TclSyntaxError
from tclint.format import FormatterOpts
from tools.formatter import NxFormatter as Formatter
from tclint.violations import Violation
from plugins.poco_plugin import commands from plugins.poco_plugin import commands
from tools import checks, parser
from pygls import server, uris from pygls import server, uris
from tools.completion_items import completion, remove_existing_items, remove_shared_keys 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.proc_docs import build_proc_docs from tools.proc_docs import build_proc_docs
@@ -29,60 +33,201 @@ class TclLanguageServer(server.LanguageServer):
self.proc_docs: dict = {} self.proc_docs: dict = {}
# Cache: (uri, version) -> (tree, violations) # Cache: (uri, version) -> (tree, violations)
self._ast_cache = {} self._ast_cache = {}
self._parser_lock = threading.RLock()
self._index_lock = threading.RLock()
self._index_tokens: dict[str, int] = {}
self._index_versions: dict[str, int | None] = {}
self._next_index_token = 0
self._diagnostic_tokens: dict[str, int] = {}
self._next_diagnostic_token = 0
def _parse_source(self, source: str):
self.parser.violations = []
tree = self.parser.parse(source)
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
def get_tree(self, document: TextDocument): def get_tree(self, document: TextDocument):
key = (document.uri, document.version) key = (document.uri, document.version)
with self._parser_lock:
cached = self._ast_cache.get(key) cached = self._ast_cache.get(key)
if cached: if cached is not None:
return cached[0] return cached[0]
# Parse and cache tree, violations = self._parse_source(document.source)
self.parser.violations = []
tree = self.parser.parse(document.source)
violations = list(self.parser.violations)
self._ast_cache[key] = (tree, violations) self._ast_cache[key] = (tree, violations)
return tree return tree
def get_tree_and_violations(self, document: TextDocument): def get_tree_and_violations(self, document: TextDocument):
key = (document.uri, document.version) key = (document.uri, document.version)
with self._parser_lock:
cached = self._ast_cache.get(key) cached = self._ast_cache.get(key)
if cached: if cached is not None:
return cached return cached
# Parse and cache tree, violations = self._parse_source(document.source)
self.parser.violations = []
tree = self.parser.parse(document.source)
violations = list(self.parser.violations)
self._ast_cache[key] = (tree, violations) self._ast_cache[key] = (tree, violations)
return tree, violations return tree, violations
def clear_cache_for_uri(self, uri: str): def clear_cache_for_uri(self, uri: str):
to_delete = [k for k in self._ast_cache.keys() if k[0] == uri] with self._parser_lock:
for k in to_delete: to_delete = [key for key in self._ast_cache if key[0] == uri]
del self._ast_cache[k] for key in to_delete:
del self._ast_cache[key]
def update_poco_completion_for_file(self, document: TextDocument): @staticmethod
def _normalized_path(path: pathlib.Path | str) -> str:
return os.path.normcase(os.path.abspath(os.fspath(path)))
@classmethod
def _is_same_or_child(
cls, candidate: pathlib.Path | str, parent: pathlib.Path | str
) -> bool:
candidate_path = cls._normalized_path(candidate)
parent_path = cls._normalized_path(parent)
try:
return os.path.commonpath([candidate_path, parent_path]) == parent_path
except ValueError:
return False
@classmethod
def paths_equal(
cls, first: pathlib.Path | str, second: pathlib.Path | str
) -> bool:
return cls._normalized_path(first) == cls._normalized_path(second)
def index_snapshot(self) -> tuple[dict, dict, dict]:
"""Return stable copies for request handlers running beside the indexer."""
with self._index_lock:
return (
{path: list(items) for path, items in self.poco_completion.items()},
{
path: dict(signatures)
for path, signatures in self.proc_signatures.items()
},
{path: dict(docs) for path, docs in self.proc_docs.items()},
)
def diagnostic_snapshot(self, uri: str):
with self._index_lock:
return self.diagnostics.get(uri)
def _begin_index_update(self, filepath: str, version: int | None) -> int | None:
with self._index_lock:
indexed_version = self._index_versions.get(filepath)
if version is None and indexed_version is not None:
return None
if (
version is not None
and indexed_version is not None
and version < indexed_version
):
return None
self._next_index_token += 1
token = self._next_index_token
self._index_tokens[filepath] = token
self._index_versions[filepath] = version
return token
def _discard_index_update(self, filepath: str, token: int) -> None:
with self._index_lock:
if self._index_tokens.get(filepath) != token:
return
self.poco_completion.pop(filepath, None)
self.proc_signatures.pop(filepath, None)
self.proc_docs.pop(filepath, None)
def indexed_paths_under_uri(self, uri: str) -> list[pathlib.Path]:
target = pathlib.Path(uris.to_fs_path(uri))
with self._index_lock:
indexed_paths = set(self.poco_completion)
indexed_paths.update(self.proc_signatures)
indexed_paths.update(self.proc_docs)
indexed_paths.update(self._index_tokens)
return [
pathlib.Path(path)
for path in indexed_paths
if self._is_same_or_child(path, target)
]
def remove_file_state(self, uri: str) -> None:
"""Remove cached and indexed state for a file or a complete folder."""
target = pathlib.Path(uris.to_fs_path(uri))
with self._index_lock:
for store in (
self.poco_completion,
self.proc_signatures,
self.proc_docs,
self._index_tokens,
self._index_versions,
):
for path in list(store):
if self._is_same_or_child(path, target):
del store[path]
diagnostic_uris = set(self.diagnostics)
diagnostic_uris.update(self._diagnostic_tokens)
for diagnostic_uri in diagnostic_uris:
try:
diagnostic_path = pathlib.Path(uris.to_fs_path(diagnostic_uri))
except (TypeError, ValueError):
continue
if self._is_same_or_child(diagnostic_path, target):
self.diagnostics.pop(diagnostic_uri, None)
self._diagnostic_tokens.pop(diagnostic_uri, None)
with self._parser_lock:
for key in list(self._ast_cache):
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]
def update_poco_completion_for_file(
self,
document: TextDocument,
*,
cache_tree: bool = True,
require_file_exists: bool = False,
):
"""Update poco_completion for a specific file when it changes""" """Update poco_completion for a specific file when it changes"""
filepath = str(pathlib.Path(uris.to_fs_path(document.uri))) filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
token = self._begin_index_update(filepath, document.version)
if token is None:
return False
# Remove existing completion items for this file collector = CompletionCollector()
if filepath in self.poco_completion:
del self.poco_completion[filepath]
if filepath in self.proc_signatures:
del self.proc_signatures[filepath]
if filepath in self.proc_docs:
del self.proc_docs[filepath]
# Parse and extract new completion items
completion.reset()
try: try:
tree = self.get_tree(document) tree = (
tree.accept(completion, recurse=True) self.get_tree(document)
remove_existing_items(completion.custom_functions, self.poco_completion) if cache_tree
self.poco_completion[filepath] = completion.custom_functions else self.parse_source(document.source)
remove_shared_keys(self.proc_signatures, completion.proc_signatures) )
self.proc_signatures[filepath] = completion.proc_signatures tree.accept(collector, recurse=True)
self.proc_docs[filepath] = build_proc_docs(tree, document.source) docs = build_proc_docs(tree, document.source)
except Exception as e: except Exception as e:
logging.debug(f"Error parsing {filepath}: {e}") logging.debug(f"Error parsing {filepath}: {e}")
self._discard_index_update(filepath, token)
return False
if require_file_exists and not pathlib.Path(filepath).is_file():
self._discard_index_update(filepath, token)
return False
with self._index_lock:
if self._index_tokens.get(filepath) != token:
return False
self.poco_completion[filepath] = list(collector.custom_functions)
self.proc_signatures[filepath] = dict(collector.proc_signatures)
self.proc_docs[filepath] = docs
return True
def format( def format(
self, self,
@@ -107,6 +252,7 @@ class TclLanguageServer(server.LanguageServer):
), ),
) )
with self._parser_lock:
if range is not None: if range is not None:
start, end = range start, end = range
return formatter.format_partial(document.source[start:end], self.parser) return formatter.format_partial(document.source[start:end], self.parser)
@@ -117,7 +263,8 @@ class TclLanguageServer(server.LanguageServer):
self, self,
document: TextDocument, document: TextDocument,
) -> List[Violation]: ) -> List[Violation]:
tree, violations = self.get_tree_and_violations(document) tree, cached_violations = self.get_tree_and_violations(document)
violations = list(cached_violations)
for checker in checks.get_checkers(): for checker in checks.get_checkers():
violations += checker.check(document.source, tree) violations += checker.check(document.source, tree)
return violations return violations
@@ -144,8 +291,12 @@ class TclLanguageServer(server.LanguageServer):
for violation in violations: for violation in violations:
message = violation.message message = violation.message
severity = lsp.DiagnosticSeverity.Warning severity = lsp.DiagnosticSeverity.Warning
start = lsp.Position(line=violation.start[0] - 1, character=violation.start[1] - 1) start = lsp.Position(
end = lsp.Position(line=violation.end[0] - 1, character=violation.end[1] - 1) line=violation.start[0] - 1, character=violation.start[1] - 1
)
end = lsp.Position(
line=violation.end[0] - 1, character=violation.end[1] - 1
)
diagnostics.append( diagnostics.append(
lsp.Diagnostic( lsp.Diagnostic(
@@ -166,12 +317,27 @@ class TclLanguageServer(server.LanguageServer):
return self.lint(document) return self.lint(document)
def compute_diagnostics(self, document: TextDocument): def compute_diagnostics(self, document: TextDocument):
# `None` sentinel ensures that `diagnostics` gets updated if the URI is not with self._index_lock:
# present. self._next_diagnostic_token += 1
_, previous = self.diagnostics.get(document, (0, None)) token = self._next_diagnostic_token
self._diagnostic_tokens[document.uri] = token
diagnostics = self._compute_diagnostics(document) diagnostics = self._compute_diagnostics(document)
# Only update if the list has changed with self._index_lock:
if previous != diagnostics: if self._diagnostic_tokens.get(document.uri) != token:
return
current = self.diagnostics.get(document.uri)
if current is not None:
current_version, _ = current
if (
current_version is not None
and document.version is not None
and current_version > document.version
):
return
# Keep the result id in sync even when only the document version changed.
if current != (document.version, diagnostics):
self.diagnostics[document.uri] = (document.version, diagnostics) self.diagnostics[document.uri] = (document.version, diagnostics)
+1 -37
View File
@@ -18,7 +18,7 @@ class CompletionItems:
self._custom_functions.append(value) self._custom_functions.append(value)
class _Completion(Visitor): class CompletionCollector(Visitor):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._custom_functions: list[lsp.CompletionItem] = [] self._custom_functions: list[lsp.CompletionItem] = []
@@ -32,10 +32,6 @@ class _Completion(Visitor):
def proc_signatures(self): def proc_signatures(self):
return self._proc_signatures return self._proc_signatures
def reset(self):
self._custom_functions = []
self._proc_signatures = {}
def _append_unique(self, item: lsp.CompletionItem): def _append_unique(self, item: lsp.CompletionItem):
# Avoid duplicate labels within the same file scan # Avoid duplicate labels within the same file scan
if not any(ci.label == item.label for ci in self._custom_functions): if not any(ci.label == item.label for ci in self._custom_functions):
@@ -90,35 +86,3 @@ class _Completion(Visitor):
clean_name = base_name[2:] # remove leading '::' for completion display clean_name = base_name[2:] # remove leading '::' for completion display
if clean_name not in BUILTIN_VAR_LABELS: if clean_name not in BUILTIN_VAR_LABELS:
self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable)) self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable))
def remove_existing_items(items: list[lsp.CompletionItem], store: dict) -> None:
"""
Entfernt alle CompletionItems aus dem store, deren label in der items-Liste vorkommt.
Änderungen erfolgen in-place.
"""
labels_to_remove = {item.label for item in items}
for key in list(store.keys()):
filtered = [ci for ci in store[key] if ci.label not in labels_to_remove]
if filtered:
store[key] = filtered
else:
del store[key]
def remove_shared_keys(nested_dict: dict[str, dict[str, list]], flat_dict: dict[str, list]) -> None:
"""
Entfernt alle Keys aus nested_dict[file][func], wenn func auch in flat_dict vorhanden ist.
Änderungen erfolgen in-place.
"""
for file_path, func_dict in list(nested_dict.items()):
for func_name in list(func_dict.keys()):
if func_name in flat_dict:
del nested_dict[file_path][func_name]
if not nested_dict[file_path]:
del nested_dict[file_path]
completion = _Completion()
@@ -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