Merge remote-tracking branch 'origin/main' into name-path-overloads

Conflicts:
	src/serena/cli.py
This commit is contained in:
Dominik Jain committed 2025-11-17 16:24:13 +01:00
commit d6839656ac
17 files changed
+510 -89

No files matched your search

+1 -1
View File
@@ -200,6 +200,7 @@ pylint.html
/*.yml
!*.template.yml
/agent-ui
/test/**/.serena
# dynamic LS installations
/src/multilspy/language_servers/*/static
@@ -220,7 +221,6 @@ pylint.html
*.tmp
tmp/
.serena/
.vscode/
# Claude settings
+1
View File
@@ -0,0 +1 @@
/cache
+5 -2
View File
@@ -178,9 +178,12 @@ Here are some examples of commands you might find useful:
<serena> tools description find_symbol
# creating a new Serena project in the current directory
<serena> project generate-yml
<serena> project create
# indexing the project in the current directory
# creating and immediately indexing a project
<serena> project create --index
# indexing the project in the current directory (auto-creates if needed)
<serena> project index
# run a health check on the project in the current directory
+9 -3
View File
@@ -12,6 +12,7 @@ setting up a project with Serena typically involves the following steps:
3. **Onboarding**: Getting Serena familiar with the project (creating memories)
4. **Working on coding tasks**: Using Serena to help you with actual coding tasks in the project
(project-creation-indexing)=
## Project Creation & Indexing
You can create a project either
@@ -22,17 +23,19 @@ You can create a project either
To explicitly create a project, use the following command while in the project directory:
<serena> project generate-yml [options]
<serena> project create [options]
For instance, when using `uvx`, run
uvx --from git+https://github.com/oraios/serena serena project generate-yml [options]
uvx --from git+https://github.com/oraios/serena serena project create [options]
* For an empty project, you will need to specify the programming language
(e.g., `--language python`).
* For an existing project, the main programming language will be detected automatically,
but you can choose to explicitly specify multiple languages by passing the `--language` parameter
multiple times (e.g. `--language python --language typescript`).
* You can optionally specify a custom project name with `--name "My Project"`.
* You can immediately index the project after creation with `--index`.
After creation, you can adjust the project settings in the generated `.serena/project.yml` file.
@@ -57,7 +60,10 @@ You can either choose to do this
* "Activate the project /path/to/my_project" (for first-time activation with auto-creation)
* "Activate the project my_project"
Note that this option requires the `activate_project` tool to be active (which it isn't in context `ide-assistant` where t.
Note that this option requires the `activate_project` tool to be active,
which it isn't in the (default version) of context `ide-assistant` if a project is provided at startup.
(The tool is deactivated because we assume that in the ide-assistant context the user will only work on the open project and have
no need to switch it.)
* when the MCP server starts, by passing the project path or name as a command-line argument
(e.g. when working on a fixed project in `ide-assistant` mode): `--project <path|name>`
+2 -7
View File
@@ -22,13 +22,8 @@ Serena is configured in four places:
See for a detailed explanation. You can override all entries from the `serena_config.yml` through command line arguments.
3. In the `.serena/project.yml` file within your project. This will hold project-level configuration that is used whenever
that project is activated. This file will be autogenerated when you first use Serena on that project, but you can also
generate it explicitly with
```shell
uvx --from git+https://github.com/oraios/serena serena project generate-yml
```
(or use the `--directory` command version).
create it explicitly with `serena project create [options]` (see the [](project-creation-indexing)
for details on available options).
4. Through the context and modes (see below).
After the initial setup, continue with one of the sections below, depending on how you
+5 -1
View File
@@ -76,7 +76,11 @@
formatter = pkgs.alejandra;
packages = {
serena = pythonSet.mkVirtualEnv "serena" workspace.deps.default;
serena-env = pythonSet.mkVirtualEnv "serena-env" workspace.deps.default;
serena = pkgs.runCommand "serena" {} ''
mkdir -p $out/bin
ln -s ${packages.serena-env}/bin/serena $out/bin/serena
'';
default = packages.serena;
};
+87 -26
View File
@@ -1,3 +1,4 @@
import collections
import glob
import json
import os
@@ -11,6 +12,7 @@ from typing import Any, Literal
import click
from sensai.util import logging
from sensai.util.logging import FileLoggerContext, datetime_tag
from sensai.util.string import dict_string
from tqdm import tqdm
from serena.agent import SerenaAgent
@@ -414,15 +416,21 @@ class ProjectCommands(AutoRegisteringGroup):
)
@staticmethod
@click.command("generate-yml", help="Generate a project.yml file.")
@click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd())
@click.option(
"--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times."
)
def generate_yml(project_path: str, language: tuple[str, ...]) -> None:
def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> ProjectConfig:
"""
Helper method to create a project configuration file.
:param project_path: Path to the project directory
:param name: Optional project name (defaults to directory name if not specified)
:param language: Tuple of language names
:return: The generated ProjectConfig instance
:raises FileExistsError: If project.yml already exists
:raises ValueError: If an unsupported language is specified
"""
yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml())
if os.path.exists(yml_path):
raise FileExistsError(f"Project file {yml_path} already exists.")
languages: list[Language] = []
if language:
for lang in language:
@@ -431,12 +439,51 @@ class ProjectCommands(AutoRegisteringGroup):
except ValueError:
all_langs = [l.value for l in Language]
raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate(project_root=project_path, languages=languages if languages else None)
print(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.")
generated_conf = ProjectConfig.autogenerate(
project_root=project_path, project_name=name, languages=languages if languages else None
)
return generated_conf
@staticmethod
@click.command("index", help="Index a project by saving symbols to the LSP cache.")
@click.command("create", help="Create a new Serena project configuration.")
@click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd())
@click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.")
@click.option(
"--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times."
)
@click.option("--index", is_flag=True, help="Index the project after creation.")
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for indexing (only used if --index is set).",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file (only used if --index is set).")
def create(project_path: str, name: str | None, language: tuple[str, ...], index: bool, log_level: str, timeout: float) -> None:
try:
generated_conf = ProjectCommands._create_project(project_path, name, language)
yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml())
click.echo(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.")
if index:
click.echo("Indexing project...")
ProjectCommands._index_project(project_path, log_level, timeout=timeout)
except FileExistsError as e:
raise click.ClickException(f"Project already exists: {e}\nUse 'serena project index' to index an existing project.")
except ValueError as e:
raise click.ClickException(str(e))
@staticmethod
@click.command("index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).")
@click.option(
"--language",
type=str,
multiple=True,
help="Programming language(s) (only used if auto-creating project.yml). Inferred if not specified.",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
@@ -444,7 +491,20 @@ class ProjectCommands(AutoRegisteringGroup):
help="Log level for indexing.",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
def index(project: str, log_level: str, timeout: float) -> None:
def index(project: str, name: str | None, language: tuple[str, ...], log_level: str, timeout: float) -> None:
# Check if project.yml exists, if not auto-create it
yml_path = os.path.join(project, ProjectConfig.rel_path_to_project_yml())
if not os.path.exists(yml_path):
click.echo(f"Project configuration not found at {yml_path}. Auto-creating...")
try:
generated_conf = ProjectCommands._create_project(project, name, language)
click.echo(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.")
except FileExistsError:
# Race condition - file was created between check and creation
pass
except ValueError as e:
raise click.ClickException(str(e))
ProjectCommands._index_project(project, log_level, timeout=timeout)
@staticmethod
@@ -471,22 +531,23 @@ class ProjectCommands(AutoRegisteringGroup):
files = proj.gather_source_files()
servers = list(ls_mgr.iter_language_servers())
for k, ls in enumerate(servers, start=1):
click.echo(f"Indexing for language {ls.language.value} ({k}/{len(servers)}) …")
collected_exceptions: list[Exception] = []
files_failed = []
for i, f in enumerate(tqdm(files, desc="Indexing")):
try:
ls.request_document_symbols(f)
except Exception as e:
log.error(f"Failed to index {f}, continuing.")
collected_exceptions.append(e)
files_failed.append(f)
if (i + 1) % 10 == 0:
ls.save_cache()
ls.save_cache()
click.echo(f"Symbols saved to {ls.cache_dir}")
collected_exceptions: list[Exception] = []
files_failed = []
language_file_counts = collections.defaultdict(lambda: 0)
for i, f in enumerate(tqdm(files, desc="Indexing")):
try:
ls = ls_mgr.get_language_server(f)
ls.request_document_symbols(f)
language_file_counts[ls.language] += 1
except Exception as e:
log.error(f"Failed to index {f}, continuing.")
collected_exceptions.append(e)
files_failed.append(f)
if (i + 1) % 10 == 0:
ls_mgr.save_all_caches()
language_file_counts = {k.value: v for k, v in language_file_counts.items()}
click.echo(f"Indexed files per language: {dict_string(language_file_counts, brackets=None)}")
ls_mgr.save_all_caches()
if len(files_failed) > 0:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
+4 -4
View File
@@ -26,21 +26,21 @@ class MemoriesManager:
self._memory_dir.mkdir(parents=True, exist_ok=True)
self._encoding = SERENA_FILE_ENCODING
def _get_memory_file_path(self, name: str) -> Path:
def get_memory_file_path(self, name: str) -> Path:
# strip all .md from the name. Models tend to get confused, sometimes passing the .md extension and sometimes not.
name = name.replace(".md", "")
filename = f"{name}.md"
return self._memory_dir / filename
def load_memory(self, name: str) -> str:
memory_file_path = self._get_memory_file_path(name)
memory_file_path = self.get_memory_file_path(name)
if not memory_file_path.exists():
return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it."
with open(memory_file_path, encoding=self._encoding) as f:
return f.read()
def save_memory(self, name: str, content: str) -> str:
memory_file_path = self._get_memory_file_path(name)
memory_file_path = self.get_memory_file_path(name)
with open(memory_file_path, "w", encoding=self._encoding) as f:
f.write(content)
return f"Memory {name} written."
@@ -49,7 +49,7 @@ class MemoriesManager:
return [f.name.replace(".md", "") for f in self._memory_dir.iterdir() if f.is_file()]
def delete_memory(self, name: str) -> str:
memory_file_path = self._get_memory_file_path(name)
memory_file_path = self.get_memory_file_path(name)
memory_file_path.unlink()
return f"Memory {name} deleted."
+17 -1
View File
@@ -195,7 +195,23 @@ class ReplaceRegexTool(Tool, ToolMarkerCanEdit):
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.project.validate_relative_path(relative_path, require_not_ignored=True)
return self.replace_regex(
relative_path, regex, repl, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True
)
def replace_regex(
self,
relative_path: str,
regex: str,
repl: str,
allow_multiple_occurrences: bool = False,
require_not_ignored: bool = True,
) -> str:
"""
Performs the regex replacement, with additional options not exposed in the tool.
This function can be used internally by other tools.
"""
self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored)
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)
+24 -1
View File
@@ -1,6 +1,6 @@
import json
from serena.tools import Tool
from serena.tools import ReplaceRegexTool, Tool
class WriteMemoryTool(Tool):
@@ -64,3 +64,26 @@ class DeleteMemoryTool(Tool):
or no longer relevant for the project.
"""
return self.memories_manager.delete_memory(memory_file_name)
class EditMemoryTool(Tool):
def apply(
self,
memory_file_name: str,
regex: str,
repl: str,
) -> str:
r"""
Replaces content matching a regular expression in a memory.
:param memory_file_name: the name of the memory
:param regex: a Python-style regular expression (flags DOTALL and MULTILINE enabled, i.e.
'.' matches all characters, multi-line matching is enabled).
Apply the usual escaping as needed for reserved characters in Python-style regex.
:param repl: the string to replace the matched content with, which may contain
backreferences like \1, \2, etc. for groups matched by the regex.
Insert new content verbatim, except for backslashes, which have to be escaped.
"""
replace_regex_tool = self.agent.get_tool(ReplaceRegexTool)
rel_path = self.memories_manager.get_memory_file_path(memory_file_name).relative_to(self.get_project_root())
return replace_regex_tool.replace_regex(str(rel_path), regex, repl, require_not_ignored=False)
@@ -247,17 +247,10 @@ class CSharpLanguageServer(SolidLanguageServer):
Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available.
Returns a tuple of (dotnet_path, language_server_dll_path).
"""
language_enum = cls.get_language_enum_instance()
ls_specific_settings = solidlsp_settings.ls_specific_settings or {}
language_specific_config = ls_specific_settings.get(language_enum, {})
language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
runtime_dependency_overrides = cast(list[dict[str, Any]], language_specific_config.get("runtime_dependencies", []))
logger.log("Resolving runtime dependencies", logging.DEBUG)
if language_specific_config:
logger.log(
f"Language-specific config for {language_enum}: {language_specific_config}",
logging.DEBUG,
)
runtime_dependencies = RuntimeDependencyCollection(
_RUNTIME_DEPENDENCIES,
@@ -448,9 +441,8 @@ class CSharpLanguageServer(SolidLanguageServer):
logger.log("Downloading .NET 9 runtime...", logging.INFO)
dotnet_dir.mkdir(parents=True, exist_ok=True)
custom_dotnet_runtime_url = solidlsp_settings.ls_specific_settings.get(cls.get_language_enum_instance(), {}).get(
"dotnet_runtime_url"
)
custom_settings = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
custom_dotnet_runtime_url = custom_settings.get("dotnet_runtime_url")
if custom_dotnet_runtime_url is not None:
logger.log(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}", logging.INFO)
url = custom_dotnet_runtime_url
+20 -21
View File
@@ -341,52 +341,51 @@ class EclipseJDTLS(SolidLanguageServer):
repo_uri = pathlib.Path(repository_absolute_path).as_uri()
# Load user's Maven and Gradle configuration paths from ls_specific_settings["java"]
custom_java_settings = self._solidlsp_settings.ls_specific_settings.get(self.get_language_enum_instance(), {})
# Maven settings: default to ~/.m2/settings.xml
default_maven_settings = os.path.join(os.path.expanduser("~"), ".m2", "settings.xml")
maven_user_settings = None
if "maven_user_settings" in custom_java_settings:
default_maven_settings_path = os.path.join(os.path.expanduser("~"), ".m2", "settings.xml")
custom_maven_settings_path = self._custom_settings.get("maven_user_settings")
if custom_maven_settings_path is not None:
# User explicitly provided a path
maven_settings_path = custom_java_settings["maven_user_settings"]
if not os.path.exists(maven_settings_path):
if not os.path.exists(custom_maven_settings_path):
error_msg = (
f"Maven settings file not found: {maven_settings_path}. "
f"Provided maven settings file not found: {custom_maven_settings_path}. "
f"Fix: create the file, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> maven_user_settings), "
f"or remove the setting to use default (~/.m2/settings.xml)"
f"or remove the setting to use default ({default_maven_settings_path})"
)
self.logger.log(error_msg, logging.ERROR)
raise FileNotFoundError(error_msg)
maven_user_settings = maven_settings_path
self.logger.log(f"Using Maven settings from custom location: {maven_user_settings}", logging.INFO)
elif os.path.exists(default_maven_settings):
maven_user_settings = default_maven_settings
self.logger.log(f"Using Maven settings from default location: {maven_user_settings}", logging.INFO)
maven_settings_path = custom_maven_settings_path
self.logger.log(f"Using Maven settings from custom location: {maven_settings_path}", logging.INFO)
elif os.path.exists(default_maven_settings_path):
maven_settings_path = default_maven_settings_path
self.logger.log(f"Using Maven settings from default location: {maven_settings_path}", logging.INFO)
else:
maven_settings_path = None
self.logger.log(
f"Maven settings not found at default location ({default_maven_settings}), will use JDTLS defaults", logging.INFO
f"Maven settings not found at default location ({default_maven_settings_path}), will use JDTLS defaults", logging.INFO
)
# Gradle user home: default to ~/.gradle
default_gradle_home = os.path.join(os.path.expanduser("~"), ".gradle")
gradle_user_home = None
if "gradle_user_home" in custom_java_settings:
custom_gradle_home = self._custom_settings.get("gradle_user_home")
if custom_gradle_home is not None:
# User explicitly provided a path
gradle_user_home_path = custom_java_settings["gradle_user_home"]
if not os.path.exists(gradle_user_home_path):
if not os.path.exists(custom_gradle_home):
error_msg = (
f"Gradle user home directory not found: {gradle_user_home_path}. "
f"Gradle user home directory not found: {custom_gradle_home}. "
f"Fix: create the directory, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_user_home), "
f"or remove the setting to use default (~/.gradle)"
)
self.logger.log(error_msg, logging.ERROR)
raise FileNotFoundError(error_msg)
gradle_user_home = gradle_user_home_path
gradle_user_home = custom_gradle_home
self.logger.log(f"Using Gradle user home from custom location: {gradle_user_home}", logging.INFO)
elif os.path.exists(default_gradle_home):
gradle_user_home = default_gradle_home
self.logger.log(f"Using Gradle user home from default location: {gradle_user_home}", logging.INFO)
else:
gradle_user_home = None
self.logger.log(
f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults", logging.INFO
)
@@ -577,7 +576,7 @@ class EclipseJDTLS(SolidLanguageServer):
"checkProjectSettingsExclusions": False,
"updateBuildConfiguration": "interactive",
"maven": {
"userSettings": maven_user_settings,
"userSettings": maven_settings_path,
"globalSettings": None,
"notCoveredPluginExecutionSeverity": "warning",
"defaultMojoExecutionAction": "ignore",
@@ -250,6 +250,11 @@ class ElixirTools(SolidLanguageServer):
"didChangeConfiguration": {"dynamicRegistration": True},
"executeCommand": {"dynamicRegistration": True},
},
"window": {
"showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}},
"showDocument": {"support": True},
"workDoneProgress": True,
},
},
"workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}],
}
+18 -10
View File
@@ -26,17 +26,14 @@ class Intelephense(SolidLanguageServer):
Provides PHP specific instantiation of the LanguageServer class using Intelephense.
You can pass the following entries in ls_specific_settings["php"]:
- maxMemory
- maxFileSize
- maxMemory: sets intelephense.maxMemory
- maxFileSize: sets intelephense.files.maxSize
- ignore_vendor: whether or ignore directories named "vendor" (default: true)
"""
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For PHP projects, we should ignore:
# - vendor: third-party dependencies <managed by Composer
# - node_modules: if the project has JavaScript components
# - cache: commonly used for caching
return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"]
return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames
@classmethod
def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> list[str]:
@@ -100,6 +97,18 @@ class Intelephense(SolidLanguageServer):
)
self.request_id = 0
# For PHP projects, we should ignore:
# - node_modules: if the project has JavaScript components
# - cache: commonly used for caching
# - (configurable) vendor: third-party dependencies <managed by Composer
self._ignored_dirnames = {"node_modules", "cache"}
if self._custom_settings.get("ignore_vendor", True):
self._ignored_dirnames.add("vendor")
self.logger.log(
f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}",
logging.INFO,
)
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialization params for the Intelephense Language Server.
@@ -130,9 +139,8 @@ class Intelephense(SolidLanguageServer):
if license_key:
initialization_options["licenceKey"] = license_key
custom_intelephense_settings = self._solidlsp_settings.ls_specific_settings.get(self.get_language_enum_instance(), {})
max_memory = custom_intelephense_settings.get("maxMemory")
max_file_size = custom_intelephense_settings.get("maxFileSize")
max_memory = self._custom_settings.get("maxMemory")
max_file_size = self._custom_settings.get("maxFileSize")
if max_memory is not None:
initialization_options["intelephense.maxMemory"] = max_memory
if max_file_size is not None:
+6
View File
@@ -238,6 +238,12 @@ class SolidLanguageServer(ABC):
improves/fixes its output).
"""
self._solidlsp_settings = solidlsp_settings
lang = self.get_language_enum_instance()
self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang)
logger.log(
f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}",
logging.DEBUG,
)
self._encoding = config.encoding
self.logger = logger
self.repository_root_path: str = repository_root_path
+35 -1
View File
@@ -2,14 +2,19 @@
Defines settings for Solid-LSP
"""
import logging
import os
import pathlib
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from sensai.util.string import ToStringMixin
if TYPE_CHECKING:
from solidlsp.ls_config import Language
log = logging.getLogger(__name__)
@dataclass
class SolidLSPSettings:
@@ -23,7 +28,7 @@ class SolidLSPSettings:
For instance, if this is ".solidlsp" and the project is located at "/home/user/myproject",
then Solid-LSP will store project-specific data in "/home/user/myproject/.solidlsp".
"""
ls_specific_settings: dict["Language", Any] = field(default_factory=dict)
ls_specific_settings: dict["Language", dict[str, Any]] = field(default_factory=dict)
"""
Advanced configuration option allowing to configure language server implementation specific options.
Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available.
@@ -37,3 +42,32 @@ class SolidLSPSettings:
@property
def ls_resources_dir(self):
return os.path.join(str(self.solidlsp_dir), "language_servers", "static")
class CustomLSSettings(ToStringMixin):
def __init__(self, settings: dict[str, Any] | None) -> None:
self.settings = settings or {}
def get(self, key: str, default_value: Any = None) -> Any:
"""
Returns the custom setting for the given key or the default value if not set.
If a custom value is set for the given key, the retrieval is logged.
:param key: the key
:param default_value: the default value to use if no custom value is set
:return: the value
"""
if key in self.settings:
value = self.settings[key]
log.info("Using custom LS setting %s for key '%s'", value, key)
else:
value = default_value
return value
def get_ls_specific_settings(self, language: "Language") -> CustomLSSettings:
"""
Get the language server specific settings for the given language.
:param language: The programming language.
:return: A dictionary of settings for the language server.
"""
return self.CustomLSSettings(self.ls_specific_settings.get(language))
+268
View File
@@ -0,0 +1,268 @@
"""Tests for CLI project commands (create, index)."""
import os
import shutil
import tempfile
import time
import pytest
from click.testing import CliRunner
from serena.cli import ProjectCommands
from serena.config.serena_config import ProjectConfig
pytestmark = pytest.mark.filterwarnings("ignore::UserWarning")
@pytest.fixture
def temp_project_dir():
"""Create a temporary directory for testing."""
tmpdir = tempfile.mkdtemp()
try:
yield tmpdir
finally:
# if windows, wait a bit to avoid PermissionError on cleanup
if os.name == "nt":
time.sleep(0.2)
shutil.rmtree(tmpdir, ignore_errors=True)
@pytest.fixture
def temp_project_dir_with_python_file():
"""Create a temporary directory with a Python file for testing."""
tmpdir = tempfile.mkdtemp()
try:
# Create a simple Python file so language detection works
py_file = os.path.join(tmpdir, "test.py")
with open(py_file, "w") as f:
f.write("def hello():\n pass\n")
yield tmpdir
finally:
# if windows, wait a bit to avoid PermissionError on cleanup
if os.name == "nt":
time.sleep(0.2)
shutil.rmtree(tmpdir, ignore_errors=True)
@pytest.fixture
def cli_runner():
"""Create a CliRunner for testing Click commands."""
return CliRunner()
class TestProjectCreate:
"""Tests for 'project create' command."""
def test_create_basic_with_language(self, cli_runner, temp_project_dir):
"""Test basic project creation with explicit language."""
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "Generated project.yml" in result.output
assert "python" in result.output.lower()
# Verify project.yml was created
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
assert os.path.exists(yml_path), f"project.yml not found at {yml_path}"
def test_create_auto_detect_language(self, cli_runner, temp_project_dir_with_python_file):
"""Test project creation with auto-detected language."""
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir_with_python_file])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "Generated project.yml" in result.output
assert "python" in result.output.lower()
# Verify project.yml was created
yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml")
assert os.path.exists(yml_path)
def test_create_with_name(self, cli_runner, temp_project_dir):
"""Test project creation with custom name and explicit language."""
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--name", "my-custom-project", "--language", "python"])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "Generated project.yml" in result.output
# Verify project.yml was created
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
assert os.path.exists(yml_path)
def test_create_with_language(self, cli_runner, temp_project_dir):
"""Test project creation with specified language."""
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "Generated project.yml" in result.output
assert "python" in result.output.lower()
def test_create_with_multiple_languages(self, cli_runner, temp_project_dir):
"""Test project creation with multiple languages."""
result = cli_runner.invoke(
ProjectCommands.create,
[temp_project_dir, "--language", "python", "--language", "typescript"],
)
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "Generated project.yml" in result.output
def test_create_with_invalid_language(self, cli_runner, temp_project_dir):
"""Test project creation with invalid language raises error."""
result = cli_runner.invoke(
ProjectCommands.create,
[temp_project_dir, "--language", "invalid-lang"],
)
assert result.exit_code != 0, "Should fail with invalid language"
assert "Unknown language" in result.output or "invalid-lang" in result.output
def test_create_already_exists(self, cli_runner, temp_project_dir):
"""Test that creating a project twice fails gracefully."""
# Create once with explicit language
result1 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
assert result1.exit_code == 0
# Try to create again - should fail gracefully
result2 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
assert result2.exit_code != 0, "Should fail when project.yml already exists"
assert "already exists" in result2.output.lower()
assert "Error:" in result2.output # Should be user-friendly error
def test_create_with_index_flag(self, cli_runner, temp_project_dir_with_python_file):
"""Test project creation with --index flag performs indexing."""
result = cli_runner.invoke(
ProjectCommands.create,
[temp_project_dir_with_python_file, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"],
)
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "Generated project.yml" in result.output
assert "Indexing project" in result.output
# Verify project.yml was created
yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml")
assert os.path.exists(yml_path)
# Verify cache directory was created (proof of indexing)
cache_dir = os.path.join(temp_project_dir_with_python_file, ".serena", "cache")
assert os.path.exists(cache_dir), "Cache directory should exist after indexing"
def test_create_without_index_flag(self, cli_runner, temp_project_dir):
"""Test that project creation without --index does NOT perform indexing."""
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
assert result.exit_code == 0
assert "Generated project.yml" in result.output
assert "Indexing" not in result.output
# Verify cache directory was NOT created
cache_dir = os.path.join(temp_project_dir, ".serena", "cache")
assert not os.path.exists(cache_dir), "Cache directory should not exist without --index"
class TestProjectIndex:
"""Tests for 'project index' command."""
def test_index_auto_creates_project_with_files(self, cli_runner, temp_project_dir_with_python_file):
"""Test that index command auto-creates project.yml if it doesn't exist (with source files)."""
result = cli_runner.invoke(ProjectCommands.index, [temp_project_dir_with_python_file, "--log-level", "ERROR", "--timeout", "5"])
# Should succeed and perform indexing
assert result.exit_code == 0, f"Command failed: {result.output}"
assert "Auto-creating" in result.output or "Indexing" in result.output
# Verify project.yml was auto-created
yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml")
assert os.path.exists(yml_path), "project.yml should be auto-created"
def test_index_with_explicit_language(self, cli_runner, temp_project_dir):
"""Test index with explicit --language for empty directory."""
result = cli_runner.invoke(
ProjectCommands.index,
[temp_project_dir, "--language", "python", "--log-level", "ERROR", "--timeout", "5"],
)
# Should succeed even without source files if language is explicit
assert result.exit_code == 0, f"Command failed: {result.output}"
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
assert os.path.exists(yml_path)
def test_index_with_language_auto_creates(self, cli_runner, temp_project_dir):
"""Test index with --language option for auto-creation."""
result = cli_runner.invoke(
ProjectCommands.index,
[temp_project_dir, "--language", "python", "--log-level", "ERROR"],
)
assert result.exit_code == 0 or "Indexing" in result.output
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
assert os.path.exists(yml_path)
def test_index_is_equivalent_to_create_with_index(self, cli_runner, temp_project_dir_with_python_file):
"""Test that 'index' behaves like 'create --index' for new projects."""
# Use manual temp directory creation with Windows-safe cleanup
# to avoid PermissionError on Windows CI when language servers hold file locks
dir1 = tempfile.mkdtemp()
dir2 = tempfile.mkdtemp()
try:
# Setup both directories with same file
for d in [dir1, dir2]:
with open(os.path.join(d, "test.py"), "w") as f:
f.write("def hello():\n pass\n")
# Run 'create --index' on dir1
result1 = cli_runner.invoke(
ProjectCommands.create, [dir1, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"]
)
# Run 'index' on dir2
result2 = cli_runner.invoke(ProjectCommands.index, [dir2, "--language", "python", "--log-level", "ERROR", "--timeout", "5"])
# Both should succeed
assert result1.exit_code == 0, f"create --index failed: {result1.output}"
assert result2.exit_code == 0, f"index failed: {result2.output}"
# Both should create project.yml
assert os.path.exists(os.path.join(dir1, ".serena", "project.yml"))
assert os.path.exists(os.path.join(dir2, ".serena", "project.yml"))
# Both should create cache (proof of indexing)
assert os.path.exists(os.path.join(dir1, ".serena", "cache"))
assert os.path.exists(os.path.join(dir2, ".serena", "cache"))
finally:
# Windows-safe cleanup: wait for file handles to be released
if os.name == "nt":
time.sleep(0.2)
# Use ignore_errors to handle lingering file locks on Windows
shutil.rmtree(dir1, ignore_errors=True)
shutil.rmtree(dir2, ignore_errors=True)
class TestProjectCreateHelper:
"""Tests for _create_project helper method."""
def test_create_project_helper_returns_config(self, temp_project_dir):
"""Test that _create_project returns a ProjectConfig with explicit language."""
config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",))
assert isinstance(config, ProjectConfig)
assert config.project_name == "test-project"
def test_create_project_helper_with_auto_detect(self, temp_project_dir_with_python_file):
"""Test _create_project with auto-detected language."""
config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ())
assert isinstance(config, ProjectConfig)
assert config.project_name == "my-project"
assert len(config.languages) >= 1
def test_create_project_helper_with_languages(self, temp_project_dir):
"""Test _create_project with language specification."""
config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript"))
assert isinstance(config, ProjectConfig)
assert len(config.languages) >= 1
def test_create_project_helper_file_exists_error(self, temp_project_dir):
"""Test _create_project raises error if project.yml exists."""
# Create project first with explicit language
ProjectCommands._create_project(temp_project_dir, None, ("python",))
# Try to create again - should raise FileExistsError
with pytest.raises(FileExistsError):
ProjectCommands._create_project(temp_project_dir, None, ("python",))
if __name__ == "__main__":
# For manual testing, you can run this file directly:
# uv run pytest test/serena/test_cli_project_commands.py -v
pytest.main([__file__, "-v"])