diff --git a/CHANGELOG.md b/CHANGELOG.md index 500caf5..8c111c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased - 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] diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 2fc7e45..da2af72 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -44,11 +44,11 @@ from pygls import uris, workspace from common.load_data import standard_items from tools.folding_ranges import build_folding_ranges 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.signature_help import build_signature_help from tools.file_sourcing import get_all_psc_files, read_psc_file from lsp_tclserver import TclLanguageServer +from pygls.workspace.text_document import TextDocument WORKSPACE_SETTINGS = {} @@ -78,6 +78,7 @@ LSP_SERVER = TclLanguageServer( def did_open(params: lsp.DidOpenTextDocumentParams) -> None: """LSP handler for textDocument/didOpen request.""" document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + LSP_SERVER.clear_cache_for_uri(document.uri) LSP_SERVER.compute_diagnostics(document) # Also update custom completion and proc docs for this file 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) -def did_close(_: lsp.DidCloseTextDocumentParams) -> None: +def did_close(params: lsp.DidCloseTextDocumentParams) -> None: """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) def did_change(params: lsp.DidChangeTextDocumentParams) -> None: """LSP handler for textDocument/didChange request""" 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.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.TEXT_DOCUMENT_DIAGNOSTIC, lsp.DiagnosticOptions( @@ -112,13 +176,18 @@ def did_change(params: lsp.DidChangeTextDocumentParams) -> None: ) def document_diagnostic(params: lsp.DocumentDiagnosticParams): """Return diagnostics for the requested document""" - was_cached = True - if (uri := params.text_document.uri) not in LSP_SERVER.diagnostics: - was_cached = False + uri = params.text_document.uri + diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri) + was_cached = diagnostic_state is not None + if diagnostic_state is None: doc = LSP_SERVER.workspace.get_text_document(uri) 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}" 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) # 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 = ( standard_items.tcl_keyword_list + standard_items.nx_procs @@ -170,14 +240,15 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: ) 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] = [] - seen_var_labels: set[str] = set() + seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set() for it in base_items + dynamic_items: - if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable: - if it.label in seen_var_labels: - continue - seen_var_labels.add(it.label) + key = (it.label, getattr(it, "kind", None)) + if key in seen_items: + continue + seen_items.add(key) merged.append(it) 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))) custom_signatures: dict[str, list[str]] = {} custom_docs: dict[str, str] = {} + _, proc_signatures, proc_docs = LSP_SERVER.index_snapshot() # Prefer declarations from the current document if duplicate proc names # 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: 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: custom_docs.update(docs) - custom_docs.update(LSP_SERVER.proc_docs.get(filepath, {})) + custom_docs.update(proc_docs.get(filepath, {})) return build_signature_help( document.source, @@ -248,7 +320,8 @@ def inlay_hints(params: lsp.InlayHintParams): # Merge proc signatures across files and traverse once 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) generator = InlayHintGenerator(merged_signatures) @@ -268,7 +341,8 @@ def semantic_tokens(params: lsp.SemanticTokensParams): data = [] plugins = [] - hl = _Highlighter(plugins, LSP_SERVER.poco_completion) + poco_completion, _, _ = LSP_SERVER.index_snapshot() + hl = _Highlighter(plugins, poco_completion) # Reuse cached AST 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 # Build a merged map of proc -> docs gathered during initialization and updates 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) if token in proc_docs: @@ -420,7 +495,8 @@ def goto_definition(params: lsp.DefinitionParams): # 2) Search in indexed files from proc_signatures # Build list of candidate files that declare this token as a proc 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: candidate_files.append(file_path) @@ -530,42 +606,24 @@ def initialized(_params: lsp.InitializedParams): for psc_file in psc_files: poco_files = read_psc_file(psc_file) for sourced_layer in poco_files: - completion.reset() - try: - file_root = pathlib.Path(root).joinpath( - sourced_layer.subfolder if sourced_layer.subfolder else "" - ) - for tcl_file in sourced_layer.files: - filepath = pathlib.Path(file_root).joinpath( - f"{tcl_file}.tcl" + file_root = pathlib.Path(root).joinpath( + sourced_layer.subfolder if sourced_layer.subfolder else "" + ) + for tcl_file in sourced_layer.files: + filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl") + if not filepath.exists(): + continue + try: + document = TextDocument( + uri=filepath.as_uri(), language_id="tcl" ) - if not filepath.exists(): - continue - completion.reset() - document = LSP_SERVER.workspace.get_text_document( - filepath.as_uri() + LSP_SERVER.update_poco_completion_for_file( + document, + cache_tree=False, + require_file_exists=True, ) - tree = LSP_SERVER.parser.parse(document.source) - tree.accept(completion, recurse=True) - remove_existing_items( - completion.custom_functions, LSP_SERVER.poco_completion - ) - LSP_SERVER.poco_completion[str(filepath)] = ( - completion.custom_functions - ) - 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}") + except Exception as error: + log_to_output(f"Fehler beim Parsen von {filepath}: {error}") log_to_output("Background indexing completed.") except Exception as e: log_to_output(f"Background indexing failed: {e}") diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index 117fcc4..770e999 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -1,16 +1,20 @@ import logging +import os import pathlib +import threading from typing import List, Optional, Tuple + 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 tools import checks, parser 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 @@ -29,60 +33,201 @@ class TclLanguageServer(server.LanguageServer): self.proc_docs: dict = {} # Cache: (uri, version) -> (tree, violations) 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): key = (document.uri, document.version) - cached = self._ast_cache.get(key) - if cached: - return cached[0] - # Parse and cache - self.parser.violations = [] - tree = self.parser.parse(document.source) - violations = list(self.parser.violations) - self._ast_cache[key] = (tree, violations) - return tree + with self._parser_lock: + cached = self._ast_cache.get(key) + if cached is not None: + return cached[0] + tree, violations = self._parse_source(document.source) + self._ast_cache[key] = (tree, violations) + return tree def get_tree_and_violations(self, document: TextDocument): key = (document.uri, document.version) - cached = self._ast_cache.get(key) - if cached: - return cached - # Parse and cache - self.parser.violations = [] - tree = self.parser.parse(document.source) - violations = list(self.parser.violations) - self._ast_cache[key] = (tree, violations) - return tree, violations + with self._parser_lock: + cached = self._ast_cache.get(key) + if cached is not None: + return cached + tree, violations = self._parse_source(document.source) + self._ast_cache[key] = (tree, violations) + return tree, violations def clear_cache_for_uri(self, uri: str): - to_delete = [k for k in self._ast_cache.keys() if k[0] == uri] - for k in to_delete: - del self._ast_cache[k] + 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] - 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""" 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 - 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() + collector = CompletionCollector() try: - tree = self.get_tree(document) - tree.accept(completion, recurse=True) - remove_existing_items(completion.custom_functions, self.poco_completion) - self.poco_completion[filepath] = completion.custom_functions - remove_shared_keys(self.proc_signatures, completion.proc_signatures) - self.proc_signatures[filepath] = completion.proc_signatures - self.proc_docs[filepath] = build_proc_docs(tree, document.source) + tree = ( + self.get_tree(document) + if cache_tree + else self.parse_source(document.source) + ) + tree.accept(collector, recurse=True) + docs = build_proc_docs(tree, document.source) except Exception as 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( self, @@ -107,17 +252,19 @@ class TclLanguageServer(server.LanguageServer): ), ) - if range is not None: - start, end = range - return formatter.format_partial(document.source[start:end], self.parser) + with self._parser_lock: + if range is not None: + start, end = range + return formatter.format_partial(document.source[start:end], self.parser) - return formatter.format_top(document.source, self.parser) + return formatter.format_top(document.source, self.parser) def linter( self, document: TextDocument, ) -> 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(): violations += checker.check(document.source, tree) return violations @@ -144,8 +291,12 @@ class TclLanguageServer(server.LanguageServer): for violation in violations: message = violation.message severity = lsp.DiagnosticSeverity.Warning - start = lsp.Position(line=violation.start[0] - 1, character=violation.start[1] - 1) - end = lsp.Position(line=violation.end[0] - 1, character=violation.end[1] - 1) + start = lsp.Position( + 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( lsp.Diagnostic( @@ -166,12 +317,27 @@ class TclLanguageServer(server.LanguageServer): return self.lint(document) def compute_diagnostics(self, document: TextDocument): - # `None` sentinel ensures that `diagnostics` gets updated if the URI is not - # present. - _, previous = self.diagnostics.get(document, (0, None)) + with self._index_lock: + self._next_diagnostic_token += 1 + token = self._next_diagnostic_token + self._diagnostic_tokens[document.uri] = token diagnostics = self._compute_diagnostics(document) - # Only update if the list has changed - if previous != diagnostics: - self.diagnostics[document.uri] = (document.version, diagnostics) + with self._index_lock: + 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) diff --git a/server/src/tools/completion_items.py b/server/src/tools/completion_items.py index 3cade14..976cfc1 100644 --- a/server/src/tools/completion_items.py +++ b/server/src/tools/completion_items.py @@ -18,7 +18,7 @@ class CompletionItems: self._custom_functions.append(value) -class _Completion(Visitor): +class CompletionCollector(Visitor): def __init__(self): super().__init__() self._custom_functions: list[lsp.CompletionItem] = [] @@ -32,10 +32,6 @@ class _Completion(Visitor): def proc_signatures(self): return self._proc_signatures - def reset(self): - self._custom_functions = [] - self._proc_signatures = {} - def _append_unique(self, item: lsp.CompletionItem): # Avoid duplicate labels within the same file scan 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 if clean_name not in BUILTIN_VAR_LABELS: 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() diff --git a/server/tests/python_tests/test_index_stability.py b/server/tests/python_tests/test_index_stability.py new file mode 100644 index 0000000..4f07d62 --- /dev/null +++ b/server/tests/python_tests/test_index_stability.py @@ -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