feat: add Erlang language server support via Erlang LS (#436)

This commit is contained in:
Alex Whitney authored and GitHub committed 2025-08-23 18:01:28 +02:00
1 parent d6d1e78fee
commit 602f552f7e
24 files changed
+1955 -6

No files matched your search

+14
View File
@@ -55,6 +55,20 @@ jobs:
with:
elixir-version: "1.18.4"
otp-version: "26.1"
# Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu
# In local tests, erlang seems to work though
# - name: Install Erlang Language Server
# if: runner.os != 'Windows'
# shell: bash
# run: |
# # Install rebar3 if not already available
# which rebar3 || (curl -fsSL https://github.com/erlang/rebar3/releases/download/3.23.0/rebar3 -o /tmp/rebar3 && chmod +x /tmp/rebar3 && sudo mv /tmp/rebar3 /usr/local/bin/rebar3)
# # Clone and build erlang_ls
# git clone https://github.com/erlang-ls/erlang_ls.git /tmp/erlang_ls
# cd /tmp/erlang_ls
# make install PREFIX=/usr/local
# # Ensure erlang_ls is in PATH
# echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install clojure tools
uses: DeLaGuardo/setup-clojure@13.4
with:
+1
View File
@@ -8,6 +8,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* **Dart now officially supported**: Dart was always working, but now tests were added, and it is promoted to "officially supported"
* **Rust now uses already installed rustup**: The rust-analyzer is no longer bundled with Serena. Instead, it uses the rust-analyzer from your Rust toolchain managed by rustup. This ensures compatibility with your Rust version and eliminates outdated bundled binaries.
* **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage.
* **Add support for Erlang** experimental, may hang or be slow, uses the recently archived [erlang_ls](https://github.com/erlang-ls/erlang_ls)
* Client support:
* New mode `oaicompat-agent` and extensions in the openai tool compatibility, **permitting Serena to work with llama.cpp**
+6 -5
View File
@@ -102,19 +102,20 @@ With Serena, we provide direct, out-of-the-box support for:
* PHP (uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features)
* Go (requires installation of gopls)
* Rust (requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain)
* C/C++ (you may experience issues with finding references, we are working on it)
* Zig (requires installation of ZLS - Zig Language Server)
* C#
* Ruby
* Swift
* Kotlin (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear)
* 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 installation of NextLS and Elixir; **Windows not supported**)
* Clojure
* Dart
* Bash
* C/C++ (you may experience issues with finding references, we are working on it)
* Zig (requires installation of ZLS - Zig Language Server)
* Lua (automatically downloads lua-language-server if not installed)
* Nix (requires nixd installation)
* Dart
* Kotlin (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear)
* Elixir (requires installation of NextLS and Elixir; **Windows not supported**)
* Erlang (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls), experimental, might be slow or hang)
Support for further languages can easily be added by providing a shallow adapter for a new language server implementation,
see Serena's [memory on that](.serena/memories/adding_new_language_support_guide.md).
+2 -1
View File
@@ -112,7 +112,7 @@ PYDEVD_DISABLE_FILE_VALIDATION = "1"
# Uses PYTEST_MARKERS env var for default markers
# For custom markers, one can either adjust the env var or just use -m option in the command line,
# as the second -m option will override the first one.
test = "pytest test -vv -m \"${PYTEST_MARKERS:-not java and not rust}\""
test = "pytest test -vv -m \"${PYTEST_MARKERS:-not java and not rust and not erlang}\""
_black_check = "black --check src scripts test"
_ruff_check = "ruff check src scripts test"
_black_format = "black src scripts test"
@@ -269,6 +269,7 @@ markers = [
"lua: language server running for Lua",
"nix: language server running for Nix",
"dart: language server running for Dart",
"erlang: language server running for Erlang",
]
[tool.codespell]
@@ -0,0 +1,230 @@
"""Erlang Language Server implementation using Erlang LS."""
import logging
import os
import shutil
import subprocess
import threading
import time
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
class ErlangLanguageServer(SolidLanguageServer):
"""Language server for Erlang using Erlang LS."""
def __init__(
self,
config: LanguageServerConfig,
logger: LanguageServerLogger,
repository_root_path: str,
solidlsp_settings: SolidLSPSettings,
):
"""
Creates an ErlangLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
self.erlang_ls_path = shutil.which("erlang_ls")
if not self.erlang_ls_path:
raise RuntimeError("Erlang LS not found. Install from: https://github.com/erlang-ls/erlang_ls")
if not self._check_erlang_installation():
raise RuntimeError("Erlang/OTP not found. Install from: https://www.erlang.org/downloads")
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=[self.erlang_ls_path, "--transport", "stdio"], cwd=repository_root_path),
"erlang",
solidlsp_settings,
)
# Add server readiness tracking like Elixir
self.server_ready = threading.Event()
# Set generous timeout for Erlang LS initialization
self.set_request_timeout(120.0)
def _check_erlang_installation(self) -> bool:
"""Check if Erlang/OTP is available."""
try:
result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
@classmethod
def _get_erlang_version(cls):
"""Get the installed Erlang/OTP version or None if not found."""
try:
result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
return result.stderr.strip() # erl -version outputs to stderr
except (subprocess.SubprocessError, FileNotFoundError):
return None
return None
@classmethod
def _check_rebar3_available(cls) -> bool:
"""Check if rebar3 build tool is available."""
try:
result = subprocess.run(["rebar3", "version"], check=False, capture_output=True, text=True, timeout=10)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _start_server(self):
"""Start Erlang LS server process with proper initialization waiting."""
def register_capability_handler(params):
return
def window_log_message(msg):
"""Handle window/logMessage notifications from Erlang LS"""
message_text = msg.get("message", "")
self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO)
# Look for Erlang LS readiness signals
# Common patterns: "Started Erlang LS", "initialized", "ready"
readiness_signals = [
"Started Erlang LS",
"server started",
"initialized",
"ready to serve requests",
"compilation finished",
"indexing complete",
]
message_lower = message_text.lower()
for signal in readiness_signals:
if signal.lower() in message_lower:
self.logger.log(f"Erlang LS readiness signal detected: {message_text}", logging.INFO)
self.server_ready.set()
break
def do_nothing(params):
return
def check_server_ready(params):
"""Handle $/progress notifications from Erlang LS as fallback."""
value = params.get("value", {})
# Check for initialization completion progress
if value.get("kind") == "end":
message = value.get("message", "")
if any(word in message.lower() for word in ["initialized", "ready", "complete"]):
self.logger.log("Erlang LS initialization progress completed", logging.INFO)
# Set as fallback if no window/logMessage was received
if not self.server_ready.is_set():
self.server_ready.set()
# Set up notification handlers
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", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.logger.log("Starting Erlang LS server process", logging.INFO)
self.server.start()
# Send initialize request
initialize_params = {
"processId": None,
"rootPath": self.repository_root_path,
"rootUri": f"file://{self.repository_root_path}",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True},
"completion": {"dynamicRegistration": True},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {"dynamicRegistration": True},
"hover": {"dynamicRegistration": True},
}
},
}
self.logger.log("Sending initialize request to Erlang LS", logging.INFO)
init_response = self.server.send.initialize(initialize_params)
# Verify server capabilities
if "capabilities" in init_response:
self.logger.log(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO)
self.server.notify.initialized({})
self.completions_available.set()
# Wait for Erlang LS to be ready - adjust timeout based on environment
is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
is_macos = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False
# macOS in CI can be particularly slow for language server startup
if is_ci and is_macos:
ready_timeout = 240.0 # 4 minutes for macOS CI
env_desc = "macOS CI"
elif is_ci:
ready_timeout = 180.0 # 3 minutes for other CI
env_desc = "CI"
else:
ready_timeout = 60.0 # 1 minute for local
env_desc = "local"
self.logger.log(f"Waiting up to {ready_timeout} seconds for Erlang LS readiness ({env_desc} environment)...", logging.INFO)
if self.server_ready.wait(timeout=ready_timeout):
self.logger.log("Erlang LS is ready and available for requests", logging.INFO)
# Add settling period for indexing - adjust based on environment
settling_time = 15.0 if is_ci else 5.0
self.logger.log(f"Allowing {settling_time} seconds for Erlang LS indexing to complete...", logging.INFO)
time.sleep(settling_time)
self.logger.log("Erlang LS settling period complete", logging.INFO)
else:
# Set ready anyway and continue - Erlang LS might not send explicit ready messages
self.logger.log(
f"Erlang LS readiness timeout reached after {ready_timeout}s, proceeding anyway (common in CI)", logging.WARNING
)
self.server_ready.set()
# Still give some time for basic initialization even without explicit readiness signal
basic_settling_time = 20.0 if is_ci else 10.0
self.logger.log(f"Allowing {basic_settling_time} seconds for basic Erlang LS initialization...", logging.INFO)
time.sleep(basic_settling_time)
self.logger.log("Basic Erlang LS initialization period complete", logging.INFO)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For Erlang projects, we should ignore:
# - _build: rebar3 build artifacts
# - deps: dependencies
# - ebin: compiled beam files
# - .rebar3: rebar3 cache
# - logs: log files
# - node_modules: if the project has JavaScript components
return super().is_ignored_dirname(dirname) or dirname in [
"_build",
"deps",
"ebin",
".rebar3",
"logs",
"node_modules",
"_checkouts",
"cover",
]
def is_ignored_filename(self, filename: str) -> bool:
"""Check if a filename should be ignored."""
# Ignore compiled BEAM files
if filename.endswith(".beam"):
return True
# Don't ignore Erlang source files, header files, or configuration files
return False
+5
View File
@@ -253,6 +253,11 @@ class SolidLanguageServer(ABC):
ls = LuaLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings)
elif config.code_language == Language.ERLANG:
from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer
ls = ErlangLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings)
else:
logger.log(f"Language {config.code_language} is not supported", logging.ERROR)
raise SolidLSPException(f"Language {config.code_language} is not supported")
+3
View File
@@ -47,6 +47,7 @@ class Language(str, Enum):
ZIG = "zig"
LUA = "lua"
NIX = "nix"
ERLANG = "erlang"
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -118,6 +119,8 @@ class Language(str, Enum):
return FilenameMatcher("*.lua")
case self.NIX:
return FilenameMatcher("*.nix")
case self.ERLANG:
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1,14 @@
-module(hello).
-export([hello_world/0, greet/1, calculate_sum/2]).
%% Simple hello world function
hello_world() ->
io:format("Hello, World!~n").
%% Greet a person by name
greet(Name) ->
io:format("Hello, ~s!~n", [Name]).
%% Calculate sum of two numbers
calculate_sum(A, B) ->
A + B.
@@ -0,0 +1,8 @@
%% This module should be ignored by tests
-module(ignored_module).
%% This is in the ignored directory and should not be processed
-export([ignored_function/0]).
ignored_function() ->
"This should not appear in symbol searches".
@@ -0,0 +1,38 @@
%% Common record definitions for the test repository
-ifndef(RECORDS_HRL).
-define(RECORDS_HRL, true).
%% User record definition
-record(user, {
id :: integer(),
name :: string(),
email :: string(),
age :: integer(),
active = true :: boolean()
}).
%% Order record definition
-record(order, {
id :: integer(),
user_id :: integer(),
items = [] :: list(),
total :: float(),
status = pending :: pending | processing | completed | cancelled
}).
%% Item record definition
-record(item, {
id :: integer(),
name :: string(),
price :: float(),
category :: string()
}).
%% Configuration record
-record(config, {
database_url :: string(),
port :: integer(),
debug = false :: boolean()
}).
-endif.
@@ -0,0 +1,20 @@
%% Type definitions for the test repository
-ifndef(TYPES_HRL).
-define(TYPES_HRL, true).
%% Custom types
-type user_id() :: pos_integer().
-type email() :: string().
-type status() :: active | inactive | suspended.
-type price() :: float().
-type quantity() :: non_neg_integer().
%% Complex types
-type order_line() :: {item_id :: pos_integer(), quantity :: quantity(), price :: price()}.
-type search_result() :: {ok, list()} | {error, term()}.
%% Callback types for behaviors
-type init_result() :: {ok, term()} | {stop, term()}.
-type handle_call_result() :: {reply, term(), term()} | {stop, term(), term()}.
-endif.
@@ -0,0 +1,16 @@
-module(math_utils).
-export([add/2, multiply/2, factorial/1]).
%% Add two numbers
add(X, Y) ->
X + Y.
%% Multiply two numbers
multiply(X, Y) ->
X * Y.
%% Calculate factorial
factorial(0) ->
1;
factorial(N) when N > 0 ->
N * factorial(N - 1).
@@ -0,0 +1,33 @@
%% Rebar3 configuration for test repository
{erl_opts, [
debug_info,
warnings_as_errors,
warn_export_all,
warn_unused_import,
{i, "include"}
]}.
{deps, [
{eunit, ".*", {git, "https://github.com/richcarl/eunit.git", {tag, "2.3.6"}}}
]}.
{profiles, [
{test, [
{erl_opts, [debug_info]},
{deps, [
{proper, "1.3.0"}
]}
]}
]}.
{cover_enabled, true}.
{cover_print_enabled, true}.
{dialyzer, [
{warnings, [
unmatched_returns,
error_handling,
race_conditions,
underspecs
]}
]}.
@@ -0,0 +1,117 @@
%% Main application module
-module(app).
-behaviour(application).
-include("../include/records.hrl").
%% Application callbacks
-export([
start/2,
stop/1
]).
%% API exports
-export([
start_services/0,
stop_services/0,
get_config/0,
health_check/0
]).
%%%===================================================================
%%% Application callbacks
%%%===================================================================
-spec start(application:start_type(), term()) -> {ok, pid()} | {error, term()}.
start(_StartType, _StartArgs) ->
io:format("Starting test application~n"),
case start_services() of
ok ->
supervisor:start_link({local, app_sup}, ?MODULE, []);
{error, Reason} ->
{error, Reason}
end.
-spec stop(term()) -> ok.
stop(_State) ->
io:format("Stopping test application~n"),
stop_services(),
ok.
%%%===================================================================
%%% API functions
%%%===================================================================
-spec start_services() -> ok | {error, term()}.
start_services() ->
try
{ok, _Pid} = services:start_link(),
io:format("Services started successfully~n"),
ok
catch
error:Reason ->
io:format("Failed to start services: ~p~n", [Reason]),
{error, Reason}
end.
-spec stop_services() -> ok.
stop_services() ->
try
services:stop(),
io:format("Services stopped successfully~n"),
ok
catch
error:Reason ->
io:format("Error stopping services: ~p~n", [Reason]),
ok
end.
-spec get_config() -> #config{}.
get_config() ->
#config{
database_url = "postgresql://localhost:5432/testdb",
port = 8080,
debug = true
}.
-spec health_check() -> {ok, #{atom() => term()}} | {error, term()}.
health_check() ->
try
Stats = services:get_statistics(),
Config = get_config(),
HealthInfo = #{
status => healthy,
timestamp => utils:timestamp(),
config => Config,
statistics => Stats,
uptime => erlang:statistics(wall_clock)
},
{ok, HealthInfo}
catch
error:Reason ->
{error, {health_check_failed, Reason}}
end.
%%%===================================================================
%%% Supervisor callbacks (simple implementation)
%%%===================================================================
init([]) ->
%% Simple supervisor strategy
SupFlags = #{
strategy => one_for_one,
intensity => 5,
period => 10
},
ChildSpecs = [
#{
id => services,
start => {services, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [services]
}
],
{ok, {SupFlags, ChildSpecs}}.
@@ -0,0 +1,83 @@
%% Models module with record operations and business logic
-module(models).
-include("../include/records.hrl").
-include("../include/types.hrl").
%% Export functions
-export([
create_user/4,
update_user/2,
get_user_by_id/1,
create_order/3,
add_item_to_order/3,
calculate_order_total/1,
validate_email/1,
format_user_info/1
]).
%% User operations
-spec create_user(integer(), string(), email(), integer()) -> #user{}.
create_user(Id, Name, Email, Age) ->
#user{
id = Id,
name = Name,
email = Email,
age = Age,
active = true
}.
-spec update_user(#user{}, [{atom(), term()}]) -> #user{}.
update_user(User, Updates) ->
lists:foldl(fun update_user_field/2, User, Updates).
-spec get_user_by_id(user_id()) -> {ok, #user{}} | {error, not_found}.
get_user_by_id(Id) ->
%% Simulate database lookup
case Id of
1 -> {ok, create_user(1, "John Doe", "john@example.com", 30)};
2 -> {ok, create_user(2, "Jane Smith", "jane@example.com", 25)};
_ -> {error, not_found}
end.
%% Order operations
-spec create_order(integer(), user_id(), list()) -> #order{}.
create_order(Id, UserId, Items) ->
#order{
id = Id,
user_id = UserId,
items = Items,
total = 0.0,
status = pending
}.
-spec add_item_to_order(#order{}, #item{}, quantity()) -> #order{}.
add_item_to_order(Order, Item, Quantity) ->
NewItem = Item#item{id = Quantity}, % Store quantity in id field for simplicity
Order#order{items = [NewItem | Order#order.items]}.
-spec calculate_order_total(#order{}) -> float().
calculate_order_total(#order{items = Items}) ->
lists:foldl(fun(#item{price = Price, id = Qty}, Acc) ->
Acc + (Price * Qty)
end, 0.0, Items).
%% Helper functions
-spec update_user_field({atom(), term()}, #user{}) -> #user{}.
update_user_field({name, Name}, User) -> User#user{name = Name};
update_user_field({email, Email}, User) -> User#user{email = Email};
update_user_field({age, Age}, User) -> User#user{age = Age};
update_user_field({active, Active}, User) -> User#user{active = Active};
update_user_field(_, User) -> User.
-spec validate_email(string()) -> boolean().
validate_email(Email) ->
string:str(Email, "@") > 0 andalso string:str(Email, ".") > 0.
-spec format_user_info(#user{}) -> string().
format_user_info(#user{name = Name, email = Email, age = Age, active = Active}) ->
Status = case Active of
true -> "active";
false -> "inactive"
end,
lists:flatten(io_lib:format("~s (~s) - Age: ~w - Status: ~s",
[Name, Email, Age, Status])).
@@ -0,0 +1,146 @@
%% Services module implementing gen_server behavior
-module(services).
-behaviour(gen_server).
-include("../include/records.hrl").
-include("../include/types.hrl").
%% API exports
-export([
start_link/0,
stop/0,
register_user/4,
get_user/1,
create_order/2,
update_order_status/2,
get_statistics/0
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).
%% State record
-record(state, {
users = #{} :: map(),
orders = #{} :: map(),
next_user_id = 1 :: integer(),
next_order_id = 1 :: integer()
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec stop() -> ok.
stop() ->
gen_server:stop(?MODULE).
-spec register_user(string(), email(), integer(), boolean()) -> {ok, user_id()} | {error, term()}.
register_user(Name, Email, Age, Active) ->
gen_server:call(?MODULE, {register_user, Name, Email, Age, Active}).
-spec get_user(user_id()) -> {ok, #user{}} | {error, not_found}.
get_user(UserId) ->
gen_server:call(?MODULE, {get_user, UserId}).
-spec create_order(user_id(), list()) -> {ok, integer()} | {error, term()}.
create_order(UserId, Items) ->
gen_server:call(?MODULE, {create_order, UserId, Items}).
-spec update_order_status(integer(), atom()) -> ok | {error, term()}.
update_order_status(OrderId, Status) ->
gen_server:call(?MODULE, {update_order_status, OrderId, Status}).
-spec get_statistics() -> #{atom() => integer()}.
get_statistics() ->
gen_server:call(?MODULE, get_statistics).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
-spec init([]) -> {ok, #state{}}.
init([]) ->
{ok, #state{}}.
-spec handle_call(term(), {pid(), term()}, #state{}) -> handle_call_result().
handle_call({register_user, Name, Email, Age, Active}, _From, State) ->
UserId = State#state.next_user_id,
User = models:create_user(UserId, Name, Email, Age),
UpdatedUser = models:update_user(User, [{active, Active}]),
NewUsers = maps:put(UserId, UpdatedUser, State#state.users),
NewState = State#state{
users = NewUsers,
next_user_id = UserId + 1
},
{reply, {ok, UserId}, NewState};
handle_call({get_user, UserId}, _From, State) ->
case maps:get(UserId, State#state.users, not_found) of
not_found -> {reply, {error, not_found}, State};
User -> {reply, {ok, User}, State}
end;
handle_call({create_order, UserId, Items}, _From, State) ->
case maps:get(UserId, State#state.users, not_found) of
not_found ->
{reply, {error, user_not_found}, State};
_User ->
OrderId = State#state.next_order_id,
Order = models:create_order(OrderId, UserId, Items),
NewOrders = maps:put(OrderId, Order, State#state.orders),
NewState = State#state{
orders = NewOrders,
next_order_id = OrderId + 1
},
{reply, {ok, OrderId}, NewState}
end;
handle_call({update_order_status, OrderId, Status}, _From, State) ->
case maps:get(OrderId, State#state.orders, not_found) of
not_found ->
{reply, {error, order_not_found}, State};
Order ->
UpdatedOrder = Order#order{status = Status},
NewOrders = maps:put(OrderId, UpdatedOrder, State#state.orders),
NewState = State#state{orders = NewOrders},
{reply, ok, NewState}
end;
handle_call(get_statistics, _From, State) ->
Stats = #{
total_users => maps:size(State#state.users),
total_orders => maps:size(State#state.orders),
next_user_id => State#state.next_user_id,
next_order_id => State#state.next_order_id
},
{reply, Stats, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
-spec handle_cast(term(), #state{}) -> {noreply, #state{}}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), #state{}) -> {noreply, #state{}}.
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), #state{}) -> ok.
terminate(_Reason, _State) ->
ok.
-spec code_change(term() | {down, term()}, #state{}, term()) -> {ok, #state{}}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
@@ -0,0 +1,135 @@
%% Utility functions module
-module(utils).
-include("../include/types.hrl").
%% String utilities
-export([
capitalize/1,
trim/1,
split_string/2,
format_currency/1,
validate_input/2
]).
%% List utilities
-export([
find_by_id/2,
group_by/2,
partition_by/2,
safe_nth/2
]).
%% Math utilities
-export([
calculate_discount/2,
round_to_decimal/2,
percentage/2
]).
%% Date/Time utilities
-export([
timestamp/0,
format_datetime/1,
days_between/2
]).
%%%===================================================================
%%% String utilities
%%%===================================================================
-spec capitalize(string()) -> string().
capitalize([]) -> [];
capitalize([H|T]) -> [string:to_upper(H) | T].
-spec trim(string()) -> string().
trim(String) ->
string:strip(string:strip(String, right), left).
-spec split_string(string(), string()) -> [string()].
split_string(String, Delimiter) ->
string:tokens(String, Delimiter).
-spec format_currency(float()) -> string().
format_currency(Amount) ->
lists:flatten(io_lib:format("$~.2f", [Amount])).
-spec validate_input(atom(), term()) -> boolean().
validate_input(email, Email) when is_list(Email) ->
models:validate_email(Email);
validate_input(age, Age) when is_integer(Age) ->
Age >= 0 andalso Age =< 150;
validate_input(name, Name) when is_list(Name) ->
length(Name) > 0 andalso length(Name) =< 100;
validate_input(_, _) ->
false.
%%%===================================================================
%%% List utilities
%%%===================================================================
-spec find_by_id(integer(), [tuple()]) -> {ok, tuple()} | {error, not_found}.
find_by_id(_Id, []) -> {error, not_found};
find_by_id(Id, [H|T]) when element(2, H) =:= Id -> {ok, H};
find_by_id(Id, [_|T]) -> find_by_id(Id, T).
-spec group_by(fun((term()) -> term()), [term()]) -> [{term(), [term()]}].
group_by(Fun, List) ->
Dict = lists:foldl(fun(Item, Acc) ->
Key = Fun(Item),
case lists:keyfind(Key, 1, Acc) of
{Key, Values} ->
lists:keyreplace(Key, 1, Acc, {Key, [Item|Values]});
false ->
[{Key, [Item]}|Acc]
end
end, [], List),
[{K, lists:reverse(V)} || {K, V} <- Dict].
-spec partition_by(fun((term()) -> boolean()), [term()]) -> {[term()], [term()]}.
partition_by(Predicate, List) ->
lists:partition(Predicate, List).
-spec safe_nth(integer(), [term()]) -> {ok, term()} | {error, out_of_bounds}.
safe_nth(N, List) when N > 0 andalso N =< length(List) ->
{ok, lists:nth(N, List)};
safe_nth(_, _) ->
{error, out_of_bounds}.
%%%===================================================================
%%% Math utilities
%%%===================================================================
-spec calculate_discount(float(), float()) -> float().
calculate_discount(Price, DiscountPercent) when DiscountPercent >= 0 andalso DiscountPercent =< 100 ->
Price * (100 - DiscountPercent) / 100.
-spec round_to_decimal(float(), integer()) -> float().
round_to_decimal(Number, Decimals) ->
Factor = math:pow(10, Decimals),
round(Number * Factor) / Factor.
-spec percentage(number(), number()) -> float().
percentage(Part, Total) when Total =/= 0 ->
(Part / Total) * 100;
percentage(_, 0) ->
0.0.
%%%===================================================================
%%% Date/Time utilities
%%%===================================================================
-spec timestamp() -> integer().
timestamp() ->
{MegaSecs, Secs, _MicroSecs} = os:timestamp(),
MegaSecs * 1000000 + Secs.
-spec format_datetime(integer()) -> string().
format_datetime(Timestamp) ->
{{Year, Month, Day}, {Hour, Minute, Second}} =
calendar:gregorian_seconds_to_datetime(Timestamp + 62167219200),
lists:flatten(io_lib:format("~4..0w-~2..0w-~2..0w ~2..0w:~2..0w:~2..0w",
[Year, Month, Day, Hour, Minute, Second])).
-spec days_between(integer(), integer()) -> integer().
days_between(Timestamp1, Timestamp2) ->
abs(Timestamp2 - Timestamp1) div (24 * 3600).
@@ -0,0 +1,77 @@
%% Unit tests for models module
-module(models_tests).
-include_lib("eunit/include/eunit.hrl").
-include("../include/records.hrl").
%%%===================================================================
%%% Test fixtures
%%%===================================================================
sample_user() ->
models:create_user(1, "John Doe", "john@example.com", 30).
sample_order() ->
Items = [
#item{id = 1, name = "Widget", price = 10.99, category = "tools"},
#item{id = 2, name = "Gadget", price = 25.50, category = "electronics"}
],
models:create_order(1, 1, Items).
%%%===================================================================
%%% User tests
%%%===================================================================
create_user_test() ->
User = models:create_user(1, "John Doe", "john@example.com", 30),
?assertEqual(1, User#user.id),
?assertEqual("John Doe", User#user.name),
?assertEqual("john@example.com", User#user.email),
?assertEqual(30, User#user.age),
?assertEqual(true, User#user.active).
update_user_test() ->
User = sample_user(),
UpdatedUser = models:update_user(User, [{name, "Jane Doe"}, {age, 25}]),
?assertEqual("Jane Doe", UpdatedUser#user.name),
?assertEqual(25, UpdatedUser#user.age),
?assertEqual("john@example.com", UpdatedUser#user.email). % unchanged
get_user_by_id_test() ->
?assertEqual({ok, #user{id = 1, name = "John Doe"}},
models:get_user_by_id(1)),
?assertEqual({error, not_found}, models:get_user_by_id(999)).
%%%===================================================================
%%% Order tests
%%%===================================================================
create_order_test() ->
Order = models:create_order(1, 1, []),
?assertEqual(1, Order#order.id),
?assertEqual(1, Order#order.user_id),
?assertEqual([], Order#order.items),
?assertEqual(pending, Order#order.status).
calculate_order_total_test() ->
Order = sample_order(),
Total = models:calculate_order_total(Order),
?assertEqual(36.49, Total). % 10.99 * 1 + 25.50 * 2
%%%===================================================================
%%% Validation tests
%%%===================================================================
validate_email_test() ->
?assertEqual(true, models:validate_email("user@example.com")),
?assertEqual(true, models:validate_email("test.email@domain.co.uk")),
?assertEqual(false, models:validate_email("invalid-email")),
?assertEqual(false, models:validate_email("@domain.com")),
?assertEqual(false, models:validate_email("user@")).
format_user_info_test() ->
User = sample_user(),
Info = models:format_user_info(User),
?assert(string:str(Info, "John Doe") > 0),
?assert(string:str(Info, "john@example.com") > 0),
?assert(string:str(Info, "30") > 0),
?assert(string:str(Info, "active") > 0).
@@ -0,0 +1,85 @@
%% Unit tests for utils module
-module(utils_tests).
-include_lib("eunit/include/eunit.hrl").
%%%===================================================================
%%% String utility tests
%%%===================================================================
capitalize_test() ->
?assertEqual("Hello", utils:capitalize("hello")),
?assertEqual("Test", utils:capitalize("test")),
?assertEqual("", utils:capitalize("")).
trim_test() ->
?assertEqual("hello", utils:trim(" hello ")),
?assertEqual("test", utils:trim("test")),
?assertEqual("", utils:trim(" ")).
format_currency_test() ->
?assertEqual("$10.50", utils:format_currency(10.5)),
?assertEqual("$0.99", utils:format_currency(0.99)),
?assertEqual("$100.00", utils:format_currency(100.0)).
validate_input_test() ->
?assertEqual(true, utils:validate_input(email, "test@example.com")),
?assertEqual(false, utils:validate_input(email, "invalid")),
?assertEqual(true, utils:validate_input(age, 25)),
?assertEqual(false, utils:validate_input(age, -5)),
?assertEqual(true, utils:validate_input(name, "John")),
?assertEqual(false, utils:validate_input(name, "")).
%%%===================================================================
%%% List utility tests
%%%===================================================================
find_by_id_test() ->
Items = [{item, 1, "first"}, {item, 2, "second"}, {item, 3, "third"}],
?assertEqual({ok, {item, 2, "second"}}, utils:find_by_id(2, Items)),
?assertEqual({error, not_found}, utils:find_by_id(999, Items)).
safe_nth_test() ->
List = [a, b, c, d, e],
?assertEqual({ok, c}, utils:safe_nth(3, List)),
?assertEqual({error, out_of_bounds}, utils:safe_nth(10, List)),
?assertEqual({error, out_of_bounds}, utils:safe_nth(0, List)).
%%%===================================================================
%%% Math utility tests
%%%===================================================================
calculate_discount_test() ->
?assertEqual(90.0, utils:calculate_discount(100.0, 10.0)),
?assertEqual(75.0, utils:calculate_discount(100.0, 25.0)),
?assertEqual(100.0, utils:calculate_discount(100.0, 0.0)).
round_to_decimal_test() ->
?assertEqual(10.99, utils:round_to_decimal(10.9876, 2)),
?assertEqual(15.0, utils:round_to_decimal(15.0001, 2)),
?assertEqual(0.33, utils:round_to_decimal(1/3, 2)).
percentage_test() ->
?assertEqual(50.0, utils:percentage(50, 100)),
?assertEqual(25.0, utils:percentage(1, 4)),
?assertEqual(0.0, utils:percentage(10, 0)).
%%%===================================================================
%%% Date/Time utility tests
%%%===================================================================
timestamp_test() ->
Timestamp = utils:timestamp(),
?assert(is_integer(Timestamp)),
?assert(Timestamp > 0).
format_datetime_test() ->
% Test with a known timestamp
Formatted = utils:format_datetime(1234567890),
?assert(is_list(Formatted)),
?assert(length(Formatted) > 0).
days_between_test() ->
Day1 = 1000000,
Day2 = 1000000 + (3 * 24 * 3600), % 3 days later
?assertEqual(3, utils:days_between(Day1, Day2)),
?assertEqual(3, utils:days_between(Day2, Day1)).
+33
View File
@@ -0,0 +1,33 @@
import platform
def _test_erlang_ls_available() -> str:
"""Test if Erlang LS is available and return error reason if not."""
# Check if we're on Windows (Erlang LS doesn't support Windows)
if platform.system() == "Windows":
return "Erlang LS does not support Windows"
# Try to import and check Erlang availability
try:
from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer
# Check if Erlang/OTP is installed
erlang_version = ErlangLanguageServer._get_erlang_version()
if not erlang_version:
return "Erlang/OTP is not installed or not in PATH"
# Check if rebar3 is available (commonly used build tool)
rebar3_available = ErlangLanguageServer._check_rebar3_available()
if not rebar3_available:
return "rebar3 is not installed or not in PATH (required for project compilation)"
return "" # No error, Erlang LS should be available
except ImportError as e:
return f"Failed to import ErlangLanguageServer: {e}"
except Exception as e:
return f"Error checking Erlang LS availability: {e}"
ERLANG_LS_UNAVAILABLE_REASON = _test_erlang_ls_available()
ERLANG_LS_UNAVAILABLE = bool(ERLANG_LS_UNAVAILABLE_REASON)
+174
View File
@@ -0,0 +1,174 @@
"""
Erlang-specific test configuration and fixtures.
"""
import os
import subprocess
import time
from pathlib import Path
import pytest
def ensure_erlang_test_repo_compiled(repo_path: str) -> None:
"""Ensure the Erlang test repository dependencies are installed and project is compiled.
Erlang LS requires the project to be fully compiled and indexed before providing
complete references and symbol resolution. This function:
1. Installs dependencies via 'rebar3 deps'
2. Compiles the project via 'rebar3 compile'
This is essential in CI environments where dependencies aren't pre-installed.
Args:
repo_path: Path to the Erlang project root directory
"""
# Check if this looks like an Erlang project
rebar_config = os.path.join(repo_path, "rebar.config")
if not os.path.exists(rebar_config):
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"Erlang test repository already compiled in {repo_path}")
return
try:
print("Installing dependencies and compiling Erlang test repository for optimal Erlang LS performance...")
# First, install dependencies with increased timeout for CI
print("=" * 60)
print("Step 1/2: Installing Erlang dependencies...")
print("=" * 60)
start_time = time.time()
deps_result = subprocess.run(
["rebar3", "deps"],
cwd=repo_path,
capture_output=True,
text=True,
timeout=180,
check=False, # 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 Erlang project...")
print("=" * 60)
start_time = time.time()
compile_result = subprocess.run(
["rebar3", "compile"],
cwd=repo_path,
capture_output=True,
text=True,
timeout=300,
check=False, # 5 minutes for compilation (Dialyzer 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"✓ Erlang 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: Erlang setup timed out after {e.timeout} seconds")
print(f"Command: {' '.join(e.cmd)}")
print("This may indicate slow CI environment - Erlang 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: 'rebar3' command not found - Erlang test repository may not be compiled")
print("Please ensure rebar3 is installed and available in PATH")
except Exception as e:
print(f"❌ ERROR: Failed to prepare Erlang test repository: {e}")
@pytest.fixture(scope="session", autouse=True)
def setup_erlang_test_environment():
"""Automatically prepare Erlang test environment for all Erlang tests.
This fixture runs once per test session and automatically:
1. Installs dependencies via 'rebar3 deps'
2. Compiles the Erlang test repository via 'rebar3 compile'
It uses autouse=True so it runs automatically without needing to be explicitly
requested by tests. This ensures Erlang 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" / "erlang" / "test_repo"
ensure_erlang_test_repo_compiled(str(test_repo_path))
return str(test_repo_path)
@pytest.fixture(scope="session")
def erlang_test_repo_path(setup_erlang_test_environment):
"""Get the path to the prepared Erlang test repository.
This fixture depends on setup_erlang_test_environment to ensure dependencies
are installed and compilation has completed before returning the path.
"""
return setup_erlang_test_environment
+43
View File
@@ -0,0 +1,43 @@
"""
Basic integration tests for the Erlang language server functionality.
These tests validate the functionality of the language server APIs
like request_references using the test repository.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON
@pytest.mark.erlang
@pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}")
class TestErlangLanguageServerBasics:
"""Test basic functionality of the Erlang language server."""
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_language_server_initialization(self, language_server: SolidLanguageServer) -> None:
"""Test that the Erlang language server initializes properly."""
assert language_server is not None
assert language_server.language == Language.ERLANG
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test document symbols retrieval for Erlang files."""
try:
file_path = "hello.erl"
symbols_tuple = language_server.request_document_symbols(file_path)
assert isinstance(symbols_tuple, tuple)
assert len(symbols_tuple) == 2
all_symbols, root_symbols = symbols_tuple
assert isinstance(all_symbols, list)
assert isinstance(root_symbols, list)
except Exception as e:
if "not fully initialized" in str(e):
pytest.skip("Erlang language server not fully initialized")
else:
raise
@@ -0,0 +1,227 @@
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 ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [
pytest.mark.erlang,
pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"),
]
@pytest.fixture(scope="module")
def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]:
"""Fixture to set up an LS for the erlang test repo with the 'ignored_dir' directory ignored."""
ignored_paths = ["_build", "ignored_dir"]
ls = create_ls(ignored_paths=ignored_paths, language=Language.ERLANG)
ls.start()
try:
yield ls
finally:
try:
ls.stop(shutdown_timeout=1.0) # Shorter timeout for CI
except Exception as e:
print(f"Warning: Error stopping language server: {e}")
# Force cleanup if needed
if hasattr(ls, "server") and hasattr(ls.server, "process"):
try:
ls.server.process.terminate()
except:
pass
@pytest.mark.timeout(60) # Add 60 second timeout
@pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False)
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], 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 src, include, and test directories, but not _build or ignored_dir
expected_dirs = {"src", "include", "test"}
found_expected = expected_dirs.intersection(children_names)
assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}"
assert "_build" not in children_names, f"_build should not be in {children_names}"
assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}"
@pytest.mark.timeout(60) # Add 60 second timeout
@pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False)
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True)
def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that find_references ignores the configured directory."""
# Location of user record, which might be referenced in ignored_dir
definition_file = "include/records.hrl"
# Find the user record 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", "").lower()), None)
if user_symbol:
break
if not user_symbol or "selectionRange" not in user_symbol:
pytest.skip("User record 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 _build and ignored_dir do not appear in the references
assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored"
@pytest.mark.timeout(90) # Longer timeout for this complex test
@pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False)
@pytest.mark.parametrize("repo_path", [Language.ERLANG], 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 = ["_build*", "ignored_*", "*.tmp"]
ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ERLANG)
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 src, include, and test directories, but not _build or ignored_dir
expected_dirs = {"src", "include", "test"}
found_expected = expected_dirs.intersection(children_names)
assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}"
assert "_build" not in children_names, f"_build 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 = "include/records.hrl"
# Find the user record 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", "").lower()), 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 _build and ignored_dir do not appear in references
assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored (glob)"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)"
finally:
try:
ls.stop(shutdown_timeout=1.0) # Shorter timeout for CI
except Exception as e:
print(f"Warning: Error stopping glob pattern test LS: {e}")
# Force cleanup if needed
if hasattr(ls, "server") and hasattr(ls.server, "process"):
try:
ls.server.process.terminate()
except:
pass
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_default_ignored_directories(language_server: SolidLanguageServer):
"""Test that default Erlang directories are ignored."""
# Test that Erlang-specific directories are ignored by default
assert language_server.is_ignored_dirname("_build"), "_build should be ignored"
assert language_server.is_ignored_dirname("ebin"), "ebin should be ignored"
assert language_server.is_ignored_dirname("deps"), "deps should be ignored"
assert language_server.is_ignored_dirname(".rebar3"), ".rebar3 should be ignored"
assert language_server.is_ignored_dirname("_checkouts"), "_checkouts 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("src"), "src should not be ignored"
assert not language_server.is_ignored_dirname("include"), "include should not be ignored"
assert not language_server.is_ignored_dirname("test"), "test should not be ignored"
assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored"
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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", "ebin", "deps", ".rebar3", "_checkouts", "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 = {"src", "include", "test"}
found_important = important_dirs.intersection(children_names)
assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}"
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_ignore_compiled_files(language_server: SolidLanguageServer):
"""Test that compiled Erlang files are ignored."""
# Test that beam files are ignored
assert language_server.is_ignored_filename("module.beam"), "BEAM files should be ignored"
assert language_server.is_ignored_filename("app.beam"), "BEAM files should be ignored"
# Test that source files are not ignored
assert not language_server.is_ignored_filename("module.erl"), "Erlang source files should not be ignored"
assert not language_server.is_ignored_filename("records.hrl"), "Header files should not be ignored"
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_rebar_directories_ignored(language_server: SolidLanguageServer):
"""Test that rebar-specific directories are ignored."""
# Test rebar3-specific directories
assert language_server.is_ignored_dirname("_build"), "rebar3 _build should be ignored"
assert language_server.is_ignored_dirname("_checkouts"), "rebar3 _checkouts should be ignored"
assert language_server.is_ignored_dirname(".rebar3"), "rebar3 cache should be ignored"
# Test that rebar.lock and rebar.config are not ignored (they are configuration files)
assert not language_server.is_ignored_filename("rebar.config"), "rebar.config should not be ignored"
assert not language_server.is_ignored_filename("rebar.lock"), "rebar.lock should not be ignored"
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True)
def test_document_symbols_ignores_dirs(ls_with_ignored_dirs: SolidLanguageServer):
"""Test that document symbols from ignored directories are not included."""
# Try to get symbols from a file in ignored directory (should not find it)
try:
ignored_file = "ignored_dir/ignored_module.erl"
symbols = ls_with_ignored_dirs.request_document_symbols(ignored_file)
# If we get here, the file was found - symbols should be empty or None
if symbols:
assert len(symbols) == 0, "Should not find symbols in ignored directory"
except Exception:
# This is expected - the file should not be accessible
pass
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_erlang_specific_ignore_patterns(language_server: SolidLanguageServer):
"""Test Erlang-specific ignore patterns work correctly."""
erlang_ignored_dirs = ["_build", "ebin", ".rebar3", "_checkouts", "cover"]
# These should be ignored
for dirname in erlang_ignored_dirs:
assert language_server.is_ignored_dirname(dirname), f"{dirname} should be ignored"
# These should not be ignored
erlang_important_dirs = ["src", "include", "test", "priv"]
for dirname in erlang_important_dirs:
assert not language_server.is_ignored_dirname(dirname), f"{dirname} should not be ignored"
@@ -0,0 +1,445 @@
"""
Tests for the Erlang 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 ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [
pytest.mark.erlang,
pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"),
]
class TestErlangLanguageServerSymbols:
"""Test the Erlang language server's symbol-related functionality."""
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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("src", "models.erl")
# 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 "create_user(" in line and "-spec" not in line:
create_user_line = i + 1 # 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:
assert "create_user" in containing_symbol["name"]
assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function
if "body" in containing_symbol:
assert "create_user" in containing_symbol["body"]
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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 models module but outside any function
file_path = os.path.join("src", "models.erl")
# Find the module definition
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
module_line = None
for i, line in enumerate(lines):
if "-module(models)" in line:
module_line = i + 2 # Go inside the module
break
if module_line is None:
pytest.skip("Could not find models module")
containing_symbol = language_server.request_containing_symbol(file_path, module_line, 5)
# Verify that we found the containing symbol
if containing_symbol:
assert "models" in containing_symbol["name"] or "module" in containing_symbol["name"].lower()
assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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("src", "models.erl")
# Find a function inside models module
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
function_body_line = None
for i, line in enumerate(lines):
if "create_user(" in line and "-spec" not in line:
# Go deeper into the function body where there might be case expressions
for j in range(i + 1, min(i + 10, len(lines))):
if lines[j].strip() and not lines[j].strip().startswith("%"):
function_body_line = j
break
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", "models"]
assert any(name in containing_symbol["name"] for name in expected_names)
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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 comments)
file_path = os.path.join("src", "models.erl")
# Line 1-2 are likely module declaration or comments
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 "models" in str(containing_symbol)
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_request_referencing_symbols_record(self, language_server: SolidLanguageServer) -> None:
"""Test request_referencing_symbols for a record."""
# Test referencing symbols for user record
file_path = os.path.join("include", "records.hrl")
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 record 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:
models_references = [
symbol
for symbol in ref_symbols
if "location" in symbol and "uri" in symbol["location"] and "models.erl" in symbol["location"]["uri"]
]
# We expect some references from models.erl
assert len(models_references) >= 0 # At least attempt to find references
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None:
"""Test request_referencing_symbols for a function."""
# Test referencing symbols for create_user function
file_path = os.path.join("src", "models.erl")
symbols = language_server.request_document_symbols(file_path)
create_user_symbol = None
for symbol_group in symbols:
create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None)
if create_user_symbol:
break
if not create_user_symbol or "selectionRange" not in create_user_symbol:
pytest.skip("create_user function symbol or its selectionRange not found")
sel_start = create_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:
# We might find references from services.erl or test files
service_references = [
symbol
for symbol in ref_symbols
if "location" in symbol
and "uri" in symbol["location"]
and ("services.erl" in symbol["location"]["uri"] or "test" in symbol["location"]["uri"])
]
assert len(service_references) >= 0 # At least attempt to find references
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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("src", "models.erl")
# 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.ERLANG], 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 models:create_user is called in services.erl
file_path = os.path.join("src", "services.erl")
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
models_call_line = None
for i, line in enumerate(lines):
if "models:create_user(" in line:
models_call_line = i
break
if models_call_line is None:
pytest.skip("Could not find models:create_user call")
# Try to find the definition of models:create_user
defining_symbol = language_server.request_defining_symbol(file_path, models_call_line, 20)
if defining_symbol:
assert "create_user" in defining_symbol.get("name", "") or "models" in defining_symbol.get("name", "")
if "location" in defining_symbol and "uri" in defining_symbol["location"]:
assert "models.erl" in defining_symbol["location"]["uri"]
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_request_defining_symbol_record_usage(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a record usage."""
# Find a place where #user{} record is used in models.erl
file_path = os.path.join("src", "models.erl")
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
record_usage_line = None
for i, line in enumerate(lines):
if "#user{" in line:
record_usage_line = i
break
if record_usage_line is None:
pytest.skip("Could not find #user{} record usage")
defining_symbol = language_server.request_defining_symbol(file_path, record_usage_line, 10)
if defining_symbol:
assert "user" in defining_symbol.get("name", "").lower()
if "location" in defining_symbol and "uri" in defining_symbol["location"]:
assert "records.hrl" in defining_symbol["location"]["uri"]
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_request_defining_symbol_module_call(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a module function call."""
# Find a place where utils:validate_input is called
file_path = os.path.join("src", "models.erl")
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
utils_call_line = None
for i, line in enumerate(lines):
if "validate_email(" in line:
utils_call_line = i
break
if utils_call_line is None:
pytest.skip("Could not find function call in models.erl")
defining_symbol = language_server.request_defining_symbol(file_path, utils_call_line, 15)
if defining_symbol:
assert "validate" in defining_symbol.get("name", "") or "email" in defining_symbol.get("name", "")
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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("src", "models.erl")
# Line 3 is likely a blank line or comment
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.ERLANG], indirect=True)
def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None:
"""Test integration between different symbol methods."""
file_path = os.path.join("src", "models.erl")
# Find create_user function definition
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
create_user_line = None
for i, line in enumerate(lines):
if "create_user(" in line and "-spec" not in line:
create_user_line = i
break
if create_user_line is None:
pytest.skip("Could not find create_user function")
# Test containing symbol
containing = language_server.request_containing_symbol(file_path, create_user_line + 2, 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.timeout(120) # Add explicit timeout for this complex test
@pytest.mark.xfail(
reason="Known intermittent timeout issue in Erlang LS in CI environments. "
"May pass locally but can timeout on slower CI systems.",
strict=False,
)
@pytest.mark.parametrize("language_server", [Language.ERLANG], 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 src directory
src_dir = None
for child in root["children"]:
if child["name"] == "src":
src_dir = child
break
if src_dir:
# Check for our Erlang modules
file_names = [child["name"] for child in src_dir.get("children", [])]
expected_modules = ["models", "services", "utils", "app"]
found_modules = [name for name in expected_modules if any(name in fname for fname 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.ERLANG], indirect=True)
def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None:
"""Test request_dir_overview functionality."""
src_overview = language_server.request_dir_overview("src")
# Should get an overview of the src directory
assert src_overview is not None
overview_keys = list(src_overview.keys()) if hasattr(src_overview, "keys") else []
src_files = [key for key in overview_keys if key.startswith("src/") or "src" in key]
assert len(src_files) > 0, f"Expected to find src/ files in overview keys: {overview_keys}"
# Should contain information about our modules
overview_text = str(src_overview).lower()
expected_terms = ["models", "services", "user", "create_user", "gen_server"]
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.ERLANG], indirect=True)
def test_containing_symbol_of_record_field(self, language_server: SolidLanguageServer) -> None:
"""Test containing symbol for record field access."""
file_path = os.path.join("src", "models.erl")
# Find a record field access like User#user.name
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
record_field_line = None
for i, line in enumerate(lines):
if "#user{" in line and ("name" in line or "email" in line or "id" in line):
record_field_line = i
break
if record_field_line is None:
pytest.skip("Could not find record field access")
containing_symbol = language_server.request_containing_symbol(file_path, record_field_line, 10)
if containing_symbol:
# Should be contained within a function
assert "name" in containing_symbol
expected_names = ["create_user", "update_user", "format_user_info"]
assert any(name in containing_symbol["name"] for name in expected_names)
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_containing_symbol_of_spec(self, language_server: SolidLanguageServer) -> None:
"""Test containing symbol for function specs."""
file_path = os.path.join("src", "models.erl")
# Find a -spec directive
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
spec_line = None
for i, line in enumerate(lines):
if line.strip().startswith("-spec") and "create_user" in line:
spec_line = i
break
if spec_line is None:
pytest.skip("Could not find -spec directive")
containing_symbol = language_server.request_containing_symbol(file_path, spec_line, 5)
if containing_symbol:
# Should be contained within the module or the function it specifies
assert "name" in containing_symbol
expected_names = ["models", "create_user"]
assert any(name in containing_symbol["name"] for name in expected_names)
@pytest.mark.timeout(90) # Add explicit timeout
@pytest.mark.xfail(
reason="Known intermittent timeout issue in Erlang LS in CI environments. "
"May pass locally but can timeout on slower CI systems, especially macOS. "
"Similar to known Next LS timeout issues.",
strict=False,
)
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
def test_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None:
"""Test finding references across different files."""
# Test that we can find references to models module functions in services.erl
file_path = os.path.join("src", "models.erl")
symbols = language_server.request_document_symbols(file_path)
create_user_symbol = None
for symbol_group in symbols:
create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None)
if create_user_symbol:
break
if not create_user_symbol or "selectionRange" not in create_user_symbol:
pytest.skip("create_user function symbol not found")
sel_start = create_user_symbol["selectionRange"]["start"]
ref_symbols = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
]
# Look for cross-file references
cross_file_refs = [
symbol
for symbol in ref_symbols
if "location" in symbol and "uri" in symbol["location"] and not symbol["location"]["uri"].endswith("models.erl")
]
# We might find references in services.erl or test files
if cross_file_refs:
assert len(cross_file_refs) > 0, "Should find some cross-file references"