This is page 10 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 -------------------------------------------------------------------------------- /src/serena/config/serena_config.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | The Serena Model Context Protocol (MCP) Server 3 | """ 4 | 5 | import os 6 | import shutil 7 | from collections.abc import Iterable 8 | from copy import deepcopy 9 | from dataclasses import dataclass, field 10 | from datetime import datetime 11 | from functools import cached_property 12 | from pathlib import Path 13 | from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar 14 | 15 | import yaml 16 | from ruamel.yaml.comments import CommentedMap 17 | from sensai.util import logging 18 | from sensai.util.logging import LogTime, datetime_tag 19 | from sensai.util.string import ToStringMixin 20 | 21 | from serena.constants import ( 22 | DEFAULT_ENCODING, 23 | PROJECT_TEMPLATE_FILE, 24 | REPO_ROOT, 25 | SERENA_CONFIG_TEMPLATE_FILE, 26 | SERENA_MANAGED_DIR_IN_HOME, 27 | SERENA_MANAGED_DIR_NAME, 28 | ) 29 | from serena.util.general import load_yaml, save_yaml 30 | from serena.util.inspection import determine_programming_language_composition 31 | from solidlsp.ls_config import Language 32 | 33 | from ..analytics import RegisteredTokenCountEstimator 34 | from ..util.class_decorators import singleton 35 | 36 | if TYPE_CHECKING: 37 | from ..project import Project 38 | 39 | log = logging.getLogger(__name__) 40 | T = TypeVar("T") 41 | DEFAULT_TOOL_TIMEOUT: float = 240 42 | 43 | 44 | @singleton 45 | class SerenaPaths: 46 | """ 47 | Provides paths to various Serena-related directories and files. 48 | """ 49 | 50 | def __init__(self) -> None: 51 | self.user_config_dir: str = SERENA_MANAGED_DIR_IN_HOME 52 | """ 53 | the path to the user's Serena configuration directory, which is typically ~/.serena 54 | """ 55 | 56 | def get_next_log_file_path(self, prefix: str) -> str: 57 | """ 58 | :param prefix: the filename prefix indicating the type of the log file 59 | :return: the full path to the log file to use 60 | """ 61 | log_dir = os.path.join(self.user_config_dir, "logs", datetime.now().strftime("%Y-%m-%d")) 62 | os.makedirs(log_dir, exist_ok=True) 63 | return os.path.join(log_dir, prefix + "_" + datetime_tag() + ".txt") 64 | 65 | # TODO: Paths from constants.py should be moved here 66 | 67 | 68 | class ToolSet: 69 | def __init__(self, tool_names: set[str]) -> None: 70 | self._tool_names = tool_names 71 | 72 | @classmethod 73 | def default(cls) -> "ToolSet": 74 | """ 75 | :return: the default tool set, which contains all tools that are enabled by default 76 | """ 77 | from serena.tools import ToolRegistry 78 | 79 | return cls(set(ToolRegistry().get_tool_names_default_enabled())) 80 | 81 | def apply(self, *tool_inclusion_definitions: "ToolInclusionDefinition") -> "ToolSet": 82 | """ 83 | :param tool_inclusion_definitions: the definitions to apply 84 | :return: a new tool set with the definitions applied 85 | """ 86 | from serena.tools import ToolRegistry 87 | 88 | registry = ToolRegistry() 89 | tool_names = set(self._tool_names) 90 | for definition in tool_inclusion_definitions: 91 | included_tools = [] 92 | excluded_tools = [] 93 | for included_tool in definition.included_optional_tools: 94 | if not registry.is_valid_tool_name(included_tool): 95 | raise ValueError(f"Invalid tool name '{included_tool}' provided for inclusion") 96 | if included_tool not in tool_names: 97 | tool_names.add(included_tool) 98 | included_tools.append(included_tool) 99 | for excluded_tool in definition.excluded_tools: 100 | if not registry.is_valid_tool_name(excluded_tool): 101 | raise ValueError(f"Invalid tool name '{excluded_tool}' provided for exclusion") 102 | if excluded_tool in tool_names: 103 | tool_names.remove(excluded_tool) 104 | excluded_tools.append(excluded_tool) 105 | if included_tools: 106 | log.info(f"{definition} included {len(included_tools)} tools: {', '.join(included_tools)}") 107 | if excluded_tools: 108 | log.info(f"{definition} excluded {len(excluded_tools)} tools: {', '.join(excluded_tools)}") 109 | return ToolSet(tool_names) 110 | 111 | def without_editing_tools(self) -> "ToolSet": 112 | """ 113 | :return: a new tool set that excludes all tools that can edit 114 | """ 115 | from serena.tools import ToolRegistry 116 | 117 | registry = ToolRegistry() 118 | tool_names = set(self._tool_names) 119 | for tool_name in self._tool_names: 120 | if registry.get_tool_class_by_name(tool_name).can_edit(): 121 | tool_names.remove(tool_name) 122 | return ToolSet(tool_names) 123 | 124 | def get_tool_names(self) -> set[str]: 125 | """ 126 | Returns the names of the tools that are currently included in the tool set. 127 | """ 128 | return self._tool_names 129 | 130 | def includes_name(self, tool_name: str) -> bool: 131 | return tool_name in self._tool_names 132 | 133 | 134 | @dataclass 135 | class ToolInclusionDefinition: 136 | excluded_tools: Iterable[str] = () 137 | included_optional_tools: Iterable[str] = () 138 | 139 | 140 | class SerenaConfigError(Exception): 141 | pass 142 | 143 | 144 | def get_serena_managed_in_project_dir(project_root: str | Path) -> str: 145 | return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) 146 | 147 | 148 | def is_running_in_docker() -> bool: 149 | """Check if we're running inside a Docker container.""" 150 | # Check for Docker-specific files 151 | if os.path.exists("/.dockerenv"): 152 | return True 153 | # Check cgroup for docker references 154 | try: 155 | with open("/proc/self/cgroup") as f: 156 | return "docker" in f.read() 157 | except FileNotFoundError: 158 | return False 159 | 160 | 161 | @dataclass(kw_only=True) 162 | class ProjectConfig(ToolInclusionDefinition, ToStringMixin): 163 | project_name: str 164 | language: Language 165 | ignored_paths: list[str] = field(default_factory=list) 166 | read_only: bool = False 167 | ignore_all_files_in_gitignore: bool = True 168 | initial_prompt: str = "" 169 | encoding: str = DEFAULT_ENCODING 170 | 171 | SERENA_DEFAULT_PROJECT_FILE = "project.yml" 172 | 173 | def _tostring_includes(self) -> list[str]: 174 | return ["project_name"] 175 | 176 | @classmethod 177 | def autogenerate( 178 | cls, project_root: str | Path, project_name: str | None = None, project_language: Language | None = None, save_to_disk: bool = True 179 | ) -> Self: 180 | """ 181 | Autogenerate a project configuration for a given project root. 182 | 183 | :param project_root: the path to the project root 184 | :param project_name: the name of the project; if None, the name of the project will be the name of the directory 185 | containing the project 186 | :param project_language: the programming language of the project; if None, it will be determined automatically 187 | :param save_to_disk: whether to save the project configuration to disk 188 | :return: the project configuration 189 | """ 190 | project_root = Path(project_root).resolve() 191 | if not project_root.exists(): 192 | raise FileNotFoundError(f"Project root not found: {project_root}") 193 | with LogTime("Project configuration auto-generation", logger=log): 194 | project_name = project_name or project_root.name 195 | if project_language is None: 196 | language_composition = determine_programming_language_composition(str(project_root)) 197 | if len(language_composition) == 0: 198 | raise ValueError( 199 | f"No source files found in {project_root}\n\n" 200 | f"To use Serena with this project, you need to either:\n" 201 | f"1. Add source files in one of the supported languages (Python, JavaScript/TypeScript, Java, C#, Rust, Go, Ruby, C++, PHP, Swift, Elixir, Terraform, Bash)\n" 202 | f"2. Create a project configuration file manually at:\n" 203 | f" {os.path.join(project_root, cls.rel_path_to_project_yml())}\n\n" 204 | f"Example project.yml:\n" 205 | f" project_name: {project_name}\n" 206 | f" language: python # or typescript, java, csharp, rust, go, ruby, cpp, php, swift, elixir, terraform, bash\n" 207 | ) 208 | # find the language with the highest percentage 209 | dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) 210 | else: 211 | dominant_language = project_language.value 212 | config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True) 213 | config_with_comments["project_name"] = project_name 214 | config_with_comments["language"] = dominant_language 215 | if save_to_disk: 216 | save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) 217 | return cls._from_dict(config_with_comments) 218 | 219 | @classmethod 220 | def rel_path_to_project_yml(cls) -> str: 221 | return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) 222 | 223 | @classmethod 224 | def _from_dict(cls, data: dict[str, Any]) -> Self: 225 | """ 226 | Create a ProjectConfig instance from a configuration dictionary 227 | """ 228 | language_str = data["language"].lower() 229 | project_name = data["project_name"] 230 | # backwards compatibility 231 | if language_str == "javascript": 232 | log.warning(f"Found deprecated project language `javascript` in project {project_name}, please change to `typescript`") 233 | language_str = "typescript" 234 | try: 235 | language = Language(language_str) 236 | except ValueError as e: 237 | raise ValueError(f"Invalid language: {data['language']}.\nValid languages are: {[l.value for l in Language]}") from e 238 | return cls( 239 | project_name=project_name, 240 | language=language, 241 | ignored_paths=data.get("ignored_paths", []), 242 | excluded_tools=data.get("excluded_tools", []), 243 | included_optional_tools=data.get("included_optional_tools", []), 244 | read_only=data.get("read_only", False), 245 | ignore_all_files_in_gitignore=data.get("ignore_all_files_in_gitignore", True), 246 | initial_prompt=data.get("initial_prompt", ""), 247 | encoding=data.get("encoding", DEFAULT_ENCODING), 248 | ) 249 | 250 | @classmethod 251 | def load(cls, project_root: Path | str, autogenerate: bool = False) -> Self: 252 | """ 253 | Load a ProjectConfig instance from the path to the project root. 254 | """ 255 | project_root = Path(project_root) 256 | yaml_path = project_root / cls.rel_path_to_project_yml() 257 | if not yaml_path.exists(): 258 | if autogenerate: 259 | return cls.autogenerate(project_root) 260 | else: 261 | raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") 262 | with open(yaml_path, encoding="utf-8") as f: 263 | yaml_data = yaml.safe_load(f) 264 | if "project_name" not in yaml_data: 265 | yaml_data["project_name"] = project_root.name 266 | return cls._from_dict(yaml_data) 267 | 268 | 269 | class RegisteredProject(ToStringMixin): 270 | def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None: 271 | """ 272 | Represents a registered project in the Serena configuration. 273 | 274 | :param project_root: the root directory of the project 275 | :param project_config: the configuration of the project 276 | """ 277 | self.project_root = Path(project_root).resolve() 278 | self.project_config = project_config 279 | self._project_instance = project_instance 280 | 281 | def _tostring_exclude_private(self) -> bool: 282 | return True 283 | 284 | @property 285 | def project_name(self) -> str: 286 | return self.project_config.project_name 287 | 288 | @classmethod 289 | def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject": 290 | return RegisteredProject( 291 | project_root=project_instance.project_root, 292 | project_config=project_instance.project_config, 293 | project_instance=project_instance, 294 | ) 295 | 296 | def matches_root_path(self, path: str | Path) -> bool: 297 | """ 298 | Check if the given path matches the project root path. 299 | 300 | :param path: the path to check 301 | :return: True if the path matches the project root, False otherwise 302 | """ 303 | return self.project_root == Path(path).resolve() 304 | 305 | def get_project_instance(self) -> "Project": 306 | """ 307 | Returns the project instance for this registered project, loading it if necessary. 308 | """ 309 | if self._project_instance is None: 310 | from ..project import Project 311 | 312 | with LogTime(f"Loading project instance for {self}", logger=log): 313 | self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config) 314 | return self._project_instance 315 | 316 | 317 | @dataclass(kw_only=True) 318 | class SerenaConfig(ToolInclusionDefinition, ToStringMixin): 319 | """ 320 | Holds the Serena agent configuration, which is typically loaded from a YAML configuration file 321 | (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed. 322 | For testing purposes, it can also be instantiated directly with the desired parameters. 323 | """ 324 | 325 | projects: list[RegisteredProject] = field(default_factory=list) 326 | gui_log_window_enabled: bool = False 327 | log_level: int = logging.INFO 328 | trace_lsp_communication: bool = False 329 | web_dashboard: bool = True 330 | web_dashboard_open_on_launch: bool = True 331 | tool_timeout: float = DEFAULT_TOOL_TIMEOUT 332 | loaded_commented_yaml: CommentedMap | None = None 333 | config_file_path: str | None = None 334 | """ 335 | the path to the configuration file to which updates of the configuration shall be saved; 336 | if None, the configuration is not saved to disk 337 | """ 338 | jetbrains: bool = False 339 | """ 340 | whether to apply JetBrains mode 341 | """ 342 | record_tool_usage_stats: bool = False 343 | """Whether to record tool usage statistics, they will be shown in the web dashboard if recording is active. 344 | """ 345 | token_count_estimator: str = RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name 346 | """Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics. 347 | See the `RegisteredTokenCountEstimator` enum for available options. 348 | 349 | Note: some token estimators (like tiktoken) may require downloading data files 350 | on the first run, which can take some time and require internet access. Others, like the Anthropic ones, may require an API key 351 | and rate limits may apply. 352 | """ 353 | default_max_tool_answer_chars: int = 150_000 354 | """Used as default for tools where the apply method has a default maximal answer length. 355 | Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default 356 | through the global configuration. 357 | """ 358 | ls_specific_settings: dict = field(default_factory=dict) 359 | """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" 360 | 361 | CONFIG_FILE = "serena_config.yml" 362 | CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization 363 | 364 | def _tostring_includes(self) -> list[str]: 365 | return ["config_file_path"] 366 | 367 | @classmethod 368 | def generate_config_file(cls, config_file_path: str) -> None: 369 | """ 370 | Generates a Serena configuration file at the specified path from the template file. 371 | 372 | :param config_file_path: the path where the configuration file should be generated 373 | """ 374 | log.info(f"Auto-generating Serena configuration file in {config_file_path}") 375 | loaded_commented_yaml = load_yaml(SERENA_CONFIG_TEMPLATE_FILE, preserve_comments=True) 376 | save_yaml(config_file_path, loaded_commented_yaml, preserve_comments=True) 377 | 378 | @classmethod 379 | def _determine_config_file_path(cls) -> str: 380 | """ 381 | :return: the location where the Serena configuration file is stored/should be stored 382 | """ 383 | if is_running_in_docker(): 384 | return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER) 385 | else: 386 | config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, cls.CONFIG_FILE) 387 | 388 | # if the config file does not exist, check if we can migrate it from the old location 389 | if not os.path.exists(config_path): 390 | old_config_path = os.path.join(REPO_ROOT, cls.CONFIG_FILE) 391 | if os.path.exists(old_config_path): 392 | log.info(f"Moving Serena configuration file from {old_config_path} to {config_path}") 393 | os.makedirs(os.path.dirname(config_path), exist_ok=True) 394 | shutil.move(old_config_path, config_path) 395 | 396 | return config_path 397 | 398 | @classmethod 399 | def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig": 400 | """ 401 | Static constructor to create SerenaConfig from the configuration file 402 | """ 403 | config_file_path = cls._determine_config_file_path() 404 | 405 | # create the configuration file from the template if necessary 406 | if not os.path.exists(config_file_path): 407 | if not generate_if_missing: 408 | raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}") 409 | log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") 410 | cls.generate_config_file(config_file_path) 411 | 412 | # load the configuration 413 | log.info(f"Loading Serena configuration from {config_file_path}") 414 | try: 415 | loaded_commented_yaml = load_yaml(config_file_path, preserve_comments=True) 416 | except Exception as e: 417 | raise ValueError(f"Error loading Serena configuration from {config_file_path}: {e}") from e 418 | 419 | # create the configuration instance 420 | instance = cls(loaded_commented_yaml=loaded_commented_yaml, config_file_path=config_file_path) 421 | 422 | # read projects 423 | if "projects" not in loaded_commented_yaml: 424 | raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") 425 | 426 | # load list of known projects 427 | instance.projects = [] 428 | num_project_migrations = 0 429 | for path in loaded_commented_yaml["projects"]: 430 | path = Path(path).resolve() 431 | if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()): 432 | log.warning(f"Project path {path} does not exist or does not contain a project configuration file, skipping.") 433 | continue 434 | if path.is_file(): 435 | path = cls._migrate_out_of_project_config_file(path) 436 | if path is None: 437 | continue 438 | num_project_migrations += 1 439 | project_config = ProjectConfig.load(path) 440 | project = RegisteredProject( 441 | project_root=str(path), 442 | project_config=project_config, 443 | ) 444 | instance.projects.append(project) 445 | 446 | # set other configuration parameters 447 | if is_running_in_docker(): 448 | instance.gui_log_window_enabled = False # not supported in Docker 449 | else: 450 | instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False) 451 | instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO)) 452 | instance.web_dashboard = loaded_commented_yaml.get("web_dashboard", True) 453 | instance.web_dashboard_open_on_launch = loaded_commented_yaml.get("web_dashboard_open_on_launch", True) 454 | instance.tool_timeout = loaded_commented_yaml.get("tool_timeout", DEFAULT_TOOL_TIMEOUT) 455 | instance.trace_lsp_communication = loaded_commented_yaml.get("trace_lsp_communication", False) 456 | instance.excluded_tools = loaded_commented_yaml.get("excluded_tools", []) 457 | instance.included_optional_tools = loaded_commented_yaml.get("included_optional_tools", []) 458 | instance.jetbrains = loaded_commented_yaml.get("jetbrains", False) 459 | instance.record_tool_usage_stats = loaded_commented_yaml.get("record_tool_usage_stats", False) 460 | instance.token_count_estimator = loaded_commented_yaml.get( 461 | "token_count_estimator", RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name 462 | ) 463 | instance.default_max_tool_answer_chars = loaded_commented_yaml.get("default_max_tool_answer_chars", 150_000) 464 | instance.ls_specific_settings = loaded_commented_yaml.get("ls_specific_settings", {}) 465 | 466 | # re-save the configuration file if any migrations were performed 467 | if num_project_migrations > 0: 468 | log.info( 469 | f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration" 470 | ) 471 | instance.save() 472 | 473 | return instance 474 | 475 | @classmethod 476 | def _migrate_out_of_project_config_file(cls, path: Path) -> Path | None: 477 | """ 478 | Migrates a legacy project configuration file (which is a YAML file containing the project root) to the 479 | in-project configuration file (project.yml) inside the project root directory. 480 | 481 | :param path: the path to the legacy project configuration file 482 | :return: the project root path if the migration was successful, None otherwise. 483 | """ 484 | log.info(f"Found legacy project configuration file {path}, migrating to in-project configuration.") 485 | try: 486 | with open(path, encoding="utf-8") as f: 487 | project_config_data = yaml.safe_load(f) 488 | if "project_name" not in project_config_data: 489 | project_name = path.stem 490 | with open(path, "a", encoding="utf-8") as f: 491 | f.write(f"\nproject_name: {project_name}") 492 | project_root = project_config_data["project_root"] 493 | shutil.move(str(path), str(Path(project_root) / ProjectConfig.rel_path_to_project_yml())) 494 | return Path(project_root).resolve() 495 | except Exception as e: 496 | log.error(f"Error migrating configuration file: {e}") 497 | return None 498 | 499 | @cached_property 500 | def project_paths(self) -> list[str]: 501 | return sorted(str(project.project_root) for project in self.projects) 502 | 503 | @cached_property 504 | def project_names(self) -> list[str]: 505 | return sorted(project.project_config.project_name for project in self.projects) 506 | 507 | def get_project(self, project_root_or_name: str) -> Optional["Project"]: 508 | # look for project by name 509 | project_candidates = [] 510 | for project in self.projects: 511 | if project.project_config.project_name == project_root_or_name: 512 | project_candidates.append(project) 513 | if len(project_candidates) == 1: 514 | return project_candidates[0].get_project_instance() 515 | elif len(project_candidates) > 1: 516 | raise ValueError( 517 | f"Multiple projects found with name '{project_root_or_name}'. Please activate it by location instead. " 518 | f"Locations: {[p.project_root for p in project_candidates]}" 519 | ) 520 | # no project found by name; check if it's a path 521 | if os.path.isdir(project_root_or_name): 522 | for project in self.projects: 523 | if project.matches_root_path(project_root_or_name): 524 | return project.get_project_instance() 525 | return None 526 | 527 | def add_project_from_path(self, project_root: Path | str) -> "Project": 528 | """ 529 | Add a project to the Serena configuration from a given path. Will raise a FileExistsError if a 530 | project already exists at the path. 531 | 532 | :param project_root: the path to the project to add 533 | :return: the project that was added 534 | """ 535 | from ..project import Project 536 | 537 | project_root = Path(project_root).resolve() 538 | if not project_root.exists(): 539 | raise FileNotFoundError(f"Error: Path does not exist: {project_root}") 540 | if not project_root.is_dir(): 541 | raise FileNotFoundError(f"Error: Path is not a directory: {project_root}") 542 | 543 | for already_registered_project in self.projects: 544 | if str(already_registered_project.project_root) == str(project_root): 545 | raise FileExistsError( 546 | f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." 547 | ) 548 | 549 | project_config = ProjectConfig.load(project_root, autogenerate=True) 550 | 551 | new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) 552 | self.projects.append(RegisteredProject.from_project_instance(new_project)) 553 | self.save() 554 | 555 | return new_project 556 | 557 | def remove_project(self, project_name: str) -> None: 558 | # find the index of the project with the desired name and remove it 559 | for i, project in enumerate(list(self.projects)): 560 | if project.project_name == project_name: 561 | del self.projects[i] 562 | break 563 | else: 564 | raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") 565 | self.save() 566 | 567 | def save(self) -> None: 568 | """ 569 | Saves the configuration to the file from which it was loaded (if any) 570 | """ 571 | if self.config_file_path is None: 572 | return 573 | assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML" 574 | loaded_original_yaml = deepcopy(self.loaded_commented_yaml) 575 | # projects are unique absolute paths 576 | # we also canonicalize them before saving 577 | loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) 578 | save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True) 579 | ``` -------------------------------------------------------------------------------- /test/solidlsp/python/test_symbol_retrieval.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the language server symbol-related functionality. 3 | 4 | These tests focus on the following methods: 5 | - request_containing_symbol 6 | - request_referencing_symbols 7 | """ 8 | 9 | import os 10 | 11 | import pytest 12 | 13 | from serena.symbol import LanguageServerSymbol 14 | from solidlsp import SolidLanguageServer 15 | from solidlsp.ls_config import Language 16 | from solidlsp.ls_types import SymbolKind 17 | 18 | pytestmark = pytest.mark.python 19 | 20 | 21 | class TestLanguageServerSymbols: 22 | """Test the language server's symbol-related functionality.""" 23 | 24 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 25 | def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: 26 | """Test request_containing_symbol for a function.""" 27 | # Test for a position inside the create_user method 28 | file_path = os.path.join("test_repo", "services.py") 29 | # Line 17 is inside the create_user method body 30 | containing_symbol = language_server.request_containing_symbol(file_path, 17, 20, include_body=True) 31 | 32 | # Verify that we found the containing symbol 33 | assert containing_symbol is not None 34 | assert containing_symbol["name"] == "create_user" 35 | assert containing_symbol["kind"] == SymbolKind.Method 36 | if "body" in containing_symbol: 37 | assert containing_symbol["body"].strip().startswith("def create_user(self") 38 | 39 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 40 | def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: 41 | """Test request_referencing_symbols for a variable.""" 42 | file_path = os.path.join("test_repo", "variables.py") 43 | # Line 75 contains the field status that is later modified 44 | ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 74, 4)] 45 | 46 | assert len(ref_symbols) > 0 47 | ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_symbols if "location" in ref and "range" in ref["location"]] 48 | ref_names = [ref["name"] for ref in ref_symbols] 49 | assert 87 in ref_lines 50 | assert 95 in ref_lines 51 | assert "dataclass_instance" in ref_names 52 | assert "second_dataclass" in ref_names 53 | 54 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 55 | def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: 56 | """Test request_containing_symbol for a class.""" 57 | # Test for a position inside the UserService class but outside any method 58 | file_path = os.path.join("test_repo", "services.py") 59 | # Line 9 is the class definition line for UserService 60 | containing_symbol = language_server.request_containing_symbol(file_path, 9, 7) 61 | 62 | # Verify that we found the containing symbol 63 | assert containing_symbol is not None 64 | assert containing_symbol["name"] == "UserService" 65 | assert containing_symbol["kind"] == SymbolKind.Class 66 | 67 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 68 | def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: 69 | """Test request_containing_symbol with nested scopes.""" 70 | # Test for a position inside a method which is inside a class 71 | file_path = os.path.join("test_repo", "services.py") 72 | # Line 18 is inside the create_user method inside UserService class 73 | containing_symbol = language_server.request_containing_symbol(file_path, 18, 25) 74 | 75 | # Verify that we found the innermost containing symbol (the method) 76 | assert containing_symbol is not None 77 | assert containing_symbol["name"] == "create_user" 78 | assert containing_symbol["kind"] == SymbolKind.Method 79 | 80 | # Get the parent containing symbol 81 | if "location" in containing_symbol and "range" in containing_symbol["location"]: 82 | parent_symbol = language_server.request_containing_symbol( 83 | file_path, 84 | containing_symbol["location"]["range"]["start"]["line"], 85 | containing_symbol["location"]["range"]["start"]["character"] - 1, 86 | ) 87 | 88 | # Verify that the parent is the class 89 | assert parent_symbol is not None 90 | assert parent_symbol["name"] == "UserService" 91 | assert parent_symbol["kind"] == SymbolKind.Class 92 | 93 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 94 | def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: 95 | """Test request_containing_symbol for a position with no containing symbol.""" 96 | # Test for a position outside any function/class (e.g., in imports) 97 | file_path = os.path.join("test_repo", "services.py") 98 | # Line 1 is in imports, not inside any function or class 99 | containing_symbol = language_server.request_containing_symbol(file_path, 1, 10) 100 | 101 | # Should return None or an empty dictionary 102 | assert containing_symbol is None or containing_symbol == {} 103 | 104 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 105 | def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None: 106 | """Test request_referencing_symbols for a function.""" 107 | # Test referencing symbols for create_user function 108 | file_path = os.path.join("test_repo", "services.py") 109 | # Line 15 contains the create_user function definition 110 | symbols = language_server.request_document_symbols(file_path) 111 | create_user_symbol = next((s for s in symbols[0] if s.get("name") == "create_user"), None) 112 | if not create_user_symbol or "selectionRange" not in create_user_symbol: 113 | raise AssertionError("create_user symbol or its selectionRange not found") 114 | sel_start = create_user_symbol["selectionRange"]["start"] 115 | ref_symbols = [ 116 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 117 | ] 118 | assert len(ref_symbols) > 0, "No referencing symbols found for create_user (selectionRange)" 119 | 120 | # Verify the structure of referencing symbols 121 | for symbol in ref_symbols: 122 | assert "name" in symbol 123 | assert "kind" in symbol 124 | if "location" in symbol and "range" in symbol["location"]: 125 | assert "start" in symbol["location"]["range"] 126 | assert "end" in symbol["location"]["range"] 127 | 128 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 129 | def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None: 130 | """Test request_referencing_symbols for a class.""" 131 | # Test referencing symbols for User class 132 | file_path = os.path.join("test_repo", "models.py") 133 | # Line 31 contains the User class definition 134 | symbols = language_server.request_document_symbols(file_path) 135 | user_symbol = next((s for s in symbols[0] if s.get("name") == "User"), None) 136 | if not user_symbol or "selectionRange" not in user_symbol: 137 | raise AssertionError("User symbol or its selectionRange not found") 138 | sel_start = user_symbol["selectionRange"]["start"] 139 | ref_symbols = [ 140 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 141 | ] 142 | services_references = [ 143 | symbol 144 | for symbol in ref_symbols 145 | if "location" in symbol and "uri" in symbol["location"] and "services.py" in symbol["location"]["uri"] 146 | ] 147 | assert len(services_references) > 0, "No referencing symbols from services.py for User (selectionRange)" 148 | 149 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 150 | def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None: 151 | """Test request_referencing_symbols for a function parameter.""" 152 | # Test referencing symbols for id parameter in get_user 153 | file_path = os.path.join("test_repo", "services.py") 154 | # Line 24 contains the get_user method with id parameter 155 | symbols = language_server.request_document_symbols(file_path) 156 | get_user_symbol = next((s for s in symbols[0] if s.get("name") == "get_user"), None) 157 | if not get_user_symbol or "selectionRange" not in get_user_symbol: 158 | raise AssertionError("get_user symbol or its selectionRange not found") 159 | sel_start = get_user_symbol["selectionRange"]["start"] 160 | ref_symbols = [ 161 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 162 | ] 163 | method_refs = [ 164 | symbol 165 | for symbol in ref_symbols 166 | if "location" in symbol and "range" in symbol["location"] and symbol["location"]["range"]["start"]["line"] > sel_start["line"] 167 | ] 168 | assert len(method_refs) > 0, "No referencing symbols within method body for get_user (selectionRange)" 169 | 170 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 171 | def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: 172 | """Test request_referencing_symbols for a position with no symbol.""" 173 | # For positions with no symbol, the method might throw an error or return None/empty list 174 | # We'll modify our test to handle this by using a try-except block 175 | 176 | file_path = os.path.join("test_repo", "services.py") 177 | # Line 3 is a blank line or comment 178 | try: 179 | ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] 180 | # If we get here, make sure we got an empty result 181 | assert ref_symbols == [] or ref_symbols is None 182 | except Exception: 183 | # The method might raise an exception for invalid positions 184 | # which is acceptable behavior 185 | pass 186 | 187 | # Tests for request_defining_symbol 188 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 189 | def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: 190 | """Test request_defining_symbol for a variable usage.""" 191 | # Test finding the definition of a symbol in the create_user method 192 | file_path = os.path.join("test_repo", "services.py") 193 | # Line 21 contains self.users[id] = user 194 | defining_symbol = language_server.request_defining_symbol(file_path, 21, 10) 195 | 196 | # Verify that we found the defining symbol 197 | # The defining symbol method returns a dictionary with information about the defining symbol 198 | assert defining_symbol is not None 199 | assert defining_symbol.get("name") == "create_user" 200 | 201 | # Verify the location and kind of the symbol 202 | # SymbolKind.Method = 6 for a method 203 | assert defining_symbol.get("kind") == SymbolKind.Method.value 204 | if "location" in defining_symbol and "uri" in defining_symbol["location"]: 205 | assert "services.py" in defining_symbol["location"]["uri"] 206 | 207 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 208 | def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: 209 | """Test request_defining_symbol for an imported class.""" 210 | # Test finding the definition of the 'User' class used in the UserService.create_user method 211 | file_path = os.path.join("test_repo", "services.py") 212 | # Line 20 references 'User' which was imported from models 213 | defining_symbol = language_server.request_defining_symbol(file_path, 20, 15) 214 | 215 | # Verify that we found the defining symbol - this should be the User class from models 216 | assert defining_symbol is not None 217 | assert defining_symbol.get("name") == "User" 218 | 219 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 220 | def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None: 221 | """Test request_defining_symbol for a method call.""" 222 | # Create an example file path for a file that calls UserService.create_user 223 | examples_file_path = os.path.join("examples", "user_management.py") 224 | 225 | # Find the line number where create_user is called 226 | # This could vary, so we'll use a relative position that makes sense 227 | defining_symbol = language_server.request_defining_symbol(examples_file_path, 10, 30) 228 | 229 | # Verify that we found the defining symbol - should be the create_user method 230 | # Because this might fail if the structure isn't exactly as expected, we'll use try-except 231 | try: 232 | assert defining_symbol is not None 233 | assert defining_symbol.get("name") == "create_user" 234 | # The defining symbol should be in the services.py file 235 | if "location" in defining_symbol and "uri" in defining_symbol["location"]: 236 | assert "services.py" in defining_symbol["location"]["uri"] 237 | except AssertionError: 238 | # If the file structure doesn't match what we expect, we can't guarantee this test 239 | # will pass, so we'll consider it a warning rather than a failure 240 | import warnings 241 | 242 | warnings.warn("Could not verify method call definition - file structure may differ from expected") 243 | 244 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 245 | def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: 246 | """Test request_defining_symbol for a position with no symbol.""" 247 | # Test for a position with no symbol (e.g., whitespace or comment) 248 | file_path = os.path.join("test_repo", "services.py") 249 | # Line 3 is a blank line 250 | defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) 251 | 252 | # Should return None for positions with no symbol 253 | assert defining_symbol is None 254 | 255 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 256 | def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None: 257 | """Test request_containing_symbol where the symbol is a variable.""" 258 | # Test for a position inside a variable definition 259 | file_path = os.path.join("test_repo", "services.py") 260 | # Line 74 defines the 'user' variable 261 | containing_symbol = language_server.request_containing_symbol(file_path, 73, 1) 262 | 263 | # Verify that we found the containing symbol 264 | assert containing_symbol is not None 265 | assert containing_symbol["name"] == "user_var_str" 266 | assert containing_symbol["kind"] == SymbolKind.Variable 267 | 268 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 269 | def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: 270 | """Test request_defining_symbol for a nested function or closure.""" 271 | # Use the existing nested.py file which contains nested classes and methods 272 | file_path = os.path.join("test_repo", "nested.py") 273 | 274 | # Test 1: Find definition of nested method - line with 'b = OuterClass().NestedClass().find_me()' 275 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 35) # Position of find_me() call 276 | 277 | # This should resolve to the find_me method in the NestedClass 278 | assert defining_symbol is not None 279 | assert defining_symbol.get("name") == "find_me" 280 | assert defining_symbol.get("kind") == SymbolKind.Method.value 281 | 282 | # Test 2: Find definition of the nested class 283 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass 284 | 285 | # This should resolve to the NestedClass 286 | assert defining_symbol is not None 287 | assert defining_symbol.get("name") == "NestedClass" 288 | assert defining_symbol.get("kind") == SymbolKind.Class.value 289 | 290 | # Test 3: Find definition of a method-local function 291 | defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func 292 | 293 | # This is challenging for many language servers and may fail 294 | try: 295 | assert defining_symbol is not None 296 | assert defining_symbol.get("name") == "func_within_func" 297 | except (AssertionError, TypeError, KeyError): 298 | # This is expected to potentially fail in many implementations 299 | import warnings 300 | 301 | warnings.warn("Could not resolve nested class method definition - implementation limitation") 302 | 303 | # Test 2: Find definition of the nested class 304 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass 305 | 306 | # This should resolve to the NestedClass 307 | assert defining_symbol is not None 308 | assert defining_symbol.get("name") == "NestedClass" 309 | assert defining_symbol.get("kind") == SymbolKind.Class.value 310 | 311 | # Test 3: Find definition of a method-local function 312 | defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func 313 | 314 | # This is challenging for many language servers and may fail 315 | assert defining_symbol is not None 316 | assert defining_symbol.get("name") == "func_within_func" 317 | assert defining_symbol.get("kind") == SymbolKind.Function.value 318 | 319 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 320 | def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: 321 | """Test the integration between different symbol-related methods.""" 322 | # This test demonstrates using the various symbol methods together 323 | # by finding a symbol and then checking its definition 324 | 325 | file_path = os.path.join("test_repo", "services.py") 326 | 327 | # First approach: Use a method from the UserService class 328 | # Step 1: Find a method we know exists 329 | containing_symbol = language_server.request_containing_symbol(file_path, 15, 8) # create_user method 330 | assert containing_symbol is not None 331 | assert containing_symbol["name"] == "create_user" 332 | 333 | # Step 2: Get the defining symbol for the same position 334 | # This should be the same method 335 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 8) 336 | assert defining_symbol is not None 337 | assert defining_symbol["name"] == "create_user" 338 | 339 | # Step 3: Verify that they refer to the same symbol 340 | assert defining_symbol["kind"] == containing_symbol["kind"] 341 | if "location" in defining_symbol and "location" in containing_symbol: 342 | assert defining_symbol["location"]["uri"] == containing_symbol["location"]["uri"] 343 | 344 | # The integration test is successful if we've gotten this far, 345 | # as it demonstrates the integration between request_containing_symbol and request_defining_symbol 346 | 347 | # Try to get the container information for our method, but be flexible 348 | # since implementations may vary 349 | container_name = defining_symbol.get("containerName", None) 350 | if container_name and "UserService" in container_name: 351 | # If containerName contains UserService, that's a valid implementation 352 | pass 353 | else: 354 | # Try an alternative approach - looking for the containing class 355 | try: 356 | # Look for the class symbol in the file 357 | for line in range(5, 12): # Approximate range where UserService class should be defined 358 | symbol = language_server.request_containing_symbol(file_path, line, 5) # column 5 should be within class definition 359 | if symbol and symbol.get("name") == "UserService" and symbol.get("kind") == SymbolKind.Class.value: 360 | # Found the class - this is also a valid implementation 361 | break 362 | except Exception: 363 | # Just log a warning - this is an alternative verification and not essential 364 | import warnings 365 | 366 | warnings.warn("Could not verify container hierarchy - implementation detail") 367 | 368 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 369 | def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: 370 | """Test that the symbol tree structure is correctly built.""" 371 | # Get all symbols in the test file 372 | repo_structure = language_server.request_full_symbol_tree() 373 | assert len(repo_structure) == 1 374 | # Assert that the root symbol is the test_repo directory 375 | assert repo_structure[0]["name"] == "test_repo" 376 | assert repo_structure[0]["kind"] == SymbolKind.Package 377 | assert "children" in repo_structure[0] 378 | # Assert that the children are the top-level packages 379 | child_names = {child["name"] for child in repo_structure[0]["children"]} 380 | child_kinds = {child["kind"] for child in repo_structure[0]["children"]} 381 | assert child_names == {"test_repo", "custom_test", "examples", "scripts"} 382 | assert child_kinds == {SymbolKind.Package} 383 | examples_package = next(child for child in repo_structure[0]["children"] if child["name"] == "examples") 384 | # assert that children are __init__ and user_management 385 | assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"} 386 | assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File} 387 | 388 | # assert that tree of user_management node is same as retrieved directly 389 | user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management") 390 | if "location" in user_management_node and "relativePath" in user_management_node["location"]: 391 | user_management_rel_path = user_management_node["location"]["relativePath"] 392 | assert user_management_rel_path == os.path.join("examples", "user_management.py") 393 | _, user_management_roots = language_server.request_document_symbols(os.path.join("examples", "user_management.py")) 394 | assert user_management_roots == user_management_node["children"] 395 | 396 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 397 | def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None: 398 | """Test that the symbol tree structure is correctly built.""" 399 | # Get all symbols in the test file 400 | examples_package_roots = language_server.request_full_symbol_tree(within_relative_path="examples") 401 | assert len(examples_package_roots) == 1 402 | examples_package = examples_package_roots[0] 403 | assert examples_package["name"] == "examples" 404 | assert examples_package["kind"] == SymbolKind.Package 405 | # assert that children are __init__ and user_management 406 | assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"} 407 | assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File} 408 | 409 | # assert that tree of user_management node is same as retrieved directly 410 | user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management") 411 | if "location" in user_management_node and "relativePath" in user_management_node["location"]: 412 | user_management_rel_path = user_management_node["location"]["relativePath"] 413 | assert user_management_rel_path == os.path.join("examples", "user_management.py") 414 | _, user_management_roots = language_server.request_document_symbols(os.path.join("examples", "user_management.py")) 415 | assert user_management_roots == user_management_node["children"] 416 | 417 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 418 | def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: 419 | """Test that request_dir_overview returns correct symbol information for files in a directory.""" 420 | # Get overview of the examples directory 421 | overview = language_server.request_dir_overview("test_repo") 422 | 423 | # Verify that we have entries for both files 424 | assert os.path.join("test_repo", "nested.py") in overview 425 | 426 | # Get the symbols for user_management.py 427 | services_symbols = overview[os.path.join("test_repo", "services.py")] 428 | assert len(services_symbols) > 0 429 | 430 | # Check for specific symbols from services.py 431 | expected_symbols = { 432 | "UserService", 433 | "ItemService", 434 | "create_service_container", 435 | "user_var_str", 436 | "user_service", 437 | } 438 | retrieved_symbols = {symbol["name"] for symbol in services_symbols if "name" in symbol} 439 | assert expected_symbols.issubset(retrieved_symbols) 440 | 441 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 442 | def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: 443 | """Test that request_document_overview returns correct symbol information for a file.""" 444 | # Get overview of the user_management.py file 445 | overview = language_server.request_document_overview(os.path.join("examples", "user_management.py")) 446 | 447 | # Verify that we have entries for both files 448 | symbol_names = {LanguageServerSymbol(s_info).name for s_info in overview} 449 | assert {"UserStats", "UserManager", "process_user_data", "main"}.issubset(symbol_names) 450 | 451 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) 452 | def test_containing_symbol_of_var_is_file(self, language_server: SolidLanguageServer) -> None: 453 | """Test that the containing symbol of a variable is the file itself.""" 454 | # Get the containing symbol of a variable in a file 455 | file_path = os.path.join("test_repo", "services.py") 456 | # import of typing 457 | references_to_typing = [ 458 | ref.symbol 459 | for ref in language_server.request_referencing_symbols(file_path, 4, 6, include_imports=False, include_file_symbols=True) 460 | ] 461 | assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File} 462 | assert {ref["body"] for ref in references_to_typing} == {""} 463 | 464 | # now include bodies 465 | references_to_typing = [ 466 | ref.symbol 467 | for ref in language_server.request_referencing_symbols( 468 | file_path, 4, 6, include_imports=False, include_file_symbols=True, include_body=True 469 | ) 470 | ] 471 | assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File} 472 | assert references_to_typing[0]["body"] 473 | ``` -------------------------------------------------------------------------------- /src/serena/symbol.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | import logging 3 | import os 4 | from abc import ABC, abstractmethod 5 | from collections.abc import Iterator, Sequence 6 | from dataclasses import asdict, dataclass 7 | from typing import TYPE_CHECKING, Any, Self, Union 8 | 9 | from sensai.util.string import ToStringMixin 10 | 11 | from solidlsp import SolidLanguageServer 12 | from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol 13 | from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation 14 | 15 | from .project import Project 16 | 17 | if TYPE_CHECKING: 18 | from .agent import SerenaAgent 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | @dataclass 24 | class LanguageServerSymbolLocation: 25 | """ 26 | Represents the (start) location of a symbol identifier, which, within Serena, uniquely identifies the symbol. 27 | """ 28 | 29 | relative_path: str | None 30 | """ 31 | the relative path of the file containing the symbol; if None, the symbol is defined outside of the project's scope 32 | """ 33 | line: int | None 34 | """ 35 | the line number in which the symbol identifier is defined (if the symbol is a function, class, etc.); 36 | may be None for some types of symbols (e.g. SymbolKind.File) 37 | """ 38 | column: int | None 39 | """ 40 | the column number in which the symbol identifier is defined (if the symbol is a function, class, etc.); 41 | may be None for some types of symbols (e.g. SymbolKind.File) 42 | """ 43 | 44 | def __post_init__(self) -> None: 45 | if self.relative_path is not None: 46 | self.relative_path = self.relative_path.replace("/", os.path.sep) 47 | 48 | def to_dict(self, include_relative_path: bool = True) -> dict[str, Any]: 49 | result = asdict(self) 50 | if not include_relative_path: 51 | result.pop("relative_path", None) 52 | return result 53 | 54 | def has_position_in_file(self) -> bool: 55 | return self.relative_path is not None and self.line is not None and self.column is not None 56 | 57 | 58 | @dataclass 59 | class PositionInFile: 60 | """ 61 | Represents a character position within a file 62 | """ 63 | 64 | line: int 65 | """ 66 | the 0-based line number in the file 67 | """ 68 | col: int 69 | """ 70 | the 0-based column 71 | """ 72 | 73 | def to_lsp_position(self) -> Position: 74 | """ 75 | Convert to LSP Position. 76 | """ 77 | return Position(line=self.line, character=self.col) 78 | 79 | 80 | class Symbol(ABC): 81 | @abstractmethod 82 | def get_body_start_position(self) -> PositionInFile | None: 83 | pass 84 | 85 | @abstractmethod 86 | def get_body_end_position(self) -> PositionInFile | None: 87 | pass 88 | 89 | def get_body_start_position_or_raise(self) -> PositionInFile: 90 | """ 91 | Get the start position of the symbol body, raising an error if it is not defined. 92 | """ 93 | pos = self.get_body_start_position() 94 | if pos is None: 95 | raise ValueError(f"Body start position is not defined for {self}") 96 | return pos 97 | 98 | def get_body_end_position_or_raise(self) -> PositionInFile: 99 | """ 100 | Get the end position of the symbol body, raising an error if it is not defined. 101 | """ 102 | pos = self.get_body_end_position() 103 | if pos is None: 104 | raise ValueError(f"Body end position is not defined for {self}") 105 | return pos 106 | 107 | @abstractmethod 108 | def is_neighbouring_definition_separated_by_empty_line(self) -> bool: 109 | """ 110 | :return: whether a symbol definition of this symbol's kind is usually separated from the 111 | previous/next definition by at least one empty line. 112 | """ 113 | 114 | 115 | class LanguageServerSymbol(Symbol, ToStringMixin): 116 | _NAME_PATH_SEP = "/" 117 | 118 | @staticmethod 119 | def match_name_path( 120 | name_path: str, 121 | symbol_name_path_parts: list[str], 122 | substring_matching: bool, 123 | ) -> bool: 124 | """ 125 | Checks if a given `name_path` matches a symbol's qualified name parts. 126 | See docstring of `Symbol.find` for more details. 127 | """ 128 | assert name_path, "name_path must not be empty" 129 | assert symbol_name_path_parts, "symbol_name_path_parts must not be empty" 130 | name_path_sep = LanguageServerSymbol._NAME_PATH_SEP 131 | 132 | is_absolute_pattern = name_path.startswith(name_path_sep) 133 | pattern_parts = name_path.lstrip(name_path_sep).rstrip(name_path_sep).split(name_path_sep) 134 | 135 | # filtering based on ancestors 136 | if len(pattern_parts) > len(symbol_name_path_parts): 137 | # can't possibly match if pattern has more parts than symbol 138 | return False 139 | if is_absolute_pattern and len(pattern_parts) != len(symbol_name_path_parts): 140 | # for absolute patterns, the number of parts must match exactly 141 | return False 142 | if symbol_name_path_parts[-len(pattern_parts) : -1] != pattern_parts[:-1]: 143 | # ancestors must match 144 | return False 145 | 146 | # matching the last part of the symbol name 147 | name_to_match = pattern_parts[-1] 148 | symbol_name = symbol_name_path_parts[-1] 149 | if substring_matching: 150 | return name_to_match in symbol_name 151 | else: 152 | return name_to_match == symbol_name 153 | 154 | def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None: 155 | self.symbol_root = symbol_root_from_ls 156 | 157 | def _tostring_includes(self) -> list[str]: 158 | return [] 159 | 160 | def _tostring_additional_entries(self) -> dict[str, Any]: 161 | return dict(name=self.name, kind=self.kind, num_children=len(self.symbol_root["children"])) 162 | 163 | @property 164 | def name(self) -> str: 165 | return self.symbol_root["name"] 166 | 167 | @property 168 | def kind(self) -> str: 169 | return SymbolKind(self.symbol_kind).name 170 | 171 | @property 172 | def symbol_kind(self) -> SymbolKind: 173 | return self.symbol_root["kind"] 174 | 175 | def is_neighbouring_definition_separated_by_empty_line(self) -> bool: 176 | return self.symbol_kind in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Class, SymbolKind.Interface, SymbolKind.Struct) 177 | 178 | @property 179 | def relative_path(self) -> str | None: 180 | location = self.symbol_root.get("location") 181 | if location: 182 | return location.get("relativePath") 183 | return None 184 | 185 | @property 186 | def location(self) -> LanguageServerSymbolLocation: 187 | """ 188 | :return: the start location of the actual symbol identifier 189 | """ 190 | return LanguageServerSymbolLocation(relative_path=self.relative_path, line=self.line, column=self.column) 191 | 192 | @property 193 | def body_start_position(self) -> Position | None: 194 | location = self.symbol_root.get("location") 195 | if location: 196 | range_info = location.get("range") 197 | if range_info: 198 | start_pos = range_info.get("start") 199 | if start_pos: 200 | return start_pos 201 | return None 202 | 203 | @property 204 | def body_end_position(self) -> Position | None: 205 | location = self.symbol_root.get("location") 206 | if location: 207 | range_info = location.get("range") 208 | if range_info: 209 | end_pos = range_info.get("end") 210 | if end_pos: 211 | return end_pos 212 | return None 213 | 214 | def get_body_start_position(self) -> PositionInFile | None: 215 | start_pos = self.body_start_position 216 | if start_pos is None: 217 | return None 218 | return PositionInFile(line=start_pos["line"], col=start_pos["character"]) 219 | 220 | def get_body_end_position(self) -> PositionInFile | None: 221 | end_pos = self.body_end_position 222 | if end_pos is None: 223 | return None 224 | return PositionInFile(line=end_pos["line"], col=end_pos["character"]) 225 | 226 | def get_body_line_numbers(self) -> tuple[int | None, int | None]: 227 | start_pos = self.body_start_position 228 | end_pos = self.body_end_position 229 | start_line = start_pos["line"] if start_pos else None 230 | end_line = end_pos["line"] if end_pos else None 231 | return start_line, end_line 232 | 233 | @property 234 | def line(self) -> int | None: 235 | """ 236 | :return: the line in which the symbol identifier is defined. 237 | """ 238 | if "selectionRange" in self.symbol_root: 239 | return self.symbol_root["selectionRange"]["start"]["line"] 240 | else: 241 | # line is expected to be undefined for some types of symbols (e.g. SymbolKind.File) 242 | return None 243 | 244 | @property 245 | def column(self) -> int | None: 246 | if "selectionRange" in self.symbol_root: 247 | return self.symbol_root["selectionRange"]["start"]["character"] 248 | else: 249 | # precise location is expected to be undefined for some types of symbols (e.g. SymbolKind.File) 250 | return None 251 | 252 | @property 253 | def body(self) -> str | None: 254 | return self.symbol_root.get("body") 255 | 256 | def get_name_path(self) -> str: 257 | """ 258 | Get the name path of the symbol (e.g. "class/method/inner_function"). 259 | """ 260 | return self._NAME_PATH_SEP.join(self.get_name_path_parts()) 261 | 262 | def get_name_path_parts(self) -> list[str]: 263 | """ 264 | Get the parts of the name path of the symbol (e.g. ["class", "method", "inner_function"]). 265 | """ 266 | ancestors_within_file = list(self.iter_ancestors(up_to_symbol_kind=SymbolKind.File)) 267 | ancestors_within_file.reverse() 268 | return [a.name for a in ancestors_within_file] + [self.name] 269 | 270 | def iter_children(self) -> Iterator[Self]: 271 | for c in self.symbol_root["children"]: 272 | yield self.__class__(c) 273 | 274 | def iter_ancestors(self, up_to_symbol_kind: SymbolKind | None = None) -> Iterator[Self]: 275 | """ 276 | Iterate over all ancestors of the symbol, starting with the parent and going up to the root or 277 | the given symbol kind. 278 | 279 | :param up_to_symbol_kind: if provided, iteration will stop *before* the first ancestor of the given kind. 280 | A typical use case is to pass `SymbolKind.File` or `SymbolKind.Package`. 281 | """ 282 | parent = self.get_parent() 283 | if parent is not None: 284 | if up_to_symbol_kind is None or parent.symbol_kind != up_to_symbol_kind: 285 | yield parent 286 | yield from parent.iter_ancestors(up_to_symbol_kind=up_to_symbol_kind) 287 | 288 | def get_parent(self) -> Self | None: 289 | parent_root = self.symbol_root.get("parent") 290 | if parent_root is None: 291 | return None 292 | return self.__class__(parent_root) 293 | 294 | def find( 295 | self, 296 | name_path: str, 297 | substring_matching: bool = False, 298 | include_kinds: Sequence[SymbolKind] | None = None, 299 | exclude_kinds: Sequence[SymbolKind] | None = None, 300 | ) -> list[Self]: 301 | """ 302 | Find all symbols within the symbol's subtree that match the given `name_path`. 303 | The matching behavior is determined by the structure of `name_path`, which can 304 | either be a simple name (e.g. "method") or a name path like "class/method" (relative name path) 305 | or "/class/method" (absolute name path). 306 | 307 | Key aspects of the name path matching behavior: 308 | - Trailing slashes in `name_path` play no role and are ignored. 309 | - The name of the retrieved symbols will match (either exactly or as a substring) 310 | the last segment of `name_path`, while other segments will restrict the search to symbols that 311 | have a desired sequence of ancestors. 312 | - If there is no starting or intermediate slash in `name_path`, there is no 313 | restriction on the ancestor symbols. For example, passing `method` will match 314 | against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc. 315 | - If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols 316 | with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against 317 | `class/method` as well as `nested_class/class/method` but not `method`. 318 | - If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning 319 | that the first segment of it must match the first segment of the symbol's name path. 320 | For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`. 321 | Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`. 322 | 323 | :param name_path: the name path to match against 324 | :param substring_matching: whether to use substring matching (as opposed to exact matching) 325 | of the last segment of `name_path` against the symbol name. 326 | :param include_kinds: an optional sequence of ints representing the LSP symbol kind. 327 | If provided, only symbols of the given kinds will be included in the result. 328 | :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. 329 | """ 330 | result = [] 331 | 332 | def should_include(s: "LanguageServerSymbol") -> bool: 333 | if include_kinds is not None and s.symbol_kind not in include_kinds: 334 | return False 335 | if exclude_kinds is not None and s.symbol_kind in exclude_kinds: 336 | return False 337 | return LanguageServerSymbol.match_name_path( 338 | name_path=name_path, 339 | symbol_name_path_parts=s.get_name_path_parts(), 340 | substring_matching=substring_matching, 341 | ) 342 | 343 | def traverse(s: "LanguageServerSymbol") -> None: 344 | if should_include(s): 345 | result.append(s) 346 | for c in s.iter_children(): 347 | traverse(c) 348 | 349 | traverse(self) 350 | return result 351 | 352 | def to_dict( 353 | self, 354 | kind: bool = False, 355 | location: bool = False, 356 | depth: int = 0, 357 | include_body: bool = False, 358 | include_children_body: bool = False, 359 | include_relative_path: bool = True, 360 | ) -> dict[str, Any]: 361 | """ 362 | Converts the symbol to a dictionary. 363 | 364 | :param kind: whether to include the kind of the symbol 365 | :param location: whether to include the location of the symbol 366 | :param depth: the depth of the symbol 367 | :param include_body: whether to include the body of the top-level symbol. 368 | :param include_children_body: whether to also include the body of the children. 369 | Note that the body of the children is part of the body of the parent symbol, 370 | so there is usually no need to set this to True unless you want process the output 371 | and pass the children without passing the parent body to the LM. 372 | :param include_relative_path: whether to include the relative path of the symbol in the location 373 | entry. Relative paths of the symbol's children are always excluded. 374 | :return: a dictionary representation of the symbol 375 | """ 376 | result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()} 377 | 378 | if kind: 379 | result["kind"] = self.kind 380 | 381 | if location: 382 | result["location"] = self.location.to_dict(include_relative_path=include_relative_path) 383 | body_start_line, body_end_line = self.get_body_line_numbers() 384 | result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} 385 | 386 | if include_body: 387 | if self.body is None: 388 | log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.") 389 | result["body"] = self.body 390 | 391 | def add_children(s: Self) -> list[dict[str, Any]]: 392 | children = [] 393 | for c in s.iter_children(): 394 | children.append( 395 | c.to_dict( 396 | kind=kind, 397 | location=location, 398 | depth=depth - 1, 399 | include_body=include_children_body, 400 | include_children_body=include_children_body, 401 | # all children have the same relative path as the parent 402 | include_relative_path=False, 403 | ) 404 | ) 405 | return children 406 | 407 | if depth > 0: 408 | result["children"] = add_children(self) 409 | 410 | return result 411 | 412 | 413 | @dataclass 414 | class ReferenceInLanguageServerSymbol(ToStringMixin): 415 | """ 416 | Represents the location of a reference to another symbol within a symbol/file. 417 | 418 | The contained symbol is the symbol within which the reference is located, 419 | not the symbol that is referenced. 420 | """ 421 | 422 | symbol: LanguageServerSymbol 423 | """ 424 | the symbol within which the reference is located 425 | """ 426 | line: int 427 | """ 428 | the line number in which the reference is located (0-based) 429 | """ 430 | character: int 431 | """ 432 | the column number in which the reference is located (0-based) 433 | """ 434 | 435 | @classmethod 436 | def from_lsp_reference(cls, reference: LSPReferenceInSymbol) -> Self: 437 | return cls(symbol=LanguageServerSymbol(reference.symbol), line=reference.line, character=reference.character) 438 | 439 | def get_relative_path(self) -> str | None: 440 | return self.symbol.location.relative_path 441 | 442 | 443 | class LanguageServerSymbolRetriever: 444 | def __init__(self, lang_server: SolidLanguageServer, agent: Union["SerenaAgent", None] = None) -> None: 445 | """ 446 | :param lang_server: the language server to use for symbol retrieval as well as editing operations. 447 | :param agent: the agent to use (only needed for marking files as modified). You can pass None if you don't 448 | need an agent to be aware of file modifications performed by the symbol manager. 449 | """ 450 | self._lang_server = lang_server 451 | self.agent = agent 452 | 453 | def set_language_server(self, lang_server: SolidLanguageServer) -> None: 454 | """ 455 | Set the language server to use for symbol retrieval and editing operations. 456 | This is useful if you want to change the language server after initializing the SymbolManager. 457 | """ 458 | self._lang_server = lang_server 459 | 460 | def get_language_server(self) -> SolidLanguageServer: 461 | return self._lang_server 462 | 463 | def find_by_name( 464 | self, 465 | name_path: str, 466 | include_body: bool = False, 467 | include_kinds: Sequence[SymbolKind] | None = None, 468 | exclude_kinds: Sequence[SymbolKind] | None = None, 469 | substring_matching: bool = False, 470 | within_relative_path: str | None = None, 471 | ) -> list[LanguageServerSymbol]: 472 | """ 473 | Find all symbols that match the given name. See docstring of `Symbol.find` for more details. 474 | The only parameter not mentioned there is `within_relative_path`, which can be used to restrict the search 475 | to symbols within a specific file or directory. 476 | """ 477 | symbols: list[LanguageServerSymbol] = [] 478 | symbol_roots = self._lang_server.request_full_symbol_tree(within_relative_path=within_relative_path, include_body=include_body) 479 | for root in symbol_roots: 480 | symbols.extend( 481 | LanguageServerSymbol(root).find( 482 | name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching 483 | ) 484 | ) 485 | return symbols 486 | 487 | def get_document_symbols(self, relative_path: str) -> list[LanguageServerSymbol]: 488 | symbol_dicts, _roots = self._lang_server.request_document_symbols(relative_path, include_body=False) 489 | symbols = [LanguageServerSymbol(s) for s in symbol_dicts] 490 | return symbols 491 | 492 | def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None: 493 | if location.relative_path is None: 494 | return None 495 | symbol_dicts, _roots = self._lang_server.request_document_symbols(location.relative_path, include_body=False) 496 | for symbol_dict in symbol_dicts: 497 | symbol = LanguageServerSymbol(symbol_dict) 498 | if symbol.location == location: 499 | return symbol 500 | return None 501 | 502 | def find_referencing_symbols( 503 | self, 504 | name_path: str, 505 | relative_file_path: str, 506 | include_body: bool = False, 507 | include_kinds: Sequence[SymbolKind] | None = None, 508 | exclude_kinds: Sequence[SymbolKind] | None = None, 509 | ) -> list[ReferenceInLanguageServerSymbol]: 510 | """ 511 | Find all symbols that reference the symbol with the given name. 512 | If multiple symbols fit the name (e.g. for variables that are overwritten), will use the first one. 513 | 514 | :param name_path: the name path of the symbol to find 515 | :param relative_file_path: the relative path of the file in which the referenced symbol is defined. 516 | :param include_body: whether to include the body of all symbols in the result. 517 | Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. 518 | :param include_kinds: which kinds of symbols to include in the result. 519 | :param exclude_kinds: which kinds of symbols to exclude from the result. 520 | """ 521 | symbol_candidates = self.find_by_name(name_path, substring_matching=False, within_relative_path=relative_file_path) 522 | if len(symbol_candidates) == 0: 523 | log.warning(f"No symbol with name {name_path} found in file {relative_file_path}") 524 | return [] 525 | if len(symbol_candidates) > 1: 526 | log.error( 527 | f"Found {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}." 528 | f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. " 529 | f"Found symbols for {name_path=} in {relative_file_path=}: \n" 530 | f"{json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)}" 531 | ) 532 | symbol = symbol_candidates[0] 533 | return self.find_referencing_symbols_by_location( 534 | symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds 535 | ) 536 | 537 | def find_referencing_symbols_by_location( 538 | self, 539 | symbol_location: LanguageServerSymbolLocation, 540 | include_body: bool = False, 541 | include_kinds: Sequence[SymbolKind] | None = None, 542 | exclude_kinds: Sequence[SymbolKind] | None = None, 543 | ) -> list[ReferenceInLanguageServerSymbol]: 544 | """ 545 | Find all symbols that reference the symbol at the given location. 546 | 547 | :param symbol_location: the location of the symbol for which to find references. 548 | Does not need to include an end_line, as it is unused in the search. 549 | :param include_body: whether to include the body of all symbols in the result. 550 | Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. 551 | Note: you can filter out the bodies of the children if you set include_children_body=False 552 | in the to_dict method. 553 | :param include_kinds: an optional sequence of ints representing the LSP symbol kind. 554 | If provided, only symbols of the given kinds will be included in the result. 555 | :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. 556 | Takes precedence over include_kinds. 557 | :return: a list of symbols that reference the given symbol 558 | """ 559 | if not symbol_location.has_position_in_file(): 560 | raise ValueError("Symbol location does not contain a valid position in a file") 561 | assert symbol_location.relative_path is not None 562 | assert symbol_location.line is not None 563 | assert symbol_location.column is not None 564 | references = self._lang_server.request_referencing_symbols( 565 | relative_file_path=symbol_location.relative_path, 566 | line=symbol_location.line, 567 | column=symbol_location.column, 568 | include_imports=False, 569 | include_self=False, 570 | include_body=include_body, 571 | include_file_symbols=True, 572 | ) 573 | 574 | if include_kinds is not None: 575 | references = [s for s in references if s.symbol["kind"] in include_kinds] 576 | 577 | if exclude_kinds is not None: 578 | references = [s for s in references if s.symbol["kind"] not in exclude_kinds] 579 | 580 | return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] 581 | 582 | @dataclass 583 | class SymbolOverviewElement: 584 | name_path: str 585 | kind: int 586 | 587 | @classmethod 588 | def from_symbol(cls, symbol: LanguageServerSymbol) -> Self: 589 | return cls(name_path=symbol.get_name_path(), kind=int(symbol.symbol_kind)) 590 | 591 | def get_symbol_overview(self, relative_path: str) -> dict[str, list[SymbolOverviewElement]]: 592 | path_to_unified_symbols = self._lang_server.request_overview(relative_path) 593 | result = {} 594 | for file_path, unified_symbols in path_to_unified_symbols.items(): 595 | # TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables 596 | # The language server methods would need to be adjusted for this. 597 | result[file_path] = [self.SymbolOverviewElement.from_symbol(LanguageServerSymbol(s)) for s in unified_symbols] 598 | return result 599 | 600 | 601 | class JetBrainsSymbol(Symbol): 602 | def __init__(self, symbol_dict: dict, project: Project) -> None: 603 | """ 604 | :param symbol_dict: dictionary as returned by the JetBrains plugin client. 605 | """ 606 | self._project = project 607 | self._dict = symbol_dict 608 | self._cached_file_content: str | None = None 609 | self._cached_body_start_position: PositionInFile | None = None 610 | self._cached_body_end_position: PositionInFile | None = None 611 | 612 | def get_relative_path(self) -> str: 613 | return self._dict["relative_path"] 614 | 615 | def get_file_content(self) -> str: 616 | if self._cached_file_content is None: 617 | path = os.path.join(self._project.project_root, self.get_relative_path()) 618 | with open(path, encoding=self._project.project_config.encoding) as f: 619 | self._cached_file_content = f.read() 620 | return self._cached_file_content 621 | 622 | def is_position_in_file_available(self) -> bool: 623 | return "text_range" in self._dict 624 | 625 | def get_body_start_position(self) -> PositionInFile | None: 626 | if not self.is_position_in_file_available(): 627 | return None 628 | if self._cached_body_start_position is None: 629 | pos = self._dict["text_range"]["start_pos"] 630 | line, col = pos["line"], pos["col"] 631 | self._cached_body_start_position = PositionInFile(line=line, col=col) 632 | return self._cached_body_start_position 633 | 634 | def get_body_end_position(self) -> PositionInFile | None: 635 | if not self.is_position_in_file_available(): 636 | return None 637 | if self._cached_body_end_position is None: 638 | pos = self._dict["text_range"]["end_pos"] 639 | line, col = pos["line"], pos["col"] 640 | self._cached_body_end_position = PositionInFile(line=line, col=col) 641 | return self._cached_body_end_position 642 | 643 | def is_neighbouring_definition_separated_by_empty_line(self) -> bool: 644 | # NOTE: Symbol types cannot really be differentiated, because types are not handled in a language-agnostic way. 645 | return False 646 | ``` -------------------------------------------------------------------------------- /src/serena/agent.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | The Serena Model Context Protocol (MCP) Server 3 | """ 4 | 5 | import multiprocessing 6 | import os 7 | import platform 8 | import sys 9 | import threading 10 | import webbrowser 11 | from collections.abc import Callable 12 | from concurrent.futures import Future, ThreadPoolExecutor 13 | from logging import Logger 14 | from pathlib import Path 15 | from typing import TYPE_CHECKING, Any, Optional, TypeVar 16 | 17 | from sensai.util import logging 18 | from sensai.util.logging import LogTime 19 | 20 | from interprompt.jinja_template import JinjaTemplate 21 | from serena import serena_version 22 | from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats 23 | from serena.config.context_mode import RegisteredContext, SerenaAgentContext, SerenaAgentMode 24 | from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, ToolSet, get_serena_managed_in_project_dir 25 | from serena.dashboard import SerenaDashboardAPI 26 | from serena.project import Project 27 | from serena.prompt_factory import SerenaPromptFactory 28 | from serena.tools import ActivateProjectTool, GetCurrentConfigTool, Tool, ToolMarker, ToolRegistry 29 | from serena.util.inspection import iter_subclasses 30 | from serena.util.logging import MemoryLogHandler 31 | from solidlsp import SolidLanguageServer 32 | 33 | if TYPE_CHECKING: 34 | from serena.gui_log_viewer import GuiLogViewer 35 | 36 | log = logging.getLogger(__name__) 37 | TTool = TypeVar("TTool", bound="Tool") 38 | T = TypeVar("T") 39 | SUCCESS_RESULT = "OK" 40 | 41 | 42 | class ProjectNotFoundError(Exception): 43 | pass 44 | 45 | 46 | class MemoriesManager: 47 | def __init__(self, project_root: str): 48 | self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" 49 | self._memory_dir.mkdir(parents=True, exist_ok=True) 50 | 51 | def _get_memory_file_path(self, name: str) -> Path: 52 | # strip all .md from the name. Models tend to get confused, sometimes passing the .md extension and sometimes not. 53 | name = name.replace(".md", "") 54 | filename = f"{name}.md" 55 | return self._memory_dir / filename 56 | 57 | def load_memory(self, name: str) -> str: 58 | memory_file_path = self._get_memory_file_path(name) 59 | if not memory_file_path.exists(): 60 | return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it." 61 | with open(memory_file_path, encoding="utf-8") as f: 62 | return f.read() 63 | 64 | def save_memory(self, name: str, content: str) -> str: 65 | memory_file_path = self._get_memory_file_path(name) 66 | with open(memory_file_path, "w", encoding="utf-8") as f: 67 | f.write(content) 68 | return f"Memory {name} written." 69 | 70 | def list_memories(self) -> list[str]: 71 | return [f.name.replace(".md", "") for f in self._memory_dir.iterdir() if f.is_file()] 72 | 73 | def delete_memory(self, name: str) -> str: 74 | memory_file_path = self._get_memory_file_path(name) 75 | memory_file_path.unlink() 76 | return f"Memory {name} deleted." 77 | 78 | 79 | class AvailableTools: 80 | def __init__(self, tools: list[Tool]): 81 | """ 82 | :param tools: the list of available tools 83 | """ 84 | self.tools = tools 85 | self.tool_names = [tool.get_name_from_cls() for tool in tools] 86 | self.tool_marker_names = set() 87 | for marker_class in iter_subclasses(ToolMarker): 88 | for tool in tools: 89 | if isinstance(tool, marker_class): 90 | self.tool_marker_names.add(marker_class.__name__) 91 | 92 | def __len__(self) -> int: 93 | return len(self.tools) 94 | 95 | 96 | class SerenaAgent: 97 | def __init__( 98 | self, 99 | project: str | None = None, 100 | project_activation_callback: Callable[[], None] | None = None, 101 | serena_config: SerenaConfig | None = None, 102 | context: SerenaAgentContext | None = None, 103 | modes: list[SerenaAgentMode] | None = None, 104 | memory_log_handler: MemoryLogHandler | None = None, 105 | ): 106 | """ 107 | :param project: the project to load immediately or None to not load any project; may be a path to the project or a name of 108 | an already registered project; 109 | :param project_activation_callback: a callback function to be called when a project is activated. 110 | :param serena_config: the Serena configuration or None to read the configuration from the default location. 111 | :param context: the context in which the agent is operating, None for default context. 112 | The context may adjust prompts, tool availability, and tool descriptions. 113 | :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. 114 | The modes may adjust prompts, tool availability, and tool descriptions. 115 | :param memory_log_handler: a MemoryLogHandler instance from which to read log messages; if None, a new one will be created 116 | if necessary. 117 | """ 118 | # obtain serena configuration using the decoupled factory function 119 | self.serena_config = serena_config or SerenaConfig.from_config_file() 120 | 121 | # project-specific instances, which will be initialized upon project activation 122 | self._active_project: Project | None = None 123 | self.language_server: SolidLanguageServer | None = None 124 | self.memories_manager: MemoriesManager | None = None 125 | 126 | # adjust log level 127 | serena_log_level = self.serena_config.log_level 128 | if Logger.root.level > serena_log_level: 129 | log.info(f"Changing the root logger level to {serena_log_level}") 130 | Logger.root.setLevel(serena_log_level) 131 | 132 | def get_memory_log_handler() -> MemoryLogHandler: 133 | nonlocal memory_log_handler 134 | if memory_log_handler is None: 135 | memory_log_handler = MemoryLogHandler(level=serena_log_level) 136 | Logger.root.addHandler(memory_log_handler) 137 | return memory_log_handler 138 | 139 | # open GUI log window if enabled 140 | self._gui_log_viewer: Optional["GuiLogViewer"] = None 141 | if self.serena_config.gui_log_window_enabled: 142 | if platform.system() == "Darwin": 143 | log.warning("GUI log window is not supported on macOS") 144 | else: 145 | # even importing on macOS may fail if tkinter dependencies are unavailable (depends on Python interpreter installation 146 | # which uv used as a base, unfortunately) 147 | from serena.gui_log_viewer import GuiLogViewer 148 | 149 | self._gui_log_viewer = GuiLogViewer("dashboard", title="Serena Logs", memory_log_handler=get_memory_log_handler()) 150 | self._gui_log_viewer.start() 151 | 152 | # set the agent context 153 | if context is None: 154 | context = SerenaAgentContext.load_default() 155 | self._context = context 156 | 157 | # instantiate all tool classes 158 | self._all_tools: dict[type[Tool], Tool] = {tool_class: tool_class(self) for tool_class in ToolRegistry().get_all_tool_classes()} 159 | tool_names = [tool.get_name_from_cls() for tool in self._all_tools.values()] 160 | 161 | # If GUI log window is enabled, set the tool names for highlighting 162 | if self._gui_log_viewer is not None: 163 | self._gui_log_viewer.set_tool_names(tool_names) 164 | 165 | self._tool_usage_stats: ToolUsageStats | None = None 166 | if self.serena_config.record_tool_usage_stats: 167 | token_count_estimator = RegisteredTokenCountEstimator[self.serena_config.token_count_estimator] 168 | log.info(f"Tool usage statistics recording is enabled with token count estimator: {token_count_estimator.name}.") 169 | self._tool_usage_stats = ToolUsageStats(token_count_estimator) 170 | 171 | # start the dashboard (web frontend), registering its log handler 172 | if self.serena_config.web_dashboard: 173 | self._dashboard_thread, port = SerenaDashboardAPI( 174 | get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats 175 | ).run_in_thread() 176 | dashboard_url = f"http://127.0.0.1:{port}/dashboard/index.html" 177 | log.info("Serena web dashboard started at %s", dashboard_url) 178 | if self.serena_config.web_dashboard_open_on_launch: 179 | # open the dashboard URL in the default web browser (using a separate process to control 180 | # output redirection) 181 | process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) 182 | process.start() 183 | process.join(timeout=1) 184 | 185 | # log fundamental information 186 | log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") 187 | log.info("Configuration file: %s", self.serena_config.config_file_path) 188 | log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) 189 | log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}") 190 | 191 | self._check_shell_settings() 192 | 193 | # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), 194 | # limited by the Serena config, the context (which is fixed for the session) and JetBrains mode 195 | tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] 196 | if self._context.name == RegisteredContext.IDE_ASSISTANT.value: 197 | tool_inclusion_definitions.extend(self._ide_assistant_context_tool_inclusion_definitions(project)) 198 | if self.serena_config.jetbrains: 199 | tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) 200 | 201 | self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions) 202 | self._exposed_tools = AvailableTools([t for t in self._all_tools.values() if self._base_tool_set.includes_name(t.get_name())]) 203 | log.info(f"Number of exposed tools: {len(self._exposed_tools)}") 204 | 205 | # create executor for starting the language server and running tools in another thread 206 | # This executor is used to achieve linear task execution, so it is important to use a single-threaded executor. 207 | self._task_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="SerenaAgentExecutor") 208 | self._task_executor_lock = threading.Lock() 209 | self._task_executor_task_index = 1 210 | 211 | # Initialize the prompt factory 212 | self.prompt_factory = SerenaPromptFactory() 213 | self._project_activation_callback = project_activation_callback 214 | 215 | # set the active modes 216 | if modes is None: 217 | modes = SerenaAgentMode.load_default_modes() 218 | self._modes = modes 219 | 220 | self._active_tools: dict[type[Tool], Tool] = {} 221 | self._update_active_tools() 222 | 223 | # activate a project configuration (if provided or if there is only a single project available) 224 | if project is not None: 225 | try: 226 | self.activate_project_from_path_or_name(project) 227 | except Exception as e: 228 | log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e) 229 | 230 | def get_context(self) -> SerenaAgentContext: 231 | return self._context 232 | 233 | def get_tool_description_override(self, tool_name: str) -> str | None: 234 | return self._context.tool_description_overrides.get(tool_name, None) 235 | 236 | def _check_shell_settings(self) -> None: 237 | # On Windows, Claude Code sets COMSPEC to Git-Bash (often even with a path containing spaces), 238 | # which causes all sorts of trouble, preventing language servers from being launched correctly. 239 | # So we make sure that COMSPEC is unset if it has been set to bash specifically. 240 | if platform.system() == "Windows": 241 | comspec = os.environ.get("COMSPEC", "") 242 | if "bash" in comspec: 243 | os.environ["COMSPEC"] = "" # force use of default shell 244 | log.info("Adjusting COMSPEC environment variable to use the default shell instead of '%s'", comspec) 245 | 246 | def _ide_assistant_context_tool_inclusion_definitions(self, project_root_or_name: str | None) -> list[ToolInclusionDefinition]: 247 | """ 248 | In the IDE assistant context, the agent is assumed to work on a single project, and we thus 249 | want to apply that project's tool exclusions/inclusions from the get-go, limiting the set 250 | of tools that will be exposed to the client. 251 | Furthermore, we disable tools that are only relevant for project activation. 252 | So if the project exists, we apply all the aforementioned exclusions. 253 | 254 | :param project_root_or_name: the project root path or project name 255 | :return: 256 | """ 257 | tool_inclusion_definitions = [] 258 | if project_root_or_name is not None: 259 | # Note: Auto-generation is disabled, because the result must be returned instantaneously 260 | # (project generation could take too much time), so as not to delay MCP server startup 261 | # and provide responses to the client immediately. 262 | project = self.load_project_from_path_or_name(project_root_or_name, autogenerate=False) 263 | if project is not None: 264 | tool_inclusion_definitions.append( 265 | ToolInclusionDefinition( 266 | excluded_tools=[ActivateProjectTool.get_name_from_cls(), GetCurrentConfigTool.get_name_from_cls()] 267 | ) 268 | ) 269 | tool_inclusion_definitions.append(project.project_config) 270 | return tool_inclusion_definitions 271 | 272 | def record_tool_usage_if_enabled(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None: 273 | """ 274 | Record the usage of a tool with the given input and output strings if tool usage statistics recording is enabled. 275 | """ 276 | tool_name = tool.get_name() 277 | if self._tool_usage_stats is not None: 278 | input_str = str(input_kwargs) 279 | output_str = str(tool_result) 280 | log.debug(f"Recording tool usage for tool '{tool_name}'") 281 | self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str) 282 | else: 283 | log.debug(f"Tool usage statistics recording is disabled, not recording usage of '{tool_name}'.") 284 | 285 | @staticmethod 286 | def _open_dashboard(url: str) -> None: 287 | # Redirect stdout and stderr file descriptors to /dev/null, 288 | # making sure that nothing can be written to stdout/stderr, even by subprocesses 289 | null_fd = os.open(os.devnull, os.O_WRONLY) 290 | os.dup2(null_fd, sys.stdout.fileno()) 291 | os.dup2(null_fd, sys.stderr.fileno()) 292 | os.close(null_fd) 293 | 294 | # open the dashboard URL in the default web browser 295 | webbrowser.open(url) 296 | 297 | def get_project_root(self) -> str: 298 | """ 299 | :return: the root directory of the active project (if any); raises a ValueError if there is no active project 300 | """ 301 | project = self.get_active_project() 302 | if project is None: 303 | raise ValueError("Cannot get project root if no project is active.") 304 | return project.project_root 305 | 306 | def get_exposed_tool_instances(self) -> list["Tool"]: 307 | """ 308 | :return: the tool instances which are exposed (e.g. to the MCP client). 309 | Note that the set of exposed tools is fixed for the session, as 310 | clients don't react to changes in the set of tools, so this is the superset 311 | of tools that can be offered during the session. 312 | If a client should attempt to use a tool that is dynamically disabled 313 | (e.g. because a project is activated that disables it), it will receive an error. 314 | """ 315 | return list(self._exposed_tools.tools) 316 | 317 | def get_active_project(self) -> Project | None: 318 | """ 319 | :return: the active project or None if no project is active 320 | """ 321 | return self._active_project 322 | 323 | def get_active_project_or_raise(self) -> Project: 324 | """ 325 | :return: the active project or raises an exception if no project is active 326 | """ 327 | project = self.get_active_project() 328 | if project is None: 329 | raise ValueError("No active project. Please activate a project first.") 330 | return project 331 | 332 | def set_modes(self, modes: list[SerenaAgentMode]) -> None: 333 | """ 334 | Set the current mode configurations. 335 | 336 | :param modes: List of mode names or paths to use 337 | """ 338 | self._modes = modes 339 | self._update_active_tools() 340 | 341 | log.info(f"Set modes to {[mode.name for mode in modes]}") 342 | 343 | def get_active_modes(self) -> list[SerenaAgentMode]: 344 | """ 345 | :return: the list of active modes 346 | """ 347 | return list(self._modes) 348 | 349 | def _format_prompt(self, prompt_template: str) -> str: 350 | template = JinjaTemplate(prompt_template) 351 | return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names) 352 | 353 | def create_system_prompt(self) -> str: 354 | available_markers = self._exposed_tools.tool_marker_names 355 | log.info("Generating system prompt with available_tools=(see exposed tools), available_markers=%s", available_markers) 356 | system_prompt = self.prompt_factory.create_system_prompt( 357 | context_system_prompt=self._format_prompt(self._context.prompt), 358 | mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self._modes], 359 | available_tools=self._exposed_tools.tool_names, 360 | available_markers=available_markers, 361 | ) 362 | log.info("System prompt:\n%s", system_prompt) 363 | return system_prompt 364 | 365 | def _update_active_tools(self) -> None: 366 | """ 367 | Update the active tools based on enabled modes and the active project. 368 | The base tool set already takes the Serena configuration and the context into account 369 | (as well as any internal modes that are not handled dynamically, such as JetBrains mode). 370 | """ 371 | tool_set = self._base_tool_set.apply(*self._modes) 372 | if self._active_project is not None: 373 | tool_set = tool_set.apply(self._active_project.project_config) 374 | if self._active_project.project_config.read_only: 375 | tool_set = tool_set.without_editing_tools() 376 | 377 | self._active_tools = { 378 | tool_class: tool_instance 379 | for tool_class, tool_instance in self._all_tools.items() 380 | if tool_set.includes_name(tool_instance.get_name()) 381 | } 382 | 383 | log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") 384 | 385 | def issue_task(self, task: Callable[[], Any], name: str | None = None) -> Future: 386 | """ 387 | Issue a task to the executor for asynchronous execution. 388 | It is ensured that tasks are executed in the order they are issued, one after another. 389 | 390 | :param task: the task to execute 391 | :param name: the name of the task for logging purposes; if None, use the task function's name 392 | :return: a Future object representing the execution of the task 393 | """ 394 | with self._task_executor_lock: 395 | task_name = f"Task-{self._task_executor_task_index}[{name or task.__name__}]" 396 | self._task_executor_task_index += 1 397 | 398 | def task_execution_wrapper() -> Any: 399 | with LogTime(task_name, logger=log): 400 | return task() 401 | 402 | log.info(f"Scheduling {task_name}") 403 | return self._task_executor.submit(task_execution_wrapper) 404 | 405 | def execute_task(self, task: Callable[[], T]) -> T: 406 | """ 407 | Executes the given task synchronously via the agent's task executor. 408 | This is useful for tasks that need to be executed immediately and whose results are needed right away. 409 | 410 | :param task: the task to execute 411 | :return: the result of the task execution 412 | """ 413 | future = self.issue_task(task) 414 | return future.result() 415 | 416 | def is_using_language_server(self) -> bool: 417 | """ 418 | :return: whether this agent uses language server-based code analysis 419 | """ 420 | return not self.serena_config.jetbrains 421 | 422 | def _activate_project(self, project: Project) -> None: 423 | log.info(f"Activating {project.project_name} at {project.project_root}") 424 | self._active_project = project 425 | self._update_active_tools() 426 | 427 | # initialize project-specific instances which do not depend on the language server 428 | self.memories_manager = MemoriesManager(project.project_root) 429 | 430 | def init_language_server() -> None: 431 | # start the language server 432 | with LogTime("Language server initialization", logger=log): 433 | self.reset_language_server() 434 | assert self.language_server is not None 435 | 436 | # initialize the language server in the background (if in language server mode) 437 | if self.is_using_language_server(): 438 | self.issue_task(init_language_server) 439 | 440 | if self._project_activation_callback is not None: 441 | self._project_activation_callback() 442 | 443 | def load_project_from_path_or_name(self, project_root_or_name: str, autogenerate: bool) -> Project | None: 444 | """ 445 | Get a project instance from a path or a name. 446 | 447 | :param project_root_or_name: the path to the project root or the name of the project 448 | :param autogenerate: whether to autogenerate the project for the case where first argument is a directory 449 | which does not yet contain a Serena project configuration file 450 | :return: the project instance if it was found/could be created, None otherwise 451 | """ 452 | project_instance: Project | None = self.serena_config.get_project(project_root_or_name) 453 | if project_instance is not None: 454 | log.info(f"Found registered project '{project_instance.project_name}' at path {project_instance.project_root}") 455 | elif autogenerate and os.path.isdir(project_root_or_name): 456 | project_instance = self.serena_config.add_project_from_path(project_root_or_name) 457 | log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}") 458 | return project_instance 459 | 460 | def activate_project_from_path_or_name(self, project_root_or_name: str) -> Project: 461 | """ 462 | Activate a project from a path or a name. 463 | If the project was already registered, it will just be activated. 464 | If the argument is a path at which no Serena project previously existed, the project will be created beforehand. 465 | Raises ProjectNotFoundError if the project could neither be found nor created. 466 | 467 | :return: a tuple of the project instance and a Boolean indicating whether the project was newly 468 | created 469 | """ 470 | project_instance: Project | None = self.load_project_from_path_or_name(project_root_or_name, autogenerate=True) 471 | if project_instance is None: 472 | raise ProjectNotFoundError( 473 | f"Project '{project_root_or_name}' not found: Not a valid project name or directory. " 474 | f"Existing project names: {self.serena_config.project_names}" 475 | ) 476 | self._activate_project(project_instance) 477 | return project_instance 478 | 479 | def get_active_tool_classes(self) -> list[type["Tool"]]: 480 | """ 481 | :return: the list of active tool classes for the current project 482 | """ 483 | return list(self._active_tools.keys()) 484 | 485 | def get_active_tool_names(self) -> list[str]: 486 | """ 487 | :return: the list of names of the active tools for the current project 488 | """ 489 | return sorted([tool.get_name_from_cls() for tool in self.get_active_tool_classes()]) 490 | 491 | def tool_is_active(self, tool_class: type["Tool"] | str) -> bool: 492 | """ 493 | :param tool_class: the class or name of the tool to check 494 | :return: True if the tool is active, False otherwise 495 | """ 496 | if isinstance(tool_class, str): 497 | return tool_class in self.get_active_tool_names() 498 | else: 499 | return tool_class in self.get_active_tool_classes() 500 | 501 | def get_current_config_overview(self) -> str: 502 | """ 503 | :return: a string overview of the current configuration, including the active and available configuration options 504 | """ 505 | result_str = "Current configuration:\n" 506 | result_str += f"Serena version: {serena_version()}\n" 507 | result_str += f"Loglevel: {self.serena_config.log_level}, trace_lsp_communication={self.serena_config.trace_lsp_communication}\n" 508 | if self._active_project is not None: 509 | result_str += f"Active project: {self._active_project.project_name}\n" 510 | else: 511 | result_str += "No active project\n" 512 | result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n" 513 | result_str += f"Active context: {self._context.name}\n" 514 | 515 | # Active modes 516 | active_mode_names = [mode.name for mode in self.get_active_modes()] 517 | result_str += "Active modes: {}\n".format(", ".join(active_mode_names)) + "\n" 518 | 519 | # Available but not active modes 520 | all_available_modes = SerenaAgentMode.list_registered_mode_names() 521 | inactive_modes = [mode for mode in all_available_modes if mode not in active_mode_names] 522 | if inactive_modes: 523 | result_str += "Available but not active modes: {}\n".format(", ".join(inactive_modes)) + "\n" 524 | 525 | # Active tools 526 | result_str += "Active tools (after all exclusions from the project, context, and modes):\n" 527 | active_tool_names = self.get_active_tool_names() 528 | # print the tool names in chunks 529 | chunk_size = 4 530 | for i in range(0, len(active_tool_names), chunk_size): 531 | chunk = active_tool_names[i : i + chunk_size] 532 | result_str += " " + ", ".join(chunk) + "\n" 533 | 534 | # Available but not active tools 535 | all_tool_names = sorted([tool.get_name_from_cls() for tool in self._all_tools.values()]) 536 | inactive_tool_names = [tool for tool in all_tool_names if tool not in active_tool_names] 537 | if inactive_tool_names: 538 | result_str += "Available but not active tools:\n" 539 | for i in range(0, len(inactive_tool_names), chunk_size): 540 | chunk = inactive_tool_names[i : i + chunk_size] 541 | result_str += " " + ", ".join(chunk) + "\n" 542 | 543 | return result_str 544 | 545 | def is_language_server_running(self) -> bool: 546 | return self.language_server is not None and self.language_server.is_running() 547 | 548 | def reset_language_server(self) -> None: 549 | """ 550 | Starts/resets the language server for the current project 551 | """ 552 | tool_timeout = self.serena_config.tool_timeout 553 | if tool_timeout is None or tool_timeout < 0: 554 | ls_timeout = None 555 | else: 556 | if tool_timeout < 10: 557 | raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds") 558 | ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout 559 | 560 | # stop the language server if it is running 561 | if self.is_language_server_running(): 562 | assert self.language_server is not None 563 | log.info(f"Stopping the current language server at {self.language_server.repository_root_path} ...") 564 | self.language_server.stop() 565 | self.language_server = None 566 | 567 | # instantiate and start the language server 568 | assert self._active_project is not None 569 | self.language_server = self._active_project.create_language_server( 570 | log_level=self.serena_config.log_level, 571 | ls_timeout=ls_timeout, 572 | trace_lsp_communication=self.serena_config.trace_lsp_communication, 573 | ls_specific_settings=self.serena_config.ls_specific_settings, 574 | ) 575 | log.info(f"Starting the language server for {self._active_project.project_name}") 576 | self.language_server.start() 577 | if not self.language_server.is_running(): 578 | raise RuntimeError( 579 | f"Failed to start the language server for {self._active_project.project_name} at {self._active_project.project_root}" 580 | ) 581 | 582 | def get_tool(self, tool_class: type[TTool]) -> TTool: 583 | return self._all_tools[tool_class] # type: ignore 584 | 585 | def print_tool_overview(self) -> None: 586 | ToolRegistry().print_tool_overview(self._active_tools.values()) 587 | 588 | def __del__(self) -> None: 589 | """ 590 | Destructor to clean up the language server instance and GUI logger 591 | """ 592 | if not hasattr(self, "_is_initialized"): 593 | return 594 | log.info("SerenaAgent is shutting down ...") 595 | if self.is_language_server_running(): 596 | log.info("Stopping the language server ...") 597 | assert self.language_server is not None 598 | self.language_server.save_cache() 599 | self.language_server.stop() 600 | if self._gui_log_viewer: 601 | log.info("Stopping the GUI log window ...") 602 | self._gui_log_viewer.stop() 603 | 604 | def get_tool_by_name(self, tool_name: str) -> Tool: 605 | tool_class = ToolRegistry().get_tool_class_by_name(tool_name) 606 | return self.get_tool(tool_class) 607 | ``` -------------------------------------------------------------------------------- /src/solidlsp/lsp_protocol_handler/lsp_requests.py: -------------------------------------------------------------------------------- ```python 1 | # Code generated. DO NOT EDIT. 2 | # LSP v3.17.0 3 | # TODO: Look into use of https://pypi.org/project/ts2python/ to generate the types for https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ 4 | 5 | """ 6 | This file provides the python interface corresponding to the requests and notifications defined in Typescript in the language server protocol. 7 | This file is obtained from https://github.com/predragnikolic/OLSP under the MIT License with the following terms: 8 | 9 | MIT License 10 | 11 | Copyright (c) 2023 Предраг Николић 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | """ 31 | 32 | from typing import Union 33 | 34 | from solidlsp.lsp_protocol_handler import lsp_types 35 | 36 | 37 | class LspRequest: 38 | def __init__(self, send_request): 39 | self.send_request = send_request 40 | 41 | async def implementation( 42 | self, params: lsp_types.ImplementationParams 43 | ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: 44 | """A request to resolve the implementation locations of a symbol at a given text 45 | document position. The request's parameter is of type [TextDocumentPositionParams] 46 | (#TextDocumentPositionParams) the response is of type {@link Definition} or a 47 | Thenable that resolves to such. 48 | """ 49 | return await self.send_request("textDocument/implementation", params) 50 | 51 | async def type_definition( 52 | self, params: lsp_types.TypeDefinitionParams 53 | ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: 54 | """A request to resolve the type definition locations of a symbol at a given text 55 | document position. The request's parameter is of type [TextDocumentPositionParams] 56 | (#TextDocumentPositionParams) the response is of type {@link Definition} or a 57 | Thenable that resolves to such. 58 | """ 59 | return await self.send_request("textDocument/typeDefinition", params) 60 | 61 | async def document_color(self, params: lsp_types.DocumentColorParams) -> list["lsp_types.ColorInformation"]: 62 | """A request to list all color symbols found in a given text document. The request's 63 | parameter is of type {@link DocumentColorParams} the 64 | response is of type {@link ColorInformation ColorInformation[]} or a Thenable 65 | that resolves to such. 66 | """ 67 | return await self.send_request("textDocument/documentColor", params) 68 | 69 | async def color_presentation(self, params: lsp_types.ColorPresentationParams) -> list["lsp_types.ColorPresentation"]: 70 | """A request to list all presentation for a color. The request's 71 | parameter is of type {@link ColorPresentationParams} the 72 | response is of type {@link ColorInformation ColorInformation[]} or a Thenable 73 | that resolves to such. 74 | """ 75 | return await self.send_request("textDocument/colorPresentation", params) 76 | 77 | async def folding_range(self, params: lsp_types.FoldingRangeParams) -> list["lsp_types.FoldingRange"] | None: 78 | """A request to provide folding ranges in a document. The request's 79 | parameter is of type {@link FoldingRangeParams}, the 80 | response is of type {@link FoldingRangeList} or a Thenable 81 | that resolves to such. 82 | """ 83 | return await self.send_request("textDocument/foldingRange", params) 84 | 85 | async def declaration( 86 | self, params: lsp_types.DeclarationParams 87 | ) -> Union["lsp_types.Declaration", list["lsp_types.LocationLink"], None]: 88 | """A request to resolve the type definition locations of a symbol at a given text 89 | document position. The request's parameter is of type [TextDocumentPositionParams] 90 | (#TextDocumentPositionParams) the response is of type {@link Declaration} 91 | or a typed array of {@link DeclarationLink} or a Thenable that resolves 92 | to such. 93 | """ 94 | return await self.send_request("textDocument/declaration", params) 95 | 96 | async def selection_range(self, params: lsp_types.SelectionRangeParams) -> list["lsp_types.SelectionRange"] | None: 97 | """A request to provide selection ranges in a document. The request's 98 | parameter is of type {@link SelectionRangeParams}, the 99 | response is of type {@link SelectionRange SelectionRange[]} or a Thenable 100 | that resolves to such. 101 | """ 102 | return await self.send_request("textDocument/selectionRange", params) 103 | 104 | async def prepare_call_hierarchy(self, params: lsp_types.CallHierarchyPrepareParams) -> list["lsp_types.CallHierarchyItem"] | None: 105 | """A request to result a `CallHierarchyItem` in a document at a given position. 106 | Can be used as an input to an incoming or outgoing call hierarchy. 107 | 108 | @since 3.16.0 109 | """ 110 | return await self.send_request("textDocument/prepareCallHierarchy", params) 111 | 112 | async def incoming_calls( 113 | self, params: lsp_types.CallHierarchyIncomingCallsParams 114 | ) -> list["lsp_types.CallHierarchyIncomingCall"] | None: 115 | """A request to resolve the incoming calls for a given `CallHierarchyItem`. 116 | 117 | @since 3.16.0 118 | """ 119 | return await self.send_request("callHierarchy/incomingCalls", params) 120 | 121 | async def outgoing_calls( 122 | self, params: lsp_types.CallHierarchyOutgoingCallsParams 123 | ) -> list["lsp_types.CallHierarchyOutgoingCall"] | None: 124 | """A request to resolve the outgoing calls for a given `CallHierarchyItem`. 125 | 126 | @since 3.16.0 127 | """ 128 | return await self.send_request("callHierarchy/outgoingCalls", params) 129 | 130 | async def semantic_tokens_full(self, params: lsp_types.SemanticTokensParams) -> Union["lsp_types.SemanticTokens", None]: 131 | """@since 3.16.0""" 132 | return await self.send_request("textDocument/semanticTokens/full", params) 133 | 134 | async def semantic_tokens_delta( 135 | self, params: lsp_types.SemanticTokensDeltaParams 136 | ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: 137 | """@since 3.16.0""" 138 | return await self.send_request("textDocument/semanticTokens/full/delta", params) 139 | 140 | async def semantic_tokens_range(self, params: lsp_types.SemanticTokensRangeParams) -> Union["lsp_types.SemanticTokens", None]: 141 | """@since 3.16.0""" 142 | return await self.send_request("textDocument/semanticTokens/range", params) 143 | 144 | async def linked_editing_range(self, params: lsp_types.LinkedEditingRangeParams) -> Union["lsp_types.LinkedEditingRanges", None]: 145 | """A request to provide ranges that can be edited together. 146 | 147 | @since 3.16.0 148 | """ 149 | return await self.send_request("textDocument/linkedEditingRange", params) 150 | 151 | async def will_create_files(self, params: lsp_types.CreateFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: 152 | """The will create files request is sent from the client to the server before files are actually 153 | created as long as the creation is triggered from within the client. 154 | 155 | @since 3.16.0 156 | """ 157 | return await self.send_request("workspace/willCreateFiles", params) 158 | 159 | async def will_rename_files(self, params: lsp_types.RenameFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: 160 | """The will rename files request is sent from the client to the server before files are actually 161 | renamed as long as the rename is triggered from within the client. 162 | 163 | @since 3.16.0 164 | """ 165 | return await self.send_request("workspace/willRenameFiles", params) 166 | 167 | async def will_delete_files(self, params: lsp_types.DeleteFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: 168 | """The did delete files notification is sent from the client to the server when 169 | files were deleted from within the client. 170 | 171 | @since 3.16.0 172 | """ 173 | return await self.send_request("workspace/willDeleteFiles", params) 174 | 175 | async def moniker(self, params: lsp_types.MonikerParams) -> list["lsp_types.Moniker"] | None: 176 | """A request to get the moniker of a symbol at a given text document position. 177 | The request parameter is of type {@link TextDocumentPositionParams}. 178 | The response is of type {@link Moniker Moniker[]} or `null`. 179 | """ 180 | return await self.send_request("textDocument/moniker", params) 181 | 182 | async def prepare_type_hierarchy(self, params: lsp_types.TypeHierarchyPrepareParams) -> list["lsp_types.TypeHierarchyItem"] | None: 183 | """A request to result a `TypeHierarchyItem` in a document at a given position. 184 | Can be used as an input to a subtypes or supertypes type hierarchy. 185 | 186 | @since 3.17.0 187 | """ 188 | return await self.send_request("textDocument/prepareTypeHierarchy", params) 189 | 190 | async def type_hierarchy_supertypes( 191 | self, params: lsp_types.TypeHierarchySupertypesParams 192 | ) -> list["lsp_types.TypeHierarchyItem"] | None: 193 | """A request to resolve the supertypes for a given `TypeHierarchyItem`. 194 | 195 | @since 3.17.0 196 | """ 197 | return await self.send_request("typeHierarchy/supertypes", params) 198 | 199 | async def type_hierarchy_subtypes(self, params: lsp_types.TypeHierarchySubtypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: 200 | """A request to resolve the subtypes for a given `TypeHierarchyItem`. 201 | 202 | @since 3.17.0 203 | """ 204 | return await self.send_request("typeHierarchy/subtypes", params) 205 | 206 | async def inline_value(self, params: lsp_types.InlineValueParams) -> list["lsp_types.InlineValue"] | None: 207 | """A request to provide inline values in a document. The request's parameter is of 208 | type {@link InlineValueParams}, the response is of type 209 | {@link InlineValue InlineValue[]} or a Thenable that resolves to such. 210 | 211 | @since 3.17.0 212 | """ 213 | return await self.send_request("textDocument/inlineValue", params) 214 | 215 | async def inlay_hint(self, params: lsp_types.InlayHintParams) -> list["lsp_types.InlayHint"] | None: 216 | """A request to provide inlay hints in a document. The request's parameter is of 217 | type {@link InlayHintsParams}, the response is of type 218 | {@link InlayHint InlayHint[]} or a Thenable that resolves to such. 219 | 220 | @since 3.17.0 221 | """ 222 | return await self.send_request("textDocument/inlayHint", params) 223 | 224 | async def resolve_inlay_hint(self, params: lsp_types.InlayHint) -> "lsp_types.InlayHint": 225 | """A request to resolve additional properties for an inlay hint. 226 | The request's parameter is of type {@link InlayHint}, the response is 227 | of type {@link InlayHint} or a Thenable that resolves to such. 228 | 229 | @since 3.17.0 230 | """ 231 | return await self.send_request("inlayHint/resolve", params) 232 | 233 | async def text_document_diagnostic(self, params: lsp_types.DocumentDiagnosticParams) -> "lsp_types.DocumentDiagnosticReport": 234 | """The document diagnostic request definition. 235 | 236 | @since 3.17.0 237 | """ 238 | return await self.send_request("textDocument/diagnostic", params) 239 | 240 | async def workspace_diagnostic(self, params: lsp_types.WorkspaceDiagnosticParams) -> "lsp_types.WorkspaceDiagnosticReport": 241 | """The workspace diagnostic request definition. 242 | 243 | @since 3.17.0 244 | """ 245 | return await self.send_request("workspace/diagnostic", params) 246 | 247 | async def initialize(self, params: lsp_types.InitializeParams) -> "lsp_types.InitializeResult": 248 | """The initialize request is sent from the client to the server. 249 | It is sent once as the request after starting up the server. 250 | The requests parameter is of type {@link InitializeParams} 251 | the response if of type {@link InitializeResult} of a Thenable that 252 | resolves to such. 253 | """ 254 | return await self.send_request("initialize", params) 255 | 256 | async def shutdown(self) -> None: 257 | """A shutdown request is sent from the client to the server. 258 | It is sent once when the client decides to shutdown the 259 | server. The only notification that is sent after a shutdown request 260 | is the exit event. 261 | """ 262 | return await self.send_request("shutdown") 263 | 264 | async def will_save_wait_until(self, params: lsp_types.WillSaveTextDocumentParams) -> list["lsp_types.TextEdit"] | None: 265 | """A document will save request is sent from the client to the server before 266 | the document is actually saved. The request can return an array of TextEdits 267 | which will be applied to the text document before it is saved. Please note that 268 | clients might drop results if computing the text edits took too long or if a 269 | server constantly fails on this request. This is done to keep the save fast and 270 | reliable. 271 | """ 272 | return await self.send_request("textDocument/willSaveWaitUntil", params) 273 | 274 | async def completion( 275 | self, params: lsp_types.CompletionParams 276 | ) -> Union[list["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: 277 | """Request to request completion at a given text document position. The request's 278 | parameter is of type {@link TextDocumentPosition} the response 279 | is of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} 280 | or a Thenable that resolves to such. 281 | 282 | The request can delay the computation of the {@link CompletionItem.detail `detail`} 283 | and {@link CompletionItem.documentation `documentation`} properties to the `completionItem/resolve` 284 | request. However, properties that are needed for the initial sorting and filtering, like `sortText`, 285 | `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. 286 | """ 287 | return await self.send_request("textDocument/completion", params) 288 | 289 | async def resolve_completion_item(self, params: lsp_types.CompletionItem) -> "lsp_types.CompletionItem": 290 | """Request to resolve additional information for a given completion item.The request's 291 | parameter is of type {@link CompletionItem} the response 292 | is of type {@link CompletionItem} or a Thenable that resolves to such. 293 | """ 294 | return await self.send_request("completionItem/resolve", params) 295 | 296 | async def hover(self, params: lsp_types.HoverParams) -> Union["lsp_types.Hover", None]: 297 | """Request to request hover information at a given text document position. The request's 298 | parameter is of type {@link TextDocumentPosition} the response is of 299 | type {@link Hover} or a Thenable that resolves to such. 300 | """ 301 | return await self.send_request("textDocument/hover", params) 302 | 303 | async def signature_help(self, params: lsp_types.SignatureHelpParams) -> Union["lsp_types.SignatureHelp", None]: 304 | return await self.send_request("textDocument/signatureHelp", params) 305 | 306 | async def definition(self, params: lsp_types.DefinitionParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: 307 | """A request to resolve the definition location of a symbol at a given text 308 | document position. The request's parameter is of type [TextDocumentPosition] 309 | (#TextDocumentPosition) the response is of either type {@link Definition} 310 | or a typed array of {@link DefinitionLink} or a Thenable that resolves 311 | to such. 312 | """ 313 | return await self.send_request("textDocument/definition", params) 314 | 315 | async def references(self, params: lsp_types.ReferenceParams) -> list["lsp_types.Location"] | None: 316 | """A request to resolve project-wide references for the symbol denoted 317 | by the given text document position. The request's parameter is of 318 | type {@link ReferenceParams} the response is of type 319 | {@link Location Location[]} or a Thenable that resolves to such. 320 | """ 321 | return await self.send_request("textDocument/references", params) 322 | 323 | async def document_highlight(self, params: lsp_types.DocumentHighlightParams) -> list["lsp_types.DocumentHighlight"] | None: 324 | """Request to resolve a {@link DocumentHighlight} for a given 325 | text document position. The request's parameter is of type [TextDocumentPosition] 326 | (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] 327 | (#DocumentHighlight) or a Thenable that resolves to such. 328 | """ 329 | return await self.send_request("textDocument/documentHighlight", params) 330 | 331 | async def document_symbol( 332 | self, params: lsp_types.DocumentSymbolParams 333 | ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.DocumentSymbol"] | None: 334 | """A request to list all symbols found in a given text document. The request's 335 | parameter is of type {@link TextDocumentIdentifier} the 336 | response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable 337 | that resolves to such. 338 | """ 339 | return await self.send_request("textDocument/documentSymbol", params) 340 | 341 | async def code_action(self, params: lsp_types.CodeActionParams) -> list[Union["lsp_types.Command", "lsp_types.CodeAction"]] | None: 342 | """A request to provide commands for the given text document and range.""" 343 | return await self.send_request("textDocument/codeAction", params) 344 | 345 | async def resolve_code_action(self, params: lsp_types.CodeAction) -> "lsp_types.CodeAction": 346 | """Request to resolve additional information for a given code action.The request's 347 | parameter is of type {@link CodeAction} the response 348 | is of type {@link CodeAction} or a Thenable that resolves to such. 349 | """ 350 | return await self.send_request("codeAction/resolve", params) 351 | 352 | async def workspace_symbol( 353 | self, params: lsp_types.WorkspaceSymbolParams 354 | ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.WorkspaceSymbol"] | None: 355 | """A request to list project-wide symbols matching the query string given 356 | by the {@link WorkspaceSymbolParams}. The response is 357 | of type {@link SymbolInformation SymbolInformation[]} or a Thenable that 358 | resolves to such. 359 | 360 | @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients 361 | need to advertise support for WorkspaceSymbols via the client capability 362 | `workspace.symbol.resolveSupport`. 363 | """ 364 | return await self.send_request("workspace/symbol", params) 365 | 366 | async def resolve_workspace_symbol(self, params: lsp_types.WorkspaceSymbol) -> "lsp_types.WorkspaceSymbol": 367 | """A request to resolve the range inside the workspace 368 | symbol's location. 369 | 370 | @since 3.17.0 371 | """ 372 | return await self.send_request("workspaceSymbol/resolve", params) 373 | 374 | async def code_lens(self, params: lsp_types.CodeLensParams) -> list["lsp_types.CodeLens"] | None: 375 | """A request to provide code lens for the given text document.""" 376 | return await self.send_request("textDocument/codeLens", params) 377 | 378 | async def resolve_code_lens(self, params: lsp_types.CodeLens) -> "lsp_types.CodeLens": 379 | """A request to resolve a command for a given code lens.""" 380 | return await self.send_request("codeLens/resolve", params) 381 | 382 | async def document_link(self, params: lsp_types.DocumentLinkParams) -> list["lsp_types.DocumentLink"] | None: 383 | """A request to provide document links""" 384 | return await self.send_request("textDocument/documentLink", params) 385 | 386 | async def resolve_document_link(self, params: lsp_types.DocumentLink) -> "lsp_types.DocumentLink": 387 | """Request to resolve additional information for a given document link. The request's 388 | parameter is of type {@link DocumentLink} the response 389 | is of type {@link DocumentLink} or a Thenable that resolves to such. 390 | """ 391 | return await self.send_request("documentLink/resolve", params) 392 | 393 | async def formatting(self, params: lsp_types.DocumentFormattingParams) -> list["lsp_types.TextEdit"] | None: 394 | """A request to to format a whole document.""" 395 | return await self.send_request("textDocument/formatting", params) 396 | 397 | async def range_formatting(self, params: lsp_types.DocumentRangeFormattingParams) -> list["lsp_types.TextEdit"] | None: 398 | """A request to to format a range in a document.""" 399 | return await self.send_request("textDocument/rangeFormatting", params) 400 | 401 | async def on_type_formatting(self, params: lsp_types.DocumentOnTypeFormattingParams) -> list["lsp_types.TextEdit"] | None: 402 | """A request to format a document on type.""" 403 | return await self.send_request("textDocument/onTypeFormatting", params) 404 | 405 | async def rename(self, params: lsp_types.RenameParams) -> Union["lsp_types.WorkspaceEdit", None]: 406 | """A request to rename a symbol.""" 407 | return await self.send_request("textDocument/rename", params) 408 | 409 | async def prepare_rename(self, params: lsp_types.PrepareRenameParams) -> Union["lsp_types.PrepareRenameResult", None]: 410 | """A request to test and perform the setup necessary for a rename. 411 | 412 | @since 3.16 - support for default behavior 413 | """ 414 | return await self.send_request("textDocument/prepareRename", params) 415 | 416 | async def execute_command(self, params: lsp_types.ExecuteCommandParams) -> Union["lsp_types.LSPAny", None]: 417 | """A request send from the client to the server to execute a command. The request might return 418 | a workspace edit which the client will apply to the workspace. 419 | """ 420 | return await self.send_request("workspace/executeCommand", params) 421 | 422 | 423 | class LspNotification: 424 | def __init__(self, send_notification): 425 | self.send_notification = send_notification 426 | 427 | def did_change_workspace_folders(self, params: lsp_types.DidChangeWorkspaceFoldersParams) -> None: 428 | """The `workspace/didChangeWorkspaceFolders` notification is sent from the client to the server when the workspace 429 | folder configuration changes. 430 | """ 431 | return self.send_notification("workspace/didChangeWorkspaceFolders", params) 432 | 433 | def cancel_work_done_progress(self, params: lsp_types.WorkDoneProgressCancelParams) -> None: 434 | """The `window/workDoneProgress/cancel` notification is sent from the client to the server to cancel a progress 435 | initiated on the server side. 436 | """ 437 | return self.send_notification("window/workDoneProgress/cancel", params) 438 | 439 | def did_create_files(self, params: lsp_types.CreateFilesParams) -> None: 440 | """The did create files notification is sent from the client to the server when 441 | files were created from within the client. 442 | 443 | @since 3.16.0 444 | """ 445 | return self.send_notification("workspace/didCreateFiles", params) 446 | 447 | def did_rename_files(self, params: lsp_types.RenameFilesParams) -> None: 448 | """The did rename files notification is sent from the client to the server when 449 | files were renamed from within the client. 450 | 451 | @since 3.16.0 452 | """ 453 | return self.send_notification("workspace/didRenameFiles", params) 454 | 455 | def did_delete_files(self, params: lsp_types.DeleteFilesParams) -> None: 456 | """The will delete files request is sent from the client to the server before files are actually 457 | deleted as long as the deletion is triggered from within the client. 458 | 459 | @since 3.16.0 460 | """ 461 | return self.send_notification("workspace/didDeleteFiles", params) 462 | 463 | def did_open_notebook_document(self, params: lsp_types.DidOpenNotebookDocumentParams) -> None: 464 | """A notification sent when a notebook opens. 465 | 466 | @since 3.17.0 467 | """ 468 | return self.send_notification("notebookDocument/didOpen", params) 469 | 470 | def did_change_notebook_document(self, params: lsp_types.DidChangeNotebookDocumentParams) -> None: 471 | return self.send_notification("notebookDocument/didChange", params) 472 | 473 | def did_save_notebook_document(self, params: lsp_types.DidSaveNotebookDocumentParams) -> None: 474 | """A notification sent when a notebook document is saved. 475 | 476 | @since 3.17.0 477 | """ 478 | return self.send_notification("notebookDocument/didSave", params) 479 | 480 | def did_close_notebook_document(self, params: lsp_types.DidCloseNotebookDocumentParams) -> None: 481 | """A notification sent when a notebook closes. 482 | 483 | @since 3.17.0 484 | """ 485 | return self.send_notification("notebookDocument/didClose", params) 486 | 487 | def initialized(self, params: lsp_types.InitializedParams) -> None: 488 | """The initialized notification is sent from the client to the 489 | server after the client is fully initialized and the server 490 | is allowed to send requests from the server to the client. 491 | """ 492 | return self.send_notification("initialized", params) 493 | 494 | def exit(self) -> None: 495 | """The exit event is sent from the client to the server to 496 | ask the server to exit its process. 497 | """ 498 | return self.send_notification("exit") 499 | 500 | def workspace_did_change_configuration(self, params: lsp_types.DidChangeConfigurationParams) -> None: 501 | """The configuration change notification is sent from the client to the server 502 | when the client's configuration has changed. The notification contains 503 | the changed configuration as defined by the language client. 504 | """ 505 | return self.send_notification("workspace/didChangeConfiguration", params) 506 | 507 | def did_open_text_document(self, params: lsp_types.DidOpenTextDocumentParams) -> None: 508 | """The document open notification is sent from the client to the server to signal 509 | newly opened text documents. The document's truth is now managed by the client 510 | and the server must not try to read the document's truth using the document's 511 | uri. Open in this sense means it is managed by the client. It doesn't necessarily 512 | mean that its content is presented in an editor. An open notification must not 513 | be sent more than once without a corresponding close notification send before. 514 | This means open and close notification must be balanced and the max open count 515 | is one. 516 | """ 517 | return self.send_notification("textDocument/didOpen", params) 518 | 519 | def did_change_text_document(self, params: lsp_types.DidChangeTextDocumentParams) -> None: 520 | """The document change notification is sent from the client to the server to signal 521 | changes to a text document. 522 | """ 523 | return self.send_notification("textDocument/didChange", params) 524 | 525 | def did_close_text_document(self, params: lsp_types.DidCloseTextDocumentParams) -> None: 526 | """The document close notification is sent from the client to the server when 527 | the document got closed in the client. The document's truth now exists where 528 | the document's uri points to (e.g. if the document's uri is a file uri the 529 | truth now exists on disk). As with the open notification the close notification 530 | is about managing the document's content. Receiving a close notification 531 | doesn't mean that the document was open in an editor before. A close 532 | notification requires a previous open notification to be sent. 533 | """ 534 | return self.send_notification("textDocument/didClose", params) 535 | 536 | def did_save_text_document(self, params: lsp_types.DidSaveTextDocumentParams) -> None: 537 | """The document save notification is sent from the client to the server when 538 | the document got saved in the client. 539 | """ 540 | return self.send_notification("textDocument/didSave", params) 541 | 542 | def will_save_text_document(self, params: lsp_types.WillSaveTextDocumentParams) -> None: 543 | """A document will save notification is sent from the client to the server before 544 | the document is actually saved. 545 | """ 546 | return self.send_notification("textDocument/willSave", params) 547 | 548 | def did_change_watched_files(self, params: lsp_types.DidChangeWatchedFilesParams) -> None: 549 | """The watched files notification is sent from the client to the server when 550 | the client detects changes to file watched by the language client. 551 | """ 552 | return self.send_notification("workspace/didChangeWatchedFiles", params) 553 | 554 | def set_trace(self, params: lsp_types.SetTraceParams) -> None: 555 | return self.send_notification("$/setTrace", params) 556 | 557 | def cancel_request(self, params: lsp_types.CancelParams) -> None: 558 | return self.send_notification("$/cancelRequest", params) 559 | 560 | def progress(self, params: lsp_types.ProgressParams) -> None: 561 | return self.send_notification("$/progress", params) 562 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/rust_analyzer.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import shutil 9 | import subprocess 10 | import threading 11 | 12 | from overrides import override 13 | 14 | from solidlsp.ls import SolidLanguageServer 15 | from solidlsp.ls_config import LanguageServerConfig 16 | from solidlsp.ls_logger import LanguageServerLogger 17 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 18 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 19 | from solidlsp.settings import SolidLSPSettings 20 | 21 | 22 | class RustAnalyzer(SolidLanguageServer): 23 | """ 24 | Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. 25 | """ 26 | 27 | @staticmethod 28 | def _get_rustup_version(): 29 | """Get installed rustup version or None if not found.""" 30 | try: 31 | result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False) 32 | if result.returncode == 0: 33 | return result.stdout.strip() 34 | except FileNotFoundError: 35 | return None 36 | return None 37 | 38 | @staticmethod 39 | def _get_rust_analyzer_path(): 40 | """Get rust-analyzer path via rustup or system PATH.""" 41 | # First try rustup 42 | try: 43 | result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False) 44 | if result.returncode == 0: 45 | return result.stdout.strip() 46 | except FileNotFoundError: 47 | pass 48 | 49 | # Fallback to system PATH 50 | return shutil.which("rust-analyzer") 51 | 52 | @staticmethod 53 | def _ensure_rust_analyzer_installed(): 54 | """Ensure rust-analyzer is available, install via rustup if needed.""" 55 | path = RustAnalyzer._get_rust_analyzer_path() 56 | if path: 57 | return path 58 | 59 | # Check if rustup is available 60 | if not RustAnalyzer._get_rustup_version(): 61 | raise RuntimeError( 62 | "Neither rust-analyzer nor rustup is installed.\n" 63 | "Please install Rust via https://rustup.rs/ or install rust-analyzer separately." 64 | ) 65 | 66 | # Try to install rust-analyzer component 67 | result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True) 68 | if result.returncode != 0: 69 | raise RuntimeError(f"Failed to install rust-analyzer via rustup: {result.stderr}") 70 | 71 | # Try again after installation 72 | path = RustAnalyzer._get_rust_analyzer_path() 73 | if not path: 74 | raise RuntimeError("rust-analyzer installation succeeded but binary not found in PATH") 75 | 76 | return path 77 | 78 | def __init__( 79 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 80 | ): 81 | """ 82 | Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 83 | """ 84 | rustanalyzer_executable_path = self._ensure_rust_analyzer_installed() 85 | logger.log(f"Using rust-analyzer at: {rustanalyzer_executable_path}", logging.INFO) 86 | 87 | super().__init__( 88 | config, 89 | logger, 90 | repository_root_path, 91 | ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), 92 | "rust", 93 | solidlsp_settings, 94 | ) 95 | self.server_ready = threading.Event() 96 | self.service_ready_event = threading.Event() 97 | self.initialize_searcher_command_available = threading.Event() 98 | self.resolve_main_method_available = threading.Event() 99 | 100 | @override 101 | def is_ignored_dirname(self, dirname: str) -> bool: 102 | return super().is_ignored_dirname(dirname) or dirname in ["target"] 103 | 104 | @staticmethod 105 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 106 | """ 107 | Returns the initialize params for the Rust Analyzer Language Server. 108 | """ 109 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 110 | initialize_params = { 111 | "clientInfo": {"name": "Visual Studio Code - Insiders", "version": "1.82.0-insider"}, 112 | "locale": "en", 113 | "capabilities": { 114 | "workspace": { 115 | "applyEdit": True, 116 | "workspaceEdit": { 117 | "documentChanges": True, 118 | "resourceOperations": ["create", "rename", "delete"], 119 | "failureHandling": "textOnlyTransactional", 120 | "normalizesLineEndings": True, 121 | "changeAnnotationSupport": {"groupsOnLabel": True}, 122 | }, 123 | "configuration": True, 124 | "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, 125 | "symbol": { 126 | "dynamicRegistration": True, 127 | "symbolKind": {"valueSet": list(range(1, 27))}, 128 | "tagSupport": {"valueSet": [1]}, 129 | "resolveSupport": {"properties": ["location.range"]}, 130 | }, 131 | "codeLens": {"refreshSupport": True}, 132 | "executeCommand": {"dynamicRegistration": True}, 133 | "didChangeConfiguration": {"dynamicRegistration": True}, 134 | "workspaceFolders": True, 135 | "semanticTokens": {"refreshSupport": True}, 136 | "fileOperations": { 137 | "dynamicRegistration": True, 138 | "didCreate": True, 139 | "didRename": True, 140 | "didDelete": True, 141 | "willCreate": True, 142 | "willRename": True, 143 | "willDelete": True, 144 | }, 145 | "inlineValue": {"refreshSupport": True}, 146 | "inlayHint": {"refreshSupport": True}, 147 | "diagnostics": {"refreshSupport": True}, 148 | }, 149 | "textDocument": { 150 | "publishDiagnostics": { 151 | "relatedInformation": True, 152 | "versionSupport": False, 153 | "tagSupport": {"valueSet": [1, 2]}, 154 | "codeDescriptionSupport": True, 155 | "dataSupport": True, 156 | }, 157 | "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, 158 | "completion": { 159 | "dynamicRegistration": True, 160 | "contextSupport": True, 161 | "completionItem": { 162 | "snippetSupport": True, 163 | "commitCharactersSupport": True, 164 | "documentationFormat": ["markdown", "plaintext"], 165 | "deprecatedSupport": True, 166 | "preselectSupport": True, 167 | "tagSupport": {"valueSet": [1]}, 168 | "insertReplaceSupport": True, 169 | "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, 170 | "insertTextModeSupport": {"valueSet": [1, 2]}, 171 | "labelDetailsSupport": True, 172 | }, 173 | "insertTextMode": 2, 174 | "completionItemKind": { 175 | "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] 176 | }, 177 | "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, 178 | }, 179 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 180 | "signatureHelp": { 181 | "dynamicRegistration": True, 182 | "signatureInformation": { 183 | "documentationFormat": ["markdown", "plaintext"], 184 | "parameterInformation": {"labelOffsetSupport": True}, 185 | "activeParameterSupport": True, 186 | }, 187 | "contextSupport": True, 188 | }, 189 | "definition": {"dynamicRegistration": True, "linkSupport": True}, 190 | "references": {"dynamicRegistration": True}, 191 | "documentHighlight": {"dynamicRegistration": True}, 192 | "documentSymbol": { 193 | "dynamicRegistration": True, 194 | "symbolKind": {"valueSet": list(range(1, 27))}, 195 | "hierarchicalDocumentSymbolSupport": True, 196 | "tagSupport": {"valueSet": [1]}, 197 | "labelSupport": True, 198 | }, 199 | "codeAction": { 200 | "dynamicRegistration": True, 201 | "isPreferredSupport": True, 202 | "disabledSupport": True, 203 | "dataSupport": True, 204 | "resolveSupport": {"properties": ["edit"]}, 205 | "codeActionLiteralSupport": { 206 | "codeActionKind": { 207 | "valueSet": [ 208 | "", 209 | "quickfix", 210 | "refactor", 211 | "refactor.extract", 212 | "refactor.inline", 213 | "refactor.rewrite", 214 | "source", 215 | "source.organizeImports", 216 | ] 217 | } 218 | }, 219 | "honorsChangeAnnotations": False, 220 | }, 221 | "codeLens": {"dynamicRegistration": True}, 222 | "formatting": {"dynamicRegistration": True}, 223 | "rangeFormatting": {"dynamicRegistration": True}, 224 | "onTypeFormatting": {"dynamicRegistration": True}, 225 | "rename": { 226 | "dynamicRegistration": True, 227 | "prepareSupport": True, 228 | "prepareSupportDefaultBehavior": 1, 229 | "honorsChangeAnnotations": True, 230 | }, 231 | "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, 232 | "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, 233 | "implementation": {"dynamicRegistration": True, "linkSupport": True}, 234 | "colorProvider": {"dynamicRegistration": True}, 235 | "foldingRange": { 236 | "dynamicRegistration": True, 237 | "rangeLimit": 5000, 238 | "lineFoldingOnly": True, 239 | "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, 240 | "foldingRange": {"collapsedText": False}, 241 | }, 242 | "declaration": {"dynamicRegistration": True, "linkSupport": True}, 243 | "selectionRange": {"dynamicRegistration": True}, 244 | "callHierarchy": {"dynamicRegistration": True}, 245 | "semanticTokens": { 246 | "dynamicRegistration": True, 247 | "tokenTypes": [ 248 | "namespace", 249 | "type", 250 | "class", 251 | "enum", 252 | "interface", 253 | "struct", 254 | "typeParameter", 255 | "parameter", 256 | "variable", 257 | "property", 258 | "enumMember", 259 | "event", 260 | "function", 261 | "method", 262 | "macro", 263 | "keyword", 264 | "modifier", 265 | "comment", 266 | "string", 267 | "number", 268 | "regexp", 269 | "operator", 270 | "decorator", 271 | ], 272 | "tokenModifiers": [ 273 | "declaration", 274 | "definition", 275 | "readonly", 276 | "static", 277 | "deprecated", 278 | "abstract", 279 | "async", 280 | "modification", 281 | "documentation", 282 | "defaultLibrary", 283 | ], 284 | "formats": ["relative"], 285 | "requests": {"range": True, "full": {"delta": True}}, 286 | "multilineTokenSupport": False, 287 | "overlappingTokenSupport": False, 288 | "serverCancelSupport": True, 289 | "augmentsSyntaxTokens": False, 290 | }, 291 | "linkedEditingRange": {"dynamicRegistration": True}, 292 | "typeHierarchy": {"dynamicRegistration": True}, 293 | "inlineValue": {"dynamicRegistration": True}, 294 | "inlayHint": { 295 | "dynamicRegistration": True, 296 | "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, 297 | }, 298 | "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, 299 | }, 300 | "window": { 301 | "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, 302 | "showDocument": {"support": True}, 303 | "workDoneProgress": True, 304 | }, 305 | "general": { 306 | "staleRequestSupport": { 307 | "cancel": True, 308 | "retryOnContentModified": [ 309 | "textDocument/semanticTokens/full", 310 | "textDocument/semanticTokens/range", 311 | "textDocument/semanticTokens/full/delta", 312 | ], 313 | }, 314 | "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, 315 | "markdown": { 316 | "parser": "marked", 317 | "version": "1.1.0", 318 | "allowedTags": [ 319 | "ul", 320 | "li", 321 | "p", 322 | "code", 323 | "blockquote", 324 | "ol", 325 | "h1", 326 | "h2", 327 | "h3", 328 | "h4", 329 | "h5", 330 | "h6", 331 | "hr", 332 | "em", 333 | "pre", 334 | "table", 335 | "thead", 336 | "tbody", 337 | "tr", 338 | "th", 339 | "td", 340 | "div", 341 | "del", 342 | "a", 343 | "strong", 344 | "br", 345 | "img", 346 | "span", 347 | ], 348 | }, 349 | "positionEncodings": ["utf-16"], 350 | }, 351 | "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, 352 | "experimental": { 353 | "snippetTextEdit": True, 354 | "codeActionGroup": True, 355 | "hoverActions": True, 356 | "serverStatusNotification": True, 357 | "colorDiagnosticOutput": True, 358 | "openServerLogs": True, 359 | "localDocs": True, 360 | "commands": { 361 | "commands": [ 362 | "rust-analyzer.runSingle", 363 | "rust-analyzer.debugSingle", 364 | "rust-analyzer.showReferences", 365 | "rust-analyzer.gotoLocation", 366 | "editor.action.triggerParameterHints", 367 | ] 368 | }, 369 | }, 370 | }, 371 | "initializationOptions": { 372 | "cargoRunner": None, 373 | "runnables": {"extraEnv": None, "problemMatcher": ["$rustc"], "command": None, "extraArgs": []}, 374 | "statusBar": {"clickAction": "openLogs"}, 375 | "server": {"path": None, "extraEnv": None}, 376 | "trace": {"server": "verbose", "extension": False}, 377 | "debug": { 378 | "engine": "auto", 379 | "sourceFileMap": {"/rustc/<id>": "${env:USERPROFILE}/.rustup/toolchains/<toolchain-id>/lib/rustlib/src/rust"}, 380 | "openDebugPane": False, 381 | "engineSettings": {}, 382 | }, 383 | "restartServerOnConfigChange": False, 384 | "typing": {"continueCommentsOnNewline": True, "autoClosingAngleBrackets": {"enable": False}}, 385 | "diagnostics": { 386 | "previewRustcOutput": False, 387 | "useRustcErrorCode": False, 388 | "disabled": [], 389 | "enable": True, 390 | "experimental": {"enable": False}, 391 | "remapPrefix": {}, 392 | "warningsAsHint": [], 393 | "warningsAsInfo": [], 394 | }, 395 | "discoverProjectRunner": None, 396 | "showUnlinkedFileNotification": True, 397 | "showDependenciesExplorer": True, 398 | "assist": {"emitMustUse": False, "expressionFillDefault": "todo"}, 399 | "cachePriming": {"enable": True, "numThreads": 0}, 400 | "cargo": { 401 | "autoreload": True, 402 | "buildScripts": { 403 | "enable": True, 404 | "invocationLocation": "workspace", 405 | "invocationStrategy": "per_workspace", 406 | "overrideCommand": None, 407 | "useRustcWrapper": True, 408 | }, 409 | "cfgs": {}, 410 | "extraArgs": [], 411 | "extraEnv": {}, 412 | "features": [], 413 | "noDefaultFeatures": False, 414 | "sysroot": "discover", 415 | "sysrootSrc": None, 416 | "target": None, 417 | "unsetTest": ["core"], 418 | }, 419 | "checkOnSave": True, 420 | "check": { 421 | "allTargets": True, 422 | "command": "check", 423 | "extraArgs": [], 424 | "extraEnv": {}, 425 | "features": None, 426 | "ignore": [], 427 | "invocationLocation": "workspace", 428 | "invocationStrategy": "per_workspace", 429 | "noDefaultFeatures": None, 430 | "overrideCommand": None, 431 | "targets": None, 432 | }, 433 | "completion": { 434 | "autoimport": {"enable": True}, 435 | "autoself": {"enable": True}, 436 | "callable": {"snippets": "fill_arguments"}, 437 | "fullFunctionSignatures": {"enable": False}, 438 | "limit": None, 439 | "postfix": {"enable": True}, 440 | "privateEditable": {"enable": False}, 441 | "snippets": { 442 | "custom": { 443 | "Arc::new": { 444 | "postfix": "arc", 445 | "body": "Arc::new(${receiver})", 446 | "requires": "std::sync::Arc", 447 | "description": "Put the expression into an `Arc`", 448 | "scope": "expr", 449 | }, 450 | "Rc::new": { 451 | "postfix": "rc", 452 | "body": "Rc::new(${receiver})", 453 | "requires": "std::rc::Rc", 454 | "description": "Put the expression into an `Rc`", 455 | "scope": "expr", 456 | }, 457 | "Box::pin": { 458 | "postfix": "pinbox", 459 | "body": "Box::pin(${receiver})", 460 | "requires": "std::boxed::Box", 461 | "description": "Put the expression into a pinned `Box`", 462 | "scope": "expr", 463 | }, 464 | "Ok": { 465 | "postfix": "ok", 466 | "body": "Ok(${receiver})", 467 | "description": "Wrap the expression in a `Result::Ok`", 468 | "scope": "expr", 469 | }, 470 | "Err": { 471 | "postfix": "err", 472 | "body": "Err(${receiver})", 473 | "description": "Wrap the expression in a `Result::Err`", 474 | "scope": "expr", 475 | }, 476 | "Some": { 477 | "postfix": "some", 478 | "body": "Some(${receiver})", 479 | "description": "Wrap the expression in an `Option::Some`", 480 | "scope": "expr", 481 | }, 482 | } 483 | }, 484 | }, 485 | "files": {"excludeDirs": [], "watcher": "client"}, 486 | "highlightRelated": { 487 | "breakPoints": {"enable": True}, 488 | "closureCaptures": {"enable": True}, 489 | "exitPoints": {"enable": True}, 490 | "references": {"enable": True}, 491 | "yieldPoints": {"enable": True}, 492 | }, 493 | "hover": { 494 | "actions": { 495 | "debug": {"enable": True}, 496 | "enable": True, 497 | "gotoTypeDef": {"enable": True}, 498 | "implementations": {"enable": True}, 499 | "references": {"enable": False}, 500 | "run": {"enable": True}, 501 | }, 502 | "documentation": {"enable": True, "keywords": {"enable": True}}, 503 | "links": {"enable": True}, 504 | "memoryLayout": {"alignment": "hexadecimal", "enable": True, "niches": False, "offset": "hexadecimal", "size": "both"}, 505 | }, 506 | "imports": { 507 | "granularity": {"enforce": False, "group": "crate"}, 508 | "group": {"enable": True}, 509 | "merge": {"glob": True}, 510 | "preferNoStd": False, 511 | "preferPrelude": False, 512 | "prefix": "plain", 513 | }, 514 | "inlayHints": { 515 | "bindingModeHints": {"enable": False}, 516 | "chainingHints": {"enable": True}, 517 | "closingBraceHints": {"enable": True, "minLines": 25}, 518 | "closureCaptureHints": {"enable": False}, 519 | "closureReturnTypeHints": {"enable": "never"}, 520 | "closureStyle": "impl_fn", 521 | "discriminantHints": {"enable": "never"}, 522 | "expressionAdjustmentHints": {"enable": "never", "hideOutsideUnsafe": False, "mode": "prefix"}, 523 | "lifetimeElisionHints": {"enable": "never", "useParameterNames": False}, 524 | "maxLength": 25, 525 | "parameterHints": {"enable": True}, 526 | "reborrowHints": {"enable": "never"}, 527 | "renderColons": True, 528 | "typeHints": {"enable": True, "hideClosureInitialization": False, "hideNamedConstructor": False}, 529 | }, 530 | "interpret": {"tests": False}, 531 | "joinLines": {"joinAssignments": True, "joinElseIf": True, "removeTrailingComma": True, "unwrapTrivialBlock": True}, 532 | "lens": { 533 | "debug": {"enable": True}, 534 | "enable": True, 535 | "forceCustomCommands": True, 536 | "implementations": {"enable": True}, 537 | "location": "above_name", 538 | "references": { 539 | "adt": {"enable": False}, 540 | "enumVariant": {"enable": False}, 541 | "method": {"enable": False}, 542 | "trait": {"enable": False}, 543 | }, 544 | "run": {"enable": True}, 545 | }, 546 | "linkedProjects": [], 547 | "lru": {"capacity": None, "query": {"capacities": {}}}, 548 | "notifications": {"cargoTomlNotFound": True}, 549 | "numThreads": None, 550 | "procMacro": {"attributes": {"enable": True}, "enable": True, "ignored": {}, "server": None}, 551 | "references": {"excludeImports": False}, 552 | "rust": {"analyzerTargetDir": None}, 553 | "rustc": {"source": None}, 554 | "rustfmt": {"extraArgs": [], "overrideCommand": None, "rangeFormatting": {"enable": False}}, 555 | "semanticHighlighting": { 556 | "doc": {"comment": {"inject": {"enable": True}}}, 557 | "nonStandardTokens": True, 558 | "operator": {"enable": True, "specialization": {"enable": False}}, 559 | "punctuation": {"enable": False, "separate": {"macro": {"bang": False}}, "specialization": {"enable": False}}, 560 | "strings": {"enable": True}, 561 | }, 562 | "signatureInfo": {"detail": "full", "documentation": {"enable": True}}, 563 | "workspace": {"symbol": {"search": {"kind": "only_types", "limit": 128, "scope": "workspace"}}}, 564 | }, 565 | "trace": "verbose", 566 | "processId": os.getpid(), 567 | "rootPath": repository_absolute_path, 568 | "rootUri": root_uri, 569 | "workspaceFolders": [ 570 | { 571 | "uri": root_uri, 572 | "name": os.path.basename(repository_absolute_path), 573 | } 574 | ], 575 | } 576 | return initialize_params 577 | 578 | def _start_server(self): 579 | """ 580 | Starts the Rust Analyzer Language Server 581 | """ 582 | 583 | def register_capability_handler(params): 584 | assert "registrations" in params 585 | for registration in params["registrations"]: 586 | if registration["method"] == "workspace/executeCommand": 587 | self.initialize_searcher_command_available.set() 588 | self.resolve_main_method_available.set() 589 | return 590 | 591 | def lang_status_handler(params): 592 | # TODO: Should we wait for 593 | # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} 594 | # Before proceeding? 595 | if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": 596 | self.service_ready_event.set() 597 | 598 | def execute_client_command_handler(params): 599 | return [] 600 | 601 | def do_nothing(params): 602 | return 603 | 604 | def check_experimental_status(params): 605 | if params["quiescent"] == True: 606 | self.server_ready.set() 607 | 608 | def window_log_message(msg): 609 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 610 | 611 | self.server.on_request("client/registerCapability", register_capability_handler) 612 | self.server.on_notification("language/status", lang_status_handler) 613 | self.server.on_notification("window/logMessage", window_log_message) 614 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 615 | self.server.on_notification("$/progress", do_nothing) 616 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 617 | self.server.on_notification("language/actionableNotification", do_nothing) 618 | self.server.on_notification("experimental/serverStatus", check_experimental_status) 619 | 620 | self.logger.log("Starting RustAnalyzer server process", logging.INFO) 621 | self.server.start() 622 | initialize_params = self._get_initialize_params(self.repository_root_path) 623 | 624 | self.logger.log( 625 | "Sending initialize request from LSP client to LSP server and awaiting response", 626 | logging.INFO, 627 | ) 628 | init_response = self.server.send.initialize(initialize_params) 629 | assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 630 | assert "completionProvider" in init_response["capabilities"] 631 | assert init_response["capabilities"]["completionProvider"] == { 632 | "resolveProvider": True, 633 | "triggerCharacters": [":", ".", "'", "("], 634 | "completionItem": {"labelDetailsSupport": True}, 635 | } 636 | self.server.notify.initialized({}) 637 | self.completions_available.set() 638 | 639 | self.server_ready.wait() 640 | ```