diff --git a/src/solidlsp/language_servers/nextflow_language_server.py b/src/solidlsp/language_servers/nextflow_language_server.py index d39cc834..0b3fcf17 100644 --- a/src/solidlsp/language_servers/nextflow_language_server.py +++ b/src/solidlsp/language_servers/nextflow_language_server.py @@ -88,6 +88,10 @@ class NextflowLanguageServer(SolidLanguageServer): self._active_progress_tokens: set[str] = set() self._progress_lock = threading.Lock() + # Whether the server's deferred workspace scan has been observed to complete + # (see _flush_deferred_workspace_scan). + self._workspace_scan_flushed = False + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @@ -297,6 +301,32 @@ class NextflowLanguageServer(SolidLanguageServer): else: log.warning("Nextflow LS did not signal scan completion within %.0fs; proceeding anyway", _WORKSPACE_SCAN_TIMEOUT) + def _flush_deferred_workspace_scan(self, relative_file_path: str) -> None: + """ + Forces the workspace scan, which the server defers past the first request of a session. + + The scan runs in ``LanguageService.update0`` behind a 1s debounce, and only on a round with no + pending file change; the session's first ``didOpen`` re-defers it, leaving references to be + answered from an AST cache holding just that one file. + + ``completion`` calls ``updateNow``, which runs the update synchronously before replying, so two + such requests force the scan whatever the workspace size: the first drains the pending change, + the second finds none and scans. Their results are discarded. + """ + if self._workspace_scan_flushed: + return + + params: lsp_types.CompletionParams = { + "textDocument": {"uri": self._resolve_file_uri(relative_file_path)}, + "position": {"line": 0, "character": 0}, + } + for _ in range(2): + try: + self.server.send.completion(params) + except Exception as e: + log.debug("Completion request used to flush the Nextflow workspace scan failed: %s", e) + self._workspace_scan_flushed = True + @override def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: """ @@ -309,13 +339,14 @@ class NextflowLanguageServer(SolidLanguageServer): come back empty. Sending a ``documentSymbol`` request for the same file first is a cheap way to block until the update has been applied. """ + self._flush_deferred_workspace_scan(relative_file_path) self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}}) return super()._send_references_request(relative_file_path, line, column) @override def _get_wait_time_for_cross_file_referencing(self) -> float: - """Small safety buffer; the actual synchronisation happens in _send_references_request.""" - return 1.0 + """No blind wait is needed: _send_references_request synchronises with the server explicitly.""" + return 0.0 @override def _document_symbols_cache_fingerprint(self) -> Hashable: diff --git a/test/solidlsp/nextflow/test_nextflow_basic.py b/test/solidlsp/nextflow/test_nextflow_basic.py index 4f7243d1..d60bd2cd 100644 --- a/test/solidlsp/nextflow/test_nextflow_basic.py +++ b/test/solidlsp/nextflow/test_nextflow_basic.py @@ -7,6 +7,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import LanguageServerId from solidlsp.ls_utils import SymbolUtils +from test.conftest import start_default_ls_context pytestmark = pytest.mark.skipif(shutil.which("java") is None, reason="Nextflow language server requires a Java runtime") @@ -74,6 +75,25 @@ class TestNextflowLanguageServer: # main.nf, 0-indexed line 12: greetings = GREET(names) assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}" + def test_find_references_across_files_on_a_fresh_server(self) -> None: + """Cross-file references must not depend on another request having opened the referencing file. + + The module-scoped ``language_server`` fixture is shared, so by the time + ``test_find_references_across_files`` runs, earlier tests have already opened main.nf and thereby + forced the server to compile it. This test starts its own server and touches nothing but the + defining file, which is what a real session's first request looks like. + """ + module_path = os.path.join("modules", "greet", "main.nf") + with start_default_ls_context(LanguageServerId.NEXTFLOW) as language_server: + symbol = self._get_symbol(language_server, module_path, "GREET") + start = symbol["selectionRange"]["start"] + + refs = language_server.request_references(module_path, start["line"], start["character"]) + + ref_lines = self._reference_lines(refs) + # main.nf, 0-indexed line 12: greetings = GREET(names) + assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}" + @staticmethod def _reference_lines(references: list[dict]) -> set[tuple[str, int]]: """(relative path, 0-indexed start line) per reference; paths use the platform separator."""