mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 12:17:02 +00:00
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:
1 parent
27d7d0e0ec
commit
76b94f3274
17 files changed
+529
-9
No files matched your search
@@ -323,6 +323,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
|
||||
".lua",
|
||||
".m",
|
||||
".matlab",
|
||||
".nf",
|
||||
".php",
|
||||
".proto",
|
||||
".ps1",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user