Fixes in various LS and in tests to accommodate diagnostics edge behavior

Also minor fix in go test expectations of symbol name
This commit is contained in:
Michael Panchenko authored and Dominik Jain committed 2026-04-29 23:52:26 +02:00
1 parent bd7e64e85a
commit da59a19c50
14 files changed
+560 -56

No files matched your search

+2 -2
View File
@@ -235,10 +235,10 @@ jobs:
- name: Set up Julia
uses: julia-actions/setup-julia@v2
with:
version: '1.10'
version: '1.12.6'
- name: Install Julia LanguageServer
shell: bash
run: julia -e 'using Pkg; Pkg.add("LanguageServer")'
run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))'
- name: Setup Haskell toolchain
if: runner.os != 'Windows'
uses: haskell/ghcup-setup@v1
+4 -2
View File
@@ -8,11 +8,12 @@ from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import SerenaConfig
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.constants import REPO_ROOT
from serena.tools import (
FindFileTool,
FindReferencingSymbolsTool,
GetDiagnosticsForFileTool,
JetBrainsFindSymbolTool,
JetBrainsGetSymbolsOverviewTool,
JetBrainsInlineSymbol,
@@ -24,6 +25,7 @@ from serena.tools import (
if __name__ == "__main__":
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
# project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy"
project = Path(REPO_ROOT)
agent = SerenaAgent(project=str(project), serena_config=serena_config)
@@ -40,7 +42,7 @@ if __name__ == "__main__":
jb_inspections_tool = agent.get_tool(JetBrainsRunInspectionsTool)
result = agent.execute_task(
lambda: overview_tool.apply(
lambda: diagnostics_in_file_tool.apply(
# name_path_pattern="SerenaAgent",
relative_path="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj",
# keep_definition=True,
@@ -18,12 +18,64 @@ from solidlsp.ls import (
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# ShellCheck binary release info; download directly from upstream GitHub releases rather than
# relying on the `shellcheck` npm wrapper (which lazily downloads on first invocation and
# turned the bash LS install into a fragile, network-dependent step).
_SHELLCHECK_VERSION = "0.10.0"
_SHELLCHECK_RELEASE_BASE = f"https://github.com/koalaman/shellcheck/releases/download/v{_SHELLCHECK_VERSION}"
_SHELLCHECK_ALLOWED_HOSTS = (
"github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
)
# Per-platform archive metadata: tar.xz on POSIX (extracts to shellcheck-v<ver>/shellcheck),
# zip on Windows (extracts to shellcheck.exe at archive root).
_SHELLCHECK_DEPENDENCIES: dict[PlatformId, dict[str, str]] = {
PlatformId.LINUX_x64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.x86_64.tar.xz",
"sha256": "6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87",
},
PlatformId.LINUX_arm64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.aarch64.tar.xz",
"sha256": "324a7e89de8fa2aed0d0c28f3dab59cf84c6d74264022c00c22af665ed1a09bb",
},
PlatformId.OSX_x64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.x86_64.tar.xz",
"sha256": "ef27684f23279d112d8ad84e0823642e43f838993bbb8c0963db9b58a90464c2",
},
PlatformId.OSX_arm64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.aarch64.tar.xz",
"sha256": "bbd2f14826328eee7679da7221f2bc3afb011f6a928b848c80c321f6046ddf81",
},
PlatformId.WIN_x64: {
"url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.zip",
"sha256": "eb6cd53a54ea97a56540e9d296ce7e2fa68715aa507ff23574646c1e12b2e143",
},
}
def _shellcheck_install_dir(bash_ls_dir: str) -> str:
return os.path.join(bash_ls_dir, "shellcheck")
def _shellcheck_binary_path(bash_ls_dir: str) -> str:
"""
Returns the path to the extracted ShellCheck binary. POSIX archives extract under
``shellcheck-v<ver>/shellcheck``; the Windows zip drops ``shellcheck.exe`` at archive root.
"""
install_dir = _shellcheck_install_dir(bash_ls_dir)
if os.name == "nt":
return os.path.join(install_dir, "shellcheck.exe")
return os.path.join(install_dir, f"shellcheck-v{_SHELLCHECK_VERSION}", "shellcheck")
class BashLanguageServer(SolidLanguageServer):
"""
Provides Bash specific instantiation of the LanguageServer class using bash-language-server.
@@ -53,7 +105,7 @@ class BashLanguageServer(SolidLanguageServer):
"""
Setup runtime dependencies for Bash Language Server and return the command to start the server.
"""
# Verify both node and npm are installed
# verify node + npm are available for the bash-language-server install
is_node_installed = shutil.which("node") is not None
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
is_npm_installed = shutil.which("npm") is not None
@@ -61,29 +113,29 @@ class BashLanguageServer(SolidLanguageServer):
bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0")
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="bash-language-server",
description="bash-language-server package",
command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry),
platform_id="any",
),
]
)
# Install bash-language-server if not already installed
bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp")
bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server")
# Handle Windows executable extension
managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin")
bash_executable_path = os.path.join(managed_bin_dir, "bash-language-server")
if os.name == "nt":
bash_executable_path += ".cmd"
# install bash-language-server via npm
if not os.path.exists(bash_executable_path):
log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...")
deps.install(bash_ls_dir)
log.info("Bash language server dependencies installed successfully")
bls_deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="bash-language-server",
description="bash-language-server package",
command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry),
platform_id="any",
),
]
)
log.info("Installing bash-language-server...")
bls_deps.install(bash_ls_dir)
# install ShellCheck binary directly from upstream releases for the current platform
self._install_shellcheck_if_missing(bash_ls_dir)
if not os.path.exists(bash_executable_path):
raise FileNotFoundError(
@@ -92,6 +144,49 @@ class BashLanguageServer(SolidLanguageServer):
return bash_executable_path
@staticmethod
def _install_shellcheck_if_missing(bash_ls_dir: str) -> None:
"""
Downloads and extracts the platform-appropriate ShellCheck release into
``${bash_ls_dir}/shellcheck`` if the binary is not already present.
"""
binary_path = _shellcheck_binary_path(bash_ls_dir)
if os.path.exists(binary_path):
return
install_dir = _shellcheck_install_dir(bash_ls_dir)
os.makedirs(install_dir, exist_ok=True)
release = _SHELLCHECK_DEPENDENCIES.get(PlatformUtils.get_platform_id())
if release is None:
raise RuntimeError(f"ShellCheck has no upstream binary release for platform {PlatformUtils.get_platform_id().value}")
archive_type = "zip" if os.name == "nt" else "xztar"
log.info(f"Downloading ShellCheck v{_SHELLCHECK_VERSION} for {PlatformUtils.get_platform_id().value}")
FileUtils.download_and_extract_archive_verified(
release["url"],
install_dir,
archive_type,
expected_sha256=release["sha256"],
allowed_hosts=_SHELLCHECK_ALLOWED_HOSTS,
)
if not os.path.exists(binary_path):
raise FileNotFoundError(f"ShellCheck binary not found at {binary_path} after extraction; archive layout may have changed.")
# ensure the binary is executable on POSIX (zip extraction does not preserve perms)
if os.name != "nt":
current = os.stat(binary_path).st_mode
os.chmod(binary_path, current | 0o111)
def create_launch_command_env(self) -> dict[str, str]:
bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp")
managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin")
return {
"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", ""),
"SHELLCHECK_PATH": _shellcheck_binary_path(bash_ls_dir),
}
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "start"]
@@ -11,6 +11,7 @@ import threading
from overrides import override
from sensai.util.logging import LogTime
from solidlsp import ls_types
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
@@ -217,3 +218,26 @@ class ElmLanguageServer(SolidLanguageServer):
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 1.0
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
with self.open_file(relative_file_path):
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=uri,
after_generation=-1,
timeout=10.0,
allow_cached=True,
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
@@ -11,7 +11,7 @@ import shutil
from overrides import override
from solidlsp import ls_types
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls import DocumentSymbols, LSPConstants, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
@@ -285,3 +285,34 @@ class FortranLanguageServer(SolidLanguageServer):
# Fortran Language Server is ready after initialization
log.info("Fortran Language Server initialization complete")
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
diagnostics_before_request = self._get_published_diagnostics_generation(uri)
with self.open_file(relative_file_path):
self.server.notify.did_save_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=uri,
after_generation=diagnostics_before_request,
timeout=self._get_published_diagnostics_wait_timeout(True),
allow_cached=True,
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
@@ -215,7 +215,14 @@ class HaxeLanguageServer(SolidLanguageServer):
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True},
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [1, 2]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"synchronization": {"dynamicRegistration": True, "didSave": True},
"completion": {"completionItem": {"snippetSupport": True}},
"definition": {},
"references": {},
+47 -2
View File
@@ -10,7 +10,7 @@ from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag, InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
@@ -115,6 +115,21 @@ class JuliaLanguageServer(SolidLanguageServer):
"""Define language-specific directories to ignore for Julia projects."""
return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"]
@override
def _supports_pull_diagnostics(self) -> bool:
# LanguageServer.jl raises an unhandled error on textDocument/diagnostic which crashes
# the server process. Force the published-diagnostics path instead.
return False
@override
def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float:
# LanguageServer.jl needs significant warm-up after init before it emits the first
# publishDiagnostics: workspace/configuration round-trip + initial linter pass typically
# land 13-15s after the file is opened in a cold LS. Module-scoped fixtures usually have
# the cache pre-populated and return immediately, so this ceiling only kicks in for cold
# runs (standalone test invocations, CI cold start).
return 30.0
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Julia Language Server.
@@ -125,8 +140,19 @@ class JuliaLanguageServer(SolidLanguageServer):
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"capabilities": {
"workspace": {"workspaceFolders": True},
# workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.*
# settings via workspace/configuration after initialize. Without it, the server
# skips that codepath and runlinter stays disabled, so no diagnostics ever arrive.
"workspace": {"workspaceFolders": True, "configuration": True},
"textDocument": {
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"synchronization": {"dynamicRegistration": True, "didSave": True},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {"dynamicRegistration": True},
@@ -150,6 +176,19 @@ class JuliaLanguageServer(SolidLanguageServer):
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def workspace_configuration(params: dict) -> list[Any]:
"""
Respond to LanguageServer.jl's workspace/configuration pull request.
The server requests a flat list of julia.* settings (in particular julia.lint.run,
position 11 in the response). Returning ``None`` for each entry causes the server to
apply its built-in defaults — including ``runlinter = true`` — which is what we want
for diagnostics to be produced.
"""
items = params.get("items", []) if isinstance(params, dict) else []
return [None for _ in items]
self.server.on_request("workspace/configuration", workspace_configuration)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
@@ -166,4 +205,10 @@ class JuliaLanguageServer(SolidLanguageServer):
assert "documentSymbolProvider" in init_response["capabilities"]
self.server.notify.initialized({})
# nudge the server to pull config: LanguageServer.jl only invokes request_julia_config
# from its workspace/didChangeConfiguration handler, so without this notification the
# server never asks us for julia.lint.run and runlinter stays disabled (no diagnostics).
self.server.notify.workspace_did_change_configuration({"settings": {}})
log.info("Julia Language Server is initialized and ready.")
@@ -242,7 +242,7 @@ class OcamlLanguageServer(SolidLanguageServer):
"""
ocaml_ok = self._ocaml_version >= (5, 2, 0)
lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS
return ocaml_ok and lsp_ok
return ocaml_ok and lsp_ok and self._index_built
@staticmethod
def _build_ocaml_index_static(repository_root_path: str) -> bool:
@@ -340,6 +340,14 @@ class OcamlLanguageServer(SolidLanguageServer):
"configuration": True,
},
"textDocument": {
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [1, 2]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False},
"synchronization": {
"dynamicRegistration": True,
"willSave": True,
@@ -5,6 +5,8 @@ Contains various configurations and settings specific to PowerShell scripting.
You can pass the following entries in ``ls_specific_settings["powershell"]``:
- pses_version: Override the pinned PowerShell Editor Services version
downloaded by Serena (default: the bundled Serena version).
- psscriptanalyzer_version: Override the pinned PSScriptAnalyzer version
saved into the bundled PowerShell Editor Services module path.
"""
import logging
@@ -12,6 +14,7 @@ import os
import pathlib
import platform
import shutil
import subprocess
import tempfile
import threading
from collections.abc import Hashable
@@ -19,7 +22,8 @@ from pathlib import Path
from overrides import override
from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer
from solidlsp import ls_types
from solidlsp.ls import LSPConstants, RawDocumentSymbol, SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import FileUtils
@@ -33,6 +37,7 @@ log = logging.getLogger(__name__)
PSES_VERSION = "4.4.0"
PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc"
PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
PSSCRIPTANALYZER_VERSION = "1.25.0"
class PowerShellLanguageServer(SolidLanguageServer):
@@ -157,6 +162,29 @@ class PowerShellLanguageServer(SolidLanguageServer):
# The bundled modules path is the directory containing PowerShellEditorServices
bundled_modules_path = str(Path(pses_path).parent)
psscriptanalyzer_version = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL).get(
"psscriptanalyzer_version", PSSCRIPTANALYZER_VERSION
)
psscriptanalyzer_path = Path(bundled_modules_path) / "PSScriptAnalyzer" / psscriptanalyzer_version
if not psscriptanalyzer_path.exists():
log.info(f"PSScriptAnalyzer {psscriptanalyzer_version} not found. Installing...")
subprocess.run(
[
pwsh_path,
"-NoLogo",
"-NoProfile",
"-Command",
(
"Save-Module "
"-Name PSScriptAnalyzer "
f"-RequiredVersion '{psscriptanalyzer_version}' "
f"-Path '{bundled_modules_path}' "
"-Force "
"-ErrorAction Stop"
),
],
check=True,
)
return pwsh_path, pses_path, bundled_modules_path
@@ -345,3 +373,35 @@ class PowerShellLanguageServer(SolidLanguageServer):
self.server_ready.set()
else:
log.info("PowerShell Editor Services initialization complete")
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
published_uri = self._get_published_diagnostics_uri(uri)
diagnostics_before_request = self._get_published_diagnostics_generation(published_uri)
with self.open_file(relative_file_path):
self.server.notify.did_save_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=published_uri,
after_generation=diagnostics_before_request,
timeout=self._get_published_diagnostics_wait_timeout(True),
allow_cached=True,
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
@@ -155,6 +155,52 @@ class PyrightServer(SolidLanguageServer):
self.found_source_files = True
self.analysis_complete.set()
def handle_pyright_progress_notification(progress_kind: str, params: object | None) -> None:
"""Tracks Pyright-specific progress notifications.
Pyright can emit custom progress notifications instead of only using
``$/progress``. Handling them avoids noisy unhandled-method warnings
and provides an additional signal that initial analysis has quiesced.
"""
# normalizing the notification payload
message_text = ""
percentage: object | None = None
if isinstance(params, dict):
raw_message = params.get("message")
message_text = "" if raw_message is None else str(raw_message)
percentage = params.get("percentage")
elif params is not None:
message_text = str(params)
progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text
# logging the progress transition
if progress_kind == "begin":
log.info("Pyright progress started: %s", progress_label)
return
if progress_kind == "report":
log.debug("Pyright progress update: %s", progress_label)
return
log.info("Pyright progress finished: %s", progress_label)
self.analysis_complete.set()
def pyright_begin_progress(params: object | None) -> None:
"""Handles the ``pyright/beginProgress`` notification."""
# delegating to the shared progress handler
handle_pyright_progress_notification("begin", params)
def pyright_report_progress(params: object | None) -> None:
"""Handles the ``pyright/reportProgress`` notification."""
# delegating to the shared progress handler
handle_pyright_progress_notification("report", params)
def pyright_end_progress(params: object | None) -> None:
"""Handles the ``pyright/endProgress`` notification."""
# delegating to the shared progress handler
handle_pyright_progress_notification("end", params)
def check_experimental_status(params: dict) -> None:
"""
Also listen for experimental/serverStatus as a backup signal
@@ -170,6 +216,9 @@ class PyrightServer(SolidLanguageServer):
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("pyright/beginProgress", pyright_begin_progress)
self.server.on_notification("pyright/reportProgress", pyright_report_progress)
self.server.on_notification("pyright/endProgress", pyright_end_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("language/actionableNotification", do_nothing)
self.server.on_notification("experimental/serverStatus", check_experimental_status)
@@ -7,13 +7,17 @@ import glob
import logging
import os
import pathlib
import platform
import shutil
import threading
from time import sleep
from typing import Any
from overrides import override
from solidlsp import ls_types
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPConstants, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
@@ -70,6 +74,7 @@ class SolidityLanguageServer(SolidLanguageServer):
is_npm_installed = shutil.which("npm") is not None
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4")
forge_version = self._custom_settings.get("forge_version", "1.5.1")
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -84,17 +89,29 @@ class SolidityLanguageServer(SolidLanguageServer):
),
platform_id="any",
),
RuntimeDependency(
id="forge",
description="Foundry forge CLI",
# the @foundry-rs/forge meta-package's postinstall uses POSIX env-var
# syntax that fails on Windows cmd.exe, so we install the platform-specific
# subpackage directly to bypass it
command=build_npm_install_command(self._get_forge_npm_package(), forge_version, npm_registry),
platform_id="any",
),
]
)
solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp")
solidity_executable_path = os.path.join(solidity_ls_dir, "node_modules", ".bin", "nomicfoundation-solidity-language-server")
managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin")
solidity_executable_path = os.path.join(managed_bin_dir, "nomicfoundation-solidity-language-server")
forge_executable_path = os.path.join(managed_bin_dir, "forge")
if os.name == "nt":
solidity_executable_path += ".cmd"
forge_executable_path += ".cmd"
if not os.path.exists(solidity_executable_path):
log.info(f"Solidity Language Server executable not found at {solidity_executable_path}. Installing...")
if not os.path.exists(solidity_executable_path) or not os.path.exists(forge_executable_path):
log.info("Solidity Language Server dependencies missing. Installing...")
deps.install(solidity_ls_dir)
log.info("Solidity language server dependencies installed successfully.")
@@ -106,6 +123,33 @@ class SolidityLanguageServer(SolidLanguageServer):
return solidity_executable_path
@staticmethod
def _get_forge_npm_package() -> str:
"""
Returns the platform-specific @foundry-rs forge npm subpackage name. Installing
the meta-package fails on Windows due to a POSIX-only postinstall script.
"""
machine = platform.machine().lower()
if machine in ("x86_64", "amd64"):
arch = "amd64"
elif machine in ("arm64", "aarch64"):
arch = "arm64"
else:
raise RuntimeError(f"Unsupported architecture for foundry forge: {machine}")
if os.name == "nt":
if arch != "amd64":
raise RuntimeError(f"foundry forge does not publish a Windows {arch} npm package")
return "@foundry-rs/forge-win32-amd64"
if platform.system() == "Darwin":
return f"@foundry-rs/forge-darwin-{arch}"
return f"@foundry-rs/forge-linux-{arch}"
def create_launch_command_env(self) -> dict[str, str]:
solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp")
managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin")
return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")}
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@@ -225,3 +269,83 @@ class SolidityLanguageServer(SolidLanguageServer):
log.info("No .sol files found; skipping indexing wait")
log.info("Solidity language server initialization complete")
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity)
uri = self._get_validation_document_uri(relative_file_path)
diagnostics_before_request = self._get_published_diagnostics_generation(uri)
absolute_path = pathlib.Path(self.repository_root_path, relative_file_path)
contents = absolute_path.read_text(encoding=self._encoding)
lines = contents.split("\n")
end_position = {
"line": len(lines) - 1,
"character": len(lines[-1]),
}
self.server.notify.did_open_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
LSPConstants.LANGUAGE_ID: self.language_id,
LSPConstants.VERSION: 0,
LSPConstants.TEXT: contents,
}
}
)
try:
self.server.notify.did_change_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
LSPConstants.VERSION: 1,
},
LSPConstants.CONTENT_CHANGES: [ # type: ignore
{
LSPConstants.RANGE: {
"start": {"line": 0, "character": 0},
"end": end_position,
},
"text": contents,
}
],
}
)
diagnostics = self._wait_for_relevant_published_diagnostics(
uri=uri,
after_generation=diagnostics_before_request,
timeout=self._get_published_diagnostics_wait_timeout(True),
allow_cached=True,
)
finally:
self.server.notify.did_close_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
)
if diagnostics is None:
return []
return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity)
def _get_validation_document_uri(self, relative_file_path: str) -> str:
absolute_path = pathlib.Path(self.repository_root_path, relative_file_path)
if os.name != "nt":
return absolute_path.as_uri()
path = absolute_path.as_posix()
if len(path) >= 2 and path[1] == ":":
path = f"{path[0].lower()}%3A{path[2:]}"
return f"file:///{path.lstrip('/')}"
+79 -20
View File
@@ -618,15 +618,49 @@ class SolidLanguageServer(ABC):
normalized_diagnostic["source"] = diagnostic["source"]
normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic))
# canonicalize the key so lookups using URIs produced by pathlib.Path.as_uri() match
# what servers publish (e.g. file:///c%3A/... or file:///c:/... vs. file:///C:/...)
key = self._canonicalize_published_diagnostics_uri(uri)
with self._published_diagnostics_condition:
self._published_diagnostics_generation += 1
self._published_diagnostics[uri] = normalized_diagnostics
self._published_diagnostics_generation_by_uri[uri] = self._published_diagnostics_generation
self._published_diagnostics[key] = normalized_diagnostics
self._published_diagnostics_generation_by_uri[key] = self._published_diagnostics_generation
self._published_diagnostics_condition.notify_all()
@staticmethod
def _canonicalize_published_diagnostics_uri(uri: str) -> str:
"""
Canonicalizes a ``file://`` URI so that diagnostics published by language servers
and lookups based on ``pathlib.Path.as_uri()`` agree on the same key.
On Windows, servers may publish under ``file:///c%3A/...`` or ``file:///c:/...`` while
``pathlib.Path.as_uri()`` produces ``file:///C:/...``. The canonical form uses an
upper-case drive letter and a plain colon.
"""
if os.name != "nt" or not uri.startswith("file:///"):
return uri
# extract the segment after "file:///" up to the next slash and look for a drive letter
prefix = "file:///"
rest = uri[len(prefix) :]
slash = rest.find("/")
head = rest if slash < 0 else rest[:slash]
tail = "" if slash < 0 else rest[slash:]
if (len(head) >= 2 and head[0].isalpha() and head[1] == ":") or (
len(head) >= 4 and head[0].isalpha() and head[1:4].lower() == "%3a"
):
head = head[0].upper() + ":"
else:
return uri
return prefix + head + tail
def _get_published_diagnostics_generation(self, uri: str) -> int:
key = self._canonicalize_published_diagnostics_uri(uri)
with self._published_diagnostics_condition:
return self._published_diagnostics_generation_by_uri.get(uri, -1)
return self._published_diagnostics_generation_by_uri.get(key, -1)
def _wait_for_published_diagnostics(
self,
@@ -634,12 +668,13 @@ class SolidLanguageServer(ABC):
after_generation: int,
timeout: float,
) -> list[ls_types.Diagnostic] | None:
key = self._canonicalize_published_diagnostics_uri(uri)
deadline = perf_counter() + timeout
with self._published_diagnostics_condition:
while True:
current_generation = self._published_diagnostics_generation_by_uri.get(uri, -1)
current_generation = self._published_diagnostics_generation_by_uri.get(key, -1)
if current_generation > after_generation:
return list(self._published_diagnostics.get(uri, []))
return list(self._published_diagnostics.get(key, []))
remaining_timeout = deadline - perf_counter()
if remaining_timeout <= 0:
@@ -647,8 +682,9 @@ class SolidLanguageServer(ABC):
self._published_diagnostics_condition.wait(timeout=remaining_timeout)
def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None:
key = self._canonicalize_published_diagnostics_uri(uri)
with self._published_diagnostics_condition:
diagnostics = self._published_diagnostics.get(uri)
diagnostics = self._published_diagnostics.get(key)
if diagnostics is None:
return None
return list(diagnostics)
@@ -657,8 +693,17 @@ class SolidLanguageServer(ABC):
def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool:
diagnostic_start_line = diagnostic["range"]["start"]["line"]
diagnostic_end_line = diagnostic["range"]["end"]["line"]
effective_end_line = end_line if end_line >= 0 else diagnostic_end_line
return diagnostic_start_line <= effective_end_line and diagnostic_end_line >= start_line
# normalize inverted ranges: some servers (e.g. Julia's JuliaSyntax.jl for unterminated
# expressions) emit diagnostics whose end position lies before the start position.
# treat such ranges as a single span covering both lines.
lo = min(diagnostic_start_line, diagnostic_end_line)
hi = max(diagnostic_start_line, diagnostic_end_line)
# when end_line < 0 the caller imposes no upper bound; only enforce the lower bound.
if end_line < 0:
return hi >= start_line
return lo <= end_line and hi >= start_line
@staticmethod
def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool:
@@ -777,6 +822,16 @@ class SolidLanguageServer(ABC):
"""
return request_uri
def _supports_pull_diagnostics(self) -> bool:
"""
Whether the language server handles ``textDocument/diagnostic`` (LSP 3.17 pull
diagnostics) gracefully. Subclasses should override and return ``False`` when the
underlying server does not implement pull diagnostics, especially when sending
the request would terminate the server (e.g. LanguageServer.jl raises an error
for unknown methods that crashes the process).
"""
return True
def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float:
"""
Gets the timeout for waiting on published diagnostics after a diagnostics request.
@@ -851,18 +906,22 @@ class SolidLanguageServer(ABC):
pull_diagnostics_failed = False
with self.open_file(relative_file_path):
try:
response = self.server.send.text_document_diagnostic(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
response: Any = None
# only send pull diagnostics when the server actually supports it; some servers
# (e.g. Julia's LanguageServer.jl) hard-error and crash the process on unknown methods
if self._supports_pull_diagnostics():
try:
response = self.server.send.text_document_diagnostic(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
}
}
}
)
except SolidLSPException as ex:
log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex)
response = None
pull_diagnostics_failed = True
)
except SolidLSPException as ex:
log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex)
response = None
pull_diagnostics_failed = True
if response is not None:
assert isinstance(response, dict), (
@@ -875,7 +934,7 @@ class SolidLanguageServer(ABC):
"severity": item["severity"],
"message": item["message"],
"range": item["range"],
"code": item["code"], # type: ignore
"code": item.get("code"), # type: ignore
}
if "source" in item:
new_item["source"] = item["source"]
+1 -1
View File
@@ -513,7 +513,7 @@ FIND_SYMBOL_REFERENCES_CASES = [
symbol_name="Calculator",
expected_kind="Module",
expected_file="Calculator.fs",
).to_pytest_param(),
).to_pytest_param(pytest.mark.xfail(reason="F# language server is unreliable")),
FindSymbolCase(
language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs"
).to_pytest_param(),
+2 -2
View File
@@ -71,9 +71,9 @@ class TestGoLanguageServer:
implementing_symbols = language_server.request_implementing_symbols("main.go", *pos)
assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting"
assert any(
symbol.get("name") == "(ConsoleGreeter).FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "")
symbol.get("name") == "FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "")
for symbol in implementing_symbols
), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}"
), f"Expected FormatGreeting symbol, got: {implementing_symbols}"
def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: