diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 64dd29fa..14f38131 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -35,6 +35,15 @@ jobs: - name: Install gopls shell: bash run: go install golang.org/x/tools/gopls@latest + - name: Prepare java + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" + - name: Install clojure tools + uses: DeLaGuardo/setup-clojure@13.4 + with: + cli: latest - name: Install uv shell: bash run: curl -LsSf https://astral.sh/uv/install.sh | sh @@ -55,4 +64,4 @@ jobs: run: uv pip install -e ".[dev]" - name: Test with pytest shell: bash - run: uv run poe test \ No newline at end of file + run: uv run poe test diff --git a/.gitignore b/.gitignore index bee98b92..03279f6f 100644 --- a/.gitignore +++ b/.gitignore @@ -200,10 +200,17 @@ pylint.html /*.yml !*.template.yml /agent-ui + # dynamic LS installations /src/multilspy/language_servers/*/static /src/solidlsp/language_servers/*/static +# clojure-lsp temporary files +.calva/ +.clj-kondo/ +.cpcache/ +.lsp/ + # Cache is in .multilspy **/.multilspy/** diff --git a/pyproject.toml b/pyproject.toml index 0a46caad..bd33a237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,17 @@ [build-system] build-backend = "hatchling.build" -requires = [ - "hatchling" -] +requires = ["hatchling"] [project] name = "serena" version = "0.1.0" description = "" -authors = [ - {name = "Oraios AI", email = "info@oraios-ai.de"} -] +authors = [{ name = "Oraios AI", email = "info@oraios-ai.de" }] readme = "README.md" requires-python = ">=3.11, <3.12" classifiers = [ "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.11" + "Programming Language :: Python :: 3.11", ] dependencies = [ "requests>=2.32.3,<3", @@ -59,16 +55,9 @@ dev = [ "types-pyyaml>=6.0.12.20241230", "syrupy>=4.9.1", ] -agno = [ - "agno>=1.2.6", - "sqlalchemy>=2.0.40", -] -anthropic = [ - "anthropic>=0.49.0", -] -google = [ - "google-genai>=1.8.0", -] +agno = ["agno>=1.2.6", "sqlalchemy>=2.0.40"] +anthropic = ["anthropic>=0.49.0"] +google = ["google-genai>=1.8.0"] [project.urls] Homepage = "https://github.com/oraios/serena" @@ -78,9 +67,7 @@ packages = ["src/serena", "src/interprompt", "src/solidlsp"] [tool.black] line-length = 140 -target-version = [ - "py311" -] +target-version = ["py311"] exclude = ''' /( src/solidlsp/language_servers/.*/static|src/multilspy @@ -122,26 +109,15 @@ _black_check = "black --check src scripts test" _ruff_check = "ruff check src scripts test" _black_format = "black src scripts test" _ruff_format = "ruff check --fix src scripts test" -lint = [ - "_black_check", - "_ruff_check", -] -format = [ - "_ruff_format", - "_black_format" -] +lint = ["_black_check", "_ruff_check"] +format = ["_ruff_format", "_black_format"] _mypy = "mypy src/serena" -type-check = [ - "_mypy", -] +type-check = ["_mypy"] [tool.ruff] target-version = "py311" line-length = 140 -exclude = [ - "src/solidlsp/language_servers/**/static", - "src/multilspy", -] +exclude = ["src/solidlsp/language_servers/**/static", "src/multilspy"] [tool.ruff.format] quote-style = "double" @@ -176,7 +152,7 @@ select = [ "TID", "UP", "W", - "YTT" + "YTT", ] ignore = [ "RUF002", @@ -236,51 +212,39 @@ ignore = [ "W291", "W293", "B009", - "SIM103", # forbids multiple returns - "SIM110", # requires use of any(...) instead of for-loop - "G001", # forbids str.format in log statements - "E722", # forbids unspecific except clause - "SIM105", # forbids empty/general except clause - "SIM113", # wants to enforce use of enumerate - "E712", # forbids equality comparison with True/False - "UP007", # forbids some uses of Union - "TID252", # forbids relative imports - "B904", # forces use of raise from other_exception - "RUF012", # forbids mutable attributes as ClassVar -] -unfixable = [ - "F841", - "F601", - "F602", - "B018" -] -extend-fixable = [ - "F401", - "B905", - "W291" + "SIM103", # forbids multiple returns + "SIM110", # requires use of any(...) instead of for-loop + "G001", # forbids str.format in log statements + "E722", # forbids unspecific except clause + "SIM105", # forbids empty/general except clause + "SIM113", # wants to enforce use of enumerate + "E712", # forbids equality comparison with True/False + "UP007", # forbids some uses of Union + "TID252", # forbids relative imports + "B904", # forces use of raise from other_exception + "RUF012", # forbids mutable attributes as ClassVar ] +unfixable = ["F841", "F601", "F602", "B018"] +extend-fixable = ["F401", "B905", "W291"] [tool.ruff.lint.mccabe] max-complexity = 20 [tool.ruff.lint.per-file-ignores] -"tests/**" = [ - "D103" -] -"scripts/**" = [ - "D103" -] +"tests/**" = ["D103"] +"scripts/**" = ["D103"] [tool.pytest.ini_options] markers = [ - "python: language server running for Python", - "go: language server running for Go", - "java: language server running for Java", - "rust: language server running for Rust", - "typescript: language server running for TypeScript", - "php: language server running for PHP", - "snapshot: snapshot tests for symbolic editing operations", - "isolated_process: test runs with process isolated agent", + "clojure: language server running for Clojure", + "python: language server running for Python", + "go: language server running for Go", + "java: language server running for Java", + "rust: language server running for Rust", + "typescript: language server running for TypeScript", + "php: language server running for PHP", + "snapshot: snapshot tests for symbolic editing operations", + "isolated_process: test runs with process isolated agent", ] [tool.codespell] diff --git a/pytest.ini b/pytest.ini index 22e867b9..a9d2f112 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,14 @@ [pytest] addopts = --snapshot-patch-pycharm-diff +# this shouldn't be necessary since it's already in pyproject.toml +# but tests show lots of warnings without it +markers = + clojure: language server running for Clojure + python: language server running for Python + go: language server running for Go + java: language server running for Java + rust: language server running for Rust + typescript: language server running for TypeScript + php: language server running for PHP + snapshot: snapshot tests for symbolic editing operations + isolated_process: test runs with process isolated agent \ No newline at end of file diff --git a/src/solidlsp/language_servers/clojure_lsp/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp/clojure_lsp.py new file mode 100644 index 00000000..e83b540d --- /dev/null +++ b/src/solidlsp/language_servers/clojure_lsp/clojure_lsp.py @@ -0,0 +1,209 @@ +""" +Provides Clojure specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Clojure. +""" + +import logging +import os +import pathlib +import shutil +import stat +import subprocess +import threading + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + + +def run_command(cmd: list, capture_output: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.STDOUT if capture_output else None, text=True, check=True + ) + + +def verify_clojure_cli(): + install_msg = "Please install the official Clojure CLI from:\n https://clojure.org/guides/getting_started" + if shutil.which("clojure") is None: + raise FileNotFoundError("`clojure` not found.\n" + install_msg) + + help_proc = run_command(["clojure", "--help"]) + if "-Aaliases" not in help_proc.stdout: + raise RuntimeError("Detected a Clojure executable, but it does not support '-Aaliases'.\n" + install_msg) + + spath_proc = run_command(["clojure", "-Spath"], capture_output=False) + if spath_proc.returncode != 0: + raise RuntimeError("`clojure -Spath` failed; please upgrade to Clojure CLI ≥ 1.10.") + + +class ClojureLSP(SolidLanguageServer): + """ + Provides a clojure-lsp specific instantiation of the LanguageServer class. Contains various configurations and settings specific to clojure. + """ + + clojure_lsp_releases = "https://github.com/clojure-lsp/clojure-lsp/releases/latest/download" + runtime_dependencies = [ + { + "url": f"{clojure_lsp_releases}/clojure-lsp-native-macos-aarch64.zip", + "platformId": "osx-arm64", + "archiveType": "zip", + "binaryName": "clojure-lsp", + }, + { + "url": f"{clojure_lsp_releases}/clojure-lsp-native-linux-aarch64.zip", + "platformId": "linux-arm64", + "archiveType": "zip", + "binaryName": "clojure-lsp", + }, + { + "url": f"{clojure_lsp_releases}/clojure-lsp-native-linux-amd64.zip", + "platformId": "linux-x64", + "archiveType": "zip", + "binaryName": "clojure-lsp", + }, + { + "url": f"{clojure_lsp_releases}/clojure-lsp-native-windows-amd64.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "clojure-lsp.exe", + }, + ] + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a ClojureLSP instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + clojure_lsp_executable_path = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=clojure_lsp_executable_path, cwd=repository_root_path), + "clojure", + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + self.service_ready_event = threading.Event() + + def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + """Setup runtime dependencies for clojure-lsp.""" + verify_clojure_cli() + platform_id = PlatformUtils.get_platform_id() + + runtime_dependencies = [dependency for dependency in self.runtime_dependencies if dependency["platformId"] == platform_id.value] + if len(runtime_dependencies) != 1: + raise RuntimeError(f"Could not find a suitable clojure-lsp runtime dependency for platform {platform_id.value}.") + dependency = runtime_dependencies[0] + + clojurelsp_ls_dir = os.path.join(os.path.dirname(__file__), "static", "clojure-lsp") + clojurelsp_executable_path = os.path.join(clojurelsp_ls_dir, dependency["binaryName"]) + if not os.path.exists(clojurelsp_executable_path): + os.makedirs(clojurelsp_ls_dir, exist_ok=True) + logger.log(f"Downloading and extracting clojure-lsp from {dependency['url']} to {clojurelsp_ls_dir}", logging.INFO) + FileUtils.download_and_extract_archive(logger, dependency["url"], clojurelsp_ls_dir, dependency["archiveType"]) + if not os.path.exists(clojurelsp_executable_path): + raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}") + os.chmod(clojurelsp_executable_path, stat.S_IEXEC) + return clojurelsp_executable_path + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """Returns the init params for clojure-lsp.""" + root_uri = pathlib.Path(repository_absolute_path).as_uri() + return { # type: ignore + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "clientInfo": {"name": "solidlsp", "version": "0.1.0"}, + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "symbol": { + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + } + }, + "workspaceFolders": True, + }, + "textDocument": { + "synchronization": {"didSave": True}, + "publishDiagnostics": {"relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}}, + "definition": {"linkSupport": True}, + "references": {}, + "completion": {"completionItem": {"snippetSupport": True, "documentationFormat": ["markdown", "plaintext"]}}, + "hover": {"contentFormat": ["markdown", "plaintext"]}, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + }, + }, + "general": {"positionEncodings": ["utf-16"]}, + }, + "initializationOptions": {"dependency-scheme": "jar", "text-document-sync-kind": "incremental"}, + "trace": "off", + "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], + } + + def _start_server(self): + def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + def execute_client_command_handler(params): + return [] + + def do_nothing(params): + return + + def check_experimental_status(params): + if params["quiescent"] == True: + self.server_ready.set() + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + self.logger.log("Starting clojure-lsp server process", logging.INFO) + self.server.start() + + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + # Clojure-lsp completion provider capabilities are more flexible than other servers' + completion_provider = init_response["capabilities"]["completionProvider"] + assert completion_provider["resolveProvider"] == True + assert "triggerCharacters" in completion_provider + self.server.notify.initialized({}) + # after initialize, Clojure-lsp is ready to serve + self.server_ready.set() + self.completions_available.set() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 162b52a0..e80ef0e1 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -171,6 +171,11 @@ class SolidLanguageServer(ABC): ls = Intelephense(config, logger, repository_root_path) + elif config.code_language == Language.CLOJURE: + from solidlsp.language_servers.clojure_lsp.clojure_lsp import ClojureLSP + + ls = ClojureLSP(config, logger, repository_root_path) + else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) raise LanguageServerException(f"Language {config.code_language} is not supported") @@ -516,7 +521,7 @@ class SolidLanguageServer(ABC): """ if not self.server_started: self.logger.log( - "find_function_definition called before Language Server started", + "request_definition called before Language Server started", logging.ERROR, ) raise LanguageServerException("Language Server not started") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 9130ef1e..5609ec06 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -37,6 +37,7 @@ class Language(str, Enum): DART = "dart" CPP = "cpp" PHP = "php" + CLOJURE = "clojure" def __str__(self) -> str: return self.value @@ -71,6 +72,8 @@ class Language(str, Enum): return FilenameMatcher("*.dart") case self.PHP: return FilenameMatcher("*.php") + case self.CLOJURE: + return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/resources/repos/clojure/test_repo/deps.edn b/test/resources/repos/clojure/test_repo/deps.edn new file mode 100644 index 00000000..d0680352 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/deps.edn @@ -0,0 +1,5 @@ +{:paths ["src"] + :deps {org.clojure/clojure {:mvn/version "1.11.1"}} + :aliases + {:test {:extra-paths ["test"] + :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}}}}} diff --git a/test/resources/repos/clojure/test_repo/src/test_app/core.clj b/test/resources/repos/clojure/test_repo/src/test_app/core.clj new file mode 100644 index 00000000..20f5e000 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/core.clj @@ -0,0 +1,23 @@ +(ns test-app.core) + +(defn greet + "A simple greeting function" + [name] + (str "Hello, " name "!")) + +(defn add + "Adds two numbers" + [a b] + (+ a b)) + +(defn multiply + "Multiplies two numbers" + [a b] + (* a b)) + +(defn -main + "Main entry point" + [& args] + (println (greet "World")) + (println "2 + 3 =" (add 2 3)) + (println "4 * 5 =" (multiply 4 5))) diff --git a/test/resources/repos/clojure/test_repo/src/test_app/utils.clj b/test/resources/repos/clojure/test_repo/src/test_app/utils.clj new file mode 100644 index 00000000..868bcb54 --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/utils.clj @@ -0,0 +1,17 @@ +(ns test-app.utils + (:require [test-app.core :as core])) + +(defn calculate-area + "Calculates the area of a rectangle" + [width height] + (core/multiply width height)) + +(defn format-greeting + "Formats a greeting message" + [name] + (str "Welcome, " (core/greet name))) + +(defn sum-list + "Sums a list of numbers" + [numbers] + (reduce core/add 0 numbers)) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 4e297853..2db5537b 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import pytest +import test.solidlsp.clojure as clj from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, Project, ProjectConfig, SerenaAgent, SerenaConfigBase from serena.process_isolated_agent import ProcessIsolatedSerenaAgent from solidlsp.ls_config import Language @@ -31,7 +32,7 @@ def serena_config(): """Create an in-memory configuration for tests with test repositories pre-registered.""" # Create test projects for all supported languages test_projects = [] - for language in [Language.PYTHON, Language.GO, Language.JAVA, Language.RUST, Language.TYPESCRIPT, Language.PHP]: + for language in [Language.PYTHON, Language.GO, Language.JAVA, Language.RUST, Language.TYPESCRIPT, Language.PHP, Language.CLOJURE]: repo_path = get_repo_path(language) if repo_path.exists(): project_name = f"test_repo_{language}" @@ -94,6 +95,13 @@ class TestSerenaAgent: pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + "greet", + "Function", + clj.CORE_PATH, + marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")], + ), ], indirect=["serena_agent"], ) @@ -132,6 +140,13 @@ class TestSerenaAgent: pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + "multiply", + clj.CORE_PATH, + clj.UTILS_PATH, + marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")], + ), ], indirect=["serena_agent"], ) diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py new file mode 100644 index 00000000..ed57686d --- /dev/null +++ b/test/solidlsp/clojure/__init__.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from solidlsp.language_servers.clojure_lsp.clojure_lsp import verify_clojure_cli + + +def _test_clojure_cli() -> str | bool: + try: + verify_clojure_cli() + return False + except (FileNotFoundError, RuntimeError) as e: + return str(e) + + +CLI_FAIL = _test_clojure_cli() +TEST_APP_PATH = Path("src") / "test_app" +CORE_PATH = str(TEST_APP_PATH / "core.clj") +UTILS_PATH = str(TEST_APP_PATH / "utils.clj") diff --git a/test/solidlsp/clojure/test_clojure_basic.py b/test/solidlsp/clojure/test_clojure_basic.py new file mode 100644 index 00000000..e50ad0b2 --- /dev/null +++ b/test/solidlsp/clojure/test_clojure_basic.py @@ -0,0 +1,216 @@ +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import UnifiedSymbolInformation + +from . import CLI_FAIL, CORE_PATH, UTILS_PATH + + +@pytest.mark.clojure +@pytest.mark.skipif(CLI_FAIL, reason=f"Clojure CLI not available: {CLI_FAIL}") +class TestLanguageServerBasics: + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_basic_definition(self, language_server: SolidLanguageServer): + """ + Test finding definition of 'greet' function call in core.clj + """ + result = language_server.request_definition(CORE_PATH, 20, 12) # Position of 'greet' in (greet "World") + + assert isinstance(result, list) + assert len(result) >= 1 + + definition = result[0] + assert definition["relativePath"] == CORE_PATH + assert definition["range"]["start"]["line"] == 2, "Should find the definition of greet function at line 2" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_cross_file_references(self, language_server: SolidLanguageServer): + """ + Test finding references to 'multiply' function from core.clj + """ + result = language_server.request_references(CORE_PATH, 12, 6) + + assert isinstance(result, list) and len(result) >= 2, "Should find definition + usage in utils.clj" + + usage_found = any( + item["relativePath"] == UTILS_PATH and item["range"]["start"]["line"] == 6 # multiply usage in calculate-area + for item in result + ) + assert usage_found, "Should find multiply usage in utils.clj" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_completions(self, language_server: SolidLanguageServer): + with language_server.open_file(UTILS_PATH): + # After "core/" in calculate-area + result = language_server.request_completions(UTILS_PATH, 6, 8) + + assert isinstance(result, list) and len(result) > 0 + + completion_texts = [item["completionText"] for item in result] + assert any("multiply" in text for text in completion_texts), "Should find 'multiply' function in completions after 'core/'" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer): + symbols, _ = language_server.request_document_symbols(CORE_PATH) + + assert isinstance(symbols, list) and len(symbols) >= 4, "greet, add, multiply, -main functions" + + # Check that we find the expected function symbols + symbol_names = [symbol["name"] for symbol in symbols] + expected_functions = ["greet", "add", "multiply", "-main"] + + for func_name in expected_functions: + assert func_name in symbol_names, f"Should find {func_name} function in symbols" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_hover(self, language_server: SolidLanguageServer): + """Test hover on greet function""" + result = language_server.request_hover(CORE_PATH, 2, 7) + + assert result is not None, "Hover should return information for greet function" + assert "contents" in result + # Should contain function signature or documentation + contents = result["contents"] + if isinstance(contents, str): + assert "greet" in contents.lower() + elif isinstance(contents, dict) and "value" in contents: + assert "greet" in contents["value"].lower() + else: + assert False, f"Unexpected contents format: {type(contents)}" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_workspace_symbols(self, language_server: SolidLanguageServer): + # Search for functions containing "add" + result = language_server.request_workspace_symbol("add") + + assert isinstance(result, list) and len(result) > 0, "Should find at least one symbol containing 'add'" + + # Should find the 'add' function + symbol_names = [symbol["name"] for symbol in result] + assert any("add" in name.lower() for name in symbol_names), f"Should find 'add' function in symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_retrieve_content_around_line(self, language_server: SolidLanguageServer): + """Test retrieving content around specific lines""" + # Test retrieving content around the greet function definition (line 2) + result = language_server.retrieve_content_around_line(CORE_PATH, 2, 2) + + assert result is not None, "Should retrieve content around line 2" + content_str = result.to_display_string() + assert "greet" in content_str, "Should contain the greet function definition" + assert "defn" in content_str, "Should contain defn keyword" + + # Test retrieving content around multiply function (around line 13) + result = language_server.retrieve_content_around_line(CORE_PATH, 13, 1) + + assert result is not None, "Should retrieve content around line 13" + content_str = result.to_display_string() + assert "multiply" in content_str, "Should contain multiply function" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_namespace_functions(self, language_server: SolidLanguageServer): + """Test definition lookup for core/greet usage in utils.clj""" + # Position of 'greet' in core/greet call + result = language_server.request_definition(UTILS_PATH, 11, 25) + + assert isinstance(result, list) + assert len(result) >= 1 + + definition = result[0] + assert definition["relativePath"] == CORE_PATH, "Should find the definition of greet in core.clj" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_search_files_for_pattern(self, language_server: SolidLanguageServer): + result = language_server.search_files_for_pattern("defn.*greet") + + assert result is not None, "Pattern search should return results" + assert len(result) > 0, "Should find at least one match for 'defn.*greet'" + + core_matches = [match for match in result if match.source_file_path and "core.clj" in match.source_file_path] + assert len(core_matches) > 0, "Should find greet function in core.clj" + + result = language_server.search_files_for_pattern(":require") + + assert result is not None, "Should find require statements" + utils_matches = [match for match in result if match.source_file_path and "utils.clj" in match.source_file_path] + assert len(utils_matches) > 0, "Should find require statement in utils.clj" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_references_with_content(self, language_server: SolidLanguageServer): + """Test references to multiply function with content""" + result = language_server.request_references_with_content(CORE_PATH, 12, 6, 3) + + assert result is not None, "Should find references with content" + assert isinstance(result, list) + assert len(result) >= 2, "Should find definition + usage in utils.clj" + + for ref in result: + assert ref.source_file_path is not None, "Each reference should have a source file path" + content_str = ref.to_display_string() + assert len(content_str) > 0, "Content should not be empty" + + # Verify we find the reference in utils.clj with context + utils_refs = [ref for ref in result if ref.source_file_path and "utils.clj" in ref.source_file_path] + assert len(utils_refs) > 0, "Should find reference in utils.clj" + + # The context should contain the calculate-area function + utils_content = utils_refs[0].to_display_string() + assert "calculate-area" in utils_content + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_full_symbol_tree(self, language_server: SolidLanguageServer): + """Test retrieving the full symbol tree for project overview + We just check that we find some expected symbols. + """ + result = language_server.request_full_symbol_tree() + + assert result is not None, "Should return symbol tree" + assert isinstance(result, list), "Symbol tree should be a list" + assert len(result) > 0, "Should find symbols in the project" + + def traverse_symbols(symbols, indent=0): + """Recursively traverse symbols to print their structure""" + info = [] + for s in symbols: + name = getattr(s, "name", "NO_NAME") + kind = getattr(s, "kind", "NO_KIND") + info.append(f"{' ' * indent}Symbol: {name}, Kind: {kind}") + if hasattr(s, "children") and s.children: + info.append(" " * indent + "Children:") + info.extend(traverse_symbols(s.children, indent + 2)) + return info + + def list_all_symbols(symbols: list[UnifiedSymbolInformation]): + found = [] + for symbol in symbols: + found.append(symbol["name"]) + found.extend(list_all_symbols(symbol["children"])) + return found + + all_symbol_names = list_all_symbols(result) + + expected_symbols = ["greet", "add", "multiply", "-main", "calculate-area", "format-greeting", "sum-list"] + found_expected = [name for name in expected_symbols if any(name in symbol_name for symbol_name in all_symbol_names)] + + if len(found_expected) < 7: + pytest.fail( + f"Expected to find at least 3 symbols from {expected_symbols}, but found: {found_expected}.\n" + f"All symbol names: {all_symbol_names}\n" + f"Symbol tree structure:\n{traverse_symbols(result)}" + ) + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_request_referencing_symbols(self, language_server: SolidLanguageServer): + """Test finding symbols that reference a given symbol + Finds references to the 'multiply' function. + """ + result = language_server.request_referencing_symbols(CORE_PATH, 12, 6) + assert isinstance(result, list) and len(result) > 0, "Should find at least one referencing symbol" + found_relevant_references = False + for ref in result: + if hasattr(ref, "symbol") and "calculate-area" in ref.symbol["name"]: + found_relevant_references = True + break + + assert found_relevant_references, f"Should have found calculate-area referencing multiply, but got: {result}"