diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 551dedf3..ac700fcf 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -66,7 +66,7 @@ jobs: # test can fall through to "no batch". To add/rebalance: edit a MARKERS_* var, and (for a new # group) add a case branch below + the batch name to the matrix list. env: - MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl" + MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl or nextflow" MARKERS_NATIVE: "cpp or rust or go or zig or pascal or swift" # swift is macOS-only (skips elsewhere via the guard) # other-langs: grab-bag + scripting languages + haskell + qml. Haskell (GHC/HLS) and qml (qmlls) are # installed only on Linux -- too slow/expensive to build on the macOS runner (Haskell), or not worth diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fea982a..2cd22234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,9 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - Allow language server priorities to be configured in `serena_config.yml` (for auto-detection during project creation) + - **Add support for Nextflow** (language server `nextflow`), using the official + [Nextflow language server](https://github.com/nextflow-io/language-server); the JAR is downloaded + automatically, a Java 17+ runtime is required - Add `python_basedpyright` as an alternative Python language server - Java/JDTLS: stop downloading and loading the unused IntelliCode completion-ranking bundle; the retired `intellicode_version`, `intellicode_xmx` and `intellicode_xms` settings remain accepted but are ignored #1821 diff --git a/README.md b/README.md index 70e9d4c7..a7941e37 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language The underlying language servers are typically open-source projects or at least freely available for use. When using Serena's language server backend, we provide **support for over 40 programming languages**, including -Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, YAML, and Zig. +Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nextflow, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 884cc4b7..87742337 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -115,6 +115,11 @@ Some languages require additional installations or setup steps, as noted. * **mSL** (mIRC Scripting Language) (auto-installed; no external dependencies required — uses a custom pygls-based LSP server shipped with Serena; supports document symbols, workspace symbols, references, and go-to-definition for aliases, events, menus, dialogs, and CTCP handlers in `.mrc` files) +* **Nextflow** + (uses the official [Nextflow language server](https://github.com/nextflow-io/language-server), which is automatically + downloaded; requires a Java 17+ runtime, discovered via `ls_specific_settings.nextflow.java_home`, `JAVA_HOME` or `java` on PATH; + covers `.nf` scripts — Nextflow `.config` files are not treated as source files, since the language server reports no symbols for them; + processes, workflows and functions are reported under their declared name, e.g. `GREET` for `process GREET`) * **Nix** (requires nixd installation) * **OCaml** diff --git a/pyproject.toml b/pyproject.toml index 6731feeb..e9df07a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -345,6 +345,7 @@ markers = [ "zig: language server running for Zig", "lua: language server running for Lua", "luau: language server running for Luau", + "nextflow: language server running for Nextflow", "nix: language server running for Nix", "dart: language server running for Dart", "erlang: language server running for Erlang", diff --git a/scripts/update_downloaded_dependency_hashes.py b/scripts/update_downloaded_dependency_hashes.py index 02e56bda..8e4136f0 100644 --- a/scripts/update_downloaded_dependency_hashes.py +++ b/scripts/update_downloaded_dependency_hashes.py @@ -1,9 +1,11 @@ from sensai.util import logging from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS +from solidlsp.language_servers.nextflow_language_server import NextflowLanguageServer from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer if __name__ == "__main__": logging.configure() EclipseJDTLS.DependencyProvider.update_dep_hashes() + NextflowLanguageServer.DependencyProvider.update_dep_hashes() KotlinLanguageServer.DependencyProvider.update_dep_hashes() diff --git a/src/serena/hooks.py b/src/serena/hooks.py index 6c2aafa2..cd1fb84f 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -323,6 +323,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): ".lua", ".m", ".matlab", + ".nf", ".php", ".proto", ".ps1", diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 3d22e3d6..5b1d0293 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -9,13 +9,14 @@ project_name: "project_name" # gdscript go groovy haskell haxe # hlsl html java json julia # kotlin latex lean4 lua luau -# markdown matlab msl nix ocaml -# pascal perl php php_phpactor php_phpantom -# powershell python python_basedpyright python_jedi python_pyrefly -# python_ty qml r rego ruby -# ruby_solargraph rust scala scss solidity -# svelte swift systemverilog terraform toml -# typescript typescript_vts vue yaml zig +# markdown matlab msl nextflow nix +# ocaml pascal perl php php_phpactor +# php_phpantom powershell python python_basedpyright python_jedi +# python_pyrefly python_ty qml r rego +# ruby ruby_solargraph rust scala scss +# solidity svelte swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated; generated with scripts/print_language_list.py; # For the current list, see values of the LanguageServerId enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) diff --git a/src/solidlsp/language_servers/nextflow_language_server.py b/src/solidlsp/language_servers/nextflow_language_server.py new file mode 100644 index 00000000..d39cc834 --- /dev/null +++ b/src/solidlsp/language_servers/nextflow_language_server.py @@ -0,0 +1,337 @@ +""" +Provides Nextflow specific instantiation of the LanguageServer class, using the official +Nextflow language server (https://github.com/nextflow-io/language-server), which is +distributed as a fat JAR and requires a Java runtime. + +You can configure the following options in ls_specific_settings (in serena_config.yml): + + ls_specific_settings: + nextflow: + ls_path: '/path/to/language-server-all.jar' # use an existing JAR instead of downloading one + nextflow_ls_version: '26.04.3' # JAR version to download (default: bundled version) + java_home: '/path/to/jdk' # JDK to run the JAR with (default: JAVA_HOME or PATH) + jvm_options: ['-Xmx2G'] # JVM options for the language server process + exclude_patterns: ['work', '.nextflow'] # workspace paths the language server shall ignore +""" + +import logging +import os +import shutil +import threading +from collections.abc import Hashable +from pathlib import Path + +from overrides import override + +from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + RawDocumentSymbol, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.lsp_protocol_handler import lsp_types +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +NEXTFLOW_LS_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# NOTE: after bumping this, re-run scripts/update_downloaded_dependency_hashes.py and commit the +# updated src/solidlsp/resources/downloaded_dependency_hashes.json. +DEFAULT_NEXTFLOW_LS_VERSION = "26.04.3" + +NEXTFLOW_LS_JAR_NAME = "language-server-all.jar" + +#: the Nextflow language server is built for Java 17+ +MIN_JDK_VERSION = 17 + +#: paths that hold Nextflow's own outputs rather than pipeline sources; scanning them is pure overhead +DEFAULT_EXCLUDE_PATTERNS = ["work", ".nextflow"] + +#: the server scans the workspace only when a configuration change asks it to, and it compares the new +#: configuration against its own defaults (see LanguageServerConfiguration.defaults() upstream, where the +#: error reporting mode is "warnings"), so the settings we push must differ in at least one of the keys +#: that trigger a scan +_SCAN_TRIGGERING_ERROR_REPORTING_MODE = "errors" + +#: upper bound for the initial workspace scan, which compiles every .nf file in the project +_WORKSPACE_SCAN_TIMEOUT = 180.0 + +#: declaration keywords the server prepends to symbol names (see ScriptSymbolProvider.getSymbolName upstream) +_SYMBOL_NAME_PREFIXES = frozenset(("process", "workflow", "function", "record", "enum")) + + +class NextflowLanguageServer(SolidLanguageServer): + """ + Provides Nextflow specific instantiation of the LanguageServer class. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a Nextflow Language Server instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "nextflow", + solidlsp_settings, + ) + + # The workspace scan is asynchronous and reported via $/progress under the "initialize" token; + # requests answered before it ends see an empty AST cache. + self._scan_complete = threading.Event() + self._active_progress_tokens: set[str] = set() + self._progress_lock = threading.Lock() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + @classmethod + def _create_dep_language_server(cls, version: str | None = None) -> DownloadedDependency: + version = version or DEFAULT_NEXTFLOW_LS_VERSION + return DownloadedDependency( + url=f"https://github.com/nextflow-io/language-server/releases/download/v{version}/{NEXTFLOW_LS_JAR_NAME}", + allowed_hosts=NEXTFLOW_LS_ALLOWED_HOSTS, + ) + + @classmethod + def update_dep_hashes(cls) -> None: + deps = [cls._create_dep_language_server()] + with DownloadedDependencyHashDatabase.get_instance().update_context() as db: + for dep in deps: + db.update(dep) + + def _get_or_install_core_dependency(self) -> str: + version = self._custom_settings.get("nextflow_ls_version", DEFAULT_NEXTFLOW_LS_VERSION) + install_dir = os.path.join(self._ls_resources_dir, f"nextflow_language_server-{version}") + jar_path = os.path.join(install_dir, NEXTFLOW_LS_JAR_NAME) + + if not os.path.exists(jar_path): + os.makedirs(install_dir, exist_ok=True) + log.info("Downloading Nextflow Language Server %s ...", version) + self._create_dep_language_server(version).download_to(jar_path) + + if not os.path.exists(jar_path): + raise FileNotFoundError(f"Nextflow Language Server JAR not found at {jar_path}") + return jar_path + + def _create_launch_command(self, core_path: str) -> list[str]: + java_exe = self._resolve_java() + jvm_options = self._custom_settings.get("jvm_options", []) + if not isinstance(jvm_options, list): + log.warning("The 'jvm_options' setting should be a list of strings. Ignoring the provided value: %s", jvm_options) + jvm_options = [] + return [java_exe, *jvm_options, "-jar", core_path] + + def _resolve_java(self) -> str: + """ + Locates a ``java`` executable (``java_home`` setting -> ``JAVA_HOME`` -> PATH) and verifies + that it is new enough to run the language server. + + :return: the path to the java executable + """ + java_exe_name = "java.exe" if os.name == "nt" else "java" + + java_exe: str | None = None + if explicit_home := self._custom_settings.get("java_home"): + candidate = str(Path(explicit_home) / "bin" / java_exe_name) + if not os.path.exists(candidate): + raise SolidLSPException( + f"java_home='{explicit_home}' is invalid: '{candidate}' does not exist. " + f"Set ls_specific_settings.nextflow.java_home to a JDK home that contains bin/{java_exe_name}." + ) + java_exe = candidate + elif env_home := os.environ.get("JAVA_HOME"): + candidate = str(Path(env_home) / "bin" / java_exe_name) + if os.path.exists(candidate): + java_exe = candidate + else: + log.warning("JAVA_HOME='%s' invalid (no '%s'), falling back to PATH.", env_home, candidate) + if java_exe is None: + java_exe = shutil.which("java") + if java_exe is None: + raise SolidLSPException( + "Could not locate a Java installation for the Nextflow language server. " + "Set ls_specific_settings.nextflow.java_home, set the JAVA_HOME environment variable, " + f"or ensure 'java' is on PATH. Required: JDK {MIN_JDK_VERSION}+." + ) + + # reuse JDTLS's JVM interrogation, which reports the real java.home even for stub launchers + from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS + + java_home, major_version = EclipseJDTLS.DependencyProvider._inspect_java(java_exe) + if major_version < MIN_JDK_VERSION: + raise SolidLSPException( + f"The Nextflow language server requires JDK {MIN_JDK_VERSION}+ but '{java_exe}' is JDK {major_version} " + f"(java.home={java_home}). Install a newer JDK and update ls_specific_settings.nextflow.java_home " + "or JAVA_HOME." + ) + log.info("Using JDK %d at %s for the Nextflow language server", major_version, java_exe) + return java_exe + + def _create_base_initialize_params(self) -> dict: + """ + Returns the initialize params for the Nextflow Language Server. + """ + return { + "capabilities": { + "textDocument": { + "synchronization": {"dynamicRegistration": True, "didSave": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": False}}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + "documentLink": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}}, + }, + "window": {"workDoneProgress": True}, + }, + } + + def _create_configuration_settings(self) -> dict: + """ + Returns the settings pushed via workspace/didChangeConfiguration, which is what makes the server + scan the workspace (see ``_start_server``). + """ + exclude_patterns = self._custom_settings.get("exclude_patterns", DEFAULT_EXCLUDE_PATTERNS) + if not isinstance(exclude_patterns, list): + log.warning("The 'exclude_patterns' setting should be a list of strings. Ignoring the provided value: %s", exclude_patterns) + exclude_patterns = DEFAULT_EXCLUDE_PATTERNS + return { + "nextflow": { + "debug": False, + # deliberately not the server's default of "warnings": the value must differ from the + # defaults for the configuration change to trigger the workspace scan + "errorReportingMode": _SCAN_TRIGGERING_ERROR_REPORTING_MODE, + "files": {"exclude": exclude_patterns}, + } + } + + def _start_server(self) -> None: + """ + Starts the Nextflow Language Server. + """ + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def work_done_progress_create(params: dict) -> dict: + token = str(params.get("token", "")) + with self._progress_lock: + self._active_progress_tokens.add(token) + self._scan_complete.clear() + return {} + + def progress_handler(params: dict) -> None: + token = str(params.get("token", "")) + value = params.get("value", {}) + kind = value.get("kind") + if kind == "begin": + with self._progress_lock: + self._active_progress_tokens.add(token) + self._scan_complete.clear() + log.info("Nextflow LS progress [%s]: started - %s", token, value.get("title", "")) + elif kind == "report": + log.debug("Nextflow LS progress [%s]: %s", token, value.get("message", "")) + elif kind == "end": + with self._progress_lock: + self._active_progress_tokens.discard(token) + if not self._active_progress_tokens: + self._scan_complete.set() + log.info("Nextflow LS progress [%s]: ended", token) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", progress_handler) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Nextflow server process") + self.server.start() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(self._create_initialize_params()) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "hoverProvider" in capabilities, "Server must support hover" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" + + self.server.notify.initialized({}) + + # The server does not index anything on `initialized`; it (re-)scans the workspace only when a + # configuration change arrives whose error reporting mode, exclude patterns or plugin registry + # URL differ from what it currently holds. Without this notification, every symbol request + # answers with an empty result. + self._scan_complete.clear() + self.server.notify.workspace_did_change_configuration({"settings": self._create_configuration_settings()}) + + log.info("Waiting for the Nextflow workspace scan to complete...") + if self._scan_complete.wait(timeout=_WORKSPACE_SCAN_TIMEOUT): + log.info("Nextflow LS ready") + else: + log.warning("Nextflow LS did not signal scan completion within %.0fs; proceeding anyway", _WORKSPACE_SCAN_TIMEOUT) + + @override + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: + """ + Flushes the server's pending recompile before asking for references. + + Every ``didOpen``/``didChange`` schedules a debounced (1s) update of the file's AST, and + ``LanguageService.references`` is the one request that does not await it (``documentSymbol``, + ``codeLens``, ``documentLink`` and ``semanticTokensFull`` all do). A references request issued + within the debounce window therefore races the recompile of the very file it asks about and can + 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.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 + + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + """ + Strips the declaration keyword the language server prefixes to every symbol name + ("process GREET", "workflow SAY_HELLO", "function foo"), so that symbols can be addressed + by the name they are written with in the source. The implicit entry workflow keeps its + "" placeholder name, which is what the server calls it. + """ + name = symbol["name"] + prefix, sep, rest = name.partition(" ") + if sep and prefix in _SYMBOL_NAME_PREFIXES: + return rest + return name diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 25a9f720..d24ff2f0 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -185,6 +185,12 @@ class LanguageServerId(str, Enum): Supports .qml files. Requires Qt 6 installation providing qmlls on PATH. See https://doc.qt.io/qt-6/qtqml-tool-qmlls.html """ + NEXTFLOW = "nextflow" + """Nextflow language server (https://github.com/nextflow-io/language-server), the one that backs the + official VS Code extension. Supports .nf scripts (Nextflow .config files are parsed by the server, but + it reports no symbols for them, so they are not treated as source files here). + Automatically downloads the language server JAR; requires Java 17+ (JAVA_HOME or 'java' on PATH). + """ # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -581,6 +587,10 @@ class LanguageServerId(str, Enum): return FilenameMatcher(".gd", ".gdscript") case self.QML: return FilenameMatcher(".qml") + case self.NEXTFLOW: + # only scripts: the language server does have a service for .config files, but it provides + # no symbols for them, so treating them as source files would only pollute the symbol index + return FilenameMatcher(".nf") case self.HTML: return FilenameMatcher(".html", ".htm") case self.SCSS: @@ -876,6 +886,10 @@ class LanguageServerId(str, Enum): from solidlsp.language_servers.qml_language_server import QmlLanguageServer return QmlLanguageServer + case self.NEXTFLOW: + from solidlsp.language_servers.nextflow_language_server import NextflowLanguageServer + + return NextflowLanguageServer case self.HTML: from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer diff --git a/src/solidlsp/resources/downloaded_dependency_hashes.json b/src/solidlsp/resources/downloaded_dependency_hashes.json index c6602add..d1006ff8 100644 --- a/src/solidlsp/resources/downloaded_dependency_hashes.json +++ b/src/solidlsp/resources/downloaded_dependency_hashes.json @@ -5,6 +5,7 @@ "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-arm64-1.54.0-923.vsix": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-x64-1.54.0-923.vsix": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4", + "https://github.com/nextflow-io/language-server/releases/download/v26.04.3/language-server-all.jar": "20cfa34f6e202d6b8babd8d786202ce00e0d39b70ccec3290e2ab3fbd02bc016", "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-win-x64.zip": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-x64.zip": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-aarch64.zip": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", diff --git a/test/resources/repos/nextflow/test_repo/.gitignore b/test/resources/repos/nextflow/test_repo/.gitignore new file mode 100644 index 00000000..3a54819e --- /dev/null +++ b/test/resources/repos/nextflow/test_repo/.gitignore @@ -0,0 +1,3 @@ +work/ +.nextflow/ +.nextflow.log* diff --git a/test/resources/repos/nextflow/test_repo/main.nf b/test/resources/repos/nextflow/test_repo/main.nf new file mode 100644 index 00000000..d391f217 --- /dev/null +++ b/test/resources/repos/nextflow/test_repo/main.nf @@ -0,0 +1,23 @@ +#!/usr/bin/env nextflow + +include { GREET; SHOUT } from './modules/greet/main.nf' +include { buildGreeting } from './modules/util/main.nf' + +params.names = ['Ada', 'Grace'] + +workflow SAY_HELLO { + take: + names + + main: + greetings = GREET(names) + shouted = SHOUT(greetings) + + emit: + shouted +} + +workflow { + log.info buildGreeting('World') + SAY_HELLO(Channel.fromList(params.names)) +} diff --git a/test/resources/repos/nextflow/test_repo/modules/greet/main.nf b/test/resources/repos/nextflow/test_repo/modules/greet/main.nf new file mode 100644 index 00000000..0a687436 --- /dev/null +++ b/test/resources/repos/nextflow/test_repo/modules/greet/main.nf @@ -0,0 +1,27 @@ +process GREET { + tag "$name" + + input: + val name + + output: + path "greeting.txt" + + script: + """ + echo "Hello, ${name}!" > greeting.txt + """ +} + +process SHOUT { + input: + path greeting + + output: + path "shout.txt" + + script: + """ + tr '[:lower:]' '[:upper:]' < ${greeting} > shout.txt + """ +} diff --git a/test/resources/repos/nextflow/test_repo/modules/util/main.nf b/test/resources/repos/nextflow/test_repo/modules/util/main.nf new file mode 100644 index 00000000..f9a8eab6 --- /dev/null +++ b/test/resources/repos/nextflow/test_repo/modules/util/main.nf @@ -0,0 +1,7 @@ +def normalizeName(String name) { + return name.trim().toLowerCase() +} + +def buildGreeting(String name) { + return "Hello, " + normalizeName(name) +} diff --git a/test/resources/repos/nextflow/test_repo/nextflow.config b/test/resources/repos/nextflow/test_repo/nextflow.config new file mode 100644 index 00000000..6c5f47b9 --- /dev/null +++ b/test/resources/repos/nextflow/test_repo/nextflow.config @@ -0,0 +1,8 @@ +manifest { + name = 'serena-nextflow-test-repo' + description = 'Minimal Nextflow pipeline used by the solidlsp Nextflow language server tests' +} + +process { + cpus = 1 +} diff --git a/test/solidlsp/nextflow/test_nextflow_basic.py b/test/solidlsp/nextflow/test_nextflow_basic.py new file mode 100644 index 00000000..4f7243d1 --- /dev/null +++ b/test/solidlsp/nextflow/test_nextflow_basic.py @@ -0,0 +1,87 @@ +import os +import shutil +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import LanguageServerId +from solidlsp.ls_utils import SymbolUtils + +pytestmark = pytest.mark.skipif(shutil.which("java") is None, reason="Nextflow language server requires a Java runtime") + + +@pytest.mark.nextflow +class TestNextflowLanguageServer: + @pytest.mark.parametrize("language_server", [LanguageServerId.NEXTFLOW], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.NEXTFLOW], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Language server starts and attaches to the test repository.""" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [LanguageServerId.NEXTFLOW], indirect=True) + def test_document_symbols_workflows(self, language_server: SolidLanguageServer) -> None: + """Workflows declared in a script are reported, with the ``workflow`` keyword stripped.""" + all_symbols, _ = language_server.request_document_symbols("main.nf").get_all_symbols_and_roots() + names = [s["name"] for s in all_symbols] + assert "SAY_HELLO" in names, f"SAY_HELLO not found in main.nf symbols. Found: {names}" + # the implicit entry workflow has no name of its own; the language server calls it "" + assert "" in names, f"entry workflow not found in main.nf symbols. Found: {names}" + + @pytest.mark.parametrize("language_server", [LanguageServerId.NEXTFLOW], indirect=True) + def test_document_symbols_processes_and_functions(self, language_server: SolidLanguageServer) -> None: + """Processes and functions are reported without their declaration keyword.""" + process_symbols, _ = language_server.request_document_symbols("modules/greet/main.nf").get_all_symbols_and_roots() + process_names = [s["name"] for s in process_symbols] + assert "GREET" in process_names, f"GREET not found in modules/greet/main.nf symbols. Found: {process_names}" + assert "SHOUT" in process_names, f"SHOUT not found in modules/greet/main.nf symbols. Found: {process_names}" + + function_symbols, _ = language_server.request_document_symbols("modules/util/main.nf").get_all_symbols_and_roots() + function_names = [s["name"] for s in function_symbols] + assert "normalizeName" in function_names, f"normalizeName not found in modules/util/main.nf symbols. Found: {function_names}" + assert "buildGreeting" in function_names, f"buildGreeting not found in modules/util/main.nf symbols. Found: {function_names}" + + @pytest.mark.parametrize("language_server", [LanguageServerId.NEXTFLOW], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + for name in ("SAY_HELLO", "GREET", "SHOUT", "normalizeName", "buildGreeting"): + assert SymbolUtils.symbol_tree_contains_name(symbols, name), f"{name} not found in symbol tree" + + @pytest.mark.parametrize("language_server", [LanguageServerId.NEXTFLOW], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """``normalizeName`` is defined and called within modules/util/main.nf only.""" + util_path = os.path.join("modules", "util", "main.nf") + symbol = self._get_symbol(language_server, util_path, "normalizeName") + start = symbol["selectionRange"]["start"] + + refs = language_server.request_references(util_path, start["line"], start["character"]) + ref_lines = self._reference_lines(refs) + # modules/util/main.nf, 0-indexed line 5: return "Hello, " + normalizeName(name) + assert (util_path, 5) in ref_lines, f"call to normalizeName inside buildGreeting not found. Found: {ref_lines}" + + @pytest.mark.parametrize("language_server", [LanguageServerId.NEXTFLOW], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """The GREET process is defined in a module and used by the workflow in main.nf.""" + module_path = os.path.join("modules", "greet", "main.nf") + 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 2: include { GREET; SHOUT } from './modules/greet/main.nf' + assert ("main.nf", 2) in ref_lines, f"include of GREET in main.nf not found. Found: {ref_lines}" + # 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.""" + return {(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references} + + @staticmethod + def _get_symbol(language_server: SolidLanguageServer, relative_path: str, name: str) -> dict: + all_symbols, _ = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + matches = [s for s in all_symbols if s["name"] == name] + assert matches, f"{name} not found in {relative_path}. Found: {[s['name'] for s in all_symbols]}" + return matches[0]