diff --git a/pyproject.toml b/pyproject.toml index 1f2c122e..6f353f7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -253,6 +253,7 @@ ignore = [ "C400", # wants to unnecessarily force use of list comprehension "UP037", # can incorrectly (!) convert quoted type to unquoted type, causing an error "UP045", # imposes T | None instead of Optional[T] + "UP031", # forbids % operator to format strings ] unfixable = ["F841", "F601", "F602", "B018"] extend-fixable = ["F401", "B905", "W291"] diff --git a/src/serena/cli.py b/src/serena/cli.py index 2a38ed5c..dc47d9b4 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -441,8 +441,11 @@ class ProjectCommands(AutoRegisteringGroup): raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}") generated_conf = ProjectConfig.autogenerate( - project_root=project_path, project_name=name, languages=languages if languages else None + project_root=project_path, project_name=name, languages=languages if languages else None, interactive=True ) + yml_path = ProjectConfig.path_to_project_yml(project_path) + languages_str = ", ".join([lang.value for lang in generated_conf.languages]) if generated_conf.languages else "N/A" + click.echo(f"Generated project with languages {{{languages_str}}} at {yml_path}.") return generated_conf @staticmethod @@ -462,10 +465,7 @@ class ProjectCommands(AutoRegisteringGroup): @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}.") - + ProjectCommands._create_project(project_path, name, language) if index: click.echo("Indexing project...") ProjectCommands._index_project(project_path, log_level, timeout=timeout) @@ -497,8 +497,7 @@ class ProjectCommands(AutoRegisteringGroup): 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}.") + ProjectCommands._create_project(project, name, language) except FileExistsError: # Race condition - file was created between check and creation pass diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index e1fc8ae3..c5345d48 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -34,6 +34,7 @@ from solidlsp.ls_config import Language from ..analytics import RegisteredTokenCountEstimator from ..util.class_decorators import singleton +from ..util.cli_util import ask_yes_no if TYPE_CHECKING: from ..project import Project @@ -113,7 +114,12 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): @classmethod def autogenerate( - cls, project_root: str | Path, project_name: str | None = None, languages: list[Language] | None = None, save_to_disk: bool = True + cls, + project_root: str | Path, + project_name: str | None = None, + languages: list[Language] | None = None, + save_to_disk: bool = True, + interactive: bool = False, ) -> Self: """ Autogenerate a project configuration for a given project root. @@ -123,6 +129,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): containing the project :param languages: the languages of the project; if None, they will be determined automatically :param save_to_disk: whether to save the project configuration to disk + :param interactive: whether to run in interactive CLI mode, asking the user for input where appropriate :return: the project configuration """ project_root = Path(project_root).resolve() @@ -131,30 +138,53 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): with LogTime("Project configuration auto-generation", logger=log): project_name = project_name or project_root.name if languages is None: + # determine languages automatically language_composition = determine_programming_language_composition(str(project_root)) if len(language_composition) == 0: + language_values = ", ".join([lang.value for lang in Language]) raise ValueError( f"No source files found in {project_root}\n\n" - f"To use Serena with this project, you need to either:\n" - f"1. Add source files in one of the supported languages (Python, JavaScript/TypeScript, Java, C#, Rust, Go, Ruby, C++, PHP, Swift, Elixir, Terraform, Bash)\n" - f"2. Create a project configuration file manually at:\n" - f" {os.path.join(project_root, cls.rel_path_to_project_yml())}\n\n" - f"Example project.yml:\n" - f" project_name: {project_name}\n" - f" language: python # or typescript, java, csharp, rust, go, ruby, cpp, php, swift, elixir, terraform, bash\n" + f"To use Serena with this project, you need to either\n" + f" 1. specify a programming language by adding parameters --language \n" + f" when creating the project via the Serena CLI command OR\n" + f" 2. add source files in one of the supported languages first.\n\n" + f"Supported languages are: {language_values}\n" + f"Read the documentation for more information." ) - # find the language with the highest percentage - dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) - languages_to_use = [dominant_language] + # sort languages by number of files found + languages_and_percentages = sorted(language_composition.items(), key=lambda item: item[1], reverse=True) + # find the language with the highest percentage and enable it + top_language_pair = languages_and_percentages[0] + other_language_pairs = languages_and_percentages[1:] + languages_to_use = [top_language_pair[0]] + # if in interactive mode, ask the user which other languages to enable + if len(other_language_pairs) > 0 and interactive: + print( + "Detected and enabled main language '%s' (%.2f%% of source files)." % (top_language_pair[0], top_language_pair[1]) + ) + print(f"Additionally detected {len(other_language_pairs)} other language(s).\n") + print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.") + print(" Additional language servers use resources and some languages may require additional") + print(" system-level installations/configuration (see Serena documentation).") + print("\nWhich additional languages do you want to enable?") + for lang, perc in other_language_pairs: + enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang, perc), default=False) + if enable: + languages_to_use.append(lang) + print() else: languages_to_use = [lang.value for lang in languages] config_with_comments = cls.load_commented_map(PROJECT_TEMPLATE_FILE) config_with_comments["project_name"] = project_name config_with_comments["languages"] = languages_to_use if save_to_disk: - save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) + save_yaml(cls.path_to_project_yml(project_root), config_with_comments, preserve_comments=True) return cls._from_dict(config_with_comments) + @classmethod + def path_to_project_yml(cls, project_root: str | Path) -> str: + return os.path.join(project_root, cls.rel_path_to_project_yml()) + @classmethod def rel_path_to_project_yml(cls) -> str: return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) diff --git a/src/serena/util/cli_util.py b/src/serena/util/cli_util.py new file mode 100644 index 00000000..66f725bb --- /dev/null +++ b/src/serena/util/cli_util.py @@ -0,0 +1,12 @@ +def ask_yes_no(question: str, default: bool | None = None) -> bool: + default_prompt = "Y/n" if default else "y/N" + + while True: + answer = input(f"{question} [{default_prompt}] ").strip().lower() + if answer == "" and default is not None: + return default + if answer in ("y", "yes"): + return True + if answer in ("n", "no"): + return False + print("Please answer yes/y or no/n.") diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index adf64670..ccdf79a3 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -57,7 +57,7 @@ class TestProjectCreate: """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 "Generated project" in result.output assert "python" in result.output.lower() # Verify project.yml was created @@ -68,7 +68,7 @@ class TestProjectCreate: """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 "Generated project" in result.output assert "python" in result.output.lower() # Verify project.yml was created @@ -79,7 +79,7 @@ class TestProjectCreate: """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 + assert "Generated project" in result.output # Verify project.yml was created yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") @@ -89,7 +89,7 @@ class TestProjectCreate: """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 "Generated project" in result.output assert "python" in result.output.lower() def test_create_with_multiple_languages(self, cli_runner, temp_project_dir): @@ -99,7 +99,7 @@ class TestProjectCreate: [temp_project_dir, "--language", "python", "--language", "typescript"], ) assert result.exit_code == 0, f"Command failed: {result.output}" - assert "Generated project.yml" in result.output + assert "Generated project" in result.output def test_create_with_invalid_language(self, cli_runner, temp_project_dir): """Test project creation with invalid language raises error.""" @@ -129,7 +129,7 @@ class TestProjectCreate: [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 "Generated project" in result.output assert "Indexing project" in result.output # Verify project.yml was created @@ -144,7 +144,7 @@ class TestProjectCreate: """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 "Generated project" in result.output assert "Indexing" not in result.output # Verify cache directory was NOT created