updated: elixir language server, replaced with expert (#783)

This commit is contained in:
Lenz Gschwendtner authored and GitHub committed 2025-12-11 15:57:13 +07:00
1 parent fb01cc38f7
commit 2ec96089f6
11 files changed
+210 -200

No files matched your search

+2 -2
View File
@@ -90,8 +90,8 @@ jobs:
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: erlef/setup-beam@v1 uses: erlef/setup-beam@v1
with: with:
elixir-version: "1.18.4" elixir-version: "1.19.3"
otp-version: "26.1" otp-version: "28"
# Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu # Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu
# In local tests, erlang seems to work though # In local tests, erlang seems to work though
# - name: Install Erlang Language Server # - name: Install Erlang Language Server
+1 -1
View File
@@ -20,7 +20,7 @@ With Serena, we provide direct, out-of-the-box support for:
* **Clojure** * **Clojure**
* **Dart** * **Dart**
* **Elixir** * **Elixir**
(requires installation of NextLS and Elixir; Windows not supported) (requires Elixir installation; Expert language server is downloaded automatically)
* **Elm** * **Elm**
(requires Elm compiler) (requires Elm compiler)
* **Erlang** * **Erlang**
+1
View File
@@ -301,6 +301,7 @@ markers = [
"fortran: language server running for Fortran", "fortran: language server running for Fortran",
"haskell: Haskell language server tests", "haskell: Haskell language server tests",
"yaml: language server running for YAML", "yaml: language server running for YAML",
"slow: tests that require additional Expert instances and have long startup times (~60-90s each)",
] ]
[tool.codespell] [tool.codespell]
@@ -1,18 +1,6 @@
# Elixir Language Server Integration # Elixir Language Server Integration
This directory contains the integration for Elixir language support using [Next LS](https://github.com/elixir-tools/next-ls) from the elixir-tools project. This directory contains the integration for Elixir language support using [Expert](https://github.com/elixir-lang/expert), the official Elixir language server.
> **⚠️ Windows Not Supported**: Next LS does not provide Windows binaries, so Elixir language server integration is only available on Linux and macOS.
## Known Issues
### Next LS v0.23.3 Timeout Enumeration Bug
There is a known intermittent bug in Next LS v0.23.3 where `textDocument/definition` requests can fail with:
```
Protocol.UndefinedError: protocol Enumerable not implemented for :timeout of type Atom
```
This bug is tracked in [Next LS Issue #543](https://github.com/elixir-tools/next-ls/issues/543) and primarily occurs in CI environments. The affected test (`test_request_defining_symbol_none`) is marked as expected to fail until this upstream bug is resolved.
## Prerequisites ## Prerequisites
@@ -22,9 +10,10 @@ Before using the Elixir language server integration, you need to have:
- Install from: https://elixir-lang.org/install.html - Install from: https://elixir-lang.org/install.html
- Verify with: `elixir --version` - Verify with: `elixir --version`
2. **Next LS** installed and available in your PATH 2. **Expert** (optional - will be downloaded automatically if not found)
- Install from: https://github.com/elixir-tools/next-ls#installation - Expert binaries are automatically downloaded from GitHub releases
- Verify with: `nextls --version` - Manual installation: https://github.com/elixir-lang/expert#installation
- If installed manually, ensure `expert` is in your PATH
## Features ## Features
@@ -42,13 +31,20 @@ The Elixir integration provides:
## Configuration ## Configuration
The integration uses the default Next LS configuration with: The integration uses the default Expert configuration with:
- **MIX_ENV**: `dev` - **MIX_ENV**: `dev`
- **MIX_TARGET**: `host` - **MIX_TARGET**: `host`
- **Experimental completions**: Disabled by default - **Experimental completions**: Disabled by default
- **Credo extension**: Enabled by default - **Credo extension**: Enabled by default
### Version Management (asdf)
Expert automatically respects project-specific Elixir versions when using asdf:
- If a `.tool-versions` file exists in the project root, Expert will use the specified Elixir version
- Expert is launched from the project directory, allowing it to pick up project configuration
- No additional configuration needed - just ensure asdf is installed and the project has a `.tool-versions` file
## Usage ## Usage
The Elixir language server is automatically selected when working with Elixir projects. It will be used for: The Elixir language server is automatically selected when working with Elixir projects. It will be used for:
@@ -63,14 +59,14 @@ The Elixir language server is automatically selected when working with Elixir pr
### Important: Project Compilation ### Important: Project Compilation
Next LS requires your Elixir project to be **compiled** for optimal performance, especially for: Expert requires your Elixir project to be **compiled** for optimal performance, especially for:
- Cross-file reference resolution - Cross-file reference resolution
- Complete symbol information - Complete symbol information
- Accurate go-to-definition - Accurate go-to-definition
**For production use**: Ensure your project is compiled with `mix compile` before using the language server. **For production use**: Ensure your project is compiled with `mix compile` before using the language server.
**For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Next LS performance. **For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Expert performance.
## Testing ## Testing
@@ -83,8 +79,9 @@ pytest test/solidlsp/elixir/ -m elixir
## Implementation Details ## Implementation Details
- **Main class**: `ElixirTools` in `elixir_tools.py` - **Main class**: `ElixirTools` in `elixir_tools.py`
- **Initialization parameters**: Defined in `initialize_params.json`
- **Language identifier**: `"elixir"` - **Language identifier**: `"elixir"`
- **Command**: `nextls --stdio` - **Command**: `expert --stdio`
- **Supported platforms**: Linux (x64, arm64), macOS (x64, arm64), Windows (x64, arm64)
- **Binary distribution**: Downloaded from [GitHub releases](https://github.com/elixir-lang/expert/releases)
The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior. The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior.
@@ -11,7 +11,6 @@ from overrides import override
from solidlsp.ls import SolidLanguageServer from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils
from solidlsp.lsp_protocol_handler import lsp_types
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings from solidlsp.settings import SolidLSPSettings
@@ -23,12 +22,12 @@ log = logging.getLogger(__name__)
class ElixirTools(SolidLanguageServer): class ElixirTools(SolidLanguageServer):
""" """
Provides Elixir specific instantiation of the LanguageServer class using Next LS from elixir-tools. Provides Elixir specific instantiation of the LanguageServer class using Expert, the official Elixir language server.
""" """
@override @override
def _get_wait_time_for_cross_file_referencing(self) -> float: def _get_wait_time_for_cross_file_referencing(self) -> float:
return 10.0 # Elixir projects need a lot of time to compile and index before cross-file references work return 10.0 # Elixir projects need time to compile and index before cross-file references work
@override @override
def is_ignored_dirname(self, dirname: str) -> bool: def is_ignored_dirname(self, dirname: str) -> bool:
@@ -38,7 +37,8 @@ class ElixirTools(SolidLanguageServer):
# - node_modules: if the project has JavaScript components # - node_modules: if the project has JavaScript components
# - .elixir_ls: ElixirLS artifacts (in case both are present) # - .elixir_ls: ElixirLS artifacts (in case both are present)
# - cover: coverage reports # - cover: coverage reports
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", "cover"] # - .expert: Expert artifacts
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", ".expert", "cover"]
@override @override
def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
@@ -49,42 +49,6 @@ class ElixirTools(SolidLanguageServer):
return super().is_ignored_path(relative_path, ignore_unsupported_files) return super().is_ignored_path(relative_path, ignore_unsupported_files)
@staticmethod
def _is_next_ls_internal_file(abs_path: str) -> bool:
"""Check if an absolute path is a Next LS internal file that should be ignored."""
return any(
pattern in abs_path
for pattern in [
".burrito", # Next LS runtime directory
"next_ls_erts-", # Next LS Erlang runtime
"_next_ls_private_", # Next LS private files
"/priv/monkey/", # Next LS monkey patching directory
]
)
@override
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
"""Override to filter out Next LS internal files from references."""
from solidlsp.ls_utils import PathUtils
# Get the raw response from the parent implementation
raw_response = super()._send_references_request(relative_file_path, line, column)
if raw_response is None:
return None
# Filter out Next LS internal files
filtered_response = []
for item in raw_response:
if isinstance(item, dict) and "uri" in item:
abs_path = PathUtils.uri_to_path(item["uri"])
if self._is_next_ls_internal_file(abs_path):
log.debug(f"Filtering out Next LS internal file: {abs_path}")
continue
filtered_response.append(item)
return filtered_response
@classmethod @classmethod
def _get_elixir_version(cls) -> str | None: def _get_elixir_version(cls) -> str | None:
"""Get the installed Elixir version or None if not found.""" """Get the installed Elixir version or None if not found."""
@@ -99,8 +63,8 @@ class ElixirTools(SolidLanguageServer):
@classmethod @classmethod
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
""" """
Setup runtime dependencies for Next LS. Setup runtime dependencies for Expert.
Downloads the Next LS binary for the current platform and returns the path to the executable. Downloads the Expert binary for the current platform and returns the path to the executable.
""" """
# Check if Elixir is available first # Check if Elixir is available first
elixir_version = cls._get_elixir_version() elixir_version = cls._get_elixir_version()
@@ -111,113 +75,137 @@ class ElixirTools(SolidLanguageServer):
log.info(f"Found Elixir: {elixir_version}") log.info(f"Found Elixir: {elixir_version}")
platform_id = PlatformUtils.get_platform_id() # First, check if expert is already in PATH (user may have installed it manually)
import shutil
# Check for Windows and provide a helpful error message expert_in_path = shutil.which("expert")
if platform_id.value.startswith("win"): if expert_in_path:
raise RuntimeError( log.info(f"Found Expert in PATH: {expert_in_path}")
"Windows is not supported by Next LS. The Next LS project does not provide Windows binaries. " return expert_in_path
"Consider using Windows Subsystem for Linux (WSL) or a virtual machine with Linux/macOS."
) platform_id = PlatformUtils.get_platform_id()
valid_platforms = [ valid_platforms = [
PlatformId.LINUX_x64, PlatformId.LINUX_x64,
PlatformId.LINUX_arm64, PlatformId.LINUX_arm64,
PlatformId.OSX_x64, PlatformId.OSX_x64,
PlatformId.OSX_arm64, PlatformId.OSX_arm64,
PlatformId.WIN_x64,
PlatformId.WIN_arm64,
] ]
assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for Next LS at the moment" assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for Expert at the moment"
next_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "next-ls") expert_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "expert")
NEXTLS_VERSION = "v0.23.4" EXPERT_VERSION = "nightly"
# Define runtime dependencies inline # Define runtime dependencies inline
runtime_deps = { runtime_deps = {
PlatformId.LINUX_x64: RuntimeDependency( PlatformId.LINUX_x64: RuntimeDependency(
id="next_ls_linux_amd64", id="expert_linux_amd64",
platform_id="linux-x64", platform_id="linux-x64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_linux_amd64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_linux_amd64",
archive_type="binary", archive_type="binary",
binary_name="next_ls_linux_amd64", binary_name="expert_linux_amd64",
extract_path="next_ls", extract_path="expert",
), ),
PlatformId.LINUX_arm64: RuntimeDependency( PlatformId.LINUX_arm64: RuntimeDependency(
id="next_ls_linux_arm64", id="expert_linux_arm64",
platform_id="linux-arm64", platform_id="linux-arm64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_linux_arm64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_linux_arm64",
archive_type="binary", archive_type="binary",
binary_name="next_ls_linux_arm64", binary_name="expert_linux_arm64",
extract_path="next_ls", extract_path="expert",
), ),
PlatformId.OSX_x64: RuntimeDependency( PlatformId.OSX_x64: RuntimeDependency(
id="next_ls_darwin_amd64", id="expert_darwin_amd64",
platform_id="osx-x64", platform_id="osx-x64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_darwin_amd64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_darwin_amd64",
archive_type="binary", archive_type="binary",
binary_name="next_ls_darwin_amd64", binary_name="expert_darwin_amd64",
extract_path="next_ls", extract_path="expert",
), ),
PlatformId.OSX_arm64: RuntimeDependency( PlatformId.OSX_arm64: RuntimeDependency(
id="next_ls_darwin_arm64", id="expert_darwin_arm64",
platform_id="osx-arm64", platform_id="osx-arm64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_darwin_arm64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_darwin_arm64",
archive_type="binary", archive_type="binary",
binary_name="next_ls_darwin_arm64", binary_name="expert_darwin_arm64",
extract_path="next_ls", extract_path="expert",
),
PlatformId.WIN_x64: RuntimeDependency(
id="expert_windows_amd64",
platform_id="win-x64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_windows_amd64.exe",
archive_type="binary",
binary_name="expert_windows_amd64.exe",
extract_path="expert.exe",
),
PlatformId.WIN_arm64: RuntimeDependency(
id="expert_windows_arm64",
platform_id="win-arm64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_windows_arm64.exe",
archive_type="binary",
binary_name="expert_windows_arm64.exe",
extract_path="expert.exe",
), ),
} }
dependency = runtime_deps[platform_id] dependency = runtime_deps[platform_id]
executable_path = os.path.join(next_ls_dir, "nextls") # On Windows, use .exe extension
executable_name = "expert.exe" if platform_id.value.startswith("win") else "expert"
executable_path = os.path.join(expert_dir, executable_name)
assert dependency.binary_name is not None assert dependency.binary_name is not None
binary_path = os.path.join(next_ls_dir, dependency.binary_name) binary_path = os.path.join(expert_dir, dependency.binary_name)
if not os.path.exists(executable_path): if not os.path.exists(executable_path):
log.info(f"Downloading Next LS binary from {dependency.url}") log.info(f"Downloading Expert binary from {dependency.url}")
assert dependency.url is not None assert dependency.url is not None
FileUtils.download_file(dependency.url, binary_path) FileUtils.download_file(dependency.url, binary_path)
# Make the binary executable on Unix-like systems # Make the binary executable on Unix-like systems
os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) if not platform_id.value.startswith("win"):
os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
# Create a symlink with the expected name # Create a symlink with the expected name on Unix-like systems
if binary_path != executable_path: if binary_path != executable_path and not platform_id.value.startswith("win"):
if os.path.exists(executable_path): if os.path.exists(executable_path):
os.remove(executable_path) os.remove(executable_path)
os.symlink(os.path.basename(binary_path), executable_path) os.symlink(os.path.basename(binary_path), executable_path)
assert os.path.exists(executable_path), f"Next LS executable not found at {executable_path}" assert os.path.exists(executable_path), f"Expert executable not found at {executable_path}"
log.info(f"Next LS binary ready at: {executable_path}") log.info(f"Expert binary ready at: {executable_path}")
return executable_path return executable_path
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
nextls_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) expert_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
super().__init__( super().__init__(
config, config,
repository_root_path, repository_root_path,
ProcessLaunchInfo(cmd=f'"{nextls_executable_path}" --stdio', cwd=repository_root_path), ProcessLaunchInfo(cmd=f"{expert_executable_path} --stdio", cwd=repository_root_path),
"elixir", "elixir",
solidlsp_settings, solidlsp_settings,
) )
self.server_ready = threading.Event() self.server_ready = threading.Event()
self.request_id = 0 self.request_id = 0
# Set generous timeout for Next LS which can be slow to initialize and respond # Set generous timeout for Expert which can be slow to initialize and respond
self.set_request_timeout(180.0) # 60 seconds for all environments self.set_request_timeout(180.0)
@staticmethod @staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
""" """
Returns the initialize params for the Next LS Language Server. Returns the initialize params for the Expert Language Server.
""" """
root_uri = pathlib.Path(repository_absolute_path).as_uri() # Ensure the path is absolute
abs_path = os.path.abspath(repository_absolute_path)
root_uri = pathlib.Path(abs_path).as_uri()
initialize_params = { initialize_params = {
"processId": os.getpid(), "processId": os.getpid(),
"locale": "en", "locale": "en",
"rootPath": repository_absolute_path, "rootPath": abs_path,
"rootUri": root_uri, "rootUri": root_uri,
"initializationOptions": { "initializationOptions": {
"mix_env": "dev", "mix_env": "dev",
@@ -275,89 +263,80 @@ class ElixirTools(SolidLanguageServer):
return cast(InitializeParams, initialize_params) return cast(InitializeParams, initialize_params)
def _start_server(self) -> None: def _start_server(self) -> None:
"""Start Next LS server process""" """Start Expert server process"""
def register_capability_handler(params: Any) -> None: def register_capability_handler(params: Any) -> None:
log.debug(f"LSP: client/registerCapability: {params}")
return return
def window_log_message(msg: Any) -> None: def window_log_message(msg: Any) -> None:
"""Handle window/logMessage notifications from Next LS""" """Handle window/logMessage notifications from Expert"""
message_type = msg.get("type", 4) # 1=Error, 2=Warning, 3=Info, 4=Log
message_text = msg.get("message", "") message_text = msg.get("message", "")
log.info(f"LSP: window/logMessage: {message_text}")
# Check for the specific Next LS readiness signal # Log at appropriate level based on message type
# Based on Next LS source: "Runtime for folder #{name} is ready..." if message_type == 1:
if "Runtime for folder" in message_text and "is ready..." in message_text: log.error(f"Expert: {message_text}")
log.info("Next LS runtime is ready based on official log message") elif message_type == 2:
self.server_ready.set() log.warning(f"Expert: {message_text}")
else:
def do_nothing(params: Any) -> None: log.debug(f"Expert: {message_text}")
return
def check_server_ready(params: Any) -> None: def check_server_ready(params: Any) -> None:
""" """
Handle $/progress notifications from Next LS. Handle $/progress notifications from Expert.
Keep as fallback for error detection, but primary readiness detection Expert sends progress updates during compilation and indexing.
is now done via window/logMessage handler. The server is considered ready when project build completes.
""" """
value = params.get("value", {}) value = params.get("value", {})
kind = value.get("kind", "")
title = value.get("title", "")
# Check for initialization completion progress (fallback signal) if kind == "begin":
if value.get("kind") == "end": # Track when building the project starts (not "Building engine")
message = value.get("message", "") if title.startswith("Building ") and not title.startswith("Building engine"):
if "has initialized!" in message: self._building_project = True
log.info("Next LS initialization progress completed") elif kind == "end":
# Note: We don't set server_ready here - we wait for the log message # Project build completion is the main readiness signal
if getattr(self, "_building_project", False):
log.debug("Expert project build completed - server is ready")
self._building_project = False
self.server_ready.set()
def work_done_progress(params: Any) -> None: def work_done_progress_create(params: Any) -> None:
""" """Handle window/workDoneProgress/create requests from Expert."""
Handle $/workDoneProgress notifications from Next LS. return
Keep for completeness but primary readiness detection is via window/logMessage.
""" def publish_diagnostics(params: Any) -> None:
value = params.get("value", {}) """Handle textDocument/publishDiagnostics notifications."""
if value.get("kind") == "end": return
log.info("Next LS work done progress completed")
# Note: We don't set server_ready here - we wait for the log message
self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", check_server_ready) self.server.on_notification("$/progress", check_server_ready)
self.server.on_request("window/workDoneProgress/create", do_nothing) self.server.on_request("window/workDoneProgress/create", work_done_progress_create)
self.server.on_notification("$/workDoneProgress", work_done_progress) self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting Next LS server process") log.debug("Starting Expert server process")
self.server.start() self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path) initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response") log.debug("Sending initialize request to Expert")
init_response = self.server.send.initialize(initialize_params) init_response = self.server.send.initialize(initialize_params)
# Verify server capabilities - be more lenient with Next LS # Verify basic server capabilities
log.info(f"Next LS capabilities: {list(init_response['capabilities'].keys())}")
# Next LS may not provide all capabilities immediately, so we check for basic ones
assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}" assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}"
# Some capabilities might be optional or provided later. This is expected, so we log as info
if "completionProvider" not in init_response["capabilities"]:
log.info("completionProvider not available in initial capabilities")
if "definitionProvider" not in init_response["capabilities"]:
log.info("definitionProvider not available in initial capabilities")
self.server.notify.initialized({}) self.server.notify.initialized({})
self.completions_available.set() self.completions_available.set()
# Wait for Next LS to send the specific "Runtime for folder X is ready..." log message # Expert needs time to compile the project and build indexes on first run.
# This is the authoritative signal that Next LS is truly ready for requests # This can take 2-3+ minutes for mid-sized codebases.
ready_timeout = 180.0 # After the first run, subsequent startups are much faster.
log.info(f"Waiting up to {ready_timeout} seconds for Next LS runtime readiness...") ready_timeout = 300.0 # 5 minutes
log.debug(f"Waiting up to {ready_timeout}s for Expert to compile and index...")
if self.server_ready.wait(timeout=ready_timeout): if self.server_ready.wait(timeout=ready_timeout):
log.info("Next LS is ready and available for requests") log.debug("Expert is ready for requests")
else: else:
error_msg = f"Next LS failed to initialize within {ready_timeout} seconds. This may indicate a problem with the Elixir installation, project compilation, or Next LS itself." log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.")
log.error(error_msg) self.server_ready.set() # Mark as ready anyway to allow requests
raise RuntimeError(error_msg)
+3
View File
@@ -227,6 +227,9 @@ class SolidLanguageServer(ABC):
if solidlsp_settings is None: if solidlsp_settings is None:
solidlsp_settings = SolidLSPSettings() solidlsp_settings = SolidLSPSettings()
# Ensure repository_root_path is absolute to avoid issues with file URIs
repository_root_path = os.path.abspath(repository_root_path)
ls_class = config.code_language.get_ls_class() ls_class = config.code_language.get_ls_class()
# For now, we assume that all language server implementations have the same signature of the constructor # For now, we assume that all language server implementations have the same signature of the constructor
# (which, unfortunately, differs from the signature of the base class). # (which, unfortunately, differs from the signature of the base class).
+6 -13
View File
@@ -1,12 +1,5 @@
import platform def _test_expert_available() -> str:
"""Test if Expert is available and return error reason if not."""
def _test_nextls_available() -> str:
"""Test if Next LS is available and return error reason if not."""
# Check if we're on Windows (Next LS doesn't support Windows)
if platform.system() == "Windows":
return "Next LS does not support Windows"
# Try to import and check Elixir availability # Try to import and check Elixir availability
try: try:
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
@@ -16,13 +9,13 @@ def _test_nextls_available() -> str:
if not elixir_version: if not elixir_version:
return "Elixir is not installed or not in PATH" return "Elixir is not installed or not in PATH"
return "" # No error, Next LS should be available return "" # No error, Expert should be available
except ImportError as e: except ImportError as e:
return f"Failed to import ElixirTools: {e}" return f"Failed to import ElixirTools: {e}"
except Exception as e: except Exception as e:
return f"Error checking Next LS availability: {e}" return f"Error checking Expert availability: {e}"
NEXTLS_UNAVAILABLE_REASON = _test_nextls_available() EXPERT_UNAVAILABLE_REASON = _test_expert_available()
NEXTLS_UNAVAILABLE = bool(NEXTLS_UNAVAILABLE_REASON) EXPERT_UNAVAILABLE = bool(EXPERT_UNAVAILABLE_REASON)
+2 -2
View File
@@ -12,10 +12,10 @@ import pytest
from solidlsp import SolidLanguageServer from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language from solidlsp.ls_config import Language
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module # These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")]
class TestElixirBasic: class TestElixirBasic:
@@ -1,3 +1,4 @@
import os
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
@@ -7,23 +8,38 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language from solidlsp.ls_config import Language
from test.conftest import start_ls_context from test.conftest import start_ls_context
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module # These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Expert not available: {EXPERT_UNAVAILABLE_REASON}")]
# Skip slow tests in CI - they require multiple Expert instances which is too slow
IN_CI = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
SKIP_SLOW_IN_CI = pytest.mark.skipif(
IN_CI,
reason="Slow tests skipped in CI - require multiple Expert instances (~60-90s each)",
)
@pytest.fixture(scope="module") @pytest.fixture(scope="session")
def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]:
"""Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored.""" """Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored.
Uses session scope to avoid restarting Expert for each test.
"""
ignored_paths = ["scripts", "ignored_dir"] ignored_paths = ["scripts", "ignored_dir"]
with start_ls_context(language=Language.ELIXIR, ignored_paths=ignored_paths) as ls: with start_ls_context(language=Language.ELIXIR, ignored_paths=ignored_paths) as ls:
yield ls yield ls
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True) @pytest.mark.slow
@SKIP_SLOW_IN_CI
def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that request_full_symbol_tree ignores the configured directory.""" """Tests that request_full_symbol_tree ignores the configured directory.
Note: This test uses a separate Expert instance with custom ignored paths,
which adds ~60-90s startup time.
"""
root = ls_with_ignored_dirs.request_full_symbol_tree()[0] root = ls_with_ignored_dirs.request_full_symbol_tree()[0]
root_children = root["children"] root_children = root["children"]
children_names = {child["name"] for child in root_children} children_names = {child["name"] for child in root_children}
@@ -35,9 +51,14 @@ def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}" assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}"
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True) @pytest.mark.slow
@SKIP_SLOW_IN_CI
def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that find_references ignores the configured directory.""" """Tests that find_references ignores the configured directory.
Note: This test uses a separate Expert instance with custom ignored paths,
which adds ~60-90s startup time.
"""
# Location of User struct, which is referenced in scripts and ignored_dir # Location of User struct, which is referenced in scripts and ignored_dir
definition_file = "lib/models.ex" definition_file = "lib/models.ex"
@@ -60,9 +81,15 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored" assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored"
@pytest.mark.slow
@SKIP_SLOW_IN_CI
@pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True)
def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
"""Tests that refs and symbols with glob patterns are ignored.""" """Tests that refs and symbols with glob patterns are ignored.
Note: This test uses a separate Expert instance with custom ignored paths,
which adds ~60-90s startup time.
"""
ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts
with start_ls_context(language=Language.ELIXIR, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls: with start_ls_context(language=Language.ELIXIR, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls:
@@ -114,6 +141,10 @@ def test_default_ignored_directories(language_server: SolidLanguageServer):
assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored" assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored"
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: document_symbols may return nil for some files (flaky)",
raises=Exception,
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer):
"""Test that symbol tree excludes build and dependency directories.""" """Test that symbol tree excludes build and dependency directories."""
@@ -14,10 +14,10 @@ from serena.project import Project
from solidlsp import SolidLanguageServer from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language from solidlsp.ls_config import Language
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module # These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")]
class TestElixirIntegration: class TestElixirIntegration:
@@ -15,15 +15,18 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind from solidlsp.ls_types import SymbolKind
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module # These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")]
class TestElixirLanguageServerSymbols: class TestElixirLanguageServerSymbols:
"""Test the Elixir language server's symbol-related functionality.""" """Test the Elixir language server's symbol-related functionality."""
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: document_symbols returns nil for some files (FunctionClauseError in XPExpert.EngineApi.document_symbols/2)"
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a function.""" """Test request_containing_symbol for a function."""
@@ -159,6 +162,9 @@ class TestElixirLanguageServerSymbols:
pass pass
# Tests for request_defining_symbol # Tests for request_defining_symbol
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)"
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None: def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a function call.""" """Test request_defining_symbol for a function call."""
@@ -183,6 +189,9 @@ class TestElixirLanguageServerSymbols:
if "location" in defining_symbol and "uri" in defining_symbol["location"]: if "location" in defining_symbol and "uri" in defining_symbol["location"]:
assert "models.ex" in defining_symbol["location"]["uri"] assert "models.ex" in defining_symbol["location"]["uri"]
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)"
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None: def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a struct usage.""" """Test request_defining_symbol for a struct usage."""
@@ -205,10 +214,7 @@ class TestElixirLanguageServerSymbols:
assert "User" in defining_symbol.get("name", "") assert "User" in defining_symbol.get("name", "")
@pytest.mark.xfail( @pytest.mark.xfail(
reason="Known intermittent bug in Next LS v0.23.3: Protocol.UndefinedError for :timeout atom. " reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)"
"Occurs in CI environments but may pass locally. "
"See https://github.com/elixir-tools/next-ls/issues/543",
strict=False,
) )
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None:
@@ -271,7 +277,7 @@ class TestElixirLanguageServerSymbols:
break break
if lib_dir: if lib_dir:
# Next LS returns module names instead of file names (e.g., 'services' instead of 'services.ex') # Expert returns module names instead of file names (e.g., 'services' instead of 'services.ex')
file_names = [child["name"] for child in lib_dir.get("children", [])] file_names = [child["name"] for child in lib_dir.get("children", [])]
expected_modules = ["models", "services", "examples", "utils", "test_repo"] expected_modules = ["models", "services", "examples", "utils", "test_repo"]
found_modules = [name for name in expected_modules if name in file_names] found_modules = [name for name in expected_modules if name in file_names]
@@ -284,7 +290,7 @@ class TestElixirLanguageServerSymbols:
# Should get an overview of the lib directory # Should get an overview of the lib directory
assert lib_overview is not None assert lib_overview is not None
# Next LS returns keys like 'lib/services.ex' instead of just 'lib' # Expert returns keys like 'lib/services.ex' instead of just 'lib'
overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else [] overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else []
lib_files = [key for key in overview_keys if key.startswith("lib/")] lib_files = [key for key in overview_keys if key.startswith("lib/")]
assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}" assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}"
@@ -298,10 +304,10 @@ class TestElixirLanguageServerSymbols:
# @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) # @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
# def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: # def test_request_document_overview(self, language_server: SolidLanguageServer) -> None:
# """Test request_document_overview functionality.""" # """Test request_document_overview functionality."""
# # COMMENTED OUT: Next LS document overview doesn't contain expected terms # # COMMENTED OUT: Expert document overview doesn't contain expected terms
# # Next LS return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content # # Expert return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content
# # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present # # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present
# # This appears to be a limitation of Next LS document overview functionality # # This appears to be a limitation of Expert document overview functionality
# # # #
# file_path = os.path.join("lib", "models.ex") # file_path = os.path.join("lib", "models.ex")
# doc_overview = language_server.request_document_overview(file_path) # doc_overview = language_server.request_document_overview(file_path)