This is page 2 of 14. Use http://codebase.md/oraios/serena?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .devcontainer │ └── devcontainer.json ├── .dockerignore ├── .env.example ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── config.yml │ │ ├── feature_request.md │ │ └── issue--bug--performance-problem--question-.md │ └── workflows │ ├── codespell.yml │ ├── docker.yml │ ├── junie.yml │ ├── lint_and_docs.yaml │ ├── publish.yml │ └── pytest.yml ├── .gitignore ├── .serena │ ├── memories │ │ ├── adding_new_language_support_guide.md │ │ ├── serena_core_concepts_and_architecture.md │ │ ├── serena_repository_structure.md │ │ └── suggested_commands.md │ └── project.yml ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── CLAUDE.md ├── compose.yaml ├── CONTRIBUTING.md ├── docker_build_and_run.sh ├── DOCKER.md ├── Dockerfile ├── docs │ ├── custom_agent.md │ └── serena_on_chatgpt.md ├── flake.lock ├── flake.nix ├── lessons_learned.md ├── LICENSE ├── llms-install.md ├── public │ └── .gitignore ├── pyproject.toml ├── README.md ├── resources │ ├── serena-icons.cdr │ ├── serena-logo-dark-mode.svg │ ├── serena-logo.cdr │ ├── serena-logo.svg │ └── vscode_sponsor_logo.png ├── roadmap.md ├── scripts │ ├── agno_agent.py │ ├── demo_run_tools.py │ ├── gen_prompt_factory.py │ ├── mcp_server.py │ ├── print_mode_context_options.py │ └── print_tool_overview.py ├── src │ ├── interprompt │ │ ├── __init__.py │ │ ├── .syncCommitId.remote │ │ ├── .syncCommitId.this │ │ ├── jinja_template.py │ │ ├── multilang_prompt.py │ │ ├── prompt_factory.py │ │ └── util │ │ ├── __init__.py │ │ └── class_decorators.py │ ├── README.md │ ├── serena │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── agno.py │ │ ├── analytics.py │ │ ├── cli.py │ │ ├── code_editor.py │ │ ├── config │ │ │ ├── __init__.py │ │ │ ├── context_mode.py │ │ │ └── serena_config.py │ │ ├── constants.py │ │ ├── dashboard.py │ │ ├── generated │ │ │ └── generated_prompt_factory.py │ │ ├── gui_log_viewer.py │ │ ├── mcp.py │ │ ├── project.py │ │ ├── prompt_factory.py │ │ ├── resources │ │ │ ├── config │ │ │ │ ├── contexts │ │ │ │ │ ├── agent.yml │ │ │ │ │ ├── chatgpt.yml │ │ │ │ │ ├── codex.yml │ │ │ │ │ ├── context.template.yml │ │ │ │ │ ├── desktop-app.yml │ │ │ │ │ ├── ide-assistant.yml │ │ │ │ │ └── oaicompat-agent.yml │ │ │ │ ├── internal_modes │ │ │ │ │ └── jetbrains.yml │ │ │ │ ├── modes │ │ │ │ │ ├── editing.yml │ │ │ │ │ ├── interactive.yml │ │ │ │ │ ├── mode.template.yml │ │ │ │ │ ├── no-onboarding.yml │ │ │ │ │ ├── onboarding.yml │ │ │ │ │ ├── one-shot.yml │ │ │ │ │ └── planning.yml │ │ │ │ └── prompt_templates │ │ │ │ ├── simple_tool_outputs.yml │ │ │ │ └── system_prompt.yml │ │ │ ├── dashboard │ │ │ │ ├── dashboard.js │ │ │ │ ├── index.html │ │ │ │ ├── jquery.min.js │ │ │ │ ├── serena-icon-16.png │ │ │ │ ├── serena-icon-32.png │ │ │ │ ├── serena-icon-48.png │ │ │ │ ├── serena-logs-dark-mode.png │ │ │ │ └── serena-logs.png │ │ │ ├── project.template.yml │ │ │ └── serena_config.template.yml │ │ ├── symbol.py │ │ ├── text_utils.py │ │ ├── tools │ │ │ ├── __init__.py │ │ │ ├── cmd_tools.py │ │ │ ├── config_tools.py │ │ │ ├── file_tools.py │ │ │ ├── jetbrains_plugin_client.py │ │ │ ├── jetbrains_tools.py │ │ │ ├── memory_tools.py │ │ │ ├── symbol_tools.py │ │ │ ├── tools_base.py │ │ │ └── workflow_tools.py │ │ └── util │ │ ├── class_decorators.py │ │ ├── exception.py │ │ ├── file_system.py │ │ ├── general.py │ │ ├── git.py │ │ ├── inspection.py │ │ ├── logging.py │ │ ├── shell.py │ │ └── thread.py │ └── solidlsp │ ├── __init__.py │ ├── .gitignore │ ├── language_servers │ │ ├── al_language_server.py │ │ ├── bash_language_server.py │ │ ├── clangd_language_server.py │ │ ├── clojure_lsp.py │ │ ├── common.py │ │ ├── csharp_language_server.py │ │ ├── dart_language_server.py │ │ ├── eclipse_jdtls.py │ │ ├── elixir_tools │ │ │ ├── __init__.py │ │ │ ├── elixir_tools.py │ │ │ └── README.md │ │ ├── elm_language_server.py │ │ ├── erlang_language_server.py │ │ ├── gopls.py │ │ ├── intelephense.py │ │ ├── jedi_server.py │ │ ├── kotlin_language_server.py │ │ ├── lua_ls.py │ │ ├── marksman.py │ │ ├── nixd_ls.py │ │ ├── omnisharp │ │ │ ├── initialize_params.json │ │ │ ├── runtime_dependencies.json │ │ │ └── workspace_did_change_configuration.json │ │ ├── omnisharp.py │ │ ├── perl_language_server.py │ │ ├── pyright_server.py │ │ ├── r_language_server.py │ │ ├── ruby_lsp.py │ │ ├── rust_analyzer.py │ │ ├── solargraph.py │ │ ├── sourcekit_lsp.py │ │ ├── terraform_ls.py │ │ ├── typescript_language_server.py │ │ ├── vts_language_server.py │ │ └── zls.py │ ├── ls_config.py │ ├── ls_exceptions.py │ ├── ls_handler.py │ ├── ls_logger.py │ ├── ls_request.py │ ├── ls_types.py │ ├── ls_utils.py │ ├── ls.py │ ├── lsp_protocol_handler │ │ ├── lsp_constants.py │ │ ├── lsp_requests.py │ │ ├── lsp_types.py │ │ └── server.py │ ├── settings.py │ └── util │ ├── subprocess_util.py │ └── zip.py ├── test │ ├── __init__.py │ ├── conftest.py │ ├── resources │ │ └── repos │ │ ├── al │ │ │ └── test_repo │ │ │ ├── app.json │ │ │ └── src │ │ │ ├── Codeunits │ │ │ │ ├── CustomerMgt.Codeunit.al │ │ │ │ └── PaymentProcessorImpl.Codeunit.al │ │ │ ├── Enums │ │ │ │ └── CustomerType.Enum.al │ │ │ ├── Interfaces │ │ │ │ └── IPaymentProcessor.Interface.al │ │ │ ├── Pages │ │ │ │ ├── CustomerCard.Page.al │ │ │ │ └── CustomerList.Page.al │ │ │ ├── TableExtensions │ │ │ │ └── Item.TableExt.al │ │ │ └── Tables │ │ │ └── Customer.Table.al │ │ ├── bash │ │ │ └── test_repo │ │ │ ├── config.sh │ │ │ ├── main.sh │ │ │ └── utils.sh │ │ ├── clojure │ │ │ └── test_repo │ │ │ ├── deps.edn │ │ │ └── src │ │ │ └── test_app │ │ │ ├── core.clj │ │ │ └── utils.clj │ │ ├── csharp │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── Models │ │ │ │ └── Person.cs │ │ │ ├── Program.cs │ │ │ ├── serena.sln │ │ │ └── TestProject.csproj │ │ ├── dart │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── helper.dart │ │ │ │ ├── main.dart │ │ │ │ └── models.dart │ │ │ └── pubspec.yaml │ │ ├── elixir │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── examples.ex │ │ │ │ ├── ignored_dir │ │ │ │ │ └── ignored_module.ex │ │ │ │ ├── models.ex │ │ │ │ ├── services.ex │ │ │ │ ├── test_repo.ex │ │ │ │ └── utils.ex │ │ │ ├── mix.exs │ │ │ ├── mix.lock │ │ │ ├── scripts │ │ │ │ └── build_script.ex │ │ │ └── test │ │ │ ├── models_test.exs │ │ │ └── test_repo_test.exs │ │ ├── elm │ │ │ └── test_repo │ │ │ ├── elm.json │ │ │ ├── Main.elm │ │ │ └── Utils.elm │ │ ├── erlang │ │ │ └── test_repo │ │ │ ├── hello.erl │ │ │ ├── ignored_dir │ │ │ │ └── ignored_module.erl │ │ │ ├── include │ │ │ │ ├── records.hrl │ │ │ │ └── types.hrl │ │ │ ├── math_utils.erl │ │ │ ├── rebar.config │ │ │ ├── src │ │ │ │ ├── app.erl │ │ │ │ ├── models.erl │ │ │ │ ├── services.erl │ │ │ │ └── utils.erl │ │ │ └── test │ │ │ ├── models_tests.erl │ │ │ └── utils_tests.erl │ │ ├── go │ │ │ └── test_repo │ │ │ └── main.go │ │ ├── java │ │ │ └── test_repo │ │ │ ├── pom.xml │ │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── test_repo │ │ │ ├── Main.java │ │ │ ├── Model.java │ │ │ ├── ModelUser.java │ │ │ └── Utils.java │ │ ├── kotlin │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ └── kotlin │ │ │ └── test_repo │ │ │ ├── Main.kt │ │ │ ├── Model.kt │ │ │ ├── ModelUser.kt │ │ │ └── Utils.kt │ │ ├── lua │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── main.lua │ │ │ ├── src │ │ │ │ ├── calculator.lua │ │ │ │ └── utils.lua │ │ │ └── tests │ │ │ └── test_calculator.lua │ │ ├── markdown │ │ │ └── test_repo │ │ │ ├── api.md │ │ │ ├── CONTRIBUTING.md │ │ │ ├── guide.md │ │ │ └── README.md │ │ ├── nix │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── default.nix │ │ │ ├── flake.nix │ │ │ ├── lib │ │ │ │ └── utils.nix │ │ │ ├── modules │ │ │ │ └── example.nix │ │ │ └── scripts │ │ │ └── hello.sh │ │ ├── perl │ │ │ └── test_repo │ │ │ ├── helper.pl │ │ │ └── main.pl │ │ ├── php │ │ │ └── test_repo │ │ │ ├── helper.php │ │ │ ├── index.php │ │ │ └── simple_var.php │ │ ├── python │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── custom_test │ │ │ │ ├── __init__.py │ │ │ │ └── advanced_features.py │ │ │ ├── examples │ │ │ │ ├── __init__.py │ │ │ │ └── user_management.py │ │ │ ├── ignore_this_dir_with_postfix │ │ │ │ └── ignored_module.py │ │ │ ├── scripts │ │ │ │ ├── __init__.py │ │ │ │ └── run_app.py │ │ │ └── test_repo │ │ │ ├── __init__.py │ │ │ ├── complex_types.py │ │ │ ├── models.py │ │ │ ├── name_collisions.py │ │ │ ├── nested_base.py │ │ │ ├── nested.py │ │ │ ├── overloaded.py │ │ │ ├── services.py │ │ │ ├── utils.py │ │ │ └── variables.py │ │ ├── r │ │ │ └── test_repo │ │ │ ├── .Rbuildignore │ │ │ ├── DESCRIPTION │ │ │ ├── examples │ │ │ │ └── analysis.R │ │ │ ├── NAMESPACE │ │ │ └── R │ │ │ ├── models.R │ │ │ └── utils.R │ │ ├── ruby │ │ │ └── test_repo │ │ │ ├── .solargraph.yml │ │ │ ├── examples │ │ │ │ └── user_management.rb │ │ │ ├── lib.rb │ │ │ ├── main.rb │ │ │ ├── models.rb │ │ │ ├── nested.rb │ │ │ ├── services.rb │ │ │ └── variables.rb │ │ ├── rust │ │ │ ├── test_repo │ │ │ │ ├── Cargo.lock │ │ │ │ ├── Cargo.toml │ │ │ │ └── src │ │ │ │ ├── lib.rs │ │ │ │ └── main.rs │ │ │ └── test_repo_2024 │ │ │ ├── Cargo.lock │ │ │ ├── Cargo.toml │ │ │ └── src │ │ │ ├── lib.rs │ │ │ └── main.rs │ │ ├── swift │ │ │ └── test_repo │ │ │ ├── Package.swift │ │ │ └── src │ │ │ ├── main.swift │ │ │ └── utils.swift │ │ ├── terraform │ │ │ └── test_repo │ │ │ ├── data.tf │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── typescript │ │ │ └── test_repo │ │ │ ├── .serena │ │ │ │ └── project.yml │ │ │ ├── index.ts │ │ │ ├── tsconfig.json │ │ │ └── use_helper.ts │ │ └── zig │ │ └── test_repo │ │ ├── .gitignore │ │ ├── build.zig │ │ ├── src │ │ │ ├── calculator.zig │ │ │ ├── main.zig │ │ │ └── math_utils.zig │ │ └── zls.json │ ├── serena │ │ ├── __init__.py │ │ ├── __snapshots__ │ │ │ └── test_symbol_editing.ambr │ │ ├── config │ │ │ ├── __init__.py │ │ │ └── test_serena_config.py │ │ ├── test_edit_marker.py │ │ ├── test_mcp.py │ │ ├── test_serena_agent.py │ │ ├── test_symbol_editing.py │ │ ├── test_symbol.py │ │ ├── test_text_utils.py │ │ ├── test_tool_parameter_types.py │ │ └── util │ │ ├── test_exception.py │ │ └── test_file_system.py │ └── solidlsp │ ├── al │ │ └── test_al_basic.py │ ├── bash │ │ ├── __init__.py │ │ └── test_bash_basic.py │ ├── clojure │ │ ├── __init__.py │ │ └── test_clojure_basic.py │ ├── csharp │ │ └── test_csharp_basic.py │ ├── dart │ │ ├── __init__.py │ │ └── test_dart_basic.py │ ├── elixir │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_elixir_basic.py │ │ ├── test_elixir_ignored_dirs.py │ │ ├── test_elixir_integration.py │ │ └── test_elixir_symbol_retrieval.py │ ├── elm │ │ └── test_elm_basic.py │ ├── erlang │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_erlang_basic.py │ │ ├── test_erlang_ignored_dirs.py │ │ └── test_erlang_symbol_retrieval.py │ ├── go │ │ └── test_go_basic.py │ ├── java │ │ └── test_java_basic.py │ ├── kotlin │ │ └── test_kotlin_basic.py │ ├── lua │ │ └── test_lua_basic.py │ ├── markdown │ │ ├── __init__.py │ │ └── test_markdown_basic.py │ ├── nix │ │ └── test_nix_basic.py │ ├── perl │ │ └── test_perl_basic.py │ ├── php │ │ └── test_php_basic.py │ ├── python │ │ ├── test_python_basic.py │ │ ├── test_retrieval_with_ignored_dirs.py │ │ └── test_symbol_retrieval.py │ ├── r │ │ ├── __init__.py │ │ └── test_r_basic.py │ ├── ruby │ │ ├── test_ruby_basic.py │ │ └── test_ruby_symbol_retrieval.py │ ├── rust │ │ ├── test_rust_2024_edition.py │ │ └── test_rust_basic.py │ ├── swift │ │ └── test_swift_basic.py │ ├── terraform │ │ └── test_terraform_basic.py │ ├── typescript │ │ └── test_typescript_basic.py │ ├── util │ │ └── test_zip.py │ └── zig │ └── test_zig_basic.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/test_repo/nested_base.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Module to test parsing of classes with nested module paths in base classes. 3 | """ 4 | 5 | from typing import Generic, TypeVar 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class BaseModule: 11 | """Base module class for nested module tests.""" 12 | 13 | 14 | class SubModule: 15 | """Sub-module class for nested paths.""" 16 | 17 | class NestedBase: 18 | """Nested base class.""" 19 | 20 | def base_method(self): 21 | """Base method.""" 22 | return "base" 23 | 24 | class NestedLevel2: 25 | """Nested level 2.""" 26 | 27 | def nested_level_2_method(self): 28 | """Nested level 2 method.""" 29 | return "nested_level_2" 30 | 31 | class GenericBase(Generic[T]): 32 | """Generic nested base class.""" 33 | 34 | def generic_method(self, value: T) -> T: 35 | """Generic method.""" 36 | return value 37 | 38 | 39 | # Classes extending base classes with single-level nesting 40 | class FirstLevel(SubModule): 41 | """Class extending a class from a nested module path.""" 42 | 43 | def first_level_method(self): 44 | """First level method.""" 45 | return "first" 46 | 47 | 48 | # Classes extending base classes with multi-level nesting 49 | class TwoLevel(SubModule.NestedBase): 50 | """Class extending a doubly-nested base class.""" 51 | 52 | def multi_level_method(self): 53 | """Multi-level method.""" 54 | return "multi" 55 | 56 | def base_method(self): 57 | """Override of base method.""" 58 | return "overridden" 59 | 60 | 61 | class ThreeLevel(SubModule.NestedBase.NestedLevel2): 62 | """Class extending a triply-nested base class.""" 63 | 64 | def three_level_method(self): 65 | """Three-level method.""" 66 | return "three" 67 | 68 | 69 | # Class extending a generic base class with nesting 70 | class GenericExtension(SubModule.GenericBase[str]): 71 | """Class extending a generic nested base class.""" 72 | 73 | def generic_extension_method(self, text: str) -> str: 74 | """Extension method.""" 75 | return f"Extended: {text}" 76 | ``` -------------------------------------------------------------------------------- /src/serena/util/inspection.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | import os 3 | from collections.abc import Generator 4 | from typing import TypeVar 5 | 6 | from serena.util.file_system import find_all_non_ignored_files 7 | from solidlsp.ls_config import Language 8 | 9 | T = TypeVar("T") 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def iter_subclasses(cls: type[T], recursive: bool = True) -> Generator[type[T], None, None]: 15 | """Iterate over all subclasses of a class. If recursive is True, also iterate over all subclasses of all subclasses.""" 16 | for subclass in cls.__subclasses__(): 17 | yield subclass 18 | if recursive: 19 | yield from iter_subclasses(subclass, recursive) 20 | 21 | 22 | def determine_programming_language_composition(repo_path: str) -> dict[str, float]: 23 | """ 24 | Determine the programming language composition of a repository. 25 | 26 | :param repo_path: Path to the repository to analyze 27 | 28 | :return: Dictionary mapping language names to percentages of files matching each language 29 | """ 30 | all_files = find_all_non_ignored_files(repo_path) 31 | 32 | if not all_files: 33 | return {} 34 | 35 | # Count files for each language 36 | language_counts: dict[str, int] = {} 37 | total_files = len(all_files) 38 | 39 | for language in Language.iter_all(include_experimental=False): 40 | matcher = language.get_source_fn_matcher() 41 | count = 0 42 | 43 | for file_path in all_files: 44 | # Use just the filename for matching, not the full path 45 | filename = os.path.basename(file_path) 46 | if matcher.is_relevant_filename(filename): 47 | count += 1 48 | 49 | if count > 0: 50 | language_counts[str(language)] = count 51 | 52 | # Convert counts to percentages 53 | language_percentages: dict[str, float] = {} 54 | for language_name, count in language_counts.items(): 55 | percentage = (count / total_files) * 100 56 | language_percentages[language_name] = round(percentage, 2) 57 | 58 | return language_percentages 59 | ``` -------------------------------------------------------------------------------- /src/solidlsp/ls_logger.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Multilspy logger module. 3 | """ 4 | 5 | import inspect 6 | import logging 7 | from datetime import datetime 8 | 9 | from pydantic import BaseModel 10 | 11 | 12 | class LogLine(BaseModel): 13 | """ 14 | Represents a line in the Multilspy log 15 | """ 16 | 17 | time: str 18 | level: str 19 | caller_file: str 20 | caller_name: str 21 | caller_line: int 22 | message: str 23 | 24 | 25 | class LanguageServerLogger: 26 | """ 27 | Logger class 28 | """ 29 | 30 | def __init__(self, json_format: bool = False, log_level: int = logging.INFO) -> None: 31 | self.logger = logging.getLogger("solidlsp") 32 | self.logger.setLevel(log_level) 33 | self.json_format = json_format 34 | 35 | def log(self, debug_message: str, level: int, sanitized_error_message: str = "", stacklevel: int = 2) -> None: 36 | """ 37 | Log the debug and sanitized messages using the logger 38 | """ 39 | debug_message = debug_message.replace("'", '"').replace("\n", " ") 40 | sanitized_error_message = sanitized_error_message.replace("'", '"').replace("\n", " ") 41 | 42 | # Collect details about the callee 43 | curframe = inspect.currentframe() 44 | calframe = inspect.getouterframes(curframe, 2) 45 | caller_file = calframe[1][1].split("/")[-1] 46 | caller_line = calframe[1][2] 47 | caller_name = calframe[1][3] 48 | 49 | if self.json_format: 50 | # Construct the debug log line 51 | debug_log_line = LogLine( 52 | time=str(datetime.now().strftime("%Y-%m-%d %H:%M:%S")), 53 | level=logging.getLevelName(level), 54 | caller_file=caller_file, 55 | caller_name=caller_name, 56 | caller_line=caller_line, 57 | message=debug_message, 58 | ) 59 | 60 | self.logger.log( 61 | level=level, 62 | msg=debug_log_line.json(), 63 | stacklevel=stacklevel, 64 | ) 65 | else: 66 | self.logger.log(level, debug_message, stacklevel=stacklevel) 67 | ``` -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Build and Push Docker Images 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ 'v*' ] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 15 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Log in to Container Registry 28 | if: github.event_name != 'pull_request' 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Extract metadata 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 | tags: | 41 | type=ref,event=branch 42 | type=ref,event=pr 43 | type=semver,pattern={{version}} 44 | type=semver,pattern={{major}}.{{minor}} 45 | type=raw,value=latest,enable={{is_default_branch}} 46 | 47 | - name: Build and push production image 48 | uses: docker/build-push-action@v5 49 | with: 50 | context: . 51 | target: production 52 | platforms: linux/amd64,linux/arm64 53 | push: ${{ github.event_name != 'pull_request' }} 54 | tags: ${{ steps.meta.outputs.tags }} 55 | labels: ${{ steps.meta.outputs.labels }} 56 | cache-from: type=gha 57 | cache-to: type=gha,mode=max 58 | 59 | - name: Build and push development image 60 | uses: docker/build-push-action@v5 61 | with: 62 | context: . 63 | target: development 64 | platforms: linux/amd64,linux/arm64 65 | push: ${{ github.event_name != 'pull_request' }} 66 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev 67 | labels: ${{ steps.meta.outputs.labels }} 68 | cache-from: type=gha 69 | cache-to: type=gha,mode=max ``` -------------------------------------------------------------------------------- /src/solidlsp/lsp_protocol_handler/lsp_constants.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | This module contains constants used in the LSP protocol. 3 | """ 4 | 5 | 6 | class LSPConstants: 7 | """ 8 | This class contains constants used in the LSP protocol. 9 | """ 10 | 11 | # the key for uri used to represent paths 12 | URI = "uri" 13 | 14 | # the key for range, which is a from and to position within a text document 15 | RANGE = "range" 16 | 17 | # A key used in LocationLink type, used as the span of the origin link 18 | ORIGIN_SELECTION_RANGE = "originSelectionRange" 19 | 20 | # A key used in LocationLink type, used as the target uri of the link 21 | TARGET_URI = "targetUri" 22 | 23 | # A key used in LocationLink type, used as the target range of the link 24 | TARGET_RANGE = "targetRange" 25 | 26 | # A key used in LocationLink type, used as the target selection range of the link 27 | TARGET_SELECTION_RANGE = "targetSelectionRange" 28 | 29 | # key for the textDocument field in the request 30 | TEXT_DOCUMENT = "textDocument" 31 | 32 | # key used to represent the language a document is in - "java", "csharp", etc. 33 | LANGUAGE_ID = "languageId" 34 | 35 | # key used to represent the version of a document (a shared value between the client and server) 36 | VERSION = "version" 37 | 38 | # key used to represent the text of a document being sent from the client to the server on open 39 | TEXT = "text" 40 | 41 | # key used to represent a position (line and colnum) within a text document 42 | POSITION = "position" 43 | 44 | # key used to represent the line number of a position 45 | LINE = "line" 46 | 47 | # key used to represent the column number of a position 48 | CHARACTER = "character" 49 | 50 | # key used to represent the changes made to a document 51 | CONTENT_CHANGES = "contentChanges" 52 | 53 | # key used to represent name of symbols 54 | NAME = "name" 55 | 56 | # key used to represent the kind of symbols 57 | KIND = "kind" 58 | 59 | # key used to represent children in document symbols 60 | CHILDREN = "children" 61 | 62 | # key used to represent the location in symbols 63 | LOCATION = "location" 64 | 65 | # Severity level of the diagnostic 66 | SEVERITY = "severity" 67 | 68 | # The message of the diagnostic 69 | MESSAGE = "message" 70 | ``` -------------------------------------------------------------------------------- /src/serena/resources/config/contexts/chatgpt.yml: -------------------------------------------------------------------------------- ```yaml 1 | description: A configuration specific for chatgpt, which has a limit of 30 tools and requires short descriptions. 2 | prompt: | 3 | You are running in desktop app context where the tools give you access to the code base as well as some 4 | access to the file system, if configured. You interact with the user through a chat interface that is separated 5 | from the code base. As a consequence, if you are in interactive mode, your communication with the user should 6 | involve high-level thinking and planning as well as some summarization of any code edits that you make. 7 | For viewing the code edits the user will view them in a separate code editor window, and the back-and-forth 8 | between the chat and the code editor should be minimized as well as facilitated by you. 9 | If complex changes have been made, advise the user on how to review them in the code editor. 10 | If complex relationships that the user asked for should be visualized or explained, consider creating 11 | a diagram in addition to your text-based communication. Note that in the chat interface you have various rendering 12 | options for text, html, and mermaid diagrams, as has been explained to you in your initial instructions. 13 | excluded_tools: [] 14 | included_optional_tools: 15 | - switch_modes 16 | 17 | tool_description_overrides: 18 | find_symbol: | 19 | Retrieves symbols matching `name_path` in a file. 20 | Use `depth > 0` to include children. `name_path` can be: "foo": any symbol named "foo"; "foo/bar": "bar" within "foo"; "/foo/bar": only top-level "foo/bar" 21 | replace_regex: | 22 | Replaces text using regular expressions. Preferred for smaller edits where symbol-level tools aren't appropriate. 23 | Use wildcards (.*?) to match large sections efficiently: "beginning.*?end" instead of specifying exact content. 24 | Essential for multi-line replacements. 25 | search_for_pattern: | 26 | Flexible pattern search across codebase. Prefer symbolic operations when possible. 27 | Uses DOTALL matching. Use non-greedy quantifiers (.*?) to avoid over-matching. 28 | Supports file filtering via globs and code-only restriction. ``` -------------------------------------------------------------------------------- /src/serena/tools/cmd_tools.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tools supporting the execution of (external) commands 3 | """ 4 | 5 | import os.path 6 | 7 | from serena.tools import Tool, ToolMarkerCanEdit 8 | from serena.util.shell import execute_shell_command 9 | 10 | 11 | class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): 12 | """ 13 | Executes a shell command. 14 | """ 15 | 16 | def apply( 17 | self, 18 | command: str, 19 | cwd: str | None = None, 20 | capture_stderr: bool = True, 21 | max_answer_chars: int = -1, 22 | ) -> str: 23 | """ 24 | Execute a shell command and return its output. If there is a memory about suggested commands, read that first. 25 | Never execute unsafe shell commands! 26 | IMPORTANT: Do not use this tool to start 27 | * long-running processes (e.g. servers) that are not intended to terminate quickly, 28 | * processes that require user interaction. 29 | 30 | :param command: the shell command to execute 31 | :param cwd: the working directory to execute the command in. If None, the project root will be used. 32 | :param capture_stderr: whether to capture and return stderr output 33 | :param max_answer_chars: if the output is longer than this number of characters, 34 | no content will be returned. -1 means using the default value, don't adjust unless there is no other way to get the content 35 | required for the task. 36 | :return: a JSON object containing the command's stdout and optionally stderr output 37 | """ 38 | if cwd is None: 39 | _cwd = self.get_project_root() 40 | else: 41 | if os.path.isabs(cwd): 42 | _cwd = cwd 43 | else: 44 | _cwd = os.path.join(self.get_project_root(), cwd) 45 | if not os.path.isdir(_cwd): 46 | raise FileNotFoundError( 47 | f"Specified a relative working directory ({cwd}), but the resulting path is not a directory: {_cwd}" 48 | ) 49 | 50 | result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr) 51 | result = result.json() 52 | return self._limit_length(result, max_answer_chars) 53 | ``` -------------------------------------------------------------------------------- /src/serena/util/thread.py: -------------------------------------------------------------------------------- ```python 1 | import threading 2 | from collections.abc import Callable 3 | from enum import Enum 4 | from typing import Generic, TypeVar 5 | 6 | from sensai.util.string import ToStringMixin 7 | 8 | 9 | class TimeoutException(Exception): 10 | def __init__(self, message: str, timeout: float) -> None: 11 | super().__init__(message) 12 | self.timeout = timeout 13 | 14 | 15 | T = TypeVar("T") 16 | 17 | 18 | class ExecutionResult(Generic[T], ToStringMixin): 19 | 20 | class Status(Enum): 21 | SUCCESS = "success" 22 | TIMEOUT = "timeout" 23 | EXCEPTION = "error" 24 | 25 | def __init__(self) -> None: 26 | self.result_value: T | None = None 27 | self.status: ExecutionResult.Status | None = None 28 | self.exception: Exception | None = None 29 | 30 | def set_result_value(self, value: T) -> None: 31 | self.result_value = value 32 | self.status = ExecutionResult.Status.SUCCESS 33 | 34 | def set_timed_out(self, exception: TimeoutException) -> None: 35 | self.exception = exception 36 | self.status = ExecutionResult.Status.TIMEOUT 37 | 38 | def set_exception(self, exception: Exception) -> None: 39 | self.exception = exception 40 | self.status = ExecutionResult.Status.EXCEPTION 41 | 42 | 43 | def execute_with_timeout(func: Callable[[], T], timeout: float, function_name: str) -> ExecutionResult[T]: 44 | """ 45 | Executes the given function with a timeout 46 | 47 | :param func: the function to execute 48 | :param timeout: the timeout in seconds 49 | :param function_name: the name of the function (for error messages) 50 | :returns: the execution result 51 | """ 52 | execution_result: ExecutionResult[T] = ExecutionResult() 53 | 54 | def target() -> None: 55 | try: 56 | value = func() 57 | execution_result.set_result_value(value) 58 | except Exception as e: 59 | execution_result.set_exception(e) 60 | 61 | thread = threading.Thread(target=target, daemon=True) 62 | thread.start() 63 | thread.join(timeout=timeout) 64 | 65 | if thread.is_alive(): 66 | timeout_exception = TimeoutException(f"Execution of '{function_name}' timed out after {timeout} seconds.", timeout) 67 | execution_result.set_timed_out(timeout_exception) 68 | 69 | return execution_result 70 | ``` -------------------------------------------------------------------------------- /src/serena/constants.py: -------------------------------------------------------------------------------- ```python 1 | from pathlib import Path 2 | 3 | _repo_root_path = Path(__file__).parent.parent.parent.resolve() 4 | _serena_pkg_path = Path(__file__).parent.resolve() 5 | 6 | SERENA_MANAGED_DIR_NAME = ".serena" 7 | _serena_in_home_managed_dir = Path.home() / ".serena" 8 | 9 | SERENA_MANAGED_DIR_IN_HOME = str(_serena_in_home_managed_dir) 10 | 11 | # TODO: Path-related constants should be moved to SerenaPaths; don't add further constants here. 12 | REPO_ROOT = str(_repo_root_path) 13 | PROMPT_TEMPLATES_DIR_INTERNAL = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") 14 | PROMPT_TEMPLATES_DIR_IN_USER_HOME = str(_serena_in_home_managed_dir / "prompt_templates") 15 | SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") 16 | """The contexts that are shipped with the Serena package, i.e. the default contexts.""" 17 | USER_CONTEXT_YAMLS_DIR = str(_serena_in_home_managed_dir / "contexts") 18 | """Contexts defined by the user. If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, the user context will override the default one.""" 19 | SERENAS_OWN_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") 20 | """The modes that are shipped with the Serena package, i.e. the default modes.""" 21 | USER_MODE_YAMLS_DIR = str(_serena_in_home_managed_dir / "modes") 22 | """Modes defined by the user. If a name of a mode matches a name of a mode in SERENAS_OWN_MODE_YAMLS_DIR, the user mode will override the default one.""" 23 | INTERNAL_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "internal_modes") 24 | """Internal modes, never overridden by user modes.""" 25 | SERENA_DASHBOARD_DIR = str(_serena_pkg_path / "resources" / "dashboard") 26 | SERENA_ICON_DIR = str(_serena_pkg_path / "resources" / "icons") 27 | 28 | DEFAULT_ENCODING = "utf-8" 29 | DEFAULT_CONTEXT = "desktop-app" 30 | DEFAULT_MODES = ("interactive", "editing") 31 | 32 | PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.yml") 33 | SERENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml") 34 | 35 | SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s" 36 | ``` -------------------------------------------------------------------------------- /src/serena/util/logging.py: -------------------------------------------------------------------------------- ```python 1 | import queue 2 | import threading 3 | from collections.abc import Callable 4 | 5 | from sensai.util import logging 6 | 7 | from serena.constants import SERENA_LOG_FORMAT 8 | 9 | 10 | class MemoryLogHandler(logging.Handler): 11 | def __init__(self, level: int = logging.NOTSET) -> None: 12 | super().__init__(level=level) 13 | self.setFormatter(logging.Formatter(SERENA_LOG_FORMAT)) 14 | self._log_buffer = LogBuffer() 15 | self._log_queue: queue.Queue[str] = queue.Queue() 16 | self._stop_event = threading.Event() 17 | self._emit_callbacks: list[Callable[[str], None]] = [] 18 | 19 | # start background thread to process logs 20 | self.worker_thread = threading.Thread(target=self._process_queue, daemon=True) 21 | self.worker_thread.start() 22 | 23 | def add_emit_callback(self, callback: Callable[[str], None]) -> None: 24 | """ 25 | Adds a callback that will be called with each log message. 26 | The callback should accept a single string argument (the log message). 27 | """ 28 | self._emit_callbacks.append(callback) 29 | 30 | def emit(self, record: logging.LogRecord) -> None: 31 | msg = self.format(record) 32 | self._log_queue.put_nowait(msg) 33 | 34 | def _process_queue(self) -> None: 35 | while not self._stop_event.is_set(): 36 | try: 37 | msg = self._log_queue.get(timeout=1) 38 | self._log_buffer.append(msg) 39 | for callback in self._emit_callbacks: 40 | try: 41 | callback(msg) 42 | except: 43 | pass 44 | self._log_queue.task_done() 45 | except queue.Empty: 46 | continue 47 | 48 | def get_log_messages(self) -> list[str]: 49 | return self._log_buffer.get_log_messages() 50 | 51 | 52 | class LogBuffer: 53 | """ 54 | A thread-safe buffer for storing log messages. 55 | """ 56 | 57 | def __init__(self) -> None: 58 | self._log_messages: list[str] = [] 59 | self._lock = threading.Lock() 60 | 61 | def append(self, msg: str) -> None: 62 | with self._lock: 63 | self._log_messages.append(msg) 64 | 65 | def get_log_messages(self) -> list[str]: 66 | with self._lock: 67 | return self._log_messages.copy() 68 | ``` -------------------------------------------------------------------------------- /test/resources/repos/bash/test_repo/config.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # Configuration script for project setup 4 | 5 | # Environment variables 6 | export PROJECT_NAME="bash-test-project" 7 | export PROJECT_VERSION="1.0.0" 8 | export LOG_LEVEL="INFO" 9 | export CONFIG_DIR="./config" 10 | 11 | # Default settings 12 | DEFAULT_TIMEOUT=30 13 | DEFAULT_RETRIES=3 14 | DEFAULT_PORT=8080 15 | 16 | # Configuration arrays 17 | declare -A ENVIRONMENTS=( 18 | ["dev"]="development" 19 | ["prod"]="production" 20 | ["test"]="testing" 21 | ) 22 | 23 | declare -A DATABASE_CONFIGS=( 24 | ["host"]="localhost" 25 | ["port"]="5432" 26 | ["name"]="myapp_db" 27 | ["user"]="dbuser" 28 | ) 29 | 30 | # Function to load configuration 31 | load_config() { 32 | local env="${1:-dev}" 33 | local config_file="${CONFIG_DIR}/${env}.conf" 34 | 35 | if [[ -f "$config_file" ]]; then 36 | echo "Loading configuration from $config_file" 37 | source "$config_file" 38 | else 39 | echo "Warning: Configuration file $config_file not found, using defaults" 40 | fi 41 | } 42 | 43 | # Function to validate configuration 44 | validate_config() { 45 | local errors=0 46 | 47 | if [[ -z "$PROJECT_NAME" ]]; then 48 | echo "Error: PROJECT_NAME is not set" >&2 49 | ((errors++)) 50 | fi 51 | 52 | if [[ -z "$PROJECT_VERSION" ]]; then 53 | echo "Error: PROJECT_VERSION is not set" >&2 54 | ((errors++)) 55 | fi 56 | 57 | if [[ $DEFAULT_PORT -lt 1024 || $DEFAULT_PORT -gt 65535 ]]; then 58 | echo "Error: Invalid port number $DEFAULT_PORT" >&2 59 | ((errors++)) 60 | fi 61 | 62 | return $errors 63 | } 64 | 65 | # Function to print configuration 66 | print_config() { 67 | echo "=== Current Configuration ===" 68 | echo "Project Name: $PROJECT_NAME" 69 | echo "Version: $PROJECT_VERSION" 70 | echo "Log Level: $LOG_LEVEL" 71 | echo "Default Port: $DEFAULT_PORT" 72 | echo "Default Timeout: $DEFAULT_TIMEOUT" 73 | echo "Default Retries: $DEFAULT_RETRIES" 74 | 75 | echo "\n=== Environments ===" 76 | for env in "${!ENVIRONMENTS[@]}"; do 77 | echo " $env: ${ENVIRONMENTS[$env]}" 78 | done 79 | 80 | echo "\n=== Database Configuration ===" 81 | for key in "${!DATABASE_CONFIGS[@]}"; do 82 | echo " $key: ${DATABASE_CONFIGS[$key]}" 83 | done 84 | } 85 | 86 | # Initialize configuration if this script is run directly 87 | if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 88 | load_config "$1" 89 | validate_config 90 | print_config 91 | fi 92 | ``` -------------------------------------------------------------------------------- /src/serena/util/exception.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import sys 3 | 4 | from serena.agent import log 5 | 6 | 7 | def is_headless_environment() -> bool: 8 | """ 9 | Detect if we're running in a headless environment where GUI operations would fail. 10 | 11 | Returns True if: 12 | - No DISPLAY variable on Linux/Unix 13 | - Running in SSH session 14 | - Running in WSL without X server 15 | - Running in Docker container 16 | """ 17 | # Check if we're on Windows - GUI usually works there 18 | if sys.platform == "win32": 19 | return False 20 | 21 | # Check for DISPLAY variable (required for X11) 22 | if not os.environ.get("DISPLAY"): # type: ignore 23 | return True 24 | 25 | # Check for SSH session 26 | if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"): 27 | return True 28 | 29 | # Check for common CI/container environments 30 | if os.environ.get("CI") or os.environ.get("CONTAINER") or os.path.exists("/.dockerenv"): 31 | return True 32 | 33 | # Check for WSL (only on Unix-like systems where os.uname exists) 34 | if hasattr(os, "uname"): 35 | if "microsoft" in os.uname().release.lower(): 36 | # In WSL, even with DISPLAY set, X server might not be running 37 | # This is a simplified check - could be improved 38 | return True 39 | 40 | return False 41 | 42 | 43 | def show_fatal_exception_safe(e: Exception) -> None: 44 | """ 45 | Shows the given exception in the GUI log viewer on the main thread and ensures that the exception is logged or at 46 | least printed to stderr. 47 | """ 48 | # Log the error and print it to stderr 49 | log.error(f"Fatal exception: {e}", exc_info=e) 50 | print(f"Fatal exception: {e}", file=sys.stderr) 51 | 52 | # Don't attempt GUI in headless environments 53 | if is_headless_environment(): 54 | log.debug("Skipping GUI error display in headless environment") 55 | return 56 | 57 | # attempt to show the error in the GUI 58 | try: 59 | # NOTE: The import can fail on macOS if Tk is not available (depends on Python interpreter installation, which uv 60 | # used as a base); while tkinter as such is always available, its dependencies can be unavailable on macOS. 61 | from serena.gui_log_viewer import show_fatal_exception 62 | 63 | show_fatal_exception(e) 64 | except Exception as gui_error: 65 | log.debug(f"Failed to show GUI error dialog: {gui_error}") 66 | ``` -------------------------------------------------------------------------------- /test/solidlsp/ruby/test_ruby_basic.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from solidlsp import SolidLanguageServer 7 | from solidlsp.ls_config import Language 8 | from solidlsp.ls_utils import SymbolUtils 9 | 10 | 11 | @pytest.mark.ruby 12 | class TestRubyLanguageServer: 13 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 14 | def test_find_symbol(self, language_server: SolidLanguageServer) -> None: 15 | symbols = language_server.request_full_symbol_tree() 16 | assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoClass"), "DemoClass not found in symbol tree" 17 | assert SymbolUtils.symbol_tree_contains_name(symbols, "helper_function"), "helper_function not found in symbol tree" 18 | assert SymbolUtils.symbol_tree_contains_name(symbols, "print_value"), "print_value not found in symbol tree" 19 | 20 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 21 | def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: 22 | file_path = os.path.join("main.rb") 23 | symbols = language_server.request_document_symbols(file_path) 24 | helper_symbol = None 25 | for sym in symbols[0]: 26 | if sym.get("name") == "helper_function": 27 | helper_symbol = sym 28 | break 29 | print(helper_symbol) 30 | assert helper_symbol is not None, "Could not find 'helper_function' symbol in main.rb" 31 | 32 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 33 | @pytest.mark.parametrize("repo_path", [Language.RUBY], indirect=True) 34 | def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 35 | # Test finding Calculator.add method definition from line 17: Calculator.new.add(demo.value, 10) 36 | definition_location_list = language_server.request_definition( 37 | str(repo_path / "main.rb"), 16, 17 38 | ) # add method at line 17 (0-indexed 16), position 17 39 | 40 | assert len(definition_location_list) == 1 41 | definition_location = definition_location_list[0] 42 | print(f"Found definition: {definition_location}") 43 | assert definition_location["uri"].endswith("lib.rb") 44 | assert definition_location["range"]["start"]["line"] == 1 # add method on line 2 (0-indexed 1) 45 | ``` -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/test_repo/services.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Services module demonstrating function usage and dependencies. 3 | """ 4 | 5 | from typing import Any 6 | 7 | from .models import Item, User 8 | 9 | 10 | class UserService: 11 | """Service for user-related operations""" 12 | 13 | def __init__(self, user_db: dict[str, User] | None = None): 14 | self.users = user_db or {} 15 | 16 | def create_user(self, id: str, name: str, email: str) -> User: 17 | """Create a new user and store it""" 18 | if id in self.users: 19 | raise ValueError(f"User with ID {id} already exists") 20 | 21 | user = User(id=id, name=name, email=email) 22 | self.users[id] = user 23 | return user 24 | 25 | def get_user(self, id: str) -> User | None: 26 | """Get a user by ID""" 27 | return self.users.get(id) 28 | 29 | def list_users(self) -> list[User]: 30 | """Get a list of all users""" 31 | return list(self.users.values()) 32 | 33 | def delete_user(self, id: str) -> bool: 34 | """Delete a user by ID""" 35 | if id in self.users: 36 | del self.users[id] 37 | return True 38 | return False 39 | 40 | 41 | class ItemService: 42 | """Service for item-related operations""" 43 | 44 | def __init__(self, item_db: dict[str, Item] | None = None): 45 | self.items = item_db or {} 46 | 47 | def create_item(self, id: str, name: str, price: float, category: str) -> Item: 48 | """Create a new item and store it""" 49 | if id in self.items: 50 | raise ValueError(f"Item with ID {id} already exists") 51 | 52 | item = Item(id=id, name=name, price=price, category=category) 53 | self.items[id] = item 54 | return item 55 | 56 | def get_item(self, id: str) -> Item | None: 57 | """Get an item by ID""" 58 | return self.items.get(id) 59 | 60 | def list_items(self, category: str | None = None) -> list[Item]: 61 | """List all items, optionally filtered by category""" 62 | if category: 63 | return [item for item in self.items.values() if item.category == category] 64 | return list(self.items.values()) 65 | 66 | 67 | # Factory function for services 68 | def create_service_container() -> dict[str, Any]: 69 | """Create a container with all services""" 70 | container = {"user_service": UserService(), "item_service": ItemService()} 71 | return container 72 | 73 | 74 | user_var_str = "user_var" 75 | 76 | 77 | user_service = UserService() 78 | user_service.create_user("1", "Alice", "[email protected]") 79 | ``` -------------------------------------------------------------------------------- /src/serena/tools/memory_tools.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | 3 | from serena.tools import Tool 4 | 5 | 6 | class WriteMemoryTool(Tool): 7 | """ 8 | Writes a named memory (for future reference) to Serena's project-specific memory store. 9 | """ 10 | 11 | def apply(self, memory_name: str, content: str, max_answer_chars: int = -1) -> str: 12 | """ 13 | Write some information about this project that can be useful for future tasks to a memory in md format. 14 | The memory name should be meaningful. 15 | """ 16 | if max_answer_chars == -1: 17 | max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars 18 | if len(content) > max_answer_chars: 19 | raise ValueError( 20 | f"Content for {memory_name} is too long. Max length is {max_answer_chars} characters. " + "Please make the content shorter." 21 | ) 22 | 23 | return self.memories_manager.save_memory(memory_name, content) 24 | 25 | 26 | class ReadMemoryTool(Tool): 27 | """ 28 | Reads the memory with the given name from Serena's project-specific memory store. 29 | """ 30 | 31 | def apply(self, memory_file_name: str, max_answer_chars: int = -1) -> str: 32 | """ 33 | Read the content of a memory file. This tool should only be used if the information 34 | is relevant to the current task. You can infer whether the information 35 | is relevant from the memory file name. 36 | You should not read the same memory file multiple times in the same conversation. 37 | """ 38 | return self.memories_manager.load_memory(memory_file_name) 39 | 40 | 41 | class ListMemoriesTool(Tool): 42 | """ 43 | Lists memories in Serena's project-specific memory store. 44 | """ 45 | 46 | def apply(self) -> str: 47 | """ 48 | List available memories. Any memory can be read using the `read_memory` tool. 49 | """ 50 | return json.dumps(self.memories_manager.list_memories()) 51 | 52 | 53 | class DeleteMemoryTool(Tool): 54 | """ 55 | Deletes a memory from Serena's project-specific memory store. 56 | """ 57 | 58 | def apply(self, memory_file_name: str) -> str: 59 | """ 60 | Delete a memory file. Should only happen if a user asks for it explicitly, 61 | for example by saying that the information retrieved from a memory file is no longer correct 62 | or no longer relevant for the project. 63 | """ 64 | return self.memories_manager.delete_memory(memory_file_name) 65 | ``` -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/test_repo/overloaded.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Module demonstrating function and method overloading with typing.overload 3 | """ 4 | 5 | from typing import Any, overload 6 | 7 | 8 | # Example of function overloading 9 | @overload 10 | def process_data(data: str) -> dict[str, str]: ... 11 | 12 | 13 | @overload 14 | def process_data(data: int) -> dict[str, int]: ... 15 | 16 | 17 | @overload 18 | def process_data(data: list[str | int]) -> dict[str, list[str | int]]: ... 19 | 20 | 21 | def process_data(data: str | int | list[str | int]) -> dict[str, Any]: 22 | """ 23 | Process data based on its type. 24 | 25 | - If string: returns a dict with 'value': <string> 26 | - If int: returns a dict with 'value': <int> 27 | - If list: returns a dict with 'value': <list> 28 | """ 29 | return {"value": data} 30 | 31 | 32 | # Class with overloaded methods 33 | class DataProcessor: 34 | """ 35 | A class demonstrating method overloading. 36 | """ 37 | 38 | @overload 39 | def transform(self, input_value: str) -> str: ... 40 | 41 | @overload 42 | def transform(self, input_value: int) -> int: ... 43 | 44 | @overload 45 | def transform(self, input_value: list[Any]) -> list[Any]: ... 46 | 47 | def transform(self, input_value: str | int | list[Any]) -> str | int | list[Any]: 48 | """ 49 | Transform input based on its type. 50 | 51 | - If string: returns the string in uppercase 52 | - If int: returns the int multiplied by 2 53 | - If list: returns the list sorted 54 | """ 55 | if isinstance(input_value, str): 56 | return input_value.upper() 57 | elif isinstance(input_value, int): 58 | return input_value * 2 59 | elif isinstance(input_value, list): 60 | try: 61 | return sorted(input_value) 62 | except TypeError: 63 | return input_value 64 | return input_value 65 | 66 | @overload 67 | def fetch(self, id: int) -> dict[str, Any]: ... 68 | 69 | @overload 70 | def fetch(self, id: str, cache: bool = False) -> dict[str, Any] | None: ... 71 | 72 | def fetch(self, id: int | str, cache: bool = False) -> dict[str, Any] | None: 73 | """ 74 | Fetch data for a given ID. 75 | 76 | Args: 77 | id: The ID to fetch, either numeric or string 78 | cache: Whether to use cache for string IDs 79 | 80 | Returns: 81 | Data dictionary or None if not found 82 | 83 | """ 84 | # Implementation would actually fetch data 85 | if isinstance(id, int): 86 | return {"id": id, "type": "numeric"} 87 | else: 88 | return {"id": id, "type": "string", "cached": cache} 89 | ``` -------------------------------------------------------------------------------- /test/resources/repos/ruby/test_repo/examples/user_management.rb: -------------------------------------------------------------------------------- ```ruby 1 | require '../services.rb' 2 | require '../models.rb' 3 | 4 | class UserStats 5 | attr_reader :user_count, :active_users, :last_updated 6 | 7 | def initialize 8 | @user_count = 0 9 | @active_users = 0 10 | @last_updated = Time.now 11 | end 12 | 13 | def update_stats(total, active) 14 | @user_count = total 15 | @active_users = active 16 | @last_updated = Time.now 17 | end 18 | 19 | def activity_ratio 20 | return 0.0 if @user_count == 0 21 | (@active_users.to_f / @user_count * 100).round(2) 22 | end 23 | 24 | def formatted_stats 25 | "Users: #{@user_count}, Active: #{@active_users} (#{activity_ratio}%)" 26 | end 27 | end 28 | 29 | class UserManager 30 | def initialize 31 | @service = Services::UserService.new 32 | @stats = UserStats.new 33 | end 34 | 35 | def create_user_with_tracking(id, name, email = nil) 36 | user = @service.create_user(id, name) 37 | user.email = email if email 38 | 39 | update_statistics 40 | notify_user_created(user) 41 | 42 | user 43 | end 44 | 45 | def get_user_details(id) 46 | user = @service.get_user(id) 47 | return nil unless user 48 | 49 | { 50 | user_info: user.full_info, 51 | created_at: Time.now, 52 | stats: @stats.formatted_stats 53 | } 54 | end 55 | 56 | def bulk_create_users(user_data_list) 57 | created_users = [] 58 | 59 | user_data_list.each do |data| 60 | user = create_user_with_tracking(data[:id], data[:name], data[:email]) 61 | created_users << user 62 | end 63 | 64 | created_users 65 | end 66 | 67 | private 68 | 69 | def update_statistics 70 | total_users = @service.users.length 71 | # For demo purposes, assume all users are active 72 | @stats.update_stats(total_users, total_users) 73 | end 74 | 75 | def notify_user_created(user) 76 | puts "User created: #{user.name} (ID: #{user.id})" 77 | end 78 | end 79 | 80 | def process_user_data(raw_data) 81 | processed = raw_data.map do |entry| 82 | { 83 | id: entry["id"] || entry[:id], 84 | name: entry["name"] || entry[:name], 85 | email: entry["email"] || entry[:email] 86 | } 87 | end 88 | 89 | processed.reject { |entry| entry[:name].nil? || entry[:name].empty? } 90 | end 91 | 92 | def main 93 | # Example usage 94 | manager = UserManager.new 95 | 96 | sample_data = [ 97 | { id: 1, name: "Alice Johnson", email: "[email protected]" }, 98 | { id: 2, name: "Bob Smith", email: "[email protected]" }, 99 | { id: 3, name: "Charlie Brown" } 100 | ] 101 | 102 | users = manager.bulk_create_users(sample_data) 103 | 104 | users.each do |user| 105 | details = manager.get_user_details(user.id) 106 | puts details[:user_info] 107 | end 108 | 109 | puts "\nFinal statistics:" 110 | stats = UserStats.new 111 | stats.update_stats(users.length, users.length) 112 | puts stats.formatted_stats 113 | end 114 | 115 | # Execute if this file is run directly 116 | main if __FILE__ == $0 ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Base stage with common dependencies 2 | FROM python:3.11-slim AS base 3 | SHELL ["/bin/bash", "-c"] 4 | 5 | # Set environment variables to make Python print directly to the terminal and avoid .pyc files. 6 | ENV PYTHONUNBUFFERED=1 7 | ENV PYTHONDONTWRITEBYTECODE=1 8 | 9 | # Install system dependencies required for package manager and build tools. 10 | # sudo, wget, zip needed for some assistants, like junie 11 | RUN apt-get update && apt-get install -y --no-install-recommends \ 12 | curl \ 13 | build-essential \ 14 | git \ 15 | ssh \ 16 | sudo \ 17 | wget \ 18 | zip \ 19 | unzip \ 20 | git \ 21 | && rm -rf /var/lib/apt/lists/* 22 | 23 | # Install pipx. 24 | RUN python3 -m pip install --no-cache-dir pipx \ 25 | && pipx ensurepath 26 | 27 | # Install nodejs 28 | ENV NVM_VERSION=0.40.3 29 | ENV NODE_VERSION=22.18.0 30 | RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash 31 | # standard location 32 | ENV NVM_DIR=/root/.nvm 33 | RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION} 34 | RUN . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION} 35 | RUN . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION} 36 | ENV PATH="${NVM_DIR}/versions/node/v${NODE_VERSION}/bin/:${PATH}" 37 | 38 | # Add local bin to the path 39 | ENV PATH="${PATH}:/root/.local/bin" 40 | 41 | # Install the latest version of uv 42 | RUN curl -LsSf https://astral.sh/uv/install.sh | sh 43 | 44 | # Install Rust and rustup for rust-analyzer support (minimal profile) 45 | ENV RUSTUP_HOME=/usr/local/rustup 46 | ENV CARGO_HOME=/usr/local/cargo 47 | ENV PATH="${CARGO_HOME}/bin:${PATH}" 48 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ 49 | --default-toolchain stable \ 50 | --profile minimal \ 51 | && rustup component add rust-analyzer 52 | 53 | # Set the working directory 54 | WORKDIR /workspaces/serena 55 | 56 | # Development target 57 | FROM base AS development 58 | # Copy all files for development 59 | COPY . /workspaces/serena/ 60 | 61 | # Create virtual environment and install dependencies with dev extras 62 | RUN uv venv 63 | RUN . .venv/bin/activate 64 | RUN uv pip install --all-extras -r pyproject.toml -e . 65 | ENV PATH="/workspaces/serena/.venv/bin:${PATH}" 66 | 67 | # Entrypoint to ensure environment is activated 68 | ENTRYPOINT ["/bin/bash", "-c", "source .venv/bin/activate && $0 $@"] 69 | 70 | # Production target 71 | FROM base AS production 72 | # Copy only necessary files for production 73 | COPY pyproject.toml /workspaces/serena/ 74 | COPY README.md /workspaces/serena/ 75 | COPY src/ /workspaces/serena/src/ 76 | 77 | # Create virtual environment and install dependencies (production only) 78 | RUN uv venv 79 | RUN . .venv/bin/activate 80 | RUN uv pip install -r pyproject.toml -e . 81 | ENV PATH="/workspaces/serena/.venv/bin:${PATH}" 82 | 83 | # Entrypoint to ensure environment is activated 84 | ENTRYPOINT ["/bin/bash", "-c", "source .venv/bin/activate && $0 $@"] 85 | 86 | ``` -------------------------------------------------------------------------------- /docs/custom_agent.md: -------------------------------------------------------------------------------- ```markdown 1 | # Custom Agents with Serena 2 | 3 | As a reference implementation, we provide an integration with the [Agno](https://docs.agno.com/introduction/playground) agent framework. 4 | Agno is a model-agnostic agent framework that allows you to turn Serena into an agent 5 | (independent of the MCP technology) with a large number of underlying LLMs. While Agno has recently 6 | added support for MCP servers out of the box, our Agno integration predates this and is a good illustration of how 7 | easy it is to integrate Serena into an arbitrary agent framework. 8 | 9 | Here's how it works: 10 | 11 | 1. Download the agent-ui code with npx 12 | ```shell 13 | npx create-agent-ui@latest 14 | ``` 15 | or, alternatively, clone it manually: 16 | ```shell 17 | git clone https://github.com/agno-agi/agent-ui.git 18 | cd agent-ui 19 | pnpm install 20 | pnpm dev 21 | ``` 22 | 23 | 2. Install serena with the optional requirements: 24 | ```shell 25 | # You can also only select agno,google or agno,anthropic instead of all-extras 26 | uv pip install --all-extras -r pyproject.toml -e . 27 | ``` 28 | 29 | 3. Copy `.env.example` to `.env` and fill in the API keys for the provider(s) you 30 | intend to use. 31 | 32 | 4. Start the agno agent app with 33 | ```shell 34 | uv run python scripts/agno_agent.py 35 | ``` 36 | By default, the script uses Claude as the model, but you can choose any model 37 | supported by Agno (which is essentially any existing model). 38 | 39 | 5. In a new terminal, start the agno UI with 40 | ```shell 41 | cd agent-ui 42 | pnpm dev 43 | ``` 44 | Connect the UI to the agent you started above and start chatting. You will have 45 | the same tools as in the MCP server version. 46 | 47 | 48 | Here is a short demo of Serena performing a small analysis task with the newest Gemini model: 49 | 50 | https://github.com/user-attachments/assets/ccfcb968-277d-4ca9-af7f-b84578858c62 51 | 52 | 53 | ⚠️ IMPORTANT: In contrast to the MCP server approach, tool execution in the Agno UI does 54 | not ask for the user's permission. The shell tool is particularly critical, as it can perform arbitrary code execution. 55 | While we have never encountered any issues with 56 | this in our testing with Claude, allowing this may not be entirely safe. 57 | You may choose to disable certain tools for your setup in your Serena project's 58 | configuration file (`.yml`). 59 | 60 | 61 | ## Other Agent Frameworks 62 | 63 | It should be straightforward to incorporate Serena into any 64 | agent framework (like [pydantic-ai](https://ai.pydantic.dev/), [langgraph](https://langchain-ai.github.io/langgraph/tutorials/introduction/) or others). 65 | Typically, you need only to write an adapter for Serena's tools to the tool representation in the framework of your choice, 66 | as was done by us for Agno with [SerenaAgnoToolkit](/src/serena/agno.py). 67 | 68 | ``` -------------------------------------------------------------------------------- /test/solidlsp/python/test_retrieval_with_ignored_dirs.py: -------------------------------------------------------------------------------- ```python 1 | from collections.abc import Generator 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from solidlsp import SolidLanguageServer 7 | from solidlsp.ls_config import Language 8 | from test.conftest import create_ls 9 | 10 | # This mark will be applied to all tests in this module 11 | pytestmark = pytest.mark.python 12 | 13 | 14 | @pytest.fixture(scope="module") 15 | def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: 16 | """Fixture to set up an LS for the python test repo with the 'scripts' directory ignored.""" 17 | ignored_paths = ["scripts", "custom_test"] 18 | ls = create_ls(ignored_paths=ignored_paths, language=Language.PYTHON) 19 | ls.start() 20 | try: 21 | yield ls 22 | finally: 23 | ls.stop() 24 | 25 | 26 | @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.PYTHON], indirect=True) 27 | def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): 28 | """Tests that request_full_symbol_tree ignores the configured directory.""" 29 | root = ls_with_ignored_dirs.request_full_symbol_tree()[0] 30 | root_children = root["children"] 31 | children_names = {child["name"] for child in root_children} 32 | assert children_names == {"test_repo", "examples"} 33 | 34 | 35 | @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.PYTHON], indirect=True) 36 | def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): 37 | """Tests that find_references ignores the configured directory.""" 38 | # Location of Item, which is referenced in scripts 39 | definition_file = "test_repo/models.py" 40 | definition_line = 56 41 | definition_col = 6 42 | 43 | references = ls_with_ignored_dirs.request_references(definition_file, definition_line, definition_col) 44 | 45 | # assert that scripts does not appear in the references 46 | assert not any("scripts" in ref["relativePath"] for ref in references) 47 | 48 | 49 | @pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True) 50 | def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: 51 | """Tests that refs and symbols with glob patterns are ignored.""" 52 | ignored_paths = ["*ipts", "custom_t*"] 53 | ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.PYTHON) 54 | ls.start() 55 | # same as in the above tests 56 | root = ls.request_full_symbol_tree()[0] 57 | root_children = root["children"] 58 | children_names = {child["name"] for child in root_children} 59 | assert children_names == {"test_repo", "examples"} 60 | 61 | # test that the refs and symbols with glob patterns are ignored 62 | definition_file = "test_repo/models.py" 63 | definition_line = 56 64 | definition_col = 6 65 | 66 | references = ls.request_references(definition_file, definition_line, definition_col) 67 | assert not any("scripts" in ref["relativePath"] for ref in references) 68 | ``` -------------------------------------------------------------------------------- /test/solidlsp/java/test_java_basic.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | 3 | import pytest 4 | 5 | from solidlsp import SolidLanguageServer 6 | from solidlsp.ls_config import Language 7 | from solidlsp.ls_utils import SymbolUtils 8 | 9 | 10 | @pytest.mark.java 11 | class TestJavaLanguageServer: 12 | @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) 13 | def test_find_symbol(self, language_server: SolidLanguageServer) -> None: 14 | symbols = language_server.request_full_symbol_tree() 15 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" 16 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" 17 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" 18 | 19 | @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) 20 | def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: 21 | # Use correct Maven/Java file paths 22 | file_path = os.path.join("src", "main", "java", "test_repo", "Utils.java") 23 | refs = language_server.request_references(file_path, 4, 20) 24 | assert any("Main.java" in ref.get("relativePath", "") for ref in refs), "Main should reference Utils.printHello" 25 | 26 | # Dynamically determine the correct line/column for the 'Model' class name 27 | file_path = os.path.join("src", "main", "java", "test_repo", "Model.java") 28 | symbols = language_server.request_document_symbols(file_path) 29 | model_symbol = None 30 | for sym in symbols[0]: 31 | if sym.get("name") == "Model" and sym.get("kind") == 5: # 5 = Class 32 | model_symbol = sym 33 | break 34 | assert model_symbol is not None, "Could not find 'Model' class symbol in Model.java" 35 | # Use selectionRange if present, otherwise fall back to range 36 | if "selectionRange" in model_symbol: 37 | sel_start = model_symbol["selectionRange"]["start"] 38 | else: 39 | sel_start = model_symbol["range"]["start"] 40 | refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) 41 | assert any( 42 | "Main.java" in ref.get("relativePath", "") for ref in refs 43 | ), "Main should reference Model (tried all positions in selectionRange)" 44 | 45 | @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) 46 | def test_overview_methods(self, language_server: SolidLanguageServer) -> None: 47 | symbols = language_server.request_full_symbol_tree() 48 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" 49 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" 50 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" 51 | ``` -------------------------------------------------------------------------------- /test/solidlsp/kotlin/test_kotlin_basic.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | 3 | import pytest 4 | 5 | from solidlsp import SolidLanguageServer 6 | from solidlsp.ls_config import Language 7 | from solidlsp.ls_utils import SymbolUtils 8 | 9 | 10 | @pytest.mark.kotlin 11 | class TestKotlinLanguageServer: 12 | @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) 13 | def test_find_symbol(self, language_server: SolidLanguageServer) -> None: 14 | symbols = language_server.request_full_symbol_tree() 15 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" 16 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" 17 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" 18 | 19 | @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) 20 | def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: 21 | # Use correct Kotlin file paths 22 | file_path = os.path.join("src", "main", "kotlin", "test_repo", "Utils.kt") 23 | refs = language_server.request_references(file_path, 3, 12) 24 | assert any("Main.kt" in ref.get("relativePath", "") for ref in refs), "Main should reference Utils.printHello" 25 | 26 | # Dynamically determine the correct line/column for the 'Model' class name 27 | file_path = os.path.join("src", "main", "kotlin", "test_repo", "Model.kt") 28 | symbols = language_server.request_document_symbols(file_path) 29 | model_symbol = None 30 | for sym in symbols[0]: 31 | print(sym) 32 | print("\n") 33 | if sym.get("name") == "Model" and sym.get("kind") == 23: # 23 = Class 34 | model_symbol = sym 35 | break 36 | assert model_symbol is not None, "Could not find 'Model' class symbol in Model.kt" 37 | # Use selectionRange if present, otherwise fall back to range 38 | if "selectionRange" in model_symbol: 39 | sel_start = model_symbol["selectionRange"]["start"] 40 | else: 41 | sel_start = model_symbol["range"]["start"] 42 | refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) 43 | assert any( 44 | "Main.kt" in ref.get("relativePath", "") for ref in refs 45 | ), "Main should reference Model (tried all positions in selectionRange)" 46 | 47 | @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) 48 | def test_overview_methods(self, language_server: SolidLanguageServer) -> None: 49 | symbols = language_server.request_full_symbol_tree() 50 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" 51 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" 52 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" 53 | ``` -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/test_repo/variables.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Test module for variable declarations and usage. 3 | 4 | This module tests various types of variable declarations and usages including: 5 | - Module-level variables 6 | - Class-level variables 7 | - Instance variables 8 | - Variable reassignments 9 | """ 10 | 11 | from dataclasses import dataclass, field 12 | 13 | # Module-level variables 14 | module_var = "Initial module value" 15 | 16 | reassignable_module_var = 10 17 | reassignable_module_var = 20 # Reassigned 18 | 19 | # Module-level variable with type annotation 20 | typed_module_var: int = 42 21 | 22 | 23 | # Regular class with class and instance variables 24 | class VariableContainer: 25 | """Class that contains various variables.""" 26 | 27 | # Class-level variables 28 | class_var = "Initial class value" 29 | 30 | reassignable_class_var = True 31 | reassignable_class_var = False # Reassigned #noqa: PIE794 32 | 33 | # Class-level variable with type annotation 34 | typed_class_var: str = "typed value" 35 | 36 | def __init__(self): 37 | # Instance variables 38 | self.instance_var = "Initial instance value" 39 | self.reassignable_instance_var = 100 40 | 41 | # Instance variable with type annotation 42 | self.typed_instance_var: list[str] = ["item1", "item2"] 43 | 44 | def modify_instance_var(self): 45 | # Reassign instance variable 46 | self.instance_var = "Modified instance value" 47 | self.reassignable_instance_var = 200 # Reassigned 48 | 49 | def use_module_var(self): 50 | # Use module-level variables 51 | result = module_var + " used in method" 52 | other_result = reassignable_module_var + 5 53 | return result, other_result 54 | 55 | def use_class_var(self): 56 | # Use class-level variables 57 | result = VariableContainer.class_var + " used in method" 58 | other_result = VariableContainer.reassignable_class_var 59 | return result, other_result 60 | 61 | 62 | # Dataclass with variables 63 | @dataclass 64 | class VariableDataclass: 65 | """Dataclass that contains various fields.""" 66 | 67 | # Field variables with type annotations 68 | id: int 69 | name: str 70 | items: list[str] = field(default_factory=list) 71 | metadata: dict[str, str] = field(default_factory=dict) 72 | optional_value: float | None = None 73 | 74 | # This will be reassigned in various places 75 | status: str = "pending" 76 | 77 | 78 | # Function that uses the module variables 79 | def use_module_variables(): 80 | """Function that uses module-level variables.""" 81 | result = module_var + " used in function" 82 | other_result = reassignable_module_var * 2 83 | return result, other_result 84 | 85 | 86 | # Create instances and use variables 87 | dataclass_instance = VariableDataclass(id=1, name="Test") 88 | dataclass_instance.status = "active" # Reassign dataclass field 89 | 90 | # Use variables at module level 91 | module_result = module_var + " used at module level" 92 | other_module_result = reassignable_module_var + 30 93 | 94 | # Create a second dataclass instance with different status 95 | second_dataclass = VariableDataclass(id=2, name="Another Test") 96 | second_dataclass.status = "completed" # Another reassignment of status 97 | ``` -------------------------------------------------------------------------------- /test/solidlsp/terraform/test_terraform_basic.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Basic integration tests for the Terraform language server functionality. 3 | 4 | These tests validate the functionality of the language server APIs 5 | like request_references using the test repository. 6 | """ 7 | 8 | import pytest 9 | 10 | from solidlsp import SolidLanguageServer 11 | from solidlsp.ls_config import Language 12 | 13 | 14 | @pytest.mark.terraform 15 | class TestLanguageServerBasics: 16 | """Test basic functionality of the Terraform language server.""" 17 | 18 | @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) 19 | def test_basic_definition(self, language_server: SolidLanguageServer) -> None: 20 | """Test basic definition lookup functionality.""" 21 | # Simple test to verify the language server is working 22 | file_path = "main.tf" 23 | # Just try to get document symbols - this should work without hanging 24 | symbols = language_server.request_document_symbols(file_path) 25 | assert len(symbols) > 0, "Should find at least some symbols in main.tf" 26 | 27 | @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) 28 | def test_request_references_aws_instance(self, language_server: SolidLanguageServer) -> None: 29 | """Test request_references on an aws_instance resource.""" 30 | # Get references to an aws_instance resource in main.tf 31 | file_path = "main.tf" 32 | # Find aws_instance resources 33 | symbols = language_server.request_document_symbols(file_path) 34 | aws_instance_symbol = next((s for s in symbols[0] if s.get("name") == 'resource "aws_instance" "web_server"'), None) 35 | if not aws_instance_symbol or "selectionRange" not in aws_instance_symbol: 36 | raise AssertionError("aws_instance symbol or its selectionRange not found") 37 | sel_start = aws_instance_symbol["selectionRange"]["start"] 38 | references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) 39 | assert len(references) >= 1, "aws_instance should be referenced at least once" 40 | 41 | @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) 42 | def test_request_references_variable(self, language_server: SolidLanguageServer) -> None: 43 | """Test request_references on a variable.""" 44 | # Get references to a variable in variables.tf 45 | file_path = "variables.tf" 46 | # Find variable definitions 47 | symbols = language_server.request_document_symbols(file_path) 48 | var_symbol = next((s for s in symbols[0] if s.get("name") == 'variable "instance_type"'), None) 49 | if not var_symbol or "selectionRange" not in var_symbol: 50 | raise AssertionError("variable symbol or its selectionRange not found") 51 | sel_start = var_symbol["selectionRange"]["start"] 52 | references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) 53 | assert len(references) >= 1, "variable should be referenced at least once" 54 | ``` -------------------------------------------------------------------------------- /test/solidlsp/elm/test_elm_basic.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | 3 | import pytest 4 | 5 | from solidlsp import SolidLanguageServer 6 | from solidlsp.ls_config import Language 7 | from solidlsp.ls_utils import SymbolUtils 8 | 9 | 10 | @pytest.mark.elm 11 | class TestElmLanguageServer: 12 | @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) 13 | def test_find_symbol(self, language_server: SolidLanguageServer) -> None: 14 | symbols = language_server.request_full_symbol_tree() 15 | assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" 16 | assert SymbolUtils.symbol_tree_contains_name(symbols, "calculateSum"), "calculateSum function not found in symbol tree" 17 | assert SymbolUtils.symbol_tree_contains_name(symbols, "formatMessage"), "formatMessage function not found in symbol tree" 18 | assert SymbolUtils.symbol_tree_contains_name(symbols, "addNumbers"), "addNumbers function not found in symbol tree" 19 | 20 | @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) 21 | def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: 22 | file_path = os.path.join("Main.elm") 23 | symbols = language_server.request_document_symbols(file_path) 24 | greet_symbol = None 25 | for sym in symbols[0]: 26 | if sym.get("name") == "greet": 27 | greet_symbol = sym 28 | break 29 | assert greet_symbol is not None, "Could not find 'greet' symbol in Main.elm" 30 | sel_start = greet_symbol["selectionRange"]["start"] 31 | refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) 32 | assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Main.elm should reference greet function" 33 | 34 | @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) 35 | def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: 36 | # Test formatMessage function which is defined in Utils.elm and used in Main.elm 37 | utils_path = os.path.join("Utils.elm") 38 | symbols = language_server.request_document_symbols(utils_path) 39 | formatMessage_symbol = None 40 | for sym in symbols[0]: 41 | if sym.get("name") == "formatMessage": 42 | formatMessage_symbol = sym 43 | break 44 | assert formatMessage_symbol is not None, "Could not find 'formatMessage' symbol in Utils.elm" 45 | 46 | # Get references from the definition in Utils.elm 47 | sel_start = formatMessage_symbol["selectionRange"]["start"] 48 | refs = language_server.request_references(utils_path, sel_start["line"], sel_start["character"]) 49 | 50 | # Verify that we found references 51 | assert refs, "Expected to find references for formatMessage" 52 | 53 | # Verify that at least one reference is in Main.elm (where formatMessage is used) 54 | assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Expected to find usage of formatMessage in Main.elm" 55 | ``` -------------------------------------------------------------------------------- /test/solidlsp/rust/test_rust_basic.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | 3 | import pytest 4 | 5 | from solidlsp import SolidLanguageServer 6 | from solidlsp.ls_config import Language 7 | from solidlsp.ls_utils import SymbolUtils 8 | 9 | 10 | @pytest.mark.rust 11 | class TestRustLanguageServer: 12 | @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) 13 | def test_find_references_raw(self, language_server: SolidLanguageServer) -> None: 14 | # Directly test the request_references method for the add function 15 | file_path = os.path.join("src", "lib.rs") 16 | symbols = language_server.request_document_symbols(file_path) 17 | add_symbol = None 18 | for sym in symbols[0]: 19 | if sym.get("name") == "add": 20 | add_symbol = sym 21 | break 22 | assert add_symbol is not None, "Could not find 'add' function symbol in lib.rs" 23 | sel_start = add_symbol["selectionRange"]["start"] 24 | refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) 25 | assert any( 26 | "main.rs" in ref.get("relativePath", "") for ref in refs 27 | ), "main.rs should reference add (raw, tried all positions in selectionRange)" 28 | 29 | @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) 30 | def test_find_symbol(self, language_server: SolidLanguageServer) -> None: 31 | symbols = language_server.request_full_symbol_tree() 32 | assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" 33 | assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" 34 | # Add more as needed based on test_repo 35 | 36 | @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) 37 | def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: 38 | # Find references to 'add' defined in lib.rs, should be referenced from main.rs 39 | file_path = os.path.join("src", "lib.rs") 40 | symbols = language_server.request_document_symbols(file_path) 41 | add_symbol = None 42 | for sym in symbols[0]: 43 | if sym.get("name") == "add": 44 | add_symbol = sym 45 | break 46 | assert add_symbol is not None, "Could not find 'add' function symbol in lib.rs" 47 | sel_start = add_symbol["selectionRange"]["start"] 48 | refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) 49 | assert any( 50 | "main.rs" in ref.get("relativePath", "") for ref in refs 51 | ), "main.rs should reference add (tried all positions in selectionRange)" 52 | 53 | @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) 54 | def test_overview_methods(self, language_server: SolidLanguageServer) -> None: 55 | symbols = language_server.request_full_symbol_tree() 56 | assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" 57 | assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" 58 | ``` -------------------------------------------------------------------------------- /docs/serena_on_chatgpt.md: -------------------------------------------------------------------------------- ```markdown 1 | 2 | # Connecting Serena MCP Server to ChatGPT via MCPO & Cloudflare Tunnel 3 | 4 | This guide explains how to expose a **locally running Serena MCP server** (powered by MCPO) to the internet using **Cloudflare Tunnel**, and how to connect it to **ChatGPT as a Custom GPT with tool access**. 5 | 6 | Once configured, ChatGPT becomes a powerful **coding agent** with direct access to your codebase, shell, and file system — so **read the security notes carefully**. 7 | 8 | --- 9 | ## Prerequisites 10 | 11 | Make sure you have [uv](https://docs.astral.sh/uv/getting-started/installation/) 12 | and [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed. 13 | 14 | ## 1. Start the Serena MCP Server via MCPO 15 | 16 | Run the following command to launch Serena as http server (assuming port 8000): 17 | 18 | ```bash 19 | uvx mcpo --port 8000 --api-key <YOUR_SECRET_KEY> -- \ 20 | uvx --from git+https://github.com/oraios/serena \ 21 | serena start-mcp-server --context chatgpt --project $(pwd) 22 | ``` 23 | 24 | - `--api-key` is required to secure the server. 25 | - `--project` should point to the root of your codebase. 26 | 27 | You can also use other options, and you don't have to pass `--project` if you want to work on multiple projects 28 | or want to activate it later. See 29 | 30 | ```shell 31 | uvx --from git+https://github.com/oraios/serena serena start-mcp-server --help 32 | ``` 33 | 34 | --- 35 | 36 | ## 2. Expose the Server Using Cloudflare Tunnel 37 | 38 | Run: 39 | 40 | ```bash 41 | cloudflared tunnel --url http://localhost:8000 42 | ``` 43 | 44 | This will give you a **public HTTPS URL** like: 45 | 46 | ``` 47 | https://serena-agent-tunnel.trycloudflare.com 48 | ``` 49 | 50 | Your server is now securely exposed to the internet. 51 | 52 | --- 53 | 54 | ## 3. Connect It to ChatGPT (Custom GPT) 55 | 56 | ### Steps: 57 | 58 | 1. Go to [ChatGPT → Explore GPTs → Create](https://chat.openai.com/gpts/editor) 59 | 2. During setup, click **“Add APIs”** 60 | 3. Set up **API Key authentication** with the auth type as **Bearer** and enter the api key you used to start the MCPO server. 61 | 4. In the **Schema** section, click on **import from URL** and paste `<cloudflared_url>/openapi.json` with the URL you got from the previous step. 62 | 5. Add the following line to the top of the imported JSON schema: 63 | ``` 64 | "servers": ["url": "<cloudflared_url>"], 65 | ``` 66 | **Important**: don't include a trailing slash at the end of the URL! 67 | 68 | ChatGPT will read the schema and create functions automatically. 69 | 70 | --- 71 | 72 | ## Security Warning — Read Carefully 73 | 74 | Depending on your configuration and enabled tools, Serena's MCP server may: 75 | - Execute **arbitrary shell commands** 76 | - Read, write, and modify **files in your codebase** 77 | 78 | This gives ChatGPT the same powers as a remote developer on your machine. 79 | 80 | ### ⚠️ Key Rules: 81 | - **NEVER expose your API key** 82 | - **Only expose this server when needed**, and monitor its use. 83 | 84 | In your project’s `.serena/project.yml` or global config, you can disable tools like: 85 | 86 | ```yaml 87 | excluded_tools: 88 | - execute_shell_command 89 | - ... 90 | read_only: true 91 | ``` 92 | 93 | This is strongly recommended if you want a read-only or safer agent. 94 | 95 | 96 | --- 97 | 98 | ## Final Thoughts 99 | 100 | With this setup, ChatGPT becomes a coding assistant **running on your local code** — able to index, search, edit, and even run shell commands depending on your configuration. 101 | 102 | Use responsibly, and keep security in mind. 103 | ``` -------------------------------------------------------------------------------- /test/resources/repos/ruby/test_repo/variables.rb: -------------------------------------------------------------------------------- ```ruby 1 | require './models.rb' 2 | 3 | # Global variables for testing references 4 | $global_counter = 0 5 | $global_config = { 6 | debug: true, 7 | timeout: 30 8 | } 9 | 10 | class DataContainer 11 | attr_accessor :status, :data, :metadata 12 | 13 | def initialize 14 | @status = "pending" 15 | @data = {} 16 | @metadata = { 17 | created_at: Time.now, 18 | version: "1.0" 19 | } 20 | end 21 | 22 | def update_status(new_status) 23 | old_status = @status 24 | @status = new_status 25 | log_status_change(old_status, new_status) 26 | end 27 | 28 | def process_data(input_data) 29 | @data = input_data 30 | @status = "processing" 31 | 32 | # Process the data 33 | result = @data.transform_values { |v| v.to_s.upcase } 34 | @status = "completed" 35 | 36 | result 37 | end 38 | 39 | def get_metadata_info 40 | info = "Status: #{@status}, Version: #{@metadata[:version]}" 41 | info += ", Created: #{@metadata[:created_at]}" 42 | info 43 | end 44 | 45 | private 46 | 47 | def log_status_change(old_status, new_status) 48 | puts "Status changed from #{old_status} to #{new_status}" 49 | end 50 | end 51 | 52 | class StatusTracker 53 | def initialize 54 | @tracked_items = [] 55 | end 56 | 57 | def add_item(item) 58 | @tracked_items << item 59 | item.status = "tracked" if item.respond_to?(:status=) 60 | end 61 | 62 | def find_by_status(target_status) 63 | @tracked_items.select { |item| item.status == target_status } 64 | end 65 | 66 | def update_all_status(new_status) 67 | @tracked_items.each do |item| 68 | item.status = new_status if item.respond_to?(:status=) 69 | end 70 | end 71 | end 72 | 73 | # Module level variables and functions 74 | module ProcessingHelper 75 | PROCESSING_MODES = ["sync", "async", "batch"].freeze 76 | 77 | @@instance_count = 0 78 | 79 | def self.create_processor(mode = "sync") 80 | @@instance_count += 1 81 | { 82 | id: @@instance_count, 83 | mode: mode, 84 | created_at: Time.now 85 | } 86 | end 87 | 88 | def self.get_instance_count 89 | @@instance_count 90 | end 91 | end 92 | 93 | # Test instances for reference testing 94 | dataclass_instance = DataContainer.new 95 | dataclass_instance.status = "initialized" 96 | 97 | second_dataclass = DataContainer.new 98 | second_dataclass.update_status("ready") 99 | 100 | tracker = StatusTracker.new 101 | tracker.add_item(dataclass_instance) 102 | tracker.add_item(second_dataclass) 103 | 104 | # Function that uses the variables 105 | def demonstrate_variable_usage 106 | puts "Global counter: #{$global_counter}" 107 | 108 | container = DataContainer.new 109 | container.status = "demo" 110 | 111 | processor = ProcessingHelper.create_processor("async") 112 | puts "Created processor #{processor[:id]} in #{processor[:mode]} mode" 113 | 114 | container 115 | end 116 | 117 | # More complex variable interactions 118 | class VariableInteractionTest 119 | def initialize 120 | @internal_status = "created" 121 | @data_containers = [] 122 | end 123 | 124 | def add_container(container) 125 | @data_containers << container 126 | container.status = "added_to_collection" 127 | @internal_status = "modified" 128 | end 129 | 130 | def process_all_containers 131 | @data_containers.each do |container| 132 | container.status = "batch_processed" 133 | end 134 | @internal_status = "processing_complete" 135 | end 136 | 137 | def get_status_summary 138 | statuses = @data_containers.map(&:status) 139 | { 140 | internal: @internal_status, 141 | containers: statuses, 142 | count: @data_containers.length 143 | } 144 | end 145 | end 146 | 147 | # Create instances for testing 148 | interaction_test = VariableInteractionTest.new 149 | interaction_test.add_container(dataclass_instance) 150 | interaction_test.add_container(second_dataclass) ``` -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/test_repo/utils.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Utility functions and classes demonstrating various Python features. 3 | """ 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from typing import Any, TypeVar 8 | 9 | # Type variables for generic functions 10 | T = TypeVar("T") 11 | U = TypeVar("U") 12 | 13 | 14 | def setup_logging(level: str = "INFO") -> logging.Logger: 15 | """Set up and return a configured logger""" 16 | levels = { 17 | "DEBUG": logging.DEBUG, 18 | "INFO": logging.INFO, 19 | "WARNING": logging.WARNING, 20 | "ERROR": logging.ERROR, 21 | "CRITICAL": logging.CRITICAL, 22 | } 23 | 24 | logger = logging.getLogger("test_repo") 25 | logger.setLevel(levels.get(level.upper(), logging.INFO)) 26 | 27 | handler = logging.StreamHandler() 28 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 29 | handler.setFormatter(formatter) 30 | logger.addHandler(handler) 31 | 32 | return logger 33 | 34 | 35 | # Decorator example 36 | def log_execution(func: Callable) -> Callable: 37 | """Decorator to log function execution""" 38 | 39 | def wrapper(*args, **kwargs): 40 | logger = logging.getLogger("test_repo") 41 | logger.info(f"Executing function: {func.__name__}") 42 | result = func(*args, **kwargs) 43 | logger.info(f"Completed function: {func.__name__}") 44 | return result 45 | 46 | return wrapper 47 | 48 | 49 | # Higher-order function 50 | def map_list(items: list[T], mapper: Callable[[T], U]) -> list[U]: 51 | """Map a function over a list of items""" 52 | return [mapper(item) for item in items] 53 | 54 | 55 | # Class with various Python features 56 | class ConfigManager: 57 | """Manages configuration with various access patterns""" 58 | 59 | _instance = None 60 | 61 | # Singleton pattern 62 | def __new__(cls, *args, **kwargs): 63 | if not cls._instance: 64 | cls._instance = super().__new__(cls) 65 | return cls._instance 66 | 67 | def __init__(self, initial_config: dict[str, Any] | None = None): 68 | if not hasattr(self, "initialized"): 69 | self.config = initial_config or {} 70 | self.initialized = True 71 | 72 | def __getitem__(self, key: str) -> Any: 73 | """Allow dictionary-like access""" 74 | return self.config.get(key) 75 | 76 | def __setitem__(self, key: str, value: Any) -> None: 77 | """Allow dictionary-like setting""" 78 | self.config[key] = value 79 | 80 | @property 81 | def debug_mode(self) -> bool: 82 | """Property example""" 83 | return self.config.get("debug", False) 84 | 85 | @debug_mode.setter 86 | def debug_mode(self, value: bool) -> None: 87 | self.config["debug"] = value 88 | 89 | 90 | # Context manager example 91 | class Timer: 92 | """Context manager for timing code execution""" 93 | 94 | def __init__(self, name: str = "Timer"): 95 | self.name = name 96 | self.start_time = None 97 | self.end_time = None 98 | 99 | def __enter__(self): 100 | import time 101 | 102 | self.start_time = time.time() 103 | return self 104 | 105 | def __exit__(self, exc_type, exc_val, exc_tb): 106 | import time 107 | 108 | self.end_time = time.time() 109 | print(f"{self.name} took {self.end_time - self.start_time:.6f} seconds") 110 | 111 | 112 | # Functions with default arguments 113 | def retry(func: Callable, max_attempts: int = 3, delay: float = 1.0) -> Any: 114 | """Retry a function with backoff""" 115 | import time 116 | 117 | for attempt in range(max_attempts): 118 | try: 119 | return func() 120 | except Exception as e: 121 | if attempt == max_attempts - 1: 122 | raise e 123 | time.sleep(delay * (2**attempt)) 124 | ``` -------------------------------------------------------------------------------- /test/solidlsp/markdown/test_markdown_basic.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Basic integration tests for the markdown language server functionality. 3 | 4 | These tests validate the functionality of the language server APIs 5 | like request_document_symbols using the markdown test repository. 6 | """ 7 | 8 | import pytest 9 | 10 | from solidlsp import SolidLanguageServer 11 | from solidlsp.ls_config import Language 12 | 13 | 14 | @pytest.mark.markdown 15 | class TestMarkdownLanguageServerBasics: 16 | """Test basic functionality of the markdown language server.""" 17 | 18 | @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) 19 | def test_markdown_language_server_initialization(self, language_server: SolidLanguageServer) -> None: 20 | """Test that markdown language server can be initialized successfully.""" 21 | assert language_server is not None 22 | assert language_server.language == Language.MARKDOWN 23 | 24 | @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) 25 | def test_markdown_request_document_symbols(self, language_server: SolidLanguageServer) -> None: 26 | """Test request_document_symbols for markdown files.""" 27 | # Test getting symbols from README.md 28 | all_symbols, _root_symbols = language_server.request_document_symbols("README.md", include_body=False) 29 | 30 | # Extract heading symbols (LSP Symbol Kind 15 is String, but marksman uses kind 15 for headings) 31 | # Note: Different markdown LSPs may use different symbol kinds for headings 32 | # Marksman typically uses kind 15 (String) for markdown headings 33 | heading_names = [symbol["name"] for symbol in all_symbols] 34 | 35 | # Should detect headings from README.md 36 | assert "Test Repository" in heading_names or len(all_symbols) > 0, "Should find at least one heading" 37 | 38 | @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) 39 | def test_markdown_request_symbols_from_guide(self, language_server: SolidLanguageServer) -> None: 40 | """Test symbol detection in guide.md file.""" 41 | all_symbols, _root_symbols = language_server.request_document_symbols("guide.md", include_body=False) 42 | 43 | # At least some headings should be found 44 | assert len(all_symbols) > 0, f"Should find headings in guide.md, found {len(all_symbols)}" 45 | 46 | @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) 47 | def test_markdown_request_symbols_from_api(self, language_server: SolidLanguageServer) -> None: 48 | """Test symbol detection in api.md file.""" 49 | all_symbols, _root_symbols = language_server.request_document_symbols("api.md", include_body=False) 50 | 51 | # Should detect headings from api.md 52 | assert len(all_symbols) > 0, f"Should find headings in api.md, found {len(all_symbols)}" 53 | 54 | @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) 55 | def test_markdown_request_document_symbols_with_body(self, language_server: SolidLanguageServer) -> None: 56 | """Test request_document_symbols with body extraction.""" 57 | # Test with include_body=True 58 | all_symbols, _root_symbols = language_server.request_document_symbols("README.md", include_body=True) 59 | 60 | # Should have found some symbols 61 | assert len(all_symbols) > 0, "Should find symbols in README.md" 62 | 63 | # Note: Not all markdown LSPs provide body information for symbols 64 | # This test is more lenient and just verifies the API works 65 | assert all_symbols is not None, "Should return symbols even if body extraction is limited" 66 | ``` -------------------------------------------------------------------------------- /src/serena/tools/config_tools.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | 3 | from serena.config.context_mode import SerenaAgentMode 4 | from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional 5 | 6 | 7 | class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): 8 | """ 9 | Activates a project by name. 10 | """ 11 | 12 | def apply(self, project: str) -> str: 13 | """ 14 | Activates the project with the given name. 15 | 16 | :param project: the name of a registered project to activate or a path to a project directory 17 | """ 18 | active_project = self.agent.activate_project_from_path_or_name(project) 19 | if active_project.is_newly_created: 20 | result_str = ( 21 | f"Created and activated a new project with name '{active_project.project_name}' at {active_project.project_root}, language: {active_project.project_config.language.value}. " 22 | "You can activate this project later by name.\n" 23 | f"The project's Serena configuration is in {active_project.path_to_project_yml()}. In particular, you may want to edit the project name and the initial prompt." 24 | ) 25 | else: 26 | result_str = f"Activated existing project with name '{active_project.project_name}' at {active_project.project_root}, language: {active_project.project_config.language.value}" 27 | 28 | if active_project.project_config.initial_prompt: 29 | result_str += f"\nAdditional project information:\n {active_project.project_config.initial_prompt}" 30 | result_str += ( 31 | f"\nAvailable memories:\n {json.dumps(list(self.memories_manager.list_memories()))}" 32 | + "You should not read these memories directly, but rather use the `read_memory` tool to read them later if needed for the task." 33 | ) 34 | result_str += f"\nAvailable tools:\n {json.dumps(self.agent.get_active_tool_names())}" 35 | return result_str 36 | 37 | 38 | class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): 39 | """ 40 | Removes a project from the Serena configuration. 41 | """ 42 | 43 | def apply(self, project_name: str) -> str: 44 | """ 45 | Removes a project from the Serena configuration. 46 | 47 | :param project_name: Name of the project to remove 48 | """ 49 | self.agent.serena_config.remove_project(project_name) 50 | return f"Successfully removed project '{project_name}' from configuration." 51 | 52 | 53 | class SwitchModesTool(Tool, ToolMarkerOptional): 54 | """ 55 | Activates modes by providing a list of their names 56 | """ 57 | 58 | def apply(self, modes: list[str]) -> str: 59 | """ 60 | Activates the desired modes, like ["editing", "interactive"] or ["planning", "one-shot"] 61 | 62 | :param modes: the names of the modes to activate 63 | """ 64 | mode_instances = [SerenaAgentMode.load(mode) for mode in modes] 65 | self.agent.set_modes(mode_instances) 66 | 67 | # Inform the Agent about the activated modes and the currently active tools 68 | result_str = f"Successfully activated modes: {', '.join([mode.name for mode in mode_instances])}" + "\n" 69 | result_str += "\n".join([mode_instance.prompt for mode_instance in mode_instances]) + "\n" 70 | result_str += f"Currently active tools: {', '.join(self.agent.get_active_tool_names())}" 71 | return result_str 72 | 73 | 74 | class GetCurrentConfigTool(Tool): 75 | """ 76 | Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. 77 | """ 78 | 79 | def apply(self) -> str: 80 | """ 81 | Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes. 82 | """ 83 | return self.agent.get_current_config_overview() 84 | ``` -------------------------------------------------------------------------------- /test/solidlsp/util/test_zip.py: -------------------------------------------------------------------------------- ```python 1 | import sys 2 | import zipfile 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from solidlsp.util.zip import SafeZipExtractor 8 | 9 | 10 | @pytest.fixture 11 | def temp_zip_file(tmp_path: Path) -> Path: 12 | """Create a temporary ZIP file for testing.""" 13 | zip_path = tmp_path / "test.zip" 14 | with zipfile.ZipFile(zip_path, "w") as zipf: 15 | zipf.writestr("file1.txt", "Hello World 1") 16 | zipf.writestr("file2.txt", "Hello World 2") 17 | zipf.writestr("folder/file3.txt", "Hello World 3") 18 | return zip_path 19 | 20 | 21 | def test_extract_all_success(temp_zip_file: Path, tmp_path: Path) -> None: 22 | """All files should extract without error.""" 23 | dest_dir = tmp_path / "extracted" 24 | extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) 25 | extractor.extract_all() 26 | 27 | assert (dest_dir / "file1.txt").read_text() == "Hello World 1" 28 | assert (dest_dir / "file2.txt").read_text() == "Hello World 2" 29 | assert (dest_dir / "folder" / "file3.txt").read_text() == "Hello World 3" 30 | 31 | 32 | def test_include_patterns(temp_zip_file: Path, tmp_path: Path) -> None: 33 | """Only files matching include_patterns should be extracted.""" 34 | dest_dir = tmp_path / "extracted" 35 | extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False, include_patterns=["*.txt"]) 36 | extractor.extract_all() 37 | 38 | assert (dest_dir / "file1.txt").exists() 39 | assert (dest_dir / "file2.txt").exists() 40 | assert (dest_dir / "folder" / "file3.txt").exists() 41 | 42 | 43 | def test_exclude_patterns(temp_zip_file: Path, tmp_path: Path) -> None: 44 | """Files matching exclude_patterns should be skipped.""" 45 | dest_dir = tmp_path / "extracted" 46 | extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False, exclude_patterns=["file2.txt"]) 47 | extractor.extract_all() 48 | 49 | assert (dest_dir / "file1.txt").exists() 50 | assert not (dest_dir / "file2.txt").exists() 51 | assert (dest_dir / "folder" / "file3.txt").exists() 52 | 53 | 54 | def test_include_and_exclude_patterns(temp_zip_file: Path, tmp_path: Path) -> None: 55 | """Exclude should override include if both match.""" 56 | dest_dir = tmp_path / "extracted" 57 | extractor = SafeZipExtractor( 58 | temp_zip_file, 59 | dest_dir, 60 | verbose=False, 61 | include_patterns=["*.txt"], 62 | exclude_patterns=["file1.txt"], 63 | ) 64 | extractor.extract_all() 65 | 66 | assert not (dest_dir / "file1.txt").exists() 67 | assert (dest_dir / "file2.txt").exists() 68 | assert (dest_dir / "folder" / "file3.txt").exists() 69 | 70 | 71 | def test_skip_on_error(monkeypatch, temp_zip_file: Path, tmp_path: Path) -> None: 72 | """Should skip a file that raises an error and continue extracting others.""" 73 | dest_dir = tmp_path / "extracted" 74 | 75 | original_open = zipfile.ZipFile.open 76 | 77 | def failing_open(self, member, *args, **kwargs): 78 | if member.filename == "file2.txt": 79 | raise OSError("Simulated failure") 80 | return original_open(self, member, *args, **kwargs) 81 | 82 | # Patch the method on the class, not on an instance 83 | monkeypatch.setattr(zipfile.ZipFile, "open", failing_open) 84 | 85 | extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) 86 | extractor.extract_all() 87 | 88 | assert (dest_dir / "file1.txt").exists() 89 | assert not (dest_dir / "file2.txt").exists() 90 | assert (dest_dir / "folder" / "file3.txt").exists() 91 | 92 | 93 | @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows-only test") 94 | def test_long_path_normalization(temp_zip_file: Path, tmp_path: Path) -> None: 95 | r"""Ensure _normalize_path adds \\?\\ prefix on Windows.""" 96 | dest_dir = tmp_path / ("a" * 250) # Simulate long path 97 | extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) 98 | norm_path = extractor._normalize_path(dest_dir / "file.txt") 99 | assert str(norm_path).startswith("\\\\?\\") 100 | ``` -------------------------------------------------------------------------------- /roadmap.md: -------------------------------------------------------------------------------- ```markdown 1 | # Roadmap 2 | 3 | This document gives an overview of the ongoing and future development of Serena. 4 | If you have a proposal or want to discuss something, feel free to open a discussion 5 | on Github. For a summary of the past development, see the [changelog](/CHANGELOG.md). 6 | 7 | Want to see us reach our goals faster? You can help out with an issue, start a discussion, or 8 | inform us about funding opportunities so that we can devote more time to the project. 9 | 10 | ## Overall Goals 11 | 12 | Serena has the potential to be the go-to tool for most LLM coding tasks, since it is 13 | unique in its ability to be used as MCP Server in any kind of environment 14 | while still being a capable agent. We want to achieve the following goals in terms of functionality: 15 | 16 | 1. Top performance (comparable to API-based coding agents) when used through official (free) clients like Claude Desktop. 17 | 1. Lowering API costs and potentially improving performance of coding clients (Claude Code, Codex, Cline, Roo, Cursor/Windsurf/VSCode etc). 18 | 1. Transparency and simplicity of use. Achieved through the dashboard/logging GUI. 19 | 1. Integrations with major frameworks that don't accept MCP. Usable as a library. 20 | 21 | Apart from the functional goals, we have the goal of having great code design, so that Serena can be viewed 22 | as a reference for how to implement MCP Servers. Such projects are an emerging technology, and 23 | best practices are yet to be determined. We will share our experiences in [lessons learned](/lessons_learned.md). 24 | 25 | 26 | ## Immediate/Ongoing 27 | 28 | - Support for projects using multiple programming languages. 29 | - Evaluate whether `ReplaceLinesTool` can be removed in favor of a more reliable and performant editing approach. 30 | - Generally experiment with various approaches to editing tools 31 | - Manual evaluation on selected tasks from SWE-verified 32 | - Manual evaluation of cost-lowering and performance when used within popular non-MCP agents 33 | - Improvements in prompts, in particular giving examples and extending modes and contexts 34 | 35 | ## Upcoming 36 | 37 | - Publishing Serena as a package that can also be used as library 38 | - Use linting and type-hierarchy from the LSP in tools 39 | - Tools for refactoring (rename, move) - speculative, maybe won't do this. 40 | - Tracking edits and rolling them back with the dashboard 41 | - Improve configurability and safety of shell tool. Maybe autogeneration of tools from a list of commands and descriptions. 42 | - Transparent comparison with DesktopCommander and ... 43 | - Automatic evaluation using OpenHands, submission to SWE-Bench 44 | - Evaluation whether incorporating other MCPs increases performance or usability (memory bank is a candidate) 45 | - More documentation and best practices 46 | 47 | ## Stretch 48 | 49 | - Allow for sandboxing and parallel instances of Serena, maybe use openhands or codex for that 50 | - Incorporate a verifier model or generally a second model (maybe for applying edits) as a tool. 51 | - Building on the above, allow for the second model itself to be reachable through an MCP server, so it can be used for free 52 | - Tracking edits performed with shell tools 53 | 54 | ## Beyond Serena 55 | 56 | The technologies and approaches taken in Serena can be used for various research and service ideas. Some thought that we had are: 57 | 58 | - PR and issue assistant working with GitHub, similar to how [OpenHands](https://github.com/All-Hands-AI/OpenHands) 59 | and [qodo](https://github.com/qodo-ai/pr-agent) operate. Should be callable through @serena 60 | - Tuning a coding LLM with Serena's tools with RL on one-shot tasks. We would need compute-funding for that 61 | - Develop a web app to quantitatively compare the performance of various agents by scraping PRs and manually crafted metadata. 62 | The main metric for coding agents should be *developer experience*, and that is hard to grasp and is poorly correlated with 63 | performance on current benchmarks. ``` -------------------------------------------------------------------------------- /src/interprompt/prompt_factory.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | import os 3 | from typing import Any 4 | 5 | from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class PromptFactoryBase: 11 | """Base class for auto-generated prompt factory classes.""" 12 | 13 | def __init__(self, prompts_dir: str | list[str], lang_code: str = DEFAULT_LANG_CODE, fallback_mode=LanguageFallbackMode.EXCEPTION): 14 | """ 15 | :param prompts_dir: the directory containing the prompt templates and prompt lists. 16 | If a list is provided, will look for prompt templates in the dirs from left to right 17 | (first one containing the desired template wins). 18 | :param lang_code: the language code to use for retrieving the prompt templates and prompt lists. 19 | Leave as `default` for single-language use cases. 20 | :param fallback_mode: the fallback mode to use when a prompt template or prompt list is not found for the requested language. 21 | Irrelevant for single-language use cases. 22 | """ 23 | self.lang_code = lang_code 24 | self._prompt_collection = MultiLangPromptCollection(prompts_dir, fallback_mode=fallback_mode) 25 | 26 | def _render_prompt(self, prompt_name: str, params: dict[str, Any]) -> str: 27 | del params["self"] 28 | return self._prompt_collection.render_prompt_template(prompt_name, params, lang_code=self.lang_code) 29 | 30 | def _get_prompt_list(self, prompt_name: str) -> PromptList: 31 | return self._prompt_collection.get_prompt_list(prompt_name, self.lang_code) 32 | 33 | 34 | def autogenerate_prompt_factory_module(prompts_dir: str, target_module_path: str) -> None: 35 | """ 36 | Auto-generates a prompt factory module for the given prompt directory. 37 | The generated `PromptFactory` class is meant to be the central entry class for retrieving and rendering prompt templates and prompt 38 | lists in your application. 39 | It will contain one method per prompt template and prompt list, and is useful for both single- and multi-language use cases. 40 | 41 | :param prompts_dir: the directory containing the prompt templates and prompt lists 42 | :param target_module_path: the path to the target module file (.py). Important: The module will be overwritten! 43 | """ 44 | generated_code = """ 45 | # ruff: noqa 46 | # black: skip 47 | # mypy: ignore-errors 48 | 49 | # NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually! 50 | 51 | from interprompt.multilang_prompt import PromptList 52 | from interprompt.prompt_factory import PromptFactoryBase 53 | from typing import Any 54 | 55 | 56 | class PromptFactory(PromptFactoryBase): 57 | \""" 58 | A class for retrieving and rendering prompt templates and prompt lists. 59 | \""" 60 | """ 61 | # ---- add methods based on prompt template names and parameters and prompt list names ---- 62 | prompt_collection = MultiLangPromptCollection(prompts_dir) 63 | 64 | for template_name in prompt_collection.get_prompt_template_names(): 65 | template_parameters = prompt_collection.get_prompt_template_parameters(template_name) 66 | if len(template_parameters) == 0: 67 | method_params_str = "" 68 | else: 69 | method_params_str = ", *, " + ", ".join([f"{param}: Any" for param in template_parameters]) 70 | generated_code += f""" 71 | def create_{template_name}(self{method_params_str}) -> str: 72 | return self._render_prompt('{template_name}', locals()) 73 | """ 74 | for prompt_list_name in prompt_collection.get_prompt_list_names(): 75 | generated_code += f""" 76 | def get_list_{prompt_list_name}(self) -> PromptList: 77 | return self._get_prompt_list('{prompt_list_name}') 78 | """ 79 | os.makedirs(os.path.dirname(target_module_path), exist_ok=True) 80 | with open(target_module_path, "w", encoding="utf-8") as f: 81 | f.write(generated_code) 82 | log.info(f"Prompt factory generated successfully in {target_module_path}") 83 | ``` -------------------------------------------------------------------------------- /src/solidlsp/lsp_protocol_handler/server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | This file provides the implementation of the JSON-RPC client, that launches and 3 | communicates with the language server. 4 | 5 | The initial implementation of this file was obtained from 6 | https://github.com/predragnikolic/OLSP under the MIT License with the following terms: 7 | 8 | MIT License 9 | 10 | Copyright (c) 2023 Предраг Николић 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | """ 30 | 31 | import dataclasses 32 | import json 33 | import logging 34 | import os 35 | from typing import Any, Union 36 | 37 | from .lsp_types import ErrorCodes 38 | 39 | StringDict = dict[str, Any] 40 | PayloadLike = Union[list[StringDict], StringDict, None] 41 | CONTENT_LENGTH = "Content-Length: " 42 | ENCODING = "utf-8" 43 | log = logging.getLogger(__name__) 44 | 45 | 46 | @dataclasses.dataclass 47 | class ProcessLaunchInfo: 48 | """ 49 | This class is used to store the information required to launch a process. 50 | """ 51 | 52 | # The command to launch the process 53 | cmd: str | list[str] 54 | 55 | # The environment variables to set for the process 56 | env: dict[str, str] = dataclasses.field(default_factory=dict) 57 | 58 | # The working directory for the process 59 | cwd: str = os.getcwd() 60 | 61 | 62 | class LSPError(Exception): 63 | def __init__(self, code: ErrorCodes, message: str) -> None: 64 | super().__init__(message) 65 | self.code = code 66 | 67 | def to_lsp(self) -> StringDict: 68 | return {"code": self.code, "message": super().__str__()} 69 | 70 | @classmethod 71 | def from_lsp(cls, d: StringDict) -> "LSPError": 72 | return LSPError(d["code"], d["message"]) 73 | 74 | def __str__(self) -> str: 75 | return f"{super().__str__()} ({self.code})" 76 | 77 | 78 | def make_response(request_id: Any, params: PayloadLike) -> StringDict: 79 | return {"jsonrpc": "2.0", "id": request_id, "result": params} 80 | 81 | 82 | def make_error_response(request_id: Any, err: LSPError) -> StringDict: 83 | return {"jsonrpc": "2.0", "id": request_id, "error": err.to_lsp()} 84 | 85 | 86 | def make_notification(method: str, params: PayloadLike) -> StringDict: 87 | return {"jsonrpc": "2.0", "method": method, "params": params} 88 | 89 | 90 | def make_request(method: str, request_id: Any, params: PayloadLike) -> StringDict: 91 | return {"jsonrpc": "2.0", "method": method, "id": request_id, "params": params} 92 | 93 | 94 | class StopLoopException(Exception): 95 | pass 96 | 97 | 98 | def create_message(payload: PayloadLike): 99 | body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING) 100 | return ( 101 | f"Content-Length: {len(body)}\r\n".encode(ENCODING), 102 | "Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n".encode(ENCODING), 103 | body, 104 | ) 105 | 106 | 107 | class MessageType: 108 | error = 1 109 | warning = 2 110 | info = 3 111 | log = 4 112 | 113 | 114 | def content_length(line: bytes) -> int | None: 115 | if line.startswith(b"Content-Length: "): 116 | _, value = line.split(b"Content-Length: ") 117 | value = value.strip() 118 | try: 119 | return int(value) 120 | except ValueError: 121 | raise ValueError(f"Invalid Content-Length header: {value}") 122 | return None 123 | ``` -------------------------------------------------------------------------------- /.serena/memories/serena_repository_structure.md: -------------------------------------------------------------------------------- ```markdown 1 | # Serena Repository Structure 2 | 3 | ## Overview 4 | Serena is a multi-language code assistant that combines two main components: 5 | 1. **Serena Core** - The main agent framework with tools and MCP server 6 | 2. **SolidLSP** - A unified Language Server Protocol wrapper for multiple programming languages 7 | 8 | ## Top-Level Structure 9 | 10 | ``` 11 | serena/ 12 | ├── src/ # Main source code 13 | │ ├── serena/ # Serena agent framework 14 | │ ├── solidlsp/ # LSP wrapper library 15 | │ └── interprompt/ # Multi-language prompt templates 16 | ├── test/ # Test suites 17 | │ ├── serena/ # Serena agent tests 18 | │ ├── solidlsp/ # Language server tests 19 | │ └── resources/repos/ # Test repositories for each language 20 | ├── scripts/ # Build and utility scripts 21 | ├── resources/ # Static resources and configurations 22 | ├── pyproject.toml # Python project configuration 23 | ├── README.md # Project documentation 24 | └── CHANGELOG.md # Version history 25 | ``` 26 | 27 | ## Source Code Organization 28 | 29 | ### Serena Core (`src/serena/`) 30 | - **`agent.py`** - Main SerenaAgent class that orchestrates everything 31 | - **`tools/`** - MCP tools for file operations, symbols, memory, etc. 32 | - `file_tools.py` - File system operations (read, write, search) 33 | - `symbol_tools.py` - Symbol-based code operations (find, edit) 34 | - `memory_tools.py` - Knowledge persistence and retrieval 35 | - `config_tools.py` - Project and mode management 36 | - `workflow_tools.py` - Onboarding and meta-operations 37 | - **`config/`** - Configuration management 38 | - `serena_config.py` - Main configuration classes 39 | - `context_mode.py` - Context and mode definitions 40 | - **`util/`** - Utility modules 41 | - **`mcp.py`** - MCP server implementation 42 | - **`cli.py`** - Command-line interface 43 | 44 | ### SolidLSP (`src/solidlsp/`) 45 | - **`ls.py`** - Main SolidLanguageServer class 46 | - **`language_servers/`** - Language-specific implementations 47 | - `csharp_language_server.py` - C# (Microsoft.CodeAnalysis.LanguageServer) 48 | - `python_server.py` - Python (Pyright) 49 | - `typescript_language_server.py` - TypeScript 50 | - `rust_analyzer.py` - Rust 51 | - `gopls.py` - Go 52 | - And many more... 53 | - **`ls_config.py`** - Language server configuration 54 | - **`ls_types.py`** - LSP type definitions 55 | - **`ls_utils.py`** - Utilities for working with LSP data 56 | 57 | ### Interprompt (`src/interprompt/`) 58 | - Multi-language prompt template system 59 | - Jinja2-based templating with language fallbacks 60 | 61 | ## Test Structure 62 | 63 | ### Language Server Tests (`test/solidlsp/`) 64 | Each language has its own test directory: 65 | ``` 66 | test/solidlsp/ 67 | ├── csharp/ 68 | │ └── test_csharp_basic.py 69 | ├── python/ 70 | │ └── test_python_basic.py 71 | ├── typescript/ 72 | │ └── test_typescript_basic.py 73 | └── ... 74 | ``` 75 | 76 | ### Test Resources (`test/resources/repos/`) 77 | Contains minimal test projects for each language: 78 | ``` 79 | test/resources/repos/ 80 | ├── csharp/test_repo/ 81 | │ ├── serena.sln 82 | │ ├── TestProject.csproj 83 | │ ├── Program.cs 84 | │ └── Models/Person.cs 85 | ├── python/test_repo/ 86 | ├── typescript/test_repo/ 87 | └── ... 88 | ``` 89 | 90 | ### Test Infrastructure 91 | - **`test/conftest.py`** - Shared test fixtures and utilities 92 | - **`create_ls()`** function - Creates language server instances for testing 93 | - **`language_server` fixture** - Parametrized fixture for multi-language tests 94 | 95 | ## Key Configuration Files 96 | 97 | - **`pyproject.toml`** - Python dependencies, build config, and tool settings 98 | - **`.serena/`** directories - Project-specific Serena configuration and memories 99 | - **`CLAUDE.md`** - Instructions for AI assistants working on the project 100 | 101 | ## Dependencies Management 102 | 103 | The project uses modern Python tooling: 104 | - **uv** for fast dependency resolution and virtual environments 105 | - **pytest** for testing with language-specific markers (`@pytest.mark.csharp`) 106 | - **ruff** for linting and formatting 107 | - **mypy** for type checking 108 | 109 | ## Build and Development 110 | 111 | - **Docker support** - Full containerized development environment 112 | - **GitHub Actions** - CI/CD with language server testing 113 | - **Development scripts** in `scripts/` directory ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "RoslynExtensionsOptions": { 3 | "EnableDecompilationSupport": false, 4 | "EnableAnalyzersSupport": true, 5 | "EnableImportCompletion": true, 6 | "EnableAsyncCompletion": false, 7 | "DocumentAnalysisTimeoutMs": 30000, 8 | "DiagnosticWorkersThreadCount": 18, 9 | "AnalyzeOpenDocumentsOnly": true, 10 | "InlayHintsOptions": { 11 | "EnableForParameters": false, 12 | "ForLiteralParameters": false, 13 | "ForIndexerParameters": false, 14 | "ForObjectCreationParameters": false, 15 | "ForOtherParameters": false, 16 | "SuppressForParametersThatDifferOnlyBySuffix": false, 17 | "SuppressForParametersThatMatchMethodIntent": false, 18 | "SuppressForParametersThatMatchArgumentName": false, 19 | "EnableForTypes": false, 20 | "ForImplicitVariableTypes": false, 21 | "ForLambdaParameterTypes": false, 22 | "ForImplicitObjectCreation": false 23 | }, 24 | "LocationPaths": null 25 | }, 26 | "FormattingOptions": { 27 | "OrganizeImports": false, 28 | "EnableEditorConfigSupport": true, 29 | "NewLine": "\n", 30 | "UseTabs": false, 31 | "TabSize": 4, 32 | "IndentationSize": 4, 33 | "SpacingAfterMethodDeclarationName": false, 34 | "SeparateImportDirectiveGroups": false, 35 | "SpaceWithinMethodDeclarationParenthesis": false, 36 | "SpaceBetweenEmptyMethodDeclarationParentheses": false, 37 | "SpaceAfterMethodCallName": false, 38 | "SpaceWithinMethodCallParentheses": false, 39 | "SpaceBetweenEmptyMethodCallParentheses": false, 40 | "SpaceAfterControlFlowStatementKeyword": true, 41 | "SpaceWithinExpressionParentheses": false, 42 | "SpaceWithinCastParentheses": false, 43 | "SpaceWithinOtherParentheses": false, 44 | "SpaceAfterCast": false, 45 | "SpaceBeforeOpenSquareBracket": false, 46 | "SpaceBetweenEmptySquareBrackets": false, 47 | "SpaceWithinSquareBrackets": false, 48 | "SpaceAfterColonInBaseTypeDeclaration": true, 49 | "SpaceAfterComma": true, 50 | "SpaceAfterDot": false, 51 | "SpaceAfterSemicolonsInForStatement": true, 52 | "SpaceBeforeColonInBaseTypeDeclaration": true, 53 | "SpaceBeforeComma": false, 54 | "SpaceBeforeDot": false, 55 | "SpaceBeforeSemicolonsInForStatement": false, 56 | "SpacingAroundBinaryOperator": "single", 57 | "IndentBraces": false, 58 | "IndentBlock": true, 59 | "IndentSwitchSection": true, 60 | "IndentSwitchCaseSection": true, 61 | "IndentSwitchCaseSectionWhenBlock": true, 62 | "LabelPositioning": "oneLess", 63 | "WrappingPreserveSingleLine": true, 64 | "WrappingKeepStatementsOnSingleLine": true, 65 | "NewLinesForBracesInTypes": true, 66 | "NewLinesForBracesInMethods": true, 67 | "NewLinesForBracesInProperties": true, 68 | "NewLinesForBracesInAccessors": true, 69 | "NewLinesForBracesInAnonymousMethods": true, 70 | "NewLinesForBracesInControlBlocks": true, 71 | "NewLinesForBracesInAnonymousTypes": true, 72 | "NewLinesForBracesInObjectCollectionArrayInitializers": true, 73 | "NewLinesForBracesInLambdaExpressionBody": true, 74 | "NewLineForElse": true, 75 | "NewLineForCatch": true, 76 | "NewLineForFinally": true, 77 | "NewLineForMembersInObjectInit": true, 78 | "NewLineForMembersInAnonymousTypes": true, 79 | "NewLineForClausesInQuery": true 80 | }, 81 | "FileOptions": { 82 | "SystemExcludeSearchPatterns": [ 83 | "**/node_modules/**/*", 84 | "**/bin/**/*", 85 | "**/obj/**/*", 86 | "**/.git/**/*", 87 | "**/.git", 88 | "**/.svn", 89 | "**/.hg", 90 | "**/CVS", 91 | "**/.DS_Store", 92 | "**/Thumbs.db" 93 | ], 94 | "ExcludeSearchPatterns": [] 95 | }, 96 | "RenameOptions": { 97 | "RenameOverloads": false, 98 | "RenameInStrings": false, 99 | "RenameInComments": false 100 | }, 101 | "ImplementTypeOptions": { 102 | "InsertionBehavior": 0, 103 | "PropertyGenerationBehavior": 0 104 | }, 105 | "DotNetCliOptions": { 106 | "LocationPaths": null 107 | }, 108 | "Plugins": { 109 | "LocationPaths": null 110 | } 111 | } ``` -------------------------------------------------------------------------------- /test/solidlsp/perl/test_perl_basic.py: -------------------------------------------------------------------------------- ```python 1 | import platform 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from solidlsp import SolidLanguageServer 7 | from solidlsp.ls_config import Language 8 | 9 | 10 | @pytest.mark.perl 11 | @pytest.mark.skipif(platform.system() == "Windows", reason="Perl::LanguageServer does not support native Windows operation") 12 | class TestPerlLanguageServer: 13 | """ 14 | Tests for Perl::LanguageServer integration. 15 | 16 | Perl::LanguageServer provides comprehensive LSP support for Perl including: 17 | - Document symbols (functions, variables) 18 | - Go to definition (including cross-file) 19 | - Find references (including cross-file) - this was not available in PLS 20 | """ 21 | 22 | @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) 23 | @pytest.mark.parametrize("repo_path", [Language.PERL], indirect=True) 24 | def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 25 | """Test that the language server starts and stops successfully.""" 26 | # The fixture already handles start and stop 27 | assert language_server.is_running() 28 | assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() 29 | 30 | @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) 31 | def test_document_symbols(self, language_server: SolidLanguageServer) -> None: 32 | """Test that document symbols are correctly identified.""" 33 | # Request document symbols 34 | all_symbols, _ = language_server.request_document_symbols("main.pl", include_body=False) 35 | 36 | assert all_symbols, "Expected to find symbols in main.pl" 37 | assert len(all_symbols) > 0, "Expected at least one symbol" 38 | 39 | # DEBUG: Print all symbols 40 | print("\n=== All symbols in main.pl ===") 41 | for s in all_symbols: 42 | line = s.get("range", {}).get("start", {}).get("line", "?") 43 | print(f"Line {line}: {s.get('name')} (kind={s.get('kind')})") 44 | 45 | # Check that we can find function symbols 46 | function_symbols = [s for s in all_symbols if s.get("kind") == 12] # 12 = Function/Method 47 | assert len(function_symbols) >= 2, f"Expected at least 2 functions (greet, use_helper_function), found {len(function_symbols)}" 48 | 49 | function_names = [s.get("name") for s in function_symbols] 50 | assert "greet" in function_names, f"Expected 'greet' function in symbols, found: {function_names}" 51 | assert "use_helper_function" in function_names, f"Expected 'use_helper_function' in symbols, found: {function_names}" 52 | 53 | # @pytest.mark.skip(reason="Perl::LanguageServer cross-file definition tracking needs configuration") 54 | @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) 55 | def test_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: 56 | definition_location_list = language_server.request_definition("main.pl", 17, 0) 57 | 58 | assert len(definition_location_list) == 1 59 | definition_location = definition_location_list[0] 60 | print(f"Found definition: {definition_location}") 61 | assert definition_location["uri"].endswith("helper.pl") 62 | assert definition_location["range"]["start"]["line"] == 4 # add method on line 2 (0-indexed 1) 63 | 64 | @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) 65 | def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: 66 | """Test finding references to a function across multiple files.""" 67 | reference_locations = language_server.request_references("helper.pl", 4, 5) 68 | 69 | assert len(reference_locations) >= 2, f"Expected at least 2 references to helper_function, found {len(reference_locations)}" 70 | 71 | main_pl_refs = [ref for ref in reference_locations if ref["uri"].endswith("main.pl")] 72 | assert len(main_pl_refs) >= 2, f"Expected at least 2 references in main.pl, found {len(main_pl_refs)}" 73 | 74 | main_pl_lines = sorted([ref["range"]["start"]["line"] for ref in main_pl_refs]) 75 | assert 17 in main_pl_lines, f"Expected reference at line 18 (0-indexed 17), found: {main_pl_lines}" 76 | assert 20 in main_pl_lines, f"Expected reference at line 21 (0-indexed 20), found: {main_pl_lines}" 77 | ``` -------------------------------------------------------------------------------- /src/solidlsp/util/zip.py: -------------------------------------------------------------------------------- ```python 1 | import fnmatch 2 | import logging 3 | import os 4 | import sys 5 | import zipfile 6 | from pathlib import Path 7 | from typing import Optional 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class SafeZipExtractor: 13 | """ 14 | A utility class for extracting ZIP archives safely. 15 | 16 | Features: 17 | - Handles long file paths on Windows 18 | - Skips files that fail to extract, continuing with the rest 19 | - Creates necessary directories automatically 20 | - Optional include/exclude pattern filters 21 | """ 22 | 23 | def __init__( 24 | self, 25 | archive_path: Path, 26 | extract_dir: Path, 27 | verbose: bool = True, 28 | include_patterns: Optional[list[str]] = None, 29 | exclude_patterns: Optional[list[str]] = None, 30 | ) -> None: 31 | """ 32 | Initialize the SafeZipExtractor. 33 | 34 | :param archive_path: Path to the ZIP archive file 35 | :param extract_dir: Directory where files will be extracted 36 | :param verbose: Whether to log status messages 37 | :param include_patterns: List of glob patterns for files to extract (None = all files) 38 | :param exclude_patterns: List of glob patterns for files to skip 39 | """ 40 | self.archive_path = Path(archive_path) 41 | self.extract_dir = Path(extract_dir) 42 | self.verbose = verbose 43 | self.include_patterns = include_patterns or [] 44 | self.exclude_patterns = exclude_patterns or [] 45 | 46 | def extract_all(self) -> None: 47 | """ 48 | Extract all files from the archive, skipping any that fail. 49 | """ 50 | if not self.archive_path.exists(): 51 | raise FileNotFoundError(f"Archive not found: {self.archive_path}") 52 | 53 | if self.verbose: 54 | log.info(f"Extracting from: {self.archive_path} to {self.extract_dir}") 55 | 56 | with zipfile.ZipFile(self.archive_path, "r") as zip_ref: 57 | for member in zip_ref.infolist(): 58 | if self._should_extract(member.filename): 59 | self._extract_member(zip_ref, member) 60 | elif self.verbose: 61 | log.info(f"Skipped: {member.filename}") 62 | 63 | def _should_extract(self, filename: str) -> bool: 64 | """ 65 | Determine whether a file should be extracted based on include/exclude patterns. 66 | 67 | :param filename: The file name from the archive 68 | :return: True if the file should be extracted 69 | """ 70 | # If include_patterns is set, only extract if it matches at least one pattern 71 | if self.include_patterns: 72 | if not any(fnmatch.fnmatch(filename, pattern) for pattern in self.include_patterns): 73 | return False 74 | 75 | # If exclude_patterns is set, skip if it matches any pattern 76 | if self.exclude_patterns: 77 | if any(fnmatch.fnmatch(filename, pattern) for pattern in self.exclude_patterns): 78 | return False 79 | 80 | return True 81 | 82 | def _extract_member(self, zip_ref: zipfile.ZipFile, member: zipfile.ZipInfo) -> None: 83 | """ 84 | Extract a single member from the archive with error handling. 85 | 86 | :param zip_ref: Open ZipFile object 87 | :param member: ZipInfo object representing the file 88 | """ 89 | try: 90 | target_path = self.extract_dir / member.filename 91 | 92 | # Ensure directory structure exists 93 | target_path.parent.mkdir(parents=True, exist_ok=True) 94 | 95 | # Handle long paths on Windows 96 | final_path = self._normalize_path(target_path) 97 | 98 | # Extract file 99 | with zip_ref.open(member) as source, open(final_path, "wb") as target: 100 | target.write(source.read()) 101 | 102 | if self.verbose: 103 | log.info(f"Extracted: {member.filename}") 104 | 105 | except Exception as e: 106 | log.error(f"Failed to extract {member.filename}: {e}") 107 | 108 | @staticmethod 109 | def _normalize_path(path: Path) -> Path: 110 | """ 111 | Adjust path to handle long paths on Windows. 112 | 113 | :param path: Original path 114 | :return: Normalized path 115 | """ 116 | if sys.platform.startswith("win"): 117 | return Path(rf"\\?\{os.path.abspath(path)}") 118 | return path 119 | 120 | 121 | # Example usage: 122 | # extractor = SafeZipExtractor( 123 | # archive_path=Path("file.nupkg"), 124 | # extract_dir=Path("extract_dir"), 125 | # include_patterns=["*.dll", "*.xml"], 126 | # exclude_patterns=["*.pdb"] 127 | # ) 128 | # extractor.extract_all() 129 | ``` -------------------------------------------------------------------------------- /.serena/project.yml: -------------------------------------------------------------------------------- ```yaml 1 | # language of the project (csharp, python, rust, java, typescript, javascript, go, cpp, or ruby) 2 | # Special requirements: 3 | # * csharp: Requires the presence of a .sln file in the project folder. 4 | language: python 5 | 6 | # whether to use the project's gitignore file to ignore files 7 | # Added on 2025-04-07 8 | ignore_all_files_in_gitignore: true 9 | # list of additional paths to ignore 10 | # same syntax as gitignore, so you can use * and ** 11 | # Was previously called `ignored_dirs`, please update your config if you are using that. 12 | # Added (renamed)on 2025-04-07 13 | ignored_paths: [] 14 | 15 | # whether the project is in read-only mode 16 | # If set to true, all editing tools will be disabled and attempts to use them will result in an error 17 | # Added on 2025-04-18 18 | read_only: false 19 | 20 | 21 | # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. 22 | # Below is the complete list of tools for convenience. 23 | # To make sure you have the latest list of tools, and to view their descriptions, 24 | # execute `uv run scripts/print_tool_overview.py`. 25 | # 26 | # * `activate_project`: Activates a project by name. 27 | # * `check_onboarding_performed`: Checks whether project onboarding was already performed. 28 | # * `create_text_file`: Creates/overwrites a file in the project directory. 29 | # * `delete_lines`: Deletes a range of lines within a file. 30 | # * `delete_memory`: Deletes a memory from Serena's project-specific memory store. 31 | # * `execute_shell_command`: Executes a shell command. 32 | # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. 33 | # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). 34 | # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). 35 | # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. 36 | # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. 37 | # * `initial_instructions`: Gets the initial instructions for the current project. 38 | # Should only be used in settings where the system prompt cannot be set, 39 | # e.g. in clients you have no control over, like Claude Desktop. 40 | # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. 41 | # * `insert_at_line`: Inserts content at a given line in a file. 42 | # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. 43 | # * `list_dir`: Lists files and directories in the given directory (optionally with recursion). 44 | # * `list_memories`: Lists memories in Serena's project-specific memory store. 45 | # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). 46 | # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). 47 | # * `read_file`: Reads a file within the project directory. 48 | # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. 49 | # * `remove_project`: Removes a project from the Serena configuration. 50 | # * `replace_lines`: Replaces a range of lines within a file with new content. 51 | # * `replace_symbol_body`: Replaces the full definition of a symbol. 52 | # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. 53 | # * `search_for_pattern`: Performs a search for a pattern in the project. 54 | # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. 55 | # * `switch_modes`: Activates modes by providing a list of their names 56 | # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. 57 | # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. 58 | # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. 59 | # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. 60 | excluded_tools: [] 61 | 62 | # initial prompt for the project. It will always be given to the LLM upon activating the project 63 | # (contrary to the memories, which are loaded on demand). 64 | initial_prompt: "" 65 | 66 | project_name: "serena" 67 | ``` -------------------------------------------------------------------------------- /src/serena/resources/project.template.yml: -------------------------------------------------------------------------------- ```yaml 1 | # language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) 2 | # * For C, use cpp 3 | # * For JavaScript, use typescript 4 | # Special requirements: 5 | # * csharp: Requires the presence of a .sln file in the project folder. 6 | language: python 7 | 8 | # whether to use the project's gitignore file to ignore files 9 | # Added on 2025-04-07 10 | ignore_all_files_in_gitignore: true 11 | # list of additional paths to ignore 12 | # same syntax as gitignore, so you can use * and ** 13 | # Was previously called `ignored_dirs`, please update your config if you are using that. 14 | # Added (renamed) on 2025-04-07 15 | ignored_paths: [] 16 | 17 | # whether the project is in read-only mode 18 | # If set to true, all editing tools will be disabled and attempts to use them will result in an error 19 | # Added on 2025-04-18 20 | read_only: false 21 | 22 | # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. 23 | # Below is the complete list of tools for convenience. 24 | # To make sure you have the latest list of tools, and to view their descriptions, 25 | # execute `uv run scripts/print_tool_overview.py`. 26 | # 27 | # * `activate_project`: Activates a project by name. 28 | # * `check_onboarding_performed`: Checks whether project onboarding was already performed. 29 | # * `create_text_file`: Creates/overwrites a file in the project directory. 30 | # * `delete_lines`: Deletes a range of lines within a file. 31 | # * `delete_memory`: Deletes a memory from Serena's project-specific memory store. 32 | # * `execute_shell_command`: Executes a shell command. 33 | # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. 34 | # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). 35 | # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). 36 | # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. 37 | # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. 38 | # * `initial_instructions`: Gets the initial instructions for the current project. 39 | # Should only be used in settings where the system prompt cannot be set, 40 | # e.g. in clients you have no control over, like Claude Desktop. 41 | # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. 42 | # * `insert_at_line`: Inserts content at a given line in a file. 43 | # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. 44 | # * `list_dir`: Lists files and directories in the given directory (optionally with recursion). 45 | # * `list_memories`: Lists memories in Serena's project-specific memory store. 46 | # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). 47 | # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). 48 | # * `read_file`: Reads a file within the project directory. 49 | # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. 50 | # * `remove_project`: Removes a project from the Serena configuration. 51 | # * `replace_lines`: Replaces a range of lines within a file with new content. 52 | # * `replace_symbol_body`: Replaces the full definition of a symbol. 53 | # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. 54 | # * `search_for_pattern`: Performs a search for a pattern in the project. 55 | # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. 56 | # * `switch_modes`: Activates modes by providing a list of their names 57 | # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. 58 | # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. 59 | # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. 60 | # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. 61 | excluded_tools: [] 62 | 63 | # initial prompt for the project. It will always be given to the LLM upon activating the project 64 | # (contrary to the memories, which are loaded on demand). 65 | initial_prompt: "" 66 | 67 | project_name: "project_name" 68 | ``` -------------------------------------------------------------------------------- /lessons_learned.md: -------------------------------------------------------------------------------- ```markdown 1 | # Lessons Learned 2 | 3 | In this document we briefly collect what we have learned while developing and using Serena, 4 | what works well and what doesn't. 5 | 6 | ## What Worked 7 | 8 | ### Separate Tool Logic From MCP Implementation 9 | 10 | MCP is just another protocol, one should let the details of it creep into the application logic. 11 | The official docs suggest using function annotations to define tools and prompts. While that may be 12 | useful for small projects to get going fast, it is not wise for more serious projects. In Serena, 13 | all tools are defined independently and then converted to instances of `MCPTool` using our `make_tool` 14 | function. 15 | 16 | ### Autogenerated PromptFactory 17 | 18 | Prompt templates are central for most LLM applications, so one needs good representations of them in the code, 19 | while at the same time they often need to be customizable and exposed to users. In Serena we address these conflicting 20 | needs by defining prompt templates (in jinja format) in separate yamls that users can easily modify and by autogenerated 21 | a `PromptFactory` class with meaningful method and parameter names from these yamls. The latter is committed to our code. 22 | We separated out the generation logic into the [interprompt](/src/interprompt/README.md) subpackage that can be used as a library. 23 | 24 | ### Tempfiles and Snapshots for Testing of Editing Tools 25 | 26 | We test most aspects of Serena by having a small "project" for each supported language in `tests/resources`. 27 | For the editing tools, which would change the code in these projects, we use tempfiles to copy over the code. 28 | The pretty awesome [syrupy](https://github.com/syrupy-project/syrupy) pytest plugin helped in developing 29 | snapshot tests. 30 | 31 | ### Dashboard and GUI for Logging 32 | 33 | It is very useful to know what the MCP Server is doing. We collect and display logs in a GUI or a web dashboard, 34 | which helps a lot in seeing what's going on and in identifying any issues. 35 | 36 | ### Unrestricted Bash Tool 37 | 38 | We know it's not particularly safe to permit unlimited shell commands outside a sandbox, but we did quite some 39 | evaluations and so far... nothing bad has happened. Seems like the current versions of the AI overlords rarely want to execute `sudo rm - rf /`. 40 | Still, we are working on a safer approach as well as better integration with sandboxing. 41 | 42 | ### Multilspy 43 | 44 | The [multilspy](https://github.com/microsoft/multilspy/) project helped us a lot in getting started and stands at the core of Serena. 45 | Many more well known python implementations of language servers were subpar in code quality and design (for example, missing types). 46 | 47 | ### Developing Serena with Serena 48 | 49 | We clearly notice that the better the tool gets, the easier it is to make it even better 50 | 51 | ## Prompting 52 | 53 | ### Shouting and Emotive Language May Be Needed 54 | 55 | When developing the `ReplaceRegexTool` we were initially not able to make Claude 4 (in Claude Desktop) use wildcards to save on output tokens. Neither 56 | examples nor explicit instructions helped. It was only after adding 57 | 58 | ``` 59 | IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD! 60 | ``` 61 | 62 | to the initial instructions and to the tool description that Claude finally started following the instructions. 63 | 64 | ## What Didn't Work 65 | 66 | ### Lifespan Handling by MCP Clients 67 | 68 | The MCP technology is clearly very green. Even though there is a lifespan context in the MCP SDK, 69 | many clients, including Claude Desktop, fail to properly clean up, leaving zombie processes behind. 70 | We mitigate this through the GUI window and the dashboard, so the user sees whether Serena is running 71 | and can terminate it there. 72 | 73 | ### Trusting Asyncio 74 | 75 | Running multiple asyncio apps led to non-deterministic 76 | event loop contamination and deadlocks, which were very hard to debug 77 | and understand. We solved this with a large hammer, by putting all asyncio apps into a separate 78 | process. It made the code much more complex and slightly enhanced RAM requirements, but it seems 79 | like that was the only way to reliably overcome asyncio deadlock issues. 80 | 81 | ### Cross-OS Tkinter GUI 82 | 83 | Different OS have different limitations when it comes to starting a window or dealing with Tkinter 84 | installations. This was so messy to get right that we pivoted to a web-dashboard instead 85 | 86 | ### Editing Based on Line Numbers 87 | 88 | Not only are LLMs notoriously bad in counting, but also the line numbers change after edit operations, 89 | and LLMs are also often too dumb to understand that they should update the line numbers information they had 90 | received before. We pivoted to string-matching and symbol-name based editing. ``` -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | from pathlib import Path 3 | 4 | import pytest 5 | from sensai.util.logging import configure 6 | 7 | from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME 8 | from serena.project import Project 9 | from serena.util.file_system import GitignoreParser 10 | from solidlsp.ls import SolidLanguageServer 11 | from solidlsp.ls_config import Language, LanguageServerConfig 12 | from solidlsp.ls_logger import LanguageServerLogger 13 | from solidlsp.settings import SolidLSPSettings 14 | 15 | configure(level=logging.ERROR) 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def resources_dir() -> Path: 20 | """Path to the test resources directory.""" 21 | current_dir = Path(__file__).parent 22 | return current_dir / "resources" 23 | 24 | 25 | class LanguageParamRequest: 26 | param: Language 27 | 28 | 29 | def get_repo_path(language: Language) -> Path: 30 | return Path(__file__).parent / "resources" / "repos" / language / "test_repo" 31 | 32 | 33 | def create_ls( 34 | language: Language, 35 | repo_path: str | None = None, 36 | ignored_paths: list[str] | None = None, 37 | trace_lsp_communication: bool = False, 38 | log_level: int = logging.ERROR, 39 | ) -> SolidLanguageServer: 40 | ignored_paths = ignored_paths or [] 41 | if repo_path is None: 42 | repo_path = str(get_repo_path(language)) 43 | gitignore_parser = GitignoreParser(str(repo_path)) 44 | for spec in gitignore_parser.get_ignore_specs(): 45 | ignored_paths.extend(spec.patterns) 46 | config = LanguageServerConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication) 47 | logger = LanguageServerLogger(log_level=log_level) 48 | return SolidLanguageServer.create( 49 | config, 50 | logger, 51 | repo_path, 52 | solidlsp_settings=SolidLSPSettings(solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, project_data_relative_path=SERENA_MANAGED_DIR_NAME), 53 | ) 54 | 55 | 56 | def create_default_ls(language: Language) -> SolidLanguageServer: 57 | repo_path = str(get_repo_path(language)) 58 | return create_ls(language, repo_path) 59 | 60 | 61 | def create_default_project(language: Language) -> Project: 62 | repo_path = str(get_repo_path(language)) 63 | return Project.load(repo_path) 64 | 65 | 66 | @pytest.fixture(scope="session") 67 | def repo_path(request: LanguageParamRequest) -> Path: 68 | """Get the repository path for a specific language. 69 | 70 | This fixture requires a language parameter via pytest.mark.parametrize: 71 | 72 | Example: 73 | ``` 74 | @pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True) 75 | def test_python_repo(repo_path): 76 | assert (repo_path / "src").exists() 77 | ``` 78 | 79 | """ 80 | if not hasattr(request, "param"): 81 | raise ValueError("Language parameter must be provided via pytest.mark.parametrize") 82 | 83 | language = request.param 84 | return get_repo_path(language) 85 | 86 | 87 | @pytest.fixture(scope="session") 88 | def language_server(request: LanguageParamRequest): 89 | """Create a language server instance configured for the specified language. 90 | 91 | This fixture requires a language parameter via pytest.mark.parametrize: 92 | 93 | Example: 94 | ``` 95 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 96 | def test_python_server(language_server: SyncLanguageServer) -> None: 97 | # Use the Python language server 98 | pass 99 | ``` 100 | 101 | You can also test multiple languages in a single test: 102 | ``` 103 | @pytest.mark.parametrize("language_server", [Language.PYTHON, Language.TYPESCRIPT], indirect=True) 104 | def test_multiple_languages(language_server: SyncLanguageServer) -> None: 105 | # This test will run once for each language 106 | pass 107 | ``` 108 | 109 | """ 110 | if not hasattr(request, "param"): 111 | raise ValueError("Language parameter must be provided via pytest.mark.parametrize") 112 | 113 | language = request.param 114 | server = create_default_ls(language) 115 | server.start() 116 | try: 117 | yield server 118 | finally: 119 | server.stop() 120 | 121 | 122 | @pytest.fixture(scope="session") 123 | def project(request: LanguageParamRequest): 124 | """Create a Project for the specified language. 125 | 126 | This fixture requires a language parameter via pytest.mark.parametrize: 127 | 128 | Example: 129 | ``` 130 | @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True) 131 | def test_python_project(project: Project) -> None: 132 | # Use the Python project to test something 133 | pass 134 | ``` 135 | 136 | You can also test multiple languages in a single test: 137 | ``` 138 | @pytest.mark.parametrize("project", [Language.PYTHON, Language.TYPESCRIPT], indirect=True) 139 | def test_multiple_languages(project: SyncLanguageServer) -> None: 140 | # This test will run once for each language 141 | pass 142 | ``` 143 | 144 | """ 145 | if not hasattr(request, "param"): 146 | raise ValueError("Language parameter must be provided via pytest.mark.parametrize") 147 | 148 | language = request.param 149 | yield create_default_project(language) 150 | ``` -------------------------------------------------------------------------------- /src/serena/resources/serena_config.template.yml: -------------------------------------------------------------------------------- ```yaml 1 | gui_log_window: False 2 | # whether to open a graphical window with Serena's logs. 3 | # This is mainly supported on Windows and (partly) on Linux; not available on macOS. 4 | # If you want to see the logs in a web browser, use the `web_dashboard` option instead. 5 | # Limitations: doesn't seem to work with the community version of Claude Desktop for Linux 6 | # Might also cause problems with some MCP clients - if you have any issues, try disabling this 7 | 8 | # Being able to inspect logs is useful both for troubleshooting and for monitoring the tool calls, 9 | # especially when using the agno playground, since the tool calls are not always shown, 10 | # and the input params are never shown in the agno UI. 11 | # When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. 12 | # Note: unfortunately, the various entities starting the Serena server or agent do so in 13 | # mysterious ways, often starting multiple instances of the process without shutting down 14 | # previous instances. This can lead to multiple log windows being opened, and only the last 15 | # window being updated. Since we can't control how agno or Claude Desktop start Serena, 16 | # we have to live with this limitation for now. 17 | 18 | web_dashboard: True 19 | # whether to open the Serena web dashboard (which will be accessible through your web browser) that 20 | # shows Serena's current session logs - as an alternative to the GUI log window which 21 | # is supported on all platforms. 22 | 23 | web_dashboard_open_on_launch: True 24 | # whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard 25 | # is enabled). If set to False, you can still open the dashboard manually by navigating to 26 | # http://localhost:24282/dashboard/ in your web browser (24282 = 0x5EDA, SErena DAshboard). 27 | # If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc. 28 | 29 | log_level: 20 30 | # the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error) 31 | 32 | trace_lsp_communication: False 33 | # whether to trace the communication between Serena and the language servers. 34 | # This is useful for debugging language server issues. 35 | 36 | ls_specific_settings: {} 37 | # Added on 23.08.2025 38 | # Advanced configuration option allowing to configure language server implementation specific options. Maps the language 39 | # (same entry as in project.yml) to the options. 40 | # 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. 41 | # No documentation on options means no options are available. 42 | # 43 | 44 | tool_timeout: 240 45 | # timeout, in seconds, after which tool executions are terminated 46 | 47 | excluded_tools: [] 48 | # list of tools to be globally excluded 49 | 50 | included_optional_tools: [] 51 | # list of optional tools (which are disabled by default) to be included 52 | 53 | jetbrains: False 54 | # whether to enable JetBrains mode and use tools based on the Serena JetBrains IDE plugin 55 | # instead of language server-based tools 56 | # NOTE: The plugin is yet unreleased. This is for Serena developers only. 57 | 58 | 59 | default_max_tool_answer_chars: 150000 60 | # Used as default for tools where the apply method has a default maximal answer length. 61 | # Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default 62 | # through the global configuration. 63 | 64 | record_tool_usage_stats: False 65 | # whether to record tool usage statistics, they will be shown in the web dashboard if recording is active. 66 | 67 | token_count_estimator: TIKTOKEN_GPT4O 68 | # Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics. 69 | # See the `RegisteredTokenCountEstimator` enum for available options. 70 | # 71 | # Note: some token estimators (like tiktoken) may require downloading data files 72 | # on the first run, which can take some time and require internet access. Others, like the Anthropic ones, may require an API key 73 | # and rate limits may apply. 74 | 75 | 76 | # MANAGED BY SERENA, KEEP AT THE BOTTOM OF THE YAML AND DON'T EDIT WITHOUT NEED 77 | # The list of registered projects. 78 | # To add a project, within a chat, simply ask Serena to "activate the project /path/to/project" or, 79 | # if the project was previously added, "activate the project <project name>". 80 | # By default, the project's name will be the name of the directory containing the project, but you may change it 81 | # by editing the (auto-generated) project configuration file `/path/project/project/.serena/project.yml` file. 82 | # If you want to maintain full control of the project configuration, create the project.yml file manually and then 83 | # instruct Serena to activate the project by its path for first-time activation. 84 | # NOTE: Make sure there are no name collisions in the names of registered projects. 85 | projects: [] 86 | ``` -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/examples/user_management.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Example demonstrating user management with the test_repo module. 3 | 4 | This example showcases: 5 | - Creating and managing users 6 | - Using various object types and relationships 7 | - Type annotations and complex Python patterns 8 | """ 9 | 10 | import logging 11 | from dataclasses import dataclass 12 | from typing import Any 13 | 14 | from test_repo.models import User, create_user_object 15 | from test_repo.services import UserService 16 | 17 | # Set up logging 18 | logging.basicConfig(level=logging.INFO) 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | @dataclass 23 | class UserStats: 24 | """Statistics about user activity.""" 25 | 26 | user_id: str 27 | login_count: int = 0 28 | last_active_days: int = 0 29 | engagement_score: float = 0.0 30 | 31 | def is_active(self) -> bool: 32 | """Check if the user is considered active.""" 33 | return self.last_active_days < 30 34 | 35 | 36 | class UserManager: 37 | """Example class demonstrating complex user management.""" 38 | 39 | def __init__(self, service: UserService): 40 | self.service = service 41 | self.active_users: dict[str, User] = {} 42 | self.user_stats: dict[str, UserStats] = {} 43 | 44 | def register_user(self, name: str, email: str, roles: list[str] | None = None) -> User: 45 | """Register a new user.""" 46 | logger.info(f"Registering new user: {name} ({email})") 47 | user = self.service.create_user(name=name, email=email, roles=roles) 48 | self.active_users[user.id] = user 49 | self.user_stats[user.id] = UserStats(user_id=user.id) 50 | return user 51 | 52 | def get_user(self, user_id: str) -> User | None: 53 | """Get a user by ID.""" 54 | if user_id in self.active_users: 55 | return self.active_users[user_id] 56 | 57 | # Try to fetch from service 58 | user = self.service.get_user(user_id) 59 | if user: 60 | self.active_users[user.id] = user 61 | return user 62 | 63 | def update_user_stats(self, user_id: str, login_count: int, days_since_active: int) -> None: 64 | """Update statistics for a user.""" 65 | if user_id not in self.user_stats: 66 | self.user_stats[user_id] = UserStats(user_id=user_id) 67 | 68 | stats = self.user_stats[user_id] 69 | stats.login_count = login_count 70 | stats.last_active_days = days_since_active 71 | 72 | # Calculate engagement score based on activity 73 | engagement = (100 - min(days_since_active, 100)) * 0.8 74 | engagement += min(login_count, 20) * 0.2 75 | stats.engagement_score = engagement 76 | 77 | def get_active_users(self) -> list[User]: 78 | """Get all active users.""" 79 | active_user_ids = [user_id for user_id, stats in self.user_stats.items() if stats.is_active()] 80 | return [self.active_users[user_id] for user_id in active_user_ids if user_id in self.active_users] 81 | 82 | def get_user_by_email(self, email: str) -> User | None: 83 | """Find a user by their email address.""" 84 | for user in self.active_users.values(): 85 | if user.email == email: 86 | return user 87 | return None 88 | 89 | 90 | # Example function demonstrating type annotations 91 | def process_user_data(users: list[User], include_inactive: bool = False, transform_func: callable | None = None) -> dict[str, Any]: 92 | """Process user data with optional transformations.""" 93 | result: dict[str, Any] = {"users": [], "total": 0, "admin_count": 0} 94 | 95 | for user in users: 96 | if transform_func: 97 | user_data = transform_func(user.to_dict()) 98 | else: 99 | user_data = user.to_dict() 100 | 101 | result["users"].append(user_data) 102 | result["total"] += 1 103 | 104 | if "admin" in user.roles: 105 | result["admin_count"] += 1 106 | 107 | return result 108 | 109 | 110 | def main(): 111 | """Main function demonstrating the usage of UserManager.""" 112 | # Initialize service and manager 113 | service = UserService() 114 | manager = UserManager(service) 115 | 116 | # Register some users 117 | admin = manager.register_user("Admin User", "[email protected]", ["admin"]) 118 | user1 = manager.register_user("Regular User", "[email protected]", ["user"]) 119 | user2 = manager.register_user("Another User", "[email protected]", ["user"]) 120 | 121 | # Update some stats 122 | manager.update_user_stats(admin.id, 100, 5) 123 | manager.update_user_stats(user1.id, 50, 10) 124 | manager.update_user_stats(user2.id, 10, 45) # Inactive user 125 | 126 | # Get active users 127 | active_users = manager.get_active_users() 128 | logger.info(f"Active users: {len(active_users)}") 129 | 130 | # Process user data 131 | user_data = process_user_data(active_users, transform_func=lambda u: {**u, "full_name": u.get("name", "")}) 132 | 133 | logger.info(f"Processed {user_data['total']} users, {user_data['admin_count']} admins") 134 | 135 | # Example of calling create_user directly 136 | external_user = create_user_object(id="ext123", name="External User", email="[email protected]", roles=["external"]) 137 | logger.info(f"Created external user: {external_user.name}") 138 | 139 | 140 | if __name__ == "__main__": 141 | main() 142 | ``` -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/ignore_this_dir_with_postfix/ignored_module.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Example demonstrating user management with the test_repo module. 3 | 4 | This example showcases: 5 | - Creating and managing users 6 | - Using various object types and relationships 7 | - Type annotations and complex Python patterns 8 | """ 9 | 10 | import logging 11 | from dataclasses import dataclass 12 | from typing import Any 13 | 14 | from test_repo.models import User, create_user_object 15 | from test_repo.services import UserService 16 | 17 | # Set up logging 18 | logging.basicConfig(level=logging.INFO) 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | @dataclass 23 | class UserStats: 24 | """Statistics about user activity.""" 25 | 26 | user_id: str 27 | login_count: int = 0 28 | last_active_days: int = 0 29 | engagement_score: float = 0.0 30 | 31 | def is_active(self) -> bool: 32 | """Check if the user is considered active.""" 33 | return self.last_active_days < 30 34 | 35 | 36 | class UserManager: 37 | """Example class demonstrating complex user management.""" 38 | 39 | def __init__(self, service: UserService): 40 | self.service = service 41 | self.active_users: dict[str, User] = {} 42 | self.user_stats: dict[str, UserStats] = {} 43 | 44 | def register_user(self, name: str, email: str, roles: list[str] | None = None) -> User: 45 | """Register a new user.""" 46 | logger.info(f"Registering new user: {name} ({email})") 47 | user = self.service.create_user(name=name, email=email, roles=roles) 48 | self.active_users[user.id] = user 49 | self.user_stats[user.id] = UserStats(user_id=user.id) 50 | return user 51 | 52 | def get_user(self, user_id: str) -> User | None: 53 | """Get a user by ID.""" 54 | if user_id in self.active_users: 55 | return self.active_users[user_id] 56 | 57 | # Try to fetch from service 58 | user = self.service.get_user(user_id) 59 | if user: 60 | self.active_users[user.id] = user 61 | return user 62 | 63 | def update_user_stats(self, user_id: str, login_count: int, days_since_active: int) -> None: 64 | """Update statistics for a user.""" 65 | if user_id not in self.user_stats: 66 | self.user_stats[user_id] = UserStats(user_id=user_id) 67 | 68 | stats = self.user_stats[user_id] 69 | stats.login_count = login_count 70 | stats.last_active_days = days_since_active 71 | 72 | # Calculate engagement score based on activity 73 | engagement = (100 - min(days_since_active, 100)) * 0.8 74 | engagement += min(login_count, 20) * 0.2 75 | stats.engagement_score = engagement 76 | 77 | def get_active_users(self) -> list[User]: 78 | """Get all active users.""" 79 | active_user_ids = [user_id for user_id, stats in self.user_stats.items() if stats.is_active()] 80 | return [self.active_users[user_id] for user_id in active_user_ids if user_id in self.active_users] 81 | 82 | def get_user_by_email(self, email: str) -> User | None: 83 | """Find a user by their email address.""" 84 | for user in self.active_users.values(): 85 | if user.email == email: 86 | return user 87 | return None 88 | 89 | 90 | # Example function demonstrating type annotations 91 | def process_user_data(users: list[User], include_inactive: bool = False, transform_func: callable | None = None) -> dict[str, Any]: 92 | """Process user data with optional transformations.""" 93 | result: dict[str, Any] = {"users": [], "total": 0, "admin_count": 0} 94 | 95 | for user in users: 96 | if transform_func: 97 | user_data = transform_func(user.to_dict()) 98 | else: 99 | user_data = user.to_dict() 100 | 101 | result["users"].append(user_data) 102 | result["total"] += 1 103 | 104 | if "admin" in user.roles: 105 | result["admin_count"] += 1 106 | 107 | return result 108 | 109 | 110 | def main(): 111 | """Main function demonstrating the usage of UserManager.""" 112 | # Initialize service and manager 113 | service = UserService() 114 | manager = UserManager(service) 115 | 116 | # Register some users 117 | admin = manager.register_user("Admin User", "[email protected]", ["admin"]) 118 | user1 = manager.register_user("Regular User", "[email protected]", ["user"]) 119 | user2 = manager.register_user("Another User", "[email protected]", ["user"]) 120 | 121 | # Update some stats 122 | manager.update_user_stats(admin.id, 100, 5) 123 | manager.update_user_stats(user1.id, 50, 10) 124 | manager.update_user_stats(user2.id, 10, 45) # Inactive user 125 | 126 | # Get active users 127 | active_users = manager.get_active_users() 128 | logger.info(f"Active users: {len(active_users)}") 129 | 130 | # Process user data 131 | user_data = process_user_data(active_users, transform_func=lambda u: {**u, "full_name": u.get("name", "")}) 132 | 133 | logger.info(f"Processed {user_data['total']} users, {user_data['admin_count']} admins") 134 | 135 | # Example of calling create_user directly 136 | external_user = create_user_object(id="ext123", name="External User", email="[email protected]", roles=["external"]) 137 | logger.info(f"Created external user: {external_user.name}") 138 | 139 | 140 | if __name__ == "__main__": 141 | main() 142 | ```