mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 08:15:59 +00:00
Merge remote-tracking branch 'origin/main' into name-path-overloads
Conflicts: src/serena/cli.py
This commit is contained in:
commit
d6839656ac
17 files changed
+510
-89
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/cache
|
||||
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}],
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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"])
|
||||
Reference in new issue
Block a user