diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index b2316674..6df6169c 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -15,6 +15,7 @@ jobs: codespell: name: Check for spelling errors runs-on: ubuntu-latest + timeout-minutes: 2 steps: - name: Checkout diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9e9d0ed1..a89e7fd6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,8 +4,6 @@ on: push: branches: [ main ] tags: [ 'v*' ] - pull_request: - branches: [ main ] env: REGISTRY: ghcr.io @@ -14,6 +12,7 @@ env: jobs: build-and-push: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read packages: write diff --git a/.github/workflows/lint_and_docs.yaml b/.github/workflows/lint_and_docs.yaml index 03cdad94..daef60c8 100644 --- a/.github/workflows/lint_and_docs.yaml +++ b/.github/workflows/lint_and_docs.yaml @@ -6,6 +6,7 @@ on: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 5 permissions: id-token: write pages: write diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index ded9968a..ee6a8177 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,8 +1,11 @@ name: Upload Python Package on: - release: - types: [created] + workflow_dispatch: # Manual trigger only - workflow disabled from auto-running + +#on: +# release: +# types: [created] jobs: deploy: diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 14f38131..cf3b93a4 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -14,7 +14,6 @@ jobs: cpu: name: Tests on ${{ matrix.os }} runs-on: ${{ matrix.os }} - if: "!contains(github.event.head_commit.message, 'ci skip')" strategy: fail-fast: false matrix: @@ -32,9 +31,25 @@ jobs: # Add Go bin directory to PATH for this workflow # GITHUB_PATH is a special file that GitHub Actions uses to modify PATH # Writing to this file adds the directory to the PATH for subsequent steps + - name: Cache Go binaries + id: cache-go-binaries + uses: actions/cache@v3 + with: + path: | + ~/go/bin + ~/.cache/go-build + key: go-binaries-${{ runner.os }}-gopls-latest - name: Install gopls + if: steps.cache-go-binaries.outputs.cache-hit != 'true' shell: bash run: go install golang.org/x/tools/gopls@latest + - name: Set up Elixir + if: runner.os != 'Windows' + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.18.4' + otp-version: '26.1' + - name: Prepare java uses: actions/setup-java@v3 with: @@ -44,6 +59,11 @@ jobs: uses: DeLaGuardo/setup-clojure@13.4 with: cli: latest + - name: Install Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.5.0" + terraform_wrapper: false - name: Install uv shell: bash run: curl -LsSf https://astral.sh/uv/install.sh | sh @@ -53,6 +73,14 @@ jobs: with: path: .venv key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }} + - name: Cache language servers + id: cache-language-servers + uses: actions/cache@v3 + with: + path: src/solidlsp/language_servers/static + key: language-servers-${{ runner.os }}-v1 + restore-keys: | + language-servers-${{ runner.os }}- - name: Create virtual environment shell: bash run: | diff --git a/.gitignore b/.gitignore index fabbe519..64c7e71e 100644 --- a/.gitignore +++ b/.gitignore @@ -204,6 +204,7 @@ pylint.html # dynamic LS installations /src/multilspy/language_servers/*/static /src/solidlsp/language_servers/*/static +/src/solidlsp/language_servers/static # clojure-lsp temporary files .calva/ @@ -221,3 +222,8 @@ tmp/ # Claude settings .claude/settings.local.json + +# Elixir +/test/resources/repos/elixir/test_repo/deps +# Exception: Don't ignore Elixir test repository lib directory (contains source code) +!/test/resources/repos/elixir/test_repo/lib diff --git a/.serena/memories/process_isolation_current_status.md b/.serena/memories/process_isolation_current_status.md deleted file mode 100644 index 67d9490a..00000000 --- a/.serena/memories/process_isolation_current_status.md +++ /dev/null @@ -1,126 +0,0 @@ -# Process Isolation: Current Status and Architecture - -## Current Status: Disabled by Default - -Process isolation is **disabled by default** in the current Serena implementation: -- **Configuration**: `USE_PROCESS_ISOLATION = False` in `src/serena/constants.py:22` -- **Reason**: No longer needed with solid-lsp architecture -- **Default Operation**: Single process mode with MCP server, agent, and language servers in same process - -## Why Process Isolation Is No Longer Needed - -### Historical Context -Process isolation was **mandatory** when using multilspy because: -1. **Asyncio Contamination**: Multilspy leaked coroutines into MCP server's event loop -2. **Event Loop Conflicts**: Multiple asyncio contexts caused deadlocks -3. **Only Solution**: Complete process separation was the only way to prevent issues - -### Current Architecture Benefits -With solid-lsp, process isolation became **unnecessary** because: -1. **Clean Async Boundaries**: No coroutine leakage between MCP server and language server contexts -2. **Single Process Safety**: Solid-lsp designed to work safely within MCP server process -3. **Performance Gains**: Direct method calls instead of expensive IPC - -## Architecture Components (Still Present) - -The process isolation infrastructure remains **available but unused** by default: - -### Core Components (`src/serena/process_isolated_agent.py`) - -#### 1. **ProcessIsolatedSerenaAgent** (lines 392-550) -- **Purpose**: Wrapper that manages isolated agent process -- **Communication**: Uses `multiprocessing.Pipe()` for bidirectional communication -- **Process Management**: Creates and manages `SerenaAgentWorker` subprocess -- **Status**: **Available but not used by default** - -#### 2. **SerenaAgentWorker** (lines 173-389) -- **Purpose**: Worker process hosting actual SerenaAgent -- **Event Loop**: Polling loop checking for requests every 500ms -- **Request Handling**: INITIALIZE, TOOL_CALL, SHUTDOWN, etc. -- **Status**: **Available but not used by default** - -#### 3. **ProcessIsolatedDashboard** (lines 133-170) -- **Purpose**: Runs web dashboard in separate process -- **Integration**: Async web server with port management -- **Status**: **Available but not used by default** - -### Global Synchronization (lines 25-28) -```python -_global_log_queue: multiprocessing.Queue = multiprocessing.Queue() -_dashboard_ready_event = multiprocessing.Event() -_dashboard_port_value = multiprocessing.Value("i", 0) -global_shutdown_event = multiprocessing.Event() -``` -**Status**: **Available but not actively used** - -## Current MCP Factory Selection - -In `src/serena/mcp.py:590`: -```python -if not USE_PROCESS_ISOLATION: - mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) -else: - mcp_factory = SerenaMCPFactoryWithProcessIsolation(context=context, project=project_file) -``` - -### Default Flow -1. **`USE_PROCESS_ISOLATION = False`** → **`SerenaMCPFactorySingleProcess`** -2. **Single Process**: MCP server, SerenaAgent, and solid-lsp in same process -3. **Direct Communication**: No IPC overhead, direct method calls - -### Fallback Option -If `USE_PROCESS_ISOLATION = True`: -1. **`SerenaMCPFactoryWithProcessIsolation`** would be used -2. **Separate Processes**: MCP server and SerenaAgent in different processes -3. **IPC Communication**: Higher latency but complete isolation - -## Benefits of Current Single Process Architecture - -### 1. **Performance Improvements** -- **Lower Latency**: Direct method calls vs IPC communication -- **Reduced Memory**: No duplicate process memory footprints -- **Faster Startup**: No process creation and initialization overhead - -### 2. **Operational Simplicity** -- **Unified Logging**: All components log to same destination -- **Simpler Debugging**: Single process, unified stack traces -- **Resource Management**: Simpler cleanup and shutdown procedures - -### 3. **Stability Benefits** -- **No IPC Failures**: Eliminated inter-process communication failure modes -- **Consistent State**: No synchronization issues between processes -- **Reliable Shutdown**: No orphaned processes or cleanup complexity - -## When Process Isolation Might Still Be Useful - -While not needed by default, process isolation could still be beneficial for: - -### 1. **Fault Isolation** -- **Agent Crashes**: Prevent agent failures from affecting MCP server -- **Memory Protection**: Isolate memory leaks to specific processes -- **Recovery**: Restart failed components without full system restart - -### 2. **Resource Management** -- **Memory Limits**: Constrain memory usage of specific components -- **CPU Isolation**: Prevent CPU-intensive operations from blocking MCP server -- **Security**: Additional process boundaries for security-sensitive environments - -### 3. **Debugging and Development** -- **Component Isolation**: Debug specific components in isolation -- **Performance Analysis**: Measure resource usage per component -- **Development Safety**: Prevent development errors from affecting stable components - -## Configuration Management - -### Enabling Process Isolation -To re-enable process isolation: -1. **Set**: `USE_PROCESS_ISOLATION = True` in `src/serena/constants.py` -2. **Result**: Automatic fallback to `SerenaMCPFactoryWithProcessIsolation` -3. **Trade-off**: Higher resource usage but complete component isolation - -### Current Recommendation -- **Default**: Keep `USE_PROCESS_ISOLATION = False` for optimal performance -- **Special Cases**: Enable only when specific isolation requirements exist -- **Testing**: Both modes should be tested to ensure compatibility - -The current architecture successfully eliminated the need for process isolation while maintaining the capability as a fallback option for specialized use cases. \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md index 296c37b1..2ecbe796 100644 --- a/.serena/memories/suggested_commands.md +++ b/.serena/memories/suggested_commands.md @@ -17,7 +17,6 @@ The following tasks should generally be executed using `uv run poe `. "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", ] ``` By default, `uv run poe test` uses the markers set in the env var `PYTEST_MARKERS`, or, if it unset, uses `-m "not java and not rust and not isolated process"`. diff --git a/.vscode/settings.json b/.vscode/settings.json index 56a7855f..6c3ee2ca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,4 +11,4 @@ "sensai", "vibing" ], -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index bdee1950..ca4378fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,6 @@ Status of the `main` branch. Changes prior to the next official version change w * The MCP server is the only asyncio-based component now, which resolves cross-component loop contamination, such that process isolation is no longer required. Neither are non-graceful shutdowns on Windows. -* **Support for Clojure** -* Better default, extended parameters and description for restricting the search in `search_for_pattern` * **Improved editing tools**: The editing logic was simplified and improved, making it more robust. * The "minimal indentation" logic was removed, because LLMs did not understand it. * The logic for the insertion of empty lines was improved (mostly controlled by the LLM now) @@ -18,7 +16,12 @@ Status of the `main` branch. Changes prior to the next official version change w * allows the language server to be initialized in the background, making the MCP server respond to requests immediately upon startup, * ensures that all tool executions are fully synchronized (executed linearly). -* Better support for C# by switching from `omnisharp` to Microsoft's official C# language server. +* `SearchForPatternTool`: Better default, extended parameters and description for restricting the search +* Language support: + * Better support for C# by switching from `omnisharp` to Microsoft's official C# language server. + * **Add support for Clojure** +* Configuration: + * Add option `web_dashboard_open_on_launch` (allowing the dashboard to be enabled without opening a browser window) Fixes: * Fix `ExecuteShellCommandTool` and `GetCurrentConfigTool` hanging on Windows diff --git a/README.md b/README.md index 5f5854c0..3f35fae2 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ With Serena, we provide * Rust * C# (requires dotnet to be installed. We switched the underlying language server recently, please report any issues you encounter) * Java (_Note_: startup is slow, initial startup especially so. There may be issues with java on macos and linux, we are working on it.) + * Elixir (Requires NextLS and Elixir install; **Windows not supported** - Next LS does not provide Windows binaries) * Clojure * C/C++ (You may experience issues with finding references, we are working on it) * indirect support (may require some code changes/manual installation) for: @@ -201,9 +202,15 @@ This and other settings can be adjusted in the [configuration](#configuration) a git clone https://github.com/oraios/serena cd serena ``` -2. Optionally create a config file from the template and adjust it according to your preferences. +2. Optionally create the configuration file in your home directory, i.e. + + * `~/.serena/serena_config.yml` on Linux and macOS, or + * `%USERPROFILE%\.serena\serena_config.yml` on Windows. + + by copying the template and then adjusting it according to your needs: ```shell - cp src/serena/resources/serena_config.template.yml serena_config.yml + mkdir ~/.serena + cp src/serena/resources/serena_config.template.yml ~/.serena/serena_config.yml ``` If you just want the default config, you can skip this part, and a config file will be created when you first run Serena. 3. Run the server with `uv`: @@ -279,7 +286,9 @@ Run with parameter `--help` to get a list of available options. Serena's behavior (active tools and prompts as well as logging configuration, etc.) is configured in four places: -1. The `serena_config.yml` for general settings that apply to all clients and projects +1. The `serena_config.yml` for general settings that apply to all clients and projects. + It is located in your user directory under `.serena/serena_config.yml`. + If you do not explicitly create the file, it will be auto-generated when you first run Serena. 2. In the arguments passed to the `serena-mcp-server` in your client's config (see below), which will apply to all sessions started by the respective client. In particular, the [context](#contexts) parameter should be set appropriately for Serena to be best adjusted to existing tools and capabilities of your client. @@ -346,7 +355,8 @@ claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena- ``` ℹ️ Once in Claude Code, you should ask Claude to "Read the initial instructions" as your first prompt, such that it will receive information -on how to use Serena's tools. +on how to use Serena's tools. Provide this prompt whenever you start a new conversation and perhaps even after a compacting operation, +such that Claude is correctly primed to use Serena's tools at all times. ### Claude Desktop diff --git a/pyproject.toml b/pyproject.toml index 0e244680..ad37014b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,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 and not isolated_process}\"" +test = "pytest test -vv -m \"${PYTEST_MARKERS:-not java and not rust}\"" _black_check = "black --check src scripts test" _ruff_check = "ruff check src scripts test" _black_format = "black src scripts test" @@ -224,6 +224,7 @@ ignore = [ "TID252", # forbids relative imports "B904", # forces use of raise from other_exception "RUF012", # forbids mutable attributes as ClassVar + "SIM117", # forbids nested with statements ] unfixable = ["F841", "F601", "F602", "B018"] extend-fixable = ["F401", "B905", "W291"] @@ -246,8 +247,9 @@ markers = [ "typescript: language server running for TypeScript", "php: language server running for PHP", "csharp: language server running for C#", + "elixir: language server running for Elixir", + "terraform: language server running for Terraform", "snapshot: snapshot tests for symbolic editing operations", - "isolated_process: test runs with process isolated agent", ] [tool.codespell] diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index e3c92861..64929b38 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -8,19 +8,8 @@ from pprint import pprint from serena.agent import * from serena.constants import REPO_ROOT - -@dataclass -class InMemorySerenaConfig(SerenaConfigBase): - """ - In-memory implementation of Serena configuration with the GUI disabled. - """ - - gui_log_window_enabled: bool = False - web_dashboard: bool = False - - if __name__ == "__main__": - agent = SerenaAgent(project=REPO_ROOT, serena_config=InMemorySerenaConfig()) + agent = SerenaAgent(project=REPO_ROOT, serena_config=SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)) # apply a tool find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) diff --git a/scripts/print_mode_context_options.py b/scripts/print_mode_context_options.py index 20a24d79..fe7f768f 100644 --- a/scripts/print_mode_context_options.py +++ b/scripts/print_mode_context_options.py @@ -1,4 +1,4 @@ -from serena.config import SerenaAgentContext, SerenaAgentMode +from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode if __name__ == "__main__": print("---------- Available modes: ----------") diff --git a/src/serena/agent.py b/src/serena/agent.py index a875255e..88c32268 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -2,62 +2,40 @@ The Serena Model Context Protocol (MCP) Server """ -import inspect -import json +import multiprocessing import os import platform -import re -import shutil import sys import threading -import traceback import webbrowser from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Callable, Generator, Iterable, Sequence +from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor -from copy import copy, deepcopy -from dataclasses import asdict, dataclass, field -from fnmatch import fnmatch -from functools import cached_property from logging import Logger from pathlib import Path -from types import TracebackType -from typing import TYPE_CHECKING, Any, Literal, Self, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union import click -import yaml -from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata -from overrides import override from pathspec import PathSpec -from ruamel.yaml.comments import CommentedMap from sensai.util import logging -from sensai.util.logging import FallbackHandler, LogTime -from sensai.util.string import ToStringMixin, dict_string +from sensai.util.logging import LogTime from serena import serena_version from serena.analytics import record_tool_usage -from serena.config import SerenaAgentContext, SerenaAgentMode +from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode +from serena.config.serena_config import Project, SerenaConfig, get_serena_managed_dir from serena.constants import ( - DEFAULT_ENCODING, - PROJECT_TEMPLATE_FILE, - REPO_ROOT, - SELENA_CONFIG_TEMPLATE_FILE, SERENA_LOG_FORMAT, - SERENA_MANAGED_DIR_NAME, ) from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI -from serena.prompt_factory import PromptFactory, SerenaPromptFactory +from serena.prompt_factory import SerenaPromptFactory from serena.symbol import SymbolManager -from serena.text_utils import search_files -from serena.util.file_system import GitignoreParser, match_path, scan_directory -from serena.util.general import load_yaml, save_yaml -from serena.util.inspection import determine_programming_language_composition, iter_subclasses -from serena.util.shell import execute_shell_command +from serena.tools import Tool, ToolRegistry +from serena.util.file_system import GitignoreParser, match_path from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger -from solidlsp.ls_types import SymbolKind if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewerHandler @@ -69,448 +47,10 @@ SUCCESS_RESULT = "OK" DEFAULT_TOOL_TIMEOUT: float = 240 -def _sanitize_symbol_dict(symbol_dict: dict[str, Any]) -> dict[str, Any]: - """ - Sanitize a symbol dictionary inplace by removing unnecessary information. - """ - # We replace the location entry, which repeats line information already included in body_location - # and has unnecessary information on column, by just the relative path. - symbol_dict = copy(symbol_dict) - s_relative_path = symbol_dict.get("location", {}).get("relative_path") - if s_relative_path is not None: - symbol_dict["relative_path"] = s_relative_path - symbol_dict.pop("location", None) - # also remove name, name_path should be enough - symbol_dict.pop("name") - return symbol_dict - - -def show_fatal_exception_safe(e: Exception) -> None: - """ - Shows the given exception in the GUI log viewer on the main thread and ensures that the exception is logged or at - least printed to stderr. - """ - # Make sure the error is logged (adding a fallback handler which writes to stderr in case there is no other handler) - fallback_handler = FallbackHandler(logging.StreamHandler(sys.stderr)) - Logger.root.addHandler(fallback_handler) - log.error(f"Fatal exception: {e}", exc_info=e) - - # attempt to show the error in the GUI - try: - # NOTE: The import can fail on macOS if Tk is not available (depends on Python interpreter installation, which uv - # used as a base); while tkinter as such is always available, its dependencies can be unavailable on macOS. - from serena.gui_log_viewer import show_fatal_exception - - show_fatal_exception(e) - except: - pass - - -class SerenaConfigError(Exception): - pass - - -def get_serena_managed_dir(project_root: str | Path) -> str: - return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) - - -def is_running_in_docker() -> bool: - """Check if we're running inside a Docker container.""" - # Check for Docker-specific files - if os.path.exists("/.dockerenv"): - return True - # Check cgroup for docker references - try: - with open("/proc/self/cgroup") as f: - return "docker" in f.read() - except FileNotFoundError: - return False - - -@dataclass -class ProjectConfig(ToStringMixin): - project_name: str - language: Language - ignored_paths: list[str] = field(default_factory=list) - excluded_tools: set[str] = field(default_factory=set) - read_only: bool = False - ignore_all_files_in_gitignore: bool = True - initial_prompt: str = "" - encoding: str = DEFAULT_ENCODING - - SERENA_DEFAULT_PROJECT_FILE = "project.yml" - - @classmethod - def autogenerate(cls, project_root: str | Path, project_name: str | None = None, save_to_disk: bool = True) -> Self: - """ - Autogenerate a project configuration for a given project root. - - :param project_root: the path to the project root - :param project_name: the name of the project; if None, the name of the project will be the name of the directory - containing the project - :param save_to_disk: whether to save the project configuration to disk - :return: the project configuration - """ - project_root = Path(project_root).resolve() - if not project_root.exists(): - raise FileNotFoundError(f"Project root not found: {project_root}") - project_name = project_name or project_root.name - language_composition = determine_programming_language_composition(str(project_root)) - if len(language_composition) == 0: - raise ValueError( - f"Failed to autogenerate project.yaml: no programming language detected in project {project_root}. " - f"You can either add some files that correspond to one of the supported programming languages, " - f"or create the file {os.path.join(project_root, cls.rel_path_to_project_yml())} manually and specify the language there." - ) - # find the language with the highest percentage - dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) - config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True) - config_with_comments["project_name"] = project_name - config_with_comments["language"] = dominant_language - if save_to_disk: - save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) - return cls.from_json_dict(config_with_comments) - - @classmethod - def rel_path_to_project_yml(cls) -> str: - return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) - - @classmethod - def from_json_dict(cls, data: dict[str, Any]) -> Self: - """ - Create a ProjectConfig instance from a configuration dictionary - """ - language_str = data["language"].lower() - project_name = data["project_name"] - # backwards compatibility - if language_str == "javascript": - log.warning(f"Found deprecated project language `javascript` in project {project_name}, please change to `typescript`") - language_str = "typescript" - try: - language = Language(language_str) - except ValueError as e: - raise ValueError(f"Invalid language: {data['language']}.\nValid languages are: {[l.value for l in Language]}") from e - return cls( - project_name=project_name, - language=language, - ignored_paths=data.get("ignored_paths", []), - excluded_tools=set(data.get("excluded_tools", [])), - read_only=data.get("read_only", False), - ignore_all_files_in_gitignore=data.get("ignore_all_files_in_gitignore", True), - initial_prompt=data.get("initial_prompt", ""), - encoding=data.get("encoding", DEFAULT_ENCODING), - ) - - def to_json_dict(self) -> dict[str, Any]: - result = asdict(self) - result["language"] = result["language"].value - result["excluded_tools"] = list(result["excluded_tools"]) - return result - - @classmethod - def load(cls, project_root: Path | str, autogenerate: bool = True) -> Self: - """ - Load a ProjectConfig instance from the path to the project root. - """ - project_root = Path(project_root) - yaml_path = project_root / cls.rel_path_to_project_yml() - if not yaml_path.exists(): - if autogenerate: - return cls.autogenerate(project_root) - else: - raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") - with open(yaml_path, encoding="utf-8") as f: - yaml_data = yaml.safe_load(f) - if "project_name" not in yaml_data: - yaml_data["project_name"] = project_root.name - return cls.from_json_dict(yaml_data) - - def get_excluded_tool_classes(self) -> set[type["Tool"]]: - return set(ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools) - - class ProjectNotFoundError(Exception): pass -@dataclass -class Project: - project_root: str - project_config: ProjectConfig - - @property - def project_name(self) -> str: - return self.project_config.project_name - - @property - def language(self) -> Language: - return self.project_config.language - - @classmethod - def load(cls, project_root: str | Path, autogenerate: bool = True) -> Self: - project_root = Path(project_root).resolve() - if not project_root.exists(): - raise FileNotFoundError(f"Project root not found: {project_root}") - project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) - return cls(project_root=str(project_root), project_config=project_config) - - @classmethod - def from_json_dict(cls, data: dict) -> Self: - return cls(project_root=data["project_root"], project_config=ProjectConfig.from_json_dict(data["project_config"])) - - def to_json_dict(self) -> dict: - return {"project_root": self.project_root, "project_config": self.project_config.to_json_dict()} - - def path_to_project_yml(self) -> str: - return os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) - - -@dataclass(kw_only=True) -class SerenaConfigBase(ABC): - """ - Abstract base class for Serena configuration handling - """ - - projects: list[Project] = field(default_factory=list) - gui_log_window_enabled: bool = False - log_level: int = logging.INFO - trace_lsp_communication: bool = False - web_dashboard: bool = True - tool_timeout: float = DEFAULT_TOOL_TIMEOUT - - @cached_property - def project_paths(self) -> list[str]: - return sorted(project.project_root for project in self.projects) - - @cached_property - def project_names(self) -> list[str]: - return sorted(project.project_config.project_name for project in self.projects) - - def get_project(self, project_root_or_name: str) -> Project | None: - for project in self.projects: - if project.project_config.project_name == project_root_or_name: - return project - if os.path.isdir(project_root_or_name): - project_root = Path(project_root_or_name).resolve() - for project in self.projects: - if Path(project.project_root).resolve() == project_root: - return project - return None - - def add_project_from_path(self, project_root: Path | str, project_name: str | None = None) -> tuple[Project, bool]: - """ - Add a project to the Serena configuration from a given path. Will raise a FileExistsError if the - name or path is already registered. - - :param project_root: the path to the project to add - :param project_name: the name of the project to add; if None, the name of the project will be the name of the directory - containing the project - :return: the project that was added and a boolean indicating whether a new project configuration was generated and - saved to disk. It may be that no new project configuration was generated if the project configuration already - exists on disk but the project itself was not added yet to the Serena configuration. - """ - project_root = Path(project_root).resolve() - if not project_root.exists(): - raise FileNotFoundError(f"Error: Path does not exist: {project_root}") - if not project_root.is_dir(): - raise FileNotFoundError(f"Error: Path is not a directory: {project_root}") - - if project_name is None: - project_name = project_root.name - for already_registered_project in self.projects: - if already_registered_project.project_name == project_name: - raise FileExistsError( - f"Project name '{project_name}' already exists and points to {already_registered_project.project_root}." - ) - if str(already_registered_project.project_root) == str(project_root): - raise FileExistsError( - f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." - ) - - try: - project_config = ProjectConfig.load(project_root) - new_project_config_generated = False - except FileNotFoundError: - project_config = ProjectConfig.autogenerate(project_root, save_to_disk=True) - new_project_config_generated = True - - new_project = Project(project_root=str(project_root), project_config=project_config) - self._add_new_project(new_project) - - return new_project, new_project_config_generated - - def _add_new_project(self, project: Project) -> None: - """ - Adds a new project to the Serena configuration. No checks are performed here, - this method is intended to be overridden by subclasses. - """ - self.projects.append(project) - - def remove_project(self, project_name: str) -> None: - # find the index of the project with the desired name and remove it - for i, project in enumerate(self.projects): - if project.project_name == project_name: - del self.projects[i] - break - else: - raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") - - def to_json_dict(self) -> dict: - """Convert configuration to dictionary for serialization.""" - result = asdict(self) - result["projects"] = [project.to_json_dict() for project in self.projects] - return result - - @classmethod - def from_json_dict(cls, data: dict) -> Self: - """Create configuration from dictionary.""" - data = copy(data) - data["projects"] = [Project.from_json_dict(project_data) for project_data in data["projects"]] - return cls(**data) - - -@dataclass(kw_only=True) -class SerenaConfig(SerenaConfigBase): - """ - Handles user-defined Serena configuration based on the (fixed) Serena configuration file. - Updates to the instance will be automatically saved to the configuration file. - Usually, there should be only one instance of this class in the application. - """ - - loaded_commented_yaml: CommentedMap - - CONFIG_FILE = "serena_config.yml" - CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization - - @classmethod - def autogenerate(cls) -> None: - log.info("Autogenerating Serena configuration file") - if os.path.exists(cls.get_config_file_path()): - raise FileExistsError( - f"Serena configuration file already exists at {cls.get_config_file_path()}. Please remove it if you want to autogenerate a new one." - ) - loaded_commented_yaml = load_yaml(SELENA_CONFIG_TEMPLATE_FILE, preserve_comments=True) - save_yaml(cls.get_config_file_path(), loaded_commented_yaml, preserve_comments=True) - - @classmethod - def get_config_file_path(cls) -> str: - config_file = cls.CONFIG_FILE_DOCKER if is_running_in_docker() else cls.CONFIG_FILE - return os.path.join(REPO_ROOT, config_file) - - @classmethod - def _load_commented_yaml(cls, config_file: str, generate_if_missing: bool = True) -> CommentedMap: - if not os.path.exists(config_file): - if not generate_if_missing: - raise FileNotFoundError(f"Serena configuration file not found: {config_file}") - log.info(f"Serena configuration file not found at {config_file}, autogenerating...") - cls.autogenerate() - try: - return load_yaml(config_file, preserve_comments=True) - except Exception as e: - raise ValueError(f"Error loading Serena configuration from {config_file}: {e}") from e - - @classmethod - def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig": - """ - Static constructor to create SerenaConfig from the configuration file - """ - config_file = cls.get_config_file_path() - log.info(f"Loading Serena configuration from {config_file}") - loaded_commented_yaml = cls._load_commented_yaml(config_file, generate_if_missing) - # Create instance - instance = cls(loaded_commented_yaml=loaded_commented_yaml) - - # read projects - if "projects" not in loaded_commented_yaml: - raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") - - # load list of known projects - instance.projects = [] - num_project_migrations = 0 - for path in loaded_commented_yaml["projects"]: - path = Path(path).resolve() - if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()): - log.warning(f"Project path {path} does not exist or does not contain a project configuration file, skipping.") - continue - if path.is_file(): - path = cls._migrate_out_of_project_config_file(path) - if path is None: - continue - num_project_migrations += 1 - project = Project.load(path) - instance.projects.append(project) - - # Force disable GUI in Docker environment - if is_running_in_docker(): - instance.gui_log_window_enabled = False - else: - instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False) - instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO)) - instance.web_dashboard = loaded_commented_yaml.get("web_dashboard", True) - instance.tool_timeout = loaded_commented_yaml.get("tool_timeout", DEFAULT_TOOL_TIMEOUT) - instance.trace_lsp_communication = loaded_commented_yaml.get("trace_lsp_communication", False) - - # re-save the configuration file if any migrations were performed - if num_project_migrations > 0: - log.info( - f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration" - ) - instance.save() - - return instance - - @classmethod - def _migrate_out_of_project_config_file(cls, path: Path) -> Path | None: - """ - Migrates a legacy project configuration file (which is a YAML file containing the project root) to the - in-project configuration file (project.yml) inside the project root directory. - - :param path: the path to the legacy project configuration file - :return: the project root path if the migration was successful, None otherwise. - """ - log.info(f"Found legacy project configuration file {path}, migrating to in-project configuration.") - try: - with open(path, encoding="utf-8") as f: - project_config_data = yaml.safe_load(f) - if "project_name" not in project_config_data: - project_name = path.stem - with open(path, "a", encoding="utf-8") as f: - f.write(f"\nproject_name: {project_name}") - project_root = project_config_data["project_root"] - shutil.move(str(path), str(Path(project_root) / ProjectConfig.rel_path_to_project_yml())) - return Path(project_root).resolve() - except Exception as e: - log.error(f"Error migrating configuration file: {e}") - return None - - def save(self) -> None: - loaded_original_yaml = deepcopy(self.loaded_commented_yaml) - # projects are unique absolute paths - # we also canonicalize them before saving - loaded_original_yaml["projects"] = sorted({str(Path(project.project_root).resolve()) for project in self.projects}) - save_yaml(self.get_config_file_path(), loaded_original_yaml, preserve_comments=True) - - @override - def _add_new_project(self, project: Project) -> None: - super()._add_new_project(project) - self.save() - - @override - def remove_project(self, project_name: str) -> None: - super().remove_project(project_name) - self.save() - - def to_json_dict(self) -> dict: - result = super().to_json_dict() - result.pop("loaded_commented_yaml", None) - return result - - @classmethod - def from_json_dict(cls, data: dict) -> Self: - data["loaded_commented_yaml"] = cls._load_commented_yaml(cls.get_config_file_path()) - return super().from_json_dict(data) - - class LinesRead: def __init__(self) -> None: self.files: dict[str, set[tuple[int, int]]] = defaultdict(lambda: set()) @@ -578,71 +118,6 @@ class MemoriesManagerMDFilesInProject(MemoriesManager): return f"Memory {name} deleted." -def create_serena_config( - serena_config: SerenaConfigBase | None = None, - enable_web_dashboard: bool | None = None, - enable_gui_log_window: bool | None = None, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, -) -> SerenaConfig: - """ - Create a SerenaConfig instance without instantiating a full SerenaAgent. - - This function extracts the configuration creation logic from SerenaAgent.__init__ - to allow creating configurations independently for process isolation and other use cases. - - :param serena_config: the base Serena configuration or None to read from default location - :param enable_web_dashboard: Whether to enable the web dashboard - :param enable_gui_log_window: Whether to enable the GUI log window - :param log_level: Log level - :param trace_lsp_communication: Whether to trace LSP communication - :param tool_timeout: Timeout in seconds for tool execution - :return: A fully configured SerenaConfig instance - """ - # obtain serena configuration - if serena_config is not None: - # If a complete SerenaConfig is provided, use it directly - if isinstance(serena_config, SerenaConfig): - config = serena_config - else: - # For SerenaConfigBase instances (like test configs), create an in-memory SerenaConfig - # that preserves the base config attributes without loading from file - from ruamel.yaml.comments import CommentedMap - - config = SerenaConfig.__new__(SerenaConfig) # Create without calling __init__ - # Initialize basic attributes from base config - config.projects = getattr(serena_config, "projects", []) - config.gui_log_window_enabled = serena_config.gui_log_window_enabled - config.log_level = serena_config.log_level - config.trace_lsp_communication = serena_config.trace_lsp_communication - config.web_dashboard = serena_config.web_dashboard - config.tool_timeout = serena_config.tool_timeout - # Set empty yaml for in-memory config - config.loaded_commented_yaml = CommentedMap() - else: - config = SerenaConfig.from_config_file() - - # Apply parameter overrides - if enable_web_dashboard is not None: - config.web_dashboard = enable_web_dashboard - if enable_gui_log_window is not None: - config.gui_log_window_enabled = enable_gui_log_window - if log_level is not None: - log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper()) - # transform to int - config.log_level = logging.getLevelNamesMapping()[log_level] - if trace_lsp_communication is not None: - config.trace_lsp_communication = trace_lsp_communication - if tool_timeout is not None: - config.tool_timeout = tool_timeout - - # Note: Project registration/activation is handled separately by the caller - # since it involves complex logic that may require the full agent context - - return config - - def create_ls_for_project( project: str | Project, log_level: int = logging.INFO, @@ -716,38 +191,22 @@ class SerenaAgent: self, project: str | None = None, project_activation_callback: Callable[[], None] | None = None, - serena_config: SerenaConfigBase | None = None, + serena_config: SerenaConfig | None = None, context: SerenaAgentContext | None = None, modes: list[SerenaAgentMode] | None = None, - enable_web_dashboard: bool | None = None, - enable_gui_log_window: bool | None = None, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, ): """ :param project: the project to load immediately or None to not load any project; may be a path to the project or a name of an already registered project; :param project_activation_callback: a callback function to be called when a project is activated. + :param serena_config: the Serena configuration or None to read the configuration from the default location. :param context: the context in which the agent is operating, None for default context. The context may adjust prompts, tool availability, and tool descriptions. :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. The modes may adjust prompts, tool availability, and tool descriptions. - :param serena_config: the Serena configuration or None to read the configuration from the default location. - :param enable_web_dashboard: whether to enable the web dashboard; If None, will take the value from the Serena configuration. - :param enable_gui_log_window: whether to enable the GUI log window; If None, will take the value from the Serena configuration. - :param log_level: the log level for the GUI log window; If None, will take the value from the serena configuration. - :param tool_timeout: the timeout in seconds for tool execution. If None, will take the value from the serena configuration. """ # obtain serena configuration using the decoupled factory function - self.serena_config = create_serena_config( - serena_config=serena_config, - enable_web_dashboard=enable_web_dashboard, - enable_gui_log_window=enable_gui_log_window, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, - ) + self.serena_config = serena_config or SerenaConfig.from_config_file() # adjust log level serena_log_level = self.serena_config.log_level @@ -793,9 +252,15 @@ class SerenaAgent: dashboard_log_handler = MemoryLogHandler(level=serena_log_level) Logger.root.addHandler(dashboard_log_handler) self._dashboard_thread, port = SerenaDashboardAPI(dashboard_log_handler, tool_names).run_in_thread() - webbrowser.open(f"http://localhost:{port}/dashboard/index.html") + if self.serena_config.web_dashboard_open_on_launch: + # open the dashboard URL in the default web browser (using a separate process to control + # output redirection) + process = multiprocessing.Process(target=self._open_dashboard, args=(port,)) + process.start() + # log fundamental information log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") + log.info("Configuration file: %s", self.serena_config.config_file_path) log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) # create executor for starting the language server and running tools in another thread @@ -840,6 +305,18 @@ class SerenaAgent: "You should now pass either --project or --project ." ) + @staticmethod + def _open_dashboard(port: int) -> None: + # Redirect stdout and stderr file descriptors to /dev/null, + # making sure that nothing can be written to stdout/stderr, even by subprocesses + null_fd = os.open(os.devnull, os.O_WRONLY) + os.dup2(null_fd, sys.stdout.fileno()) + os.dup2(null_fd, sys.stderr.fileno()) + os.close(null_fd) + + # open the dashboard URL in the default web browser + webbrowser.open(f"http://localhost:{port}/dashboard/index.html") + def get_project_root(self) -> str: """ :return: the root directory of the active project (if any); raises a ValueError if there is no active project @@ -1194,1290 +671,3 @@ class SerenaAgent: log.info("Stopping the GUI log window ...") self._gui_log_handler.stop_viewer() Logger.root.removeHandler(self._gui_log_handler) - - -class Component(ABC): - def __init__(self, agent: "SerenaAgent"): - self.agent = agent - - @property - def language_server(self) -> SolidLanguageServer: - assert self.agent.language_server is not None - return self.agent.language_server - - def get_project_root(self) -> str: - """ - :return: the root directory of the active project, raises a ValueError if no active project configuration is set - """ - return self.agent.get_project_root() - - @property - def prompt_factory(self) -> PromptFactory: - return self.agent.prompt_factory - - @property - def memories_manager(self) -> MemoriesManager: - assert self.agent.memories_manager is not None - return self.agent.memories_manager - - @property - def symbol_manager(self) -> SymbolManager: - assert self.agent.symbol_manager is not None - return self.agent.symbol_manager - - @property - def lines_read(self) -> LinesRead: - assert self.agent.lines_read is not None - return self.agent.lines_read - - -_DEFAULT_MAX_ANSWER_LENGTH = int(2e5) - - -class ToolMarkerCanEdit: - """ - Marker class for all tools that can perform editing operations on files. - """ - - -class ToolMarkerDoesNotRequireActiveProject: - pass - - -class ToolInterface(ABC): - """Protocol defining the complete interface that make_tool() expects from a tool.""" - - @abstractmethod - def get_name(self) -> str: - """Get the tool name.""" - ... - - @abstractmethod - def get_apply_docstring(self) -> str: - """Get the docstring for the tool application, used by the MCP server.""" - ... - - @abstractmethod - def get_apply_fn_metadata(self) -> FuncMetadata: - """Get the metadata for the tool application function, used by the MCP server.""" - ... - - @abstractmethod - def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs: Any) -> str: - """Apply the tool with logging and exception handling.""" - ... - - -class Tool(Component, ToolInterface): - # NOTE: each tool should implement the apply method, which is then used in - # the central method of the Tool class `apply_ex`. - # Failure to do so will result in a RuntimeError at tool execution time. - # The apply method is not declared as part of the base Tool interface since we cannot - # know the signature of the (input parameters of the) method in advance. - # - # The docstring and types of the apply method are used to generate the tool description - # (which is use by the LLM, so a good description is important) - # and to validate the tool call arguments. - - @classmethod - def get_name_from_cls(cls) -> str: - name = cls.__name__ - if name.endswith("Tool"): - name = name[:-4] - # convert to snake_case - name = "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") - return name - - def get_name(self) -> str: - return self.get_name_from_cls() - - def get_apply_fn(self) -> Callable: - apply_fn = getattr(self, "apply") - if apply_fn is None: - raise RuntimeError(f"apply not defined in {self}. Did you forget to implement it?") - return apply_fn - - @classmethod - def can_edit(cls) -> bool: - """ - Returns whether this tool can perform editing operations on code. - - :return: True if the tool can edit code, False otherwise - """ - return issubclass(cls, ToolMarkerCanEdit) - - @classmethod - def get_tool_description(cls) -> str: - docstring = cls.__doc__ - if docstring is None: - return "" - return docstring.strip() - - @classmethod - def get_apply_docstring_from_cls(cls) -> str: - """Get the docstring for the apply method from the class (static metadata). - Needed for creating MCP tools in a separate process without running into serialization issues. - """ - # First try to get from __dict__ to handle dynamic docstring changes - if "apply" in cls.__dict__: - apply_fn = cls.__dict__["apply"] - else: - # Fall back to getattr for inherited methods - apply_fn = getattr(cls, "apply", None) - if apply_fn is None: - raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") - - docstring = apply_fn.__doc__ - if not docstring: - raise AttributeError(f"apply method has no (or empty) docstring in {cls}. Did you forget to implement it?") - return docstring.strip() - - def get_apply_docstring(self) -> str: - """Get the docstring for the apply method (instance method implementing ToolProtocol).""" - return self.get_apply_docstring_from_cls() - - def get_apply_fn_metadata(self) -> FuncMetadata: - """Get the metadata for the apply method (instance method implementing ToolProtocol).""" - return self.get_apply_fn_metadata_from_cls() - - @classmethod - def get_apply_fn_metadata_from_cls(cls) -> FuncMetadata: - """Get the metadata for the apply method from the class (static metadata). - Needed for creating MCP tools in a separate process without running into serialization issues. - """ - # First try to get from __dict__ to handle dynamic docstring changes - if "apply" in cls.__dict__: - apply_fn = cls.__dict__["apply"] - else: - # Fall back to getattr for inherited methods - apply_fn = getattr(cls, "apply", None) - if apply_fn is None: - raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") - - return func_metadata(apply_fn, skip_names=["self", "cls"]) - - def _log_tool_application(self, frame: Any) -> None: - params = {} - ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"} - for param, value in frame.f_locals.items(): - if param in ignored_params: - continue - if param == "kwargs": - params.update(value) - else: - params[param] = value - log.info(f"{self.get_name_from_cls()}: {dict_string(params)}") - - @staticmethod - def _limit_length(result: str, max_answer_chars: int) -> str: - if (n_chars := len(result)) > max_answer_chars: - result = ( - f"The answer is too long ({n_chars} characters). " - + "Please try a more specific tool query or raise the max_answer_chars parameter." - ) - return result - - def is_active(self) -> bool: - return self.agent.tool_is_active(self.__class__) - - def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs) -> str: # type: ignore - """ - Applies the tool with the given arguments - """ - - def task() -> str: - apply_fn = self.get_apply_fn() - - try: - if not self.is_active(): - return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" - except Exception as e: - return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}" - - if log_call: - self._log_tool_application(inspect.currentframe()) - try: - # check whether the tool requires an active project and language server - if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): - if self.agent._active_project is None: - return ( - "Error: No active project. Ask to user to select a project from this list: " - + f"{self.agent.serena_config.project_names}" - ) - if not self.agent.is_language_server_running(): - log.info("Language server is not running. Starting it ...") - self.agent.reset_language_server() - - # apply the actual tool - result = apply_fn(**kwargs) - input_chars = sum(len(str(v)) for v in kwargs.values()) - output_chars = len(result) if isinstance(result, str) else len(str(result)) - record_tool_usage(self.get_name_from_cls(), input_chars, output_chars) - - except Exception as e: - if not catch_exceptions: - raise - msg = f"Error executing tool: {e}\n{traceback.format_exc()}" - log.error( - f"Error executing tool: {e}. " - f"Consider restarting the language server to solve this (especially, if it's a timeout of a symbolic operation)", - exc_info=e, - ) - result = msg - - if log_call: - log.info(f"Result: {result}") - - try: - self.language_server.save_cache() - except Exception as e: - log.error(f"Error saving language server cache: {e}") - - return result - - future = self.agent.issue_task(task, name=self.__class__.__name__) - return future.result(timeout=self.agent.serena_config.tool_timeout) - - -class RestartLanguageServerTool(Tool): - """Restarts the language server, may be necessary when edits not through Serena happen.""" - - def apply(self) -> str: - """Use this tool only on explicit user request or after confirmation. - It may be necessary to restart the language server if the user performs edits - not through Serena, so the language server state becomes outdated and further editing attempts lead to errors. - - If such editing errors happen, you should suggest using this tool. - """ - self.agent.reset_language_server() - return SUCCESS_RESULT - - -class ReadFileTool(Tool): - """ - Reads a file within the project directory. - """ - - def apply( - self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH - ) -> str: - """ - Reads the given file or a chunk of it. Generally, symbolic operations - like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for. - Reading the entire file is only recommended if there is no other way to get the content required for the task. - - :param relative_path: the relative path to the file to read - :param start_line: the 0-based index of the first line to be retrieved. - :param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file. - :param max_answer_chars: if the file (chunk) is longer than this number of characters, - no content will be returned. Don't adjust unless there is really no other way to get the content - required for the task. - :return: the full text of the file at the given relative path - """ - self.agent.validate_relative_path(relative_path) - - result = self.language_server.retrieve_full_file_content(relative_path) - result_lines = result.splitlines() - if end_line is None: - result_lines = result_lines[start_line:] - else: - self.lines_read.add_lines_read(relative_path, (start_line, end_line)) - result_lines = result_lines[start_line : end_line + 1] - result = "\n".join(result_lines) - - return self._limit_length(result, max_answer_chars) - - -class CreateTextFileTool(Tool, ToolMarkerCanEdit): - """ - Creates/overwrites a file in the project directory. - """ - - def apply(self, relative_path: str, content: str) -> str: - """ - Write a new file (or overwrite an existing file). For existing files, it is strongly recommended - to use symbolic operations like replace_symbol_body or insert_after_symbol/insert_before_symbol, if possible. - You can also use insert_at_line to insert content at a specific line for existing files if the symbolic operations - are not the right choice for what you want to do. - - If ever used on an existing file, the content has to be the complete content of that file (so it - may never end with something like "The remaining content of the file is left unchanged."). - For operations that just replace a part of a file, use the replace_lines or the symbolic editing tools instead. - - :param relative_path: the relative path to the file to create - :param content: the (utf-8-encoded) content to write to the file - :return: a message indicating success or failure - """ - self.agent.validate_relative_path(relative_path) - - abs_path = (Path(self.get_project_root()) / relative_path).resolve() - will_overwrite_existing = abs_path.exists() - - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(content, encoding="utf-8") - answer = f"File created: {relative_path}." - if will_overwrite_existing: - answer += " Overwrote existing file." - return answer - - -class ListDirTool(Tool): - """ - Lists files and directories in the given directory (optionally with recursion). - """ - - def apply(self, relative_path: str, recursive: bool, max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH) -> str: - """ - Lists all non-gitignored files and directories in the given directory (optionally with recursion). - - :param relative_path: the relative path to the directory to list; pass "." to scan the project root - :param recursive: whether to scan subdirectories recursively - :param max_answer_chars: if the output is longer than this number of characters, - no content will be returned. Don't adjust unless there is really no other way to get the content - required for the task. - :return: a JSON object with the names of directories and files within the given directory - """ - self.agent.validate_relative_path(relative_path) - - dirs, files = scan_directory( - os.path.join(self.get_project_root(), relative_path), - relative_to=self.get_project_root(), - recursive=recursive, - is_ignored_dir=self.agent.path_is_gitignored, - is_ignored_file=self.agent.path_is_gitignored, - ) - - result = json.dumps({"dirs": dirs, "files": files}) - return self._limit_length(result, max_answer_chars) - - -class FindFileTool(Tool): - """ - Finds files in the given relative paths - """ - - def apply(self, file_mask: str, relative_path: str) -> str: - """ - Finds non-gitignored files matching the given file mask within the given relative path - - :param file_mask: the filename or file mask (using the wildcards * or ?) to search for - :param relative_path: the relative path to the directory to search in; pass "." to scan the project root - :return: a JSON object with the list of matching files - """ - self.agent.validate_relative_path(relative_path) - - dir_to_scan = os.path.join(self.get_project_root(), relative_path) - - # find the files by ignoring everything that doesn't match - def is_ignored_file(abs_path: str) -> bool: - if self.agent.path_is_gitignored(abs_path): - return True - filename = os.path.basename(abs_path) - return not fnmatch(filename, file_mask) - - dirs, files = scan_directory( - path=dir_to_scan, - recursive=True, - is_ignored_dir=self.agent.path_is_gitignored, - is_ignored_file=is_ignored_file, - relative_to=self.get_project_root(), - ) - - result = json.dumps({"files": files}) - return result - - -class GetSymbolsOverviewTool(Tool): - """ - Gets an overview of the top-level symbols defined in a given file or directory. - """ - - def apply(self, relative_path: str, max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH) -> str: - """ - Gets an overview of the given file or directory. - For each analyzed file, we list the top-level symbols in the file (name_path, kind). - Use this tool to get a high-level understanding of the code symbols. - Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols. - - :param relative_path: the relative path to the file or directory to get the overview of - :param max_answer_chars: if the overview is longer than this number of characters, - no content will be returned. Don't adjust unless there is really no other way to get the content - required for the task. If the overview is too long, you should use a smaller directory instead, - (e.g. a subdirectory). - :return: a JSON object mapping relative paths of all contained files to info about top-level symbols in the file (name_path, kind). - """ - path_to_symbol_infos = self.language_server.request_overview(relative_path) - result = {} - for file_path, symbols in path_to_symbol_infos.items(): - # TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables - # The language server methods would need to be adjusted for this. - result[file_path] = [{"name_path": symbol[0], "kind": int(symbol[1])} for symbol in symbols] - - result_json_str = json.dumps(result) - return self._limit_length(result_json_str, max_answer_chars) - - -class FindSymbolTool(Tool): - """ - Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). - """ - - def apply( - self, - name_path: str, - depth: int = 0, - relative_path: str | None = None, - include_body: bool = False, - include_kinds: list[int] | None = None, - exclude_kinds: list[int] | None = None, - substring_matching: bool = False, - max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH, - ) -> str: - """ - Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given `name_path`, - which represents a pattern for the symbol's path within the symbol tree of a single file. - The returned symbol location can be used for edits or further queries. - Specify `depth > 0` to retrieve children (e.g., methods of a class). - - The matching behavior is determined by the structure of `name_path`, which can - either be a simple name (e.g. "method") or a name path like "class/method" (relative name path) - or "/class/method" (absolute name path). Note that the name path is not a path in the file system - but rather a path in the symbol tree **within a single file**. Thus, file or directory names should never - be included in the `name_path`. For restricting the search to a single file or directory, - the `within_relative_path` parameter should be used instead. The retrieved symbols' `name_path` attribute - will always be composed of symbol names, never file or directory names. - - Key aspects of the name path matching behavior: - - Trailing slashes in `name_path` play no role and are ignored. - - The name of the retrieved symbols will match (either exactly or as a substring) - the last segment of `name_path`, while other segments will restrict the search to symbols that - have a desired sequence of ancestors. - - If there is no starting or intermediate slash in `name_path`, there is no - restriction on the ancestor symbols. For example, passing `method` will match - against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc. - - If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols - with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against - `class/method` as well as `nested_class/class/method` but not `method`. - - If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning - that the first segment of it must match the first segment of the symbol's name path. - For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`. - Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`. - - - :param name_path: The name path pattern to search for, see above for details. - :param depth: Depth to retrieve descendants (e.g., 1 for class methods/attributes). - :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. - If a directory is passed, the search will be restricted to the files in that directory. - If a file is passed, the search will be restricted to that file. - If you have some knowledge about the codebase, you should use this parameter, as it will significantly - speed up the search as well as reduce the number of results. - :param include_body: If True, include the symbol's source code. Use judiciously. - :param include_kinds: Optional. List of LSP symbol kind integers to include. (e.g., 5 for Class, 12 for Function). - Valid kinds: 1=file, 2=module, 3=namespace, 4=package, 5=class, 6=method, 7=property, 8=field, 9=constructor, 10=enum, - 11=interface, 12=function, 13=variable, 14=constant, 15=string, 16=number, 17=boolean, 18=array, 19=object, - 20=key, 21=null, 22=enum member, 23=struct, 24=event, 25=operator, 26=type parameter - :param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`. - :param substring_matching: If True, use substring matching for the last segment of `name`. - :param max_answer_chars: Max characters for the JSON result. If exceeded, no content is returned. - :return: JSON string: a list of symbols (with locations) matching the name. - """ - parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None - parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None - symbols = self.symbol_manager.find_by_name( - name_path, - include_body=include_body, - include_kinds=parsed_include_kinds, - exclude_kinds=parsed_exclude_kinds, - substring_matching=substring_matching, - within_relative_path=relative_path, - ) - symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols] - result = json.dumps(symbol_dicts) - return self._limit_length(result, max_answer_chars) - - -class FindReferencingSymbolsTool(Tool): - """ - Finds symbols that reference the symbol at the given location (optionally filtered by type). - """ - - def apply( - self, - name_path: str, - relative_path: str, - include_kinds: list[int] | None = None, - exclude_kinds: list[int] | None = None, - max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH, - ) -> str: - """ - Finds symbols that reference the symbol at the given `name_path`. The result will contain metadata about the referencing symbols - as well as a short code snippet around the reference (unless `include_body` is True, then the short snippet will be omitted). - Note that among other kinds of references, this function can be used to find (direct) subclasses of a class, - as subclasses are referencing symbols that have the kind class. - - :param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool. - :param relative_path: the relative path to the file containing the symbol for which to find references. - Note that here you can't pass a directory but must pass a file. - :param include_kinds: same as in the `find_symbol` tool. - :param exclude_kinds: same as in the `find_symbol` tool. - :param max_answer_chars: same as in the `find_symbol` tool. - :return: a list of JSON objects with the symbols referencing the requested symbol - """ - include_body = False # It is probably never a good idea to include the body of the referencing symbols - parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None - parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None - references_in_symbols = self.symbol_manager.find_referencing_symbols( - name_path, - relative_file_path=relative_path, - include_body=include_body, - include_kinds=parsed_include_kinds, - exclude_kinds=parsed_exclude_kinds, - ) - reference_dicts = [] - for ref in references_in_symbols: - ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body) - ref_dict = _sanitize_symbol_dict(ref_dict) - if not include_body: - ref_relative_path = ref.symbol.location.relative_path - assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug." - content_around_ref = self.language_server.retrieve_content_around_line( - relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1 - ) - ref_dict["content_around_reference"] = content_around_ref.to_display_string() - reference_dicts.append(ref_dict) - result = json.dumps(reference_dicts) - return self._limit_length(result, max_answer_chars) - - -class ReplaceSymbolBodyTool(Tool, ToolMarkerCanEdit): - """ - Replaces the full definition of a symbol. - """ - - def apply( - self, - name_path: str, - relative_path: str, - body: str, - ) -> str: - r""" - Replaces the body of the symbol with the given `name_path`. - - :param name_path: for finding the symbol to replace, same logic as in the `find_symbol` tool. - :param relative_path: the relative path to the file containing the symbol - :param body: the new symbol body. Important: Begin directly with the symbol definition and provide no - leading indentation for the first line (but do indent the rest of the body according to the context). - """ - self.symbol_manager.replace_body( - name_path, - relative_file_path=relative_path, - body=body, - use_same_indentation=False, - ) - return SUCCESS_RESULT - - -class InsertAfterSymbolTool(Tool, ToolMarkerCanEdit): - """ - Inserts content after the end of the definition of a given symbol. - """ - - def apply( - self, - name_path: str, - relative_path: str, - body: str, - ) -> str: - """ - Inserts the given body/content after the end of the definition of the given symbol (via the symbol's location). - A typical use case is to insert a new class, function, method, field or variable assignment. - - :param name_path: name path of the symbol after which to insert content (definitions in the `find_symbol` tool apply) - :param relative_path: the relative path to the file containing the symbol - :param body: the body/content to be inserted. The inserted code shall begin with the next line after - the symbol. - """ - self.symbol_manager.insert_after_symbol(name_path, relative_file_path=relative_path, body=body, use_same_indentation=False) - return SUCCESS_RESULT - - -class InsertBeforeSymbolTool(Tool, ToolMarkerCanEdit): - """ - Inserts content before the beginning of the definition of a given symbol. - """ - - def apply( - self, - name_path: str, - relative_path: str, - body: str, - ) -> str: - """ - Inserts the given body/content before the beginning of the definition of the given symbol (via the symbol's location). - A typical use case is to insert a new class, function, method, field or variable assignment. - It also can be used to insert a new import statement before the first symbol in the file. - - :param name_path: name path of the symbol before which to insert content (definitions in the `find_symbol` tool apply) - :param relative_path: the relative path to the file containing the symbol - :param body: the body/content to be inserted before the line in which the referenced symbol is defined - """ - self.symbol_manager.insert_before_symbol(name_path, relative_file_path=relative_path, body=body, use_same_indentation=False) - return SUCCESS_RESULT - - -class EditedFileContext: - """ - Context manager for file editing. - - Create the context, then use `set_updated_content` to set the new content, the original content - being provided in `original_content`. - When exiting the context without an exception, the updated content will be written back to the file. - """ - - def __init__(self, relative_path: str, agent: SerenaAgent): - self._project = agent.get_active_project() - assert self._project is not None - self._abs_path = os.path.join(self._project.project_root, relative_path) - if not os.path.isfile(self._abs_path): - raise FileNotFoundError(f"File {self._abs_path} does not exist.") - with open(self._abs_path, encoding=self._project.project_config.encoding) as f: - self._original_content = f.read() - self._updated_content: str | None = None - - def __enter__(self) -> Self: - return self - - def get_original_content(self) -> str: - """ - :return: the original content of the file before any modifications. - """ - return self._original_content - - def set_updated_content(self, content: str) -> None: - """ - Sets the updated content of the file, which will be written back to the file - when the context is exited without an exception. - - :param content: the updated content of the file - """ - self._updated_content = content - - def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: - if self._updated_content is not None and exc_type is None: - assert self._project is not None - with open(self._abs_path, "w", encoding=self._project.project_config.encoding) as f: - f.write(self._updated_content) - log.info(f"Updated content written to {self._abs_path}") - # Language servers should automatically detect the change and update its state accordingly. - # If they do not, we may have to add a call to notify it. - - -class ReplaceRegexTool(Tool, ToolMarkerCanEdit): - """ - Replaces content in a file by using regular expressions. - """ - - def apply( - self, - relative_path: str, - regex: str, - repl: str, - allow_multiple_occurrences: bool = False, - ) -> str: - r""" - Replaces one or more occurrences of the given regular expression. - This is the preferred way to replace content in a file whenever the symbol-level - tools are not appropriate. - Even large sections of code can be replaced by providing a concise regular expression of - the form "beginning.*?end-of-text-to-be-replaced". - Always try to use wildcards to avoid specifying the exact content of the code to be replaced, - especially if it spans several lines. - - IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD! - - :param relative_path: the relative path to the file - :param regex: a Python-style regular expression, matches of which will be replaced. - Dot matches all characters, multi-line matching is enabled. - :param repl: the string to replace the matched content with, which may contain - backreferences like \1, \2, etc. - :param allow_multiple_occurrences: if True, the regex may match multiple occurrences in the file - and all of them will be replaced. - If this is set to False and the regex matches multiple occurrences, an error will be returned - (and you may retry with a revised, more specific regex). - """ - self.agent.validate_relative_path(relative_path) - with EditedFileContext(relative_path, self.agent) as context: - original_content = context.get_original_content() - updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE) - if n == 0: - return f"Error: No matches found for regex '{regex}' in file '{relative_path}'." - if not allow_multiple_occurrences and n > 1: - return ( - f"Error: Regex '{regex}' matches {n} occurrences in file '{relative_path}'. " - "Please revise the regex to be more specific or enable allow_multiple_occurrences if this is expected." - ) - context.set_updated_content(updated_content) - return SUCCESS_RESULT - - -class DeleteLinesTool(Tool, ToolMarkerCanEdit): - """ - Deletes a range of lines within a file. - """ - - def apply( - self, - relative_path: str, - start_line: int, - end_line: int, - ) -> str: - """ - Deletes the given lines in the file. - Requires that the same range of lines was previously read using the `read_file` tool to verify correctness - of the operation. - - :param relative_path: the relative path to the file - :param start_line: the 0-based index of the first line to be deleted - :param end_line: the 0-based index of the last line to be deleted - """ - if not self.lines_read.were_lines_read(relative_path, (start_line, end_line)): - read_lines_tool = self.agent.get_tool(ReadFileTool) - return f"Error: Must call `{read_lines_tool.get_name_from_cls()}` first to read exactly the affected lines." - self.symbol_manager.delete_lines(relative_path, start_line, end_line) - return SUCCESS_RESULT - - -class ReplaceLinesTool(Tool, ToolMarkerCanEdit): - """ - Replaces a range of lines within a file with new content. - """ - - def apply( - self, - relative_path: str, - start_line: int, - end_line: int, - content: str, - ) -> str: - """ - Replaces the given range of lines in the given file. - Requires that the same range of lines was previously read using the `read_file` tool to verify correctness - of the operation. - - :param relative_path: the relative path to the file - :param start_line: the 0-based index of the first line to be deleted - :param end_line: the 0-based index of the last line to be deleted - :param content: the content to insert - """ - if not content.endswith("\n"): - content += "\n" - result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line) - if result != SUCCESS_RESULT: - return result - self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content) - return SUCCESS_RESULT - - -class InsertAtLineTool(Tool, ToolMarkerCanEdit): - """ - Inserts content at a given line in a file. - """ - - def apply( - self, - relative_path: str, - line: int, - content: str, - ) -> str: - """ - Inserts the given content at the given line in the file, pushing existing content of the line down. - In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which - symbol you are looking for. - However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body). - - :param relative_path: the relative path to the file - :param line: the 0-based index of the line to insert content at - :param content: the content to be inserted - """ - if not content.endswith("\n"): - content += "\n" - self.symbol_manager.insert_at_line(relative_path, line, content) - return SUCCESS_RESULT - - -class CheckOnboardingPerformedTool(Tool): - """ - Checks whether project onboarding was already performed. - """ - - def apply(self) -> str: - """ - Checks whether project onboarding was already performed. - You should always call this tool before beginning to actually work on the project/after activating a project, - but after calling the initial instructions tool. - """ - list_memories_tool = self.agent.get_tool(ListMemoriesTool) - memories = json.loads(list_memories_tool.apply()) - if len(memories) == 0: - return ( - "Onboarding not performed yet (no memories available). " - + "You should perform onboarding by calling the `onboarding` tool before proceeding with the task." - ) - else: - return f"""The onboarding was already performed, below is the list of available memories. - Do not read them immediately, just remember that they exist and that you can read them later, if it is necessary - for the current task. - Some memories may be based on previous conversations, others may be general for the current project. - You should be able to tell which one you need based on the name of the memory. - - {memories}""" - - -class OnboardingTool(Tool): - """ - Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). - """ - - def apply(self) -> str: - """ - Call this tool if onboarding was not performed yet. - You will call this tool at most once per conversation. - - :return: instructions on how to create the onboarding information - """ - system = platform.system() - return self.prompt_factory.create_onboarding_prompt(system=system) - - -class WriteMemoryTool(Tool): - """ - Writes a named memory (for future reference) to Serena's project-specific memory store. - """ - - def apply(self, memory_name: str, content: str, max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH) -> str: - """ - Write some information about this project that can be useful for future tasks to a memory. - Use markdown formatting for the content. - The information should be short and to the point. - The memory name should be meaningful, such that from the name you can infer what the information is about. - It is better to have multiple small memories than to have a single large one because - memories will be read one by one and we only ever want to read relevant memories. - - This tool is either called during the onboarding process or when you have identified - something worth remembering about the project from the past conversation. - """ - if len(content) > max_answer_chars: - raise ValueError( - f"Content for {memory_name} is too long. Max length is {max_answer_chars} characters. " + "Please make the content shorter." - ) - - return self.memories_manager.save_memory(memory_name, content) - - -class ReadMemoryTool(Tool): - """ - Reads the memory with the given name from Serena's project-specific memory store. - """ - - def apply(self, memory_file_name: str, max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH) -> str: - """ - Read the content of a memory file. This tool should only be used if the information - is relevant to the current task. You can infer whether the information - is relevant from the memory file name. - You should not read the same memory file multiple times in the same conversation. - """ - return self.memories_manager.load_memory(memory_file_name) - - -class ListMemoriesTool(Tool): - """ - Lists memories in Serena's project-specific memory store. - """ - - def apply(self) -> str: - """ - List available memories. Any memory can be read using the `read_memory` tool. - """ - return json.dumps(self.memories_manager.list_memories()) - - -class DeleteMemoryTool(Tool): - """ - Deletes a memory from Serena's project-specific memory store. - """ - - def apply(self, memory_file_name: str) -> str: - """ - Delete a memory file. Should only happen if a user asks for it explicitly, - for example by saying that the information retrieved from a memory file is no longer correct - or no longer relevant for the project. - """ - return self.memories_manager.delete_memory(memory_file_name) - - -class ThinkAboutCollectedInformationTool(Tool): - """ - Thinking tool for pondering the completeness of collected information. - """ - - def apply(self) -> str: - """ - Think about the collected information and whether it is sufficient and relevant. - This tool should ALWAYS be called after you have completed a non-trivial sequence of searching steps like - find_symbol, find_referencing_symbols, search_files_for_pattern, read_file, etc. - """ - return self.prompt_factory.create_think_about_collected_information() - - -class ThinkAboutTaskAdherenceTool(Tool): - """ - Thinking tool for determining whether the agent is still on track with the current task. - """ - - def apply(self) -> str: - """ - Think about the task at hand and whether you are still on track. - Especially important if the conversation has been going on for a while and there - has been a lot of back and forth. - - This tool should ALWAYS be called before you insert, replace, or delete code. - """ - return self.prompt_factory.create_think_about_task_adherence() - - -class ThinkAboutWhetherYouAreDoneTool(Tool): - """ - Thinking tool for determining whether the task is truly completed. - """ - - def apply(self) -> str: - """ - Whenever you feel that you are done with what the user has asked for, it is important to call this tool. - """ - return self.prompt_factory.create_think_about_whether_you_are_done() - - -class SummarizeChangesTool(Tool): - """ - Provides instructions for summarizing the changes made to the codebase. - """ - - def apply(self) -> str: - """ - Summarize the changes you have made to the codebase. - This tool should always be called after you have fully completed any non-trivial coding task, - but only after the think_about_whether_you_are_done call. - """ - return self.prompt_factory.create_summarize_changes() - - -class PrepareForNewConversationTool(Tool): - """ - Provides instructions for preparing for a new conversation (in order to continue with the necessary context). - """ - - def apply(self) -> str: - """ - Instructions for preparing for a new conversation. This tool should only be called on explicit user request. - """ - return self.prompt_factory.create_prepare_for_new_conversation() - - -class SearchForPatternTool(Tool): - """ - Performs a search for a pattern in the project. - """ - - def apply( - self, - substring_pattern: str, - context_lines_before: int = 0, - context_lines_after: int = 0, - paths_include_glob: str | None = None, - paths_exclude_glob: str | None = None, - relative_path: str = "", - restrict_search_to_code_files: bool = False, - max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH, - ) -> str: - """ - Offers a flexible search for arbitrary patterns in the codebase, including the - possibility to search in non-code files. - Generally, symbolic operations like find_symbol or find_referencing_symbols - should be preferred if you know which symbols you are looking for. - - Pattern Matching Logic: - For each match, the returned result will contain the full lines where the - substring pattern is found, as well as optionally some lines before and after it. The pattern will be compiled with - DOTALL, meaning that the dot will match all characters including newlines. - This also means that it never makes sense to have .* at the beginning or end of the pattern, - but it may make sense to have it in the middle for complex patterns. - If a pattern matches multiple lines, all those lines will be part of the match. - Be careful to not use greedy quantifiers unnecessarily, it is usually better to use non-greedy quantifiers like .*? to avoid - matching too much content. - - File Selection Logic: - The files in which the search is performed can be restricted very flexibly. - Using `restrict_search_to_code_files` is useful if you are only interested in code symbols (i.e., those - symbols that can be manipulated with symbolic tools like find_symbol). - You can also restrict the search to a specific file or directory, - and provide glob patterns to include or exclude certain files on top of that. - The globs are matched against relative file paths from the project root (not to the `relative_path` parameter that - is used to further restrict the search). - Smartly combining the various restrictions allows you to perform very targeted searches. - - - :param substring_pattern: Regular expression for a substring pattern to search for - :param context_lines_before: Number of lines of context to include before each match - :param context_lines_after: Number of lines of context to include after each match - :param paths_include_glob: optional glob pattern specifying files to include in the search. - Matches against relative file paths from the project root (e.g., "*.py", "src/**/*.ts"). - Only matches files, not directories. - :param paths_exclude_glob: optional glob pattern specifying files to exclude from the search. - Matches against relative file paths from the project root (e.g., "*test*", "**/*_generated.py"). - Takes precedence over paths_include_glob. Only matches files, not directories. - :param relative_path: only subpaths of this path (relative to the repo root) will be analyzed. If a path to a single - file is passed, only that will be searched. The path must exist, otherwise a `FileNotFoundError` is raised. - :param max_answer_chars: if the output is longer than this number of characters, - no content will be returned. Don't adjust unless there is really no other way to get the content - required for the task. Instead, if the output is too long, you should - make a stricter query. - :param restrict_search_to_code_files: whether to restrict the search to only those files where - analyzed code symbols can be found. Otherwise, will search all non-ignored files. - Set this to True if your search is only meant to discover code that can be manipulated with symbolic tools. - For example, for finding classes or methods from a name pattern. - Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files, - which is why it is the default. - :return: A JSON object mapping file paths to lists of matched consecutive lines (with context, if requested). - """ - abs_path = os.path.join(self.get_project_root(), relative_path) - if not os.path.exists(abs_path): - raise FileNotFoundError(f"Relative path {relative_path} does not exist.") - - if restrict_search_to_code_files: - matches = self.language_server.search_files_for_pattern( - pattern=substring_pattern, - relative_path=relative_path, - context_lines_before=context_lines_before, - context_lines_after=context_lines_after, - paths_include_glob=paths_include_glob, - paths_exclude_glob=paths_exclude_glob, - ) - else: - if os.path.isfile(abs_path): - rel_paths_to_search = [relative_path] - else: - dirs, rel_paths_to_search = scan_directory( - path=abs_path, - recursive=True, - is_ignored_dir=self.agent.path_is_gitignored, - is_ignored_file=self.agent.path_is_gitignored, - relative_to=self.get_project_root(), - ) - # TODO (maybe): not super efficient to walk through the files again and filter if glob patterns are provided - # but it probably never matters and this version required no further refactoring - matches = search_files( - rel_paths_to_search, - substring_pattern, - root_path=self.get_project_root(), - paths_include_glob=paths_include_glob, - paths_exclude_glob=paths_exclude_glob, - ) - # group matches by file - file_to_matches: dict[str, list[str]] = defaultdict(list) - for match in matches: - assert match.source_file_path is not None - file_to_matches[match.source_file_path].append(match.to_display_string()) - result = json.dumps(file_to_matches) - return self._limit_length(result, max_answer_chars) - - -class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): - """ - Executes a shell command. - """ - - def apply( - self, - command: str, - cwd: str | None = None, - capture_stderr: bool = True, - max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH, - ) -> str: - """ - Execute a shell command and return its output. - - IMPORTANT: you should always consider the memory about suggested shell commands before using this tool. - If this memory was not loaded in the current conversation, you should load it using the `read_memory` tool - before using this tool. - - You should have at least once looked at the suggested shell commands from the corresponding memory - created during the onboarding process before using this tool. - Never execute unsafe shell commands like `rm -rf /` or similar! Generally be very careful with deletions. - - :param command: the shell command to execute - :param cwd: the working directory to execute the command in. If None, the project root will be used. - :param capture_stderr: whether to capture and return stderr output - :param max_answer_chars: if the output is longer than this number of characters, - no content will be returned. Don't adjust unless there is really no other way to get the content - required for the task. - :return: a JSON object containing the command's stdout and optionally stderr output - """ - _cwd = cwd or self.get_project_root() - result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr) - result = result.json() - return self._limit_length(result, max_answer_chars) - - -class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): - """ - Activates a project by name. - """ - - def apply(self, project: str) -> str: - """ - Activates the project with the given name. - - :param project: the name of a registered project to activate or a path to a project directory - """ - active_project, new_project_generated, new_project_config_generated = self.agent.activate_project_from_path_or_name(project) - if new_project_generated: - result_str = ( - f"Created and activated a new project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}. " - + "You can activate this project later by name." - ) - else: - result_str = f"Activated existing project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}" - if new_project_config_generated: - result_str += ( - f"\nNote: A new project configuration was autogenerated because the given path did not contain a {ProjectConfig.SERENA_DEFAULT_PROJECT_FILE} file." - + f"You can now edit the project configuration in the file {active_project.path_to_project_yml()}. In particular, you may want to edit the project name and the initial prompt." - ) - - if active_project.project_config.initial_prompt: - result_str += f"\nAdditional project information:\n {active_project.project_config.initial_prompt}" - result_str += ( - f"\nAvailable memories:\n {json.dumps(list(self.memories_manager.list_memories()))}" - + "You should not read these memories directly, but rather use the `read_memory` tool to read them later if needed for the task." - ) - result_str += f"\nAvailable tools:\n {json.dumps(self.agent.get_active_tool_names())}" - return result_str - - -class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): - """ - Removes a project from the Serena configuration. - """ - - def apply(self, project_name: str) -> str: - """ - Removes a project from the Serena configuration. - - :param project_name: Name of the project to remove - """ - self.agent.serena_config.remove_project(project_name) - return f"Successfully removed project '{project_name}' from configuration." - - -class SwitchModesTool(Tool): - """ - Activates modes by providing a list of their names - """ - - def apply(self, modes: list[str]) -> str: - """ - Activates the desired modes, like ["editing", "interactive"] or ["planning", "one-shot"] - - :param modes: the names of the modes to activate - """ - mode_instances = [SerenaAgentMode.load(mode) for mode in modes] - self.agent.set_modes(mode_instances) - - # Inform the Agent about the activated modes and the currently active tools - result_str = f"Successfully activated modes: {', '.join([mode.name for mode in mode_instances])}" + "\n" - result_str += "\n".join([mode_instance.prompt for mode_instance in mode_instances]) + "\n" - result_str += f"Currently active tools: {', '.join(self.agent.get_active_tool_names())}" - return result_str - - -class GetCurrentConfigTool(Tool): - """ - Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. - """ - - def apply(self) -> str: - """ - Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes. - """ - return self.agent.get_current_config_overview() - - -class InitialInstructionsTool(Tool): - """ - Gets the initial instructions for the current project. - Should only be used in settings where the system prompt cannot be set, - e.g. in clients you have no control over, like Claude Desktop. - """ - - def apply(self) -> str: - """ - Get the initial instructions for the current coding project. - You should always call this tool before starting to work (including using any other tool) on any programming task! - The only exception is when a user asks you to activate a project, in which case you should call the `activate_project` first - instead and then call this tool. - """ - return self.agent.create_system_prompt() - - -def _iter_tool_classes(same_module_only: bool = True) -> Generator[type[Tool], None, None]: - """ - Iterate over Tool subclasses. - - :param same_module_only: Whether to only iterate over tools defined in the same module as the Tool class - or over all subclasses of Tool. - """ - for tool_class in iter_subclasses(Tool): - if same_module_only and tool_class.__module__ != Tool.__module__: - continue - yield tool_class - - -_TOOL_REGISTRY_DICT: dict[str, type[Tool]] = {tool_class.get_name_from_cls(): tool_class for tool_class in _iter_tool_classes()} -"""maps tool name to the corresponding tool class""" - - -class ToolRegistry: - @staticmethod - def get_tool_class_by_name(tool_name: str) -> type[Tool]: - try: - return _TOOL_REGISTRY_DICT[tool_name] - except KeyError as e: - available_tools = "\n".join(ToolRegistry.get_tool_names()) - raise ValueError(f"Tool with name {tool_name} not found. Available tools:\n{available_tools}") from e - - @staticmethod - def get_all_tool_classes() -> list[type[Tool]]: - return list(_TOOL_REGISTRY_DICT.values()) - - @staticmethod - def get_tool_names() -> list[str]: - return list(_TOOL_REGISTRY_DICT.keys()) - - @staticmethod - def tool_dict() -> dict[str, type[Tool]]: - """Maps tool name to the corresponding tool class""" - return copy(_TOOL_REGISTRY_DICT) - - @staticmethod - def print_tool_overview(tools: Iterable[type[Tool] | Tool] | None = None) -> None: - """ - Print a summary of the tools. If no tools are passed, a summary of all tools is printed. - """ - if tools is None: - tools = _TOOL_REGISTRY_DICT.values() - - tool_dict: dict[str, type[Tool] | Tool] = {} - for tool_class in tools: - tool_dict[tool_class.get_name_from_cls()] = tool_class - for tool_name in sorted(tool_dict.keys()): - tool_class = tool_dict[tool_name] - print(f" * `{tool_name}`: {tool_class.get_tool_description().strip()}") diff --git a/src/serena/agno.py b/src/serena/agno.py index 0bb0e70f..07ab7371 100644 --- a/src/serena/agno.py +++ b/src/serena/agno.py @@ -14,9 +14,10 @@ from agno.tools.toolkit import Toolkit from dotenv import load_dotenv from sensai.util.logging import LogTime -from serena.agent import SerenaAgent, Tool, show_fatal_exception_safe -from serena.config import SerenaAgentContext +from serena.agent import SerenaAgent, Tool +from serena.config.context_mode import SerenaAgentContext from serena.constants import REPO_ROOT +from serena.util.exception import show_fatal_exception_safe log = logging.getLogger(__name__) diff --git a/src/serena/config/__init__.py b/src/serena/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/serena/config.py b/src/serena/config/context_mode.py similarity index 100% rename from src/serena/config.py rename to src/serena/config/context_mode.py diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py new file mode 100644 index 00000000..d53716e3 --- /dev/null +++ b/src/serena/config/serena_config.py @@ -0,0 +1,412 @@ +""" +The Serena Model Context Protocol (MCP) Server +""" + +import os +import shutil +from copy import deepcopy +from dataclasses import dataclass, field +from functools import cached_property +from pathlib import Path +from typing import TYPE_CHECKING, Any, Self, TypeVar + +import yaml +from ruamel.yaml.comments import CommentedMap +from sensai.util import logging +from sensai.util.string import ToStringMixin + +from serena.constants import ( + DEFAULT_ENCODING, + PROJECT_TEMPLATE_FILE, + REPO_ROOT, + SELENA_CONFIG_TEMPLATE_FILE, + SERENA_MANAGED_DIR_NAME, +) +from serena.tools import Tool, ToolRegistry +from serena.util.general import load_yaml, save_yaml +from serena.util.inspection import determine_programming_language_composition +from solidlsp.ls_config import Language + +if TYPE_CHECKING: + pass + +log = logging.getLogger(__name__) +T = TypeVar("T") +DEFAULT_TOOL_TIMEOUT: float = 240 + + +class SerenaConfigError(Exception): + pass + + +def get_serena_managed_dir(project_root: str | Path) -> str: + return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) + + +def is_running_in_docker() -> bool: + """Check if we're running inside a Docker container.""" + # Check for Docker-specific files + if os.path.exists("/.dockerenv"): + return True + # Check cgroup for docker references + try: + with open("/proc/self/cgroup") as f: + return "docker" in f.read() + except FileNotFoundError: + return False + + +@dataclass +class ProjectConfig(ToStringMixin): + project_name: str + language: Language + ignored_paths: list[str] = field(default_factory=list) + excluded_tools: set[str] = field(default_factory=set) + read_only: bool = False + ignore_all_files_in_gitignore: bool = True + initial_prompt: str = "" + encoding: str = DEFAULT_ENCODING + + SERENA_DEFAULT_PROJECT_FILE = "project.yml" + + @classmethod + def autogenerate(cls, project_root: str | Path, project_name: str | None = None, save_to_disk: bool = True) -> Self: + """ + Autogenerate a project configuration for a given project root. + + :param project_root: the path to the project root + :param project_name: the name of the project; if None, the name of the project will be the name of the directory + containing the project + :param save_to_disk: whether to save the project configuration to disk + :return: the project configuration + """ + project_root = Path(project_root).resolve() + if not project_root.exists(): + raise FileNotFoundError(f"Project root not found: {project_root}") + project_name = project_name or project_root.name + language_composition = determine_programming_language_composition(str(project_root)) + if len(language_composition) == 0: + raise ValueError( + f"No source files found in {project_root}\n\n" + f"To use Serena with this project, you need to either:\n" + f"1. Add source files in one of the supported languages (Python, JavaScript/TypeScript, Java, C#, Rust, Go, Ruby, C++, PHP)\n" + f"2. Create a project configuration file manually at:\n" + f" {os.path.join(project_root, cls.rel_path_to_project_yml())}\n\n" + f"Example project.yml:\n" + f" project_name: {project_name}\n" + f" language: python # or typescript, java, csharp, rust, go, ruby, cpp, php\n" + ) + # find the language with the highest percentage + dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) + config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True) + config_with_comments["project_name"] = project_name + config_with_comments["language"] = dominant_language + if save_to_disk: + save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) + return cls._from_dict(config_with_comments) + + @classmethod + def rel_path_to_project_yml(cls) -> str: + return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) + + @classmethod + def _from_dict(cls, data: dict[str, Any]) -> Self: + """ + Create a ProjectConfig instance from a configuration dictionary + """ + language_str = data["language"].lower() + project_name = data["project_name"] + # backwards compatibility + if language_str == "javascript": + log.warning(f"Found deprecated project language `javascript` in project {project_name}, please change to `typescript`") + language_str = "typescript" + try: + language = Language(language_str) + except ValueError as e: + raise ValueError(f"Invalid language: {data['language']}.\nValid languages are: {[l.value for l in Language]}") from e + return cls( + project_name=project_name, + language=language, + ignored_paths=data.get("ignored_paths", []), + excluded_tools=set(data.get("excluded_tools", [])), + read_only=data.get("read_only", False), + ignore_all_files_in_gitignore=data.get("ignore_all_files_in_gitignore", True), + initial_prompt=data.get("initial_prompt", ""), + encoding=data.get("encoding", DEFAULT_ENCODING), + ) + + @classmethod + def load(cls, project_root: Path | str, autogenerate: bool = True) -> Self: + """ + Load a ProjectConfig instance from the path to the project root. + """ + project_root = Path(project_root) + yaml_path = project_root / cls.rel_path_to_project_yml() + if not yaml_path.exists(): + if autogenerate: + return cls.autogenerate(project_root) + else: + raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") + with open(yaml_path, encoding="utf-8") as f: + yaml_data = yaml.safe_load(f) + if "project_name" not in yaml_data: + yaml_data["project_name"] = project_root.name + return cls._from_dict(yaml_data) + + def get_excluded_tool_classes(self) -> set[type["Tool"]]: + return set(ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools) + + +@dataclass +class Project: + project_root: str + project_config: ProjectConfig + + @property + def project_name(self) -> str: + return self.project_config.project_name + + @property + def language(self) -> Language: + return self.project_config.language + + @classmethod + def load(cls, project_root: str | Path, autogenerate: bool = True) -> Self: + project_root = Path(project_root).resolve() + if not project_root.exists(): + raise FileNotFoundError(f"Project root not found: {project_root}") + project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) + return cls(project_root=str(project_root), project_config=project_config) + + def path_to_project_yml(self) -> str: + return os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) + + +@dataclass(kw_only=True) +class SerenaConfig: + """ + Holds the Serena agent configuration, which is typically loaded from a YAML configuration file + (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed. + For testing purposes, it can also be instantiated directly with the desired parameters. + """ + + projects: list[Project] = field(default_factory=list) + gui_log_window_enabled: bool = False + log_level: int = logging.INFO + trace_lsp_communication: bool = False + web_dashboard: bool = True + web_dashboard_open_on_launch: bool = True + tool_timeout: float = DEFAULT_TOOL_TIMEOUT + loaded_commented_yaml: CommentedMap | None = None + config_file_path: str | None = None + """ + the path to the configuration file to which updates of the configuration shall be saved; + if None, the configuration is not saved to disk + """ + + CONFIG_FILE = "serena_config.yml" + CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization + + @classmethod + def _generate_config_file(cls, config_file_path: str) -> None: + """ + Generates a Serena configuration file at the specified path from the template file. + + :param config_file_path: the path where the configuration file should be generated + """ + log.info(f"Auto-generating Serena configuration file in {config_file_path}") + loaded_commented_yaml = load_yaml(SELENA_CONFIG_TEMPLATE_FILE, preserve_comments=True) + save_yaml(config_file_path, loaded_commented_yaml, preserve_comments=True) + + @classmethod + def _determine_config_file_path(cls) -> str: + """ + :return: the location where the Serena configuration file is stored/should be stored + """ + if is_running_in_docker(): + return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER) + else: + config_path = str(Path.home() / SERENA_MANAGED_DIR_NAME / cls.CONFIG_FILE) + + # if the config file does not exist, check if we can migrate it from the old location + if not os.path.exists(config_path): + old_config_path = os.path.join(REPO_ROOT, cls.CONFIG_FILE) + if os.path.exists(old_config_path): + log.info(f"Moving Serena configuration file from {old_config_path} to {config_path}") + os.makedirs(os.path.dirname(config_path), exist_ok=True) + shutil.move(old_config_path, config_path) + + return config_path + + @classmethod + def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig": + """ + Static constructor to create SerenaConfig from the configuration file + """ + config_file_path = cls._determine_config_file_path() + + # create the configuration file from the template if necessary + if not os.path.exists(config_file_path): + if not generate_if_missing: + raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}") + log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") + cls._generate_config_file(config_file_path) + + # load the configuration + log.info(f"Loading Serena configuration from {config_file_path}") + try: + loaded_commented_yaml = load_yaml(config_file_path, preserve_comments=True) + except Exception as e: + raise ValueError(f"Error loading Serena configuration from {config_file_path}: {e}") from e + + # create the configuration instance + instance = cls(loaded_commented_yaml=loaded_commented_yaml, config_file_path=config_file_path) + + # read projects + if "projects" not in loaded_commented_yaml: + raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") + + # load list of known projects + instance.projects = [] + num_project_migrations = 0 + for path in loaded_commented_yaml["projects"]: + path = Path(path).resolve() + if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()): + log.warning(f"Project path {path} does not exist or does not contain a project configuration file, skipping.") + continue + if path.is_file(): + path = cls._migrate_out_of_project_config_file(path) + if path is None: + continue + num_project_migrations += 1 + project = Project.load(path) + instance.projects.append(project) + + # set other configuration parameters + if is_running_in_docker(): + instance.gui_log_window_enabled = False # not supported in Docker + else: + instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False) + instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO)) + instance.web_dashboard = loaded_commented_yaml.get("web_dashboard", True) + instance.web_dashboard_open_on_launch = loaded_commented_yaml.get("web_dashboard_open_on_launch", True) + instance.tool_timeout = loaded_commented_yaml.get("tool_timeout", DEFAULT_TOOL_TIMEOUT) + instance.trace_lsp_communication = loaded_commented_yaml.get("trace_lsp_communication", False) + + # re-save the configuration file if any migrations were performed + if num_project_migrations > 0: + log.info( + f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration" + ) + instance.save() + + return instance + + @classmethod + def _migrate_out_of_project_config_file(cls, path: Path) -> Path | None: + """ + Migrates a legacy project configuration file (which is a YAML file containing the project root) to the + in-project configuration file (project.yml) inside the project root directory. + + :param path: the path to the legacy project configuration file + :return: the project root path if the migration was successful, None otherwise. + """ + log.info(f"Found legacy project configuration file {path}, migrating to in-project configuration.") + try: + with open(path, encoding="utf-8") as f: + project_config_data = yaml.safe_load(f) + if "project_name" not in project_config_data: + project_name = path.stem + with open(path, "a", encoding="utf-8") as f: + f.write(f"\nproject_name: {project_name}") + project_root = project_config_data["project_root"] + shutil.move(str(path), str(Path(project_root) / ProjectConfig.rel_path_to_project_yml())) + return Path(project_root).resolve() + except Exception as e: + log.error(f"Error migrating configuration file: {e}") + return None + + @cached_property + def project_paths(self) -> list[str]: + return sorted(project.project_root for project in self.projects) + + @cached_property + def project_names(self) -> list[str]: + return sorted(project.project_config.project_name for project in self.projects) + + def get_project(self, project_root_or_name: str) -> Project | None: + for project in self.projects: + if project.project_config.project_name == project_root_or_name: + return project + if os.path.isdir(project_root_or_name): + project_root = Path(project_root_or_name).resolve() + for project in self.projects: + if Path(project.project_root).resolve() == project_root: + return project + return None + + def add_project_from_path(self, project_root: Path | str, project_name: str | None = None) -> tuple[Project, bool]: + """ + Add a project to the Serena configuration from a given path. Will raise a FileExistsError if the + name or path is already registered. + + :param project_root: the path to the project to add + :param project_name: the name of the project to add; if None, the name of the project will be the name of the directory + containing the project + :return: the project that was added and a boolean indicating whether a new project configuration was generated and + saved to disk. It may be that no new project configuration was generated if the project configuration already + exists on disk but the project itself was not added yet to the Serena configuration. + """ + project_root = Path(project_root).resolve() + if not project_root.exists(): + raise FileNotFoundError(f"Error: Path does not exist: {project_root}") + if not project_root.is_dir(): + raise FileNotFoundError(f"Error: Path is not a directory: {project_root}") + + if project_name is None: + project_name = project_root.name + for already_registered_project in self.projects: + if already_registered_project.project_name == project_name: + raise FileExistsError( + f"Project name '{project_name}' already exists and points to {already_registered_project.project_root}." + ) + if str(already_registered_project.project_root) == str(project_root): + raise FileExistsError( + f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." + ) + + try: + project_config = ProjectConfig.load(project_root) + new_project_config_generated = False + except FileNotFoundError: + project_config = ProjectConfig.autogenerate(project_root, save_to_disk=True) + new_project_config_generated = True + + new_project = Project(project_root=str(project_root), project_config=project_config) + self.projects.append(new_project) + self.save() + + return new_project, new_project_config_generated + + def remove_project(self, project_name: str) -> None: + # find the index of the project with the desired name and remove it + for i, project in enumerate(self.projects): + if project.project_name == project_name: + del self.projects[i] + break + else: + raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") + self.save() + + def save(self) -> None: + """ + Saves the configuration to the file from which it was loaded (if any) + """ + if self.config_file_path is None: + return + assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML" + loaded_original_yaml = deepcopy(self.loaded_commented_yaml) + # projects are unique absolute paths + # we also canonicalize them before saving + loaded_original_yaml["projects"] = sorted({str(Path(project.project_root).resolve()) for project in self.projects}) + save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True) diff --git a/src/serena/constants.py b/src/serena/constants.py index 368a7430..447364be 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -19,6 +19,4 @@ DEFAULT_MODES = ("interactive", "editing") PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.yml") SELENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml") -USE_PROCESS_ISOLATION = False - SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s" diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 7063bb95..4f6d03e7 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -150,11 +150,8 @@ class SerenaDashboardAPI: if self._shutdown_callback: self._shutdown_callback() else: - # Try to use the global shutdown function from process_isolated_agent - from serena.process_isolated_agent import request_global_shutdown - - request_global_shutdown() # noinspection PyProtectedMember + # noinspection PyUnresolvedReferences os._exit(0) @staticmethod diff --git a/src/serena/mcp.py b/src/serena/mcp.py index db742039..3f918194 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -2,20 +2,14 @@ The Serena Model Context Protocol (MCP) Server """ -import asyncio -import contextlib -import os -import signal import sys -import threading -import time from abc import abstractmethod from collections.abc import AsyncIterator, Iterator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass from logging import Formatter, Logger, StreamHandler from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, cast import click import docstring_parser @@ -26,24 +20,13 @@ from pydantic_settings import SettingsConfigDict from sensai.util import logging from serena.agent import ( - ActivateProjectTool, - Project, SerenaAgent, SerenaConfig, - ToolInterface, - ToolRegistry, - create_serena_config, - show_fatal_exception_safe, -) -from serena.config import RegisteredContext, SerenaAgentContext, SerenaAgentMode -from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, USE_PROCESS_ISOLATION -from serena.process_isolated_agent import ( - ProcessIsolatedDashboard, - ProcessIsolatedSerenaAgent, - ProcessIsolatedTool, - global_shutdown_event, - request_global_shutdown, ) +from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode +from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES +from serena.tools import ToolInterface +from serena.util.exception import show_fatal_exception_safe log = logging.getLogger(__name__) LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s" @@ -165,15 +148,23 @@ class SerenaMCPFactory: :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. """ try: - serena_config = create_serena_config( - enable_web_dashboard=enable_web_dashboard, - enable_gui_log_window=enable_gui_log_window, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, - ) + config = SerenaConfig.from_config_file() + + # update configuration with the provided parameters + if enable_web_dashboard is not None: + config.web_dashboard = enable_web_dashboard + if enable_gui_log_window is not None: + config.gui_log_window_enabled = enable_gui_log_window + if log_level is not None: + log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper()) + config.log_level = logging.getLevelNamesMapping()[log_level] + if trace_lsp_communication is not None: + config.trace_lsp_communication = trace_lsp_communication + if tool_timeout is not None: + config.tool_timeout = tool_timeout + modes_instances = [SerenaAgentMode.load(mode) for mode in modes] - self._instantiate_agent(serena_config, modes_instances) + self._instantiate_agent(config, modes_instances) except Exception as e: show_fatal_exception_safe(e) @@ -224,235 +215,6 @@ class SerenaMCPFactorySingleProcess(SerenaMCPFactory): yield -class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): - """ - MCP server factory with process isolation for the SerenaAgent and its language server; they run in a separate process - from the MCP server. - """ - - def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None): - """ - :param context: The context name or path to context file - :param project: Either an absolute path to the project directory or a name of an already registered project. - If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name - afterward. - """ - super().__init__(context=context, project=project) - - self.active_tool_names: set[str] | None = None - self.serena_agent_process: ProcessIsolatedSerenaAgent | None = None - self.serena_dashboard_process: ProcessIsolatedDashboard | None = None - - @staticmethod - def _determine_active_tool_names(context: SerenaAgentContext, project: Project | None) -> set[str]: - """ - Determine the names of tools that should be included in this session based on the context. - """ - tools_excluded_in_this_session = context.get_excluded_tool_classes() - - # if a project has been loaded, it will be activated at startup and in ide-assistant context, - # we assume that no other project will be activated in this session. - # Therefore, we exclude the activate project tool - is_ide_assistant = context.name == RegisteredContext.IDE_ASSISTANT.value - if is_ide_assistant and project is not None: - tools_excluded_in_this_session.extend(project.project_config.get_excluded_tool_classes()) - tools_excluded_in_this_session.append(ActivateProjectTool) - - tool_names_excluded_in_this_session = {tool.get_name_from_cls() for tool in tools_excluded_in_this_session} - - all_tool_names = set(ToolRegistry.get_tool_names()) - tool_names_included_in_this_session = all_tool_names - tool_names_excluded_in_this_session - return tool_names_included_in_this_session - - @staticmethod - def make_mcp_tool(tool: ToolInterface) -> MCPTool: - func_name = tool.get_name() - func_doc = tool.get_apply_docstring() or "" - func_arg_metadata = tool.get_apply_fn_metadata() - is_async = False - parameters = func_arg_metadata.arg_model.model_json_schema() - - docstring = docstring_parser.parse(func_doc) - - # Mount the tool description as a combination of the docstring description and - # the return value description, if it exists. - if docstring.description: - func_doc = f"{docstring.description.strip().strip('.')}." - else: - func_doc = "" - if docstring.returns and (docstring_returns_descr := docstring.returns.description): - # Only add a space before "Returns" if func_doc is not empty - prefix = " " if func_doc else "" - func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." - - # Parse the parameter descriptions from the docstring and add pass its description - # to the parameter schema. - docstring_params = {param.arg_name: param for param in docstring.params} - parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] - for parameter, properties in parameters_properties.items(): - if (param_doc := docstring_params.get(parameter)) and param_doc.description: - param_desc = f"{param_doc.description.strip().strip('.') + '.'}" - properties["description"] = param_desc[0].upper() + param_desc[1:] - - def execute_fn(**kwargs) -> str: # type: ignore - return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) - - return MCPTool( - fn=execute_fn, - name=func_name, - description=func_doc, - parameters=parameters, - fn_metadata=func_arg_metadata, - is_async=is_async, - context_kwarg=None, - annotations=None, - ) - - def _iter_tools(self) -> Iterator[ToolInterface]: - assert self.active_tool_names is not None - assert self.serena_agent_process is not None - for tool_name in self.active_tool_names: - yield ProcessIsolatedTool(process_agent=self.serena_agent_process, tool_name=tool_name) - - # noinspection PyProtectedMember - def _set_mcp_tools(self, mcp: FastMCP) -> None: - """Update the tools in the MCP server""" - if mcp is not None: - mcp._tool_manager._tools = {} - for tool in self._iter_tools(): - mcp_tool = self.make_mcp_tool(tool) - mcp._tool_manager._tools[tool.get_name()] = mcp_tool - - def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: - if self.project is not None: - self.project_instance = serena_config.get_project(self.project) - self.serena_agent_process = ProcessIsolatedSerenaAgent( - project=self.project, serena_config=serena_config, modes=modes, context=self.context - ) - self.active_tool_names = self._determine_active_tool_names(self.context, self.project_instance) - if serena_config.web_dashboard: - assert self.active_tool_names is not None - self.serena_dashboard_process = ProcessIsolatedDashboard(tool_names=sorted(self.active_tool_names)) - - def create_mcp_server( - self, - host: str = "0.0.0.0", - port: int = 8000, - modes: Sequence[str] = DEFAULT_MODES, - enable_web_dashboard: bool | None = None, - enable_gui_log_window: bool | None = None, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, - ) -> FastMCP: - """ - Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination. - - :param host: The host to bind to - :param port: The port to bind to - :param modes: List of mode names or paths to mode files - :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. - :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. - If not specified, will take the value from the serena configuration. - :param log_level: Log level. If not specified, will take the value from the serena configuration. - :param trace_lsp_communication: Whether to trace the communication between Serena and the language servers. - This is useful for debugging language server issues. - :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. - """ - try: - serena_config = create_serena_config( - enable_web_dashboard=enable_web_dashboard, - enable_gui_log_window=enable_gui_log_window, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, - ) - modes_instances = [SerenaAgentMode.load(mode) for mode in modes] - self._instantiate_agent(serena_config, modes_instances) - - except Exception as e: - show_fatal_exception_safe(e) - raise - - # Override model_config to disable the use of `.env` files for reading settings, because user projects are likely to contain - # `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings; - # retain only FASTMCP_ prefix for already set environment variables. - Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") - - mcp_settings = Settings(lifespan=self.server_lifespan, host=host, port=port) - mcp = FastMCP(**mcp_settings.model_dump()) - return mcp - - @asynccontextmanager - async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: - """Manage server startup and shutdown lifecycle.""" - - def signal_handler(signum: int, frame: Any) -> None: - log.info(f"Received signal {signum} in main process") - request_global_shutdown() - - def force_exit() -> None: - time.sleep(2.0) # Wait 2 seconds for graceful shutdown - log.warning("Forcing exit after timeout") - # noinspection PyProtectedMember - # noinspection PyUnresolvedReferences - os._exit(1) - - threading.Thread(target=force_exit, daemon=True).start() - - # Install signal handlers - sigint_singal = signal.signal(signal.SIGINT, signal_handler) - sigterm_signal = signal.signal(signal.SIGTERM, signal_handler) - - if self.serena_dashboard_process is not None: - log.info("Starting dashboard process") - assert self.serena_dashboard_process is not None - self.serena_dashboard_process.start() - log.info("Starting serena agent process") - assert self.serena_agent_process is not None - self.serena_agent_process.start() - - self._set_mcp_tools(mcp_server) - - async def monitor_global_shutdown() -> None: - """Monitor the global shutdown event and trigger local shutdown.""" - while not global_shutdown_event.is_set(): - # Poll the multiprocessing Event in async context - await asyncio.sleep(0.1) - continue - log.info("Global shutdown event detected, initiating server shutdown") - request_global_shutdown() - # Send SIGTERM to self to trigger graceful shutdown - os.kill(os.getpid(), signal.SIGTERM) - - # Start monitoring task - monitor_task = asyncio.create_task(monitor_global_shutdown()) - - log.info("MCP server lifetime setup complete") - try: - yield - except (KeyboardInterrupt, SystemExit): - log.info("Received shutdown signal") - request_global_shutdown() - except Exception as e: - log.error(f"Error in server lifespan: {e}") - request_global_shutdown() - finally: - # Cancel monitor task - monitor_task.cancel() - - with contextlib.suppress(asyncio.CancelledError): - await monitor_task - - self.serena_agent_process.stop() - if self.serena_dashboard_process is not None: - self.serena_dashboard_process.stop() - request_global_shutdown() - log.info("Shutting down all processes") - signal.signal(signal.SIGINT, sigint_singal) - signal.signal(signal.SIGTERM, sigterm_signal) - - class ProjectType(click.ParamType): name = "[PROJECT_NAME|PROJECT_PATH]" @@ -588,13 +350,7 @@ def start_mcp_server( # This is for backward compatibility with the old CLI, should be removed in the future! project_file = project_file_arg if project_file_arg is not None else project - mcp_factory: SerenaMCPFactory - if not USE_PROCESS_ISOLATION: - mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) - else: - mcp_factory = SerenaMCPFactoryWithProcessIsolation(context=context, project=project_file) - - # Use process isolation by default to prevent asyncio event loop contamination + mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) mcp_server = mcp_factory.create_mcp_server( host=host, port=port, diff --git a/src/serena/process_isolated_agent.py b/src/serena/process_isolated_agent.py deleted file mode 100644 index 081dcc65..00000000 --- a/src/serena/process_isolated_agent.py +++ /dev/null @@ -1,571 +0,0 @@ -import asyncio -import contextlib -import logging -import multiprocessing -import os -import threading -import traceback -import webbrowser -from enum import StrEnum -from logging.handlers import QueueHandler -from multiprocessing.connection import Connection -from multiprocessing.sharedctypes import Synchronized -from multiprocessing.synchronize import Event as EventClass -from typing import Any, Literal, Self - -from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata - -from serena.agent import SerenaAgent, SerenaConfig, SerenaConfigBase, Tool, ToolInterface, ToolRegistry -from serena.config import SerenaAgentContext, SerenaAgentMode -from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI - -log = logging.getLogger(__name__) - -# Global synchronization primitives -_global_log_queue: multiprocessing.Queue = multiprocessing.Queue() -_dashboard_ready_event = multiprocessing.Event() -_dashboard_port_value = multiprocessing.Value("i", 0) -global_shutdown_event = multiprocessing.Event() - - -def request_global_shutdown() -> None: - """Signal the global shutdown event.""" - global_shutdown_event.set() - log.info("Global shutdown event set") - - -def _dashboard_worker( - tool_names: list[str], - log_q: "multiprocessing.Queue[Any]", - dashboard_ready_event: EventClass, - port_value: "Synchronized[int]", - shutdown_evt: EventClass, -) -> None: - """Entry point for the dashboard process.""" - # Route all logging to the shared queue - root = logging.getLogger() - root.handlers.clear() - root.setLevel(logging.DEBUG) - root.addHandler(QueueHandler(log_q)) - - async def _process_logs(api: SerenaDashboardAPI) -> None: - while not shutdown_evt.is_set(): - while not log_q.empty(): - record = log_q.get_nowait() - if record is None: - break - api.memory_log_handler.emit(record) - # Small delay to avoid busy waiting - await asyncio.sleep(0.1) - - async def _monitor_shutdown() -> None: - # Poll the multiprocessing Event in async context - # Check every 100ms until shutdown is requested - loop = asyncio.get_event_loop() - while True: - # Check in executor to avoid blocking - result = await loop.run_in_executor(None, shutdown_evt.is_set) - if result: - break - await asyncio.sleep(0.1) - - async def _async_main() -> None: - api = SerenaDashboardAPI( - memory_log_handler=MemoryLogHandler(), - tool_names=tool_names, - shutdown_callback=shutdown_evt.set, - ) - # Pick a free port and signal readiness - port = api._find_first_free_port(0x5EDA) - port_value.value = port - - # Start Flask server in a thread - def run_flask_server() -> None: - api._app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True) - - server_thread = threading.Thread(target=run_flask_server, daemon=True) - server_thread.start() - - shutdown_task = asyncio.create_task(_monitor_shutdown()) - logging_loop_task = asyncio.create_task(_process_logs(api)) - dashboard_ready_event.set() - - done, pending = await asyncio.wait( - [shutdown_task, logging_loop_task], - return_when=asyncio.FIRST_COMPLETED, - ) - - # Cancel remaining tasks - for task in pending: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - try: - asyncio.run(_async_main()) - except BaseException: - logging.exception("Dashboard worker crashed") - finally: - logging.info("Dashboard worker exiting") - - -def _shutdown_process(proc: multiprocessing.Process, timeout: float = 1.0) -> None: - """Helper to shutdown a process gracefully.""" - if proc.is_alive(): - proc.terminate() - proc.join(timeout=timeout) - if proc.is_alive(): - log.error("Process did not terminate gracefully, forcing kill") - proc.kill() - proc.join(timeout=timeout) - if proc.is_alive(): - log.error("Process did not respond to kill") - - -class ProcessIsolatedDashboard: - """Dashboard running in a separate process to avoid asyncio contamination.""" - - def __init__(self, tool_names: list[str]): - self.tool_names = tool_names - self.process: multiprocessing.Process | None = None - - def start(self, timeout: float = 10.0) -> None: - """Start the dashboard process.""" - if self.process is not None: - raise RuntimeError("Dashboard already started") - - self.process = multiprocessing.Process( - target=_dashboard_worker, - args=(self.tool_names, _global_log_queue, _dashboard_ready_event, _dashboard_port_value, global_shutdown_event), - daemon=True, - ) - self.process.start() - - if not _dashboard_ready_event.wait(timeout): - self.process.terminate() - self.process.join(timeout=1.0) - self.process = None - raise RuntimeError("Dashboard failed to start within timeout") - - port = _dashboard_port_value.value - log.info(f"Dashboard started on port {port}") - webbrowser.open(f"http://localhost:{port}/dashboard/index.html") - - def stop(self, timeout: float = 1.0) -> None: - """Signal shutdown and wait for the dashboard process to exit.""" - if self.process is None: - return - - log.info("Stopping dashboard process") - request_global_shutdown() - _shutdown_process(self.process, timeout=timeout) - self.process = None - - -class SerenaAgentWorker: - """Worker process that hosts the actual SerenaAgent.""" - - class RequestMethod(StrEnum): - INITIALIZE = "initialize" - TOOL_CALL = "tool_call" - GET_ACTIVE_TOOL_NAMES = "get_active_tool_names" - IS_LANGUAGE_SERVER_RUNNING = "is_language_server_running" - RESET_LANGUAGE_SERVER = "reset_language_server" - GET_EXPOSED_TOOL_NAMES = "get_exposed_tool_names" - SHUTDOWN = "shutdown" - - def __init__(self, conn: Connection): - self.conn = conn - self.agent: SerenaAgent | None = None - - def run(self, log_queue: "multiprocessing.Queue[str]") -> None: - """Main worker loop - runs in separate process.""" - qh = QueueHandler(log_queue) - root = logging.getLogger() - root.setLevel(logging.DEBUG) - root.addHandler(qh) - - log.info("SerenaAgent worker process started") - try: - while not global_shutdown_event.is_set(): - try: - # Use polling to avoid blocking indefinitely - if self.conn.poll(timeout=0.5): # Poll every 500ms - try: - request = self.conn.recv() - if request is None: # Explicit shutdown signal - break - - response = self._handle_request(request) - self.conn.send(response) - except EOFError: - # Connection closed - parent process terminated - log.info("Connection closed, worker shutting down") - break - except (BrokenPipeError, ConnectionResetError): - # Connection broken, can't communicate - log.info("Connection broken, worker shutting down") - break - except Exception as e: - log.error(f"Error processing request: {e}") - try: - response = { - "error": f"Worker process error: {e!s}", - "traceback": traceback.format_exc(), - } - self.conn.send(response) - except (EOFError, BrokenPipeError, ConnectionResetError): - # Connection is broken, can't send error response - log.info("Connection broken during error response, shutting down") - break - # Continue polling if no data available - - except Exception as e: - log.error(f"Error in worker main loop: {e}") - break - except Exception as e: - log.error(f"Fatal error in worker process: {e}") - finally: - self._cleanup() - log.info("SerenaAgent worker process stopped") - os._exit(0) # Exit without raising any further exceptions - - def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]: - """Handle a single request.""" - try: - method = request["method"] - params = request.get("params", {}) - - match method: - case self.RequestMethod.INITIALIZE: - return self._initialize(params) - case self.RequestMethod.TOOL_CALL: - return self._tool_call(params) - case self.RequestMethod.GET_ACTIVE_TOOL_NAMES: - return self._get_active_tool_names() - case self.RequestMethod.IS_LANGUAGE_SERVER_RUNNING: - return self._is_language_server_running() - case self.RequestMethod.RESET_LANGUAGE_SERVER: - return self._reset_language_server() - case self.RequestMethod.GET_EXPOSED_TOOL_NAMES: - return self._get_exposed_tool_names() - case self.RequestMethod.SHUTDOWN: - return self.shutdown() - case _: - return {"error": f"Unknown method: {method}"} - except Exception as e: - return {"error": str(e), "traceback": traceback.format_exc()} - - def _initialize(self, params: dict[str, Any]) -> dict[str, Any]: - """Initialize the SerenaAgent.""" - if self.agent is not None: - return {"result": "SerenaAgent already initialized"} - try: - # Extract all possible initialization parameters - context_param = params.get("context") - project = params.get("project") - serena_config = SerenaConfig.from_json_dict(params["serena_config"]) - context = SerenaAgentContext.from_json_dict(context_param) if context_param is not None else None - modes = [SerenaAgentMode.from_json_dict(m) for m in params["modes"]] - log_level = params.get("log_level") - trace_lsp_communication = params.get("trace_lsp_communication") - tool_timeout = params.get("tool_timeout") - - self.agent = SerenaAgent( - project=project, - serena_config=serena_config, - context=context, - modes=modes, - enable_web_dashboard=False, - enable_gui_log_window=False, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, - ) - return {"result": "SerenaAgent initialized successfully"} - except Exception as e: - return {"error": f"Failed to initialize SerenaAgent: {e!s}", "traceback": traceback.format_exc()} - - def _tool_call(self, params: dict[str, Any]) -> dict[str, Any]: - """Execute a tool call.""" - if self.agent is None: - return {"error": "SerenaAgent not initialized"} - - try: - tool_name = params["tool_name"] - tool_params = params["tool_params"] - - # Get the tool by name - tool = None - for tool_instance in self.agent._active_tools.values(): - if tool_instance.get_name_from_cls() == tool_name: - tool = tool_instance - break - - if tool is None: - return {"error": f"Tool '{tool_name}' not found or not active"} - - # Execute the tool - result = tool.apply_ex(**tool_params) - - return {"result": result} - - except Exception as e: - return {"error": str(e), "traceback": traceback.format_exc()} - - def _get_active_tool_names(self) -> dict[str, Any]: - """Get list of active tool names.""" - if self.agent is None: - return {"error": "SerenaAgent not initialized"} - - try: - tool_names = self.agent.get_active_tool_names() - return {"result": tool_names} - except Exception as e: - return {"error": str(e), "traceback": traceback.format_exc()} - - def _is_language_server_running(self) -> dict[str, Any]: - """Check if language server is running.""" - if self.agent is None: - return {"error": "SerenaAgent not initialized"} - - try: - is_running = self.agent.is_language_server_running() - return {"result": is_running} - except Exception as e: - return {"error": str(e), "traceback": traceback.format_exc()} - - def _reset_language_server(self) -> dict[str, Any]: - """Reset the language server.""" - if self.agent is None: - return {"error": "SerenaAgent not initialized"} - - try: - self.agent.reset_language_server() - return {"result": "Language server reset successfully"} - except Exception as e: - return {"error": str(e), "traceback": traceback.format_exc()} - - def _get_exposed_tool_names(self) -> dict[str, Any]: - """Get exposed tool names for MCP tool creation.""" - if self.agent is None: - return {"error": "SerenaAgent not initialized"} - - try: - tool_instances = self.agent.get_exposed_tool_instances() - # Return only tool names - metadata will be reconstructed from ToolRegistry - tool_names = [tool.get_name_from_cls() for tool in tool_instances] - return {"result": tool_names} - except Exception as e: - return {"error": str(e), "traceback": traceback.format_exc()} - - def shutdown(self) -> dict[str, Any]: - try: - log.info("Shutting down SerenaAgent worker process on request") - self._cleanup() - request_global_shutdown() - # Return successful response before exiting - return {"result": "Shutdown initiated"} - except Exception as e: - log.error(f"Error during shutdown: {e}") - return {"error": str(e), "traceback": traceback.format_exc()} - - def _cleanup(self) -> None: - """Clean up resources.""" - if self.agent is not None: - try: - if self.agent.is_language_server_running() and self.agent.language_server is not None: - self.agent.language_server.stop() - except Exception as e: - log.error(f"Error stopping language server: {e}") - self.agent = None - - -class ProcessIsolatedSerenaAgent: - """Process-isolated wrapper for SerenaAgent that prevents asyncio contamination.""" - - def __init__( - self, - project: str | None = None, - serena_config: SerenaConfigBase | None = None, - context: SerenaAgentContext | None = None, - modes: list[SerenaAgentMode] | None = None, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, - ): - self.project = project - self.serena_config = serena_config or SerenaConfig.from_config_file() - self.context = context - self.modes = modes or [] - self.log_level = log_level - self.trace_lsp_communication = trace_lsp_communication - self.tool_timeout = tool_timeout - - self.process: multiprocessing.Process | None = None - self.conn: Connection | None = None - - def start(self) -> None: - """Start the worker process.""" - if self.process is not None: - raise RuntimeError("ProcessIsolatedSerenaAgent already started") - - log.info("Starting process-isolated SerenaAgent") - - # Create communication pipe - parent_conn, child_conn = multiprocessing.Pipe() - self.conn = parent_conn # type: ignore - - # Create and start worker process, passing along the dashboard's queue if available - worker = SerenaAgentWorker(child_conn) # type: ignore - self.process = multiprocessing.Process(target=worker.run, args=[_global_log_queue]) - self.process.start() - - # Prepare initialization parameters, converting complex objects to dict if present - init_params = { - "project": self.project, - "serena_config": self.serena_config.to_json_dict(), - "context": self.context.to_json_dict() if self.context is not None else None, - "modes": [m.to_json_dict() for m in self.modes], - "log_level": self.log_level, - "trace_lsp_communication": self.trace_lsp_communication, - "tool_timeout": self.tool_timeout, - } - # Initialize the agent in the worker process - try: - self._make_request_with_result(SerenaAgentWorker.RequestMethod.INITIALIZE, init_params) - except Exception as e: - self.stop() - raise RuntimeError(f"Failed to initialize SerenaAgent: {e}") from e - - log.info("Process-isolated SerenaAgent started successfully") - - def stop(self) -> None: - """Stop the worker process.""" - if self.process is None: - return - log.info("Stopping SerenaAgent process") - try: - # Close connection to signal worker to shutdown - if self.conn is not None: - self.conn.close() - _shutdown_process(self.process, timeout=2.0) - self.process = None - except KeyboardInterrupt: - log.warning("Keyboard interrupt during shutdown - forcing termination") - if self.process and self.process.is_alive(): - self.process.kill() - self.process.join(timeout=1.0) - except Exception as e: - log.error(f"Error stopping worker process: {e}") - finally: - self.process = None - self.conn = None - - log.info("SerenaAgent stopped") - - def _make_request(self, method: SerenaAgentWorker.RequestMethod, params: dict[str, Any] | None = None) -> dict[str, Any]: - """Make a request to the worker process.""" - if self.process is None or not self.process.is_alive(): - raise RuntimeError("Worker process is not running") - - if self.conn is None: - raise RuntimeError("Connection is not initialized") - - request = {"method": method, "params": params or {}} - - # Send request - try: - self.conn.send(request) - except (EOFError, BrokenPipeError) as e: - raise RuntimeError("Failed to send request: worker process may have crashed") from e - - # Wait for response with timeout - timeout = self.serena_config.tool_timeout - if self.conn.poll(timeout): - try: - return self.conn.recv() - except (EOFError, BrokenPipeError) as e: - raise RuntimeError("Failed to receive response: worker process may have crashed") from e - else: - raise TimeoutError(f"Request {method} timed out after {timeout} seconds") - - def _make_request_with_result(self, method: SerenaAgentWorker.RequestMethod, params: dict[str, Any] | None = None) -> Any: - """Make a request and return the result, raising an exception if there's an error.""" - response = self._make_request(method, params) - if "error" in response: - raise RuntimeError(f"Request {method} failed: {response['error']}") - return response["result"] - - def tool_call(self, tool_name: str, **tool_params: Any) -> str: - """Call a tool in the worker process.""" - return self._make_request_with_result( - SerenaAgentWorker.RequestMethod.TOOL_CALL, {"tool_name": tool_name, "tool_params": tool_params} - ) - - def get_tool(self, tool_cls: type[Tool]) -> "ProcessIsolatedTool": - """Get a process-isolated tool that delegates to this agent.""" - tool_name = tool_cls.get_name_from_cls() - return ProcessIsolatedTool(self, tool_name) - - def get_active_tool_names(self) -> list[str]: - """Get list of active tool names.""" - return self._make_request_with_result(SerenaAgentWorker.RequestMethod.GET_ACTIVE_TOOL_NAMES) - - def is_language_server_running(self) -> bool: - """Check if language server is running.""" - return self._make_request_with_result(SerenaAgentWorker.RequestMethod.IS_LANGUAGE_SERVER_RUNNING) - - def reset_language_server(self) -> None: - """Reset the language server.""" - self._make_request_with_result(SerenaAgentWorker.RequestMethod.RESET_LANGUAGE_SERVER) - - def shutdown_from_dashboard(self) -> None: - """Request shutdown from dashboard.""" - self._make_request_with_result(SerenaAgentWorker.RequestMethod.SHUTDOWN) - - def get_exposed_tool_names(self) -> list[str]: - """Get tool names for MCP tool creation.""" - return self._make_request_with_result(SerenaAgentWorker.RequestMethod.GET_EXPOSED_TOOL_NAMES) - - def __enter__(self) -> Self: - self.start() - return self - - def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - self.stop() - - -class ProcessIsolatedTool(ToolInterface): - """A clean tool wrapper that delegates to ProcessIsolatedSerenaAgent.""" - - def __init__(self, process_agent: ProcessIsolatedSerenaAgent, tool_name: str): - self.process_agent = process_agent - self._tool_name = tool_name - - @property - def _tool_class(self) -> type[Tool]: - return ToolRegistry.get_tool_class_by_name(self._tool_name) - - def get_name(self) -> str: - """Get the tool name for this process-isolated tool.""" - return self._tool_name - - def get_apply_docstring(self) -> str: - """Get the docstring for the apply method.""" - # in the actual tool, this is a classmethod - return self._tool_class.get_apply_docstring_from_cls() - - def get_apply_fn_metadata(self) -> FuncMetadata: - """Get the metadata for the apply method.""" - # in the actual tool, this is a classmethod - return self._tool_class.get_apply_fn_metadata_from_cls() - - def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs: Any) -> str: - """Apply the tool with logging and exception handling.""" - try: - return self.process_agent.tool_call(self._tool_name, **kwargs) - except Exception as e: - if catch_exceptions: - return f"Error executing tool {self._tool_name}: {e!s}" - raise diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index e6509877..0013f656 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -20,6 +20,12 @@ web_dashboard: True # shows Serena's current session logs - as an alternative to the GUI log window which # is supported on all platforms. +web_dashboard_open_on_launch: True +# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard +# is enabled). If set to False, you can still open the dashboard manually by navigating to +# http://localhost:24282/dashboard/ in your web browser. +# If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc. + log_level: 20 # the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 0f48bb54..4fa90677 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -189,8 +189,11 @@ class SymbolLocation: if self.relative_path is not None: self.relative_path = self.relative_path.replace("/", os.path.sep) - def to_dict(self) -> dict[str, Any]: - return asdict(self) + def to_dict(self, include_relative_path: bool = True) -> dict[str, Any]: + result = asdict(self) + if not include_relative_path: + result.pop("relative_path", None) + return result def has_position_in_file(self) -> bool: return self.relative_path is not None and self.line is not None and self.column is not None @@ -426,7 +429,13 @@ class Symbol(ToStringMixin): return result def to_dict( - self, kind: bool = False, location: bool = False, depth: int = 0, include_body: bool = False, include_children_body: bool = False + self, + kind: bool = False, + location: bool = False, + depth: int = 0, + include_body: bool = False, + include_children_body: bool = False, + include_relative_path=True, ) -> dict[str, Any]: """ Converts the symbol to a dictionary. @@ -439,6 +448,8 @@ class Symbol(ToStringMixin): Note that the body of the children is part of the body of the parent symbol, so there is usually no need to set this to True unless you want process the output and pass the children without passing the parent body to the LM. + :param include_relative_path: whether to include the relative path of the symbol in the location + entry. Relative paths of the symbol's children are always excluded. :return: a dictionary representation of the symbol """ result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()} @@ -447,7 +458,7 @@ class Symbol(ToStringMixin): result["kind"] = self.kind if location: - result["location"] = self.location.to_dict() + result["location"] = self.location.to_dict(include_relative_path=include_relative_path) body_start_line, body_end_line = self.get_body_line_numbers() result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} @@ -466,6 +477,8 @@ class Symbol(ToStringMixin): depth=depth - 1, include_body=include_children_body, include_children_body=include_children_body, + # all children have the same relative path as the parent + include_relative_path=False, ) ) return children diff --git a/src/serena/tools/__init__.py b/src/serena/tools/__init__.py new file mode 100644 index 00000000..4e05360c --- /dev/null +++ b/src/serena/tools/__init__.py @@ -0,0 +1,8 @@ +# ruff: noqa +from .tools_base import * +from .file_tools import * +from .ls_tools import * +from .memory_tools import * +from .cmd_tools import * +from .config_tools import * +from .workflow_tools import * diff --git a/src/serena/tools/cmd_tools.py b/src/serena/tools/cmd_tools.py new file mode 100644 index 00000000..0db0da08 --- /dev/null +++ b/src/serena/tools/cmd_tools.py @@ -0,0 +1,43 @@ +""" +Tools supporting the execution of (external) commands +""" + +from serena.tools import TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool, ToolMarkerCanEdit +from serena.util.shell import execute_shell_command + + +class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): + """ + Executes a shell command. + """ + + def apply( + self, + command: str, + cwd: str | None = None, + capture_stderr: bool = True, + max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH, + ) -> str: + """ + Execute a shell command and return its output. + + IMPORTANT: you should always consider the memory about suggested shell commands before using this tool. + If this memory was not loaded in the current conversation, you should load it using the `read_memory` tool + before using this tool. + + You should have at least once looked at the suggested shell commands from the corresponding memory + created during the onboarding process before using this tool. + Never execute unsafe shell commands like `rm -rf /` or similar! Generally be very careful with deletions. + + :param command: the shell command to execute + :param cwd: the working directory to execute the command in. If None, the project root will be used. + :param capture_stderr: whether to capture and return stderr output + :param max_answer_chars: if the output is longer than this number of characters, + no content will be returned. Don't adjust unless there is really no other way to get the content + required for the task. + :return: a JSON object containing the command's stdout and optionally stderr output + """ + _cwd = cwd or self.get_project_root() + result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr) + result = result.json() + return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py new file mode 100644 index 00000000..7445b2ba --- /dev/null +++ b/src/serena/tools/config_tools.py @@ -0,0 +1,89 @@ +import json + +from serena.config.context_mode import SerenaAgentMode +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject + + +class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Activates a project by name. + """ + + def apply(self, project: str) -> str: + """ + Activates the project with the given name. + + :param project: the name of a registered project to activate or a path to a project directory + """ + from ..config.serena_config import ProjectConfig + + active_project, new_project_generated, new_project_config_generated = self.agent.activate_project_from_path_or_name(project) + if new_project_generated: + result_str = ( + f"Created and activated a new project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}. " + + "You can activate this project later by name." + ) + else: + result_str = f"Activated existing project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}" + if new_project_config_generated: + result_str += ( + f"\nNote: A new project configuration was autogenerated because the given path did not contain a {ProjectConfig.SERENA_DEFAULT_PROJECT_FILE} file." + + f"You can now edit the project configuration in the file {active_project.path_to_project_yml()}. In particular, you may want to edit the project name and the initial prompt." + ) + + if active_project.project_config.initial_prompt: + result_str += f"\nAdditional project information:\n {active_project.project_config.initial_prompt}" + result_str += ( + f"\nAvailable memories:\n {json.dumps(list(self.memories_manager.list_memories()))}" + + "You should not read these memories directly, but rather use the `read_memory` tool to read them later if needed for the task." + ) + result_str += f"\nAvailable tools:\n {json.dumps(self.agent.get_active_tool_names())}" + return result_str + + +class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Removes a project from the Serena configuration. + """ + + def apply(self, project_name: str) -> str: + """ + Removes a project from the Serena configuration. + + :param project_name: Name of the project to remove + """ + self.agent.serena_config.remove_project(project_name) + return f"Successfully removed project '{project_name}' from configuration." + + +class SwitchModesTool(Tool): + """ + Activates modes by providing a list of their names + """ + + def apply(self, modes: list[str]) -> str: + """ + Activates the desired modes, like ["editing", "interactive"] or ["planning", "one-shot"] + + :param modes: the names of the modes to activate + """ + mode_instances = [SerenaAgentMode.load(mode) for mode in modes] + self.agent.set_modes(mode_instances) + + # Inform the Agent about the activated modes and the currently active tools + result_str = f"Successfully activated modes: {', '.join([mode.name for mode in mode_instances])}" + "\n" + result_str += "\n".join([mode_instance.prompt for mode_instance in mode_instances]) + "\n" + result_str += f"Currently active tools: {', '.join(self.agent.get_active_tool_names())}" + return result_str + + +class GetCurrentConfigTool(Tool): + """ + Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. + """ + + def apply(self) -> str: + """ + Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes. + """ + return self.agent.get_current_config_overview() diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py new file mode 100644 index 00000000..47c04476 --- /dev/null +++ b/src/serena/tools/file_tools.py @@ -0,0 +1,393 @@ +""" +File and file system-related tools, specifically for + * listing directory contents + * reading files + * creating files + * editing at the file level +""" + +import json +import os +import re +from collections import defaultdict +from fnmatch import fnmatch +from pathlib import Path + +from serena.text_utils import search_files +from serena.tools import SUCCESS_RESULT, TOOL_DEFAULT_MAX_ANSWER_LENGTH, EditedFileContext, Tool, ToolMarkerCanEdit +from serena.util.file_system import scan_directory + + +class ReadFileTool(Tool): + """ + Reads a file within the project directory. + """ + + def apply( + self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH + ) -> str: + """ + Reads the given file or a chunk of it. Generally, symbolic operations + like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for. + Reading the entire file is only recommended if there is no other way to get the content required for the task. + + :param relative_path: the relative path to the file to read + :param start_line: the 0-based index of the first line to be retrieved. + :param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file. + :param max_answer_chars: if the file (chunk) is longer than this number of characters, + no content will be returned. Don't adjust unless there is really no other way to get the content + required for the task. + :return: the full text of the file at the given relative path + """ + self.agent.validate_relative_path(relative_path) + + result = self.language_server.retrieve_full_file_content(relative_path) + result_lines = result.splitlines() + if end_line is None: + result_lines = result_lines[start_line:] + else: + self.lines_read.add_lines_read(relative_path, (start_line, end_line)) + result_lines = result_lines[start_line : end_line + 1] + result = "\n".join(result_lines) + + return self._limit_length(result, max_answer_chars) + + +class CreateTextFileTool(Tool, ToolMarkerCanEdit): + """ + Creates/overwrites a file in the project directory. + """ + + def apply(self, relative_path: str, content: str) -> str: + """ + Write a new file (or overwrite an existing file). For existing files, it is strongly recommended + to use symbolic operations like replace_symbol_body or insert_after_symbol/insert_before_symbol, if possible. + You can also use insert_at_line to insert content at a specific line for existing files if the symbolic operations + are not the right choice for what you want to do. + + If ever used on an existing file, the content has to be the complete content of that file (so it + may never end with something like "The remaining content of the file is left unchanged."). + For operations that just replace a part of a file, use the replace_lines or the symbolic editing tools instead. + + :param relative_path: the relative path to the file to create + :param content: the (utf-8-encoded) content to write to the file + :return: a message indicating success or failure + """ + self.agent.validate_relative_path(relative_path) + + abs_path = (Path(self.get_project_root()) / relative_path).resolve() + will_overwrite_existing = abs_path.exists() + + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_text(content, encoding="utf-8") + answer = f"File created: {relative_path}." + if will_overwrite_existing: + answer += " Overwrote existing file." + return answer + + +class ListDirTool(Tool): + """ + Lists files and directories in the given directory (optionally with recursion). + """ + + def apply(self, relative_path: str, recursive: bool, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str: + """ + Lists all non-gitignored files and directories in the given directory (optionally with recursion). + + :param relative_path: the relative path to the directory to list; pass "." to scan the project root + :param recursive: whether to scan subdirectories recursively + :param max_answer_chars: if the output is longer than this number of characters, + no content will be returned. Don't adjust unless there is really no other way to get the content + required for the task. + :return: a JSON object with the names of directories and files within the given directory + """ + self.agent.validate_relative_path(relative_path) + + dirs, files = scan_directory( + os.path.join(self.get_project_root(), relative_path), + relative_to=self.get_project_root(), + recursive=recursive, + is_ignored_dir=self.agent.path_is_gitignored, + is_ignored_file=self.agent.path_is_gitignored, + ) + + result = json.dumps({"dirs": dirs, "files": files}) + return self._limit_length(result, max_answer_chars) + + +class FindFileTool(Tool): + """ + Finds files in the given relative paths + """ + + def apply(self, file_mask: str, relative_path: str) -> str: + """ + Finds non-gitignored files matching the given file mask within the given relative path + + :param file_mask: the filename or file mask (using the wildcards * or ?) to search for + :param relative_path: the relative path to the directory to search in; pass "." to scan the project root + :return: a JSON object with the list of matching files + """ + self.agent.validate_relative_path(relative_path) + + dir_to_scan = os.path.join(self.get_project_root(), relative_path) + + # find the files by ignoring everything that doesn't match + def is_ignored_file(abs_path: str) -> bool: + if self.agent.path_is_gitignored(abs_path): + return True + filename = os.path.basename(abs_path) + return not fnmatch(filename, file_mask) + + dirs, files = scan_directory( + path=dir_to_scan, + recursive=True, + is_ignored_dir=self.agent.path_is_gitignored, + is_ignored_file=is_ignored_file, + relative_to=self.get_project_root(), + ) + + result = json.dumps({"files": files}) + return result + + +class ReplaceRegexTool(Tool, ToolMarkerCanEdit): + """ + Replaces content in a file by using regular expressions. + """ + + def apply( + self, + relative_path: str, + regex: str, + repl: str, + allow_multiple_occurrences: bool = False, + ) -> str: + r""" + Replaces one or more occurrences of the given regular expression. + This is the preferred way to replace content in a file whenever the symbol-level + tools are not appropriate. + Even large sections of code can be replaced by providing a concise regular expression of + the form "beginning.*?end-of-text-to-be-replaced". + Always try to use wildcards to avoid specifying the exact content of the code to be replaced, + especially if it spans several lines. + + IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD! + + :param relative_path: the relative path to the file + :param regex: a Python-style regular expression, matches of which will be replaced. + Dot matches all characters, multi-line matching is enabled. + :param repl: the string to replace the matched content with, which may contain + backreferences like \1, \2, etc. + :param allow_multiple_occurrences: if True, the regex may match multiple occurrences in the file + and all of them will be replaced. + If this is set to False and the regex matches multiple occurrences, an error will be returned + (and you may retry with a revised, more specific regex). + """ + self.agent.validate_relative_path(relative_path) + with EditedFileContext(relative_path, self.agent) as context: + original_content = context.get_original_content() + updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE) + if n == 0: + return f"Error: No matches found for regex '{regex}' in file '{relative_path}'." + if not allow_multiple_occurrences and n > 1: + return ( + f"Error: Regex '{regex}' matches {n} occurrences in file '{relative_path}'. " + "Please revise the regex to be more specific or enable allow_multiple_occurrences if this is expected." + ) + context.set_updated_content(updated_content) + return SUCCESS_RESULT + + +class DeleteLinesTool(Tool, ToolMarkerCanEdit): + """ + Deletes a range of lines within a file. + """ + + def apply( + self, + relative_path: str, + start_line: int, + end_line: int, + ) -> str: + """ + Deletes the given lines in the file. + Requires that the same range of lines was previously read using the `read_file` tool to verify correctness + of the operation. + + :param relative_path: the relative path to the file + :param start_line: the 0-based index of the first line to be deleted + :param end_line: the 0-based index of the last line to be deleted + """ + if not self.lines_read.were_lines_read(relative_path, (start_line, end_line)): + read_lines_tool = self.agent.get_tool(ReadFileTool) + return f"Error: Must call `{read_lines_tool.get_name_from_cls()}` first to read exactly the affected lines." + self.symbol_manager.delete_lines(relative_path, start_line, end_line) + return SUCCESS_RESULT + + +class ReplaceLinesTool(Tool, ToolMarkerCanEdit): + """ + Replaces a range of lines within a file with new content. + """ + + def apply( + self, + relative_path: str, + start_line: int, + end_line: int, + content: str, + ) -> str: + """ + Replaces the given range of lines in the given file. + Requires that the same range of lines was previously read using the `read_file` tool to verify correctness + of the operation. + + :param relative_path: the relative path to the file + :param start_line: the 0-based index of the first line to be deleted + :param end_line: the 0-based index of the last line to be deleted + :param content: the content to insert + """ + if not content.endswith("\n"): + content += "\n" + result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line) + if result != SUCCESS_RESULT: + return result + self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content) + return SUCCESS_RESULT + + +class InsertAtLineTool(Tool, ToolMarkerCanEdit): + """ + Inserts content at a given line in a file. + """ + + def apply( + self, + relative_path: str, + line: int, + content: str, + ) -> str: + """ + Inserts the given content at the given line in the file, pushing existing content of the line down. + In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which + symbol you are looking for. + However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body). + + :param relative_path: the relative path to the file + :param line: the 0-based index of the line to insert content at + :param content: the content to be inserted + """ + if not content.endswith("\n"): + content += "\n" + self.symbol_manager.insert_at_line(relative_path, line, content) + return SUCCESS_RESULT + + +class SearchForPatternTool(Tool): + """ + Performs a search for a pattern in the project. + """ + + def apply( + self, + substring_pattern: str, + context_lines_before: int = 0, + context_lines_after: int = 0, + paths_include_glob: str | None = None, + paths_exclude_glob: str | None = None, + relative_path: str = "", + restrict_search_to_code_files: bool = False, + max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH, + ) -> str: + """ + Offers a flexible search for arbitrary patterns in the codebase, including the + possibility to search in non-code files. + Generally, symbolic operations like find_symbol or find_referencing_symbols + should be preferred if you know which symbols you are looking for. + + Pattern Matching Logic: + For each match, the returned result will contain the full lines where the + substring pattern is found, as well as optionally some lines before and after it. The pattern will be compiled with + DOTALL, meaning that the dot will match all characters including newlines. + This also means that it never makes sense to have .* at the beginning or end of the pattern, + but it may make sense to have it in the middle for complex patterns. + If a pattern matches multiple lines, all those lines will be part of the match. + Be careful to not use greedy quantifiers unnecessarily, it is usually better to use non-greedy quantifiers like .*? to avoid + matching too much content. + + File Selection Logic: + The files in which the search is performed can be restricted very flexibly. + Using `restrict_search_to_code_files` is useful if you are only interested in code symbols (i.e., those + symbols that can be manipulated with symbolic tools like find_symbol). + You can also restrict the search to a specific file or directory, + and provide glob patterns to include or exclude certain files on top of that. + The globs are matched against relative file paths from the project root (not to the `relative_path` parameter that + is used to further restrict the search). + Smartly combining the various restrictions allows you to perform very targeted searches. + + + :param substring_pattern: Regular expression for a substring pattern to search for + :param context_lines_before: Number of lines of context to include before each match + :param context_lines_after: Number of lines of context to include after each match + :param paths_include_glob: optional glob pattern specifying files to include in the search. + Matches against relative file paths from the project root (e.g., "*.py", "src/**/*.ts"). + Only matches files, not directories. + :param paths_exclude_glob: optional glob pattern specifying files to exclude from the search. + Matches against relative file paths from the project root (e.g., "*test*", "**/*_generated.py"). + Takes precedence over paths_include_glob. Only matches files, not directories. + :param relative_path: only subpaths of this path (relative to the repo root) will be analyzed. If a path to a single + file is passed, only that will be searched. The path must exist, otherwise a `FileNotFoundError` is raised. + :param max_answer_chars: if the output is longer than this number of characters, + no content will be returned. Don't adjust unless there is really no other way to get the content + required for the task. Instead, if the output is too long, you should + make a stricter query. + :param restrict_search_to_code_files: whether to restrict the search to only those files where + analyzed code symbols can be found. Otherwise, will search all non-ignored files. + Set this to True if your search is only meant to discover code that can be manipulated with symbolic tools. + For example, for finding classes or methods from a name pattern. + Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files, + which is why it is the default. + :return: A JSON object mapping file paths to lists of matched consecutive lines (with context, if requested). + """ + abs_path = os.path.join(self.get_project_root(), relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError(f"Relative path {relative_path} does not exist.") + + if restrict_search_to_code_files: + matches = self.language_server.search_files_for_pattern( + pattern=substring_pattern, + relative_path=relative_path, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, + paths_include_glob=paths_include_glob, + paths_exclude_glob=paths_exclude_glob, + ) + else: + if os.path.isfile(abs_path): + rel_paths_to_search = [relative_path] + else: + dirs, rel_paths_to_search = scan_directory( + path=abs_path, + recursive=True, + is_ignored_dir=self.agent.path_is_gitignored, + is_ignored_file=self.agent.path_is_gitignored, + relative_to=self.get_project_root(), + ) + # TODO (maybe): not super efficient to walk through the files again and filter if glob patterns are provided + # but it probably never matters and this version required no further refactoring + matches = search_files( + rel_paths_to_search, + substring_pattern, + root_path=self.get_project_root(), + paths_include_glob=paths_include_glob, + paths_exclude_glob=paths_exclude_glob, + ) + # group matches by file + file_to_matches: dict[str, list[str]] = defaultdict(list) + for match in matches: + assert match.source_file_path is not None + file_to_matches[match.source_file_path].append(match.to_display_string()) + result = json.dumps(file_to_matches) + return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/ls_tools.py b/src/serena/tools/ls_tools.py new file mode 100644 index 00000000..f88bb7a6 --- /dev/null +++ b/src/serena/tools/ls_tools.py @@ -0,0 +1,279 @@ +""" +Language server-related tools +""" + +import json +from collections.abc import Sequence +from copy import copy +from typing import Any + +from serena.tools import SUCCESS_RESULT, TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool, ToolMarkerCanEdit +from solidlsp.ls_types import SymbolKind + + +def _sanitize_symbol_dict(symbol_dict: dict[str, Any]) -> dict[str, Any]: + """ + Sanitize a symbol dictionary inplace by removing unnecessary information. + """ + # We replace the location entry, which repeats line information already included in body_location + # and has unnecessary information on column, by just the relative path. + symbol_dict = copy(symbol_dict) + s_relative_path = symbol_dict.get("location", {}).get("relative_path") + if s_relative_path is not None: + symbol_dict["relative_path"] = s_relative_path + symbol_dict.pop("location", None) + # also remove name, name_path should be enough + symbol_dict.pop("name") + return symbol_dict + + +class RestartLanguageServerTool(Tool): + """Restarts the language server, may be necessary when edits not through Serena happen.""" + + def apply(self) -> str: + """Use this tool only on explicit user request or after confirmation. + It may be necessary to restart the language server if the user performs edits + not through Serena, so the language server state becomes outdated and further editing attempts lead to errors. + + If such editing errors happen, you should suggest using this tool. + """ + self.agent.reset_language_server() + return SUCCESS_RESULT + + +class GetSymbolsOverviewTool(Tool): + """ + Gets an overview of the top-level symbols defined in a given file or directory. + """ + + def apply(self, relative_path: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str: + """ + Gets an overview of the given file or directory. + For each analyzed file, we list the top-level symbols in the file (name_path, kind). + Use this tool to get a high-level understanding of the code symbols. + Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols. + + :param relative_path: the relative path to the file or directory to get the overview of + :param max_answer_chars: if the overview is longer than this number of characters, + no content will be returned. Don't adjust unless there is really no other way to get the content + required for the task. If the overview is too long, you should use a smaller directory instead, + (e.g. a subdirectory). + :return: a JSON object mapping relative paths of all contained files to info about top-level symbols in the file (name_path, kind). + """ + path_to_symbol_infos = self.language_server.request_overview(relative_path) + result = {} + for file_path, symbols in path_to_symbol_infos.items(): + # TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables + # The language server methods would need to be adjusted for this. + result[file_path] = [{"name_path": symbol[0], "kind": int(symbol[1])} for symbol in symbols] + + result_json_str = json.dumps(result) + return self._limit_length(result_json_str, max_answer_chars) + + +class FindSymbolTool(Tool): + """ + Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). + """ + + def apply( + self, + name_path: str, + depth: int = 0, + relative_path: str | None = None, + include_body: bool = False, + include_kinds: list[int] | None = None, + exclude_kinds: list[int] | None = None, + substring_matching: bool = False, + max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH, + ) -> str: + """ + Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given `name_path`, + which represents a pattern for the symbol's path within the symbol tree of a single file. + The returned symbol location can be used for edits or further queries. + Specify `depth > 0` to retrieve children (e.g., methods of a class). + + The matching behavior is determined by the structure of `name_path`, which can + either be a simple name (e.g. "method") or a name path like "class/method" (relative name path) + or "/class/method" (absolute name path). Note that the name path is not a path in the file system + but rather a path in the symbol tree **within a single file**. Thus, file or directory names should never + be included in the `name_path`. For restricting the search to a single file or directory, + the `within_relative_path` parameter should be used instead. The retrieved symbols' `name_path` attribute + will always be composed of symbol names, never file or directory names. + + Key aspects of the name path matching behavior: + - Trailing slashes in `name_path` play no role and are ignored. + - The name of the retrieved symbols will match (either exactly or as a substring) + the last segment of `name_path`, while other segments will restrict the search to symbols that + have a desired sequence of ancestors. + - If there is no starting or intermediate slash in `name_path`, there is no + restriction on the ancestor symbols. For example, passing `method` will match + against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc. + - If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols + with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against + `class/method` as well as `nested_class/class/method` but not `method`. + - If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning + that the first segment of it must match the first segment of the symbol's name path. + For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`. + Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`. + + + :param name_path: The name path pattern to search for, see above for details. + :param depth: Depth to retrieve descendants (e.g., 1 for class methods/attributes). + :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. + If a directory is passed, the search will be restricted to the files in that directory. + If a file is passed, the search will be restricted to that file. + If you have some knowledge about the codebase, you should use this parameter, as it will significantly + speed up the search as well as reduce the number of results. + :param include_body: If True, include the symbol's source code. Use judiciously. + :param include_kinds: Optional. List of LSP symbol kind integers to include. (e.g., 5 for Class, 12 for Function). + Valid kinds: 1=file, 2=module, 3=namespace, 4=package, 5=class, 6=method, 7=property, 8=field, 9=constructor, 10=enum, + 11=interface, 12=function, 13=variable, 14=constant, 15=string, 16=number, 17=boolean, 18=array, 19=object, + 20=key, 21=null, 22=enum member, 23=struct, 24=event, 25=operator, 26=type parameter + :param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`. + :param substring_matching: If True, use substring matching for the last segment of `name`. + :param max_answer_chars: Max characters for the JSON result. If exceeded, no content is returned. + :return: JSON string: a list of symbols (with locations) matching the name. + """ + parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None + parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None + symbols = self.symbol_manager.find_by_name( + name_path, + include_body=include_body, + include_kinds=parsed_include_kinds, + exclude_kinds=parsed_exclude_kinds, + substring_matching=substring_matching, + within_relative_path=relative_path, + ) + symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols] + result = json.dumps(symbol_dicts) + return self._limit_length(result, max_answer_chars) + + +class FindReferencingSymbolsTool(Tool): + """ + Finds symbols that reference the symbol at the given location (optionally filtered by type). + """ + + def apply( + self, + name_path: str, + relative_path: str, + include_kinds: list[int] | None = None, + exclude_kinds: list[int] | None = None, + max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH, + ) -> str: + """ + Finds symbols that reference the symbol at the given `name_path`. The result will contain metadata about the referencing symbols + as well as a short code snippet around the reference (unless `include_body` is True, then the short snippet will be omitted). + Note that among other kinds of references, this function can be used to find (direct) subclasses of a class, + as subclasses are referencing symbols that have the kind class. + + :param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool. + :param relative_path: the relative path to the file containing the symbol for which to find references. + Note that here you can't pass a directory but must pass a file. + :param include_kinds: same as in the `find_symbol` tool. + :param exclude_kinds: same as in the `find_symbol` tool. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a list of JSON objects with the symbols referencing the requested symbol + """ + include_body = False # It is probably never a good idea to include the body of the referencing symbols + parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None + parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None + references_in_symbols = self.symbol_manager.find_referencing_symbols( + name_path, + relative_file_path=relative_path, + include_body=include_body, + include_kinds=parsed_include_kinds, + exclude_kinds=parsed_exclude_kinds, + ) + reference_dicts = [] + for ref in references_in_symbols: + ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body) + ref_dict = _sanitize_symbol_dict(ref_dict) + if not include_body: + ref_relative_path = ref.symbol.location.relative_path + assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug." + content_around_ref = self.language_server.retrieve_content_around_line( + relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1 + ) + ref_dict["content_around_reference"] = content_around_ref.to_display_string() + reference_dicts.append(ref_dict) + result = json.dumps(reference_dicts) + return self._limit_length(result, max_answer_chars) + + +class ReplaceSymbolBodyTool(Tool, ToolMarkerCanEdit): + """ + Replaces the full definition of a symbol. + """ + + def apply( + self, + name_path: str, + relative_path: str, + body: str, + ) -> str: + r""" + Replaces the body of the symbol with the given `name_path`. + + :param name_path: for finding the symbol to replace, same logic as in the `find_symbol` tool. + :param relative_path: the relative path to the file containing the symbol + :param body: the new symbol body. Important: Begin directly with the symbol definition and provide no + leading indentation for the first line (but do indent the rest of the body according to the context). + """ + self.symbol_manager.replace_body( + name_path, + relative_file_path=relative_path, + body=body, + use_same_indentation=False, + ) + return SUCCESS_RESULT + + +class InsertAfterSymbolTool(Tool, ToolMarkerCanEdit): + """ + Inserts content after the end of the definition of a given symbol. + """ + + def apply( + self, + name_path: str, + relative_path: str, + body: str, + ) -> str: + """ + Inserts the given body/content after the end of the definition of the given symbol (via the symbol's location). + A typical use case is to insert a new class, function, method, field or variable assignment. + + :param name_path: name path of the symbol after which to insert content (definitions in the `find_symbol` tool apply) + :param relative_path: the relative path to the file containing the symbol + :param body: the body/content to be inserted. The inserted code shall begin with the next line after + the symbol. + """ + self.symbol_manager.insert_after_symbol(name_path, relative_file_path=relative_path, body=body, use_same_indentation=False) + return SUCCESS_RESULT + + +class InsertBeforeSymbolTool(Tool, ToolMarkerCanEdit): + """ + Inserts content before the beginning of the definition of a given symbol. + """ + + def apply( + self, + name_path: str, + relative_path: str, + body: str, + ) -> str: + """ + Inserts the given body/content before the beginning of the definition of the given symbol (via the symbol's location). + A typical use case is to insert a new class, function, method, field or variable assignment. + It also can be used to insert a new import statement before the first symbol in the file. + + :param name_path: name path of the symbol before which to insert content (definitions in the `find_symbol` tool apply) + :param relative_path: the relative path to the file containing the symbol + :param body: the body/content to be inserted before the line in which the referenced symbol is defined + """ + self.symbol_manager.insert_before_symbol(name_path, relative_file_path=relative_path, body=body, use_same_indentation=False) + return SUCCESS_RESULT diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py new file mode 100644 index 00000000..63bba4fe --- /dev/null +++ b/src/serena/tools/memory_tools.py @@ -0,0 +1,69 @@ +import json + +from serena.tools import TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool + + +class WriteMemoryTool(Tool): + """ + Writes a named memory (for future reference) to Serena's project-specific memory store. + """ + + def apply(self, memory_name: str, content: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str: + """ + Write some information about this project that can be useful for future tasks to a memory. + Use markdown formatting for the content. + The information should be short and to the point. + The memory name should be meaningful, such that from the name you can infer what the information is about. + It is better to have multiple small memories than to have a single large one because + memories will be read one by one and we only ever want to read relevant memories. + + This tool is either called during the onboarding process or when you have identified + something worth remembering about the project from the past conversation. + """ + if len(content) > max_answer_chars: + raise ValueError( + f"Content for {memory_name} is too long. Max length is {max_answer_chars} characters. " + "Please make the content shorter." + ) + + return self.memories_manager.save_memory(memory_name, content) + + +class ReadMemoryTool(Tool): + """ + Reads the memory with the given name from Serena's project-specific memory store. + """ + + def apply(self, memory_file_name: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str: + """ + Read the content of a memory file. This tool should only be used if the information + is relevant to the current task. You can infer whether the information + is relevant from the memory file name. + You should not read the same memory file multiple times in the same conversation. + """ + return self.memories_manager.load_memory(memory_file_name) + + +class ListMemoriesTool(Tool): + """ + Lists memories in Serena's project-specific memory store. + """ + + def apply(self) -> str: + """ + List available memories. Any memory can be read using the `read_memory` tool. + """ + return json.dumps(self.memories_manager.list_memories()) + + +class DeleteMemoryTool(Tool): + """ + Deletes a memory from Serena's project-specific memory store. + """ + + def apply(self, memory_file_name: str) -> str: + """ + Delete a memory file. Should only happen if a user asks for it explicitly, + for example by saying that the information retrieved from a memory file is no longer correct + or no longer relevant for the project. + """ + return self.memories_manager.delete_memory(memory_file_name) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py new file mode 100644 index 00000000..b0a3cd73 --- /dev/null +++ b/src/serena/tools/tools_base.py @@ -0,0 +1,372 @@ +import inspect +import os +import traceback +from abc import ABC, abstractmethod +from collections.abc import Callable, Generator, Iterable +from copy import copy +from types import TracebackType +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata +from sensai.util import logging +from sensai.util.string import dict_string + +from serena.prompt_factory import PromptFactory +from serena.symbol import SymbolManager +from serena.util.inspection import iter_subclasses +from solidlsp import SolidLanguageServer + +if TYPE_CHECKING: + from serena.agent import LinesRead, MemoriesManager, SerenaAgent + +log = logging.getLogger(__name__) +T = TypeVar("T") +SUCCESS_RESULT = "OK" + + +class Component(ABC): + def __init__(self, agent: "SerenaAgent"): + self.agent = agent + + @property + def language_server(self) -> SolidLanguageServer: + assert self.agent.language_server is not None + return self.agent.language_server + + def get_project_root(self) -> str: + """ + :return: the root directory of the active project, raises a ValueError if no active project configuration is set + """ + return self.agent.get_project_root() + + @property + def prompt_factory(self) -> PromptFactory: + return self.agent.prompt_factory + + @property + def memories_manager(self) -> "MemoriesManager": + assert self.agent.memories_manager is not None + return self.agent.memories_manager + + @property + def symbol_manager(self) -> SymbolManager: + assert self.agent.symbol_manager is not None + return self.agent.symbol_manager + + @property + def lines_read(self) -> "LinesRead": + assert self.agent.lines_read is not None + return self.agent.lines_read + + +TOOL_DEFAULT_MAX_ANSWER_LENGTH = int(2e5) + + +class ToolMarkerCanEdit: + """ + Marker class for all tools that can perform editing operations on files. + """ + + +class ToolMarkerDoesNotRequireActiveProject: + pass + + +class ToolInterface(ABC): + """Protocol defining the complete interface that make_tool() expects from a tool.""" + + @abstractmethod + def get_name(self) -> str: + """Get the tool name.""" + ... + + @abstractmethod + def get_apply_docstring(self) -> str: + """Get the docstring for the tool application, used by the MCP server.""" + ... + + @abstractmethod + def get_apply_fn_metadata(self) -> FuncMetadata: + """Get the metadata for the tool application function, used by the MCP server.""" + ... + + @abstractmethod + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs: Any) -> str: + """Apply the tool with logging and exception handling.""" + ... + + +class Tool(Component, ToolInterface): + # NOTE: each tool should implement the apply method, which is then used in + # the central method of the Tool class `apply_ex`. + # Failure to do so will result in a RuntimeError at tool execution time. + # The apply method is not declared as part of the base Tool interface since we cannot + # know the signature of the (input parameters of the) method in advance. + # + # The docstring and types of the apply method are used to generate the tool description + # (which is use by the LLM, so a good description is important) + # and to validate the tool call arguments. + + @classmethod + def get_name_from_cls(cls) -> str: + name = cls.__name__ + if name.endswith("Tool"): + name = name[:-4] + # convert to snake_case + name = "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") + return name + + def get_name(self) -> str: + return self.get_name_from_cls() + + def get_apply_fn(self) -> Callable: + apply_fn = getattr(self, "apply") + if apply_fn is None: + raise RuntimeError(f"apply not defined in {self}. Did you forget to implement it?") + return apply_fn + + @classmethod + def can_edit(cls) -> bool: + """ + Returns whether this tool can perform editing operations on code. + + :return: True if the tool can edit code, False otherwise + """ + return issubclass(cls, ToolMarkerCanEdit) + + @classmethod + def get_tool_description(cls) -> str: + docstring = cls.__doc__ + if docstring is None: + return "" + return docstring.strip() + + @classmethod + def get_apply_docstring_from_cls(cls) -> str: + """Get the docstring for the apply method from the class (static metadata). + Needed for creating MCP tools in a separate process without running into serialization issues. + """ + # First try to get from __dict__ to handle dynamic docstring changes + if "apply" in cls.__dict__: + apply_fn = cls.__dict__["apply"] + else: + # Fall back to getattr for inherited methods + apply_fn = getattr(cls, "apply", None) + if apply_fn is None: + raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") + + docstring = apply_fn.__doc__ + if not docstring: + raise AttributeError(f"apply method has no (or empty) docstring in {cls}. Did you forget to implement it?") + return docstring.strip() + + def get_apply_docstring(self) -> str: + """Get the docstring for the apply method (instance method implementing ToolProtocol).""" + return self.get_apply_docstring_from_cls() + + def get_apply_fn_metadata(self) -> FuncMetadata: + """Get the metadata for the apply method (instance method implementing ToolProtocol).""" + return self.get_apply_fn_metadata_from_cls() + + @classmethod + def get_apply_fn_metadata_from_cls(cls) -> FuncMetadata: + """Get the metadata for the apply method from the class (static metadata). + Needed for creating MCP tools in a separate process without running into serialization issues. + """ + # First try to get from __dict__ to handle dynamic docstring changes + if "apply" in cls.__dict__: + apply_fn = cls.__dict__["apply"] + else: + # Fall back to getattr for inherited methods + apply_fn = getattr(cls, "apply", None) + if apply_fn is None: + raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") + + return func_metadata(apply_fn, skip_names=["self", "cls"]) + + def _log_tool_application(self, frame: Any) -> None: + params = {} + ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"} + for param, value in frame.f_locals.items(): + if param in ignored_params: + continue + if param == "kwargs": + params.update(value) + else: + params[param] = value + log.info(f"{self.get_name_from_cls()}: {dict_string(params)}") + + @staticmethod + def _limit_length(result: str, max_answer_chars: int) -> str: + if (n_chars := len(result)) > max_answer_chars: + result = ( + f"The answer is too long ({n_chars} characters). " + + "Please try a more specific tool query or raise the max_answer_chars parameter." + ) + return result + + def is_active(self) -> bool: + return self.agent.tool_is_active(self.__class__) + + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs) -> str: # type: ignore + """ + Applies the tool with the given arguments + """ + + def task() -> str: + apply_fn = self.get_apply_fn() + + try: + if not self.is_active(): + return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" + except Exception as e: + return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}" + + if log_call: + self._log_tool_application(inspect.currentframe()) + try: + # check whether the tool requires an active project and language server + if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): + if self.agent._active_project is None: + return ( + "Error: No active project. Ask to user to select a project from this list: " + + f"{self.agent.serena_config.project_names}" + ) + if not self.agent.is_language_server_running(): + log.info("Language server is not running. Starting it ...") + self.agent.reset_language_server() + + # apply the actual tool + result = apply_fn(**kwargs) + + except Exception as e: + if not catch_exceptions: + raise + msg = f"Error executing tool: {e}\n{traceback.format_exc()}" + log.error( + f"Error executing tool: {e}. " + f"Consider restarting the language server to solve this (especially, if it's a timeout of a symbolic operation)", + exc_info=e, + ) + result = msg + + if log_call: + log.info(f"Result: {result}") + + try: + self.language_server.save_cache() + except Exception as e: + log.error(f"Error saving language server cache: {e}") + + return result + + future = self.agent.issue_task(task, name=self.__class__.__name__) + return future.result(timeout=self.agent.serena_config.tool_timeout) + + +class EditedFileContext: + """ + Context manager for file editing. + + Create the context, then use `set_updated_content` to set the new content, the original content + being provided in `original_content`. + When exiting the context without an exception, the updated content will be written back to the file. + """ + + def __init__(self, relative_path: str, agent: "SerenaAgent"): + self._project = agent.get_active_project() + assert self._project is not None + self._abs_path = os.path.join(self._project.project_root, relative_path) + if not os.path.isfile(self._abs_path): + raise FileNotFoundError(f"File {self._abs_path} does not exist.") + with open(self._abs_path, encoding=self._project.project_config.encoding) as f: + self._original_content = f.read() + self._updated_content: str | None = None + + def __enter__(self) -> Self: + return self + + def get_original_content(self) -> str: + """ + :return: the original content of the file before any modifications. + """ + return self._original_content + + def set_updated_content(self, content: str) -> None: + """ + Sets the updated content of the file, which will be written back to the file + when the context is exited without an exception. + + :param content: the updated content of the file + """ + self._updated_content = content + + def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: + if self._updated_content is not None and exc_type is None: + assert self._project is not None + with open(self._abs_path, "w", encoding=self._project.project_config.encoding) as f: + f.write(self._updated_content) + log.info(f"Updated content written to {self._abs_path}") + # Language servers should automatically detect the change and update its state accordingly. + # If they do not, we may have to add a call to notify it. + + +class ToolRegistry: + _tool_dict: dict[str, type[Tool]] | None = None + """maps tool name to the corresponding tool class""" + + @staticmethod + def _iter_tool_classes() -> Generator[type[Tool], None, None]: + """ + Iterate over Tool subclasses. + """ + for cls in iter_subclasses(Tool): + if cls.__module__.startswith("serena.tools"): + yield cls + + @classmethod + def _get_tool_dict(cls) -> dict[str, type[Tool]]: + if cls._tool_dict is None: + cls._tool_dict = {} + for tool_class in cls._iter_tool_classes(): + name = tool_class.get_name_from_cls() + if name in cls._tool_dict: + raise ValueError(f"Duplicate tool name found: {name}. Tool classes must have unique names.") + cls._tool_dict[name] = tool_class + return cls._tool_dict + + @classmethod + def get_tool_class_by_name(cls, tool_name: str) -> type[Tool]: + try: + return cls._get_tool_dict()[tool_name] + except KeyError as e: + available_tools = "\n".join(ToolRegistry.get_tool_names()) + raise ValueError(f"Tool with name {tool_name} not found. Available tools:\n{available_tools}") from e + + @classmethod + def get_all_tool_classes(cls) -> list[type[Tool]]: + return list(cls._get_tool_dict().values()) + + @classmethod + def get_tool_names(cls) -> list[str]: + return list(cls._get_tool_dict().keys()) + + @classmethod + def tool_dict(cls) -> dict[str, type[Tool]]: + """Maps tool name to the corresponding tool class""" + return copy(cls._get_tool_dict()) + + @classmethod + def print_tool_overview(cls, tools: Iterable[type[Tool] | Tool] | None = None) -> None: + """ + Print a summary of the tools. If no tools are passed, a summary of all tools is printed. + """ + if tools is None: + tools = cls._get_tool_dict().values() + + tool_dict: dict[str, type[Tool] | Tool] = {} + for tool_class in tools: + tool_dict[tool_class.get_name_from_cls()] = tool_class + for tool_name in sorted(tool_dict.keys()): + tool_class = tool_dict[tool_name] + print(f" * `{tool_name}`: {tool_class.get_tool_description().strip()}") diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py new file mode 100644 index 00000000..2c09a202 --- /dev/null +++ b/src/serena/tools/workflow_tools.py @@ -0,0 +1,139 @@ +""" +Tools supporting the general workflow of the agent +""" + +import json +import platform + +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject + + +class CheckOnboardingPerformedTool(Tool): + """ + Checks whether project onboarding was already performed. + """ + + def apply(self) -> str: + """ + Checks whether project onboarding was already performed. + You should always call this tool before beginning to actually work on the project/after activating a project, + but after calling the initial instructions tool. + """ + from .memory_tools import ListMemoriesTool + + list_memories_tool = self.agent.get_tool(ListMemoriesTool) + memories = json.loads(list_memories_tool.apply()) + if len(memories) == 0: + return ( + "Onboarding not performed yet (no memories available). " + + "You should perform onboarding by calling the `onboarding` tool before proceeding with the task." + ) + else: + return f"""The onboarding was already performed, below is the list of available memories. + Do not read them immediately, just remember that they exist and that you can read them later, if it is necessary + for the current task. + Some memories may be based on previous conversations, others may be general for the current project. + You should be able to tell which one you need based on the name of the memory. + + {memories}""" + + +class OnboardingTool(Tool): + """ + Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). + """ + + def apply(self) -> str: + """ + Call this tool if onboarding was not performed yet. + You will call this tool at most once per conversation. + + :return: instructions on how to create the onboarding information + """ + system = platform.system() + return self.prompt_factory.create_onboarding_prompt(system=system) + + +class ThinkAboutCollectedInformationTool(Tool): + """ + Thinking tool for pondering the completeness of collected information. + """ + + def apply(self) -> str: + """ + Think about the collected information and whether it is sufficient and relevant. + This tool should ALWAYS be called after you have completed a non-trivial sequence of searching steps like + find_symbol, find_referencing_symbols, search_files_for_pattern, read_file, etc. + """ + return self.prompt_factory.create_think_about_collected_information() + + +class ThinkAboutTaskAdherenceTool(Tool): + """ + Thinking tool for determining whether the agent is still on track with the current task. + """ + + def apply(self) -> str: + """ + Think about the task at hand and whether you are still on track. + Especially important if the conversation has been going on for a while and there + has been a lot of back and forth. + + This tool should ALWAYS be called before you insert, replace, or delete code. + """ + return self.prompt_factory.create_think_about_task_adherence() + + +class ThinkAboutWhetherYouAreDoneTool(Tool): + """ + Thinking tool for determining whether the task is truly completed. + """ + + def apply(self) -> str: + """ + Whenever you feel that you are done with what the user has asked for, it is important to call this tool. + """ + return self.prompt_factory.create_think_about_whether_you_are_done() + + +class SummarizeChangesTool(Tool): + """ + Provides instructions for summarizing the changes made to the codebase. + """ + + def apply(self) -> str: + """ + Summarize the changes you have made to the codebase. + This tool should always be called after you have fully completed any non-trivial coding task, + but only after the think_about_whether_you_are_done call. + """ + return self.prompt_factory.create_summarize_changes() + + +class PrepareForNewConversationTool(Tool): + """ + Provides instructions for preparing for a new conversation (in order to continue with the necessary context). + """ + + def apply(self) -> str: + """ + Instructions for preparing for a new conversation. This tool should only be called on explicit user request. + """ + return self.prompt_factory.create_prepare_for_new_conversation() + + +class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Gets the initial instructions for the current project. + Should only be used in settings where the system prompt cannot be set, + e.g. in clients you have no control over, like Claude Desktop. + """ + + def apply(self) -> str: + """ + Get the initial instructions for the current coding project. + You should always call this tool before starting to work (including using any other tool) on any programming task! + The only exception is when a user asks you to activate a project, in which case you should call the `activate_project` first + instead and then call this tool. + """ + return self.agent.create_system_prompt() diff --git a/src/serena/util/exception.py b/src/serena/util/exception.py new file mode 100644 index 00000000..8ab2f007 --- /dev/null +++ b/src/serena/util/exception.py @@ -0,0 +1,65 @@ +import os +import sys + +from serena.agent import log + + +def is_headless_environment() -> bool: + """ + Detect if we're running in a headless environment where GUI operations would fail. + + Returns True if: + - No DISPLAY variable on Linux/Unix + - Running in SSH session + - Running in WSL without X server + - Running in Docker container + """ + # Check if we're on Windows - GUI usually works there + if sys.platform == "win32": + return False + + # Check for DISPLAY variable (required for X11) + if not os.environ.get("DISPLAY"): + return True + + # Check for SSH session + if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"): + return True + + # Check for common CI/container environments + if os.environ.get("CI") or os.environ.get("CONTAINER") or os.path.exists("/.dockerenv"): + return True + + # Check for WSL (only on Unix-like systems where os.uname exists) + if hasattr(os, "uname"): + if "microsoft" in os.uname().release.lower(): + # In WSL, even with DISPLAY set, X server might not be running + # This is a simplified check - could be improved + return True + + return False + + +def show_fatal_exception_safe(e: Exception) -> None: + """ + Shows the given exception in the GUI log viewer on the main thread and ensures that the exception is logged or at + least printed to stderr. + """ + # Log the error and print it to stderr + log.error(f"Fatal exception: {e}", exc_info=e) + print(f"Fatal exception: {e}", file=sys.stderr) + + # Don't attempt GUI in headless environments + if is_headless_environment(): + log.debug("Skipping GUI error display in headless environment") + return + + # attempt to show the error in the GUI + try: + # NOTE: The import can fail on macOS if Tk is not available (depends on Python interpreter installation, which uv + # used as a base); while tkinter as such is always available, its dependencies can be unavailable on macOS. + from serena.gui_log_viewer import show_fatal_exception + + show_fatal_exception(e) + except Exception as gui_error: + log.debug(f"Failed to show GUI error dialog: {gui_error}") diff --git a/src/solidlsp/.gitignore b/src/solidlsp/.gitignore new file mode 100644 index 00000000..c6d33567 --- /dev/null +++ b/src/solidlsp/.gitignore @@ -0,0 +1 @@ +language_servers/static \ No newline at end of file diff --git a/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py similarity index 64% rename from src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py rename to src/solidlsp/language_servers/clangd_language_server.py index 893c5cc7..b8845d84 100644 --- a/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -2,7 +2,6 @@ Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. """ -import json import logging import os import pathlib @@ -12,10 +11,11 @@ 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 +from .common import RuntimeDependency, RuntimeDependencyCollection + class ClangdLanguageServer(SolidLanguageServer): """ @@ -28,7 +28,7 @@ class ClangdLanguageServer(SolidLanguageServer): """ Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - clangd_executable_path = self.setup_runtime_dependencies(logger, config) + clangd_executable_path = self._setup_runtime_dependencies(logger, config) super().__init__( config, logger, @@ -41,42 +41,49 @@ class ClangdLanguageServer(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() - def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + @staticmethod + def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig) -> str: """ - Setup runtime dependencies for ClangdLanguageServer. + Setup runtime dependencies for ClangdLanguageServer and return the command to start the server. """ - platform_id = PlatformUtils.get_platform_id() - - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json")) as f: - d = json.load(f) - del d["_description"] - - assert platform_id.value in [ - "linux-x64", - "win-x64", - "osx-arm64", - ], ( - "Unsupported platform: " + platform_id.value + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="Clangd", + description="Clangd for Linux (x64)", + url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-linux-19.1.2.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="clangd_19.1.2/bin/clangd", + ), + RuntimeDependency( + id="Clangd", + description="Clangd for Windows (x64)", + url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-windows-19.1.2.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="clangd_19.1.2/bin/clangd.exe", + ), + RuntimeDependency( + id="Clangd", + description="Clangd for macOS (Arm64)", + url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-mac-19.1.2.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="clangd_19.1.2/bin/clangd", + ), + ] ) - runtime_dependencies = d["runtimeDependencies"] - runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] - assert len(runtime_dependencies) == 1 - # Select dependency matching the current platform - dependency = next((dep for dep in runtime_dependencies if dep["platformId"] == platform_id.value), None) - if dependency is None: - raise RuntimeError(f"No runtime dependency found for platform {platform_id.value}") - clangd_ls_dir = os.path.join(os.path.dirname(__file__), "static", "clangd") - clangd_executable_path = os.path.join(clangd_ls_dir, "clangd_19.1.2", "bin", dependency["binaryName"]) + dep = deps.single_for_current_platform() + clangd_executable_path = deps.binary_path(clangd_ls_dir) if not os.path.exists(clangd_executable_path): - clangd_url = dependency["url"] - logger.log(f"Clangd executable not found at {clangd_executable_path}. Downloading from {clangd_url}", logging.INFO) - os.makedirs(clangd_ls_dir, exist_ok=True) - if dependency["archiveType"] == "zip": - FileUtils.download_and_extract_archive(logger, clangd_url, clangd_ls_dir, dependency["archiveType"]) - else: - raise RuntimeError(f"Unsupported archive type: {dependency['archiveType']}") + logger.log( + f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}", + logging.INFO, + ) + deps.install(logger, clangd_ls_dir) if not os.path.exists(clangd_executable_path): raise FileNotFoundError( f"Clangd executable not found at {clangd_executable_path}.\n" @@ -86,29 +93,34 @@ class ClangdLanguageServer(SolidLanguageServer): return clangd_executable_path - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the clangd Language Server. """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json")) as f: - d = json.load(f) + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": "$name", + } + ], + } - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d + return initialize_params def _start_server(self): """ diff --git a/src/solidlsp/language_servers/clangd_language_server/initialize_params.json b/src/solidlsp/language_servers/clangd_language_server/initialize_params.json deleted file mode 100644 index 4330560e..00000000 --- a/src/solidlsp/language_servers/clangd_language_server/initialize_params.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "locale": "en", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "capabilities": { - "textDocument": { - "synchronization": { - "didSave": true, - "dynamicRegistration": true - }, - "completion": { - "dynamicRegistration": true, - "completionItem": { - "snippetSupport": true - } - }, - "definition": { - "dynamicRegistration": true - } - }, - "workspace": { - "workspaceFolders": true, - "didChangeConfiguration": { - "dynamicRegistration": true - } - } - }, - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/clangd_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/clangd_language_server/runtime_dependencies.json deleted file mode 100644 index f96239db..00000000 --- a/src/solidlsp/language_servers/clangd_language_server/runtime_dependencies.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "_description": "Used to download the runtime dependencies for running Clangd.", - "runtimeDependencies": [ - { - "id": "Clangd", - "description": "Clangd for Linux (x64)", - "url": "https://github.com/clangd/clangd/releases/download/19.1.2/clangd-linux-19.1.2.zip", - "platformId": "linux-x64", - "archiveType": "zip", - "binaryName": "clangd" - }, - { - "id": "Clangd", - "description": "Clangd for Windows (x64)", - "url": "https://github.com/clangd/clangd/releases/download/19.1.2/clangd-windows-19.1.2.zip", - "platformId": "win-x64", - "archiveType": "zip", - "binaryName": "clangd.exe" - }, - { - "id": "Clangd", - "description": "Clangd for macOS (Arm64)", - "url": "https://github.com/clangd/clangd/releases/download/19.1.2/clangd-mac-19.1.2.zip", - "platformId": "osx-arm64", - "archiveType": "zip", - "binaryName": "clangd" - } - ] -} diff --git a/src/solidlsp/language_servers/clojure_lsp/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py similarity index 72% rename from src/solidlsp/language_servers/clojure_lsp/clojure_lsp.py rename to src/solidlsp/language_servers/clojure_lsp.py index e83b540d..cae8abc2 100644 --- a/src/solidlsp/language_servers/clojure_lsp/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -13,10 +13,11 @@ 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 +from .common import RuntimeDependency, RuntimeDependencyCollection + def run_command(cmd: list, capture_output: bool = True) -> subprocess.CompletedProcess: return subprocess.run( @@ -44,38 +45,44 @@ class ClojureLSP(SolidLanguageServer): """ 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", - }, - ] + runtime_dependencies = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-macos-aarch64.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="clojure-lsp", + ), + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-aarch64.zip", + platform_id="linux-arm64", + archive_type="zip", + binary_name="clojure-lsp", + ), + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-amd64.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="clojure-lsp", + ), + RuntimeDependency( + id="clojure-lsp", + url=f"{clojure_lsp_releases}/clojure-lsp-native-windows-amd64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="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) + clojure_lsp_executable_path = self._setup_runtime_dependencies(logger, config) super().__init__( config, logger, @@ -88,44 +95,40 @@ class ClojureLSP(SolidLanguageServer): 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.""" + @staticmethod + def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + """Setup runtime dependencies for clojure-lsp and return the command to start the server.""" 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] + deps = ClojureLSP.runtime_dependencies + dependency = deps.single_for_current_platform() clojurelsp_ls_dir = os.path.join(os.path.dirname(__file__), "static", "clojure-lsp") - clojurelsp_executable_path = os.path.join(clojurelsp_ls_dir, dependency["binaryName"]) + clojurelsp_executable_path = deps.binary_path(clojurelsp_ls_dir) 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"]) + logger.log( + f"Downloading and extracting clojure-lsp from {dependency.url} to {clojurelsp_ls_dir}", + logging.INFO, + ) + deps.install(logger, clojurelsp_ls_dir) 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: + @staticmethod + def _get_initialize_params(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] - } - }, + "symbol": {"symbolKind": {"valueSet": list(range(1, 27))}}, "workspaceFolders": True, }, "textDocument": { @@ -133,13 +136,10 @@ class ClojureLSP(SolidLanguageServer): "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] - }, + "symbolKind": {"valueSet": list(range(1, 27))}, # }, }, "general": {"positionEncodings": ["utf-16"]}, diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py new file mode 100644 index 00000000..a0973e56 --- /dev/null +++ b/src/solidlsp/language_servers/common.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +import subprocess +from collections.abc import Sequence +from dataclasses import dataclass + +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import FileUtils, PlatformUtils + + +@dataclass(kw_only=True) +class RuntimeDependency: + """Represents a runtime dependency for a language server.""" + + id: str + platform_id: str | None = None + url: str | None = None + archive_type: str | None = None + binary_name: str | None = None + command: str | None = None + package_name: str | None = None + package_version: str | None = None + extract_path: str | None = None + description: str | None = None + + +class RuntimeDependencyCollection: + """Utility to handle installation of runtime dependencies.""" + + def __init__(self, dependencies: Sequence[RuntimeDependency]): + self._dependencies = list(dependencies) + + def for_platform(self, platform_id: str) -> list[RuntimeDependency]: + return [d for d in self._dependencies if d.platform_id in (platform_id, "any", "platform-agnostic", None)] + + def for_current_platform(self) -> list[RuntimeDependency]: + return self.for_platform(PlatformUtils.get_platform_id().value) + + def single_for_current_platform(self) -> RuntimeDependency: + deps = self.for_current_platform() + if len(deps) != 1: + raise RuntimeError(f"Expected exactly one runtime dependency for {PlatformUtils.get_platform_id().value}, found {len(deps)}") + return deps[0] + + def binary_path(self, target_dir: str) -> str: + dep = self.single_for_current_platform() + if not dep.binary_name: + return target_dir + return os.path.join(target_dir, dep.binary_name) + + def install(self, logger: LanguageServerLogger, target_dir: str) -> dict[str, str]: + """Install all dependencies for the current platform into *target_dir*. + + Returns a mapping from dependency id to the resolved binary path. + """ + os.makedirs(target_dir, exist_ok=True) + results: dict[str, str] = {} + for dep in self.for_current_platform(): + if dep.url: + self._install_from_url(dep, logger, target_dir) + if dep.command: + self._run_command(dep.command, target_dir) + if dep.binary_name: + results[dep.id] = os.path.join(target_dir, dep.binary_name) + else: + results[dep.id] = target_dir + return results + + @staticmethod + def _run_command(command: str, cwd: str) -> None: + if PlatformUtils.get_platform_id().value.startswith("win"): + subprocess.run( + command, + shell=True, + check=True, + cwd=cwd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + import pwd + + user = pwd.getpwuid(os.getuid()).pw_name + subprocess.run( + command, + shell=True, + check=True, + user=user, + cwd=cwd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + @staticmethod + def _install_from_url(dep: RuntimeDependency, logger: LanguageServerLogger, target_dir: str) -> None: + if dep.archive_type == "gz" and dep.binary_name: + dest = os.path.join(target_dir, dep.binary_name) + FileUtils.download_and_extract_archive(logger, dep.url, dest, dep.archive_type) + else: + FileUtils.download_and_extract_archive(logger, dep.url, target_dir, dep.archive_type or "zip") diff --git a/src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py similarity index 84% rename from src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py rename to src/solidlsp/language_servers/csharp_language_server.py index fbf13779..7d11a1df 100644 --- a/src/solidlsp/language_servers/csharp_language_server/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -8,11 +8,11 @@ import os import platform import shutil import stat +import subprocess import tarfile import threading import urllib.request import zipfile -from dataclasses import dataclass from pathlib import Path from typing import cast @@ -26,21 +26,7 @@ from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo - -@dataclass -class RuntimeDependency: - """Represents a runtime dependency for the C# language server.""" - - id: str - description: str - platform_id: str - archive_type: str - binary_name: str - package_name: str | None = None - package_version: str | None = None - extract_path: str | None = None - url: str | None = None - +from .common import RuntimeDependency # Runtime dependencies configuration RUNTIME_DEPENDENCIES = [ @@ -194,13 +180,13 @@ class CSharpLanguageServer(SolidLanguageServer): Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - dotnet_path, language_server_path, cache_dir = self._ensure_server_installed(logger, config) + dotnet_path, language_server_path = self._ensure_server_installed(logger, config) # Find solution or project file solution_or_project = find_solution_or_project_file(repository_root_path) # Create log directory - log_dir = cache_dir / "logs" + log_dir = Path(self.ls_resources_dir()) / "logs" log_dir.mkdir(parents=True, exist_ok=True) # Build command using dotnet directly @@ -228,30 +214,21 @@ class CSharpLanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"] - def _ensure_server_installed(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str, Path]: + @classmethod + def _ensure_server_installed(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str]: """ Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available. - Returns a tuple of (dotnet_path, language_server_dll_path, cache_dir). + Returns a tuple of (dotnet_path, language_server_dll_path). """ - # Determine platform runtime ID - runtime_id = self._get_runtime_id() + runtime_id = CSharpLanguageServer._get_runtime_id() + lang_server_dep, dotnet_runtime_dep = CSharpLanguageServer._get_runtime_dependencies(runtime_id) + dotnet_path = CSharpLanguageServer._ensure_dotnet_runtime(logger, dotnet_runtime_dep) + server_dll_path = CSharpLanguageServer._ensure_language_server(logger, lang_server_dep) - # Set up cache directory - cache_dir = Path.home() / ".cache" / "serena" / "language-servers" / "csharp" - cache_dir.mkdir(parents=True, exist_ok=True) + return dotnet_path, server_dll_path - # Get runtime dependencies for this platform - lang_server_dep, dotnet_runtime_dep = self._get_runtime_dependencies(runtime_id) - - # Ensure .NET runtime is available - dotnet_path = self._ensure_dotnet_runtime(logger, cache_dir, dotnet_runtime_dep) - - # Ensure language server is available - server_dll_path = self._ensure_language_server(logger, cache_dir, lang_server_dep) - - return dotnet_path, server_dll_path, cache_dir - - def _get_runtime_id(self) -> str: + @staticmethod + def _get_runtime_id() -> str: """Determine the runtime ID based on the platform.""" system = platform.system().lower() machine = platform.machine().lower() @@ -265,7 +242,8 @@ class CSharpLanguageServer(SolidLanguageServer): else: raise LanguageServerException(f"Unsupported platform: {system} {machine}") - def _get_runtime_dependencies(self, runtime_id: str) -> tuple[RuntimeDependency, RuntimeDependency]: + @staticmethod + def _get_runtime_dependencies(runtime_id: str) -> tuple[RuntimeDependency, RuntimeDependency]: """Get the language server and .NET runtime dependencies for the platform.""" lang_server_dep = None dotnet_runtime_dep = None @@ -283,15 +261,14 @@ class CSharpLanguageServer(SolidLanguageServer): return lang_server_dep, dotnet_runtime_dep - def _ensure_dotnet_runtime(self, logger: LanguageServerLogger, cache_dir: Path, runtime_dep: RuntimeDependency) -> str: + @staticmethod + def _ensure_dotnet_runtime(logger: LanguageServerLogger, runtime_dep: RuntimeDependency) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" - # Check if dotnet is already available in system + # Check if dotnet is already available on the system system_dotnet = shutil.which("dotnet") if system_dotnet: # Check if it's .NET 9 try: - import subprocess - result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) if "Microsoft.NETCore.App 9." in result.stdout: logger.log("Found system .NET 9 runtime", logging.INFO) @@ -300,14 +277,15 @@ class CSharpLanguageServer(SolidLanguageServer): pass # Download .NET 9 runtime using config - return self._ensure_dotnet_runtime_from_config(logger, cache_dir, runtime_dep) + return CSharpLanguageServer._ensure_dotnet_runtime_from_config(logger, runtime_dep) - def _ensure_language_server(self, logger: LanguageServerLogger, cache_dir: Path, lang_server_dep: RuntimeDependency) -> str: + @classmethod + def _ensure_language_server(cls, logger: LanguageServerLogger, lang_server_dep: RuntimeDependency) -> str: """Ensure language server is available and return the DLL path.""" package_name = lang_server_dep.package_name package_version = lang_server_dep.package_version - server_dir = cache_dir / f"{package_name}.{package_version}" + server_dir = Path(cls.ls_resources_dir()) / f"{package_name}.{package_version}" server_dll = server_dir / lang_server_dep.binary_name if server_dll.exists(): @@ -316,10 +294,10 @@ class CSharpLanguageServer(SolidLanguageServer): # Download and install the language server logger.log(f"Downloading {package_name} version {package_version}...", logging.INFO) - package_path = self._download_nuget_package_direct(logger, package_name, package_version, cache_dir) + package_path = CSharpLanguageServer._download_nuget_package_direct(logger, package_name, package_version) # Extract and install - self._extract_language_server(lang_server_dep, package_path, server_dir) + CSharpLanguageServer._extract_language_server(lang_server_dep, package_path, server_dir) if not server_dll.exists(): raise LanguageServerException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction") @@ -331,7 +309,8 @@ class CSharpLanguageServer(SolidLanguageServer): logger.log(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}", logging.INFO) return str(server_dll) - def _extract_language_server(self, lang_server_dep: RuntimeDependency, package_path: Path, server_dir: Path) -> None: + @staticmethod + def _extract_language_server(lang_server_dep: RuntimeDependency, package_path: Path, server_dir: Path) -> None: """Extract language server files from downloaded package.""" extract_path = lang_server_dep.extract_path or "lib/net9.0" source_dir = package_path / extract_path @@ -353,9 +332,8 @@ class CSharpLanguageServer(SolidLanguageServer): server_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(source_dir, server_dir, dirs_exist_ok=True) - def _download_nuget_package_direct( - self, logger: LanguageServerLogger, package_name: str, package_version: str, cache_dir: Path - ) -> Path: + @classmethod + def _download_nuget_package_direct(cls, logger: LanguageServerLogger, package_name: str, package_version: str) -> Path: """ Download a NuGet package directly from the Azure NuGet feed. Returns the path to the extracted package directory. @@ -363,7 +341,7 @@ class CSharpLanguageServer(SolidLanguageServer): azure_feed_url = "https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json" # Create temporary directory for package download - temp_dir = cache_dir / "temp_downloads" + temp_dir = Path(cls.ls_resources_dir()) / "temp_downloads" temp_dir.mkdir(parents=True, exist_ok=True) try: @@ -411,18 +389,17 @@ class CSharpLanguageServer(SolidLanguageServer): f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}" ) from e - def _ensure_dotnet_runtime_from_config(self, logger: LanguageServerLogger, cache_dir: Path, runtime_dep: RuntimeDependency) -> str: + @classmethod + def _ensure_dotnet_runtime_from_config(cls, logger: LanguageServerLogger, runtime_dep: RuntimeDependency) -> str: """ Ensure .NET 9 runtime is available using runtime dependency configuration. Returns the path to the dotnet executable. """ - # Check if dotnet is already available in system + # Check if dotnet is already available on the system system_dotnet = shutil.which("dotnet") if system_dotnet: # Check if it's .NET 9 try: - import subprocess - result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) if "Microsoft.NETCore.App 9." in result.stdout: logger.log("Found system .NET 9 runtime", logging.INFO) @@ -431,7 +408,7 @@ class CSharpLanguageServer(SolidLanguageServer): pass # Download .NET 9 runtime using config - dotnet_dir = cache_dir / "dotnet-runtime-9.0" + dotnet_dir = Path(cls.ls_resources_dir()) / "dotnet-runtime-9.0" dotnet_exe = dotnet_dir / runtime_dep.binary_name if dotnet_exe.exists(): @@ -499,9 +476,7 @@ class CSharpLanguageServer(SolidLanguageServer): "didChangeWatchedFiles": {"dynamicRegistration": True}, "symbol": { "dynamicRegistration": 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] - }, + "symbolKind": {"valueSet": list(range(1, 27))}, }, "executeCommand": {"dynamicRegistration": True}, "configuration": True, @@ -510,20 +485,6 @@ class CSharpLanguageServer(SolidLanguageServer): }, "textDocument": { "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, - "completion": { - "dynamicRegistration": True, - "contextSupport": True, - "completionItem": { - "snippetSupport": True, - "commitCharactersSupport": True, - "documentationFormat": ["markdown", "plaintext"], - "deprecatedSupport": True, - "preselectSupport": True, - }, - "completionItemKind": { - "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] - }, - }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, @@ -534,38 +495,11 @@ class CSharpLanguageServer(SolidLanguageServer): }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, - "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": 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] - }, + "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, }, - "codeAction": { - "dynamicRegistration": True, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports", - ] - } - }, - }, - "codeLens": {"dynamicRegistration": True}, - "formatting": {"dynamicRegistration": True}, - "rangeFormatting": {"dynamicRegistration": True}, - "onTypeFormatting": {"dynamicRegistration": True}, - "rename": {"dynamicRegistration": True}, - "publishDiagnostics": {"relatedInformation": True}, - "foldingRange": {"dynamicRegistration": True, "rangeLimit": 5000, "lineFoldingOnly": True}, }, }, }, diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py new file mode 100644 index 00000000..5bc6d670 --- /dev/null +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -0,0 +1,158 @@ +import logging +import os +import pathlib +import stat + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + +from .common import RuntimeDependency, RuntimeDependencyCollection + + +class DartLanguageServer(SolidLanguageServer): + """ + Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. + """ + + def __init__(self, config, logger, repository_root_path): + """ + Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + executable_path = self._setup_runtime_dependencies(logger) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), + "dart", + ) + + @classmethod + def _setup_runtime_dependencies(cls, logger: "LanguageServerLogger") -> str: + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for Linux (x64)", + url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-linux-x64-release.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="dart-sdk/bin/dart", + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for Windows (x64)", + url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-x64-release.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="dart-sdk/bin/dart.exe", + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for Windows (arm64)", + url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-arm64-release.zip", + platform_id="win-arm64", + archive_type="zip", + binary_name="dart-sdk/bin/dart.exe", + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for macOS (x64)", + url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-x64-release.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name="dart-sdk/bin/dart", + ), + RuntimeDependency( + id="DartLanguageServer", + description="Dart Language Server for macOS (arm64)", + url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-arm64-release.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="dart-sdk/bin/dart", + ), + ] + ) + + dart_ls_dir = cls.ls_resources_dir() + dart_executable_path = deps.binary_path(dart_ls_dir) + + if not os.path.exists(dart_ls_dir): + os.makedirs(dart_ls_dir) + deps.install(logger, dart_ls_dir) + + assert os.path.exists(dart_executable_path) + os.chmod(dart_executable_path, stat.S_IEXEC) + + return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" + + @staticmethod + def _get_initialize_params(repository_absolute_path: str): + """ + Returns the initialize params for the Dart Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "capabilities": {}, + "initializationOptions": { + "onlyAnalyzeProjectsWithOpenFiles": False, + "closingLabels": False, + "outline": False, + "flutterOutline": False, + "allowOpenUri": False, + }, + "trace": "verbose", + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": pathlib.Path(repository_absolute_path).as_uri(), + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + + return initialize_params + + def _start_server(self): + """ + Start the language server and yield when the server is ready. + """ + + def execute_client_command_handler(params): + return [] + + def do_nothing(params): + return + + def check_experimental_status(params): + pass + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + 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 dart-language-server server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log( + "Sending initialize request to dart-language-server", + logging.DEBUG, + ) + init_response = self.server.send_request("initialize", initialize_params) + self.logger.log( + f"Received initialize response from dart-language-server: {init_response}", + logging.INFO, + ) + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/dart_language_server/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server/dart_language_server.py deleted file mode 100644 index 58d71014..00000000 --- a/src/solidlsp/language_servers/dart_language_server/dart_language_server.py +++ /dev/null @@ -1,119 +0,0 @@ -import json -import logging -import os -import pathlib -import stat - -from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_logger import LanguageServerLogger -from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo - - -class DartLanguageServer(SolidLanguageServer): - """ - Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. - """ - - def __init__(self, config, logger, repository_root_path): - """ - Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. - """ - executable_path = self.setup_runtime_dependencies(logger) - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), - "dart", - ) - - def setup_runtime_dependencies(self, logger: "LanguageServerLogger") -> str: - platform_id = PlatformUtils.get_platform_id() - - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json")) as f: - d = json.load(f) - del d["_description"] - - runtime_dependencies = d["runtimeDependencies"] - runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] - - assert len(runtime_dependencies) == 1 - dependency = runtime_dependencies[0] - - dart_ls_dir = os.path.join(os.path.dirname(__file__), "static", "dart-language-server") - dart_executable_path = os.path.join(dart_ls_dir, dependency["binaryName"]) - - if not os.path.exists(dart_ls_dir): - os.makedirs(dart_ls_dir) - FileUtils.download_and_extract_archive(logger, dependency["url"], dart_ls_dir, dependency["archiveType"]) - - assert os.path.exists(dart_executable_path) - os.chmod(dart_executable_path, stat.S_IEXEC) - - return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" - - def _get_initialize_params(self, repository_absolute_path: str): - """ - Returns the initialize params for the Dart Language Server. - """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json")) as f: - d = json.load(f) - - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d - - def _start_server(self): - """ - Start the language server and yield when the server is ready. - """ - - def execute_client_command_handler(params): - return [] - - def do_nothing(params): - return - - def check_experimental_status(params): - pass - - def window_log_message(msg): - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - - self.server.on_request("client/registerCapability", do_nothing) - self.server.on_notification("language/status", do_nothing) - 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 dart-language-server server process", logging.INFO) - self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request to dart-language-server", - logging.DEBUG, - ) - init_response = self.server.send_request("initialize", initialize_params) - self.logger.log( - f"Received initialize response from dart-language-server: {init_response}", - logging.INFO, - ) - - self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/dart_language_server/initialize_params.json b/src/solidlsp/language_servers/dart_language_server/initialize_params.json deleted file mode 100644 index e90e408a..00000000 --- a/src/solidlsp/language_servers/dart_language_server/initialize_params.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "_description": "This file contains the initialization parameters for the Dart Language Server.", - "processId": "$processId", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "capabilities": {}, - "initializationOptions": { - "onlyAnalyzeProjectsWithOpenFiles": false, - "suggestFromUnimportedLibraries": true, - "closingLabels": false, - "outline": false, - "flutterOutline": false, - "allowOpenUri": false - }, - "trace": "verbose", - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] - -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/dart_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/dart_language_server/runtime_dependencies.json deleted file mode 100644 index 32e49299..00000000 --- a/src/solidlsp/language_servers/dart_language_server/runtime_dependencies.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "_description": "Used to download the runtime dependencies for running Dart Language Server, downloaded from https://dart.dev/get-dart/archive", - "runtimeDependencies": [ - { - "id": "DartLanguageServer", - "description": "Dart Language Server for Linux (x64)", - "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-linux-x64-release.zip", - "platformId": "linux-x64", - "archiveType": "zip", - "binaryName": "dart-sdk/bin/dart" - }, - { - "id": "DartLanguageServer", - "description": "Dart Language Server for Windows (x64)", - "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-x64-release.zip", - "platformId": "win-x64", - "archiveType": "zip", - "binaryName": "dart-sdk/bin/dart.exe" - }, - { - "id": "DartLanguageServer", - "description": "Dart Language Server for Windows (arm64)", - "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-arm64-release.zip", - "platformId": "win-arm64", - "archiveType": "zip", - "binaryName": "dart-sdk/bin/dart.exe" - }, - { - "id": "DartLanguageServer", - "description": "Dart Language Server for macOS (x64)", - "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-x64-release.zip", - "platformId": "osx-x64", - "archiveType": "zip", - "binaryName": "dart-sdk/bin/dart" - }, - { - "id": "DartLanguageServer", - "description": "Dart Language Server for macOS (arm64)", - "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-arm64-release.zip", - "platformId": "osx-arm64", - "archiveType": "zip", - "binaryName": "dart-sdk/bin/dart" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py new file mode 100644 index 00000000..5ebb378c --- /dev/null +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -0,0 +1,718 @@ +""" +Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java. +""" + +import dataclasses +import logging +import os +import pathlib +import shutil +import stat +import threading +import uuid +from pathlib import PurePath + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +@dataclasses.dataclass +class RuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of EclipseJDTLS + """ + + gradle_path: str + lombok_jar_path: str + jre_path: str + jre_home_path: str + jdtls_launcher_jar_path: str + jdtls_readonly_config_path: str + intellicode_jar_path: str + intellisense_members_path: str + + +class EclipseJDTLS(SolidLanguageServer): + """ + The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class + """ + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a new EclipseJDTLS instance initializing the language server settings appropriately. + This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + runtime_dependency_paths = self._setupRuntimeDependencies(logger, config) + self.runtime_dependency_paths = runtime_dependency_paths + + # ws_dir is the workspace directory for the EclipseJDTLS server + ws_dir = str( + PurePath( + SolidLSPSettings.get_language_server_directory(), + "EclipseJDTLS", + "workspaces", + uuid.uuid4().hex, + ) + ) + + # shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces + shared_cache_location = str(PurePath(SolidLSPSettings.get_global_cache_directory(), "lsp", "EclipseJDTLS", "sharedIndex")) + + jre_path = self.runtime_dependency_paths.jre_path + lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path + + jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path + + os.makedirs(ws_dir, exist_ok=True) + + data_dir = str(PurePath(ws_dir, "data_dir")) + jdtls_config_path = str(PurePath(ws_dir, "config_path")) + + jdtls_readonly_config_path = self.runtime_dependency_paths.jdtls_readonly_config_path + + if not os.path.exists(jdtls_config_path): + shutil.copytree(jdtls_readonly_config_path, jdtls_config_path) + + for static_path in [ + jre_path, + lombok_jar_path, + jdtls_launcher_jar, + jdtls_config_path, + jdtls_readonly_config_path, + ]: + assert os.path.exists(static_path), static_path + + # TODO: Add "self.runtime_dependency_paths.jre_home_path"/bin to $PATH as well + proc_env = {"syntaxserver": "false", "JAVA_HOME": self.runtime_dependency_paths.jre_home_path} + proc_cwd = repository_root_path + cmd = " ".join( + [ + jre_path, + "--add-modules=ALL-SYSTEM", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/sun.nio.fs=ALL-UNNAMED", + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Djava.import.generatesMetadataFilesAtProjectRoot=false", + "-Dfile.encoding=utf8", + "-noverify", + "-XX:+UseParallelGC", + "-XX:GCTimeRatio=4", + "-XX:AdaptiveSizePolicyWeight=90", + "-Dsun.zip.disableMemoryMapping=true", + "-Djava.lsp.joinOnCompletion=true", + "-Xmx3G", + "-Xms100m", + "-Xlog:disable", + "-Dlog.level=ALL", + f'"-javaagent:{lombok_jar_path}"', + f'"-Djdt.core.sharedIndexLocation={shared_cache_location}"', + "-jar", + f'"{jdtls_launcher_jar}"', + "-configuration", + f'"{jdtls_config_path}"', + "-data", + f'"{data_dir}"', + ] + ) + + self.service_ready_event = threading.Event() + self.intellicode_enable_command_available = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java") + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # Ignore common Java build directories from different build tools: + # - Maven: target + # - Gradle: build, .gradle + # - Eclipse: bin, .settings + # - IntelliJ IDEA: out, .idea + # - General: classes, dist, lib + return super().is_ignored_dirname(dirname) or dirname in [ + "target", # Maven + "build", # Gradle + "bin", # Eclipse + "out", # IntelliJ IDEA + "classes", # General + "dist", # General + "lib", # General + ] + + @classmethod + def _setupRuntimeDependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> RuntimeDependencyPaths: + """ + Setup runtime dependencies for EclipseJDTLS and return the paths. + """ + platformId = PlatformUtils.get_platform_id() + + runtime_dependencies = { + "gradle": { + "platform-agnostic": { + "url": "https://services.gradle.org/distributions/gradle-7.3.3-bin.zip", + "archiveType": "zip", + "relative_extraction_path": ".", + } + }, + "vscode-java": { + "darwin-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + }, + "osx-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-macosx-aarch64", + "jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_mac_arm", + }, + "osx-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-macosx-x86_64", + "jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_mac", + }, + "linux-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + }, + "linux-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-linux-x86_64", + "jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_linux", + }, + "win-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-win32-x86_64", + "jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_win", + }, + }, + "intellicode": { + "platform-agnostic": { + "url": "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", + "alternate_url": "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/1.2.30/vspackage", + "archiveType": "zip", + "relative_extraction_path": "intellicode", + "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", + "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members", + } + }, + } + + # assert platformId.value in [ + # "linux-x64", + # "win-x64", + # ], "Only linux-x64 platform is supported for in multilspy at the moment" + + gradle_path = str( + PurePath( + cls.ls_resources_dir(), + "gradle-7.3.3", + ) + ) + + if not os.path.exists(gradle_path): + FileUtils.download_and_extract_archive( + logger, + runtime_dependencies["gradle"]["platform-agnostic"]["url"], + str(PurePath(gradle_path).parent), + runtime_dependencies["gradle"]["platform-agnostic"]["archiveType"], + ) + + assert os.path.exists(gradle_path) + + dependency = runtime_dependencies["vscode-java"][platformId.value] + vscode_java_path = str(PurePath(cls.ls_resources_dir(), dependency["relative_extraction_path"])) + os.makedirs(vscode_java_path, exist_ok=True) + jre_home_path = str(PurePath(vscode_java_path, dependency["jre_home_path"])) + jre_path = str(PurePath(vscode_java_path, dependency["jre_path"])) + lombok_jar_path = str(PurePath(vscode_java_path, dependency["lombok_jar_path"])) + jdtls_launcher_jar_path = str(PurePath(vscode_java_path, dependency["jdtls_launcher_jar_path"])) + jdtls_readonly_config_path = str(PurePath(vscode_java_path, dependency["jdtls_readonly_config_path"])) + if not all( + [ + os.path.exists(vscode_java_path), + os.path.exists(jre_home_path), + os.path.exists(jre_path), + os.path.exists(lombok_jar_path), + os.path.exists(jdtls_launcher_jar_path), + os.path.exists(jdtls_readonly_config_path), + ] + ): + FileUtils.download_and_extract_archive(logger, dependency["url"], vscode_java_path, dependency["archiveType"]) + + os.chmod(jre_path, stat.S_IEXEC) + + assert os.path.exists(vscode_java_path) + assert os.path.exists(jre_home_path) + assert os.path.exists(jre_path) + assert os.path.exists(lombok_jar_path) + assert os.path.exists(jdtls_launcher_jar_path) + assert os.path.exists(jdtls_readonly_config_path) + + dependency = runtime_dependencies["intellicode"]["platform-agnostic"] + intellicode_directory_path = str(PurePath(cls.ls_resources_dir(), dependency["relative_extraction_path"])) + os.makedirs(intellicode_directory_path, exist_ok=True) + intellicode_jar_path = str(PurePath(intellicode_directory_path, dependency["intellicode_jar_path"])) + intellisense_members_path = str(PurePath(intellicode_directory_path, dependency["intellisense_members_path"])) + if not all( + [ + os.path.exists(intellicode_directory_path), + os.path.exists(intellicode_jar_path), + os.path.exists(intellisense_members_path), + ] + ): + FileUtils.download_and_extract_archive(logger, dependency["url"], intellicode_directory_path, dependency["archiveType"]) + + assert os.path.exists(intellicode_directory_path) + assert os.path.exists(intellicode_jar_path) + assert os.path.exists(intellisense_members_path) + + return RuntimeDependencyPaths( + gradle_path=gradle_path, + lombok_jar_path=lombok_jar_path, + jre_path=jre_path, + jre_home_path=jre_home_path, + jdtls_launcher_jar_path=jdtls_launcher_jar_path, + jdtls_readonly_config_path=jdtls_readonly_config_path, + intellicode_jar_path=intellicode_jar_path, + intellisense_members_path=intellisense_members_path, + ) + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize parameters for the EclipseJDTLS server. + """ + # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available + + if not os.path.isabs(repository_absolute_path): + repository_absolute_path = os.path.abspath(repository_absolute_path) + repo_uri = pathlib.Path(repository_absolute_path).as_uri() + + initialize_params = { + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": pathlib.Path(repository_absolute_path).as_uri(), + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "codeLens": {"refreshSupport": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + # TODO: we have an assert that completion provider is not included in the capabilities at server startup + # Removing this will cause the assert to fail. Investigate why this is the case, simplify config + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": False, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": False, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "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] + }, + "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + "serverCancelSupport": True, + "augmentsSyntaxTokens": True, + }, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "general": { + "staleRequestSupport": { + "cancel": True, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta", + ], + }, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "positionEncodings": ["utf-16"], + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + }, + "initializationOptions": { + "bundles": ["intellicode-core.jar"], + "settings": { + "java": { + "home": None, + "jdt": { + "ls": { + "java": {"home": None}, + "vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m -Xlog:disable", + "lombokSupport": {"enabled": True}, + "protobufSupport": {"enabled": True}, + "androidSupport": {"enabled": True}, + } + }, + "errors": {"incompleteClasspath": {"severity": "error"}}, + "configuration": { + "checkProjectSettingsExclusions": False, + "updateBuildConfiguration": "interactive", + "maven": { + "userSettings": None, + "globalSettings": None, + "notCoveredPluginExecutionSeverity": "warning", + "defaultMojoExecutionAction": "ignore", + }, + "workspaceCacheLimit": 90, + "runtimes": [ + {"name": "JavaSE-21", "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", "default": True} + ], + }, + "trace": {"server": "verbose"}, + "import": { + "maven": { + "enabled": True, + "offline": {"enabled": False}, + "disableTestClasspathFlag": False, + }, + "gradle": { + "enabled": True, + "wrapper": {"enabled": True}, + "version": None, + "home": "abs(static/gradle-7.3.3)", + "java": {"home": "abs(static/launch_jres/21.0.7-linux-x86_64)"}, + "offline": {"enabled": False}, + "arguments": None, + "jvmArguments": None, + "user": {"home": None}, + "annotationProcessing": {"enabled": True}, + }, + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**", + ], + "generatesMetadataFilesAtProjectRoot": False, + }, + "maven": {"downloadSources": True, "updateSnapshots": True}, + "eclipse": {"downloadSources": True}, + "signatureHelp": {"enabled": True, "description": {"enabled": True}}, + "implementationsCodeLens": {"enabled": True}, + "format": { + "enabled": True, + "settings": {"url": None, "profile": None}, + "comments": {"enabled": True}, + "onType": {"enabled": True}, + "insertSpaces": True, + "tabSize": 4, + }, + "saveActions": {"organizeImports": False}, + "project": { + "referencedLibraries": ["lib/**/*.jar"], + "importOnFirstTimeStartup": "automatic", + "importHint": True, + "resourceFilters": ["node_modules", "\\.git"], + "encoding": "ignore", + "exportJar": {"targetPath": "${workspaceFolder}/${workspaceFolderBasename}.jar"}, + }, + "contentProvider": {"preferred": None}, + "autobuild": {"enabled": True}, + "maxConcurrentBuilds": 1, + "selectionRange": {"enabled": True}, + "showBuildStatusOnStart": {"enabled": "notification"}, + "server": {"launchMode": "Standard"}, + "sources": {"organizeImports": {"starThreshold": 99, "staticStarThreshold": 99}}, + "imports": {"gradle": {"wrapper": {"checksums": []}}}, + "templates": {"fileHeader": [], "typeComment": []}, + "references": {"includeAccessors": True, "includeDecompiledSources": True}, + "typeHierarchy": {"lazyLoad": False}, + "settings": {"url": None}, + "symbols": {"includeSourceMethodDeclarations": False}, + "inlayHints": {"parameterNames": {"enabled": "literals", "exclusions": []}}, + "codeAction": {"sortMembers": {"avoidVolatileChanges": True}}, + "compile": { + "nullAnalysis": { + "nonnull": [ + "javax.annotation.Nonnull", + "org.eclipse.jdt.annotation.NonNull", + "org.springframework.lang.NonNull", + ], + "nullable": [ + "javax.annotation.Nullable", + "org.eclipse.jdt.annotation.Nullable", + "org.springframework.lang.Nullable", + ], + "mode": "automatic", + } + }, + "sharedIndexes": {"enabled": "auto", "location": ""}, + "silentNotification": False, + "dependency": { + "showMembers": False, + "syncWithFolderExplorer": True, + "autoRefresh": True, + "refreshDelay": 2000, + "packagePresentation": "flat", + }, + "help": {"firstView": "auto", "showReleaseNotes": True, "collectErrorLog": False}, + "test": {"defaultConfig": "", "config": {}}, + } + }, + }, + "trace": "verbose", + "processId": os.getpid(), + "workspaceFolders": [ + { + "uri": repo_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + + initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] + bundles = [self.runtime_dependency_paths.intellicode_jar_path] + initialize_params["initializationOptions"]["bundles"] = bundles + initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ + {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} + ] + + for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: + assert "name" in runtime + assert "path" in runtime + assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" + + gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] + gradle_settings["home"] = self.runtime_dependency_paths.gradle_path + gradle_settings["java"]["home"] = self.runtime_dependency_paths.jre_path + return initialize_params + + def _start_server(self): + """ + Starts the Eclipse JDTLS Language Server + """ + + def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "textDocument/completion": + assert registration["registerOptions"]["resolveProvider"] == True + assert registration["registerOptions"]["triggerCharacters"] == [ + ".", + "@", + "#", + "*", + " ", + ] + self.completions_available.set() + if registration["method"] == "workspace/executeCommand": + if "java.intellicode.enable" in registration["registerOptions"]["commands"]: + self.intellicode_enable_command_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): + assert params["command"] == "_java.reloadBundles.command" + assert params["arguments"] == [] + return [] + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + 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.logger.log("Starting EclipseJDTLS 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" not in init_response["capabilities"] + assert "executeCommandProvider" not in init_response["capabilities"] + + self.server.notify.initialized({}) + + self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) + + self.intellicode_enable_command_available.wait() + + java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path + assert os.path.exists(java_intellisense_members_path) + intellicode_enable_result = self.server.send.execute_command( + { + "command": "java.intellicode.enable", + "arguments": [True, java_intellisense_members_path], + } + ) + assert intellicode_enable_result + + # TODO: Add comments about why we wait here, and how this can be optimized + self.service_ready_event.wait() diff --git a/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py deleted file mode 100644 index 7453bf15..00000000 --- a/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py +++ /dev/null @@ -1,388 +0,0 @@ -""" -Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java. -""" - -import dataclasses -import json -import logging -import os -import pathlib -import shutil -import stat -import threading -import uuid -from pathlib import PurePath - -from overrides import override - -from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger -from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo -from solidlsp.settings import SolidLSPSettings - - -@dataclasses.dataclass -class RuntimeDependencyPaths: - """ - Stores the paths to the runtime dependencies of EclipseJDTLS - """ - - gradle_path: str - lombok_jar_path: str - jre_path: str - jre_home_path: str - jdtls_launcher_jar_path: str - jdtls_readonly_config_path: str - intellicode_jar_path: str - intellisense_members_path: str - - -class EclipseJDTLS(SolidLanguageServer): - """ - The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class - """ - - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): - """ - Creates a new EclipseJDTLS instance initializing the language server settings appropriately. - This class is not meant to be instantiated directly. Use LanguageServer.create() instead. - """ - runtime_dependency_paths = self.setupRuntimeDependencies(logger, config) - self.runtime_dependency_paths = runtime_dependency_paths - - # ws_dir is the workspace directory for the EclipseJDTLS server - ws_dir = str( - PurePath( - SolidLSPSettings.get_language_server_directory(), - "EclipseJDTLS", - "workspaces", - uuid.uuid4().hex, - ) - ) - - # shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces - shared_cache_location = str(PurePath(SolidLSPSettings.get_global_cache_directory(), "lsp", "EclipseJDTLS", "sharedIndex")) - - jre_path = self.runtime_dependency_paths.jre_path - lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path - - jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path - - os.makedirs(ws_dir, exist_ok=True) - - data_dir = str(PurePath(ws_dir, "data_dir")) - jdtls_config_path = str(PurePath(ws_dir, "config_path")) - - jdtls_readonly_config_path = self.runtime_dependency_paths.jdtls_readonly_config_path - - if not os.path.exists(jdtls_config_path): - shutil.copytree(jdtls_readonly_config_path, jdtls_config_path) - - for static_path in [ - jre_path, - lombok_jar_path, - jdtls_launcher_jar, - jdtls_config_path, - jdtls_readonly_config_path, - ]: - assert os.path.exists(static_path), static_path - - # TODO: Add "self.runtime_dependency_paths.jre_home_path"/bin to $PATH as well - proc_env = {"syntaxserver": "false", "JAVA_HOME": self.runtime_dependency_paths.jre_home_path} - proc_cwd = repository_root_path - cmd = " ".join( - [ - jre_path, - "--add-modules=ALL-SYSTEM", - "--add-opens", - "java.base/java.util=ALL-UNNAMED", - "--add-opens", - "java.base/java.lang=ALL-UNNAMED", - "--add-opens", - "java.base/sun.nio.fs=ALL-UNNAMED", - "-Declipse.application=org.eclipse.jdt.ls.core.id1", - "-Dosgi.bundles.defaultStartLevel=4", - "-Declipse.product=org.eclipse.jdt.ls.core.product", - "-Djava.import.generatesMetadataFilesAtProjectRoot=false", - "-Dfile.encoding=utf8", - "-noverify", - "-XX:+UseParallelGC", - "-XX:GCTimeRatio=4", - "-XX:AdaptiveSizePolicyWeight=90", - "-Dsun.zip.disableMemoryMapping=true", - "-Djava.lsp.joinOnCompletion=true", - "-Xmx3G", - "-Xms100m", - "-Xlog:disable", - "-Dlog.level=ALL", - f'"-javaagent:{lombok_jar_path}"', - f'"-Djdt.core.sharedIndexLocation={shared_cache_location}"', - "-jar", - f'"{jdtls_launcher_jar}"', - "-configuration", - f'"{jdtls_config_path}"', - "-data", - f'"{data_dir}"', - ] - ) - - self.service_ready_event = threading.Event() - self.intellicode_enable_command_available = threading.Event() - self.initialize_searcher_command_available = threading.Event() - - super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java") - - @override - def is_ignored_dirname(self, dirname: str) -> bool: - # Ignore common Java build directories from different build tools: - # - Maven: target - # - Gradle: build, .gradle - # - Eclipse: bin, .settings - # - IntelliJ IDEA: out, .idea - # - General: classes, dist, lib - return super().is_ignored_dirname(dirname) or dirname in [ - "target", # Maven - "build", # Gradle - "bin", # Eclipse - "out", # IntelliJ IDEA - "classes", # General - "dist", # General - "lib", # General - ] - - def setupRuntimeDependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> RuntimeDependencyPaths: - """ - Setup runtime dependencies for EclipseJDTLS. - """ - platformId = PlatformUtils.get_platform_id() - - with open(str(PurePath(os.path.dirname(__file__), "runtime_dependencies.json")), encoding="utf-8") as f: - runtimeDependencies = json.load(f) - del runtimeDependencies["_description"] - - os.makedirs(str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static")), exist_ok=True) - - # assert platformId.value in [ - # "linux-x64", - # "win-x64", - # ], "Only linux-x64 platform is supported for in multilspy at the moment" - - gradle_path = str( - PurePath( - os.path.abspath(os.path.dirname(__file__)), - "static/gradle-7.3.3", - ) - ) - - if not os.path.exists(gradle_path): - FileUtils.download_and_extract_archive( - logger, - runtimeDependencies["gradle"]["platform-agnostic"]["url"], - str(PurePath(gradle_path).parent), - runtimeDependencies["gradle"]["platform-agnostic"]["archiveType"], - ) - - assert os.path.exists(gradle_path) - - dependency = runtimeDependencies["vscode-java"][platformId.value] - vscode_java_path = str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"])) - os.makedirs(vscode_java_path, exist_ok=True) - jre_home_path = str(PurePath(vscode_java_path, dependency["jre_home_path"])) - jre_path = str(PurePath(vscode_java_path, dependency["jre_path"])) - lombok_jar_path = str(PurePath(vscode_java_path, dependency["lombok_jar_path"])) - jdtls_launcher_jar_path = str(PurePath(vscode_java_path, dependency["jdtls_launcher_jar_path"])) - jdtls_readonly_config_path = str(PurePath(vscode_java_path, dependency["jdtls_readonly_config_path"])) - if not all( - [ - os.path.exists(vscode_java_path), - os.path.exists(jre_home_path), - os.path.exists(jre_path), - os.path.exists(lombok_jar_path), - os.path.exists(jdtls_launcher_jar_path), - os.path.exists(jdtls_readonly_config_path), - ] - ): - FileUtils.download_and_extract_archive(logger, dependency["url"], vscode_java_path, dependency["archiveType"]) - - os.chmod(jre_path, stat.S_IEXEC) - - assert os.path.exists(vscode_java_path) - assert os.path.exists(jre_home_path) - assert os.path.exists(jre_path) - assert os.path.exists(lombok_jar_path) - assert os.path.exists(jdtls_launcher_jar_path) - assert os.path.exists(jdtls_readonly_config_path) - - dependency = runtimeDependencies["intellicode"]["platform-agnostic"] - intellicode_directory_path = str( - PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"]) - ) - os.makedirs(intellicode_directory_path, exist_ok=True) - intellicode_jar_path = str(PurePath(intellicode_directory_path, dependency["intellicode_jar_path"])) - intellisense_members_path = str(PurePath(intellicode_directory_path, dependency["intellisense_members_path"])) - if not all( - [ - os.path.exists(intellicode_directory_path), - os.path.exists(intellicode_jar_path), - os.path.exists(intellisense_members_path), - ] - ): - FileUtils.download_and_extract_archive(logger, dependency["url"], intellicode_directory_path, dependency["archiveType"]) - - assert os.path.exists(intellicode_directory_path) - assert os.path.exists(intellicode_jar_path) - assert os.path.exists(intellisense_members_path) - - return RuntimeDependencyPaths( - gradle_path=gradle_path, - lombok_jar_path=lombok_jar_path, - jre_path=jre_path, - jre_home_path=jre_home_path, - jdtls_launcher_jar_path=jdtls_launcher_jar_path, - jdtls_readonly_config_path=jdtls_readonly_config_path, - intellicode_jar_path=intellicode_jar_path, - intellisense_members_path=intellisense_members_path, - ) - - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize parameters for the EclipseJDTLS server. - """ - # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available - with open(str(PurePath(os.path.dirname(__file__), "initialize_params.json")), encoding="utf-8") as f: - d: InitializeParams = json.load(f) - - del d["_description"] - - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - - assert d["processId"] == "os.getpid()" - d["processId"] = os.getpid() - - assert d["rootPath"] == "repository_absolute_path" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "pathlib.Path(repository_absolute_path).as_uri()" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["initializationOptions"]["workspaceFolders"] == "[pathlib.Path(repository_absolute_path).as_uri()]" - d["initializationOptions"]["workspaceFolders"] = [pathlib.Path(repository_absolute_path).as_uri()] - - assert ( - d["workspaceFolders"] - == '[\n {\n "uri": pathlib.Path(repository_absolute_path).as_uri(),\n "name": os.path.basename(repository_absolute_path),\n }\n ]' - ) - d["workspaceFolders"] = [ - { - "uri": pathlib.Path(repository_absolute_path).as_uri(), - "name": os.path.basename(repository_absolute_path), - } - ] - - assert d["initializationOptions"]["bundles"] == ["intellicode-core.jar"] - bundles = [self.runtime_dependency_paths.intellicode_jar_path] - d["initializationOptions"]["bundles"] = bundles - - assert d["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] == [ - {"name": "JavaSE-21", "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", "default": True} - ] - d["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ - {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} - ] - - for runtime in d["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: - assert "name" in runtime - assert "path" in runtime - assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" - - assert d["initializationOptions"]["settings"]["java"]["import"]["gradle"]["home"] == "abs(static/gradle-7.3.3)" - d["initializationOptions"]["settings"]["java"]["import"]["gradle"]["home"] = self.runtime_dependency_paths.gradle_path - - d["initializationOptions"]["settings"]["java"]["import"]["gradle"]["java"]["home"] = self.runtime_dependency_paths.jre_path - - return d - - def _start_server(self): - """ - Starts the Eclipse JDTLS Language Server - """ - - def register_capability_handler(params): - assert "registrations" in params - for registration in params["registrations"]: - if registration["method"] == "textDocument/completion": - assert registration["registerOptions"]["resolveProvider"] == True - assert registration["registerOptions"]["triggerCharacters"] == [ - ".", - "@", - "#", - "*", - " ", - ] - self.completions_available.set() - if registration["method"] == "workspace/executeCommand": - if "java.intellicode.enable" in registration["registerOptions"]["commands"]: - self.intellicode_enable_command_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): - assert params["command"] == "_java.reloadBundles.command" - assert params["arguments"] == [] - return [] - - def window_log_message(msg): - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - - def do_nothing(params): - return - - 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.logger.log("Starting EclipseJDTLS 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" not in init_response["capabilities"] - assert "executeCommandProvider" not in init_response["capabilities"] - - self.server.notify.initialized({}) - - self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) - - self.intellicode_enable_command_available.wait() - - java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path - assert os.path.exists(java_intellisense_members_path) - intellicode_enable_result = self.server.send.execute_command( - { - "command": "java.intellicode.enable", - "arguments": [True, java_intellisense_members_path], - } - ) - assert intellicode_enable_result - - # TODO: Add comments about why we wait here, and how this can be optimized - self.service_ready_event.wait() diff --git a/src/solidlsp/language_servers/eclipse_jdtls/initialize_params.json b/src/solidlsp/language_servers/eclipse_jdtls/initialize_params.json deleted file mode 100644 index b65b7b62..00000000 --- a/src/solidlsp/language_servers/eclipse_jdtls/initialize_params.json +++ /dev/null @@ -1,849 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "clientInfo": { - "name": "Visual Studio Code - Insiders", - "version": "1.77.0-insider" - }, - "locale": "en", - "rootPath": "repository_absolute_path", - "rootUri": "pathlib.Path(repository_absolute_path).as_uri()", - "capabilities": { - "workspace": { - "applyEdit": true, - "workspaceEdit": { - "documentChanges": true, - "resourceOperations": [ - "create", - "rename", - "delete" - ], - "failureHandling": "textOnlyTransactional", - "normalizesLineEndings": true, - "changeAnnotationSupport": { - "groupsOnLabel": true - } - }, - "didChangeConfiguration": { - "dynamicRegistration": true - }, - "didChangeWatchedFiles": { - "dynamicRegistration": true, - "relativePatternSupport": true - }, - "symbol": { - "dynamicRegistration": 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 - ] - }, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "resolveSupport": { - "properties": [ - "location.range" - ] - } - }, - "codeLens": { - "refreshSupport": true - }, - "executeCommand": { - "dynamicRegistration": true - }, - "configuration": true, - "workspaceFolders": true, - "semanticTokens": { - "refreshSupport": true - }, - "fileOperations": { - "dynamicRegistration": true, - "didCreate": true, - "didRename": true, - "didDelete": true, - "willCreate": true, - "willRename": true, - "willDelete": true - }, - "inlineValue": { - "refreshSupport": true - }, - "inlayHint": { - "refreshSupport": true - }, - "diagnostics": { - "refreshSupport": true - } - }, - "textDocument": { - "publishDiagnostics": { - "relatedInformation": true, - "versionSupport": false, - "tagSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "codeDescriptionSupport": true, - "dataSupport": true - }, - "synchronization": { - "dynamicRegistration": true, - "willSave": true, - "willSaveWaitUntil": true, - "didSave": true - }, - "completion": { - "dynamicRegistration": true, - "contextSupport": true, - "completionItem": { - "snippetSupport": false, - "commitCharactersSupport": true, - "documentationFormat": [ - "markdown", - "plaintext" - ], - "deprecatedSupport": true, - "preselectSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "insertReplaceSupport": false, - "resolveSupport": { - "properties": [ - "documentation", - "detail", - "additionalTextEdits" - ] - }, - "insertTextModeSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "labelDetailsSupport": true - }, - "insertTextMode": 2, - "completionItemKind": { - "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 - ] - }, - "completionList": { - "itemDefaults": [ - "commitCharacters", - "editRange", - "insertTextFormat", - "insertTextMode" - ] - } - }, - "hover": { - "dynamicRegistration": true, - "contentFormat": [ - "markdown", - "plaintext" - ] - }, - "signatureHelp": { - "dynamicRegistration": true, - "signatureInformation": { - "documentationFormat": [ - "markdown", - "plaintext" - ], - "parameterInformation": { - "labelOffsetSupport": true - }, - "activeParameterSupport": true - }, - "contextSupport": true - }, - "definition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "references": { - "dynamicRegistration": true - }, - "documentHighlight": { - "dynamicRegistration": true - }, - "documentSymbol": { - "dynamicRegistration": 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 - ] - }, - "hierarchicalDocumentSymbolSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "labelSupport": true - }, - "codeAction": { - "dynamicRegistration": true, - "isPreferredSupport": true, - "disabledSupport": true, - "dataSupport": true, - "resolveSupport": { - "properties": [ - "edit" - ] - }, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports" - ] - } - }, - "honorsChangeAnnotations": false - }, - "codeLens": { - "dynamicRegistration": true - }, - "formatting": { - "dynamicRegistration": true - }, - "rangeFormatting": { - "dynamicRegistration": true - }, - "onTypeFormatting": { - "dynamicRegistration": true - }, - "rename": { - "dynamicRegistration": true, - "prepareSupport": true, - "prepareSupportDefaultBehavior": 1, - "honorsChangeAnnotations": true - }, - "documentLink": { - "dynamicRegistration": true, - "tooltipSupport": true - }, - "typeDefinition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "implementation": { - "dynamicRegistration": true, - "linkSupport": true - }, - "colorProvider": { - "dynamicRegistration": true - }, - "foldingRange": { - "dynamicRegistration": true, - "rangeLimit": 5000, - "lineFoldingOnly": true, - "foldingRangeKind": { - "valueSet": [ - "comment", - "imports", - "region" - ] - }, - "foldingRange": { - "collapsedText": false - } - }, - "declaration": { - "dynamicRegistration": true, - "linkSupport": true - }, - "selectionRange": { - "dynamicRegistration": true - }, - "callHierarchy": { - "dynamicRegistration": true - }, - "semanticTokens": { - "dynamicRegistration": true, - "tokenTypes": [ - "namespace", - "type", - "class", - "enum", - "interface", - "struct", - "typeParameter", - "parameter", - "variable", - "property", - "enumMember", - "event", - "function", - "method", - "macro", - "keyword", - "modifier", - "comment", - "string", - "number", - "regexp", - "operator", - "decorator" - ], - "tokenModifiers": [ - "declaration", - "definition", - "readonly", - "static", - "deprecated", - "abstract", - "async", - "modification", - "documentation", - "defaultLibrary" - ], - "formats": [ - "relative" - ], - "requests": { - "range": true, - "full": { - "delta": true - } - }, - "multilineTokenSupport": false, - "overlappingTokenSupport": false, - "serverCancelSupport": true, - "augmentsSyntaxTokens": true - }, - "linkedEditingRange": { - "dynamicRegistration": true - }, - "typeHierarchy": { - "dynamicRegistration": true - }, - "inlineValue": { - "dynamicRegistration": true - }, - "inlayHint": { - "dynamicRegistration": true, - "resolveSupport": { - "properties": [ - "tooltip", - "textEdits", - "label.tooltip", - "label.location", - "label.command" - ] - } - }, - "diagnostic": { - "dynamicRegistration": true, - "relatedDocumentSupport": false - } - }, - "window": { - "showMessage": { - "messageActionItem": { - "additionalPropertiesSupport": true - } - }, - "showDocument": { - "support": true - }, - "workDoneProgress": true - }, - "general": { - "staleRequestSupport": { - "cancel": true, - "retryOnContentModified": [ - "textDocument/semanticTokens/full", - "textDocument/semanticTokens/range", - "textDocument/semanticTokens/full/delta" - ] - }, - "regularExpressions": { - "engine": "ECMAScript", - "version": "ES2020" - }, - "markdown": { - "parser": "marked", - "version": "1.1.0" - }, - "positionEncodings": [ - "utf-16" - ] - }, - "notebookDocument": { - "synchronization": { - "dynamicRegistration": true, - "executionSummarySupport": true - } - } - }, - "initializationOptions": { - "bundles": [ - "intellicode-core.jar" - ], - "workspaceFolders": "[pathlib.Path(repository_absolute_path).as_uri()]", - "settings": { - "java": { - "home": null, - "jdt": { - "ls": { - "java": { - "home": null - }, - "vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m -Xlog:disable", - "lombokSupport": { - "enabled": true - }, - "protobufSupport": { - "enabled": true - }, - "androidSupport": { - "enabled": true - } - } - }, - "errors": { - "incompleteClasspath": { - "severity": "error" - } - }, - "configuration": { - "checkProjectSettingsExclusions": false, - "updateBuildConfiguration": "interactive", - "maven": { - "userSettings": null, - "globalSettings": null, - "notCoveredPluginExecutionSeverity": "warning", - "defaultMojoExecutionAction": "ignore" - }, - "workspaceCacheLimit": 90, - "runtimes": [ - { - "name": "JavaSE-21", - "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", - "default": true - } - ] - }, - "trace": { - "server": "verbose" - }, - "import": { - "maven": { - "enabled": true, - "offline": { - "enabled": false - }, - "disableTestClasspathFlag": false - }, - "gradle": { - "enabled": true, - "wrapper": { - "enabled": true - }, - "version": null, - "home": "abs(static/gradle-7.3.3)", - "java": { - "home": "abs(static/launch_jres/21.0.7-linux-x86_64)" - }, - "offline": { - "enabled": false - }, - "arguments": null, - "jvmArguments": null, - "user": { - "home": null - }, - "annotationProcessing": { - "enabled": true - } - }, - "exclusions": [ - "**/node_modules/**", - "**/.metadata/**", - "**/archetype-resources/**", - "**/META-INF/maven/**" - ], - "generatesMetadataFilesAtProjectRoot": false - }, - "maven": { - "downloadSources": true, - "updateSnapshots": true - }, - "eclipse": { - "downloadSources": true - }, - "referencesCodeLens": { - "enabled": true - }, - "signatureHelp": { - "enabled": true, - "description": { - "enabled": true - } - }, - "implementationsCodeLens": { - "enabled": true - }, - "format": { - "enabled": true, - "settings": { - "url": null, - "profile": null - }, - "comments": { - "enabled": true - }, - "onType": { - "enabled": true - }, - "insertSpaces": true, - "tabSize": 4 - }, - "saveActions": { - "organizeImports": false - }, - "project": { - "referencedLibraries": [ - "lib/**/*.jar" - ], - "importOnFirstTimeStartup": "automatic", - "importHint": true, - "resourceFilters": [ - "node_modules", - "\\.git" - ], - "encoding": "ignore", - "exportJar": { - "targetPath": "${workspaceFolder}/${workspaceFolderBasename}.jar" - } - }, - "contentProvider": { - "preferred": null - }, - "autobuild": { - "enabled": true - }, - "maxConcurrentBuilds": 1, - "recommendations": { - "dependency": { - "analytics": { - "show": true - } - } - }, - "completion": { - "maxResults": 0, - "enabled": true, - "guessMethodArguments": true, - "favoriteStaticMembers": [ - "org.junit.Assert.*", - "org.junit.Assume.*", - "org.junit.jupiter.api.Assertions.*", - "org.junit.jupiter.api.Assumptions.*", - "org.junit.jupiter.api.DynamicContainer.*", - "org.junit.jupiter.api.DynamicTest.*", - "org.mockito.Mockito.*", - "org.mockito.ArgumentMatchers.*", - "org.mockito.Answers.*" - ], - "filteredTypes": [ - "java.awt.*", - "com.sun.*", - "sun.*", - "jdk.*", - "org.graalvm.*", - "io.micrometer.shaded.*" - ], - "importOrder": [ - "#", - "java", - "javax", - "org", - "com", - "" - ], - "postfix": { - "enabled": false - }, - "matchCase": "off" - }, - "foldingRange": { - "enabled": true - }, - "progressReports": { - "enabled": false - }, - "codeGeneration": { - "hashCodeEquals": { - "useJava7Objects": false, - "useInstanceof": false - }, - "useBlocks": false, - "generateComments": false, - "toString": { - "template": "${object.className} [${member.name()}=${member.value}, ${otherMembers}]", - "codeStyle": "STRING_CONCATENATION", - "skipNullValues": false, - "listArrayContents": true, - "limitElements": 0 - }, - "insertionLocation": "afterCursor" - }, - "selectionRange": { - "enabled": true - }, - "showBuildStatusOnStart": { - "enabled": "notification" - }, - "server": { - "launchMode": "Standard" - }, - "sources": { - "organizeImports": { - "starThreshold": 99, - "staticStarThreshold": 99 - } - }, - "imports": { - "gradle": { - "wrapper": { - "checksums": [] - } - } - }, - "templates": { - "fileHeader": [], - "typeComment": [] - }, - "references": { - "includeAccessors": true, - "includeDecompiledSources": true - }, - "typeHierarchy": { - "lazyLoad": false - }, - "settings": { - "url": null - }, - "symbols": { - "includeSourceMethodDeclarations": false - }, - "quickfix": { - "showAt": "line" - }, - "inlayHints": { - "parameterNames": { - "enabled": "literals", - "exclusions": [] - } - }, - "codeAction": { - "sortMembers": { - "avoidVolatileChanges": true - } - }, - "compile": { - "nullAnalysis": { - "nonnull": [ - "javax.annotation.Nonnull", - "org.eclipse.jdt.annotation.NonNull", - "org.springframework.lang.NonNull" - ], - "nullable": [ - "javax.annotation.Nullable", - "org.eclipse.jdt.annotation.Nullable", - "org.springframework.lang.Nullable" - ], - "mode": "automatic" - } - }, - "cleanup": { - "actionsOnSave": [] - }, - "sharedIndexes": { - "enabled": "auto", - "location": "" - }, - "refactoring": { - "extract": { - "interface": { - "replace": true - } - } - }, - "debug": { - "logLevel": "verbose", - "settings": { - "showHex": false, - "showStaticVariables": false, - "showQualifiedNames": false, - "showLogicalStructure": true, - "showToString": true, - "maxStringLength": 0, - "numericPrecision": 0, - "hotCodeReplace": "manual", - "enableRunDebugCodeLens": true, - "forceBuildBeforeLaunch": true, - "onBuildFailureProceed": false, - "console": "integratedTerminal", - "exceptionBreakpoint": { - "skipClasses": [] - }, - "stepping": { - "skipClasses": [], - "skipSynthetics": false, - "skipStaticInitializers": false, - "skipConstructors": false - }, - "jdwp": { - "limitOfVariablesPerJdwpRequest": 100, - "requestTimeout": 3000, - "async": "auto" - }, - "vmArgs": "" - } - }, - "silentNotification": false, - "dependency": { - "showMembers": false, - "syncWithFolderExplorer": true, - "autoRefresh": true, - "refreshDelay": 2000, - "packagePresentation": "flat" - }, - "help": { - "firstView": "auto", - "showReleaseNotes": true, - "collectErrorLog": false - }, - "test": { - "defaultConfig": "", - "config": {} - } - } - }, - "extendedClientCapabilities": { - "progressReportProvider": false, - "classFileContentsSupport": true, - "overrideMethodsPromptSupport": true, - "hashCodeEqualsPromptSupport": true, - "advancedOrganizeImportsSupport": true, - "generateToStringPromptSupport": true, - "advancedGenerateAccessorsSupport": true, - "generateConstructorsPromptSupport": true, - "generateDelegateMethodsPromptSupport": true, - "advancedExtractRefactoringSupport": true, - "inferSelectionSupport": [ - "extractMethod", - "extractVariable", - "extractField" - ], - "moveRefactoringSupport": true, - "clientHoverProvider": true, - "clientDocumentSymbolProvider": true, - "gradleChecksumWrapperPromptSupport": true, - "resolveAdditionalTextEditsSupport": true, - "advancedIntroduceParameterRefactoringSupport": true, - "actionableRuntimeNotificationSupport": true, - "shouldLanguageServerExitOnShutdown": true, - "onCompletionItemSelectedCommand": "editor.action.triggerParameterHints", - "extractInterfaceSupport": true, - "advancedUpgradeGradleSupport": true - }, - "triggerFiles": [] - }, - "trace": "verbose", - "workspaceFolders": "[\n {\n \"uri\": pathlib.Path(repository_absolute_path).as_uri(),\n \"name\": os.path.basename(repository_absolute_path),\n }\n ]" -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/eclipse_jdtls/runtime_dependencies.json b/src/solidlsp/language_servers/eclipse_jdtls/runtime_dependencies.json deleted file mode 100644 index 1b000405..00000000 --- a/src/solidlsp/language_servers/eclipse_jdtls/runtime_dependencies.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "_description": "This file lists the runtime dependencies for the Java Language Server", - "gradle": { - "platform-agnostic": { - "url": "https://services.gradle.org/distributions/gradle-7.3.3-bin.zip", - "archiveType": "zip", - "relative_extraction_path": "." - } - }, - "vscode-java": { - "darwin-arm64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", - "archiveType": "zip", - "relative_extraction_path": "vscode-java" - }, - "osx-arm64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", - "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "jre_home_path": "extension/jre/21.0.7-macosx-aarch64", - "jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", - "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", - "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", - "jdtls_readonly_config_path": "extension/server/config_mac_arm" - }, - "osx-x64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", - "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "jre_home_path": "extension/jre/21.0.7-macosx-x86_64", - "jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", - "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", - "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", - "jdtls_readonly_config_path": "extension/server/config_mac" - }, - "linux-arm64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", - "archiveType": "zip", - "relative_extraction_path": "vscode-java" - }, - "linux-x64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", - "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "jre_home_path": "extension/jre/21.0.7-linux-x86_64", - "jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java", - "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", - "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", - "jdtls_readonly_config_path": "extension/server/config_linux" - }, - "win-x64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", - "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "jre_home_path": "extension/jre/21.0.7-win32-x86_64", - "jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", - "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", - "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", - "jdtls_readonly_config_path": "extension/server/config_win" - } - }, - "intellicode": { - "platform-agnostic": { - "url": "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", - "alternate_url": "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/1.2.30/vspackage", - "archiveType": "zip", - "relative_extraction_path": "intellicode", - "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", - "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members" - } - } -} diff --git a/src/solidlsp/language_servers/elixir_tools/README.md b/src/solidlsp/language_servers/elixir_tools/README.md new file mode 100644 index 00000000..b3c56d17 --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/README.md @@ -0,0 +1,90 @@ +# Elixir Language Server Integration + +This directory contains the integration for Elixir language support using [Next LS](https://github.com/elixir-tools/next-ls) from the elixir-tools project. + +> **⚠️ Windows Not Supported**: Next LS does not provide Windows binaries, so Elixir language server integration is only available on Linux and macOS. + +## Known Issues + +### Next LS v0.23.3 Timeout Enumeration Bug +There is a known intermittent bug in Next LS v0.23.3 where `textDocument/definition` requests can fail with: +``` +Protocol.UndefinedError: protocol Enumerable not implemented for :timeout of type Atom +``` + +This bug is tracked in [Next LS Issue #543](https://github.com/elixir-tools/next-ls/issues/543) and primarily occurs in CI environments. The affected test (`test_request_defining_symbol_none`) is marked as expected to fail until this upstream bug is resolved. + +## Prerequisites + +Before using the Elixir language server integration, you need to have: + +1. **Elixir** installed and available in your PATH + - Install from: https://elixir-lang.org/install.html + - Verify with: `elixir --version` + +2. **Next LS** installed and available in your PATH + - Install from: https://github.com/elixir-tools/next-ls#installation + - Verify with: `nextls --version` + +## Features + +The Elixir integration provides: + +- **Language Server Protocol (LSP) support** via Next LS +- **File extension recognition** for `.ex` and `.exs` files +- **Project structure awareness** with proper handling of Elixir-specific directories: + - `_build/` - Compiled artifacts (ignored) + - `deps/` - Dependencies (ignored) + - `.elixir_ls/` - ElixirLS artifacts (ignored) + - `cover/` - Coverage reports (ignored) + - `lib/` - Source code (not ignored) + - `test/` - Test files (not ignored) + +## Configuration + +The integration uses the default Next LS configuration with: + +- **MIX_ENV**: `dev` +- **MIX_TARGET**: `host` +- **Experimental completions**: Disabled by default +- **Credo extension**: Enabled by default + +## Usage + +The Elixir language server is automatically selected when working with Elixir projects. It will be used for: + +- Code completion +- Go to definition +- Find references +- Document symbols +- Hover information +- Code formatting +- Diagnostics (via Credo integration) + +### Important: Project Compilation + +Next LS requires your Elixir project to be **compiled** for optimal performance, especially for: +- Cross-file reference resolution +- Complete symbol information +- Accurate go-to-definition + +**For production use**: Ensure your project is compiled with `mix compile` before using the language server. + +**For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Next LS performance. + +## Testing + +Run the Elixir-specific tests with: + +```bash +pytest test/solidlsp/elixir/ -m elixir +``` + +## Implementation Details + +- **Main class**: `ElixirTools` in `elixir_tools.py` +- **Initialization parameters**: Defined in `initialize_params.json` +- **Language identifier**: `"elixir"` +- **Command**: `nextls --stdio` + +The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior. \ No newline at end of file diff --git a/src/solidlsp/language_servers/elixir_tools/__init__.py b/src/solidlsp/language_servers/elixir_tools/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/__init__.py @@ -0,0 +1 @@ + diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py new file mode 100644 index 00000000..96674722 --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -0,0 +1,292 @@ +import json +import logging +import os +import pathlib +import stat +import subprocess +import threading +import time +from pathlib import PurePath + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + + +class ElixirTools(SolidLanguageServer): + """ + Provides Elixir specific instantiation of the LanguageServer class using Next LS from elixir-tools. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Elixir projects, we should ignore: + # - _build: compiled artifacts + # - deps: dependencies + # - node_modules: if the project has JavaScript components + # - .elixir_ls: ElixirLS artifacts (in case both are present) + # - cover: coverage reports + return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", "cover"] + + def _is_next_ls_internal_file(self, abs_path: str) -> bool: + """Check if an absolute path is a Next LS internal file that should be ignored.""" + return any( + pattern in abs_path + for pattern in [ + ".burrito", # Next LS runtime directory + "next_ls_erts-", # Next LS Erlang runtime + "_next_ls_private_", # Next LS private files + "/priv/monkey/", # Next LS monkey patching directory + ] + ) + + @override + def _send_references_request(self, relative_file_path: str, line: int, column: int): + """Override to filter out Next LS internal files from references.""" + from solidlsp.ls_utils import PathUtils + + # Get the raw response from the parent implementation + raw_response = super()._send_references_request(relative_file_path, line, column) + + if raw_response is None: + return None + + # Filter out Next LS internal files + filtered_response = [] + for item in raw_response: + if isinstance(item, dict) and "uri" in item: + abs_path = PathUtils.uri_to_path(item["uri"]) + if self._is_next_ls_internal_file(abs_path): + self.logger.log(f"Filtering out Next LS internal file: {abs_path}", logging.DEBUG) + continue + filtered_response.append(item) + + return filtered_response + + @staticmethod + def _get_elixir_version(): + """Get the installed Elixir version or None if not found.""" + try: + result = subprocess.run(["elixir", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + def setupRuntimeDependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + """ + Setup runtime dependencies for Next LS. + Downloads the Next LS binary for the current platform and returns the path to the executable. + """ + # Check if Elixir is available first + elixir_version = self._get_elixir_version() + if not elixir_version: + raise RuntimeError( + "Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH." + ) + + logger.log(f"Found Elixir: {elixir_version}", logging.INFO) + + platformId = PlatformUtils.get_platform_id() + + with open(str(PurePath(os.path.dirname(__file__), "runtime_dependencies.json")), encoding="utf-8") as f: + runtimeDependencies = json.load(f) + del runtimeDependencies["_description"] + + os.makedirs(str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static")), exist_ok=True) + + # Map platform IDs to runtime dependency keys + platform_mapping = { + "linux-x64": "linux-x64", + "osx-x64": "darwin-x64", + "osx-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "darwin-arm64": "darwin-arm64", + "win-x64": "win-x64", + } + + platform_key = platform_mapping.get(platformId.value) + if not platform_key: + raise RuntimeError(f"Unsupported platform for Next LS: {platformId.value}") + + # Check for Windows and provide a helpful error message + if platformId.value.startswith("win"): + raise RuntimeError( + "Windows is not supported by Next LS. The Next LS project does not provide Windows binaries. " + "Consider using Windows Subsystem for Linux (WSL) or a virtual machine with Linux/macOS." + ) + + dependency = runtimeDependencies["next_ls"][platform_key] + next_ls_dir = str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"])) + os.makedirs(next_ls_dir, exist_ok=True) + + executable_path = str(PurePath(next_ls_dir, dependency["executable_name"])) + binary_path = str(PurePath(next_ls_dir, dependency["binary_name"])) + + if not os.path.exists(executable_path): + logger.log(f"Downloading Next LS binary from {dependency['url']}", logging.INFO) + FileUtils.download_file(logger, dependency["url"], binary_path) + + # Make the binary executable on Unix-like systems + if not platformId.value.startswith("win"): + os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + # Create a symlink or copy with the expected name + if binary_path != executable_path: + if os.path.exists(executable_path): + os.remove(executable_path) + if platformId.value.startswith("win"): + # On Windows, copy the file + import shutil + + shutil.copy2(binary_path, executable_path) + else: + # On Unix-like systems, create a symlink + os.symlink(os.path.basename(binary_path), executable_path) + + assert os.path.exists(executable_path), f"Next LS executable not found at {executable_path}" + + logger.log(f"Next LS binary ready at: {executable_path}", logging.INFO) + return executable_path + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + nextls_executable_path = self.setupRuntimeDependencies(logger, config) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=f'"{nextls_executable_path}" --stdio', cwd=repository_root_path), + "elixir", + ) + self.server_ready = threading.Event() + self.request_id = 0 + + # Set generous timeout for Next LS which can be slow to initialize and respond + self.set_request_timeout(180.0) # 60 seconds for all environments + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Next LS Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + def _start_server(self): + """Start Next LS server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + """Handle window/logMessage notifications from Next LS""" + message_text = msg.get("message", "") + self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) + + # Check for the specific Next LS readiness signal + # Based on Next LS source: "Runtime for folder #{name} is ready..." + if "Runtime for folder" in message_text and "is ready..." in message_text: + self.logger.log("Next LS runtime is ready based on official log message", logging.INFO) + self.server_ready.set() + + def do_nothing(params): + return + + def check_server_ready(params): + """ + Handle $/progress notifications from Next LS. + Keep as fallback for error detection, but primary readiness detection + is now done via window/logMessage handler. + """ + value = params.get("value", {}) + + # Check for initialization completion progress (fallback signal) + if value.get("kind") == "end": + message = value.get("message", "") + if "has initialized!" in message: + self.logger.log("Next LS initialization progress completed", logging.INFO) + # Note: We don't set server_ready here - we wait for the log message + + def work_done_progress(params): + """ + Handle $/workDoneProgress notifications from Next LS. + Keep for completeness but primary readiness detection is via window/logMessage. + """ + value = params.get("value", {}) + if value.get("kind") == "end": + self.logger.log("Next LS work done progress completed", logging.INFO) + # Note: We don't set server_ready here - we wait for the log message + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", check_server_ready) + self.server.on_notification("window/workDoneProgress/create", do_nothing) + self.server.on_notification("$/workDoneProgress", work_done_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting Next LS server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities - be more lenient with Next LS + self.logger.log(f"Next LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO) + + # Next LS may not provide all capabilities immediately, so we check for basic ones + assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}" + + # Some capabilities might be optional or provided later + if "completionProvider" not in init_response["capabilities"]: + self.logger.log("Warning: completionProvider not available in initial capabilities", logging.WARNING) + if "definitionProvider" not in init_response["capabilities"]: + self.logger.log("Warning: definitionProvider not available in initial capabilities", logging.WARNING) + + self.server.notify.initialized({}) + self.completions_available.set() + + # Wait for Next LS to send the specific "Runtime for folder X is ready..." log message + # This is the authoritative signal that Next LS is truly ready for requests + ready_timeout = 180.0 + self.logger.log(f"Waiting up to {ready_timeout} seconds for Next LS runtime readiness...", logging.INFO) + + if self.server_ready.wait(timeout=ready_timeout): + self.logger.log("Next LS is ready and available for requests", logging.INFO) + + # Add a small settling period to ensure background indexing is complete + # Next LS often continues compilation/indexing in background after ready signal + settling_time = 120.0 + self.logger.log(f"Allowing {settling_time} seconds for Next LS background indexing to complete...", logging.INFO) + time.sleep(settling_time) + self.logger.log("Next LS settling period complete", logging.INFO) + else: + error_msg = f"Next LS failed to initialize within {ready_timeout} seconds. This may indicate a problem with the Elixir installation, project compilation, or Next LS itself." + self.logger.log(error_msg, logging.ERROR) + raise RuntimeError(error_msg) diff --git a/src/solidlsp/language_servers/elixir_tools/initialize_params.json b/src/solidlsp/language_servers/elixir_tools/initialize_params.json new file mode 100644 index 00000000..51ce266f --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/initialize_params.json @@ -0,0 +1,97 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "initializationOptions": { + "mix_env": "dev", + "mix_target": "host", + "experimental": { + "completions": { + "enable": false + } + }, + "extensions": { + "credo": { + "enable": true, + "cli_options": [] + } + } + }, + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": true, + "dynamicRegistration": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ] + } + }, + "definition": { + "dynamicRegistration": true + }, + "references": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "hierarchicalDocumentSymbolSupport": true, + "symbolKind": { + "valueSet": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "formatting": { + "dynamicRegistration": true + }, + "codeAction": { + "dynamicRegistration": true, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + } + } + }, + "workspace": { + "workspaceFolders": true, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "executeCommand": { + "dynamicRegistration": true + } + } + }, + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/elixir_tools/runtime_dependencies.json b/src/solidlsp/language_servers/elixir_tools/runtime_dependencies.json new file mode 100644 index 00000000..32641a0d --- /dev/null +++ b/src/solidlsp/language_servers/elixir_tools/runtime_dependencies.json @@ -0,0 +1,33 @@ +{ + "_description": "This file lists the runtime dependencies for the Elixir Language Server (Next LS)", + "next_ls": { + "linux-x64": { + "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_linux_amd64", + "archiveType": "binary", + "relative_extraction_path": "next_ls", + "binary_name": "next_ls_linux_amd64", + "executable_name": "nextls" + }, + "darwin-x64": { + "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_amd64", + "archiveType": "binary", + "relative_extraction_path": "next_ls", + "binary_name": "next_ls_darwin_amd64", + "executable_name": "nextls" + }, + "darwin-arm64": { + "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_arm64", + "archiveType": "binary", + "relative_extraction_path": "next_ls", + "binary_name": "next_ls_darwin_arm64", + "executable_name": "nextls" + }, + "win-x64": { + "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_windows_amd64.exe", + "archiveType": "binary", + "relative_extraction_path": "next_ls", + "binary_name": "next_ls_windows_amd64.exe", + "executable_name": "nextls.exe" + } + } +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/gopls/gopls.py b/src/solidlsp/language_servers/gopls.py similarity index 75% rename from src/solidlsp/language_servers/gopls/gopls.py rename to src/solidlsp/language_servers/gopls.py index c3fadd71..70eb15fa 100644 --- a/src/solidlsp/language_servers/gopls/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -1,4 +1,3 @@ -import json import logging import os import pathlib @@ -49,19 +48,19 @@ class Gopls(SolidLanguageServer): return None return None - @classmethod - def setup_runtime_dependency(cls): + @staticmethod + def _setup_runtime_dependency(): """ Check if required Go runtime dependencies are available. Raises RuntimeError with helpful message if dependencies are missing. """ - go_version = cls._get_go_version() + go_version = Gopls._get_go_version() if not go_version: raise RuntimeError( "Go is not installed. Please install Go from https://golang.org/doc/install and make sure it is added to your PATH." ) - gopls_version = cls._get_gopls_version() + gopls_version = Gopls._get_gopls_version() if not gopls_version: raise RuntimeError( "Found a Go version but gopls is not installed.\n" @@ -72,7 +71,7 @@ class Gopls(SolidLanguageServer): return True def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): - self.setup_runtime_dependency() + self._setup_runtime_dependency() super().__init__( config, @@ -84,29 +83,37 @@ class Gopls(SolidLanguageServer): self.server_ready = threading.Event() self.request_id = 0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ - Returns the initialize params for the TypeScript Language Server. + Returns the initialize params for the Go Language Server. """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: - d = json.load(f) - - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params def _start_server(self): """Start gopls server process""" diff --git a/src/solidlsp/language_servers/intelephense/intelephense.py b/src/solidlsp/language_servers/intelephense.py similarity index 66% rename from src/solidlsp/language_servers/intelephense/intelephense.py rename to src/solidlsp/language_servers/intelephense.py index 19eaa8b1..f9c83976 100644 --- a/src/solidlsp/language_servers/intelephense/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -2,12 +2,10 @@ Provides PHP specific instantiation of the LanguageServer class using Intelephense. """ -import json import logging import os import pathlib import shutil -import subprocess from time import sleep from overrides import override @@ -19,6 +17,8 @@ from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DefinitionParams, InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from .common import RuntimeDependency, RuntimeDependencyCollection + class Intelephense(SolidLanguageServer): """ @@ -33,9 +33,10 @@ class Intelephense(SolidLanguageServer): # - cache: commonly used for caching return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"] - def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + @classmethod + def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: """ - Setup runtime dependencies for Intelephense. + Setup runtime dependencies for Intelephense and return the command to start the server. """ platform_id = PlatformUtils.get_platform_id() @@ -50,13 +51,6 @@ class Intelephense(SolidLanguageServer): ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy PHP at the moment" - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f: - d = json.load(f) - del d["_description"] - - runtime_dependencies = d.get("runtimeDependencies", []) - intelephense_ls_dir = os.path.join(os.path.dirname(__file__), "static", "php-lsp") - # Verify both node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." @@ -64,69 +58,61 @@ class Intelephense(SolidLanguageServer): assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." # Install intelephense if not already installed - if not os.path.exists(intelephense_ls_dir): - os.makedirs(intelephense_ls_dir, exist_ok=True) - for dependency in runtime_dependencies: - # Windows doesn't support the 'user' parameter and doesn't have pwd module - if PlatformUtils.get_platform_id().value.startswith("win"): - subprocess.run( - dependency["command"], - shell=True, - check=True, - cwd=intelephense_ls_dir, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - else: - # On Unix-like systems, run as non-root user - import pwd - - user = pwd.getpwuid(os.getuid()).pw_name - subprocess.run( - dependency["command"], - shell=True, - check=True, - user=user, - cwd=intelephense_ls_dir, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - + intelephense_ls_dir = os.path.join(cls.ls_resources_dir(), "php-lsp") + os.makedirs(intelephense_ls_dir, exist_ok=True) intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") - assert os.path.exists(intelephense_executable_path), "intelephense executable not found. Please install intelephense and try again." + if not os.path.exists(intelephense_executable_path): + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="intelephense", + command="npm install --prefix ./ intelephense@1.14.4", + platform_id="any", + ) + ] + ) + deps.install(logger, intelephense_ls_dir) + + assert os.path.exists( + intelephense_executable_path + ), f"intelephense executable not found at {intelephense_executable_path}, something went wrong." return f"{intelephense_executable_path} --stdio" def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): # Setup runtime dependencies before initializing - intelephense_cmd = self.setup_runtime_dependencies(logger, config) + intelephense_cmd = self._setup_runtime_dependencies(logger, config) super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), "php") self.request_id = 0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the TypeScript Language Server. """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: - d = json.load(f) + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d + return initialize_params def _start_server(self): """Start Intelephense server process""" diff --git a/src/solidlsp/language_servers/intelephense/initialize_params.json b/src/solidlsp/language_servers/intelephense/initialize_params.json deleted file mode 100644 index 4330560e..00000000 --- a/src/solidlsp/language_servers/intelephense/initialize_params.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "locale": "en", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "capabilities": { - "textDocument": { - "synchronization": { - "didSave": true, - "dynamicRegistration": true - }, - "completion": { - "dynamicRegistration": true, - "completionItem": { - "snippetSupport": true - } - }, - "definition": { - "dynamicRegistration": true - } - }, - "workspace": { - "workspaceFolders": true, - "didChangeConfiguration": { - "dynamicRegistration": true - } - } - }, - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/intelephense/runtime_dependencies.json b/src/solidlsp/language_servers/intelephense/runtime_dependencies.json deleted file mode 100644 index e3a6d7e1..00000000 --- a/src/solidlsp/language_servers/intelephense/runtime_dependencies.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "_description": "Used to download the runtime dependencies for running intelephense. Obtained from https://www.npmjs.com/package/intelephense", - "runtimeDependencies": [ - { - "id": "intelephense", - "description": "Intelephense package for Linux, OSX, and Windows. Both x64 and arm64 are supported.", - "command": "npm install --prefix ./ intelephense@1.14.4" - } - ] -} diff --git a/src/solidlsp/language_servers/jedi_language_server/initialize_params.json b/src/solidlsp/language_servers/jedi_language_server/initialize_params.json deleted file mode 100644 index 5a04872c..00000000 --- a/src/solidlsp/language_servers/jedi_language_server/initialize_params.json +++ /dev/null @@ -1,905 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "clientInfo": { - "name": "Visual Studio Code - Insiders", - "version": "1.81.0-insider" - }, - "locale": "en", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "capabilities": { - "workspace": { - "applyEdit": true, - "workspaceEdit": { - "documentChanges": true, - "resourceOperations": [ - "create", - "rename", - "delete" - ], - "failureHandling": "textOnlyTransactional", - "normalizesLineEndings": true, - "changeAnnotationSupport": { - "groupsOnLabel": true - } - }, - "configuration": true, - "didChangeWatchedFiles": { - "dynamicRegistration": true, - "relativePatternSupport": true - }, - "symbol": { - "dynamicRegistration": 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 - ] - }, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "resolveSupport": { - "properties": [ - "location.range" - ] - } - }, - "codeLens": { - "refreshSupport": true - }, - "executeCommand": { - "dynamicRegistration": true - }, - "didChangeConfiguration": { - "dynamicRegistration": true - }, - "workspaceFolders": true, - "semanticTokens": { - "refreshSupport": true - }, - "fileOperations": { - "dynamicRegistration": true, - "didCreate": true, - "didRename": true, - "didDelete": true, - "willCreate": true, - "willRename": true, - "willDelete": true - }, - "inlineValue": { - "refreshSupport": true - }, - "inlayHint": { - "refreshSupport": true - }, - "diagnostics": { - "refreshSupport": true - } - }, - "textDocument": { - "publishDiagnostics": { - "relatedInformation": true, - "versionSupport": false, - "tagSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "codeDescriptionSupport": true, - "dataSupport": true - }, - "synchronization": { - "dynamicRegistration": true, - "willSave": true, - "willSaveWaitUntil": true, - "didSave": true - }, - "completion": { - "dynamicRegistration": true, - "contextSupport": true, - "completionItem": { - "snippetSupport": true, - "commitCharactersSupport": true, - "documentationFormat": [ - "markdown", - "plaintext" - ], - "deprecatedSupport": true, - "preselectSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "insertReplaceSupport": true, - "resolveSupport": { - "properties": [ - "documentation", - "detail", - "additionalTextEdits" - ] - }, - "insertTextModeSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "labelDetailsSupport": true - }, - "insertTextMode": 2, - "completionItemKind": { - "valueSet": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25 - ] - }, - "completionList": { - "itemDefaults": [ - "commitCharacters", - "editRange", - "insertTextFormat", - "insertTextMode" - ] - } - }, - "hover": { - "dynamicRegistration": true, - "contentFormat": [ - "markdown", - "plaintext" - ] - }, - "signatureHelp": { - "dynamicRegistration": true, - "signatureInformation": { - "documentationFormat": [ - "markdown", - "plaintext" - ], - "parameterInformation": { - "labelOffsetSupport": true - }, - "activeParameterSupport": true - }, - "contextSupport": true - }, - "definition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "references": { - "dynamicRegistration": true - }, - "documentHighlight": { - "dynamicRegistration": true - }, - "documentSymbol": { - "dynamicRegistration": 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 - ] - }, - "hierarchicalDocumentSymbolSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "labelSupport": true - }, - "codeAction": { - "dynamicRegistration": true, - "isPreferredSupport": true, - "disabledSupport": true, - "dataSupport": true, - "resolveSupport": { - "properties": [ - "edit" - ] - }, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports" - ] - } - }, - "honorsChangeAnnotations": false - }, - "codeLens": { - "dynamicRegistration": true - }, - "formatting": { - "dynamicRegistration": true - }, - "rangeFormatting": { - "dynamicRegistration": true - }, - "onTypeFormatting": { - "dynamicRegistration": true - }, - "rename": { - "dynamicRegistration": true, - "prepareSupport": true, - "prepareSupportDefaultBehavior": 1, - "honorsChangeAnnotations": true - }, - "documentLink": { - "dynamicRegistration": true, - "tooltipSupport": true - }, - "typeDefinition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "implementation": { - "dynamicRegistration": true, - "linkSupport": true - }, - "colorProvider": { - "dynamicRegistration": true - }, - "foldingRange": { - "dynamicRegistration": true, - "rangeLimit": 5000, - "lineFoldingOnly": true, - "foldingRangeKind": { - "valueSet": [ - "comment", - "imports", - "region" - ] - }, - "foldingRange": { - "collapsedText": false - } - }, - "declaration": { - "dynamicRegistration": true, - "linkSupport": true - }, - "selectionRange": { - "dynamicRegistration": true - }, - "callHierarchy": { - "dynamicRegistration": true - }, - "semanticTokens": { - "dynamicRegistration": true, - "tokenTypes": [ - "namespace", - "type", - "class", - "enum", - "interface", - "struct", - "typeParameter", - "parameter", - "variable", - "property", - "enumMember", - "event", - "function", - "method", - "macro", - "keyword", - "modifier", - "comment", - "string", - "number", - "regexp", - "operator", - "decorator" - ], - "tokenModifiers": [ - "declaration", - "definition", - "readonly", - "static", - "deprecated", - "abstract", - "async", - "modification", - "documentation", - "defaultLibrary" - ], - "formats": [ - "relative" - ], - "requests": { - "range": true, - "full": { - "delta": true - } - }, - "multilineTokenSupport": false, - "overlappingTokenSupport": false, - "serverCancelSupport": true, - "augmentsSyntaxTokens": false - }, - "linkedEditingRange": { - "dynamicRegistration": true - }, - "typeHierarchy": { - "dynamicRegistration": true - }, - "inlineValue": { - "dynamicRegistration": true - }, - "inlayHint": { - "dynamicRegistration": true, - "resolveSupport": { - "properties": [ - "tooltip", - "textEdits", - "label.tooltip", - "label.location", - "label.command" - ] - } - }, - "diagnostic": { - "dynamicRegistration": true, - "relatedDocumentSupport": false - } - }, - "window": { - "showMessage": { - "messageActionItem": { - "additionalPropertiesSupport": true - } - }, - "showDocument": { - "support": true - }, - "workDoneProgress": true - }, - "general": { - "staleRequestSupport": { - "cancel": true, - "retryOnContentModified": [ - "textDocument/semanticTokens/full", - "textDocument/semanticTokens/range", - "textDocument/semanticTokens/full/delta" - ] - }, - "regularExpressions": { - "engine": "ECMAScript", - "version": "ES2020" - }, - "markdown": { - "parser": "marked", - "version": "1.1.0", - "allowedTags": [ - "ul", - "li", - "p", - "code", - "blockquote", - "ol", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "em", - "pre", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - "div", - "del", - "a", - "strong", - "br", - "img", - "span" - ] - }, - "positionEncodings": [ - "utf-16" - ] - }, - "notebookDocument": { - "synchronization": { - "dynamicRegistration": true, - "executionSummarySupport": true - } - }, - "experimental": { - "snippetTextEdit": true, - "codeActionGroup": true, - "hoverActions": true, - "serverStatusNotification": true, - "colorDiagnosticOutput": true, - "openServerLogs": true, - "commands": { - "commands": [ - "rust-analyzer.runSingle", - "rust-analyzer.debugSingle", - "rust-analyzer.showReferences", - "rust-analyzer.gotoLocation", - "editor.action.triggerParameterHints" - ] - } - } - }, - "initializationOptions": { - "cargoRunner": null, - "runnables": { - "extraEnv": null, - "problemMatcher": [ - "$rustc" - ], - "command": null, - "extraArgs": [] - }, - "server": { - "path": null, - "extraEnv": null - }, - "trace": { - "server": "verbose", - "extension": true - }, - "debug": { - "engine": "auto", - "sourceFileMap": { - "/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust" - }, - "openDebugPane": false, - "engineSettings": {} - }, - "restartServerOnConfigChange": false, - "typing": { - "continueCommentsOnNewline": true, - "autoClosingAngleBrackets": { - "enable": false - } - }, - "diagnostics": { - "previewRustcOutput": false, - "useRustcErrorCode": false, - "disabled": [], - "enable": true, - "experimental": { - "enable": false - }, - "remapPrefix": {}, - "warningsAsHint": [], - "warningsAsInfo": [] - }, - "discoverProjectCommand": null, - "showUnlinkedFileNotification": true, - "showDependenciesExplorer": true, - "assist": { - "emitMustUse": false, - "expressionFillDefault": "todo" - }, - "cachePriming": { - "enable": true, - "numThreads": 0 - }, - "cargo": { - "autoreload": true, - "buildScripts": { - "enable": true, - "invocationLocation": "workspace", - "invocationStrategy": "per_workspace", - "overrideCommand": null, - "useRustcWrapper": true - }, - "cfgs": {}, - "extraArgs": [], - "extraEnv": {}, - "features": [], - "noDefaultFeatures": false, - "sysroot": "discover", - "sysrootSrc": null, - "target": null, - "unsetTest": [ - "core" - ] - }, - "checkOnSave": true, - "check": { - "allTargets": true, - "command": "check", - "extraArgs": [], - "extraEnv": {}, - "features": null, - "invocationLocation": "workspace", - "invocationStrategy": "per_workspace", - "noDefaultFeatures": null, - "overrideCommand": null, - "targets": null - }, - "completion": { - "autoimport": { - "enable": true - }, - "autoself": { - "enable": true - }, - "callable": { - "snippets": "fill_arguments" - }, - "limit": null, - "postfix": { - "enable": true - }, - "privateEditable": { - "enable": false - }, - "snippets": { - "custom": { - "Arc::new": { - "postfix": "arc", - "body": "Arc::new(${receiver})", - "requires": "std::sync::Arc", - "description": "Put the expression into an `Arc`", - "scope": "expr" - }, - "Rc::new": { - "postfix": "rc", - "body": "Rc::new(${receiver})", - "requires": "std::rc::Rc", - "description": "Put the expression into an `Rc`", - "scope": "expr" - }, - "Box::pin": { - "postfix": "pinbox", - "body": "Box::pin(${receiver})", - "requires": "std::boxed::Box", - "description": "Put the expression into a pinned `Box`", - "scope": "expr" - }, - "Ok": { - "postfix": "ok", - "body": "Ok(${receiver})", - "description": "Wrap the expression in a `Result::Ok`", - "scope": "expr" - }, - "Err": { - "postfix": "err", - "body": "Err(${receiver})", - "description": "Wrap the expression in a `Result::Err`", - "scope": "expr" - }, - "Some": { - "postfix": "some", - "body": "Some(${receiver})", - "description": "Wrap the expression in an `Option::Some`", - "scope": "expr" - } - } - } - }, - "files": { - "excludeDirs": [], - "watcher": "client" - }, - "highlightRelated": { - "breakPoints": { - "enable": true - }, - "closureCaptures": { - "enable": true - }, - "exitPoints": { - "enable": true - }, - "references": { - "enable": true - }, - "yieldPoints": { - "enable": true - } - }, - "hover": { - "actions": { - "debug": { - "enable": true - }, - "enable": true, - "gotoTypeDef": { - "enable": true - }, - "implementations": { - "enable": true - }, - "references": { - "enable": false - }, - "run": { - "enable": true - } - }, - "documentation": { - "enable": true, - "keywords": { - "enable": true - } - }, - "links": { - "enable": true - }, - "memoryLayout": { - "alignment": "hexadecimal", - "enable": true, - "niches": false, - "offset": "hexadecimal", - "size": "both" - } - }, - "imports": { - "granularity": { - "enforce": false, - "group": "crate" - }, - "group": { - "enable": true - }, - "merge": { - "glob": true - }, - "prefer": { - "no": { - "std": false - } - }, - "prefix": "plain" - }, - "inlayHints": { - "bindingModeHints": { - "enable": false - }, - "chainingHints": { - "enable": true - }, - "closingBraceHints": { - "enable": true, - "minLines": 25 - }, - "closureCaptureHints": { - "enable": false - }, - "closureReturnTypeHints": { - "enable": "never" - }, - "closureStyle": "impl_fn", - "discriminantHints": { - "enable": "never" - }, - "expressionAdjustmentHints": { - "enable": "never", - "hideOutsideUnsafe": false, - "mode": "prefix" - }, - "lifetimeElisionHints": { - "enable": "never", - "useParameterNames": false - }, - "maxLength": 25, - "parameterHints": { - "enable": true - }, - "reborrowHints": { - "enable": "never" - }, - "renderColons": true, - "typeHints": { - "enable": true, - "hideClosureInitialization": false, - "hideNamedConstructor": false - } - }, - "interpret": { - "tests": false - }, - "joinLines": { - "joinAssignments": true, - "joinElseIf": true, - "removeTrailingComma": true, - "unwrapTrivialBlock": true - }, - "lens": { - "debug": { - "enable": true - }, - "enable": true, - "forceCustomCommands": true, - "implementations": { - "enable": true - }, - "location": "above_name", - "references": { - "adt": { - "enable": false - }, - "enumVariant": { - "enable": false - }, - "method": { - "enable": false - }, - "trait": { - "enable": false - } - }, - "run": { - "enable": true - } - }, - "linkedProjects": [], - "lru": { - "capacity": null, - "query": { - "capacities": {} - } - }, - "notifications": { - "cargoTomlNotFound": true - }, - "numThreads": null, - "procMacro": { - "attributes": { - "enable": true - }, - "enable": true, - "ignored": {}, - "server": null - }, - "references": { - "excludeImports": false - }, - "rustc": { - "source": null - }, - "rustfmt": { - "extraArgs": [], - "overrideCommand": null, - "rangeFormatting": { - "enable": false - } - }, - "semanticHighlighting": { - "doc": { - "comment": { - "inject": { - "enable": true - } - } - }, - "nonStandardTokens": true, - "operator": { - "enable": true, - "specialization": { - "enable": false - } - }, - "punctuation": { - "enable": false, - "separate": { - "macro": { - "bang": false - } - }, - "specialization": { - "enable": false - } - }, - "strings": { - "enable": true - } - }, - "signatureInfo": { - "detail": "full", - "documentation": { - "enable": true - } - }, - "workspace": { - "symbols": { - "maxSymbols": 0 - } - } - }, - "trace": "verbose", - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/jedi_language_server/jedi_server.py b/src/solidlsp/language_servers/jedi_language_server/jedi_server.py deleted file mode 100644 index 467d3cab..00000000 --- a/src/solidlsp/language_servers/jedi_language_server/jedi_server.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. -""" - -import json -import logging -import os -import pathlib - -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.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo - - -class JediServer(SolidLanguageServer): - """ - Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. - """ - - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): - """ - Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. - """ - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), - "python", - ) - - @override - def is_ignored_dirname(self, dirname: str) -> bool: - return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] - - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the Jedi Language Server. - """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: - d = json.load(f) - - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d - - def _start_server(self): - """ - Starts the JEDI Language Server - """ - - def execute_client_command_handler(params): - return [] - - def do_nothing(params): - return - - def check_experimental_status(params): - if params["quiescent"] == True: - self.completions_available.set() - - def window_log_message(msg): - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - - self.server.on_request("client/registerCapability", do_nothing) - self.server.on_notification("language/status", do_nothing) - 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 jedi-language-server 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"] - assert init_response["capabilities"]["completionProvider"] == { - "triggerCharacters": [".", "'", '"'], - "resolveProvider": True, - } - - self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/jedi_server.py b/src/solidlsp/language_servers/jedi_server.py new file mode 100644 index 00000000..161ce754 --- /dev/null +++ b/src/solidlsp/language_servers/jedi_server.py @@ -0,0 +1,194 @@ +""" +Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. +""" + +import logging +import os +import pathlib + +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.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + + +class JediServer(SolidLanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. + """ + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), + "python", + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Jedi Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "clientInfo": {"name": "Serena", "version": "0.1.0"}, + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": root_uri, + # Note: this is not necessarily the minimal set of capabilities... + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "configuration": True, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "workspaceFolders": True, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "linkedEditingRange": {"dynamicRegistration": True}, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "inlayHint": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + "experimental": { + "serverStatusNotification": True, + "openServerLogs": True, + }, + }, + # See https://github.com/pappasam/jedi-language-server?tab=readme-ov-file + # We use the default options except for maxSymbols, where 0 means no limit + "initializationOptions": { + "workspace": { + "symbols": {"ignoreFolders": [".nox", ".tox", ".venv", "__pycache__", "venv"], "maxSymbols": 0}, + }, + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params + + def _start_server(self): + """ + Starts the JEDI Language Server + """ + + def execute_client_command_handler(params): + return [] + + def do_nothing(params): + return + + def check_experimental_status(params): + if params["quiescent"] == True: + self.completions_available.set() + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + 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 jedi-language-server 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"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": [".", "'", '"'], + "resolveProvider": True, + } + + self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py new file mode 100644 index 00000000..ef2a77ca --- /dev/null +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -0,0 +1,463 @@ +""" +Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. +""" + +import dataclasses +import logging +import os +import pathlib +import stat + +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 + + +@dataclasses.dataclass +class KotlinRuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of Kotlin Language Server + """ + + java_path: str + java_home_path: str + kotlin_executable_path: str + + +class KotlinLanguageServer(SolidLanguageServer): + """ + Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. + """ + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + runtime_dependency_paths = self._setup_runtime_dependencies(logger, config) + self.runtime_dependency_paths = runtime_dependency_paths + + # Create command to execute the Kotlin Language Server script + cmd = f'"{self.runtime_dependency_paths.kotlin_executable_path}"' + + # Set environment variables including JAVA_HOME + proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), + "kotlin", + ) + + @classmethod + def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> KotlinRuntimeDependencyPaths: + """ + Setup runtime dependencies for Kotlin Language Server and return the paths. + """ + platform_id = PlatformUtils.get_platform_id() + + # Verify platform support + assert ( + platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-") + ), "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" + + # Runtime dependency information + runtime_dependencies = { + "runtimeDependency": { + "id": "KotlinLsp", + "description": "Kotlin Language Server", + "url": "https://github.com/fwcd/kotlin-language-server/releases/download/1.3.13/server.zip", + "archiveType": "zip", + }, + "java": { + "win-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-win32-x86_64", + "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", + }, + "linux-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-x86_64", + "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java", + }, + "linux-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-aarch64", + "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java", + }, + "osx-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-x86_64", + "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", + }, + "osx-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-aarch64", + "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", + }, + }, + } + + kotlin_dependency = runtime_dependencies["runtimeDependency"] + java_dependency = runtime_dependencies["java"][platform_id.value] + + # Setup paths for dependencies + static_dir = os.path.join(cls.ls_resources_dir(), "kotlin_language_server") + os.makedirs(static_dir, exist_ok=True) + + # Setup Java paths + java_dir = os.path.join(static_dir, "java") + os.makedirs(java_dir, exist_ok=True) + + java_home_path = os.path.join(java_dir, java_dependency["java_home_path"]) + java_path = os.path.join(java_dir, java_dependency["java_path"]) + + # Download and extract Java if not exists + if not os.path.exists(java_path): + logger.log(f"Downloading Java for {platform_id.value}...", logging.INFO) + FileUtils.download_and_extract_archive(logger, java_dependency["url"], java_dir, java_dependency["archiveType"]) + # Make Java executable + if not platform_id.value.startswith("win-"): + os.chmod(java_path, 0o755) + + assert os.path.exists(java_path), f"Java executable not found at {java_path}" + + # Setup Kotlin Language Server paths + kotlin_ls_dir = os.path.join(static_dir, "server") + + # Get platform-specific executable script path + if platform_id.value.startswith("win-"): + kotlin_script = os.path.join(kotlin_ls_dir, "bin", "kotlin-language-server.bat") + else: + kotlin_script = os.path.join(kotlin_ls_dir, "bin", "kotlin-language-server") + + # Download and extract Kotlin Language Server if script doesn't exist + if not os.path.exists(kotlin_script): + logger.log("Downloading Kotlin Language Server...", logging.INFO) + FileUtils.download_and_extract_archive(logger, kotlin_dependency["url"], static_dir, kotlin_dependency["archiveType"]) + + # Make script executable on Unix platforms + if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): + os.chmod( + kotlin_script, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH + ) + + # Use script file + if os.path.exists(kotlin_script): + kotlin_executable_path = kotlin_script + logger.log(f"Using Kotlin Language Server script at {kotlin_script}", logging.INFO) + else: + raise FileNotFoundError(f"Kotlin Language Server script not found at {kotlin_script}") + + return KotlinRuntimeDependencyPaths( + java_path=java_path, java_home_path=java_home_path, kotlin_executable_path=kotlin_executable_path + ) + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Kotlin Language Server. + """ + if not os.path.isabs(repository_absolute_path): + repository_absolute_path = os.path.abspath(repository_absolute_path) + + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "clientInfo": {"name": "Multilspy Kotlin Client", "version": "1.0.0"}, + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "codeLens": {"refreshSupport": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": False, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": False, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "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] + }, + "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "isPreferredSupport": True, + "disabledSupport": True, + "dataSupport": True, + "resolveSupport": {"properties": ["edit"]}, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + "honorsChangeAnnotations": False, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, + "foldingRange": {"collapsedText": False}, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + "serverCancelSupport": True, + "augmentsSyntaxTokens": True, + }, + "linkedEditingRange": {"dynamicRegistration": True}, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "inlayHint": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + "general": { + "staleRequestSupport": { + "cancel": True, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta", + ], + }, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "markdown": {"parser": "marked", "version": "1.1.0"}, + "positionEncodings": ["utf-16"], + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + }, + "initializationOptions": { + "workspaceFolders": [root_uri], + "storagePath": None, + "codegen": {"enabled": False}, + "compiler": {"jvm": {"target": "default"}}, + "completion": {"snippets": {"enabled": True}}, + "diagnostics": {"enabled": True, "level": 4, "debounceTime": 250}, + "scripts": {"enabled": True, "buildScriptsEnabled": True}, + "indexing": {"enabled": True}, + "externalSources": {"useKlsScheme": False, "autoConvertToKotlin": False}, + "inlayHints": {"typeHints": False, "parameterHints": False, "chainedHints": False}, + "formatting": { + "formatter": "ktfmt", + "ktfmt": { + "style": "google", + "indent": 4, + "maxWidth": 100, + "continuationIndent": 8, + "removeUnusedImports": True, + }, + }, + }, + "trace": "verbose", + "processId": os.getpid(), + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params + + def _start_server(self): + """ + Starts the Kotlin Language Server + """ + + def execute_client_command_handler(params): + return [] + + def do_nothing(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + 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.logger.log("Starting Kotlin 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) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "hoverProvider" in capabilities, "Server must support hover" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "signatureHelpProvider" in capabilities, "Server must support signature help" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" + assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" + + self.server.notify.initialized({}) + self.completions_available.set() diff --git a/src/solidlsp/language_servers/kotlin_language_server/initialize_params.json b/src/solidlsp/language_servers/kotlin_language_server/initialize_params.json deleted file mode 100644 index a7b2ec0a..00000000 --- a/src/solidlsp/language_servers/kotlin_language_server/initialize_params.json +++ /dev/null @@ -1,521 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "clientInfo": { - "name": "Multilspy Kotlin Client", - "version": "1.0.0" - }, - "locale": "en", - "rootPath": "repository_absolute_path", - "rootUri": "pathlib.Path(repository_absolute_path).as_uri()", - "capabilities": { - "workspace": { - "applyEdit": true, - "workspaceEdit": { - "documentChanges": true, - "resourceOperations": [ - "create", - "rename", - "delete" - ], - "failureHandling": "textOnlyTransactional", - "normalizesLineEndings": true, - "changeAnnotationSupport": { - "groupsOnLabel": true - } - }, - "didChangeConfiguration": { - "dynamicRegistration": true - }, - "didChangeWatchedFiles": { - "dynamicRegistration": true, - "relativePatternSupport": true - }, - "symbol": { - "dynamicRegistration": 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 - ] - }, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "resolveSupport": { - "properties": [ - "location.range" - ] - } - }, - "codeLens": { - "refreshSupport": true - }, - "executeCommand": { - "dynamicRegistration": true - }, - "configuration": true, - "workspaceFolders": true, - "semanticTokens": { - "refreshSupport": true - }, - "fileOperations": { - "dynamicRegistration": true, - "didCreate": true, - "didRename": true, - "didDelete": true, - "willCreate": true, - "willRename": true, - "willDelete": true - }, - "inlineValue": { - "refreshSupport": true - }, - "inlayHint": { - "refreshSupport": true - }, - "diagnostics": { - "refreshSupport": true - } - }, - "textDocument": { - "publishDiagnostics": { - "relatedInformation": true, - "versionSupport": false, - "tagSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "codeDescriptionSupport": true, - "dataSupport": true - }, - "synchronization": { - "dynamicRegistration": true, - "willSave": true, - "willSaveWaitUntil": true, - "didSave": true - }, - "completion": { - "dynamicRegistration": true, - "contextSupport": true, - "completionItem": { - "snippetSupport": false, - "commitCharactersSupport": true, - "documentationFormat": [ - "markdown", - "plaintext" - ], - "deprecatedSupport": true, - "preselectSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "insertReplaceSupport": false, - "resolveSupport": { - "properties": [ - "documentation", - "detail", - "additionalTextEdits" - ] - }, - "insertTextModeSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "labelDetailsSupport": true - }, - "insertTextMode": 2, - "completionItemKind": { - "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 - ] - }, - "completionList": { - "itemDefaults": [ - "commitCharacters", - "editRange", - "insertTextFormat", - "insertTextMode" - ] - } - }, - "hover": { - "dynamicRegistration": true, - "contentFormat": [ - "markdown", - "plaintext" - ] - }, - "signatureHelp": { - "dynamicRegistration": true, - "signatureInformation": { - "documentationFormat": [ - "markdown", - "plaintext" - ], - "parameterInformation": { - "labelOffsetSupport": true - }, - "activeParameterSupport": true - }, - "contextSupport": true - }, - "definition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "references": { - "dynamicRegistration": true - }, - "documentHighlight": { - "dynamicRegistration": true - }, - "documentSymbol": { - "dynamicRegistration": 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 - ] - }, - "hierarchicalDocumentSymbolSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "labelSupport": true - }, - "codeAction": { - "dynamicRegistration": true, - "isPreferredSupport": true, - "disabledSupport": true, - "dataSupport": true, - "resolveSupport": { - "properties": [ - "edit" - ] - }, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports" - ] - } - }, - "honorsChangeAnnotations": false - }, - "codeLens": { - "dynamicRegistration": true - }, - "formatting": { - "dynamicRegistration": true - }, - "rangeFormatting": { - "dynamicRegistration": true - }, - "onTypeFormatting": { - "dynamicRegistration": true - }, - "rename": { - "dynamicRegistration": true, - "prepareSupport": true, - "prepareSupportDefaultBehavior": 1, - "honorsChangeAnnotations": true - }, - "documentLink": { - "dynamicRegistration": true, - "tooltipSupport": true - }, - "typeDefinition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "implementation": { - "dynamicRegistration": true, - "linkSupport": true - }, - "colorProvider": { - "dynamicRegistration": true - }, - "foldingRange": { - "dynamicRegistration": true, - "rangeLimit": 5000, - "lineFoldingOnly": true, - "foldingRangeKind": { - "valueSet": [ - "comment", - "imports", - "region" - ] - }, - "foldingRange": { - "collapsedText": false - } - }, - "declaration": { - "dynamicRegistration": true, - "linkSupport": true - }, - "selectionRange": { - "dynamicRegistration": true - }, - "callHierarchy": { - "dynamicRegistration": true - }, - "semanticTokens": { - "dynamicRegistration": true, - "tokenTypes": [ - "namespace", - "type", - "class", - "enum", - "interface", - "struct", - "typeParameter", - "parameter", - "variable", - "property", - "enumMember", - "event", - "function", - "method", - "macro", - "keyword", - "modifier", - "comment", - "string", - "number", - "regexp", - "operator", - "decorator" - ], - "tokenModifiers": [ - "declaration", - "definition", - "readonly", - "static", - "deprecated", - "abstract", - "async", - "modification", - "documentation", - "defaultLibrary" - ], - "formats": [ - "relative" - ], - "requests": { - "range": true, - "full": { - "delta": true - } - }, - "multilineTokenSupport": false, - "overlappingTokenSupport": false, - "serverCancelSupport": true, - "augmentsSyntaxTokens": true - }, - "linkedEditingRange": { - "dynamicRegistration": true - }, - "typeHierarchy": { - "dynamicRegistration": true - }, - "inlineValue": { - "dynamicRegistration": true - }, - "inlayHint": { - "dynamicRegistration": true, - "resolveSupport": { - "properties": [ - "tooltip", - "textEdits", - "label.tooltip", - "label.location", - "label.command" - ] - } - }, - "diagnostic": { - "dynamicRegistration": true, - "relatedDocumentSupport": false - } - }, - "window": { - "showMessage": { - "messageActionItem": { - "additionalPropertiesSupport": true - } - }, - "showDocument": { - "support": true - }, - "workDoneProgress": true - }, - "general": { - "staleRequestSupport": { - "cancel": true, - "retryOnContentModified": [ - "textDocument/semanticTokens/full", - "textDocument/semanticTokens/range", - "textDocument/semanticTokens/full/delta" - ] - }, - "regularExpressions": { - "engine": "ECMAScript", - "version": "ES2020" - }, - "markdown": { - "parser": "marked", - "version": "1.1.0" - }, - "positionEncodings": [ - "utf-16" - ] - }, - "notebookDocument": { - "synchronization": { - "dynamicRegistration": true, - "executionSummarySupport": true - } - } - }, - "initializationOptions": { - "workspaceFolders": "[pathlib.Path(repository_absolute_path).as_uri()]", - "storagePath": null, - "codegen": { - "enabled": false - }, - "compiler": { - "jvm": { - "target": "default" - } - }, - "completion": { - "snippets": { - "enabled": true - } - }, - "diagnostics": { - "enabled": true, - "level": 4, - "debounceTime": 250 - }, - "scripts": { - "enabled": true, - "buildScriptsEnabled": true - }, - "indexing": { - "enabled": true - }, - "externalSources": { - "useKlsScheme": false, - "autoConvertToKotlin": false - }, - "inlayHints": { - "typeHints": false, - "parameterHints": false, - "chainedHints": false - }, - "formatting": { - "formatter": "ktfmt", - "ktfmt": { - "style": "google", - "indent": 4, - "maxWidth": 100, - "continuationIndent": 8, - "removeUnusedImports": true - } - } - }, - "trace": "verbose", - "workspaceFolders": "[\n {\n \"uri\": pathlib.Path(repository_absolute_path).as_uri(),\n \"name\": os.path.basename(repository_absolute_path),\n }\n ]" -} diff --git a/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py deleted file mode 100644 index 6ddab2c7..00000000 --- a/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. -""" - -import dataclasses -import json -import logging -import os -import pathlib -import stat - -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 - - -@dataclasses.dataclass -class KotlinRuntimeDependencyPaths: - """ - Stores the paths to the runtime dependencies of Kotlin Language Server - """ - - java_path: str - java_home_path: str - kotlin_executable_path: str - - -class KotlinLanguageServer(SolidLanguageServer): - """ - Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. - """ - - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): - """ - Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. - """ - runtime_dependency_paths = self.setup_runtime_dependencies(logger, config) - self.runtime_dependency_paths = runtime_dependency_paths - - # Create command to execute the Kotlin Language Server script - cmd = f'"{self.runtime_dependency_paths.kotlin_executable_path}"' - - # Set environment variables including JAVA_HOME - proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} - - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), - "kotlin", - ) - - def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> KotlinRuntimeDependencyPaths: - """ - Setup runtime dependencies for Kotlin Language Server. - """ - platform_id = PlatformUtils.get_platform_id() - - # Verify platform support - assert ( - platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-") - ), "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" - - # Load dependency information - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f: - d = json.load(f) - del d["_description"] - - kotlin_dependency = d["runtimeDependency"] - java_dependency = d["java"][platform_id.value] - - # Setup paths for dependencies - static_dir = os.path.join(os.path.dirname(__file__), "static") - os.makedirs(static_dir, exist_ok=True) - - # Setup Java paths - java_dir = os.path.join(static_dir, "java") - os.makedirs(java_dir, exist_ok=True) - - java_home_path = os.path.join(java_dir, java_dependency["java_home_path"]) - java_path = os.path.join(java_dir, java_dependency["java_path"]) - - # Download and extract Java if not exists - if not os.path.exists(java_path): - logger.log(f"Downloading Java for {platform_id.value}...", logging.INFO) - FileUtils.download_and_extract_archive(logger, java_dependency["url"], java_dir, java_dependency["archiveType"]) - # Make Java executable - if not platform_id.value.startswith("win-"): - os.chmod(java_path, 0o755) - - assert os.path.exists(java_path), f"Java executable not found at {java_path}" - - # Setup Kotlin Language Server paths - kotlin_ls_dir = os.path.join(static_dir, "server") - - # Get platform-specific executable script path - if platform_id.value.startswith("win-"): - kotlin_script = os.path.join(kotlin_ls_dir, "bin", "kotlin-language-server.bat") - else: - kotlin_script = os.path.join(kotlin_ls_dir, "bin", "kotlin-language-server") - - # Download and extract Kotlin Language Server if script doesn't exist - if not os.path.exists(kotlin_script): - logger.log("Downloading Kotlin Language Server...", logging.INFO) - FileUtils.download_and_extract_archive(logger, kotlin_dependency["url"], static_dir, kotlin_dependency["archiveType"]) - - # Make script executable on Unix platforms - if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): - os.chmod( - kotlin_script, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH - ) - - # Use script file - if os.path.exists(kotlin_script): - kotlin_executable_path = kotlin_script - logger.log(f"Using Kotlin Language Server script at {kotlin_script}", logging.INFO) - else: - raise FileNotFoundError(f"Kotlin Language Server script not found at {kotlin_script}") - - return KotlinRuntimeDependencyPaths( - java_path=java_path, java_home_path=java_home_path, kotlin_executable_path=kotlin_executable_path - ) - - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the Kotlin Language Server. - """ - with open(str(pathlib.PurePath(os.path.dirname(__file__), "initialize_params.json")), encoding="utf-8") as f: - d: InitializeParams = json.load(f) - - del d["_description"] - - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - - assert d["processId"] == "os.getpid()" - d["processId"] = os.getpid() - - assert d["rootPath"] == "repository_absolute_path" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "pathlib.Path(repository_absolute_path).as_uri()" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["initializationOptions"]["workspaceFolders"] == "[pathlib.Path(repository_absolute_path).as_uri()]" - d["initializationOptions"]["workspaceFolders"] = [pathlib.Path(repository_absolute_path).as_uri()] - - assert ( - d["workspaceFolders"] - == '[\n {\n "uri": pathlib.Path(repository_absolute_path).as_uri(),\n "name": os.path.basename(repository_absolute_path),\n }\n ]' - ) - d["workspaceFolders"] = [ - { - "uri": pathlib.Path(repository_absolute_path).as_uri(), - "name": os.path.basename(repository_absolute_path), - } - ] - - return d - - def _start_server(self): - """ - Starts the Kotlin Language Server - """ - - def execute_client_command_handler(params): - return [] - - def do_nothing(params): - return - - def window_log_message(msg): - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - - self.server.on_request("client/registerCapability", do_nothing) - self.server.on_notification("language/status", do_nothing) - 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.logger.log("Starting Kotlin 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) - - capabilities = init_response["capabilities"] - assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" - assert "hoverProvider" in capabilities, "Server must support hover" - assert "completionProvider" in capabilities, "Server must support code completion" - assert "signatureHelpProvider" in capabilities, "Server must support signature help" - assert "definitionProvider" in capabilities, "Server must support go to definition" - assert "referencesProvider" in capabilities, "Server must support find references" - assert "documentSymbolProvider" in capabilities, "Server must support document symbols" - assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" - assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" - - self.server.notify.initialized({}) - self.completions_available.set() diff --git a/src/solidlsp/language_servers/kotlin_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/kotlin_language_server/runtime_dependencies.json deleted file mode 100644 index 3568c9d4..00000000 --- a/src/solidlsp/language_servers/kotlin_language_server/runtime_dependencies.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "_description": "Used to download the runtime dependencies for Kotlin Language Server from https://github.com/fwcd/kotlin-language-server", - "runtimeDependency": { - "id": "KotlinLsp", - "description": "Kotlin Language Server", - "url": "https://github.com/fwcd/kotlin-language-server/releases/download/1.3.13/server.zip", - "archiveType": "zip" - }, - "java": { - "win-x64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", - "archiveType": "zip", - "java_home_path": "extension/jre/21.0.7-win32-x86_64", - "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe" - }, - "linux-x64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", - "archiveType": "zip", - "java_home_path": "extension/jre/21.0.7-linux-x86_64", - "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java" - }, - "linux-arm64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", - "archiveType": "zip", - "java_home_path": "extension/jre/21.0.7-linux-aarch64", - "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java" - }, - "osx-x64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", - "archiveType": "zip", - "java_home_path": "extension/jre/21.0.7-macosx-x86_64", - "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java" - }, - "osx-arm64": { - "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", - "archiveType": "zip", - "java_home_path": "extension/jre/21.0.7-macosx-aarch64", - "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java" - } - } -} diff --git a/src/solidlsp/language_servers/omnisharp/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py similarity index 93% rename from src/solidlsp/language_servers/omnisharp/omnisharp.py rename to src/solidlsp/language_servers/omnisharp.py index e66540ca..046d1e84 100644 --- a/src/solidlsp/language_servers/omnisharp/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -63,7 +63,7 @@ class OmniSharp(SolidLanguageServer): """ Creates an OmniSharp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - omnisharp_executable_path, dll_path = self.setupRuntimeDependencies(logger, config) + omnisharp_executable_path, dll_path = self._setup_runtime_dependencies(logger, config) slnfilename = find_least_depth_sln_file(repository_root_path) if slnfilename is None: @@ -112,11 +112,12 @@ class OmniSharp(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj"] - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Omnisharp Language Server. """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: + with open(os.path.join(os.path.dirname(__file__), "omnisharp", "initialize_params.json"), encoding="utf-8") as f: d = json.load(f) del d["_description"] @@ -136,30 +137,32 @@ class OmniSharp(SolidLanguageServer): return d - def setupRuntimeDependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str]: + @classmethod + def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str]: """ Setup runtime dependencies for OmniSharp. """ platform_id = PlatformUtils.get_platform_id() dotnet_version = PlatformUtils.get_dotnet_version() - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f: + with open(os.path.join(os.path.dirname(__file__), "omnisharp", "runtime_dependencies.json"), encoding="utf-8") as f: d = json.load(f) del d["_description"] assert platform_id in [ PlatformId.LINUX_x64, PlatformId.WIN_x64, - ], "Only linux-x64 and win-x64 platform is supported for in multilspy at the moment" + ], f"Only linux-x64 and win-x64 platform is supported at the moment but got {platform_id=}" assert dotnet_version in [ DotnetVersion.V6, DotnetVersion.V7, DotnetVersion.V8, - ], "Only dotnet version 6 and 7 are supported in multilspy at the moment" + DotnetVersion.V9, + ], f"Only dotnet version 6-9 are supported at the moment but got {dotnet_version=}" # TODO: Do away with this assumption # Currently, runtime binaries are not available for .Net 7 and .Net 8. Hence, we assume .Net 6 runtime binaries to be compatible with .Net 7, .Net 8 - if dotnet_version in [DotnetVersion.V7, DotnetVersion.V8]: + if dotnet_version in [DotnetVersion.V7, DotnetVersion.V8, DotnetVersion.V9]: dotnet_version = DotnetVersion.V6 runtime_dependencies = d["runtimeDependencies"] @@ -178,7 +181,7 @@ class OmniSharp(SolidLanguageServer): assert "OmniSharp" in runtime_dependencies assert "RazorOmnisharp" in runtime_dependencies - omnisharp_ls_dir = os.path.join(os.path.dirname(__file__), "static", "OmniSharp") + omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(), "OmniSharp") if not os.path.exists(omnisharp_ls_dir): os.makedirs(omnisharp_ls_dir) FileUtils.download_and_extract_archive(logger, runtime_dependencies["OmniSharp"]["url"], omnisharp_ls_dir, "zip") @@ -186,7 +189,7 @@ class OmniSharp(SolidLanguageServer): assert os.path.exists(omnisharp_executable_path) os.chmod(omnisharp_executable_path, stat.S_IEXEC) - razor_omnisharp_ls_dir = os.path.join(os.path.dirname(__file__), "static", "RazorOmnisharp") + razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(), "RazorOmnisharp") if not os.path.exists(razor_omnisharp_ls_dir): os.makedirs(razor_omnisharp_ls_dir) FileUtils.download_and_extract_archive(logger, runtime_dependencies["RazorOmnisharp"]["url"], razor_omnisharp_ls_dir, "zip") @@ -363,7 +366,7 @@ class OmniSharp(SolidLanguageServer): ) init_response = self.server.send.initialize(initialize_params) self.server.notify.initialized({}) - with open(os.path.join(os.path.dirname(__file__), "workspace_did_change_configuration.json"), encoding="utf-8") as f: + with open(os.path.join(os.path.dirname(__file__), "omnisharp", "workspace_did_change_configuration.json"), encoding="utf-8") as f: self.server.notify.workspace_did_change_configuration({"settings": json.load(f)}) assert "capabilities" in init_response if "definitionProvider" in init_response["capabilities"] and init_response["capabilities"]["definitionProvider"]: diff --git a/src/solidlsp/language_servers/pyright_language_server/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py similarity index 78% rename from src/solidlsp/language_servers/pyright_language_server/pyright_server.py rename to src/solidlsp/language_servers/pyright_server.py index 893db94e..905bb3d6 100644 --- a/src/solidlsp/language_servers/pyright_language_server/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -46,7 +46,8 @@ class PyrightServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Pyright Language Server. """ @@ -68,34 +69,17 @@ class PyrightServer(SolidLanguageServer): }, "capabilities": { "workspace": { - "applyEdit": True, "workspaceEdit": {"documentChanges": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "didChangeWatchedFiles": {"dynamicRegistration": True}, "symbol": { "dynamicRegistration": 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] - }, + "symbolKind": {"valueSet": list(range(1, 27))}, }, "executeCommand": {"dynamicRegistration": True}, }, "textDocument": { "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, - "completion": { - "dynamicRegistration": True, - "contextSupport": True, - "completionItem": { - "snippetSupport": True, - "commitCharactersSupport": True, - "documentationFormat": ["markdown", "plaintext"], - "deprecatedSupport": True, - "preselectSupport": True, - }, - "completionItemKind": { - "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] - }, - }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, @@ -106,36 +90,11 @@ class PyrightServer(SolidLanguageServer): }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, - "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": 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] - }, + "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, }, - "codeAction": { - "dynamicRegistration": True, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports", - ] - } - }, - }, - "codeLens": {"dynamicRegistration": True}, - "formatting": {"dynamicRegistration": True}, - "rangeFormatting": {"dynamicRegistration": True}, - "onTypeFormatting": {"dynamicRegistration": True}, - "rename": {"dynamicRegistration": True}, "publishDiagnostics": {"relatedInformation": True}, }, }, diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py new file mode 100644 index 00000000..bf79fa3b --- /dev/null +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -0,0 +1,632 @@ +""" +Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. +""" + +import logging +import os +import pathlib +import stat +import threading + +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.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + +from .common import RuntimeDependency, RuntimeDependencyCollection + + +class RustAnalyzer(SolidLanguageServer): + """ + Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. + """ + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + rustanalyzer_executable_path = self._setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), + "rust", + ) + self.server_ready = threading.Event() + self.service_ready_event = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["target"] + + @classmethod + def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + """ + Setup runtime dependencies for rust_analyzer and return the command to start the server. + """ + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="RustAnalyzer", + description="RustAnalyzer for macOS (arm64)", + url="https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-aarch64-apple-darwin.gz", + platform_id="osx-arm64", + archive_type="gz", + binary_name="rust_analyzer", + ), + RuntimeDependency( + id="RustAnalyzer", + description="RustAnalyzer for Linux (x64)", + url="https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-x86_64-unknown-linux-gnu.gz", + platform_id="linux-x64", + archive_type="gz", + binary_name="rust_analyzer", + ), + RuntimeDependency( + id="RustAnalyzer", + description="RustAnalyzer for Windows (x64)", + url="https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-x86_64-pc-windows-msvc.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="rust-analyzer.exe", + ), + ] + ) + + # assert platform_id.value in [ + # "linux-x64", + # "win-x64", + # ], "Only linux-x64 and win-x64 platform is supported for in multilspy at the moment" + + rustanalyzer_ls_dir = os.path.join(cls.ls_resources_dir(), "RustAnalyzer") + rustanalyzer_executable_path = deps.binary_path(rustanalyzer_ls_dir) + if not os.path.exists(rustanalyzer_ls_dir): + os.makedirs(rustanalyzer_ls_dir) + deps.install(logger, rustanalyzer_ls_dir) + assert os.path.exists(rustanalyzer_executable_path) + os.chmod(rustanalyzer_executable_path, stat.S_IEXEC) + + return rustanalyzer_executable_path + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Rust Analyzer Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "clientInfo": {"name": "Visual Studio Code - Insiders", "version": "1.82.0-insider"}, + "locale": "en", + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "configuration": True, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "codeLens": {"refreshSupport": True}, + "executeCommand": {"dynamicRegistration": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + "fileOperations": { + "dynamicRegistration": True, + "didCreate": True, + "didRename": True, + "didDelete": True, + "willCreate": True, + "willRename": True, + "willDelete": True, + }, + "inlineValue": {"refreshSupport": True}, + "inlayHint": {"refreshSupport": True}, + "diagnostics": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": True, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "isPreferredSupport": True, + "disabledSupport": True, + "dataSupport": True, + "resolveSupport": {"properties": ["edit"]}, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + "honorsChangeAnnotations": False, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, + "foldingRange": {"collapsedText": False}, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + "serverCancelSupport": True, + "augmentsSyntaxTokens": False, + }, + "linkedEditingRange": {"dynamicRegistration": True}, + "typeHierarchy": {"dynamicRegistration": True}, + "inlineValue": {"dynamicRegistration": True}, + "inlayHint": { + "dynamicRegistration": True, + "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + "general": { + "staleRequestSupport": { + "cancel": True, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta", + ], + }, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "markdown": { + "parser": "marked", + "version": "1.1.0", + "allowedTags": [ + "ul", + "li", + "p", + "code", + "blockquote", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "em", + "pre", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "div", + "del", + "a", + "strong", + "br", + "img", + "span", + ], + }, + "positionEncodings": ["utf-16"], + }, + "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, + "experimental": { + "snippetTextEdit": True, + "codeActionGroup": True, + "hoverActions": True, + "serverStatusNotification": True, + "colorDiagnosticOutput": True, + "openServerLogs": True, + "localDocs": True, + "commands": { + "commands": [ + "rust-analyzer.runSingle", + "rust-analyzer.debugSingle", + "rust-analyzer.showReferences", + "rust-analyzer.gotoLocation", + "editor.action.triggerParameterHints", + ] + }, + }, + }, + "initializationOptions": { + "cargoRunner": None, + "runnables": {"extraEnv": None, "problemMatcher": ["$rustc"], "command": None, "extraArgs": []}, + "statusBar": {"clickAction": "openLogs"}, + "server": {"path": None, "extraEnv": None}, + "trace": {"server": "verbose", "extension": False}, + "debug": { + "engine": "auto", + "sourceFileMap": {"/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust"}, + "openDebugPane": False, + "engineSettings": {}, + }, + "restartServerOnConfigChange": False, + "typing": {"continueCommentsOnNewline": True, "autoClosingAngleBrackets": {"enable": False}}, + "diagnostics": { + "previewRustcOutput": False, + "useRustcErrorCode": False, + "disabled": [], + "enable": True, + "experimental": {"enable": False}, + "remapPrefix": {}, + "warningsAsHint": [], + "warningsAsInfo": [], + }, + "discoverProjectRunner": None, + "showUnlinkedFileNotification": True, + "showDependenciesExplorer": True, + "assist": {"emitMustUse": False, "expressionFillDefault": "todo"}, + "cachePriming": {"enable": True, "numThreads": 0}, + "cargo": { + "autoreload": True, + "buildScripts": { + "enable": True, + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "overrideCommand": None, + "useRustcWrapper": True, + }, + "cfgs": {}, + "extraArgs": [], + "extraEnv": {}, + "features": [], + "noDefaultFeatures": False, + "sysroot": "discover", + "sysrootSrc": None, + "target": None, + "unsetTest": ["core"], + }, + "checkOnSave": True, + "check": { + "allTargets": True, + "command": "check", + "extraArgs": [], + "extraEnv": {}, + "features": None, + "ignore": [], + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "noDefaultFeatures": None, + "overrideCommand": None, + "targets": None, + }, + "completion": { + "autoimport": {"enable": True}, + "autoself": {"enable": True}, + "callable": {"snippets": "fill_arguments"}, + "fullFunctionSignatures": {"enable": False}, + "limit": None, + "postfix": {"enable": True}, + "privateEditable": {"enable": False}, + "snippets": { + "custom": { + "Arc::new": { + "postfix": "arc", + "body": "Arc::new(${receiver})", + "requires": "std::sync::Arc", + "description": "Put the expression into an `Arc`", + "scope": "expr", + }, + "Rc::new": { + "postfix": "rc", + "body": "Rc::new(${receiver})", + "requires": "std::rc::Rc", + "description": "Put the expression into an `Rc`", + "scope": "expr", + }, + "Box::pin": { + "postfix": "pinbox", + "body": "Box::pin(${receiver})", + "requires": "std::boxed::Box", + "description": "Put the expression into a pinned `Box`", + "scope": "expr", + }, + "Ok": { + "postfix": "ok", + "body": "Ok(${receiver})", + "description": "Wrap the expression in a `Result::Ok`", + "scope": "expr", + }, + "Err": { + "postfix": "err", + "body": "Err(${receiver})", + "description": "Wrap the expression in a `Result::Err`", + "scope": "expr", + }, + "Some": { + "postfix": "some", + "body": "Some(${receiver})", + "description": "Wrap the expression in an `Option::Some`", + "scope": "expr", + }, + } + }, + }, + "files": {"excludeDirs": [], "watcher": "client"}, + "highlightRelated": { + "breakPoints": {"enable": True}, + "closureCaptures": {"enable": True}, + "exitPoints": {"enable": True}, + "references": {"enable": True}, + "yieldPoints": {"enable": True}, + }, + "hover": { + "actions": { + "debug": {"enable": True}, + "enable": True, + "gotoTypeDef": {"enable": True}, + "implementations": {"enable": True}, + "references": {"enable": False}, + "run": {"enable": True}, + }, + "documentation": {"enable": True, "keywords": {"enable": True}}, + "links": {"enable": True}, + "memoryLayout": {"alignment": "hexadecimal", "enable": True, "niches": False, "offset": "hexadecimal", "size": "both"}, + }, + "imports": { + "granularity": {"enforce": False, "group": "crate"}, + "group": {"enable": True}, + "merge": {"glob": True}, + "preferNoStd": False, + "preferPrelude": False, + "prefix": "plain", + }, + "inlayHints": { + "bindingModeHints": {"enable": False}, + "chainingHints": {"enable": True}, + "closingBraceHints": {"enable": True, "minLines": 25}, + "closureCaptureHints": {"enable": False}, + "closureReturnTypeHints": {"enable": "never"}, + "closureStyle": "impl_fn", + "discriminantHints": {"enable": "never"}, + "expressionAdjustmentHints": {"enable": "never", "hideOutsideUnsafe": False, "mode": "prefix"}, + "lifetimeElisionHints": {"enable": "never", "useParameterNames": False}, + "maxLength": 25, + "parameterHints": {"enable": True}, + "reborrowHints": {"enable": "never"}, + "renderColons": True, + "typeHints": {"enable": True, "hideClosureInitialization": False, "hideNamedConstructor": False}, + }, + "interpret": {"tests": False}, + "joinLines": {"joinAssignments": True, "joinElseIf": True, "removeTrailingComma": True, "unwrapTrivialBlock": True}, + "lens": { + "debug": {"enable": True}, + "enable": True, + "forceCustomCommands": True, + "implementations": {"enable": True}, + "location": "above_name", + "references": { + "adt": {"enable": False}, + "enumVariant": {"enable": False}, + "method": {"enable": False}, + "trait": {"enable": False}, + }, + "run": {"enable": True}, + }, + "linkedProjects": [], + "lru": {"capacity": None, "query": {"capacities": {}}}, + "notifications": {"cargoTomlNotFound": True}, + "numThreads": None, + "procMacro": {"attributes": {"enable": True}, "enable": True, "ignored": {}, "server": None}, + "references": {"excludeImports": False}, + "rust": {"analyzerTargetDir": None}, + "rustc": {"source": None}, + "rustfmt": {"extraArgs": [], "overrideCommand": None, "rangeFormatting": {"enable": False}}, + "semanticHighlighting": { + "doc": {"comment": {"inject": {"enable": True}}}, + "nonStandardTokens": True, + "operator": {"enable": True, "specialization": {"enable": False}}, + "punctuation": {"enable": False, "separate": {"macro": {"bang": False}}, "specialization": {"enable": False}}, + "strings": {"enable": True}, + }, + "signatureInfo": {"detail": "full", "documentation": {"enable": True}}, + "workspace": {"symbol": {"search": {"kind": "only_types", "limit": 128, "scope": "workspace"}}}, + }, + "trace": "verbose", + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params + + def _start_server(self): + """ + Starts the Rust Analyzer Language Server + """ + + 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 RustAnalyzer 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"] + assert init_response["capabilities"]["completionProvider"] == { + "resolveProvider": True, + "triggerCharacters": [":", ".", "'", "("], + "completionItem": {"labelDetailsSupport": True}, + } + self.server.notify.initialized({}) + self.completions_available.set() + + self.server_ready.wait() diff --git a/src/solidlsp/language_servers/rust_analyzer/initialize_params.json b/src/solidlsp/language_servers/rust_analyzer/initialize_params.json deleted file mode 100644 index 00368eed..00000000 --- a/src/solidlsp/language_servers/rust_analyzer/initialize_params.json +++ /dev/null @@ -1,917 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "clientInfo": { - "name": "Visual Studio Code - Insiders", - "version": "1.82.0-insider" - }, - "locale": "en", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "capabilities": { - "workspace": { - "applyEdit": true, - "workspaceEdit": { - "documentChanges": true, - "resourceOperations": [ - "create", - "rename", - "delete" - ], - "failureHandling": "textOnlyTransactional", - "normalizesLineEndings": true, - "changeAnnotationSupport": { - "groupsOnLabel": true - } - }, - "configuration": true, - "didChangeWatchedFiles": { - "dynamicRegistration": true, - "relativePatternSupport": true - }, - "symbol": { - "dynamicRegistration": 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 - ] - }, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "resolveSupport": { - "properties": [ - "location.range" - ] - } - }, - "codeLens": { - "refreshSupport": true - }, - "executeCommand": { - "dynamicRegistration": true - }, - "didChangeConfiguration": { - "dynamicRegistration": true - }, - "workspaceFolders": true, - "semanticTokens": { - "refreshSupport": true - }, - "fileOperations": { - "dynamicRegistration": true, - "didCreate": true, - "didRename": true, - "didDelete": true, - "willCreate": true, - "willRename": true, - "willDelete": true - }, - "inlineValue": { - "refreshSupport": true - }, - "inlayHint": { - "refreshSupport": true - }, - "diagnostics": { - "refreshSupport": true - } - }, - "textDocument": { - "publishDiagnostics": { - "relatedInformation": true, - "versionSupport": false, - "tagSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "codeDescriptionSupport": true, - "dataSupport": true - }, - "synchronization": { - "dynamicRegistration": true, - "willSave": true, - "willSaveWaitUntil": true, - "didSave": true - }, - "completion": { - "dynamicRegistration": true, - "contextSupport": true, - "completionItem": { - "snippetSupport": true, - "commitCharactersSupport": true, - "documentationFormat": [ - "markdown", - "plaintext" - ], - "deprecatedSupport": true, - "preselectSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "insertReplaceSupport": true, - "resolveSupport": { - "properties": [ - "documentation", - "detail", - "additionalTextEdits" - ] - }, - "insertTextModeSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "labelDetailsSupport": true - }, - "insertTextMode": 2, - "completionItemKind": { - "valueSet": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25 - ] - }, - "completionList": { - "itemDefaults": [ - "commitCharacters", - "editRange", - "insertTextFormat", - "insertTextMode" - ] - } - }, - "hover": { - "dynamicRegistration": true, - "contentFormat": [ - "markdown", - "plaintext" - ] - }, - "signatureHelp": { - "dynamicRegistration": true, - "signatureInformation": { - "documentationFormat": [ - "markdown", - "plaintext" - ], - "parameterInformation": { - "labelOffsetSupport": true - }, - "activeParameterSupport": true - }, - "contextSupport": true - }, - "definition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "references": { - "dynamicRegistration": true - }, - "documentHighlight": { - "dynamicRegistration": true - }, - "documentSymbol": { - "dynamicRegistration": 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 - ] - }, - "hierarchicalDocumentSymbolSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "labelSupport": true - }, - "codeAction": { - "dynamicRegistration": true, - "isPreferredSupport": true, - "disabledSupport": true, - "dataSupport": true, - "resolveSupport": { - "properties": [ - "edit" - ] - }, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports" - ] - } - }, - "honorsChangeAnnotations": false - }, - "codeLens": { - "dynamicRegistration": true - }, - "formatting": { - "dynamicRegistration": true - }, - "rangeFormatting": { - "dynamicRegistration": true - }, - "onTypeFormatting": { - "dynamicRegistration": true - }, - "rename": { - "dynamicRegistration": true, - "prepareSupport": true, - "prepareSupportDefaultBehavior": 1, - "honorsChangeAnnotations": true - }, - "documentLink": { - "dynamicRegistration": true, - "tooltipSupport": true - }, - "typeDefinition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "implementation": { - "dynamicRegistration": true, - "linkSupport": true - }, - "colorProvider": { - "dynamicRegistration": true - }, - "foldingRange": { - "dynamicRegistration": true, - "rangeLimit": 5000, - "lineFoldingOnly": true, - "foldingRangeKind": { - "valueSet": [ - "comment", - "imports", - "region" - ] - }, - "foldingRange": { - "collapsedText": false - } - }, - "declaration": { - "dynamicRegistration": true, - "linkSupport": true - }, - "selectionRange": { - "dynamicRegistration": true - }, - "callHierarchy": { - "dynamicRegistration": true - }, - "semanticTokens": { - "dynamicRegistration": true, - "tokenTypes": [ - "namespace", - "type", - "class", - "enum", - "interface", - "struct", - "typeParameter", - "parameter", - "variable", - "property", - "enumMember", - "event", - "function", - "method", - "macro", - "keyword", - "modifier", - "comment", - "string", - "number", - "regexp", - "operator", - "decorator" - ], - "tokenModifiers": [ - "declaration", - "definition", - "readonly", - "static", - "deprecated", - "abstract", - "async", - "modification", - "documentation", - "defaultLibrary" - ], - "formats": [ - "relative" - ], - "requests": { - "range": true, - "full": { - "delta": true - } - }, - "multilineTokenSupport": false, - "overlappingTokenSupport": false, - "serverCancelSupport": true, - "augmentsSyntaxTokens": false - }, - "linkedEditingRange": { - "dynamicRegistration": true - }, - "typeHierarchy": { - "dynamicRegistration": true - }, - "inlineValue": { - "dynamicRegistration": true - }, - "inlayHint": { - "dynamicRegistration": true, - "resolveSupport": { - "properties": [ - "tooltip", - "textEdits", - "label.tooltip", - "label.location", - "label.command" - ] - } - }, - "diagnostic": { - "dynamicRegistration": true, - "relatedDocumentSupport": false - } - }, - "window": { - "showMessage": { - "messageActionItem": { - "additionalPropertiesSupport": true - } - }, - "showDocument": { - "support": true - }, - "workDoneProgress": true - }, - "general": { - "staleRequestSupport": { - "cancel": true, - "retryOnContentModified": [ - "textDocument/semanticTokens/full", - "textDocument/semanticTokens/range", - "textDocument/semanticTokens/full/delta" - ] - }, - "regularExpressions": { - "engine": "ECMAScript", - "version": "ES2020" - }, - "markdown": { - "parser": "marked", - "version": "1.1.0", - "allowedTags": [ - "ul", - "li", - "p", - "code", - "blockquote", - "ol", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "em", - "pre", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - "div", - "del", - "a", - "strong", - "br", - "img", - "span" - ] - }, - "positionEncodings": [ - "utf-16" - ] - }, - "notebookDocument": { - "synchronization": { - "dynamicRegistration": true, - "executionSummarySupport": true - } - }, - "experimental": { - "snippetTextEdit": true, - "codeActionGroup": true, - "hoverActions": true, - "serverStatusNotification": true, - "colorDiagnosticOutput": true, - "openServerLogs": true, - "localDocs": true, - "commands": { - "commands": [ - "rust-analyzer.runSingle", - "rust-analyzer.debugSingle", - "rust-analyzer.showReferences", - "rust-analyzer.gotoLocation", - "editor.action.triggerParameterHints" - ] - } - } - }, - "initializationOptions": { - "cargoRunner": null, - "runnables": { - "extraEnv": null, - "problemMatcher": [ - "$rustc" - ], - "command": null, - "extraArgs": [] - }, - "statusBar": { - "clickAction": "openLogs" - }, - "server": { - "path": null, - "extraEnv": null - }, - "trace": { - "server": "verbose", - "extension": false - }, - "debug": { - "engine": "auto", - "sourceFileMap": { - "/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust" - }, - "openDebugPane": false, - "engineSettings": {} - }, - "restartServerOnConfigChange": false, - "typing": { - "continueCommentsOnNewline": true, - "autoClosingAngleBrackets": { - "enable": false - } - }, - "diagnostics": { - "previewRustcOutput": false, - "useRustcErrorCode": false, - "disabled": [], - "enable": true, - "experimental": { - "enable": false - }, - "remapPrefix": {}, - "warningsAsHint": [], - "warningsAsInfo": [] - }, - "discoverProjectRunner": null, - "showUnlinkedFileNotification": true, - "showDependenciesExplorer": true, - "assist": { - "emitMustUse": false, - "expressionFillDefault": "todo" - }, - "cachePriming": { - "enable": true, - "numThreads": 0 - }, - "cargo": { - "autoreload": true, - "buildScripts": { - "enable": true, - "invocationLocation": "workspace", - "invocationStrategy": "per_workspace", - "overrideCommand": null, - "useRustcWrapper": true - }, - "cfgs": {}, - "extraArgs": [], - "extraEnv": {}, - "features": [], - "noDefaultFeatures": false, - "sysroot": "discover", - "sysrootSrc": null, - "target": null, - "unsetTest": [ - "core" - ] - }, - "checkOnSave": true, - "check": { - "allTargets": true, - "command": "check", - "extraArgs": [], - "extraEnv": {}, - "features": null, - "ignore": [], - "invocationLocation": "workspace", - "invocationStrategy": "per_workspace", - "noDefaultFeatures": null, - "overrideCommand": null, - "targets": null - }, - "completion": { - "autoimport": { - "enable": true - }, - "autoself": { - "enable": true - }, - "callable": { - "snippets": "fill_arguments" - }, - "fullFunctionSignatures": { - "enable": false - }, - "limit": null, - "postfix": { - "enable": true - }, - "privateEditable": { - "enable": false - }, - "snippets": { - "custom": { - "Arc::new": { - "postfix": "arc", - "body": "Arc::new(${receiver})", - "requires": "std::sync::Arc", - "description": "Put the expression into an `Arc`", - "scope": "expr" - }, - "Rc::new": { - "postfix": "rc", - "body": "Rc::new(${receiver})", - "requires": "std::rc::Rc", - "description": "Put the expression into an `Rc`", - "scope": "expr" - }, - "Box::pin": { - "postfix": "pinbox", - "body": "Box::pin(${receiver})", - "requires": "std::boxed::Box", - "description": "Put the expression into a pinned `Box`", - "scope": "expr" - }, - "Ok": { - "postfix": "ok", - "body": "Ok(${receiver})", - "description": "Wrap the expression in a `Result::Ok`", - "scope": "expr" - }, - "Err": { - "postfix": "err", - "body": "Err(${receiver})", - "description": "Wrap the expression in a `Result::Err`", - "scope": "expr" - }, - "Some": { - "postfix": "some", - "body": "Some(${receiver})", - "description": "Wrap the expression in an `Option::Some`", - "scope": "expr" - } - } - } - }, - "files": { - "excludeDirs": [], - "watcher": "client" - }, - "highlightRelated": { - "breakPoints": { - "enable": true - }, - "closureCaptures": { - "enable": true - }, - "exitPoints": { - "enable": true - }, - "references": { - "enable": true - }, - "yieldPoints": { - "enable": true - } - }, - "hover": { - "actions": { - "debug": { - "enable": true - }, - "enable": true, - "gotoTypeDef": { - "enable": true - }, - "implementations": { - "enable": true - }, - "references": { - "enable": false - }, - "run": { - "enable": true - } - }, - "documentation": { - "enable": true, - "keywords": { - "enable": true - } - }, - "links": { - "enable": true - }, - "memoryLayout": { - "alignment": "hexadecimal", - "enable": true, - "niches": false, - "offset": "hexadecimal", - "size": "both" - } - }, - "imports": { - "granularity": { - "enforce": false, - "group": "crate" - }, - "group": { - "enable": true - }, - "merge": { - "glob": true - }, - "preferNoStd": false, - "preferPrelude": false, - "prefix": "plain" - }, - "inlayHints": { - "bindingModeHints": { - "enable": false - }, - "chainingHints": { - "enable": true - }, - "closingBraceHints": { - "enable": true, - "minLines": 25 - }, - "closureCaptureHints": { - "enable": false - }, - "closureReturnTypeHints": { - "enable": "never" - }, - "closureStyle": "impl_fn", - "discriminantHints": { - "enable": "never" - }, - "expressionAdjustmentHints": { - "enable": "never", - "hideOutsideUnsafe": false, - "mode": "prefix" - }, - "lifetimeElisionHints": { - "enable": "never", - "useParameterNames": false - }, - "maxLength": 25, - "parameterHints": { - "enable": true - }, - "reborrowHints": { - "enable": "never" - }, - "renderColons": true, - "typeHints": { - "enable": true, - "hideClosureInitialization": false, - "hideNamedConstructor": false - } - }, - "interpret": { - "tests": false - }, - "joinLines": { - "joinAssignments": true, - "joinElseIf": true, - "removeTrailingComma": true, - "unwrapTrivialBlock": true - }, - "lens": { - "debug": { - "enable": true - }, - "enable": true, - "forceCustomCommands": true, - "implementations": { - "enable": true - }, - "location": "above_name", - "references": { - "adt": { - "enable": false - }, - "enumVariant": { - "enable": false - }, - "method": { - "enable": false - }, - "trait": { - "enable": false - } - }, - "run": { - "enable": true - } - }, - "linkedProjects": [], - "lru": { - "capacity": null, - "query": { - "capacities": {} - } - }, - "notifications": { - "cargoTomlNotFound": true - }, - "numThreads": null, - "procMacro": { - "attributes": { - "enable": true - }, - "enable": true, - "ignored": {}, - "server": null - }, - "references": { - "excludeImports": false - }, - "rust": { - "analyzerTargetDir": null - }, - "rustc": { - "source": null - }, - "rustfmt": { - "extraArgs": [], - "overrideCommand": null, - "rangeFormatting": { - "enable": false - } - }, - "semanticHighlighting": { - "doc": { - "comment": { - "inject": { - "enable": true - } - } - }, - "nonStandardTokens": true, - "operator": { - "enable": true, - "specialization": { - "enable": false - } - }, - "punctuation": { - "enable": false, - "separate": { - "macro": { - "bang": false - } - }, - "specialization": { - "enable": false - } - }, - "strings": { - "enable": true - } - }, - "signatureInfo": { - "detail": "full", - "documentation": { - "enable": true - } - }, - "workspace": { - "symbol": { - "search": { - "kind": "only_types", - "limit": 128, - "scope": "workspace" - } - } - } - }, - "trace": "verbose", - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/rust_analyzer/runtime_dependencies.json b/src/solidlsp/language_servers/rust_analyzer/runtime_dependencies.json deleted file mode 100644 index 517d8b87..00000000 --- a/src/solidlsp/language_servers/rust_analyzer/runtime_dependencies.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "_description": "Used to download the runtime dependencies for running RustAnalyzer. Obtained from https://github.com/rust-lang/rust-analyzer/releases", - "runtimeDependencies": [ - { - "id": "RustAnalyzer", - "description": "RustAnalyzer for Linux (x64)", - "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-aarch64-apple-darwin.gz", - "platformId": "osx-arm64", - "archiveType": "gz", - "binaryName": "rust_analyzer" - }, - { - "id": "RustAnalyzer", - "description": "RustAnalyzer for Linux (x64)", - "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-x86_64-unknown-linux-gnu.gz", - "platformId": "linux-x64", - "archiveType": "gz", - "binaryName": "rust_analyzer" - }, - { - "id": "RustAnalyzer", - "description": "RustAnalyzer for Windows (x64)", - "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-x86_64-pc-windows-msvc.zip", - "platformId": "win-x64", - "archiveType": "zip", - "binaryName": "rust-analyzer.exe" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py deleted file mode 100644 index 1f951e5c..00000000 --- a/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. -""" - -import json -import logging -import os -import pathlib -import stat -import threading - -from overrides import override - -from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger -from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo - - -class RustAnalyzer(SolidLanguageServer): - """ - Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. - """ - - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): - """ - Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. - """ - rustanalyzer_executable_path = self.setup_runtime_dependencies(logger, config) - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), - "rust", - ) - self.server_ready = threading.Event() - self.service_ready_event = threading.Event() - self.initialize_searcher_command_available = threading.Event() - self.resolve_main_method_available = threading.Event() - - @override - def is_ignored_dirname(self, dirname: str) -> bool: - return super().is_ignored_dirname(dirname) or dirname in ["target"] - - def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: - """ - Setup runtime dependencies for rust_analyzer. - """ - platform_id = PlatformUtils.get_platform_id() - - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f: - d = json.load(f) - del d["_description"] - - # assert platform_id.value in [ - # "linux-x64", - # "win-x64", - # ], "Only linux-x64 and win-x64 platform is supported for in multilspy at the moment" - - runtime_dependencies = d["runtimeDependencies"] - runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] - assert len(runtime_dependencies) == 1 - dependency = runtime_dependencies[0] - - rustanalyzer_ls_dir = os.path.join(os.path.dirname(__file__), "static", "RustAnalyzer") - rustanalyzer_executable_path = os.path.join(rustanalyzer_ls_dir, dependency["binaryName"]) - if not os.path.exists(rustanalyzer_ls_dir): - os.makedirs(rustanalyzer_ls_dir) - if dependency["archiveType"] == "gz": - FileUtils.download_and_extract_archive(logger, dependency["url"], rustanalyzer_executable_path, dependency["archiveType"]) - else: - FileUtils.download_and_extract_archive(logger, dependency["url"], rustanalyzer_ls_dir, dependency["archiveType"]) - assert os.path.exists(rustanalyzer_executable_path) - os.chmod(rustanalyzer_executable_path, stat.S_IEXEC) - - return rustanalyzer_executable_path - - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the Rust Analyzer Language Server. - """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: - d = json.load(f) - - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d - - def _start_server(self): - """ - Starts the Rust Analyzer Language Server - """ - - 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 RustAnalyzer 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"] - assert init_response["capabilities"]["completionProvider"] == { - "resolveProvider": True, - "triggerCharacters": [":", ".", "'", "("], - "completionItem": {"labelDetailsSupport": True}, - } - self.server.notify.initialized({}) - self.completions_available.set() - - self.server_ready.wait() diff --git a/src/solidlsp/language_servers/solargraph/solargraph.py b/src/solidlsp/language_servers/solargraph.py similarity index 82% rename from src/solidlsp/language_servers/solargraph/solargraph.py rename to src/solidlsp/language_servers/solargraph.py index 171cc888..8034ad80 100644 --- a/src/solidlsp/language_servers/solargraph/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -31,7 +31,7 @@ class Solargraph(SolidLanguageServer): Creates a Solargraph instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - solargraph_executable_path = self.setup_runtime_dependencies(logger, config, repository_root_path) + solargraph_executable_path = self._setup_runtime_dependencies(logger, config, repository_root_path) super().__init__( config, logger, @@ -48,15 +48,21 @@ class Solargraph(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["vendor"] - def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig, repository_root_path: str) -> str: + @staticmethod + def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig, repository_root_path: str) -> str: """ - Setup runtime dependencies for Solargraph. + Setup runtime dependencies for Solargraph and return the command to start the server. """ - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f: - d = json.load(f) - del d["_description"] + runtime_dependencies = [ + { + "url": "https://rubygems.org/downloads/solargraph-0.51.1.gem", + "installCommand": "gem install solargraph -v 0.51.1", + "binaryName": "solargraph", + "archiveType": "gem", + } + ] - dependency = d["runtimeDependencies"][0] + dependency = runtime_dependencies[0] # Check if Ruby is installed try: @@ -93,29 +99,26 @@ class Solargraph(SolidLanguageServer): except subprocess.CalledProcessError as e: raise RuntimeError(f"Failed to check or install Solargraph. {e.stderr}") from e - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Solargraph Language Server. """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: - d = json.load(f) - - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "capabilities": {}, + "trace": "verbose", + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": pathlib.Path(repository_absolute_path).as_uri(), + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params def _start_server(self): """ diff --git a/src/solidlsp/language_servers/solargraph/initialize_params.json b/src/solidlsp/language_servers/solargraph/initialize_params.json deleted file mode 100644 index 2cbb41f8..00000000 --- a/src/solidlsp/language_servers/solargraph/initialize_params.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "_description": "This file contains the initialization parameters for the Solargraph Language Server.", - "processId": "$processId", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "capabilities": { - }, - "trace": "verbose", - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] -} diff --git a/src/solidlsp/language_servers/solargraph/runtime_dependencies.json b/src/solidlsp/language_servers/solargraph/runtime_dependencies.json deleted file mode 100644 index 38cc2372..00000000 --- a/src/solidlsp/language_servers/solargraph/runtime_dependencies.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "_description": "This file contains URLs and other metadata required for downloading and installing the Solargraph language server.", - "runtimeDependencies": [ - { - "url": "https://rubygems.org/downloads/solargraph-0.51.1.gem", - "installCommand": "gem install solargraph -v 0.51.1", - "binaryName": "solargraph", - "archiveType": "gem" - } - ] -} diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py new file mode 100644 index 00000000..5a230a23 --- /dev/null +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -0,0 +1,203 @@ +import logging +import os +import shutil +import stat +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import PathUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + +from .common import RuntimeDependency, RuntimeDependencyCollection + + +class TerraformLS(SolidLanguageServer): + """ + Provides Terraform specific instantiation of the LanguageServer class using terraform-ls. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"] + + @staticmethod + def _ensure_tf_command_available(logger: LanguageServerLogger): + logger.log("Starting terraform version detection...", logging.DEBUG) + + # 1. Try to find terraform using shutil.which + terraform_cmd = shutil.which("terraform") + if terraform_cmd is not None: + logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) + return + + # TODO: is this needed? + # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) + if not terraform_cmd: + terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") + if terraform_cli_path: + logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) + # TODO: use binary name from runtime dependencies if we keep this code + if os.name == "nt": + terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") + else: + terraform_binary = os.path.join(terraform_cli_path, "terraform") + if os.path.exists(terraform_binary): + terraform_cmd = terraform_binary + logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) + return + + raise RuntimeError( + "Terraform executable not found, please ensure Terraform is installed." + "See https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli for instructions." + ) + + @classmethod + def _setup_runtime_dependencies(cls, logger: LanguageServerLogger) -> str: + """ + Setup runtime dependencies for terraform-ls. + Downloads and installs terraform-ls if not already present. + """ + cls._ensure_tf_command_available(logger) + platform_id = PlatformUtils.get_platform_id() + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for macOS (ARM64)", + url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_arm64.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="terraform-ls", + ), + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for macOS (x64)", + url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_amd64.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name="terraform-ls", + ), + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for Linux (x64)", + url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_amd64.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="terraform-ls", + ), + RuntimeDependency( + id="TerraformLS", + description="terraform-ls for Windows (x64)", + url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_windows_amd64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="terraform-ls.exe", + ), + ] + ) + dependency = deps.single_for_current_platform() + + terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir()) + if not os.path.exists(terraform_ls_executable_path): + logger.log(f"Downloading terraform-ls from {dependency.url}", logging.INFO) + deps.install(logger, cls.ls_resources_dir()) + + assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" + + # Make the executable file executable on Unix-like systems + if platform_id.value != "win-x64": + os.chmod(terraform_ls_executable_path, stat.S_IEXEC | stat.S_IREAD) + + return terraform_ls_executable_path + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + terraform_ls_executable_path = self._setup_runtime_dependencies(logger) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), + "terraform", + ) + self.server_ready = threading.Event() + self.request_id = 0 + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Terraform Language Server. + """ + root_uri = PathUtils.path_to_uri(repository_absolute_path) + return { + "processId": os.getpid(), + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + "workspaceFolders": [ + { + "name": os.path.basename(repository_absolute_path), + "uri": root_uri, + } + ], + } + + def _start_server(self): + """Start terraform-ls server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting terraform-ls server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # terraform-ls server is typically ready immediately after initialization + self.server_ready.set() + self.server_ready.wait() diff --git a/src/solidlsp/language_servers/gopls/initialize_params.json b/src/solidlsp/language_servers/terraform_ls/initialize_params.json similarity index 100% rename from src/solidlsp/language_servers/gopls/initialize_params.json rename to src/solidlsp/language_servers/terraform_ls/initialize_params.json diff --git a/src/solidlsp/language_servers/terraform_ls/runtime_dependencies.json b/src/solidlsp/language_servers/terraform_ls/runtime_dependencies.json new file mode 100644 index 00000000..873e7ecf --- /dev/null +++ b/src/solidlsp/language_servers/terraform_ls/runtime_dependencies.json @@ -0,0 +1,37 @@ +{ + "_description": "Used to download the runtime dependencies for running terraform-ls. Obtained from https://releases.hashicorp.com/terraform-ls/", + "runtimeDependencies": [ + { + "id": "TerraformLS", + "description": "terraform-ls for macOS (ARM64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_arm64.zip", + "platformId": "osx-arm64", + "archiveType": "zip", + "binaryName": "terraform-ls" + }, + { + "id": "TerraformLS", + "description": "terraform-ls for macOS (x64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_amd64.zip", + "platformId": "osx-x64", + "archiveType": "zip", + "binaryName": "terraform-ls" + }, + { + "id": "TerraformLS", + "description": "terraform-ls for Linux (x64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_amd64.zip", + "platformId": "linux-x64", + "archiveType": "zip", + "binaryName": "terraform-ls" + }, + { + "id": "TerraformLS", + "description": "terraform-ls for Windows (x64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_windows_amd64.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "terraform-ls.exe" + } + ] +} diff --git a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py new file mode 100644 index 00000000..62f31cb5 --- /dev/null +++ b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py @@ -0,0 +1,194 @@ +import json +import logging +import os +import pathlib +import shutil +import stat +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + + +class TerraformLS(SolidLanguageServer): + """ + Provides Terraform specific instantiation of the LanguageServer class using terraform-ls. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"] + + @staticmethod + def _get_terraform_version(logger: LanguageServerLogger) -> str: + logger.log("Starting terraform version detection...", logging.DEBUG) + + # 1. Try to find terraform using shutil.which + terraform_cmd = shutil.which("terraform") + if terraform_cmd is not None: + logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) + + # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) + if not terraform_cmd: + terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") + if terraform_cli_path: + logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) + if os.name == "nt": + terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") + else: + terraform_binary = os.path.join(terraform_cli_path, "terraform") + if os.path.exists(terraform_binary): + terraform_cmd = terraform_binary + logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) + + if not terraform_cmd: + raise RuntimeError("Terraform executable not found. Please ensure Terraform is installed and accessible in your system's PATH.") + + logger.log(f"Attempting to run: {terraform_cmd} version (with 15s timeout)", logging.DEBUG) + result = subprocess.run( + [terraform_cmd, "version"], + capture_output=True, + text=True, + check=False, + timeout=15, # CRITICAL: 15 second timeout to prevent hangs + ) + if result.returncode == 0: + logger.log("terraform version command succeeded", logging.DEBUG) + return result.stdout.strip() + else: + raise RuntimeError(f"terraform version command failed with return code {result.returncode}: {result.stderr}") + + # Note: needs to remain static because it's called before init is complete + @staticmethod + def _setup_runtime_dependencies(logger: LanguageServerLogger) -> str: + """ + Setup runtime dependencies for terraform-ls. + Downloads and installs terraform-ls if not already present. + """ + # First check if Terraform is available + terraform_version = TerraformLS._get_terraform_version(logger) + if not terraform_version: + raise RuntimeError( + "Terraform executable not found or failed to execute. " + "Please ensure Terraform is installed and accessible in your system's PATH.\n" + "If it's installed, check for permission issues or corrupted installation.\n" + "Download from https://www.terraform.io/downloads" + ) + + platform_id = PlatformUtils.get_platform_id() + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] + assert ( + len(runtime_dependencies) == 1 + ), f"Expected exactly one runtime dependency for platform {platform_id.value}, found {len(runtime_dependencies)}" + dependency = runtime_dependencies[0] + + terraform_ls_dir = os.path.join(os.path.dirname(__file__), "static", "TerraformLS") + terraform_ls_executable_path = os.path.join(terraform_ls_dir, dependency["binaryName"]) + + if not os.path.exists(terraform_ls_dir): + os.makedirs(terraform_ls_dir) + + if not os.path.exists(terraform_ls_executable_path): + logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) + FileUtils.download_and_extract_archive(logger, dependency["url"], terraform_ls_dir, dependency["archiveType"]) + + assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" + + # Make the executable file executable on Unix-like systems + if platform_id.value != "win-x64": + os.chmod(terraform_ls_executable_path, stat.S_IEXEC | stat.S_IREAD) + + return terraform_ls_executable_path + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + terraform_ls_executable_path = self._setup_runtime_dependencies(logger) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), + "terraform", + ) + self.server_ready = threading.Event() + self.request_id = 0 + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Terraform Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + def _start_server(self): + """Start terraform-ls server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting terraform-ls server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # terraform-ls server is typically ready immediately after initialization + self.server_ready.set() + self.server_ready.wait() diff --git a/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py similarity index 73% rename from src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py rename to src/solidlsp/language_servers/typescript_language_server.py index f2204065..04ce473c 100644 --- a/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -2,12 +2,10 @@ Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. """ -import json import logging import os import pathlib import shutil -import subprocess import threading from time import sleep @@ -20,6 +18,8 @@ from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from .common import RuntimeDependency, RuntimeDependencyCollection + # Platform-specific imports if os.name != "nt": # Unix-like systems import pwd @@ -33,7 +33,7 @@ else: # Conditionally import pwd module (Unix-only) if not PlatformUtils.get_platform_id().value.startswith("win"): - import pwd + pass class TypeScriptLanguageServer(SolidLanguageServer): @@ -45,7 +45,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): """ Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - ts_lsp_executable_path = self.setup_runtime_dependencies(logger, config) + ts_lsp_executable_path = self._setup_runtime_dependencies(logger, config) super().__init__( config, logger, @@ -65,9 +65,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): "coverage", ] - def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + @classmethod + def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: """ - Setup runtime dependencies for TypeScript Language Server. + Setup runtime dependencies for TypeScript Language Server and return the command to start the server. """ platform_id = PlatformUtils.get_platform_id() @@ -82,12 +83,23 @@ class TypeScriptLanguageServer(SolidLanguageServer): ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" - with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json")) as f: - d = json.load(f) - del d["_description"] - - runtime_dependencies = d.get("runtimeDependencies", []) - tsserver_ls_dir = os.path.join(os.path.dirname(__file__), "static", "ts-lsp") + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="typescript", + description="typescript package", + command="npm install --prefix ./ typescript@5.5.4", + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="typescript-language-server package", + command="npm install --prefix ./ typescript-language-server@4.3.3", + platform_id="any", + ), + ] + ) + tsserver_ls_dir = os.path.join(cls.ls_resources_dir(), "ts-lsp") tsserver_executable_path = os.path.join(tsserver_ls_dir, "typescript-language-server") # Verify both node and npm are installed @@ -99,29 +111,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): # Install typescript and typescript-language-server if not already installed if not os.path.exists(tsserver_ls_dir): os.makedirs(tsserver_ls_dir, exist_ok=True) - for dependency in runtime_dependencies: - # Windows doesn't support the 'user' parameter and doesn't have pwd module - if PlatformUtils.get_platform_id().value.startswith("win"): - subprocess.run( - dependency["command"], - shell=True, - check=True, - cwd=tsserver_ls_dir, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - else: - # On Unix-like systems, run as non-root user - user = pwd.getpwuid(os.getuid()).pw_name - subprocess.run( - dependency["command"], - shell=True, - check=True, - user=user, - cwd=tsserver_ls_dir, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + deps.install(logger, tsserver_ls_dir) tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") @@ -130,29 +120,46 @@ class TypeScriptLanguageServer(SolidLanguageServer): ), "typescript-language-server executable not found. Please install typescript-language-server and try again." return f"{tsserver_executable_path} --stdio" - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the TypeScript Language Server. """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json")) as f: - d = json.load(f) - - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params def _start_server(self): """ diff --git a/src/solidlsp/language_servers/typescript_language_server/initialize_params.json b/src/solidlsp/language_servers/typescript_language_server/initialize_params.json deleted file mode 100644 index 6209ba47..00000000 --- a/src/solidlsp/language_servers/typescript_language_server/initialize_params.json +++ /dev/null @@ -1,906 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "clientInfo": { - "name": "Visual Studio Code - Insiders", - "version": "1.82.0-insider" - }, - "locale": "en", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "capabilities": { - "workspace": { - "applyEdit": true, - "workspaceEdit": { - "documentChanges": true, - "resourceOperations": [ - "create", - "rename", - "delete" - ], - "failureHandling": "textOnlyTransactional", - "normalizesLineEndings": true, - "changeAnnotationSupport": { - "groupsOnLabel": true - } - }, - "configuration": true, - "didChangeWatchedFiles": { - "dynamicRegistration": true, - "relativePatternSupport": true - }, - "symbol": { - "dynamicRegistration": 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 - ] - }, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "resolveSupport": { - "properties": [ - "location.range" - ] - } - }, - "codeLens": { - "refreshSupport": true - }, - "executeCommand": { - "dynamicRegistration": true - }, - "didChangeConfiguration": { - "dynamicRegistration": true - }, - "workspaceFolders": true, - "semanticTokens": { - "refreshSupport": true - }, - "fileOperations": { - "dynamicRegistration": true, - "didCreate": true, - "didRename": true, - "didDelete": true, - "willCreate": true, - "willRename": true, - "willDelete": true - }, - "inlineValue": { - "refreshSupport": true - }, - "inlayHint": { - "refreshSupport": true - }, - "diagnostics": { - "refreshSupport": true - } - }, - "textDocument": { - "publishDiagnostics": { - "relatedInformation": true, - "versionSupport": false, - "tagSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "codeDescriptionSupport": true, - "dataSupport": true - }, - "synchronization": { - "dynamicRegistration": true, - "willSave": true, - "willSaveWaitUntil": true, - "didSave": true - }, - "completion": { - "dynamicRegistration": true, - "contextSupport": true, - "completionItem": { - "snippetSupport": true, - "commitCharactersSupport": true, - "documentationFormat": [ - "markdown", - "plaintext" - ], - "deprecatedSupport": true, - "preselectSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "insertReplaceSupport": true, - "resolveSupport": { - "properties": [ - "documentation", - "detail", - "additionalTextEdits" - ] - }, - "insertTextModeSupport": { - "valueSet": [ - 1, - 2 - ] - }, - "labelDetailsSupport": true - }, - "insertTextMode": 2, - "completionItemKind": { - "valueSet": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25 - ] - }, - "completionList": { - "itemDefaults": [ - "commitCharacters", - "editRange", - "insertTextFormat", - "insertTextMode" - ] - } - }, - "hover": { - "dynamicRegistration": true, - "contentFormat": [ - "markdown", - "plaintext" - ] - }, - "signatureHelp": { - "dynamicRegistration": true, - "signatureInformation": { - "documentationFormat": [ - "markdown", - "plaintext" - ], - "parameterInformation": { - "labelOffsetSupport": true - }, - "activeParameterSupport": true - }, - "contextSupport": true - }, - "definition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "references": { - "dynamicRegistration": true - }, - "documentHighlight": { - "dynamicRegistration": true - }, - "documentSymbol": { - "dynamicRegistration": 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 - ] - }, - "hierarchicalDocumentSymbolSupport": true, - "tagSupport": { - "valueSet": [ - 1 - ] - }, - "labelSupport": true - }, - "codeAction": { - "dynamicRegistration": true, - "isPreferredSupport": true, - "disabledSupport": true, - "dataSupport": true, - "resolveSupport": { - "properties": [ - "edit" - ] - }, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports" - ] - } - }, - "honorsChangeAnnotations": false - }, - "codeLens": { - "dynamicRegistration": true - }, - "formatting": { - "dynamicRegistration": true - }, - "rangeFormatting": { - "dynamicRegistration": true - }, - "onTypeFormatting": { - "dynamicRegistration": true - }, - "rename": { - "dynamicRegistration": true, - "prepareSupport": true, - "prepareSupportDefaultBehavior": 1, - "honorsChangeAnnotations": true - }, - "documentLink": { - "dynamicRegistration": true, - "tooltipSupport": true - }, - "typeDefinition": { - "dynamicRegistration": true, - "linkSupport": true - }, - "implementation": { - "dynamicRegistration": true, - "linkSupport": true - }, - "colorProvider": { - "dynamicRegistration": true - }, - "foldingRange": { - "dynamicRegistration": true, - "rangeLimit": 5000, - "lineFoldingOnly": true, - "foldingRangeKind": { - "valueSet": [ - "comment", - "imports", - "region" - ] - }, - "foldingRange": { - "collapsedText": false - } - }, - "declaration": { - "dynamicRegistration": true, - "linkSupport": true - }, - "selectionRange": { - "dynamicRegistration": true - }, - "callHierarchy": { - "dynamicRegistration": true - }, - "semanticTokens": { - "dynamicRegistration": true, - "tokenTypes": [ - "namespace", - "type", - "class", - "enum", - "interface", - "struct", - "typeParameter", - "parameter", - "variable", - "property", - "enumMember", - "event", - "function", - "method", - "macro", - "keyword", - "modifier", - "comment", - "string", - "number", - "regexp", - "operator", - "decorator" - ], - "tokenModifiers": [ - "declaration", - "definition", - "readonly", - "static", - "deprecated", - "abstract", - "async", - "modification", - "documentation", - "defaultLibrary" - ], - "formats": [ - "relative" - ], - "requests": { - "range": true, - "full": { - "delta": true - } - }, - "multilineTokenSupport": false, - "overlappingTokenSupport": false, - "serverCancelSupport": true, - "augmentsSyntaxTokens": false - }, - "linkedEditingRange": { - "dynamicRegistration": true - }, - "typeHierarchy": { - "dynamicRegistration": true - }, - "inlineValue": { - "dynamicRegistration": true - }, - "inlayHint": { - "dynamicRegistration": true, - "resolveSupport": { - "properties": [ - "tooltip", - "textEdits", - "label.tooltip", - "label.location", - "label.command" - ] - } - }, - "diagnostic": { - "dynamicRegistration": true, - "relatedDocumentSupport": false - } - }, - "general": { - "staleRequestSupport": { - "cancel": true, - "retryOnContentModified": [ - "textDocument/semanticTokens/full", - "textDocument/semanticTokens/range", - "textDocument/semanticTokens/full/delta" - ] - }, - "regularExpressions": { - "engine": "ECMAScript", - "version": "ES2020" - }, - "markdown": { - "parser": "marked", - "version": "1.1.0", - "allowedTags": [ - "ul", - "li", - "p", - "code", - "blockquote", - "ol", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "em", - "pre", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - "div", - "del", - "a", - "strong", - "br", - "img", - "span" - ] - }, - "positionEncodings": [ - "utf-16" - ] - }, - "notebookDocument": { - "synchronization": { - "dynamicRegistration": true, - "executionSummarySupport": true - } - }, - "experimental": { - "snippetTextEdit": true, - "codeActionGroup": true, - "hoverActions": true, - "serverStatusNotification": true, - "colorDiagnosticOutput": true, - "openServerLogs": true, - "localDocs": true, - "commands": { - "commands": [ - "rust-analyzer.runSingle", - "rust-analyzer.debugSingle", - "rust-analyzer.showReferences", - "rust-analyzer.gotoLocation", - "editor.action.triggerParameterHints" - ] - } - } - }, - "initializationOptions": { - "cargoRunner": null, - "runnables": { - "extraEnv": null, - "problemMatcher": [ - "$rustc" - ], - "command": null, - "extraArgs": [] - }, - "statusBar": { - "clickAction": "openLogs" - }, - "server": { - "path": null, - "extraEnv": null - }, - "trace": { - "server": "verbose", - "extension": false - }, - "debug": { - "engine": "auto", - "sourceFileMap": { - "/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust" - }, - "openDebugPane": false, - "engineSettings": {} - }, - "restartServerOnConfigChange": false, - "typing": { - "continueCommentsOnNewline": true, - "autoClosingAngleBrackets": { - "enable": false - } - }, - "diagnostics": { - "previewRustcOutput": false, - "useRustcErrorCode": false, - "disabled": [], - "enable": true, - "experimental": { - "enable": false - }, - "remapPrefix": {}, - "warningsAsHint": [], - "warningsAsInfo": [] - }, - "discoverProjectRunner": null, - "showUnlinkedFileNotification": true, - "showDependenciesExplorer": true, - "assist": { - "emitMustUse": false, - "expressionFillDefault": "todo" - }, - "cachePriming": { - "enable": true, - "numThreads": 0 - }, - "cargo": { - "autoreload": true, - "buildScripts": { - "enable": true, - "invocationLocation": "workspace", - "invocationStrategy": "per_workspace", - "overrideCommand": null, - "useRustcWrapper": true - }, - "cfgs": {}, - "extraArgs": [], - "extraEnv": {}, - "features": [], - "noDefaultFeatures": false, - "sysroot": "discover", - "sysrootSrc": null, - "target": null, - "unsetTest": [ - "core" - ] - }, - "checkOnSave": true, - "check": { - "allTargets": true, - "command": "check", - "extraArgs": [], - "extraEnv": {}, - "features": null, - "ignore": [], - "invocationLocation": "workspace", - "invocationStrategy": "per_workspace", - "noDefaultFeatures": null, - "overrideCommand": null, - "targets": null - }, - "completion": { - "autoimport": { - "enable": true - }, - "autoself": { - "enable": true - }, - "callable": { - "snippets": "fill_arguments" - }, - "fullFunctionSignatures": { - "enable": false - }, - "limit": null, - "postfix": { - "enable": true - }, - "privateEditable": { - "enable": false - }, - "snippets": { - "custom": { - "Arc::new": { - "postfix": "arc", - "body": "Arc::new(${receiver})", - "requires": "std::sync::Arc", - "description": "Put the expression into an `Arc`", - "scope": "expr" - }, - "Rc::new": { - "postfix": "rc", - "body": "Rc::new(${receiver})", - "requires": "std::rc::Rc", - "description": "Put the expression into an `Rc`", - "scope": "expr" - }, - "Box::pin": { - "postfix": "pinbox", - "body": "Box::pin(${receiver})", - "requires": "std::boxed::Box", - "description": "Put the expression into a pinned `Box`", - "scope": "expr" - }, - "Ok": { - "postfix": "ok", - "body": "Ok(${receiver})", - "description": "Wrap the expression in a `Result::Ok`", - "scope": "expr" - }, - "Err": { - "postfix": "err", - "body": "Err(${receiver})", - "description": "Wrap the expression in a `Result::Err`", - "scope": "expr" - }, - "Some": { - "postfix": "some", - "body": "Some(${receiver})", - "description": "Wrap the expression in an `Option::Some`", - "scope": "expr" - } - } - } - }, - "files": { - "excludeDirs": [], - "watcher": "client" - }, - "highlightRelated": { - "breakPoints": { - "enable": true - }, - "closureCaptures": { - "enable": true - }, - "exitPoints": { - "enable": true - }, - "references": { - "enable": true - }, - "yieldPoints": { - "enable": true - } - }, - "hover": { - "actions": { - "debug": { - "enable": true - }, - "enable": true, - "gotoTypeDef": { - "enable": true - }, - "implementations": { - "enable": true - }, - "references": { - "enable": false - }, - "run": { - "enable": true - } - }, - "documentation": { - "enable": true, - "keywords": { - "enable": true - } - }, - "links": { - "enable": true - }, - "memoryLayout": { - "alignment": "hexadecimal", - "enable": true, - "niches": false, - "offset": "hexadecimal", - "size": "both" - } - }, - "imports": { - "granularity": { - "enforce": false, - "group": "crate" - }, - "group": { - "enable": true - }, - "merge": { - "glob": true - }, - "preferNoStd": false, - "preferPrelude": false, - "prefix": "plain" - }, - "inlayHints": { - "bindingModeHints": { - "enable": false - }, - "chainingHints": { - "enable": true - }, - "closingBraceHints": { - "enable": true, - "minLines": 25 - }, - "closureCaptureHints": { - "enable": false - }, - "closureReturnTypeHints": { - "enable": "never" - }, - "closureStyle": "impl_fn", - "discriminantHints": { - "enable": "never" - }, - "expressionAdjustmentHints": { - "enable": "never", - "hideOutsideUnsafe": false, - "mode": "prefix" - }, - "lifetimeElisionHints": { - "enable": "never", - "useParameterNames": false - }, - "maxLength": 25, - "parameterHints": { - "enable": true - }, - "reborrowHints": { - "enable": "never" - }, - "renderColons": true, - "typeHints": { - "enable": true, - "hideClosureInitialization": false, - "hideNamedConstructor": false - } - }, - "interpret": { - "tests": false - }, - "joinLines": { - "joinAssignments": true, - "joinElseIf": true, - "removeTrailingComma": true, - "unwrapTrivialBlock": true - }, - "lens": { - "debug": { - "enable": true - }, - "enable": true, - "forceCustomCommands": true, - "implementations": { - "enable": true - }, - "location": "above_name", - "references": { - "adt": { - "enable": false - }, - "enumVariant": { - "enable": false - }, - "method": { - "enable": false - }, - "trait": { - "enable": false - } - }, - "run": { - "enable": true - } - }, - "linkedProjects": [], - "lru": { - "capacity": null, - "query": { - "capacities": {} - } - }, - "notifications": { - "cargoTomlNotFound": true - }, - "numThreads": null, - "procMacro": { - "attributes": { - "enable": true - }, - "enable": true, - "ignored": {}, - "server": null - }, - "references": { - "excludeImports": false - }, - "rust": { - "analyzerTargetDir": null - }, - "rustc": { - "source": null - }, - "rustfmt": { - "extraArgs": [], - "overrideCommand": null, - "rangeFormatting": { - "enable": false - } - }, - "semanticHighlighting": { - "doc": { - "comment": { - "inject": { - "enable": true - } - } - }, - "nonStandardTokens": true, - "operator": { - "enable": true, - "specialization": { - "enable": false - } - }, - "punctuation": { - "enable": false, - "separate": { - "macro": { - "bang": false - } - }, - "specialization": { - "enable": false - } - }, - "strings": { - "enable": true - } - }, - "signatureInfo": { - "detail": "full", - "documentation": { - "enable": true - } - }, - "workspace": { - "symbol": { - "search": { - "kind": "only_types", - "limit": 128, - "scope": "workspace" - } - } - } - }, - "trace": "verbose", - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/typescript_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/typescript_language_server/runtime_dependencies.json deleted file mode 100644 index 90824a7c..00000000 --- a/src/solidlsp/language_servers/typescript_language_server/runtime_dependencies.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "_description": "Used to download the runtime dependencies for running typescript-language-server. Obtained from https://github.com/typescript-language-server/typescript-language-server/releases", - "runtimeDependencies": [ - { - "id": "typescript", - "description": "typescript package for Linux, OSX, and Windows. Both x64 and arm64 are supported.", - "command": "npm install --prefix ./ typescript@5.5.4" - }, - { - "id": "typescript-language-server", - "description": "typescript-language-server package for Linux, OSX, and Windows. Both x64 and arm64 are supported.", - "command": "npm install --prefix ./ typescript-language-server@4.3.3" - } - ] -} - diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 4b8163cc..b19c4708 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -89,6 +89,17 @@ class SolidLanguageServer(ABC): """ return dirname.startswith(".") + @classmethod + def ls_resources_dir(cls, mkdir: bool = True) -> str: + """ + Returns the directory where the language server resources are downloaded. + This is used to store language server binaries, configuration files, etc. + """ + result = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) + if mkdir: + os.makedirs(result, exist_ok=True) + return result + @classmethod def create( cls, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, timeout: float | None = None @@ -108,28 +119,32 @@ class SolidLanguageServer(ABC): ls: SolidLanguageServer if config.code_language == Language.PYTHON: - from solidlsp.language_servers.pyright_language_server.pyright_server import ( + # We can also use jedi + # from solidlsp.language_servers.jedi_server import JediServer + # + # ls = JediServer(config, logger, repository_root_path) + from solidlsp.language_servers.pyright_server import ( PyrightServer, ) ls = PyrightServer(config, logger, repository_root_path) elif config.code_language == Language.JAVA: - from solidlsp.language_servers.eclipse_jdtls.eclipse_jdtls import ( + from solidlsp.language_servers.eclipse_jdtls import ( EclipseJDTLS, ) ls = EclipseJDTLS(config, logger, repository_root_path) elif config.code_language == Language.KOTLIN: - from solidlsp.language_servers.kotlin_language_server.kotlin_language_server import ( + from solidlsp.language_servers.kotlin_language_server import ( KotlinLanguageServer, ) ls = KotlinLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.RUST: - from solidlsp.language_servers.rust_analyzer.rust_analyzer import ( + from solidlsp.language_servers.rust_analyzer import ( RustAnalyzer, ) @@ -141,47 +156,57 @@ class SolidLanguageServer(ABC): # from solidlsp.language_servers.omnisharp.omnisharp import OmniSharp # ls = OmniSharp(config, logger, repository_root_path) - from solidlsp.language_servers.csharp_language_server.csharp_language_server import CSharpLanguageServer + from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer ls = CSharpLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.TYPESCRIPT: - from solidlsp.language_servers.typescript_language_server.typescript_language_server import ( + from solidlsp.language_servers.typescript_language_server import ( TypeScriptLanguageServer, ) ls = TypeScriptLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.GO: - from solidlsp.language_servers.gopls.gopls import Gopls + from solidlsp.language_servers.gopls import Gopls ls = Gopls(config, logger, repository_root_path) elif config.code_language == Language.RUBY: - from solidlsp.language_servers.solargraph.solargraph import Solargraph + from solidlsp.language_servers.solargraph import Solargraph ls = Solargraph(config, logger, repository_root_path) elif config.code_language == Language.DART: - from solidlsp.language_servers.dart_language_server.dart_language_server import DartLanguageServer + from solidlsp.language_servers.dart_language_server import DartLanguageServer ls = DartLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.CPP: - from solidlsp.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer + from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer ls = ClangdLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.PHP: - from solidlsp.language_servers.intelephense.intelephense import Intelephense + from solidlsp.language_servers.intelephense import Intelephense ls = Intelephense(config, logger, repository_root_path) elif config.code_language == Language.CLOJURE: - from solidlsp.language_servers.clojure_lsp.clojure_lsp import ClojureLSP + from solidlsp.language_servers.clojure_lsp import ClojureLSP ls = ClojureLSP(config, logger, repository_root_path) + elif config.code_language == Language.ELIXIR: + from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools + + ls = ElixirTools(config, logger, repository_root_path) + + elif config.code_language == Language.TERRAFORM: + from solidlsp.language_servers.terraform_ls import TerraformLS + + ls = TerraformLS(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") @@ -664,6 +689,14 @@ class SolidLanguageServer(ABC): assert LSPConstants.RANGE in item abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) + if not Path(abs_path).is_relative_to(self.repository_root_path): + self.logger.log( + "Found a reference in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " + f"Path: {abs_path}. This is a bug but we currently simply skip these references.", + logging.WARNING, + ) + continue + rel_path = Path(abs_path).relative_to(self.repository_root_path) if self.is_ignored_path(str(rel_path)): self.logger.log(f"Ignoring reference in {rel_path} since it should be ignored", logging.DEBUG) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 5609ec06..e5df9011 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -38,6 +38,8 @@ class Language(str, Enum): CPP = "cpp" PHP = "php" CLOJURE = "clojure" + ELIXIR = "elixir" + TERRAFORM = "terraform" def __str__(self) -> str: return self.value @@ -74,6 +76,10 @@ class Language(str, Enum): return FilenameMatcher("*.php") case self.CLOJURE: return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn + case self.ELIXIR: + return FilenameMatcher("*.ex", "*.exs") + case self.TERRAFORM: + return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate") case _: raise ValueError(f"Unhandled language: {self}") diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 57875c04..b46491ec 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -206,10 +206,10 @@ class FileUtils: os.makedirs(os.path.dirname(tmp_file_name), exist_ok=True) FileUtils.download_file(logger, url, tmp_file_name) if archive_type in ["zip", "tar", "gztar", "bztar", "xztar"]: - assert os.path.isdir(target_path) + os.makedirs(target_path, exist_ok=True) shutil.unpack_archive(tmp_file_name, target_path, archive_type) elif archive_type == "zip.gz": - assert os.path.isdir(target_path) + os.makedirs(target_path, exist_ok=True) tmp_file_name_ungzipped = tmp_file_name + ".zip" tmp_files.append(tmp_file_name_ungzipped) with gzip.open(tmp_file_name, "rb") as f_in, open(tmp_file_name_ungzipped, "wb") as f_out: @@ -257,6 +257,7 @@ class DotnetVersion(str, Enum): V6 = "6" V7 = "7" V8 = "8" + V9 = "9" VMONO = "mono" @@ -345,15 +346,14 @@ class PlatformUtils: # Check for supported versions in order of preference (latest first) for version_cmd_output in available_version_cmd_output: + if version_cmd_output.startswith("9"): + return DotnetVersion.V9 if version_cmd_output.startswith("8"): return DotnetVersion.V8 - for version_cmd_output in available_version_cmd_output: if version_cmd_output.startswith("7"): return DotnetVersion.V7 - for version_cmd_output in available_version_cmd_output: if version_cmd_output.startswith("6"): return DotnetVersion.V6 - for version_cmd_output in available_version_cmd_output: if version_cmd_output.startswith("4"): return DotnetVersion.V4 diff --git a/test/resources/repos/csharp/serena.sln b/test/resources/repos/csharp/test_repo/serena.sln similarity index 98% rename from test/resources/repos/csharp/serena.sln rename to test/resources/repos/csharp/test_repo/serena.sln index 30e1b535..ef7a1315 100644 --- a/test/resources/repos/csharp/serena.sln +++ b/test/resources/repos/csharp/test_repo/serena.sln @@ -1,38 +1,38 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{EF7103B4-4C75-1E6D-A485-A154A88D107A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "repos", "repos", "{E2326EEF-E677-6A44-0935-7677816F09E7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{C21E6CE7-177A-86D9-040F-A317F18B6DBF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject", "test\resources\repos\csharp\test_repo\TestProject.csproj", "{A4D04E18-760A-73F9-3303-0542F6298C84}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {EF7103B4-4C75-1E6D-A485-A154A88D107A} = {0C88DD14-F956-CE84-757C-A364CCF449FC} - {E2326EEF-E677-6A44-0935-7677816F09E7} = {EF7103B4-4C75-1E6D-A485-A154A88D107A} - {C21E6CE7-177A-86D9-040F-A317F18B6DBF} = {E2326EEF-E677-6A44-0935-7677816F09E7} - {A4D04E18-760A-73F9-3303-0542F6298C84} = {C21E6CE7-177A-86D9-040F-A317F18B6DBF} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {BDCA748E-D888-4BAF-BF24-DAC683113BFC} - EndGlobalSection -EndGlobal +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{EF7103B4-4C75-1E6D-A485-A154A88D107A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "repos", "repos", "{E2326EEF-E677-6A44-0935-7677816F09E7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{C21E6CE7-177A-86D9-040F-A317F18B6DBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject", "test\resources\repos\csharp\test_repo\TestProject.csproj", "{A4D04E18-760A-73F9-3303-0542F6298C84}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EF7103B4-4C75-1E6D-A485-A154A88D107A} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {E2326EEF-E677-6A44-0935-7677816F09E7} = {EF7103B4-4C75-1E6D-A485-A154A88D107A} + {C21E6CE7-177A-86D9-040F-A317F18B6DBF} = {E2326EEF-E677-6A44-0935-7677816F09E7} + {A4D04E18-760A-73F9-3303-0542F6298C84} = {C21E6CE7-177A-86D9-040F-A317F18B6DBF} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BDCA748E-D888-4BAF-BF24-DAC683113BFC} + EndGlobalSection +EndGlobal diff --git a/test/resources/repos/elixir/test_repo/.gitignore b/test/resources/repos/elixir/test_repo/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/test/resources/repos/elixir/test_repo/lib/examples.ex b/test/resources/repos/elixir/test_repo/lib/examples.ex new file mode 100644 index 00000000..57c27283 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/examples.ex @@ -0,0 +1,211 @@ +defmodule TestRepo.Examples do + @moduledoc """ + Examples module demonstrating usage of models and services. + Similar to Python's examples directory, this shows how different modules work together. + """ + + alias TestRepo.Models.{User, Item} + alias TestRepo.Services.{UserService, ItemService, OrderService} + + defmodule UserManagement do + @doc """ + Creates a complete user workflow example. + """ + def run_user_example do + # Start user service + {:ok, user_service} = UserService.start_link() + + # Create users + {:ok, alice} = UserService.create_user(user_service, "1", "Alice", "alice@example.com", ["admin"]) + {:ok, bob} = UserService.create_user(user_service, "2", "Bob", "bob@example.com", ["user"]) + + # Get users + {:ok, retrieved_alice} = UserService.get_user(user_service, "1") + + # List all users + all_users = UserService.list_users(user_service) + + # Clean up + GenServer.stop(user_service) + + %{ + created_alice: alice, + created_bob: bob, + retrieved_alice: retrieved_alice, + all_users: all_users + } + end + + @doc """ + Demonstrates user role management. + """ + def manage_user_roles do + user = User.new("role_user", "Role User", "role@example.com") + + # Add roles + user_with_admin = User.add_role(user, "admin") + user_with_multiple = User.add_role(user_with_admin, "moderator") + + # Check roles + has_admin = User.has_role?(user_with_multiple, "admin") + has_guest = User.has_role?(user_with_multiple, "guest") + + %{ + original_user: user, + user_with_roles: user_with_multiple, + has_admin: has_admin, + has_guest: has_guest + } + end + end + + defmodule ShoppingExample do + @doc """ + Creates a complete shopping workflow. + """ + def run_shopping_example do + # Create user and items + user = User.new("customer1", "Customer One", "customer@example.com") + item1 = Item.new("widget1", "Super Widget", 19.99, "electronics") + item2 = Item.new("gadget1", "Cool Gadget", 29.99, "electronics") + + # Create order + order = OrderService.create_order("order1", user) + + # Add items to order + order_with_item1 = OrderService.add_item_to_order(order, item1) + order_with_items = OrderService.add_item_to_order(order_with_item1, item2) + + # Process the order + processed_order = OrderService.process_order(order_with_items) + completed_order = OrderService.complete_order(processed_order) + + %{ + user: user, + items: [item1, item2], + final_order: completed_order, + total_cost: completed_order.total + } + end + + @doc """ + Demonstrates item filtering and searching. + """ + def item_filtering_example do + # Start item service + {:ok, item_service} = ItemService.start_link() + + # Create various items + ItemService.create_item(item_service, "laptop", "Gaming Laptop", 1299.99, "electronics") + ItemService.create_item(item_service, "book", "Elixir Guide", 39.99, "books") + ItemService.create_item(item_service, "phone", "Smartphone", 699.99, "electronics") + ItemService.create_item(item_service, "novel", "Great Novel", 19.99, "books") + + # Get all items + all_items = ItemService.list_items(item_service) + + # Filter by category + electronics = ItemService.list_items(item_service, "electronics") + books = ItemService.list_items(item_service, "books") + + # Clean up + Agent.stop(item_service) + + %{ + all_items: all_items, + electronics: electronics, + books: books, + total_items: length(all_items), + electronics_count: length(electronics), + books_count: length(books) + } + end + end + + defmodule IntegrationExample do + @doc """ + Runs a complete e-commerce scenario. + """ + def run_full_scenario do + # Setup services + container = TestRepo.Services.create_service_container() + TestRepo.Services.setup_sample_data(container) + + # Get sample data + {:ok, sample_user} = UserService.get_user(container.user_service, TestRepo.Services.sample_user_id()) + {:ok, sample_item} = ItemService.get_item(container.item_service, TestRepo.Services.sample_item_id()) + + # Create additional items + {:ok, premium_item} = ItemService.create_item( + container.item_service, + "premium", + "Premium Product", + 99.99, + "premium" + ) + + # Create order with multiple items + order = OrderService.create_order("big_order", sample_user, [sample_item]) + order_with_premium = OrderService.add_item_to_order(order, premium_item) + + # Process through order lifecycle + processing_order = OrderService.process_order(order_with_premium) + final_order = OrderService.complete_order(processing_order) + + # Serialize everything for output + serialized_user = TestRepo.Services.serialize_model(sample_user) + serialized_order = TestRepo.Services.serialize_model(final_order) + + # Clean up + GenServer.stop(container.user_service) + Agent.stop(container.item_service) + + %{ + scenario: "full_ecommerce", + user: serialized_user, + order: serialized_order, + total_revenue: final_order.total, + items_sold: length(final_order.items) + } + end + + @doc """ + Demonstrates error handling scenarios. + """ + def error_handling_example do + {:ok, user_service} = UserService.start_link() + + # Try to create duplicate user + {:ok, _user1} = UserService.create_user(user_service, "dup", "User", "user@example.com") + duplicate_result = UserService.create_user(user_service, "dup", "Another User", "another@example.com") + + # Try to get non-existent user + missing_user_result = UserService.get_user(user_service, "nonexistent") + + # Try to delete non-existent user + delete_result = UserService.delete_user(user_service, "nonexistent") + + GenServer.stop(user_service) + + %{ + duplicate_user_error: duplicate_result, + missing_user_error: missing_user_result, + delete_missing_error: delete_result + } + end + end + + @doc """ + Main function to run all examples. + """ + def run_all_examples do + %{ + user_management: UserManagement.run_user_example(), + role_management: UserManagement.manage_user_roles(), + shopping: ShoppingExample.run_shopping_example(), + item_filtering: ShoppingExample.item_filtering_example(), + integration: IntegrationExample.run_full_scenario(), + error_handling: IntegrationExample.error_handling_example() + } + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex b/test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex new file mode 100644 index 00000000..a311f1d5 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex @@ -0,0 +1,23 @@ +defmodule TestRepo.IgnoredDir.IgnoredModule do + @moduledoc """ + This module is in a directory that should be ignored by the language server. + It's used for testing directory filtering functionality. + """ + + alias TestRepo.Models.User + + @doc """ + This function references the User model to test that ignored directories + don't show up in symbol references. + """ + def create_ignored_user do + User.new("ignored", "Ignored User", "ignored@example.com") + end + + @doc """ + Another function that uses models. + """ + def process_ignored_user(user) do + User.add_role(user, "ignored_role") + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/models.ex b/test/resources/repos/elixir/test_repo/lib/models.ex new file mode 100644 index 00000000..4e8a42d3 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/models.ex @@ -0,0 +1,166 @@ +defmodule TestRepo.Models do + @moduledoc """ + Models module demonstrating various Elixir patterns including structs, protocols, and behaviours. + """ + + defprotocol Serializable do + @doc "Convert model to map representation" + def to_map(model) + end + + defmodule User do + @type t :: %__MODULE__{ + id: String.t(), + name: String.t() | nil, + email: String.t(), + roles: list(String.t()) + } + + defstruct [:id, :name, :email, roles: []] + + @doc """ + Creates a new user. + + ## Examples + + iex> TestRepo.Models.User.new("1", "Alice", "alice@example.com") + %TestRepo.Models.User{id: "1", name: "Alice", email: "alice@example.com", roles: []} + + """ + def new(id, name, email, roles \\ []) do + %__MODULE__{id: id, name: name, email: email, roles: roles} + end + + @doc """ + Checks if user has a specific role. + """ + def has_role?(%__MODULE__{roles: roles}, role) do + role in roles + end + + @doc """ + Adds a role to the user. + """ + def add_role(%__MODULE__{roles: roles} = user, role) do + %{user | roles: [role | roles]} + end + end + + defmodule Item do + @type t :: %__MODULE__{ + id: String.t(), + name: String.t(), + price: float(), + category: String.t() + } + + defstruct [:id, :name, :price, :category] + + @doc """ + Creates a new item. + + ## Examples + + iex> TestRepo.Models.Item.new("1", "Widget", 19.99, "electronics") + %TestRepo.Models.Item{id: "1", name: "Widget", price: 19.99, category: "electronics"} + + """ + def new(id, name, price, category) do + %__MODULE__{id: id, name: name, price: price, category: category} + end + + @doc """ + Formats price for display. + """ + def display_price(%__MODULE__{price: price}) do + "$#{:erlang.float_to_binary(price, decimals: 2)}" + end + + @doc """ + Checks if item is in a specific category. + """ + def in_category?(%__MODULE__{category: category}, target_category) do + category == target_category + end + end + + defmodule Order do + alias TestRepo.Models.{User, Item} + + @type t :: %__MODULE__{ + id: String.t(), + user: User.t(), + items: list(Item.t()), + total: float(), + status: atom() + } + + defstruct [:id, :user, items: [], total: 0.0, status: :pending] + + @doc """ + Creates a new order. + """ + def new(id, user, items \\ []) do + total = calculate_total(items) + %__MODULE__{id: id, user: user, items: items, total: total} + end + + @doc """ + Adds an item to the order. + """ + def add_item(%__MODULE__{items: items} = order, item) do + new_items = [item | items] + %{order | items: new_items, total: calculate_total(new_items)} + end + + @doc """ + Updates order status. + """ + def update_status(%__MODULE__{} = order, status) do + %{order | status: status} + end + + defp calculate_total(items) do + Enum.reduce(items, 0.0, fn item, acc -> acc + item.price end) + end + end + + # Protocol implementations + defimpl Serializable, for: User do + def to_map(%User{id: id, name: name, email: email, roles: roles}) do + %{id: id, name: name, email: email, roles: roles} + end + end + + defimpl Serializable, for: Item do + def to_map(%Item{id: id, name: name, price: price, category: category}) do + %{id: id, name: name, price: price, category: category} + end + end + + defimpl Serializable, for: Order do + def to_map(%Order{id: id, user: user, items: items, total: total, status: status}) do + %{ + id: id, + user: Serializable.to_map(user), + items: Enum.map(items, &Serializable.to_map/1), + total: total, + status: status + } + end + end + + @doc """ + Factory function to create a sample user. + """ + def create_sample_user do + User.new("sample", "Sample User", "sample@example.com", ["user"]) + end + + @doc """ + Factory function to create a sample item. + """ + def create_sample_item do + Item.new("sample", "Sample Item", 9.99, "sample") + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/services.ex b/test/resources/repos/elixir/test_repo/lib/services.ex new file mode 100644 index 00000000..b6768a9a --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/services.ex @@ -0,0 +1,257 @@ +defmodule TestRepo.Services do + @moduledoc """ + Services module demonstrating function usage and dependencies. + Similar to Python's services.py, this module uses the models defined in TestRepo.Models. + """ + + alias TestRepo.Models.{User, Item, Order, Serializable} + + defmodule UserService do + use GenServer + + # Client API + + @doc """ + Starts the UserService GenServer. + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, %{}, opts) + end + + @doc """ + Creates a new user and stores it. + """ + def create_user(pid, id, name, email, roles \\ []) do + GenServer.call(pid, {:create_user, id, name, email, roles}) + end + + @doc """ + Gets a user by ID. + """ + def get_user(pid, id) do + GenServer.call(pid, {:get_user, id}) + end + + @doc """ + Lists all users. + """ + def list_users(pid) do + GenServer.call(pid, :list_users) + end + + @doc """ + Deletes a user by ID. + """ + def delete_user(pid, id) do + GenServer.call(pid, {:delete_user, id}) + end + + # Server callbacks + + @impl true + def init(_) do + {:ok, %{}} + end + + @impl true + def handle_call({:create_user, id, name, email, roles}, _from, users) do + if Map.has_key?(users, id) do + {:reply, {:error, "User with ID #{id} already exists"}, users} + else + user = User.new(id, name, email, roles) + new_users = Map.put(users, id, user) + {:reply, {:ok, user}, new_users} + end + end + + @impl true + def handle_call({:get_user, id}, _from, users) do + case Map.get(users, id) do + nil -> {:reply, {:error, :not_found}, users} + user -> {:reply, {:ok, user}, users} + end + end + + @impl true + def handle_call(:list_users, _from, users) do + user_list = Map.values(users) + {:reply, user_list, users} + end + + @impl true + def handle_call({:delete_user, id}, _from, users) do + if Map.has_key?(users, id) do + new_users = Map.delete(users, id) + {:reply, :ok, new_users} + else + {:reply, {:error, :not_found}, users} + end + end + end + + defmodule ItemService do + use Agent + + @doc """ + Starts the ItemService Agent. + """ + def start_link(opts \\ []) do + Agent.start_link(fn -> %{} end, opts) + end + + @doc """ + Creates a new item and stores it. + """ + def create_item(pid, id, name, price, category) do + Agent.get_and_update(pid, fn items -> + if Map.has_key?(items, id) do + {{:error, "Item with ID #{id} already exists"}, items} + else + item = Item.new(id, name, price, category) + new_items = Map.put(items, id, item) + {{:ok, item}, new_items} + end + end) + end + + @doc """ + Gets an item by ID. + """ + def get_item(pid, id) do + Agent.get(pid, fn items -> + case Map.get(items, id) do + nil -> {:error, :not_found} + item -> {:ok, item} + end + end) + end + + @doc """ + Lists all items, optionally filtered by category. + """ + def list_items(pid, category \\ nil) do + Agent.get(pid, fn items -> + item_list = Map.values(items) + + case category do + nil -> item_list + cat -> Enum.filter(item_list, &Item.in_category?(&1, cat)) + end + end) + end + + @doc """ + Deletes an item by ID. + """ + def delete_item(pid, id) do + Agent.get_and_update(pid, fn items -> + if Map.has_key?(items, id) do + new_items = Map.delete(items, id) + {:ok, new_items} + else + {{:error, :not_found}, items} + end + end) + end + end + + defmodule OrderService do + @doc """ + Creates a new order. + """ + def create_order(id, user, items \\ []) do + Order.new(id, user, items) + end + + @doc """ + Adds an item to an existing order. + """ + def add_item_to_order(order, item) do + Order.add_item(order, item) + end + + @doc """ + Updates the status of an order. + """ + def update_order_status(order, status) do + Order.update_status(order, status) + end + + @doc """ + Processes an order (changes status to :processing). + """ + def process_order(order) do + update_order_status(order, :processing) + end + + @doc """ + Completes an order (changes status to :completed). + """ + def complete_order(order) do + update_order_status(order, :completed) + end + + @doc """ + Cancels an order (changes status to :cancelled). + """ + def cancel_order(order) do + update_order_status(order, :cancelled) + end + end + + @doc """ + Factory function to create a service container. + """ + def create_service_container do + {:ok, user_service} = UserService.start_link() + {:ok, item_service} = ItemService.start_link() + + %{ + user_service: user_service, + item_service: item_service, + order_service: OrderService + } + end + + @doc """ + Helper function to serialize any model that implements the Serializable protocol. + """ + def serialize_model(model) do + Serializable.to_map(model) + end + + # Module-level variables for testing + @sample_user_id "sample_user" + @sample_item_id "sample_item" + + @doc """ + Gets the sample user ID. + """ + def sample_user_id, do: @sample_user_id + + @doc """ + Gets the sample item ID. + """ + def sample_item_id, do: @sample_item_id + + # Create some sample data at module load time + def setup_sample_data(container) do + # Create sample user + UserService.create_user( + container.user_service, + @sample_user_id, + "Sample User", + "sample@example.com", + ["user", "customer"] + ) + + # Create sample item + ItemService.create_item( + container.item_service, + @sample_item_id, + "Sample Widget", + 29.99, + "electronics" + ) + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/test_repo.ex b/test/resources/repos/elixir/test_repo/lib/test_repo.ex new file mode 100644 index 00000000..96a2fd89 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/test_repo.ex @@ -0,0 +1,31 @@ +defmodule TestRepo do + @moduledoc """ + Documentation for `TestRepo`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> TestRepo.hello() + :world + + """ + def hello do + :world + end + + @doc """ + Adds two numbers together. + + ## Examples + + iex> TestRepo.add(2, 3) + 5 + + """ + def add(a, b) do + a + b + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/lib/utils.ex b/test/resources/repos/elixir/test_repo/lib/utils.ex new file mode 100644 index 00000000..458823ed --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/utils.ex @@ -0,0 +1,48 @@ +defmodule TestRepo.Utils do + @moduledoc """ + Utility functions for TestRepo. + """ + + @doc """ + Converts a string to uppercase. + + ## Examples + + iex> TestRepo.Utils.upcase("hello") + "HELLO" + + """ + def upcase(string) when is_binary(string) do + String.upcase(string) + end + + @doc """ + Calculates the factorial of a number. + + ## Examples + + iex> TestRepo.Utils.factorial(5) + 120 + + """ + def factorial(0), do: 1 + def factorial(n) when n > 0 do + n * factorial(n - 1) + end + + @doc """ + Checks if a number is even. + + ## Examples + + iex> TestRepo.Utils.even?(4) + true + + iex> TestRepo.Utils.even?(3) + false + + """ + def even?(n) when is_integer(n) do + rem(n, 2) == 0 + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/mix.exs b/test/resources/repos/elixir/test_repo/mix.exs new file mode 100644 index 00000000..76bba38e --- /dev/null +++ b/test/resources/repos/elixir/test_repo/mix.exs @@ -0,0 +1,25 @@ +defmodule TestRepo.MixProject do + use Mix.Project + + def project do + [ + app: :test_repo, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + ] + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/mix.lock b/test/resources/repos/elixir/test_repo/mix.lock new file mode 100644 index 00000000..004414be --- /dev/null +++ b/test/resources/repos/elixir/test_repo/mix.lock @@ -0,0 +1,6 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, +} diff --git a/test/resources/repos/elixir/test_repo/scripts/build_script.ex b/test/resources/repos/elixir/test_repo/scripts/build_script.ex new file mode 100644 index 00000000..cc89dfed --- /dev/null +++ b/test/resources/repos/elixir/test_repo/scripts/build_script.ex @@ -0,0 +1,26 @@ +defmodule TestRepo.Scripts.BuildScript do + @moduledoc """ + Build script that references models. + This is in the scripts directory which should be ignored in some tests. + """ + + alias TestRepo.Models.{User, Item} + + @doc """ + Script function that creates test data. + """ + def create_test_data do + user = User.new("script_user", "Script User", "script@example.com") + item = Item.new("script_item", "Script Item", 1.0, "script") + + {user, item} + end + + @doc """ + Another script function referencing User. + """ + def cleanup_users do + # This would reference User in a real scenario + IO.puts("Cleaning up users...") + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/test/models_test.exs b/test/resources/repos/elixir/test_repo/test/models_test.exs new file mode 100644 index 00000000..52260471 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/test/models_test.exs @@ -0,0 +1,169 @@ +defmodule TestRepo.ModelsTest do + use ExUnit.Case + doctest TestRepo.Models + + alias TestRepo.Models.{User, Item, Order, Serializable} + + describe "User" do + test "creates a new user with default roles" do + user = User.new("1", "Alice", "alice@example.com") + + assert user.id == "1" + assert user.name == "Alice" + assert user.email == "alice@example.com" + assert user.roles == [] + end + + test "creates a user with specified roles" do + user = User.new("2", "Bob", "bob@example.com", ["admin", "user"]) + + assert user.roles == ["admin", "user"] + end + + test "checks if user has role" do + user = User.new("3", "Charlie", "charlie@example.com", ["admin"]) + + assert User.has_role?(user, "admin") + refute User.has_role?(user, "guest") + end + + test "adds role to user" do + user = User.new("4", "David", "david@example.com") + user_with_role = User.add_role(user, "moderator") + + assert User.has_role?(user_with_role, "moderator") + assert length(user_with_role.roles) == 1 + end + end + + describe "Item" do + test "creates a new item" do + item = Item.new("widget1", "Super Widget", 19.99, "electronics") + + assert item.id == "widget1" + assert item.name == "Super Widget" + assert item.price == 19.99 + assert item.category == "electronics" + end + + test "formats price for display" do + item = Item.new("item1", "Test Item", 29.99, "test") + + assert Item.display_price(item) == "$29.99" + end + + test "checks if item is in category" do + item = Item.new("book1", "Elixir Book", 39.99, "books") + + assert Item.in_category?(item, "books") + refute Item.in_category?(item, "electronics") + end + end + + describe "Order" do + setup do + user = User.new("customer1", "Customer", "customer@example.com") + item1 = Item.new("item1", "Item 1", 10.00, "category1") + item2 = Item.new("item2", "Item 2", 20.00, "category2") + + %{user: user, item1: item1, item2: item2} + end + + test "creates a new order", %{user: user} do + order = Order.new("order1", user) + + assert order.id == "order1" + assert order.user == user + assert order.items == [] + assert order.total == 0.0 + assert order.status == :pending + end + + test "creates order with items", %{user: user, item1: item1, item2: item2} do + order = Order.new("order2", user, [item1, item2]) + + assert length(order.items) == 2 + assert order.total == 30.0 + end + + test "adds item to order", %{user: user, item1: item1, item2: item2} do + order = Order.new("order3", user, [item1]) + order_with_item = Order.add_item(order, item2) + + assert length(order_with_item.items) == 2 + assert order_with_item.total == 30.0 + end + + test "updates order status", %{user: user} do + order = Order.new("order4", user) + processed_order = Order.update_status(order, :processing) + + assert processed_order.status == :processing + end + end + + describe "Serializable protocol" do + test "serializes User" do + user = User.new("1", "Alice", "alice@example.com", ["admin"]) + serialized = Serializable.to_map(user) + + expected = %{ + id: "1", + name: "Alice", + email: "alice@example.com", + roles: ["admin"] + } + + assert serialized == expected + end + + test "serializes Item" do + item = Item.new("widget1", "Widget", 19.99, "electronics") + serialized = Serializable.to_map(item) + + expected = %{ + id: "widget1", + name: "Widget", + price: 19.99, + category: "electronics" + } + + assert serialized == expected + end + + test "serializes Order" do + user = User.new("1", "Alice", "alice@example.com") + item = Item.new("widget1", "Widget", 19.99, "electronics") + order = Order.new("order1", user, [item]) + + serialized = Serializable.to_map(order) + + assert serialized.id == "order1" + assert serialized.total == 19.99 + assert serialized.status == :pending + assert is_map(serialized.user) + assert is_list(serialized.items) + assert length(serialized.items) == 1 + end + end + + describe "factory functions" do + test "creates sample user" do + user = TestRepo.Models.create_sample_user() + + assert user.id == "sample" + assert user.name == "Sample User" + assert user.email == "sample@example.com" + assert "user" in user.roles + end + + test "creates sample item" do + item = TestRepo.Models.create_sample_item() + + assert item.id == "sample" + assert item.name == "Sample Item" + assert item.price == 9.99 + assert item.category == "sample" + end + end +end \ No newline at end of file diff --git a/test/resources/repos/elixir/test_repo/test/test_repo_test.exs b/test/resources/repos/elixir/test_repo/test/test_repo_test.exs new file mode 100644 index 00000000..62f87c20 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/test/test_repo_test.exs @@ -0,0 +1,14 @@ +defmodule TestRepoTest do + use ExUnit.Case + doctest TestRepo + + test "greets the world" do + assert TestRepo.hello() == :world + end + + test "adds numbers correctly" do + assert TestRepo.add(2, 3) == 5 + assert TestRepo.add(-1, 1) == 0 + assert TestRepo.add(0, 0) == 0 + end +end \ No newline at end of file diff --git a/test/resources/repos/terraform/test_repo/data.tf b/test/resources/repos/terraform/test_repo/data.tf new file mode 100644 index 00000000..0dd2d93f --- /dev/null +++ b/test/resources/repos/terraform/test_repo/data.tf @@ -0,0 +1,28 @@ +# Data sources for the Terraform configuration + +# Get the latest Ubuntu AMI +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] # Canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# Get available availability zones +data "aws_availability_zones" "available" { + state = "available" +} + +# Get current AWS caller identity +data "aws_caller_identity" "current" {} + +# Get current AWS region +data "aws_region" "current" {} diff --git a/test/resources/repos/terraform/test_repo/main.tf b/test/resources/repos/terraform/test_repo/main.tf new file mode 100644 index 00000000..d186c206 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/main.tf @@ -0,0 +1,126 @@ +# Main Terraform configuration +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +# EC2 Instance +resource "aws_instance" "web_server" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + + vpc_security_group_ids = [aws_security_group.web_sg.id] + subnet_id = aws_subnet.public.id + + user_data = <<-EOF + #!/bin/bash + apt-get update + apt-get install -y nginx + systemctl start nginx + systemctl enable nginx + EOF + + tags = { + Name = "${var.project_name}-web-server" + Environment = var.environment + Project = var.project_name + } +} + +# S3 Bucket +resource "aws_s3_bucket" "app_bucket" { + bucket = "${var.project_name}-${var.environment}-bucket" + + tags = { + Name = "${var.project_name}-bucket" + Environment = var.environment + Project = var.project_name + } +} + +resource "aws_s3_bucket_versioning" "app_bucket_versioning" { + bucket = aws_s3_bucket.app_bucket.id + versioning_configuration { + status = "Enabled" + } +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.project_name}-vpc" + Environment = var.environment + Project = var.project_name + } +} + +# Internet Gateway +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-igw" + Environment = var.environment + Project = var.project_name + } +} + +# Public Subnet +resource "aws_subnet" "public" { + vpc_id = aws_vpc.main.id + cidr_block = "10.0.1.0/24" + availability_zone = data.aws_availability_zones.available.names[0] + map_public_ip_on_launch = true + + tags = { + Name = "${var.project_name}-public-subnet" + Environment = var.environment + Project = var.project_name + } +} + +# Security Group +resource "aws_security_group" "web_sg" { + name_prefix = "${var.project_name}-web-" + vpc_id = aws_vpc.main.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-web-sg" + Environment = var.environment + Project = var.project_name + } +} diff --git a/test/resources/repos/terraform/test_repo/outputs.tf b/test/resources/repos/terraform/test_repo/outputs.tf new file mode 100644 index 00000000..856ed6dd --- /dev/null +++ b/test/resources/repos/terraform/test_repo/outputs.tf @@ -0,0 +1,46 @@ +# Output values for the Terraform configuration + +output "instance_id" { + description = "ID of the EC2 instance" + value = aws_instance.web_server.id +} + +output "instance_public_ip" { + description = "Public IP address of the EC2 instance" + value = aws_instance.web_server.public_ip +} + +output "instance_public_dns" { + description = "Public DNS name of the EC2 instance" + value = aws_instance.web_server.public_dns +} + +output "s3_bucket_name" { + description = "Name of the S3 bucket" + value = aws_s3_bucket.app_bucket.bucket +} + +output "s3_bucket_arn" { + description = "ARN of the S3 bucket" + value = aws_s3_bucket.app_bucket.arn +} + +output "vpc_id" { + description = "ID of the VPC" + value = aws_vpc.main.id +} + +output "subnet_id" { + description = "ID of the public subnet" + value = aws_subnet.public.id +} + +output "security_group_id" { + description = "ID of the security group" + value = aws_security_group.web_sg.id +} + +output "application_url" { + description = "URL to access the application" + value = "http://${aws_instance.web_server.public_dns}" +} diff --git a/test/resources/repos/terraform/test_repo/variables.tf b/test/resources/repos/terraform/test_repo/variables.tf new file mode 100644 index 00000000..92437bd9 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/variables.tf @@ -0,0 +1,61 @@ +# Input variables for the Terraform configuration + +variable "aws_region" { + description = "AWS region for resources" + type = string + default = "us-west-2" +} + +variable "instance_type" { + description = "EC2 instance type" + type = string + default = "t3.micro" + + validation { + condition = contains([ + "t3.micro", "t3.small", "t3.medium", + "t2.micro", "t2.small", "t2.medium" + ], var.instance_type) + error_message = "Instance type must be a valid t2 or t3 instance type." + } +} + +variable "environment" { + description = "Environment name (dev, staging, prod)" + type = string + default = "dev" + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "project_name" { + description = "Name of the project" + type = string + default = "terraform-test" + + validation { + condition = can(regex("^[a-z0-9-]+$", var.project_name)) + error_message = "Project name must contain only lowercase letters, numbers, and hyphens." + } +} + +variable "enable_monitoring" { + description = "Enable CloudWatch monitoring" + type = bool + default = false +} + +variable "allowed_cidr_blocks" { + description = "List of CIDR blocks allowed to access the application" + type = list(string) + default = ["0.0.0.0/0"] +} + +variable "tags" { + description = "Additional tags to apply to resources" + type = map(string) + default = {} +} diff --git a/test/serena/config/__init__.py b/test/serena/config/__init__.py new file mode 100644 index 00000000..6b95e0de --- /dev/null +++ b/test/serena/config/__init__.py @@ -0,0 +1 @@ +# Empty init file for test package diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py new file mode 100644 index 00000000..14eb3cd9 --- /dev/null +++ b/test/serena/config/test_serena_config.py @@ -0,0 +1,138 @@ +import shutil +import tempfile +from pathlib import Path + +import pytest + +from serena.config.serena_config import ProjectConfig +from solidlsp.ls_config import Language + + +class TestProjectConfigAutogenerate: + """Test class for ProjectConfig autogeneration functionality.""" + + def setup_method(self): + """Set up test environment before each test method.""" + # Create a temporary directory for testing + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir) + + def teardown_method(self): + """Clean up test environment after each test method.""" + # Remove the temporary directory + shutil.rmtree(self.test_dir) + + def test_autogenerate_empty_directory(self): + """Test that autogenerate raises ValueError with helpful message for empty directory.""" + with pytest.raises(ValueError) as exc_info: + ProjectConfig.autogenerate(self.project_path, save_to_disk=False) + + error_message = str(exc_info.value) + # Check that the error message contains all the key information + assert "No source files found" in error_message + assert str(self.project_path.resolve()) in error_message + assert "To use Serena with this project" in error_message + assert "Add source files in one of the supported languages" in error_message + assert "Create a project configuration file manually" in error_message + assert str(Path(".serena") / "project.yml") in error_message + assert "Example project.yml:" in error_message + assert f"project_name: {self.project_path.name}" in error_message + assert "language: python" in error_message + + def test_autogenerate_with_python_files(self): + """Test successful autogeneration with Python source files.""" + # Create a Python file + python_file = self.project_path / "main.py" + python_file.write_text("def hello():\n print('Hello, world!')\n") + + # Run autogenerate + config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False) + + # Verify the configuration + assert config.project_name == self.project_path.name + assert config.language == Language.PYTHON + + def test_autogenerate_with_multiple_languages(self): + """Test autogeneration picks dominant language when multiple are present.""" + # Create files for multiple languages + (self.project_path / "main.py").write_text("print('Python')") + (self.project_path / "util.py").write_text("def util(): pass") + (self.project_path / "small.js").write_text("console.log('JS');") + + # Run autogenerate - should pick Python as dominant + config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False) + + assert config.language == Language.PYTHON + + def test_autogenerate_saves_to_disk(self): + """Test that autogenerate can save the configuration to disk.""" + # Create a Go file + go_file = self.project_path / "main.go" + go_file.write_text("package main\n\nfunc main() {}\n") + + # Run autogenerate with save_to_disk=True + config = ProjectConfig.autogenerate(self.project_path, save_to_disk=True) + + # Verify the configuration file was created + config_path = self.project_path / ".serena" / "project.yml" + assert config_path.exists() + + # Verify the content + assert config.language == Language.GO + + def test_autogenerate_nonexistent_path(self): + """Test that autogenerate raises FileNotFoundError for non-existent path.""" + non_existent = self.project_path / "does_not_exist" + + with pytest.raises(FileNotFoundError) as exc_info: + ProjectConfig.autogenerate(non_existent, save_to_disk=False) + + assert "Project root not found" in str(exc_info.value) + + def test_autogenerate_with_gitignored_files_only(self): + """Test autogenerate behavior when only gitignored files exist.""" + # Create a .gitignore that ignores all Python files + gitignore = self.project_path / ".gitignore" + gitignore.write_text("*.py\n") + + # Create Python files that will be ignored + (self.project_path / "ignored.py").write_text("print('ignored')") + + # Should still raise ValueError as no source files are detected + with pytest.raises(ValueError) as exc_info: + ProjectConfig.autogenerate(self.project_path, save_to_disk=False) + + assert "No source files found" in str(exc_info.value) + + def test_autogenerate_custom_project_name(self): + """Test autogenerate with custom project name.""" + # Create a TypeScript file + ts_file = self.project_path / "index.ts" + ts_file.write_text("const greeting: string = 'Hello';\n") + + # Run autogenerate with custom name + custom_name = "my-custom-project" + config = ProjectConfig.autogenerate(self.project_path, project_name=custom_name, save_to_disk=False) + + assert config.project_name == custom_name + assert config.language == Language.TYPESCRIPT + + def test_autogenerate_error_message_format(self): + """Test the specific format of the error message for better user experience.""" + with pytest.raises(ValueError) as exc_info: + ProjectConfig.autogenerate(self.project_path, save_to_disk=False) + + error_lines = str(exc_info.value).split("\n") + + # Verify the structure of the error message + assert len(error_lines) >= 8 # Should have multiple lines of helpful information + + # Check for numbered instructions + assert any("1." in line for line in error_lines) + assert any("2." in line for line in error_lines) + + # Check for supported languages list + assert any("Python" in line and "TypeScript" in line for line in error_lines) + + # Check example includes comment about language options + assert any("# or typescript, java, csharp" in line for line in error_lines) diff --git a/test/serena/test_edit_marker.py b/test/serena/test_edit_marker.py index 9a07f271..ce1c5585 100644 --- a/test/serena/test_edit_marker.py +++ b/test/serena/test_edit_marker.py @@ -1,4 +1,4 @@ -from serena.agent import CreateTextFileTool, ReadFileTool, Tool +from serena.tools import CreateTextFileTool, ReadFileTool, Tool class TestEditMarker: diff --git a/test/serena/test_make_tool_process_isolation.py b/test/serena/test_make_tool_process_isolation.py deleted file mode 100644 index eb9b3f06..00000000 --- a/test/serena/test_make_tool_process_isolation.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for make_tool consistency between regular tools and ProcessIsolatedTool.""" - -import pytest -from mcp.server.fastmcp.tools.base import Tool as MCPTool - -from serena.agent import SerenaAgent, ToolRegistry -from serena.mcp import SerenaMCPFactory -from serena.process_isolated_agent import ProcessIsolatedSerenaAgent, ProcessIsolatedTool -from test.serena.test_serena_agent import SerenaConfigForTests - -make_tool = SerenaMCPFactory.make_mcp_tool - - -@pytest.fixture -def in_memory_config(): - """Create an in-memory configuration for tests.""" - return SerenaConfigForTests() - - -@pytest.fixture -def regular_agent(in_memory_config): - """Create a regular SerenaAgent for comparison.""" - return SerenaAgent(serena_config=in_memory_config) - - -@pytest.fixture -def process_isolated_agent(in_memory_config): - """Create a ProcessIsolatedSerenaAgent for comparison.""" - agent = ProcessIsolatedSerenaAgent(serena_config=in_memory_config) - agent.start() - yield agent - agent.stop() - - -class TestMakeToolProcessIsolation: - """Test that make_tool produces identical metadata for regular and process-isolated tools.""" - - @pytest.mark.parametrize("tool_name", ToolRegistry.get_tool_names()) - def test_make_tool_metadata_consistency( - self, tool_name: str, regular_agent: SerenaAgent, process_isolated_agent: ProcessIsolatedSerenaAgent - ): - """Test that make_tool produces identical metadata for regular and process-isolated tools.""" - # Get regular tool instance - tool_class = ToolRegistry.get_tool_class_by_name(tool_name) - regular_tool = regular_agent.get_tool(tool_class) - - # Get ProcessIsolatedTool instance - isolated_tool = ProcessIsolatedTool(process_isolated_agent, tool_name) - - # Create MCP tools from both - regular_mcp_tool = make_tool(regular_tool) - isolated_mcp_tool = make_tool(isolated_tool) - - # Verify both are MCPTool instances - assert isinstance(regular_mcp_tool, MCPTool) - assert isinstance(isolated_mcp_tool, MCPTool) - - # Test name consistency - assert regular_mcp_tool.name == isolated_mcp_tool.name - assert regular_mcp_tool.name == tool_name - - # Test description consistency - assert regular_mcp_tool.description == isolated_mcp_tool.description, ( - f"Tool {tool_name}: descriptions differ\n" - f"Regular: {regular_mcp_tool.description}\n" - f"Isolated: {isolated_mcp_tool.description}" - ) - - # Test parameters schema consistency - assert regular_mcp_tool.parameters == isolated_mcp_tool.parameters, ( - f"Tool {tool_name}: parameter schemas differ\n" - f"Regular: {regular_mcp_tool.parameters}\n" - f"Isolated: {isolated_mcp_tool.parameters}" - ) - - # Test function metadata consistency (compare schemas, not class objects) - regular_schema = regular_mcp_tool.fn_metadata.arg_model.model_json_schema() - isolated_schema = isolated_mcp_tool.fn_metadata.arg_model.model_json_schema() - assert ( - regular_schema == isolated_schema - ), f"Tool {tool_name}: function metadata schemas differ\nRegular: {regular_schema}\nIsolated: {isolated_schema}" - - # Test async flag consistency - assert regular_mcp_tool.is_async == isolated_mcp_tool.is_async - - # Test context kwarg consistency - assert regular_mcp_tool.context_kwarg == isolated_mcp_tool.context_kwarg - - @pytest.mark.parametrize("tool_name", ToolRegistry.get_tool_names()[:5]) # Test first 5 tools for faster execution - def test_tool_protocol_methods_consistency( - self, tool_name: str, regular_agent: SerenaAgent, process_isolated_agent: ProcessIsolatedSerenaAgent - ): - """Test that Tool methods return identical results for regular and process-isolated tools.""" - # Get regular tool instance - tool_class = ToolRegistry.get_tool_class_by_name(tool_name) - regular_tool = regular_agent.get_tool(tool_class) - - # Get ProcessIsolatedTool instance - isolated_tool = ProcessIsolatedTool(process_isolated_agent, tool_name) - - # Test get_name() - assert regular_tool.get_name_from_cls() == isolated_tool.get_name() - assert regular_tool.get_name_from_cls() == tool_name - - # Test get_apply_docstring() - regular_docstring = regular_tool.get_apply_docstring() - isolated_docstring = isolated_tool.get_apply_docstring() - assert ( - regular_docstring == isolated_docstring - ), f"Tool {tool_name}: docstrings differ\nRegular: {regular_docstring}\nIsolated: {isolated_docstring}" - - # Test get_apply_fn_metadata() - regular_metadata = regular_tool.get_apply_fn_metadata() - isolated_metadata = isolated_tool.get_apply_fn_metadata() - - # Compare metadata properties (compare schemas, not class objects) - regular_schema = regular_metadata.arg_model.model_json_schema() - isolated_schema = isolated_metadata.arg_model.model_json_schema() - assert ( - regular_schema == isolated_schema - ), f"Tool {tool_name}: metadata schemas differ\nRegular: {regular_schema}\nIsolated: {isolated_schema}" - - def test_process_isolated_tool_uses_tool_registry(self, process_isolated_agent: ProcessIsolatedSerenaAgent): - """Test that ProcessIsolatedTool correctly uses ToolRegistry for metadata.""" - tool_name = ToolRegistry.get_tool_names()[0] # Use first available tool - isolated_tool = ProcessIsolatedTool(process_isolated_agent, tool_name) - - # Verify that the tool uses ToolRegistry - assert isolated_tool._tool_class == ToolRegistry.get_tool_class_by_name(tool_name) - - # Verify that metadata comes from the tool class - expected_docstring = isolated_tool._tool_class.get_apply_docstring_from_cls() - expected_metadata = isolated_tool._tool_class.get_apply_fn_metadata_from_cls() - - assert isolated_tool.get_apply_docstring() == expected_docstring - # Compare schemas, not class objects - isolated_schema = isolated_tool.get_apply_fn_metadata().arg_model.model_json_schema() - expected_schema = expected_metadata.arg_model.model_json_schema() - assert isolated_schema == expected_schema - - def test_tool_registry_completeness(self, regular_agent: SerenaAgent, process_isolated_agent: ProcessIsolatedSerenaAgent): - """Test that all tools are available in both agents and the registry.""" - # Get tool names from both agents - regular_active_tools = set(regular_agent.get_active_tool_names()) - regular_all_tools = set(tool.get_name_from_cls() for tool in regular_agent.get_exposed_tool_instances()) - isolated_tool_names = set(process_isolated_agent.get_exposed_tool_names()) - - # The process isolated agent should have all tools (exposed, not just active) - assert regular_all_tools == isolated_tool_names, ( - f"Tool sets differ:\n" - f"Regular exposed only: {regular_all_tools - isolated_tool_names}\n" - f"Isolated only: {isolated_tool_names - regular_all_tools}" - ) - - # Active tools should be a subset of all tools - assert regular_active_tools.issubset( - regular_all_tools - ), f"Some active tools not in exposed tools: {regular_active_tools - regular_all_tools}" - - # All tools should be in the registry - registry_tool_names = set(ToolRegistry.get_tool_names()) - assert regular_all_tools.issubset(registry_tool_names), f"Some tools not in registry: {regular_all_tools - registry_tool_names}" diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index bae716dc..460c124c 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -1,32 +1,17 @@ import json import os import time -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 serena.agent import SerenaAgent +from serena.config.serena_config import Project, ProjectConfig, SerenaConfig +from serena.tools import FindReferencingSymbolsTool, FindSymbolTool from solidlsp.ls_config import Language from test.conftest import get_repo_path -@dataclass -class SerenaConfigForTests(SerenaConfigBase): - """ - In-memory implementation of Serena configuration with the GUI disabled. - """ - - gui_log_window_enabled: bool = False - web_dashboard: bool = False - - def __post_init__(self): - # Initialize with empty projects list if not already set - if not hasattr(self, "projects") or self.projects is None: - self.projects = [] - - @pytest.fixture def serena_config(): """Create an in-memory configuration for tests with test repositories pre-registered.""" @@ -60,7 +45,7 @@ def serena_config(): ) test_projects.append(project) - config = SerenaConfigForTests() + config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False) config.projects = test_projects return config @@ -70,31 +55,10 @@ def serena_agent(request: pytest.FixtureRequest, serena_config): language = Language(request.param) project_name = f"test_repo_{language}" - # Check if this test should use process isolation by looking at the test parameters - isolated_process = False - if hasattr(request, "node") and hasattr(request.node, "callspec"): - # Get the isolated_process parameter value from the test - params = request.node.callspec.params - isolated_process = params.get("isolated_process", False) - - if isolated_process: - agent = ProcessIsolatedSerenaAgent(project=project_name, serena_config=serena_config) - agent.start() - - # Add cleanup to stop the process - def cleanup(): - agent.stop() - - request.addfinalizer(cleanup) - return agent - else: - return SerenaAgent(project=project_name, serena_config=serena_config) + return SerenaAgent(project=project_name, serena_config=serena_config) class TestSerenaAgent: - @pytest.mark.parametrize( - "isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)] - ) @pytest.mark.parametrize( "serena_agent,symbol_name,expected_kind,expected_file", [ @@ -115,7 +79,7 @@ class TestSerenaAgent: ], indirect=["serena_agent"], ) - def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str, isolated_process: bool): + def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str): agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex(name_path=symbol_name) @@ -126,9 +90,6 @@ class TestSerenaAgent: for s in symbols ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}" - @pytest.mark.parametrize( - "isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)] - ) @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", [ @@ -161,7 +122,7 @@ class TestSerenaAgent: ], indirect=["serena_agent"], ) - def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str, isolated_process: bool) -> None: + def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str) -> None: agent = serena_agent # Find the symbol location first @@ -182,9 +143,6 @@ class TestSerenaAgent: ref["relative_path"] == ref_file for ref in refs ), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" - @pytest.mark.parametrize( - "isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)] - ) @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", [ @@ -259,7 +217,6 @@ class TestSerenaAgent: expected_symbol_name: str, expected_kind: str, expected_file: str, - isolated_process: bool, ): agent = serena_agent @@ -282,9 +239,6 @@ class TestSerenaAgent: for s in symbols ), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project.language.name}. Symbols: {symbols}" - @pytest.mark.parametrize( - "isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)] - ) @pytest.mark.parametrize( "serena_agent,name_path", [ @@ -307,7 +261,6 @@ class TestSerenaAgent: self, serena_agent, name_path: str, - isolated_process: bool, ): agent = serena_agent diff --git a/test/serena/util/test_exception.py b/test/serena/util/test_exception.py new file mode 100644 index 00000000..e13e57f7 --- /dev/null +++ b/test/serena/util/test_exception.py @@ -0,0 +1,116 @@ +import os +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from serena.util.exception import is_headless_environment, show_fatal_exception_safe + + +class TestHeadlessEnvironmentDetection: + """Test class for headless environment detection functionality.""" + + def test_is_headless_no_display(self): + """Test that environment without DISPLAY is detected as headless on Linux.""" + with patch("sys.platform", "linux"): + with patch.dict(os.environ, {}, clear=True): + assert is_headless_environment() is True + + def test_is_headless_ssh_connection(self): + """Test that SSH sessions are detected as headless.""" + with patch("sys.platform", "linux"): + with patch.dict(os.environ, {"SSH_CONNECTION": "192.168.1.1 22 192.168.1.2 22", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + with patch.dict(os.environ, {"SSH_CLIENT": "192.168.1.1 22 22", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + def test_is_headless_wsl(self): + """Test that WSL environment is detected as headless.""" + # Skip this test on Windows since os.uname doesn't exist + if not hasattr(os, "uname"): + pytest.skip("os.uname not available on this platform") + + with patch("sys.platform", "linux"): + with patch("os.uname") as mock_uname: + mock_uname.return_value = Mock(release="5.15.153.1-microsoft-standard-WSL2") + with patch.dict(os.environ, {"DISPLAY": ":0"}): + assert is_headless_environment() is True + + def test_is_headless_docker(self): + """Test that Docker containers are detected as headless.""" + with patch("sys.platform", "linux"): + # Test with CI environment variable + with patch.dict(os.environ, {"CI": "true", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + # Test with CONTAINER environment variable + with patch.dict(os.environ, {"CONTAINER": "docker", "DISPLAY": ":0"}): + assert is_headless_environment() is True + + # Test with .dockerenv file + with patch("os.path.exists") as mock_exists: + mock_exists.return_value = True + with patch.dict(os.environ, {"DISPLAY": ":0"}): + assert is_headless_environment() is True + + def test_is_not_headless_windows(self): + """Test that Windows is never detected as headless.""" + with patch("sys.platform", "win32"): + # Even without DISPLAY, Windows should not be headless + with patch.dict(os.environ, {}, clear=True): + assert is_headless_environment() is False + + +class TestShowFatalExceptionSafe: + """Test class for safe fatal exception display functionality.""" + + @patch("serena.util.exception.is_headless_environment", return_value=True) + @patch("serena.util.exception.log") + def test_show_fatal_exception_safe_headless(self, mock_log, mock_is_headless): + """Test that GUI is not attempted in headless environment.""" + test_exception = ValueError("Test error") + + # The import should never happen in headless mode + with patch("serena.gui_log_viewer.show_fatal_exception") as mock_show_gui: + show_fatal_exception_safe(test_exception) + mock_show_gui.assert_not_called() + + # Verify debug log about skipping GUI + mock_log.debug.assert_called_once_with("Skipping GUI error display in headless environment") + + @patch("serena.util.exception.is_headless_environment", return_value=False) + @patch("serena.util.exception.log") + def test_show_fatal_exception_safe_with_gui(self, mock_log, mock_is_headless): + """Test that GUI is attempted when not in headless environment.""" + test_exception = ValueError("Test error") + + # Mock the GUI function + with patch("serena.gui_log_viewer.show_fatal_exception") as mock_show_gui: + show_fatal_exception_safe(test_exception) + mock_show_gui.assert_called_once_with(test_exception) + + @patch("serena.util.exception.is_headless_environment", return_value=False) + @patch("serena.util.exception.log") + def test_show_fatal_exception_safe_gui_failure(self, mock_log, mock_is_headless): + """Test graceful handling when GUI display fails.""" + test_exception = ValueError("Test error") + gui_error = ImportError("No module named 'tkinter'") + + # Mock the GUI function to raise an exception + with patch("serena.gui_log_viewer.show_fatal_exception", side_effect=gui_error): + show_fatal_exception_safe(test_exception) + + # Verify debug log about GUI failure + mock_log.debug.assert_called_with(f"Failed to show GUI error dialog: {gui_error}") + + def test_show_fatal_exception_safe_prints_to_stderr(self): + """Test that exceptions are always printed to stderr.""" + test_exception = ValueError("Test error message") + + with patch("sys.stderr", new_callable=MagicMock) as mock_stderr: + with patch("serena.util.exception.is_headless_environment", return_value=True): + with patch("serena.util.exception.log"): + show_fatal_exception_safe(test_exception) + + # Verify print was called with the correct arguments + mock_stderr.write.assert_any_call("Fatal exception: Test error message") diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py index c16da80d..be945a53 100644 --- a/test/solidlsp/clojure/__init__.py +++ b/test/solidlsp/clojure/__init__.py @@ -1,6 +1,6 @@ from pathlib import Path -from solidlsp.language_servers.clojure_lsp.clojure_lsp import verify_clojure_cli +from solidlsp.language_servers.clojure_lsp import verify_clojure_cli def _test_clojure_cli() -> bool: diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 56db5cfa..59f393ac 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch import pytest from solidlsp import SolidLanguageServer -from solidlsp.language_servers.csharp_language_server.csharp_language_server import ( +from solidlsp.language_servers.csharp_language_server import ( CSharpLanguageServer, breadth_first_file_scan, find_solution_or_project_file, @@ -57,7 +57,7 @@ class TestCSharpLanguageServer: break assert add_symbol is not None, "Could not find 'Add' method symbol in Program.cs" sel_start = add_symbol["selectionRange"]["start"] - refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) assert any( "Program.cs" in ref.get("relativePath", "") for ref in refs ), "Program.cs should reference Add method (tried all positions in selectionRange)" @@ -196,55 +196,52 @@ class TestCSharpSolutionProjectOpening: # Should still prefer .sln file even though it's deeper assert result == str(solution_file) - @patch("solidlsp.language_servers.csharp_language_server.csharp_language_server.CSharpLanguageServer._ensure_server_installed") - @patch("solidlsp.language_servers.csharp_language_server.csharp_language_server.CSharpLanguageServer._start_server") + @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._ensure_server_installed") + @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server") def test_csharp_language_server_logs_solution_discovery(self, mock_start_server, mock_ensure_server_installed): """Test that CSharpLanguageServer logs solution/project discovery during initialization.""" - with tempfile.TemporaryDirectory() as cache_dir: - # Mock the server installation - mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll", Path(cache_dir)) + mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll") - # Create test directory with solution file - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - solution_file = temp_path / "TestSolution.sln" - solution_file.touch() + # Create test directory with solution file + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + solution_file = temp_path / "TestSolution.sln" + solution_file.touch() - # Mock logger to capture log messages - mock_logger = Mock() - mock_config = Mock(spec=LanguageServerConfig) - mock_config.ignored_paths = [] + # Mock logger to capture log messages + mock_logger = Mock() + mock_config = Mock(spec=LanguageServerConfig) + mock_config.ignored_paths = [] - # Create CSharpLanguageServer instance - CSharpLanguageServer(mock_config, mock_logger, str(temp_path)) + # Create CSharpLanguageServer instance + CSharpLanguageServer(mock_config, mock_logger, str(temp_path)) - # Verify that logger was called with solution file discovery - mock_logger.log.assert_any_call(f"Found solution/project file: {solution_file}", 20) # logging.INFO + # Verify that logger was called with solution file discovery + mock_logger.log.assert_any_call(f"Found solution/project file: {solution_file}", 20) # logging.INFO - @patch("solidlsp.language_servers.csharp_language_server.csharp_language_server.CSharpLanguageServer._ensure_server_installed") - @patch("solidlsp.language_servers.csharp_language_server.csharp_language_server.CSharpLanguageServer._start_server") + @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._ensure_server_installed") + @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server") def test_csharp_language_server_logs_no_solution_warning(self, mock_start_server, mock_ensure_server_installed): """Test that CSharpLanguageServer logs warning when no solution/project files are found.""" - with tempfile.TemporaryDirectory() as cache_dir: - # Mock the server installation - mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll", Path(cache_dir)) + # Mock the server installation + mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll") - # Create empty test directory - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) + # Create empty test directory + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) - # Mock logger to capture log messages - mock_logger = Mock() - mock_config = Mock(spec=LanguageServerConfig) - mock_config.ignored_paths = [] + # Mock logger to capture log messages + mock_logger = Mock() + mock_config = Mock(spec=LanguageServerConfig) + mock_config.ignored_paths = [] - # Create CSharpLanguageServer instance - CSharpLanguageServer(mock_config, mock_logger, str(temp_path)) + # Create CSharpLanguageServer instance + CSharpLanguageServer(mock_config, mock_logger, str(temp_path)) - # Verify that logger was called with warning about no solution/project files - mock_logger.log.assert_any_call( - "No .sln or .csproj file found, language server will attempt auto-discovery", 30 # logging.WARNING - ) + # Verify that logger was called with warning about no solution/project files + mock_logger.log.assert_any_call( + "No .sln or .csproj file found, language server will attempt auto-discovery", 30 # logging.WARNING + ) def test_solution_and_project_opening_with_real_test_repo(self): """Test solution and project opening with the actual C# test repository.""" diff --git a/test/solidlsp/elixir/__init__.py b/test/solidlsp/elixir/__init__.py new file mode 100644 index 00000000..4d7e8860 --- /dev/null +++ b/test/solidlsp/elixir/__init__.py @@ -0,0 +1,28 @@ +import platform + + +def _test_nextls_available() -> str: + """Test if Next LS is available and return error reason if not.""" + # Check if we're on Windows (Next LS doesn't support Windows) + if platform.system() == "Windows": + return "Next LS does not support Windows" + + # Try to import and check Elixir availability + try: + from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools + + # Check if Elixir is installed + elixir_version = ElixirTools._get_elixir_version() + if not elixir_version: + return "Elixir is not installed or not in PATH" + + return "" # No error, Next LS should be available + + except ImportError as e: + return f"Failed to import ElixirTools: {e}" + except Exception as e: + return f"Error checking Next LS availability: {e}" + + +NEXTLS_UNAVAILABLE_REASON = _test_nextls_available() +NEXTLS_UNAVAILABLE = bool(NEXTLS_UNAVAILABLE_REASON) diff --git a/test/solidlsp/elixir/conftest.py b/test/solidlsp/elixir/conftest.py new file mode 100644 index 00000000..34523765 --- /dev/null +++ b/test/solidlsp/elixir/conftest.py @@ -0,0 +1,174 @@ +""" +Elixir-specific test configuration and fixtures. +""" + +import os +import subprocess +import time +from pathlib import Path + +import pytest + + +def ensure_elixir_test_repo_compiled(repo_path: str) -> None: + """Ensure the Elixir test repository dependencies are installed and project is compiled. + + Next LS requires the project to be fully compiled and indexed before providing + complete references and symbol resolution. This function: + 1. Installs dependencies via 'mix deps.get' + 2. Compiles the project via 'mix compile' + + This is essential in CI environments where dependencies aren't pre-installed. + + Args: + repo_path: Path to the Elixir project root directory + + """ + # Check if this looks like an Elixir project + mix_file = os.path.join(repo_path, "mix.exs") + if not os.path.exists(mix_file): + return + + # Check if already compiled (optimization for repeated runs) + build_path = os.path.join(repo_path, "_build") + deps_path = os.path.join(repo_path, "deps") + + if os.path.exists(build_path) and os.path.exists(deps_path): + print(f"Elixir test repository already compiled in {repo_path}") + return + + try: + print("Installing dependencies and compiling Elixir test repository for optimal Next LS performance...") + + # First, install dependencies with increased timeout for CI + print("=" * 60) + print("Step 1/2: Installing Elixir dependencies...") + print("=" * 60) + start_time = time.time() + + deps_result = subprocess.run( + ["mix", "deps.get"], + cwd=repo_path, + capture_output=True, + text=True, + timeout=180, + 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 Elixir project...") + print("=" * 60) + start_time = time.time() + + compile_result = subprocess.run( + ["mix", "compile"], + cwd=repo_path, + capture_output=True, + text=True, + timeout=300, + check=False, # 5 minutes for compilation (Credo compilation can be slow in CI) + ) + + compile_duration = time.time() - start_time + print(f"Compilation completed in {compile_duration:.2f} seconds") + + # Always log the output for transparency + if compile_result.stdout.strip(): + print("Compilation stdout:") + print("-" * 40) + print(compile_result.stdout) + print("-" * 40) + + if compile_result.stderr.strip(): + print("Compilation stderr:") + print("-" * 40) + print(compile_result.stderr) + print("-" * 40) + + if compile_result.returncode == 0: + print(f"✓ Elixir test repository compiled successfully in {repo_path}") + else: + print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}") + # Still continue - warnings are often non-fatal + + print("=" * 60) + print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds") + print("=" * 60) + + except subprocess.TimeoutExpired as e: + print("=" * 60) + print(f"❌ TIMEOUT: Elixir setup timed out after {e.timeout} seconds") + print(f"Command: {' '.join(e.cmd)}") + print("This may indicate slow CI environment - Next LS may still work but with reduced functionality") + + # Try to get partial output if available + if hasattr(e, "stdout") and e.stdout: + print("Partial stdout before timeout:") + print("-" * 40) + print(e.stdout) + print("-" * 40) + if hasattr(e, "stderr") and e.stderr: + print("Partial stderr before timeout:") + print("-" * 40) + print(e.stderr) + print("-" * 40) + print("=" * 60) + + except FileNotFoundError: + print("❌ ERROR: 'mix' command not found - Elixir test repository may not be compiled") + print("Please ensure Elixir is installed and available in PATH") + except Exception as e: + print(f"❌ ERROR: Failed to prepare Elixir test repository: {e}") + + +@pytest.fixture(scope="session", autouse=True) +def setup_elixir_test_environment(): + """Automatically prepare Elixir test environment for all Elixir tests. + + This fixture runs once per test session and automatically: + 1. Installs dependencies via 'mix deps.get' + 2. Compiles the Elixir test repository via 'mix compile' + + It uses autouse=True so it runs automatically without needing to be explicitly + requested by tests. This ensures Next LS has a fully prepared project to work with. + + Uses generous timeouts (3-5 minutes) to accommodate slow CI environments. + All output is logged for transparency and debugging. + """ + # Get the test repo path relative to this conftest.py file + test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "elixir" / "test_repo" + ensure_elixir_test_repo_compiled(str(test_repo_path)) + return str(test_repo_path) + + +@pytest.fixture(scope="session") +def elixir_test_repo_path(setup_elixir_test_environment): + """Get the path to the prepared Elixir test repository. + + This fixture depends on setup_elixir_test_environment to ensure dependencies + are installed and compilation has completed before returning the path. + """ + return setup_elixir_test_environment diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py new file mode 100644 index 00000000..10391406 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -0,0 +1,112 @@ +""" +Basic integration tests for the Elixir language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the test repository. +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] + + +class TestElixirBasic: + """Basic Elixir language server functionality tests.""" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_references_function_definition(self, language_server: SolidLanguageServer): + """Test finding references to a function definition.""" + file_path = os.path.join("lib", "models.ex") + symbols = language_server.request_document_symbols(file_path) + + # Find the User module's 'new' function + user_new_symbol = None + for symbol in symbols[0]: # Top level symbols + if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module + for child in symbol.get("children", []): + if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function + user_new_symbol = child + break + break + + if not user_new_symbol or "selectionRange" not in user_new_symbol: + pytest.skip("User.new function or its selectionRange not found") + + sel_start = user_new_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert references is not None + assert len(references) > 0 + + # Should find at least one reference (the definition itself) + found_definition = any(ref["uri"].endswith("models.ex") for ref in references) + assert found_definition, "Should find the function definition" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_references_create_user_function(self, language_server: SolidLanguageServer): + """Test finding references to create_user function.""" + file_path = os.path.join("lib", "services.ex") + symbols = language_server.request_document_symbols(file_path) + + # Find the UserService module's 'create_user' function + create_user_symbol = None + for symbol in symbols[0]: # Top level symbols + if symbol.get("name") == "UserService" and symbol.get("kind") == 2: # Module + for child in symbol.get("children", []): + if child.get("name", "").startswith("def create_user(") and child.get("kind") == 12: # Function + create_user_symbol = child + break + break + + if not create_user_symbol or "selectionRange" not in create_user_symbol: + pytest.skip("UserService.create_user function or its selectionRange not found") + + sel_start = create_user_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert references is not None + assert len(references) > 0 + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer): + """Test finding symbols that reference a specific function.""" + file_path = os.path.join("lib", "models.ex") + symbols = language_server.request_document_symbols(file_path) + + # Find the User module's 'new' function + user_new_symbol = None + for symbol in symbols[0]: # Top level symbols + if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module + for child in symbol.get("children", []): + if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function + user_new_symbol = child + break + break + + if not user_new_symbol or "selectionRange" not in user_new_symbol: + pytest.skip("User.new function or its selectionRange not found") + + sel_start = user_new_symbol["selectionRange"]["start"] + referencing_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + + assert referencing_symbols is not None + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_timeout_enumeration_bug(self, language_server: SolidLanguageServer): + """Test that enumeration doesn't timeout (regression test).""" + # This should complete without timing out + symbols = language_server.request_document_symbols("lib/models.ex") + assert symbols is not None + + # Test multiple symbol requests in succession + for _ in range(3): + symbols = language_server.request_document_symbols("lib/services.ex") + assert symbols is not None diff --git a/test/solidlsp/elixir/test_elixir_ignored_dirs.py b/test/solidlsp/elixir/test_elixir_ignored_dirs.py new file mode 100644 index 00000000..95a6f5f2 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_ignored_dirs.py @@ -0,0 +1,142 @@ +from collections.abc import Generator +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import create_ls + +from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] + + +@pytest.fixture(scope="module") +def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: + """Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored.""" + ignored_paths = ["scripts", "ignored_dir"] + ls = create_ls(ignored_paths=ignored_paths, language=Language.ELIXIR) + ls.start() + try: + yield ls + finally: + ls.stop() + + +@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True) +def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that request_full_symbol_tree ignores the configured directory.""" + root = ls_with_ignored_dirs.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + + # Should have lib and test directories, but not scripts or ignored_dir + expected_dirs = {"lib", "test"} + assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" + assert "scripts" not in children_names, f"scripts should not be in {children_names}" + assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}" + + +@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True) +def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): + """Tests that find_references ignores the configured directory.""" + # Location of User struct, which is referenced in scripts and ignored_dir + definition_file = "lib/models.ex" + + # Find the User struct definition + symbols = ls_with_ignored_dirs.request_document_symbols(definition_file) + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) + if user_symbol: + break + + if not user_symbol or "selectionRange" not in user_symbol: + pytest.skip("User symbol not found for reference testing") + + sel_start = user_symbol["selectionRange"]["start"] + references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"]) + + # Assert that scripts and ignored_dir do not appear in the references + assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored" + assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored" + + +@pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True) +def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: + """Tests that refs and symbols with glob patterns are ignored.""" + ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts + ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ELIXIR) + ls.start() + + try: + # Same as in the above tests + root = ls.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + + # Should have lib and test directories, but not scripts or ignored_dir + expected_dirs = {"lib", "test"} + assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" + assert "scripts" not in children_names, f"scripts should not be in {children_names} (glob pattern)" + assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)" + + # Test that the refs and symbols with glob patterns are ignored + definition_file = "lib/models.ex" + + # Find the User struct definition + symbols = ls.request_document_symbols(definition_file) + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) + if user_symbol: + break + + if user_symbol and "selectionRange" in user_symbol: + sel_start = user_symbol["selectionRange"]["start"] + references = ls.request_references(definition_file, sel_start["line"], sel_start["character"]) + + # Assert that scripts and ignored_dir do not appear in references + assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored (glob)" + assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)" + finally: + ls.stop() + + +@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) +def test_default_ignored_directories(language_server: SolidLanguageServer): + """Test that default Elixir directories are ignored.""" + # Test that Elixir-specific directories are ignored by default + assert language_server.is_ignored_dirname("_build"), "_build should be ignored" + assert language_server.is_ignored_dirname("deps"), "deps should be ignored" + assert language_server.is_ignored_dirname(".elixir_ls"), ".elixir_ls should be ignored" + assert language_server.is_ignored_dirname("cover"), "cover should be ignored" + assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" + + # Test that important directories are not ignored + assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" + assert not language_server.is_ignored_dirname("test"), "test should not be ignored" + assert not language_server.is_ignored_dirname("config"), "config should not be ignored" + assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored" + + +@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) +def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): + """Test that symbol tree excludes build and dependency directories.""" + symbol_tree = language_server.request_full_symbol_tree() + + if symbol_tree: + root = symbol_tree[0] + children_names = {child["name"] for child in root.get("children", [])} + + # Build and dependency directories should not appear + ignored_dirs = {"_build", "deps", ".elixir_ls", "cover", "node_modules"} + found_ignored = ignored_dirs.intersection(children_names) + assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}" + + # Important directories should appear + important_dirs = {"lib", "test"} + found_important = important_dirs.intersection(children_names) + assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" diff --git a/test/solidlsp/elixir/test_elixir_integration.py b/test/solidlsp/elixir/test_elixir_integration.py new file mode 100644 index 00000000..25c86a8f --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_integration.py @@ -0,0 +1,157 @@ +""" +Integration tests for Elixir language server with test repository. + +These tests verify that the language server works correctly with a real Elixir project +and can perform advanced operations like cross-file symbol resolution. +""" + +import os +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] + + +class TestElixirIntegration: + """Integration tests for Elixir language server with test repository.""" + + @pytest.fixture + def elixir_test_repo_path(self): + """Get the path to the Elixir test repository.""" + test_dir = Path(__file__).parent.parent.parent + return str(test_dir / "resources" / "repos" / "elixir" / "test_repo") + + def test_elixir_repo_structure(self, elixir_test_repo_path): + """Test that the Elixir test repository has the expected structure.""" + repo_path = Path(elixir_test_repo_path) + + # Check that key files exist + assert (repo_path / "mix.exs").exists(), "mix.exs should exist" + assert (repo_path / "lib" / "test_repo.ex").exists(), "main module should exist" + assert (repo_path / "lib" / "utils.ex").exists(), "utils module should exist" + assert (repo_path / "lib" / "models.ex").exists(), "models module should exist" + assert (repo_path / "lib" / "services.ex").exists(), "services module should exist" + assert (repo_path / "lib" / "examples.ex").exists(), "examples module should exist" + assert (repo_path / "test" / "test_repo_test.exs").exists(), "test file should exist" + assert (repo_path / "test" / "models_test.exs").exists(), "models test should exist" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer): + """Test that symbols can be resolved across different files.""" + # Test that User struct from models.ex can be found when referenced in services.ex + services_file = os.path.join("lib", "services.ex") + + # Find where User is referenced in services.ex + content = language_server.retrieve_full_file_content(services_file) + lines = content.split("\n") + user_reference_line = None + for i, line in enumerate(lines): + if "alias TestRepo.Models.{User" in line: + user_reference_line = i + break + + if user_reference_line is None: + pytest.skip("Could not find User reference in services.ex") + + # Try to find the definition + defining_symbol = language_server.request_defining_symbol(services_file, user_reference_line, 30) + + if defining_symbol and "location" in defining_symbol: + # Should point to models.ex + assert "models.ex" in defining_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_comprehensive_symbol_search(self, language_server: SolidLanguageServer): + """Test comprehensive symbol search across the entire project.""" + # Search for all function definitions + function_pattern = r"def\s+\w+\s*[\(\s]" + function_matches = language_server.search_files_for_pattern(function_pattern) + + # Should find functions across multiple files + if function_matches: + files_with_functions = set() + for match in function_matches: + if match.source_file_path: + files_with_functions.add(os.path.basename(match.source_file_path)) + + # Should find functions in multiple files + expected_files = {"models.ex", "services.ex", "examples.ex", "utils.ex", "test_repo.ex"} + found_files = expected_files.intersection(files_with_functions) + assert len(found_files) > 0, f"Expected functions in {expected_files}, found in {files_with_functions}" + + # Search for struct definitions + struct_pattern = r"defstruct\s+\[" + struct_matches = language_server.search_files_for_pattern(struct_pattern) + + if struct_matches: + # Should find structs primarily in models.ex + models_structs = [m for m in struct_matches if m.source_file_path and "models.ex" in m.source_file_path] + assert len(models_structs) > 0, "Should find struct definitions in models.ex" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_module_hierarchy_understanding(self, language_server: SolidLanguageServer): + """Test that the language server understands Elixir module hierarchy.""" + models_file = os.path.join("lib", "models.ex") + symbols = language_server.request_document_symbols(models_file) + + if symbols: + # Flatten symbol structure + all_symbols = [] + for symbol_group in symbols: + if isinstance(symbol_group, list): + all_symbols.extend(symbol_group) + else: + all_symbols.append(symbol_group) + + symbol_names = [s.get("name", "") for s in all_symbols] + + # Should understand nested module structure + expected_modules = ["TestRepo.Models", "User", "Item", "Order"] + found_modules = [name for name in expected_modules if any(name in symbol_name for symbol_name in symbol_names)] + assert len(found_modules) > 0, f"Expected modules {expected_modules}, found symbols {symbol_names}" + + def test_file_extension_matching(self): + """Test that the Elixir language recognizes the correct file extensions.""" + language = Language.ELIXIR + matcher = language.get_source_fn_matcher() + + # Test Elixir file extensions + assert matcher.is_relevant_filename("lib/test_repo.ex") + assert matcher.is_relevant_filename("test/test_repo_test.exs") + assert matcher.is_relevant_filename("config/config.exs") + assert matcher.is_relevant_filename("mix.exs") + assert matcher.is_relevant_filename("lib/models.ex") + assert matcher.is_relevant_filename("lib/services.ex") + + # Test non-Elixir files + assert not matcher.is_relevant_filename("README.md") + assert not matcher.is_relevant_filename("lib/test_repo.py") + assert not matcher.is_relevant_filename("package.json") + assert not matcher.is_relevant_filename("Cargo.toml") + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_protocol_and_implementation_understanding(self, language_server: SolidLanguageServer): + """Test that the language server understands Elixir protocols and implementations.""" + # Search for protocol definitions + protocol_pattern = r"defprotocol\s+\w+" + protocol_matches = language_server.search_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") + + if protocol_matches: + # Should find the Serializable protocol + serializable_matches = [m for m in protocol_matches if "Serializable" in str(m)] + assert len(serializable_matches) > 0, "Should find Serializable protocol definition" + + # Search for protocol implementations + impl_pattern = r"defimpl\s+\w+" + impl_matches = language_server.search_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") + + if impl_matches: + # Should find multiple implementations + assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}" diff --git a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py new file mode 100644 index 00000000..a6c31b6d --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py @@ -0,0 +1,342 @@ +""" +Tests for the Elixir language server symbol-related functionality. + +These tests focus on the following methods: +- request_containing_symbol +- request_referencing_symbols +- request_defining_symbol +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + +from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON + +# These marks will be applied to all tests in this module +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] + + +class TestElixirLanguageServerSymbols: + """Test the Elixir language server's symbol-related functionality.""" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a function.""" + # Test for a position inside the create_user function + file_path = os.path.join("lib", "services.ex") + + # Find the create_user function in the file + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + create_user_line = None + for i, line in enumerate(lines): + if "def create_user(" in line: + create_user_line = i + 2 # Go inside the function body + break + + if create_user_line is None: + pytest.skip("Could not find create_user function") + + containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True) + + # Verify that we found the containing symbol + if containing_symbol: + # Next LS returns the full function signature instead of just the function name + assert containing_symbol["name"] == "def create_user(pid, id, name, email, roles \\\\ [])" + assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function + if "body" in containing_symbol: + assert "def create_user" in containing_symbol["body"] + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a module.""" + # Test for a position inside the UserService module but outside any function + file_path = os.path.join("lib", "services.ex") + + # Find the UserService module definition + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_service_line = None + for i, line in enumerate(lines): + if "defmodule UserService do" in line: + user_service_line = i + 1 # Go inside the module + break + + if user_service_line is None: + pytest.skip("Could not find UserService module") + + containing_symbol = language_server.request_containing_symbol(file_path, user_service_line, 5) + + # Verify that we found the containing symbol + if containing_symbol: + assert "UserService" in containing_symbol["name"] + assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol with nested scopes.""" + # Test for a position inside a function which is inside a module + file_path = os.path.join("lib", "services.ex") + + # Find a function inside UserService + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + function_body_line = None + for i, line in enumerate(lines): + if "def create_user(" in line: + function_body_line = i + 3 # Go deeper into the function body + break + + if function_body_line is None: + pytest.skip("Could not find function body") + + containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15) + + # Verify that we found the innermost containing symbol (the function) + if containing_symbol: + expected_names = ["create_user", "UserService"] + assert any(name in containing_symbol["name"] for name in expected_names) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_containing_symbol for a position with no containing symbol.""" + # Test for a position outside any function/module (e.g., in module doc) + file_path = os.path.join("lib", "services.ex") + # Line 1-3 are likely in module documentation or imports + containing_symbol = language_server.request_containing_symbol(file_path, 2, 10) + + # Should return None or an empty dictionary, or the top-level module + # This is acceptable behavior for module-level positions + assert containing_symbol is None or containing_symbol == {} or "TestRepo.Services" in str(containing_symbol) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_referencing_symbols_struct(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a struct.""" + # Test referencing symbols for User struct + file_path = os.path.join("lib", "models.ex") + + symbols = language_server.request_document_symbols(file_path) + user_symbol = None + for symbol_group in symbols: + user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) + if user_symbol: + break + + if not user_symbol or "selectionRange" not in user_symbol: + pytest.skip("User symbol or its selectionRange not found") + + sel_start = user_symbol["selectionRange"]["start"] + ref_symbols = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) + ] + + if ref_symbols: + services_references = [ + symbol + for symbol in ref_symbols + if "location" in symbol and "uri" in symbol["location"] and "services.ex" in symbol["location"]["uri"] + ] + # We expect some references from services.ex + assert len(services_references) >= 0 # At least attempt to find references + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols for a position with no symbol.""" + file_path = os.path.join("lib", "services.ex") + # Line 3 is likely a blank line or comment + try: + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] + # If we get here, make sure we got an empty result + assert ref_symbols == [] or ref_symbols is None + except Exception: + # The method might raise an exception for invalid positions + # which is acceptable behavior + pass + + # Tests for request_defining_symbol + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a function call.""" + # Find a place where User.new is called in services.ex + file_path = os.path.join("lib", "services.ex") + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_new_call_line = None + for i, line in enumerate(lines): + if "User.new(" in line: + user_new_call_line = i + break + + if user_new_call_line is None: + pytest.skip("Could not find User.new call") + + # Try to find the definition of User.new + defining_symbol = language_server.request_defining_symbol(file_path, user_new_call_line, 15) + + if defining_symbol: + assert defining_symbol.get("name") == "new" or "User" in defining_symbol.get("name", "") + if "location" in defining_symbol and "uri" in defining_symbol["location"]: + assert "models.ex" in defining_symbol["location"]["uri"] + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a struct usage.""" + # Find a place where User struct is used in services.ex + file_path = os.path.join("lib", "services.ex") + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_usage_line = None + for i, line in enumerate(lines): + if "alias TestRepo.Models.{User" in line: + user_usage_line = i + break + + if user_usage_line is None: + pytest.skip("Could not find User struct usage") + + defining_symbol = language_server.request_defining_symbol(file_path, user_usage_line, 30) + + if defining_symbol: + assert "User" in defining_symbol.get("name", "") + + @pytest.mark.xfail( + reason="Known intermittent bug in Next LS v0.23.3: Protocol.UndefinedError for :timeout atom. " + "Occurs in CI environments but may pass locally. " + "See https://github.com/elixir-tools/next-ls/issues/543", + strict=False, + ) + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: + """Test request_defining_symbol for a position with no symbol.""" + # Test for a position with no symbol (e.g., whitespace or comment) + file_path = os.path.join("lib", "services.ex") + # Line 3 is likely a blank line + defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) + + # Should return None or empty + assert defining_symbol is None or defining_symbol == {} + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: + """Test integration between different symbol methods.""" + file_path = os.path.join("lib", "models.ex") + + # Find User struct definition + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + user_struct_line = None + for i, line in enumerate(lines): + if "defmodule User do" in line: + user_struct_line = i + break + + if user_struct_line is None: + pytest.skip("Could not find User struct") + + # Test containing symbol + containing = language_server.request_containing_symbol(file_path, user_struct_line + 5, 10) + + if containing: + # Test that we can find references to this symbol + if "location" in containing and "range" in containing["location"]: + start_pos = containing["location"]["range"]["start"] + refs = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"]) + ] + # We should find some references or none (both are valid outcomes) + assert isinstance(refs, list) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: + """Test that symbol tree structure is correctly built.""" + symbol_tree = language_server.request_full_symbol_tree() + + # Should get a tree structure + assert len(symbol_tree) > 0 + + # Should have our test repository structure + root = symbol_tree[0] + assert "children" in root + + # Look for lib directory + lib_dir = None + for child in root["children"]: + if child["name"] == "lib": + lib_dir = child + break + + if lib_dir: + # Next LS returns module names instead of file names (e.g., 'services' instead of 'services.ex') + file_names = [child["name"] for child in lib_dir.get("children", [])] + expected_modules = ["models", "services", "examples", "utils", "test_repo"] + found_modules = [name for name in expected_modules if name in file_names] + assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: + """Test request_dir_overview functionality.""" + lib_overview = language_server.request_dir_overview("lib") + + # Should get an overview of the lib directory + assert lib_overview is not None + # Next LS returns keys like 'lib/services.ex' instead of just 'lib' + overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else [] + lib_files = [key for key in overview_keys if key.startswith("lib/")] + assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}" + + # Should contain information about our modules + overview_text = str(lib_overview).lower() + expected_terms = ["models", "services", "user", "item"] + found_terms = [term for term in expected_terms if term in overview_text] + assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" + + # @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + # def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: + # """Test request_document_overview functionality.""" + # # COMMENTED OUT: Next LS document overview doesn't contain expected terms + # # Next LS return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content + # # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present + # # This appears to be a limitation of Next LS document overview functionality + # # + # file_path = os.path.join("lib", "models.ex") + # doc_overview = language_server.request_document_overview(file_path) + # + # # Should get an overview of the models.ex file + # assert doc_overview is not None + # + # # Should contain information about our structs and functions + # overview_text = str(doc_overview).lower() + # expected_terms = ["user", "item", "order", "struct", "defmodule"] + # found_terms = [term for term in expected_terms if term in overview_text] + # assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_containing_symbol_of_module_attribute(self, language_server: SolidLanguageServer) -> None: + """Test containing symbol for module attributes.""" + file_path = os.path.join("lib", "models.ex") + + # Find a module attribute like @type or @doc + content = language_server.retrieve_full_file_content(file_path) + lines = content.split("\n") + attribute_line = None + for i, line in enumerate(lines): + if line.strip().startswith("@type") or line.strip().startswith("@doc"): + attribute_line = i + break + + if attribute_line is None: + pytest.skip("Could not find module attribute") + + containing_symbol = language_server.request_containing_symbol(file_path, attribute_line, 5) + + if containing_symbol: + # Should be contained within a module + assert "name" in containing_symbol + # The containing symbol should be a module + expected_names = ["User", "Item", "Order", "TestRepo.Models"] + assert any(name in containing_symbol["name"] for name in expected_names) diff --git a/test/solidlsp/terraform/test_terraform_basic.py b/test/solidlsp/terraform/test_terraform_basic.py new file mode 100644 index 00000000..ff20ac66 --- /dev/null +++ b/test/solidlsp/terraform/test_terraform_basic.py @@ -0,0 +1,53 @@ +""" +Basic integration tests for the Terraform 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 + + +@pytest.mark.terraform +class TestLanguageServerBasics: + """Test basic functionality of the Terraform language server.""" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_basic_definition(self, language_server: SolidLanguageServer) -> None: + """Test basic definition lookup functionality.""" + # Simple test to verify the language server is working + file_path = "main.tf" + # Just try to get document symbols - this should work without hanging + symbols = language_server.request_document_symbols(file_path) + assert len(symbols) > 0, "Should find at least some symbols in main.tf" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_request_references_aws_instance(self, language_server: SolidLanguageServer) -> None: + """Test request_references on an aws_instance resource.""" + # Get references to an aws_instance resource in main.tf + file_path = "main.tf" + # Find aws_instance resources + symbols = language_server.request_document_symbols(file_path) + aws_instance_symbol = next((s for s in symbols[0] if s.get("name") == 'resource "aws_instance" "web_server"'), None) + if not aws_instance_symbol or "selectionRange" not in aws_instance_symbol: + raise AssertionError("aws_instance symbol or its selectionRange not found") + sel_start = aws_instance_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) >= 1, "aws_instance should be referenced at least once" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_request_references_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_references on a variable.""" + # Get references to a variable in variables.tf + file_path = "variables.tf" + # Find variable definitions + symbols = language_server.request_document_symbols(file_path) + var_symbol = next((s for s in symbols[0] if s.get("name") == 'variable "instance_type"'), None) + if not var_symbol or "selectionRange" not in var_symbol: + raise AssertionError("variable symbol or its selectionRange not found") + sel_start = var_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) >= 1, "variable should be referenced at least once"