From 602f552f7e6825809b1db1e4d6986b927978c13c Mon Sep 17 00:00:00 2001 From: Alex Whitney Date: Sat, 23 Aug 2025 12:01:28 -0400 Subject: [PATCH] feat: add Erlang language server support via Erlang LS (#436) --- .github/workflows/pytest.yml | 14 + CHANGELOG.md | 1 + README.md | 11 +- pyproject.toml | 3 +- .../erlang_language_server.py | 230 +++++++++ src/solidlsp/ls.py | 5 + src/solidlsp/ls_config.py | 3 + .../repos/erlang/test_repo/hello.erl | 14 + .../test_repo/ignored_dir/ignored_module.erl | 8 + .../erlang/test_repo/include/records.hrl | 38 ++ .../repos/erlang/test_repo/include/types.hrl | 20 + .../repos/erlang/test_repo/math_utils.erl | 16 + .../repos/erlang/test_repo/rebar.config | 33 ++ .../repos/erlang/test_repo/src/app.erl | 117 +++++ .../repos/erlang/test_repo/src/models.erl | 83 ++++ .../repos/erlang/test_repo/src/services.erl | 146 ++++++ .../repos/erlang/test_repo/src/utils.erl | 135 ++++++ .../erlang/test_repo/test/models_tests.erl | 77 +++ .../erlang/test_repo/test/utils_tests.erl | 85 ++++ test/solidlsp/erlang/__init__.py | 33 ++ test/solidlsp/erlang/conftest.py | 174 +++++++ test/solidlsp/erlang/test_erlang_basic.py | 43 ++ .../erlang/test_erlang_ignored_dirs.py | 227 +++++++++ .../erlang/test_erlang_symbol_retrieval.py | 445 ++++++++++++++++++ 24 files changed, 1955 insertions(+), 6 deletions(-) create mode 100644 src/solidlsp/language_servers/erlang_language_server.py create mode 100644 test/resources/repos/erlang/test_repo/hello.erl create mode 100644 test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl create mode 100644 test/resources/repos/erlang/test_repo/include/records.hrl create mode 100644 test/resources/repos/erlang/test_repo/include/types.hrl create mode 100644 test/resources/repos/erlang/test_repo/math_utils.erl create mode 100644 test/resources/repos/erlang/test_repo/rebar.config create mode 100644 test/resources/repos/erlang/test_repo/src/app.erl create mode 100644 test/resources/repos/erlang/test_repo/src/models.erl create mode 100644 test/resources/repos/erlang/test_repo/src/services.erl create mode 100644 test/resources/repos/erlang/test_repo/src/utils.erl create mode 100644 test/resources/repos/erlang/test_repo/test/models_tests.erl create mode 100644 test/resources/repos/erlang/test_repo/test/utils_tests.erl create mode 100644 test/solidlsp/erlang/__init__.py create mode 100644 test/solidlsp/erlang/conftest.py create mode 100644 test/solidlsp/erlang/test_erlang_basic.py create mode 100644 test/solidlsp/erlang/test_erlang_ignored_dirs.py create mode 100644 test/solidlsp/erlang/test_erlang_symbol_retrieval.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index bfd4e9f8..d61637ba 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5acca9..3ae2d69f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/README.md b/README.md index fbd7810c..d59dbd3c 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/pyproject.toml b/pyproject.toml index badf5252..20931afd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/solidlsp/language_servers/erlang_language_server.py b/src/solidlsp/language_servers/erlang_language_server.py new file mode 100644 index 00000000..aaa85f1c --- /dev/null +++ b/src/solidlsp/language_servers/erlang_language_server.py @@ -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 diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 1dbd4f2a..8d8b1f2e 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 45435d44..946df96a 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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}") diff --git a/test/resources/repos/erlang/test_repo/hello.erl b/test/resources/repos/erlang/test_repo/hello.erl new file mode 100644 index 00000000..34f27af7 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/hello.erl @@ -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. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl b/test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl new file mode 100644 index 00000000..4f60fbd0 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl @@ -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". \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/include/records.hrl b/test/resources/repos/erlang/test_repo/include/records.hrl new file mode 100644 index 00000000..f0b69961 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/include/records.hrl @@ -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. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/include/types.hrl b/test/resources/repos/erlang/test_repo/include/types.hrl new file mode 100644 index 00000000..dec0f9df --- /dev/null +++ b/test/resources/repos/erlang/test_repo/include/types.hrl @@ -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. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/math_utils.erl b/test/resources/repos/erlang/test_repo/math_utils.erl new file mode 100644 index 00000000..3ff58612 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/math_utils.erl @@ -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). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/rebar.config b/test/resources/repos/erlang/test_repo/rebar.config new file mode 100644 index 00000000..ef8802ae --- /dev/null +++ b/test/resources/repos/erlang/test_repo/rebar.config @@ -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 + ]} +]}. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/app.erl b/test/resources/repos/erlang/test_repo/src/app.erl new file mode 100644 index 00000000..96e40150 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/app.erl @@ -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}}. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/models.erl b/test/resources/repos/erlang/test_repo/src/models.erl new file mode 100644 index 00000000..255ab186 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/models.erl @@ -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])). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/services.erl b/test/resources/repos/erlang/test_repo/src/services.erl new file mode 100644 index 00000000..c066fd7b --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/services.erl @@ -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}. \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/src/utils.erl b/test/resources/repos/erlang/test_repo/src/utils.erl new file mode 100644 index 00000000..e97309f0 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/utils.erl @@ -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). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/test/models_tests.erl b/test/resources/repos/erlang/test_repo/test/models_tests.erl new file mode 100644 index 00000000..c4ce47ca --- /dev/null +++ b/test/resources/repos/erlang/test_repo/test/models_tests.erl @@ -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). \ No newline at end of file diff --git a/test/resources/repos/erlang/test_repo/test/utils_tests.erl b/test/resources/repos/erlang/test_repo/test/utils_tests.erl new file mode 100644 index 00000000..042c3c92 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/test/utils_tests.erl @@ -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)). \ No newline at end of file diff --git a/test/solidlsp/erlang/__init__.py b/test/solidlsp/erlang/__init__.py new file mode 100644 index 00000000..6c9c2f06 --- /dev/null +++ b/test/solidlsp/erlang/__init__.py @@ -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) diff --git a/test/solidlsp/erlang/conftest.py b/test/solidlsp/erlang/conftest.py new file mode 100644 index 00000000..5ac77795 --- /dev/null +++ b/test/solidlsp/erlang/conftest.py @@ -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 diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py new file mode 100644 index 00000000..6375e6bb --- /dev/null +++ b/test/solidlsp/erlang/test_erlang_basic.py @@ -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 diff --git a/test/solidlsp/erlang/test_erlang_ignored_dirs.py b/test/solidlsp/erlang/test_erlang_ignored_dirs.py new file mode 100644 index 00000000..f36c1df5 --- /dev/null +++ b/test/solidlsp/erlang/test_erlang_ignored_dirs.py @@ -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" diff --git a/test/solidlsp/erlang/test_erlang_symbol_retrieval.py b/test/solidlsp/erlang/test_erlang_symbol_retrieval.py new file mode 100644 index 00000000..b113030c --- /dev/null +++ b/test/solidlsp/erlang/test_erlang_symbol_retrieval.py @@ -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"