mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 08:18:36 +00:00
Merge branch 'main' into refactoring/simplify_ls_setup
This commit is contained in:
commit
1af8748f6f
71 files changed
+5041
-3059
No files matched your search
@@ -15,6 +15,7 @@ jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
id-token: write
|
||||
pages: write
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -35,6 +35,13 @@ jobs:
|
||||
- name: Install gopls
|
||||
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 +51,40 @@ 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 terraform-ls (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Installing terraform-ls manually for Windows"
|
||||
$ver = '0.32.7'
|
||||
$zip = "terraform-ls_${ver}_windows_amd64.zip"
|
||||
Invoke-WebRequest -Uri "https://releases.hashicorp.com/terraform-ls/$ver/$zip" -OutFile $zip
|
||||
|
||||
$dest = "$env:USERPROFILE\terraform-ls"
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
Expand-Archive $zip -DestinationPath $dest -Force
|
||||
|
||||
Write-Host "terraform-ls installed to: $dest"
|
||||
echo "$dest" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Install terraform-ls (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
ver=0.32.7
|
||||
os=$(uname | tr '[:upper:]' '[:lower:]')
|
||||
echo "Installing terraform-ls ${ver} for ${os}"
|
||||
curl -sSL -o tfls.zip \
|
||||
"https://releases.hashicorp.com/terraform-ls/${ver}/terraform-ls_${ver}_${os}_amd64.zip"
|
||||
mkdir -p "$HOME/bin"
|
||||
unzip -q tfls.zip -d "$HOME/bin"
|
||||
chmod +x "$HOME/bin/terraform-ls"
|
||||
echo "$HOME/bin" >> "$GITHUB_PATH"
|
||||
echo "terraform-ls installed to $HOME/bin"
|
||||
- name: Install uv
|
||||
shell: bash
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
@@ -222,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
|
||||
@@ -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.
|
||||
@@ -17,7 +17,6 @@ The following tasks should generally be executed using `uv run poe <task_name>`.
|
||||
"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"`.
|
||||
|
||||
Vendored
+2
-1
@@ -11,4 +11,5 @@
|
||||
"sensai",
|
||||
"vibing"
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from serena.config import SerenaAgentContext, SerenaAgentMode
|
||||
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("---------- Available modes: ----------")
|
||||
|
||||
+32
-1839
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -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__)
|
||||
|
||||
|
||||
Whitespace-only changes.
File renamed without changes.
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
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"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_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)
|
||||
@@ -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"
|
||||
@@ -128,11 +128,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
|
||||
|
||||
+22
-266
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
+17
-4
@@ -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
|
||||
|
||||
@@ -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 *
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()}")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,23 @@
|
||||
import sys
|
||||
|
||||
from serena.agent import log
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1 @@
|
||||
language_servers/static
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"_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
|
||||
},
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"workspaceFolders": true,
|
||||
"didChangeConfiguration": {
|
||||
"dynamicRegistration": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspaceFolders": [
|
||||
{
|
||||
"uri": "$uri",
|
||||
"name": "$name"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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"]
|
||||
|
||||
def _get_terraform_version(self) -> str:
|
||||
self.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:
|
||||
self.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:
|
||||
self.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
|
||||
self.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.")
|
||||
|
||||
self.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:
|
||||
self.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}")
|
||||
|
||||
def setup_runtime_dependencies(self) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for terraform-ls.
|
||||
Downloads and installs terraform-ls if not already present.
|
||||
"""
|
||||
# First check if Terraform is available
|
||||
terraform_version = self._get_terraform_version()
|
||||
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):
|
||||
self.logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO)
|
||||
FileUtils.download_and_extract_archive(self.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()
|
||||
|
||||
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
|
||||
|
||||
def _get_initialize_params(self, 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()
|
||||
@@ -186,6 +186,16 @@ class SolidLanguageServer(ABC):
|
||||
|
||||
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.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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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 = {}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from serena.agent import CreateTextFileTool, ReadFileTool, Tool
|
||||
from serena.tools import CreateTextFileTool, ReadFileTool, Tool
|
||||
|
||||
|
||||
class TestEditMarker:
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)}"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user