mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 14:17:49 +00:00
feat: add Erlang language server support via Erlang LS (#436)
This commit is contained in:
1 parent
d6d1e78fee
commit
602f552f7e
24 files changed
+1955
-6
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)).
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user