This is page 4 of 14. Use http://codebase.md/oraios/serena?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .devcontainer │ └── devcontainer.json ├── .dockerignore ├── .env.example ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── config.yml │ │ ├── feature_request.md │ │ └── issue--bug--performance-problem--question-.md │ └── workflows │ ├── codespell.yml │ ├── docker.yml │ ├── junie.yml │ ├── lint_and_docs.yaml │ ├── publish.yml │ └── pytest.yml ├── .gitignore ├── .serena │ ├── memories │ │ ├── adding_new_language_support_guide.md │ │ ├── serena_core_concepts_and_architecture.md │ │ ├── serena_repository_structure.md │ │ └── suggested_commands.md │ └── project.yml ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── CLAUDE.md ├── compose.yaml ├── CONTRIBUTING.md ├── docker_build_and_run.sh ├── DOCKER.md ├── Dockerfile ├── docs │ ├── custom_agent.md │ └── serena_on_chatgpt.md ├── flake.lock ├── flake.nix ├── lessons_learned.md ├── LICENSE ├── llms-install.md ├── public │ └── .gitignore ├── pyproject.toml ├── README.md ├── resources │ ├── serena-icons.cdr │ ├── serena-logo-dark-mode.svg │ ├── serena-logo.cdr │ ├── serena-logo.svg │ └── vscode_sponsor_logo.png ├── roadmap.md ├── scripts │ ├── agno_agent.py │ ├── demo_run_tools.py │ ├── gen_prompt_factory.py │ ├── mcp_server.py │ ├── print_mode_context_options.py │ └── print_tool_overview.py ├── src │ ├── interprompt │ │ ├── __init__.py │ │ ├── .syncCommitId.remote │ │ ├── .syncCommitId.this │ │ ├── jinja_template.py │ │ ├── multilang_prompt.py │ │ ├── prompt_factory.py │ │ └── util │ │ ├── __init__.py │ │ └── class_decorators.py │ ├── README.md │ ├── serena │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── agno.py │ │ ├── analytics.py │ │ ├── cli.py │ │ ├── code_editor.py │ │ ├── config │ │ │ ├── __init__.py │ │ │ ├── context_mode.py │ │ │ └── serena_config.py │ │ ├── constants.py │ │ ├── dashboard.py │ │ ├── generated │ │ │ └── generated_prompt_factory.py │ │ ├── gui_log_viewer.py │ │ ├── mcp.py │ │ ├── project.py │ │ ├── prompt_factory.py │ │ ├── resources │ │ │ ├── config │ │ │ │ ├── contexts │ │ │ │ │ ├── agent.yml │ │ │ │ │ ├── chatgpt.yml │ │ │ │ │ ├── codex.yml │ │ │ │ │ ├── context.template.yml │ │ │ │ │ ├── desktop-app.yml │ │ │ │ │ ├── ide-assistant.yml │ │ │ │ │ └── oaicompat-agent.yml │ │ │ │ ├── internal_modes │ │ │ │ │ └── jetbrains.yml │ │ │ │ ├── modes │ │ │ │ │ ├── editing.yml │ │ │ │ │ ├── interactive.yml │ │ │ │ │ ├── mode.template.yml │ │ │ │ │ ├── no-onboarding.yml │ │ │ │ │ ├── onboarding.yml │ │ │ │ │ ├── one-shot.yml │ │ │ │ │ └── planning.yml │ │ │ │ └── prompt_templates │ │ │ │ ├── simple_tool_outputs.yml │ │ │ │ └── system_prompt.yml │ │ │ ├── dashboard │ │ │ │ ├── dashboard.js │ │ │ │ ├── index.html │ │ │ │ ├── jquery.min.js │ │ │ │ ├── serena-icon-16.png │ │ │ │ ├── serena-icon-32.png │ │ │ │ ├── serena-icon-48.png │ │ │ │ ├── serena-logs-dark-mode.png │ │ │ │ └── serena-logs.png │ │ │ ├── project.template.yml │ │ │ └── serena_config.template.yml │ │ ├── symbol.py │ │ ├── text_utils.py │ │ ├── tools │ │ │ ├── __init__.py │ │ │ ├── cmd_tools.py │ │ │ ├── config_tools.py │ │ │ ├── file_tools.py │ │ │ ├── jetbrains_plugin_client.py │ │ │ ├── jetbrains_tools.py │ │ │ ├── memory_tools.py │ │ │ ├── symbol_tools.py │ │ │ ├── tools_base.py │ │ │ └── workflow_tools.py │ │ └── util │ │ ├── class_decorators.py │ │ ├── exception.py │ │ ├── file_system.py │ │ ├── general.py │ │ ├── git.py │ │ ├── inspection.py │ │ ├── logging.py │ │ ├── shell.py │ │ └── thread.py │ └── solidlsp │ ├── __init__.py │ ├── .gitignore │ ├── language_servers │ │ ├── al_language_server.py │ │ ├── bash_language_server.py │ │ ├── clangd_language_server.py │ │ ├── clojure_lsp.py │ │ ├── common.py │ │ ├── csharp_language_server.py │ │ ├── dart_language_server.py │ │ ├── eclipse_jdtls.py │ │ ├── elixir_tools │ │ │ ├── __init__.py │ │ │ ├── elixir_tools.py │ │ │ └── README.md │ │ ├── elm_language_server.py │ │ ├── erlang_language_server.py │ │ ├── gopls.py │ │ ├── intelephense.py │ │ ├── jedi_server.py │ │ ├── kotlin_language_server.py │ │ ├── lua_ls.py │ │ ├── marksman.py │ │ ├── nixd_ls.py │ │ ├── omnisharp │ │ │ ├── initialize_params.json │ │ │ ├── runtime_dependencies.json │ │ │ └── workspace_did_change_configuration.json │ │ ├── omnisharp.py │ │ ├── perl_language_server.py │ │ ├── pyright_server.py │ │ ├── r_language_server.py │ │ ├── ruby_lsp.py │ │ ├── rust_analyzer.py │ │ ├── solargraph.py │ │ ├── sourcekit_lsp.py │ │ ├── terraform_ls.py │ │ ├── typescript_language_server.py │ │ ├── vts_language_server.py │ │ └── zls.py │ ├── ls_config.py │ ├── ls_exceptions.py │ ├── ls_handler.py │ ├── ls_logger.py │ ├── ls_request.py │ ├── ls_types.py │ ├── ls_utils.py │ ├── ls.py │ ├── lsp_protocol_handler │ │ ├── lsp_constants.py │ │ ├── lsp_requests.py │ │ ├── lsp_types.py │ │ └── server.py │ ├── settings.py │ └── util │ ├── subprocess_util.py │ └── zip.py ├── test │ ├── __init__.py │ ├── conftest.py │ ├── resources │ │ └── repos │ │ ├── al │ │ │ └── test_repo │ │ │ ├── app.json │ │ │ └── src │ │ │ ├── Codeunits │ │ │ │ ├── CustomerMgt.Codeunit.al │ │ │ │ └── PaymentProcessorImpl.Codeunit.al │ │ │ ├── Enums │ │ │ │ └── CustomerType.Enum.al │ │ │ ├── Interfaces │ │ │ │ └── IPaymentProcessor.Interface.al │ │ │ ├── Pages │ │ │ │ ├── CustomerCard.Page.al │ │ │ │ └── CustomerList.Page.al │ │ │ ├── TableExtensions │ │ │ │ └── Item.TableExt.al │ │ │ └── Tables │ │ │ └── Customer.Table.al │ │ ├── bash │ │ │ └── test_repo │ │ │ ├── config.sh │ │ │ ├── main.sh │ │ │ └── utils.sh │ │ ├── clojure │ │ │ └── test_repo │ │ │ ├── deps.edn │ │ │ └── src │ │ │ └── test_app │ │ │ ├── core.clj │ │ │ └── utils.clj │ │ ├── csharp │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── Models │ │ │ │ └── Person.cs │ │ │ ├── Program.cs │ │ │ ├── serena.sln │ │ │ └── TestProject.csproj │ │ ├── dart │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── helper.dart │ │ │ │ ├── main.dart │ │ │ │ └── models.dart │ │ │ └── pubspec.yaml │ │ ├── elixir │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── examples.ex │ │ │ │ ├── ignored_dir │ │ │ │ │ └── ignored_module.ex │ │ │ │ ├── models.ex │ │ │ │ ├── services.ex │ │ │ │ ├── test_repo.ex │ │ │ │ └── utils.ex │ │ │ ├── mix.exs │ │ │ ├── mix.lock │ │ │ ├── scripts │ │ │ │ └── build_script.ex │ │ │ └── test │ │ │ ├── models_test.exs │ │ │ └── test_repo_test.exs │ │ ├── elm │ │ │ └── test_repo │ │ │ ├── elm.json │ │ │ ├── Main.elm │ │ │ └── Utils.elm │ │ ├── erlang │ │ │ └── test_repo │ │ │ ├── hello.erl │ │ │ ├── ignored_dir │ │ │ │ └── ignored_module.erl │ │ │ ├── include │ │ │ │ ├── records.hrl │ │ │ │ └── types.hrl │ │ │ ├── math_utils.erl │ │ │ ├── rebar.config │ │ │ ├── src │ │ │ │ ├── app.erl │ │ │ │ ├── models.erl │ │ │ │ ├── services.erl │ │ │ │ └── utils.erl │ │ │ └── test │ │ │ ├── models_tests.erl │ │ │ └── utils_tests.erl │ │ ├── go │ │ │ └── test_repo │ │ │ └── main.go │ │ ├── java │ │ │ └── test_repo │ │ │ ├── pom.xml │ │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── test_repo │ │ │ ├── Main.java │ │ │ ├── Model.java │ │ │ ├── ModelUser.java │ │ │ └── Utils.java │ │ ├── kotlin │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ └── kotlin │ │ │ └── test_repo │ │ │ ├── Main.kt │ │ │ ├── Model.kt │ │ │ ├── ModelUser.kt │ │ │ └── Utils.kt │ │ ├── lua │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── main.lua │ │ │ ├── src │ │ │ │ ├── calculator.lua │ │ │ │ └── utils.lua │ │ │ └── tests │ │ │ └── test_calculator.lua │ │ ├── markdown │ │ │ └── test_repo │ │ │ ├── api.md │ │ │ ├── CONTRIBUTING.md │ │ │ ├── guide.md │ │ │ └── README.md │ │ ├── nix │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── default.nix │ │ │ ├── flake.nix │ │ │ ├── lib │ │ │ │ └── utils.nix │ │ │ ├── modules │ │ │ │ └── example.nix │ │ │ └── scripts │ │ │ └── hello.sh │ │ ├── perl │ │ │ └── test_repo │ │ │ ├── helper.pl │ │ │ └── main.pl │ │ ├── php │ │ │ └── test_repo │ │ │ ├── helper.php │ │ │ ├── index.php │ │ │ └── simple_var.php │ │ ├── python │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── custom_test │ │ │ │ ├── __init__.py │ │ │ │ └── advanced_features.py │ │ │ ├── examples │ │ │ │ ├── __init__.py │ │ │ │ └── user_management.py │ │ │ ├── ignore_this_dir_with_postfix │ │ │ │ └── ignored_module.py │ │ │ ├── scripts │ │ │ │ ├── __init__.py │ │ │ │ └── run_app.py │ │ │ └── test_repo │ │ │ ├── __init__.py │ │ │ ├── complex_types.py │ │ │ ├── models.py │ │ │ ├── name_collisions.py │ │ │ ├── nested_base.py │ │ │ ├── nested.py │ │ │ ├── overloaded.py │ │ │ ├── services.py │ │ │ ├── utils.py │ │ │ └── variables.py │ │ ├── r │ │ │ └── test_repo │ │ │ ├── .Rbuildignore │ │ │ ├── DESCRIPTION │ │ │ ├── examples │ │ │ │ └── analysis.R │ │ │ ├── NAMESPACE │ │ │ └── R │ │ │ ├── models.R │ │ │ └── utils.R │ │ ├── ruby │ │ │ └── test_repo │ │ │ ├── .solargraph.yml │ │ │ ├── examples │ │ │ │ └── user_management.rb │ │ │ ├── lib.rb │ │ │ ├── main.rb │ │ │ ├── models.rb │ │ │ ├── nested.rb │ │ │ ├── services.rb │ │ │ └── variables.rb │ │ ├── rust │ │ │ ├── test_repo │ │ │ │ ├── Cargo.lock │ │ │ │ ├── Cargo.toml │ │ │ │ └── src │ │ │ │ ├── lib.rs │ │ │ │ └── main.rs │ │ │ └── test_repo_2024 │ │ │ ├── Cargo.lock │ │ │ ├── Cargo.toml │ │ │ └── src │ │ │ ├── lib.rs │ │ │ └── main.rs │ │ ├── swift │ │ │ └── test_repo │ │ │ ├── Package.swift │ │ │ └── src │ │ │ ├── main.swift │ │ │ └── utils.swift │ │ ├── terraform │ │ │ └── test_repo │ │ │ ├── data.tf │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── typescript │ │ │ └── test_repo │ │ │ ├── .serena │ │ │ │ └── project.yml │ │ │ ├── index.ts │ │ │ ├── tsconfig.json │ │ │ └── use_helper.ts │ │ └── zig │ │ └── test_repo │ │ ├── .gitignore │ │ ├── build.zig │ │ ├── src │ │ │ ├── calculator.zig │ │ │ ├── main.zig │ │ │ └── math_utils.zig │ │ └── zls.json │ ├── serena │ │ ├── __init__.py │ │ ├── __snapshots__ │ │ │ └── test_symbol_editing.ambr │ │ ├── config │ │ │ ├── __init__.py │ │ │ └── test_serena_config.py │ │ ├── test_edit_marker.py │ │ ├── test_mcp.py │ │ ├── test_serena_agent.py │ │ ├── test_symbol_editing.py │ │ ├── test_symbol.py │ │ ├── test_text_utils.py │ │ ├── test_tool_parameter_types.py │ │ └── util │ │ ├── test_exception.py │ │ └── test_file_system.py │ └── solidlsp │ ├── al │ │ └── test_al_basic.py │ ├── bash │ │ ├── __init__.py │ │ └── test_bash_basic.py │ ├── clojure │ │ ├── __init__.py │ │ └── test_clojure_basic.py │ ├── csharp │ │ └── test_csharp_basic.py │ ├── dart │ │ ├── __init__.py │ │ └── test_dart_basic.py │ ├── elixir │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_elixir_basic.py │ │ ├── test_elixir_ignored_dirs.py │ │ ├── test_elixir_integration.py │ │ └── test_elixir_symbol_retrieval.py │ ├── elm │ │ └── test_elm_basic.py │ ├── erlang │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_erlang_basic.py │ │ ├── test_erlang_ignored_dirs.py │ │ └── test_erlang_symbol_retrieval.py │ ├── go │ │ └── test_go_basic.py │ ├── java │ │ └── test_java_basic.py │ ├── kotlin │ │ └── test_kotlin_basic.py │ ├── lua │ │ └── test_lua_basic.py │ ├── markdown │ │ ├── __init__.py │ │ └── test_markdown_basic.py │ ├── nix │ │ └── test_nix_basic.py │ ├── perl │ │ └── test_perl_basic.py │ ├── php │ │ └── test_php_basic.py │ ├── python │ │ ├── test_python_basic.py │ │ ├── test_retrieval_with_ignored_dirs.py │ │ └── test_symbol_retrieval.py │ ├── r │ │ ├── __init__.py │ │ └── test_r_basic.py │ ├── ruby │ │ ├── test_ruby_basic.py │ │ └── test_ruby_symbol_retrieval.py │ ├── rust │ │ ├── test_rust_2024_edition.py │ │ └── test_rust_basic.py │ ├── swift │ │ └── test_swift_basic.py │ ├── terraform │ │ └── test_terraform_basic.py │ ├── typescript │ │ └── test_typescript_basic.py │ ├── util │ │ └── test_zip.py │ └── zig │ └── test_zig_basic.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /test/resources/repos/python/test_repo/test_repo/models.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Models module that demonstrates various Python class patterns. 3 | """ 4 | 5 | from abc import ABC, abstractmethod 6 | from typing import Any, Generic, TypeVar 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | class BaseModel(ABC): 12 | """ 13 | Abstract base class for all models. 14 | """ 15 | 16 | def __init__(self, id: str, name: str | None = None): 17 | self.id = id 18 | self.name = name or id 19 | 20 | @abstractmethod 21 | def to_dict(self) -> dict[str, Any]: 22 | """Convert model to dictionary representation""" 23 | 24 | @classmethod 25 | def from_dict(cls, data: dict[str, Any]) -> "BaseModel": 26 | """Create a model instance from dictionary data""" 27 | id = data.get("id", "") 28 | name = data.get("name") 29 | return cls(id=id, name=name) 30 | 31 | 32 | class User(BaseModel): 33 | """ 34 | User model representing a system user. 35 | """ 36 | 37 | def __init__(self, id: str, name: str | None = None, email: str = "", roles: list[str] | None = None): 38 | super().__init__(id, name) 39 | self.email = email 40 | self.roles = roles or [] 41 | 42 | def to_dict(self) -> dict[str, Any]: 43 | return {"id": self.id, "name": self.name, "email": self.email, "roles": self.roles} 44 | 45 | @classmethod 46 | def from_dict(cls, data: dict[str, Any]) -> "User": 47 | instance = super().from_dict(data) 48 | instance.email = data.get("email", "") 49 | instance.roles = data.get("roles", []) 50 | return instance 51 | 52 | def has_role(self, role: str) -> bool: 53 | """Check if user has a specific role""" 54 | return role in self.roles 55 | 56 | 57 | class Item(BaseModel): 58 | """ 59 | Item model representing a product or service. 60 | """ 61 | 62 | def __init__(self, id: str, name: str | None = None, price: float = 0.0, category: str = ""): 63 | super().__init__(id, name) 64 | self.price = price 65 | self.category = category 66 | 67 | def to_dict(self) -> dict[str, Any]: 68 | return {"id": self.id, "name": self.name, "price": self.price, "category": self.category} 69 | 70 | def get_display_price(self) -> str: 71 | """Format price for display""" 72 | return f"${self.price:.2f}" 73 | 74 | 75 | # Generic type example 76 | class Collection(Generic[T]): 77 | def __init__(self, items: list[T] | None = None): 78 | self.items = items or [] 79 | 80 | def add(self, item: T) -> None: 81 | self.items.append(item) 82 | 83 | def get_all(self) -> list[T]: 84 | return self.items 85 | 86 | 87 | # Factory function 88 | def create_user_object(id: str, name: str, email: str, roles: list[str] | None = None) -> User: 89 | """Factory function to create a user""" 90 | return User(id=id, name=name, email=email, roles=roles) 91 | 92 | 93 | # Multiple inheritance examples 94 | 95 | 96 | class Loggable: 97 | """ 98 | Mixin class that provides logging functionality. 99 | Example of a common mixin pattern used with multiple inheritance. 100 | """ 101 | 102 | def __init__(self, **kwargs): 103 | super().__init__(**kwargs) 104 | self.log_entries: list[str] = [] 105 | 106 | def log(self, message: str) -> None: 107 | """Add a log entry""" 108 | self.log_entries.append(message) 109 | 110 | def get_logs(self) -> list[str]: 111 | """Get all log entries""" 112 | return self.log_entries 113 | 114 | 115 | class Serializable: 116 | """ 117 | Mixin class that provides JSON serialization capabilities. 118 | Another example of a mixin for multiple inheritance. 119 | """ 120 | 121 | def __init__(self, **kwargs): 122 | super().__init__(**kwargs) 123 | 124 | def to_json(self) -> dict[str, Any]: 125 | """Convert to JSON-serializable dictionary""" 126 | return self.to_dict() if hasattr(self, "to_dict") else {} 127 | 128 | @classmethod 129 | def from_json(cls, data: dict[str, Any]) -> Any: 130 | """Create instance from JSON data""" 131 | return cls.from_dict(data) if hasattr(cls, "from_dict") else cls(**data) 132 | 133 | 134 | class Auditable: 135 | """ 136 | Mixin for tracking creation and modification timestamps. 137 | """ 138 | 139 | def __init__(self, **kwargs): 140 | super().__init__(**kwargs) 141 | self.created_at: str = kwargs.get("created_at", "") 142 | self.updated_at: str = kwargs.get("updated_at", "") 143 | 144 | def update_timestamp(self, timestamp: str) -> None: 145 | """Update the last modified timestamp""" 146 | self.updated_at = timestamp 147 | 148 | 149 | # Diamond inheritance pattern 150 | class BaseService(ABC): 151 | """ 152 | Base class for service objects - demonstrates diamond inheritance pattern. 153 | """ 154 | 155 | def __init__(self, name: str = "base"): 156 | self.service_name = name 157 | 158 | @abstractmethod 159 | def get_service_info(self) -> dict[str, str]: 160 | """Get service information""" 161 | 162 | 163 | class DataService(BaseService): 164 | """ 165 | Data handling service. 166 | """ 167 | 168 | def __init__(self, **kwargs): 169 | name = kwargs.pop("name", "data") 170 | super().__init__(name=name) 171 | self.data_source = kwargs.get("data_source", "default") 172 | 173 | def get_service_info(self) -> dict[str, str]: 174 | return {"service_type": "data", "service_name": self.service_name, "data_source": self.data_source} 175 | 176 | 177 | class NetworkService(BaseService): 178 | """ 179 | Network connectivity service. 180 | """ 181 | 182 | def __init__(self, **kwargs): 183 | name = kwargs.pop("name", "network") 184 | super().__init__(name=name) 185 | self.endpoint = kwargs.get("endpoint", "localhost") 186 | 187 | def get_service_info(self) -> dict[str, str]: 188 | return {"service_type": "network", "service_name": self.service_name, "endpoint": self.endpoint} 189 | 190 | 191 | class DataSyncService(DataService, NetworkService): 192 | """ 193 | Service that syncs data over network - example of diamond inheritance. 194 | Inherits from both DataService and NetworkService, which both inherit from BaseService. 195 | """ 196 | 197 | def __init__(self, **kwargs): 198 | super().__init__(**kwargs) 199 | self.sync_interval = kwargs.get("sync_interval", 60) 200 | 201 | def get_service_info(self) -> dict[str, str]: 202 | info = super().get_service_info() 203 | info.update({"service_type": "data_sync", "sync_interval": str(self.sync_interval)}) 204 | return info 205 | 206 | 207 | # Multiple inheritance with mixins 208 | 209 | 210 | class LoggableUser(User, Loggable): 211 | """ 212 | User class with logging capabilities. 213 | Example of extending a concrete class with a mixin. 214 | """ 215 | 216 | def __init__(self, id: str, name: str | None = None, email: str = "", roles: list[str] | None = None): 217 | super().__init__(id=id, name=name, email=email, roles=roles) 218 | 219 | def add_role(self, role: str) -> None: 220 | """Add a role to the user and log the action""" 221 | if role not in self.roles: 222 | self.roles.append(role) 223 | self.log(f"Added role '{role}' to user {self.id}") 224 | 225 | 226 | class TrackedItem(Item, Serializable, Auditable): 227 | """ 228 | Item with serialization and auditing capabilities. 229 | Example of a class inheriting from a concrete class and multiple mixins. 230 | """ 231 | 232 | def __init__( 233 | self, id: str, name: str | None = None, price: float = 0.0, category: str = "", created_at: str = "", updated_at: str = "" 234 | ): 235 | super().__init__(id=id, name=name, price=price, category=category, created_at=created_at, updated_at=updated_at) 236 | self.stock_level = 0 237 | 238 | def update_stock(self, quantity: int) -> None: 239 | """Update stock level and timestamp""" 240 | self.stock_level = quantity 241 | self.update_timestamp(f"stock_update_{quantity}") 242 | 243 | def to_dict(self) -> dict[str, Any]: 244 | result = super().to_dict() 245 | result.update({"stock_level": self.stock_level, "created_at": self.created_at, "updated_at": self.updated_at}) 246 | return result 247 | ``` -------------------------------------------------------------------------------- /.serena/memories/adding_new_language_support_guide.md: -------------------------------------------------------------------------------- ```markdown 1 | # Adding New Language Support to Serena 2 | 3 | This guide explains how to add support for a new programming language to Serena. 4 | 5 | ## Overview 6 | 7 | Adding a new language involves: 8 | 9 | 1. **Language Server Implementation** - Creating a language-specific server class 10 | 2. **Language Registration** - Adding the language to enums and configurations 11 | 3. **Test Repository** - Creating a minimal test project 12 | 4. **Test Suite** - Writing comprehensive tests 13 | 5. **Runtime Dependencies** - Configuring automatic language server downloads 14 | 15 | ## Step 1: Language Server Implementation 16 | 17 | ### 1.1 Create Language Server Class 18 | 19 | Create a new file in `src/solidlsp/language_servers/` (e.g., `new_language_server.py`). 20 | Have a look at `intelephense.py` for a reference implementation of a language server which downloads all its dependencies, at `gopls.py` for an LS that needs some preinstalled 21 | dependencies, and on `pyright_server.py` that does not need any additional dependencies 22 | because the language server can be installed directly as python package. 23 | 24 | 25 | ```python 26 | from solidlsp.ls import SolidLanguageServer 27 | from solidlsp.ls_config import LanguageServerConfig 28 | from solidlsp.ls_logger import LanguageServerLogger 29 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 30 | 31 | class NewLanguageServer(SolidLanguageServer): 32 | """ 33 | Language server implementation for NewLanguage. 34 | """ 35 | 36 | def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): 37 | # Determine language server command 38 | cmd = self._get_language_server_command() 39 | 40 | super().__init__( 41 | config, 42 | logger, 43 | repository_root_path, 44 | ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), 45 | "new_language", # Language ID for LSP 46 | ) 47 | 48 | def _get_language_server_command(self) -> list[str]: 49 | """Get the command to start the language server.""" 50 | # Example: return ["new-language-server", "--stdio"] 51 | pass 52 | 53 | @override 54 | def is_ignored_dirname(self, dirname: str) -> bool: 55 | """Define language-specific directories to ignore.""" 56 | return super().is_ignored_dirname(dirname) or dirname in ["build", "dist", "target"] 57 | ``` 58 | 59 | ### 1.2 Language Server Discovery and Installation 60 | 61 | For languages requiring automatic installation, implement download logic similar to C#: 62 | 63 | ```python 64 | @classmethod 65 | def _ensure_server_installed(cls, logger: LanguageServerLogger) -> str: 66 | """Ensure language server is installed and return path.""" 67 | # Check system installation first 68 | system_server = shutil.which("new-language-server") 69 | if system_server: 70 | return system_server 71 | 72 | # Download and install if needed 73 | server_path = cls._download_and_install_server(logger) 74 | return server_path 75 | 76 | def _download_and_install_server(cls, logger: LanguageServerLogger) -> str: 77 | """Download and install the language server.""" 78 | # Implementation specific to your language server 79 | pass 80 | ``` 81 | 82 | ### 1.3 LSP Initialization 83 | 84 | Override initialization methods if needed: 85 | 86 | ```python 87 | def _get_initialize_params(self) -> InitializeParams: 88 | """Return language-specific initialization parameters.""" 89 | return { 90 | "processId": os.getpid(), 91 | "rootUri": PathUtils.path_to_uri(self.repository_root_path), 92 | "capabilities": { 93 | # Language-specific capabilities 94 | } 95 | } 96 | 97 | def _start_server(self): 98 | """Start the language server with custom handlers.""" 99 | # Set up notification handlers 100 | self.server.on_notification("window/logMessage", self._handle_log_message) 101 | 102 | # Start server and initialize 103 | self.server.start() 104 | init_response = self.server.send.initialize(self._get_initialize_params()) 105 | self.server.notify.initialized({}) 106 | ``` 107 | 108 | ## Step 2: Language Registration 109 | 110 | ### 2.1 Add to Language Enum 111 | 112 | In `src/solidlsp/ls_config.py`, add your language to the `Language` enum: 113 | 114 | ```python 115 | class Language(str, Enum): 116 | # Existing languages... 117 | NEW_LANGUAGE = "new_language" 118 | 119 | def get_source_fn_matcher(self) -> FilenameMatcher: 120 | match self: 121 | # Existing cases... 122 | case self.NEW_LANGUAGE: 123 | return FilenameMatcher("*.newlang", "*.nl") # File extensions 124 | ``` 125 | 126 | ### 2.2 Update Language Server Factory 127 | 128 | In `src/solidlsp/ls.py`, add your language to the `create` method: 129 | 130 | ```python 131 | @classmethod 132 | def create(cls, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str) -> "SolidLanguageServer": 133 | match config.code_language: 134 | # Existing cases... 135 | case Language.NEW_LANGUAGE: 136 | from solidlsp.language_servers.new_language_server import NewLanguageServer 137 | return NewLanguageServer(config, logger, repository_root_path) 138 | ``` 139 | 140 | ## Step 3: Test Repository 141 | 142 | ### 3.1 Create Test Project 143 | 144 | Create a minimal project in `test/resources/repos/new_language/test_repo/`: 145 | 146 | ``` 147 | test/resources/repos/new_language/test_repo/ 148 | ├── main.newlang # Main source file 149 | ├── lib/ 150 | │ └── helper.newlang # Additional source for testing 151 | ├── project.toml # Project configuration (if applicable) 152 | └── .gitignore # Ignore build artifacts 153 | ``` 154 | 155 | ### 3.2 Example Source Files 156 | 157 | Create meaningful source files that demonstrate: 158 | 159 | - **Classes/Types** - For symbol testing 160 | - **Functions/Methods** - For reference finding 161 | - **Imports/Dependencies** - For cross-file operations 162 | - **Nested Structures** - For hierarchical symbol testing 163 | 164 | Example `main.newlang`: 165 | ``` 166 | import lib.helper 167 | 168 | class Calculator { 169 | func add(a: Int, b: Int) -> Int { 170 | return a + b 171 | } 172 | 173 | func subtract(a: Int, b: Int) -> Int { 174 | return helper.subtract(a, b) // Reference to imported function 175 | } 176 | } 177 | 178 | class Program { 179 | func main() { 180 | let calc = Calculator() 181 | let result = calc.add(5, 3) // Reference to add method 182 | print(result) 183 | } 184 | } 185 | ``` 186 | 187 | ## Step 4: Test Suite 188 | 189 | Testing the language server implementation is of crucial importance, and the tests will 190 | form the main part of the review process. Make sure that the tests are up to the standard 191 | of Serena to make the review go smoother. 192 | 193 | General rules for tests: 194 | 195 | 1. Tests for symbols and references should always check that the expected symbol names and references were actually found. 196 | Just testing that a list came back or that the result is not None is insufficient. 197 | 2. Tests should never be skipped, the only exception is skipping based on some package being available or on an unsupported OS. 198 | 3. Tests should run in CI, check if there is a suitable GitHub action for installing the dependencies. 199 | 200 | ### 4.1 Basic Tests 201 | 202 | Create `test/solidlsp/new_language/test_new_language_basic.py`. 203 | Have a look at the structure of existing tests, for example, in `test/solidlsp/php/test_php_basic.py` 204 | You should at least test: 205 | 206 | 1. Finding symbols 207 | 2. Finding within-file references 208 | 3. Finding cross-file references 209 | 210 | Have a look at `test/solidlsp/php/test_php_basic.py` as an example for what should be tested. 211 | Don't forget to add a new language marker to `pytest.ini`. 212 | 213 | ### 4.2 Integration Tests 214 | 215 | Consider adding new cases to the parametrized tests in `test_serena_agent.py` for the new language. 216 | 217 | 218 | ### 5 Documentation 219 | 220 | Update: 221 | 222 | - **README.md** - Add language to supported languages list 223 | - **CHANGELOG.md** - Document the new language support 224 | - **Language-specific docs** - Installation requirements, known issues 225 | ``` -------------------------------------------------------------------------------- /test/solidlsp/php/test_php_basic.py: -------------------------------------------------------------------------------- ```python 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from solidlsp import SolidLanguageServer 6 | from solidlsp.ls_config import Language 7 | 8 | 9 | @pytest.mark.php 10 | class TestPhpLanguageServer: 11 | @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) 12 | @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) 13 | def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 14 | """Test that the language server starts and stops successfully.""" 15 | # The fixture already handles start and stop 16 | assert language_server.is_running() 17 | assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() 18 | 19 | @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) 20 | @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) 21 | def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 22 | 23 | # In index.php: 24 | # Line 9 (1-indexed): $greeting = greet($userName); 25 | # Line 11 (1-indexed): echo $greeting; 26 | # We want to find the definition of $greeting (defined on line 9) 27 | # from its usage in echo $greeting; on line 11. 28 | # LSP is 0-indexed: definition on line 8, usage on line 10. 29 | # $greeting in echo $greeting; is at char 5 on line 11 (0-indexed: line 10, char 5) 30 | # e c h o $ g r e e t i n g 31 | # ^ char 5 32 | definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) # cursor on 'g' in $greeting 33 | 34 | assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" 35 | assert len(definition_location_list) == 1 36 | definition_location = definition_location_list[0] 37 | assert definition_location["uri"].endswith("index.php") 38 | # Definition of $greeting is on line 10 (1-indexed) / line 9 (0-indexed), char 0 39 | assert definition_location["range"]["start"]["line"] == 9 40 | assert definition_location["range"]["start"]["character"] == 0 41 | 42 | @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) 43 | @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) 44 | def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 45 | definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) # helperFunction 46 | 47 | assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" 48 | assert len(definition_location_list) == 1 49 | definition_location = definition_location_list[0] 50 | assert definition_location["uri"].endswith("helper.php") 51 | assert definition_location["range"]["start"]["line"] == 2 52 | assert definition_location["range"]["start"]["character"] == 0 53 | 54 | @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) 55 | @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) 56 | def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 57 | file_path = str(repo_path / "simple_var.php") 58 | 59 | # In simple_var.php: 60 | # Line 2 (1-indexed): $localVar = "test"; 61 | # Line 3 (1-indexed): echo $localVar; 62 | # LSP is 0-indexed: definition on line 1, usage on line 2 63 | # Find definition of $localVar (char 5 on line 3 / 0-indexed: line 2, char 5) 64 | # $localVar in echo $localVar; (e c h o $ l o c a l V a r) 65 | # ^ char 5 66 | definition_location_list = language_server.request_definition(file_path, 2, 6) # cursor on 'l' in $localVar 67 | 68 | assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" 69 | assert len(definition_location_list) == 1 70 | definition_location = definition_location_list[0] 71 | assert definition_location["uri"].endswith("simple_var.php") 72 | assert definition_location["range"]["start"]["line"] == 1 # Definition of $localVar (0-indexed) 73 | assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) 74 | 75 | @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) 76 | @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) 77 | def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 78 | index_php_path = str(repo_path / "index.php") 79 | 80 | # In index.php (0-indexed lines): 81 | # Line 9: $greeting = greet($userName); // Definition of $greeting 82 | # Line 11: echo $greeting; // Usage of $greeting 83 | # Find references for $greeting from its usage in "echo $greeting;" (line 11, char 6 for 'g') 84 | references = language_server.request_references(index_php_path, 11, 6) 85 | 86 | assert references 87 | # Intelephense, when asked for references from usage, seems to only return the usage itself. 88 | assert len(references) == 1, "Expected to find 1 reference for $greeting (the usage itself)" 89 | 90 | expected_locations = [{"uri_suffix": "index.php", "line": 11, "character": 5}] # Usage: echo $greeting (points to $) 91 | 92 | # Convert actual references to a comparable format and sort 93 | actual_locations = sorted( 94 | [ 95 | { 96 | "uri_suffix": loc["uri"].split("/")[-1], 97 | "line": loc["range"]["start"]["line"], 98 | "character": loc["range"]["start"]["character"], 99 | } 100 | for loc in references 101 | ], 102 | key=lambda x: (x["uri_suffix"], x["line"], x["character"]), 103 | ) 104 | 105 | expected_locations = sorted(expected_locations, key=lambda x: (x["uri_suffix"], x["line"], x["character"])) 106 | 107 | assert actual_locations == expected_locations 108 | 109 | @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) 110 | @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) 111 | def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: 112 | helper_php_path = str(repo_path / "helper.php") 113 | # In index.php (0-indexed lines): 114 | # Line 13: helperFunction(); // Usage of helperFunction 115 | # Find references for helperFunction from its definition 116 | references = language_server.request_references(helper_php_path, 2, len("function ")) 117 | 118 | assert references, f"Expected non-empty references for helperFunction but got {references=}" 119 | # Intelephense might return 1 (usage) or 2 (usage + definition) references. 120 | # Let's check for at least the usage in index.php 121 | # Definition is in helper.php, line 2, char 0 (based on previous findings) 122 | # Usage is in index.php, line 13, char 0 123 | 124 | actual_locations_comparable = [] 125 | for loc in references: 126 | actual_locations_comparable.append( 127 | { 128 | "uri_suffix": loc["uri"].split("/")[-1], 129 | "line": loc["range"]["start"]["line"], 130 | "character": loc["range"]["start"]["character"], 131 | } 132 | ) 133 | 134 | usage_in_index_php = {"uri_suffix": "index.php", "line": 13, "character": 0} 135 | assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found" 136 | ``` -------------------------------------------------------------------------------- /src/serena/resources/config/modes/editing.yml: -------------------------------------------------------------------------------- ```yaml 1 | description: All tools, with detailed instructions for code editing 2 | prompt: | 3 | You are operating in editing mode. You can edit files with the provided tools 4 | to implement the requested changes to the code base while adhering to the project's code style and patterns. 5 | Use symbolic editing tools whenever possible for precise code modifications. 6 | If no editing task has yet been provided, wait for the user to provide one. 7 | 8 | When writing new code, think about where it belongs best. Don't generate new files if you don't plan on actually 9 | integrating them into the codebase, instead use the editing tools to insert the code directly into the existing files in that case. 10 | 11 | You have two main approaches for editing code - editing by regex and editing by symbol. 12 | The symbol-based approach is appropriate if you need to adjust an entire symbol, e.g. a method, a class, a function, etc. 13 | But it is not appropriate if you need to adjust just a few lines of code within a symbol, for that you should 14 | use the regex-based approach that is described below. 15 | 16 | Let us first discuss the symbol-based approach. 17 | Symbols are identified by their name path and relative file path, see the description of the `find_symbol` tool for more details 18 | on how the `name_path` matches symbols. 19 | You can get information about available symbols by using the `get_symbols_overview` tool for finding top-level symbols in a file, 20 | or by using `find_symbol` if you already know the symbol's name path. You generally try to read as little code as possible 21 | while still solving your task, meaning you only read the bodies when you need to, and after you have found the symbol you want to edit. 22 | Before calling symbolic reading tools, you should have a basic understanding of the repository structure that you can get from memories 23 | or by using the `list_dir` and `find_file` tools (or similar). 24 | For example, if you are working with python code and already know that you need to read the body of the constructor of the class Foo, you can directly 25 | use `find_symbol` with the name path `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit, 26 | you can use `find_symbol` with the name path `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding 27 | to read the desired methods with `include_body=True`. 28 | In particular, keep in mind the description of the `replace_symbol_body` tool. If you want to add some new code at the end of the file, you should 29 | use the `insert_after_symbol` tool with the last top-level symbol in the file. If you want to add an import, often a good strategy is to use 30 | `insert_before_symbol` with the first top-level symbol in the file. 31 | You can understand relationships between symbols by using the `find_referencing_symbols` tool. If not explicitly requested otherwise by a user, 32 | you make sure that when you edit a symbol, it is either done in a backward-compatible way, or you find and adjust the references as needed. 33 | The `find_referencing_symbols` tool will give you code snippets around the references, as well as symbolic information. 34 | You will generally be able to use the info from the snippets and the regex-based approach to adjust the references as well. 35 | You can assume that all symbol editing tools are reliable, so you don't need to verify the results if the tool returns without error. 36 | 37 | {% if 'replace_regex' in available_tools %} 38 | Let us discuss the regex-based approach. 39 | The regex-based approach is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation. 40 | This is the case if you need to adjust just a few lines of code within a method, or a chunk that is much smaller than a whole symbol. 41 | You use other tools to find the relevant content and 42 | then use your knowledge of the codebase to write the regex, if you haven't collected enough information of this content yet. 43 | You are extremely good at regex, so you never need to check whether the replacement produced the correct result. 44 | In particular, you know what to escape and what not to escape, and you know how to use wildcards. 45 | Also, the regex tool never adds any indentation (contrary to the symbolic editing tools), so you have to take care to add the correct indentation 46 | when using it to insert code. 47 | Moreover, the replacement tool will fail if it can't perform the desired replacement, and this is all the feedback you need. 48 | Your overall goal for replacement operations is to use relatively short regexes, since I want you to minimize the number 49 | of output tokens. For replacements of larger chunks of code, this means you intelligently make use of wildcards for the middle part 50 | and of characteristic snippets for the before/after parts that uniquely identify the chunk. 51 | 52 | For small replacements, up to a single line, you follow the following rules: 53 | 54 | 1. If the snippet to be replaced is likely to be unique within the file, you perform the replacement by directly using the escaped version of the 55 | original. 56 | 2. If the snippet is probably not unique, and you want to replace all occurrences, you use the `allow_multiple_occurrences` flag. 57 | 3. If the snippet is not unique, and you want to replace a specific occurrence, you make use of the code surrounding the snippet 58 | to extend the regex with content before/after such that the regex will have exactly one match. 59 | 4. You generally assume that a snippet is unique, knowing that the tool will return an error on multiple matches. You only read more file content 60 | (for crafvarting a more specific regex) if such a failure unexpectedly occurs. 61 | 62 | Examples: 63 | 64 | 1 Small replacement 65 | You have read code like 66 | 67 | ```python 68 | ... 69 | x = linear(x) 70 | x = relu(x) 71 | return x 72 | ... 73 | ``` 74 | 75 | and you want to replace `x = relu(x)` with `x = gelu(x)`. 76 | You first try `replace_regex()` with the regex `x = relu\(x\)` and the replacement `x = gelu(x)`. 77 | If this fails due to multiple matches, you will try `(linear\(x\)\s*)x = relu\(x\)(\s*return)` with the replacement `\1x = gelu(x)\2`. 78 | 79 | 2 Larger replacement 80 | 81 | You have read code like 82 | 83 | ```python 84 | def my_func(): 85 | ... 86 | # a comment before the snippet 87 | x = add_fifteen(x) 88 | # beginning of long section within my_func 89 | .... 90 | # end of long section 91 | call_subroutine(z) 92 | call_second_subroutine(z) 93 | ``` 94 | and you want to replace the code starting with `x = add_fifteen(x)` until (including) `call_subroutine(z)`, but not `call_second_subroutine(z)`. 95 | Initially, you assume that the the beginning and end of the chunk uniquely determine it within the file. 96 | Therefore, you perform the replacement by using the regex `x = add_fifteen\(x\)\s*.*?call_subroutine\(z\)` 97 | and the replacement being the new code you want to insert. 98 | 99 | If this fails due to multiple matches, you will try to extend the regex with the content before/after the snippet and match groups. 100 | The matching regex becomes: 101 | `(before the snippet\s*)x = add_fifteen\(x\)\s*.*?call_subroutine\(z\)` 102 | and the replacement includes the group as (schematically): 103 | `\1<new_code>` 104 | 105 | Generally, I remind you that you rely on the regex tool with providing you the correct feedback, no need for more verification! 106 | 107 | IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD! 108 | {% endif %} 109 | excluded_tools: 110 | - replace_lines 111 | - insert_at_line 112 | - delete_lines 113 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/marksman.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Markdown specific instantiation of the LanguageServer class using marksman. 3 | Contains various configurations and settings specific to Markdown. 4 | """ 5 | 6 | import logging 7 | import os 8 | import pathlib 9 | import threading 10 | 11 | from overrides import override 12 | 13 | from solidlsp.ls import SolidLanguageServer 14 | from solidlsp.ls_config import LanguageServerConfig 15 | from solidlsp.ls_logger import LanguageServerLogger 16 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 17 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 18 | from solidlsp.settings import SolidLSPSettings 19 | 20 | from .common import RuntimeDependency, RuntimeDependencyCollection 21 | 22 | 23 | class Marksman(SolidLanguageServer): 24 | """ 25 | Provides Markdown specific instantiation of the LanguageServer class using marksman. 26 | """ 27 | 28 | marksman_releases = "https://github.com/artempyanykh/marksman/releases/download/2024-12-18" 29 | runtime_dependencies = RuntimeDependencyCollection( 30 | [ 31 | RuntimeDependency( 32 | id="marksman", 33 | url=f"{marksman_releases}/marksman-linux-x64", 34 | platform_id="linux-x64", 35 | archive_type="binary", 36 | binary_name="marksman", 37 | ), 38 | RuntimeDependency( 39 | id="marksman", 40 | url=f"{marksman_releases}/marksman-linux-arm64", 41 | platform_id="linux-arm64", 42 | archive_type="binary", 43 | binary_name="marksman", 44 | ), 45 | RuntimeDependency( 46 | id="marksman", 47 | url=f"{marksman_releases}/marksman-macos", 48 | platform_id="osx-x64", 49 | archive_type="binary", 50 | binary_name="marksman", 51 | ), 52 | RuntimeDependency( 53 | id="marksman", 54 | url=f"{marksman_releases}/marksman-macos", 55 | platform_id="osx-arm64", 56 | archive_type="binary", 57 | binary_name="marksman", 58 | ), 59 | RuntimeDependency( 60 | id="marksman", 61 | url=f"{marksman_releases}/marksman.exe", 62 | platform_id="win-x64", 63 | archive_type="binary", 64 | binary_name="marksman.exe", 65 | ), 66 | ] 67 | ) 68 | 69 | @classmethod 70 | def _setup_runtime_dependencies( 71 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 72 | ) -> str: 73 | """Setup runtime dependencies for marksman and return the command to start the server.""" 74 | deps = cls.runtime_dependencies 75 | dependency = deps.get_single_dep_for_current_platform() 76 | 77 | marksman_ls_dir = cls.ls_resources_dir(solidlsp_settings) 78 | marksman_executable_path = deps.binary_path(marksman_ls_dir) 79 | if not os.path.exists(marksman_executable_path): 80 | logger.log( 81 | f"Downloading marksman from {dependency.url} to {marksman_ls_dir}", 82 | logging.INFO, 83 | ) 84 | deps.install(logger, marksman_ls_dir) 85 | if not os.path.exists(marksman_executable_path): 86 | raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}") 87 | os.chmod(marksman_executable_path, 0o755) 88 | return marksman_executable_path 89 | 90 | def __init__( 91 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 92 | ): 93 | """ 94 | Creates a Marksman instance. This class is not meant to be instantiated directly. 95 | Use LanguageServer.create() instead. 96 | """ 97 | marksman_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 98 | 99 | super().__init__( 100 | config, 101 | logger, 102 | repository_root_path, 103 | ProcessLaunchInfo(cmd=f"{marksman_executable_path} server", cwd=repository_root_path), 104 | "markdown", 105 | solidlsp_settings, 106 | ) 107 | self.server_ready = threading.Event() 108 | 109 | @override 110 | def is_ignored_dirname(self, dirname: str) -> bool: 111 | return super().is_ignored_dirname(dirname) or dirname in ["node_modules", ".obsidian", ".vitepress", ".vuepress"] 112 | 113 | @staticmethod 114 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 115 | """ 116 | Returns the initialize params for the Marksman Language Server. 117 | """ 118 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 119 | initialize_params: InitializeParams = { # type: ignore 120 | "processId": os.getpid(), 121 | "locale": "en", 122 | "rootPath": repository_absolute_path, 123 | "rootUri": root_uri, 124 | "capabilities": { 125 | "textDocument": { 126 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 127 | "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, 128 | "definition": {"dynamicRegistration": True}, 129 | "references": {"dynamicRegistration": True}, 130 | "documentSymbol": { 131 | "dynamicRegistration": True, 132 | "hierarchicalDocumentSymbolSupport": True, 133 | "symbolKind": {"valueSet": list(range(1, 27))}, 134 | }, 135 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 136 | "codeAction": {"dynamicRegistration": True}, 137 | }, 138 | "workspace": { 139 | "workspaceFolders": True, 140 | "didChangeConfiguration": {"dynamicRegistration": True}, 141 | "symbol": {"dynamicRegistration": True}, 142 | }, 143 | }, 144 | "workspaceFolders": [ 145 | { 146 | "uri": root_uri, 147 | "name": os.path.basename(repository_absolute_path), 148 | } 149 | ], 150 | } 151 | return initialize_params 152 | 153 | def _start_server(self): 154 | """ 155 | Starts the Marksman Language Server and waits for it to be ready. 156 | """ 157 | 158 | def register_capability_handler(_params): 159 | return 160 | 161 | def window_log_message(msg): 162 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 163 | 164 | def do_nothing(_params): 165 | return 166 | 167 | self.server.on_request("client/registerCapability", register_capability_handler) 168 | self.server.on_notification("window/logMessage", window_log_message) 169 | self.server.on_notification("$/progress", do_nothing) 170 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 171 | 172 | self.logger.log("Starting marksman server process", logging.INFO) 173 | self.server.start() 174 | initialize_params = self._get_initialize_params(self.repository_root_path) 175 | 176 | self.logger.log( 177 | "Sending initialize request from LSP client to marksman server and awaiting response", 178 | logging.INFO, 179 | ) 180 | init_response = self.server.send.initialize(initialize_params) 181 | self.logger.log(f"Received initialize response from marksman server: {init_response}", logging.DEBUG) 182 | 183 | # Verify server capabilities 184 | assert "textDocumentSync" in init_response["capabilities"] 185 | assert "completionProvider" in init_response["capabilities"] 186 | assert "definitionProvider" in init_response["capabilities"] 187 | 188 | self.server.notify.initialized({}) 189 | 190 | # marksman is typically ready immediately after initialization 191 | self.logger.log("Marksman server initialization complete", logging.INFO) 192 | self.server_ready.set() 193 | self.completions_available.set() 194 | ``` -------------------------------------------------------------------------------- /test/solidlsp/elixir/test_elixir_integration.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Integration tests for Elixir language server with test repository. 3 | 4 | These tests verify that the language server works correctly with a real Elixir project 5 | and can perform advanced operations like cross-file symbol resolution. 6 | """ 7 | 8 | import os 9 | from pathlib import Path 10 | 11 | import pytest 12 | 13 | from serena.project import Project 14 | from solidlsp import SolidLanguageServer 15 | from solidlsp.ls_config import Language 16 | 17 | from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON 18 | 19 | # These marks will be applied to all tests in this module 20 | pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] 21 | 22 | 23 | class TestElixirIntegration: 24 | """Integration tests for Elixir language server with test repository.""" 25 | 26 | @pytest.fixture 27 | def elixir_test_repo_path(self): 28 | """Get the path to the Elixir test repository.""" 29 | test_dir = Path(__file__).parent.parent.parent 30 | return str(test_dir / "resources" / "repos" / "elixir" / "test_repo") 31 | 32 | def test_elixir_repo_structure(self, elixir_test_repo_path): 33 | """Test that the Elixir test repository has the expected structure.""" 34 | repo_path = Path(elixir_test_repo_path) 35 | 36 | # Check that key files exist 37 | assert (repo_path / "mix.exs").exists(), "mix.exs should exist" 38 | assert (repo_path / "lib" / "test_repo.ex").exists(), "main module should exist" 39 | assert (repo_path / "lib" / "utils.ex").exists(), "utils module should exist" 40 | assert (repo_path / "lib" / "models.ex").exists(), "models module should exist" 41 | assert (repo_path / "lib" / "services.ex").exists(), "services module should exist" 42 | assert (repo_path / "lib" / "examples.ex").exists(), "examples module should exist" 43 | assert (repo_path / "test" / "test_repo_test.exs").exists(), "test file should exist" 44 | assert (repo_path / "test" / "models_test.exs").exists(), "models test should exist" 45 | 46 | @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) 47 | def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer): 48 | """Test that symbols can be resolved across different files.""" 49 | # Test that User struct from models.ex can be found when referenced in services.ex 50 | services_file = os.path.join("lib", "services.ex") 51 | 52 | # Find where User is referenced in services.ex 53 | content = language_server.retrieve_full_file_content(services_file) 54 | lines = content.split("\n") 55 | user_reference_line = None 56 | for i, line in enumerate(lines): 57 | if "alias TestRepo.Models.{User" in line: 58 | user_reference_line = i 59 | break 60 | 61 | if user_reference_line is None: 62 | pytest.skip("Could not find User reference in services.ex") 63 | 64 | # Try to find the definition 65 | defining_symbol = language_server.request_defining_symbol(services_file, user_reference_line, 30) 66 | 67 | if defining_symbol and "location" in defining_symbol: 68 | # Should point to models.ex 69 | assert "models.ex" in defining_symbol["location"]["uri"] 70 | 71 | @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) 72 | def test_module_hierarchy_understanding(self, language_server: SolidLanguageServer): 73 | """Test that the language server understands Elixir module hierarchy.""" 74 | models_file = os.path.join("lib", "models.ex") 75 | symbols = language_server.request_document_symbols(models_file) 76 | 77 | if symbols: 78 | # Flatten symbol structure 79 | all_symbols = [] 80 | for symbol_group in symbols: 81 | if isinstance(symbol_group, list): 82 | all_symbols.extend(symbol_group) 83 | else: 84 | all_symbols.append(symbol_group) 85 | 86 | symbol_names = [s.get("name", "") for s in all_symbols] 87 | 88 | # Should understand nested module structure 89 | expected_modules = ["TestRepo.Models", "User", "Item", "Order"] 90 | found_modules = [name for name in expected_modules if any(name in symbol_name for symbol_name in symbol_names)] 91 | assert len(found_modules) > 0, f"Expected modules {expected_modules}, found symbols {symbol_names}" 92 | 93 | def test_file_extension_matching(self): 94 | """Test that the Elixir language recognizes the correct file extensions.""" 95 | language = Language.ELIXIR 96 | matcher = language.get_source_fn_matcher() 97 | 98 | # Test Elixir file extensions 99 | assert matcher.is_relevant_filename("lib/test_repo.ex") 100 | assert matcher.is_relevant_filename("test/test_repo_test.exs") 101 | assert matcher.is_relevant_filename("config/config.exs") 102 | assert matcher.is_relevant_filename("mix.exs") 103 | assert matcher.is_relevant_filename("lib/models.ex") 104 | assert matcher.is_relevant_filename("lib/services.ex") 105 | 106 | # Test non-Elixir files 107 | assert not matcher.is_relevant_filename("README.md") 108 | assert not matcher.is_relevant_filename("lib/test_repo.py") 109 | assert not matcher.is_relevant_filename("package.json") 110 | assert not matcher.is_relevant_filename("Cargo.toml") 111 | 112 | 113 | class TestElixirProject: 114 | @pytest.mark.parametrize("project", [Language.ELIXIR], indirect=True) 115 | def test_comprehensive_symbol_search(self, project: Project): 116 | """Test comprehensive symbol search across the entire project.""" 117 | # Search for all function definitions 118 | function_pattern = r"def\s+\w+\s*[\(\s]" 119 | function_matches = project.search_source_files_for_pattern(function_pattern) 120 | 121 | # Should find functions across multiple files 122 | if function_matches: 123 | files_with_functions = set() 124 | for match in function_matches: 125 | if match.source_file_path: 126 | files_with_functions.add(os.path.basename(match.source_file_path)) 127 | 128 | # Should find functions in multiple files 129 | expected_files = {"models.ex", "services.ex", "examples.ex", "utils.ex", "test_repo.ex"} 130 | found_files = expected_files.intersection(files_with_functions) 131 | assert len(found_files) > 0, f"Expected functions in {expected_files}, found in {files_with_functions}" 132 | 133 | # Search for struct definitions 134 | struct_pattern = r"defstruct\s+\[" 135 | struct_matches = project.search_source_files_for_pattern(struct_pattern) 136 | 137 | if struct_matches: 138 | # Should find structs primarily in models.ex 139 | models_structs = [m for m in struct_matches if m.source_file_path and "models.ex" in m.source_file_path] 140 | assert len(models_structs) > 0, "Should find struct definitions in models.ex" 141 | 142 | @pytest.mark.parametrize("project", [Language.ELIXIR], indirect=True) 143 | def test_protocol_and_implementation_understanding(self, project: Project): 144 | """Test that the language server understands Elixir protocols and implementations.""" 145 | # Search for protocol definitions 146 | protocol_pattern = r"defprotocol\s+\w+" 147 | protocol_matches = project.search_source_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") 148 | 149 | if protocol_matches: 150 | # Should find the Serializable protocol 151 | serializable_matches = [m for m in protocol_matches if "Serializable" in str(m)] 152 | assert len(serializable_matches) > 0, "Should find Serializable protocol definition" 153 | 154 | # Search for protocol implementations 155 | impl_pattern = r"defimpl\s+\w+" 156 | impl_matches = project.search_source_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") 157 | 158 | if impl_matches: 159 | # Should find multiple implementations 160 | assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}" 161 | ``` -------------------------------------------------------------------------------- /test/solidlsp/al/test_al_basic.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | 3 | import pytest 4 | 5 | from solidlsp import SolidLanguageServer 6 | from solidlsp.ls_config import Language 7 | from solidlsp.ls_utils import SymbolUtils 8 | 9 | 10 | @pytest.mark.al 11 | class TestALLanguageServer: 12 | @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) 13 | def test_find_symbol(self, language_server: SolidLanguageServer) -> None: 14 | """Test that AL Language Server can find symbols in the test repository.""" 15 | symbols = language_server.request_full_symbol_tree() 16 | 17 | # Check for table symbols - AL returns full object names like 'Table 50000 "TEST Customer"' 18 | assert SymbolUtils.symbol_tree_contains_name(symbols, 'Table 50000 "TEST Customer"'), "TEST Customer table not found in symbol tree" 19 | 20 | # Check for page symbols 21 | assert SymbolUtils.symbol_tree_contains_name( 22 | symbols, 'Page 50001 "TEST Customer Card"' 23 | ), "TEST Customer Card page not found in symbol tree" 24 | assert SymbolUtils.symbol_tree_contains_name( 25 | symbols, 'Page 50002 "TEST Customer List"' 26 | ), "TEST Customer List page not found in symbol tree" 27 | 28 | # Check for codeunit symbols 29 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Codeunit 50000 CustomerMgt"), "CustomerMgt codeunit not found in symbol tree" 30 | assert SymbolUtils.symbol_tree_contains_name( 31 | symbols, "Codeunit 50001 PaymentProcessorImpl" 32 | ), "PaymentProcessorImpl codeunit not found in symbol tree" 33 | 34 | # Check for enum symbol 35 | assert SymbolUtils.symbol_tree_contains_name(symbols, "Enum 50000 CustomerType"), "CustomerType enum not found in symbol tree" 36 | 37 | # Check for interface symbol 38 | assert SymbolUtils.symbol_tree_contains_name( 39 | symbols, "Interface IPaymentProcessor" 40 | ), "IPaymentProcessor interface not found in symbol tree" 41 | 42 | @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) 43 | def test_find_table_fields(self, language_server: SolidLanguageServer) -> None: 44 | """Test that AL Language Server can find fields within a table.""" 45 | file_path = os.path.join("src", "Tables", "Customer.Table.al") 46 | symbols = language_server.request_document_symbols(file_path) 47 | 48 | # AL tables should have their fields as child symbols 49 | customer_table = None 50 | _all_symbols, root_symbols = symbols 51 | for sym in root_symbols: 52 | if "TEST Customer" in sym.get("name", ""): 53 | customer_table = sym 54 | break 55 | 56 | assert customer_table is not None, "Could not find TEST Customer table symbol" 57 | 58 | # Check for field symbols (AL nests fields under a "fields" group) 59 | if "children" in customer_table: 60 | # Find the fields group 61 | fields_group = None 62 | for child in customer_table.get("children", []): 63 | if child.get("name") == "fields": 64 | fields_group = child 65 | break 66 | 67 | assert fields_group is not None, "Fields group not found in Customer table" 68 | 69 | # Check actual field names 70 | if "children" in fields_group: 71 | field_names = [child.get("name", "") for child in fields_group.get("children", [])] 72 | assert any("Name" in name for name in field_names), f"Name field not found. Fields: {field_names}" 73 | assert any("Balance" in name for name in field_names), f"Balance field not found. Fields: {field_names}" 74 | 75 | @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) 76 | def test_find_procedures(self, language_server: SolidLanguageServer) -> None: 77 | """Test that AL Language Server can find procedures in codeunits.""" 78 | file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") 79 | symbols = language_server.request_document_symbols(file_path) 80 | 81 | # Find the codeunit symbol - AL returns 'Codeunit 50000 CustomerMgt' 82 | codeunit_symbol = None 83 | _all_symbols, root_symbols = symbols 84 | for sym in root_symbols: 85 | if "CustomerMgt" in sym.get("name", ""): 86 | codeunit_symbol = sym 87 | break 88 | 89 | assert codeunit_symbol is not None, "Could not find CustomerMgt codeunit symbol" 90 | 91 | # Check for procedure symbols (if hierarchical) 92 | if "children" in codeunit_symbol: 93 | procedure_names = [child.get("name", "") for child in codeunit_symbol.get("children", [])] 94 | assert any("CreateCustomer" in name for name in procedure_names), "CreateCustomer procedure not found" 95 | # Note: UpdateCustomerBalance doesn't exist in our test repo, check for actual procedures 96 | assert any("TestNoSeries" in name for name in procedure_names), "TestNoSeries procedure not found" 97 | 98 | @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) 99 | def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: 100 | """Test that AL Language Server can find references to symbols.""" 101 | # Find references to the Customer table from the CustomerMgt codeunit 102 | table_file = os.path.join("src", "Tables", "Customer.Table.al") 103 | symbols = language_server.request_document_symbols(table_file) 104 | 105 | # Find the Customer table symbol 106 | customer_symbol = None 107 | _all_symbols, root_symbols = symbols 108 | for sym in root_symbols: 109 | if "TEST Customer" in sym.get("name", ""): 110 | customer_symbol = sym 111 | break 112 | 113 | if customer_symbol and "selectionRange" in customer_symbol: 114 | sel_start = customer_symbol["selectionRange"]["start"] 115 | refs = language_server.request_references(table_file, sel_start["line"], sel_start["character"]) 116 | 117 | # The Customer table should be referenced in CustomerMgt.Codeunit.al 118 | assert any( 119 | "CustomerMgt.Codeunit.al" in ref.get("relativePath", "") for ref in refs 120 | ), "Customer table should be referenced in CustomerMgt.Codeunit.al" 121 | 122 | # It should also be referenced in CustomerCard.Page.al 123 | assert any( 124 | "CustomerCard.Page.al" in ref.get("relativePath", "") for ref in refs 125 | ), "Customer table should be referenced in CustomerCard.Page.al" 126 | 127 | @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) 128 | def test_cross_file_symbols(self, language_server: SolidLanguageServer) -> None: 129 | """Test that AL Language Server can handle cross-file symbol relationships.""" 130 | # Get all symbols to verify cross-file visibility 131 | symbols = language_server.request_full_symbol_tree() 132 | 133 | # Count how many AL-specific symbols we found 134 | al_symbols = [] 135 | 136 | def collect_symbols(syms): 137 | for sym in syms: 138 | if isinstance(sym, dict): 139 | name = sym.get("name", "") 140 | # Look for AL object names (Table, Page, Codeunit, etc.) 141 | if any(keyword in name for keyword in ["Table", "Page", "Codeunit", "Enum", "Interface"]): 142 | al_symbols.append(name) 143 | if "children" in sym: 144 | collect_symbols(sym["children"]) 145 | 146 | collect_symbols(symbols) 147 | 148 | # We should find symbols from multiple files 149 | assert len(al_symbols) >= 5, f"Expected at least 5 AL object symbols, found {len(al_symbols)}: {al_symbols}" 150 | 151 | # Verify we have symbols from different AL object types 152 | has_table = any("Table" in s for s in al_symbols) 153 | has_page = any("Page" in s for s in al_symbols) 154 | has_codeunit = any("Codeunit" in s for s in al_symbols) 155 | 156 | assert has_table, f"No Table symbols found in: {al_symbols}" 157 | assert has_page, f"No Page symbols found in: {al_symbols}" 158 | assert has_codeunit, f"No Codeunit symbols found in: {al_symbols}" 159 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/elm_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import shutil 9 | import threading 10 | 11 | from overrides import override 12 | from sensai.util.logging import LogTime 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 | from .common import RuntimeDependency, RuntimeDependencyCollection 22 | 23 | 24 | class ElmLanguageServer(SolidLanguageServer): 25 | """ 26 | Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. 27 | """ 28 | 29 | def __init__( 30 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 31 | ): 32 | """ 33 | Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 34 | """ 35 | elm_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 36 | super().__init__( 37 | config, 38 | logger, 39 | repository_root_path, 40 | ProcessLaunchInfo(cmd=elm_lsp_executable_path, cwd=repository_root_path), 41 | "elm", 42 | solidlsp_settings, 43 | ) 44 | self.server_ready = threading.Event() 45 | 46 | @override 47 | def is_ignored_dirname(self, dirname: str) -> bool: 48 | return super().is_ignored_dirname(dirname) or dirname in [ 49 | "elm-stuff", 50 | "node_modules", 51 | "dist", 52 | "build", 53 | ] 54 | 55 | @classmethod 56 | def _setup_runtime_dependencies( 57 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 58 | ) -> list[str]: 59 | """ 60 | Setup runtime dependencies for Elm Language Server and return the command to start the server. 61 | """ 62 | # Check if elm-language-server is already installed globally 63 | system_elm_ls = shutil.which("elm-language-server") 64 | if system_elm_ls: 65 | logger.log(f"Found system-installed elm-language-server at {system_elm_ls}", logging.INFO) 66 | return [system_elm_ls, "--stdio"] 67 | 68 | # Verify node and npm are installed 69 | is_node_installed = shutil.which("node") is not None 70 | assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." 71 | is_npm_installed = shutil.which("npm") is not None 72 | assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." 73 | 74 | deps = RuntimeDependencyCollection( 75 | [ 76 | RuntimeDependency( 77 | id="elm-language-server", 78 | description="@elm-tooling/elm-language-server package", 79 | command=["npm", "install", "--prefix", "./", "@elm-tooling/[email protected]"], 80 | platform_id="any", 81 | ), 82 | ] 83 | ) 84 | 85 | # Install elm-language-server if not already installed 86 | elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "elm-lsp") 87 | elm_ls_executable_path = os.path.join(elm_ls_dir, "node_modules", ".bin", "elm-language-server") 88 | if not os.path.exists(elm_ls_executable_path): 89 | logger.log(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...", logging.INFO) 90 | with LogTime("Installation of Elm language server dependencies", logger=logger.logger): 91 | deps.install(logger, elm_ls_dir) 92 | 93 | if not os.path.exists(elm_ls_executable_path): 94 | raise FileNotFoundError( 95 | f"elm-language-server executable not found at {elm_ls_executable_path}, something went wrong with the installation." 96 | ) 97 | return [elm_ls_executable_path, "--stdio"] 98 | 99 | @staticmethod 100 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 101 | """ 102 | Returns the initialize params for the Elm Language Server. 103 | """ 104 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 105 | 106 | initialize_params = { 107 | "locale": "en", 108 | "capabilities": { 109 | "textDocument": { 110 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 111 | "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, 112 | "definition": {"dynamicRegistration": True}, 113 | "references": {"dynamicRegistration": True}, 114 | "documentSymbol": { 115 | "dynamicRegistration": True, 116 | "hierarchicalDocumentSymbolSupport": True, 117 | "symbolKind": {"valueSet": list(range(1, 27))}, 118 | }, 119 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 120 | "codeAction": {"dynamicRegistration": True}, 121 | "rename": {"dynamicRegistration": True}, 122 | }, 123 | "workspace": { 124 | "workspaceFolders": True, 125 | "didChangeConfiguration": {"dynamicRegistration": True}, 126 | "symbol": {"dynamicRegistration": True}, 127 | }, 128 | }, 129 | "initializationOptions": { 130 | "elmPath": "elm", 131 | "elmFormatPath": "elm-format", 132 | "elmTestPath": "elm-test", 133 | "skipInstallPackageConfirmation": True, 134 | "onlyUpdateDiagnosticsOnSave": False, 135 | }, 136 | "processId": os.getpid(), 137 | "rootPath": repository_absolute_path, 138 | "rootUri": root_uri, 139 | "workspaceFolders": [ 140 | { 141 | "uri": root_uri, 142 | "name": os.path.basename(repository_absolute_path), 143 | } 144 | ], 145 | } 146 | return initialize_params 147 | 148 | def _start_server(self): 149 | """ 150 | Starts the Elm Language Server, waits for the server to be ready and yields the LanguageServer instance. 151 | """ 152 | 153 | def do_nothing(params): 154 | return 155 | 156 | def window_log_message(msg): 157 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 158 | 159 | self.server.on_notification("window/logMessage", window_log_message) 160 | self.server.on_notification("$/progress", do_nothing) 161 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 162 | 163 | self.logger.log("Starting Elm server process", logging.INFO) 164 | self.server.start() 165 | initialize_params = self._get_initialize_params(self.repository_root_path) 166 | 167 | self.logger.log( 168 | "Sending initialize request from LSP client to LSP server and awaiting response", 169 | logging.INFO, 170 | ) 171 | init_response = self.server.send.initialize(initialize_params) 172 | 173 | # Elm-specific capability checks 174 | assert "textDocumentSync" in init_response["capabilities"] 175 | assert "completionProvider" in init_response["capabilities"] 176 | assert "definitionProvider" in init_response["capabilities"] 177 | assert "referencesProvider" in init_response["capabilities"] 178 | assert "documentSymbolProvider" in init_response["capabilities"] 179 | 180 | self.server.notify.initialized({}) 181 | self.logger.log("Elm server initialized successfully, waiting for workspace scan...", logging.INFO) 182 | 183 | self.server_ready.set() 184 | self.completions_available.set() 185 | self.logger.log("Elm server ready", logging.INFO) 186 | 187 | @override 188 | def _get_wait_time_for_cross_file_referencing(self) -> float: 189 | return 1.0 190 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/intelephense.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides PHP specific instantiation of the LanguageServer class using Intelephense. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import shutil 9 | from time import sleep 10 | 11 | from overrides import override 12 | 13 | from solidlsp.ls import SolidLanguageServer 14 | from solidlsp.ls_config import LanguageServerConfig 15 | from solidlsp.ls_logger import LanguageServerLogger 16 | from solidlsp.ls_utils import PlatformId, PlatformUtils 17 | from solidlsp.lsp_protocol_handler.lsp_types import DefinitionParams, InitializeParams 18 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 19 | from solidlsp.settings import SolidLSPSettings 20 | 21 | from .common import RuntimeDependency, RuntimeDependencyCollection 22 | 23 | 24 | class Intelephense(SolidLanguageServer): 25 | """ 26 | Provides PHP specific instantiation of the LanguageServer class using Intelephense. 27 | 28 | You can pass the following entries in ls_specific_settings["php"]: 29 | - maxMemory 30 | - maxFileSize 31 | """ 32 | 33 | @override 34 | def is_ignored_dirname(self, dirname: str) -> bool: 35 | # For PHP projects, we should ignore: 36 | # - vendor: third-party dependencies managed by Composer 37 | # - node_modules: if the project has JavaScript components 38 | # - cache: commonly used for caching 39 | return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"] 40 | 41 | @classmethod 42 | def _setup_runtime_dependencies( 43 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 44 | ) -> str: 45 | """ 46 | Setup runtime dependencies for Intelephense and return the command to start the server. 47 | """ 48 | platform_id = PlatformUtils.get_platform_id() 49 | 50 | valid_platforms = [ 51 | PlatformId.LINUX_x64, 52 | PlatformId.LINUX_arm64, 53 | PlatformId.OSX, 54 | PlatformId.OSX_x64, 55 | PlatformId.OSX_arm64, 56 | PlatformId.WIN_x64, 57 | PlatformId.WIN_arm64, 58 | ] 59 | assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy PHP at the moment" 60 | 61 | # Verify both node and npm are installed 62 | is_node_installed = shutil.which("node") is not None 63 | assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." 64 | is_npm_installed = shutil.which("npm") is not None 65 | assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." 66 | 67 | # Install intelephense if not already installed 68 | intelephense_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "php-lsp") 69 | os.makedirs(intelephense_ls_dir, exist_ok=True) 70 | intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") 71 | if not os.path.exists(intelephense_executable_path): 72 | deps = RuntimeDependencyCollection( 73 | [ 74 | RuntimeDependency( 75 | id="intelephense", 76 | command="npm install --prefix ./ [email protected]", 77 | platform_id="any", 78 | ) 79 | ] 80 | ) 81 | deps.install(logger, intelephense_ls_dir) 82 | 83 | assert os.path.exists( 84 | intelephense_executable_path 85 | ), f"intelephense executable not found at {intelephense_executable_path}, something went wrong." 86 | 87 | return f"{intelephense_executable_path} --stdio" 88 | 89 | def __init__( 90 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 91 | ): 92 | # Setup runtime dependencies before initializing 93 | intelephense_cmd = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 94 | 95 | super().__init__( 96 | config, 97 | logger, 98 | repository_root_path, 99 | ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), 100 | "php", 101 | solidlsp_settings, 102 | ) 103 | self.request_id = 0 104 | 105 | def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: 106 | """ 107 | Returns the initialization params for the Intelephense Language Server. 108 | """ 109 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 110 | initialize_params = { 111 | "locale": "en", 112 | "capabilities": { 113 | "textDocument": { 114 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 115 | "definition": {"dynamicRegistration": True}, 116 | }, 117 | "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, 118 | }, 119 | "processId": os.getpid(), 120 | "rootPath": repository_absolute_path, 121 | "rootUri": root_uri, 122 | "workspaceFolders": [ 123 | { 124 | "uri": root_uri, 125 | "name": os.path.basename(repository_absolute_path), 126 | } 127 | ], 128 | } 129 | initialization_options = {} 130 | # Add license key if provided via environment variable 131 | license_key = os.environ.get("INTELEPHENSE_LICENSE_KEY") 132 | if license_key: 133 | initialization_options["licenceKey"] = license_key 134 | 135 | custom_intelephense_settings = self._solidlsp_settings.ls_specific_settings.get(self.get_language_enum_instance(), {}) 136 | max_memory = custom_intelephense_settings.get("maxMemory") 137 | max_file_size = custom_intelephense_settings.get("maxFileSize") 138 | if max_memory is not None: 139 | initialization_options["intelephense.maxMemory"] = max_memory 140 | if max_file_size is not None: 141 | initialization_options["intelephense.files.maxSize"] = max_file_size 142 | 143 | initialize_params["initializationOptions"] = initialization_options 144 | return initialize_params 145 | 146 | def _start_server(self): 147 | """Start Intelephense server process""" 148 | 149 | def register_capability_handler(params): 150 | return 151 | 152 | def window_log_message(msg): 153 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 154 | 155 | def do_nothing(params): 156 | return 157 | 158 | self.server.on_request("client/registerCapability", register_capability_handler) 159 | self.server.on_notification("window/logMessage", window_log_message) 160 | self.server.on_notification("$/progress", do_nothing) 161 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 162 | 163 | self.logger.log("Starting Intelephense server process", logging.INFO) 164 | self.server.start() 165 | initialize_params = self._get_initialize_params(self.repository_root_path) 166 | 167 | self.logger.log( 168 | "Sending initialize request from LSP client to LSP server and awaiting response", 169 | logging.INFO, 170 | ) 171 | init_response = self.server.send.initialize(initialize_params) 172 | self.logger.log( 173 | "After sent initialize params", 174 | logging.INFO, 175 | ) 176 | 177 | # Verify server capabilities 178 | assert "textDocumentSync" in init_response["capabilities"] 179 | assert "completionProvider" in init_response["capabilities"] 180 | assert "definitionProvider" in init_response["capabilities"] 181 | 182 | self.server.notify.initialized({}) 183 | self.completions_available.set() 184 | 185 | # Intelephense server is typically ready immediately after initialization 186 | # TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for! 187 | 188 | @override 189 | # For some reason, the LS may need longer to process this, so we just retry 190 | def _send_references_request(self, relative_file_path: str, line: int, column: int): 191 | # TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is 192 | # despite the LS having processed requests already. I don't know what causes this, but sleeping 193 | # one second helps. It may be that sleeping only once is enough but that's hard to reliably test. 194 | # May be related to the time it takes to read the files or something like that. 195 | # The sleeping doesn't seem to be needed on all systems 196 | sleep(1) 197 | return super()._send_references_request(relative_file_path, line, column) 198 | 199 | @override 200 | def _send_definition_request(self, definition_params: DefinitionParams): 201 | # TODO: same as above, also only a problem if the definition is in another file 202 | sleep(1) 203 | return super()._send_definition_request(definition_params) 204 | ``` -------------------------------------------------------------------------------- /src/serena/config/context_mode.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Context and Mode configuration loader 3 | """ 4 | 5 | import os 6 | from dataclasses import dataclass, field 7 | from enum import Enum 8 | from pathlib import Path 9 | from typing import TYPE_CHECKING, Self 10 | 11 | import yaml 12 | from sensai.util import logging 13 | from sensai.util.string import ToStringMixin 14 | 15 | from serena.config.serena_config import ToolInclusionDefinition 16 | from serena.constants import ( 17 | DEFAULT_CONTEXT, 18 | DEFAULT_MODES, 19 | INTERNAL_MODE_YAMLS_DIR, 20 | SERENAS_OWN_CONTEXT_YAMLS_DIR, 21 | SERENAS_OWN_MODE_YAMLS_DIR, 22 | USER_CONTEXT_YAMLS_DIR, 23 | USER_MODE_YAMLS_DIR, 24 | ) 25 | 26 | if TYPE_CHECKING: 27 | pass 28 | 29 | log = logging.getLogger(__name__) 30 | 31 | 32 | @dataclass(kw_only=True) 33 | class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): 34 | """Represents a mode of operation for the agent, typically read off a YAML file. 35 | An agent can be in multiple modes simultaneously as long as they are not mutually exclusive. 36 | The modes can be adjusted after the agent is running, for example for switching from planning to editing. 37 | """ 38 | 39 | name: str 40 | prompt: str 41 | """ 42 | a Jinja2 template for the generation of the system prompt. 43 | It is formatted by the agent (see SerenaAgent._format_prompt()). 44 | """ 45 | description: str = "" 46 | 47 | def _tostring_includes(self) -> list[str]: 48 | return ["name"] 49 | 50 | def print_overview(self) -> None: 51 | """Print an overview of the mode.""" 52 | print(f"{self.name}:\n {self.description}") 53 | if self.excluded_tools: 54 | print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools))) 55 | 56 | @classmethod 57 | def from_yaml(cls, yaml_path: str | Path) -> Self: 58 | """Load a mode from a YAML file.""" 59 | with open(yaml_path, encoding="utf-8") as f: 60 | data = yaml.safe_load(f) 61 | name = data.pop("name", Path(yaml_path).stem) 62 | return cls(name=name, **data) 63 | 64 | @classmethod 65 | def get_path(cls, name: str) -> str: 66 | """Get the path to the YAML file for a mode.""" 67 | fname = f"{name}.yml" 68 | custom_mode_path = os.path.join(USER_MODE_YAMLS_DIR, fname) 69 | if os.path.exists(custom_mode_path): 70 | return custom_mode_path 71 | 72 | own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) 73 | if not os.path.exists(own_yaml_path): 74 | raise FileNotFoundError( 75 | f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}." 76 | f"Available modes:\n{cls.list_registered_mode_names()}" 77 | ) 78 | return own_yaml_path 79 | 80 | @classmethod 81 | def from_name(cls, name: str) -> Self: 82 | """Load a registered Serena mode.""" 83 | mode_path = cls.get_path(name) 84 | return cls.from_yaml(mode_path) 85 | 86 | @classmethod 87 | def from_name_internal(cls, name: str) -> Self: 88 | """Loads an internal Serena mode""" 89 | yaml_path = os.path.join(INTERNAL_MODE_YAMLS_DIR, f"{name}.yml") 90 | if not os.path.exists(yaml_path): 91 | raise FileNotFoundError(f"Internal mode '{name}' not found in {INTERNAL_MODE_YAMLS_DIR}") 92 | return cls.from_yaml(yaml_path) 93 | 94 | @classmethod 95 | def list_registered_mode_names(cls, include_user_modes: bool = True) -> list[str]: 96 | """Names of all registered modes (from the corresponding YAML files in the serena repo).""" 97 | modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml") if f.name != "mode.template.yml"] 98 | if include_user_modes: 99 | modes += cls.list_custom_mode_names() 100 | return sorted(set(modes)) 101 | 102 | @classmethod 103 | def list_custom_mode_names(cls) -> list[str]: 104 | """Names of all custom modes defined by the user.""" 105 | return [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] 106 | 107 | @classmethod 108 | def load_default_modes(cls) -> list[Self]: 109 | """Load the default modes (interactive and editing).""" 110 | return [cls.from_name(mode) for mode in DEFAULT_MODES] 111 | 112 | @classmethod 113 | def load(cls, name_or_path: str | Path) -> Self: 114 | if str(name_or_path).endswith(".yml"): 115 | return cls.from_yaml(name_or_path) 116 | return cls.from_name(str(name_or_path)) 117 | 118 | 119 | @dataclass(kw_only=True) 120 | class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): 121 | """Represents a context where the agent is operating (an IDE, a chat, etc.), typically read off a YAML file. 122 | An agent can only be in a single context at a time. 123 | The contexts cannot be changed after the agent is running. 124 | """ 125 | 126 | name: str 127 | prompt: str 128 | """ 129 | a Jinja2 template for the generation of the system prompt. 130 | It is formatted by the agent (see SerenaAgent._format_prompt()). 131 | """ 132 | description: str = "" 133 | tool_description_overrides: dict[str, str] = field(default_factory=dict) 134 | """Maps tool names to custom descriptions, default descriptions are extracted from the tool docstrings.""" 135 | 136 | def _tostring_includes(self) -> list[str]: 137 | return ["name"] 138 | 139 | @classmethod 140 | def from_yaml(cls, yaml_path: str | Path) -> Self: 141 | """Load a context from a YAML file.""" 142 | with open(yaml_path, encoding="utf-8") as f: 143 | data = yaml.safe_load(f) 144 | name = data.pop("name", Path(yaml_path).stem) 145 | # Ensure backwards compatibility for tool_description_overrides 146 | if "tool_description_overrides" not in data: 147 | data["tool_description_overrides"] = {} 148 | return cls(name=name, **data) 149 | 150 | @classmethod 151 | def get_path(cls, name: str) -> str: 152 | """Get the path to the YAML file for a context.""" 153 | fname = f"{name}.yml" 154 | custom_context_path = os.path.join(USER_CONTEXT_YAMLS_DIR, fname) 155 | if os.path.exists(custom_context_path): 156 | return custom_context_path 157 | 158 | own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname) 159 | if not os.path.exists(own_yaml_path): 160 | raise FileNotFoundError( 161 | f"Context {name} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}." 162 | f"Available contexts:\n{cls.list_registered_context_names()}" 163 | ) 164 | return own_yaml_path 165 | 166 | @classmethod 167 | def from_name(cls, name: str) -> Self: 168 | """Load a registered Serena context.""" 169 | context_path = cls.get_path(name) 170 | return cls.from_yaml(context_path) 171 | 172 | @classmethod 173 | def load(cls, name_or_path: str | Path) -> Self: 174 | if str(name_or_path).endswith(".yml"): 175 | return cls.from_yaml(name_or_path) 176 | return cls.from_name(str(name_or_path)) 177 | 178 | @classmethod 179 | def list_registered_context_names(cls, include_user_contexts: bool = True) -> list[str]: 180 | """Names of all registered contexts (from the corresponding YAML files in the serena repo).""" 181 | contexts = [f.stem for f in Path(SERENAS_OWN_CONTEXT_YAMLS_DIR).glob("*.yml")] 182 | if include_user_contexts: 183 | contexts += cls.list_custom_context_names() 184 | return sorted(set(contexts)) 185 | 186 | @classmethod 187 | def list_custom_context_names(cls) -> list[str]: 188 | """Names of all custom contexts defined by the user.""" 189 | return [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")] 190 | 191 | @classmethod 192 | def load_default(cls) -> Self: 193 | """Load the default context.""" 194 | return cls.from_name(DEFAULT_CONTEXT) 195 | 196 | def print_overview(self) -> None: 197 | """Print an overview of the mode.""" 198 | print(f"{self.name}:\n {self.description}") 199 | if self.excluded_tools: 200 | print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools))) 201 | 202 | 203 | class RegisteredContext(Enum): 204 | """A registered context.""" 205 | 206 | IDE_ASSISTANT = "ide-assistant" 207 | """For Serena running within an assistant that already has basic tools, like Claude Code, Cline, Cursor, etc.""" 208 | DESKTOP_APP = "desktop-app" 209 | """For Serena running within Claude Desktop or a similar app which does not have built-in tools for code editing.""" 210 | AGENT = "agent" 211 | """For Serena running as a standalone agent, e.g. through agno.""" 212 | 213 | def load(self) -> SerenaAgentContext: 214 | """Load the context.""" 215 | return SerenaAgentContext.from_name(self.value) 216 | 217 | 218 | class RegisteredMode(Enum): 219 | """A registered mode.""" 220 | 221 | INTERACTIVE = "interactive" 222 | """Interactive mode, for multi-turn interactions.""" 223 | EDITING = "editing" 224 | """Editing tools are activated.""" 225 | PLANNING = "planning" 226 | """Editing tools are deactivated.""" 227 | ONE_SHOT = "one-shot" 228 | """Non-interactive mode, where the goal is to finish a task autonomously.""" 229 | 230 | def load(self) -> SerenaAgentMode: 231 | """Load the mode.""" 232 | return SerenaAgentMode.from_name(self.value) 233 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/perl_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. 3 | 4 | Note: Windows is not supported as Nix itself doesn't support Windows natively. 5 | """ 6 | 7 | import logging 8 | import os 9 | import pathlib 10 | import subprocess 11 | import time 12 | 13 | from overrides import override 14 | 15 | from solidlsp.ls import SolidLanguageServer 16 | from solidlsp.ls_config import LanguageServerConfig 17 | from solidlsp.ls_logger import LanguageServerLogger 18 | from solidlsp.ls_utils import PlatformId, PlatformUtils 19 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 20 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 21 | from solidlsp.settings import SolidLSPSettings 22 | 23 | 24 | class PerlLanguageServer(SolidLanguageServer): 25 | """ 26 | Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. 27 | """ 28 | 29 | @staticmethod 30 | def _get_perl_version(): 31 | """Get the installed Perl version or None if not found.""" 32 | try: 33 | result = subprocess.run(["perl", "-v"], capture_output=True, text=True, check=False) 34 | if result.returncode == 0: 35 | return result.stdout.strip() 36 | except FileNotFoundError: 37 | return None 38 | return None 39 | 40 | @staticmethod 41 | def _get_perl_language_server_version(): 42 | """Get the installed Perl::LanguageServer version or None if not found.""" 43 | try: 44 | result = subprocess.run( 45 | ["perl", "-MPerl::LanguageServer", "-e", "print $Perl::LanguageServer::VERSION"], 46 | capture_output=True, 47 | text=True, 48 | check=False, 49 | ) 50 | if result.returncode == 0: 51 | return result.stdout.strip() 52 | except FileNotFoundError: 53 | return None 54 | return None 55 | 56 | @override 57 | def is_ignored_dirname(self, dirname: str) -> bool: 58 | # For Perl projects, we should ignore: 59 | # - blib: build library directory 60 | # - local: local Perl module installation 61 | # - .carton: Carton dependency manager cache 62 | # - vendor: vendored dependencies 63 | # - _build: Module::Build output 64 | return super().is_ignored_dirname(dirname) or dirname in ["blib", "local", ".carton", "vendor", "_build", "cover_db"] 65 | 66 | @classmethod 67 | def _setup_runtime_dependencies(cls) -> str: 68 | """ 69 | Check if required Perl runtime dependencies are available. 70 | Raises RuntimeError with helpful message if dependencies are missing. 71 | """ 72 | platform_id = PlatformUtils.get_platform_id() 73 | 74 | valid_platforms = [ 75 | PlatformId.LINUX_x64, 76 | PlatformId.LINUX_arm64, 77 | PlatformId.OSX, 78 | PlatformId.OSX_x64, 79 | PlatformId.OSX_arm64, 80 | ] 81 | if platform_id not in valid_platforms: 82 | raise RuntimeError(f"Platform {platform_id} is not supported for Perl at the moment") 83 | 84 | perl_version = cls._get_perl_version() 85 | if not perl_version: 86 | raise RuntimeError( 87 | "Perl is not installed. Please install Perl from https://www.perl.org/get.html and make sure it is added to your PATH." 88 | ) 89 | 90 | perl_ls_version = cls._get_perl_language_server_version() 91 | if not perl_ls_version: 92 | raise RuntimeError( 93 | "Found a Perl version but Perl::LanguageServer is not installed.\n" 94 | "Please install Perl::LanguageServer: cpanm Perl::LanguageServer\n" 95 | "See: https://metacpan.org/pod/Perl::LanguageServer" 96 | ) 97 | 98 | return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" 99 | 100 | def __init__( 101 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 102 | ): 103 | # Setup runtime dependencies before initializing 104 | perl_ls_cmd = self._setup_runtime_dependencies() 105 | 106 | super().__init__( 107 | config, 108 | logger, 109 | repository_root_path, 110 | ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), 111 | "perl", 112 | solidlsp_settings, 113 | ) 114 | self.request_id = 0 115 | 116 | @staticmethod 117 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 118 | """ 119 | Returns the initialize params for Perl::LanguageServer. 120 | Based on the expected structure from Perl::LanguageServer::Methods::_rpcreq_initialize. 121 | """ 122 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 123 | initialize_params = { 124 | "processId": os.getpid(), 125 | "rootPath": repository_absolute_path, 126 | "rootUri": root_uri, 127 | "capabilities": { 128 | "textDocument": { 129 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 130 | "definition": {"dynamicRegistration": True}, 131 | "references": {"dynamicRegistration": True}, 132 | "documentSymbol": {"dynamicRegistration": True}, 133 | "hover": {"dynamicRegistration": True}, 134 | }, 135 | "workspace": { 136 | "workspaceFolders": True, 137 | "didChangeConfiguration": {"dynamicRegistration": True}, 138 | "symbol": {"dynamicRegistration": True}, 139 | }, 140 | }, 141 | "initializationOptions": {}, 142 | "workspaceFolders": [ 143 | { 144 | "uri": root_uri, 145 | "name": os.path.basename(repository_absolute_path), 146 | } 147 | ], 148 | } 149 | 150 | return initialize_params 151 | 152 | def _start_server(self): 153 | """Start Perl::LanguageServer process""" 154 | 155 | def register_capability_handler(params): 156 | return 157 | 158 | def window_log_message(msg): 159 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 160 | 161 | def do_nothing(params): 162 | return 163 | 164 | def workspace_configuration_handler(params): 165 | """Handle workspace/configuration request from Perl::LanguageServer.""" 166 | self.logger.log(f"Received workspace/configuration request: {params}", logging.INFO) 167 | 168 | perl_config = { 169 | "perlInc": [self.repository_root_path, "."], 170 | "fileFilter": [".pm", ".pl"], 171 | "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], 172 | } 173 | 174 | return [perl_config] 175 | 176 | self.server.on_request("client/registerCapability", register_capability_handler) 177 | self.server.on_request("workspace/configuration", workspace_configuration_handler) 178 | self.server.on_notification("window/logMessage", window_log_message) 179 | self.server.on_notification("$/progress", do_nothing) 180 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 181 | 182 | self.logger.log("Starting Perl::LanguageServer process", logging.INFO) 183 | self.server.start() 184 | initialize_params = self._get_initialize_params(self.repository_root_path) 185 | 186 | self.logger.log( 187 | "Sending initialize request from LSP client to LSP server and awaiting response", 188 | logging.INFO, 189 | ) 190 | init_response = self.server.send.initialize(initialize_params) 191 | self.logger.log( 192 | "After sent initialize params", 193 | logging.INFO, 194 | ) 195 | 196 | # Verify server capabilities 197 | assert "textDocumentSync" in init_response["capabilities"] 198 | assert "definitionProvider" in init_response["capabilities"] 199 | assert "referencesProvider" in init_response["capabilities"] 200 | 201 | self.server.notify.initialized({}) 202 | 203 | # Send workspace configuration to Perl::LanguageServer 204 | # Perl::LanguageServer requires didChangeConfiguration to set perlInc, fileFilter, and ignoreDirs 205 | # See: Perl::LanguageServer::Methods::workspace::_rpcnot_didChangeConfiguration 206 | perl_config = { 207 | "settings": { 208 | "perl": { 209 | "perlInc": [self.repository_root_path, "."], 210 | "fileFilter": [".pm", ".pl"], 211 | "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], 212 | } 213 | } 214 | } 215 | self.logger.log(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}", logging.INFO) 216 | self.server.notify.workspace_did_change_configuration(perl_config) 217 | 218 | self.completions_available.set() 219 | 220 | # Perl::LanguageServer needs time to index files and resolve cross-file references 221 | # Without this delay, requests for definitions/references may return empty results 222 | settling_time = 0.5 223 | self.logger.log(f"Allowing {settling_time} seconds for Perl::LanguageServer to index files...", logging.INFO) 224 | time.sleep(settling_time) 225 | self.logger.log("Perl::LanguageServer settling period complete", logging.INFO) 226 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/pyright_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import re 9 | import threading 10 | 11 | from overrides import override 12 | 13 | from solidlsp.ls import SolidLanguageServer 14 | from solidlsp.ls_config import LanguageServerConfig 15 | from solidlsp.ls_logger import LanguageServerLogger 16 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 17 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 18 | from solidlsp.settings import SolidLSPSettings 19 | 20 | 21 | class PyrightServer(SolidLanguageServer): 22 | """ 23 | Provides Python specific instantiation of the LanguageServer class using Pyright. 24 | Contains various configurations and settings specific to Python. 25 | """ 26 | 27 | def __init__( 28 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 29 | ): 30 | """ 31 | Creates a PyrightServer instance. This class is not meant to be instantiated directly. 32 | Use LanguageServer.create() instead. 33 | """ 34 | super().__init__( 35 | config, 36 | logger, 37 | repository_root_path, 38 | # Note 1: we can also use `pyright-langserver --stdio` but it requires pyright to be installed with npm 39 | # Note 2: we can also use `bpyright-langserver --stdio` if we ever are unhappy with pyright 40 | ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), 41 | "python", 42 | solidlsp_settings, 43 | ) 44 | 45 | # Event to signal when initial workspace analysis is complete 46 | self.analysis_complete = threading.Event() 47 | self.found_source_files = False 48 | 49 | @override 50 | def is_ignored_dirname(self, dirname: str) -> bool: 51 | return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] 52 | 53 | @staticmethod 54 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 55 | """ 56 | Returns the initialize params for the Pyright Language Server. 57 | """ 58 | # Create basic initialization parameters 59 | initialize_params: InitializeParams = { # type: ignore 60 | "processId": os.getpid(), 61 | "rootPath": repository_absolute_path, 62 | "rootUri": pathlib.Path(repository_absolute_path).as_uri(), 63 | "initializationOptions": { 64 | "exclude": [ 65 | "**/__pycache__", 66 | "**/.venv", 67 | "**/.env", 68 | "**/build", 69 | "**/dist", 70 | "**/.pixi", 71 | ], 72 | "reportMissingImports": "error", 73 | }, 74 | "capabilities": { 75 | "workspace": { 76 | "workspaceEdit": {"documentChanges": True}, 77 | "didChangeConfiguration": {"dynamicRegistration": True}, 78 | "didChangeWatchedFiles": {"dynamicRegistration": True}, 79 | "symbol": { 80 | "dynamicRegistration": True, 81 | "symbolKind": {"valueSet": list(range(1, 27))}, 82 | }, 83 | "executeCommand": {"dynamicRegistration": True}, 84 | }, 85 | "textDocument": { 86 | "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, 87 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 88 | "signatureHelp": { 89 | "dynamicRegistration": True, 90 | "signatureInformation": { 91 | "documentationFormat": ["markdown", "plaintext"], 92 | "parameterInformation": {"labelOffsetSupport": True}, 93 | }, 94 | }, 95 | "definition": {"dynamicRegistration": True}, 96 | "references": {"dynamicRegistration": True}, 97 | "documentSymbol": { 98 | "dynamicRegistration": True, 99 | "symbolKind": {"valueSet": list(range(1, 27))}, 100 | "hierarchicalDocumentSymbolSupport": True, 101 | }, 102 | "publishDiagnostics": {"relatedInformation": True}, 103 | }, 104 | }, 105 | "workspaceFolders": [ 106 | {"uri": pathlib.Path(repository_absolute_path).as_uri(), "name": os.path.basename(repository_absolute_path)} 107 | ], 108 | } 109 | 110 | return initialize_params 111 | 112 | def _start_server(self): 113 | """ 114 | Starts the Pyright Language Server and waits for initial workspace analysis to complete. 115 | 116 | This prevents zombie processes by ensuring Pyright has finished its initial background 117 | tasks before we consider the server ready. 118 | 119 | Usage: 120 | ``` 121 | async with lsp.start_server(): 122 | # LanguageServer has been initialized and workspace analysis is complete 123 | await lsp.request_definition(...) 124 | await lsp.request_references(...) 125 | # Shutdown the LanguageServer on exit from scope 126 | # LanguageServer has been shutdown cleanly 127 | ``` 128 | """ 129 | 130 | def execute_client_command_handler(params): 131 | return [] 132 | 133 | def do_nothing(params): 134 | return 135 | 136 | def window_log_message(msg): 137 | """ 138 | Monitor Pyright's log messages to detect when initial analysis is complete. 139 | Pyright logs "Found X source files" when it finishes scanning the workspace. 140 | """ 141 | message_text = msg.get("message", "") 142 | self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) 143 | 144 | # Look for "Found X source files" which indicates workspace scanning is complete 145 | # Unfortunately, pyright is unreliable and there seems to be no better way 146 | if re.search(r"Found \d+ source files?", message_text): 147 | self.logger.log("Pyright workspace scanning complete", logging.INFO) 148 | self.found_source_files = True 149 | self.analysis_complete.set() 150 | self.completions_available.set() 151 | 152 | def check_experimental_status(params): 153 | """ 154 | Also listen for experimental/serverStatus as a backup signal 155 | """ 156 | if params.get("quiescent") == True: 157 | self.logger.log("Received experimental/serverStatus with quiescent=true", logging.INFO) 158 | if not self.found_source_files: 159 | self.analysis_complete.set() 160 | self.completions_available.set() 161 | 162 | # Set up notification handlers 163 | self.server.on_request("client/registerCapability", do_nothing) 164 | self.server.on_notification("language/status", do_nothing) 165 | self.server.on_notification("window/logMessage", window_log_message) 166 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 167 | self.server.on_notification("$/progress", do_nothing) 168 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 169 | self.server.on_notification("language/actionableNotification", do_nothing) 170 | self.server.on_notification("experimental/serverStatus", check_experimental_status) 171 | 172 | self.logger.log("Starting pyright-langserver server process", logging.INFO) 173 | self.server.start() 174 | 175 | # Send proper initialization parameters 176 | initialize_params = self._get_initialize_params(self.repository_root_path) 177 | 178 | self.logger.log( 179 | "Sending initialize request from LSP client to pyright server and awaiting response", 180 | logging.INFO, 181 | ) 182 | init_response = self.server.send.initialize(initialize_params) 183 | self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) 184 | 185 | # Verify that the server supports our required features 186 | assert "textDocumentSync" in init_response["capabilities"] 187 | assert "completionProvider" in init_response["capabilities"] 188 | assert "definitionProvider" in init_response["capabilities"] 189 | 190 | # Complete the initialization handshake 191 | self.server.notify.initialized({}) 192 | 193 | # Wait for Pyright to complete its initial workspace analysis 194 | # This prevents zombie processes by ensuring background tasks finish 195 | self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) 196 | if self.analysis_complete.wait(timeout=5.0): 197 | self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) 198 | else: 199 | self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) 200 | # Fallback: assume analysis is complete after timeout 201 | self.analysis_complete.set() 202 | self.completions_available.set() 203 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/clangd_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import threading 9 | 10 | from solidlsp.ls import SolidLanguageServer 11 | from solidlsp.ls_config import LanguageServerConfig 12 | from solidlsp.ls_logger import LanguageServerLogger 13 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 14 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 15 | from solidlsp.settings import SolidLSPSettings 16 | 17 | from .common import RuntimeDependency, RuntimeDependencyCollection 18 | 19 | 20 | class ClangdLanguageServer(SolidLanguageServer): 21 | """ 22 | Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. 23 | As the project gets bigger in size, building index will take time. Try running clangd multiple times to ensure index is built properly. 24 | Also make sure compile_commands.json is created at root of the source directory. Check clangd test case for example. 25 | """ 26 | 27 | def __init__( 28 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 29 | ): 30 | """ 31 | Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 32 | """ 33 | clangd_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 34 | super().__init__( 35 | config, 36 | logger, 37 | repository_root_path, 38 | ProcessLaunchInfo(cmd=clangd_executable_path, cwd=repository_root_path), 39 | "cpp", 40 | solidlsp_settings, 41 | ) 42 | self.server_ready = threading.Event() 43 | self.service_ready_event = threading.Event() 44 | self.initialize_searcher_command_available = threading.Event() 45 | self.resolve_main_method_available = threading.Event() 46 | 47 | @classmethod 48 | def _setup_runtime_dependencies( 49 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 50 | ) -> str: 51 | """ 52 | Setup runtime dependencies for ClangdLanguageServer and return the command to start the server. 53 | """ 54 | deps = RuntimeDependencyCollection( 55 | [ 56 | RuntimeDependency( 57 | id="Clangd", 58 | description="Clangd for Linux (x64)", 59 | url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-linux-19.1.2.zip", 60 | platform_id="linux-x64", 61 | archive_type="zip", 62 | binary_name="clangd_19.1.2/bin/clangd", 63 | ), 64 | RuntimeDependency( 65 | id="Clangd", 66 | description="Clangd for Windows (x64)", 67 | url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-windows-19.1.2.zip", 68 | platform_id="win-x64", 69 | archive_type="zip", 70 | binary_name="clangd_19.1.2/bin/clangd.exe", 71 | ), 72 | RuntimeDependency( 73 | id="Clangd", 74 | description="Clangd for macOS (x64)", 75 | url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-mac-19.1.2.zip", 76 | platform_id="osx-x64", 77 | archive_type="zip", 78 | binary_name="clangd_19.1.2/bin/clangd", 79 | ), 80 | RuntimeDependency( 81 | id="Clangd", 82 | description="Clangd for macOS (Arm64)", 83 | url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-mac-19.1.2.zip", 84 | platform_id="osx-arm64", 85 | archive_type="zip", 86 | binary_name="clangd_19.1.2/bin/clangd", 87 | ), 88 | ] 89 | ) 90 | 91 | clangd_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "clangd") 92 | dep = deps.get_single_dep_for_current_platform() 93 | clangd_executable_path = deps.binary_path(clangd_ls_dir) 94 | if not os.path.exists(clangd_executable_path): 95 | logger.log( 96 | f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}", 97 | logging.INFO, 98 | ) 99 | deps.install(logger, clangd_ls_dir) 100 | if not os.path.exists(clangd_executable_path): 101 | raise FileNotFoundError( 102 | f"Clangd executable not found at {clangd_executable_path}.\n" 103 | "Make sure you have installed clangd. See https://clangd.llvm.org/installation" 104 | ) 105 | os.chmod(clangd_executable_path, 0o755) 106 | 107 | return clangd_executable_path 108 | 109 | @staticmethod 110 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 111 | """ 112 | Returns the initialize params for the clangd Language Server. 113 | """ 114 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 115 | initialize_params = { 116 | "locale": "en", 117 | "capabilities": { 118 | "textDocument": { 119 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 120 | "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, 121 | "definition": {"dynamicRegistration": True}, 122 | }, 123 | "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, 124 | }, 125 | "processId": os.getpid(), 126 | "rootPath": repository_absolute_path, 127 | "rootUri": root_uri, 128 | "workspaceFolders": [ 129 | { 130 | "uri": root_uri, 131 | "name": "$name", 132 | } 133 | ], 134 | } 135 | 136 | return initialize_params 137 | 138 | def _start_server(self): 139 | """ 140 | Starts the Clangd Language Server, waits for the server to be ready and yields the LanguageServer instance. 141 | 142 | Usage: 143 | ``` 144 | async with lsp.start_server(): 145 | # LanguageServer has been initialized and ready to serve requests 146 | await lsp.request_definition(...) 147 | await lsp.request_references(...) 148 | # Shutdown the LanguageServer on exit from scope 149 | # LanguageServer has been shutdown 150 | """ 151 | 152 | def register_capability_handler(params): 153 | assert "registrations" in params 154 | for registration in params["registrations"]: 155 | if registration["method"] == "workspace/executeCommand": 156 | self.initialize_searcher_command_available.set() 157 | self.resolve_main_method_available.set() 158 | return 159 | 160 | def lang_status_handler(params): 161 | # TODO: Should we wait for 162 | # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} 163 | # Before proceeding? 164 | if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": 165 | self.service_ready_event.set() 166 | 167 | def execute_client_command_handler(params): 168 | return [] 169 | 170 | def do_nothing(params): 171 | return 172 | 173 | def check_experimental_status(params): 174 | if params["quiescent"] == True: 175 | self.server_ready.set() 176 | 177 | def window_log_message(msg): 178 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 179 | 180 | self.server.on_request("client/registerCapability", register_capability_handler) 181 | self.server.on_notification("language/status", lang_status_handler) 182 | self.server.on_notification("window/logMessage", window_log_message) 183 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 184 | self.server.on_notification("$/progress", do_nothing) 185 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 186 | self.server.on_notification("language/actionableNotification", do_nothing) 187 | self.server.on_notification("experimental/serverStatus", check_experimental_status) 188 | 189 | self.logger.log("Starting Clangd server process", logging.INFO) 190 | self.server.start() 191 | initialize_params = self._get_initialize_params(self.repository_root_path) 192 | 193 | self.logger.log( 194 | "Sending initialize request from LSP client to LSP server and awaiting response", 195 | logging.INFO, 196 | ) 197 | init_response = self.server.send.initialize(initialize_params) 198 | assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 199 | assert "completionProvider" in init_response["capabilities"] 200 | assert init_response["capabilities"]["completionProvider"] == { 201 | "triggerCharacters": [".", "<", ">", ":", '"', "/", "*"], 202 | "resolveProvider": False, 203 | } 204 | 205 | self.server.notify.initialized({}) 206 | 207 | self.completions_available.set() 208 | # set ready flag 209 | self.server_ready.set() 210 | self.server_ready.wait() 211 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/jedi_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | 9 | from overrides import override 10 | 11 | from solidlsp.ls import SolidLanguageServer 12 | from solidlsp.ls_config import LanguageServerConfig 13 | from solidlsp.ls_logger import LanguageServerLogger 14 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 15 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 16 | from solidlsp.settings import SolidLSPSettings 17 | 18 | 19 | class JediServer(SolidLanguageServer): 20 | """ 21 | Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. 22 | """ 23 | 24 | def __init__( 25 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 26 | ): 27 | """ 28 | Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 29 | """ 30 | super().__init__( 31 | config, 32 | logger, 33 | repository_root_path, 34 | ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), 35 | "python", 36 | solidlsp_settings, 37 | ) 38 | 39 | @override 40 | def is_ignored_dirname(self, dirname: str) -> bool: 41 | return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] 42 | 43 | @staticmethod 44 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 45 | """ 46 | Returns the initialize params for the Jedi Language Server. 47 | """ 48 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 49 | initialize_params = { 50 | "processId": os.getpid(), 51 | "clientInfo": {"name": "Serena", "version": "0.1.0"}, 52 | "locale": "en", 53 | "rootPath": repository_absolute_path, 54 | "rootUri": root_uri, 55 | # Note: this is not necessarily the minimal set of capabilities... 56 | "capabilities": { 57 | "workspace": { 58 | "applyEdit": True, 59 | "workspaceEdit": { 60 | "documentChanges": True, 61 | "resourceOperations": ["create", "rename", "delete"], 62 | "failureHandling": "textOnlyTransactional", 63 | "normalizesLineEndings": True, 64 | "changeAnnotationSupport": {"groupsOnLabel": True}, 65 | }, 66 | "configuration": True, 67 | "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, 68 | "symbol": { 69 | "dynamicRegistration": True, 70 | "symbolKind": {"valueSet": list(range(1, 27))}, 71 | "tagSupport": {"valueSet": [1]}, 72 | "resolveSupport": {"properties": ["location.range"]}, 73 | }, 74 | "workspaceFolders": True, 75 | "fileOperations": { 76 | "dynamicRegistration": True, 77 | "didCreate": True, 78 | "didRename": True, 79 | "didDelete": True, 80 | "willCreate": True, 81 | "willRename": True, 82 | "willDelete": True, 83 | }, 84 | "inlineValue": {"refreshSupport": True}, 85 | "inlayHint": {"refreshSupport": True}, 86 | "diagnostics": {"refreshSupport": True}, 87 | }, 88 | "textDocument": { 89 | "publishDiagnostics": { 90 | "relatedInformation": True, 91 | "versionSupport": False, 92 | "tagSupport": {"valueSet": [1, 2]}, 93 | "codeDescriptionSupport": True, 94 | "dataSupport": True, 95 | }, 96 | "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, 97 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 98 | "signatureHelp": { 99 | "dynamicRegistration": True, 100 | "signatureInformation": { 101 | "documentationFormat": ["markdown", "plaintext"], 102 | "parameterInformation": {"labelOffsetSupport": True}, 103 | "activeParameterSupport": True, 104 | }, 105 | "contextSupport": True, 106 | }, 107 | "definition": {"dynamicRegistration": True, "linkSupport": True}, 108 | "references": {"dynamicRegistration": True}, 109 | "documentHighlight": {"dynamicRegistration": True}, 110 | "documentSymbol": { 111 | "dynamicRegistration": True, 112 | "symbolKind": {"valueSet": list(range(1, 27))}, 113 | "hierarchicalDocumentSymbolSupport": True, 114 | "tagSupport": {"valueSet": [1]}, 115 | "labelSupport": True, 116 | }, 117 | "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, 118 | "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, 119 | "implementation": {"dynamicRegistration": True, "linkSupport": True}, 120 | "declaration": {"dynamicRegistration": True, "linkSupport": True}, 121 | "selectionRange": {"dynamicRegistration": True}, 122 | "callHierarchy": {"dynamicRegistration": True}, 123 | "linkedEditingRange": {"dynamicRegistration": True}, 124 | "typeHierarchy": {"dynamicRegistration": True}, 125 | "inlineValue": {"dynamicRegistration": True}, 126 | "inlayHint": { 127 | "dynamicRegistration": True, 128 | "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, 129 | }, 130 | "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, 131 | }, 132 | "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, 133 | "experimental": { 134 | "serverStatusNotification": True, 135 | "openServerLogs": True, 136 | }, 137 | }, 138 | # See https://github.com/pappasam/jedi-language-server?tab=readme-ov-file 139 | # We use the default options except for maxSymbols, where 0 means no limit 140 | "initializationOptions": { 141 | "workspace": { 142 | "symbols": {"ignoreFolders": [".nox", ".tox", ".venv", "__pycache__", "venv"], "maxSymbols": 0}, 143 | }, 144 | }, 145 | "trace": "verbose", 146 | "workspaceFolders": [ 147 | { 148 | "uri": root_uri, 149 | "name": os.path.basename(repository_absolute_path), 150 | } 151 | ], 152 | } 153 | return initialize_params 154 | 155 | def _start_server(self): 156 | """ 157 | Starts the JEDI Language Server 158 | """ 159 | 160 | def execute_client_command_handler(params): 161 | return [] 162 | 163 | def do_nothing(params): 164 | return 165 | 166 | def check_experimental_status(params): 167 | if params["quiescent"] == True: 168 | self.completions_available.set() 169 | 170 | def window_log_message(msg): 171 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 172 | 173 | self.server.on_request("client/registerCapability", do_nothing) 174 | self.server.on_notification("language/status", do_nothing) 175 | self.server.on_notification("window/logMessage", window_log_message) 176 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 177 | self.server.on_notification("$/progress", do_nothing) 178 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 179 | self.server.on_notification("language/actionableNotification", do_nothing) 180 | self.server.on_notification("experimental/serverStatus", check_experimental_status) 181 | 182 | self.logger.log("Starting jedi-language-server server process", logging.INFO) 183 | self.server.start() 184 | initialize_params = self._get_initialize_params(self.repository_root_path) 185 | 186 | self.logger.log( 187 | "Sending initialize request from LSP client to LSP server and awaiting response", 188 | logging.INFO, 189 | ) 190 | init_response = self.server.send.initialize(initialize_params) 191 | assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 192 | assert "completionProvider" in init_response["capabilities"] 193 | assert init_response["capabilities"]["completionProvider"] == { 194 | "triggerCharacters": [".", "'", '"'], 195 | "resolveProvider": True, 196 | } 197 | 198 | self.server.notify.initialized({}) 199 | ``` -------------------------------------------------------------------------------- /src/serena/resources/dashboard/index.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | 4 | <head> 5 | <meta charset="UTF-8"> 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 | <title>Serena Dashboard</title> 8 | <link rel="icon" type="image/png" sizes="16x16" href="serena-icon-16.png"> 9 | <link rel="icon" type="image/png" sizes="32x32" href="serena-icon-32.png"> 10 | <link rel="icon" type="image/png" sizes="48x48" href="serena-icon-48.png"> 11 | <script src="jquery.min.js"></script> 12 | <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> 13 | <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script> 14 | <script src="dashboard.js"></script> 15 | <style> 16 | :root { 17 | /* Light theme variables */ 18 | --bg-primary: #f5f5f5; 19 | --bg-secondary: #ffffff; 20 | --text-primary: #000000; 21 | --text-secondary: #333333; 22 | --text-muted: #666666; 23 | --border-color: #ddd; 24 | --btn-primary: #eaa45d; 25 | --btn-hover: #dca662; 26 | --btn-disabled: #6c757d; 27 | --shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 28 | --tool-highlight: #ffff00; 29 | --tool-highlight-text: #000000; 30 | --log-debug: #808080; 31 | --log-info: #000000; 32 | --log-warning: #FF8C00; 33 | --log-error: #FF0000; 34 | --stats-header: #f8f9fa; 35 | } 36 | 37 | [data-theme="dark"] { 38 | /* Dark theme variables */ 39 | --bg-primary: #1a1a1a; 40 | --bg-secondary: #2d2d2d; 41 | --text-primary: #ffffff; 42 | --text-secondary: #e0e0e0; 43 | --text-muted: #b0b0b0; 44 | --border-color: #444; 45 | --btn-primary: #eaa45d; 46 | --btn-hover: #dca662; 47 | --btn-disabled: #6c757d; 48 | --shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 49 | --tool-highlight: #ffd700; 50 | --tool-highlight-text: #000000; 51 | --log-debug: #808080; 52 | --log-info: #ffffff; 53 | --log-warning: #FF8C00; 54 | --log-error: #FF0000; 55 | --stats-header: #3a3a3a; 56 | } 57 | 58 | body { 59 | font-family: 'Consolas', 'Monaco', 'Courier New', monospace; 60 | margin: 0; 61 | padding: 20px; 62 | background-color: var(--bg-primary); 63 | color: var(--text-primary); 64 | transition: background-color 0.3s ease, color 0.3s ease; 65 | } 66 | 67 | .header { 68 | text-align: center; 69 | margin-bottom: 20px; 70 | } 71 | 72 | .log-container { 73 | background-color: var(--bg-secondary); 74 | border: 1px solid var(--border-color); 75 | border-radius: 5px; 76 | height: 600px; 77 | overflow-y: auto; 78 | overflow-x: auto; 79 | padding: 10px; 80 | white-space: pre-wrap; 81 | font-size: 12px; 82 | line-height: 1.4; 83 | color: var(--text-primary); 84 | transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; 85 | } 86 | 87 | .controls { 88 | margin-bottom: 10px; 89 | text-align: center; 90 | display: flex; 91 | justify-content: center; 92 | align-items: center; 93 | gap: 10px; 94 | flex-wrap: wrap; 95 | } 96 | 97 | .logo { 98 | margin-bottom: 10px; 99 | text-align: center; 100 | } 101 | 102 | .btn { 103 | background-color: var(--btn-primary); 104 | color: white; 105 | border: none; 106 | padding: 8px 16px; 107 | border-radius: 4px; 108 | cursor: pointer; 109 | font-size: 14px; 110 | transition: background-color 0.3s ease; 111 | } 112 | 113 | .btn:hover { 114 | background-color: var(--btn-hover); 115 | } 116 | 117 | .btn:disabled { 118 | background-color: var(--btn-disabled); 119 | cursor: not-allowed; 120 | } 121 | 122 | .theme-toggle { 123 | display: flex; 124 | align-items: center; 125 | gap: 5px; 126 | background-color: var(--bg-secondary); 127 | border: 1px solid var(--border-color); 128 | border-radius: 4px; 129 | padding: 6px 12px; 130 | cursor: pointer; 131 | transition: background-color 0.3s ease, border-color 0.3s ease; 132 | } 133 | 134 | .theme-toggle:hover { 135 | background-color: var(--border-color); 136 | } 137 | 138 | .theme-toggle .icon { 139 | font-size: 16px; 140 | } 141 | 142 | .log-debug { 143 | color: var(--log-debug); 144 | } 145 | 146 | .log-info { 147 | color: var(--log-info); 148 | } 149 | 150 | .log-warning { 151 | color: var(--log-warning); 152 | } 153 | 154 | .log-error { 155 | color: var(--log-error); 156 | } 157 | 158 | .log-default { 159 | color: var(--log-info); 160 | } 161 | 162 | /* Tool name highlighting */ 163 | .tool-name { 164 | background-color: var(--tool-highlight); 165 | color: var(--tool-highlight-text); 166 | font-weight: bold; 167 | } 168 | 169 | .loading { 170 | text-align: center; 171 | color: var(--text-muted); 172 | font-style: italic; 173 | } 174 | 175 | .error-message { 176 | color: var(--log-error); 177 | text-align: center; 178 | margin: 10px 0; 179 | } 180 | 181 | .charts-container { 182 | display: flex; 183 | flex-wrap: wrap; 184 | gap: 15px; 185 | justify-content: space-between; 186 | max-width: 1400px; 187 | margin: 0 auto; 188 | } 189 | 190 | .chart-group { 191 | flex: 1; 192 | min-width: 280px; 193 | max-width: 320px; 194 | text-align: center; 195 | } 196 | 197 | .chart-wide { 198 | flex: 0 0 100%; 199 | min-width: 100%; 200 | margin-top: 10px; 201 | } 202 | 203 | .chart-group h3 { 204 | margin: 0 0 10px 0; 205 | color: var(--text-secondary); 206 | } 207 | 208 | .stats-summary { 209 | margin: 0 auto; 210 | border-collapse: collapse; 211 | background: var(--bg-secondary); 212 | border-radius: 5px; 213 | overflow: hidden; 214 | box-shadow: var(--shadow); 215 | transition: background-color 0.3s ease, box-shadow 0.3s ease; 216 | } 217 | 218 | .stats-summary th, 219 | .stats-summary td { 220 | padding: 10px 20px; 221 | text-align: left; 222 | border-bottom: 1px solid var(--border-color); 223 | color: var(--text-primary); 224 | transition: border-color 0.3s ease, color 0.3s ease; 225 | } 226 | 227 | .stats-summary th { 228 | background-color: var(--stats-header); 229 | font-weight: bold; 230 | transition: background-color 0.3s ease; 231 | } 232 | 233 | .stats-summary tr:last-child td { 234 | border-bottom: none; 235 | } 236 | 237 | @media (max-width: 768px) { 238 | .charts-container { 239 | flex-direction: column; 240 | } 241 | 242 | .chart-group, 243 | .chart-wide { 244 | min-width: auto; 245 | max-width: none; 246 | } 247 | 248 | .controls { 249 | flex-direction: column; 250 | gap: 5px; 251 | } 252 | } 253 | </style> 254 | </head> 255 | 256 | <body> 257 | <div class="header"> 258 | <img id="serena-logo" src="serena-logs.png" alt="Serena" style="max-width: 400px; height: auto;"> 259 | </div> 260 | 261 | <div class="controls"> 262 | <button id="load-logs" class="btn">Reload Log</button> 263 | <button id="shutdown" class="btn">Shutdown Server</button> 264 | <button id="toggle-stats" class="btn">Show Stats</button> 265 | <div id="theme-toggle" class="theme-toggle" title="Toggle theme"> 266 | <span class="icon" id="theme-icon">🌙</span> 267 | <span id="theme-text">Dark</span> 268 | </div> 269 | </div> 270 | 271 | <div id="error-container"></div> 272 | <div id="log-container" class="log-container"></div> 273 | 274 | <div id="stats-section" style="display:none; margin-top:20px;"> 275 | <div style="text-align:center; margin-bottom:20px;"> 276 | <button id="refresh-stats" class="btn">Refresh Stats</button> 277 | <button id="clear-stats" class="btn">Clear Stats</button> 278 | </div> 279 | 280 | <div id="stats-summary" style="margin-bottom:20px; text-align:center;"></div> 281 | <div id="estimator-name" style="text-align:center; margin-bottom:10px;"></div> 282 | <div id="no-stats-message" style="text-align:center; color:var(--text-muted); font-style:italic; display:none;"> 283 | No tool stats collected. Have you enabled tool stats collection in the configuration? 284 | </div> 285 | 286 | 287 | <div class="charts-container"> 288 | <div class="chart-group"> 289 | <h3>Tool Calls</h3> 290 | <canvas id="count-chart" height="200"></canvas> 291 | </div> 292 | <div class="chart-group"> 293 | <h3>Input Tokens</h3> 294 | <canvas id="input-chart" height="200"></canvas> 295 | </div> 296 | <div class="chart-group"> 297 | <h3>Output Tokens</h3> 298 | <canvas id="output-chart" height="200"></canvas> 299 | </div> 300 | <div class="chart-group chart-wide"> 301 | <h3>Input vs Output Tokens</h3> 302 | <canvas id="tokens-chart" height="120"></canvas> 303 | </div> 304 | </div> 305 | </div> 306 | 307 | <script> 308 | $(document).ready(function () { 309 | const dashboard = new Dashboard(); 310 | }); 311 | </script> 312 | </body> 313 | 314 | </html> ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/terraform_ls.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | import os 3 | import shutil 4 | import threading 5 | 6 | from overrides import override 7 | 8 | from solidlsp.ls import SolidLanguageServer 9 | from solidlsp.ls_config import LanguageServerConfig 10 | from solidlsp.ls_logger import LanguageServerLogger 11 | from solidlsp.ls_utils import PathUtils, PlatformUtils 12 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 13 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 14 | from solidlsp.settings import SolidLSPSettings 15 | 16 | from .common import RuntimeDependency, RuntimeDependencyCollection 17 | 18 | 19 | class TerraformLS(SolidLanguageServer): 20 | """ 21 | Provides Terraform specific instantiation of the LanguageServer class using terraform-ls. 22 | """ 23 | 24 | @override 25 | def is_ignored_dirname(self, dirname: str) -> bool: 26 | return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"] 27 | 28 | @staticmethod 29 | def _ensure_tf_command_available(logger: LanguageServerLogger): 30 | logger.log("Starting terraform version detection...", logging.DEBUG) 31 | 32 | # 1. Try to find terraform using shutil.which 33 | terraform_cmd = shutil.which("terraform") 34 | if terraform_cmd is not None: 35 | logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) 36 | return 37 | 38 | # TODO: is this needed? 39 | # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) 40 | if not terraform_cmd: 41 | terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") 42 | if terraform_cli_path: 43 | logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) 44 | # TODO: use binary name from runtime dependencies if we keep this code 45 | if os.name == "nt": 46 | terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") 47 | else: 48 | terraform_binary = os.path.join(terraform_cli_path, "terraform") 49 | if os.path.exists(terraform_binary): 50 | terraform_cmd = terraform_binary 51 | logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) 52 | return 53 | 54 | raise RuntimeError( 55 | "Terraform executable not found, please ensure Terraform is installed." 56 | "See https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli for instructions." 57 | ) 58 | 59 | @classmethod 60 | def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str: 61 | """ 62 | Setup runtime dependencies for terraform-ls. 63 | Downloads and installs terraform-ls if not already present. 64 | """ 65 | cls._ensure_tf_command_available(logger) 66 | platform_id = PlatformUtils.get_platform_id() 67 | deps = RuntimeDependencyCollection( 68 | [ 69 | RuntimeDependency( 70 | id="TerraformLS", 71 | description="terraform-ls for macOS (ARM64)", 72 | url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_arm64.zip", 73 | platform_id="osx-arm64", 74 | archive_type="zip", 75 | binary_name="terraform-ls", 76 | ), 77 | RuntimeDependency( 78 | id="TerraformLS", 79 | description="terraform-ls for macOS (x64)", 80 | url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_amd64.zip", 81 | platform_id="osx-x64", 82 | archive_type="zip", 83 | binary_name="terraform-ls", 84 | ), 85 | RuntimeDependency( 86 | id="TerraformLS", 87 | description="terraform-ls for Linux (ARM64)", 88 | url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_arm64.zip", 89 | platform_id="linux-arm64", 90 | archive_type="zip", 91 | binary_name="terraform-ls", 92 | ), 93 | RuntimeDependency( 94 | id="TerraformLS", 95 | description="terraform-ls for Linux (x64)", 96 | url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_amd64.zip", 97 | platform_id="linux-x64", 98 | archive_type="zip", 99 | binary_name="terraform-ls", 100 | ), 101 | RuntimeDependency( 102 | id="TerraformLS", 103 | description="terraform-ls for Windows (x64)", 104 | url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_windows_amd64.zip", 105 | platform_id="win-x64", 106 | archive_type="zip", 107 | binary_name="terraform-ls.exe", 108 | ), 109 | ] 110 | ) 111 | dependency = deps.get_single_dep_for_current_platform() 112 | 113 | terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir(solidlsp_settings)) 114 | if not os.path.exists(terraform_ls_executable_path): 115 | logger.log(f"Downloading terraform-ls from {dependency.url}", logging.INFO) 116 | deps.install(logger, cls.ls_resources_dir(solidlsp_settings)) 117 | 118 | assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" 119 | 120 | # Make the executable file executable on Unix-like systems 121 | if platform_id.value != "win-x64": 122 | os.chmod(terraform_ls_executable_path, 0o755) 123 | 124 | return terraform_ls_executable_path 125 | 126 | def __init__( 127 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 128 | ): 129 | """ 130 | Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 131 | """ 132 | terraform_ls_executable_path = self._setup_runtime_dependencies(logger, solidlsp_settings) 133 | 134 | super().__init__( 135 | config, 136 | logger, 137 | repository_root_path, 138 | ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), 139 | "terraform", 140 | solidlsp_settings, 141 | ) 142 | self.server_ready = threading.Event() 143 | self.request_id = 0 144 | 145 | @staticmethod 146 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 147 | """ 148 | Returns the initialize params for the Terraform Language Server. 149 | """ 150 | root_uri = PathUtils.path_to_uri(repository_absolute_path) 151 | return { 152 | "processId": os.getpid(), 153 | "locale": "en", 154 | "rootPath": repository_absolute_path, 155 | "rootUri": root_uri, 156 | "capabilities": { 157 | "textDocument": { 158 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 159 | "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, 160 | "definition": {"dynamicRegistration": True}, 161 | "documentSymbol": { 162 | "dynamicRegistration": True, 163 | "hierarchicalDocumentSymbolSupport": True, 164 | "symbolKind": {"valueSet": list(range(1, 27))}, 165 | }, 166 | }, 167 | "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, 168 | }, 169 | "workspaceFolders": [ 170 | { 171 | "name": os.path.basename(repository_absolute_path), 172 | "uri": root_uri, 173 | } 174 | ], 175 | } 176 | 177 | def _start_server(self): 178 | """Start terraform-ls server process""" 179 | 180 | def register_capability_handler(params): 181 | return 182 | 183 | def window_log_message(msg): 184 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 185 | 186 | def do_nothing(params): 187 | return 188 | 189 | self.server.on_request("client/registerCapability", register_capability_handler) 190 | self.server.on_notification("window/logMessage", window_log_message) 191 | self.server.on_notification("$/progress", do_nothing) 192 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 193 | 194 | self.logger.log("Starting terraform-ls server process", logging.INFO) 195 | self.server.start() 196 | initialize_params = self._get_initialize_params(self.repository_root_path) 197 | 198 | self.logger.log( 199 | "Sending initialize request from LSP client to LSP server and awaiting response", 200 | logging.INFO, 201 | ) 202 | init_response = self.server.send.initialize(initialize_params) 203 | 204 | # Verify server capabilities 205 | assert "textDocumentSync" in init_response["capabilities"] 206 | assert "completionProvider" in init_response["capabilities"] 207 | assert "definitionProvider" in init_response["capabilities"] 208 | 209 | self.server.notify.initialized({}) 210 | self.completions_available.set() 211 | 212 | # terraform-ls server is typically ready immediately after initialization 213 | self.server_ready.set() 214 | self.server_ready.wait() 215 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/erlang_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """Erlang Language Server implementation using Erlang LS.""" 2 | 3 | import logging 4 | import os 5 | import shutil 6 | import subprocess 7 | import threading 8 | import time 9 | 10 | from overrides import override 11 | 12 | from solidlsp.ls import SolidLanguageServer 13 | from solidlsp.ls_config import LanguageServerConfig 14 | from solidlsp.ls_logger import LanguageServerLogger 15 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 16 | from solidlsp.settings import SolidLSPSettings 17 | 18 | 19 | class ErlangLanguageServer(SolidLanguageServer): 20 | """Language server for Erlang using Erlang LS.""" 21 | 22 | def __init__( 23 | self, 24 | config: LanguageServerConfig, 25 | logger: LanguageServerLogger, 26 | repository_root_path: str, 27 | solidlsp_settings: SolidLSPSettings, 28 | ): 29 | """ 30 | Creates an ErlangLanguageServer instance. This class is not meant to be instantiated directly. 31 | Use LanguageServer.create() instead. 32 | """ 33 | self.erlang_ls_path = shutil.which("erlang_ls") 34 | if not self.erlang_ls_path: 35 | raise RuntimeError("Erlang LS not found. Install from: https://github.com/erlang-ls/erlang_ls") 36 | 37 | if not self._check_erlang_installation(): 38 | raise RuntimeError("Erlang/OTP not found. Install from: https://www.erlang.org/downloads") 39 | 40 | super().__init__( 41 | config, 42 | logger, 43 | repository_root_path, 44 | ProcessLaunchInfo(cmd=[self.erlang_ls_path, "--transport", "stdio"], cwd=repository_root_path), 45 | "erlang", 46 | solidlsp_settings, 47 | ) 48 | 49 | # Add server readiness tracking like Elixir 50 | self.server_ready = threading.Event() 51 | 52 | # Set generous timeout for Erlang LS initialization 53 | self.set_request_timeout(120.0) 54 | 55 | def _check_erlang_installation(self) -> bool: 56 | """Check if Erlang/OTP is available.""" 57 | try: 58 | result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10) 59 | return result.returncode == 0 60 | except (subprocess.SubprocessError, FileNotFoundError): 61 | return False 62 | 63 | @classmethod 64 | def _get_erlang_version(cls): 65 | """Get the installed Erlang/OTP version or None if not found.""" 66 | try: 67 | result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10) 68 | if result.returncode == 0: 69 | return result.stderr.strip() # erl -version outputs to stderr 70 | except (subprocess.SubprocessError, FileNotFoundError): 71 | return None 72 | return None 73 | 74 | @classmethod 75 | def _check_rebar3_available(cls) -> bool: 76 | """Check if rebar3 build tool is available.""" 77 | try: 78 | result = subprocess.run(["rebar3", "version"], check=False, capture_output=True, text=True, timeout=10) 79 | return result.returncode == 0 80 | except (subprocess.SubprocessError, FileNotFoundError): 81 | return False 82 | 83 | def _start_server(self): 84 | """Start Erlang LS server process with proper initialization waiting.""" 85 | 86 | def register_capability_handler(params): 87 | return 88 | 89 | def window_log_message(msg): 90 | """Handle window/logMessage notifications from Erlang LS""" 91 | message_text = msg.get("message", "") 92 | self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) 93 | 94 | # Look for Erlang LS readiness signals 95 | # Common patterns: "Started Erlang LS", "initialized", "ready" 96 | readiness_signals = [ 97 | "Started Erlang LS", 98 | "server started", 99 | "initialized", 100 | "ready to serve requests", 101 | "compilation finished", 102 | "indexing complete", 103 | ] 104 | 105 | message_lower = message_text.lower() 106 | for signal in readiness_signals: 107 | if signal.lower() in message_lower: 108 | self.logger.log(f"Erlang LS readiness signal detected: {message_text}", logging.INFO) 109 | self.server_ready.set() 110 | break 111 | 112 | def do_nothing(params): 113 | return 114 | 115 | def check_server_ready(params): 116 | """Handle $/progress notifications from Erlang LS as fallback.""" 117 | value = params.get("value", {}) 118 | 119 | # Check for initialization completion progress 120 | if value.get("kind") == "end": 121 | message = value.get("message", "") 122 | if any(word in message.lower() for word in ["initialized", "ready", "complete"]): 123 | self.logger.log("Erlang LS initialization progress completed", logging.INFO) 124 | # Set as fallback if no window/logMessage was received 125 | if not self.server_ready.is_set(): 126 | self.server_ready.set() 127 | 128 | # Set up notification handlers 129 | self.server.on_request("client/registerCapability", register_capability_handler) 130 | self.server.on_notification("window/logMessage", window_log_message) 131 | self.server.on_notification("$/progress", check_server_ready) 132 | self.server.on_notification("window/workDoneProgress/create", do_nothing) 133 | self.server.on_notification("$/workDoneProgress", do_nothing) 134 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 135 | 136 | self.logger.log("Starting Erlang LS server process", logging.INFO) 137 | self.server.start() 138 | 139 | # Send initialize request 140 | initialize_params = { 141 | "processId": None, 142 | "rootPath": self.repository_root_path, 143 | "rootUri": f"file://{self.repository_root_path}", 144 | "capabilities": { 145 | "textDocument": { 146 | "synchronization": {"didSave": True}, 147 | "completion": {"dynamicRegistration": True}, 148 | "definition": {"dynamicRegistration": True}, 149 | "references": {"dynamicRegistration": True}, 150 | "documentSymbol": {"dynamicRegistration": True}, 151 | "hover": {"dynamicRegistration": True}, 152 | } 153 | }, 154 | } 155 | 156 | self.logger.log("Sending initialize request to Erlang LS", logging.INFO) 157 | init_response = self.server.send.initialize(initialize_params) 158 | 159 | # Verify server capabilities 160 | if "capabilities" in init_response: 161 | self.logger.log(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO) 162 | 163 | self.server.notify.initialized({}) 164 | self.completions_available.set() 165 | 166 | # Wait for Erlang LS to be ready - adjust timeout based on environment 167 | is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" 168 | is_macos = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False 169 | 170 | # macOS in CI can be particularly slow for language server startup 171 | if is_ci and is_macos: 172 | ready_timeout = 240.0 # 4 minutes for macOS CI 173 | env_desc = "macOS CI" 174 | elif is_ci: 175 | ready_timeout = 180.0 # 3 minutes for other CI 176 | env_desc = "CI" 177 | else: 178 | ready_timeout = 60.0 # 1 minute for local 179 | env_desc = "local" 180 | 181 | self.logger.log(f"Waiting up to {ready_timeout} seconds for Erlang LS readiness ({env_desc} environment)...", logging.INFO) 182 | 183 | if self.server_ready.wait(timeout=ready_timeout): 184 | self.logger.log("Erlang LS is ready and available for requests", logging.INFO) 185 | 186 | # Add settling period for indexing - adjust based on environment 187 | settling_time = 15.0 if is_ci else 5.0 188 | self.logger.log(f"Allowing {settling_time} seconds for Erlang LS indexing to complete...", logging.INFO) 189 | time.sleep(settling_time) 190 | self.logger.log("Erlang LS settling period complete", logging.INFO) 191 | else: 192 | # Set ready anyway and continue - Erlang LS might not send explicit ready messages 193 | self.logger.log( 194 | f"Erlang LS readiness timeout reached after {ready_timeout}s, proceeding anyway (common in CI)", logging.WARNING 195 | ) 196 | self.server_ready.set() 197 | 198 | # Still give some time for basic initialization even without explicit readiness signal 199 | basic_settling_time = 20.0 if is_ci else 10.0 200 | self.logger.log(f"Allowing {basic_settling_time} seconds for basic Erlang LS initialization...", logging.INFO) 201 | time.sleep(basic_settling_time) 202 | self.logger.log("Basic Erlang LS initialization period complete", logging.INFO) 203 | 204 | @override 205 | def is_ignored_dirname(self, dirname: str) -> bool: 206 | # For Erlang projects, we should ignore: 207 | # - _build: rebar3 build artifacts 208 | # - deps: dependencies 209 | # - ebin: compiled beam files 210 | # - .rebar3: rebar3 cache 211 | # - logs: log files 212 | # - node_modules: if the project has JavaScript components 213 | return super().is_ignored_dirname(dirname) or dirname in [ 214 | "_build", 215 | "deps", 216 | "ebin", 217 | ".rebar3", 218 | "logs", 219 | "node_modules", 220 | "_checkouts", 221 | "cover", 222 | ] 223 | 224 | def is_ignored_filename(self, filename: str) -> bool: 225 | """Check if a filename should be ignored.""" 226 | # Ignore compiled BEAM files 227 | if filename.endswith(".beam"): 228 | return True 229 | # Don't ignore Erlang source files, header files, or configuration files 230 | return False 231 | ``` -------------------------------------------------------------------------------- /test/serena/test_mcp.py: -------------------------------------------------------------------------------- ```python 1 | """Tests for the mcp.py module in serena.""" 2 | 3 | import pytest 4 | from mcp.server.fastmcp.tools.base import Tool as MCPTool 5 | 6 | from serena.agent import Tool, ToolRegistry 7 | from serena.config.context_mode import SerenaAgentContext 8 | from serena.mcp import SerenaMCPFactory 9 | 10 | make_tool = SerenaMCPFactory.make_mcp_tool 11 | 12 | 13 | # Create a mock agent for tool initialization 14 | class MockAgent: 15 | def __init__(self): 16 | self.project_config = None 17 | self.serena_config = None 18 | 19 | @staticmethod 20 | def get_context() -> SerenaAgentContext: 21 | return SerenaAgentContext.load_default() 22 | 23 | 24 | class BaseMockTool(Tool): 25 | """A mock Tool class for testing.""" 26 | 27 | def __init__(self): 28 | super().__init__(MockAgent()) # type: ignore 29 | 30 | 31 | class BasicTool(BaseMockTool): 32 | """A mock Tool class for testing.""" 33 | 34 | def apply(self, name: str, age: int = 0) -> str: 35 | """This is a test function. 36 | 37 | :param name: The person's name 38 | :param age: The person's age 39 | :return: A greeting message 40 | """ 41 | return f"Hello {name}, you are {age} years old!" 42 | 43 | def apply_ex( 44 | self, 45 | log_call: bool = True, 46 | catch_exceptions: bool = True, 47 | **kwargs, 48 | ) -> str: 49 | """Mock implementation of apply_ex.""" 50 | return self.apply(**kwargs) 51 | 52 | 53 | def test_make_tool_basic() -> None: 54 | """Test that make_tool correctly creates an MCP tool from a Tool object.""" 55 | mock_tool = BasicTool() 56 | 57 | mcp_tool = make_tool(mock_tool) 58 | 59 | # Test that the MCP tool has the correct properties 60 | assert isinstance(mcp_tool, MCPTool) 61 | assert mcp_tool.name == "basic" 62 | assert "This is a test function. Returns A greeting message." in mcp_tool.description 63 | 64 | # Test that the parameters were correctly processed 65 | parameters = mcp_tool.parameters 66 | assert "properties" in parameters 67 | assert "name" in parameters["properties"] 68 | assert "age" in parameters["properties"] 69 | assert parameters["properties"]["name"]["description"] == "The person's name." 70 | assert parameters["properties"]["age"]["description"] == "The person's age." 71 | 72 | 73 | def test_make_tool_execution() -> None: 74 | """Test that the execution function created by make_tool works correctly.""" 75 | mock_tool = BasicTool() 76 | mcp_tool = make_tool(mock_tool) 77 | 78 | # Execute the MCP tool function 79 | result = mcp_tool.fn(name="Alice", age=30) 80 | 81 | assert result == "Hello Alice, you are 30 years old!" 82 | 83 | 84 | def test_make_tool_no_params() -> None: 85 | """Test make_tool with a function that has no parameters.""" 86 | 87 | class NoParamsTool(BaseMockTool): 88 | def apply(self) -> str: 89 | """This is a test function with no parameters. 90 | 91 | :return: A simple result 92 | """ 93 | return "Simple result" 94 | 95 | def apply_ex(self, *args, **kwargs) -> str: 96 | return self.apply() 97 | 98 | tool = NoParamsTool() 99 | mcp_tool = make_tool(tool) 100 | 101 | assert mcp_tool.name == "no_params" 102 | assert "This is a test function with no parameters. Returns A simple result." in mcp_tool.description 103 | assert mcp_tool.parameters["properties"] == {} 104 | 105 | 106 | def test_make_tool_no_return_description() -> None: 107 | """Test make_tool with a function that has no return description.""" 108 | 109 | class NoReturnTool(BaseMockTool): 110 | def apply(self, param: str) -> str: 111 | """This is a test function. 112 | 113 | :param param: The parameter 114 | """ 115 | return f"Processed: {param}" 116 | 117 | def apply_ex(self, *args, **kwargs) -> str: 118 | return self.apply(**kwargs) 119 | 120 | tool = NoReturnTool() 121 | mcp_tool = make_tool(tool) 122 | 123 | assert mcp_tool.name == "no_return" 124 | assert mcp_tool.description == "This is a test function." 125 | assert mcp_tool.parameters["properties"]["param"]["description"] == "The parameter." 126 | 127 | 128 | def test_make_tool_parameter_not_in_docstring() -> None: 129 | """Test make_tool when a parameter in properties is not in the docstring.""" 130 | 131 | class MissingParamTool(BaseMockTool): 132 | def apply(self, name: str, missing_param: str = "") -> str: 133 | """This is a test function. 134 | 135 | :param name: The person's name 136 | """ 137 | return f"Hello {name}! Missing param: {missing_param}" 138 | 139 | def apply_ex(self, *args, **kwargs) -> str: 140 | return self.apply(**kwargs) 141 | 142 | tool = MissingParamTool() 143 | mcp_tool = make_tool(tool) 144 | 145 | assert "name" in mcp_tool.parameters["properties"] 146 | assert "missing_param" in mcp_tool.parameters["properties"] 147 | assert mcp_tool.parameters["properties"]["name"]["description"] == "The person's name." 148 | assert "description" not in mcp_tool.parameters["properties"]["missing_param"] 149 | 150 | 151 | def test_make_tool_multiline_docstring() -> None: 152 | """Test make_tool with a complex multi-line docstring.""" 153 | 154 | class ComplexDocTool(BaseMockTool): 155 | def apply(self, project_file_path: str, host: str, port: int) -> str: 156 | """Create an MCP server. 157 | 158 | This function creates and configures a Model Context Protocol server 159 | with the specified settings. 160 | 161 | :param project_file_path: The path to the project file, or None 162 | :param host: The host to bind to 163 | :param port: The port to bind to 164 | :return: A configured FastMCP server instance 165 | """ 166 | return f"Server config: {project_file_path}, {host}:{port}" 167 | 168 | def apply_ex(self, *args, **kwargs) -> str: 169 | return self.apply(**kwargs) 170 | 171 | tool = ComplexDocTool() 172 | mcp_tool = make_tool(tool) 173 | 174 | assert "Create an MCP server" in mcp_tool.description 175 | assert "Returns A configured FastMCP server instance" in mcp_tool.description 176 | assert mcp_tool.parameters["properties"]["project_file_path"]["description"] == "The path to the project file, or None." 177 | assert mcp_tool.parameters["properties"]["host"]["description"] == "The host to bind to." 178 | assert mcp_tool.parameters["properties"]["port"]["description"] == "The port to bind to." 179 | 180 | 181 | def test_make_tool_capitalization_and_periods() -> None: 182 | """Test that make_tool properly handles capitalization and periods in descriptions.""" 183 | 184 | class FormatTool(BaseMockTool): 185 | def apply(self, param1: str, param2: str, param3: str) -> str: 186 | """Test function. 187 | 188 | :param param1: lowercase description 189 | :param param2: description with period. 190 | :param param3: description with Capitalized word. 191 | """ 192 | return f"Formatted: {param1}, {param2}, {param3}" 193 | 194 | def apply_ex(self, *args, **kwargs) -> str: 195 | return self.apply(**kwargs) 196 | 197 | tool = FormatTool() 198 | mcp_tool = make_tool(tool) 199 | 200 | assert mcp_tool.parameters["properties"]["param1"]["description"] == "Lowercase description." 201 | assert mcp_tool.parameters["properties"]["param2"]["description"] == "Description with period." 202 | assert mcp_tool.parameters["properties"]["param3"]["description"] == "Description with Capitalized word." 203 | 204 | 205 | def test_make_tool_missing_apply() -> None: 206 | """Test make_tool with a tool that doesn't have an apply method.""" 207 | 208 | class BadTool(BaseMockTool): 209 | pass 210 | 211 | tool = BadTool() 212 | 213 | with pytest.raises(AttributeError): 214 | make_tool(tool) 215 | 216 | 217 | @pytest.mark.parametrize( 218 | "docstring, expected_description", 219 | [ 220 | ( 221 | """This is a test function. 222 | 223 | :param param: The parameter 224 | :return: A result 225 | """, 226 | "This is a test function. Returns A result.", 227 | ), 228 | ( 229 | """ 230 | :param param: The parameter 231 | :return: A result 232 | """, 233 | "Returns A result.", 234 | ), 235 | ( 236 | """ 237 | :param param: The parameter 238 | """, 239 | "", 240 | ), 241 | ("Description without params.", "Description without params."), 242 | ], 243 | ) 244 | def test_make_tool_descriptions(docstring, expected_description) -> None: 245 | """Test make_tool with various docstring formats.""" 246 | 247 | class TestTool(BaseMockTool): 248 | def apply(self, param: str) -> str: 249 | return f"Result: {param}" 250 | 251 | def apply_ex(self, *args, **kwargs) -> str: 252 | return self.apply(**kwargs) 253 | 254 | # Dynamically set the docstring 255 | TestTool.apply.__doc__ = docstring 256 | 257 | tool = TestTool() 258 | mcp_tool = make_tool(tool) 259 | 260 | assert mcp_tool.name == "test" 261 | assert mcp_tool.description == expected_description 262 | 263 | 264 | def is_test_mock_class(tool_class: type) -> bool: 265 | """Check if a class is a test mock class.""" 266 | # Check if the class is defined in a test module 267 | module_name = tool_class.__module__ 268 | return ( 269 | module_name.startswith(("test.", "tests.")) 270 | or "test_" in module_name 271 | or tool_class.__name__ 272 | in [ 273 | "BaseMockTool", 274 | "BasicTool", 275 | "BadTool", 276 | "NoParamsTool", 277 | "NoReturnTool", 278 | "MissingParamTool", 279 | "ComplexDocTool", 280 | "FormatTool", 281 | "NoDescriptionTool", 282 | ] 283 | ) 284 | 285 | 286 | @pytest.mark.parametrize("tool_class", ToolRegistry().get_all_tool_classes()) 287 | def test_make_tool_all_tools(tool_class) -> None: 288 | """Test that make_tool works for all tools in the codebase.""" 289 | # Create an instance of the tool 290 | tool_instance = tool_class(MockAgent()) 291 | 292 | # Try to create an MCP tool from it 293 | mcp_tool = make_tool(tool_instance) 294 | 295 | # Basic validation 296 | assert isinstance(mcp_tool, MCPTool) 297 | assert mcp_tool.name == tool_class.get_name_from_cls() 298 | 299 | # The description should be a string (either from docstring or default) 300 | assert isinstance(mcp_tool.description, str) 301 | ```