diff --git a/.gitignore b/.gitignore index 27086cc8..89bbe0b2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000..14d86ad6 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index 1af40b13..f248a4c7 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -178,9 +178,12 @@ Here are some examples of commands you might find useful: tools description find_symbol # creating a new Serena project in the current directory - project generate-yml + project create -# indexing the project in the current directory +# creating and immediately indexing a project + project create --index + +# indexing the project in the current directory (auto-creates if needed) project index # run a health check on the project in the current directory diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index aca2143a..35afb55e 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -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: - project generate-yml [options] + 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 ` diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index bf1a85f7..341c53d7 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -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 diff --git a/flake.nix b/flake.nix index 7529365b..e311aa0d 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; diff --git a/src/serena/cli.py b/src/serena/cli.py index 9fa88c9c..07062f2d 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -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) diff --git a/src/serena/project.py b/src/serena/project.py index c1227523..3cd5eb08 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -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." diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 6a7479bf..1d091535 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -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) diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py index f005c8af..c6e2b93b 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -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) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index d5531fd2..09b2570d 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -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 diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index c87f97f4..296829ff 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -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", diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 1986c062..5c7a6fb5 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -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)}], } diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index dfac4eb5..aabcb583 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -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 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 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: diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 06ba0285..d4bfa12b 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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 diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py index 66ecadf2..dff5df9a 100644 --- a/src/solidlsp/settings.py +++ b/src/solidlsp/settings.py @@ -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)) diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py new file mode 100644 index 00000000..adf64670 --- /dev/null +++ b/test/serena/test_cli_project_commands.py @@ -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"])