diff --git a/CHANGELOG.md b/CHANGELOG.md index b4497495..e6228cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index e6126b32..91b804e6 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -157,6 +157,7 @@ to get a list of all available options. Some useful options include: * `--project `: 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 `: specify the operation [context](contexts) in which Serena shall operate * `--mode `: specify one or more [modes](modes) to enable (can be passed several times) * `--enable-web-dashboard `: enable or disable the web dashboard (enabled by default) @@ -234,4 +235,4 @@ Here are some examples of commands you might find useful: prompts edit-override prompt-name ``` -Explore the full set of commands and options using the CLI itself! \ No newline at end of file +Explore the full set of commands and options using the CLI itself! diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 74b84a00..b474100a 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -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 -- 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 diff --git a/scripts/profile_tool_call.py b/scripts/profile_tool_call.py index e0e69981..8fe922a5 100644 --- a/scripts/profile_tool_call.py +++ b/scripts/profile_tool_call.py @@ -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 diff --git a/src/serena/agent.py b/src/serena/agent.py index e596dd1c..ca763b87 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -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}") diff --git a/src/serena/cli.py b/src/serena/cli.py index e7cca2be..bc4af3e6 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -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, diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 6cc94350..f1f34fd9 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -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: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 06e1c6d1..5c3b26fd 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -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) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 9002c3bf..55d8b87b 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -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, ) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 24c9bd38..f4e6646f 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -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) diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 5a5d587e..d197991d 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -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 ". -# 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: [] diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index ea52e9eb..09e7a0e3 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -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: + # + # When matching + # .*? + # 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) diff --git a/src/serena/util/gui.py b/src/serena/util/gui.py new file mode 100644 index 00000000..f87236d4 --- /dev/null +++ b/src/serena/util/gui.py @@ -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 diff --git a/test/resources/repos/typescript/test_repo/ws_manager.js b/test/resources/repos/typescript/test_repo/ws_manager.js new file mode 100644 index 00000000..60173214 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/ws_manager.js @@ -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); + } +} \ No newline at end of file diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index ccdf79a3..893299e6 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -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 diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 441cdf53..761affc2 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -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", + )