Merge pull request #261 from dbernazal/elixir-support

Elixir Support
This commit is contained in:
Michael Panchenko
2025-07-06 00:13:27 +02:00
committed by GitHub
29 changed files with 2482 additions and 0 deletions
+7
View File
@@ -36,6 +36,13 @@ jobs:
- name: Install gopls
shell: bash
run: go install golang.org/x/tools/gopls@latest
- name: Set up Elixir
if: runner.os != 'Windows'
uses: erlef/setup-beam@v1
with:
elixir-version: '1.18.4'
otp-version: '26.1'
- name: Prepare java
uses: actions/setup-java@v3
with:
+5
View File
@@ -221,3 +221,8 @@ tmp/
# Claude settings
.claude/settings.local.json
# Elixir
/test/resources/repos/elixir/test_repo/deps
# Exception: Don't ignore Elixir test repository lib directory (contains source code)
!/test/resources/repos/elixir/test_repo/lib
+1
View File
@@ -73,6 +73,7 @@ With Serena, we provide
* Rust
* C# (requires dotnet to be installed. We switched the underlying language server recently, please report any issues you encounter)
* Java (_Note_: startup is slow, initial startup especially so. There may be issues with java on macos and linux, we are working on it.)
* Elixir (Requires NextLS and Elixir install; **Windows not supported** - Next LS does not provide Windows binaries)
* Clojure
* C/C++ (You may experience issues with finding references, we are working on it)
* indirect support (may require some code changes/manual installation) for:
+1
View File
@@ -247,6 +247,7 @@ markers = [
"typescript: language server running for TypeScript",
"php: language server running for PHP",
"csharp: language server running for C#",
"elixir: language server running for Elixir",
"snapshot: snapshot tests for symbolic editing operations",
]
@@ -0,0 +1,90 @@
# 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.
> **⚠️ 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
Before using the Elixir language server integration, you need to have:
1. **Elixir** installed and available in your PATH
- Install from: https://elixir-lang.org/install.html
- Verify with: `elixir --version`
2. **Next LS** installed and available in your PATH
- Install from: https://github.com/elixir-tools/next-ls#installation
- Verify with: `nextls --version`
## Features
The Elixir integration provides:
- **Language Server Protocol (LSP) support** via Next LS
- **File extension recognition** for `.ex` and `.exs` files
- **Project structure awareness** with proper handling of Elixir-specific directories:
- `_build/` - Compiled artifacts (ignored)
- `deps/` - Dependencies (ignored)
- `.elixir_ls/` - ElixirLS artifacts (ignored)
- `cover/` - Coverage reports (ignored)
- `lib/` - Source code (not ignored)
- `test/` - Test files (not ignored)
## Configuration
The integration uses the default Next LS configuration with:
- **MIX_ENV**: `dev`
- **MIX_TARGET**: `host`
- **Experimental completions**: Disabled by default
- **Credo extension**: Enabled by default
## Usage
The Elixir language server is automatically selected when working with Elixir projects. It will be used for:
- Code completion
- Go to definition
- Find references
- Document symbols
- Hover information
- Code formatting
- Diagnostics (via Credo integration)
### Important: Project Compilation
Next LS requires your Elixir project to be **compiled** for optimal performance, especially for:
- Cross-file reference resolution
- Complete symbol information
- Accurate go-to-definition
**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.
## Testing
Run the Elixir-specific tests with:
```bash
pytest test/solidlsp/elixir/ -m elixir
```
## Implementation Details
- **Main class**: `ElixirTools` in `elixir_tools.py`
- **Initialization parameters**: Defined in `initialize_params.json`
- **Language identifier**: `"elixir"`
- **Command**: `nextls --stdio`
The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior.
@@ -0,0 +1 @@
@@ -0,0 +1,288 @@
import json
import logging
import os
import pathlib
import stat
import subprocess
import threading
import time
from pathlib import PurePath
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
class ElixirTools(SolidLanguageServer):
"""
Provides Elixir specific instantiation of the LanguageServer class using Next LS from elixir-tools.
"""
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For Elixir projects, we should ignore:
# - _build: compiled artifacts
# - deps: dependencies
# - node_modules: if the project has JavaScript components
# - .elixir_ls: ElixirLS artifacts (in case both are present)
# - cover: coverage reports
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", "cover"]
def _is_next_ls_internal_file(self, 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):
"""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):
self.logger.log(f"Filtering out Next LS internal file: {abs_path}", logging.DEBUG)
continue
filtered_response.append(item)
return filtered_response
@staticmethod
def _get_elixir_version():
"""Get the installed Elixir version or None if not found."""
try:
result = subprocess.run(["elixir", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
return None
return None
def setupRuntimeDependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str:
"""
Setup runtime dependencies for Next LS.
Downloads the Next LS binary for the current platform and returns the path to the executable.
"""
# Check if Elixir is available first
elixir_version = self._get_elixir_version()
if not elixir_version:
raise RuntimeError(
"Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH."
)
logger.log(f"Found Elixir: {elixir_version}", logging.INFO)
platformId = PlatformUtils.get_platform_id()
with open(str(PurePath(os.path.dirname(__file__), "runtime_dependencies.json")), encoding="utf-8") as f:
runtimeDependencies = json.load(f)
del runtimeDependencies["_description"]
os.makedirs(str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static")), exist_ok=True)
# Map platform IDs to runtime dependency keys
platform_mapping = {
"linux-x64": "linux-x64",
"osx-x64": "darwin-x64",
"osx-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"darwin-arm64": "darwin-arm64",
"win-x64": "win-x64"
}
platform_key = platform_mapping.get(platformId.value)
if not platform_key:
raise RuntimeError(f"Unsupported platform for Next LS: {platformId.value}")
# Check for Windows and provide a helpful error message
if platformId.value.startswith("win"):
raise RuntimeError(
"Windows is not supported by Next LS. The Next LS project does not provide Windows binaries. "
"Consider using Windows Subsystem for Linux (WSL) or a virtual machine with Linux/macOS."
)
dependency = runtimeDependencies["next_ls"][platform_key]
next_ls_dir = str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"]))
os.makedirs(next_ls_dir, exist_ok=True)
executable_path = str(PurePath(next_ls_dir, dependency["executable_name"]))
binary_path = str(PurePath(next_ls_dir, dependency["binary_name"]))
if not os.path.exists(executable_path):
logger.log(f"Downloading Next LS binary from {dependency['url']}", logging.INFO)
FileUtils.download_file(logger, dependency["url"], binary_path)
# Make the binary executable on Unix-like systems
if not platformId.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 or copy with the expected name
if binary_path != executable_path:
if os.path.exists(executable_path):
os.remove(executable_path)
if platformId.value.startswith("win"):
# On Windows, copy the file
import shutil
shutil.copy2(binary_path, executable_path)
else:
# On Unix-like systems, create a symlink
os.symlink(os.path.basename(binary_path), executable_path)
assert os.path.exists(executable_path), f"Next LS executable not found at {executable_path}"
logger.log(f"Next LS binary ready at: {executable_path}", logging.INFO)
return executable_path
def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str):
nextls_executable_path = self.setupRuntimeDependencies(logger, config)
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=f'"{nextls_executable_path}" --stdio', cwd=repository_root_path),
"elixir",
)
self.server_ready = threading.Event()
self.request_id = 0
# Set generous timeout for Next LS which can be slow to initialize and respond
self.set_request_timeout(180.0) # 60 seconds for all environments
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Next LS Language Server.
"""
with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f:
d = json.load(f)
del d["_description"]
d["processId"] = os.getpid()
assert d["rootPath"] == "$rootPath"
d["rootPath"] = repository_absolute_path
assert d["rootUri"] == "$rootUri"
d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri()
assert d["workspaceFolders"][0]["uri"] == "$uri"
d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri()
assert d["workspaceFolders"][0]["name"] == "$name"
d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path)
return d
def _start_server(self):
"""Start Next LS server process"""
def register_capability_handler(params):
return
def window_log_message(msg):
"""Handle window/logMessage notifications from Next LS"""
message_text = msg.get("message", "")
self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO)
# Check for the specific Next LS readiness signal
# Based on Next LS source: "Runtime for folder #{name} is ready..."
if "Runtime for folder" in message_text and "is ready..." in message_text:
self.logger.log("Next LS runtime is ready based on official log message", logging.INFO)
self.server_ready.set()
def do_nothing(params):
return
def check_server_ready(params):
"""
Handle $/progress notifications from Next LS.
Keep as fallback for error detection, but primary readiness detection
is now done via window/logMessage handler.
"""
value = params.get("value", {})
# Check for initialization completion progress (fallback signal)
if value.get("kind") == "end":
message = value.get("message", "")
if "has initialized!" in message:
self.logger.log("Next LS initialization progress completed", logging.INFO)
# Note: We don't set server_ready here - we wait for the log message
def work_done_progress(params):
"""
Handle $/workDoneProgress notifications from Next LS.
Keep for completeness but primary readiness detection is via window/logMessage.
"""
value = params.get("value", {})
if value.get("kind") == "end":
self.logger.log("Next LS work done progress completed", logging.INFO)
# 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_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", check_server_ready)
self.server.on_notification("window/workDoneProgress/create", do_nothing)
self.server.on_notification("$/workDoneProgress", work_done_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.logger.log("Starting Next LS server process", logging.INFO)
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(
"Sending initialize request from LSP client to LSP server and awaiting response",
logging.INFO,
)
init_response = self.server.send.initialize(initialize_params)
# Verify server capabilities - be more lenient with Next LS
self.logger.log(f"Next LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO)
# 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']}"
# Some capabilities might be optional or provided later
if "completionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: completionProvider not available in initial capabilities", logging.WARNING)
if "definitionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: definitionProvider not available in initial capabilities", logging.WARNING)
self.server.notify.initialized({})
self.completions_available.set()
# Wait for Next LS to send the specific "Runtime for folder X is ready..." log message
# This is the authoritative signal that Next LS is truly ready for requests
ready_timeout = 180.0
self.logger.log(f"Waiting up to {ready_timeout} seconds for Next LS runtime readiness...", logging.INFO)
if self.server_ready.wait(timeout=ready_timeout):
self.logger.log("Next LS is ready and available for requests", logging.INFO)
# Add a small settling period to ensure background indexing is complete
# Next LS often continues compilation/indexing in background after ready signal
settling_time = 120.0
self.logger.log(f"Allowing {settling_time} seconds for Next LS background indexing to complete...", logging.INFO)
time.sleep(settling_time)
self.logger.log("Next LS settling period complete", logging.INFO)
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."
self.logger.log(error_msg, logging.ERROR)
raise RuntimeError(error_msg)
@@ -0,0 +1,97 @@
{
"_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize",
"processId": "os.getpid()",
"locale": "en",
"rootPath": "$rootPath",
"rootUri": "$rootUri",
"initializationOptions": {
"mix_env": "dev",
"mix_target": "host",
"experimental": {
"completions": {
"enable": false
}
},
"extensions": {
"credo": {
"enable": true,
"cli_options": []
}
}
},
"capabilities": {
"textDocument": {
"synchronization": {
"didSave": true,
"dynamicRegistration": true
},
"completion": {
"dynamicRegistration": true,
"completionItem": {
"snippetSupport": true,
"documentationFormat": [
"markdown",
"plaintext"
]
}
},
"definition": {
"dynamicRegistration": true
},
"references": {
"dynamicRegistration": true
},
"documentSymbol": {
"dynamicRegistration": true,
"hierarchicalDocumentSymbolSupport": true,
"symbolKind": {
"valueSet": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26
]
}
},
"hover": {
"dynamicRegistration": true,
"contentFormat": [
"markdown",
"plaintext"
]
},
"formatting": {
"dynamicRegistration": true
},
"codeAction": {
"dynamicRegistration": true,
"codeActionLiteralSupport": {
"codeActionKind": {
"valueSet": [
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports"
]
}
}
}
},
"workspace": {
"workspaceFolders": true,
"didChangeConfiguration": {
"dynamicRegistration": true
},
"executeCommand": {
"dynamicRegistration": true
}
}
},
"workspaceFolders": [
{
"uri": "$uri",
"name": "$name"
}
]
}
@@ -0,0 +1,33 @@
{
"_description": "This file lists the runtime dependencies for the Elixir Language Server (Next LS)",
"next_ls": {
"linux-x64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_linux_amd64",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_linux_amd64",
"executable_name": "nextls"
},
"darwin-x64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_amd64",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_darwin_amd64",
"executable_name": "nextls"
},
"darwin-arm64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_arm64",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_darwin_arm64",
"executable_name": "nextls"
},
"win-x64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_windows_amd64.exe",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_windows_amd64.exe",
"executable_name": "nextls.exe"
}
}
}
+5
View File
@@ -182,6 +182,11 @@ class SolidLanguageServer(ABC):
ls = ClojureLSP(config, logger, repository_root_path)
elif config.code_language == Language.ELIXIR:
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
ls = ElixirTools(config, logger, repository_root_path)
else:
logger.log(f"Language {config.code_language} is not supported", logging.ERROR)
raise LanguageServerException(f"Language {config.code_language} is not supported")
+3
View File
@@ -38,6 +38,7 @@ class Language(str, Enum):
CPP = "cpp"
PHP = "php"
CLOJURE = "clojure"
ELIXIR = "elixir"
def __str__(self) -> str:
return self.value
@@ -74,6 +75,8 @@ class Language(str, Enum):
return FilenameMatcher("*.php")
case self.CLOJURE:
return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn
case self.ELIXIR:
return FilenameMatcher("*.ex", "*.exs")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1,211 @@
defmodule TestRepo.Examples do
@moduledoc """
Examples module demonstrating usage of models and services.
Similar to Python's examples directory, this shows how different modules work together.
"""
alias TestRepo.Models.{User, Item}
alias TestRepo.Services.{UserService, ItemService, OrderService}
defmodule UserManagement do
@doc """
Creates a complete user workflow example.
"""
def run_user_example do
# Start user service
{:ok, user_service} = UserService.start_link()
# Create users
{:ok, alice} = UserService.create_user(user_service, "1", "Alice", "alice@example.com", ["admin"])
{:ok, bob} = UserService.create_user(user_service, "2", "Bob", "bob@example.com", ["user"])
# Get users
{:ok, retrieved_alice} = UserService.get_user(user_service, "1")
# List all users
all_users = UserService.list_users(user_service)
# Clean up
GenServer.stop(user_service)
%{
created_alice: alice,
created_bob: bob,
retrieved_alice: retrieved_alice,
all_users: all_users
}
end
@doc """
Demonstrates user role management.
"""
def manage_user_roles do
user = User.new("role_user", "Role User", "role@example.com")
# Add roles
user_with_admin = User.add_role(user, "admin")
user_with_multiple = User.add_role(user_with_admin, "moderator")
# Check roles
has_admin = User.has_role?(user_with_multiple, "admin")
has_guest = User.has_role?(user_with_multiple, "guest")
%{
original_user: user,
user_with_roles: user_with_multiple,
has_admin: has_admin,
has_guest: has_guest
}
end
end
defmodule ShoppingExample do
@doc """
Creates a complete shopping workflow.
"""
def run_shopping_example do
# Create user and items
user = User.new("customer1", "Customer One", "customer@example.com")
item1 = Item.new("widget1", "Super Widget", 19.99, "electronics")
item2 = Item.new("gadget1", "Cool Gadget", 29.99, "electronics")
# Create order
order = OrderService.create_order("order1", user)
# Add items to order
order_with_item1 = OrderService.add_item_to_order(order, item1)
order_with_items = OrderService.add_item_to_order(order_with_item1, item2)
# Process the order
processed_order = OrderService.process_order(order_with_items)
completed_order = OrderService.complete_order(processed_order)
%{
user: user,
items: [item1, item2],
final_order: completed_order,
total_cost: completed_order.total
}
end
@doc """
Demonstrates item filtering and searching.
"""
def item_filtering_example do
# Start item service
{:ok, item_service} = ItemService.start_link()
# Create various items
ItemService.create_item(item_service, "laptop", "Gaming Laptop", 1299.99, "electronics")
ItemService.create_item(item_service, "book", "Elixir Guide", 39.99, "books")
ItemService.create_item(item_service, "phone", "Smartphone", 699.99, "electronics")
ItemService.create_item(item_service, "novel", "Great Novel", 19.99, "books")
# Get all items
all_items = ItemService.list_items(item_service)
# Filter by category
electronics = ItemService.list_items(item_service, "electronics")
books = ItemService.list_items(item_service, "books")
# Clean up
Agent.stop(item_service)
%{
all_items: all_items,
electronics: electronics,
books: books,
total_items: length(all_items),
electronics_count: length(electronics),
books_count: length(books)
}
end
end
defmodule IntegrationExample do
@doc """
Runs a complete e-commerce scenario.
"""
def run_full_scenario do
# Setup services
container = TestRepo.Services.create_service_container()
TestRepo.Services.setup_sample_data(container)
# Get sample data
{:ok, sample_user} = UserService.get_user(container.user_service, TestRepo.Services.sample_user_id())
{:ok, sample_item} = ItemService.get_item(container.item_service, TestRepo.Services.sample_item_id())
# Create additional items
{:ok, premium_item} = ItemService.create_item(
container.item_service,
"premium",
"Premium Product",
99.99,
"premium"
)
# Create order with multiple items
order = OrderService.create_order("big_order", sample_user, [sample_item])
order_with_premium = OrderService.add_item_to_order(order, premium_item)
# Process through order lifecycle
processing_order = OrderService.process_order(order_with_premium)
final_order = OrderService.complete_order(processing_order)
# Serialize everything for output
serialized_user = TestRepo.Services.serialize_model(sample_user)
serialized_order = TestRepo.Services.serialize_model(final_order)
# Clean up
GenServer.stop(container.user_service)
Agent.stop(container.item_service)
%{
scenario: "full_ecommerce",
user: serialized_user,
order: serialized_order,
total_revenue: final_order.total,
items_sold: length(final_order.items)
}
end
@doc """
Demonstrates error handling scenarios.
"""
def error_handling_example do
{:ok, user_service} = UserService.start_link()
# Try to create duplicate user
{:ok, _user1} = UserService.create_user(user_service, "dup", "User", "user@example.com")
duplicate_result = UserService.create_user(user_service, "dup", "Another User", "another@example.com")
# Try to get non-existent user
missing_user_result = UserService.get_user(user_service, "nonexistent")
# Try to delete non-existent user
delete_result = UserService.delete_user(user_service, "nonexistent")
GenServer.stop(user_service)
%{
duplicate_user_error: duplicate_result,
missing_user_error: missing_user_result,
delete_missing_error: delete_result
}
end
end
@doc """
Main function to run all examples.
"""
def run_all_examples do
%{
user_management: UserManagement.run_user_example(),
role_management: UserManagement.manage_user_roles(),
shopping: ShoppingExample.run_shopping_example(),
item_filtering: ShoppingExample.item_filtering_example(),
integration: IntegrationExample.run_full_scenario(),
error_handling: IntegrationExample.error_handling_example()
}
end
end
@@ -0,0 +1,23 @@
defmodule TestRepo.IgnoredDir.IgnoredModule do
@moduledoc """
This module is in a directory that should be ignored by the language server.
It's used for testing directory filtering functionality.
"""
alias TestRepo.Models.User
@doc """
This function references the User model to test that ignored directories
don't show up in symbol references.
"""
def create_ignored_user do
User.new("ignored", "Ignored User", "ignored@example.com")
end
@doc """
Another function that uses models.
"""
def process_ignored_user(user) do
User.add_role(user, "ignored_role")
end
end
@@ -0,0 +1,166 @@
defmodule TestRepo.Models do
@moduledoc """
Models module demonstrating various Elixir patterns including structs, protocols, and behaviours.
"""
defprotocol Serializable do
@doc "Convert model to map representation"
def to_map(model)
end
defmodule User do
@type t :: %__MODULE__{
id: String.t(),
name: String.t() | nil,
email: String.t(),
roles: list(String.t())
}
defstruct [:id, :name, :email, roles: []]
@doc """
Creates a new user.
## Examples
iex> TestRepo.Models.User.new("1", "Alice", "alice@example.com")
%TestRepo.Models.User{id: "1", name: "Alice", email: "alice@example.com", roles: []}
"""
def new(id, name, email, roles \\ []) do
%__MODULE__{id: id, name: name, email: email, roles: roles}
end
@doc """
Checks if user has a specific role.
"""
def has_role?(%__MODULE__{roles: roles}, role) do
role in roles
end
@doc """
Adds a role to the user.
"""
def add_role(%__MODULE__{roles: roles} = user, role) do
%{user | roles: [role | roles]}
end
end
defmodule Item do
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
price: float(),
category: String.t()
}
defstruct [:id, :name, :price, :category]
@doc """
Creates a new item.
## Examples
iex> TestRepo.Models.Item.new("1", "Widget", 19.99, "electronics")
%TestRepo.Models.Item{id: "1", name: "Widget", price: 19.99, category: "electronics"}
"""
def new(id, name, price, category) do
%__MODULE__{id: id, name: name, price: price, category: category}
end
@doc """
Formats price for display.
"""
def display_price(%__MODULE__{price: price}) do
"$#{:erlang.float_to_binary(price, decimals: 2)}"
end
@doc """
Checks if item is in a specific category.
"""
def in_category?(%__MODULE__{category: category}, target_category) do
category == target_category
end
end
defmodule Order do
alias TestRepo.Models.{User, Item}
@type t :: %__MODULE__{
id: String.t(),
user: User.t(),
items: list(Item.t()),
total: float(),
status: atom()
}
defstruct [:id, :user, items: [], total: 0.0, status: :pending]
@doc """
Creates a new order.
"""
def new(id, user, items \\ []) do
total = calculate_total(items)
%__MODULE__{id: id, user: user, items: items, total: total}
end
@doc """
Adds an item to the order.
"""
def add_item(%__MODULE__{items: items} = order, item) do
new_items = [item | items]
%{order | items: new_items, total: calculate_total(new_items)}
end
@doc """
Updates order status.
"""
def update_status(%__MODULE__{} = order, status) do
%{order | status: status}
end
defp calculate_total(items) do
Enum.reduce(items, 0.0, fn item, acc -> acc + item.price end)
end
end
# Protocol implementations
defimpl Serializable, for: User do
def to_map(%User{id: id, name: name, email: email, roles: roles}) do
%{id: id, name: name, email: email, roles: roles}
end
end
defimpl Serializable, for: Item do
def to_map(%Item{id: id, name: name, price: price, category: category}) do
%{id: id, name: name, price: price, category: category}
end
end
defimpl Serializable, for: Order do
def to_map(%Order{id: id, user: user, items: items, total: total, status: status}) do
%{
id: id,
user: Serializable.to_map(user),
items: Enum.map(items, &Serializable.to_map/1),
total: total,
status: status
}
end
end
@doc """
Factory function to create a sample user.
"""
def create_sample_user do
User.new("sample", "Sample User", "sample@example.com", ["user"])
end
@doc """
Factory function to create a sample item.
"""
def create_sample_item do
Item.new("sample", "Sample Item", 9.99, "sample")
end
end
@@ -0,0 +1,257 @@
defmodule TestRepo.Services do
@moduledoc """
Services module demonstrating function usage and dependencies.
Similar to Python's services.py, this module uses the models defined in TestRepo.Models.
"""
alias TestRepo.Models.{User, Item, Order, Serializable}
defmodule UserService do
use GenServer
# Client API
@doc """
Starts the UserService GenServer.
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts)
end
@doc """
Creates a new user and stores it.
"""
def create_user(pid, id, name, email, roles \\ []) do
GenServer.call(pid, {:create_user, id, name, email, roles})
end
@doc """
Gets a user by ID.
"""
def get_user(pid, id) do
GenServer.call(pid, {:get_user, id})
end
@doc """
Lists all users.
"""
def list_users(pid) do
GenServer.call(pid, :list_users)
end
@doc """
Deletes a user by ID.
"""
def delete_user(pid, id) do
GenServer.call(pid, {:delete_user, id})
end
# Server callbacks
@impl true
def init(_) do
{:ok, %{}}
end
@impl true
def handle_call({:create_user, id, name, email, roles}, _from, users) do
if Map.has_key?(users, id) do
{:reply, {:error, "User with ID #{id} already exists"}, users}
else
user = User.new(id, name, email, roles)
new_users = Map.put(users, id, user)
{:reply, {:ok, user}, new_users}
end
end
@impl true
def handle_call({:get_user, id}, _from, users) do
case Map.get(users, id) do
nil -> {:reply, {:error, :not_found}, users}
user -> {:reply, {:ok, user}, users}
end
end
@impl true
def handle_call(:list_users, _from, users) do
user_list = Map.values(users)
{:reply, user_list, users}
end
@impl true
def handle_call({:delete_user, id}, _from, users) do
if Map.has_key?(users, id) do
new_users = Map.delete(users, id)
{:reply, :ok, new_users}
else
{:reply, {:error, :not_found}, users}
end
end
end
defmodule ItemService do
use Agent
@doc """
Starts the ItemService Agent.
"""
def start_link(opts \\ []) do
Agent.start_link(fn -> %{} end, opts)
end
@doc """
Creates a new item and stores it.
"""
def create_item(pid, id, name, price, category) do
Agent.get_and_update(pid, fn items ->
if Map.has_key?(items, id) do
{{:error, "Item with ID #{id} already exists"}, items}
else
item = Item.new(id, name, price, category)
new_items = Map.put(items, id, item)
{{:ok, item}, new_items}
end
end)
end
@doc """
Gets an item by ID.
"""
def get_item(pid, id) do
Agent.get(pid, fn items ->
case Map.get(items, id) do
nil -> {:error, :not_found}
item -> {:ok, item}
end
end)
end
@doc """
Lists all items, optionally filtered by category.
"""
def list_items(pid, category \\ nil) do
Agent.get(pid, fn items ->
item_list = Map.values(items)
case category do
nil -> item_list
cat -> Enum.filter(item_list, &Item.in_category?(&1, cat))
end
end)
end
@doc """
Deletes an item by ID.
"""
def delete_item(pid, id) do
Agent.get_and_update(pid, fn items ->
if Map.has_key?(items, id) do
new_items = Map.delete(items, id)
{:ok, new_items}
else
{{:error, :not_found}, items}
end
end)
end
end
defmodule OrderService do
@doc """
Creates a new order.
"""
def create_order(id, user, items \\ []) do
Order.new(id, user, items)
end
@doc """
Adds an item to an existing order.
"""
def add_item_to_order(order, item) do
Order.add_item(order, item)
end
@doc """
Updates the status of an order.
"""
def update_order_status(order, status) do
Order.update_status(order, status)
end
@doc """
Processes an order (changes status to :processing).
"""
def process_order(order) do
update_order_status(order, :processing)
end
@doc """
Completes an order (changes status to :completed).
"""
def complete_order(order) do
update_order_status(order, :completed)
end
@doc """
Cancels an order (changes status to :cancelled).
"""
def cancel_order(order) do
update_order_status(order, :cancelled)
end
end
@doc """
Factory function to create a service container.
"""
def create_service_container do
{:ok, user_service} = UserService.start_link()
{:ok, item_service} = ItemService.start_link()
%{
user_service: user_service,
item_service: item_service,
order_service: OrderService
}
end
@doc """
Helper function to serialize any model that implements the Serializable protocol.
"""
def serialize_model(model) do
Serializable.to_map(model)
end
# Module-level variables for testing
@sample_user_id "sample_user"
@sample_item_id "sample_item"
@doc """
Gets the sample user ID.
"""
def sample_user_id, do: @sample_user_id
@doc """
Gets the sample item ID.
"""
def sample_item_id, do: @sample_item_id
# Create some sample data at module load time
def setup_sample_data(container) do
# Create sample user
UserService.create_user(
container.user_service,
@sample_user_id,
"Sample User",
"sample@example.com",
["user", "customer"]
)
# Create sample item
ItemService.create_item(
container.item_service,
@sample_item_id,
"Sample Widget",
29.99,
"electronics"
)
end
end
@@ -0,0 +1,31 @@
defmodule TestRepo do
@moduledoc """
Documentation for `TestRepo`.
"""
@doc """
Hello world.
## Examples
iex> TestRepo.hello()
:world
"""
def hello do
:world
end
@doc """
Adds two numbers together.
## Examples
iex> TestRepo.add(2, 3)
5
"""
def add(a, b) do
a + b
end
end
@@ -0,0 +1,48 @@
defmodule TestRepo.Utils do
@moduledoc """
Utility functions for TestRepo.
"""
@doc """
Converts a string to uppercase.
## Examples
iex> TestRepo.Utils.upcase("hello")
"HELLO"
"""
def upcase(string) when is_binary(string) do
String.upcase(string)
end
@doc """
Calculates the factorial of a number.
## Examples
iex> TestRepo.Utils.factorial(5)
120
"""
def factorial(0), do: 1
def factorial(n) when n > 0 do
n * factorial(n - 1)
end
@doc """
Checks if a number is even.
## Examples
iex> TestRepo.Utils.even?(4)
true
iex> TestRepo.Utils.even?(3)
false
"""
def even?(n) when is_integer(n) do
rem(n, 2) == 0
end
end
@@ -0,0 +1,25 @@
defmodule TestRepo.MixProject do
use Mix.Project
def project do
[
app: :test_repo,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end
end
@@ -0,0 +1,6 @@
%{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
}
@@ -0,0 +1,26 @@
defmodule TestRepo.Scripts.BuildScript do
@moduledoc """
Build script that references models.
This is in the scripts directory which should be ignored in some tests.
"""
alias TestRepo.Models.{User, Item}
@doc """
Script function that creates test data.
"""
def create_test_data do
user = User.new("script_user", "Script User", "script@example.com")
item = Item.new("script_item", "Script Item", 1.0, "script")
{user, item}
end
@doc """
Another script function referencing User.
"""
def cleanup_users do
# This would reference User in a real scenario
IO.puts("Cleaning up users...")
end
end
@@ -0,0 +1,169 @@
defmodule TestRepo.ModelsTest do
use ExUnit.Case
doctest TestRepo.Models
alias TestRepo.Models.{User, Item, Order, Serializable}
describe "User" do
test "creates a new user with default roles" do
user = User.new("1", "Alice", "alice@example.com")
assert user.id == "1"
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.roles == []
end
test "creates a user with specified roles" do
user = User.new("2", "Bob", "bob@example.com", ["admin", "user"])
assert user.roles == ["admin", "user"]
end
test "checks if user has role" do
user = User.new("3", "Charlie", "charlie@example.com", ["admin"])
assert User.has_role?(user, "admin")
refute User.has_role?(user, "guest")
end
test "adds role to user" do
user = User.new("4", "David", "david@example.com")
user_with_role = User.add_role(user, "moderator")
assert User.has_role?(user_with_role, "moderator")
assert length(user_with_role.roles) == 1
end
end
describe "Item" do
test "creates a new item" do
item = Item.new("widget1", "Super Widget", 19.99, "electronics")
assert item.id == "widget1"
assert item.name == "Super Widget"
assert item.price == 19.99
assert item.category == "electronics"
end
test "formats price for display" do
item = Item.new("item1", "Test Item", 29.99, "test")
assert Item.display_price(item) == "$29.99"
end
test "checks if item is in category" do
item = Item.new("book1", "Elixir Book", 39.99, "books")
assert Item.in_category?(item, "books")
refute Item.in_category?(item, "electronics")
end
end
describe "Order" do
setup do
user = User.new("customer1", "Customer", "customer@example.com")
item1 = Item.new("item1", "Item 1", 10.00, "category1")
item2 = Item.new("item2", "Item 2", 20.00, "category2")
%{user: user, item1: item1, item2: item2}
end
test "creates a new order", %{user: user} do
order = Order.new("order1", user)
assert order.id == "order1"
assert order.user == user
assert order.items == []
assert order.total == 0.0
assert order.status == :pending
end
test "creates order with items", %{user: user, item1: item1, item2: item2} do
order = Order.new("order2", user, [item1, item2])
assert length(order.items) == 2
assert order.total == 30.0
end
test "adds item to order", %{user: user, item1: item1, item2: item2} do
order = Order.new("order3", user, [item1])
order_with_item = Order.add_item(order, item2)
assert length(order_with_item.items) == 2
assert order_with_item.total == 30.0
end
test "updates order status", %{user: user} do
order = Order.new("order4", user)
processed_order = Order.update_status(order, :processing)
assert processed_order.status == :processing
end
end
describe "Serializable protocol" do
test "serializes User" do
user = User.new("1", "Alice", "alice@example.com", ["admin"])
serialized = Serializable.to_map(user)
expected = %{
id: "1",
name: "Alice",
email: "alice@example.com",
roles: ["admin"]
}
assert serialized == expected
end
test "serializes Item" do
item = Item.new("widget1", "Widget", 19.99, "electronics")
serialized = Serializable.to_map(item)
expected = %{
id: "widget1",
name: "Widget",
price: 19.99,
category: "electronics"
}
assert serialized == expected
end
test "serializes Order" do
user = User.new("1", "Alice", "alice@example.com")
item = Item.new("widget1", "Widget", 19.99, "electronics")
order = Order.new("order1", user, [item])
serialized = Serializable.to_map(order)
assert serialized.id == "order1"
assert serialized.total == 19.99
assert serialized.status == :pending
assert is_map(serialized.user)
assert is_list(serialized.items)
assert length(serialized.items) == 1
end
end
describe "factory functions" do
test "creates sample user" do
user = TestRepo.Models.create_sample_user()
assert user.id == "sample"
assert user.name == "Sample User"
assert user.email == "sample@example.com"
assert "user" in user.roles
end
test "creates sample item" do
item = TestRepo.Models.create_sample_item()
assert item.id == "sample"
assert item.name == "Sample Item"
assert item.price == 9.99
assert item.category == "sample"
end
end
end
@@ -0,0 +1,14 @@
defmodule TestRepoTest do
use ExUnit.Case
doctest TestRepo
test "greets the world" do
assert TestRepo.hello() == :world
end
test "adds numbers correctly" do
assert TestRepo.add(2, 3) == 5
assert TestRepo.add(-1, 1) == 0
assert TestRepo.add(0, 0) == 0
end
end
+30
View File
@@ -0,0 +1,30 @@
import platform
from pathlib import Path
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:
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
# Check if Elixir is installed
elixir_version = ElixirTools._get_elixir_version()
if not elixir_version:
return "Elixir is not installed or not in PATH"
return "" # No error, Next LS should be available
except ImportError as e:
return f"Failed to import ElixirTools: {e}"
except Exception as e:
return f"Error checking Next LS availability: {e}"
NEXTLS_UNAVAILABLE_REASON = _test_nextls_available()
NEXTLS_UNAVAILABLE = bool(NEXTLS_UNAVAILABLE_REASON)
+169
View File
@@ -0,0 +1,169 @@
"""
Elixir-specific test configuration and fixtures.
"""
import os
import subprocess
import pytest
import time
from pathlib import Path
def ensure_elixir_test_repo_compiled(repo_path: str) -> None:
"""Ensure the Elixir test repository dependencies are installed and project is compiled.
Next LS requires the project to be fully compiled and indexed before providing
complete references and symbol resolution. This function:
1. Installs dependencies via 'mix deps.get'
2. Compiles the project via 'mix compile'
This is essential in CI environments where dependencies aren't pre-installed.
Args:
repo_path: Path to the Elixir project root directory
"""
# Check if this looks like an Elixir project
mix_file = os.path.join(repo_path, "mix.exs")
if not os.path.exists(mix_file):
return
# Check if already compiled (optimization for repeated runs)
build_path = os.path.join(repo_path, "_build")
deps_path = os.path.join(repo_path, "deps")
if os.path.exists(build_path) and os.path.exists(deps_path):
print(f"Elixir test repository already compiled in {repo_path}")
return
try:
print(f"Installing dependencies and compiling Elixir test repository for optimal Next LS performance...")
# First, install dependencies with increased timeout for CI
print("=" * 60)
print("Step 1/2: Installing Elixir dependencies...")
print("=" * 60)
start_time = time.time()
deps_result = subprocess.run(
["mix", "deps.get"],
cwd=repo_path,
capture_output=True,
text=True,
timeout=180 # 3 minutes for dependency installation (CI can be slow)
)
deps_duration = time.time() - start_time
print(f"Dependencies installation completed in {deps_duration:.2f} seconds")
# Always log the output for transparency
if deps_result.stdout.strip():
print("Dependencies stdout:")
print("-" * 40)
print(deps_result.stdout)
print("-" * 40)
if deps_result.stderr.strip():
print("Dependencies stderr:")
print("-" * 40)
print(deps_result.stderr)
print("-" * 40)
if deps_result.returncode != 0:
print(f"⚠️ Warning: Dependencies installation failed with exit code {deps_result.returncode}")
# Continue anyway - some projects might not have dependencies
else:
print("✓ Dependencies installed successfully")
# Then compile the project with increased timeout for CI
print("=" * 60)
print("Step 2/2: Compiling Elixir project...")
print("=" * 60)
start_time = time.time()
compile_result = subprocess.run(
["mix", "compile"],
cwd=repo_path,
capture_output=True,
text=True,
timeout=300 # 5 minutes for compilation (Credo compilation can be slow in CI)
)
compile_duration = time.time() - start_time
print(f"Compilation completed in {compile_duration:.2f} seconds")
# Always log the output for transparency
if compile_result.stdout.strip():
print("Compilation stdout:")
print("-" * 40)
print(compile_result.stdout)
print("-" * 40)
if compile_result.stderr.strip():
print("Compilation stderr:")
print("-" * 40)
print(compile_result.stderr)
print("-" * 40)
if compile_result.returncode == 0:
print(f"✓ Elixir test repository compiled successfully in {repo_path}")
else:
print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}")
# Still continue - warnings are often non-fatal
print("=" * 60)
print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds")
print("=" * 60)
except subprocess.TimeoutExpired as e:
print("=" * 60)
print(f"❌ TIMEOUT: Elixir setup timed out after {e.timeout} seconds")
print(f"Command: {' '.join(e.cmd)}")
print("This may indicate slow CI environment - Next LS may still work but with reduced functionality")
# Try to get partial output if available
if hasattr(e, 'stdout') and e.stdout:
print("Partial stdout before timeout:")
print("-" * 40)
print(e.stdout)
print("-" * 40)
if hasattr(e, 'stderr') and e.stderr:
print("Partial stderr before timeout:")
print("-" * 40)
print(e.stderr)
print("-" * 40)
print("=" * 60)
except FileNotFoundError:
print("❌ ERROR: 'mix' command not found - Elixir test repository may not be compiled")
print("Please ensure Elixir is installed and available in PATH")
except Exception as e:
print(f"❌ ERROR: Failed to prepare Elixir test repository: {e}")
@pytest.fixture(scope="session", autouse=True)
def setup_elixir_test_environment():
"""Automatically prepare Elixir test environment for all Elixir tests.
This fixture runs once per test session and automatically:
1. Installs dependencies via 'mix deps.get'
2. Compiles the Elixir test repository via 'mix compile'
It uses autouse=True so it runs automatically without needing to be explicitly
requested by tests. This ensures Next LS has a fully prepared project to work with.
Uses generous timeouts (3-5 minutes) to accommodate slow CI environments.
All output is logged for transparency and debugging.
"""
# Get the test repo path relative to this conftest.py file
test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "elixir" / "test_repo"
ensure_elixir_test_repo_compiled(str(test_repo_path))
return str(test_repo_path)
@pytest.fixture(scope="session")
def elixir_test_repo_path(setup_elixir_test_environment):
"""Get the path to the prepared Elixir test repository.
This fixture depends on setup_elixir_test_environment to ensure dependencies
are installed and compilation has completed before returning the path.
"""
return setup_elixir_test_environment
+123
View File
@@ -0,0 +1,123 @@
"""
Basic integration tests for the Elixir language server functionality.
These tests validate the functionality of the language server APIs
like request_references using the test repository.
"""
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# 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}")
]
class TestElixirBasic:
"""Basic Elixir language server functionality tests."""
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_references_function_definition(self, language_server: SolidLanguageServer):
"""Test finding references to a function definition."""
file_path = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(file_path)
# Find the User module's 'new' function
user_new_symbol = None
for symbol in symbols[0]: # Top level symbols
if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module
for child in symbol.get("children", []):
if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function
user_new_symbol = child
break
break
if not user_new_symbol or "selectionRange" not in user_new_symbol:
pytest.skip("User.new function or its selectionRange not found")
sel_start = user_new_symbol["selectionRange"]["start"]
references = language_server.request_references(
file_path, sel_start["line"], sel_start["character"]
)
assert references is not None
assert len(references) > 0
# Should find at least one reference (the definition itself)
found_definition = any(
ref["uri"].endswith("models.ex") for ref in references
)
assert found_definition, "Should find the function definition"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_references_create_user_function(self, language_server: SolidLanguageServer):
"""Test finding references to create_user function."""
file_path = os.path.join("lib", "services.ex")
symbols = language_server.request_document_symbols(file_path)
# Find the UserService module's 'create_user' function
create_user_symbol = None
for symbol in symbols[0]: # Top level symbols
if symbol.get("name") == "UserService" and symbol.get("kind") == 2: # Module
for child in symbol.get("children", []):
if child.get("name", "").startswith("def create_user(") and child.get("kind") == 12: # Function
create_user_symbol = child
break
break
if not create_user_symbol or "selectionRange" not in create_user_symbol:
pytest.skip("UserService.create_user function or its selectionRange not found")
sel_start = create_user_symbol["selectionRange"]["start"]
references = language_server.request_references(
file_path, sel_start["line"], sel_start["character"]
)
assert references is not None
assert len(references) > 0
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer):
"""Test finding symbols that reference a specific function."""
file_path = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(file_path)
# Find the User module's 'new' function
user_new_symbol = None
for symbol in symbols[0]: # Top level symbols
if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module
for child in symbol.get("children", []):
if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function
user_new_symbol = child
break
break
if not user_new_symbol or "selectionRange" not in user_new_symbol:
pytest.skip("User.new function or its selectionRange not found")
sel_start = user_new_symbol["selectionRange"]["start"]
referencing_symbols = language_server.request_referencing_symbols(
file_path, sel_start["line"], sel_start["character"]
)
assert referencing_symbols is not None
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_timeout_enumeration_bug(self, language_server: SolidLanguageServer):
"""Test that enumeration doesn't timeout (regression test)."""
# This should complete without timing out
symbols = language_server.request_document_symbols("lib/models.ex")
assert symbols is not None
# Test multiple symbol requests in succession
for _ in range(3):
symbols = language_server.request_document_symbols("lib/services.ex")
assert symbols is not None
@@ -0,0 +1,145 @@
from collections.abc import Generator
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import create_ls
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# 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}")
]
@pytest.fixture(scope="module")
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."""
ignored_paths = ["scripts", "ignored_dir"]
ls = create_ls(ignored_paths=ignored_paths, language=Language.ELIXIR)
ls.start()
try:
yield ls
finally:
ls.stop()
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True)
def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that request_full_symbol_tree ignores the configured directory."""
root = ls_with_ignored_dirs.request_full_symbol_tree()[0]
root_children = root["children"]
children_names = {child["name"] for child in root_children}
# Should have lib and test directories, but not scripts or ignored_dir
expected_dirs = {"lib", "test"}
assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}"
assert "scripts" not in children_names, f"scripts 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)
def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that find_references ignores the configured directory."""
# Location of User struct, which is referenced in scripts and ignored_dir
definition_file = "lib/models.ex"
# Find the User struct definition
symbols = ls_with_ignored_dirs.request_document_symbols(definition_file)
user_symbol = None
for symbol_group in symbols:
user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None)
if user_symbol:
break
if not user_symbol or "selectionRange" not in user_symbol:
pytest.skip("User symbol not found for reference testing")
sel_start = user_symbol["selectionRange"]["start"]
references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"])
# Assert that scripts and ignored_dir do not appear in the references
assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored"
@pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True)
def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
"""Tests that refs and symbols with glob patterns are ignored."""
ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts
ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ELIXIR)
ls.start()
try:
# Same as in the above tests
root = ls.request_full_symbol_tree()[0]
root_children = root["children"]
children_names = {child["name"] for child in root_children}
# Should have lib and test directories, but not scripts or ignored_dir
expected_dirs = {"lib", "test"}
assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}"
assert "scripts" not in children_names, f"scripts should not be in {children_names} (glob pattern)"
assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)"
# Test that the refs and symbols with glob patterns are ignored
definition_file = "lib/models.ex"
# Find the User struct definition
symbols = ls.request_document_symbols(definition_file)
user_symbol = None
for symbol_group in symbols:
user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None)
if user_symbol:
break
if user_symbol and "selectionRange" in user_symbol:
sel_start = user_symbol["selectionRange"]["start"]
references = ls.request_references(definition_file, sel_start["line"], sel_start["character"])
# Assert that scripts and ignored_dir do not appear in references
assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored (glob)"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)"
finally:
ls.stop()
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_default_ignored_directories(language_server: SolidLanguageServer):
"""Test that default Elixir directories are ignored."""
# Test that Elixir-specific directories are ignored by default
assert language_server.is_ignored_dirname("_build"), "_build should be ignored"
assert language_server.is_ignored_dirname("deps"), "deps should be ignored"
assert language_server.is_ignored_dirname(".elixir_ls"), ".elixir_ls should be ignored"
assert language_server.is_ignored_dirname("cover"), "cover should be ignored"
assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored"
# Test that important directories are not ignored
assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored"
assert not language_server.is_ignored_dirname("test"), "test should not be ignored"
assert not language_server.is_ignored_dirname("config"), "config should not be ignored"
assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer):
"""Test that symbol tree excludes build and dependency directories."""
symbol_tree = language_server.request_full_symbol_tree()
if symbol_tree:
root = symbol_tree[0]
children_names = {child["name"] for child in root.get("children", [])}
# Build and dependency directories should not appear
ignored_dirs = {"_build", "deps", ".elixir_ls", "cover", "node_modules"}
found_ignored = ignored_dirs.intersection(children_names)
assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}"
# Important directories should appear
important_dirs = {"lib", "test"}
found_important = important_dirs.intersection(children_names)
assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}"
@@ -0,0 +1,162 @@
"""
Integration tests for Elixir language server with test repository.
These tests verify that the language server works correctly with a real Elixir project
and can perform advanced operations like cross-file symbol resolution.
"""
import pytest
import os
from pathlib import Path
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# 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}")
]
class TestElixirIntegration:
"""Integration tests for Elixir language server with test repository."""
@pytest.fixture
def elixir_test_repo_path(self):
"""Get the path to the Elixir test repository."""
test_dir = Path(__file__).parent.parent.parent
return str(test_dir / "resources" / "repos" / "elixir" / "test_repo")
def test_elixir_repo_structure(self, elixir_test_repo_path):
"""Test that the Elixir test repository has the expected structure."""
repo_path = Path(elixir_test_repo_path)
# Check that key files exist
assert (repo_path / "mix.exs").exists(), "mix.exs should exist"
assert (repo_path / "lib" / "test_repo.ex").exists(), "main module should exist"
assert (repo_path / "lib" / "utils.ex").exists(), "utils module should exist"
assert (repo_path / "lib" / "models.ex").exists(), "models module should exist"
assert (repo_path / "lib" / "services.ex").exists(), "services module should exist"
assert (repo_path / "lib" / "examples.ex").exists(), "examples module should exist"
assert (repo_path / "test" / "test_repo_test.exs").exists(), "test file should exist"
assert (repo_path / "test" / "models_test.exs").exists(), "models test should exist"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer):
"""Test that symbols can be resolved across different files."""
# Test that User struct from models.ex can be found when referenced in services.ex
services_file = os.path.join("lib", "services.ex")
models_file = os.path.join("lib", "models.ex")
# Find where User is referenced in services.ex
content = language_server.retrieve_full_file_content(services_file)
lines = content.split("\n")
user_reference_line = None
for i, line in enumerate(lines):
if "alias TestRepo.Models.{User" in line:
user_reference_line = i
break
if user_reference_line is None:
pytest.skip("Could not find User reference in services.ex")
# Try to find the definition
defining_symbol = language_server.request_defining_symbol(services_file, user_reference_line, 30)
if defining_symbol and "location" in defining_symbol:
# Should point to models.ex
assert "models.ex" in defining_symbol["location"]["uri"]
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_comprehensive_symbol_search(self, language_server: SolidLanguageServer):
"""Test comprehensive symbol search across the entire project."""
# Search for all function definitions
function_pattern = r"def\s+\w+\s*[\(\s]"
function_matches = language_server.search_files_for_pattern(function_pattern)
# Should find functions across multiple files
if function_matches:
files_with_functions = set()
for match in function_matches:
if match.source_file_path:
files_with_functions.add(os.path.basename(match.source_file_path))
# Should find functions in multiple files
expected_files = {"models.ex", "services.ex", "examples.ex", "utils.ex", "test_repo.ex"}
found_files = expected_files.intersection(files_with_functions)
assert len(found_files) > 0, f"Expected functions in {expected_files}, found in {files_with_functions}"
# Search for struct definitions
struct_pattern = r"defstruct\s+\["
struct_matches = language_server.search_files_for_pattern(struct_pattern)
if struct_matches:
# Should find structs primarily in models.ex
models_structs = [m for m in struct_matches if m.source_file_path and "models.ex" in m.source_file_path]
assert len(models_structs) > 0, "Should find struct definitions in models.ex"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_module_hierarchy_understanding(self, language_server: SolidLanguageServer):
"""Test that the language server understands Elixir module hierarchy."""
models_file = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(models_file)
if symbols:
# Flatten symbol structure
all_symbols = []
for symbol_group in symbols:
if isinstance(symbol_group, list):
all_symbols.extend(symbol_group)
else:
all_symbols.append(symbol_group)
symbol_names = [s.get("name", "") for s in all_symbols]
# Should understand nested module structure
expected_modules = ["TestRepo.Models", "User", "Item", "Order"]
found_modules = [name for name in expected_modules if any(name in symbol_name for symbol_name in symbol_names)]
assert len(found_modules) > 0, f"Expected modules {expected_modules}, found symbols {symbol_names}"
def test_file_extension_matching(self):
"""Test that the Elixir language recognizes the correct file extensions."""
language = Language.ELIXIR
matcher = language.get_source_fn_matcher()
# Test Elixir file extensions
assert matcher.is_relevant_filename("lib/test_repo.ex")
assert matcher.is_relevant_filename("test/test_repo_test.exs")
assert matcher.is_relevant_filename("config/config.exs")
assert matcher.is_relevant_filename("mix.exs")
assert matcher.is_relevant_filename("lib/models.ex")
assert matcher.is_relevant_filename("lib/services.ex")
# Test non-Elixir files
assert not matcher.is_relevant_filename("README.md")
assert not matcher.is_relevant_filename("lib/test_repo.py")
assert not matcher.is_relevant_filename("package.json")
assert not matcher.is_relevant_filename("Cargo.toml")
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_protocol_and_implementation_understanding(self, language_server: SolidLanguageServer):
"""Test that the language server understands Elixir protocols and implementations."""
models_file = os.path.join("lib", "models.ex")
# Search for protocol definitions
protocol_pattern = r"defprotocol\s+\w+"
protocol_matches = language_server.search_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex")
if protocol_matches:
# Should find the Serializable protocol
serializable_matches = [m for m in protocol_matches if "Serializable" in str(m)]
assert len(serializable_matches) > 0, "Should find Serializable protocol definition"
# Search for protocol implementations
impl_pattern = r"defimpl\s+\w+"
impl_matches = language_server.search_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex")
if impl_matches:
# Should find multiple implementations
assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}"
@@ -0,0 +1,346 @@
"""
Tests for the Elixir language server symbol-related functionality.
These tests focus on the following methods:
- request_containing_symbol
- request_referencing_symbols
- request_defining_symbol
"""
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# 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}")
]
class TestElixirLanguageServerSymbols:
"""Test the Elixir language server's symbol-related functionality."""
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a function."""
# Test for a position inside the create_user function
file_path = os.path.join("lib", "services.ex")
# Find the create_user function in the file
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
create_user_line = None
for i, line in enumerate(lines):
if "def create_user(" in line:
create_user_line = i + 2 # Go inside the function body
break
if create_user_line is None:
pytest.skip("Could not find create_user function")
containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True)
# Verify that we found the containing symbol
if containing_symbol:
# Next LS returns the full function signature instead of just the function name
assert containing_symbol["name"] == "def create_user(pid, id, name, email, roles \\\\ [])"
assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function
if "body" in containing_symbol:
assert "def create_user" in containing_symbol["body"]
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a module."""
# Test for a position inside the UserService module but outside any function
file_path = os.path.join("lib", "services.ex")
# Find the UserService module definition
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_service_line = None
for i, line in enumerate(lines):
if "defmodule UserService do" in line:
user_service_line = i + 1 # Go inside the module
break
if user_service_line is None:
pytest.skip("Could not find UserService module")
containing_symbol = language_server.request_containing_symbol(file_path, user_service_line, 5)
# Verify that we found the containing symbol
if containing_symbol:
assert "UserService" in containing_symbol["name"]
assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol with nested scopes."""
# Test for a position inside a function which is inside a module
file_path = os.path.join("lib", "services.ex")
# Find a function inside UserService
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
function_body_line = None
for i, line in enumerate(lines):
if "def create_user(" in line:
function_body_line = i + 3 # Go deeper into the function body
break
if function_body_line is None:
pytest.skip("Could not find function body")
containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15)
# Verify that we found the innermost containing symbol (the function)
if containing_symbol:
expected_names = ["create_user", "UserService"]
assert any(name in containing_symbol["name"] for name in expected_names)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a position with no containing symbol."""
# Test for a position outside any function/module (e.g., in module doc)
file_path = os.path.join("lib", "services.ex")
# Line 1-3 are likely in module documentation or imports
containing_symbol = language_server.request_containing_symbol(file_path, 2, 10)
# Should return None or an empty dictionary, or the top-level module
# This is acceptable behavior for module-level positions
assert containing_symbol is None or containing_symbol == {} or "TestRepo.Services" in str(containing_symbol)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_referencing_symbols_struct(self, language_server: SolidLanguageServer) -> None:
"""Test request_referencing_symbols for a struct."""
# Test referencing symbols for User struct
file_path = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(file_path)
user_symbol = None
for symbol_group in symbols:
user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None)
if user_symbol:
break
if not user_symbol or "selectionRange" not in user_symbol:
pytest.skip("User symbol or its selectionRange not found")
sel_start = user_symbol["selectionRange"]["start"]
ref_symbols = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
]
if ref_symbols:
services_references = [
symbol
for symbol in ref_symbols
if "location" in symbol and "uri" in symbol["location"] and "services.ex" in symbol["location"]["uri"]
]
# We expect some references from services.ex
assert len(services_references) >= 0 # At least attempt to find references
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None:
"""Test request_referencing_symbols for a position with no symbol."""
file_path = os.path.join("lib", "services.ex")
# Line 3 is likely a blank line or comment
try:
ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)]
# If we get here, make sure we got an empty result
assert ref_symbols == [] or ref_symbols is None
except Exception:
# The method might raise an exception for invalid positions
# which is acceptable behavior
pass
# Tests for request_defining_symbol
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a function call."""
# Find a place where User.new is called in services.ex
file_path = os.path.join("lib", "services.ex")
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_new_call_line = None
for i, line in enumerate(lines):
if "User.new(" in line:
user_new_call_line = i
break
if user_new_call_line is None:
pytest.skip("Could not find User.new call")
# Try to find the definition of User.new
defining_symbol = language_server.request_defining_symbol(file_path, user_new_call_line, 15)
if defining_symbol:
assert defining_symbol.get("name") == "new" or "User" in defining_symbol.get("name", "")
if "location" in defining_symbol and "uri" in defining_symbol["location"]:
assert "models.ex" in defining_symbol["location"]["uri"]
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a struct usage."""
# Find a place where User struct is used in services.ex
file_path = os.path.join("lib", "services.ex")
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_usage_line = None
for i, line in enumerate(lines):
if "alias TestRepo.Models.{User" in line:
user_usage_line = i
break
if user_usage_line is None:
pytest.skip("Could not find User struct usage")
defining_symbol = language_server.request_defining_symbol(file_path, user_usage_line, 30)
if defining_symbol:
assert "User" in defining_symbol.get("name", "")
@pytest.mark.xfail(
reason="Known intermittent bug in Next LS v0.23.3: Protocol.UndefinedError for :timeout atom. "
"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)
def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a position with no symbol."""
# Test for a position with no symbol (e.g., whitespace or comment)
file_path = os.path.join("lib", "services.ex")
# Line 3 is likely a blank line
defining_symbol = language_server.request_defining_symbol(file_path, 3, 0)
# Should return None or empty
assert defining_symbol is None or defining_symbol == {}
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None:
"""Test integration between different symbol methods."""
file_path = os.path.join("lib", "models.ex")
# Find User struct definition
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_struct_line = None
for i, line in enumerate(lines):
if "defmodule User do" in line:
user_struct_line = i
break
if user_struct_line is None:
pytest.skip("Could not find User struct")
# Test containing symbol
containing = language_server.request_containing_symbol(file_path, user_struct_line + 5, 10)
if containing:
# Test that we can find references to this symbol
if "location" in containing and "range" in containing["location"]:
start_pos = containing["location"]["range"]["start"]
refs = [ref.symbol for ref in language_server.request_referencing_symbols(
file_path, start_pos["line"], start_pos["character"]
)]
# We should find some references or none (both are valid outcomes)
assert isinstance(refs, list)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None:
"""Test that symbol tree structure is correctly built."""
symbol_tree = language_server.request_full_symbol_tree()
# Should get a tree structure
assert len(symbol_tree) > 0
# Should have our test repository structure
root = symbol_tree[0]
assert "children" in root
# Look for lib directory
lib_dir = None
for child in root["children"]:
if child["name"] == "lib":
lib_dir = child
break
if lib_dir:
# Next LS 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", [])]
expected_modules = ["models", "services", "examples", "utils", "test_repo"]
found_modules = [name for name in expected_modules if name in file_names]
assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None:
"""Test request_dir_overview functionality."""
lib_overview = language_server.request_dir_overview("lib")
# Should get an overview of the lib directory
assert lib_overview is not None
# Next LS returns keys like 'lib/services.ex' instead of just 'lib'
overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, 'keys') else []
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}"
# Should contain information about our modules
overview_text = str(lib_overview).lower()
expected_terms = ["models", "services", "user", "item"]
found_terms = [term for term in expected_terms if term in overview_text]
assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview"
# @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
# def test_request_document_overview(self, language_server: SolidLanguageServer) -> None:
# """Test request_document_overview functionality."""
# # COMMENTED OUT: Next LS document overview doesn't contain expected terms
# # Next LS return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content
# # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present
# # This appears to be a limitation of Next LS document overview functionality
# #
# file_path = os.path.join("lib", "models.ex")
# doc_overview = language_server.request_document_overview(file_path)
#
# # Should get an overview of the models.ex file
# assert doc_overview is not None
#
# # Should contain information about our structs and functions
# overview_text = str(doc_overview).lower()
# expected_terms = ["user", "item", "order", "struct", "defmodule"]
# found_terms = [term for term in expected_terms if term in overview_text]
# assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_containing_symbol_of_module_attribute(self, language_server: SolidLanguageServer) -> None:
"""Test containing symbol for module attributes."""
file_path = os.path.join("lib", "models.ex")
# Find a module attribute like @type or @doc
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
attribute_line = None
for i, line in enumerate(lines):
if line.strip().startswith("@type") or line.strip().startswith("@doc"):
attribute_line = i
break
if attribute_line is None:
pytest.skip("Could not find module attribute")
containing_symbol = language_server.request_containing_symbol(file_path, attribute_line, 5)
if containing_symbol:
# Should be contained within a module
assert "name" in containing_symbol
# The containing symbol should be a module
expected_names = ["User", "Item", "Order", "TestRepo.Models"]
assert any(name in containing_symbol["name"] for name in expected_names)