Add Nextflow language server support (#1815)

The Nextflow language server schedules a debounced (1s) AST update on every
didOpen/didChange, and LanguageService.references is the only request that does
not await it -- documentSymbol, codeLens, documentLink and semanticTokensFull
all do. A references request issued inside that window races the recompile of
the file it asks about and can return an empty list, which is what the macOS CI
runner hit. Send a documentSymbol request for the same file first, which blocks
server-side until the pending update has been applied.

Also compare reference paths using the platform separator in the tests, since
relativePath is OS-native and the hardcoded forward slashes failed on Windows.
This commit is contained in:
Evangelos Karatzas authored and GitHub committed 2026-08-08 18:58:46 +02:00
1 parent 27d7d0e0ec
commit 76b94f3274
17 files changed
+529 -9

No files matched your search

+1 -1
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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**
+1
View File
@@ -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",
@@ -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()
+1
View File
@@ -323,6 +323,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
".lua",
".m",
".matlab",
".nf",
".php",
".proto",
".ps1",
+8 -7
View File
@@ -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)
@@ -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
"<entry>" 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
+14
View File
@@ -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
@@ -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",
@@ -0,0 +1,3 @@
work/
.nextflow/
.nextflow.log*
@@ -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))
}
@@ -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
"""
}
@@ -0,0 +1,7 @@
def normalizeName(String name) {
return name.trim().toLowerCase()
}
def buildGreeting(String name) {
return "Hello, " + normalizeName(name)
}
@@ -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
}
@@ -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 "<entry>"
assert "<entry>" 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]