Merge branch 'main' into prepare-v1

Conflicts:
	src/serena/resources/serena_config.template.yml
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2025-12-17 15:40:14 +01:00
commit d8d1cb113f
16 files changed
+510 -67

No files matched your search

+2 -1
View File
@@ -6,7 +6,8 @@ Status of the `main` branch. Changes prior to the next official version change w
* Add monorepo/multi-language support
* Project configuration files (`project.yml`) can now define multiple languages.
Auto-detection adds only the most prominent language by default.
* Additional languages can be conveniently added via the Dashboard while a project is already activated.
* Additional languages can be conveniently added via the Dashboard while a project is already activated.
* The current project can be activated automatically even when the MCP configuration is global (through the --project-from-cwd flag)
* Support overloaded symbols in `FindSymbolTool` and related tools
* Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`)
* Responses of the Java language server, which handled this in its own way, are now adapted accordingly,
+2 -1
View File
@@ -157,6 +157,7 @@ to get a list of all available options.
Some useful options include:
* `--project <path|name>`: specify the project to work on by name or path.
* `--project-from-cwd`: auto-detect project from current working directory (searches up for `.serena/project.yml` or `.git`, falls back to CWD). Intended only for CLI-based agents like Claude Code, Gemini and Codex.
* `--context <context>`: specify the operation [context](contexts) in which Serena shall operate
* `--mode <mode>`: specify one or more [modes](modes) to enable (can be passed several times)
* `--enable-web-dashboard <true|false>`: enable or disable the web dashboard (enabled by default)
@@ -234,4 +235,4 @@ Here are some examples of commands you might find useful:
<serena> prompts edit-override prompt-name
```
Explore the full set of commands and options using the CLI itself!
Explore the full set of commands and options using the CLI itself!
+12
View File
@@ -88,6 +88,18 @@ Note:
that Serena is configured to work on the current project from the get-go, following
Claude Code's mode of operation.
Alternatively, use `--project-from-cwd` for user-level configuration that works across all projects:
```shell
claude mcp add --scope user serena -- <serena> start-mcp-server --context=claude-code --project-from-cwd
```
This searches up from the current directory for `.serena/project.yml` or `.git` markers,
falling back to the current directory if neither is found. This makes it suitable for a
single global MCP configuration. The `--project-from-cwd` option is intended for CLI-based agents
(like Claude Code, Gemina or Codex) that invoke Serena from within project directories.
Be sure to use at least `v1.0.52` of Claude Code (as earlier versions do not read MCP server system prompts upon startup).
### VSCode
-1
View File
@@ -25,7 +25,6 @@ if __name__ == "__main__":
serena_config = SerenaConfig.from_config_file()
serena_config.log_level = logging.INFO
serena_config.jetbrains = False
serena_config.gui_log_window_enabled = False
serena_config.web_dashboard = False
+16 -9
View File
@@ -18,13 +18,14 @@ from interprompt.jinja_template import JinjaTemplate
from serena import serena_version
from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition
from serena.config.serena_config import LanguageBackend, SerenaConfig, ToolInclusionDefinition
from serena.dashboard import SerenaDashboardAPI
from serena.ls_manager import LanguageServerManager
from serena.project import Project
from serena.prompt_factory import SerenaPromptFactory
from serena.task_executor import TaskExecutor
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry
from serena.util.gui import system_has_usable_display
from serena.util.inspection import iter_subclasses
from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import Language
@@ -226,7 +227,10 @@ class SerenaAgent:
self._tool_usage_stats = ToolUsageStats(token_count_estimator)
# log fundamental information
log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})")
log.info(
f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()}; "
f"language backend={self.serena_config.language_backend.name})"
)
log.info("Configuration file: %s", self.serena_config.config_file_path)
log.info("Available projects: {}".format(", ".join(self.serena_config.project_names)))
log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}")
@@ -238,7 +242,7 @@ class SerenaAgent:
tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context]
if self._context.single_project:
tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project))
if self.serena_config.jetbrains:
if self.serena_config.language_backend == LanguageBackend.JETBRAINS:
tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains"))
self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions)
@@ -281,11 +285,14 @@ class SerenaAgent:
dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html"
log.info("Serena web dashboard started at %s", dashboard_url)
if self.serena_config.web_dashboard_open_on_launch:
# open the dashboard URL in the default web browser (using a separate process to control
# output redirection)
process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,))
process.start()
process.join(timeout=1)
if not system_has_usable_display():
log.warning("Not opening the Serena web dashboard automatically because no usable display was detected.")
else:
# open the dashboard URL in the default web browser (using a separate process to control
# output redirection)
process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,))
process.start()
process.join(timeout=1)
# inform the GUI window (if any)
if self._gui_log_viewer is not None:
self._gui_log_viewer.set_dashboard_url(dashboard_url)
@@ -514,7 +521,7 @@ class SerenaAgent:
"""
:return: whether this agent uses language server-based code analysis
"""
return not self.serena_config.jetbrains
return self.serena_config.language_backend == LanguageBackend.LSP
def _activate_project(self, project: Project) -> None:
log.info(f"Activating {project.project_name} at {project.project_root}")
+71 -3
View File
@@ -5,6 +5,7 @@ import os
import shutil
import subprocess
import sys
from collections.abc import Iterator
from logging import Logger
from pathlib import Path
from typing import Any, Literal
@@ -17,7 +18,7 @@ from tqdm import tqdm
from serena.agent import SerenaAgent
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths
from serena.config.serena_config import LanguageBackend, ProjectConfig, SerenaConfig, SerenaPaths
from serena.constants import (
DEFAULT_CONTEXT,
DEFAULT_MODES,
@@ -35,6 +36,42 @@ from solidlsp.util.subprocess_util import subprocess_kwargs
log = logging.getLogger(__name__)
def find_project_root(root: str | Path | None = None) -> str:
"""Find project root by walking up from CWD.
Checks for .serena/project.yml first (explicit Serena project), then .git (git root).
Falls back to CWD if no marker is found.
:param root: If provided, constrains the search to this directory and below
(acts as a virtual filesystem root). Search stops at this boundary.
:return: absolute path to project root (falls back to CWD if no marker found)
"""
current = Path.cwd().resolve()
boundary = Path(root).resolve() if root is not None else None
def ancestors() -> Iterator[Path]:
"""Yield current directory and ancestors up to boundary."""
yield current
for parent in current.parents:
yield parent
if boundary is not None and parent == boundary:
return
# First pass: look for .serena
for directory in ancestors():
if (directory / ".serena" / "project.yml").is_file():
return str(directory)
# Second pass: look for .git
for directory in ancestors():
if (directory / ".git").exists(): # .git can be file (worktree) or dir
return str(directory)
# Fall back to CWD
return str(current)
# --------------------- Utilities -------------------------------------
@@ -117,6 +154,12 @@ class TopLevelCommands(AutoRegisteringGroup):
show_default=True,
help="Built-in mode names or paths to custom mode YAMLs.",
)
@click.option(
"--language-backend",
type=click.Choice([lb.value for lb in LanguageBackend]),
default=None,
help="Override the configured language backend.",
)
@click.option(
"--transport",
type=click.Choice(["stdio", "sse", "streamable-http"]),
@@ -124,8 +167,16 @@ class TopLevelCommands(AutoRegisteringGroup):
show_default=True,
help="Transport protocol.",
)
@click.option("--host", type=str, default="0.0.0.0", show_default=True)
@click.option("--port", type=int, default=8000, show_default=True)
@click.option(
"--host",
type=str,
default="0.0.0.0",
show_default=True,
help="Listen address for the MCP server (when using corresponding transport).",
)
@click.option(
"--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)."
)
@click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.")
@click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.")
@click.option(
@@ -136,11 +187,19 @@ class TopLevelCommands(AutoRegisteringGroup):
)
@click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.")
@click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.")
@click.option(
"--project-from-cwd",
is_flag=True,
default=False,
help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.",
)
def start_mcp_server(
project: str | None,
project_file_arg: str | None,
project_from_cwd: bool | None,
context: str,
modes: tuple[str, ...],
language_backend: str | None,
transport: Literal["stdio", "sse", "streamable-http"],
host: str,
port: int,
@@ -169,12 +228,21 @@ class TopLevelCommands(AutoRegisteringGroup):
log.info("Initializing Serena MCP server")
log.info("Storing logs in %s", log_path)
# Handle --project-from-cwd flag
if project_from_cwd:
if project is not None or project_file_arg is not None:
raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument")
project = find_project_root()
log.info("Auto-detected project root: %s", project)
project_file = project_file_arg or project
factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler)
server = factory.create_mcp_server(
host=host,
port=port,
modes=modes,
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None,
enable_web_dashboard=enable_web_dashboard,
enable_gui_log_window=enable_gui_log_window,
log_level=log_level,
+3 -11
View File
@@ -70,7 +70,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname)
if not os.path.exists(own_yaml_path):
raise FileNotFoundError(
f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}. "
f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}."
f"Available modes:\n{cls.list_registered_mode_names()}"
)
return own_yaml_path
@@ -78,16 +78,8 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
@classmethod
def from_name(cls, name: str) -> Self:
"""Load a registered Serena mode."""
try:
mode_path = cls.get_path(name)
return cls.from_yaml(mode_path)
except:
try:
# Allow loading an internal mode if it is explicitly requested
return cls.from_name_internal(name)
except:
pass
raise
mode_path = cls.get_path(name)
return cls.from_yaml(mode_path)
@classmethod
def from_name_internal(cls, name: str) -> Self:
+51 -12
View File
@@ -9,6 +9,7 @@ from collections.abc import Iterable
from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar
@@ -106,6 +107,26 @@ def get_serena_managed_in_project_dir(project_root: str | Path) -> str:
return os.path.join(project_root, SERENA_MANAGED_DIR_NAME)
class LanguageBackend(Enum):
LSP = "LSP"
"""
Use the language server protocol (LSP), spawning freely available language servers
via the SolidLSP library that is part of Serena
"""
JETBRAINS = "JetBrains"
"""
Use the Serena plugin in your JetBrains IDE.
(requires the plugin to be installed and the project being worked on to be open in your IDE)
"""
@staticmethod
def from_str(backend_str: str) -> "LanguageBackend":
for backend in LanguageBackend:
if backend.value.lower() == backend_str.lower():
return backend
raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}")
@dataclass(kw_only=True)
class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
project_name: str
@@ -362,9 +383,10 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
the path to the configuration file to which updates of the configuration shall be saved;
if None, the configuration is not saved to disk
"""
jetbrains: bool = False
language_backend: LanguageBackend = LanguageBackend.LSP
"""
whether to apply JetBrains mode
the language backend to use for code understanding features
"""
token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name
@@ -446,7 +468,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
# load list of known projects
instance.projects = []
num_project_migrations = 0
num_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()):
@@ -456,7 +478,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
path = cls._migrate_out_of_project_config_file(path)
if path is None:
continue
num_project_migrations += 1
num_migrations += 1
project_config = ProjectConfig.load(path)
project = RegisteredProject(
project_root=str(path),
@@ -469,7 +491,21 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
field_name = key
return loaded_commented_yaml.get(key, get_dataclass_default(SerenaConfig, field_name))
# set other configuration parameters
# determine language backend
language_backend = get_dataclass_default(SerenaConfig, "language_backend")
if "language_backend" in loaded_commented_yaml:
backend_str = loaded_commented_yaml["language_backend"]
language_backend = LanguageBackend.from_str(backend_str)
else:
# backward compatibility (migrate Boolean field "jetbrains")
if "jetbrains" in loaded_commented_yaml:
num_migrations += 1
if loaded_commented_yaml["jetbrains"]:
language_backend = LanguageBackend.JETBRAINS
del loaded_commented_yaml["jetbrains"]
instance.language_backend = language_backend
# set other configuration parameters (primitive types)
instance.gui_log_window_enabled = get_value_or_default("gui_log_window", "gui_log_window_enabled")
instance.web_dashboard_listen_address = get_value_or_default("web_dashboard_listen_address")
instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO))
@@ -479,16 +515,13 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication")
instance.excluded_tools = get_value_or_default("excluded_tools")
instance.included_optional_tools = get_value_or_default("included_optional_tools")
instance.jetbrains = get_value_or_default("jetbrains")
instance.token_count_estimator = get_value_or_default("token_count_estimator")
instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars")
instance.ls_specific_settings = get_value_or_default("ls_specific_settings")
# 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"
)
if num_migrations > 0:
log.info("Legacy configuration was migrated; re-saving configuration file")
instance.save()
return instance
@@ -591,9 +624,15 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
"""
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
# convert project objects into list of paths
loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects})
# convert language backend to string
loaded_original_yaml["language_backend"] = self.language_backend.value
save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True)
+2 -1
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from sensai.util import logging
from serena.analytics import ToolUsageStats
from serena.config.serena_config import LanguageBackend
from serena.constants import SERENA_DASHBOARD_DIR
from serena.task_executor import TaskExecutor
from serena.util.logging import MemoryLogHandler
@@ -443,7 +444,7 @@ class SerenaDashboardAPI:
available_modes=available_modes,
available_contexts=available_contexts,
available_memories=available_memories,
jetbrains_mode=self._agent.serena_config.jetbrains,
jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS,
languages=languages,
encoding=encoding,
)
+5
View File
@@ -23,6 +23,7 @@ from serena.agent import (
SerenaConfig,
)
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import LanguageBackend
from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, SERENA_LOG_FORMAT
from serena.tools import Tool
from serena.util.exception import show_fatal_exception_safe
@@ -251,6 +252,7 @@ class SerenaMCPFactory:
host: str = "0.0.0.0",
port: int = 8000,
modes: Sequence[str] = DEFAULT_MODES,
language_backend: LanguageBackend | None = None,
enable_web_dashboard: bool | None = None,
enable_gui_log_window: bool | None = None,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
@@ -263,6 +265,7 @@ class SerenaMCPFactory:
: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 language_backend: the language backend to use, overriding the configuration setting.
: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.
@@ -286,6 +289,8 @@ class SerenaMCPFactory:
config.trace_lsp_communication = trace_lsp_communication
if tool_timeout is not None:
config.tool_timeout = tool_timeout
if language_backend is not None:
config.language_backend = language_backend
modes_instances = [SerenaAgentMode.load(mode) for mode in modes]
self._instantiate_agent(config, modes_instances)
+10 -17
View File
@@ -1,3 +1,12 @@
language_backend: LSP
# the language backend to use for code understanding and manipulation.
# Possible values are:
# * LSP: Use the language server protocol (LSP), spawning freely available language servers
# via the SolidLSP library that is part of Serena.
# * JetBrains: Use the Serena plugin in your JetBrains IDE.
# (requires the plugin to be installed and the project being worked on to be open
# in your IDE).
gui_log_window: False
# whether to open a graphical window with Serena's logs.
# This is mainly supported on Windows and (partly) on Linux; not available on macOS.
@@ -42,7 +51,6 @@ ls_specific_settings: {}
# (same entry as in project.yml) to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
#
tool_timeout: 240
# timeout, in seconds, after which tool executions are terminated
@@ -53,12 +61,6 @@ excluded_tools: []
included_optional_tools: []
# list of optional tools (which are disabled by default) to be included
jetbrains: False
# whether to enable JetBrains mode and use tools based on the Serena JetBrains IDE plugin
# instead of language-server-based tools. Requires the Serena JetBrains plugin to be installed and
# a JetBrains IDE instance to be running with an open project.
default_max_tool_answer_chars: 150000
# Used as default for tools where the apply method has a default maximal answer length.
# Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default
@@ -73,14 +75,5 @@ token_count_estimator: CHAR_COUNT
# data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to
# estimate the token count using the Claude Sonnet 4 tokenizer.
# MANAGED BY SERENA, KEEP AT THE BOTTOM OF THE YAML AND DON'T EDIT WITHOUT NEED
# The list of registered projects.
# To add a project, within a chat, simply ask Serena to "activate the project /path/to/project" or,
# if the project was previously added, "activate the project <project name>".
# By default, the project's name will be the name of the directory containing the project, but you may change it
# by editing the (auto-generated) project configuration file `/path/project/project/.serena/project.yml` file.
# If you want to maintain full control of the project configuration, create the project.yml file manually and then
# instruct Serena to activate the project by its path for first-time activation.
# NOTE: Make sure there are no name collisions in the names of registered projects.
# The list of registered project paths (updated automatically).
projects: []
+48 -8
View File
@@ -9,6 +9,7 @@ File and file system-related tools, specifically for
import os
import re
from collections import defaultdict
from collections.abc import Callable
from fnmatch import fnmatch
from pathlib import Path
from typing import Literal
@@ -202,6 +203,45 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True
)
@staticmethod
def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]:
"""
Creates a replacement function that validates for ambiguity and handles backreferences.
:param regex_pattern: The regex pattern being used for matching
:param repl_template: The replacement template with $!1, $!2, etc. for backreferences
:param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE)
:return: A function suitable for use with re.sub() or re.subn()
"""
def validate_and_replace(match: re.Match) -> str:
matched_text = match.group(0)
# For multi-line match, check if the same pattern matches again within the already-matched text,
# rendering the match ambiguous. Typical pattern in the code:
# <start><other-stuff><start><stuff><end>
# When matching
# <start>.*?<end>
# this will match the entire span above, while only the suffix may have been intended.
# (See test case for a practical example.)
# To detect this, we check if the same pattern matches again within the matched text,
if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags):
raise ValueError(
"Match is ambiguous: the search pattern matches multiple overlapping occurrences. "
"Please revise the search pattern to be more specific to avoid ambiguity."
)
# Handle backreferences: replace $!1, $!2, etc. with actual matched groups
def expand_backreference(m: re.Match) -> str:
group_num = int(m.group(1))
group_value = match.group(group_num)
return group_value if group_value is not None else m.group(0)
result = re.sub(r"\$!(\d+)", expand_backreference, repl_template)
return result
return validate_and_replace
def replace_content(
self,
relative_path: str,
@@ -226,19 +266,19 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
else:
raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.")
# escape backslashes in repl
repl = repl.replace("\\", "\\\\")
# convert $!1, $!2, etc. to \1, \2, etc. for backreferences
repl = re.sub(r"\$!(\d+)", "\\\\1", repl)
regex_flags = re.DOTALL | re.MULTILINE
# create replacement function with validation and backreference handling
repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags)
# perform replacement
updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE)
updated_content, n = re.subn(regex, repl_fn, original_content, flags=regex_flags)
if n == 0:
return f"Error: No matches of search expression found in file '{relative_path}'."
raise ValueError(f"Error: No matches of search expression found in file '{relative_path}'.")
if not allow_multiple_occurrences and n > 1:
return (
f"Error: Expression matches {n} occurrences in file '{relative_path}'. "
raise ValueError(
f"Expression matches {n} occurrences in file '{relative_path}'. "
"Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected."
)
context.set_updated_content(updated_content)
+22
View File
@@ -0,0 +1,22 @@
import os
import platform
def system_has_usable_display() -> bool:
system = platform.system()
# macOS and native Windows: assume display is available for desktop usage
if system == "Darwin" or system == "Windows":
return True
# Other systems, assumed to be Unix-like (Linux, FreeBSD, Cygwin/MSYS, etc.):
# detect display availability since users may operate in CLI contexts
else:
# Check X11 or Wayland - if environment variables are set to non-empty values, assume display is usable
display = os.environ.get("DISPLAY", "")
wayland_display = os.environ.get("WAYLAND_DISPLAY", "")
if display or wayland_display:
return True
return False
@@ -0,0 +1,74 @@
/**
* Dummy WebSocket manager class for testing ambiguous regex matching.
*/
class WebSocketManager {
constructor() {
console.log("WebSocketManager initializing\nStatus OK");
this.ws = null;
this.statusElement = document.getElementById("status");
}
/**
* Connects to the WebSocket server.
*/
connectToServer() {
if (this.ws?.readyState === WebSocket.OPEN) {
this.updateConnectionStatus("Already connected", true);
return;
}
try {
this.ws = new WebSocket("ws://localhost:4402");
this.updateConnectionStatus("Connecting...", false);
this.ws.onopen = () => {
console.log("Connected to server");
this.updateConnectionStatus("Connected", true);
};
this.ws.onmessage = (event) => {
console.log("Received message:", event.data);
try {
const data = JSON.parse(event.data);
this.handleMessage(data);
} catch (error) {
console.error("Failed to parse message:", error);
this.updateConnectionStatus("Parse error", false);
}
};
this.ws.onclose = (event) => {
console.log("Connection closed");
const message = event.reason || undefined;
this.updateConnectionStatus("Disconnected", false, message);
this.ws = null;
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
this.updateConnectionStatus("Connection error", false);
};
} catch (error) {
console.error("Failed to connect to server:", error);
this.updateConnectionStatus("Connection failed", false);
}
}
/**
* Updates the connection status display.
*/
updateConnectionStatus(status, isConnected, message) {
if (this.statusElement) {
const text = message ? `${status}: ${message}` : status;
this.statusElement.textContent = text;
this.statusElement.style.color = isConnected ? "green" : "red";
}
}
/**
* Handles incoming messages.
*/
handleMessage(data) {
console.log("Handling:", data);
}
}
+81 -1
View File
@@ -4,11 +4,12 @@ import os
import shutil
import tempfile
import time
from pathlib import Path
import pytest
from click.testing import CliRunner
from serena.cli import ProjectCommands
from serena.cli import ProjectCommands, TopLevelCommands, find_project_root
from serena.config.serena_config import ProjectConfig
pytestmark = pytest.mark.filterwarnings("ignore::UserWarning")
@@ -262,6 +263,85 @@ class TestProjectCreateHelper:
ProjectCommands._create_project(temp_project_dir, None, ("python",))
class TestFindProjectRoot:
"""Tests for find_project_root helper with virtual chroot boundary."""
def test_finds_serena_from_subdirectory(self, temp_project_dir):
"""Test that .serena/project.yml is found when searching from a subdirectory."""
serena_dir = os.path.join(temp_project_dir, ".serena")
os.makedirs(serena_dir)
Path(os.path.join(serena_dir, "project.yml")).touch()
subdir = os.path.join(temp_project_dir, "src", "nested")
os.makedirs(subdir)
original_cwd = os.getcwd()
try:
os.chdir(subdir)
result = find_project_root(root=temp_project_dir)
assert result is not None
assert os.path.samefile(result, temp_project_dir)
finally:
os.chdir(original_cwd)
def test_serena_preferred_over_git(self, temp_project_dir):
"""Test that .serena/project.yml takes priority over .git at the same level."""
serena_dir = os.path.join(temp_project_dir, ".serena")
os.makedirs(serena_dir)
Path(os.path.join(serena_dir, "project.yml")).touch()
os.makedirs(os.path.join(temp_project_dir, ".git"))
original_cwd = os.getcwd()
try:
os.chdir(temp_project_dir)
result = find_project_root(root=temp_project_dir)
assert result is not None
assert os.path.isdir(os.path.join(result, ".serena"))
assert os.path.samefile(result, temp_project_dir)
finally:
os.chdir(original_cwd)
def test_git_used_as_fallback(self, temp_project_dir):
"""Test that .git is found when no .serena exists."""
os.makedirs(os.path.join(temp_project_dir, ".git"))
subdir = os.path.join(temp_project_dir, "src")
os.makedirs(subdir)
original_cwd = os.getcwd()
try:
os.chdir(subdir)
result = find_project_root(root=temp_project_dir)
assert result is not None
assert os.path.samefile(result, temp_project_dir)
finally:
os.chdir(original_cwd)
def test_falls_back_to_cwd_when_no_markers(self, temp_project_dir):
"""Test falls back to CWD when no markers exist within boundary."""
subdir = os.path.join(temp_project_dir, "src")
os.makedirs(subdir)
original_cwd = os.getcwd()
try:
os.chdir(subdir)
result = find_project_root(root=temp_project_dir)
assert os.path.samefile(result, subdir)
finally:
os.chdir(original_cwd)
class TestProjectFromCwdMutualExclusivity:
"""Tests for --project-from-cwd mutual exclusivity."""
def test_project_from_cwd_with_project_flag_fails(self, cli_runner):
"""Test that --project-from-cwd with --project raises error."""
result = cli_runner.invoke(
TopLevelCommands.start_mcp_server,
["--project-from-cwd", "--project", "/some/path"],
)
assert result.exit_code != 0
assert "cannot be used with" in result.output
if __name__ == "__main__":
# For manual testing, you can run this file directly:
# uv run pytest test/serena/test_cli_project_commands.py -v
+111 -2
View File
@@ -1,15 +1,18 @@
import json
import logging
import os
import re
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Literal
import pytest
from serena.agent import SerenaAgent
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, ReplaceSymbolBodyTool
from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool
from solidlsp.ls_config import Language
from test.conftest import get_repo_path, language_tests_enabled
from test.solidlsp import clojure as clj
@@ -56,6 +59,30 @@ def serena_config():
return config
def read_project_file(project: Project, relative_path: str) -> str:
"""Utility function to read a file from the project."""
file_path = os.path.join(project.project_root, relative_path)
with open(file_path, encoding=project.project_config.encoding) as f:
return f.read()
@contextmanager
def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]:
"""Context manager to modify a project file and revert the changes after use."""
project = serena_agent.get_active_project()
file_path = os.path.join(project.project_root, relative_path)
# Read the original content
original_content = read_project_file(project, relative_path)
try:
yield
finally:
# Revert to the original content
with open(file_path, "w", encoding=project.project_config.encoding) as f:
f.write(original_content)
@pytest.fixture
def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[SerenaAgent]:
language = Language(request.param)
@@ -70,7 +97,8 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere
agent.execute_task(lambda: None)
yield agent
# explicitly delete to free resources
# explicitly shut down to free resources
agent.shutdown(timeout=5)
@@ -355,3 +383,84 @@ class TestSerenaAgent:
replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool)
with pytest.raises(ValueError, match=match_text):
replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="")
@pytest.mark.parametrize(
"serena_agent",
[
pytest.param(
Language.TYPESCRIPT,
marks=pytest.mark.typescript,
),
],
indirect=["serena_agent"],
)
def test_replace_content_regex_with_wildcard_ok(self, serena_agent: SerenaAgent):
"""
Tests a regex-based content replacement that has a unique match
"""
relative_path = "ws_manager.js"
with project_file_modification_context(serena_agent, relative_path):
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
result = replace_content_tool.apply(
needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}',
repl='catch(error) { console.log("Never mind"); }',
relative_path=relative_path,
mode="regex",
)
assert result == SUCCESS_RESULT
@pytest.mark.parametrize(
"serena_agent",
[
pytest.param(
Language.TYPESCRIPT,
marks=pytest.mark.typescript,
),
],
indirect=["serena_agent"],
)
@pytest.mark.parametrize("mode", ["literal", "regex"])
def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]):
"""
Tests a content replacement where the needle and replacement strings contain backslashes.
This is a regression test for escaping issues.
"""
relative_path = "ws_manager.js"
needle = r'console.log("WebSocketManager initializing\nStatus OK");'
repl = r'console.log("WebSocketManager initialized\nAll systems go!");'
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
mode: Literal["literal", "regex"]
with project_file_modification_context(serena_agent, relative_path):
result = replace_content_tool.apply(
needle=re.escape(needle) if mode == "regex" else needle,
repl=repl,
relative_path=relative_path,
mode=mode,
)
assert result == SUCCESS_RESULT
new_content = read_project_file(serena_agent.get_active_project(), relative_path)
assert repl in new_content
@pytest.mark.parametrize(
"serena_agent",
[
pytest.param(
Language.TYPESCRIPT,
marks=pytest.mark.typescript,
),
],
indirect=["serena_agent"],
)
def test_replace_content_regex_with_wildcard_ambiguous(self, serena_agent: SerenaAgent):
"""
Tests that an ambiguous replacement where there is a larger match that internally contains
a smaller match triggers an exception
"""
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
with pytest.raises(ValueError, match="ambiguous"):
replace_content_tool.apply(
needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}',
repl='catch(error) { console.log("Never mind"); }',
relative_path="ws_manager.js",
mode="regex",
)